commit cc654766e3585d0d462b2cc7f210083e067a4175
parent 7a7a8e9bd138a780aa801eb157636fb18483a443
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 08:24:44 -0400
doctor: which yt-dlp, the publish credentials (set or not), room for the bundles, the source repository and the config dir
New checks: downloader/yt-dlp (path, version, image|host|override; an override
that does not run, or beside YTDLP_AUTO_UPDATE, warns), publish/cloudflare-auth
(CLOUDFLARE_API_TOKEN set, or wrangler's login config by path; warns only when a
site names a Cloudflare project), publish/r2-keys (only with
archiveStorage.bucket), publish/export-builds (writable, free >= 1.5x the
bundles), source publish/source-repo (ARCHILYZER_SOURCE_REPO naming nothing
fails) and source publish/config-dir (counted). No value is ever printed;
the doctor stays read-only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 466 insertions(+), 3 deletions(-)
diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md
@@ -64,6 +64,7 @@ Tokens, credentials and knobs a running process reads. Most configuration is not
| `ARCHILYZER_SOURCE_REPO` | this checkout's git common dir | The git DIR `archilyzer source publish` mirrors `main` from, when the checkout has none: in Docker, `/data/source.git`, the host's git common dir mounted read-only by docker-compose.source.yml. A value that names nothing refuses the publish. | common/publish/source.ts, common/bin/doctor.ts, docker/entrypoint.sh |
| `YTDLP_SOURCE_HOST_DIR` | — (required by the overlay) | Docker: the HOST path of a yt-dlp source checkout (the directory holding `yt_dlp/`), mounted read-only at `/opt/yt-dlp-src` by docker-compose.ytdlp.yml. See [RUNNING_IN_DOCKER.md](RUNNING_IN_DOCKER.md), "Substituting yt-dlp". | docker-compose.ytdlp.yml |
| `YTDLP_AUTO_UPDATE` | off | Docker: `1` runs `yt-dlp -U` on every editor boot — on the image's yt-dlp only; an override (`YTDLP_BIN` naming another) is left alone, with a warning. | docker/entrypoint.sh, common/bin/doctor.ts |
+| `XDG_CONFIG_HOME` | `~/.config` | Where `wrangler login` keeps its config (`<it>/.wrangler/config/default.toml`); the doctor looks for it there, by path, never reading it. | common/bin/doctor.ts |
| `YTDLP_SOURCE_DIR` | `/opt/yt-dlp-src` | Docker: where `/usr/local/bin/yt-dlp-from-source` finds the yt-dlp source tree it runs with the image's python. | docker/yt-dlp-from-source.sh |
| `DOCKER_BIN` | `docker` | The container engine for docker-mode builds (e.g. `podman`). | common/publish/build.ts |
| `DOCKER_BUILD_MEMORY` | no cap | Per-container memory cap for a docker-mode build (`--memory`). | common/publish/build.ts |
diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts
@@ -67,6 +67,8 @@ function checkout(): { root: string; bin: string; paths: Paths } {
parakeetBin: path.join(root, "scripts", "parakeet-stitch.mjs"),
parakeetModel: "",
parakeetCliBin: "parakeet-cli",
+ configDir: path.join(root, ".config"),
+ exportBuildsDir: path.join(root, "export", ".export-builds"),
sourceScrubFile: path.join(root, ".config", "source-scrub.txt"),
sourceDenylistFile: path.join(root, ".config", "source-denylist.txt"),
// Outside the checkout, as the XDG cache is: the tests that compare the
@@ -628,3 +630,177 @@ test("download pacing: a held platform and a raised pace warn, and nothing is wr
assert.match(dp[2].detail, /^4s between requests \(base 1s\)/);
assert.deepEqual(tree(c.root), before);
});
+
+// ── release 18: the downloader and publish checks ─────────────────────────
+
+const find = (r: DoctorReport, section: string, id: string) =>
+ r.checks.find((x) => x.section === section && x.id === id);
+
+// A HOME of the scenario's own, so wrangler's login config is never this
+// machine's.
+function home(c: ReturnType<typeof checkout>): string {
+ const h = path.join(TMP, `${path.basename(c.root)}-home`);
+ mkdirSync(h, { recursive: true });
+ return h;
+}
+
+test("downloader: a host's yt-dlp is a note; the image's is ok; an override says so, and warns beside YTDLP_AUTO_UPDATE or when it does not run", async () => {
+ const c = checkout();
+ fake(c.bin, "yt-dlp", "2026.09.30");
+ fake(c.bin, "yt-dlp-patched", "2026.10.01.patched");
+ // The from-source wrapper with no checkout mounted: a sentence, exit 127.
+ const broken = path.join(c.bin, "yt-dlp-from-source");
+ writeFileSync(broken, "#!/bin/sh\necho 'no yt_dlp package' >&2\nexit 127\n");
+ chmodSync(broken, 0o755);
+ const before = tree(c.root);
+ // A host install: no ARCHILYZER_IMAGE_YTDLP to compare with.
+ let r = await run(c);
+ assert.equal(find(r, "downloader", "yt-dlp")?.status, "info");
+ assert.match(find(r, "downloader", "yt-dlp")!.detail, /yt-dlp 2026\.09\.30 \(host: not in the runtime image\)$/);
+ // The runtime image, running its own.
+ const image = path.join(c.bin, "yt-dlp");
+ r = await run(c, { ARCHILYZER_IMAGE_YTDLP: image });
+ assert.equal(find(r, "downloader", "yt-dlp")?.status, "ok");
+ assert.match(find(r, "downloader", "yt-dlp")!.detail, /2026\.09\.30 \(image\)$/);
+ // An override (the paths' binary is another file).
+ (c.paths as { ytdlpBin: string }).ytdlpBin = path.join(c.bin, "yt-dlp-patched");
+ r = await run(c, { ARCHILYZER_IMAGE_YTDLP: image });
+ assert.equal(find(r, "downloader", "yt-dlp")?.status, "ok");
+ assert.match(find(r, "downloader", "yt-dlp")!.detail, new RegExp(`2026\\.10\\.01\\.patched \\(override; the image's is ${image.replace(/[.]/g, "\\.")}\\)$`));
+ r = await run(c, { ARCHILYZER_IMAGE_YTDLP: image, YTDLP_AUTO_UPDATE: "1" });
+ assert.equal(find(r, "downloader", "yt-dlp")?.status, "warn");
+ assert.match(find(r, "downloader", "yt-dlp")!.detail, /YTDLP_AUTO_UPDATE is set, and the entrypoint never self-updates an override/);
+ assert.equal(r.ok, true, "a warning, never a failure");
+ // An override that does not run.
+ (c.paths as { ytdlpBin: string }).ytdlpBin = broken;
+ r = await run(c, { ARCHILYZER_IMAGE_YTDLP: image });
+ assert.equal(find(r, "downloader", "yt-dlp")?.status, "warn");
+ assert.match(find(r, "downloader", "yt-dlp")!.detail, /\(override; .*\) — does not run .*docker-compose\.ytdlp\.yml/);
+ assert.deepEqual(tree(c.root), before);
+});
+
+test("publish: cloudflare-auth reports a token SET (never its value), a wrangler login by path, and warns only when a site names a project", async () => {
+ const c = checkout();
+ const sitesDir = path.join(c.paths.transcriptsDir, "sites");
+ (c.paths as { sitesDir: string }).sitesDir = sitesDir;
+ const h = home(c);
+ // Nothing configured to deploy, no credential: a note.
+ let r = await run(c, { HOME: h });
+ assert.equal(find(r, "publish", "cloudflare-auth")?.status, "info");
+ // A site that deploys, no credential: a warning naming the site.
+ mkdirSync(path.join(sitesDir, "alpha"), { recursive: true });
+ writeFileSync(path.join(sitesDir, "alpha", "site.json"), JSON.stringify({ title: "A", cloudflareProject: "alpha-pages" }));
+ mkdirSync(path.join(sitesDir, "beta"), { recursive: true });
+ writeFileSync(path.join(sitesDir, "beta", "site.json"), JSON.stringify({ title: "B" }));
+ const before = tree(c.root);
+ r = await run(c, { HOME: h });
+ assert.equal(find(r, "publish", "cloudflare-auth")?.status, "warn");
+ assert.match(find(r, "publish", "cloudflare-auth")!.detail, /1 site names a Cloudflare project \(alpha\) — every deploy refuses; set CLOUDFLARE_API_TOKEN/);
+ assert.equal(r.ok, true);
+ // A token: ok, and the value appears nowhere in the report.
+ const secret = "PLANTED-TOKEN-0123456789";
+ r = await run(c, { HOME: h, CLOUDFLARE_API_TOKEN: secret, CLOUDFLARE_ACCOUNT_ID: "PLANTED-ACCOUNT" });
+ assert.equal(find(r, "publish", "cloudflare-auth")?.status, "ok");
+ assert.match(find(r, "publish", "cloudflare-auth")!.detail, /^CLOUDFLARE_API_TOKEN is set \(never printed\); CLOUDFLARE_ACCOUNT_ID set$/);
+ assert.ok(!/PLANTED/.test(renderDoctorReport(r)) && !/PLANTED/.test(JSON.stringify(r)));
+ // A host's `wrangler login`: ok, by path.
+ const cfg = path.join(h, ".config", ".wrangler", "config");
+ mkdirSync(cfg, { recursive: true });
+ writeFileSync(path.join(cfg, "default.toml"), 'oauth_token = "PLANTED-OAUTH"\n');
+ r = await run(c, { HOME: h });
+ assert.equal(find(r, "publish", "cloudflare-auth")?.status, "ok");
+ assert.match(find(r, "publish", "cloudflare-auth")!.detail, /wrangler's login config is at .*default\.toml/);
+ assert.ok(!/PLANTED/.test(renderDoctorReport(r)), "the login config is located, never read");
+ assert.deepEqual(tree(c.root), before);
+});
+
+test("publish: r2-keys appears only with a bucket, and names what is missing — never a value", async () => {
+ const c = checkout();
+ const h = home(c);
+ let r = await run(c, { HOME: h });
+ assert.equal(find(r, "publish", "r2-keys"), undefined, "no bucket, no check");
+ writeFileSync(c.paths.settingsFile, JSON.stringify({ archiveStorage: { bucket: "overflow", publicBaseUrl: "https://r2.example" } }));
+ const before = tree(c.root);
+ r = await run(c, { HOME: h, R2_ACCESS_KEY_ID: "PLANTED-KEY" });
+ assert.equal(find(r, "publish", "r2-keys")?.status, "warn");
+ assert.match(find(r, "publish", "r2-keys")!.detail, /"overflow" is set but R2_SECRET_ACCESS_KEY, CLOUDFLARE_ACCOUNT_ID are not/);
+ r = await run(c, { HOME: h, R2_ACCESS_KEY_ID: "PLANTED-KEY", R2_SECRET_ACCESS_KEY: "PLANTED-SECRET", CLOUDFLARE_ACCOUNT_ID: "PLANTED-ACCOUNT" });
+ assert.equal(find(r, "publish", "r2-keys")?.status, "ok");
+ assert.ok(!/PLANTED/.test(renderDoctorReport(r)));
+ assert.deepEqual(tree(c.root), before);
+});
+
+test("publish: export-builds — not there yet is a note; bundles with under 1.5x their size free warn; not writable warns", async () => {
+ const c = checkout();
+ const h = home(c);
+ let r = await run(c, { HOME: h }, { freeBytes: () => 5e9 });
+ assert.equal(find(r, "publish", "export-builds")?.status, "info");
+ assert.match(find(r, "publish", "export-builds")!.detail, /does not exist yet — the first site build makes it \(5\.00 GB free\)/);
+ const builds = c.paths.exportBuildsDir;
+ for (const [id, n] of [["alpha", 3_000_000], ["_hub", 1_000_000]] as const) {
+ mkdirSync(path.join(builds, id, "out", "sub"), { recursive: true });
+ writeFileSync(path.join(builds, id, "out", "sub", "f.bin"), Buffer.alloc(n));
+ }
+ // Staging beside a bundle is not a bundle.
+ mkdirSync(path.join(builds, "alpha", ".r2-staging"), { recursive: true });
+ writeFileSync(path.join(builds, "alpha", ".r2-staging", "big.zip"), Buffer.alloc(9_000_000));
+ const before = tree(c.root);
+ r = await run(c, { HOME: h }, { freeBytes: () => 5e9 });
+ assert.equal(find(r, "publish", "export-builds")?.status, "ok");
+ assert.match(find(r, "publish", "export-builds")!.detail, /: 2 bundles, 4 MB; 5\.00 GB free$/);
+ r = await run(c, { HOME: h }, { freeBytes: () => 5_000_000 });
+ assert.equal(find(r, "publish", "export-builds")?.status, "warn");
+ assert.match(find(r, "publish", "export-builds")!.detail, /under 1\.5× the bundles \(6 MB\)/);
+ chmodSync(builds, 0o555);
+ try {
+ if (process.getuid?.() !== 0) {
+ r = await run(c, { HOME: h }, { freeBytes: () => 5e9 });
+ assert.equal(find(r, "publish", "export-builds")?.status, "warn");
+ assert.match(find(r, "publish", "export-builds")!.detail, /is not writable/);
+ }
+ } finally {
+ chmodSync(builds, 0o755);
+ }
+ assert.equal(r.ok, true);
+ assert.deepEqual(tree(c.root), before);
+});
+
+test("source publish: source-repo — the variable naming nothing fails, a readable main is ok, none is a note (a warning once the operator's files exist); config-dir is counted", async () => {
+ const c = checkout();
+ const h = home(c);
+ // No repository, nothing meant: notes.
+ let r = await run(c, { HOME: h });
+ assert.equal(find(r, "source publish", "source-repo")?.status, "info");
+ assert.equal(find(r, "source publish", "config-dir")?.status, "info");
+ assert.match(find(r, "source publish", "config-dir")!.detail, /does not exist — ARCHILYZER_CONFIG_DIR moves it/);
+ // The operator's files exist: no repository is now a warning.
+ mkdirSync(path.dirname(c.paths.sourceScrubFile), { recursive: true });
+ writeFileSync(c.paths.sourceScrubFile, "PLANTED==>x\n");
+ writeFileSync(c.paths.sourceDenylistFile, "PLANTED\n");
+ const before = tree(c.root);
+ r = await run(c, { HOME: h });
+ assert.equal(find(r, "source publish", "source-repo")?.status, "warn");
+ assert.match(find(r, "source publish", "source-repo")!.detail, /docker-compose\.source\.yml/);
+ assert.equal(find(r, "source publish", "config-dir")?.status, "ok");
+ assert.match(find(r, "source publish", "config-dir")!.detail, /\(2 entries, writable\)$/);
+ assert.ok(!/PLANTED/.test(renderDoctorReport(r)));
+ // The variable naming nothing: the publish refuses, so the doctor fails.
+ r = await run(c, { HOME: h, ARCHILYZER_SOURCE_REPO: path.join(TMP, "not-mounted.git") });
+ assert.equal(find(r, "source publish", "source-repo")?.status, "fail");
+ assert.equal(r.ok, false);
+ // A real repository's git dir, through the default probe (git on PATH).
+ const repo = path.join(TMP, `${path.basename(c.root)}-repo`);
+ mkdirSync(repo);
+ const git = (...a: string[]) => execFileSync("git", a, { cwd: repo, stdio: "pipe", env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", HOME: h } });
+ git("init", "-q", "-b", "main");
+ git("-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false", "commit", "-q", "--allow-empty", "-m", "one");
+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo }).toString().trim();
+ r = await run(c, { HOME: h, PATH: `${c.bin}${path.delimiter}${process.env.PATH}`, ARCHILYZER_SOURCE_REPO: path.join(repo, ".git") });
+ assert.equal(find(r, "source publish", "source-repo")?.status, "ok");
+ assert.equal(find(r, "source publish", "source-repo")!.detail, `${path.join(repo, ".git")} (ARCHILYZER_SOURCE_REPO): main ${head.slice(0, 12)}`);
+ // An injected probe: main that does not read is a warning naming why.
+ r = await run(c, { HOME: h }, { sourceRepo: async () => ({ via: "env", repo: "/data/source.git", main: null, error: "fatal: detected dubious ownership" }) });
+ assert.equal(find(r, "source publish", "source-repo")?.status, "warn");
+ assert.match(find(r, "source publish", "source-repo")!.detail, /^\/data\/source\.git \(ARCHILYZER_SOURCE_REPO\): main does not read — fatal: detected dubious ownership$/);
+ assert.deepEqual(tree(c.root), before);
+});
diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts
@@ -6,10 +6,14 @@
// (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).
+// 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, and the repository the source mirror reads.
//
// STRICTLY READ-ONLY. It stats, reads and runs version flags, plus the engine's
-// `image inspect` and a lock-free `git status` / `git log`. It never opens
+// `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, 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
@@ -23,9 +27,10 @@
// and must not be told it is broken.
import { execFile } from "node:child_process";
-import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
+import { accessSync, constants, existsSync, readFileSync, realpathSync, statfsSync, statSync } from "node:fs";
import { readdir } 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";
@@ -84,6 +89,23 @@ export type DoctorDeps = {
now?: Date;
// The source publish's tools. Default: probeSourceTools(env).
sourceTools?: () => Promise<SourceTools>;
+ // 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<SourceRepoProbe>;
+ // Free bytes on the filesystem holding `dir`, or null when it cannot be
+ // asked. Default: statfs.
+ freeBytes?: (dir: string) => number | null;
+};
+
+// 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
@@ -356,6 +378,40 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
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";
+ const autoUpdate = /^(1|true|yes|on)$/i.test(env.YTDLP_AUTO_UPDATE?.trim() ?? "");
+ // 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)))();
@@ -411,6 +467,59 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
}
}
+ // ── 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 oauth = wranglerLoginConfig(env);
+ const account = set("CLOUDFLARE_ACCOUNT_ID") ? "CLOUDFLARE_ACCOUNT_ID set" : "CLOUDFLARE_ACCOUNT_ID unset (fine with one account)";
+ if (set("CLOUDFLARE_API_TOKEN")) {
+ add(PB, "cloudflare-auth", "ok", `CLOUDFLARE_API_TOKEN is set (never printed); ${account}`);
+ } else if (oauth) {
+ 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; set CLOUDFLARE_API_TOKEN (in Docker: .env)`
+ : "neither CLOUDFLARE_API_TOKEN nor a `wrangler login` config — needed only to deploy to Cloudflare Pages");
+ }
+ 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(builds);
+ 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);
+ }
+ }
+ }
+ }
+
// ── 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
@@ -449,6 +558,41 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
}
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"],
@@ -783,6 +927,147 @@ async function dirSizeText(dir: string): Promise<string> {
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;
+ }
+}
+
+// `<bin> --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 `<builds>/<target>/out` bundle and their bytes, by stat (links not
+// followed). A bundle's own size is what a rebuild writes again beside it.
+async function bundleBytes(buildsDir: string): Promise<{ count: number; bytes: number }> {
+ let count = 0;
+ let bytes = 0;
+ const walk = async (d: string): Promise<void> => {
+ 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(buildsDir, { withFileTypes: true }).catch(() => [])) {
+ if (!ent.isDirectory()) continue;
+ const out = path.join(buildsDir, ent.name, "out");
+ if (!statOrNull(out)?.isDirectory()) continue;
+ count += 1;
+ await walk(out);
+ }
+ return { count, bytes };
+}
+
+// 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<string[]> {
+ 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();
+}
+
+// wrangler's `wrangler login` (OAuth) config, where wrangler keeps it: under
+// XDG_CONFIG_HOME (~/.config) since v3, ~/.wrangler before, ~/Library/
+// Preferences on macOS. Its PATH is reported; it is never read.
+function wranglerLoginConfig(env: NodeJS.ProcessEnv): string | null {
+ const home = env.HOME || os.homedir();
+ const xdg = env.XDG_CONFIG_HOME || path.join(home, ".config");
+ for (const p of [
+ path.join(xdg, ".wrangler", "config", "default.toml"),
+ path.join(home, ".wrangler", "config", "default.toml"),
+ path.join(home, "Library", "Preferences", ".wrangler", "config", "default.toml"),
+ ]) {
+ if (existsSync(p)) return p;
+ }
+ return null;
+}
+
+// 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<SourceRepoProbe> {
+ 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<boolean> {
return new Promise((resolve) => {
diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts
@@ -100,6 +100,7 @@ const DECLARED: EnvVarDecl[] = [
{ name: "ARCHILYZER_SOURCE_REPO", audience: "runtime", default: "this checkout's git common dir", readBy: "common/publish/source.ts, common/bin/doctor.ts, docker/entrypoint.sh", doc: "The git DIR `archilyzer source publish` mirrors `main` from, when the checkout has none: in Docker, `/data/source.git`, the host's git common dir mounted read-only by docker-compose.source.yml. A value that names nothing refuses the publish." },
{ name: "YTDLP_SOURCE_HOST_DIR", audience: "runtime", default: "— (required by the overlay)", readBy: "docker-compose.ytdlp.yml", doc: "Docker: the HOST path of a yt-dlp source checkout (the directory holding `yt_dlp/`), mounted read-only at `/opt/yt-dlp-src` by docker-compose.ytdlp.yml. See [RUNNING_IN_DOCKER.md](RUNNING_IN_DOCKER.md), \"Substituting yt-dlp\"." },
{ name: "YTDLP_AUTO_UPDATE", audience: "runtime", default: "off", readBy: "docker/entrypoint.sh, common/bin/doctor.ts", doc: "Docker: `1` runs `yt-dlp -U` on every editor boot — on the image's yt-dlp only; an override (`YTDLP_BIN` naming another) is left alone, with a warning." },
+ { name: "XDG_CONFIG_HOME", audience: "runtime", default: "`~/.config`", readBy: "common/bin/doctor.ts", doc: "Where `wrangler login` keeps its config (`<it>/.wrangler/config/default.toml`); the doctor looks for it there, by path, never reading it." },
{ name: "YTDLP_SOURCE_DIR", audience: "runtime", default: "`/opt/yt-dlp-src`", readBy: "docker/yt-dlp-from-source.sh", doc: "Docker: where `/usr/local/bin/yt-dlp-from-source` finds the yt-dlp source tree it runs with the image's python." },
{ name: "DOCKER_BIN", audience: "runtime", default: "`docker`", readBy: "common/publish/build.ts", doc: "The container engine for docker-mode builds (e.g. `podman`)." },
{ name: "DOCKER_BUILD_MEMORY", audience: "runtime", default: "no cap", readBy: "common/publish/build.ts", doc: "Per-container memory cap for a docker-mode build (`--memory`)." },