Port the sssf skill from ~/.agents/skills/sssf into this repo so it can be distributed and installed with the skills CLI (skills add INDigitalStudio/skills --skill sssf). - Copy the skill (SKILL.md, cookbooks, references, scripts, templates, and the visualizer app source) into sssf/. - Gitignore build/runtime artifacts: the visualizer's node_modules/ and dist/, Python bytecode, and the machine-specific repos.json. - Make the skill location-independent: install.py now stamps the skill's real path into the stamped justfile's skill_dir (replacing the hardcoded ~/.agents/skills/sssf), so 'just obs' finds the visualizer wherever the CLI installed the skill. - Update cookbooks to use <skill>/scripts/... instead of the hardcoded path, and document the skills CLI install command. - Update the repo README with install instructions.
146 lines
4.9 KiB
TypeScript
146 lines
4.9 KiB
TypeScript
/**
|
|
* 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
|
|
* <root>/<dir>/adws/adw_data/sssf.db. If nothing is discovered it falls back to
|
|
* the single-repo behaviour (--db / SSSF_DB / <cwd>/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: <repo>/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<RepoEntry[]> {
|
|
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<string>();
|
|
for (const item of data) {
|
|
const entry = item as Partial<RepoEntry>;
|
|
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
|
|
* <root>/<dir>/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 / <cwd>/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 };
|
|
}
|