Files
stack/apps/gateway/src/workspace/workspace.service.ts
Jason Woltje 12653477d6
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(gateway): project bootstrap — docs structure + default agent (#190)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-17 02:12:24 +00:00

117 lines
3.7 KiB
TypeScript

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/<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> {
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<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 });
}
}