commit b28e844cba464b6bd24b850da0a13a172077583f
parent df14a98e5e0076dcffc3fabb63f84155f591f596
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 10:40:11 -0400
doctor: the wrangler a deploy spawns, run for its major; cloudflare-auth through the deploy's own preflight
- publish/wrangler: wranglerBin(paths, env) (the pin, or WRANGLER_BIN — named
when set); missing → a note/warning naming `pnpm install`, a missing
override fails; run with --version (its debug log sent to a temp dir that is
removed — wrangler writes one under ~/.config/.wrangler/logs on every run);
a major other than WRANGLER_MAJOR, or a binary that does not start (Node
below its floor), warns.
- publish/cloudflare-auth grades with pagesDeploy's cloudflareCredentialProblem
over wranglerOAuthConfigFiles (located, never read) and quotes its sentence;
the doctor's own login-path helper is gone.
- envVars readBy: WRANGLER_BIN adds doctor.ts; ARCHILYZER_COMMIT/BRANCH name
stageBodies.ts's imageBuildFacts. ENVIRONMENT.md regenerated.
1 test (wrangler: 6 states, the log dir outside HOME and removed).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 125 insertions(+), 26 deletions(-)
diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md
@@ -73,7 +73,7 @@ Tokens, credentials and knobs a running process reads. Most configuration is not
| `ARCHIVE_CHANNEL_CONCURRENCY` | `4` | How many channels' archive zips `build archives` builds at once. | common/bin/build-archives.ts |
| `HOST` | every interface | The address `pnpm start:export` (serve-out) listens on; `127.0.0.1` keeps a private site on this machine. | export/scripts/serve-out.mjs |
| `MAX_ARCHIVE_BYTES` | the Cloudflare-safe cap | The served-file size cap for archives, in bytes; `0` = no cap. A site's own `archiveMaxBytes` wins. | common/bin/compose-site.ts |
-| `WRANGLER_BIN` | `common/node_modules/.bin/wrangler` (the pinned devDependency) | The wrangler a deploy spawns. The editor's e2e suite points it at its fake. | common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts |
+| `WRANGLER_BIN` | `common/node_modules/.bin/wrangler` (the pinned devDependency) | The wrangler a deploy spawns. The editor's e2e suite points it at its fake. | common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts, common/bin/doctor.ts |
| `CHOUGH_BIN` | `chough` on PATH | The chough transcription engine, when a worker names no binary. | common/lib/transcriptionApps.ts |
| `CHOUGH_MODEL` | chough's own | Passed to chough from a worker's model field; chough auto-downloads one when unset. | chough (set by common/lib/transcriptionApps.ts) |
| `CHOUGH_URL` | local | Passed to chough from a worker's remote-server field. | chough (set by common/lib/transcriptionApps.ts) |
@@ -160,8 +160,8 @@ The container's own set, read by `docker/*.sh`, the compose files and Caddy —
| `ARCHILYZER_SITE_OUT` | `/data/builds/site` | The built export site the `site` service serves. | docker/entrypoint.sh, docker/publish-site.sh, common/publish/deployStage.ts |
| `ARCHILYZER_HOMEPAGE_OUT` | `/data/builds/homepage` | The locally deployed homepage (`publish homepage --deploy --to local`). The `homepage` service serves it when it is non-empty, else the image's baked build. | docker/entrypoint.sh, common/publish/deployStage.ts |
| `ARCHILYZER_IMAGE_YTDLP` | baked: `/usr/local/bin/yt-dlp` | The yt-dlp the image ships. `YTDLP_BIN` naming anything else is an OVERRIDE: the boot's `yt-dlp:` line and `archilyzer doctor` say so, and `YTDLP_AUTO_UPDATE` leaves it alone. | docker/entrypoint.sh, common/bin/doctor.ts |
-| `ARCHILYZER_COMMIT` | baked: empty unless the build passed it | The commit the image was built from — the publish stamps' `commit` where there is no .git. `ARCHILYZER_COMMIT=$(git rev-parse HEAD) docker compose build`. | the Dockerfile (a build arg), the publish stamps (`IMAGE_COMMIT_ENV`) |
-| `ARCHILYZER_BRANCH` | baked: empty unless the build passed it | The branch the image was built from — the stamps' `branch`, which a production deploy checks. | the Dockerfile (a build arg), the publish stamps (`IMAGE_BRANCH_ENV`) |
+| `ARCHILYZER_COMMIT` | baked: empty unless the build passed it | The commit the image was built from — the publish stamps' `commit` where there is no .git. `ARCHILYZER_COMMIT=$(git rev-parse HEAD) docker compose build`. | the Dockerfile (a build arg), common/publish/stageBodies.ts (`imageBuildFacts`, the stamps' fallback) |
+| `ARCHILYZER_BRANCH` | baked: empty unless the build passed it | The branch the image was built from — the stamps' `branch`, which a production deploy checks. | the Dockerfile (a build arg), common/publish/stageBodies.ts (`imageBuildFacts`, the stamps' fallback) |
| `ARCHILYZER_SOURCE_HOST_DIR` | `./.git` | The HOST's git common dir docker-compose.source.yml mounts at `/data/source.git`. In a git worktree, the primary checkout's `.git`. | docker-compose.source.yml |
| `ARCHILYZER_IDLE_BOOT` | off | `1` boots the editor without arming the heartbeat or any auto-queue runner. | common/lib/idleBoot.ts (the editor) |
| `ARCHILYZER_AUTH_MODE` | `basic` | `basic`, `forward` or `none` — the only escape hatch from the exposure guard. | docker/guard-exposure.sh, docker/caddy-start.sh |
diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts
@@ -701,7 +701,8 @@ test("publish: cloudflare-auth reports a token SET (never its value), a wrangler
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/);
+ // The deploy's own preflight sentence (lib/pagesDeploy.ts), so the two cannot disagree.
+ assert.match(find(r, "publish", "cloudflare-auth")!.detail, /1 site names a Cloudflare project \(alpha\) — every deploy refuses \("REFUSED — no Cloudflare credentials: set CLOUDFLARE_API_TOKEN in \.env/);
assert.equal(r.ok, true);
// A token: ok, and the value appears nowhere in the report.
const secret = "PLANTED-TOKEN-0123456789";
@@ -923,3 +924,50 @@ test("workspace: node is graded against the pinned wrangler's engines floor when
assert.equal(find(r, "workspace", "node")?.status, "fail");
assert.deepEqual(tree(c.root), before);
});
+
+test("publish: wrangler — the pinned binary or WRANGLER_BIN, run for its major (its debug log in a temp dir that is removed); a missing override fails", async () => {
+ const c = checkout();
+ const h = home(c);
+ const sitesDir = path.join(c.paths.transcriptsDir, "sites");
+ (c.paths as { sitesDir: string }).sitesDir = sitesDir;
+ // Not installed, nothing deploys: a note naming `pnpm install`.
+ let r = await run(c, { HOME: h });
+ assert.equal(find(r, "publish", "wrangler")?.status, "info");
+ assert.match(find(r, "publish", "wrangler")!.detail, /common\/node_modules\/\.bin\/wrangler is not there — run `pnpm install`/);
+ // A site that deploys: a warning.
+ mkdirSync(path.join(sitesDir, "alpha"), { recursive: true });
+ writeFileSync(path.join(sitesDir, "alpha", "site.json"), JSON.stringify({ title: "A", cloudflareProject: "alpha-pages" }));
+ r = await run(c, { HOME: h });
+ assert.equal(find(r, "publish", "wrangler")?.status, "warn");
+ // WRANGLER_BIN naming nothing: a failure.
+ r = await run(c, { HOME: h, WRANGLER_BIN: path.join(c.bin, "no-wrangler") });
+ assert.equal(find(r, "publish", "wrangler")?.status, "fail");
+ assert.equal(r.ok, false);
+ // Fake wranglers that record where they were told to log.
+ const seen = path.join(TMP, `${path.basename(c.root)}-wrangler-log-path`);
+ const fakeWrangler = (name: string, body: string) => {
+ const p = path.join(c.bin, name);
+ writeFileSync(p, `#!/bin/sh\nprintf '%s' "$WRANGLER_LOG_PATH" > '${seen}'\n${body}\n`);
+ chmodSync(p, 0o755);
+ return p;
+ };
+ const good = fakeWrangler("wrangler-4", "echo ' ⛅️ wrangler 4.147.0'");
+ const old = fakeWrangler("wrangler-3", "echo '3.114.0'");
+ const broken = fakeWrangler("wrangler-node20", "echo 'Wrangler requires at least Node.js v22.0.0. You are using v20.11.0.' >&2; exit 1");
+ const before = tree(c.root);
+ r = await run(c, { HOME: h, WRANGLER_BIN: good });
+ assert.equal(find(r, "publish", "wrangler")?.status, "ok");
+ assert.equal(find(r, "publish", "wrangler")!.detail, `${good} 4.147.0 (WRANGLER_BIN)`);
+ const { readFileSync, existsSync } = await import("node:fs");
+ const logPath = readFileSync(seen, "utf8");
+ assert.ok(logPath.startsWith(os.tmpdir()), `the debug log went to the temp dir, not HOME (${logPath})`);
+ assert.equal(existsSync(logPath), false, "and that dir is removed");
+ r = await run(c, { HOME: h, WRANGLER_BIN: old });
+ assert.equal(find(r, "publish", "wrangler")?.status, "warn");
+ assert.match(find(r, "publish", "wrangler")!.detail, /3\.114\.0 \(WRANGLER_BIN\) — expected wrangler 4\.x/);
+ r = await run(c, { HOME: h, WRANGLER_BIN: broken });
+ assert.equal(find(r, "publish", "wrangler")?.status, "warn");
+ assert.match(find(r, "publish", "wrangler")!.detail, /does not run: Wrangler requires at least Node\.js v22\.0\.0/);
+ assert.deepEqual(tree(c.root), before);
+ assert.deepEqual(readdirSync(h), [], "nothing under HOME");
+});
diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts
@@ -16,7 +16,8 @@
// 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, never writes settings,
+// 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). 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.
@@ -30,7 +31,7 @@
import { execFile } from "node:child_process";
import { accessSync, constants, existsSync, readFileSync, realpathSync, statfsSync, statSync } from "node:fs";
-import { readdir } from "node:fs/promises";
+import { mkdtemp, readdir, rm } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
@@ -492,19 +493,53 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
{
const deployable = await sitesWithCloudflareProject(paths);
const set = (k: string) => Boolean(env[k]?.trim());
- const oauth = wranglerLoginConfig(env);
+ 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 (set("CLOUDFLARE_API_TOKEN")) {
+ if (problem === null && set("CLOUDFLARE_API_TOKEN")) {
add(PB, "cloudflare-auth", "ok", `CLOUDFLARE_API_TOKEN is set (never printed); ${account}`);
- } else if (oauth) {
+ } 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; set CLOUDFLARE_API_TOKEN (in Docker: .env)`
+ ? `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));
@@ -1144,20 +1179,36 @@ async function sitesWithCloudflareProject(paths: Paths): Promise<string[]> {
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;
+function executable(p: string): boolean {
+ try {
+ accessSync(p, constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// `<wrangler> --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 });
}
- return null;
}
// Which repository `source publish` would mirror (the same order as
diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts
@@ -109,7 +109,7 @@ const DECLARED: EnvVarDecl[] = [
{ name: "ARCHIVE_CHANNEL_CONCURRENCY", audience: "runtime", default: "`4`", readBy: "common/bin/build-archives.ts", doc: "How many channels' archive zips `build archives` builds at once." },
{ name: "HOST", audience: "runtime", default: "every interface", readBy: "export/scripts/serve-out.mjs", doc: "The address `pnpm start:export` (serve-out) listens on; `127.0.0.1` keeps a private site on this machine." },
{ name: "MAX_ARCHIVE_BYTES", audience: "runtime", default: "the Cloudflare-safe cap", readBy: "common/bin/compose-site.ts", doc: "The served-file size cap for archives, in bytes; `0` = no cap. A site's own `archiveMaxBytes` wins." },
- { name: "WRANGLER_BIN", audience: "runtime", default: "`common/node_modules/.bin/wrangler` (the pinned devDependency)", readBy: "common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts", doc: "The wrangler a deploy spawns. The editor's e2e suite points it at its fake." },
+ { name: "WRANGLER_BIN", audience: "runtime", default: "`common/node_modules/.bin/wrangler` (the pinned devDependency)", readBy: "common/lib/pagesDeploy.ts (wranglerBin), common/publish/deployStage.ts, common/bin/doctor.ts", doc: "The wrangler a deploy spawns. The editor's e2e suite points it at its fake." },
{ name: "CHOUGH_BIN", audience: "runtime", default: "`chough` on PATH", readBy: "common/lib/transcriptionApps.ts", doc: "The chough transcription engine, when a worker names no binary." },
{ name: "CHOUGH_MODEL", audience: "runtime", default: "chough's own", readBy: "chough (set by common/lib/transcriptionApps.ts)", doc: "Passed to chough from a worker's model field; chough auto-downloads one when unset." },
{ name: "CHOUGH_URL", audience: "runtime", default: "local", readBy: "chough (set by common/lib/transcriptionApps.ts)", doc: "Passed to chough from a worker's remote-server field." },
@@ -163,8 +163,8 @@ const DECLARED: EnvVarDecl[] = [
{ name: "ARCHILYZER_SITE_OUT", audience: "docker", default: "`/data/builds/site`", readBy: "docker/entrypoint.sh, docker/publish-site.sh, common/publish/deployStage.ts", doc: "The built export site the `site` service serves." },
{ name: "ARCHILYZER_HOMEPAGE_OUT", audience: "docker", default: "`/data/builds/homepage`", readBy: "docker/entrypoint.sh, common/publish/deployStage.ts", doc: "The locally deployed homepage (`publish homepage --deploy --to local`). The `homepage` service serves it when it is non-empty, else the image's baked build." },
{ name: "ARCHILYZER_IMAGE_YTDLP", audience: "docker", default: "baked: `/usr/local/bin/yt-dlp`", readBy: "docker/entrypoint.sh, common/bin/doctor.ts", doc: "The yt-dlp the image ships. `YTDLP_BIN` naming anything else is an OVERRIDE: the boot's `yt-dlp:` line and `archilyzer doctor` say so, and `YTDLP_AUTO_UPDATE` leaves it alone." },
- { name: "ARCHILYZER_COMMIT", audience: "docker", default: "baked: empty unless the build passed it", readBy: "the Dockerfile (a build arg), the publish stamps (`IMAGE_COMMIT_ENV`)", doc: "The commit the image was built from — the publish stamps' `commit` where there is no .git. `ARCHILYZER_COMMIT=$(git rev-parse HEAD) docker compose build`." },
- { name: "ARCHILYZER_BRANCH", audience: "docker", default: "baked: empty unless the build passed it", readBy: "the Dockerfile (a build arg), the publish stamps (`IMAGE_BRANCH_ENV`)", doc: "The branch the image was built from — the stamps' `branch`, which a production deploy checks." },
+ { name: "ARCHILYZER_COMMIT", audience: "docker", default: "baked: empty unless the build passed it", readBy: "the Dockerfile (a build arg), common/publish/stageBodies.ts (`imageBuildFacts`, the stamps' fallback)", doc: "The commit the image was built from — the publish stamps' `commit` where there is no .git. `ARCHILYZER_COMMIT=$(git rev-parse HEAD) docker compose build`." },
+ { name: "ARCHILYZER_BRANCH", audience: "docker", default: "baked: empty unless the build passed it", readBy: "the Dockerfile (a build arg), common/publish/stageBodies.ts (`imageBuildFacts`, the stamps' fallback)", doc: "The branch the image was built from — the stamps' `branch`, which a production deploy checks." },
{ name: "ARCHILYZER_SOURCE_HOST_DIR", audience: "docker", default: "`./.git`", readBy: "docker-compose.source.yml", doc: "The HOST's git common dir docker-compose.source.yml mounts at `/data/source.git`. In a git worktree, the primary checkout's `.git`." },
{ name: "ARCHILYZER_IDLE_BOOT", audience: "docker", default: "off", readBy: "common/lib/idleBoot.ts (the editor)", doc: "`1` boots the editor without arming the heartbeat or any auto-queue runner." },
{ name: "ARCHILYZER_AUTH_MODE", audience: "docker", default: "`basic`", readBy: "docker/guard-exposure.sh, docker/caddy-start.sh", doc: "`basic`, `forward` or `none` — the only escape hatch from the exposure guard." },