Repository Public
1/**2 * Projects: a registered directory under `AOS_WORKSPACE_ROOT`. The server creates / clones the3 * directory itself (argv arrays, no shell) and afterwards only reads two files of it4 * (`.git/HEAD`, `.git/config`). DELETE unregisters; it never touches the directory.5 */6import fs from 'node:fs';7import path from 'node:path';8import type { FastifyInstance } from 'fastify';9import { z } from 'zod';10import { CreateProject, Id, ProjectDto } from '@aos/shared';11import type { Queryable } from '../../db.js';12import { UpdateProject } from '../../dto.js';13import { HttpError, authOf, notFound, parse, type Ctx } from '../../http.js';14import { resolveInRoot, slugify, validateProjectRel } from '../../paths.js';15import { gitClone, gitInit, readBranch, readOriginUrl, sanitizeRemote, validateGitUrl } from './git.js';1617export interface ProjectRow {18 id: string; owner_id: string; name: string; description: string; rel_path: string; git_remote: string | null;19 instructions: string; created_at: Date; missions: number;20}21const SELECT = `SELECT p.id, p.owner_id, p.name, p.description, p.rel_path, p.git_remote, p.instructions, p.created_at,22 (SELECT count(*)::int FROM missions m WHERE m.project_id = p.id) AS missions23 FROM projects p`;24const IdParams = z.object({ id: Id });2526export const ownedProject = (db: Queryable, id: string, userId: string): Promise<ProjectRow | null> =>27 db.one<ProjectRow>(`${SELECT} WHERE p.id = $1 AND p.owner_id = $2`, [id, userId]);2829/** realpath of the project directory, or null when it is missing / escapes the root. */30export function workspaceOf(ctx: Ctx, relPath: string): string | null {31 try {32 const r = resolveInRoot(ctx.config.workspaceRoot, validateProjectRel(relPath));33 return r.exists && fs.statSync(r.abs).isDirectory() ? r.abs : null;34 } catch {35 return null;36 }37}3839export default async function projectsPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {40 const { db, config } = ctx;41 const dto = (r: ProjectRow): ProjectDto => {42 const abs = workspaceOf(ctx, r.rel_path);43 return {44 id: r.id, name: r.name, description: r.description, path: r.rel_path, gitRemote: sanitizeRemote(r.git_remote),45 gitBranch: abs ? readBranch(abs) : null, instructions: r.instructions, missions: r.missions, createdAt: r.created_at.toISOString(),46 };47 };4849 app.get('/projects', { config: { doc: { summary: 'Registered projects', response: z.array(ProjectDto), responseName: 'ProjectList' } } }, async (req) => {50 const rows = await db.q<ProjectRow>(`${SELECT} WHERE p.owner_id = $1 ORDER BY p.created_at DESC, p.id`, [authOf(req).userId]);51 return rows.map(dto);52 });5354 app.post('/projects', { config: { doc: { summary: 'Register a project: create (mkdir + git init), existing directory, or clone', body: CreateProject, bodyName: 'CreateProject', response: ProjectDto, responseName: 'Project' } } }, async (req, reply) => {55 const a = authOf(req);56 const body = parse(CreateProject, req.body);57 const rel = validateProjectRel(body.path?.trim() ? body.path.trim() : slugify(body.name));58 const gitUrl = body.mode === 'clone' ? validateGitUrl(body.gitUrl ?? '') : null;59 if (body.mode !== 'clone' && body.gitUrl) throw new HttpError(400, 'invalid_request', 'gitUrl is only accepted with mode "clone"');60 if (body.mode === 'existing' && !body.path?.trim()) throw new HttpError(400, 'invalid_request', 'path is required with mode "existing"');61 if (await db.one('SELECT 1 AS x FROM projects WHERE rel_path = $1', [rel])) throw new HttpError(409, 'exists', 'a project is already registered at this path');6263 fs.mkdirSync(config.workspaceRoot, { recursive: true });64 const target = resolveInRoot(config.workspaceRoot, rel);65 let created = false;66 let gitRemote: string | null = null;67 if (body.mode === 'existing') {68 if (!target.exists || !fs.statSync(target.abs).isDirectory()) throw new HttpError(404, 'path_not_found', 'no such directory under the workspace root');69 gitRemote = readOriginUrl(target.abs);70 } else {71 if (target.exists) throw new HttpError(409, 'path_exists', 'the destination already exists');72 if (gitUrl) {73 fs.mkdirSync(path.dirname(target.abs), { recursive: true }); // inside the root: target.abs is realpath-based74 await gitClone(gitUrl, target.abs, config.cloneTimeoutMs);75 gitRemote = sanitizeRemote(gitUrl);76 } else {77 fs.mkdirSync(target.abs, { recursive: true });78 try {79 await gitInit(target.abs);80 } catch {81 fs.rmSync(target.abs, { recursive: true, force: true });82 throw new HttpError(503, 'git_unavailable', 'git init failed on this host');83 }84 }85 created = true;86 // Re-check after the fact: what was created must still resolve inside the root.87 const check = resolveInRoot(config.workspaceRoot, rel);88 if (!check.exists) throw new HttpError(500, 'internal', 'project directory vanished');89 }9091 let id: string;92 try {93 const row = await db.one<{ id: string }>(94 'INSERT INTO projects (owner_id, name, description, rel_path, git_remote, instructions) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id',95 [a.userId, body.name, body.description, rel, gitRemote, body.instructions],96 );97 if (!row) throw new Error('project insert failed');98 id = row.id;99 } catch (err) {100 if (created) fs.rmSync(target.abs, { recursive: true, force: true });101 if ((err as { code?: string }).code === '23505') throw new HttpError(409, 'exists', 'a project is already registered at this path');102 throw err;103 }104 await ctx.audit.req(req, 'project.create', { target: id, detail: { mode: body.mode, path: rel } });105 const out = await ownedProject(db, id, a.userId);106 if (!out) throw notFound('project');107 return reply.code(201).send(dto(out));108 });109110 app.get('/projects/:id', { config: { doc: { summary: 'One project', response: ProjectDto, responseName: 'Project' } } }, async (req) => {111 const row = await ownedProject(db, parse(IdParams, req.params).id, authOf(req).userId);112 if (!row) throw notFound('project');113 return dto(row);114 });115116 app.patch('/projects/:id', { config: { doc: { summary: 'Rename / describe / set operator instructions', body: UpdateProject, bodyName: 'UpdateProject', response: ProjectDto, responseName: 'Project' } } }, async (req) => {117 const a = authOf(req);118 const { id } = parse(IdParams, req.params);119 const body = parse(UpdateProject, req.body);120 const rows = await db.q(121 `UPDATE projects SET name = coalesce($3, name), description = coalesce($4, description), instructions = coalesce($5, instructions), updated_at = now()122 WHERE id = $1 AND owner_id = $2 RETURNING id`,123 [id, a.userId, body.name ?? null, body.description ?? null, body.instructions ?? null],124 );125 if (rows.length === 0) throw notFound('project');126 await ctx.audit.req(req, 'project.update', { target: id, detail: { fields: Object.keys(body) } });127 const row = await ownedProject(db, id, a.userId);128 if (!row) throw notFound('project');129 return dto(row);130 });131132 app.delete('/projects/:id', { config: { doc: { summary: 'Unregister a project (the directory is left untouched)' } } }, async (req) => {133 const a = authOf(req);134 const { id } = parse(IdParams, req.params);135 const live = await db.one<{ n: number }>(136 "SELECT count(*)::int AS n FROM missions WHERE project_id = $1 AND owner_id = $2 AND status IN ('queued','running','waiting')",137 [id, a.userId],138 );139 if (live && live.n > 0) throw new HttpError(409, 'project_busy', 'cancel the running missions of this project first');140 const rows = await db.q('DELETE FROM projects WHERE id = $1 AND owner_id = $2 RETURNING id', [id, a.userId]);141 if (rows.length === 0) throw notFound('project');142 await ctx.audit.req(req, 'project.delete', { target: id });143 return { ok: true };144 });145}146