Files
stack/apps/gateway/src/workspace/workspace.service.ts
T
2026-08-09 20:12:39 -05:00

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