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/// * Team: $MOSAIC_ROOT/.workspaces/teams/// */ 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 { 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 { 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 { 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 { 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 { const teamRoot = path.join(this.mosaicRoot, '.workspaces', 'teams', teamId); await fs.mkdir(teamRoot, { recursive: true }); } }