155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const allowedRepositoryProtocols = new Set(['https:', 'git:']);
|
|
const repositoryUrlPrefixPattern = /^(?:https|git):\/\//i;
|
|
const repositoryUrlError = 'repoUrl must be a valid https:// or git:// URL';
|
|
|
|
function assertAllowedRepositoryUrl(repoUrl: string): void {
|
|
if (repoUrl.startsWith('-') || !repositoryUrlPrefixPattern.test(repoUrl)) {
|
|
throw new BadRequestException(repositoryUrlError);
|
|
}
|
|
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(repoUrl);
|
|
} catch {
|
|
throw new BadRequestException(repositoryUrlError);
|
|
}
|
|
|
|
if (!allowedRepositoryProtocols.has(parsedUrl.protocol) || parsedUrl.hostname.length === 0) {
|
|
throw new BadRequestException(repositoryUrlError);
|
|
}
|
|
}
|
|
|
|
export interface WorkspaceProject {
|
|
id: string;
|
|
ownerType: 'user' | 'team';
|
|
userId: string;
|
|
teamId: string | null;
|
|
}
|
|
|
|
@Injectable()
|
|
export class WorkspaceService {
|
|
private readonly logger = new Logger(WorkspaceService.name);
|
|
private readonly mosaicRoot: string;
|
|
|
|
constructor() {
|
|
this.mosaicRoot = process.env['MOSAIC_ROOT'] ?? '/opt/mosaic';
|
|
}
|
|
|
|
/**
|
|
* Resolve the workspace path for a project.
|
|
* Solo: $MOSAIC_ROOT/.workspaces/users/<userId>/<projectId>/
|
|
* Team: $MOSAIC_ROOT/.workspaces/teams/<teamId>/<projectId>/
|
|
*/
|
|
resolvePath(project: WorkspaceProject): string {
|
|
if (project.ownerType === 'team' && project.teamId) {
|
|
return path.join(this.mosaicRoot, '.workspaces', 'teams', project.teamId, project.id);
|
|
}
|
|
return path.join(this.mosaicRoot, '.workspaces', 'users', project.userId, project.id);
|
|
}
|
|
|
|
/**
|
|
* Create a workspace directory and initialize it as a git repo.
|
|
* If repoUrl is provided, clone instead of init.
|
|
*/
|
|
async create(project: WorkspaceProject, repoUrl?: string): Promise<string> {
|
|
if (repoUrl !== undefined) {
|
|
assertAllowedRepositoryUrl(repoUrl);
|
|
}
|
|
|
|
const workspacePath = this.resolvePath(project);
|
|
|
|
// Create directory
|
|
await fs.mkdir(workspacePath, { recursive: true });
|
|
|
|
if (repoUrl !== undefined) {
|
|
// Clone existing repo. Defense in depth keeps dangerous local helpers
|
|
// disabled and terminates option parsing before positional arguments.
|
|
await execFileAsync(
|
|
'git',
|
|
[
|
|
'-c',
|
|
'protocol.ext.allow=never',
|
|
'-c',
|
|
'protocol.file.allow=never',
|
|
'clone',
|
|
'--',
|
|
repoUrl,
|
|
'.',
|
|
],
|
|
{ cwd: workspacePath },
|
|
);
|
|
this.logger.log(`Cloned ${repoUrl} into workspace ${workspacePath}`);
|
|
} else {
|
|
// Init new git repo
|
|
await execFileAsync('git', ['init'], { cwd: workspacePath });
|
|
await execFileAsync('git', ['commit', '--allow-empty', '-m', 'Initial workspace commit'], {
|
|
cwd: workspacePath,
|
|
env: {
|
|
...process.env,
|
|
GIT_AUTHOR_NAME: 'Mosaic',
|
|
GIT_AUTHOR_EMAIL: 'mosaic@localhost',
|
|
GIT_COMMITTER_NAME: 'Mosaic',
|
|
GIT_COMMITTER_EMAIL: 'mosaic@localhost',
|
|
},
|
|
});
|
|
this.logger.log(`Initialized git workspace at ${workspacePath}`);
|
|
}
|
|
|
|
// Create standard docs structure
|
|
await fs.mkdir(path.join(workspacePath, 'docs', 'plans'), { recursive: true });
|
|
await fs.mkdir(path.join(workspacePath, 'docs', 'reports'), { recursive: true });
|
|
this.logger.log(`Created docs structure at ${workspacePath}`);
|
|
|
|
return workspacePath;
|
|
}
|
|
|
|
/**
|
|
* Delete a workspace directory recursively.
|
|
*/
|
|
async delete(project: WorkspaceProject): Promise<void> {
|
|
const workspacePath = this.resolvePath(project);
|
|
try {
|
|
await fs.rm(workspacePath, { recursive: true, force: true });
|
|
this.logger.log(`Deleted workspace at ${workspacePath}`);
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to delete workspace at ${workspacePath}: ${err}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check whether the workspace directory exists.
|
|
*/
|
|
async exists(project: WorkspaceProject): Promise<boolean> {
|
|
const workspacePath = this.resolvePath(project);
|
|
try {
|
|
await fs.access(workspacePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create the base user workspace directory (call on user registration).
|
|
*/
|
|
async createUserRoot(userId: string): Promise<void> {
|
|
const userRoot = path.join(this.mosaicRoot, '.workspaces', 'users', userId);
|
|
await fs.mkdir(userRoot, { recursive: true });
|
|
}
|
|
|
|
/**
|
|
* Create the base team workspace directory (call on team creation).
|
|
*/
|
|
async createTeamRoot(teamId: string): Promise<void> {
|
|
const teamRoot = path.join(this.mosaicRoot, '.workspaces', 'teams', teamId);
|
|
await fs.mkdir(teamRoot, { recursive: true });
|
|
}
|
|
}
|