#!/usr/bin/env node // toolchain/doctor.mjs — one preflight that pings every moving part of AC. // // Tells you *which* layer is sick before you waste time debugging the wrong // one: local dev servers, production hosts, the asset CDN, and the host tools // the pipelines lean on. Dependency-free (Node built-ins + global fetch). // // npm run doctor full sweep // npm run doctor -- --local only local dev servers + host tooling // npm run doctor -- --prod only production reachability // npm run doctor -- --strict exit non-zero if any CRITICAL check fails // // Checks degrade gracefully: a stopped dev server is a warning, not a failure. // Only checks marked `critical` (prod site + CDN) can fail --strict. import net from "node:net"; import { execFile } from "node:child_process"; import { existsSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; const args = new Set(process.argv.slice(2)); const ONLY_LOCAL = args.has("--local"); const ONLY_PROD = args.has("--prod"); const STRICT = args.has("--strict"); const REPO = join(import.meta.dirname, ".."); // ── probes ────────────────────────────────────────────────────────────────── // Is a TCP port accepting connections? (the truest "is it up" for local servers) function tcp(host, port, timeout = 1500) { return new Promise((resolve) => { const t0 = Date.now(); const sock = new net.Socket(); const done = (ok, note) => { sock.destroy(); resolve({ ok, ms: Date.now() - t0, note }); }; sock.setTimeout(timeout); sock.once("connect", () => done(true)); sock.once("timeout", () => done(false, "timeout")); sock.once("error", (e) => done(false, e.code || "error")); sock.connect(port, host); }); } // HTTP(S) reachability — reports the status code and round-trip latency. async function http(url, { method = "HEAD", timeout = 6000, expect } = {}) { const t0 = Date.now(); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); try { let res = await fetch(url, { method, signal: ctrl.signal, redirect: "manual" }); // Some hosts reject HEAD — retry once with GET before giving up. if (method === "HEAD" && (res.status === 405 || res.status === 501)) { res = await fetch(url, { method: "GET", signal: ctrl.signal, redirect: "manual" }); } const ms = Date.now() - t0; const reachable = res.status > 0 && res.status < 500; const ok = expect ? res.status === expect : reachable; return { ok, ms, note: `HTTP ${res.status}` }; } catch (e) { return { ok: false, ms: Date.now() - t0, note: e.name === "AbortError" ? "timeout" : (e.cause?.code || e.code || "unreachable") }; } finally { clearTimeout(timer); } } // Can gpg actually sign, or is its keyring wedged? // // GnuPG locks its keybox by hard-linking `pubring.db.lock` to a file named // `.#lk..`. A process killed mid-operation — a `timeout` around // a commit, a reaped background job — leaves that pair behind, and every later // gpg call blocks for two minutes and then times out. Commits fail with // "gpg failed to sign the data", which reads like a key problem and is not one. // It is machine-wide, so it takes down every agent and every shell at once. // // Reads only: it reports the stale lock and how to clear it rather than deleting // anything, since a lock whose owner is alive is doing its job. async function gpgSigning() { const dir = join(homedir(), ".gnupg", "public-keys.d"); const lock = join(dir, "pubring.db.lock"); if (!existsSync(lock)) return { ok: true, note: "unlocked" }; let owner = null; try { const target = statSync(lock).ino; for (const name of readdirSync(dir)) { if (!name.startsWith(".#lk")) continue; if (statSync(join(dir, name)).ino === target) owner = name.split(".").pop(); } } catch { return { ok: true, note: "unreadable" }; } if (!owner) return { ok: true, note: "locked, owner unknown" }; const alive = await new Promise((resolve) => { execFile("ps", ["-p", owner, "-o", "pid="], (err, out) => resolve(!err && out.trim().length > 0)); }); if (alive) return { ok: true, note: `locked by live pid ${owner}` }; return { ok: false, note: `STALE lock from dead pid ${owner} — signing is ` + `blocked; clear with: rm ~/.gnupg/public-keys.d/pubring.db.lock ` + `~/.gnupg/public-keys.d/.#lk*.${owner}` }; } // Is a command-line tool on PATH? (host tooling the pipelines shell out to) function bin(name) { return new Promise((resolve) => { execFile("command", ["-v", name], { shell: "/bin/sh" }, (err, out) => { resolve({ ok: !err && !!out.trim(), note: err ? "not on PATH" : out.trim() }); }); }); } // Is the GitHub mirror still a faithful copy of knot? // // knot is canonical, but `session-server/deploy.fish` reaches a box that fetches // from the GitHub mirror, so whenever the mirror lags, that deploy ships stale // code — and every other signal stays green while it does. On 2026-08-08 the // mirror sat 20 commits behind with a commit of its own on top, and the outage // surfaced only because someone said so in chat. // // Read-only and offline-tolerant: it asks both remotes for their tip and, when // this clone happens to hold both commits, says exactly how far apart they are. function git(args, timeout = 12000) { return new Promise((resolve) => { execFile("git", args, { cwd: REPO, timeout }, (err, out) => resolve(err ? null : out.trim())); }); } async function mirrorInSync() { const t0 = Date.now(); const tip = async (remote) => { const line = await git(["ls-remote", remote, "refs/heads/main"]); return line ? line.split(/\s+/)[0] : null; }; const [knot, mirror] = await Promise.all([tip("origin"), tip("github")]); const ms = Date.now() - t0; if (!knot || !mirror) return { ok: true, ms, note: `unreachable (${!knot ? "knot" : "github"}) — skipped` }; if (knot === mirror) return { ok: true, ms, note: `in sync @ ${knot.slice(0, 9)}` }; // Counts need both objects locally; a partial clone may not have the mirror's. const behind = await git(["rev-list", "--count", `${mirror}..${knot}`]); const ahead = await git(["rev-list", "--count", `${knot}..${mirror}`]); if (behind === null || ahead === null) return { ok: false, ms, note: `DIFFERS — knot ${knot.slice(0, 9)}, mirror ` + `${mirror.slice(0, 9)} (fetch both to compare)` }; if (ahead === "0") return { ok: false, ms, note: `mirror is ${behind} commits BEHIND knot — ` + `session-server deploys will be stale; git push github main` }; return { ok: false, ms, note: `DIVERGED — mirror has ${ahead} commit(s) knot ` + `lacks and is ${behind} behind; reconcile before deploying` }; } // Is the oven's papermill actually watching main? // // `papers/SCORE.md` promises that pushing to `papers/` rebuilds the PDFs within // a minute. On 2026-05-13 the poller was hand-paused with a systemd drop-in to // stop a runaway rebuild loop; the loop was fixed two days later, the pause // never lifted, and for four months every papers push quietly built nothing. // Nothing failed — the promise just stopped being kept, and the only place that // said so was a `running: false` in a JSON blob nobody reads. async function papersMill() { const t0 = Date.now(); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 8000); try { const res = await fetch("https://oven.aesthetic.computer/papers-build", { signal: ctrl.signal, }); const ms = Date.now() - t0; if (!res.ok) return { ok: false, ms, note: `oven says HTTP ${res.status}` }; const { poller = {}, active, recent = [] } = await res.json(); if (poller.running !== true) return { ok: false, ms, note: `poller STOPPED${poller.disabledReason ? ` — ${poller.disabledReason}` : ""}` + ` — papers pushes are not rebuilding; check ` + `/etc/systemd/system/oven.service.d/*.conf on the oven` }; if (poller.healthy === false) return { ok: false, ms, note: `poller timer alive but not ticking (last poll ` + `${poller.sinceLastPollMs ?? "?"}ms ago, ${poller.consecutiveErrors ?? 0} ` + `errors in a row)${poller.lastError ? `: ${poller.lastError.message}` : ""}` }; if (active) return { ok: true, ms, note: `building ${active.id} (${active.stage})` }; const last = recent.find((j) => j.finishedAt); if (last && last.status !== "success") return { ok: false, ms, note: `last build ${last.id} ${last.status}${last.error ? `: ${last.error}` : ""}` }; if (last) return { ok: true, ms, note: `idle; last build ok ${last.finishedAt}` }; return { ok: true, ms, note: "polling; no build recorded yet" }; } catch (e) { return { ok: false, ms: Date.now() - t0, note: e.name === "AbortError" ? "timeout" : (e.code || e.message) }; } finally { clearTimeout(timer); } } // ── the checklist ──────────────────────────────────────────────────────────── // group · label · run() → {ok, ms?, note?} · critical? · scope (local|prod|tool) const CHECKS = [ // Local dev servers — advisory: down just means you haven't started them. { group: "Local dev", label: "site (8888)", scope: "local", run: () => tcp("127.0.0.1", 8888) }, { group: "Local dev", label: "session (8889)", scope: "local", run: () => tcp("127.0.0.1", 8889) }, { group: "Local dev", label: "redis (6379)", scope: "local", run: () => tcp("127.0.0.1", 6379) }, // Production reachability — these are the real signal. { group: "Production", label: "aesthetic.computer (lith)", scope: "prod", critical: true, run: () => http("https://aesthetic.computer") }, { group: "Production", label: "assets CDN (DO Spaces)", scope: "prod", critical: true, run: () => http("https://assets.aesthetic.computer") }, { group: "Production", label: "oven (OTA builds)", scope: "prod", run: () => http("https://oven.aesthetic.computer") }, { group: "Production", label: "ai.aesthetic.computer", scope: "prod", run: () => http("https://ai.aesthetic.computer") }, { group: "Production", label: "help (aa bridge)", scope: "prod", run: () => http("https://help.aesthetic.computer") }, // Deploy provenance — reachability says a box answers, not that it answers // with the code you shipped. { group: "Deploy", label: "knot ↔ github mirror", scope: "prod", run: () => mirrorInSync() }, { group: "Deploy", label: "oven papermill (papers/)", scope: "prod", run: () => papersMill() }, // Host tooling — the binaries pipelines shell out to. { group: "Host tooling", label: "node", scope: "tool", run: () => bin("node") }, { group: "Host tooling", label: "redis-server", scope: "tool", run: () => bin("redis-server") }, { group: "Host tooling", label: "ffmpeg", scope: "tool", run: () => bin("ffmpeg") }, { group: "Host tooling", label: "doctl (CDN flush)", scope: "tool", run: () => bin("doctl") }, { group: "Host tooling", label: "gh", scope: "tool", run: () => bin("gh") }, { group: "Host tooling", label: "jq", scope: "tool", run: () => bin("jq") }, // Wedged signing blocks every commit on the machine, so it belongs in the // preflight rather than being discovered by a failing commit. { group: "Host tooling", label: "gpg signing", scope: "tool", run: () => gpgSigning() }, ]; // ── run ────────────────────────────────────────────────────────────────────── const scopeWanted = (s) => (!ONLY_LOCAL && !ONLY_PROD) || (ONLY_LOCAL && (s === "local" || s === "tool")) || (ONLY_PROD && s === "prod"); const checks = CHECKS.filter((c) => scopeWanted(c.scope)); console.log("\n🩺 aesthetic.computer doctor\n"); const results = await Promise.all( checks.map(async (c) => ({ ...c, ...(await c.run()) })), ); let group = null; let criticalFailed = false; for (const r of results) { if (r.group !== group) { group = r.group; console.log(` ${group}`); } // Down-but-non-critical (e.g. a dev server you didn't start) reads as ⚠️. const icon = r.ok ? "✅" : r.critical ? "❌" : "⚠️ "; if (!r.ok && r.critical) criticalFailed = true; const ms = r.ms != null ? ` ${String(r.ms).padStart(4)}ms` : ""; const note = r.note ? ` ${r.note}` : ""; console.log(` ${icon} ${r.label.padEnd(28)}${ms}${note}`); } const down = results.filter((r) => !r.ok); console.log( `\n ${results.length - down.length}/${results.length} healthy` + (down.length ? ` · ${down.length} need attention` : " · all green") + "\n", ); if (STRICT && criticalFailed) process.exit(1);