SPB Git

Repository Public

index.ts 7.8 KB 146 lines typescript

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
Files main
Menu

Theme