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:
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