import { 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); 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 { const workspacePath = this.resolvePath(project); // Create directory await fs.mkdir(workspacePath, { recursive: true }); if (repoUrl) { // Clone existing repo await execFileAsync('git', ['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 }); } }