import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'; import { homedir } from 'os'; import { isAbsolute, join, resolve } from 'path'; import { scanOrigin } from './scanOrigin'; export interface ProjectEntry { id: string; name: string; fe?: string; be?: string; hints?: string; } export interface Workspace { version: 1; projects: ProjectEntry[]; } export const ID = /^[a-z0-9][a-z0-9-]{0,62}$/; // cm:why Env-overridable so a test never touches the real workspace, and so a machine can keep more // than one — the path is read on every call, never captured at import time. export function workspaceRoot(): string { const override = process.env.APIFLOW_HOME; return override !== undefined && override !== '' ? resolve(override) : join(homedir(), '.apiflow'); } function workspaceFile(): string { return join(workspaceRoot(), 'workspace.json'); } export function slug(name: string): string { const out = name .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[đĐ]/g, 'd') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 63); return ID.test(out) ? out : ''; } export function readWorkspace(): Workspace { const file = workspaceFile(); if (!existsSync(file)) return { version: 1, projects: [] }; const parsed = JSON.parse(readFileSync(file, 'utf8')) as Workspace; if (parsed.version !== 1) throw new Error(`unsupported workspace version: ${String(parsed.version)}`); return { version: 1, projects: parsed.projects ?? [] }; } export function writeWorkspace(workspace: Workspace): void { mkdirSync(workspaceRoot(), { recursive: true }); const sorted = [...workspace.projects].sort((a, b) => a.id.localeCompare(b.id)); writeFileSync(workspaceFile(), `${JSON.stringify({ version: 1, projects: sorted }, null, 2)}\n`); } export function findProject(id: string): ProjectEntry | undefined { return readWorkspace().projects.find((p) => p.id === id); } // cm:why The map stopped carrying a machine path on purpose (see scanOrigin), so anything needing the // real directory back — probe emitting a harness, a re-scan — resolves it HERE against the registry. // cm:why The map says which repo it is; only this machine knows where that repo sits on it. export function localRootFor(origin: string): string | undefined { for (const p of readWorkspace().projects) { for (const side of [p.fe, p.be]) { if (side !== undefined && existsSync(side) && scanOrigin(side) === origin) return side; } } return undefined; } // cm:guard Every root is stored absolute and verified to be a directory HERE, so no other layer has // to re-check it — the server resolves an id to a path and must never widen what a path may be. export function checkRoot(label: string, value: string): string { const abs = isAbsolute(value) ? value : resolve(value); if (!existsSync(abs) || !statSync(abs).isDirectory()) { throw new Error(`${label} is not an existing directory: ${abs}`); } return abs; } export interface AddOptions { name: string; imported?: Array<'fe' | 'be'>; fe?: string; be?: string; hints?: string; id?: string; } export function addProject(options: AddOptions): ProjectEntry { const id = options.id !== undefined && options.id !== '' ? options.id : slug(options.name); if (!ID.test(id)) throw new Error(`invalid id (a-z, 0-9 and dashes only): ${id}`); // cm:why An imported side counts as a side: a BE on another machine has no directory here, and // refusing it would push the cross-machine case out of the workspace. // cm:why The workspace is where linking, reconciliation and the alert list all live. if (options.fe === undefined && options.be === undefined && (options.imported ?? []).length === 0) { throw new Error('needs at least an FE or a BE directory, or a map file to import'); } const workspace = readWorkspace(); if (workspace.projects.some((p) => p.id === id)) throw new Error(`project already exists: ${id}`); const entry: ProjectEntry = { id, name: options.name }; // cm:why Labels read as prose, not as CLI flags: the same message is shown in the Add project // form in the browser, where "--fe is not a directory" names a flag that has no field there. if (options.fe !== undefined) entry.fe = checkRoot('FE directory', options.fe); if (options.be !== undefined) entry.be = checkRoot('BE directory', options.be); if (options.hints !== undefined) entry.hints = resolve(options.hints); writeWorkspace({ version: 1, projects: [...workspace.projects, entry] }); return entry; } export interface UpdateOptions { name?: string; fe?: string | null; be?: string | null; hints?: string | null; } // cm:guard The id is NOT editable: it is the name of the directory the maps live in, so changing it // here would leave every scanned map behind under a name nothing points at any more. // cm:why `null` clears a side and `undefined` leaves it alone — a form that submits an empty text // field has to be able to say "remove the BE root", and "" cannot mean both that and "unchanged". export function updateProject(id: string, changes: UpdateOptions): ProjectEntry { const workspace = readWorkspace(); const current = workspace.projects.find((p) => p.id === id); if (current === undefined) throw new Error(`no project named ${id}`); const next: ProjectEntry = { ...current }; if (changes.name !== undefined) { if (changes.name.trim() === '') throw new Error('name must not be blank'); next.name = changes.name.trim(); } for (const side of ['fe', 'be'] as const) { const value = changes[side]; if (value === undefined) continue; if (value === null) delete next[side]; else next[side] = checkRoot(`${side.toUpperCase()} directory`, value); } if (changes.hints !== undefined) { if (changes.hints === null) delete next.hints; else next.hints = resolve(changes.hints); } if (next.fe === undefined && next.be === undefined) { throw new Error('needs at least an FE or a BE directory'); } writeWorkspace({ version: 1, projects: workspace.projects.map((p) => (p.id === id ? next : p)) }); return next; } export function removeProject(id: string): boolean { const workspace = readWorkspace(); const kept = workspace.projects.filter((p) => p.id !== id); if (kept.length === workspace.projects.length) return false; writeWorkspace({ version: 1, projects: kept }); return true; }