/** * Repo registry for the multi-repo visualizer. * * The visualizer can serve several repos' trace DBs from one process. A repo * is a slug → sssf.db mapping, declared in a JSON file: * * [ * { "slug": "tailsandstays", "name": "Tails & Stays", * "db": "/home/ima/projects/tailsandstays/adws/adw_data/sssf.db" }, * { "slug": "mempalace", "name": "MemPalace", * "db": "/home/ima/projects/mempalace/adws/adw_data/sssf.db" } * ] * * Point the server at it with SSSF_REPOS=/path/to/repos.json. When SSSF_REPOS * is unset the server auto-discovers repos under a projects root (default * ~/projects, override with SSSF_PROJECTS_ROOT) by scanning for a trace db at * //adws/adw_data/sssf.db. If nothing is discovered it falls back to * the single-repo behaviour (--db / SSSF_DB / /adws/adw_data/sssf.db) * under a slug derived from the repo dir name, so the original single-repo * deployment keeps working. */ import { existsSync, readdirSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { SssfDb, resolveDbPath } from "./db.ts"; export interface RepoEntry { slug: string; name: string; db: string; } export interface RepoRuntime { slug: string; name: string; db: SssfDb; } const SAFE_SLUG = /^[A-Za-z0-9._-]+$/; function isSafeSlug(slug: string): boolean { return SAFE_SLUG.test(slug) && slug !== "." && slug !== ".."; } /** Derive a display slug from a db path: /adws/adw_data/sssf.db → repo dir name. */ function slugFromDb(dbPath: string): string { // adw_data → adws → repo root const repoDir = dirname(dirname(dirname(dbPath))); const name = basename(repoDir); return name && isSafeSlug(name) ? name : "default"; } async function loadReposFile(path: string): Promise { const raw = Bun.file(path); if (!existsSync(path)) { throw new Error(`SSSF_REPOS file not found: ${path}`); } const data = JSON.parse(await raw.text()) as unknown; if (!Array.isArray(data)) { throw new Error(`SSSF_REPOS file must be a JSON array of { slug, name, db }`); } const entries: RepoEntry[] = []; const seen = new Set(); for (const item of data) { const entry = item as Partial; if (typeof entry.slug !== "string" || !isSafeSlug(entry.slug)) { throw new Error(`invalid repo slug: ${String(entry.slug)}`); } if (typeof entry.db !== "string") { throw new Error(`repo "${entry.slug}" is missing a db path`); } if (seen.has(entry.slug)) { throw new Error(`duplicate repo slug: ${entry.slug}`); } seen.add(entry.slug); const db = isAbsolute(entry.db) ? entry.db : resolve(dirname(path), entry.db); entries.push({ slug: entry.slug, name: typeof entry.name === "string" && entry.name ? entry.name : entry.slug, db, }); } if (entries.length === 0) { throw new Error("SSSF_REPOS file declares no repos"); } return entries; } /** * Auto-discover repos under a projects root by scanning for a trace db at * //adws/adw_data/sssf.db. Used when SSSF_REPOS is unset so new * repos appear without editing a registry file. The root defaults to * ~/projects and can be overridden with SSSF_PROJECTS_ROOT. */ function discoverRepos(root: string): RepoEntry[] { if (!existsSync(root)) { return []; } const entries: RepoEntry[] = []; for (const name of readdirSync(root, { withFileTypes: true })) { if (!name.isDirectory() || name.name.startsWith(".")) { continue; } const db = join(root, name.name, "adws", "adw_data", "sssf.db"); if (existsSync(db) && isSafeSlug(name.name)) { entries.push({ slug: name.name, name: name.name, db }); } } return entries; } /** * Build the repo registry. The first repo is served under the legacy * unprefixed /api routes (the single repo in single-repo mode, or the first * repo in multi-repo mode). */ export async function buildRepos(): Promise<{ repos: RepoRuntime[] }> { const reposFile = process.env.SSSF_REPOS; const entries: RepoEntry[] = reposFile ? await loadReposFile(reposFile) : discoverRepos(process.env.SSSF_PROJECTS_ROOT ?? join(homedir(), "projects")); if (entries.length === 0) { // No discovered repos — fall back to the single-repo behaviour so the // original deployment (--db / SSSF_DB / /adws/adw_data/sssf.db) works. entries.push({ slug: slugFromDb(resolveDbPath()), name: "", db: resolveDbPath() }); } const repos: RepoRuntime[] = []; for (const entry of entries) { let db: SssfDb; try { db = new SssfDb(entry.db); } catch (error) { console.error(`[sssf] repo "${entry.slug}": ${(error as Error).message}`); continue; } repos.push({ slug: entry.slug, name: entry.name, db }); } if (repos.length === 0) { console.error("[sssf] no repos could be opened — exiting"); process.exit(1); } return { repos }; }