Archilyzer · Source

archilyzer

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

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:
MENVIRONMENT.md | 1+
Mcommon/bin/doctor.test.ts | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/doctor.ts | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/envVars.ts | 1+
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`)." },