// `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). // // It also asks the container engine whether Build all's site build image is // there and older than its Dockerfile (common/publish/build.ts), and what a // publish needs from this machine (release 18): which yt-dlp (the image's or // an override), whether deploy credentials are SET (never their values), room // for the bundles, the publish lock (free, held, stale — never cleared here), // the index stamp and what was built from an older one, the repository the // source mirror reads, and node against the pinned wrangler's floor. // // STRICTLY READ-ONLY. It stats, reads and runs version flags, plus the engine's // `image inspect`, a lock-free `git status` / `git log` and a `git rev-parse` // of the source repository's main. It never opens // LMDB (the index is stat'd, not opened), never mkdirs outside the OS temp dir // (one for `wrangler --version`'s debug log, removed after), never writes settings, // and never binds a port (a port is "in use" when a TCP connect succeeds). Its // one network request is the seeder's egress check (release 21) — this host's // address and the seeder container's, from one echo — made only when // `settings.seeder` names sites. 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, realpathSync, statfsSync, statSync } from "node:fs"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import net from "node:net"; import os from "node:os"; 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>; export type DoctorDeps = { env: NodeJS.ProcessEnv; paths: Paths; nodeVersion?: string; probe?: (t: ToolSpec) => Promise; portInUse?: (port: number) => Promise; // 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 } | null>; // umtool's report-pipeline table, or null when there is no umtool here. umtoolTools?: () => Promise; // The container engine and the site build image. Default: ` version`, // then ` image inspect ` — both read-only. buildImage?: (q: { bin: string; image: string }) => Promise; // When the build image's Dockerfile last changed, or null when it is not // there. Default: its last commit in a checkout, its mtime when it has // uncommitted changes or there is no checkout. dockerfileChanged?: (file: string) => Promise; now?: Date; // The source publish's tools. Default: probeSourceTools(env). sourceTools?: () => Promise; // Which repository `source publish` would mirror, and whether its main reads. // Default: probeSourceRepo — ARCHILYZER_SOURCE_REPO, else the checkout's git // common dir, then `rev-parse` of main (read-only). sourceRepo?: () => Promise; // Free bytes on the filesystem holding `dir`, or null when it cannot be // asked. Default: statfs. freeBytes?: (dir: string) => number | null; // The host's egress IP and the seeder container's, asked only when the // seeder is configured. Default: probeSeederEgress. seederEgress?: () => Promise; }; // What `archilyzer doctor` learned of the seeder's way out: each egress IP, or // null with why it could not be asked. export type SeederEgressProbe = { host: string | null; seeder: string | null; hostError?: string; seederError?: string; }; // A FAIL when the two are the same address; a warning when either could not // be asked (the seeder not running is not proof of a leak, nor of its absence). export function seederEgressVerdict(p: SeederEgressProbe): { status: CheckStatus; detail: string } { if (!p.seeder) { return { status: "warn", detail: `the seeder's egress could not be asked (${p.seederError ?? "no answer"}) — is the \`seeder\` profile up? (docker-compose.seeder.yml)` }; } if (!p.host) { return { status: "warn", detail: `the seeder leaves as ${p.seeder}; this host's own egress could not be asked (${p.hostError ?? "no answer"}), so it cannot be compared` }; } if (p.host === p.seeder) { return { status: "fail", detail: `the seeder leaves as ${p.seeder} — the SAME address as this host: it is not behind the VPN. Stop it.` }; } return { status: "ok", detail: `the seeder leaves as ${p.seeder}, this host as ${p.host}: behind the VPN` }; } // The address echo both sides ask. One plain-text GET; asked only when the // seeder is configured. const EGRESS_ECHO_URL = "https://api.ipify.org"; // The seeder's container under docker-compose.seeder.yml (project `archilyzer`). const SEEDER_CONTAINER = "archilyzer-seeder-1"; export async function probeSeederEgress(env: NodeJS.ProcessEnv): Promise { const out: SeederEgressProbe = { host: null, seeder: null }; const ipLike = (s: string) => (/^[0-9a-fA-F.:]{3,45}$/.test(s.trim()) ? s.trim() : null); try { const res = await fetch(EGRESS_ECHO_URL, { signal: AbortSignal.timeout(10_000) }); out.host = ipLike(await res.text()); if (!out.host) out.hostError = "the echo did not answer with an address"; } catch (err) { out.hostError = (err as Error).message; } try { const { dockerBin } = await import("../publish/build"); const { stdout } = await execFileP( dockerBin(env), ["exec", SEEDER_CONTAINER, "node", "-e", `fetch(${JSON.stringify(EGRESS_ECHO_URL)}).then((r) => r.text()).then((t) => process.stdout.write(t))`], { timeout: 20_000 }, ); out.seeder = ipLike(String(stdout)); if (!out.seeder) out.seederError = "the echo did not answer with an address"; } catch (err) { out.seederError = ((err as Error).message.split("\n")[0] ?? "").slice(0, 200); } return out; } // Where `source publish` would read main from: the variable's path or the // checkout's common dir (null: neither), and main's commit or why it did not // read. export type SourceRepoProbe = { via: "env" | "checkout"; repo: string | null; main: string | null; error?: string; }; // Which git-filter-repo `archilyzer source publish` would run, gitleaks, and // the stagit that renders the history pages (its path, or null). export type SourceTools = { filterRepo: { via: "git"; version: string } | { via: "pipx"; version: string } | null; gitleaks: { version: string } | null; stagit: string | null; }; export type BuildImageProbe = | { engine: false } | { engine: true; image: { created: Date | null; sizeBytes: number | null } | null }; export type DockerfileChange = { at: Date; source: "commit" | "mtime" }; const MIN_NODE = [20, 9, 0] as const; // next 16's engines field export async function collectDoctorReport(deps: DoctorDeps): Promise { 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; const root = paths.monorepoRoot; // Two floors: next's (a failure — nothing runs below it) and the pinned // wrangler's engines (a warning — every deploy refuses below it; wrangler 4 // wants 22). The wrangler floor is read from its package.json when it is // installed, as the image's drift test reads it. const wranglerFloor = wranglerNodeFloor(root); if (!versionAtLeast(nodeV, MIN_NODE)) { add(W, "node", "fail", `v${nodeV} (needs >= ${MIN_NODE.join(".")})`); } else if (wranglerFloor && !versionAtLeast(nodeV, wranglerFloor.min)) { add(W, "node", "warn", `v${nodeV} — runs the apps (>= ${MIN_NODE.join(".")}), but the pinned wrangler ${wranglerFloor.version} needs node ${wranglerFloor.range}: every deploy refuses; use Node ${wranglerFloor.min[0]}`); } else { add(W, "node", "ok", `v${nodeV} (needs >= ${MIN_NODE.join(".")}${wranglerFloor ? `; deploys: >= ${wranglerFloor.min.join(".")}, wrangler ${wranglerFloor.version}` : ""})`); } 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(" ")); // The effective settings, read the way every process reads them (defaults // when the file is absent). Read-only: the reader never writes. Read before // the corpus, for the drive-health timings (release 15 slice DT): applied // here, as the index and stats bins apply them, a CLI process's drive // inspects run on the machine's timings instead of racing the defaults. const { settingsFromFile } = await import("../lib/settings"); let settings: ReturnType | null = null; let settingsError: Error | null = null; try { settings = settingsFromFile(paths.settingsFile); } catch (err) { settingsError = err as Error; } const { applyHealthTimings } = await import("../lib/storageHealth"); const timings = applyHealthTimings(settings?.storage.health); // ── 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, undefined, { fresh: true, }); 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 { secondsText, clearRuleText } = await import("../lib/storageHealthTimings"); const tuned = Object.keys(settings?.storage.health ?? {}).length > 0; add(C, "drive health", "info", `a read may take ${secondsText(timings.budgetMs)}, a check every ${secondsText(timings.passIntervalMs)} ` + `(${secondsText(timings.probeTimeoutMs)} each), a stall clears on a clean check ${clearRuleText(timings.clearAfterCleanPasses)}, ` + `${timings.inFlightPerLocation} reads in flight per drive — ${tuned ? "settings.storage.health" : "the defaults"}`); 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\``); } } // ── download pacing ────────────────────────────────────────────────────── // The auto-download lane's persisted rate-limit state (release 17, slice // RL): a platform in a cooldown or a HOLD, and a request pace a rate limit // raised. A warning, never a failure — the lane is doing what it should, and // the operator may want to know why YouTube has been quiet all evening. // Read-only: the state reader never writes. const DP = "download pacing"; if (paths.autoQueueStateFile && existsSync(paths.autoQueueStateFile)) { const { readAutoQueueState } = await import("../jobs/autoQueueState"); const st = (await readAutoQueueState(paths)).download; const nowMs = (deps.now ?? new Date()).getTime(); const mins = (ms: number) => `${Math.max(1, Math.ceil(ms / 60_000))} min`; let quiet = true; for (const [pf, e] of Object.entries(st.platformBackoff)) { const hold = st.platformHolds[pf]; if (hold) { quiet = false; add(DP, pf, "warn", `held since ${stamp(new Date(hold.since))} after ${e.fails} ${hold.rateLimited ? "rate-limited/network" : "network"} failures in a row; ` + (e.until > nowMs ? `next probe in ${mins(e.until - nowMs)} — manual fetches on it are refused until then` : "probe overdue — a clean manual Sync lifts it, or Clear hold on /operations/download")); } else if (e.until > nowMs) { quiet = false; add(DP, pf, "warn", `in a rate-limit cooldown for ${mins(e.until - nowMs)} more (attempt ${e.fails})`); } } const { effectivePaceSeconds } = await import("../jobs/platformBackoff"); for (const [pf, p] of Object.entries(st.platformPace)) { const v = effectivePaceSeconds(p, nowMs); if (v <= p.baseSeconds) continue; quiet = false; add(DP, `${pf} pace`, "warn", `${v}s between requests (base ${p.baseSeconds}s) — a rate limit raised it; it eases one step per hour with no rate limit, and one step per run of clean units (${p.cleanUnits} so far)`); } const subs = Object.values(st.subtitleDeferrals).filter((d) => d.until > nowMs); if (subs.length > 0) { add(DP, "subtitles", "info", `${subs.length} video(s) with subtitles deferred after a subtitle 429 (${subs.filter((d) => d.count >= 3).length} left alone for 7 days) — /operations/download lists them`); } if (quiet) add(DP, "platforms", "ok", "no platform in a cooldown or a hold; every pace at its base"); } else { add(DP, "platforms", "info", "no auto-queue state yet — nothing has been rate-limited"); } // ── 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`); } } if (settingsError) add(S, "schema", "fail", `settings do not load: ${settingsError.message}`); // ── social icons ───────────────────────────────────────────────────────── // Every stored social link's icon — settings.json, each site.json, // homepage.json — through the check a page runs before it inlines one // (lib/socialSvg.ts). One that fails renders as its label on every page; // editing its SVG in the editor is the fix. Named by file and label with the // reason class — never the markup. const SI = "social icons"; const icons = await storedSocialIcons(paths); if (icons.checked === 0) { add(SI, "stored icons", "info", "no stored social icons"); } else if (icons.refused.length === 0) { add(SI, "stored icons", "ok", `${icons.checked} icon${icons.checked === 1 ? "" : "s"} in ${icons.files} file${icons.files === 1 ? "" : "s"} pass the check`); } else { add(SI, "stored icons", "warn", `${icons.refused.length} of ${icons.checked} fail the check and show as their label; edit each one's SVG:\n` + icons.refused.map((r) => `${path.relative(root, r.file) || r.file}: "${r.label}" — ${r.problem}`).join("\n")); } // ── tools ──────────────────────────────────────────────────────────────── const T = "tools"; const hasCorpus = channelSlugs.length > 0; const needs = new Map(); // 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(); 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: "aria2c", bin: paths.aria2cBin, neededBy: ["archive.org files over BitTorrent (optional; else a direct download)"] }, { 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); // The default engine from the Paths this report is about, as the model // below: `app.defaultBin()` reads the process's own getPaths() (release // 11 review L7). chough has no Paths field; its default stays the app's. const defaultBin = w.appId === "whisper-cpp" ? paths.whisperBin : w.appId === "parakeet" ? paths.parakeetBin : app.defaultBin(); const bin = cfg.bin?.trim() || 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 = { "yt-dlp": "YTDLP_BIN", ffmpeg: "FFMPEG_BIN", ffprobe: "FFPROBE_BIN", "gallery-dl": "GALLERY_DL_BIN", aria2c: "ARIA2C_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(", ")}`); } // ── downloader ─────────────────────────────────────────────────────────── // WHICH yt-dlp, beside the tools row's "is it there". In the runtime image // ARCHILYZER_IMAGE_YTDLP names the one it ships; YTDLP_BIN landing anywhere // else is the operator's substitute (RUNNING_IN_DOCKER.md, "Substituting // yt-dlp") — an override, which the entrypoint's YTDLP_AUTO_UPDATE leaves // alone. Graded here only for that conflict and for a substitute that does // not run; presence is the tools row's to grade. const DL = "downloader"; { const r = reports.find((x) => x.id === "yt-dlp"); const imageYtdlp = env.ARCHILYZER_IMAGE_YTDLP?.trim() || null; const resolved = onPath(paths.ytdlpBin, env.PATH) ?? paths.ytdlpBin; const origin = imageYtdlp === null ? "host" : sameFile(resolved, imageYtdlp) ? "image" : "override"; // The entrypoint's rule exactly (a shell `case`: 1, true, yes or on, as // written — `TRUE` is off there, so it is off here). const autoUpdate = ["1", "true", "yes", "on"].includes(env.YTDLP_AUTO_UPDATE ?? ""); // The shared probe counts any output as presence (an odd version flag is // still a binary); "does it RUN" is asked here by exit status — the // from-source wrapper with no checkout mounted prints a sentence and exits // 127. const ran = r?.present ? await versionRuns(resolved, env) : { ok: false as const, error: r?.error ?? "absent" }; const what = `${resolved}${ran.ok && ran.version ? ` ${ran.version}` : ""} (${origin}` + `${origin === "override" ? `; the image's is ${imageYtdlp}` : origin === "host" ? ": not in the runtime image" : ""})`; if (!ran.ok) { add(DL, "yt-dlp", origin === "override" ? "warn" : "info", `${what} — does not run (${ran.error})${origin === "override" ? "; a from-source override needs its checkout mounted (docker-compose.ytdlp.yml)" : ""}`); } else if (origin === "override" && autoUpdate) { add(DL, "yt-dlp", "warn", `${what} — YTDLP_AUTO_UPDATE is set, and the entrypoint never self-updates an override: update it where it is built, or unset one of the two`); } else { add(DL, "yt-dlp", origin === "host" ? "info" : "ok", `${what}${autoUpdate && origin === "image" ? " — self-updated on every boot (YTDLP_AUTO_UPDATE)" : ""}`); } } // ── 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)" : ""}`); } } // ── build image ────────────────────────────────────────────────────────── // Build all builds every site in a container whenever the engine answers // `version`, from the image the settings name (common/publish/build.ts). Never // a failure — Build all (re)builds the image itself before its fan-out — and, // like a tool nothing needs yet, only a note while there is no corpus to build. const B = "build image"; { const { buildImageArgs, dockerBin } = await import("../publish/build"); const { defaultBuildPipeline } = await import("../lib/settingsSchema"); const pipeline = settings?.buildPipeline ?? defaultBuildPipeline(); const bin = dockerBin(env); const probed = await (deps.buildImage ?? ((q) => probeBuildImage(q, env)))({ bin, image: pipeline.dockerImage }); if (!probed.engine) { add(B, "build-image", "info", `skipped: \`${bin} version\` did not answer (DOCKER_BIN names the engine) — Build all builds sites one at a time on the host`); } else { const grade: CheckStatus = hasCorpus ? "warn" : "info"; const now = deps.now ?? new Date(); const file = path.resolve(root, pipeline.dockerfile); const changed = await (deps.dockerfileChanged ?? ((f) => dockerfileChangedAt(f, root, env)))(file); const rebuild = `rebuild it now: cd ${shellQuote(root)} && ${[bin, ...buildImageArgs(pipeline)].map(shellQuote).join(" ")}`; const img = probed.image; if (!changed) { add(B, "dockerfile", grade, `${pipeline.dockerfile} is not at ${file} — Build all's image build will fail (Settings → Build pipeline names it)`); } if (!img) { add(B, "build-image", grade, `no image "${pipeline.dockerImage}" — the next Build all builds it first, installing every dependency\n${rebuild}`); } else if (!img.created) { add(B, "build-image", grade, `"${pipeline.dockerImage}" is here but its creation time did not parse\n${rebuild}`); } else { const built = `"${pipeline.dockerImage}" built ${stamp(img.created)} (${ago(img.created, now)})${img.sizeBytes ? `, ${gigabytes(img.sizeBytes)}` : ""}`; const last = changed ? `${pipeline.dockerfile}'s last change (${stamp(changed.at)}, ${changed.source === "commit" ? "its last commit" : "its mtime: uncommitted or no checkout"})` : ""; if (changed && img.created < changed.at) { add(B, "build-image", grade, `${built} — before ${last}, so the next Build all rebuilds it from the changed step on\n${rebuild}`); } else { add(B, "build-image", "ok", changed ? `${built}, after ${last}` : built); } } } } // ── publish ────────────────────────────────────────────────────────────── // What a deploy needs from this machine (release 18): credentials — whether // they are SET, never a value — and room for the bundles. A warning at // worst: a checkout that never deploys is not broken, and one whose sites // name a Cloudflare project but holds no credential is told so. const PB = "publish"; { const deployable = await sitesWithCloudflareProject(paths); const set = (k: string) => Boolean(env[k]?.trim()); const pd = await import("../lib/pagesDeploy"); // The deploy's own preflight (lib/pagesDeploy.ts), so the doctor and a // deploy cannot disagree: the token, or a `wrangler login` on disk — // located by path, never read. const oauth = pd.wranglerOAuthConfigFiles(env.HOME || os.homedir(), env).find((f) => existsSync(f)) ?? null; const problem = pd.cloudflareCredentialProblem(env, oauth !== null); const account = set("CLOUDFLARE_ACCOUNT_ID") ? "CLOUDFLARE_ACCOUNT_ID set" : "CLOUDFLARE_ACCOUNT_ID unset (fine with one account)"; if (problem === null && set("CLOUDFLARE_API_TOKEN")) { add(PB, "cloudflare-auth", "ok", `CLOUDFLARE_API_TOKEN is set (never printed); ${account}`); } else if (problem === null) { add(PB, "cloudflare-auth", "ok", `no CLOUDFLARE_API_TOKEN; wrangler's login config is at ${oauth} — a host login, which a container cannot use (set the token in .env there)`); } else { add(PB, "cloudflare-auth", deployable.length > 0 ? "warn" : "info", deployable.length > 0 ? `neither CLOUDFLARE_API_TOKEN nor a \`wrangler login\` config, and ${deployable.length} site${deployable.length === 1 ? " names" : "s name"} a Cloudflare project (${deployable.join(", ")}) — every deploy refuses ("${problem.replace(/^\[deploy\] /, "")}")` : "neither CLOUDFLARE_API_TOKEN nor a `wrangler login` config — needed only to deploy to Cloudflare Pages"); } // The wrangler every deploy spawns (lib/pagesDeploy.ts wranglerBin): the // pinned devDependency of common, or WRANGLER_BIN. Run for its version — // which also proves it starts on this Node — with its debug log sent to // a temp dir that is removed (wrangler writes one on every run, under // ~/.config/.wrangler/logs by default). { const bin = pd.wranglerBin(paths, env); const override = Boolean(env.WRANGLER_BIN?.trim()); const via = override ? "WRANGLER_BIN" : "the pin in common/package.json"; if (!existsSync(bin)) { add(PB, "wrangler", override ? "fail" : deployable.length > 0 ? "warn" : "info", override ? `${bin} is not there — WRANGLER_BIN names it explicitly; every deploy fails` : `${bin} is not there — run \`pnpm install\` (wrangler is common's devDependency)${deployable.length > 0 ? "; every deploy fails until then" : ""}`); } else if (!executable(bin)) { add(PB, "wrangler", "warn", `${bin} (${via}) is not executable`); } else { const ran = await wranglerVersion(bin, env); const major = ran.ok ? Number(/(\d+)\.\d+\.\d+/.exec(ran.version)?.[1] ?? NaN) : NaN; if (!ran.ok) { add(PB, "wrangler", "warn", `${bin} (${via}) does not run: ${ran.error}`); } else if (major !== pd.WRANGLER_MAJOR) { add(PB, "wrangler", "warn", `${bin} ${ran.version} (${via}) — expected wrangler ${pd.WRANGLER_MAJOR}.x, the major the deploy's arguments are written for`); } else { add(PB, "wrangler", "ok", `${bin} ${ran.version} (${via})`); } } } const bucket = settings?.archiveStorage?.bucket?.trim(); if (bucket) { const missing = ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "CLOUDFLARE_ACCOUNT_ID"].filter((k) => !set(k)); add(PB, "r2-keys", missing.length === 0 ? "ok" : "warn", missing.length === 0 ? `archiveStorage.bucket "${bucket}": R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY and CLOUDFLARE_ACCOUNT_ID are set (never printed)` : `archiveStorage.bucket "${bucket}" is set but ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not — a deploy with an oversize archive refuses before it uploads`); } const builds = paths.exportBuildsDir; if (builds) { const free = (deps.freeBytes ?? statfsFree)(nearestExisting(builds)); const st = statOrNull(builds); const freeText = free === null ? "free space unknown" : `${gigabytes(free)} free`; if (!st) { add(PB, "export-builds", "info", `${builds} does not exist yet — the first site build makes it (${freeText})`); } else if (!writable(builds)) { add(PB, "export-builds", "warn", `${builds} is not writable — every site build fails (${freeText})`); } else { const bundles = await bundleBytes(paths); const need = Math.ceil(bundles.bytes * 1.5); const what = `${builds}: ${bundles.count} bundle${bundles.count === 1 ? "" : "s"}, ${gigabytes(bundles.bytes)}; ${freeText}`; if (free !== null && bundles.count > 0 && free < need) { add(PB, "export-builds", "warn", `${what} — under 1.5× the bundles (${gigabytes(need)}): a build writes its new bundle beside the old one before it swaps`); } else { add(PB, "export-builds", "ok", what); } } } // The publish lock (common/publish/stageLock.ts): free, held by a stage // that is running, or left by one that is gone — judged by the lock's own // rule (holderIsGone), never removed here. if (builds) { const lock = await import("../publish/stageLock"); const file = lock.publishLockPath(paths); const st = statOrNull(file); const clear = `clear it, when nothing is publishing: rm ${file}`; if (!st) { add(PB, "publish-lock", "ok", "free — no stage is publishing"); } else { const holder = lock.parseLockHolder(readOrNull(file) ?? ""); const host = lock.lockHostId(env); const nowMs = (deps.now ?? new Date()).getTime(); if (!holder) { const torn = nowMs - st.mtime.getTime() > lock.LOCK_TORN_GRACE_MS; add(PB, "publish-lock", torn ? "warn" : "info", torn ? `${file} does not parse, and has not for ${ago(st.mtime, new Date(nowMs)).replace(/ ago$/, "")} — a taker died writing it; the next stage takes it over, or ${clear}` : `${file} is being written — a stage is taking the lock`); } else if (holder.host !== host) { add(PB, "publish-lock", "warn", `held by ANOTHER host: ${lock.describeHolder(holder)} — never taken over from here (this host is "${host}"); if that host is gone, ${clear}`); } else if (lock.holderIsGone(holder, { host })) { add(PB, "publish-lock", "warn", `stale: ${lock.describeHolder(holder)} — its process is gone; the next stage takes it over, or ${clear}`); } else { add(PB, "publish-lock", "info", `held: ${lock.describeHolder(holder)} — a stage is running`); } } } // The index stamp (common/publish/stamps.ts): how old, and which built // targets came from an older one (they rebuild on their next build). if (paths.exportIndexDir) { const stamps = await import("../publish/stamps"); const idx = await stamps.readIndexStamp(paths); const built = builds ? await builtStamps(paths) : []; const now = deps.now ?? new Date(); if (!idx) { const something = built.length > 0 || (await configuredSiteIds(paths)).length > 0; add(PB, "index-stamp", something ? "warn" : "info", `no index stamp at ${stamps.indexStampPath(paths)} — update the index first: archilyzer publish index`); } else { const at = new Date(idx.builtAt); const head = `${idx.stampId}, built ${stamp(at)} (${ago(at, now)}), generation ${idx.generation}`; const older = built.filter((b) => b.indexStampId !== idx.stampId).map((b) => b.target); if (older.length > 0) { add(PB, "index-stamp", "warn", `${head}; built from an older stamp: ${older.join(", ")} — archilyzer publish build rebuilds each`); } else { add(PB, "index-stamp", "ok", `${head}${built.length > 0 ? `; ${built.length} bundle${built.length === 1 ? "" : "s"} built from it` : "; nothing built yet"}`); } } } } // ── source publish ─────────────────────────────────────────────────────── // `archilyzer build homepage` runs it (common/publish/source.ts). Never a // failure: a checkout that never publishes the homepage is not broken. A // WARN is a machine that means to (its operator files exist) and cannot. // The operator files are stat'd and their RULES COUNTED — their contents // are never printed. const SP = "source publish"; const src = await import("../publish/source"); const tools = await (deps.sourceTools ?? (() => probeSourceTools(env, paths)))(); const scrubFile = paths.sourceScrubFile; const denylistFile = paths.sourceDenylistFile; const intends = [scrubFile, denylistFile].some((f) => f && existsSync(f)); if (tools.filterRepo?.via === "git") { add(SP, "filter-repo", "ok", `git filter-repo ${tools.filterRepo.version}`); } else if (tools.filterRepo?.via === "pipx") { add(SP, "filter-repo", intends ? "warn" : "info", `not installed; \`pipx run --spec ${src.FILTER_REPO_PIPX_SPEC}\` fetches it at build time (network on first use; pipx ${tools.filterRepo.version}) — \`${src.FILTER_REPO_INSTALL}\` once removes that`); } else { add(SP, "filter-repo", intends ? "warn" : "info", `neither git-filter-repo nor pipx — \`archilyzer build homepage\` refuses; install: \`${src.FILTER_REPO_INSTALL}\``); } // stagit renders the history pages (/source/git/); without it the publish // goes on without them. A STAGIT_BIN that names nothing is a warning. The // line ends with the render cache: where it is and how big (stat'd only). const hist = await import("../publish/sourceHistory"); const cacheDir = paths.sourceHistoryCacheDir; const cache = cacheDir ? `; cache: ${cacheDir}, ${await dirSizeText(cacheDir)}` : ""; if (tools.stagit) { add(SP, "stagit", "ok", `${tools.stagit}${cache}`); } else if (env.STAGIT_BIN) { add(SP, "stagit", "warn", `not found: STAGIT_BIN=${env.STAGIT_BIN} names no executable — the source is published without its history pages (/source/git/)${cache}`); } else { add(SP, "stagit", "info", `not found (PATH, ~/.local/bin) — the source is published without its history pages (/source/git/); install it once: ${hist.STAGIT_INSTALL}${cache}`); } add(SP, "gitleaks", tools.gitleaks ? "ok" : "info", tools.gitleaks ? `gitleaks ${tools.gitleaks.version}` : "absent — the gate skips the secret scan with a WARNING (the literal audit still runs)"); // The repository whose main is mirrored: ARCHILYZER_SOURCE_REPO (a // container's read-only mount of the host's git dir), else the checkout's. // A variable naming nothing fails — the publish refuses by name. { const sr = await (deps.sourceRepo ?? (() => probeSourceRepo(env, root)))(); const via = sr.via === "env" ? "ARCHILYZER_SOURCE_REPO" : "this checkout"; if (sr.repo === null && sr.via === "env") { add(SP, "source-repo", "fail", `ARCHILYZER_SOURCE_REPO names ${env.ARCHILYZER_SOURCE_REPO?.trim()}, which is not there — \`source publish\` refuses; mount it (docker-compose.source.yml) or unset it`); } else if (sr.repo === null) { add(SP, "source-repo", intends ? "warn" : "info", "no git repository here and ARCHILYZER_SOURCE_REPO is unset — the homepage builds with an empty /source page; in Docker, add docker-compose.source.yml"); } else if (sr.main === null) { add(SP, "source-repo", "warn", `${sr.repo} (${via}): main does not read${sr.error ? ` — ${sr.error}` : ""}`); } else { add(SP, "source-repo", "ok", `${sr.repo} (${via}): main ${sr.main.slice(0, 12)}`); } } // The operator's private config dir, which holds the two files below. // Counted, never listed. { const dir = paths.configDir; const st = dir ? statOrNull(dir) : null; if (!dir || !st) { add(SP, "config-dir", "info", `${dir ?? "(unset)"} does not exist — ARCHILYZER_CONFIG_DIR moves it (in Docker: /data/config/archilyzer, the config volume)`); } else if (!st.isDirectory()) { add(SP, "config-dir", "warn", `${dir} is not a directory`); } else { const count = (await readdir(dir).catch(() => [] as string[])).length; const w = writable(dir); add(SP, "config-dir", w ? "ok" : "info", `${dir} (${count} entr${count === 1 ? "y" : "ies"}${w ? ", writable" : ", read-only: fine for reading the rules"})`); } } for (const [id, file, unit] of [ ["scrub rules", scrubFile, "rule"], ["denylist", denylistFile, "literal"], ] as const) { const st = file ? statOrNull(file) : null; if (!file || !st) { add(SP, id, "info", `${file ?? "(unset)"} is missing — \`source publish\` (and so \`build homepage\`) refuses until it exists (PUBLISH.md, "The source mirror")`); continue; } const count = (readOrNull(file) ?? "").split("\n").filter((l) => l.trim() && !l.trim().startsWith("#")).length; const mode = st.mode & 0o777; const open = (mode & 0o077) !== 0; add(SP, id, open ? "warn" : "ok", `${file} (${count} ${unit}${count === 1 ? "" : "s"}, mode ${mode.toString(8)})${open ? " — readable by others: chmod 600" : ""}`); } const publicDir = env.HOMEPAGE_PUBLIC_DIR || path.join(root, "homepage", "public"); const published = await src.readPublishedManifest(publicDir); add(SP, "published", "info", published ? `main ${published.sourceCommit.slice(0, 12)} as ${published.mirrorHead.slice(0, 12)}, ${published.generatedAt} (${published.files} files)` : `nothing published in ${path.join(publicDir, "source")}`); // ── seeder (release 21 D4b) ────────────────────────────────────────────── // The home seeder must leave through the VPN and nothing else. Its egress IP // (asked from inside its container) against this host's: the same address is // a FAIL — the seeder would be uploading from home. Not configured (no // `settings.seeder.sites`) is a note, and asks nothing of the network. const SD = "seeder"; { const { seederConfigured } = await import("../lib/seederSettings"); const seeder = settings?.seeder; if (!seeder || !seederConfigured(seeder)) { add(SD, "seeder", "info", "seeder not configured (settings.seeder.sites is empty)"); } else { const probe = await (deps.seederEgress ?? (() => probeSeederEgress(env)))(); const v = seederEgressVerdict(probe); add(SD, "egress", v.status, v.detail); } } // ── 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 = { 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 { 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 ──────────────────────────────────────────────────────────────── // The social links stored in settings.json, every sites//site.json and // sites/_homepage/homepage.json, each checked. Read-only; a missing or // unreadable file is skipped. async function storedSocialIcons(paths: Paths): Promise<{ files: number; checked: number; refused: { file: string; label: string; problem: string }[]; }> { const { parseSocialLinks } = await import("../lib/settingsSchema"); const { socialSvgProblem } = await import("../lib/socialSvg"); const candidates = [paths.settingsFile]; if (paths.sitesDir) { try { for (const e of await readdir(paths.sitesDir, { withFileTypes: true })) { if (e.isDirectory() && !e.name.startsWith("_")) { candidates.push(path.join(paths.sitesDir, e.name, "site.json")); } } } catch { /* no sites directory */ } } if (paths.homepageConfigFile) candidates.push(paths.homepageConfigFile); let files = 0; let checked = 0; const refused: { file: string; label: string; problem: string }[] = []; for (const file of candidates) { const text = file ? readOrNull(file) : null; if (text === null) continue; let raw: unknown; try { raw = JSON.parse(text); } catch { continue; } const links = parseSocialLinks((raw as { socialLinks?: unknown } | null)?.socialLinks); if (links.length === 0) continue; files += 1; for (const link of links) { checked += 1; const problem = socialSvgProblem(link.svg); if (problem) refused.push({ file, label: link.label, problem }); } } return { files, checked, refused }; } 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 { 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 { 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 } | 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 = {}; 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; } } // The engine answers `version` (what Build all asks before choosing containers), // then the image is inspected. Both only read the daemon's state. async function probeBuildImage( q: { bin: string; image: string }, env: NodeJS.ProcessEnv, ): Promise { try { await execFileP(q.bin, ["version"], { env, timeout: 15_000 }); } catch { return { engine: false }; } try { const { stdout } = await execFileP(q.bin, ["image", "inspect", "--format", "{{.Created}}|{{.Size}}", q.image], { env, timeout: 15_000, }); const [created = "", size = ""] = stdout.trim().split("|"); const bytes = Number.parseInt(size, 10); return { engine: true, image: { created: parseEngineTime(created), sizeBytes: Number.isFinite(bytes) ? bytes : null } }; } catch { return { engine: true, image: null }; } } // An image's creation time as an engine prints it: docker's RFC 3339 with // nanoseconds (2026-07-07T12:04:55.302907312-04:00), or podman's Go default // (2026-07-07 12:04:55.302907312 -0400 EDT). V8 happens to read both, through // its implementation-defined fallback; normalized here to the ISO form the // spec defines, so the answer does not rest on that. export function parseEngineTime(s: string): Date | null { const m = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.(\d+))?\s*(Z|[+-]\d{2}:?\d{2})?/.exec(s.trim()); if (!m) return null; const frac = (m[3] ?? "").slice(0, 3).padEnd(3, "0"); const tz = (m[4] ?? "Z").replace(/^([+-]\d{2})(\d{2})$/, "$1:$2"); const d = new Date(`${m[1]}T${m[2]}.${frac}${tz}`); return Number.isNaN(d.getTime()) ? null : d; } // When the Dockerfile last changed. In a checkout that is its last commit — a // clone's mtimes are the clone's — unless the file has uncommitted changes, when // the file on disk is newer than any commit and its mtime is the answer. No // checkout (or no git): the mtime. GIT_OPTIONAL_LOCKS=0 keeps `git status` from // refreshing the index, which would be a write. async function dockerfileChangedAt( file: string, root: string, env: NodeJS.ProcessEnv, ): Promise { const st = statOrNull(file); if (!st) return null; const byMtime: DockerfileChange = { at: st.mtime, source: "mtime" }; const opts = { cwd: root, env: { ...env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000 }; const rel = path.relative(root, file); try { const { stdout: dirty } = await execFileP("git", ["status", "--porcelain", "--", rel], opts); if (dirty.trim() !== "") return byMtime; const { stdout } = await execFileP("git", ["log", "-1", "--format=%ct", "--", rel], opts); const secs = Number.parseInt(stdout.trim(), 10); return Number.isFinite(secs) ? { at: new Date(secs * 1000), source: "commit" } : byMtime; } catch { return byMtime; } } function shellQuote(s: string): string { return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`; } // The same minute-precision UTC stamp the index line uses. function stamp(d: Date): string { return d.toISOString().slice(0, 16).replace("T", " "); } function ago(then: Date, now: Date): string { const mins = Math.max(0, Math.round((now.getTime() - then.getTime()) / 60_000)); if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"} ago`; const hours = Math.round(mins / 60); if (hours < 48) return `${hours} hour${hours === 1 ? "" : "s"} ago`; return `${Math.round(hours / 24)} days ago`; } function gigabytes(bytes: number): string { return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(2)} GB` : `${Math.round(bytes / 1e6)} MB`; } // What `source publish` would run, by version flags only: `git filter-repo // --version` answering 0 is an installed filter-repo; otherwise pipx's own // version (never `pipx run`, which would download). gitleaks likewise. stagit // has no version flag: it is looked up the way the publish looks it up. async function probeSourceTools(env: NodeJS.ProcessEnv, paths: Paths): Promise { const version = async (bin: string, args: string[]): Promise => { try { const { stdout, stderr } = await execFileP(bin, args, { env, timeout: 10_000 }); return (stdout || stderr).trim().split("\n")[0] ?? ""; } catch { return null; } }; const git = await version("git", ["filter-repo", "--version"]); const pipx = git === null ? await version("pipx", ["--version"]) : null; const leaks = await version("gitleaks", ["version"]); const { resolveStagit } = await import("../publish/sourceHistory"); return { filterRepo: git !== null ? { via: "git", version: git } : pipx !== null ? { via: "pipx", version: pipx } : null, gitleaks: leaks !== null ? { version: leaks } : null, stagit: resolveStagit(paths.stagitBin ?? "stagit", env), }; } // A directory's size, by stat alone ("none yet" when it is not there). async function dirSizeText(dir: string): Promise { if (!existsSync(dir)) return "none yet"; let bytes = 0; const walk = async (d: string): Promise => { for (const ent of await readdir(d, { withFileTypes: true }).catch(() => [])) { const p = path.join(d, ent.name); if (ent.isDirectory()) await walk(p); else if (ent.isFile()) bytes += statOrNull(p)?.size ?? 0; } }; await walk(dir); return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } // The same file, through symlinks (the image's yt-dlp-from-source is a link // into /repo/docker; `YTDLP_BIN=yt-dlp` resolves on PATH first). function sameFile(a: string, b: string): boolean { try { return realpathSync(a) === realpathSync(b); } catch { return path.resolve(a) === path.resolve(b); } } function writable(p: string): boolean { try { accessSync(p, constants.W_OK); return true; } catch { return false; } } // ` --version` exiting 0, and its first line; else why not. async function versionRuns( bin: string, env: NodeJS.ProcessEnv, ): Promise<{ ok: true; version: string | null } | { ok: false; error: string }> { try { const { stdout } = await execFileP(bin, ["--version"], { env, timeout: 15_000 }); return { ok: true, version: stdout.trim().split("\n")[0] || null }; } catch (err) { const e = err as { code?: unknown; stderr?: unknown }; const said = String(e.stderr ?? "").trim().split("\n")[0]; return { ok: false, error: said || `exited ${String(e.code ?? "?")}` }; } } // `p`, or its deepest ancestor that exists — where its free space is asked. function nearestExisting(p: string): string { let cur = path.resolve(p); while (!existsSync(cur)) { const up = path.dirname(cur); if (up === cur) break; cur = up; } return cur; } function statfsFree(dir: string): number | null { try { const s = statfsSync(dir); return s.bavail * s.bsize; } catch { return null; } } // Every `//out` bundle and its bytes: the `bytes` its // built.json recorded (common/publish/stamps.ts), else — a bundle no stamp // describes, from before the stages — a stat walk (links not followed). A // bundle's own size is what a rebuild writes again beside it. async function bundleBytes(paths: Paths): Promise<{ count: number; bytes: number }> { const { readBuiltStamp } = await import("../publish/stamps"); let count = 0; let bytes = 0; const walk = async (d: string): Promise => { for (const ent of await readdir(d, { withFileTypes: true }).catch(() => [])) { const p = path.join(d, ent.name); if (ent.isDirectory()) await walk(p); else if (ent.isFile()) bytes += statOrNull(p)?.size ?? 0; } }; for (const ent of await readdir(paths.exportBuildsDir, { withFileTypes: true }).catch(() => [])) { if (!ent.isDirectory() || ent.name.startsWith(".")) continue; const out = path.join(paths.exportBuildsDir, ent.name, "out"); if (!statOrNull(out)?.isDirectory()) continue; count += 1; const built = await readBuiltStamp(paths, ent.name); if (built) bytes += built.bytes; else await walk(out); } return { count, bytes }; } // Every built.json under the builds dir (sites, `_hub`, `_homepage`), by // target name. Read-only; an unreadable stamp is skipped. async function builtStamps(paths: Paths): Promise<{ target: string; indexStampId: string | null }[]> { const { readBuiltStamp } = await import("../publish/stamps"); const out: { target: string; indexStampId: string | null }[] = []; for (const ent of await readdir(paths.exportBuildsDir, { withFileTypes: true }).catch(() => [])) { if (!ent.isDirectory() || ent.name.startsWith(".")) continue; const built = await readBuiltStamp(paths, ent.name); if (built) out.push({ target: built.target, indexStampId: built.indexStampId }); } return out.sort((x, y) => x.target.localeCompare(y.target)); } // The configured site ids (a sites//site.json), `_`-dirs excluded. async function configuredSiteIds(paths: Paths): Promise { if (!paths.sitesDir) return []; const out: string[] = []; for (const e of await readdir(paths.sitesDir, { withFileTypes: true }).catch(() => [])) { if (e.isDirectory() && !e.name.startsWith("_") && existsSync(path.join(paths.sitesDir, e.name, "site.json"))) out.push(e.name); } return out.sort(); } // The pinned wrangler's Node floor, from its package.json `engines.node` // (">=22.0.0"), when wrangler is installed in common/node_modules; else null. function wranglerNodeFloor(root: string): { version: string; range: string; min: [number, number, number] } | null { const text = readOrNull(path.join(root, "common", "node_modules", "wrangler", "package.json")); if (text === null) return null; try { const pkg = JSON.parse(text) as { version?: unknown; engines?: { node?: unknown } }; const range = typeof pkg.engines?.node === "string" ? pkg.engines.node : ""; const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(range); if (!m) return null; return { version: typeof pkg.version === "string" ? pkg.version : "?", range, min: [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)], }; } catch { return null; } } // The sites whose site.json names a Cloudflare Pages project — what "this // machine is configured to deploy" means. Read-only; an unreadable file is // skipped. async function sitesWithCloudflareProject(paths: Paths): Promise { if (!paths.sitesDir) return []; const out: string[] = []; for (const e of await readdir(paths.sitesDir, { withFileTypes: true }).catch(() => [])) { if (!e.isDirectory() || e.name.startsWith("_")) continue; const text = readOrNull(path.join(paths.sitesDir, e.name, "site.json")); if (text === null) continue; try { const raw = JSON.parse(text) as { cloudflareProject?: unknown }; if (typeof raw.cloudflareProject === "string" && raw.cloudflareProject.trim()) out.push(e.name); } catch { /* not JSON: the site's own problem */ } } return out.sort(); } function executable(p: string): boolean { try { accessSync(p, constants.X_OK); return true; } catch { return false; } } // ` --version` with its debug log in a temp dir (removed after), so // the doctor writes nothing under the operator's home. async function wranglerVersion( bin: string, env: NodeJS.ProcessEnv, ): Promise<{ ok: true; version: string } | { ok: false; error: string }> { const logDir = await mkdtemp(path.join(os.tmpdir(), "archilyzer-doctor-wrangler-")); try { const { stdout } = await execFileP(bin, ["--version"], { env: { ...env, WRANGLER_LOG_PATH: logDir, WRANGLER_SEND_METRICS: "false" }, timeout: 30_000, }); const line = stdout.trim().split("\n").find((l) => /\d+\.\d+\.\d+/.test(l)) ?? stdout.trim().split("\n")[0] ?? ""; return { ok: true, version: /(\d+\.\d+\.\d+\S*)/.exec(line)?.[1] ?? line }; } catch (err) { const e = err as { code?: unknown; stderr?: unknown }; const said = String(e.stderr ?? "").split("\n").map((l) => l.trim()).find(Boolean); return { ok: false, error: said || `exited ${String(e.code ?? "?")}` }; } finally { await rm(logDir, { recursive: true, force: true }); } } // Which repository `source publish` would mirror (the same order as // source.ts's sourceRepoFor) and main's commit there. Read-only: rev-parse // with GIT_OPTIONAL_LOCKS=0. async function probeSourceRepo(env: NodeJS.ProcessEnv, root: string): Promise { const opts = { cwd: root, env: { ...env, GIT_OPTIONAL_LOCKS: "0" }, timeout: 10_000 }; const named = env.ARCHILYZER_SOURCE_REPO?.trim(); let via: SourceRepoProbe["via"] = "checkout"; let repo: string | null = null; if (named) { via = "env"; repo = existsSync(named) ? named : null; } else { try { const { stdout } = await execFileP("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], opts); repo = stdout.trim().split("\n").pop() || null; } catch { repo = null; } } if (repo === null) return { via, repo, main: null }; try { const { stdout } = await execFileP("git", [`--git-dir=${repo}`, "rev-parse", "--verify", "--quiet", "refs/heads/main^{commit}"], opts); return { via, repo, main: stdout.trim() || null, error: stdout.trim() ? undefined : "no main branch" }; } catch (err) { const stderr = String((err as { stderr?: unknown }).stderr ?? "").trim().split("\n")[0]; return { via, repo, main: null, error: stderr || "no main branch" }; } } // In use = something accepts a TCP connection on 127.0.0.1. Never binds. function tcpPortInUse(port: number): Promise { 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)); }); }