/** * `sl0ppi doctor` — is this installation coherent? * * Split into "gather facts" (IO) and "judge them" (pure), so the judging is * testable and the output cannot drift from the rules. * * It reports what it can actually verify. Notably it CANNOT fully validate a * project's layout fragment: sl0ppij has no parse-only mode, so a fragment is * only really checked when a tab is created from it. The structural check here * catches the common breakages (empty file, unbalanced braces, someone wrapping * the fragment in `layout {}`) and says so rather than implying more. */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { discoverProjects, LAYOUT_FILE, MARKER_DIR } from "../core/discover.ts"; import { loadRegistry, registryPath, type Registry } from "../core/root.ts"; import { Sl0ppij } from "../core/sl0ppij.ts"; export type Level = "ok" | "warn" | "fail"; export interface Check { level: Level; label: string; detail: string; /** What the user should do about it. */ fix?: string; } export interface Facts { sl0ppijVersion?: string; piVersion?: string; configDir?: string; configFile: boolean; plugins: { name: string; present: boolean }[]; registry: Registry; liveSessions: string[]; roots: { session: string; root: string; exists: boolean; projects: number }[]; fragments: { relPath: string; issue?: string }[]; externalTools: { name: string; present: boolean; neededBy: string }[]; } /** The judging half: pure, so the rules are testable. */ export function report(facts: Facts): Check[] { const checks: Check[] = []; checks.push( facts.sl0ppijVersion ? { level: "ok", label: "sl0ppij", detail: facts.sl0ppijVersion } : { level: "fail", label: "sl0ppij", detail: "not found on PATH", fix: "install sl0ppi, or set SL0PPI_BIN to the binary", }, ); checks.push( facts.piVersion ? { level: "ok", label: "pi", detail: facts.piVersion } : { level: "warn", label: "pi", detail: "not found on PATH", fix: "agent panes will fail to start; install pi", }, ); checks.push( facts.configFile ? { level: "ok", label: "config", detail: facts.configDir ?? "found" } : { level: "warn", label: "config", detail: `no config.kdl in ${facts.configDir ?? "the config dir"}`, fix: "run the installer, or `make install-config` from a checkout", }, ); for (const plugin of facts.plugins) { checks.push( plugin.present ? { level: "ok", label: `plugin ${plugin.name}`, detail: "installed" } : { level: "warn", label: `plugin ${plugin.name}`, detail: "missing", fix: "the shipped layout expects it; reinstall to restore it", }, ); } for (const tool of facts.externalTools) { if (!tool.present) { checks.push({ level: "warn", label: tool.name, detail: `not found; ${tool.neededBy} needs it`, fix: `install ${tool.name}`, }); } } if (Object.keys(facts.registry).length === 0) { checks.push({ level: "ok", label: "roots", detail: "none registered yet", fix: "run `sl0ppi up` in a directory of projects", }); } for (const root of facts.roots) { if (!root.exists) { checks.push({ level: "warn", label: `root ${root.session}`, detail: `${root.root} does not exist`, fix: "if it is an unmounted filesystem, mount it; otherwise remove it from " + registryPath(), }); continue; } const live = facts.liveSessions.includes(root.session) ? "session live" : "session not running"; checks.push({ level: "ok", label: `root ${root.session}`, detail: `${root.root} — ${root.projects} project(s), ${live}`, }); } for (const fragment of facts.fragments) { if (fragment.issue) { checks.push({ level: "warn", label: `layout ${fragment.relPath}`, detail: fragment.issue, fix: "see `sl0ppi init` for the expected shape (pane nodes only)", }); } } return checks; } /** * Cheap structural check of a fragment. * * Deliberately conservative: sl0ppij has no parse-only mode, so anything * subtler than this would be guessing at the KDL dialect. */ export function checkFragment(text: string): string | undefined { const stripped = text .split("\n") .map((line) => line.replace(/\/\/.*$/, "").trim()) .join("\n") .trim(); if (stripped.length === 0) return "empty (the default single-agent pane will be used)"; const open = (stripped.match(/\{/g) ?? []).length; const close = (stripped.match(/\}/g) ?? []).length; if (open !== close) return `unbalanced braces (${open} open, ${close} close)`; if (/^\s*layout\s*\{/m.test(stripped)) { return "wraps its panes in `layout {}`; a fragment is pane nodes only"; } if (/^\s*tab\b/m.test(stripped)) { return "declares a `tab`; sl0ppi adds that (it carries the project's identity)"; } return undefined; } export interface GatherOptions { client: Sl0ppij; configDir?: string; depth: number; } export function gather(options: GatherOptions): Facts { const { client } = options; const version = (bin: string, args: string[]): string | undefined => { try { const proc = new Sl0ppij({ bin }).run(args, { session: false }); return proc.code === 0 ? proc.stdout.trim().split("\n")[0] : undefined; } catch { return undefined; } }; const configDir = options.configDir ?? defaultConfigDir(); const registry = loadRegistry(); const roots = Object.entries(registry).map(([session, root]) => ({ session, root, exists: existsSync(root), projects: existsSync(root) ? discoverProjects(root, { depth: options.depth }).length : 0, })); const fragments: { relPath: string; issue?: string }[] = []; for (const root of roots) { if (!root.exists) continue; for (const relPath of discoverProjects(root.root, { depth: options.depth })) { const path = join(root.root, relPath, MARKER_DIR, LAYOUT_FILE); if (!existsSync(path)) continue; // no fragment is fine: the default applies fragments.push({ relPath, issue: checkFragment(readFileSync(path, "utf8")) }); } } return { sl0ppijVersion: version(client.bin, ["--version"]), piVersion: version(process.env.SL0PPI_PI ?? "pi", ["--version"]), configDir, configFile: existsSync(join(configDir, "config.kdl")), plugins: ["pane-finder.wasm", "zjstatus.wasm"].map((name) => ({ name, present: existsSync(join(configDir, "plugins", name)), })), registry, liveSessions: safe(() => client.liveSessions(), []), roots, fragments, // The known packaging hole: `pir` ships in the tarball but its runtime deps // do not. Better to say so than to let it fail at the moment of use. externalTools: [ { name: "python3", present: onPath("python3"), neededBy: "pir" }, { name: "fzf", present: onPath("fzf"), neededBy: "pir" }, ], }; } export function worstLevel(checks: Check[]): Level { if (checks.some((c) => c.level === "fail")) return "fail"; if (checks.some((c) => c.level === "warn")) return "warn"; return "ok"; } export function formatCheck(check: Check): string { const mark = check.level === "ok" ? "ok " : check.level === "warn" ? "warn" : "FAIL"; const fix = check.fix && check.level !== "ok" ? `\n -> ${check.fix}` : ""; return ` ${mark} ${check.label.padEnd(22)} ${check.detail}${fix}`; } function defaultConfigDir(): string { const env = process.env; if (env.SL0PPIJ_CONFIG_DIR) return env.SL0PPIJ_CONFIG_DIR; const home = env.HOME ?? ""; return join(home, ".config", "sl0ppij"); } function onPath(tool: string): boolean { const dirs = (process.env.PATH ?? "").split(":").filter(Boolean); return dirs.some((dir) => existsSync(join(dir, tool))); } function safe(fn: () => T, fallback: T): T { try { return fn(); } catch { return fallback; } }