SPB Git

Repository Public

index.ts 8.2 KB 169 lines typescript

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

Theme