Archilyzer · Source

archilyzer

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

commit 4c74fff7c1fe6725c0c50c7843c9240eae406c7d
parent e815fd32f3c9d1c3c6bf971687c67aba7d516695
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 11:14:18 -0400

Merge branch 'r18/integration' into r18/publish-lane

Diffstat:
MENVIRONMENT.md | 15+++++++++------
Mcommon/bin/compose-hub.test.ts | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/compose-hub.ts | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/bin/compose-site.postsVisibility.test.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/bin/compose-site.ts | 43++++++++++++++++++++++++++++++++++++++++---
Mcommon/bin/doctor.test.ts | 164++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/bin/doctor.ts | 230++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcommon/lib/archive/headers.test.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/archive/headers.ts | 32++++++++++++++++++++++++++++++++
Mcommon/lib/builtExport.test.ts | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/builtExport.ts | 133++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/lib/envVars.ts | 15+++++++++------
Mcommon/lib/pagesDeploy.test.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcommon/lib/pagesDeploy.ts | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcommon/package.json | 3++-
Mcommon/publish/build.test.ts | 14++++++++++++--
Mcommon/publish/build.ts | 54++++++++++++++++++++++--------------------------------
Acommon/publish/deployStage.test.ts | 689+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/deployStage.ts | 534+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/liveCheck.test.ts | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/liveCheck.ts | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/stageBodies.ts | 159++++++++++---------------------------------------------------------------------
Mcommon/publish/stageRun.test.ts | 9++++++++-
Mcommon/publish/stageRun.ts | 12++++++++++++
Mcommon/publish/stamps.ts | 17+++--------------
Acommon/publish/tombstones.test.ts | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/tombstones.ts | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 4+++-
Aeditor/e2e/fixtures/bin/fake-wrangler.mjs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/e2e/site-publish-preview.spec.ts | 6++++--
Meditor/playwright.config.ts | 15+++++++++++++++
Mplans/release-18.md | 408++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpnpm-lock.yaml | 1125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mpnpm-workspace.yaml | 1+
34 files changed, 4892 insertions(+), 452 deletions(-)

diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md @@ -60,12 +60,12 @@ Tokens, credentials and knobs a running process reads. Most configuration is not | `R2_ACCESS_KEY_ID` | — | R2 S3 credentials for uploading oversize archives at deploy time (with `R2_SECRET_ACCESS_KEY` and `CLOUDFLARE_ACCOUNT_ID`), needed only when `archiveStorage.bucket` is set. In Docker they come from `.env`. See [PUBLISH.md](PUBLISH.md). | common/publish/build.ts, common/bin/doctor.ts (set or not) | | `R2_SECRET_ACCESS_KEY` | — | See `R2_ACCESS_KEY_ID`. | common/publish/build.ts, common/bin/doctor.ts (set or not) | | `CLOUDFLARE_ACCOUNT_ID` | — | The Cloudflare account: the R2 endpoint's, and the one wrangler deploys to when the token can see more than one. In Docker it comes from `.env`. | common/publish/build.ts, wrangler, common/bin/doctor.ts (set or not) | -| `CLOUDFLARE_API_TOKEN` | unset (wrangler's own `wrangler login` config, on a host) | The API token every deploy's wrangler authenticates with (Cloudflare Pages: Edit). The way a container deploys — there is no browser for `wrangler login` in one; set it in `.env`. | wrangler (every deploy), common/bin/doctor.ts (set or not, never the value) | +| `CLOUDFLARE_API_TOKEN` | unset (wrangler's own `wrangler login` config, on a host) | The API token every deploy's wrangler authenticates with (Cloudflare Pages: Edit). The way a container deploys — there is no browser for `wrangler login` in one; set it in `.env`. | wrangler (every deploy), common/bin/doctor.ts, common/lib/pagesDeploy.ts (set or not, never the value) | | `ARCHILYZER_HOST_ID` | the hostname | Which host the publish lock (`<EXPORT_BUILDS_DIR>/.publish.lock`) names as its holder's: a lock from this host whose pid is dead is stale and taken over; another host's is waited on. docker-compose.yml fixes it for the editor (`archilyzer-editor`), whose hostname is a container id that changes on every recreate. | common/publish/stageLock.ts (the publish lock) | | `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 | +| `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, common/lib/pagesDeploy.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 | @@ -73,6 +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, 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) | @@ -156,11 +157,11 @@ The container's own set, read by `docker/*.sh`, the compose files and Caddy — | `ARCHILYZER_FETCH_MODEL` | per transcriber | Which model the first boot downloads; `none` skips it. | docker/entrypoint.sh | | `ARCHILYZER_MODELS_DIR` | `/data/models` | Where models live in the container. | docker/entrypoint.sh | | `ARCHILYZER_BUILDS_DIR` | `/data/builds` | Where the container keeps built sites. | docker/entrypoint.sh | -| `ARCHILYZER_SITE_OUT` | `/data/builds/site` | The built export site the `site` service serves. | docker/entrypoint.sh, docker/publish-site.sh | -| `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 | +| `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 | @@ -199,6 +200,8 @@ Read only by a test harness, a fake binary or a test-mode branch. Never set one | `E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT` | — | Fake yt-dlp: corrupt deterministically. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | | `E2E_FAKE_YTDLP_RECOVER_ON_RESUME` | — | Fake yt-dlp: a resumed run recovers. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | | `E2E_FAKE_YTDLP_TOTAL_CHUNKS` | — | Fake yt-dlp: how many chunks a download has. | editor/e2e/fixtures/bin/fake-ytdlp.mjs | +| `E2E_FAKE_WRANGLER_AUTH_FAIL` | — | Fake wrangler: fail as Cloudflare refusing the API token (`Authentication error [code: 10000]`). | editor/e2e/fixtures/bin/fake-wrangler.mjs | +| `E2E_LIVE_CHECK` | on | `skip`: a deploy's live check reads nothing and records `skipped`. Set for the editor's test server, whose fake wrangler deploys nothing. | common/publish/liveCheck.ts | | `E2E_FAKE_GALLERY_DL_AUTH_FAIL` | — | Fake gallery-dl: fail as an auth error. | editor/e2e/fixtures/bin/fake-gallery-dl.mjs | | `E2E_FIXTURE_MAX_LIFETIME_MS` | the watchdog's | How long a fake binary may live before its watchdog kills it. | editor/e2e/fixtures/bin/_watchdog.mjs | | `E2E_OLLAMA_STUB_MODEL` | `qwen2.5:7b` | The model the ollama stub claims to serve. | editor/e2e/fixtures/ollama-stub.mjs | diff --git a/common/bin/compose-hub.test.ts b/common/bin/compose-hub.test.ts @@ -5,6 +5,7 @@ import { lstatSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, rmSync, statSync, @@ -16,6 +17,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { getPaths, type Paths } from "../lib/paths"; import { main } from "./compose-hub"; +import { builtHubProblem } from "../lib/builtExport"; import { readGlobalAliases } from "../lib/aliasesStore"; // Run with: @@ -41,6 +43,9 @@ function fixturePaths(root: string): Paths { lmdbPath: path.join(root, "index.mdb"), // never created: no index exportPublicDir, exportIndexDir: path.join(root, ".export-index"), + // The hub's tombstones read the shared posts tree (release 18): this + // fixture's own, never the checkout's. + exportSharedPostsDir: path.join(root, ".export-index", "shared", "posts"), }; } @@ -263,3 +268,111 @@ test("compose-hub removes a site's data from public/, a linked entry by its link rmSync(root, { recursive: true, force: true }); } }); + +// Release 18: Cloudflare's edge kept serving the hub's withdrawn X shard after +// the deploy that removed it. While X posts are private the hub REPLACES those +// paths — path for path, the rollout's requirement: posts/manifest.json (empty), +// posts/thequartering-X/manifest.json (pageCount 0), posts/thequartering-X/ +// page-0000.json ([]) — and serves posts/ no-store. A Bluesky channel is not X +// and gets nothing; with X public the hub ships no posts/ at all. PRIVATE DATA +// IS NEVER NAMED ON THE HUB: an X channel only a private site carries, and one +// no site carries, get no tombstone — their slugs appear nowhere in public/. +test("while X posts are private, the hub ships tombstones for every X channel, served no-store", async () => { + const root = mkdtempSync(path.join(tmpdir(), "compose-hub-")); + const log = console.log; + try { + const paths = fixturePaths(root); + const pub = paths.exportPublicDir; + const writeJson = (file: string, value: unknown) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(value)); + }; + const X = "thequartering-X"; + const SKY = "jer-sky"; + const PRIVATE_X = "only-private-x"; + const NO_SITE_X = "no-site-x"; + writeJson(path.join(paths.channelsDir, X, "config.json"), { + handling: "youtube", + url: "https://x.com/x", + sourceKind: "social", + platform: "twitter", + }); + writeJson(path.join(paths.channelsDir, SKY, "config.json"), { + handling: "youtube", + url: "https://bsky.app/profile/jer.example", + sourceKind: "social", + platform: "bluesky", + }); + for (const slug of [PRIVATE_X, NO_SITE_X]) { + writeJson(path.join(paths.channelsDir, slug, "config.json"), { + handling: "youtube", + url: `https://x.com/${slug}`, + sourceKind: "social", + platform: "twitter", + }); + } + const site = (siteId: string, slugs: string[], extra: Record<string, unknown> = {}) => + writeJson(path.join(paths.sitesDir, siteId, "site.json"), { + siteId, + siteTitle: siteId, + channels: slugs.map((slug) => ({ slug, groupId: "default" })), + ...extra, + }); + site("pub", [X, SKY]); + site("mine", [PRIVATE_X, X], { audience: "private" }); + for (const slug of [X, SKY, PRIVATE_X, NO_SITE_X]) { + writeJson(path.join(paths.exportSharedPostsDir, slug, "manifest.json"), { + version: 1, + channelSlug: slug, + pageCount: 1, + maxPageBytes: 1000, + generatedAt: "2026-10-01T00:00:00.000Z", + slugToPage: { "1": 0 }, + }); + writeJson(path.join(paths.exportSharedPostsDir, slug, "page-0000.json"), [{ id: "1", text: "a post" }]); + } + + console.log = () => {}; + await main({ paths, settings: { social: { x: { visibility: "private" } } } }); + console.log = log; + + const read = (rel: string) => JSON.parse(readFileSync(path.join(pub, rel), "utf8")); + assert.deepEqual(read("posts/manifest.json").channels, []); + assert.equal(read("posts/manifest.json").totalCount, 0); + assert.equal(read(`posts/${X}/manifest.json`).pageCount, 0); + assert.deepEqual(read(`posts/${X}/manifest.json`).slugToPage, {}); + assert.deepEqual(read(`posts/${X}/page-0000.json`), []); + assert.ok(!existsSync(path.join(pub, "posts", SKY)), "a Bluesky channel is not withdrawn"); + // Private data is never named on the hub. + assert.deepEqual(readdirSync(path.join(pub, "posts")).sort(), ["manifest.json", X]); + for (const hidden of [PRIVATE_X, NO_SITE_X]) { + const named = readdirSync(pub, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile()) + .filter((e) => readFileSync(path.join(e.parentPath, e.name), "utf8").includes(hidden)); + assert.deepEqual(named.map((e) => e.name), [], `${hidden} is named in the hub's public/`); + assert.ok(!existsSync(path.join(pub, "posts", hidden))); + } + const headers = readFileSync(path.join(pub, "_headers"), "utf8"); + assert.ok( + headers.endsWith( + "# Withdrawn content (tombstones): never stored at the edge.\n" + + "/posts/*\n Cache-Control: no-store\n Access-Control-Allow-Origin: *\n", + ), + headers, + ); + // corpus.json advertises no posts; and the hub bundle (public/ stands in + // for out/) is still a hub: a posts/ of tombstones is its own. + assert.ok(!readFileSync(path.join(pub, "corpus.json"), "utf8").includes("posts")); + assert.equal(builtHubProblem(pub), null); + + // X public again: no posts/ at all, no no-store block. + console.log = () => {}; + await main({ paths, settings: {} }); + console.log = log; + assert.ok(!existsSync(path.join(pub, "posts"))); + assert.ok(!readFileSync(path.join(pub, "_headers"), "utf8").includes("no-store")); + } finally { + console.log = log; + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts @@ -13,6 +13,12 @@ // public/_headers <- CORS for the hub's own served JSON // public/sw.js <- the hub service worker (the hub always ships a PWA) // public/search-aliases.json <- the global alias dictionary +// public/posts/ <- TOMBSTONES only, while X posts are private: +// an empty posts/manifest.json and, per X +// channel with a shared posts tree that a +// non-private site carries, its manifest at +// pageCount 0 and `[]` pages +// (publish/tombstones.ts), served no-store // // and REMOVES every per-site entry a site's compose left in public/ // (SITE_ONLY_PUBLIC_ENTRIES below): the hub holds no site's data. @@ -38,6 +44,14 @@ import { HUB_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers"; import { buildPoolSummary } from "../controller/poolSummary"; import { HUB_SUMMARY_FILE, toHubSummary } from "../lib/hubSummary"; import { readGlobalAliases } from "../lib/aliasesStore"; +import { getSettings } from "../lib/settings"; +import { + emptyPostsManifest, + tombstoneNoStoreForHub, + withdrawnXChannels, + writePostsTombstones, + type PostsTombstone, +} from "../publish/tombstones"; import { runIfEntryPoint } from "./_cli"; import { writePublicFile } from "./_publicFile"; @@ -110,13 +124,46 @@ export const SITE_ONLY_PUBLIC_ENTRIES: readonly string[] = [ "sitemap.xml", ]; -export async function main(opts: { paths?: Paths } = {}): Promise<void> { +// THE HUB'S TOMBSTONES (release 18). A hub built over a site's compose once +// shipped that site's posts (the review's HIGH 1 above), and Cloudflare's edge +// kept serving them after the deploy that removed them. Removing is not enough +// at the edge, so while X posts are private the hub REPLACES every path an X +// channel's posts could have been served from: an empty posts manifest, and per +// X channel with a shared posts tree that a non-private site carries, its +// manifest at pageCount 0 and an empty page for each page the shared tree holds +// now — all served no-store. A channel only private sites (or no site) carry is +// never named here: private data is never named on the hub. With X +// posts public, or no X channel, the hub ships no posts/ at all, as before. +async function composeHubTombstones( + paths: Paths, + publicDir: string, + settings: { social?: { x?: { visibility?: unknown } } }, +): Promise<PostsTombstone[]> { + const slugs = await withdrawnXChannels(paths, settings, listSites(paths)); + if (slugs.length === 0) return []; + const postsDir = path.join(publicDir, "posts"); + const tombstones = await writePostsTombstones({ + postsDir, + sharedPostsDir: paths.exportSharedPostsDir, + slugs, + }); + await writePublicFile( + path.join(postsDir, "manifest.json"), + JSON.stringify(emptyPostsManifest(new Date().toISOString())), + ); + return tombstones; +} + +export async function main( + opts: { paths?: Paths; settings?: { social?: { x?: { visibility?: unknown } } } } = {}, +): Promise<void> { const paths = opts.paths ?? getPaths(); const publicDir = paths.exportPublicDir; for (const entry of SITE_ONLY_PUBLIC_ENTRIES) { await rm(path.join(publicDir, entry), { recursive: true, force: true }); } + const tombstones = await composeHubTombstones(paths, publicDir, opts.settings ?? getSettings()); // The hub's own alias dictionary is the global one (no site's overrides): // what a hub reader loads for hub-wide search (lib/archive/reader-hub.ts), // where it used to get whichever site had composed last. @@ -181,7 +228,9 @@ export async function main(opts: { paths?: Paths } = {}): Promise<void> { await writePublicFile( path.join(publicDir, "_headers"), - renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS), + renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS, { + noStore: tombstoneNoStoreForHub(tombstones), + }), ); // The hub always ships a PWA. Copy the hub service worker into place. Until @@ -196,8 +245,12 @@ export async function main(opts: { paths?: Paths } = {}): Promise<void> { const summaryNote = await composeHubSummary(paths, publicDir); + const tombstoneNote = + tombstones.length > 0 + ? `; ${tombstones.length} withdrawn X channel(s) shipped as tombstones, served no-store` + : ""; console.log( - `compose-hub: ${builtins.length} built-in pool site(s) into ${publicDir}; ${summaryNote}.`, + `compose-hub: ${builtins.length} built-in pool site(s) into ${publicDir}; ${summaryNote}${tombstoneNote}.`, ); } diff --git a/common/bin/compose-site.postsVisibility.test.ts b/common/bin/compose-site.postsVisibility.test.ts @@ -9,6 +9,12 @@ // and says `"audience": "private"` with no hubUrl. Flipping the setting back // is a rebuild. The rule itself is lib/postsVisibility.ts (its own tests). // +// Release 18: the public site's withheld X channel is not merely left out — its +// posts paths ship TOMBSTONES (publish/tombstones.ts): posts/<x>/manifest.json +// at pageCount 0 and `[]` for every page the shared tree holds, served no-store +// by the site's _headers. A tombstone is not a posts tree: `postTrees` below +// lists real trees only, `tombstones` the rest. +// // The export e2e cannot show this: its data is route-mocked, never built by // buildIndex and compose. export/e2e/x-posts-private.spec.ts serves the two // posts manifests this file pins and checks what a visitor sees. @@ -17,7 +23,15 @@ import { after, test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -160,7 +174,10 @@ async function index() { // What one site's compose put in public/. type Composed = { postsManifest: { channels: { slug: string; platform: string; postCount: number }[]; totalCount: number }; + // Real posts trees (a channel manifest with pages), and tombstones (pageCount 0). postTrees: string[]; + tombstones: string[]; + headers: string; transcriptTrees: string[]; siteJson: { channels: { slug: string }[]; hubUrl?: string }; corpus: { @@ -181,9 +198,14 @@ async function compose(siteId: string): Promise<Composed> { const pub = paths.exportPublicDir; const trees = (dir: string) => [VIDEOS, X, SKY].filter((slug) => existsSync(path.join(dir, slug))); + const isTombstone = (slug: string) => + readJson<{ pageCount: number }>(path.join(pub, "posts", slug, "manifest.json")).pageCount === 0; + const posts = trees(path.join(pub, "posts")); return { postsManifest: readJson(path.join(pub, "posts", "manifest.json")), - postTrees: trees(path.join(pub, "posts")), + postTrees: posts.filter((slug) => !isTombstone(slug)), + tombstones: posts.filter(isTombstone), + headers: readFileSync(path.join(pub, "_headers"), "utf8"), transcriptTrees: trees(path.join(pub, "transcripts")), siteJson: readJson(path.join(pub, "site.json")), corpus: readJson(path.join(pub, "corpus.json")), @@ -212,6 +234,32 @@ test("X private: a public site carries no X channel; a private site carries all assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); assert.equal(pub.postsManifest.totalCount, 1); assert.deepEqual(pub.postTrees, [SKY]); + // The withheld X channel's paths ship tombstones (release 18), path for path: + // its manifest at pageCount 0 and an empty page for the shared tree's one page. + assert.deepEqual(pub.tombstones, [X]); + const xDir = path.join(paths.exportPublicDir, "posts", X); + const sharedPages = readJson<{ pageCount: number }>( + path.join(paths.exportSharedPostsDir, X, "manifest.json"), + ).pageCount; + assert.equal(sharedPages, 1); + assert.deepEqual(readdirSync(xDir).sort(), ["manifest.json", "page-0000.json"]); + assert.deepEqual(readJson(path.join(xDir, "page-0000.json")), []); + const tomb = readJson<{ channelSlug: string; pageCount: number; slugToPage: object }>( + path.join(xDir, "manifest.json"), + ); + assert.equal(tomb.channelSlug, X); + assert.equal(tomb.pageCount, 0); + assert.deepEqual(tomb.slugToPage, {}); + assert.ok(!readFileSync(path.join(xDir, "page-0000.json"), "utf8").includes("x post")); + // …served no-store, after the CORS lines, with no second CORS header. + assert.ok( + pub.headers.endsWith( + "# Withdrawn content (tombstones): never stored at the edge.\n" + + "/posts/manifest.json\n Cache-Control: no-store\n" + + `/posts/${X}/*\n Cache-Control: no-store\n`, + ), + pub.headers, + ); assert.deepEqual(pub.transcriptTrees, [VIDEOS, SKY]); assert.deepEqual(slugs(pub.siteJson.channels), [SKY, VIDEOS]); assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]); @@ -225,7 +273,11 @@ test("X private: a public site carries no X channel; a private site carries all const priv = await compose("priv"); assert.deepEqual(slugs(priv.postsManifest.channels), [SKY, X]); assert.equal(priv.postsManifest.totalCount, 3); + // The private site carries the real tree where the public one had the + // tombstone, and withholds nothing: no tombstone, no no-store block. assert.deepEqual(priv.postTrees, [X, SKY]); + assert.deepEqual(priv.tombstones, []); + assert.ok(!priv.headers.includes("no-store")); assert.deepEqual(slugs(priv.corpus.channels), [SKY, X, VIDEOS]); assert.equal(priv.corpus.channels.find((c) => c.slug === X)?.postCount, 2); assert.ok(priv.corpus.channels.find((c) => c.slug === X)?.manifests.posts); @@ -249,6 +301,9 @@ test("X private: a public site carries no X channel; a private site carries all // private site's channel list). const again = await compose("pub"); assert.deepEqual(again.postTrees, [SKY]); + // The private site's real X tree is REPLACED by the tombstone, never left. + assert.deepEqual(again.tombstones, [X]); + assert.deepEqual(readJson(path.join(paths.exportPublicDir, "posts", X, "page-0000.json")), []); assert.deepEqual(again.transcriptTrees, [VIDEOS, SKY]); assert.deepEqual(slugs(again.siteJson.channels), [SKY, VIDEOS]); assert.deepEqual(slugs(again.corpus.channels), [SKY, VIDEOS]); @@ -272,6 +327,9 @@ test("a config compose cannot read does not ship the posts tree the index build assert.deepEqual(pub.postTrees, [SKY]); assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]); + // compose reads X as visible here (no config): no tombstone for it either, + // and no no-store block — the index build's word kept the tree out. + assert.deepEqual(pub.tombstones, []); } finally { writeFileSync(cfg, saved); } @@ -284,6 +342,7 @@ test("a compose over an index built before the setting flipped lists no X channe writeSettings("private"); const pub = await compose("pub"); assert.deepEqual(pub.postTrees, [SKY]); + assert.deepEqual(pub.tombstones, [X]); assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); assert.equal(pub.postsManifest.totalCount, 1); assert.equal(pub.corpus.channels.find((c) => c.slug === X)?.postCount, undefined); @@ -298,7 +357,10 @@ test("X public again: the next build puts X back on the public site", async () = await index(); const pub = await compose("pub"); assert.deepEqual(slugs(pub.postsManifest.channels), [SKY, X]); + // The tombstone is replaced by the real tree, and the no-store block goes. assert.deepEqual(pub.postTrees, [X, SKY]); + assert.deepEqual(pub.tombstones, []); + assert.ok(!pub.headers.includes("no-store")); assert.deepEqual(slugs(pub.corpus.channels), [SKY, X, VIDEOS]); // No setting at all is public too. @@ -326,6 +388,23 @@ test("a public site whose only posts were X posts ships an empty posts manifest // toggle on this site, with nothing special-cased. assert.deepEqual(xonly.postsManifest.channels, []); assert.deepEqual(xonly.postTrees, []); + assert.deepEqual(xonly.tombstones, [X]); assert.equal(xonly.corpus.postScheme, undefined); assert.deepEqual(slugs(xonly.corpus.channels), [VIDEOS]); + + // With no posts manifest from the index at all, the one an earlier compose + // left in public/ (here: listing the Bluesky channel) is replaced by an + // empty one beside the tombstone. + rmSync(path.join(paths.exportSitesIndexDir, "xonly", "posts", "manifest.json")); + writeJson(path.join(paths.exportPostsDir, "manifest.json"), { + version: 1, + channels: [{ slug: SKY, name: SKY, postCount: 1, platform: "bluesky" }], + totalCount: 1, + generatedAt: "2026-01-01T00:00:00.000Z", + }); + const bare = await compose("xonly"); + assert.deepEqual(bare.postsManifest.channels, []); + assert.equal(bare.postsManifest.totalCount, 0); + assert.deepEqual(bare.tombstones, [X]); + assert.equal(bare.corpus.postScheme, undefined); }); diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts @@ -41,7 +41,7 @@ import type { PostsManifest } from "../lib/posts"; import type { DigestsManifest } from "../lib/digests"; import { buildSiteDescriptor, type PublicSiteDescriptor } from "../lib/siteDescriptor"; import { shipsPwa } from "../lib/archive/contract"; -import { renderHeadersFile } from "../lib/archive/headers"; +import { SITE_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers"; import { effectiveSiteAliases } from "../lib/aliasesStore"; import { effectiveSiteTags } from "../lib/curatedTagsStore"; import { TAGS_FILENAME } from "../lib/curatedTags"; @@ -78,6 +78,11 @@ import { reportRoutes, type ComposedReports, } from "../publish/composeReports"; +import { + emptyPostsManifest, + tombstoneNoStoreForSite, + writePostsTombstones, +} from "../publish/tombstones"; import { runIfEntryPoint } from "./_cli"; import { copyPublicFile, ownDir, writePublicFile } from "./_publicFile"; @@ -93,13 +98,17 @@ import { copyPublicFile, ownDir, writePublicFile } from "./_publicFile"; // Exported, with emitAiFiles, for the cited fixture site's e2e staging // (export/e2e-report/stage.ts), which writes a cited site's contract around // fixture report views exactly as this compose would. +// +// `noStore` names the paths _headers serves uncached: the tombstones of X +// channels this site withheld (publish/tombstones.ts). export async function emitFederationFiles( site: Site, paths: ReturnType<typeof getPaths>, + opts: { noStore?: readonly string[] } = {}, ): Promise<void> { await writePublicFile( path.join(paths.exportPublicDir, "_headers"), - renderHeadersFile("compose-site.ts"), + renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { noStore: opts.noStore }), ); // A cited site has no summaries: its descriptor names no channel, and it is @@ -950,6 +959,34 @@ export async function main( }), ); } + // --- tombstones for the X channels this site withheld (release 18) --- + // A withheld channel's posts were served from this site's paths before (or + // may still be cached at the edge from a build when X was public): every + // such path is REPLACED with an empty object of the same shape and served + // no-store, never left out (publish/tombstones.ts). The site posts manifest + // above already lists no withheld channel; a site with no posts manifest from + // the index (its only posts were X posts) ships an empty one — replacing + // whatever an earlier compose left at that path (another site's, listing its + // channels). + const memberSet = new Set(memberSlugs); + const tombstones = await writePostsTombstones({ + postsDir: paths.exportPostsDir, + sharedPostsDir: paths.exportSharedPostsDir, + slugs: site.channels.map((c) => c.slug).filter((slug) => !memberSet.has(slug)), + }); + if (tombstones.length > 0) { + if (!(await exists(postsManifestSrc))) { + await writePublicFile( + path.join(paths.exportPostsDir, "manifest.json"), + JSON.stringify(emptyPostsManifest(new Date().toISOString(), siteId)), + ); + } + console.log( + `[compose] posts: ${tombstones.length} withheld X channel(s) shipped as tombstones ` + + `(${tombstones.reduce((n, t) => n + t.pages, 0)} empty page(s)), served no-store.`, + ); + } + // Same for the per-site digests manifest (which channels carry digests). const digestsManifestSrc = path.join( paths.exportSitesIndexDir, @@ -1118,7 +1155,7 @@ export async function main( const composed = await composeReports({ paths, site, allowMissingMedia, log: console.log }); // --- federation contract: /site.json descriptor + CORS _headers --- - await emitFederationFiles(site, paths); + await emitFederationFiles(site, paths, { noStore: tombstoneNoStoreForSite(tombstones) }); // A site's bundle is not a hub's. export/public is shared with the hub build, // whose compose writes hub-sites.json; left in place it ships in this site's // out/ and makes the bundle ambiguous to builtHubProblem (and to a site's diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts @@ -69,6 +69,7 @@ function checkout(): { root: string; bin: string; paths: Paths } { parakeetCliBin: "parakeet-cli", configDir: path.join(root, ".config"), exportBuildsDir: path.join(root, "export", ".export-builds"), + exportIndexDir: path.join(root, "export", ".export-index"), 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 @@ -700,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"; @@ -809,3 +811,163 @@ test("source publish: source-repo — the variable naming nothing fails, a reada 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); }); + +// ── release 18, second half: the lock, the index stamp, the wrangler floor ─ + +function indexStampJson(stampId: string, builtAt: number) { + return { + v: 1, stampId, generation: 7, scannedAt: builtAt - 60_000, builtAt, templatesAt: builtAt, commit: null, + index: { shortCircuited: false, added: 1, changed: 0, removed: 0, heldChannels: [] }, + stats: { shortCircuited: false, notIndexedYet: 0, notIndexable: 0 }, + sites: {}, hubSig: "h", + }; +} + +function builtJson(target: string, indexStampId: string | null, bytes: number) { + return { + v: 1, stampId: `b-${target}`, target, kind: target === "_hub" ? "hub" : "site", indexStampId, inputSig: "s", + builtAt: Date.UTC(2026, 9, 6, 10), commit: null, branch: null, runner: "local", audience: "public", + corpusGeneratedAt: null, files: 3, bytes, archivesStaged: 0, + }; +} + +test("publish: publish-lock — free is ok, a running holder a note, a dead one stale with the rm, another host's named and never stale", async () => { + const c = checkout(); + const h = home(c); + const builds = c.paths.exportBuildsDir; + mkdirSync(builds, { recursive: true }); + const file = path.join(builds, ".publish.lock"); + let r = await run(c, { HOME: h }); + assert.equal(find(r, "publish", "publish-lock")?.status, "ok"); + const { pidStartOf } = await import("../publish/stageLock"); + const hold = (holder: Record<string, unknown>) => writeFileSync(file, JSON.stringify(holder)); + // This process holds it: alive, same host. + hold({ pid: process.pid, host: "doctor-host", kind: "build-site", target: "alpha", since: Date.now() - 5_000, pidStart: pidStartOf(process.pid) }); + r = await run(c, { HOME: h, ARCHILYZER_HOST_ID: "doctor-host" }); + assert.equal(find(r, "publish", "publish-lock")?.status, "info"); + assert.match(find(r, "publish", "publish-lock")!.detail, /^held: build-site alpha \(pid \d+ on doctor-host/); + // A pid that is not running, same host: stale. + hold({ pid: 2 ** 22 - 3, host: "doctor-host", kind: "deploy-site", target: "alpha", since: Date.now() - 60_000 }); + r = await run(c, { HOME: h, ARCHILYZER_HOST_ID: "doctor-host" }); + assert.equal(find(r, "publish", "publish-lock")?.status, "warn"); + assert.match(find(r, "publish", "publish-lock")!.detail, new RegExp(`^stale: deploy-site alpha .* the next stage takes it over, or clear it, when nothing is publishing: rm ${file.replace(/[.]/g, "\\.")}$`)); + // The same dead pid on ANOTHER host: named, never judged. + r = await run(c, { HOME: h, ARCHILYZER_HOST_ID: "this-host" }); + assert.equal(find(r, "publish", "publish-lock")?.status, "warn"); + assert.match(find(r, "publish", "publish-lock")!.detail, /^held by ANOTHER host: deploy-site alpha \(pid \d+ on doctor-host.*this host is "this-host"/); + // A lock that does not parse: being written, then (past the grace) torn. + writeFileSync(file, "{"); + r = await run(c, { HOME: h }); + assert.equal(find(r, "publish", "publish-lock")?.status, "info"); + const before = tree(c.root); + r = await run(c, { HOME: h }, { now: new Date(Date.now() + 10 * 60_000) }); + assert.equal(find(r, "publish", "publish-lock")?.status, "warn"); + assert.match(find(r, "publish", "publish-lock")!.detail, /does not parse.*a taker died writing it/); + assert.equal(r.ok, true, "a warning, never a failure"); + assert.deepEqual(tree(c.root), before, "the doctor never clears a lock"); +}); + +test("publish: index-stamp — none is a note (a warning once something is built); its age; targets built from an older stamp; export-builds counts built.json bytes", async () => { + const c = checkout(); + const h = home(c); + const builds = c.paths.exportBuildsDir; + let r = await run(c, { HOME: h }); + assert.equal(find(r, "publish", "index-stamp")?.status, "info"); + assert.match(find(r, "publish", "index-stamp")!.detail, /no index stamp at .*stamp\.json — update the index first: archilyzer publish index$/); + // Built bundles with no stamp: a warning. + for (const [target, id, bytes] of [["alpha", "old-stamp", 7_000_000], ["_hub", "cur-stamp", 2_000_000]] as const) { + mkdirSync(path.join(builds, target, "out"), { recursive: true }); + writeFileSync(path.join(builds, target, "out", "index.html"), "x"); + writeFileSync(path.join(builds, target, "built.json"), JSON.stringify(builtJson(target, id, bytes))); + } + r = await run(c, { HOME: h }, { freeBytes: () => 5e9 }); + assert.equal(find(r, "publish", "index-stamp")?.status, "warn"); + // export-builds sums the stamps' bytes, not the one-byte files on disk. + assert.match(find(r, "publish", "export-builds")!.detail, /: 2 bundles, 9 MB; 5\.00 GB free$/); + // A stamp: alpha was built from an older one. + const now = Date.UTC(2026, 9, 6, 12); + mkdirSync(c.paths.exportIndexDir, { recursive: true }); + writeFileSync(path.join(c.paths.exportIndexDir, "stamp.json"), JSON.stringify(indexStampJson("cur-stamp", now - 3 * 3_600_000))); + const before = tree(c.root); + r = await run(c, { HOME: h }, { now: new Date(now) }); + assert.equal(find(r, "publish", "index-stamp")?.status, "warn"); + assert.match(find(r, "publish", "index-stamp")!.detail, /^cur-stamp, built 2026-10-06 09:00 \(3 hours ago\), generation 7; built from an older stamp: alpha — archilyzer publish build <id> rebuilds each$/); + // Everything from the current stamp: ok. + writeFileSync(path.join(builds, "alpha", "built.json"), JSON.stringify(builtJson("alpha", "cur-stamp", 7_000_000))); + const before2 = tree(c.root); + r = await run(c, { HOME: h }, { now: new Date(now) }); + assert.equal(find(r, "publish", "index-stamp")?.status, "ok"); + assert.match(find(r, "publish", "index-stamp")!.detail, /generation 7; 2 bundles built from it$/); + assert.equal(r.ok, true); + assert.notDeepEqual(before, before2); + assert.deepEqual(tree(c.root), before2); +}); + +test("workspace: node is graded against the pinned wrangler's engines floor when wrangler is installed — a warning below it, never a failure", async () => { + const c = checkout(); + // No wrangler installed: next's floor only. + let r = await run(c, {}, { nodeVersion: "20.11.0" }); + assert.equal(find(r, "workspace", "node")?.status, "ok"); + assert.equal(find(r, "workspace", "node")!.detail, "v20.11.0 (needs >= 20.9.0)"); + const pkg = path.join(c.root, "common", "node_modules", "wrangler"); + mkdirSync(pkg, { recursive: true }); + writeFileSync(path.join(pkg, "package.json"), JSON.stringify({ name: "wrangler", version: "4.147.0", engines: { node: ">=22.0.0" } })); + const before = tree(c.root); + r = await run(c, {}, { nodeVersion: "20.11.0" }); + assert.equal(find(r, "workspace", "node")?.status, "warn"); + assert.match(find(r, "workspace", "node")!.detail, /^v20\.11\.0 — runs the apps .* the pinned wrangler 4\.147\.0 needs node >=22\.0\.0: every deploy refuses; use Node 22$/); + assert.equal(r.ok, true); + r = await run(c, {}, { nodeVersion: "22.23.3" }); + assert.equal(find(r, "workspace", "node")?.status, "ok"); + assert.equal(find(r, "workspace", "node")!.detail, "v22.23.3 (needs >= 20.9.0; deploys: >= 22.0.0, wrangler 4.147.0)"); + r = await run(c, {}, { nodeVersion: "18.20.0" }); + 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 @@ -9,12 +9,15 @@ // 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. +// for the bundles, the publish lock (free, held, stale — never cleared here), +// the index stamp and what was built from an older one, the repository the +// source mirror reads, and node against the pinned wrangler's floor. // // 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. @@ -28,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"; @@ -134,9 +137,21 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor // ── workspace ──────────────────────────────────────────────────────────── const W = "workspace"; const nodeV = deps.nodeVersion ?? process.versions.node; - add(W, "node", versionAtLeast(nodeV, MIN_NODE) ? "ok" : "fail", - `v${nodeV} (needs >= ${MIN_NODE.join(".")})`); const root = paths.monorepoRoot; + // Two floors: next's (a failure — nothing runs below it) and the pinned + // wrangler's engines (a warning — every deploy refuses below it; wrangler 4 + // wants 22). The wrangler floor is read from its package.json when it is + // installed, as the image's drift test reads it. + const wranglerFloor = wranglerNodeFloor(root); + if (!versionAtLeast(nodeV, MIN_NODE)) { + add(W, "node", "fail", `v${nodeV} (needs >= ${MIN_NODE.join(".")})`); + } else if (wranglerFloor && !versionAtLeast(nodeV, wranglerFloor.min)) { + add(W, "node", "warn", + `v${nodeV} — runs the apps (>= ${MIN_NODE.join(".")}), but the pinned wrangler ${wranglerFloor.version} needs node ${wranglerFloor.range}: every deploy refuses; use Node ${wranglerFloor.min[0]}`); + } else { + add(W, "node", "ok", + `v${nodeV} (needs >= ${MIN_NODE.join(".")}${wranglerFloor ? `; deploys: >= ${wranglerFloor.min.join(".")}, wrangler ${wranglerFloor.version}` : ""})`); + } if (existsSync(path.join(root, "pnpm-workspace.yaml"))) { add(W, "checkout", "ok", root); } else { @@ -478,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)); @@ -509,7 +558,7 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor } 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 bundles = await bundleBytes(paths); 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) { @@ -520,6 +569,63 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor } } } + + // The publish lock (common/publish/stageLock.ts): free, held by a stage + // that is running, or left by one that is gone — judged by the lock's own + // rule (holderIsGone), never removed here. + if (builds) { + const lock = await import("../publish/stageLock"); + const file = lock.publishLockPath(paths); + const st = statOrNull(file); + const clear = `clear it, when nothing is publishing: rm ${file}`; + if (!st) { + add(PB, "publish-lock", "ok", "free — no stage is publishing"); + } else { + const holder = lock.parseLockHolder(readOrNull(file) ?? ""); + const host = lock.lockHostId(env); + const nowMs = (deps.now ?? new Date()).getTime(); + if (!holder) { + const torn = nowMs - st.mtime.getTime() > lock.LOCK_TORN_GRACE_MS; + add(PB, "publish-lock", torn ? "warn" : "info", + torn + ? `${file} does not parse, and has not for ${ago(st.mtime, new Date(nowMs)).replace(/ ago$/, "")} — a taker died writing it; the next stage takes it over, or ${clear}` + : `${file} is being written — a stage is taking the lock`); + } else if (holder.host !== host) { + add(PB, "publish-lock", "warn", + `held by ANOTHER host: ${lock.describeHolder(holder)} — never taken over from here (this host is "${host}"); if that host is gone, ${clear}`); + } else if (lock.holderIsGone(holder, { host })) { + add(PB, "publish-lock", "warn", + `stale: ${lock.describeHolder(holder)} — its process is gone; the next stage takes it over, or ${clear}`); + } else { + add(PB, "publish-lock", "info", `held: ${lock.describeHolder(holder)} — a stage is running`); + } + } + } + + // The index stamp (common/publish/stamps.ts): how old, and which built + // targets came from an older one (they rebuild on their next build). + if (paths.exportIndexDir) { + const stamps = await import("../publish/stamps"); + const idx = await stamps.readIndexStamp(paths); + const built = builds ? await builtStamps(paths) : []; + const now = deps.now ?? new Date(); + if (!idx) { + const something = built.length > 0 || (await configuredSiteIds(paths)).length > 0; + add(PB, "index-stamp", something ? "warn" : "info", + `no index stamp at ${stamps.indexStampPath(paths)} — update the index first: archilyzer publish index`); + } else { + const at = new Date(idx.builtAt); + const head = `${idx.stampId}, built ${stamp(at)} (${ago(at, now)}), generation ${idx.generation}`; + const older = built.filter((b) => b.indexStampId !== idx.stampId).map((b) => b.target); + if (older.length > 0) { + add(PB, "index-stamp", "warn", + `${head}; built from an older stamp: ${older.join(", ")} — archilyzer publish build <id> rebuilds each`); + } else { + add(PB, "index-stamp", "ok", + `${head}${built.length > 0 ? `; ${built.length} bundle${built.length === 1 ? "" : "s"} built from it` : "; nothing built yet"}`); + } + } + } } // ── source publish ─────────────────────────────────────────────────────── @@ -983,9 +1089,12 @@ function statfsFree(dir: string): number | 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 }> { +// Every `<builds>/<target>/out` bundle and its bytes: the `bytes` its +// built.json recorded (common/publish/stamps.ts), else — a bundle no stamp +// describes, from before the stages — a stat walk (links not followed). A +// bundle's own size is what a rebuild writes again beside it. +async function bundleBytes(paths: Paths): Promise<{ count: number; bytes: number }> { + const { readBuiltStamp } = await import("../publish/stamps"); let count = 0; let bytes = 0; const walk = async (d: string): Promise<void> => { @@ -995,16 +1104,61 @@ async function bundleBytes(buildsDir: string): Promise<{ count: number; bytes: n 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"); + for (const ent of await readdir(paths.exportBuildsDir, { withFileTypes: true }).catch(() => [])) { + if (!ent.isDirectory() || ent.name.startsWith(".")) continue; + const out = path.join(paths.exportBuildsDir, ent.name, "out"); if (!statOrNull(out)?.isDirectory()) continue; count += 1; - await walk(out); + const built = await readBuiltStamp(paths, ent.name); + if (built) bytes += built.bytes; + else await walk(out); } return { count, bytes }; } +// Every built.json under the builds dir (sites, `_hub`, `_homepage`), by +// target name. Read-only; an unreadable stamp is skipped. +async function builtStamps(paths: Paths): Promise<{ target: string; indexStampId: string | null }[]> { + const { readBuiltStamp } = await import("../publish/stamps"); + const out: { target: string; indexStampId: string | null }[] = []; + for (const ent of await readdir(paths.exportBuildsDir, { withFileTypes: true }).catch(() => [])) { + if (!ent.isDirectory() || ent.name.startsWith(".")) continue; + const built = await readBuiltStamp(paths, ent.name); + if (built) out.push({ target: built.target, indexStampId: built.indexStampId }); + } + return out.sort((x, y) => x.target.localeCompare(y.target)); +} + +// The configured site ids (a sites/<id>/site.json), `_`-dirs excluded. +async function configuredSiteIds(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("_") && existsSync(path.join(paths.sitesDir, e.name, "site.json"))) out.push(e.name); + } + return out.sort(); +} + +// The pinned wrangler's Node floor, from its package.json `engines.node` +// (">=22.0.0"), when wrangler is installed in common/node_modules; else null. +function wranglerNodeFloor(root: string): { version: string; range: string; min: [number, number, number] } | null { + const text = readOrNull(path.join(root, "common", "node_modules", "wrangler", "package.json")); + if (text === null) return null; + try { + const pkg = JSON.parse(text) as { version?: unknown; engines?: { node?: unknown } }; + const range = typeof pkg.engines?.node === "string" ? pkg.engines.node : ""; + const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(range); + if (!m) return null; + return { + version: typeof pkg.version === "string" ? pkg.version : "?", + range, + min: [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)], + }; + } catch { + return null; + } +} + // 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. @@ -1025,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/archive/headers.test.ts b/common/lib/archive/headers.test.ts @@ -77,6 +77,63 @@ test("renderHeadersFile: the hub block, in full", () => { assert.equal(renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS), HUB_HEADERS); }); +// The tombstone blocks (release 18): withdrawn X posts are served no-store. A +// site's /posts/* CORS rule already covers them, so its no-store rules carry no +// second CORS header (a repeated header is APPENDED: "*, *"); the hub lists no +// posts tree, so its /posts/* rule carries both. +const SITE_TOMBSTONE_BLOCK = `# Withdrawn content (tombstones): never stored at the edge. +/posts/manifest.json + Cache-Control: no-store +/posts/jer-x/* + Cache-Control: no-store +`; + +const HUB_TOMBSTONE_BLOCK = `# Withdrawn content (tombstones): never stored at the edge. +/posts/* + Cache-Control: no-store + Access-Control-Allow-Origin: * +`; + +test("renderHeadersFile: a site's no-store block follows its CORS lines, with no second CORS header", () => { + assert.equal( + renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { + noStore: ["/posts/manifest.json", "/posts/jer-x/*"], + }), + SITE_HEADERS + SITE_TOMBSTONE_BLOCK, + ); + // No paths, no block: the file is byte-identical to the one without opts. + assert.equal(renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { noStore: [] }), SITE_HEADERS); +}); + +test("renderHeadersFile: the hub's /posts/* no-store rule carries its own CORS", () => { + assert.equal( + renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS, { noStore: ["/posts/*"] }), + HUB_HEADERS + HUB_TOMBSTONE_BLOCK, + ); +}); + +test("no rendered file sets a header twice for one path", () => { + // Every pair of rules that both match a path must not both set the same + // header: wrangler appends the second value. + const files = [ + renderHeadersFile("s", SITE_CORS_PATHS, { noStore: ["/posts/manifest.json", "/posts/a/*"] }), + renderHeadersFile("h", HUB_CORS_PATHS, { noStore: ["/posts/*"] }), + ]; + for (const file of files) { + const rules: { path: string; headers: string[] }[] = []; + for (const line of file.split("\n")) { + if (line.startsWith("/")) rules.push({ path: line, headers: [] }); + else if (line.startsWith(" ")) rules[rules.length - 1].headers.push(line.trim().split(":")[0]); + } + const covers = (rule: string, p: string) => + rule.endsWith("*") ? p.startsWith(rule.slice(0, -1)) : rule === p; + for (const probe of ["/posts/manifest.json", "/posts/a/page-0000.json", "/corpus.json"]) { + const set = rules.filter((r) => covers(r.path, probe)).flatMap((r) => r.headers); + assert.equal(new Set(set).size, set.length, `${probe}: ${set.join(", ")}`); + } + } +}); + test("the two paths that used to be served without CORS are declared", () => { // The wire change. A cross-origin viewer could read every other tree and got // a CORS failure on exactly these two. diff --git a/common/lib/archive/headers.ts b/common/lib/archive/headers.ts @@ -71,17 +71,49 @@ export const HUB_CORS_PATHS: readonly string[] = [ "/robots.txt", ]; +// What a WITHDRAWN path is served with (release 18): never stored at the edge. +// Cloudflare Pages keeps serving a cached object after a deploy that changed or +// removed it (the hub served a withdrawn posts shard from a 7-day edge cache), so +// the tombstones that replace withdrawn content — and the manifests that list it +// — are marked uncacheable. publish/tombstones.ts names the paths. +const NO_STORE_HEADER = "Cache-Control: no-store"; + +// Whether a `_headers` path rule (a literal path, or one ending in a `*` splat) +// matches `p`, itself a literal path or a splat rule. A splat rule covers every +// path under its prefix. +function ruleCovers(rule: string, p: string): boolean { + if (rule.endsWith("*")) return p.startsWith(rule.slice(0, -1)); + return rule === p; +} + // Render a `_headers` file: a banner naming the generator, then one path / // indented-header pair per served surface. `generator` is the script name so a // reader of a deploy artifact knows what to edit instead of the file. +// +// `noStore` adds, after the CORS lines, one rule per path marked +// `Cache-Control: no-store`. It carries the CORS header too — but only where no +// CORS rule above already covers the path: every matching rule applies, and a +// header a later rule sets again is APPENDED (wrangler's attachHeaders), so a +// second CORS line would serve `Access-Control-Allow-Origin: *, *`, which no +// browser accepts. A site's `/posts/*` CORS rule covers its posts tombstones; the +// hub, which lists no posts tree, gets its CORS from the no-store rule itself. export function renderHeadersFile( generator: string, paths: readonly string[] = SITE_CORS_PATHS, + opts: { noStore?: readonly string[] } = {}, ): string { const out = [`# Generated by ${generator} — do not edit by hand.`]; for (const p of paths) { out.push(p, ` ${CORS_HEADER}`); } + const noStore = opts.noStore ?? []; + if (noStore.length > 0) { + out.push("# Withdrawn content (tombstones): never stored at the edge."); + for (const p of noStore) { + out.push(p, ` ${NO_STORE_HEADER}`); + if (!paths.some((rule) => ruleCovers(rule, p))) out.push(` ${CORS_HEADER}`); + } + } return `${out.join("\n")}\n`; } diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -18,6 +18,8 @@ import { PUBLISH_MAX_FILE_BYTES, publishFileSizeProblem, reportHistoryProblem, + isTombstonePostsTree, + hubReportDataIn, } from "./builtExport"; function tempOut(siteJson?: string): { dir: string; cleanup: () => void } { @@ -125,6 +127,28 @@ test("builtHubProblem accepts only a hub bundle", () => { "export/out holds no hub build — build the hub first", ); + // Release 18: a posts/ of TOMBSTONES only is the hub's own (compose-hub + // writes them for withdrawn X posts) — and one real post in it is not. + mkdirSync(path.join(hub.dir, "posts", "jer-x"), { recursive: true }); + writeFileSync(path.join(hub.dir, "posts", "manifest.json"), '{"channels":[],"totalCount":0}'); + writeFileSync( + path.join(hub.dir, "posts", "jer-x", "manifest.json"), + '{"channelSlug":"jer-x","pageCount":0,"slugToPage":{}}', + ); + writeFileSync(path.join(hub.dir, "posts", "jer-x", "page-0000.json"), "[]"); + assert.equal(isTombstonePostsTree(path.join(hub.dir, "posts")), true); + assert.equal(builtHubProblem(hub.dir), null); + writeFileSync(path.join(hub.dir, "posts", "jer-x", "page-0000.json"), '[{"id":"1"}]'); + assert.equal(isTombstonePostsTree(path.join(hub.dir, "posts")), false); + assert.equal( + builtHubProblem(hub.dir), + "export/out holds a hub build that still carries a site's data (posts) — build the hub again", + ); + writeFileSync(path.join(hub.dir, "posts", "jer-x", "page-0000.json"), "[]"); + writeFileSync(path.join(hub.dir, "posts", "manifest.json"), '{"channels":[{"slug":"jer-x"}]}'); + assert.equal(isTombstonePostsTree(path.join(hub.dir, "posts")), false); + rmSync(path.join(hub.dir, "posts"), { recursive: true }); + // Release 17 XP: a hub bundle composed over a site's data is refused. mkdirSync(path.join(hub.dir, "posts", "jer-x"), { recursive: true }); mkdirSync(path.join(hub.dir, "summaries")); @@ -139,6 +163,70 @@ test("builtHubProblem accepts only a hub bundle", () => { } }); +// Found on main 2026-10-05: the export app renders its report and moment +// routes into EVERY build — `reports/index.html`, the `_none` placeholders — +// so a hub bundle always has `reports/` and `m/`, and listing them as site +// data refused every hub. What is refused now is the report DATA a site's +// compose writes there. +test("builtHubProblem: the shell's reports/ and m/ pages pass; report data, moment data and a real post do not", () => { + const hub = tempOut(); + try { + writeFileSync(path.join(hub.dir, "hub-sites.json"), "[]"); + const put = (rel: string, body = "x") => { + mkdirSync(path.dirname(path.join(hub.dir, rel)), { recursive: true }); + writeFileSync(path.join(hub.dir, rel), body); + }; + // What `next build` renders for the export app's own routes. + for (const rel of [ + "reports/index.html", + "reports/index.txt", + "reports/_none/index.html", + "reports/_none/history/index.html", + "m/_none/index.html", + "m/_none/index.txt", + ]) { + put(rel); + } + assert.equal(hubReportDataIn(hub.dir).length, 0); + assert.equal(builtHubProblem(hub.dir), null); + + const refusedFor = (rel: string, body?: string) => { + put(rel, body); + const problem = builtHubProblem(hub.dir); + rmSync(path.join(hub.dir, rel)); + return problem; + }; + for (const rel of [ + "reports/index.json", + "reports/r1/page.json", + "reports/r1/citations.json", + "reports/r1/citations.csv", + "reports/r1/evidence-pack.zip", + "reports/r1/history/history.json", + "reports/r1/history/repo/HEAD", + "m/index.json", + "m/jer/v1/0-10/moment.json", + ]) { + const problem = refusedFor(rel); + assert.match(problem ?? "", /still carries a site's data/, rel); + assert.ok(problem!.includes(rel.endsWith("/HEAD") ? "reports/r1/history/repo/" : rel), `${rel}: ${problem}`); + } + // (The history clone is a directory: removing its HEAD leaves repo/.) + rmSync(path.join(hub.dir, "reports", "r1"), { recursive: true }); + assert.equal(builtHubProblem(hub.dir), null, "nothing left behind"); + + // A tombstone-only posts/ is the hub's own; one real post is a site's. + put("posts/manifest.json", '{"channels":[]}'); + put("posts/jer-x/manifest.json", '{"pageCount":0,"slugToPage":{}}'); + put("posts/jer-x/page-0000.json", "[]"); + assert.equal(builtHubProblem(hub.dir), null); + put("posts/jer-x/page-0000.json", '[{"id":"1","text":"a post"}]'); + assert.match(builtHubProblem(hub.dir) ?? "", /still carries a site's data \(posts\)/); + } finally { + hub.cleanup(); + } +}); + // The homepage package builds into its own homepage/out, which nothing else // writes, so "built" is one question: is there an index.html? The deploy-only // action refuses before any job on exactly the file deployHomepage checks diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -17,6 +17,14 @@ import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "no import path from "node:path"; import { isCitedSite } from "./siteSchema"; import { REPORT_HISTORY_REPO_FILE_RE } from "./report/revisions"; +import { + MOMENTS_INDEX_PATH, + REPORTS_INDEX_PATH, + REPORT_EXPORT_FILENAMES, + momentViewPath, + reportCitationsDownloadPath, + reportViewPath, +} from "./report/views"; /** * The site id of the build sitting in `outDir`, or null when there is no @@ -421,8 +429,19 @@ export function builtHubProblem(outDir: string): string | null { } // The hub holds no site's data (compose-hub removes it): a hub bundle that // still carries a site's data trees was composed over one, and could ship - // that site's posts — a private site's included. - const carried = HUB_FORBIDDEN_TREES.filter((tree) => existsSync(path.join(outDir, tree))); + // that site's posts — a private site's included. Its one posts tree is the + // tombstones compose-hub writes for withdrawn X posts (release 18), and a + // tree holding anything but tombstones is a site's. + const carried: string[] = HUB_FORBIDDEN_TREES.filter( + (tree) => + existsSync(path.join(outDir, tree)) && + !(tree === "posts" && isTombstonePostsTree(path.join(outDir, tree))), + ); + // reports/ and m/ are the export app's own routes, rendered into EVERY + // build (the hub's included: /reports/, /reports/_none/, /m/_none/) — their + // shell pages are not data. What a site's reports stage writes is. + const reportData = hubReportDataIn(outDir); + if (reportData.length > 0) carried.push(...reportData); if (carried.length > 0) { return ( `export/out holds a hub build that still carries a site's data (${carried.join(", ")}) — ` + @@ -432,8 +451,62 @@ export function builtHubProblem(outDir: string): string | null { return null; } +/** + * Whether `postsDir` (a bundle's `posts/`) holds TOMBSTONES and nothing else + * (publish/tombstones.ts): a site posts manifest listing no channel, and per + * channel dir a posts manifest at pageCount 0 and only `[]` pages. Anything + * else — a post, a listed channel, an unreadable file, a stray entry — is not. + */ +export function isTombstonePostsTree(postsDir: string): boolean { + const readJson = (file: string): unknown => { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return undefined; + } + }; + const manifest = readJson(path.join(postsDir, "manifest.json")) as + | { channels?: unknown } + | undefined; + if (!manifest || !Array.isArray(manifest.channels) || manifest.channels.length > 0) return false; + let entries: Dirent[]; + try { + entries = readdirSync(postsDir, { withFileTypes: true }); + } catch { + return false; + } + for (const e of entries) { + if (e.name === "manifest.json" && e.isFile()) continue; + if (!e.isDirectory()) return false; + const dir = path.join(postsDir, e.name); + const channel = readJson(path.join(dir, "manifest.json")) as + | { pageCount?: unknown; slugToPage?: unknown } + | undefined; + if (!channel || channel.pageCount !== 0) return false; + if (channel.slugToPage && Object.keys(channel.slugToPage as object).length > 0) return false; + let files: Dirent[]; + try { + files = readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const f of files) { + if (f.name === "manifest.json") continue; + if (!f.isFile() || !/^page-\d+\.json$/.test(f.name)) return false; + const page = readJson(path.join(dir, f.name)); + if (!Array.isArray(page) || page.length > 0) return false; + } + } + return true; +} + // The per-site data trees a hub bundle must never carry (the trees of -// compose-hub's SITE_ONLY_PUBLIC_ENTRIES). +// compose-hub's SITE_ONLY_PUBLIC_ENTRIES). A `posts/` of tombstones only is +// the hub's own (isTombstonePostsTree). `reports/` and `m/` are NOT here: the +// export app renders its report and moment routes into every build, the hub's +// too (`reports/index.html`, the `_none` placeholders), so their presence says +// nothing — hubReportDataIn looks for the report DATA inside them instead. +// (Listing them refused every hub bundle from 2026-10-05.) const HUB_FORBIDDEN_TREES = [ "summaries", "transcripts", @@ -442,11 +515,61 @@ const HUB_FORBIDDEN_TREES = [ "digests", "stats", "archives", - "reports", - "m", "media", ]; +// The files only a site's reports stage writes (lib/report/views.ts names +// them; publish/composeReports.ts writes them), by name within reports/<id>/ +// and m/…: the report index, each report's page view, its citations, its +// exports and its revision history; the moment index and each moment view. +const REPORT_DATA_FILES = new Set<string>([ + path.posix.basename(reportViewPath("x")), + path.posix.basename(reportCitationsDownloadPath("x", "json")), + path.posix.basename(reportCitationsDownloadPath("x", "csv")), + ...Object.values(REPORT_EXPORT_FILENAMES), + // reportHistory.ts: `history/history.json` beside a report's page. + "history.json", +]); +const MOMENT_DATA_FILE = path.posix.basename(momentViewPath("x")); + +/** + * The report and moment DATA in a bundle's `reports/` and `m/` (root-relative + * paths, at most a few): what a site's compose writes there, never what the + * export app's own route pages are. Empty for a hub bundle, which carries the + * shell pages only. + */ +export function hubReportDataIn(outDir: string, limit = 5): string[] { + const found: string[] = []; + for (const index of [REPORTS_INDEX_PATH, MOMENTS_INDEX_PATH]) { + if (existsSync(path.join(outDir, index))) found.push(index.replace(/^\//, "")); + } + const walk = (rel: string, isData: (name: string, depth: number) => boolean, depth: number) => { + let entries: Dirent[]; + try { + entries = readdirSync(path.join(outDir, rel), { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (found.length >= limit) return; + const child = `${rel}/${e.name}`; + if (e.isDirectory()) { + // A report's revision history clone (history/repo/) is data whatever it holds. + if (rel.startsWith("reports/") && e.name === "repo" && rel.endsWith("/history")) { + found.push(`${child}/`); + continue; + } + walk(child, isData, depth + 1); + } else if (isData(e.name, depth)) { + found.push(child); + } + } + }; + walk("reports", (name, depth) => depth >= 1 && REPORT_DATA_FILES.has(name), 0); + walk("m", (name) => name === MOMENT_DATA_FILE, 0); + return found.slice(0, limit); +} + /** * Why `outDir` — the homepage package's `homepage/out` — may not be deployed as * the homepage, as one sentence, or null when it holds a build. diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts @@ -96,12 +96,12 @@ const DECLARED: EnvVarDecl[] = [ { name: "R2_ACCESS_KEY_ID", audience: "runtime", default: "—", readBy: "common/publish/build.ts, common/bin/doctor.ts (set or not)", doc: "R2 S3 credentials for uploading oversize archives at deploy time (with `R2_SECRET_ACCESS_KEY` and `CLOUDFLARE_ACCOUNT_ID`), needed only when `archiveStorage.bucket` is set. In Docker they come from `.env`. See [PUBLISH.md](PUBLISH.md)." }, { name: "R2_SECRET_ACCESS_KEY", audience: "runtime", default: "—", readBy: "common/publish/build.ts, common/bin/doctor.ts (set or not)", doc: "See `R2_ACCESS_KEY_ID`." }, { name: "CLOUDFLARE_ACCOUNT_ID", audience: "runtime", default: "—", readBy: "common/publish/build.ts, wrangler, common/bin/doctor.ts (set or not)", doc: "The Cloudflare account: the R2 endpoint's, and the one wrangler deploys to when the token can see more than one. In Docker it comes from `.env`." }, - { name: "CLOUDFLARE_API_TOKEN", audience: "runtime", default: "unset (wrangler's own `wrangler login` config, on a host)", readBy: "wrangler (every deploy), common/bin/doctor.ts (set or not, never the value)", doc: "The API token every deploy's wrangler authenticates with (Cloudflare Pages: Edit). The way a container deploys — there is no browser for `wrangler login` in one; set it in `.env`." }, + { name: "CLOUDFLARE_API_TOKEN", audience: "runtime", default: "unset (wrangler's own `wrangler login` config, on a host)", readBy: "wrangler (every deploy), common/bin/doctor.ts, common/lib/pagesDeploy.ts (set or not, never the value)", doc: "The API token every deploy's wrangler authenticates with (Cloudflare Pages: Edit). The way a container deploys — there is no browser for `wrangler login` in one; set it in `.env`." }, { name: "ARCHILYZER_HOST_ID", audience: "runtime", default: "the hostname", readBy: "common/publish/stageLock.ts (the publish lock)", doc: "Which host the publish lock (`<EXPORT_BUILDS_DIR>/.publish.lock`) names as its holder's: a lock from this host whose pid is dead is stale and taken over; another host's is waited on. docker-compose.yml fixes it for the editor (`archilyzer-editor`), whose hostname is a container id that changes on every recreate." }, { 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: "XDG_CONFIG_HOME", audience: "runtime", default: "`~/.config`", readBy: "common/bin/doctor.ts, common/lib/pagesDeploy.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`)." }, @@ -109,6 +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, 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." }, @@ -159,11 +160,11 @@ const DECLARED: EnvVarDecl[] = [ { name: "ARCHILYZER_FETCH_MODEL", audience: "docker", default: "per transcriber", readBy: "docker/entrypoint.sh", doc: "Which model the first boot downloads; `none` skips it." }, { name: "ARCHILYZER_MODELS_DIR", audience: "docker", default: "`/data/models`", readBy: "docker/entrypoint.sh", doc: "Where models live in the container." }, { name: "ARCHILYZER_BUILDS_DIR", audience: "docker", default: "`/data/builds`", readBy: "docker/entrypoint.sh", doc: "Where the container keeps built sites." }, - { name: "ARCHILYZER_SITE_OUT", audience: "docker", default: "`/data/builds/site`", readBy: "docker/entrypoint.sh, docker/publish-site.sh", doc: "The built export site the `site` service serves." }, - { name: "ARCHILYZER_HOMEPAGE_OUT", audience: "docker", default: "`/data/builds/homepage`", readBy: "docker/entrypoint.sh", 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_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." }, @@ -197,6 +198,8 @@ const DECLARED: EnvVarDecl[] = [ { name: "E2E_FAKE_YTDLP_DETERMINISTIC_CORRUPT", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: corrupt deterministically." }, { name: "E2E_FAKE_YTDLP_RECOVER_ON_RESUME", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: a resumed run recovers." }, { name: "E2E_FAKE_YTDLP_TOTAL_CHUNKS", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-ytdlp.mjs", doc: "Fake yt-dlp: how many chunks a download has." }, + { name: "E2E_FAKE_WRANGLER_AUTH_FAIL", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-wrangler.mjs", doc: "Fake wrangler: fail as Cloudflare refusing the API token (`Authentication error [code: 10000]`)." }, + { name: "E2E_LIVE_CHECK", audience: "test", default: "on", readBy: "common/publish/liveCheck.ts", doc: "`skip`: a deploy's live check reads nothing and records `skipped`. Set for the editor's test server, whose fake wrangler deploys nothing." }, { name: "E2E_FAKE_GALLERY_DL_AUTH_FAIL", audience: "test", default: "—", readBy: "editor/e2e/fixtures/bin/fake-gallery-dl.mjs", doc: "Fake gallery-dl: fail as an auth error." }, { name: "E2E_FIXTURE_MAX_LIFETIME_MS", audience: "test", default: "the watchdog's", readBy: "editor/e2e/fixtures/bin/_watchdog.mjs", doc: "How long a fake binary may live before its watchdog kills it." }, { name: "E2E_OLLAMA_STUB_MODEL", audience: "test", default: "`qwen2.5:7b`", readBy: "editor/e2e/fixtures/ollama-stub.mjs", doc: "The model the ollama stub claims to serve." }, diff --git a/common/lib/pagesDeploy.test.ts b/common/lib/pagesDeploy.test.ts @@ -1,11 +1,22 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { + CLOUDFLARE_AUTH_REFUSED, + CLOUDFLARE_NO_CREDENTIALS, MAX_PREVIEW_BRANCH, + PRODUCTION_BRANCH, + WRANGLER_MAJOR, + cloudflareCredentialProblem, deploymentUrlIn, pagesDeployArgs, previewAliasUrl, previewBranchProblem, + wranglerAuthFailureIn, + wranglerBin, + wranglerOAuthConfigFiles, } from "./pagesDeploy"; test("previewBranchProblem accepts ordinary preview names", () => { @@ -72,44 +83,99 @@ test("previewBranchProblem refuses empty and non-strings", () => { assert.match(previewBranchProblem(42) ?? "", /must be a string/); }); -test("pagesDeployArgs without a preview is byte-identical to the production argv", () => { +test("pagesDeployArgs: production names branch main, never left to the checkout", () => { assert.deepEqual( pagesDeployArgs({ outDir: "/repo/export/out", project: "anilyzer" }), - ["wrangler", "pages", "deploy", "/repo/export/out", "--project-name", "anilyzer"], - ); - // An explicitly-empty / whitespace preview is the same thing as none: no - // --branch, so wrangler infers it exactly as it always has. - assert.deepEqual( - pagesDeployArgs({ outDir: "/o", project: "p", previewBranch: "" }), - ["wrangler", "pages", "deploy", "/o", "--project-name", "p"], - ); - assert.deepEqual( - pagesDeployArgs({ outDir: "/o", project: "p", previewBranch: " " }), - ["wrangler", "pages", "deploy", "/o", "--project-name", "p"], + ["pages", "deploy", "/repo/export/out", "--project-name", "anilyzer", "--branch", "main"], ); + // An explicitly-empty / whitespace preview is the same thing as none. + for (const blank of ["", " "]) { + assert.deepEqual( + pagesDeployArgs({ outDir: "/o", project: "p", previewBranch: blank }), + ["pages", "deploy", "/o", "--project-name", "p", "--branch", "main"], + ); + } + assert.equal(PRODUCTION_BRANCH, "main"); }); -test("pagesDeployArgs appends --branch for a preview", () => { +test("pagesDeployArgs names the preview branch, and only it", () => { assert.deepEqual( pagesDeployArgs({ outDir: "/repo/export/out", project: "anilyzer", previewBranch: "tags-exclude", }), - [ - "wrangler", - "pages", - "deploy", - "/repo/export/out", - "--project-name", - "anilyzer", - "--branch", - "tags-exclude", - ], + ["pages", "deploy", "/repo/export/out", "--project-name", "anilyzer", "--branch", "tags-exclude"], ); - assert.deepEqual( - pagesDeployArgs({ outDir: "/o", project: "p", previewBranch: " x " }).slice(-2), - ["--branch", "x"], + const args = pagesDeployArgs({ outDir: "/o", project: "p", previewBranch: " x " }); + assert.deepEqual(args.slice(-2), ["--branch", "x"]); + assert.equal(args.filter((a) => a === "--branch").length, 1); +}); + +test("wranglerBin: WRANGLER_BIN when set, else common's pinned binary", () => { + const paths = { monorepoRoot: "/repo" }; + assert.equal(wranglerBin(paths, {}), "/repo/common/node_modules/.bin/wrangler"); + assert.equal(wranglerBin(paths, { WRANGLER_BIN: " " }), "/repo/common/node_modules/.bin/wrangler"); + assert.equal(wranglerBin(paths, { WRANGLER_BIN: "/fake/wrangler" }), "/fake/wrangler"); +}); + +test("wrangler is pinned EXACTLY in common's devDependencies, at WRANGLER_MAJOR", () => { + const pkg = JSON.parse( + readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"), + ) as { devDependencies?: Record<string, string>; dependencies?: Record<string, string> }; + const pin = pkg.devDependencies?.wrangler; + assert.ok(pin, "common/package.json has no wrangler devDependency"); + assert.match(pin, /^\d+\.\d+\.\d+$/, `"${pin}" is a range, not an exact pin`); + assert.equal(Number(pin.split(".")[0]), WRANGLER_MAJOR); + assert.equal(pkg.dependencies?.wrangler, undefined); +}); + +test("wranglerAuthFailureIn: Cloudflare's refusals and wrangler's missing-login sentences", () => { + for (const line of [ + " Authentication error [code: 10000]", + "✘ [ERROR] A request to the Cloudflare API (/accounts/x/pages/projects/y) failed. Authentication error [code: 10000]", + "Invalid access token [code: 9109]", + "Unable to authenticate request [code: 10001]", + // A malformed token (CLOUDFLARE_API_TOKEN=bogus, the container smoke): + " Invalid request headers [code: 6003]", + " Invalid format for Authorization header [code: 6111]", + "✘ [ERROR] A request to the Cloudflare API (/accounts/x/pages/projects/y) failed. Invalid request headers [code: 6003]", + "In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable for wrangler to work.", + "You are not authenticated. Please run `wrangler login`.", + "Failed to refresh OAuth token", + ]) { + assert.equal(wranglerAuthFailureIn(line), true, line); + } + for (const line of [ + "✨ Success! Uploaded 12 files (40 already uploaded)", + "Project not found. The specified project name does not match any of your existing projects. [code: 8000007]", + "Take a peek over at https://abc123.anilyzer.pages.dev", + "Invalid request headers", + "[code: 6003]", + "", + ]) { + assert.equal(wranglerAuthFailureIn(line), false, line); + } + assert.equal(CLOUDFLARE_AUTH_REFUSED, "[deploy] REFUSED by Cloudflare — the API token was not accepted"); +}); + +test("cloudflareCredentialProblem: a token or an OAuth login; else the .env sentence", () => { + assert.equal(cloudflareCredentialProblem({ CLOUDFLARE_API_TOKEN: "t" }, false), null); + assert.equal(cloudflareCredentialProblem({}, true), null); + const none = cloudflareCredentialProblem({ CLOUDFLARE_API_TOKEN: " " }, false); + assert.equal(none, CLOUDFLARE_NO_CREDENTIALS); + assert.match(none ?? "", /set CLOUDFLARE_API_TOKEN in \.env/); +}); + +test("wranglerOAuthConfigFiles: the legacy dir, XDG, and macOS Preferences", () => { + assert.deepEqual(wranglerOAuthConfigFiles("/home/u", {}), [ + "/home/u/.wrangler/config/default.toml", + "/home/u/.config/.wrangler/config/default.toml", + "/home/u/Library/Preferences/.wrangler/config/default.toml", + ]); + assert.equal( + wranglerOAuthConfigFiles("/home/u", { XDG_CONFIG_HOME: "/x" })[1], + "/x/.wrangler/config/default.toml", ); }); diff --git a/common/lib/pagesDeploy.ts b/common/lib/pagesDeploy.ts @@ -7,11 +7,20 @@ // one node-free module is what makes those three agree by construction instead // of by three copies of a regex. No `node:` imports belong here. // -// WHAT A PREVIEW IS. `wrangler pages deploy` with no `--branch` infers the -// branch from the git checkout, and Cloudflare treats a deploy to the project's -// PRODUCTION branch as production and anything else as a preview. A preview gets -// its own branch alias, https://<branch>.<project>.pages.dev, plus an immutable -// per-deployment https://<hash>.<project>.pages.dev. +// WHAT A PREVIEW IS. Cloudflare treats a deploy to the project's PRODUCTION +// branch as production and anything else as a preview. A preview gets its own +// branch alias, https://<branch>.<project>.pages.dev, plus an immutable +// per-deployment https://<hash>.<project>.pages.dev. Every deploy names its +// branch (release 18): production is `--branch main`, never inferred — with no +// `--branch` wrangler took the branch from the git checkout it ran in, so a +// "production" deploy from a feature-branch checkout silently became a preview, +// and a container has no checkout at all. +// +// THE BINARY IS PINNED. wrangler is an exact devDependency of `common` +// (WRANGLER_MAJOR below; one pin for the host and the image) and is spawned as +// `wranglerBin(paths)`, never fetched by `pnpm dlx` at deploy time. + +import type { Paths } from "./paths"; // The branch names Cloudflare Pages projects use as their production branch. // Deploying to one of these is not a preview — it is the live site — so the @@ -55,29 +64,118 @@ export function previewBranchProblem(name: unknown): string | null { return null; } +/** The branch every Pages project here deploys PRODUCTION to. */ +export const PRODUCTION_BRANCH = "main"; + /** - * The wrangler argv (everything AFTER `pnpm dlx`) for one Pages deploy. - * - * With no `previewBranch` this is byte-identical to what the production deploy - * has always run — `--branch` absent means wrangler infers the branch from the - * checkout, which is the pre-existing behaviour and is deliberately unchanged. + * The wrangler argv (everything AFTER the wrangler binary — `wranglerBin`) for + * one Pages deploy: production is `--branch main`, a preview `--branch <b>`. + * A blank `previewBranch` is no preview. */ export function pagesDeployArgs(opts: { outDir: string; project: string; previewBranch?: string; }): string[] { - const args = [ - "wrangler", + const branch = opts.previewBranch?.trim() || PRODUCTION_BRANCH; + return [ "pages", "deploy", opts.outDir, "--project-name", opts.project, + "--branch", + branch, + ]; +} + +// The wrangler major the pin in common/package.json belongs to — what +// `archilyzer doctor` expects the binary to report (pagesDeploy.test.ts holds +// the pin to it). +export const WRANGLER_MAJOR = 4; + +/** + * The wrangler binary a deploy spawns: `WRANGLER_BIN` when set (the e2e fake, + * or an operator's own), else the pinned devDependency of `common`. + */ +export function wranglerBin( + paths: Pick<Paths, "monorepoRoot">, + env: Record<string, string | undefined> = typeof process === "undefined" ? {} : process.env, +): string { + return env.WRANGLER_BIN?.trim() || `${paths.monorepoRoot}/common/node_modules/.bin/wrangler`; +} + +// --------------------------------------------------------------------------- +// Credentials. A deploy with no credential at all is refused BEFORE wrangler +// (wrangler would otherwise try to open a browser for OAuth, or fail in its own +// words); a credential Cloudflare rejects is read off wrangler's output and +// said in ours. +// --------------------------------------------------------------------------- + +/** The sentence a deploy ends on when Cloudflare refused its credential. */ +export const CLOUDFLARE_AUTH_REFUSED = + "[deploy] REFUSED by Cloudflare — the API token was not accepted"; + +/** The sentence a deploy is refused with when no credential is configured. */ +export const CLOUDFLARE_NO_CREDENTIALS = + "[deploy] REFUSED — no Cloudflare credentials: set CLOUDFLARE_API_TOKEN in .env " + + "(or run `wrangler login` on this machine). Nothing was sent to Cloudflare"; + +// What wrangler 4 prints when Cloudflare rejects, or it cannot find, a +// credential: the API's own error codes (10000 "Authentication error", 9109 +// "Invalid access token" — a well-formed token that is wrong — 10001 "Unable to +// authenticate request", 6003 "Invalid request headers" and 6111 "Invalid format +// for Authorization header" — a malformed one) and wrangler's own sentences for +// a missing login in a non-interactive run. +const AUTH_FAILURE_RES: readonly RegExp[] = [ + /Authentication error \[code: 10000\]/, + /Invalid access token \[code: 9109\]/, + /Unable to authenticate request \[code: 10001\]/, + // A malformed token (not a token's shape at all): Cloudflare rejects the + // header before it reads the credential (seen with CLOUDFLARE_API_TOKEN=bogus). + /Invalid request headers \[code: 6003\]/, + /Invalid format for Authorization header \[code: 6111\]/, + /necessary to set a CLOUDFLARE_API_TOKEN environment variable/, + /You are not authenticated\. Please run `wrangler login`/, + /Failed to refresh (?:the )?OAuth token/i, +]; + +/** Whether one line of wrangler's output says the credential was not accepted. */ +export function wranglerAuthFailureIn(line: string): boolean { + return AUTH_FAILURE_RES.some((re) => re.test(line)); +} + +/** + * Where wrangler keeps an OAuth login (`wrangler login`), for a home dir and an + * environment: the legacy `~/.wrangler`, the XDG config dir (Linux), and macOS's + * Preferences. Pure; the caller stats them. + */ +export function wranglerOAuthConfigFiles( + home: string, + env: Record<string, string | undefined>, +): string[] { + const xdg = env.XDG_CONFIG_HOME?.trim() || `${home}/.config`; + return [ + `${home}/.wrangler/config/default.toml`, + `${xdg}/.wrangler/config/default.toml`, + `${home}/Library/Preferences/.wrangler/config/default.toml`, ]; - const branch = opts.previewBranch?.trim(); - if (branch) args.push("--branch", branch); - return args; +} + +/** + * Why a deploy has no credential to offer Cloudflare, as the refusal sentence — + * or null when it has one: `CLOUDFLARE_API_TOKEN` (what `.env` carries, the one + * way in the container), or a wrangler OAuth login on disk + * (`oauthLoginPresent`, a host's `wrangler login`). Never reads or prints a + * value: set or not. + */ +export function cloudflareCredentialProblem( + env: Record<string, string | undefined>, + oauthLoginPresent: boolean, +): string | null { + if (env.CLOUDFLARE_API_TOKEN?.trim()) return null; + if (oauthLoginPresent) return null; + return CLOUDFLARE_NO_CREDENTIALS; } /** diff --git a/common/package.json b/common/package.json @@ -90,6 +90,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "wrangler": "4.147.0" } } diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts @@ -158,7 +158,7 @@ test("hubProjectProblem refuses a missing project and the homepage's", () => { }); test("homepageDeployArgs: production is branch main; a preview is its own branch and never main", () => { - const base = ["wrangler", "pages", "deploy", "/repo/homepage/out", "--project-name", "archilyzer"]; + const base = ["pages", "deploy", "/repo/homepage/out", "--project-name", "archilyzer"]; assert.deepEqual(homepageDeployArgs("/repo/homepage/out"), [...base, "--branch", "main"]); assert.deepEqual(homepageDeployArgs("/repo/homepage/out", " "), [...base, "--branch", "main"]); assert.deepEqual(homepageDeployArgs("/repo/homepage/out", " r8-home "), [...base, "--branch", "r8-home"]); @@ -361,6 +361,9 @@ test("runDeployIntoLog refuses a bundle that is not the site's own before wrangl chmodSync(path.join(bin, "pnpm"), 0o755); const savedPath = process.env.PATH; process.env.PATH = bin; + // The deploy spawns wranglerBin(paths): WRANGLER_BIN, here the same fake. + const savedWrangler = process.env.WRANGLER_BIN; + process.env.WRANGLER_BIN = path.join(bin, "pnpm"); const signal = new AbortController().signal; const site = { siteId: "anilyzer", cloudflareProject: "w3c-never-real" } as Site; const testPaths = { ...paths, exportDir: root } as Paths; @@ -396,11 +399,13 @@ test("runDeployIntoLog refuses a bundle that is not the site's own before wrangl assert.equal(await runDeployIntoLog((l) => log.push(l), signal, site, good, testPaths), 0, log.join("\n")); assert.equal( readFileSync(argvFile, "utf8"), - ["dlx", "wrangler", "pages", "deploy", good, "--project-name", "w3c-never-real", ""].join("\n"), + ["pages", "deploy", good, "--project-name", "w3c-never-real", "--branch", "main", ""].join("\n"), ); assert.ok(log.includes("[deployed] https://abc123.w3c-never-real.pages.dev\n"), log.join("\n")); } finally { process.env.PATH = savedPath; + if (savedWrangler === undefined) delete process.env.WRANGLER_BIN; + else process.env.WRANGLER_BIN = savedWrangler; rmSync(root, { recursive: true, force: true }); } }); @@ -426,6 +431,9 @@ test("runDeployIntoLog refuses a private site and a private build before wrangle chmodSync(path.join(bin, "pnpm"), 0o755); const savedPath = process.env.PATH; process.env.PATH = bin; + // The deploy spawns wranglerBin(paths): WRANGLER_BIN, here the same fake. + const savedWrangler = process.env.WRANGLER_BIN; + process.env.WRANGLER_BIN = path.join(bin, "pnpm"); const signal = new AbortController().signal; const testPaths = { ...paths, exportDir: root } as Paths; try { @@ -456,6 +464,8 @@ test("runDeployIntoLog refuses a private site and a private build before wrangle assert.equal(existsSync(argvFile), false, "pnpm was spawned"); } finally { process.env.PATH = savedPath; + if (savedWrangler === undefined) delete process.env.WRANGLER_BIN; + else process.env.WRANGLER_BIN = savedWrangler; rmSync(root, { recursive: true, force: true }); } }); diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -30,6 +30,7 @@ import { pagesDeployArgs, previewAliasUrl, previewBranchProblem, + wranglerBin, } from "../lib/pagesDeploy"; import { getPaths, type Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; @@ -346,11 +347,10 @@ export async function runArchiveUploadIntoLog( // // `opts.previewBranch` makes it a PREVIEW deploy: Cloudflare treats a deploy to // any branch but the project's production branch as a preview, reachable at the -// branch alias. Omitting it leaves the argv byte-identical to what production -// has always run — no `--branch`, so wrangler infers the branch from the -// checkout, which is the long-standing behaviour (and the long-standing hazard: -// a "production" deploy run from a non-main checkout silently becomes a -// preview). +// branch alias. Omitting it deploys production, `--branch main` (release 18: +// the branch is named, never inferred from the checkout, where a "production" +// deploy from a non-main checkout used to become a preview silently). The +// binary is the pinned wrangler (wranglerBin), not `pnpm dlx`. // // Either way, the URL wrangler prints ("Take a peek over at …") earns one // terminal line of its own, because the streamed log scrolls and an operator @@ -403,8 +403,8 @@ export async function runDeployIntoLog( }; const code = await runChildIntoLog(watch, signal, { - command: "pnpm", - args: ["dlx", ...pagesDeployArgs({ outDir, project, previewBranch })], + command: wranglerBin(paths), + args: pagesDeployArgs({ outDir, project, previewBranch }), cwd: paths.exportDir, env: { ...process.env, @@ -986,8 +986,9 @@ async function runPagesDeployIntoLog( outDir: string; project: string; cwd: string; + // The binary (wranglerBin): the pinned devDependency, or WRANGLER_BIN. + wrangler: string; previewBranch?: string; - extraArgs?: string[]; }, ): Promise<number> { let deploymentUrl: string | null = null; @@ -996,16 +997,12 @@ async function runPagesDeployIntoLog( onLog(line); }; const code = await runChildIntoLog(watch, signal, { - command: "pnpm", - args: [ - "dlx", - ...pagesDeployArgs({ - outDir: opts.outDir, - project: opts.project, - previewBranch: opts.previewBranch, - }), - ...(opts.extraArgs ?? []), - ], + command: opts.wrangler, + args: pagesDeployArgs({ + outDir: opts.outDir, + project: opts.project, + previewBranch: opts.previewBranch, + }), cwd: opts.cwd, env: { ...process.env, NODE_ENV: "production" }, }); @@ -1049,6 +1046,7 @@ export async function deployHub( outDir, project: project!.trim(), cwd: paths.exportDir, + wrangler: wranglerBin(paths), previewBranch: branch, }); if (signal.aborted) return; @@ -1158,21 +1156,13 @@ async function withdrawBuiltSource(paths: Paths, onLog: (line: string) => void): } /** - * The wrangler argv (after `pnpm dlx`) for a homepage deploy of `outDir`: the - * production branch `main` — what homepage/package.json's hardcoded `deploy` - * line ran — or, with `previewBranch`, that preview branch and no `main`. - * Pure, so the test pins exactly what deployHomepage runs. + * The wrangler argv (after the binary, wranglerBin) for a homepage deploy of + * `outDir`: the production branch `main`, or, with `previewBranch`, that + * preview branch and no `main` — pagesDeployArgs, which names the branch for + * every project now. Pure, so the test pins exactly what deployHomepage runs. */ export function homepageDeployArgs(outDir: string, previewBranch?: string): string[] { - const branch = previewBranch?.trim() || undefined; - return [ - ...pagesDeployArgs({ outDir, project: HOMEPAGE_PAGES_PROJECT, previewBranch: branch }), - ...homepageProductionArgs(branch), - ]; -} - -function homepageProductionArgs(previewBranch: string | undefined): string[] { - return previewBranch ? [] : ["--branch", "main"]; + return pagesDeployArgs({ outDir, project: HOMEPAGE_PAGES_PROJECT, previewBranch }); } /** @@ -1203,8 +1193,8 @@ export async function deployHomepage( outDir, project: HOMEPAGE_PAGES_PROJECT, cwd: homepageDir(paths), + wrangler: wranglerBin(paths), previewBranch: branch, - extraArgs: homepageProductionArgs(branch), }); if (signal.aborted) return; if (code !== 0) throw new Error(`Homepage deploy failed (exit ${code}).`); diff --git a/common/publish/deployStage.test.ts b/common/publish/deployStage.test.ts @@ -0,0 +1,689 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getPaths, type Paths } from "../lib/paths"; +import { CLOUDFLARE_AUTH_REFUSED } from "../lib/pagesDeploy"; +import { readDeployedFile as readDeployed, type DeployedFile } from "./stamps"; +import { + DeployStageError, + runDeployStage, + type BuiltStamp, + type DeployStageContext, + type DeployStageRequest, +} from "./deployStage"; + +// Run with: pnpm --filter yt-dlp-transcript-common test +// +// The deploy stage over a temp tree, spawning the e2e FAKE wrangler +// (editor/e2e/fixtures/bin/fake-wrangler.mjs) as WRANGLER_BIN — the same +// binary the editor suite deploys through — and an injected fetch for the live +// check. Nothing here can reach Cloudflare: the env handed to the stage is +// built from scratch, and the fake deploys nothing. + +const FAKE_WRANGLER = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "editor", + "e2e", + "fixtures", + "bin", + "fake-wrangler.mjs", +); +const GENERATED = "2026-10-06T09:00:00.000Z"; + +type Fixture = { root: string; paths: Paths; home: string; cleanup: () => void }; + +function fixture(): Fixture { + const root = mkdtempSync(path.join(tmpdir(), "deploy-stage-")); + const home = path.join(root, "home"); + mkdirSync(home); + const sitesDir = path.join(root, "sites"); + const paths: Paths = { + ...getPaths(), + monorepoRoot: root, + // Never the checkout's corpus: the local-destination guard resolves it. + transcriptsDir: path.join(root, "transcripts"), + exportDir: path.join(root, "export"), + exportBuildsDir: path.join(root, "builds"), + sitesDir, + homepageDir: path.join(sitesDir, "_homepage"), + homepageConfigFile: path.join(sitesDir, "_homepage", "homepage.json"), + }; + mkdirSync(paths.exportDir, { recursive: true }); + return { root, paths, home, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +const writeJson = (file: string, value: unknown) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(value)); +}; + +function site(fx: Fixture, siteId: string, extra: Record<string, unknown> = {}) { + writeJson(path.join(fx.paths.sitesDir, siteId, "site.json"), { + siteId, + siteTitle: siteId, + cloudflareProject: `${siteId}-proj`, + siteUrl: `https://${siteId}.example.test`, + channels: [], + ...extra, + }); +} + +// Every field S1's strict built.json reader asks for. +function stampFields(target: string, kind: BuiltStamp["kind"]): BuiltStamp { + return { + v: 1, + stampId: `built-${target}-1`, + target, + kind, + indexStampId: "idx-1", + inputSig: "sig", + builtAt: Date.parse("2026-10-06T09:30:00.000Z"), + commit: null, + branch: "main", + runner: "local", + audience: "public", + corpusGeneratedAt: GENERATED, + files: 3, + bytes: 100, + archivesStaged: 0, + }; +} + +// A built bundle under <builds>/<target>/out and its built.json. +function built( + fx: Fixture, + target: string, + opts: { bundleOf?: string; stamp?: Partial<BuiltStamp>; corpus?: Record<string, unknown> } = {}, +): BuiltStamp { + const dir = path.join(fx.paths.exportBuildsDir, target); + const out = path.join(dir, "out"); + mkdirSync(out, { recursive: true }); + const id = opts.bundleOf ?? target; + writeJson(path.join(out, "site.json"), { siteId: id }); + writeJson(path.join(out, "corpus.json"), { generatedAt: GENERATED, site: { id }, ...opts.corpus }); + writeFileSync(path.join(out, "index.html"), "<!doctype html>"); + const stamp: BuiltStamp = { + v: 1, + stampId: `built-${target}-1`, + target, + kind: "site", + indexStampId: "idx-1", + inputSig: "sig", + builtAt: Date.parse("2026-10-06T09:30:00.000Z"), + commit: null, + branch: "main", + runner: "local", + audience: "public", + corpusGeneratedAt: GENERATED, + files: 3, + bytes: 100, + archivesStaged: 0, + ...opts.stamp, + }; + writeJson(path.join(dir, "built.json"), stamp); + return stamp; +} + +// A live check that finds the build live: every URL answers this build's +// corpus.json (or a tombstone body for a posts path), and records each URL. +function liveFetch(asked: string[], generatedAt = GENERATED): typeof fetch { + return (async (input: string | URL | Request) => { + const url = String(input); + asked.push(url); + const body = url.includes("/posts/") + ? url.includes("page-") + ? [] + : url.includes("/posts/manifest.json") + ? { channels: [] } + : { pageCount: 0 } + : { generatedAt }; + return new Response(JSON.stringify(body), { status: 200 }); + }) as typeof fetch; +} + +function ctx( + fx: Fixture, + env: Record<string, string | undefined>, + extra: Partial<DeployStageContext> = {}, +): DeployStageContext & { lines: string[]; asked: string[]; uploads: string[] } { + const lines: string[] = []; + const asked: string[] = []; + const uploads: string[] = []; + return { + paths: fx.paths, + onLog: (l) => lines.push(l), + signal: new AbortController().signal, + env: { PATH: process.env.PATH, WRANGLER_BIN: FAKE_WRANGLER, ...env }, + home: fx.home, + now: () => new Date("2026-10-06T10:00:00.000Z"), + liveCheck: { fetch: liveFetch(asked), sleep: async () => {} }, + uploadArchives: async (_s, dir) => { + uploads.push(dir); + return 0; + }, + lines, + asked, + uploads, + ...extra, + }; +} + +const TOKEN = { CLOUDFLARE_API_TOKEN: "test-token-not-real" }; + +function sidecar(fx: Fixture, target: string): { argv: string[]; branch: string }[] { + const file = path.join(fx.paths.exportBuildsDir, target, ".fake-wrangler.json"); + return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")).invocations : []; +} + +function deployedBytes(fx: Fixture, target: string): string | null { + const file = path.join(fx.paths.exportBuildsDir, target, "deployed.json"); + return existsSync(file) ? readFileSync(file, "utf8") : null; +} + +// S1's reader: deployed.json, or null when there is none (or it is malformed). +async function deployedOf(fx: Fixture, target: string): Promise<DeployedFile> { + const f = await readDeployed(fx.paths, target); + assert.ok(f, `no deployed.json for ${target}`); + return f; +} + +async function refused( + p: Promise<unknown>, + exitCode: number, + why: RegExp, +): Promise<void> { + await assert.rejects(p, (err: unknown) => { + assert.ok(err instanceof DeployStageError, String(err)); + assert.equal(err.exitCode, exitCode, err.message); + assert.match(err.message, why); + return true; + }); +} + +test("a preview deploy runs the pinned binary with --branch <b>, checks the alias live and records previews[b]", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + const stamp = built(fx, "anilyzer"); + const c = ctx(fx, TOKEN); + const req: DeployStageRequest = { kind: "deploy-site", target: "anilyzer", preview: "r18" }; + const out = await runDeployStage(c, req); + assert.equal(out.status, "ran"); + assert.equal(out.stamp, stamp.stampId); + assert.match(out.summary, /deployed to preview "r18" \(anilyzer-proj\) — live check: ok\.$/); + + const outDir = path.join(fx.paths.exportBuildsDir, "anilyzer", "out"); + assert.deepEqual(sidecar(fx, "anilyzer").map((i) => i.argv), [ + ["pages", "deploy", outDir, "--project-name", "anilyzer-proj", "--branch", "r18"], + ]); + // The archives went first, from the per-site staging dir. + assert.deepEqual(c.uploads, [path.join(fx.paths.exportBuildsDir, "anilyzer", ".r2-staging", "anilyzer", "archives")]); + assert.ok(c.lines.includes("[preview] https://r18.anilyzer-proj.pages.dev (this deployment: https://r18.anilyzer-proj.pages.dev)\n"), c.lines.join("")); + // The live check read the ALIAS, plain and cache-busted. + assert.deepEqual(c.asked, [ + "https://r18.anilyzer-proj.pages.dev/corpus.json", + `https://r18.anilyzer-proj.pages.dev/corpus.json?cb=${stamp.stampId}`, + ]); + + const rec = (await deployedOf(fx, "anilyzer")); + assert.equal(rec.v, 1); + assert.equal(rec.production, undefined); + const p = rec.previews.r18; + assert.equal(p.builtStampId, stamp.stampId); + assert.equal(p.builtAt, stamp.builtAt); + assert.equal(p.kind, "preview"); + assert.equal(p.branch, "r18"); + assert.equal(p.alias, "https://r18.anilyzer-proj.pages.dev"); + assert.equal(p.url, "https://r18.anilyzer-proj.pages.dev"); + assert.equal(p.at, Date.parse("2026-10-06T10:00:00.000Z")); + assert.match(p.wrangler ?? "", /^WRANGLER_BIN=/); + assert.equal(p.liveCheck?.verdict, "ok"); + assert.equal(p.liveCheck?.expected, GENERATED); + + // The same build to the same slot again: a no-op, no second spawn. + const again = await runDeployStage(ctx(fx, TOKEN), req); + assert.equal(again.status, "noop"); + assert.equal(sidecar(fx, "anilyzer").length, 1); + // …unless forced. + assert.equal((await runDeployStage(ctx(fx, TOKEN), { ...req, force: true })).status, "ran"); + assert.equal(sidecar(fx, "anilyzer").length, 2); + } finally { + fx.cleanup(); + } +}); + +test("production is --branch main, checked at the site's public URL, recorded beside the previews", async () => { + const fx = fixture(); + try { + site(fx, "jeralyzer"); + built(fx, "jeralyzer"); + writeJson(path.join(fx.paths.exportBuildsDir, "jeralyzer", "deployed.json"), { + v: 1, + target: "jeralyzer", + previews: { old: { builtStampId: "x", builtAt: 1, kind: "preview", branch: "old", url: null, at: 1, liveCheck: null } }, + }); + const c = ctx(fx, TOKEN); + await runDeployStage(c, { kind: "deploy-site", target: "jeralyzer" }); + assert.deepEqual(sidecar(fx, "jeralyzer")[0].argv.slice(-2), ["--branch", "main"]); + assert.equal(c.asked[0], "https://jeralyzer.example.test/corpus.json"); + const rec = (await deployedOf(fx, "jeralyzer")); + assert.equal(rec.production?.kind, "production"); + assert.equal(rec.production?.branch, undefined); + assert.equal(rec.production?.url, "https://main.jeralyzer-proj.pages.dev"); + assert.ok(rec.previews.old, "the previews already recorded are kept"); + assert.ok(c.lines.includes("[deployed] https://main.jeralyzer-proj.pages.dev\n")); + } finally { + fx.cleanup(); + } +}); + +test("every refusal leaves deployed.json untouched, and wrangler unspawned", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + site(fx, "mine", { audience: "private" }); + site(fx, "noproj", { cloudflareProject: "" }); + built(fx, "anilyzer"); + built(fx, "mine"); + built(fx, "noproj"); + const before = JSON.stringify({ v: 1, target: "anilyzer", previews: {} }, null, 2); + writeFileSync(path.join(fx.paths.exportBuildsDir, "anilyzer", "deployed.json"), before); + + const cases: [string, DeployStageRequest, Record<string, string | undefined>, number, RegExp][] = [ + ["no credential", { kind: "deploy-site", target: "anilyzer" }, {}, 1, /set CLOUDFLARE_API_TOKEN in \.env/], + ["a production branch as preview", { kind: "deploy-site", target: "anilyzer", preview: "main" }, TOKEN, 2, /is the production branch/], + ["the hub's target as a site", { kind: "deploy-site", target: "_hub" }, TOKEN, 2, /"_hub" is not a site — deploy it with deploy-hub/], + ["a private site", { kind: "deploy-site", target: "mine" }, TOKEN, 3, /is private \(audience: private\)/], + ["no Pages project", { kind: "deploy-site", target: "noproj" }, TOKEN, 3, /no Cloudflare Pages project configured/], + ["never built", { kind: "deploy-site", target: "nobuild" }, TOKEN, 3, /no build of nobuild in .*builds\/nobuild — archilyzer publish build nobuild/], + ["local and preview at once", { kind: "deploy-site", target: "anilyzer", to: "local", preview: "p" }, TOKEN, 2, /a local deploy has no preview branch/], + ["local with nowhere to copy", { kind: "deploy-site", target: "anilyzer", to: "local" }, TOKEN, 3, /^\[deploy\] REFUSED — --to local needs ARCHILYZER_SITE_OUT/], + ]; + site(fx, "nobuild"); + for (const [name, req, env, code, why] of cases) { + const c = ctx(fx, env); + await refused(runDeployStage(c, req), code, why); + assert.match(c.lines.at(-1) ?? "", /^\[deploy\] REFUSED/, name); + assert.equal(deployedBytes(fx, "anilyzer"), before, `${name}: deployed.json changed`); + assert.equal(deployedBytes(fx, "mine"), null, name); + assert.deepEqual(sidecar(fx, req.target), [], `${name}: wrangler was spawned`); + assert.deepEqual(c.asked, [], `${name}: a live check ran`); + } + } finally { + fx.cleanup(); + } +}); + +test("Cloudflare refusing the token: wrangler's error becomes the REFUSED sentence; deployed.json untouched, exit 1", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + built(fx, "anilyzer"); + const c = ctx(fx, { ...TOKEN, E2E_FAKE_WRANGLER_AUTH_FAIL: "1" }); + await refused(runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }), 1, /^\[deploy\] REFUSED by Cloudflare — the API token was not accepted$/); + assert.ok(c.lines.some((l) => l.includes("Authentication error [code: 10000]")), "wrangler's own line is in the log"); + assert.equal(c.lines.at(-1), `${CLOUDFLARE_AUTH_REFUSED}\n`); + assert.equal(deployedBytes(fx, "anilyzer"), null); + assert.deepEqual(c.asked, []); + } finally { + fx.cleanup(); + } +}); + +test("a wrangler OAuth login on disk passes the preflight with no token", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + built(fx, "anilyzer"); + writeJson(path.join(fx.home, ".config", ".wrangler", "config", "default.toml"), "oauth_token = \"x\""); + const out = await runDeployStage(ctx(fx, {}), { kind: "deploy-site", target: "anilyzer", preview: "p" }); + assert.equal(out.status, "ran"); + } finally { + fx.cleanup(); + } +}); + +test("production ships only a build of main; the same build may go to a preview", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + built(fx, "anilyzer", { stamp: { branch: "r18/feature" } }); + const c = ctx(fx, TOKEN); + await refused( + runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }), + 3, + /made from branch "r18\/feature", not main: production ships only a build of main/, + ); + assert.equal(deployedBytes(fx, "anilyzer"), null); + assert.equal((await runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "anilyzer", preview: "feat" })).status, "ran"); + // No branch recorded (a detached HEAD, an image built without + // ARCHILYZER_BRANCH) is refused for production the same way (S1's rule). + site(fx, "jasolyzer"); + built(fx, "jasolyzer", { stamp: { branch: null } }); + await refused( + runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "jasolyzer" }), + 3, + /has no branch recorded .*production ships only a build of main/, + ); + } finally { + fx.cleanup(); + } +}); + +test("the bundle guards: another site's bundle and a private build are refused before wrangler", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + built(fx, "anilyzer", { bundleOf: "jeralyzer" }); + await refused( + runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "anilyzer" }), + 3, + /holds a build of "jeralyzer", not "anilyzer" \(site\.json\)\. Nothing was sent to Cloudflare Pages/, + ); + site(fx, "bonnellyzer"); + built(fx, "bonnellyzer", { corpus: { site: { id: "bonnellyzer", audience: "private" } } }); + await refused( + runDeployStage(ctx(fx, TOKEN), { kind: "deploy-site", target: "bonnellyzer" }), + 3, + /private build of "bonnellyzer"/, + ); + assert.deepEqual(sidecar(fx, "anilyzer"), []); + assert.deepEqual(sidecar(fx, "bonnellyzer"), []); + } finally { + fx.cleanup(); + } +}); + +test("--to local copies the bundle into ARCHILYZER_SITE_OUT (its contents replaced) and records local", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + const stamp = built(fx, "anilyzer"); + const dest = path.join(fx.root, "site-out"); + mkdirSync(dest); + // What an earlier local deploy (or the container's placeholder) left. + writeFileSync(path.join(dest, "index.html"), "old"); + writeFileSync(path.join(dest, "stale.html"), "old"); + const c = ctx(fx, { ARCHILYZER_SITE_OUT: dest }); + const out = await runDeployStage(c, { kind: "deploy-site", target: "anilyzer", to: "local" }); + assert.equal(out.status, "ran"); + assert.deepEqual(readdirSync(dest).sort(), ["corpus.json", "index.html", "site.json"]); + assert.deepEqual(sidecar(fx, "anilyzer"), [], "no wrangler"); + assert.deepEqual(c.uploads, [], "no R2"); + assert.deepEqual(c.asked, [], "no live check"); + const rec = (await deployedOf(fx, "anilyzer")); + assert.equal(rec.local?.builtStampId, stamp.stampId); + assert.equal(rec.local?.kind, "local"); + assert.equal(rec.local?.liveCheck, null); + // A private site is still refused locally. + site(fx, "mine", { audience: "private" }); + built(fx, "mine"); + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: dest }), { kind: "deploy-site", target: "mine", to: "local" }), + 3, + /is private/, + ); + assert.ok(existsSync(path.join(dest, "site.json"))); + + // A destination that does not look like a bundle the copy made is not + // emptied: non-empty with no index.html, or one holding the checkout. + const notOurs = path.join(fx.root, "somebody-else"); + mkdirSync(notOurs); + writeFileSync(path.join(notOurs, "notes.txt"), "keep"); + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: notOurs }), { kind: "deploy-site", target: "anilyzer", to: "local", force: true }), + 1, + /somebody-else is not empty and holds no index\.html/, + ); + assert.deepEqual(readdirSync(notOurs), ["notes.txt"]); + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: fx.root }), { kind: "deploy-site", target: "anilyzer", to: "local", force: true }), + 1, + /holds the checkout/, + ); + assert.ok(existsSync(path.join(fx.root, "sites", "anilyzer", "site.json")), "nothing was emptied"); + + // export/out is a LINK to the bundle built last (S1): resolved, it is the + // bundle itself, inside the builds dir — refused, the bundle untouched. + const bundle = path.join(fx.paths.exportBuildsDir, "anilyzer", "out"); + symlinkSync(bundle, path.join(fx.paths.exportDir, "out")); + const bundleBefore = readdirSync(bundle).sort(); + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: path.join(fx.paths.exportDir, "out") }), { + kind: "deploy-site", + target: "anilyzer", + to: "local", + force: true, + }), + 1, + /export\/out (holds|is inside) /, + ); + assert.deepEqual(readdirSync(bundle).sort(), bundleBefore); + // …and so is any directory inside export/, the builds dir or the corpus. + for (const [inner, what] of [ + [path.join(fx.paths.exportDir, "site-out"), "export/"], + [path.join(fx.paths.exportBuildsDir, "site"), "the builds directory"], + [path.join(fx.paths.transcriptsDir, "site"), "the corpus"], + ]) { + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: inner }), { kind: "deploy-site", target: "anilyzer", to: "local", force: true }), + 1, + new RegExp(`is inside ${what.replace("/", "\\/")}`), + ); + assert.equal(existsSync(inner), false, `${inner} was made`); + } + } finally { + fx.cleanup(); + } +}); + +test("the hub's and the homepage's targets are fixed: a mismatch is a usage error and writes nothing", async () => { + const fx = fixture(); + try { + site(fx, "jeralyzer"); + built(fx, "jeralyzer"); + const before = deployedBytes(fx, "jeralyzer"); + for (const req of [ + { kind: "deploy-homepage", target: "jeralyzer" }, + { kind: "deploy-hub", target: "jeralyzer" }, + { kind: "deploy-homepage", target: "_hub" }, + ] as DeployStageRequest[]) { + const c = ctx(fx, TOKEN); + await refused(runDeployStage(c, req), 2, new RegExp(`^\\[deploy\\] REFUSED — ${req.kind} deploys "_(hub|homepage)", not "${req.target}"\\.$`)); + assert.deepEqual(c.asked, []); + } + assert.equal(deployedBytes(fx, "jeralyzer"), before); + assert.equal(deployedBytes(fx, "jeralyzer"), null); + assert.deepEqual(sidecar(fx, "jeralyzer"), []); + } finally { + fx.cleanup(); + } +}); + +test("wrangler's own lines reach the log as lines, each ending in a newline", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + built(fx, "anilyzer"); + const c = ctx(fx, TOKEN); + await runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }); + assert.ok(c.lines.some((l) => l.startsWith("✨ Deployment complete!")), c.lines.join("")); + for (const l of c.lines) assert.ok(l.endsWith("\n"), JSON.stringify(l)); + } finally { + fx.cleanup(); + } +}); + +test("an R2 failure throws the line it logged, and nothing is spawned or recorded", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + built(fx, "anilyzer"); + const c = ctx(fx, TOKEN, { uploadArchives: async () => 7 }); + await refused( + runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }), + 1, + /^\[deploy\] FAILED — the archive R2 upload exited 7; nothing was sent to Cloudflare Pages\.$/, + ); + assert.equal(c.lines.at(-1), "[deploy] FAILED — the archive R2 upload exited 7; nothing was sent to Cloudflare Pages.\n"); + assert.deepEqual(sidecar(fx, "anilyzer"), []); + assert.equal(deployedBytes(fx, "anilyzer"), null); + } finally { + fx.cleanup(); + } +}); + +test("Cancel during the live check: the deploy is recorded without a check, and the stage ends cancelled", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + const stamp = built(fx, "anilyzer"); + const cancel = new AbortController(); + const c = ctx(fx, TOKEN, { + signal: cancel.signal, + liveCheck: { + sleep: async () => {}, + fetch: (async () => { + cancel.abort(); + return new Response(JSON.stringify({ generatedAt: GENERATED }), { status: 200 }); + }) as typeof fetch, + }, + }); + await refused(runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }), 130, /cancelled during the live check/); + const rec = (await deployedOf(fx, "anilyzer")); + assert.equal(rec.production?.builtStampId, stamp.stampId); + assert.equal(rec.production?.liveCheck, null); + } finally { + fx.cleanup(); + } +}); + +test("the homepage's local deploy copies homepage/out into ARCHILYZER_HOMEPAGE_OUT, and refuses without it", async () => { + const fx = fixture(); + try { + const out = path.join(fx.root, "homepage", "out"); + mkdirSync(out, { recursive: true }); + writeFileSync(path.join(out, "index.html"), "<!doctype html>"); + writeJson(path.join(fx.paths.exportBuildsDir, "_homepage", "built.json"), { + ...stampFields("_homepage", "homepage"), + stampId: "home-1", + corpusGeneratedAt: null, + }); + const req: DeployStageRequest = { kind: "deploy-homepage", target: "_homepage", to: "local" }; + const dest = path.join(fx.root, "homepage-out"); + // The source gate is asked first, local or not: a build with no /source + // page (its source step refused) is never shipped. + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_HOMEPAGE_OUT: dest }), req), + 3, + /homepage\/out has no \/source page/, + ); + // A `--no-source` build: the page's empty state and nothing else. + mkdirSync(path.join(out, "source")); + writeFileSync(path.join(out, "source", "index.html"), "<!doctype html>"); + // Only the SITE's directory set: the homepage is not copied beside it. + await refused( + runDeployStage(ctx(fx, { ARCHILYZER_SITE_OUT: path.join(fx.root, "site-out") }), req), + 3, + /--to local needs ARCHILYZER_HOMEPAGE_OUT/, + ); + assert.equal(existsSync(dest), false); + assert.equal(deployedBytes(fx, "_homepage"), null); + const res = await runDeployStage(ctx(fx, { ARCHILYZER_HOMEPAGE_OUT: dest }), req); + assert.equal(res.status, "ran"); + assert.deepEqual(readdirSync(dest).sort(), ["index.html", "source"]); + assert.equal((await deployedOf(fx, "_homepage")).local?.builtStampId, "home-1"); + } finally { + fx.cleanup(); + } +}); + +test("the hub: its project, its bundle, and every tombstone probed plain and busted", async () => { + const fx = fixture(); + try { + writeJson(fx.paths.homepageConfigFile, { + cloudflareProject: "archilyzer-hub", + siteUrl: "https://archilyzer-hub.pages.dev", + }); + const dir = path.join(fx.paths.exportBuildsDir, "_hub"); + const out = path.join(dir, "out"); + writeJson(path.join(out, "hub-sites.json"), []); + writeJson(path.join(out, "corpus.json"), { kind: "hub", generatedAt: GENERATED }); + writeJson(path.join(out, "posts", "manifest.json"), { channels: [], totalCount: 0 }); + writeJson(path.join(out, "posts", "thequartering-X", "manifest.json"), { pageCount: 0, slugToPage: {} }); + writeJson(path.join(out, "posts", "thequartering-X", "page-0000.json"), []); + writeJson(path.join(dir, "built.json"), { ...stampFields("_hub", "hub"), stampId: "hub-1" }); + const c = ctx(fx, TOKEN); + const res = await runDeployStage(c, { kind: "deploy-hub", target: "_hub" }); + assert.equal(res.status, "ran"); + assert.deepEqual(sidecar(fx, "_hub")[0].argv, [ + "pages", "deploy", out, "--project-name", "archilyzer-hub", "--branch", "main", + ]); + for (const rel of [ + "corpus.json", + "posts/manifest.json", + "posts/thequartering-X/manifest.json", + "posts/thequartering-X/page-0000.json", + ]) { + assert.ok(c.asked.includes(`https://archilyzer-hub.pages.dev/${rel}`), rel); + assert.ok(c.asked.includes(`https://archilyzer-hub.pages.dev/${rel}?cb=hub-1`), `${rel} busted`); + } + const rec = await deployedOf(fx, "_hub"); + assert.equal(rec.production?.liveCheck?.verdict, "ok"); + assert.equal(rec.production?.liveCheck?.tombstones?.length, 3); + assert.ok(c.lines.some((l) => /3 withdrawn path\(s\) read as tombstones/.test(l)), c.lines.join("")); + + // The homepage's project is never the hub's. + writeJson(fx.paths.homepageConfigFile, { cloudflareProject: "archilyzer" }); + await refused(runDeployStage(ctx(fx, TOKEN), { kind: "deploy-hub", target: "_hub", force: true }), 3, /homepage's/); + await refused( + runDeployStage(ctx(fx, TOKEN), { kind: "deploy-hub", target: "_hub", to: "local" }), + 2, + /the hub has no local target/, + ); + } finally { + fx.cleanup(); + } +}); + +test("a stale edge is a WARNING: the deploy is recorded with its verdict and the stage succeeds", async () => { + const fx = fixture(); + try { + site(fx, "anilyzer"); + built(fx, "anilyzer"); + const asked: string[] = []; + const stale = (async (input: string | URL | Request) => { + const url = String(input); + asked.push(url); + const fresh = url.includes("?cb="); + return new Response(JSON.stringify({ generatedAt: fresh ? GENERATED : "2026-10-01T00:00:00.000Z" }), { + status: 200, + headers: { "cf-cache-status": fresh ? "MISS" : "HIT" }, + }); + }) as typeof fetch; + const c = ctx(fx, TOKEN, { liveCheck: { fetch: stale, sleep: async () => {} } }); + const res = await runDeployStage(c, { kind: "deploy-site", target: "anilyzer" }); + assert.equal(res.status, "ran"); + assert.match(res.summary, /live check: stale-edge\.$/); + assert.ok(c.lines.some((l) => l.startsWith("[live] WARNING stale-edge"))); + const rec = (await deployedOf(fx, "anilyzer")); + assert.equal(rec.production?.liveCheck?.verdict, "stale-edge"); + assert.equal(rec.production?.liveCheck?.plain.cfCacheStatus, "HIT"); + } finally { + fx.cleanup(); + } +}); diff --git a/common/publish/deployStage.ts b/common/publish/deployStage.ts @@ -0,0 +1,534 @@ +// THE DEPLOY STAGE (release 18): one body for `publish-deploy-site`, +// `publish-deploy-hub` and `publish-deploy-homepage` (stageBodies.ts runs it), +// whichever runner built the bundle. +// +// It ships `<exportBuildsDir>/<target>/out` (the homepage: `homepage/out`), +// the bundle a build stage wrote and stamped `built.json`, and records what it +// did in `deployed.json` beside it (publish/stamps.ts, S1's shapes and +// writers). The stage's `needs()` (stages.ts needsDeploy) is asked BEFORE this +// body and answers most preconditions first — no build, a run's `builtAfter` +// (it alone: it knows a no-op build's `checkedAt`), a private site, no Pages +// project, the production branch, a bundle that is not the target's, freshness. +// This body asks them again as the last word before wrangler, with the same +// exit codes. In order — and NOTHING is written to `deployed.json` unless every +// step before the record succeeded: +// +// 1. the request (exit 2): a preview branch name Cloudflare keeps verbatim; +// a local deploy has no branch; the hub has no local target; the hub's +// and the homepage's targets are fixed +// 2. the target's own refusals (exit 3): a private site (siteDeployProblem), a +// missing Pages project, the hub's project (hubProjectProblem) +// 3. the build: `built.json` must exist (exit 3, "no build of X in <dir>"), +// and — unless forced — not be the one this slot already shipped (a no-op) +// 4. PRODUCTION ships only a build of `main` (exit 3): a build from another +// branch, or with no branch recorded, is refused (a preview is fine) +// 5. the bundle guards over the bundle itself (exit 3): builtBundleProblem +// (the site's own, by site.json AND corpus.json — the stricter twin of +// builtSiteProblem, naming the directory), builtAudienceProblem, +// builtScopeProblem; the hub's builtHubProblem; the homepage's +// builtHomepageProblem + publishedSourceProblem +// 6. `--to local`: the bundle is copied into ARCHILYZER_SITE_OUT (the +// directory the compose `site` service serves; the homepage's is +// ARCHILYZER_HOMEPAGE_OUT) after localDestProblem, and the stage records +// `local` — no credential, no R2, no wrangler, no live check +// 7. the credential preflight (exit 1): no CLOUDFLARE_API_TOKEN and no +// wrangler OAuth login on disk → refused before wrangler +// 8. a site's oversize archives to R2, from `<id>/.r2-staging` +// 9. the pinned wrangler (wranglerBin), `--branch main` or `--branch <b>`; +// Cloudflare refusing the credential reads as CLOUDFLARE_AUTH_REFUSED +// 10. the live check (liveCheck.ts): a WARNING, never a failure +// 11. the `deployed.json` record (recordDeploy: temp file + rename) +// +// A refusal or a failure THROWS a DeployStageError carrying the exit code the +// stage contract names (1 refused/failed, 2 usage, 3 precondition not met — +// the codes needs() gives the same refusals — 130 cancelled); its message is +// the sentence the log already ends on, word for word, and stageRun.ts does +// not print it again. + +import os from "node:os"; +import path from "node:path"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { cp, mkdir, readdir, realpath, rm } from "node:fs/promises"; +import { runChildIntoLog } from "../jobs/runChild"; +import { + builtAudienceProblem, + builtBundleProblem, + builtHomepageProblem, + builtHubProblem, + builtScopeProblem, + isTombstonePostsTree, + siteDeployProblem, +} from "../lib/builtExport"; +import { getHomepageConfig } from "../lib/homepage"; +import { + CLOUDFLARE_AUTH_REFUSED, + PRODUCTION_BRANCH, + cloudflareCredentialProblem, + deploymentUrlIn, + pagesDeployArgs, + previewAliasUrl, + previewBranchProblem, + wranglerAuthFailureIn, + wranglerBin, + wranglerOAuthConfigFiles, +} from "../lib/pagesDeploy"; +import type { Paths } from "../lib/paths"; +import { PROJECT_URL } from "../lib/project"; +import { getSite, type Site } from "../lib/site"; +import { + HOMEPAGE_PAGES_PROJECT, + PREVIEW_SHARES_ARCHIVES_NOTICE, + bundleDir, + dockerSiteStagingDir, + homepageOutDir, + hubProjectProblem, + runArchiveUploadIntoLog, +} from "./build"; +import { liveCheckLines, runLiveCheck, type LiveCheckDeps } from "./liveCheck"; +import { + HOMEPAGE_TARGET, + HUB_TARGET, + deployRecordFor, + readBuiltStamp, + readDeployedFile, + recordDeploy, + targetDir, + type DeployRecord, + type LiveCheck, +} from "./stamps"; + +// The stamp shapes and their readers/writers are S1's (publish/stamps.ts): +// `built.json` through readBuiltStamp (strict: a malformed stamp is no build), +// `deployed.json` through recordDeploy (atomic, every other slot kept). +export type { BuiltStamp, DeployRecord, DeployedFile } from "./stamps"; +export { HOMEPAGE_TARGET, HUB_TARGET } from "./stamps"; + +export type DeployStageKind = "deploy-site" | "deploy-hub" | "deploy-homepage"; + +export type DeployStageRequest = { + kind: DeployStageKind; + // A site id; "_hub"; "_homepage". + target: string; + runId?: string; + preview?: string; + to?: "pages" | "local"; + force?: boolean; +}; + +export type DeployStageContext = { + paths: Paths; + onLog: (line: string) => void; + signal: AbortSignal; + // Default process.env: the credentials, WRANGLER_BIN, ARCHILYZER_SITE_OUT, + // ARCHILYZER_HOMEPAGE_OUT, E2E_LIVE_CHECK. + env?: Record<string, string | undefined>; + // Where wrangler's OAuth login would be (default os.homedir()). + home?: string; + now?: () => Date; + liveCheck?: LiveCheckDeps; + // The R2 step (default runArchiveUploadIntoLog); the test's seam. + uploadArchives?: (site: Site, stagingDir: string) => Promise<number>; +}; + +export type DeployStageOutcome = { status: "ran" | "noop"; stamp: string; summary: string }; + +/** A refused or failed deploy, with the stage contract's exit code. */ +export class DeployStageError extends Error { + constructor( + message: string, + readonly exitCode: 1 | 2 | 3 | 130, + ) { + super(message); + this.name = "DeployStageError"; + } +} + +/** Where a target's stamps live: `<exportBuildsDir>/<target>/`. */ +export function stampDirOf(paths: Pick<Paths, "exportBuildsDir">, target: string): string { + return targetDir(paths, target); +} + +/** The bundle a deploy of `target` ships. */ +export function bundleDirOf(paths: Paths, kind: DeployStageKind, target: string): string { + return kind === "deploy-homepage" ? homepageOutDir(paths) : bundleDir(paths, target); +} + +function readJson(file: string): unknown { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return undefined; + } +} + +// The pinned wrangler's version (common/node_modules/wrangler), or the +// override's path when WRANGLER_BIN replaced it. +function wranglerLabel(paths: Paths, env: Record<string, string | undefined>): string | undefined { + if (env.WRANGLER_BIN?.trim()) return `WRANGLER_BIN=${env.WRANGLER_BIN.trim()}`; + const pkg = readJson(path.join(paths.monorepoRoot, "common", "node_modules", "wrangler", "package.json")) as + | { version?: unknown } + | undefined; + return typeof pkg?.version === "string" ? pkg.version : undefined; +} + +// The `generatedAt` the bundle's own corpus.json carries: what the live check +// expects when the build stamp names none. +function bundleGeneratedAt(outDir: string): string | null { + const g = (readJson(path.join(outDir, "corpus.json")) as { generatedAt?: unknown } | undefined)?.generatedAt; + return typeof g === "string" ? g : null; +} + +// The hub bundle's tombstone paths worth probing: the posts manifest, and per +// withdrawn channel its manifest and first page. +function hubTombstoneProbes(outDir: string): string[] { + const posts = path.join(outDir, "posts"); + if (!existsSync(posts) || !isTombstonePostsTree(posts)) return []; + const out = ["posts/manifest.json"]; + let slugs: string[] = []; + try { + slugs = readdirSyncDirs(posts); + } catch { + slugs = []; + } + for (const slug of slugs) { + out.push(`posts/${slug}/manifest.json`); + if (existsSync(path.join(posts, slug, "page-0000.json"))) out.push(`posts/${slug}/page-0000.json`); + } + return out; +} + +function readdirSyncDirs(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); +} + +// A path with its symlinks resolved: realpath of its nearest existing +// ancestor, the rest appended (the destination may not exist yet). +async function resolvedPath(p: string): Promise<string> { + let head = path.resolve(p); + const tail: string[] = []; + for (;;) { + try { + return path.join(await realpath(head), ...tail.reverse()); + } catch { + const parent = path.dirname(head); + if (parent === head) return path.resolve(p); + tail.push(path.basename(head)); + head = parent; + } + } +} + +// Why `dest` may not be emptied and filled with `outDir`, or null. The local +// copy EMPTIES its destination, and the stage runs on hosts as well as in the +// container, so a mis-set ARCHILYZER_SITE_OUT (a home dir, the repo, a data +// volume, `export/out` — a link to the last bundle) must not be wiped. With +// every symlink resolved on both sides, the destination may not CONTAIN the +// checkout, the corpus, the builds, export/ or the bundle, nor lie INSIDE the +// corpus, the builds, export/ or the bundle; and a non-empty destination must +// look like a bundle this copy made (an `index.html` at its top — the +// container's placeholder page has one too). +export async function localDestProblem( + dest: string, + outDir: string, + paths: Pick<Paths, "monorepoRoot" | "transcriptsDir" | "exportBuildsDir" | "exportDir">, +): Promise<string | null> { + const d = await resolvedPath(dest); + const inside = (parent: string, child: string) => { + const rel = path.relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + }; + const protectedRoots: [string, string, boolean][] = [ + // [what, dir, may the destination lie inside it?] + ["the checkout", paths.monorepoRoot, true], + ["the corpus", paths.transcriptsDir, false], + ["the builds directory", paths.exportBuildsDir, false], + ["export/", paths.exportDir, false], + ["the bundle", outDir, false], + ]; + for (const [what, dir, insideOk] of protectedRoots) { + const root = await resolvedPath(dir); + if (inside(d, root)) { + return `${dest} holds ${what} (${root}) — a local deploy empties its destination; set it to the directory the local server serves.`; + } + if (!insideOk && inside(root, d)) { + return `${dest} is inside ${what} (${d}) — a local deploy empties its destination; set it to the directory the local server serves.`; + } + } + let entries: string[]; + try { + entries = await readdir(d); + } catch { + return null; // absent: it is made + } + if (entries.length > 0 && !entries.includes("index.html")) { + return `${d} is not empty and holds no index.html, so it is not a bundle a local deploy made — a local deploy empties its destination; empty it by hand or point the variable elsewhere.`; + } + return null; +} + +// Copy `outDir`'s contents into `dest`, emptying it first — its CONTENTS, +// never the directory: in the container it is a volume mount point. +// localDestProblem is asked first. +async function copyToLocal(outDir: string, dest: string): Promise<number> { + await mkdir(dest, { recursive: true }); + for (const e of await readdir(dest)) await rm(path.join(dest, e), { recursive: true, force: true }); + await cp(outDir, dest, { recursive: true, verbatimSymlinks: true }); + let files = 0; + const walk = async (d: string) => { + for (const e of await readdir(d, { withFileTypes: true })) { + if (e.isDirectory()) await walk(path.join(d, e.name)); + else files++; + } + }; + await walk(dest); + return files; +} + +/** + * Run one deploy stage. Returns `ran` (deployed and recorded) or `noop` (this + * slot already holds this build); THROWS a DeployStageError on every refusal + * and failure, leaving `deployed.json` untouched. + */ +export async function runDeployStage( + ctx: DeployStageContext, + req: DeployStageRequest, +): Promise<DeployStageOutcome> { + const { paths, signal } = ctx; + const env = ctx.env ?? process.env; + const now = ctx.now ?? (() => new Date()); + const log = (line: string) => ctx.onLog(line.endsWith("\n") ? line : `${line}\n`); + const refuse = (why: string, exitCode: 1 | 2 | 3 = 1): never => { + const line = /^\[deploy\] REFUSED/.test(why) ? why : `[deploy] REFUSED — ${why}`; + log(line); + throw new DeployStageError(line, exitCode); + }; + + // --- 1. the request (a refusal here is a usage error, exit 2) --- + const target = req.target.trim(); + // The hub's and the homepage's targets are fixed, and a site's is never one + // of them: a mismatch would read and WRITE another target's stamps (a + // homepage deploy recorded in a site's production slot). + const fixed = req.kind === "deploy-hub" ? HUB_TARGET : req.kind === "deploy-homepage" ? HOMEPAGE_TARGET : null; + if (fixed !== null && target !== fixed) { + refuse(`${req.kind} deploys "${fixed}", not "${target}".`, 2); + } + if (fixed === null && (target === HUB_TARGET || target === HOMEPAGE_TARGET)) { + refuse(`"${target}" is not a site — deploy it with ${target === HUB_TARGET ? "deploy-hub" : "deploy-homepage"}.`, 2); + } + const toLocal = req.to === "local"; + if (req.preview !== undefined) { + const problem = previewBranchProblem(req.preview); + if (problem) refuse(problem, 2); + } + const branch = req.preview?.trim() || undefined; + if (toLocal && branch) refuse("a local deploy has no preview branch — deploy locally or as a preview, not both.", 2); + if (toLocal && req.kind === "deploy-hub") refuse("the hub has no local target — deploy it to Cloudflare Pages.", 2); + const recordKind: DeployRecord["kind"] = toLocal ? "local" : branch ? "preview" : "production"; + + // --- 2. the target's own refusals --- + let site: Site | null = null; + let project: string; + let publicUrl: string | undefined; + let cwd = paths.exportDir; + if (req.kind === "deploy-site") { + site = getSite(target, paths); + const privateProblem = siteDeployProblem(site); + if (privateProblem) refuse(`${privateProblem}.`, 3); + project = site.cloudflareProject?.trim() ?? ""; + if (!project && !toLocal) refuse(`Site "${target}" has no Cloudflare Pages project configured.`, 3); + publicUrl = site.siteUrl?.trim() || undefined; + } else if (req.kind === "deploy-hub") { + const hub = getHomepageConfig(paths); + const problem = hubProjectProblem(hub.cloudflareProject); + if (problem) refuse(problem, 3); + project = hub.cloudflareProject!.trim(); + publicUrl = hub.siteUrl; + } else { + project = HOMEPAGE_PAGES_PROJECT; + publicUrl = PROJECT_URL; + cwd = path.join(paths.monorepoRoot, "homepage"); + } + + // --- 3. the build --- + const stampDir = stampDirOf(paths, target); + const outDir = bundleDirOf(paths, req.kind, target); + const built = await readBuiltStamp(paths, target); + const cliTarget = req.kind === "deploy-hub" ? "hub" : req.kind === "deploy-homepage" ? "homepage" : target; + if (!built) { + refuse( + `no build of ${target} in ${stampDir} — archilyzer publish ${req.kind === "deploy-site" ? `build ${cliTarget}` : cliTarget}`, + 3, + ); + } + const b = built!; + // (A run's `builtAfter` is the stage's needs() — stages.ts needsDeploy — asked + // before this body runs: it knows a no-op build's `checkedAt`.) + const slot = deployRecordFor(await readDeployedFile(paths, target), recordKind, branch); + if (!req.force && slot?.builtStampId === b.stampId) { + const where = recordKind === "preview" ? `preview "${branch}"` : recordKind; + const summary = `${target}: build ${b.stampId} is already deployed (${where}, ${new Date(slot.at).toISOString()}).`; + log(`[deploy] ${summary} Nothing to do.`); + return { status: "noop", stamp: b.stampId, summary }; + } + + // --- 4. production ships only a build of main (a build with no branch + // recorded — a detached HEAD, an image built without ARCHILYZER_BRANCH — is + // refused the same way: S1's rule, stages.ts needsDeploy) --- + if (recordKind === "production" && b.branch !== PRODUCTION_BRANCH) { + refuse( + (b.branch === null + ? `the build of ${target} has no branch recorded (a detached HEAD, or an image built without ARCHILYZER_BRANCH)` + : `the build of ${target} was made from branch "${b.branch}", not ${PRODUCTION_BRANCH}`) + + `: production ships only a build of ${PRODUCTION_BRANCH}. Build it from ${PRODUCTION_BRANCH}, or deploy this one as a preview.`, + 3, + ); + } + + // --- 5. the bundle guards --- + if (req.kind === "deploy-site") { + const problem = + builtBundleProblem(outDir, target) ?? builtAudienceProblem(outDir) ?? builtScopeProblem(site!, outDir); + if (problem) { + refuse(`${problem}. Nothing was sent to Cloudflare Pages; build ${target} again, then deploy.`, 3); + } + } else if (req.kind === "deploy-hub") { + const problem = builtHubProblem(outDir); + if (problem) refuse(`${problem.replace(/^export\/out/, outDir)}.`, 3); + } else { + const problem = + builtHomepageProblem(outDir) ?? (await (await import("./source")).publishedSourceProblem(paths, outDir)); + if (problem) refuse(problem, 3); + } + + const builtAt = b.builtAt; + const at = () => now().getTime(); + const record = async (r: DeployRecord): Promise<void> => { + await recordDeploy(paths, target, r); + }; + + // --- 6. --to local --- + if (toLocal) { + const dest = + (req.kind === "deploy-homepage" ? env.ARCHILYZER_HOMEPAGE_OUT : env.ARCHILYZER_SITE_OUT)?.trim() ?? ""; + if (!dest) { + refuse( + req.kind === "deploy-homepage" + ? "--to local needs ARCHILYZER_HOMEPAGE_OUT — the directory the local homepage service serves (the container sets it)." + : "--to local needs ARCHILYZER_SITE_OUT — the directory the local site service serves (the container sets it).", + 3, + ); + } + const destProblem = await localDestProblem(dest, outDir, paths); + if (destProblem) refuse(destProblem); + log(`[deploy] ${target} → ${dest} (local)`); + const files = await copyToLocal(outDir, dest); + if (signal.aborted) throw new DeployStageError("[deploy] cancelled.", 130); + await record({ builtStampId: b.stampId, builtAt, kind: "local", url: null, at: at(), liveCheck: null }); + const summary = `${target}: build ${b.stampId} copied to ${dest} (${files} files).`; + log(`[deployed] ${summary}`); + return { status: "ran", stamp: b.stampId, summary }; + } + + // --- 7. the credential preflight --- + const home = ctx.home ?? os.homedir(); + const oauth = wranglerOAuthConfigFiles(home, env).some((f) => existsSync(f)); + const credProblem = cloudflareCredentialProblem(env, oauth); + if (credProblem) refuse(`${credProblem}.`); + + // --- 8. the archives, before the pages that link them --- + if (branch) { + log(`=== Deploy ${target} (preview "${branch}") ===`); + if (site) log(PREVIEW_SHARES_ARCHIVES_NOTICE); + } + if (site) { + const staging = dockerSiteStagingDir(paths, target); + const upload = + ctx.uploadArchives ?? ((s: Site, dir: string) => runArchiveUploadIntoLog(ctx.onLog, signal, s, paths, dir)); + const code = await upload(site, staging); + if (signal.aborted) throw new DeployStageError("[deploy] cancelled.", 130); + if (code !== 0) { + const line = `[deploy] FAILED — the archive R2 upload exited ${code}; nothing was sent to Cloudflare Pages.`; + log(line); + throw new DeployStageError(line, 1); + } + } + + // --- 9. wrangler --- + let deploymentUrl: string | null = null; + let authRefused = false; + const watch = (line: string) => { + if (deploymentUrl === null) deploymentUrl = deploymentUrlIn(line, project); + if (!authRefused && wranglerAuthFailureIn(line)) authRefused = true; + // Through log(): runChildIntoLog hands lines without their newline, and a + // stage child writing raw to stdout would run wrangler's output together. + log(line); + }; + const code = await runChildIntoLog(watch, signal, { + command: wranglerBin(paths, env), + args: pagesDeployArgs({ outDir, project, previewBranch: branch }), + cwd, + env: { ...env, NODE_ENV: "production" }, + }); + if (signal.aborted) throw new DeployStageError("[deploy] cancelled.", 130); + if (code !== 0) { + if (authRefused) { + log(CLOUDFLARE_AUTH_REFUSED); + throw new DeployStageError(CLOUDFLARE_AUTH_REFUSED, 1); + } + const line = `[deploy] FAILED — wrangler exited ${code}.`; + log(line); + throw new DeployStageError(line, 1); + } + const alias = branch ? previewAliasUrl(project, branch) : undefined; + const shipped: string | null = deploymentUrl; + if (alias) log(`[preview] ${alias}${shipped ? ` (this deployment: ${shipped})` : ""}`); + else if (shipped) log(`[deployed] ${shipped}`); + + // --- 10. the live check --- + const checkUrl = (branch ? alias : publicUrl) ?? shipped; + let liveCheck: LiveCheck | null = null; + if (checkUrl) { + liveCheck = await runLiveCheck( + { + url: checkUrl, + builtStampId: b.stampId, + expected: req.kind === "deploy-homepage" ? null : (b.corpusGeneratedAt ?? bundleGeneratedAt(outDir)), + path: req.kind === "deploy-homepage" ? "" : "corpus.json", + tombstones: req.kind === "deploy-hub" ? hubTombstoneProbes(outDir) : undefined, + }, + { env, signal, ...ctx.liveCheck }, + ); + // Cancelled while checking: the deploy itself happened, so it is recorded — + // with no live check — and the stage ends cancelled. + if (signal.aborted) liveCheck = null; + else for (const line of liveCheckLines(liveCheck)) log(line); + } else { + log("[live] no URL to check: the site has no public URL and wrangler printed no deployment URL."); + } + + // --- 11. the record --- + await record({ + builtStampId: b.stampId, + builtAt, + kind: recordKind, + ...(branch ? { branch } : {}), + url: shipped, + ...(alias ? { alias } : {}), + at: at(), + ...(wranglerLabel(paths, env) ? { wrangler: wranglerLabel(paths, env) } : {}), + liveCheck, + }); + if (signal.aborted) { + const line = `[deploy] cancelled during the live check — ${target} was deployed and is recorded without one.`; + log(line); + throw new DeployStageError(line, 130); + } + const verdict = liveCheck ? ` — live check: ${liveCheck.verdict}` : ""; + const summary = + `${target}: build ${b.stampId} deployed to ${recordKind === "preview" ? `preview "${branch}"` : "production"}` + + ` (${project})${verdict}.`; + return { status: "ran", stamp: b.stampId, summary }; +} diff --git a/common/publish/liveCheck.test.ts b/common/publish/liveCheck.test.ts @@ -0,0 +1,274 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + cacheBusted, + liveCheckLines, + liveCheckVerdict, + liveUrl, + runLiveCheck, + type Probe, +} from "./liveCheck"; + +// Run with: pnpm --filter yt-dlp-transcript-common test +// +// Every case runs over an injected fetch and sleep: no test reaches a network. + +const NEW = "2026-10-06T10:00:00.000Z"; +const OLD = "2026-10-01T09:00:00.000Z"; +const STAMP = "01J-built"; + +type Answer = { status: number; body?: unknown; headers?: Record<string, string> } | Error; + +// A fetch that answers by URL: `plain` for the URL without a query, `busted` +// for the cache-busted one; records every URL asked. +function fakeFetch(route: (url: string, busted: boolean) => Answer) { + const asked: string[] = []; + const f = (async (input: string | URL | Request) => { + const url = String(input); + asked.push(url); + const a = route(url, url.includes("?cb=")); + if (a instanceof Error) throw a; + return new Response(a.body === undefined ? "" : JSON.stringify(a.body), { + status: a.status, + headers: a.headers, + }); + }) as typeof fetch; + return { f, asked }; +} + +const noSleep = { calls: 0, fn: async () => {} }; +function sleeper() { + const s = { calls: 0, ms: [] as number[], fn: async (ms: number) => void (s.calls++, s.ms.push(ms)) }; + return s; +} +const NOW = () => new Date("2026-10-06T10:05:00.000Z"); + +test("liveUrl and cacheBusted", () => { + assert.equal(liveUrl("https://a.pages.dev/", "/corpus.json"), "https://a.pages.dev/corpus.json"); + assert.equal(liveUrl("https://a.pages.dev", "posts/manifest.json"), "https://a.pages.dev/posts/manifest.json"); + assert.equal(cacheBusted("https://a/corpus.json", "s 1"), "https://a/corpus.json?cb=s%201"); + assert.equal(cacheBusted("https://a/c.json", "s", 3), "https://a/c.json?cb=s&try=3"); +}); + +test("liveCheckVerdict: the table", () => { + const ok: Probe = { status: 200, generatedAt: NEW }; + const old: Probe = { status: 200, generatedAt: OLD, cfCacheStatus: "HIT" }; + const down: Probe = { status: null, error: "ECONNREFUSED" }; + const missing: Probe = { status: 404 }; + assert.equal(liveCheckVerdict(ok, ok, NEW), "ok"); + assert.equal(liveCheckVerdict(old, ok, NEW), "stale-edge"); + // A plain read that fails is not staleness: a visitor gets nothing. + assert.equal(liveCheckVerdict(missing, ok, NEW), "unreachable"); + assert.equal(liveCheckVerdict(down, ok, NEW), "unreachable"); + assert.equal(liveCheckVerdict(old, old, NEW), "mismatch"); + assert.equal(liveCheckVerdict(ok, old, NEW), "mismatch"); + assert.equal(liveCheckVerdict(down, down, NEW), "unreachable"); + assert.equal(liveCheckVerdict(missing, missing, NEW), "unreachable"); + assert.equal(liveCheckVerdict(ok, down, NEW), "ok"); + // No expected generatedAt (a homepage): answering is all that is asked. + assert.equal(liveCheckVerdict({ status: 200 }, { status: 200 }, null), "ok"); +}); + +test("ok: both reads serve this build, on the first try", async () => { + const { f, asked } = fakeFetch(() => ({ + status: 200, + body: { generatedAt: NEW }, + headers: { "cf-cache-status": "MISS", age: "0", "cache-control": "public, max-age=0, must-revalidate" }, + })); + const s = sleeper(); + const check = await runLiveCheck( + { url: "https://jeralyzer.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: s.fn, now: NOW, env: {} }, + ); + assert.equal(check.verdict, "ok"); + assert.equal(check.at, Date.parse("2026-10-06T10:05:00.000Z")); + assert.equal(check.url, "https://jeralyzer.pages.dev"); + assert.equal(check.expected, NEW); + assert.deepEqual(check.plain, { + status: 200, + generatedAt: NEW, + cfCacheStatus: "MISS", + age: 0, + cacheControl: "public, max-age=0, must-revalidate", + }); + assert.deepEqual(asked, [ + "https://jeralyzer.pages.dev/corpus.json", + `https://jeralyzer.pages.dev/corpus.json?cb=${STAMP}`, + ]); + assert.equal(s.calls, 0); + assert.match(liveCheckLines(check)[0], /^\[live\] ok — https:\/\/jeralyzer\.pages\.dev serves this build/); +}); + +test("stale-edge: the edge HITs an old corpus.json, the busted read is this build — three tries, 10 s apart", async () => { + const { f, asked } = fakeFetch((_u, busted): Answer => + busted + ? { status: 200, body: { generatedAt: NEW }, headers: { "cf-cache-status": "MISS" } } + : { + status: 200, + body: { generatedAt: OLD }, + headers: { "cf-cache-status": "HIT", age: "86400", "cache-control": "public, s-maxage=604800" }, + }, + ); + const s = sleeper(); + const check = await runLiveCheck( + { url: "https://archilyzer-hub.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: s.fn, now: NOW, env: {} }, + ); + assert.equal(check.verdict, "stale-edge"); + assert.equal(check.plain.cfCacheStatus, "HIT"); + assert.equal(check.plain.generatedAt, OLD); + assert.equal(check.plain.age, 86400); + assert.equal(check.plain.cacheControl, "public, s-maxage=604800"); + assert.equal(check.busted.generatedAt, NEW); + assert.equal(asked.length, 6); + assert.deepEqual(s.ms, [10_000, 10_000]); + // Each retry busts with a query the edge has not seen either. + assert.ok(asked.includes(`https://archilyzer-hub.pages.dev/corpus.json?cb=${STAMP}&try=3`)); + const [line] = liveCheckLines(check); + assert.match(line, /^\[live\] WARNING stale-edge — /); + assert.match(line, /cf-cache-status HIT/); +}); + +test("a fresh deployment that answers on the second try is ok, after one sleep", async () => { + let n = 0; + const { f } = fakeFetch(() => { + n++; + return n <= 2 ? { status: 404 } : { status: 200, body: { generatedAt: NEW } }; + }); + const s = sleeper(); + const check = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: s.fn, now: NOW, env: {} }, + ); + assert.equal(check.verdict, "ok"); + assert.equal(s.calls, 1); +}); + +test("mismatch: the deployment itself serves another build", async () => { + const { f } = fakeFetch(() => ({ status: 200, body: { generatedAt: OLD } })); + const check = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: noSleep.fn, now: NOW, env: {} }, + ); + assert.equal(check.verdict, "mismatch"); + assert.match(liveCheckLines(check)[0], /^\[live\] WARNING mismatch — https:\/\/x\.pages\.dev does not serve this build/); +}); + +test("unreachable: nothing answers; the error is recorded, never thrown", async () => { + const { f } = fakeFetch(() => new Error("getaddrinfo ENOTFOUND x.pages.dev")); + const check = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: noSleep.fn, now: NOW, env: {} }, + ); + assert.equal(check.verdict, "unreachable"); + assert.equal(check.plain.status, null); + assert.match(check.plain.error ?? "", /ENOTFOUND/); + assert.match(liveCheckLines(check)[0], /^\[live\] WARNING unreachable — .*The deploy itself succeeded\.$/); +}); + +test("skipped: E2E_LIVE_CHECK=skip asks nothing", async () => { + const { f, asked } = fakeFetch(() => ({ status: 200 })); + const check = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: noSleep.fn, now: NOW, env: { E2E_LIVE_CHECK: "skip" } }, + ); + assert.equal(check.verdict, "skipped"); + assert.deepEqual(check.plain, { status: null }); + assert.deepEqual(asked, []); + assert.deepEqual(liveCheckLines(check), ["[live] skipped (E2E_LIVE_CHECK=skip)."]); +}); + +test("the hub's tombstones: each path read plain and busted; an old shard at the edge is stale-edge", async () => { + const tombstones = [ + "posts/manifest.json", + "posts/thequartering-X/manifest.json", + "posts/thequartering-X/page-0000.json", + ]; + const body = (url: string, stale: boolean): unknown => { + if (url.includes("/corpus.json")) return { generatedAt: NEW }; + if (url.includes("/posts/manifest.json")) return stale ? { channels: [{ slug: "thequartering-X" }] } : { channels: [] }; + if (url.includes("/manifest.json")) return { pageCount: stale ? 1 : 0 }; + return stale ? [{ id: "1" }] : []; + }; + // The deployment has the tombstones; the edge still serves the old shard + // page at its plain URL. + const { f, asked } = fakeFetch((url, busted) => ({ + status: 200, + body: body(url, !busted && url.endsWith("page-0000.json")), + })); + const check = await runLiveCheck( + { url: "https://archilyzer-hub.pages.dev", builtStampId: STAMP, expected: NEW, tombstones }, + { fetch: f, sleep: noSleep.fn, now: NOW, env: {} }, + ); + assert.equal(check.verdict, "stale-edge"); + assert.deepEqual( + check.tombstones?.map((t) => [t.path, t.ok]), + [ + ["posts/manifest.json", true], + ["posts/thequartering-X/manifest.json", true], + ["posts/thequartering-X/page-0000.json", false], + ], + ); + assert.ok(asked.includes(`https://archilyzer-hub.pages.dev/posts/thequartering-X/page-0000.json?cb=${STAMP}`)); + const lines = liveCheckLines(check); + assert.match(lines[0], /^\[live\] WARNING stale-edge — .* 1 withdrawn path\(s\) do not read as tombstones/); + assert.match(lines[1], /^\[live\] tombstone posts\/thequartering-X\/page-0000\.json: /); + + // All three read as tombstones: ok. + const good = fakeFetch((url) => ({ status: 200, body: body(url, false) })); + const ok = await runLiveCheck( + { url: "https://archilyzer-hub.pages.dev", builtStampId: STAMP, expected: NEW, tombstones }, + { fetch: good.f, sleep: noSleep.fn, now: NOW, env: {} }, + ); + assert.equal(ok.verdict, "ok"); + assert.match(liveCheckLines(ok)[0], /3 withdrawn path\(s\) read as tombstones\.$/); + + // The deployment itself lacks a tombstone (busted reads the old shard): mismatch. + const bad = fakeFetch((url) => ({ status: 200, body: body(url, url.includes("page-0000")) })); + const mm = await runLiveCheck( + { url: "https://archilyzer-hub.pages.dev", builtStampId: STAMP, expected: NEW, tombstones }, + { fetch: bad.f, sleep: noSleep.fn, now: NOW, env: {} }, + ); + assert.equal(mm.verdict, "mismatch"); +}); + +test("Cancel stops the check: no read after the abort, no sleep waited out", async () => { + const cancel = new AbortController(); + let reads = 0; + const { f } = fakeFetch((_u, busted): Answer => { + reads++; + // The first pair reads an old edge copy; Cancel arrives during the busted read. + if (busted) cancel.abort(); + return { status: 200, body: { generatedAt: busted ? NEW : OLD } }; + }); + const s = sleeper(); + const check = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: s.fn, now: NOW, env: {}, signal: cancel.signal }, + ); + assert.equal(reads, 2, "no retry after Cancel"); + assert.equal(s.calls, 0); + assert.equal(check.verdict, "stale-edge", "what was read is kept"); + + // Cancelled before anything was read: unreachable, naming the cancel. + const early = new AbortController(); + early.abort(); + const none = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: f, sleep: s.fn, now: NOW, env: {}, signal: early.signal }, + ); + assert.equal(none.verdict, "unreachable"); + assert.equal(none.plain.error, "cancelled"); + + // The default sleep wakes on the abort rather than waiting its 10 s out. + const mid = new AbortController(); + const old = fakeFetch(() => ({ status: 200, body: { generatedAt: OLD } })); + const started = Date.now(); + setTimeout(() => mid.abort(), 50); + const stopped = await runLiveCheck( + { url: "https://x.pages.dev", builtStampId: STAMP, expected: NEW }, + { fetch: old.f, now: NOW, env: {}, signal: mid.signal }, + ); + assert.ok(Date.now() - started < 5_000, "the 10 s interval was not waited out"); + assert.equal(stopped.verdict, "mismatch"); +}); diff --git a/common/publish/liveCheck.ts b/common/publish/liveCheck.ts @@ -0,0 +1,287 @@ +// THE LIVE CHECK (release 18): after every deploy, read what the URL actually +// serves. +// +// A deploy that exits 0 says wrangler uploaded a bundle; it does not say a +// visitor gets it. Cloudflare's edge keeps serving a cached object after a +// deploy that changed or removed it (the hub served a withdrawn X shard from a +// 7-day cache after the deploy that took it out), and a deploy can land on a +// preview when production was meant. So the deploy stage reads +// `<url>/corpus.json` twice — PLAIN, as a visitor would, and CACHE-BUSTED +// (`?cb=<builtStampId>`), which the edge has never seen and so fetches from the +// deployment — and compares each `generatedAt` with the build's own +// (`built.corpusGeneratedAt`): +// +// ok both serve this build (or the plain one does and the busted +// read failed: a visitor gets this build) +// stale-edge the busted read serves this build, the plain one answers 2xx +// with something else: the deployment is right and the edge +// still has the old object +// mismatch the busted read serves something else: not this build +// unreachable neither read answered 2xx, or the plain read failed (a +// visitor gets nothing, whatever the deployment holds) +// skipped E2E_LIVE_CHECK=skip (the e2e suite, whose fake wrangler +// deploys nothing) +// +// stale-edge and mismatch are WARNINGS: the deploy itself succeeded and the job +// ends `done`; the verdict is recorded in deployed.json and said in the log. +// Three tries, 10 s apart, before a verdict short of ok stands — a fresh +// deployment takes a few seconds to answer everywhere. +// +// The hub's deploy also probes its TOMBSTONES (publish/tombstones.ts): each +// path must answer, plain and busted, with the empty object that replaced the +// withdrawn content. +// +// Everything that touches the network or the clock is injected: `fetch`, +// `sleep`, `now`, `env`. + +// The record shapes are S1's (publish/stamps.ts): `at` is ms, `age` seconds. +import type { LiveCheck, Probe } from "./stamps"; +export type { LiveCheck, Probe } from "./stamps"; + +export type LiveCheckVerdict = LiveCheck["verdict"]; + +export type TombstoneProbe = NonNullable<LiveCheck["tombstones"]>[number]; + +export type LiveCheckDeps = { + // The stage's Cancel: stops between reads and tries, aborts a read in flight. + signal?: AbortSignal; + fetch?: typeof fetch; + sleep?: (ms: number) => Promise<void>; + now?: () => Date; + env?: Record<string, string | undefined>; + tries?: number; + intervalMs?: number; + timeoutMs?: number; +}; + +export const LIVE_CHECK_TRIES = 3; +export const LIVE_CHECK_INTERVAL_MS = 10_000; +const LIVE_CHECK_TIMEOUT_MS = 15_000; + +/** The root-relative URL of `rel` under `base` (a site URL, alias or deployment URL). */ +export function liveUrl(base: string, rel: string): string { + return `${base.replace(/\/+$/, "")}/${rel.replace(/^\/+/, "")}`; +} + +/** The cache-busted form of `url`: a query the edge has never seen. */ +export function cacheBusted(url: string, builtStampId: string, attempt = 1): string { + const cb = encodeURIComponent(builtStampId); + return `${url}${url.includes("?") ? "&" : "?"}cb=${cb}${attempt > 1 ? `&try=${attempt}` : ""}`; +} + +const reached = (p: Probe) => p.status !== null && p.status >= 200 && p.status < 300; + +/** + * The verdict for one plain + busted pair against the build's `expected` + * `generatedAt` (null: the build named none, so answering 2xx is all that is + * asked). Pure. + */ +export function liveCheckVerdict( + plain: Probe, + busted: Probe, + expected: string | null, +): Exclude<LiveCheckVerdict, "skipped"> { + const serves = (p: Probe) => reached(p) && (expected === null || p.generatedAt === expected); + if (!reached(plain) && !reached(busted)) return "unreachable"; + if (serves(busted)) { + if (serves(plain)) return "ok"; + // Stale only when the edge ANSWERS with another object; a plain read that + // fails is not staleness — a visitor gets nothing. + return reached(plain) ? "stale-edge" : "unreachable"; + } + // The busted read failed but the plain one serves this build: a visitor + // gets it, which is what the check is for. + if (!reached(busted) && serves(plain)) return "ok"; + return "mismatch"; +} + +// Whether a parsed body is the tombstone that belongs at `rel`. +function isTombstoneBody(rel: string, body: unknown): boolean { + if (/\/page-\d+\.json$/.test(rel)) return Array.isArray(body) && body.length === 0; + if (rel === "posts/manifest.json") { + const ch = (body as { channels?: unknown } | null)?.channels; + return Array.isArray(ch) && ch.length === 0; + } + return (body as { pageCount?: unknown } | null)?.pageCount === 0; +} + +type Read = { probe: Probe; body: unknown }; + +async function read(url: string, f: typeof fetch, timeoutMs: number, cancel?: AbortSignal): Promise<Read> { + try { + const timeout = AbortSignal.timeout(timeoutMs); + const signal = cancel ? AbortSignal.any([cancel, timeout]) : timeout; + const res = await f(url, { redirect: "follow", signal }); + const probe: Probe = { status: res.status }; + const h = (name: string) => res.headers.get(name) ?? undefined; + if (h("cf-cache-status")) probe.cfCacheStatus = h("cf-cache-status"); + const age = Number(h("age")); + if (h("age") !== undefined && Number.isFinite(age)) probe.age = age; + if (h("cache-control")) probe.cacheControl = h("cache-control"); + let body: unknown; + try { + body = JSON.parse(await res.text()); + } catch { + body = undefined; + } + const generatedAt = (body as { generatedAt?: unknown } | undefined)?.generatedAt; + if (typeof generatedAt === "string") probe.generatedAt = generatedAt; + return { probe, body }; + } catch (err) { + return { + probe: { status: null, error: err instanceof Error ? err.message : String(err) }, + body: undefined, + }; + } +} + +/** + * Read `<url>/<path>` (default `corpus.json`) plain and cache-busted, and each + * of `tombstones` the same way; retry up to `tries` times, `intervalMs` apart, + * until everything reads ok. Never throws. A `signal` that aborts stops it + * between reads and tries (the result then carries what was read, or an + * `unreachable` naming the cancel); the caller decides what a cancelled check + * means. + */ +export async function runLiveCheck( + opts: { + url: string; + builtStampId: string; + expected: string | null; + path?: string; + tombstones?: readonly string[]; + }, + deps: LiveCheckDeps = {}, +): Promise<LiveCheck> { + const now = deps.now ?? (() => new Date()); + const env = deps.env ?? process.env; + const base: Omit<LiveCheck, "plain" | "busted" | "verdict"> = { + at: now().getTime(), + url: opts.url, + expected: opts.expected, + }; + if (env.E2E_LIVE_CHECK === "skip") { + return { ...base, plain: { status: null }, busted: { status: null }, verdict: "skipped" }; + } + const f = deps.fetch ?? fetch; + const cancel = deps.signal; + const sleep = + deps.sleep ?? + ((ms: number) => + new Promise<void>((resolve) => { + const t = setTimeout(done, ms); + function done() { + clearTimeout(t); + cancel?.removeEventListener("abort", done); + resolve(); + } + cancel?.addEventListener("abort", done, { once: true }); + })); + const tries = Math.max(1, deps.tries ?? LIVE_CHECK_TRIES); + const interval = deps.intervalMs ?? LIVE_CHECK_INTERVAL_MS; + const timeout = deps.timeoutMs ?? LIVE_CHECK_TIMEOUT_MS; + const target = liveUrl(opts.url, opts.path ?? "corpus.json"); + + let check: LiveCheck | null = null; + for (let attempt = 1; attempt <= tries; attempt++) { + if (attempt > 1) await sleep(interval); + if (cancel?.aborted) break; + const plain = (await read(target, f, timeout, cancel)).probe; + const busted = (await read(cacheBusted(target, opts.builtStampId, attempt), f, timeout, cancel)).probe; + let verdict: LiveCheckVerdict = liveCheckVerdict(plain, busted, opts.expected); + let tombstones: TombstoneProbe[] | undefined; + if (opts.tombstones && opts.tombstones.length > 0) { + tombstones = []; + let staleOnly = true; + for (const rel of opts.tombstones) { + if (cancel?.aborted) break; + const u = liveUrl(opts.url, rel); + const p = await read(u, f, timeout, cancel); + const b = await read(cacheBusted(u, opts.builtStampId, attempt), f, timeout, cancel); + const pOk = reached(p.probe) && isTombstoneBody(rel, p.body); + const bOk = reached(b.probe) && isTombstoneBody(rel, b.body); + tombstones.push({ path: rel, plain: p.probe, busted: b.probe, ok: pOk && bOk }); + if (!(pOk && bOk) && !bOk) staleOnly = false; + } + // The corpus reads ok but a tombstone does not: the edge still serves + // the withdrawn object (stale-edge) — or the deployment never had it. + if (verdict === "ok" && tombstones.some((t) => !t.ok)) { + verdict = staleOnly ? "stale-edge" : "mismatch"; + } + } + check = { ...base, plain, busted, verdict, ...(tombstones ? { tombstones } : {}) }; + if (verdict === "ok" || cancel?.aborted) break; + } + return ( + check ?? { + ...base, + plain: { status: null, error: "cancelled" }, + busted: { status: null, error: "cancelled" }, + verdict: "unreachable", + } + ); +} + +function describe(p: Probe): string { + if (p.status === null) return p.error ? `no answer (${p.error})` : "no answer"; + const parts = [`HTTP ${p.status}`]; + if (p.generatedAt) parts.push(`generatedAt ${p.generatedAt}`); + if (p.cfCacheStatus) parts.push(`cf-cache-status ${p.cfCacheStatus}`); + if (p.age) parts.push(`age ${p.age}`); + if (p.cacheControl) parts.push(`cache-control "${p.cacheControl}"`); + return parts.join(", "); +} + +/** + * The log lines a live check ends a deploy on: one verdict line (WARNING for + * anything short of ok but skipped), and one per tombstone that read wrong. + */ +export function liveCheckLines(check: LiveCheck): string[] { + const where = check.url; + const want = check.expected ? `this build (generatedAt ${check.expected})` : "this build"; + switch (check.verdict) { + case "skipped": + return ["[live] skipped (E2E_LIVE_CHECK=skip)."]; + case "ok": { + const n = check.tombstones?.length ?? 0; + return [ + `[live] ok — ${where} serves ${want}; plain: ${describe(check.plain)}` + + (n > 0 ? `; ${n} withdrawn path(s) read as tombstones.` : "."), + ]; + } + case "unreachable": + return [ + `[live] WARNING unreachable — ${where} did not answer: plain ${describe(check.plain)}; ` + + `cache-busted ${describe(check.busted)}. The deploy itself succeeded.`, + ]; + case "stale-edge": + case "mismatch": { + const bad = (check.tombstones ?? []).filter((t) => !t.ok); + const corpusOk = liveCheckVerdict(check.plain, check.busted, check.expected) === "ok"; + const lines = corpusOk + ? [ + `[live] WARNING ${check.verdict} — ${where} serves ${want}, but ${bad.length} withdrawn ` + + `path(s) do not read as tombstones` + + (check.verdict === "stale-edge" + ? ": the deployment has them, Cloudflare's edge still serves the old objects until they expire." + : ": the deployment does not serve them."), + ] + : check.verdict === "stale-edge" + ? [ + `[live] WARNING stale-edge — the deployment serves ${want}, but ${where} still answers ` + + `with an older object from Cloudflare's edge: plain ${describe(check.plain)}; ` + + `cache-busted ${describe(check.busted)}. It is replaced when the edge's copy expires.`, + ] + : [ + `[live] WARNING mismatch — ${where} does not serve ${want}: plain ${describe(check.plain)}; ` + + `cache-busted ${describe(check.busted)}.`, + ]; + for (const t of bad) { + lines.push( + `[live] tombstone ${t.path}: plain ${describe(t.plain)}; cache-busted ${describe(t.busted)}.`, + ); + } + return lines; + } + } +} diff --git a/common/publish/stageBodies.ts b/common/publish/stageBodies.ts @@ -9,13 +9,11 @@ // forced → a no-op. Ordering between the stages of one run is enforced here, // not in anybody's memory. // -// The three deploy bodies call today's deploy functions in build.ts with the -// target's own bundle; release 18 S2 rewires them to its deploy stage -// (pinned wrangler, credential preflight, the live check). +// The three deploy bodies are release 18 S2's deploy stage (deployStage.ts): +// pinned wrangler, credential preflight, the live check, deployed.json. import { execFile } from "node:child_process"; -import { cp, mkdir, readdir, rm } from "node:fs/promises"; -import path from "node:path"; +import { readdir } from "node:fs/promises"; import { promisify } from "node:util"; import { builtAudienceProblem, @@ -25,10 +23,10 @@ import { siteDeployProblem, } from "../lib/builtExport"; import { getHomepageConfig } from "../lib/homepage"; -import { previewAliasUrl, previewBranchProblem } from "../lib/pagesDeploy"; +import { previewBranchProblem } from "../lib/pagesDeploy"; import type { Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; -import { getSite, listSites, type Site } from "../lib/site"; +import { listSites, type Site } from "../lib/site"; import { ALL_TARGET, HOMEPAGE_TARGET, @@ -38,17 +36,14 @@ import { readBuiltStamp, readDeployedFile, readIndexStamp, - recordDeploy, writeBuiltStamp, type BuiltKind, type BuiltStamp, - type DeployRecord, type IndexStamp, type Runner, } from "./stamps"; import { STAGES, - deployKindOf, type NeedsInput, type StageContext, type StageOutcome, @@ -535,92 +530,13 @@ async function buildHomepageStage(ctx: StageContext, stamp: IndexStamp): Promise // deploys // --------------------------------------------------------------------------- -/** Where `--to local` publishes a site: the `site` service's volume. */ -export function localSiteOut(env: NodeJS.ProcessEnv = process.env): string | null { - return env.ARCHILYZER_SITE_OUT?.trim() || null; -} - -// …and the homepage: what the `homepage` service serves (release 18 S5 -// declares the name; read by name here until both slices are merged). -const HOMEPAGE_OUT_NAME = "ARCHILYZER_HOMEPAGE_OUT"; - -export function localHomepageOut(env: NodeJS.ProcessEnv = process.env): string | null { - return env[HOMEPAGE_OUT_NAME]?.trim() || null; -} - -// Replace the CONTENTS of `dest` (a volume mount: never the directory itself). -async function publishLocal(src: string, dest: string): Promise<void> { - await mkdir(dest, { recursive: true }); - for (const name of await readdir(dest)) await rm(path.join(dest, name), { recursive: true, force: true }); - await cp(src, dest, { recursive: true }); -} - -// Spot the deployment URL build.ts logs on success. -function urlWatcher(onLog: (l: string) => void): { onLog: (l: string) => void; url: () => string | null } { - let url: string | null = null; - return { - onLog: (line) => { - const m = /^\[deployed\] (\S+)/.exec(line) ?? /\(this deployment: (\S+)\)/.exec(line); - if (m) url = m[1]; - onLog(line); - }, - url: () => url, - }; -} - -async function deployStage( - ctx: StageContext, - r: StageRequest, - target: string, - ship: (o: { onLog: (l: string) => void; previewBranch?: string }) => Promise<void>, - local: { src: string; dest: string | null; needs: string } | null, - project: string | null, -): Promise<StageOutcome> { - const { paths, onLog, signal } = ctx; - const built = (await readBuiltStamp(paths, target))!; - const kind = deployKindOf(r); - let url: string | null = null; - let alias: string | undefined; - if (kind === "local") { - if (!local) throw new StageFailure(`${target} has no local target`, 2); - if (!local.dest) { - throw new StageFailure(`--to local needs ${local.needs}`, 3); - } - // A bundle built private is never published, here either. - const priv = builtAudienceProblem(local.src); - if (priv) throw new StageFailure(`${priv}. Build ${target} again, then deploy.`, 3); - onLog(`[publish] copying ${local.src} -> ${local.dest}\n`); - await publishLocal(local.src, local.dest); - } else { - if (r.preview !== undefined) { - const problem = previewBranchProblem(r.preview); - if (problem) throw new StageFailure(problem, 2); - } - const w = urlWatcher(onLog); - try { - await ship({ onLog: w.onLog, previewBranch: r.preview }); - } catch (err) { - checkCancel(signal); - throw new StageFailure((err as Error).message, 1); - } - checkCancel(signal); - url = w.url(); - if (r.preview && project) alias = previewAliasUrl(project, r.preview); - } - const record: DeployRecord = { - builtStampId: built.stampId, - builtAt: built.builtAt, - kind, - ...(r.preview ? { branch: r.preview } : {}), - url, - ...(alias ? { alias } : {}), - at: Date.now(), - liveCheck: null, - }; - await recordDeploy(paths, target, record); - const where = kind === "local" ? "local" : kind === "preview" ? `preview "${r.preview}"` : "production"; - return { status: "ran", stamp: built.stampId, summary: `${target} deployed (${where})${url ? ` ${url}` : ""}` }; -} +// The three deploy bodies are ONE function, release 18 S2's runDeployStage +// (publish/deployStage.ts): the bundle guards, the credential preflight, the +// archives to R2, the pinned wrangler (wranglerBin), the live check and the +// `deployed.json` record — or `--to local` into ARCHILYZER_SITE_OUT / +// ARCHILYZER_HOMEPAGE_OUT. Its refusals and failures are DeployStageErrors +// carrying the stage's exit code; it logs each sentence itself (stageRun.ts +// does not say it again). // --------------------------------------------------------------------------- // The dispatcher @@ -676,7 +592,6 @@ export async function runStageBody(ctx: StageContext, r: StageRequest): Promise< ctx.onLog(`[publish] ${STAGES[r.kind].label} ${r.target}: ${f.reason}\n`); const stamp = input.index.stamp; - const b = await import("./build"); switch (r.kind) { case "build-site": if (r.target === ALL_TARGET) { @@ -687,50 +602,14 @@ export async function runStageBody(ctx: StageContext, r: StageRequest): Promise< return buildHubStage(ctx, stamp!); case "build-homepage": return buildHomepageStage(ctx, stamp!); - case "deploy-site": { - const site = getSite(r.target, paths); - const out = b.bundleDir(paths, site.siteId); - return deployStage( - ctx, - r, - site.siteId, - (o) => - b.deploySite(site.siteId, { - paths, - signal: ctx.signal, - onLog: o.onLog, - previewBranch: o.previewBranch, - outDir: out, - stagingDir: b.dockerSiteStagingDir(paths, site.siteId), - }), - { src: out, dest: localSiteOut(), needs: "ARCHILYZER_SITE_OUT (the directory the docker `site` service serves)" }, - site.cloudflareProject?.trim() || null, - ); - } + case "deploy-site": case "deploy-hub": - return deployStage( - ctx, - r, - HUB_TARGET, - (o) => - b.deployHub({ - paths, - signal: ctx.signal, - onLog: o.onLog, - previewBranch: o.previewBranch, - outDir: b.bundleDir(paths, HUB_TARGET), - }), - null, - getHomepageConfig(paths).cloudflareProject?.trim() || null, - ); - case "deploy-homepage": - return deployStage( - ctx, - r, - HOMEPAGE_TARGET, - (o) => b.deployHomepage({ paths, signal: ctx.signal, onLog: o.onLog, previewBranch: o.previewBranch }), - { src: b.homepageOutDir(paths), dest: localHomepageOut(), needs: "ARCHILYZER_HOMEPAGE_OUT (the directory the docker `homepage` service serves)" }, - b.HOMEPAGE_PAGES_PROJECT, + case "deploy-homepage": { + const { runDeployStage } = await import("./deployStage"); + return runDeployStage( + { paths, onLog: ctx.onLog, signal: ctx.signal }, + { kind: r.kind, target: r.target, preview: r.preview, to: r.to, force: r.force }, ); + } } } diff --git a/common/publish/stageRun.test.ts b/common/publish/stageRun.test.ts @@ -164,13 +164,20 @@ test("a site the index has not seen is blocked; a fresh site's build is a no-op; // …and with it, copies the bundle into it (its CONTENTS replaced) and records the deploy. const siteOut = path.join(ROOT, "builds", "site"); mkdirSync(siteOut, { recursive: true }); + // What an earlier local deploy left (a bundle: it has an index.html — a + // destination without one is refused, deployStage.ts localDestProblem). + writeFileSync(path.join(siteOut, "index.html"), "old"); writeFileSync(path.join(siteOut, "stale.html"), "old"); process.env.ARCHILYZER_SITE_OUT = siteOut; try { const local = await stage("deploy-site", "jer", { to: "local" }); assert.equal(local.code, 0, local.message ?? ""); assert.equal(local.outcome?.status, "ran"); - assert.ok(existsSync(path.join(siteOut, "index.html"))); + // The copy happened: the bundle's own files arrived (the destination had + // neither), its index.html replaced the seeded one, and the stale file went. + assert.ok(existsSync(path.join(siteOut, "corpus.json"))); + assert.ok(existsSync(path.join(siteOut, "site.json"))); + assert.notEqual(readFileSync(path.join(siteOut, "index.html"), "utf8"), "old"); assert.ok(!existsSync(path.join(siteOut, "stale.html"))); const rec = stamps.deployRecordFor(await stamps.readDeployedFile(paths, "jer"), "local"); assert.equal(rec?.builtStampId, "b-jer"); diff --git a/common/publish/stageRun.ts b/common/publish/stageRun.ts @@ -17,6 +17,7 @@ import path from "node:path"; import { killChildTreesNow, setKillChildTrees } from "../jobs/runChild"; import { getPaths, type Paths } from "../lib/paths"; +import { DeployStageError } from "./deployStage"; import { StageCancelled, StageFailure } from "./stageBodies"; import { LockWaitCancelled, acquirePublishLock, type LockEnv } from "./stageLock"; import { STAGES, type StageOutcome, type StageRequest } from "./stages"; @@ -117,6 +118,17 @@ export async function runStage( return { code: STAGE_EXIT.cancelled, outcome: null, message: "cancelled" }; } const message = (err as Error).message; + // The deploy stage logged its own sentence (deployStage.ts refuse()): + // the exit code is its, and the sentence is not said twice. + if (err instanceof DeployStageError) { + if (err.exitCode === STAGE_EXIT.cancelled) { + onLog(`[stage] ${name}: cancelled\n`); + return { code: STAGE_EXIT.cancelled, outcome: null, message }; + } + const word = err.exitCode === STAGE_EXIT.failed ? "FAILED" : "REFUSED"; + onLog(`[stage] ${name}: ${word} (exit ${err.exitCode})\n`); + return { code: err.exitCode, outcome: null, message }; + } if (err instanceof StageFailure) { const word = err.exitCode === STAGE_EXIT.failed ? "FAILED" : "REFUSED"; onLog(`[stage] ${name}: ${word} — ${message}\n`); diff --git a/common/publish/stamps.ts b/common/publish/stamps.ts @@ -139,20 +139,9 @@ export function deployedPath(paths: Pick<Paths, "exportBuildsDir">, target: stri } // The runtime image's build facts (Dockerfile build args → ENV): the stamps' -// `commit` / `branch` where there is no .git to ask. Release 18 S5 declares -// the two names and exports the same helper (`imageBuildFacts`, -// lib/envVars.ts); this local one is swapped for it in one line once both -// slices are merged. Empty = null. -const IMAGE_COMMIT_NAME = "ARCHILYZER_COMMIT"; -const IMAGE_BRANCH_NAME = "ARCHILYZER_BRANCH"; - -export function imageBuildFacts(env: NodeJS.ProcessEnv = process.env): { - commit: string | null; - branch: string | null; -} { - const v = (k: string) => env[k]?.trim() || null; - return { commit: v(IMAGE_COMMIT_NAME), branch: v(IMAGE_BRANCH_NAME) }; -} +// `commit` / `branch` where there is no checkout to ask. Declared once, beside +// the two names, in lib/envVars.ts (release 18 S5); re-exported for the stages. +export { imageBuildFacts } from "../lib/envVars"; /** A fresh, sortable, unique stamp id. */ export function newStampId(now = Date.now()): string { diff --git a/common/publish/tombstones.test.ts b/common/publish/tombstones.test.ts @@ -0,0 +1,176 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import { + emptyPostsManifest, + tombstoneChannelManifest, + tombstoneNoStoreForHub, + tombstoneNoStoreForSite, + tombstonePaths, + withdrawnXChannels, + writePostsTombstones, +} from "./tombstones"; + +// Run with: pnpm --filter yt-dlp-transcript-common test + +const writeJson = (file: string, value: unknown) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(value)); +}; +const readJson = (file: string) => JSON.parse(readFileSync(file, "utf8")); + +function sharedTree(shared: string, slug: string, pages: number) { + writeJson(path.join(shared, slug, "manifest.json"), { + version: 1, + channelSlug: slug, + pageCount: pages, + maxPageBytes: 4096, + generatedAt: "2026-10-01T00:00:00.000Z", + slugToPage: { a: 0 }, + }); + for (let i = 0; i < pages; i++) { + writeJson(path.join(shared, slug, `page-${String(i).padStart(4, "0")}.json`), [{ id: `p${i}` }]); + } +} + +test("the tombstone shapes: a channel manifest with no pages and no size, a site manifest with no channels", () => { + assert.deepEqual(tombstoneChannelManifest("x", "T"), { + version: 1, + channelSlug: "x", + pageCount: 0, + maxPageBytes: 0, + generatedAt: "T", + slugToPage: {}, + }); + assert.deepEqual(emptyPostsManifest("T", "pub"), { + version: 1, + channels: [], + totalCount: 0, + generatedAt: "T", + siteId: "pub", + }); + assert.equal("siteId" in emptyPostsManifest("T"), false); +}); + +test("writePostsTombstones: one empty page per shared page, replacing a real tree, never through a link", async () => { + const root = mkdtempSync(path.join(tmpdir(), "tombstones-")); + try { + const shared = path.join(root, "shared", "posts"); + sharedTree(shared, "big-x", 3); + // A real tree an earlier public build shipped: replaced. + const posts = path.join(root, "public", "posts"); + writeJson(path.join(posts, "big-x", "page-0000.json"), [{ id: "p0", text: "a post" }]); + writeJson(path.join(posts, "big-x", "page-0007.json"), [{ id: "p7" }]); + // A linked tree (a worktree's public/ entries link into the primary's): + // the link goes, its target is untouched. + const primary = path.join(root, "primary", "posts", "linked-x"); + writeJson(path.join(primary, "page-0000.json"), [{ id: "keep" }]); + symlinkSync(primary, path.join(posts, "linked-x")); + + const written = await writePostsTombstones({ + postsDir: posts, + sharedPostsDir: shared, + slugs: ["big-x", "linked-x"], + generatedAt: "T", + }); + assert.deepEqual(written, [ + { slug: "big-x", pages: 3 }, + { slug: "linked-x", pages: 0 }, + ]); + assert.deepEqual(readdirSync(path.join(posts, "big-x")).sort(), [ + "manifest.json", + "page-0000.json", + "page-0001.json", + "page-0002.json", + ]); + for (const page of ["page-0000.json", "page-0001.json", "page-0002.json"]) { + assert.deepEqual(readJson(path.join(posts, "big-x", page)), []); + } + // The shared tree's page cap (4096) is not carried: a tombstone has no size. + assert.deepEqual(readJson(path.join(posts, "big-x", "manifest.json")), tombstoneChannelManifest("big-x", "T")); + assert.deepEqual(readdirSync(path.join(posts, "linked-x")), ["manifest.json"]); + assert.deepEqual(readJson(path.join(primary, "page-0000.json")), [{ id: "keep" }]); + + assert.deepEqual(tombstonePaths(written, { withManifest: true }), [ + "posts/manifest.json", + "posts/big-x/manifest.json", + "posts/big-x/page-0000.json", + "posts/big-x/page-0001.json", + "posts/big-x/page-0002.json", + "posts/linked-x/manifest.json", + ]); + assert.deepEqual(tombstonePaths([]), []); + // Nothing to withdraw writes nothing — not even posts/. + const none = path.join(root, "none", "posts"); + assert.deepEqual(await writePostsTombstones({ postsDir: none, sharedPostsDir: shared, slugs: [] }), []); + assert.equal(existsSync(none), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("the no-store paths: a site names its manifest and each tombstone; the hub its whole posts tree", () => { + const t = [ + { slug: "a-x", pages: 1 }, + { slug: "b-x", pages: 0 }, + ]; + assert.deepEqual(tombstoneNoStoreForSite(t), ["/posts/manifest.json", "/posts/a-x/*", "/posts/b-x/*"]); + assert.deepEqual(tombstoneNoStoreForHub(t), ["/posts/*"]); + assert.deepEqual(tombstoneNoStoreForSite([]), []); + assert.deepEqual(tombstoneNoStoreForHub([]), []); +}); + +test("withdrawnXChannels: the X channels with a shared posts tree that a non-private site carries, only while X posts are private", async () => { + const root = mkdtempSync(path.join(tmpdir(), "tombstones-")); + try { + const paths = { + channelsDir: path.join(root, "channels"), + exportSharedPostsDir: path.join(root, "shared", "posts"), + } as Paths; + const config = (slug: string, platform: string) => + writeJson(path.join(paths.channelsDir, slug, "config.json"), { + handling: "youtube", + url: `https://example.test/${slug}`, + sourceKind: "social", + platform, + }); + config("z-x", "twitter"); + config("a-x", "twitter"); + config("sky", "bluesky"); + config("no-tree-x", "twitter"); + config("private-only-x", "twitter"); + config("no-site-x", "twitter"); + for (const slug of ["z-x", "a-x", "sky", "no-config", "private-only-x", "no-site-x"]) { + sharedTree(paths.exportSharedPostsDir, slug, 1); + } + const members = (...slugs: string[]) => slugs.map((slug) => ({ slug, groupId: "default" })); + const sites = [ + { channels: members("a-x", "sky", "no-config", "no-tree-x") }, + { audience: "public" as const, channels: members("z-x") }, + // A private site's X channel that no public site carries: never named. + { audience: "private" as const, channels: members("private-only-x", "a-x") }, + ]; + + const priv = { social: { x: { visibility: "private" } } }; + assert.deepEqual(await withdrawnXChannels(paths, priv, sites), ["a-x", "z-x"]); + assert.deepEqual(await withdrawnXChannels(paths, {}, sites), []); + assert.deepEqual(await withdrawnXChannels(paths, { social: { x: { visibility: "public" } } }, sites), []); + assert.deepEqual(await withdrawnXChannels(paths, priv, []), [], "no site, no tombstone"); + const empty = { ...paths, exportSharedPostsDir: path.join(root, "absent") } as Paths; + assert.deepEqual(await withdrawnXChannels(empty, priv, sites), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/common/publish/tombstones.ts b/common/publish/tombstones.ts @@ -0,0 +1,183 @@ +// TOMBSTONES FOR WITHDRAWN X POSTS (release 18). +// +// While `social.x.visibility` is "private", a public site leaves every X channel +// out whole (lib/postsVisibility.ts) and the hub carries no posts at all +// (compose-hub's SITE_ONLY_PUBLIC_ENTRIES). Leaving a path OUT of a deploy does +// not take it off Cloudflare's edge: Pages keeps serving a cached object until +// its TTL runs out, whatever the new deployment holds (the hub served a +// withdrawn X shard from a 7-day cache after the deploy that removed it). So +// withdrawn content is REPLACED, never deleted: at every path it was served +// from, the deploy ships an empty object of the same shape — +// +// posts/manifest.json a site posts manifest listing no channel +// (only when the site lists none at all) +// posts/<slug>/manifest.json the channel's posts manifest, pageCount 0 +// posts/<slug>/page-NNNN.json `[]`, for every N below the pageCount the +// shared posts tree has now +// +// — and lib/archive/headers.ts serves those paths `Cache-Control: no-store` +// (the paths are `tombstoneNoStore*` below). Nothing reads a tombstone: the +// site's posts manifest does not list the channel, so no reader asks for its +// tree, and corpus.json advertises no posts for it. A visitor holding a stale +// link gets an empty page instead of the post. +// +// The writers go through bin/_publicFile.ts: in a worktree, public/'s entries +// are links into the primary checkout, and nothing here writes through one. + +import path from "node:path"; +import { readdir, readFile, rm } from "node:fs/promises"; +import type { Paths } from "../lib/paths"; +import { + POSTS_MANIFEST_VERSION, + SITE_POSTS_MANIFEST_VERSION, + postsPageFileName, + type ChannelPostsManifest, + type PostsManifest, +} from "../lib/posts"; +import { isXPostsChannel, xPostsVisibility } from "../lib/postsVisibility"; +import { isPrivateSite, type Site } from "../lib/siteSchema"; +import { readChannelConfig } from "../controller/channels"; +import { ownDir, writePublicFile } from "../bin/_publicFile"; + +// One channel's tombstone: its slug and how many empty pages stand in for it. +export type PostsTombstone = { slug: string; pages: number }; + +// The channel posts manifest a tombstone ships: no pages, no posts — and no +// size: `maxPageBytes` is 0 (nothing reads it), so a tombstone says nothing of +// the withheld archive beyond how many empty pages stand in for it. +export function tombstoneChannelManifest(slug: string, generatedAt: string): ChannelPostsManifest { + return { + version: POSTS_MANIFEST_VERSION, + channelSlug: slug, + pageCount: 0, + maxPageBytes: 0, + generatedAt, + slugToPage: {}, + }; +} + +// The site posts manifest of a site that lists no posts channel. +export function emptyPostsManifest(generatedAt: string, siteId?: string): PostsManifest { + return { + version: SITE_POSTS_MANIFEST_VERSION, + channels: [], + totalCount: 0, + generatedAt, + ...(siteId ? { siteId } : {}), + }; +} + +// The pageCount of a channel's SHARED posts tree, or 0 when it has none or it +// cannot be read. +async function sharedPageCount(sharedPostsDir: string, slug: string): Promise<number> { + try { + const m = JSON.parse( + await readFile(path.join(sharedPostsDir, slug, "manifest.json"), "utf8"), + ) as Partial<ChannelPostsManifest>; + return Number.isInteger(m.pageCount) && (m.pageCount as number) > 0 ? (m.pageCount as number) : 0; + } catch { + return 0; + } +} + +/** + * Write a tombstone tree for each of `slugs` under `postsDir` (a served + * `posts/`): the channel dir is replaced by its manifest at pageCount 0 and one + * `[]` page for every page the shared tree holds now. Returns what was written, + * in `slugs` order. + */ +export async function writePostsTombstones(opts: { + postsDir: string; + sharedPostsDir: string; + slugs: readonly string[]; + generatedAt?: string; +}): Promise<PostsTombstone[]> { + const generatedAt = opts.generatedAt ?? new Date().toISOString(); + const written: PostsTombstone[] = []; + if (opts.slugs.length === 0) return written; + await ownDir(opts.postsDir); + for (const slug of opts.slugs) { + const dir = path.join(opts.postsDir, slug); + // Whatever was there (a real tree a public build shipped before, or a + // linked one in a worktree) goes; rm removes a link, never its target. + await rm(dir, { recursive: true, force: true }); + await ownDir(dir); + const pageCount = await sharedPageCount(opts.sharedPostsDir, slug); + await writePublicFile( + path.join(dir, "manifest.json"), + JSON.stringify(tombstoneChannelManifest(slug, generatedAt)), + ); + for (let i = 0; i < pageCount; i++) { + await writePublicFile(path.join(dir, postsPageFileName(i)), "[]"); + } + written.push({ slug, pages: pageCount }); + } + return written; +} + +/** + * The served paths a set of tombstones occupies, root-relative without a + * leading slash (`posts/<slug>/manifest.json`, `posts/<slug>/page-0000.json`, + * …), with `posts/manifest.json` first when `withManifest`. What a live check + * probes, and what a test pins. + */ +export function tombstonePaths( + tombstones: readonly PostsTombstone[], + opts: { withManifest?: boolean } = {}, +): string[] { + const out = opts.withManifest ? ["posts/manifest.json"] : []; + for (const t of tombstones) { + out.push(`posts/${t.slug}/manifest.json`); + for (let i = 0; i < t.pages; i++) out.push(`posts/${t.slug}/${postsPageFileName(i)}`); + } + return out; +} + +/** A site's no-store paths: its posts manifest and each tombstoned tree. */ +export function tombstoneNoStoreForSite(tombstones: readonly PostsTombstone[]): string[] { + if (tombstones.length === 0) return []; + return ["/posts/manifest.json", ...tombstones.map((t) => `/posts/${t.slug}/*`)]; +} + +/** The hub's no-store paths: its whole posts tree, which holds only tombstones. */ +export function tombstoneNoStoreForHub(tombstones: readonly PostsTombstone[]): string[] { + return tombstones.length === 0 ? [] : ["/posts/*"]; +} + +/** + * The X channels a hub tombstones while X posts are private: every X channel + * that has a SHARED posts tree AND is a member of at least one site whose + * audience is not private — the only channels whose posts a public bundle (a + * public site's, or a hub composed over one) could ever have served, so the + * only paths the edge can still hold. PRIVATE DATA IS NEVER NAMED ON THE HUB: + * an X channel carried only by private sites, or by no site, gets no + * tombstone, because its slug in a public bundle would itself publish it. + * Empty while X posts are public. + */ +export async function withdrawnXChannels( + paths: Paths, + settings: { social?: { x?: { visibility?: unknown } } }, + sites: readonly Pick<Site, "audience" | "channels">[], +): Promise<string[]> { + if (xPostsVisibility(settings) !== "private") return []; + const onPublicSite = new Set<string>(); + for (const site of sites) { + if (isPrivateSite(site)) continue; + for (const c of site.channels) onPublicSite.add(c.slug); + } + let entries: string[]; + try { + entries = (await readdir(paths.exportSharedPostsDir, { withFileTypes: true })) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + } catch { + return []; + } + const out: string[] = []; + for (const slug of entries) { + if (!onPublicSite.has(slug)) continue; + if (isXPostsChannel(await readChannelConfig(paths, slug))) out.push(slug); + } + return out; +} diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,13 +1,15 @@ # Changelog ## [Unreleased] +- **Deploys are pinned and checked live.** wrangler is an exact dependency of the workspace (4.147.0), so a deploy runs the version installed with the code instead of whatever `pnpm dlx` fetched that day, and every deploy names its branch: production is `--branch main`, never taken from the checkout it ran in (where a "production" deploy from a feature branch used to land as a preview). The publish stages' deploy (release 18) refuses before wrangler runs when there is no Cloudflare credential at all — "set CLOUDFLARE_API_TOKEN in .env" — and says "REFUSED by Cloudflare — the API token was not accepted" when Cloudflare rejects one; it refuses a production deploy of a build made from a branch other than `main`. After each deploy it reads `corpus.json` at the site's address twice, as a visitor would and cache-busted, and records the verdict: ok, stale-edge (the deployment is right, Cloudflare's edge still serves an older copy), mismatch, or unreachable. A verdict short of ok is a warning in the log; the deploy itself succeeded. What each target last shipped, where, and how it read is kept in `deployed.json` beside its build. +- **Withdrawn X posts ship tombstones.** While X posts are private, a public site's build no longer just leaves an X channel's posts out: at every path they were served from it ships an empty stand-in — the channel's posts manifest with no pages, and an empty page for each page the channel has — served uncached. The hub, which carries no posts, ships the same for every X channel a public site carries, with an empty posts manifest; a channel only on a private site, or on no site, is never named on the hub. Leaving a path out of a deploy does not take it off Cloudflare's edge, which kept serving a withdrawn copy for up to a week; a changed object at the same path replaces it. The hub's deploy reads each of those paths back. - **Publishing is stages, from the command line: `archilyzer publish`.** `publish index` updates the index — the LMDB index, the stats datasets and the chart templates, in one child process with an 8 GB heap — and writes an index stamp (`export/.export-index/stamp.json`) naming, for each site, a signature of everything that site's build reads. `publish build <id|all>` builds a site from that index (no data phase of its own) into its own bundle, `export/.export-builds/<id>/out`, and stamps it (`built.json`); a site whose bundle already matches the index is a no-op unless `--force`. `publish deploy <id|all> [--preview <branch>] [--to local]` ships that bundle — to Cloudflare Pages, or with `--to local` into the directory the docker `site` service serves — and records the deploy (`deployed.json`); deploying the same build again is a no-op unless `--force`. `all` passes over private sites and, to Pages, sites with no Pages project; any other site it cannot deploy is a failure, said after the rest are tried. `publish hub [--deploy]` and `publish homepage [--deploy]` do the same for the hub (`_hub/out`) and the homepage. A stage whose input is not there says so and exits 3: "update the index first", "no build of jeralyzer — archilyzer publish build jeralyzer". Production refuses a bundle built on a branch other than `main`, or with no branch recorded (a detached checkout; an image sets `ARCHILYZER_BRANCH`) — a preview of it is fine. Exit codes: 0 done or nothing to do, 1 failed, 2 usage, 3 precondition not met, 130 cancelled. - **One publish at a time on a machine.** Every stage takes `export/.export-builds/.publish.lock`; a second one — an `archilyzer publish` beside the editor, say — waits for it, saying once whom it waits for, and Ctrl-C ends the wait. A lock left by a process that is gone is taken over. A cancelled stage takes the whole process tree it started with it (`next build`'s workers, wrangler, docker). - **`export/out` is now a link to the bundle built last.** Each site, and the hub, keeps its own bundle, so building one site no longer replaces another's; `export/out` points at whichever was built most recently, so `serve out` and anything else that read it keeps working. - **`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 repository the source mirror reads, and the private config dir. +- **`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/editor/e2e/fixtures/bin/fake-wrangler.mjs b/editor/e2e/fixtures/bin/fake-wrangler.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// E2E fake wrangler (release 18). The deploy stage spawns `wranglerBin(paths)` +// — WRANGLER_BIN when set, which playwright.config.ts points here — with +// +// pages deploy <outDir> --project-name <project> --branch <branch> +// +// (common/lib/pagesDeploy.ts pagesDeployArgs). The suite must never reach +// Cloudflare, so this deploys nothing. What it does: +// +// - records every invocation in an argv sidecar, `.fake-wrangler.json`, +// BESIDE the bundle (the directory holding <outDir>): `{ invocations: [{ +// argv, cwd, project, branch, outDir, files }] }`, appended, so a spec reads +// what was "deployed", from where, to which branch; +// - prints wrangler 4's success lines, ending on "Take a peek over at +// https://<branch>.<project>.pages.dev" — the line deploymentUrlIn reads; +// - with E2E_FAKE_WRANGLER_AUTH_FAIL=1, prints wrangler's own refusal for a +// token Cloudflare does not accept ("Authentication error [code: 10000]") +// and exits 1, without writing the sidecar — the deploy stage must turn +// that into "[deploy] REFUSED by Cloudflare — the API token was not +// accepted" and leave deployed.json untouched; +// - answers `--version` like the pinned binary. +// +// A missing <outDir> fails as wrangler does. Anything else is a usage error. +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { installFixtureWatchdog } from "./_watchdog.mjs"; + +// Never outlive the run that spawned us — see _watchdog.mjs. +installFixtureWatchdog(); + +const VERSION = "4.147.0"; +const argv = process.argv.slice(2); + +if (argv[0] === "--version" || argv[0] === "-v") { + process.stdout.write(`${VERSION}\n`); + process.exit(0); +} + +const opt = (flag) => { + const i = argv.indexOf(flag); + return i < 0 ? undefined : argv[i + 1]; +}; + +if (argv[0] !== "pages" || argv[1] !== "deploy" || !argv[2] || argv[2].startsWith("--")) { + process.stderr.write(`[fake-wrangler] unsupported invocation: ${argv.join(" ")}\n`); + process.exit(2); +} +const outDir = path.resolve(argv[2]); +const project = opt("--project-name"); +const branch = opt("--branch") ?? "main"; + +process.stdout.write(`\n ⛅️ wrangler ${VERSION} (fake)\n───────────────────\n`); + +if (process.env.E2E_FAKE_WRANGLER_AUTH_FAIL === "1") { + process.stderr.write( + `\n✘ [ERROR] A request to the Cloudflare API (/accounts/0000/pages/projects/${project ?? "?"}) failed.\n\n` + + " Authentication error [code: 10000]\n\n" + + " 📎 It looks like you are authenticating Wrangler via a custom API token set in an environment variable.\n" + + " Please ensure it has the correct permissions for this operation.\n\n", + ); + process.exit(1); +} + +if (!existsSync(outDir) || !statSync(outDir).isDirectory()) { + process.stderr.write(`\n✘ [ERROR] The directory specified ("${argv[2]}") does not exist.\n`); + process.exit(1); +} +if (!project) { + process.stderr.write("\n✘ [ERROR] Must specify a project name.\n"); + process.exit(1); +} + +let files = 0; +const walk = (d) => { + for (const e of readdirSync(d, { withFileTypes: true })) { + if (e.isDirectory()) walk(path.join(d, e.name)); + else files++; + } +}; +walk(outDir); + +const sidecar = path.join(path.dirname(outDir), ".fake-wrangler.json"); +let record = { invocations: [] }; +try { + record = JSON.parse(readFileSync(sidecar, "utf8")); + if (!Array.isArray(record.invocations)) record = { invocations: [] }; +} catch { + // first invocation +} +record.invocations.push({ argv, cwd: process.cwd(), project, branch, outDir, files }); +writeFileSync(sidecar, `${JSON.stringify(record, null, 2)}\n`); + +process.stdout.write(`Uploading... (${files}/${files})\n`); +process.stdout.write(`✨ Success! Uploaded ${files} files (0 already uploaded) (0.01 sec)\n\n`); +process.stdout.write("🌎 Deploying...\n"); +process.stdout.write(`✨ Deployment complete! Take a peek over at https://${branch}.${project}.pages.dev\n`); diff --git a/editor/e2e/site-publish-preview.spec.ts b/editor/e2e/site-publish-preview.spec.ts @@ -5,8 +5,10 @@ import { resetData, writeSite } from "./helpers"; // The preview control on a site's Publish tab. // // NOTHING HERE CLICKS DEPLOY. Every external binary the suite touches has a -// fake in e2e/fixtures/bin; wrangler has none, because no spec has ever had a -// reason to reach a deploy. What is worth pinning anyway is everything decided +// fake in e2e/fixtures/bin — wrangler's is fake-wrangler.mjs since release 18 +// (WRANGLER_BIN in playwright.config.ts; it deploys nothing and writes its argv +// beside the bundle), and the deploy stage itself is publish.spec's. What is +// pinned here is everything decided // BEFORE the job: that the two deploys are told apart by their labels, that the // branch input refuses exactly what the server refuses and with the same // sentence, and that the alias is shown while you type — it is a function of diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts @@ -25,11 +25,24 @@ import { portFor } from "yt-dlp-transcript-common/lib/ports.mjs"; // fake-ytdlp.mjs's env fallbacks behind its // .fake-ytdlp-audio-check.json sidecar // E2E_FAKE_GALLERY_DL_AUTH_FAIL fake-gallery-dl.mjs fails as an auth error +// E2E_FAKE_WRANGLER_AUTH_FAIL=1 fake-wrangler.mjs fails as Cloudflare +// refusing the API token ("Authentication error +// [code: 10000]"), for the deploy stage's +// "[deploy] REFUSED by Cloudflare" sentence +// E2E_LIVE_CHECK=skip the deploy stage's live check reads nothing and +// records "skipped" (common/publish/liveCheck.ts): +// the fake wrangler deploys nothing to read. Set +// for the test server below // E2E_FIXTURE_MAX_LIFETIME_MS a fake binary's watchdog budget // E2E_OLLAMA_STUB_MODEL the model the ollama stub claims to serve // E2E_SHARDS, E2E_IMAGE, E2E_SKIP_BUILD, E2E_RETRIES // the sharded runner (scripts/run-sharded-e2e.mjs) // +// WRANGLER_BIN is not prefixed either: it is the deploy stage's own override +// (common/lib/pagesDeploy.ts wranglerBin), pointed below at +// e2e/fixtures/bin/fake-wrangler.mjs so no spec can reach Cloudflare — the fake +// writes its argv to `.fake-wrangler.json` beside the bundle it was handed. +// // The ports are NOT prefixed: they are common/lib/ports.mjs's, injected per // worktree by scripts/worktree.mjs and named in queue-lock's --ports list. Nor // are the queue's own E2E_QUEUE / E2E_PORT_CHECK / E2E_QUEUE_TIMEOUT (read by @@ -41,6 +54,8 @@ const E2E_SERVER_ENV = { E2E_AUDIO_CHECK_INTERVAL_FLOOR_MS: "50", E2E_AUDIO_CHECK_RECOVER_STEP_MS: "100", E2E_AUDIO_CHECK_RECOVER_AFTER: "2", + E2E_LIVE_CHECK: "skip", + WRANGLER_BIN: path.resolve(process.cwd(), "e2e", "fixtures", "bin", "fake-wrangler.mjs"), }; const PORT = portFor("PORT"); diff --git a/plans/release-18.md b/plans/release-18.md @@ -353,6 +353,335 @@ Probe = { status|null; generatedAt?; cfCacheStatus?; age?; cacheControl?; error? (Each slice adds a "### Slice <X>, as shipped" section here, before "## Rollout".) +### Slice S2, as shipped — deploy hardening (2026-10-06) + +Branch `r18/deploy-hardening` off `ce66f2d3`, worktree `~/Projects/r18-deploy-hardening` (editor 6901, test 6911, +export 6910), one Opus implementer, beside S1 (stage core) and S5's image half. Scratch files `s2-*` in the job's +`tmp`. The plan is above ("Model", "Deploy hardening", "Docker (a)"). + +**What it does.** +- **wrangler is pinned:** `4.147.0` (released 2026-10-02, the current 4.x), an EXACT devDependency of `common` + (`common/package.json`, the lockfile). `pnpm-workspace.yaml` `allowBuilds` gains `workerd: false`: pnpm 11 refuses an + install with an unanswered build script, and a Pages deploy never runs workerd. The lockfile diff is large because + wrangler brings `supports-color@10`, and pnpm re-keys the `debug` peers of `next`, `eslint`, `serve` and + `eslint-config-next` with it — the same versions under new peer suffixes (release 6 saw the same churn). +- **`common/lib/pagesDeploy.ts`** (node-free, as before): `pagesDeployArgs` is now the argv AFTER the binary and + always names the branch — `--branch main` (`PRODUCTION_BRANCH`) for production, `--branch <b>` for a preview; + `wranglerBin(paths, env)` = `WRANGLER_BIN`, else `<repo>/common/node_modules/.bin/wrangler`; `WRANGLER_MAJOR = 4` + (a test holds the pin exact and at that major); `wranglerAuthFailureIn(line)` reads wrangler 4's refusals + (`Authentication error [code: 10000]`, `Invalid access token [code: 9109]`, `Unable to authenticate request [code: + 10001]`, the non-interactive "set a CLOUDFLARE_API_TOKEN" sentence, "You are not authenticated", a failed OAuth + refresh) and `CLOUDFLARE_AUTH_REFUSED` is the sentence it becomes; `cloudflareCredentialProblem(env, + oauthLoginPresent)` + `wranglerOAuthConfigFiles(home, env)` (`~/.wrangler`, `$XDG_CONFIG_HOME/.wrangler`, macOS + Preferences) are the preflight — `CLOUDFLARE_API_TOKEN`, or a `wrangler login` on disk (a host's), else "[deploy] + REFUSED — no Cloudflare credentials: set CLOUDFLARE_API_TOKEN in .env …". A value is never read beyond "set or not". +- **`build.ts`, only the spawns:** `runDeployIntoLog` and `runPagesDeployIntoLog` (hub and homepage) run + `wranglerBin(paths)` with `pagesDeployArgs` instead of `pnpm dlx wrangler`; `homepageDeployArgs` is + `pagesDeployArgs` (its own `--branch main` tail is gone — the argv names the branch once). So the legacy deploy jobs + already deploy production as `--branch main` with the pinned binary; they do NOT get the preflight, the classifier or + the live check — those are the stage's (`runDeployStage`), which S1's `stages.ts` wires. +- **`common/publish/deployStage.ts` — `runDeployStage(ctx, req)`**, one body for `deploy-site`, `deploy-hub`, + `deploy-homepage` over `<exportBuildsDir>/<target>/out` (the homepage: `homepage/out`) and its `built.json`, in this + order: the request (`previewBranchProblem`; local + preview refused; the hub has no local target); the target's own + refusals (`siteDeployProblem`, no Pages project, `hubProjectProblem`); the build — no `built.json` is exit 3 "no build + of X in <dir> — archilyzer publish build X", a `builtAfter` newer than `built.builtAt` is exit 3, the slot already + holding this `stampId` is a `noop` unless `force`; production refused when `built.branch` is set and is not `main`; + the bundle guards (`builtBundleProblem` — the stricter twin of `builtSiteProblem`, naming the directory — + `builtAudienceProblem`, `builtScopeProblem`; the hub's `builtHubProblem`; the homepage's `builtHomepageProblem` + + `publishedSourceProblem`); `--to local` copies into `ARCHILYZER_SITE_OUT` (contents replaced, never the directory; + the homepage into `ARCHILYZER_HOMEPAGE_OUT`, which the container sets; either unset is refused in its own sentence) + and records `local` — no + credential, no R2, no wrangler, no live check, a private site still refused; the credential preflight; the R2 upload + from `dockerSiteStagingDir` (`<id>/.r2-staging`), a preview logging `PREVIEW_SHARES_ARCHIVES_NOTICE`; the pinned + wrangler with the classifier on its stream; the live check; and LAST the record, written atomically (temp + + rename) into `deployed.json` (`production` / `local` / `previews[branch]`, other slots kept). A refusal or failure + throws `DeployStageError` (`exitCode` 1 failed or refused at the credential/destination step, 2 a request it cannot + run — a bad branch name, local with a preview, the hub locally, a kind and target that do not match — 3 precondition + not met: no build, a private site, no Pages project, a production build not of main, a bundle guard — the codes + `needs()` gives the same refusals; 130 cancelled) whose message + is the line the log already ends on, word for word, and `deployed.json` is untouched. `deploy-hub` and + `deploy-homepage` take only `_hub` / `_homepage`, and a site deploy refuses either. Wrangler's own lines reach the log + through the same `log()`, each ending in a newline. A local destination that holds the checkout, the corpus, the + builds or the bundle, or is not empty and has no `index.html`, is refused before anything is emptied. Cancel reaches + the live check; a deploy cancelled during it is recorded without one and ends 130. The stamp types (`BuiltStamp`, `DeployRecord`, `DeployedFile`) are declared there with + the plan's field names until S1's `stamps.ts` lands. +- **`common/publish/liveCheck.ts` — `runLiveCheck`** (injected `fetch`, `sleep`, `now`, `env`, `signal`): `<url>/corpus.json` + plain and `?cb=<builtStampId>` (`&try=N` on a retry), 3 tries 10 s apart until ok, records `cf-cache-status`, `age`, + `cache-control` and `generatedAt`, compares with `built.corpusGeneratedAt` (else the bundle's own `corpus.json`). + Verdicts (`liveCheckVerdict`, pure): `ok` (both serve this build, or plain does and busted failed); `stale-edge` + (busted serves this build, plain answers 2xx with another `generatedAt`); `mismatch` (busted serves another); + `unreachable` (neither answers 2xx, or the plain read fails); `skipped` (`E2E_LIVE_CHECK=skip`, nothing fetched). + The URL is the preview alias for a preview, the site's `siteUrl` (the hub's `homepage.json` `siteUrl`, the homepage's + `PROJECT_URL`) for production, else the deployment URL wrangler printed. The homepage has no `corpus.json`: it reads + `/` and asks only for a 2xx. The hub also probes `posts/manifest.json` and each withdrawn channel's + `manifest.json` + `page-0000.json` plain and busted (`LiveCheck.tombstones`); a corpus that reads ok with a + tombstone that does not is `stale-edge` when the busted read is the tombstone, else `mismatch`. + `liveCheckLines` is the log: `[live] ok — …`, or `[live] WARNING <verdict> — …` with every probe's status, + `generatedAt`, `cf-cache-status`, `age`, `cache-control`. A warning never fails the stage. +- **Tombstones, `common/publish/tombstones.ts`:** `writePostsTombstones` replaces each slug's served `posts/<slug>/` + with its `ChannelPostsManifest` at `pageCount: 0` (`slugToPage: {}`, `maxPageBytes: 0` — no size) and `[]` for + every page below the SHARED tree's `pageCount` — through `bin/_publicFile.ts`, never through a worktree link; + `withdrawnXChannels` (every X channel with a shared posts tree that at least one non-private site carries, only + while `social.x.visibility` is private); + `tombstonePaths`, `tombstoneNoStoreForSite`, `tombstoneNoStoreForHub`. + - **compose-site** writes them for each withheld member (`site.channels` less `publishedMemberSlugs`) after the posts + reconcile and the posts manifest; with no posts manifest from the index it writes an empty one, replacing whatever + an earlier compose left there. `corpus.json` advertises no posts for them (it reads the posts manifest). + - **compose-hub** removes `SITE_ONLY_PUBLIC_ENTRIES` as before, then writes the tombstones and an empty + `posts/manifest.json`; `main` takes `settings` (default `getSettings()`). **Ruling clarification (review H1): + private data is never named on the hub.** The plan's "every X channel with a shared posts tree" is narrowed to + the X channels that at least one site whose audience is not private carries (`site.channels`): only those could + ever have been served by a public bundle, so only their paths can sit at the edge. An X channel only private sites + carry, or no site, gets no tombstone — its slug appears nowhere in the hub's `public/` (tested). With X public, or + no such channel, the hub ships no `posts/` at all, as before. For the X channel the live hub served, that is, path for path, + `posts/manifest.json` (empty), `posts/thequartering-X/manifest.json` (`pageCount` 0), + `posts/thequartering-X/page-0000.json` (`[]`), and one `[]` page per further page its shared tree holds by then — + `compose-hub.test.ts` pins the three named paths with that slug. + - **`builtHubProblem`** (`lib/builtExport.ts`, outside this slice's list — one clause, needed or the hub could not + deploy its tombstones) accepts a `posts/` that `isTombstonePostsTree` (new, same file) says holds tombstones only: + an empty posts manifest, and per channel a `pageCount: 0` manifest with no `slugToPage` and only `[]` pages; one + real post, a listed channel or a stray file and it is a site's data again, refused as before. +- **`_headers`, `renderHeadersFile(generator, paths, { noStore })`** (`common/lib/archive/headers.ts`): after the + CORS lines, `# Withdrawn content (tombstones): never stored at the edge.` and one rule per path with `Cache-Control: + no-store`, plus `Access-Control-Allow-Origin: *` ONLY where no CORS rule above covers the path — every matching rule + applies and a repeated header is APPENDED (wrangler's `attachHeaders`, FACTS), so a second CORS line would serve + `*, *`. A site: `/posts/manifest.json` and `/posts/<slug>/*` per tombstone (its `/posts/*` CORS rule covers them); + the hub: `/posts/*` with both headers. No tombstones, no block: every other `_headers` is byte-identical. The + committed fixture is `headers.test.ts`'s snapshot strings (two new, plus a test that no rendered file sets one header + twice for a path). +- **The e2e fake, `editor/e2e/fixtures/bin/fake-wrangler.mjs`** (the siblings' style, `_watchdog.mjs`): `pages deploy + <outDir> --project-name <p> --branch <b>` appends `{argv, cwd, project, branch, outDir, files}` to + `.fake-wrangler.json` BESIDE the bundle and prints wrangler 4's success lines ending on "✨ Deployment complete! Take + a peek over at https://<branch>.<project>.pages.dev"; `E2E_FAKE_WRANGLER_AUTH_FAIL=1` prints wrangler's + `Authentication error [code: 10000]` block and exits 1 with no sidecar; `--version` answers `4.147.0`. + `editor/playwright.config.ts` documents both knobs and gives the test server `WRANGLER_BIN=<the fake>` and + `E2E_LIVE_CHECK=skip` (in `E2E_SERVER_ENV`). **No spec in the list spawned a deploy before this slice, and none does + now** (`deploy-page`, `site-publish-preview`, `sites-homepage`, `build`, `site-scope` all stop before a job or hold it + with `/api/test/stuck-job`); `site-publish-preview.spec.ts`'s header comment says the fake exists now. `publish.spec` + is S4's. +- **`envVars.ts` + ENVIRONMENT.md** (S5's file; `envVars.test.ts` fails on an undeclared read): this slice's rows are + `WRANGLER_BIN` (runtime), `E2E_LIVE_CHECK` and `E2E_FAKE_WRANGLER_AUTH_FAIL` (test). `CLOUDFLARE_API_TOKEN`, + `XDG_CONFIG_HOME` and `ARCHILYZER_HOMEPAGE_OUT` are S5's rows; this branch carries its own copies, marked with a + comment and placed clear of S5's hunks, so its tests pass before S5 lands. **On merging S5: keep S5's three rows, + delete the marked copies** (`envVars.test.ts` "names are unique" fails until then), add `common/lib/pagesDeploy.ts` + / `common/publish/deployStage.ts` to their `readBy`, and regenerate ENVIRONMENT.md (`archilyzer docs env`). + +**Open question 1 — where does the hub's `s-maxage=604800` come from?** Not from this repository. Every rule the repo +renders or ships was read: `renderHeadersFile` (CORS only, until this slice's `no-store`), `homepage/public/_headers` +(`public, max-age=300, must-revalidate` on `/downloads/*` and `/source/*`, the homepage only), the R2 upload +(`public, max-age=3600`, archives only), `r2-proxy` (the same, the Worker), and `export/serve.json` / +`homepage/serve.json` (`public, max-age=3600` — local `serve`, never deployed). `git grep -i 's-maxage\|604800'` +finds only `duration.ts`'s seconds-per-week and this plan. So the 7-day edge TTL is Cloudflare's side of a +`*.pages.dev` hostname — an account-level cache setting or Pages' own edge caching — which the repo cannot read and a +`pages.dev` project has no zone to purge. Whether `Cache-Control: no-store` from `_headers` wins over it is exactly +what the hub's next deploy shows: its live check records `cache-control`, `cf-cache-status` and `age` for +`corpus.json` and each tombstone, plain and busted, in `_hub/deployed.json`. If the plain reads still `HIT` the old +shard after the deploy, the verdict is `stale-edge` and the objects expire ~2026-10-08 21:00 as before. + +**Open question 2 — is `main` the production branch of every Pages project?** Yes, as far as the records can say +without asking Cloudflare (not called). Every production deploy of every project so far ran with no `--branch` from +the primary checkout on `main`, so wrangler sent `main`, and each one changed what the PRODUCTION URL serves — a +deploy to a branch that is not the production branch is a preview and leaves production alone: release 17's XP +rollout (2026-10-04, jeralyzer, rekietalyzer, hasanalyzer, anilyzer, bonnellyzer, jasolyzer and the hub, each read +back at its `*.pages.dev`), the homepage (`archilyzer`, 2026-09-26: wrangler printed "production branch `main`"; its +deploy has always passed `--branch main`), and the hub's first deploys (release 7). PUBLISH.md's "create the project +first" line is `wrangler pages project create <name> --production-branch main`. So `--branch main` changes nothing for +these eight projects. A project whose production branch is NOT `main` would now take a "production" deploy as a +preview: production unchanged, and wrangler's output would show a preview URL. The live check reads `mismatch` only +where it probes the production URL (a site with a `siteUrl`, the hub, the homepage) and the build's `generatedAt` +differs from what production serves; a rebuild with unchanged data would still read `ok`. + +#### Found on the way, fixed here + +- compose-site left a stale `public/posts/manifest.json` from an earlier compose when the index wrote none for the + site; with tombstones to write it is now replaced by an empty one (`compose-site.postsVisibility.test.ts` case). A + site with neither tombstones nor an index manifest keeps the old behaviour. + +#### Found and left + +- **A site's live check does not probe its tombstones** — only the hub's does (the plan's scope). A site's tombstones + are written and served no-store; its `deployed.json` says nothing about them. +- **The site `generatedAt` is the summaries manifest's**, so a rebuild with no data change carries the same + `generatedAt` as the deploy before it: the live check cannot tell those two deployments apart. It tells a stale or + wrong deployment from the build's data, which is what it is for. +- **The legacy deploy paths** (`deploySite`, `deployHub`, `deployHomepage`, the docker Phase C) now run the pinned + binary with `--branch main`, but keep their old refusals: no preflight, no classifier, no live check, no record, + until S4 makes the editor's actions enqueue the stages. +- **A cited site withholding an X channel** composes no corpus at all, so it writes no tombstones (nothing of the + corpus was ever served from it). +- **`refuse` logs the sentence and throws it**: a runner that prints `err.message` after a failed stage will print it + twice. The wiring step (S1's `stages.ts`) should print only on a non-`DeployStageError`. + +#### Deviations from the plan + +- **The hub's tombstone set** is narrower than the plan's wording: X channels a non-private site carries, not every X + channel with a shared tree (review H1; the ruling clarification above). +- **The cache-busted key on a retry** is `?cb=<builtStampId>&try=N` (the plan: `?cb=<builtStampId>`): a retry needs + a key the edge has not seen either. +- **A busted read that fails while the plain one serves this build is `ok`** — a visitor gets the build. The plan's + table did not name that case. +- **The homepage's live check reads `/` for a 2xx** (the homepage has no `corpus.json`); comparing + `/source/manifest.json`'s commit is a follow-up. +- **Exit 2** is used for request-shape refusals (bad branch name, local with a preview, the hub locally, a kind and + target that do not match); the S1 argv parser may catch most of them first. +- **Files outside the slice's list:** `common/lib/builtExport.ts` (`isTombstonePostsTree` + one clause of + `builtHubProblem`), `common/publish/build.test.ts` (argv pins follow the spawn change), `pnpm-workspace.yaml` + (`workerd: false`), and `common/lib/envVars.ts` + ENVIRONMENT.md (above). +- **A site's tombstones are kept per withheld member, as the plan asks** (review M3, an operator ruling to keep or + narrow): an X channel added to a public site after X went private is still tombstoned there, so the tombstone + names its slug and page count — and, since the review, no page size. + +#### Commits + +| Commit | What | +|---|---| +| `5f3dd404` | `common:` wrangler 4.147.0 pinned (devDependency, lockfile, `workerd: false`); `pagesDeployArgs` always names the branch; `wranglerBin` replaces `pnpm dlx` in `build.ts`; `WRANGLER_MAJOR`, the auth classifier and the credential preflight in `pagesDeploy.ts` (+ tests) | +| `82929307` | `common:` tombstones (`publish/tombstones.ts` + test), compose-site and compose-hub write them, `renderHeadersFile` `noStore`, `isTombstonePostsTree` + `builtHubProblem`; the postsVisibility, compose-hub, headers and builtExport tests | +| `cf920f8c` | `common:` `publish/deployStage.ts` + `publish/liveCheck.ts` (+ tests); `editor(e2e):` `fake-wrangler.mjs`, the playwright env; `envVars.ts` + ENVIRONMENT.md | +| `211b064f` | `common:` the homepage's local deploy is `ARCHILYZER_HOMEPAGE_OUT` (refused without it); the preflight is a token or a `wrangler login`; compose-site replaces a stale posts manifest beside tombstones; the `envVars.ts` rows split into this slice's and S5's (+ tests) | +| `0f9621c4` | `plans:` this section; the editor changelog | +| `801124c3` | `common:` the review fixes (below) | +| this commit | `plans:` the review fixes in this section; the changelog's hub wording | + +#### Gates (logs `$T/s2-*.log`) + +- **tsc** (all workspaces) clean before every commit. +- **common:** **3,193/3,193** at `cf920f8c`, 134 s (the base's 3,161 + 32: `pagesDeploy.test.ts` +5, `headers.test.ts` + +3, `tombstones.test.ts` 4, `liveCheck.test.ts` 9, `deployStage.test.ts` 10, `compose-hub.test.ts` +1; the + postsVisibility and builtExport cases grew in place). At `211b064f` (+1, the homepage's local deploy): **3,193 of + 3,194**, 307 s at a load average of 33–35 from other sessions; the one failure is `controller/fetchPosts.test.ts`'s + "a drain mid-page waits for the page's cursor" (a 200 ms `setTimeout` race against the fake gallery-dl), which + failed again run alone at that load and passed at `cf920f8c`; this slice touches nothing under `controller/` or + `social/`. `deployStage.test.ts`, `pagesDeploy.test.ts`, `envVars.test.ts` and the postsVisibility test pass at + `211b064f`. `deployStage.test.ts` spawns the real fake wrangler. + **Editor unit** 142/142. **test:scripts** 596 passed, 0 failed, 3 skipped (599). **mcp** 289/289. **Export unit** + 116/116. **Homepage unit** 23/23. +- **Builds** at `cf920f8c`: `pnpm --filter editor exec next build` exit 0, 98 s; `pnpm --filter homepage run + build:nodata` exit 0, 34 s; umtool's capped build (corpus linked `-T`, `MemoryMax=5G`, the link removed) exit 0, 33 s; + `pnpm --filter export exec next build` exit 0, 99 s — over `plans/tools/compose-fixture-one-youtube-channel/public` + linked into `export/public`, NOT the primary's: the primary's `export/public` held a cited site's compose at the + time, whose `/` a plain export build cannot prerender (ENOENT `summaries/manifest.json`, exit 1, 47 s — the same on + any tree; no export file changed in this slice). +- **Numbers tool:** none. **Privacy gate:** counts only over this branch's added lines and this section — the source + denylist's 4 entries: 0 hits; identifiers with the suffix the plan forbids: 0. + + | Run | At | Specs | Result | + |---|---|---|---| + | 1 (editor) | `cf920f8c` | `deploy-page`, `site-publish-preview`, `sites-homepage`, `build`, `site-scope` | 25 passed, **4 failed**, 8.3 min (no queue wait). `site-scope` ×3 (a navigation timeout, a `toHaveURL` timeout, `page.goto: net::ERR_ABORTED … frame was detached`) and `sites-homepage` ×1 (`apiRequestContext.get: read ECONNRESET`): this worktree's files were being edited while it ran (the dev server recompiles). None reaches a deploy | + | 2 (editor) | `cf920f8c` + the compose-site fix of `211b064f`, nothing edited during the run | `site-scope`, `sites-homepage` | **17 passed**, 0 failed, 2.3 min | + | 3 (editor) | `211b064f` | the five specs of run 1 | **29 passed**, 0 failed, 2.4 min (after 3 min in the queue) | + +#### Decisions the operator could overturn + +| What I did | The alternative | +|---|---| +| `pagesDeployArgs` returns the argv after the binary and always carries `--branch` | Keep `wrangler` as its first word for `pnpm dlx` callers (there are none left) | +| `workerd: false` in `allowBuilds` (pnpm 11 refuses an unanswered build script) | `true`: run workerd's install script, which a Pages deploy never needs | +| The preflight accepts `CLOUDFLARE_API_TOKEN` or a `wrangler login` file on disk (a host) | Token only — `.env` is the one supported way in the container | +| No-store rules carry CORS only where no CORS rule covers them | Repeat CORS on every no-store rule (serves `*, *` on a site, which browsers reject) | +| The deploy stage reads `built.json` and refuses (exit 3) without it, the homepage included | Fall back to the legacy `export/out` / `homepage/out` with no stamp | +| A deploy of the same `stampId` to the same slot is a no-op unless `force` | Always deploy (the plan's `needs()` already skips fresh stages; the no-op is the stage's own second word) | +| Only the hub's live check probes tombstones | Probe a site's too (a site's withheld channels are listed in no manifest a reader follows) | +| The homepage's live check reads `/` and asks only for a 2xx (it has no `corpus.json`) | Probe `/source/manifest.json` and compare its commit | +| A public site tombstones every withheld X member (the plan), with `maxPageBytes: 0` | Tombstone only members a public build ever served (a channel added after X went private is never named) | +| A deploy cancelled during its live check is recorded (with no check) and ends 130 | Leave `deployed.json` untouched, though the bundle is live | + +#### Review fixes (review SHIP AFTER FIXES, `$T/s2-review.md`) + +| # | Fix | Commit | +|---|---|---| +| H1 | The hub's tombstones only for X channels a non-private site carries; `compose-hub.test.ts` adds an X channel on a private site only and one on no site — neither is named anywhere in the hub's `public/`; `tombstones.test.ts` pins the set. Record and changelog say so (ruling clarification: private data is never named on the hub) | `801124c3`, this commit | +| H2 | Node 22 in the image (the pinned wrangler needs `>=22`) — S5's files, left to S5 | — | +| M1 | wrangler's lines through `log()`, each ending in a newline (test) | `801124c3` | +| M2 | `deploy-hub` / `deploy-homepage` pinned to `_hub` / `_homepage`; a site deploy refuses either; exit 2, nothing written (test: `deploy-homepage` with target `jeralyzer`) | `801124c3` | +| M3 | Per-withheld-member tombstones kept on a site as planned; `maxPageBytes: 0` | `801124c3` | +| L1 | The live check takes the stage's `signal`: reads abort (`AbortSignal.any` with the timeout), the interval sleep wakes, no retry after Cancel; a deploy cancelled mid-check is recorded without one, exit 130 (tests) | `801124c3` | +| L2 | A failed plain read is `unreachable`, never `stale-edge` (verdict table test) | `801124c3` | +| L3 | Request-shape refusals exit 2 | `801124c3` | +| L4 | R2 and wrangler failures throw the line they logged (test) | `801124c3` | +| L5 | `--to local` refuses a destination that holds the checkout, corpus, builds or bundle, or is non-empty with no `index.html` — naming the path, emptying nothing (tests) | `801124c3` | +| L6 | doctor importing `wranglerOAuthConfigFiles`, `ARCHILYZER_SITE_OUT`'s `readBy` — at the S5 merge | — | +| L7 | `#### Deviations from the plan`; the bold labels are headings | this commit | +| L8 | Open question 2's sentence softened | this commit | +| L9 | PUBLISH.md's `pnpm dlx wrangler pages project create` — S6 | — | + +Gates after the fixes: tsc (all workspaces) clean; **common 3,199/3,199**, 135 s (+6: `deployStage.test.ts` +5, +`liveCheck.test.ts` +1; `fetchPosts.test.ts` passes again at a load average of 16–19); e2e below. + + | Run | At | Specs | Result | + |---|---|---|---| + | 4 (editor) | `801124c3` | the five specs of run 1 | **29 passed**, 0 failed, 2.4 min (no queue wait) | + +#### Wiring round (after S1 merged, 2026-10-06) + +`r18/integration` merged twice: at `0ce00f76` (the plan, S5's image half, Node 22, the host id, S1) and at `de1b9174` +(S5's second half). + +| Commit | What | +|---|---| +| `3b467ac3` | merge of `r18/integration` `0ce00f76`. `editor/CHANGELOG.md` and this file: both sides kept. `envVars.ts`: S5's `CLOUDFLARE_API_TOKEN`, `XDG_CONFIG_HOME`, `ARCHILYZER_HOMEPAGE_OUT` rows kept, S2's marked copies deleted, their `readBy` (and `ARCHILYZER_SITE_OUT`'s) name `pagesDeploy.ts` / `deployStage.ts`; ENVIRONMENT.md regenerated. `stamps.ts`'s local `imageBuildFacts` re-exported from `lib/envVars.ts` | +| `553a94ed` | **the wiring.** `stageBodies.ts`: `deploy-site`, `deploy-hub` and `deploy-homepage` run `runDeployStage` end to end (bundle guards, credential preflight, R2, the pinned wrangler through `wranglerBin`, the live check, `deployed.json`, `--to local`); S1's interim deploy wrapper — the `build.ts` deploy calls, its own local copy (`publishLocal`, `localSiteOut`, `localHomepageOut`) and URL watcher — is gone: one implementation. `stageRun.ts`: a `DeployStageError`'s exit code is the stage's, and its sentence (logged by the stage) is not printed again — the runner adds only `[stage] <kind> <target>: FAILED (exit 1)`. `deployStage.ts` / `liveCheck.ts` now import `BuiltStamp`, `DeployRecord`, `DeployedFile`, `LiveCheck`, `Probe` from S1's `stamps.ts` and use its readers and `recordDeploy` | +| `6bccf923` | **the hub blocker — found on main 2026-10-05, fixed here** (below) | +| `f1541070` | merge of `r18/integration` `de1b9174` (S5's second half): `stamps.ts` takes S5's one-line `imageBuildFacts` re-export; `envVars.ts` merged clean (one row per name, `readBy` combined); ENVIRONMENT.md regenerates unchanged | +| this commit | `plans:` this table; the changelog's hub bullet | + +**What changed to fit S1's shapes (S1's win):** `builtAt`, `at` (DeployRecord, LiveCheck) are ms numbers, not ISO +strings; `Probe.age` is a number of seconds; `built.json` is read through S1's strict `readBuiltStamp` (a stamp missing +any field is no build) and `deployed.json` written through `recordDeploy`. Production now also refuses a build with +**no** branch recorded (a detached HEAD, an image built without `ARCHILYZER_BRANCH`), S1's rule. `builtAfter` is +`needs()`'s alone (stages.ts `needsDeploy` knows a no-op build's `checkedAt`); the stage's own copy is gone. +"`--to local` needs ARCHILYZER_SITE_OUT / ARCHILYZER_HOMEPAGE_OUT" is S1's sentence and exit 3. The stage's +`needs()` runs first and answers most refusals (no build, private, no project, production branch, freshness) with +S1's `StageFailure`; `runDeployStage` asks them again as the last word before wrangler. A kind/target mismatch from +the `stage` row is refused by S1's argv parser before either. One S1 test changed: its `--to local` destination is +seeded with an `index.html`, since the stage refuses to empty a directory that does not look like a bundle it made. + +**The hub blocker.** `HUB_FORBIDDEN_TREES` (`lib/builtExport.ts`) listed `reports` and `m`, but the export app renders +its report and moment routes into EVERY build — `reports/index.html`, `reports/_none/…`, `m/_none/…` — so since +2026-10-05 every hub bundle was refused ("still carries a site's data (reports, m)"; S1's smoke hit it). `reports` and +`m` are off the list; `hubReportDataIn` refuses the report DATA a site's reports stage writes there instead, by the +names `lib/report/views.ts` gives them: `reports/index.json`, `m/index.json`, `reports/<id>/page.json`, its +`citations.{json,csv}`, its exports (`report.{html,pdf,md}`, `evidence-pack.zip`), its history (`history.json`, +`history/repo/`), and any `m/**/moment.json`. `media` stays on the list; a tombstone-only `posts/` still passes and a +real post does not (`builtExport.test.ts`). + +Gates after the wiring (at `f1541070`): tsc (all workspaces) clean; **common 3,281/3,281**, 162 s (one run at +`6bccf923`, before the second merge, was 3,277/3,278 at a load average of 23–27: `fetchPosts.test.ts`'s timing case +again); **editor unit 142/142**. + +Smoke (`$T/s2-smoke.sh`, the `s1-smoke-build.sh` pattern over a scratch corpus in `$T/s2-smokec`, never the real one; +`WRANGLER_BIN` = the fake, `E2E_LIVE_CHECK=skip`): `publish index` 0; `publish build smoke` 0 (61 s); **`publish hub` +0 (61 s) — the bundle carries `reports/index.html` and `m/_none/` and passes `builtHubProblem`**, `_hub/built.json` +written; `stage deploy-site smoke --preview smoke` 0 through the stage row (fake wrangler argv `pages deploy +<builds>/smoke/out --project-name w3c-never-real --branch smoke`, `[preview]` line, live check `skipped`, `deployed.json` +`previews.smoke` with ms times) — the preflight passed on this host's `wrangler login` file, the no-credential refusal +is the unit tests'; the same again: no-op (fresh); `E2E_FAKE_WRANGLER_AUTH_FAIL=1`: exit 1, `[deploy] REFUSED by +Cloudflare — the API token was not accepted` once, then `[stage] deploy-site smoke: FAILED (exit 1)`, no record; +`stage deploy-hub _hub --preview smoke` 0 (`previews.smoke` in `_hub/deployed.json`); `stage deploy-homepage smoke` +refused by S1's argv parser ("the target is _homepage", exit 2 — the step's 1 was `pnpm exec`'s); `publish deploy smoke --to local` 0 (195 files into +the scratch `siteout`, `local` recorded with `liveCheck: null`). + + | Run | At | Specs | Result | + |---|---|---|---| + | 5 (editor) | `f1541070` | the five + `build`'s neighbours: `deploy-page`, `site-publish-preview`, `sites-homepage`, `build`, `site-scope`, `cut-release` | not run: the export webServer timed out (120 s) — the smoke, run while this one waited 16 min in the queue, had composed the hub into this worktree's `export/public` (no `summaries/`). `export/public` restored (`git clean -X` + the fixture links), then run 6 | + | 6 (editor) | `f1541070` | the same six | **35 passed**, 0 failed, 2.3 min (no queue wait) | + +**The hub fix is a record, not a changelog line:** the regression (main `5c09cd7b`, 2026-10-05) is in no release, so +no user ever saw it. + +#### Round-2 review cleanups (review SHIP, `$T/s2-review-2.md`) + +| # | Fix | Commit | +|---|---|---| +| L1 | `localDestProblem` resolves symlinks (realpath of the destination's nearest existing ancestor, and of every protected root) and refuses a destination INSIDE the builds dir, `export/` (so `export/out`, the link to the last bundle) or the corpus, as well as one containing them or the checkout; tests: `ARCHILYZER_SITE_OUT=<export>/out` refused with the bundle untouched, and a directory inside each of the three refused and not made | `c5f9100f` | +| L2 | The deploy body's exit codes agree with `needs()`: a private site, no Pages project (site or hub), a production build not of main, and the bundle guards (incl. the homepage's source gate) exit 3, not 1 | `c5f9100f` | +| L3 | `deployStage.ts`'s header: S1 has landed; `builtAfter` is `needs()`'s; the exit codes per step | `c5f9100f` | +| L4 | `stageRun.test.ts` proves the local copy by files the destination did not have (`corpus.json`, `site.json`) and the replaced `index.html` | `c5f9100f` | +| L7 | The smoke row: the mismatched homepage deploy exits 2; the changelog's tombstone bullet says "a channel only on a private site, or on no site"; the "hub builds again" changelog bullet removed (the statement above stays) | `c5f9100f` | +| S5 smoke | A malformed token (`CLOUDFLARE_API_TOKEN=bogus`) gets `Invalid request headers [code: 6003]` / `Invalid format for Authorization header [code: 6111]` from Cloudflare; both now read as `[deploy] REFUSED by Cloudflare — the API token was not accepted` (9109, a well-formed wrong token, already did); a test line each | `51ef7fd1` | + +Gates after the cleanups (at `51ef7fd1`, after merging `r18/integration` `f9f7cfbf`, S5 complete, clean): tsc clean; +**common 3,282/3,282**; **editor unit 142/142**. ### Slice S1, as shipped — the stage contract, the stamps, the lock, per-target bundles and the CLI (2026-10-06) Branch `r18/stage-core` off `ce66f2d3` (the plan commit on `r18/integration`), worktree `~/Projects/r18-stage-core` @@ -708,7 +1037,7 @@ merged, so the doctor's `wrangler`, `publish-lock` and `index-stamp` checks, the | `fc793037` | RUNNING_IN_DOCKER.md names Node 22 in the image's contents; this table | | `55d779a1` | **The publish lock's host identity** (S1's review: `os.hostname()` in a container is its id, new on every recreate, so a crashed holder's lock would look foreign forever; S1's `stageLock.ts` reads `ARCHILYZER_HOST_ID ?? os.hostname()`): `ARCHILYZER_HOST_ID: archilyzer-editor` on the **editor service's** `environment`, not `x-app-env` — site, homepage and umtool share the builds volume, and a container carrying the same id with its own pid namespace would judge the editor's live lock dead and take it (visible in `docker compose config` either way; checked: only the editor has it). envVars row, no TODO needed: the compose file names it, which the test accepts (`readBy` names `stageLock.ts`, S1's). RUNNING_IN_DOCKER.md: why the id is fixed, that `run --rm` would now carry it with other pids (one more reason for `exec`), and how to clear a foreign-host lock (`rm /data/builds/.export-builds/.publish.lock`, only when nothing is publishing) | | `c238970a` | SETUP.md: Node 22 — Next needs ≥ 20.9, deploying runs the pinned wrangler (≥ 22) | -| this one | this table | +| `65d549e7` | this table | Re-run after the fixes at `07bc2395`: tsc (all workspaces) clean; `doctor`, `buildImage`, `source` and `envVars` tests **61/61** (`$T/s5-fix-tests.log`). @@ -721,6 +1050,83 @@ load in the runtime — `lmdb` opens, writes and reads (`process.versions.module binding loads; `archilyzer doctor` reports `node v22.23.3` ok and every S5 check, exit 1 only for the model the smoke skips. vulkan and cuda still unbuilt (above). +**Second half** (S1 merged: `r18/integration` `0ce00f76`, a fast-forward of this branch; S2 not yet) + +| Commit | What | +|---|---| +| `0df61c8c` | `doctor:` **`publish/publish-lock`** over S1's `stageLock.ts` — free → ok; a live holder on this host → a note; the lock's own `holderIsGone` (dead pid, a different start time, a pid younger than the lock) → stale, with `rm <exportBuildsDir>/.publish.lock`; a lock that has not parsed past `LOCK_TORN_GRACE_MS` → torn, the same; another host's (`lockHostId(env)` differs) → named, never judged. Never cleared by the doctor. **`publish/index-stamp`** over S1's `stamps.ts` — id, age, generation; the built targets (sites, `_hub`, `_homepage`) whose `indexStampId` is older, as a warning with `publish build <id>`; no stamp → "update the index first: archilyzer publish index" (a warning once anything is built or configured). **`export-builds`** sums each bundle's `built.json` `bytes` (a bundle no stamp describes is still walked). **`workspace/node`** grades against wrangler's `engines.node` when `common/node_modules/wrangler/package.json` is there (below it: a warning — every deploy refuses), read the way the image's drift test reads it. `stamps.ts`'s local `imageBuildFacts` became a re-export of `lib/envVars`'s (one definition; S1's `stamps.test.ts` 6/6 unchanged). 3 new doctor tests (lock: 5 states; stamp + bytes; node vs the floor) | +| this one | `plans:` this table and the smoke; the doctor's changelog bullet names the new checks | + +Gates at `0df61c8c`: tsc (all workspaces) clean; `doctor`, `buildImage`, `source`, `envVars`, `stamps`, +`stageLock` tests **82 passed, 1 skipped** (the wrangler floor — not installed here). + +**Compose smoke, second half** (`--target runtime` rebuilt from `0df61c8c`, 363 s, 1.79 GB; `-p r18smoke`; +the e2e `curated-tags-channel` fixture — 3 videos with VTTs — and one site `s5site` copied into +`$T/s5-corpus2`; `docker-compose.source.yml` over a throwaway repo in `$T`; editor + `site`; `down -v` after, +nothing left): +- `exec editor pnpm archilyzer publish index` → exit 0, 10 s (index +3, stats built, signatures, the stamp). +- `publish build s5site` → exit 0, 76 s: "bundle installed at /data/builds/.export-builds/s5site/out (copied + across filesystems)", 198 files, 5.3 MB. Again → "fresh — nothing to do", exit 0. +- `publish deploy s5site --to local` → exit 0, 4 s, copied into `/data/builds/site`; the `site` service + serves it: `corpus.json` names `s5site` (1 channel, 3 videos), `/` 200. +- `publish deploy s5site --preview smoke` with no token: **skipped — the credential preflight lands with + S2.** S1's deploy body still calls `build.ts`'s `deploySite` (unpinned `pnpm dlx wrangler`), which has no + preflight to refuse before wrangler; the bogus-token run is the third round's. +- `source publish --check` over the throwaway repo (read-only, host-owned, `safe.directory`) with throwaway + scrub/denylist files put in the config volume by `docker compose cp` (mode 600): exit 0, 6 s — "check + passed — would publish main 60a126e08086 as 60a126e08086: 12 files … nothing written"; gitleaks skipped + with its WARNING and no history pages, as RUNNING_IN_DOCKER.md says. This is the review's open medium: + the container's mirror over the `:ro`, foreign-owned mount works. +- `doctor` (exit 1 only for the model the smoke skips): `node v22.23.3` ok, `downloader/yt-dlp` ok (image), + `cloudflare-auth` note, `export-builds` "1 bundle, 5 MB" (from built.json), `publish-lock` "free", + `index-stamp` "…, generation 1; 1 bundle built from it", `filter-repo` ok, `source-repo` ok (main + 60a126e08086), `config-dir` "2 entries", `scrub rules` / `denylist` ok (1 each, mode 600 — counted). +- `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** (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 (Steps 1–7 above; "### As it went" is written as the rollout runs.) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: 7.7.17(react@19.2.4) next: specifier: 16.2.3 - version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.3(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) p-limit: specifier: ^7.3.0 version: 7.3.0 @@ -117,6 +117,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + wrangler: + specifier: 4.147.0 + version: 4.147.0(@types/node@20.19.39) editor: dependencies: @@ -131,7 +134,7 @@ importers: version: 7.7.17(react@19.2.4) next: specifier: 16.2.3 - version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.3(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -159,10 +162,10 @@ importers: version: 19.2.3(@types/react@19.2.14) eslint: specifier: ^9.39.4 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) eslint-config-next: specifier: 16.2.3 - version: 16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) tailwindcss: specifier: ^4.2.2 version: 4.2.4 @@ -183,7 +186,7 @@ importers: version: 1.16.0(react@19.2.4) next: specifier: 16.2.3 - version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.3(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -195,7 +198,7 @@ importers: version: 2.16.1(react@19.2.4) serve: specifier: ^14.2.6 - version: 14.2.6 + version: 14.2.6(supports-color@10.2.2) yt-dlp-transcript-common: specifier: workspace:* version: link:../common @@ -217,10 +220,10 @@ importers: version: 19.2.3(@types/react@19.2.14) eslint: specifier: ^9.39.4 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) eslint-config-next: specifier: 16.2.3 - version: 16.2.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) tailwindcss: specifier: ^4.2.2 version: 4.2.4 @@ -241,7 +244,7 @@ importers: version: 7.7.17(react@19.2.4) next: specifier: 16.2.3 - version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.3(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -250,7 +253,7 @@ importers: version: 19.2.4(react@19.2.4) serve: specifier: ^14.2.6 - version: 14.2.6 + version: 14.2.6(supports-color@10.2.2) yt-dlp-transcript-common: specifier: workspace:* version: link:../common @@ -272,10 +275,10 @@ importers: version: 19.2.3(@types/react@19.2.14) eslint: specifier: ^9.39.4 - version: 9.39.4(jiti@2.6.1) + version: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) eslint-config-next: specifier: 16.2.3 - version: 16.2.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) tailwindcss: specifier: ^4.2.2 version: 4.2.4 @@ -321,7 +324,7 @@ importers: version: 3.5.4 next: specifier: 16.2.3 - version: 16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.3(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -521,12 +524,62 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20261001.1': + resolution: {integrity: sha512-4cgSgDf28JSw/P5Dj5GCS59hzVqS5XnmGAWNkvYHLI6ODU9idGaMMNEuhJXDkEG/lsABmlVlnCgsdh2VbKWepw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261001.1': + resolution: {integrity: sha512-8ulAWruEVouNmEIsQsy9WSSCS9zkLu93W2MTwp5esiFyoPp05BNSVFIFsYSPi1pkFmBBd7fsnwMpbLkaJLaVPQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261001.1': + resolution: {integrity: sha512-kZbTZJGrhsMOdqZ2BIybjaBRLZjYsRLWj7mcNw/6Y3hodOhp5MrNzcz4iWGwNVWwyIV+7Pl+/LX5VcgnRqdHOg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261001.1': + resolution: {integrity: sha512-oOk3Zj6k/8oP0FJgZBWDn7+BqbsqIMEMaV95pulHPVbwVu4wYoLfQq2hp0vkbCNsCFLqZb7cqixXdrwD54ZIow==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261001.1': + resolution: {integrity: sha512-uRxm5W4VyBkoSaoP1BfOuH0873tE+vxsknF4sab6/5YIRvIAufChq3QDp+zlAn67PuGPGcn7W8QraA0t4ucmOQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -536,156 +589,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -772,70 +981,145 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -843,6 +1127,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -850,6 +1141,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -857,6 +1155,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -864,6 +1169,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -871,6 +1183,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -878,6 +1197,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -885,6 +1211,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -892,29 +1225,63 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -931,6 +1298,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lmdb/lmdb-darwin-arm64@3.5.4': resolution: {integrity: sha512-Kk4Kz3iyu1QiLsLZBS9Af1eSKUC8VR2T+/jyE2iAyuGw2VwK08pp5iTbZnXn6sWu0LogO/RFktMxOjiDA2sS3w==} cpu: [arm64] @@ -1090,6 +1460,15 @@ packages: engines: {node: '>=18'} hasBin: true + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -1786,6 +2165,10 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -1822,6 +2205,9 @@ packages: resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -2273,6 +2659,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -2391,6 +2780,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2538,6 +2931,9 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-abstract@1.24.2: resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} @@ -2575,6 +2971,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3152,6 +3553,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -3312,6 +3717,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + miniflare@5.20261001.0-alpha: + resolution: {integrity: sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==} + engines: {node: '>=22.0.0'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3487,6 +3896,12 @@ packages: path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3703,8 +4118,13 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -3732,6 +4152,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3858,6 +4287,10 @@ packages: babel-plugin-macros: optional: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3950,6 +4383,13 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -4035,10 +4475,37 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + workerd@1.20261001.1: + resolution: {integrity: sha512-d/SIYHFO0PT/wiFZg8in4NpRIxYuFwslX1HdylOtWkBIIUmSpkGFhK820cV84XACFylwJ48xuRoWW/8DWDPsPQ==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.147.0: + resolution: {integrity: sha512-pQYRoiq8PTAxphaG69z8+GC1DkSGd19EDZehQ8zxjo/Ko3mRB6Qs1mTrd8ZuKAarLklIjTqr1lUdCK9r4q2hUg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261001.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@8.1.0: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4054,6 +4521,12 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -4250,20 +4723,20 @@ snapshots: '@babel/compat-data@7.29.0': {} - '@babel/core@7.29.0': + '@babel/core@7.29.0(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.2 '@babel/parser': 7.29.2 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@10.2.2) '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4288,19 +4761,19 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.28.6(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@10.2.2) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.29.0(supports-color@10.2.2) + '@babel/helper-module-imports': 7.28.6(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -4327,7 +4800,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/types': 7.29.0 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.0(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -4335,7 +4808,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -4344,6 +4817,33 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261001.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261001.1 + + '@cloudflare/workerd-darwin-64@1.20261001.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261001.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261001.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261001.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261001.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -4355,6 +4855,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -4363,92 +4868,170 @@ snapshots: '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))': dependencies: - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4461,10 +5044,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.5(supports-color@10.2.2)': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4519,103 +5102,206 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.10.0 optional: true + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.4': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.4': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.4': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4635,6 +5321,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@lmdb/lmdb-darwin-arm64@3.5.4': optional: true @@ -4748,6 +5439,18 @@ snapshots: dependencies: playwright: 1.59.1 + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.4': {} @@ -5496,6 +6199,8 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@sindresorhus/is@7.2.0': {} + '@sindresorhus/merge-streams@4.0.0': {} '@sindresorhus/slugify@3.0.0': @@ -5538,6 +6243,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@speed-highlight/core@1.2.24': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -5682,15 +6389,15 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.0 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5698,23 +6405,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.59.0 '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.0(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.0 - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.0(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) '@typescript-eslint/types': 8.59.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5728,13 +6435,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/typescript-estree': 8.59.0(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -5742,13 +6449,13 @@ snapshots: '@typescript-eslint/types@8.59.0': {} - '@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.0(supports-color@10.2.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.0(typescript@5.9.3) + '@typescript-eslint/project-service': 8.59.0(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) '@typescript-eslint/types': 8.59.0 '@typescript-eslint/visitor-keys': 8.59.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.16 @@ -5757,13 +6464,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2)) '@typescript-eslint/scope-manager': 8.59.0 '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/typescript-estree': 8.59.0(supports-color@10.2.2)(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5967,6 +6674,8 @@ snapshots: baseline-browser-mapping@2.10.23: {} + blake3-wasm@2.1.5: {} + bowser@2.14.1: {} boxen@7.0.0: @@ -6082,11 +6791,11 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.8.1(supports-color@10.2.2): dependencies: bytes: 3.1.2 compressible: 2.0.18 - debug: 2.6.9 + debug: 2.6.9(supports-color@10.2.2) negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -6100,6 +6809,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6166,17 +6877,23 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - debug@2.6.9: + debug@2.6.9(supports-color@10.2.2): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 10.2.2 - debug@3.2.7: + debug@3.2.7(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js-light@2.5.1: {} @@ -6230,6 +6947,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + error-stack-parser-es@1.0.5: {} + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 @@ -6360,44 +7079,53 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} - eslint-config-next@16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.2.3 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) - globals: 16.4.0 - typescript-eslint: 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - - eslint-config-next@16.2.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.2.3(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.2.3 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2) globals: 16.4.0 - typescript-eslint: 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + typescript-eslint: 8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -6406,52 +7134,52 @@ snapshots: - eslint-plugin-import-x - supports-color - eslint-import-resolver-node@0.3.10: + eslint-import-resolver-node@0.3.10(supports-color@10.2.2): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) is-core-module: 2.16.1 resolve: 2.0.0-next.6 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) get-tsconfig: 4.14.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) optionalDependencies: - '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7 + debug: 3.2.7(supports-color@10.2.2) doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) + eslint-import-resolver-node: 0.3.10(supports-color@10.2.2) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@10.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2) hasown: 2.0.3 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -6463,40 +7191,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.3 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2)): dependencies: aria-query: 5.3.2 array-includes: 3.1.9 @@ -6506,7 +7207,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) hasown: 2.0.3 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -6515,18 +7216,18 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@10.2.2) '@babel/parser': 7.29.2 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2)): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -6534,7 +7235,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.3.2 - eslint: 9.39.4(jiti@2.6.1) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) estraverse: 5.3.0 hasown: 2.0.3 jsx-ast-utils: 3.3.5 @@ -6559,14 +7260,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.4(jiti@2.6.1): + eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@10.2.2) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 + '@eslint/eslintrc': 3.3.5(supports-color@10.2.2) '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -6576,7 +7277,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -7043,6 +7744,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -7173,6 +7876,19 @@ snapshots: mimic-fn@2.1.0: {} + miniflare@5.20261001.0-alpha(@types/node@20.19.39): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.4(@types/node@20.19.39) + undici: 7.29.1 + workerd: 1.20261001.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -7211,7 +7927,7 @@ snapshots: negotiator@0.6.4: {} - next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.3(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.3 '@swc/helpers': 0.5.15 @@ -7220,7 +7936,7 @@ snapshots: postcss: 8.4.31 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0(supports-color@10.2.2))(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.2.3 '@next/swc-darwin-x64': 16.2.3 @@ -7355,6 +8071,10 @@ snapshots: path-to-regexp@3.3.0: {} + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -7639,6 +8359,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + serve-handler@6.1.7: dependencies: bytes: 3.0.0 @@ -7649,7 +8371,7 @@ snapshots: path-to-regexp: 3.3.0 range-parser: 1.2.0 - serve@14.2.6: + serve@14.2.6(supports-color@10.2.2): dependencies: '@zeit/schemas': 2.36.0 ajv: 8.18.0 @@ -7658,7 +8380,7 @@ snapshots: chalk: 5.0.1 chalk-template: 0.4.0 clipboardy: 3.0.0 - compression: 1.8.1 + compression: 1.8.1(supports-color@10.2.2) is-port-reachable: 4.0.0 serve-handler: 6.1.7 update-check: 1.5.4 @@ -7719,6 +8441,39 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + sharp@0.35.4(@types/node@20.19.39): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 20.19.39 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -7860,12 +8615,14 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + styled-jsx@5.1.6(@babel/core@7.29.0(supports-color@10.2.2))(react@19.2.4): dependencies: client-only: 0.0.1 react: 19.2.4 optionalDependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@10.2.2) + + supports-color@10.2.2: {} supports-color@7.2.0: dependencies: @@ -7951,13 +8708,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.0(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1)(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -7973,6 +8730,12 @@ snapshots: undici-types@6.21.0: {} + undici@7.29.1: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicorn-magic@0.3.0: {} universalify@2.0.1: {} @@ -8105,12 +8868,39 @@ snapshots: word-wrap@1.2.5: {} + workerd@1.20261001.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261001.1 + '@cloudflare/workerd-darwin-arm64': 1.20261001.1 + '@cloudflare/workerd-linux-64': 1.20261001.1 + '@cloudflare/workerd-linux-arm64': 1.20261001.1 + '@cloudflare/workerd-windows-64': 1.20261001.1 + + wrangler@4.147.0(@types/node@20.19.39): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261001.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20261001.0-alpha(@types/node@20.19.39) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261001.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + wrap-ansi@8.1.0: dependencies: ansi-styles: 6.2.3 string-width: 5.1.2 strip-ansi: 7.2.0 + ws@8.21.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} @@ -8119,6 +8909,19 @@ snapshots: yoctocolors@2.1.2: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ allowBuilds: msgpackr-extract: true sharp: true unrs-resolver: true + workerd: false ignoredBuiltDependencies: - sharp