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:
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);
}