Archilyzer · Source

archilyzer

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

commit 38fc30bfe46955394023d204ffa337a278a34150
parent df14a98e5e0076dcffc3fabb63f84155f591f596
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 10:51:19 -0400

Merge r18/docker-publish (slice S5, third round: the doctor's wrangler and cloudflare-auth checks through the deploy's own helpers; the container smoke with the pinned wrangler)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
MENVIRONMENT.md | 6+++---
Mcommon/bin/doctor.test.ts | 50+++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/bin/doctor.ts | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcommon/lib/envVars.ts | 6+++---
Meditor/CHANGELOG.md | 2+-
Mplans/release-18.md | 45++++++++++++++++++++++++++++++++++++++++++---
6 files changed, 168 insertions(+), 30 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." }, diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -10,7 +10,7 @@ - **`build site`, `build all` and `deploy site` are aliases of the publish commands** and print what they run: `build site <id>` is `publish index` (skipped with `--nodata`) then `publish build <id> --force`; `build all` is `publish index` then `publish build all --runner auto` (containers when an engine answers, else one site at a time on the host); `deploy site <id>` is `publish deploy <id>`, which now ships the site's own bundle and refuses a site never built that way. `publish build all --runner docker` builds every stale site in containers on a Linux host and refuses with "the docker runner needs an engine on this host" where there is none. - **Substitute your own yt-dlp in Docker.** Point `YTDLP_BIN` at a zipapp you built, or set `YTDLP_SOURCE_HOST_DIR` to a yt-dlp checkout and start with `docker-compose.ytdlp.yml`: the image runs it with its own python, and nothing is rebuilt. Every editor boot logs `yt-dlp: <path> <version> (image|override)` (`MISSING` when it does not run; the editor still starts), and `YTDLP_AUTO_UPDATE` updates the image's yt-dlp only, warning instead of touching yours. - **The Docker image can publish.** It carries python, `pipx` and a pinned `git-filter-repo`, so the homepage's `/source` mirror builds in the container; `docker-compose.source.yml` mounts your repository read-only for it, and the scrub rules and denylist live in the config volume (`/data/config/archilyzer`). Cloudflare and R2 credentials come from `.env`. Run publish commands with `docker compose exec editor pnpm archilyzer …`, not `run --rm`. The `homepage` service serves a local deploy from the builds volume once there is one. RUNNING_IN_DOCKER.md has a Windows checklist. -- **`archilyzer doctor` checks what a publish needs.** Which yt-dlp runs (the image's, the host's or an override, and whether it runs), whether the Cloudflare token and the R2 keys are set (never their values; R2 only when a bucket is configured), free space for the site bundles, the publish lock (free, held by a running stage, or left by one that is gone — with the command to clear it; never cleared for you), the index stamp's age and which sites were built from an older one, the repository the source mirror reads, the private config dir, and whether this Node is new enough for the pinned wrangler (deploys need 22). +- **`archilyzer doctor` checks what a publish needs.** Which yt-dlp runs (the image's, the host's or an override, and whether it runs), whether the Cloudflare token and the R2 keys are set (never their values; R2 only when a bucket is configured) — judged exactly as a deploy judges them —, the wrangler a deploy runs (the pinned one or your `WRANGLER_BIN`, and that it starts and is the expected major), free space for the site bundles, the publish lock (free, held by a running stage, or left by one that is gone — with the command to clear it; never cleared for you), the index stamp's age and which sites were built from an older one, the repository the source mirror reads, the private config dir, and whether this Node is new enough for the pinned wrangler (deploys need 22). - **A cited moment at the very end of a recording prepares.** Prepare evidence media cuts a clip whose padding runs past the recording's end at the end (the recording's duration from its metadata), where it found no media for the padded span; a span that starts past the end is still refused. report-to-video keeps its strict rule. - **Exporting a changed report records a new revision of it.** `reports export` (and **Export reports** on a site's Reports tab, and the end of a prepare) commits a revision to the report's own git history, `sites/<site>/reports/<id>/history-git/`, whenever its `report.json` changed since the last one: the `report.json`, its Markdown export and the checksums of every export file, with a message of `Revision N` and a summary of the change. A re-export of an unchanged report records nothing. The commits carry the site's name and a `noreply@<site>.invalid` address with dates in UTC, never your git name, email or time zone. The Reports tab shows each report's revision, its commit and the last change under **Exports**, and the site's next build publishes the history. Add `history-git/` to the corpus repository's `.gitignore`. - **archive.org files come over BitTorrent when possible, else straight from archive.org — never through yt-dlp.** The chosen file of an archive.org import is fetched from the item's own torrent (`<identifier>_archive.torrent`, which lists archive.org as a web seed, so other peers take load off archive.org) with aria2c, only that file of the item, and seeded afterwards for 10 minutes or to a ratio of 1, whichever comes first; the log shows "torrent: <file> (n of m pieces, peers p, web seed yes)" and "seeding 10 min…". With no aria2c, a torrent that does not carry the file, or no progress for 5 minutes, it is downloaded directly from `archive.org/download/…` instead (resumable, backing off on 429/503), and the log says "fell back to direct download: <reason>". Every file is checked against archive.org's sha1/md5: a mismatch is downloaded once more directly, a second one fails the record. The record is written from the item's metadata: `metadata.info.json` with the file's page, the canonical id, the duration ffprobe measures and archive.org's playable copies of the file, the `archiveorg.json` provenance (a mirror's original title, date and uploader), and `audio.<fmt>` — an audio file already in the channel's format is used as is, anything else goes through the app's audio extraction, a video kept in the saved-video store when the channel keeps sources. An .avi/.mpeg/.flac/.wav original is fetched as archive.org's mp4 or mp3 of it. aria2c runs in its own process group: cancelling the job stops it and everything it started, and it stops itself if the editor exits. New settings block `archiveOrg` (`torrent`, `seedMinutes`, `seedRatio`, `stallMinutes`, `maxPeers`, `maxDownloadKiBps`, `maxUploadKiBps`), `ARIA2C_BIN`, an aria2c row in `archilyzer doctor`, and `aria2` in the runtime Docker images. diff --git a/plans/release-18.md b/plans/release-18.md @@ -1065,9 +1065,48 @@ nothing left): - `publish status` and `publish now` (RUNNING_IN_DOCKER.md names both) are S3's rows, not on this branch yet: `archilyzer: unknown command "publish status"` here. -**Third round** (after S2): the `wrangler` check (`wranglerBin` / `WRANGLER_MAJOR`), `cloudflare-auth` -through `cloudflareCredentialProblem` + `wranglerOAuthConfigFiles`, the envVars `readBy` touch-ups and -dedupe, the no-token and bogus-token deploy smoke. +**Third round** (S2 merged: `r18/integration` `4f3daeea`, a fast-forward of this branch; `pnpm install +--frozen-lockfile` brought in the pinned wrangler 4.147.0) + +| Commit | What | +|---|---| +| `056dfac9` | `doctor:` **`publish/wrangler`** — `wranglerBin(paths, env)` (the pin, or `WRANGLER_BIN`, named when set): not there → a note (a warning when a site names a project) naming `pnpm install`, a missing override FAILS; not executable → a warning; run with `--version`, whose major must be `WRANGLER_MAJOR` (else a warning), and a binary that does not start (Node below its floor) warns with its first stderr line. **`wrangler --version` writes a debug log under `~/.config/.wrangler/logs` on every run**, so the doctor sends it to a temp dir (`WRANGLER_LOG_PATH`) it removes — the doctor's header says so. **`cloudflare-auth`** grades with `cloudflareCredentialProblem` over `wranglerOAuthConfigFiles` (located, never read) and quotes the deploy's own sentence, so the two cannot disagree; the doctor's own login-path helper is gone. envVars `readBy`: `WRANGLER_BIN` adds `doctor.ts`; `ARCHILYZER_COMMIT`/`BRANCH` name `stageBodies.ts`'s `imageBuildFacts` (the duplicate rows were already resolved at the S2 merge). 1 test (6 states; the log dir is under the OS temp dir and gone after; nothing under HOME) | +| this one | `plans:` this table and the smoke; the doctor's changelog bullet names the wrangler check | + +Gates at `056dfac9`: tsc (all workspaces) clean (167 s). **common: 3,281/3,282** — 1 failed, +`controller/fetchPosts.test.ts` "a drain mid-page waits for the page's cursor…", a timing case, run while +the image built beside it; the file alone afterwards: 8/8 (S5 touches nothing it imports). **Editor unit:** +142/142. `doctor.test.ts` + `buildImage.test.ts` 35/35 — **the wrangler-floor drift test now runs** +(wrangler installed): Node 22 ≥ `>=22.0.0`, green, nothing skipped. + +**Compose smoke, third round** (`--target runtime` rebuilt from `056dfac9`, 351 s; the image is now +**2.00 GB** — wrangler and its workerd in `node_modules`; the same fixture, `s5site` given a +`cloudflareProject` so it is deployable; editor + `site`; `down -v` after): +- In the container: `node --version` v22.23.3; `common/node_modules/.bin/wrangler --version` and `node + common/node_modules/wrangler/bin/wrangler.js --version` both **4.147.0**, exit 0. No OAuth login files + (`/root/.wrangler/…`, `/root/.config/.wrangler/config/default.toml` absent), no token. +- `publish index` / `build s5site` / `deploy s5site --to local` → exit 0; `deployed.json` written + (sha256 `086ca1ee23883d29…`, mtime noted). +- `publish deploy s5site --preview smoke`, no credential → **exit 1**, `[deploy] REFUSED — no Cloudflare + credentials: set CLOUDFLARE_API_TOKEN in .env (or run \`wrangler login\` on this machine). Nothing was + sent to Cloudflare.`; wrangler never started; `deployed.json` byte- and mtime-identical. +- The same with `CLOUDFLARE_API_TOKEN=bogus` → exit 1, `deployed.json` identical — but the log ends + `[deploy] FAILED — wrangler exited 1.`, **not** the REFUSED sentence: Cloudflare answers a token that is + not token-SHAPED with `Invalid request headers [code: 6003]` / `Invalid format for Authorization header + [code: 6111]`, which `pagesDeploy.ts`'s `AUTH_FAILURE_RES` does not list. One more request with a + well-formed wrong token (40 random characters): Cloudflare says `Invalid access token [code: 9109]` + and the log ends on the exact **`[deploy] REFUSED by Cloudflare — the API token was not accepted`**, + exit 1, no `deployed.json`. Two requests to Cloudflare in all. Finding for S2's file (not changed + here): add codes 6003 and 6111 to the classifier — a mistyped or truncated token in `.env` is the likely + real case. +- `doctor` with no token: `node` ok "deploys: >= 22.0.0, wrangler 4.147.0"; `cloudflare-auth` WARN, quoting + the preflight's sentence, naming `s5site`; `wrangler` ok "/repo/common/node_modules/.bin/wrangler 4.147.0 + (the pin in common/package.json)"; `export-builds`, `publish-lock`, `index-stamp` ok. With the token set: + `cloudflare-auth` ok "is set (never printed)". The count of wrangler log files under the container's + `/root/.config/.wrangler/logs` was 3 before the doctor and 3 after (the deploys wrote those). + +S5 is complete with this round. Left for the rollout, as recorded above: building `runtime-vulkan` and +`runtime-cuda` once. ## Rollout