Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit d9c50207d699fd3fb9b13cdc7f0bb42f0314070a
parent 050c6202ad6be34834ae0f78f84c455a82933565
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 01:48:51 -0400

common: `archilyzer doctor` — one read-only report

The env/binary/port checks that were spread over paths.ts, umtool's doctor
and worktree.mjs, in one report: node against next's floor, the checkout and
its node_modules, the path overrides in effect, the corpus (channel count,
every channel's media reachability through inspectChannelMedia, the LMDB
index by stat only), settings.json (present, a JSON object, loads), every
binary paths.ts names plus each enabled local worker's engine, parakeet-cli
and model (looked up on PATH, not run), umtool's report-pipeline table
(read from umtool/lib/tools.mjs, probed with the shared probe), and the
port block (asked of scripts/worktree.mjs) with each port free or in use.

A FAIL is something the machine is configured to do and cannot — a settings
file that is not a JSON object (every process silently reads defaults), an
engine missing beside a corpus, yt-dlp/ffmpeg/ffprobe missing beside a
corpus, a binary an override names explicitly — and exits 1. A clone with
no corpus is not broken. `--json` prints the report.

Strictly read-only: no LMDB open, no mkdir, no settings write, no port bind
(a TCP connect). `settings.ts` gains `settingsFromFile(file)`, getSettings'
body for a named file, so the doctor reads the file its paths name.
`doctor.test.ts` (8): temp checkouts, a stubbed PATH of fake binaries, and a
byte/mtime snapshot of the tree before and after every run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/bin/archilyzer.ts | 7+++++++
Acommon/bin/doctor.test.ts | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/bin/doctor.ts | 411+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/settings.ts | 8+++++++-
4 files changed, 656 insertions(+), 1 deletion(-)

diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -190,6 +190,13 @@ export const COMMANDS: Command[] = [ run: async ({ flags }) => (await import("./settings-example")).main({ check: flags.check === true }), }, + { + path: ["doctor"], + usage: + "[--json] read-only report: node, the checkout, the corpus, settings, every tool, the port block; exit 1 on a failure", + flags: { json: "boolean" }, + run: async ({ flags }) => (await import("./doctor")).main({ json: flags.json === true }), + }, // Release notes, cut locally: the same writer as the /sites and /changelog // form and POST /api/ops/cut-release, with no editor running (release.ts). { diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts @@ -0,0 +1,231 @@ +// `archilyzer doctor` over temp checkouts and a stubbed PATH. +// +// Run with: +// pnpm --filter yt-dlp-transcript-common test +// +// Every scenario builds its own tree under the OS temp dir, points a Paths at it +// and hands the doctor a PATH holding only the fake binaries the scenario +// wants. The last assertion of each is the one that matters most: the tree is +// byte-for-byte and mtime-for-mtime what it was — the doctor wrote nothing. + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import { collectDoctorReport, renderDoctorReport, type DoctorReport } from "./doctor"; + +const TMP = mkdtempSync(path.join(os.tmpdir(), "doctor-")); +after(() => rmSync(TMP, { recursive: true, force: true })); + +let n = 0; +function checkout(): { root: string; bin: string; paths: Paths } { + const root = path.join(TMP, `c${n++}`); + mkdirSync(path.join(root, "node_modules", ".pnpm"), { recursive: true }); + writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages: []\n"); + mkdirSync(path.join(root, "scripts"), { recursive: true }); + writeFileSync(path.join(root, "scripts", "diarize.mjs"), ""); + const bin = path.join(root, ".bin"); + mkdirSync(bin); + const transcriptsDir = path.join(root, "transcripts"); + const paths = { + monorepoRoot: root, + transcriptsDir, + channelsDir: path.join(transcriptsDir, "channels"), + lmdbPath: path.join(transcriptsDir, "index.mdb"), + settingsFile: path.join(root, "settings.json"), + ytdlpBin: "yt-dlp", + ffmpegBin: "ffmpeg", + ffprobeBin: "ffprobe", + galleryDlBin: "gallery-dl", + rsyncBin: "rsync", + findmntBin: "findmnt", + udisksctlBin: "udisksctl", + claudeBin: "claude", + diarizeBin: path.join(root, "scripts", "diarize.mjs"), + whisperModel: path.join(root, "models", "ggml-base.en.bin"), + parakeetModel: "", + parakeetCliBin: "parakeet-cli", + } as unknown as Paths; + return { root, bin, paths }; +} + +function fake(binDir: string, name: string, version = "1.2.3"): void { + const p = path.join(binDir, name); + writeFileSync(p, `#!/bin/sh\necho "${name} ${version}"\n`); + chmodSync(p, 0o755); +} + +// Every file and dir under root, with its size and mtime: what "wrote nothing" +// is checked against. +function tree(root: string): string[] { + const out: string[] = []; + const walk = (d: string) => { + for (const e of readdirSync(d)) { + const p = path.join(d, e); + const st = statSync(p, { throwIfNoEntry: false }); + out.push(`${path.relative(root, p)} ${st ? `${st.size} ${st.mtimeMs}` : "dangling"}`); + if (st?.isDirectory()) walk(p); + } + }; + walk(root); + return out.sort(); +} + +async function run(c: ReturnType<typeof checkout>, env: NodeJS.ProcessEnv = {}): Promise<DoctorReport> { + return collectDoctorReport({ + env: { PATH: c.bin, ...env }, + paths: c.paths, + nodeVersion: "22.0.0", + portBlock: async () => null, + portInUse: async () => false, + umtoolTools: async () => null, + }); +} + +const status = (r: DoctorReport, id: string) => r.checks.find((c) => c.id === id)?.status; + +test("a clone with no corpus, no settings and no tools is not broken", async () => { + const c = checkout(); + const before = tree(c.root); + const r = await run(c); + assert.equal(r.ok, true, renderDoctorReport(r)); + assert.equal(status(r, "transcripts"), "info"); + assert.equal(status(r, "settings.json"), "info"); + assert.equal(status(r, "yt-dlp"), "info"); // absent, and nothing needs it + assert.deepEqual(tree(c.root), before); +}); + +test("a corpus without ffmpeg fails, and names what needs it", async () => { + const c = checkout(); + mkdirSync(path.join(c.paths.channelsDir, "chan"), { recursive: true }); + writeFileSync(path.join(c.paths.channelsDir, "chan", "config.json"), "{}"); + writeFileSync(c.paths.settingsFile, "{}"); + fake(c.bin, "yt-dlp", "2026.01.01"); + const before = tree(c.root); + const r = await run(c); + assert.equal(r.ok, false); + assert.equal(status(r, "yt-dlp"), "ok"); + assert.equal(status(r, "ffmpeg"), "fail"); + assert.equal(status(r, "ffprobe"), "fail"); + assert.match(r.checks.find((x) => x.id === "ffmpeg")!.detail, /a corpus is here/); + // No index yet: a warning, and the doctor did not build one. + assert.equal(status(r, "index"), "warn"); + assert.deepEqual(tree(c.root), before); +}); + +test("a settings file that does not parse fails", async () => { + const c = checkout(); + writeFileSync(c.paths.settingsFile, "{ not json"); + const r = await run(c); + assert.equal(status(r, "settings.json"), "fail"); + assert.equal(r.ok, false); + assert.match(renderDoctorReport(r), /FAIL settings\.json/); +}); + +test("an enabled whisper worker needs its engine and its model — beside a corpus", async () => { + const settings = { + workers: [ + { id: "w1", name: "GPU", kind: "local", enabled: true, priority: 0, appId: "whisper-cpp", config: { bin: "whisper-cli" } }, + ], + }; + // No corpus yet: nothing to transcribe, so a warning, not a failure. + const bare = checkout(); + writeFileSync(bare.paths.settingsFile, JSON.stringify(settings)); + let r = await run(bare); + assert.equal(status(r, "engine:w1"), "warn"); + assert.equal(r.ok, true, renderDoctorReport(r)); + + const c = checkout(); + mkdirSync(path.join(c.paths.channelsDir, "chan"), { recursive: true }); + writeFileSync(path.join(c.paths.channelsDir, "chan", "config.json"), "{}"); + writeFileSync(c.paths.lmdbPath, ""); + for (const b of ["yt-dlp", "ffmpeg", "ffprobe"]) fake(c.bin, b); + writeFileSync(c.paths.settingsFile, JSON.stringify(settings)); + r = await run(c); + assert.equal(status(r, "engine:w1"), "fail"); + assert.equal(status(r, "model:w1"), "fail"); + assert.equal(r.ok, false); + fake(c.bin, "whisper-cli"); + mkdirSync(path.dirname(c.paths.whisperModel), { recursive: true }); + writeFileSync(c.paths.whisperModel, "model"); + const before = tree(c.root); + r = await run(c); + assert.equal(status(r, "engine:w1"), "ok", renderDoctorReport(r)); + assert.equal(status(r, "model:w1"), "ok"); + assert.equal(r.ok, true, renderDoctorReport(r)); + assert.deepEqual(tree(c.root), before); +}); + +test("an override naming a binary that is not there fails even when nothing needs it", async () => { + const c = checkout(); + const r = await run(c, { YTDLP_BIN: "/nonexistent/yt-dlp" }); + // The report shows the override… + assert.match(r.checks.find((x) => x.id === "overrides")!.detail, /YTDLP_BIN=\/nonexistent\/yt-dlp/); + // …but the probe here ran the Paths' binary name, so pass the override's + // path the way getPaths() would have. + const c2 = checkout(); + (c2.paths as { ytdlpBin: string }).ytdlpBin = "/nonexistent/yt-dlp"; + const r2 = await run(c2, { YTDLP_BIN: "/nonexistent/yt-dlp" }); + assert.equal(status(r2, "yt-dlp"), "fail"); + assert.equal(r2.ok, false); +}); + +test("an unmounted drive is a warning, not an empty channel and not a failure", async () => { + const c = checkout(); + const chan = path.join(c.paths.channelsDir, "moved"); + mkdirSync(chan, { recursive: true }); + const target = path.join(c.root, "elsewhere", "moved", "data"); + writeFileSync(path.join(chan, "config.json"), JSON.stringify({ dataDir: target })); + symlinkSync(target, path.join(chan, "data")); // the target does not exist + // No workers: a `{}` file would synthesize whisper workers, whose engine + // this machine does not have — a real failure, and not this test's. + writeFileSync(c.paths.settingsFile, JSON.stringify({ workers: [] })); + for (const b of ["yt-dlp", "ffmpeg", "ffprobe"]) fake(c.bin, b); + writeFileSync(c.paths.lmdbPath, ""); + const before = tree(c.root); + const r = await run(c); + const media = r.checks.find((x) => x.id === "media")!; + assert.equal(media.status, "warn"); + assert.match(media.detail, /moved: unreachable/); + assert.equal(r.ok, true, renderDoctorReport(r)); + assert.deepEqual(tree(c.root), before); +}); + +test("node older than next's floor fails", async () => { + const c = checkout(); + const r = await collectDoctorReport({ + env: { PATH: c.bin }, + paths: c.paths, + nodeVersion: "20.8.1", + portBlock: async () => null, + portInUse: async () => false, + umtoolTools: async () => null, + }); + assert.equal(status(r, "node"), "fail"); +}); + +test("umtool's table and the port block are reported, never failed", async () => { + const c = checkout(); + const r = await collectDoctorReport({ + env: { PATH: c.bin }, + paths: c.paths, + nodeVersion: "22.0.0", + portBlock: async () => ({ label: "worktree #3 (offset 300)", ports: { EDITOR_PORT: "3301" } }), + portInUse: async (p) => p === 3301, + umtoolTools: async () => [{ id: "qrencode", bin: "qrencode", neededBy: ["report-video QR"], required: true }], + }); + assert.equal(status(r, "qrencode"), "warn"); + assert.match(r.checks.find((x) => x.id === "EDITOR_PORT")!.detail, /^3301 in use/); + assert.equal(r.ok, true); +}); diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts @@ -0,0 +1,411 @@ +// `archilyzer doctor` — can this checkout do what it is configured to do? +// +// One read-only report over what used to be three places: the path and binary +// overrides (lib/paths.ts), umtool's tool probe (`umtool doctor`, whose table +// this reads and whose probe it shares — lib/toolProbe.mjs) and the port block +// (scripts/worktree.mjs over lib/ports.mjs). +// +// STRICTLY READ-ONLY. It stats, reads and runs version flags. It never opens +// LMDB (the index is stat'd, not opened), never mkdirs, never writes settings, +// and never binds a port (a port is "in use" when a TCP connect succeeds). The +// one process-state change is a chdir around umtool's table, which resolves a +// path from the cwd; it is put back before anything else runs. +// +// A FAIL is something this machine is CONFIGURED to do and cannot: a settings +// file that does not parse, an enabled worker whose engine is missing, a binary +// an env override names that is not there, yt-dlp missing beside a corpus. It +// exits 1. Everything else is a warning or a note — a clone with no corpus and +// no media tools is a complete research environment over a published archive, +// and must not be told it is broken. + +import { execFile } from "node:child_process"; +import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import net from "node:net"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import type { Paths } from "../lib/paths"; +import { ENV_VARS } from "../lib/envVars"; +import { PORTS, portsForOffset } from "../lib/ports.mjs"; +import { probeTool } from "../lib/toolProbe.mjs"; + +const execFileP = promisify(execFile); + +export type CheckStatus = "ok" | "info" | "warn" | "fail"; + +export type DoctorCheck = { + section: string; + id: string; + status: CheckStatus; + detail: string; +}; + +export type DoctorReport = { + root: string; + checks: DoctorCheck[]; + // No check failed. + ok: boolean; +}; + +type ToolSpec = { + id: string; + bin?: string; + file?: string; + args?: string[]; + fallback?: string; + neededBy: string[]; + required?: boolean; +}; +type ToolReport = Awaited<ReturnType<typeof probeTool>>; + +export type DoctorDeps = { + env: NodeJS.ProcessEnv; + paths: Paths; + nodeVersion?: string; + probe?: (t: ToolSpec) => Promise<ToolReport>; + portInUse?: (port: number) => Promise<boolean>; + // The worktree's port env, or null when this is not a git checkout. Default: + // `node scripts/worktree.mjs ports`, the one implementation of "which block". + portBlock?: () => Promise<{ label: string; ports: Record<string, string> } | null>; + // umtool's report-pipeline table, or null when there is no umtool here. + umtoolTools?: () => Promise<ToolSpec[] | null>; +}; + +const MIN_NODE = [20, 9, 0] as const; // next 16's engines field + +export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorReport> { + const { env, paths } = deps; + const probe = deps.probe ?? ((t: ToolSpec) => probeTool(t, { env })); + const checks: DoctorCheck[] = []; + const add = (section: string, id: string, status: CheckStatus, detail: string) => + checks.push({ section, id, status, detail }); + + // ── workspace ──────────────────────────────────────────────────────────── + const W = "workspace"; + const nodeV = deps.nodeVersion ?? process.versions.node; + add(W, "node", versionAtLeast(nodeV, MIN_NODE) ? "ok" : "fail", + `v${nodeV} (needs >= ${MIN_NODE.join(".")})`); + const root = paths.monorepoRoot; + if (existsSync(path.join(root, "pnpm-workspace.yaml"))) { + add(W, "checkout", "ok", root); + } else { + add(W, "checkout", "fail", + `no pnpm-workspace.yaml above ${root} — run this from inside the repo, or every path below is wrong`); + } + add(W, "dependencies", existsSync(path.join(root, "node_modules", ".pnpm")) ? "ok" : "fail", + existsSync(path.join(root, "node_modules", ".pnpm")) ? "node_modules installed" : "node_modules missing — run `pnpm install`"); + const overrides = ENV_VARS.filter((v) => v.audience === "paths" && env[v.name] != null && env[v.name] !== ""); + add(W, "overrides", "info", + overrides.length === 0 + ? "no path or binary overrides set (ENVIRONMENT.md lists them)" + : overrides.map((v) => `${v.name}=${env[v.name]}`).join(" ")); + + // ── corpus ─────────────────────────────────────────────────────────────── + const C = "corpus"; + let channelSlugs: string[] = []; + if (!existsSync(paths.transcriptsDir)) { + add(C, "transcripts", "info", + `no local corpus at ${paths.transcriptsDir} — fine for research over a published archive (README, "Use an archive"); archiving needs one`); + } else { + channelSlugs = await listChannelSlugs(paths.channelsDir); + add(C, "transcripts", "ok", `${paths.transcriptsDir} (${channelSlugs.length} channel${channelSlugs.length === 1 ? "" : "s"})`); + const unreachable: string[] = []; + const { inspectChannelMedia } = await import("../lib/channelMedia"); + for (const slug of channelSlugs) { + const loc = await inspectChannelMedia(paths, slug); + if (loc.status !== "ok" && loc.status !== "in-place") { + unreachable.push(`${slug}: ${loc.status} — ${loc.detail ?? ""}`.trim()); + } + } + if (unreachable.length > 0) { + for (const u of unreachable) add(C, "media", "warn", u); + } else if (channelSlugs.length > 0) { + add(C, "media", "ok", "every channel's data/ is reachable"); + } + const index = statOrNull(paths.lmdbPath); + if (index) { + add(C, "index", "ok", `${paths.lmdbPath} (stat only; built ${index.mtime.toISOString().slice(0, 16).replace("T", " ")})`); + } else if (channelSlugs.length > 0) { + add(C, "index", "warn", `no LMDB index at ${paths.lmdbPath} — run \`archilyzer index\``); + } + } + + // ── settings ───────────────────────────────────────────────────────────── + const S = "settings"; + const settingsText = readOrNull(paths.settingsFile); + if (settingsText === null) { + add(S, "settings.json", channelSlugs.length > 0 ? "warn" : "info", + `${paths.settingsFile} does not exist: every process runs on the defaults (SETTINGS.md)`); + } else { + let parsed: unknown; + try { + parsed = JSON.parse(settingsText); + } catch (err) { + parsed = err; + } + if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && !(parsed instanceof Error)) { + add(S, "settings.json", "ok", paths.settingsFile); + } else { + add(S, "settings.json", "fail", + `${paths.settingsFile} is not a JSON object${parsed instanceof Error ? ` (${parsed.message})` : ""} — every process silently reads it as the defaults`); + } + } + // The effective settings, read the way every process reads them (defaults + // when the file is absent). Read-only: the reader never writes. + const { settingsFromFile } = await import("../lib/settings"); + let settings: ReturnType<typeof settingsFromFile> | null = null; + try { + settings = settingsFromFile(paths.settingsFile); + } catch (err) { + add(S, "schema", "fail", `settings do not load: ${(err as Error).message}`); + } + + // ── tools ──────────────────────────────────────────────────────────────── + const T = "tools"; + const hasCorpus = channelSlugs.length > 0; + const needs = new Map<string, string[]>(); // binary id -> why it is required + const need = (id: string, why: string) => needs.set(id, [...(needs.get(id) ?? []), why]); + // Needed by what is switched on, but with no corpus there is nothing for it + // to run on yet: a warning ("this will fail once you archive"), not a failure. + const later = new Map<string, string[]>(); + const needLater = (id: string, why: string) => + (hasCorpus ? need : (i: string, w: string) => later.set(i, [...(later.get(i) ?? []), w]))(id, why); + if (hasCorpus) { + need("yt-dlp", "a corpus is here (every fetch)"); + need("ffmpeg", "a corpus is here (audio extraction)"); + need("ffprobe", "a corpus is here (duration checks)"); + } + const specs: ToolSpec[] = [ + { id: "yt-dlp", bin: paths.ytdlpBin, neededBy: ["downloads, sync, metadata scans"] }, + { id: "ffmpeg", bin: paths.ffmpegBin, args: ["-version"], neededBy: ["audio extraction", "diarization", "parakeet"] }, + { id: "ffprobe", bin: paths.ffprobeBin, args: ["-version"], neededBy: ["duration checks"] }, + { id: "gallery-dl", bin: paths.galleryDlBin, neededBy: ["X/Twitter post fetches"] }, + { id: "rsync", bin: paths.rsyncBin, neededBy: ["saved-video backup"] }, + { id: "findmnt", bin: paths.findmntBin, neededBy: ["storage-location identity (optional)"] }, + // Looked up, not run: udisksctl has no version flag. + { id: "udisksctl", file: onPath(paths.udisksctlBin, env.PATH) ?? paths.udisksctlBin, neededBy: ["mounting a volume from /storage (optional)"] }, + { id: "claude", bin: paths.claudeBin, neededBy: ["the metered digest lane"] }, + { id: "diarize", file: paths.diarizeBin, neededBy: ["speaker diarization (the wrapper script)"] }, + ]; + if (settings) { + if (settings.savedVideoBackup.dest.trim() !== "") need("rsync", "a backup destination is set"); + if (settings.digest.remoteEnabled) need("claude", "the metered digest lane is on"); + if (settings.diarization.enabled) need("diarize", "diarization is on"); + for (const w of settings.workers) { + if (!w.enabled || w.kind !== "local" || !w.appId) continue; + const label = `worker "${w.name || w.id}" (${w.appId}) is enabled`; + const cfg = w.config ?? {}; + const { getTranscriptionApp } = await import("../lib/transcriptionApps"); + const app = getTranscriptionApp(w.appId); + const bin = cfg.bin?.trim() || app.defaultBin(); + // An engine is LOOKED UP, not run: a transcription engine's CLI has no + // cheap version flag, and running one to find out is not a doctor's call. + const id = `engine:${w.id}`; + specs.push({ id, file: onPath(bin, env.PATH) ?? bin, neededBy: [label] }); + needLater(id, label); + const model = cfg.model?.trim() || (w.appId === "whisper-cpp" ? paths.whisperModel : w.appId === "parakeet" ? paths.parakeetModel : ""); + if (w.appId === "parakeet") { + const cli = paths.parakeetCliBin; + specs.push({ id: `parakeet-cli:${w.id}`, file: onPath(cli, env.PATH) ?? cli, neededBy: [label] }); + needLater(`parakeet-cli:${w.id}`, label); + } + if (w.appId !== "chough" || model) { + const mid = `model:${w.id}`; + if (model) specs.push({ id: mid, file: model, neededBy: [label] }); + else add(T, mid, hasCorpus ? "fail" : "warn", `${label} but names no model, and ${w.appId === "parakeet" ? "PARAKEET_MODEL" : "WHISPER_MODEL"} is unset`); + if (model) needLater(mid, label); + } + } + if (!settings.workers.some((w) => w.enabled && w.kind !== "llm")) { + add(T, "workers", hasCorpus ? "warn" : "info", "no transcription worker is enabled — auto-transcribe does nothing (configure one on /workers)"); + } + } + const overridden = new Set( + ENV_VARS.filter((v) => v.audience === "paths" && /_(BIN|CLI)$/.test(v.name) && env[v.name]).map((v) => v.name), + ); + const envNameFor: Record<string, string> = { + "yt-dlp": "YTDLP_BIN", ffmpeg: "FFMPEG_BIN", ffprobe: "FFPROBE_BIN", "gallery-dl": "GALLERY_DL_BIN", + rsync: "RSYNC_BIN", findmnt: "FINDMNT_BIN", udisksctl: "UDISKSCTL_BIN", claude: "CLAUDE_BIN", diarize: "DIARIZE_BIN", + }; + const reports = await Promise.all(specs.map((s) => probe(s))); + for (const r of reports) { + const why = needs.get(r.id); + const envName = envNameFor[r.id]; + const explicit = envName !== undefined && overridden.has(envName); + const what = `${r.bin}${r.version ? ` ${r.version}` : ""} — ${r.neededBy.join(", ")}`; + if (r.present && !r.error) add(T, r.id, "ok", what); + else if (r.present) add(T, r.id, "warn", `${what}\n${r.error}`); + else if (why) add(T, r.id, "fail", `${r.error ?? "absent"} — required: ${why.join("; ")}`); + else if (later.has(r.id)) add(T, r.id, "warn", `${r.error ?? "absent"} — ${later.get(r.id)!.join("; ")}; it will fail once there is audio to transcribe`); + else if (explicit) add(T, r.id, "fail", `${r.error ?? "absent"} — ${envName} names it explicitly`); + else add(T, r.id, "info", `${r.error ?? "absent"} — needed only for ${r.neededBy.join(", ")}`); + } + + // ── report pipeline (umtool) ───────────────────────────────────────────── + const U = "report pipeline (umtool)"; + const umtoolSpecs = await (deps.umtoolTools ?? (() => loadUmtoolTools(root)))(); + if (umtoolSpecs) { + const ur = await Promise.all(umtoolSpecs.map((s) => probe(s))); + for (const r of ur) { + const what = `${r.bin}${r.version ? ` ${r.version}` : ""} — ${r.neededBy.join(", ")}`; + add(U, r.id, r.present && !r.error ? "ok" : r.required ? "warn" : "info", + r.present && !r.error ? what : `${r.error ?? "absent"} — ${r.neededBy.join(", ")}${r.required ? " (`umtool doctor` exits 1 for this)" : ""}`); + } + } + + // ── ports ──────────────────────────────────────────────────────────────── + const P = "ports"; + const block = await (deps.portBlock ?? (() => worktreePortBlock(root)))(); + const inUse = deps.portInUse ?? tcpPortInUse; + const ports = block?.ports ?? portsForOffset(0); + add(P, "block", "info", block ? block.label : "not a git checkout: the base ports (common/lib/ports.mjs)"); + for (const name of Object.keys(PORTS)) { + const port = Number(env[name] || ports[name]); + const busy = await inUse(port); + add(P, name, "info", `${port} ${busy ? "in use" : "free"}${env[name] ? " (from the environment)" : ""} — ${PORTS[name].what}`); + } + + return { root, checks, ok: !checks.some((c) => c.status === "fail") }; +} + +export function renderDoctorReport(report: DoctorReport): string { + const mark: Record<CheckStatus, string> = { ok: "ok", info: "--", warn: "WARN", fail: "FAIL" }; + const out: string[] = [`archilyzer doctor — ${report.root} (read-only)`]; + let section = ""; + for (const c of report.checks) { + if (c.section !== section) { + section = c.section; + out.push("", section); + } + const [first, ...rest] = c.detail.split("\n"); + out.push(` ${mark[c.status].padEnd(5)}${c.id.padEnd(20)} ${first}`); + for (const r of rest) out.push(` ${"".padEnd(25)} ${r}`); + } + const fails = report.checks.filter((c) => c.status === "fail").length; + const warns = report.checks.filter((c) => c.status === "warn").length; + out.push( + "", + fails === 0 + ? `no failures${warns ? `, ${warns} warning${warns === 1 ? "" : "s"}` : ""}` + : `${fails} failure${fails === 1 ? "" : "s"}${warns ? `, ${warns} warning${warns === 1 ? "" : "s"}` : ""} — exit 1`, + ); + return out.join("\n"); +} + +export async function main(opts: { json?: boolean; env?: NodeJS.ProcessEnv } = {}): Promise<number> { + const { getPaths } = await import("../lib/paths"); + const report = await collectDoctorReport({ env: opts.env ?? process.env, paths: getPaths() }); + console.log(opts.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report)); + return report.ok ? 0 : 1; +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +function versionAtLeast(v: string, min: readonly [number, number, number]): boolean { + const parts = v.split(".").map((n) => Number.parseInt(n, 10) || 0); + for (let i = 0; i < 3; i++) { + if ((parts[i] ?? 0) !== min[i]) return (parts[i] ?? 0) > min[i]; + } + return true; +} + +// A bare name resolved against PATH the way a spawn would, without running it. +// A name with a slash is a path and is returned as it is (or null if absent). +function onPath(bin: string, envPath: string | undefined): string | null { + if (bin.includes("/")) return existsSync(bin) ? bin : null; + for (const dir of (envPath ?? "").split(path.delimiter)) { + if (!dir) continue; + const p = path.join(dir, bin); + try { + accessSync(p, constants.X_OK); + if (statSync(p).isFile()) return p; + } catch { + /* not here */ + } + } + return null; +} + +function statOrNull(p: string) { + try { + return statSync(p); + } catch { + return null; + } +} + +function readOrNull(p: string): string | null { + try { + return readFileSync(p, "utf8"); + } catch { + return null; + } +} + +// The channel list the editor sees: DIRECTORIES under channels/ (a symlinked +// channel dir is not a channel — AGENTS.md), dot-dirs excluded. +async function listChannelSlugs(channelsDir: string): Promise<string[]> { + try { + const ents = await readdir(channelsDir, { withFileTypes: true }); + return ents.filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => d.name).sort(); + } catch { + return []; + } +} + +// umtool's table, from its own module (plain ESM, no app imports) by a runtime +// import of the file — common does not depend on umtool, and must not. Its +// facecrop path is resolved from the cwd, which is umtool/ when umtool runs it, +// so that is the cwd it is evaluated under here. +async function loadUmtoolTools(root: string): Promise<ToolSpec[] | null> { + const dir = path.join(root, "umtool"); + const file = path.join(dir, "lib", "tools.mjs"); + if (!existsSync(file)) return null; + const mod = (await import(pathToFileURL(file).href)) as { TOOLS: () => ToolSpec[] }; + const prev = process.cwd(); + process.chdir(dir); + try { + return mod.TOOLS(); + } finally { + process.chdir(prev); + } +} + +// Which port block this checkout has: scripts/worktree.mjs is the one +// implementation (it asks git), so it is asked rather than re-derived. +async function worktreePortBlock( + root: string, +): Promise<{ label: string; ports: Record<string, string> } | null> { + const script = path.join(root, "scripts", "worktree.mjs"); + if (!existsSync(script)) return null; + try { + const { stdout, stderr } = await execFileP(process.execPath, [script, "ports"], { + cwd: root, + timeout: 10_000, + }); + const ports: Record<string, string> = {}; + for (const line of stdout.split("\n")) { + const eq = line.indexOf("="); + if (eq > 0) ports[line.slice(0, eq)] = line.slice(eq + 1).trim(); + } + const label = (stderr.split("\n")[0] ?? "").trim() || "worktree block"; + return { label, ports }; + } catch { + return null; + } +} + +// In use = something accepts a TCP connection on 127.0.0.1. Never binds. +function tcpPortInUse(port: number): Promise<boolean> { + return new Promise((resolve) => { + const sock = net.connect({ port, host: "127.0.0.1" }); + const done = (v: boolean) => { + sock.destroy(); + resolve(v); + }; + sock.setTimeout(500, () => done(false)); + sock.once("connect", () => done(true)); + sock.once("error", () => done(false)); + }); +} diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -119,7 +119,13 @@ function finishRawMigrations( } export function getSettings(): SiteSettings { - const raw = rawObject(readRawSettings(getPaths().settingsFile)); + return settingsFromFile(getPaths().settingsFile); +} + +// getSettings for a named file: the same read, the same migrations, no write. +// `archilyzer doctor` reads the file its (injectable) paths name through this. +export function settingsFromFile(file: string): SiteSettings { + const raw = rawObject(readRawSettings(file)); return finishRawMigrations(siteSettingsSchema.parse(premigrateRaw(raw)), raw); }