Archilyzer · Source

archilyzer

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

commit df14a98e5e0076dcffc3fabb63f84155f591f596
parent 1122627f3c794d1e8bbbdabc3cf77588b696afcf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 10:36:06 -0400

Merge r18/deploy-hardening (slice S2: pinned wrangler with --branch, credential preflight and auth classifier, the deploy stage with live check and deployed.json, X tombstones + no-store headers, the fake wrangler; the hub bundle check refuses report data, not the app's own routes)

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

Diffstat:
MENVIRONMENT.md | 11+++++++----
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/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 | 11+++++++----
Mcommon/lib/pagesDeploy.test.ts | 112++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcommon/lib/pagesDeploy.ts | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcommon/package.json | 3++-
Mcommon/publish/build.test.ts | 14++++++++++++--
Mcommon/publish/build.ts | 54++++++++++++++++++++++--------------------------------
Acommon/publish/deployStage.test.ts | 656+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/deployStage.ts | 506+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/liveCheck.test.ts | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/liveCheck.ts | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/stageBodies.ts | 159++++++++++---------------------------------------------------------------------
Mcommon/publish/stageRun.test.ts | 3+++
Mcommon/publish/stageRun.ts | 12++++++++++++
Acommon/publish/tombstones.test.ts | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/tombstones.ts | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 3+++
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 | 310+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 1125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mpnpm-workspace.yaml | 1+
31 files changed, 4347 insertions(+), 400 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 | | `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,8 +157,8 @@ 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`) | @@ -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/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", 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,8 +160,8 @@ 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." }, @@ -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,93 @@ 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]", + "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", + "", + ]) { + 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,112 @@ 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", 10001 "Unable to authenticate request") 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\]/, + /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,656 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + 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, + 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, 1, /is private \(audience: private\)/], + ["no Pages project", { kind: "deploy-site", target: "noproj" }, TOKEN, 1, /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" }), + 1, + /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" }), + 1, + /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" }), + 1, + /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" }), + 1, + /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" }), + 1, + /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"); + } 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), + 1, + /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 }), 1, /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,506 @@ +// THE DEPLOY STAGE (release 18): one body for `publish-deploy-site`, +// `publish-deploy-hub` and `publish-deploy-homepage`, 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. In order — and NOTHING is written to +// `deployed.json` unless every step before the record succeeded: +// +// 1. the request: a preview branch name Cloudflare keeps verbatim; a local +// deploy has no branch; the hub has no local target +// 2. the target's own refusals, before anything else is read: 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>"), be +// newer than `builtAfter` when the run started a build (exit 3), and — +// unless forced — not be the one this slot already shipped (a no-op) +// 4. PRODUCTION ships only a build of `main`: a `built.branch` that is set +// and is not `main` is refused (a preview is fine) +// 5. today's bundle guards over the bundle itself: 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, what the `homepage` service serves) and the +// stage records `local` — no credential, no R2, no wrangler, no live check +// 7. the credential preflight: 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 (atomic: temp file + rename) +// +// A refusal or a failure THROWS a DeployStageError carrying the exit code the +// stage contract names (1 refused/failed, 2 a request the stage cannot run — a +// bad branch name, a kind and target that do not match, local with a preview — +// 3 precondition not met, 130 cancelled); its message is the sentence the log +// already ends on, word for word. +// +// The stamp shapes are release 18's model (plans/release-18.md, "Model"). S1's +// publish/stamps.ts owns them once it lands — the fields here are the same. + +import os from "node:os"; +import path from "node:path"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { cp, mkdir, readdir, 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(); +} + +// 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) must not be wiped: the destination may not be, or contain, the +// checkout, the corpus, the builds 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 = path.resolve(dest); + const inside = (parent: string, child: string) => { + const rel = path.relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + }; + for (const [what, dir] of [ + ["the checkout", paths.monorepoRoot], + ["the corpus", paths.transcriptsDir], + ["the builds directory", paths.exportBuildsDir], + ["export/", paths.exportDir], + ["the bundle", outDir], + ] as const) { + if (inside(d, path.resolve(dir)) || inside(path.resolve(outDir), d)) { + return `${d} holds ${what} (or is inside the bundle) — 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}.`); + project = site.cloudflareProject?.trim() ?? ""; + if (!project && !toLocal) refuse(`Site "${target}" has no Cloudflare Pages project configured.`); + publicUrl = site.siteUrl?.trim() || undefined; + } else if (req.kind === "deploy-hub") { + const hub = getHomepageConfig(paths); + const problem = hubProjectProblem(hub.cloudflareProject); + if (problem) refuse(problem); + 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.`, + ); + } + + // --- 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.`); + } + } else if (req.kind === "deploy-hub") { + const problem = builtHubProblem(outDir); + if (problem) refuse(`${problem.replace(/^export\/out/, outDir)}.`); + } else { + const problem = + builtHomepageProblem(outDir) ?? (await (await import("./source")).publishedSourceProblem(paths, outDir)); + if (problem) refuse(problem); + } + + 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,6 +164,9 @@ 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 { 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/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,6 +1,9 @@ # 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. +- **The hub builds again.** Since 2026-10-05 every hub build was refused as "still carries a site's data (reports, m)": the export app's own report and moment pages are part of every build, the hub's included. A hub build is now refused only for report data a site's build wrote — a report index, a report's page, citations, exports or history, a moment — and still for any other site data. +- **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 private sites carry 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. 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,316 @@ 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 refused/failed, 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, 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 1); `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) | ### 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` 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