Repository Public
1/**2 * Projects = registered directories under a configured workspace root. The server maps3 * `rootLabel` + a validated relative path to an absolute path; the runtime re-validates.4 * DELETE only unregisters: files are never removed.5 */6import type { FastifyInstance } from 'fastify';7import { z } from 'zod';8import { CreateProject, Id, Project, UpdateProject } from '@cr/shared';9import { HttpError, authOf, notFound, parse, type Ctx } from '../../http.js';10import { absUnder, rootByLabel, runtimeToHttp, slugify, validateRel } from '../../paths.js';11import { RuntimeError } from '../../runtime-client.js';12import { LIVE_SESSION_STATES } from '@cr/shared';1314interface ProjectRow {15 id: string;16 name: string;17 description: string;18 root_label: string;19 rel_path: string;20 git_remote: string | null;21 tags: string[];22 pinned: boolean;23 settings: Record<string, unknown>;24 last_activity_at: Date | null;25 created_at: Date;26 active_sessions: number;27}28const SELECT = `SELECT p.*, (SELECT count(*)::int FROM terminal_sessions s29 WHERE s.project_id = p.id AND s.state = ANY($1::text[])) AS active_sessions30 FROM projects p`;31const LIVE = [...LIVE_SESSION_STATES];32const IdParams = z.object({ id: Id });33const WorkspaceRoots = z.array(z.object({ label: z.string() }));3435const GIT_URL = /^(https:\/\/|ssh:\/\/|git@[A-Za-z0-9.-]+:|gitsrv:)[^\s]+$/;36/** Removes embedded credentials before a remote URL is stored or shown. */37const scrubUrl = (url: string): string => url.replace(/^(https?:\/\/)[^/@\s]+@/, '$1');3839interface GitInfo {40 branch: string | null;41 dirty: boolean | null;42 remote: string | null;43}44const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null);4546export default async function projectsPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {47 const { db, rt, audit, config } = ctx;48 const gitCache = new Map<string, { at: number; info: GitInfo | null }>();4950 /** Branch / dirty flag from the runtime, cached 30 s; any runtime problem ⇒ null (unknown). */51 async function gitInfo(abs: string, fresh = false): Promise<GitInfo | null> {52 const hit = gitCache.get(abs);53 if (!fresh && hit && Date.now() - hit.at < 30_000) return hit.info;54 let info: GitInfo | null = null;55 try {56 const r = await rt.json<Record<string, unknown> | null>('POST', '/git/info', { root: abs }, { timeoutMs: 5000 });57 if (r && r.isRepo !== false) {58 info = {59 branch: str(r.branch),60 dirty: typeof r.dirty === 'boolean' ? r.dirty : null,61 remote: str(r.remote) ?? str(r.remoteUrl),62 };63 }64 } catch (err) {65 if (!(err instanceof RuntimeError)) throw err;66 }67 gitCache.set(abs, { at: Date.now(), info });68 return info;69 }70 const absOf = (r: Pick<ProjectRow, 'root_label' | 'rel_path'>): string | null => {71 const root = config.workspaceRoots.find((w) => w.label === r.root_label);72 return root ? absUnder(root.path, r.rel_path) : null; // root removed from the configuration73 };74 async function dto(r: ProjectRow): Promise<Project> {75 const abs = absOf(r);76 const git = abs ? await gitInfo(abs) : null;77 return {78 id: r.id, name: r.name, description: r.description, rootLabel: r.root_label, path: r.rel_path,79 gitRemote: r.git_remote, gitBranch: git?.branch ?? null, gitDirty: git?.dirty ?? null,80 tags: r.tags, pinned: r.pinned, settings: r.settings, activeSessions: r.active_sessions,81 lastActivityAt: r.last_activity_at?.toISOString() ?? null, createdAt: r.created_at.toISOString(),82 };83 }84 const owned = async (id: string, ownerId: string): Promise<ProjectRow> => {85 const row = await db.one<ProjectRow>(`${SELECT} WHERE p.id = $2 AND p.owner_id = $3`, [LIVE, id, ownerId]);86 if (!row) throw notFound('project');87 return row;88 };8990 app.get('/workspace/roots', { config: { doc: { summary: 'Configured workspace root labels', response: WorkspaceRoots, responseName: 'WorkspaceRoots' } } }, () =>91 config.workspaceRoots.map((r) => ({ label: r.label })),92 );9394 app.get('/projects', { config: { doc: { summary: 'List projects', response: z.array(Project), responseName: 'ProjectList' } } }, async (req) => {95 const rows = await db.q<ProjectRow>(`${SELECT} WHERE p.owner_id = $2 ORDER BY p.pinned DESC, lower(p.name)`, [LIVE, authOf(req).userId]);96 return Promise.all(rows.map(dto));97 });9899 app.post('/projects', { config: { doc: { summary: 'Register a project (create / existing / clone)', body: CreateProject, bodyName: 'CreateProject', response: Project, responseName: 'Project' } } }, async (req, reply) => {100 const a = authOf(req);101 const body = parse(CreateProject, req.body);102 const root = rootByLabel(config, body.rootLabel);103 const rel = validateRel(body.path ?? slugify(body.name), { allowEmpty: body.mode === 'existing' });104 const abs = absUnder(root.path, rel);105 let remote: string | null = null;106 try {107 if (body.mode === 'create') {108 await rt.json('POST', '/fs/op', { root: root.path, op: 'mkdir', path: rel });109 } else if (body.mode === 'existing') {110 await rt.json('POST', '/fs/list', { root: root.path, rel }); // succeeds only for a directory inside the root111 } else {112 const url = body.gitUrl ?? '';113 if (!GIT_URL.test(url) || url.startsWith('-')) throw new HttpError(400, 'invalid_git_url', 'only https://, ssh://, git@host:path and gitsrv: URLs are accepted');114 await rt.json('POST', '/git/clone', { root: root.path, rel, url }, { timeoutMs: 10 * 60_000 });115 remote = scrubUrl(url);116 }117 } catch (err) {118 await audit.req(req, 'project.create', { ok: false, detail: { mode: body.mode } });119 if (err instanceof RuntimeError && err.status === 404) throw new HttpError(400, 'path_not_found', 'directory does not exist');120 return runtimeToHttp(err);121 }122 if (!remote) remote = (await gitInfo(abs, true))?.remote ?? null;123 let id: string;124 try {125 const row = await db.one<{ id: string }>(126 `INSERT INTO projects (owner_id, name, description, root_label, rel_path, git_remote, tags)127 VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,128 [a.userId, body.name, body.description, root.label, rel, remote ? scrubUrl(remote) : null, body.tags],129 );130 if (!row) throw new Error('insert failed');131 id = row.id;132 } catch (err) {133 if ((err as { code?: string }).code === '23505') throw new HttpError(409, 'project_exists', 'this directory is already registered as a project');134 throw err;135 }136 await audit.req(req, 'project.create', { target: id, detail: { mode: body.mode, rootLabel: root.label, path: rel } });137 void reply.code(201);138 return dto(await owned(id, a.userId));139 });140141 app.get('/projects/:id', { config: { doc: { summary: 'Get one project', response: Project, responseName: 'Project' } } }, async (req) => {142 const { id } = parse(IdParams, req.params);143 return dto(await owned(id, authOf(req).userId));144 });145146 app.patch('/projects/:id', { config: { doc: { summary: 'Update project metadata', body: UpdateProject, bodyName: 'UpdateProject', response: Project, responseName: 'Project' } } }, async (req) => {147 const a = authOf(req);148 const { id } = parse(IdParams, req.params);149 const body = parse(UpdateProject, req.body);150 await owned(id, a.userId);151 await db.q(152 `UPDATE projects SET name = coalesce($2, name), description = coalesce($3, description), tags = coalesce($4, tags),153 pinned = coalesce($5, pinned), settings = coalesce($6, settings), updated_at = now() WHERE id = $1`,154 [id, body.name ?? null, body.description ?? null, body.tags ?? null, body.pinned ?? null, body.settings ? JSON.stringify(body.settings) : null],155 );156 await audit.req(req, 'project.update', { target: id, detail: { fields: Object.keys(body) } });157 return dto(await owned(id, a.userId));158 });159160 app.delete('/projects/:id', { config: { doc: { summary: 'Unregister a project (files are never deleted)' } } }, async (req) => {161 const a = authOf(req);162 const { id } = parse(IdParams, req.params);163 await owned(id, a.userId);164 await db.q('DELETE FROM projects WHERE id = $1', [id]);165 await audit.req(req, 'project.delete', { target: id });166 return { ok: true };167 });168}169