cursedops 0.13.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,490 +6,42 @@ Build-and-ops answers that the apps here wrote independently and identically.
6
6
  bun add cursedops
7
7
  ```
8
8
 
9
+ See `docs/decisions.md` for why this library exists, its entry rules, and what was
10
+ deliberately left in the apps instead. See `docs/reference.md` for every subpath's
11
+ import shape, worked example, and the incident each one is pinned to.
12
+
9
13
  | subpath | what it is |
10
14
  |---|---|
11
- | `cursedops/roots` | finding a generation's roots, and a checkout's package root, without knowing a path — and the `cd` prefix of a printed command that RUNS when pasted (`repoCd`, 0.5.0); a worktree is followed to its primary from its `.git` file, once per directory, never a `git` per call (0.10.7) |
12
- | `cursedops/paths` | the generation's whole-tree laws (`check-paths`, `check-doc-citations`, plus any `--also`) run over ONE repo, from a checkout or a worktree — and its `forge-paths` bin (0.5.0) |
13
- | `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent, and the port a LIVE job serves on (`livePort`, 0.5.0) |
14
- | `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code — the one check no app owns (every address of a deployment serving the same built client), and since 0.5.0 its VERDICTS: the origin asked on loopback, the smoke's own environment, a network that lies about DNS, and a version that has settled |
15
- | `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke — and the readers it needs (0.5.0); `readWranglerFile(root)` since 0.9.0, lifted from four apps' `readWrangler` (task 2153); since 0.10.25 every run, red or green, appends a `kind: "deploy"` line with each leg's wall clock (`install`, `stage`, `walk`, `build`, `schema`, `upload`, `secrets`, `smoke`) to the generation's ledger (task 2246) |
16
- | `cursedops/worker-deploy` (CPU) | a `wrangler tail` around the traffic a deploy already sends, each request's BILLED CPU charged to its declared route, red on an undeclared, over-budget or uncapped route — and `runWorkerDeploy`'s `stageWalk` + `cpuTail` that wrap the walk and the smoke in it (0.7.0, task 2134, lifted from collections) |
17
- | `cursedops/worker-secrets` | a Worker holding EXACTLY its deployment's secrets, uploaded over a pipe, read back (0.5.0) — and read again, up to five times, before a set that is not exact is believed: the list lags the puts (0.11.1) |
18
- | `cursedops/worker-rollback` | a hostname back on its Mac origin: origin first, route second, the route found rather than typed (0.5.0) |
19
- | `cursedops/edge-fetch` | a request to a deployed Worker as a script must make it — curl pinned past the Mac's negative DNS cache (0.5.0); a request's own `maxTimeSec` over the instance's (0.11.1) |
20
- | `cursedops/d1-columns` | the schema step's first half: any COLUMN `db/schema.sql` has and a live D1 table lacks, read in one `pragma_table_info` query and added by `ALTER TABLE … ADD COLUMN` — refusing a key, UNIQUE, or NOT NULL-without-DEFAULT column. `runWorkerDeploy` runs it for every app with a `databaseName` (0.13.0, task 2488, lifted from roms) |
21
- | `cursedops/d1-import` | a cutover's data proof — SQL literals and the row-for-row comparison, never a count (0.5.0); and `d1SchemaText`, the generated `db/schema.sql` that refuses a statement spanning lines, because D1's exec splits on newlines (0.9.0, lifted from five apps, task 2153) — and its `forge-d1-schema` bin (0.9.1), so no app carries its own schema script at all |
22
- | `cursedops/backups` | `BACKUP_KEEP`, the fleet's one retention for dated snapshots — six apps each exported `KEEP = 14` (0.9.0, task 2153); `BACKUP_STAMP_PATTERN`, `backupStamp` and its inverse `stampTime` — the local-time run name every backup script writes and station's audit reads (0.11.2) |
15
+ | `cursedops/roots` | finding a generation's roots and a checkout's package root without knowing a path, and `repoCd`/`repoCommand`, the `cd` prefix of a printed command that RUNS when pasted |
16
+ | `cursedops/paths` | the generation's whole-tree laws (`check-paths`, `check-doc-citations`, plus any `--also`) run over ONE repo, from a checkout or a worktree — and its `forge-paths` bin |
17
+ | `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent, and the port a LIVE job serves on (`livePort`) |
18
+ | `cursedops/smoke` | the scaffolding of a deployed smoke (ledger, fetch, DNS hint, exit code) — the one check no app owns — plus its VERDICTS: the origin asked on loopback, the smoke's own environment, a network that lies about DNS, and a version that has settled |
19
+ | `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke — plus a billed-CPU tail wrapped around the walk and the smoke, and a ledger line per run |
20
+ | `cursedops/worker-secrets` | a Worker holding EXACTLY its deployment's secrets, uploaded over a pipe, read back up to five times before an inexact set is believed |
21
+ | `cursedops/worker-rollback` | a hostname back on its Mac origin — origin first, route second, the route found rather than typed |
22
+ | `cursedops/edge-fetch` | a request to a deployed Worker the way a script must make it — curl pinned past the Mac's negative DNS cache |
23
+ | `cursedops/d1-columns` | the schema step's first half — any column `db/schema.sql` has that a live D1 table lacks, added by `ALTER TABLE … ADD COLUMN` |
24
+ | `cursedops/d1-import` | a cutover's data proof — row-for-row, never a count — and `d1SchemaText`/its `forge-d1-schema` bin, the generated schema no app hand-writes |
25
+ | `cursedops/backups` | `BACKUP_KEEP`, the fleet's one retention for dated snapshots; `BACKUP_STAMP_PATTERN`, `backupStamp` and `stampTime` |
23
26
  | `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
24
- | `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normaliser and the default 404 body. No `node:` import, so it mounts inside a Worker |
25
- | `cursedops/build-info` | which commit a checkout-served process is running and whether its tree was dirty — read once at load, and a `null` retried in the background rather than cached for the life of the process. `node:child_process`, so never in a Worker |
26
- | `cursedops/bound-lists` | the static check that no SQL builds an `IN (…)` list one `?` per value — the shape D1 500s on past 100 ids. Test-time only; the recorded third exception, see below |
27
- | `cursedops/staged-client` | the IMMUTABLE client a checkout-served app serves — a build staged into `<APP_DATA_DIR>/client/<commit>/` behind `CURRENT`, so another agent's `vite build` in the checkout never changes live bytes — and its `forge-client stamp` / `forge-client stage` bin (0.6.0, lifted from family, flix, roms, station) |
28
- | `cursedops/deploy-tree` | the two refusals a checkout-served deploy needs — never deploy a dirty tree, never `git revert` in one (`readTreeStatus`, `refuseToDeploy`, `refuseToRevert`; 0.6.0, lifted from nine apps) |
29
- | `cursedops/ship` | the owner's FAST SHIPPING switch as every deploy reads it — `readShipSetting` (`$FORGE_STATE/state/autopilot/ship.json`; absent or unreadable is `fast`, no generation is `each`), `deferral`/`shipDeferral` (the sentence a production deploy prints instead of deploying, or `null`), `FORGE_SHIP_NOW=1` to ship now whatever it says. `runWorkerDeploy` asks it before installing anything; a Mac-side `bun run deploy` calls `shipDeferral` first (0.11.0). Since 0.12.0 `shipNowVerdict` honours ship-now under `$AUTOPILOT_BATCH` only for a batch holding a live claim on the ship task or a `prod-down:` incident, with `FORGE_SHIP_NOW_WHY` set, and every ask is a `kind: "ship-now"` ledger row (task 2384) |
30
- | `cursedops/relay` | the WORKER half of a relay — a Worker in front of a Mac-bound app over ONE outbound WebSocket: the frames (station's wire, byte for byte), the key-digest door (`acceptLink`), and `RelayLink`, the Durable Object an app subclasses with its name, body ceiling, timeout and (optionally) station's per-cookie offline cache. No `node:` import (0.10.0, lifted from station for roms, task 065) |
31
- | `cursedops/relay-link` | the MAC half — `dialLink` (answer each frame with the app's own `fetch`, redial 1 s → 60 s on close, no timer otherwise), `readLinkSettings` for `<APP>_LINK_URL`/`<APP>_LINK_KEY`, and `rotateLinkKey` (digest to the Worker FIRST, the key to the 0600 file only if it took) (0.10.0) |
32
- | `cursedops/relay-stage` | the signed-in STAGE WALK's skeleton both relay apps shared — `stageByteCheck` (SHA-256 of every built file, a stale byte re-asked 4 × 10 s), `putStageLinkDigest` (`--env stage` only), `grantStageOwner` (auth-stage's 2FA grant, never production's), `oldKeyRefused` (the rotation leg: 20 s refused window, 3 min ceiling), `awaitSystemResolver`, `servedPath`, `STAGE_AUTH_URL`. With `relayWorkerDeployCli` in `cursedops/worker-deploy`, a relay app's whole `worker:deploy` as data (0.10.6) |
33
- | `cursedops/public-client` | does a relay app's PUBLIC hostname serve the client this release built? `comparePublicClient` (built assets the shells name, never bodies), `stagedShell`, `publicShell` (cache-busted, Access headers as an option), `recordPublicClient` — the smoke's `public-client` row, an EDGE fault (exit 2, never a rollback) — and `shipClientIfBehind`, the deploy step that runs `worker:deploy` when the public shell is behind. Lifted from roms for station (0.10.8). `stagedBuild` + `publicBuild` (0.10.18): pass `stagedBuild` and the commit the public `/build.json` names must be this release's too — `/`'s asset names cannot see a change to another page or a fixed-name file. `builtClient(dir)` (0.10.20): the commit and entry chunk a built `dist/` carries — what a relay smoke's `awaitConverged` asks the edge for |
34
- | `cursedops/public-surface` | the ratchet on a LIBRARY's public surface — a symbol count per export subpath against a committed baseline that may only fall — and its `public-surface` bin. Not an app's: the one entry here admitted for three published libraries, see below |
27
+ | `cursedops/worker-safety` | the two static checks every Worker app's suite runs over its own source — the import-graph walk an app's own test does from `worker/index.ts` (a value import of `bun:sqlite` or a Bun-only subpath is a deploy that goes green and a Worker that never starts), and the await port's silent shapes (`rows.filter(async …)` keeps every row) |
28
+ | `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normaliser, the default 404 body |
29
+ | `cursedops/build-info` | which commit a checkout-served process is running and whether its tree was dirty, read once at load. `node:child_process`, so never in a Worker |
30
+ | `cursedops/bound-lists` | the static check that no SQL builds an `IN (…)` list one `?` per value — the shape D1 500s on past 100 ids |
31
+ | `cursedops/staged-client` | the IMMUTABLE client a checkout-served app serves — a build staged into `<APP_DATA_DIR>/client/<commit>/` behind `CURRENT` |
32
+ | `cursedops/deploy-tree` | the two refusals a checkout-served deploy needs — never deploy a dirty tree, never `git revert` in one |
33
+ | `cursedops/ship` | the owner's FAST SHIPPING switch as every deploy reads it — `readShipSetting`, `shipDeferral`, `FORGE_SHIP_NOW=1` to ship now whatever it says |
34
+ | `cursedops/relay` | the WORKER half of a relay — a Worker in front of a Mac-bound app over ONE outbound WebSocket, the frame wire, the key-digest door, and the Durable Object an app subclasses |
35
+ | `cursedops/relay-link` | the MAC half — `dialLink`, `readLinkSettings`, `rotateLinkKey` |
36
+ | `cursedops/relay-stage` | the signed-in STAGE WALK's skeleton both relay apps shared |
37
+ | `cursedops/public-client` | does a relay app's PUBLIC hostname serve the client this release built? — `comparePublicClient`, `recordPublicClient`, `shipClientIfBehind` |
38
+ | `cursedops/container-deploy` | a Cloudflare CONTAINER Worker deployed and proven — the env-file secret lines, Docker on PATH and up, `/healthz`, and `awaitRollout`: the container on the new image, not just the Worker answering |
39
+ | `cursedops/public-surface` | the ratchet on a LIBRARY's public surface — a symbol count per export subpath against a committed baseline that may only fall |
35
40
 
36
41
  Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
37
42
  name, a hostname, a port or a route. `typescript` is an **optional** peer: only
38
43
  `cursedops/public-surface` imports it, and nothing else here ever loads it.
39
44
 
40
- ## 🔴 The ceiling, and why this library has one
41
-
42
- This exists because `apps/desk` and `apps/flix` answered the same six build-and-ops
43
- questions independently, and **three of the six came out the same**. It does not exist
44
- because sharing is good.
45
-
46
- The library it replaces reached **277 files** in a previous generation, on the reasoning
47
- that a module used by 8 of 8 apps is a module worth sharing. What that produced was an
48
- app whose entire `vite.config.ts` was one line it could not read, a build system no app
49
- could opt out of, and a monorepo that could not be relocated. Every one of those 277
50
- files was individually defensible.
51
-
52
- So the entry rule here is arithmetic, not judgement:
53
-
54
- 1. **Two apps wrote it independently.** Not one app and a plan for a second — two
55
- working implementations, in the tree, that a `diff` says are the same.
56
- 2. **It is mechanism, not identity.** If a function would need to know an app's name,
57
- port, hostname, routes or health-payload shape, it belongs in the app. Every
58
- function here takes those as arguments or does not see them at all.
59
- 3. **It is a trap somebody paid for.** Each thing in here is a measured incident turned
60
- into a check — the file headers carry the dates and the costs. A convenience that
61
- has never prevented anything is not a reason to add a dependency to eight apps.
62
-
63
- A fourth rule follows from the first three: **an addition that only one app will use
64
- does not go in.** That is a library that exists to be refactored later.
65
-
66
- 🔴 **`sameDeployedShell` is the one entry that rule 1 did not let in, and it is recorded
67
- here rather than quietly excepted.** It was written once, by `patterns`, not twice —
68
- what admits it is rule 3 at a size rule 1 cannot measure: the fault it catches was
69
- invisible for six days on the app that had it, and the other five apps behind or moving
70
- behind an edge Worker still cannot tell a working deploy from a working cache. A check
71
- told ONE NAME is a check about that name; this one is about a property every deployment
72
- with two addresses has. It buys the exception by being pinned to a failure it has
73
- actually caught — `src/smoke.test.ts` runs it against the genuine 2026-09-12 shell.
74
-
75
- 🔴 **`public-surface` is the second recorded exception, and the first whose callers are
76
- not apps.** Rule 1 is met — more than met — but by three LIBRARIES: `cwip`, `cursedbelt`
77
- and `cursedbelt-server` each carried `scripts/publicSurface.ts` (430, 455 and 476 lines),
78
- and by 0.2.6 the bodies had already forked, which is what admits it. Measured 2026-09-22:
79
- `cwip`'s threw on a bare-specifier `export *` where the other two resolved and counted
80
- it, so the three numbers were not commensurable; and when the ESM-extension fix (task
81
- 269) made `./x.js` the spelling of an internal specifier, `cursedbelt-server`'s and
82
- `cursedbelt`'s copies each had to be taught `./x.js` → `./x.ts` by hand, separately,
83
- while `cwip`'s never was. Each library is published on its own and its gate must pass
84
- from a lone checkout, so neither a `file:` import nor a reach into `$FORGE/tools` could
85
- share it; a published version could, and this package is already that for the fleet.
86
- It is a devDependency of each library, so nothing their consumers install changed. On
87
- adoption all three measured **identical per-subpath counts** to their own copies
88
- (`cwip` 60 subpaths/2,089 symbols, `cursedbelt` 51/1,837, `cursedbelt-server` 34/992) —
89
- a consolidation that moves a count has changed the measurement, not the plumbing.
90
-
91
- 🔴 **`bound-lists` is the third recorded exception, admitted by rule 3 at three consumers.**
92
- It was written once, by `collections` (`src/server/boundLists.test.ts`, 2026-09-22), after
93
- the shape it refuses drew an empty gallery for every folder over 100 files on D1. The same
94
- day `music` was found carrying 24 sites and `vault` four — the identical trap in two more D1
95
- Workers — so the choice was two more copies of the scanner or one here. Copies are what
96
- `check-copies` exists to refuse, and a forked scanner is a check that quietly stops matching.
97
- It is mechanism only: each app passes its own root and directories and keeps its own
98
- "not vacuous" assertion about which files the scan must reach.
99
-
100
- 🔴 **`ship` is the fourth recorded exception: no app wrote it, because it is the switch the
101
- deploys themselves obey.** The owner's ruling (2026-09-28) was that agents run the same commands
102
- whatever the switch says and the commands deal with it — so it has to live in the one seam every
103
- production deploy passes through, which is `runWorkerDeploy` here and `deploy-tree`'s callers.
104
- It reads a file the runner owns and knows no app: rule 2 holds, and the header of `src/ship.ts`
105
- says why every failure reads as `fast`.
106
-
107
- ### What was deliberately left in the apps
108
-
109
- The comparison that produced this library found three areas that are genuinely
110
- different and must stay that way. `tasks/Procedures/graduate-an-app.md` in the
111
- generation repo carries the full verdict table, because the next app to graduate is who
112
- needs to read it:
113
-
114
- - **the build** — `desk` compiles a single binary with `bun build --compile`; `flix` is
115
- a Vite SPA with an asset budget. Not the same problem.
116
- - **the supervisor** — `desk` runs a watcher that rebuilds on a commit; `flix`
117
- deliberately has none, and its deploy reinstalls the agent instead. That difference
118
- changes what a rollback *is* in each app.
119
- - **the app config** — how an app decides it is deployed, and what that turns on, is the
120
- app.
121
-
122
- `deploy.ts` is a fourth: the two scripts share a shape and roughly thirty lines of
123
- helpers, but they sequence genuinely different steps and a shared deploy driver is
124
- exactly the 277-file mistake starting again. What they share instead is the **exit-code
125
- contract** in `cursedops/smoke` — `0` keep, `1` roll back, `2` not the app (the edge, the
126
- network, or the smoke's own environment), do not roll back — which is the only part both
127
- deploy scripts actually read.
128
-
129
- 🔴 **The WORKER deploy is the other way round, and `cursedops/worker-deploy` is not this
130
- mistake made again.** The Mac `deploy.ts` scripts sequence genuinely different steps; the
131
- four Worker `worker-deploy.ts` scripts sequence the SAME nine, and a `diff` says so — what
132
- differed between them was which of the nine each copy had forgotten. So the sequence is
133
- shared and every step's command is data the app passes; see "The Worker toolkit" below.
134
-
135
- ## `cursedops/roots`
136
-
137
- ```ts
138
- import { forgeRootFrom, packageRootFrom, requireForgeState } from "cursedops/roots";
139
-
140
- const generation = forgeRootFrom(import.meta.url); // string | null
141
- const checkout = packageRootFrom(import.meta.url); // string | null
142
- const state = requireForgeState(import.meta.dir); // string, or a thrown message
143
- ```
144
-
145
- A generation is defined by a `forge.env` above the checkout — the same rule its
146
- `check-paths` and its runner use, so the three cannot disagree. Everything returns
147
- `null` rather than guessing, because a function that invented a root would write files
148
- into a stranger's tree.
149
-
150
- 🔴 **A git worktree counts** (0.2.5). Worktrees live outside every generation by
151
- construction, so the walk up alone answered `null` there and a repo whose gate asks for
152
- the root was red in every worktree — the runner's subset gate included. When the walk
153
- fails, `git rev-parse --git-common-dir` leads back to the primary checkout and the walk
154
- runs from there. A clone is its own primary and still gets `null`.
155
-
156
- 🔴 The state root is **read out of `forge.env`**, never spelled here. A published
157
- library that hardcoded `$HOME/.<name>` would be correct for exactly one generation and
158
- silently wrong for its successor — which is the defect this replaced.
159
-
160
- ### `repoCd` — a printed command that RUNS (0.5.0, task 092)
161
-
162
- ```ts
163
- import { otherRepoCommand, repoCommand } from "cursedops/roots";
164
-
165
- const REPO_IN_FORGE = "apps/auth"; // the ONE thing the app keeps
166
- repoCommand(REPO_IN_FORGE, "bun run geo:build", import.meta.dir);
167
- // → source "…/forge.env" && cd "$FORGE/apps/auth" && bun run geo:build
168
- otherRepoCommand("apps/station", REPO_IN_FORGE, "bun run geo", import.meta.dir);
169
- ```
170
-
171
- Rule 1: four repos wrote it — `apps/binary-server/src/repoCommand.ts` (the original,
172
- 2026-09-18), `apps/auth/src/kit/repoCommand.ts`, and `scripts/forgeRoot.ts` in `family` and
173
- `roms`. Rule 3: `check-paths`' `deadCd` rule measured twelve printed commands in five repos
174
- `cd`-ing into directories two generations gone, each read at a moment something was already
175
- wrong. The segment is always the CALLER's (the library may not know a repo's name) and is
176
- refused if it is not a plain relative path; `from` is REQUIRED, because a default would be this
177
- library's own directory inside `node_modules`. A `$FORGE` with no `forge.env` in it is stale and
178
- is not honoured (`verifiedForgeRoot`; `forgeCodeRoot` keeps its older contract). Every fallback is
179
- a directory that EXISTS, and nothing composed here ever spells `cd ~/`. `repoCommand.test.ts`
180
- asserts on the COMPOSED string, which is the only thing `check-paths` cannot see.
181
-
182
- ### The state root (task 160)
183
-
184
- Nothing new was needed: `forgeState(from, env)` / `requireForgeState(from, env)` already read
185
- `FORGE_STATE` out of the `forge.env` above the calling module, which is what a launchd job with
186
- no environment and a worktree both need. An app's hand-spelled `join(HOME, ".code", "<name>")`
187
- becomes `requireForgeState(import.meta.dir, env)`. Text generated for ANOTHER machine (a systemd
188
- unit, a shell installer) interpolates the VALUE — `relative(homedir(), requireForgeState(…))`
189
- after `%h/` or `$HOME/` — never the generation's name.
190
-
191
- ## `cursedops/paths` (0.5.0, task 2089)
192
-
193
- ```jsonc
194
- "paths": "forge-paths" // check-paths + check-doc-citations
195
- "paths": "forge-paths --also check-gate-graph.ts" // cursedbelt's extra law, as an OPTION
196
- "gate": "forge-paths --only gate.ts" // 0.5.3: the shared gate runner, and ONLY it
197
- ```
198
-
199
- `--only <law>` (0.5.3, task 474) replaces the default laws instead of adding to them, so a repo's
200
- `gate` reaches `$FORGE/tools/gate.ts` through the same walk without running `check-paths` a
201
- second time or handing it `--force`. A law it names that is missing from `tools/` exits 1.
202
-
203
- Rule 1, eighteen times: `scripts/paths.ts` was in every app, `autopilot` and every library,
204
- sixteen byte-identical and three already forked (`cursedbelt` +`check-gate-graph.ts`, `station`
205
- +`check-no-clock.ts`, `autopilot` split into its own `generation.ts`). `check-copies` prices
206
- exported definitions and this file exports nothing, so eighteen copies read as zero. Rule 3: the
207
- wrapper exists because `bun ../../tools/check-paths.ts .` died on its first step in every
208
- worktree (2026-09-17). The walk and the worktree follow-back are the copies' own; **no
209
- generation is still no pass** — a lone clone exits 1 saying which walk came back empty. Every
210
- argument that is not `--also`/`--only` passes through to the laws (`bun run paths --prune`).
211
-
212
- 🔴 **Until the roster rule can read the bin, the wrapper is two lines that NAME the laws.**
213
- `check-doc-citations`' `pathsRunsThis` proves a repo runs it by reading the `paths` command or
214
- the one `scripts/` file it delegates to, so a bare `forge-paths` reads as unwired. The form that
215
- passes today — and the one this repo uses — is:
216
-
217
- ```ts
218
- // scripts/paths.ts, with "paths": "bun run scripts/paths.ts"
219
- import { runGenerationLaws } from "cursedops/paths";
220
- process.exit(runGenerationLaws(import.meta.dir, { laws: ["check-paths.ts", "check-doc-citations.ts"], argv: process.argv.slice(2) }));
221
- ```
222
-
223
- ## `cursedops/launchd`
224
-
225
- ```ts
226
- import { renderPlist, replaceAgent, answering, declaredBy } from "cursedops/launchd";
227
-
228
- const conflict = await declaredBy(LABEL, "managed-by=olddeployer");
229
- if (conflict) throw new Error(`a retired deployer still declares ${LABEL}: ${conflict}`);
230
-
231
- const { boot } = await replaceAgent(LABEL, renderPlist(spec), { also: LEGACY_LABELS });
232
- if (boot.code !== 0) throw new Error(boot.out);
233
- if (!(await answering(`http://127.0.0.1:${port}/healthz`))) throw new Error("a pid is not a service");
234
- ```
235
-
236
- `livePort(label)` (0.5.0) reads `PORT` out of the LIVE job's own `environment` block — the
237
- declaration the tunnel routes to — never `package.json`, which advertised the dev pair in eight
238
- of eight apps (`autopilot doctor`'s `ports` section, 2026-09-15, lifted with its fixture).
239
-
240
- `RunAtLoad` and `KeepAlive` are **not defaulted** — a server wants both, a nightly
241
- snapshot wants neither, and a kit that decides gets one of them wrong.
242
-
243
- ## `cursedops/smoke`
244
-
245
- ```ts
246
- import { createSmoke, guarded } from "cursedops/smoke";
247
-
248
- const smoke = createSmoke({ base: process.env.MYAPP_SMOKE_URL ?? "https://…" });
249
- await guarded(smoke, "gate", async () => {
250
- const response = await smoke.anonymous("/api/v1/private");
251
- smoke.record("gate", response.status === 401, `anonymous → ${response.status}`);
252
- });
253
- smoke.finish(); // exits 0, 1 or 2
254
- ```
255
-
256
- One check, and it is about the ADDRESSES of a deployment rather than about any app:
257
-
258
- ```ts
259
- import { sameDeployedShell } from "cursedops/smoke";
260
-
261
- // every address of one deployment, not "edge vs origin" — an app may have no
262
- // origin left. A Worker's second address is its uncached *.workers.dev preview.
263
- await sameDeployedShell(smoke, [smoke.base, `http://127.0.0.1:${port}`]);
264
- ```
265
-
266
- 🔴 It refuses when the addresses name different built assets, and it records that as an
267
- **edge fault (exit 2), never a rollback** — the origin is serving what this checkout
268
- built, and reverting it cannot reach the Worker, cache rule or Assets binding in front.
269
- Measured 2026-09-18 on `patterns`: the edge served a shell built 2026-09-12 out of a
270
- Worker nobody knew about, and every automated surface stayed green for **six days**.
271
- Only same-origin `.js`/`.css` references are compared, because the zone injects a Web
272
- Analytics beacon in front of the edge and not on loopback — a check that reds on a
273
- healthy deploy is one somebody turns off.
274
-
275
- ### The verdicts (0.5.0, task 069)
276
-
277
- ```ts
278
- import { livePort } from "cursedops/launchd";
279
- import { createSmoke, probeOrigin } from "cursedops/smoke";
280
-
281
- const smoke = createSmoke({ base: PUBLIC_URL });
282
- if (!smoke.requireEnvironment({ CF_ACCESS_CLIENT_ID: env.CF_ACCESS_CLIENT_ID }, "source $FORGE_STATE/secrets/cloudflare-access.env")) {
283
- process.exit(smoke.report()); // 2 — the smoke could not judge; not the app
284
- }
285
- await probeOrigin(smoke, livePort(LABEL), { label: LABEL }); // a dead origin is 1, always
286
- // … the app's own checks against the public hostname …
287
- process.exit(await smoke.settle()); // asks the NETWORK before charging the app
288
- ```
289
-
290
- A deployed smoke lied three ways, and each is now a verdict with a test of its failure path
291
- (`src/smokeVerdict.test.ts`):
292
-
293
- | lie | measured | verdict | exit |
294
- |---|---|---|---:|
295
- | PASSED about a dead origin | `patterns` and `collections` served a cached 200 over a 503 origin, every check green; four smokes made no loopback request at all | `origin` — `probeOrigin` asks `127.0.0.1:<livePort>`; no live port is no origin | **1**, whatever else the run saw |
296
- | FAILED about a healthy app, for want of the smoke's own credential | `desk`: `✗ FAILED — 5 of 6` because only the deploy script loaded the Access token | `environment` — `requireEnvironment` / `refuseEnvironment` | 2 |
297
- | FAILED about a healthy app, because the network substituted DNS | 2026-09-19, airline wifi answered `1.1.1.1` for a proxied host → `403 · Error 1034` | `network` — `settle()` runs `diagnoseNetwork`, `tools/check-public-dns.ts`' range invariant lifted | 2 |
298
-
299
- 🔴 **No new exit code**, on purpose: every deploy script reads `0` keep, `2` stop without
300
- reverting, anything else REVERT (`apps/collections/scripts/deploy.ts:413-424` and four
301
- siblings). A `3` for "credential missing" would be reverted by all five. The distinction is in
302
- `verdict()` and in the sentence printed. And the network diagnosis **never retries and never
303
- pins an address** — the deliverable is the distinction, not a way around the network.
304
-
305
- `steadyHealth(ask, ready)` is the Worker smoke's wait: `ready` must hold for THREE reads in a
306
- row, because on `music`'s first deploy (2026-09-23, `a54ef71`) one good `/healthz` was followed
307
- by thirteen routes answering the pre-secrets version. It returns what answered and never judges.
308
- A read that THROWS (0.11.1) is a bad read asked again, bounded by `deadlineMs` (240 s), and only
309
- a LAST read that threw is thrown to the caller: the 2026-09-29 ship reported family and music red
310
- on a first `/healthz` that timed out twice while the edge was between versions (task 2291).
311
- `runWorkerSmoke` asks each of those reads with a 10 s curl `--max-time`
312
- (`HEALTH_READ_MAX_TIME_SEC`, passed as `EdgeRequestInit.maxTimeSec`), so the window costs 20 s a
313
- read instead of 120.
314
-
315
- `awaitConverged(probes)` (0.10.19) is the same wait over SEVERAL paths: every probe must agree
316
- three rounds in a row, bounded (120 s), before any leg is judged — a Worker's code and its static
317
- assets reach the edge separately (roms `f9273ea`, task 2196: `/healthz` at HEAD while the old
318
- script answered `/host/install.sh` and the new chunk 404'd). A wait that runs out says
319
- `never converged in Ns — <what lagged>`, and the caller records it as a red row.
320
-
321
- Nothing else. The checks are the part `desk` and `flix` wrote differently on purpose,
322
- and a shared kit that starts absorbing route lists and health-payload shapes is how the
323
- last one reached 277 files.
324
-
325
- ## `cursedops/api-floor`
326
-
327
- ```ts
328
- import { API_PREFIX, apiNotFoundBody, canonicalApiPath, canonicalApiRequest } from "cursedops/api-floor";
329
-
330
- // AFTER the app's routes, BEFORE the static catch-all, and `all` not `get`.
331
- const floor = (c) => {
332
- const urlPath = new URL(c.req.url).pathname;
333
- // A trailing slash is never meaningful under /api/ — re-dispatch, once.
334
- if (canonicalApiPath(urlPath) !== null) return app.fetch(canonicalApiRequest(c.req.raw));
335
- return Response.json(apiNotFoundBody(c.req.method, urlPath, "myapp", commit), { status: 404 });
336
- };
337
- app.all(API_PREFIX, floor);
338
- app.all(`${API_PREFIX}/*`, floor);
339
- ```
340
-
341
- 🔴 Measured 2026-09-17 on `station`: a page that had never loaded on any commit, because
342
- its client asked for `/api/openclaw/`, Hono mounts a sub-app's `get("/")` at the mount
343
- point without the trailing slash and matches strictly, and the miss fell into
344
- `app.get("*")` and came back as 1.7 KB of `index.html` **with a 200**. The client read
345
- that HTML honestly — *"an API older than this page"* — and sent the owner to deploy a
346
- commit that could not have helped.
347
-
348
- 🔴 Wire it **inside the router**, never as a wrapper around the host's `fetch`. A router
349
- has more callers than `Bun.serve` — a Worker's `export default`, and every harness that
350
- drives `app.fetch` — and a wrap installed at one of them is absent from the rest. That is
351
- the exact fault the previous generation's artifact shipped for six days with a green
352
- harness.
353
-
354
- Both patterns are registered although `"/api/*"` alone already matches a bare `/api` on
355
- hono@4.13.7 (measured 2026-09-18) — that is an undocumented property of one matcher
356
- version, and one extra route registration is cheaper than a floor with a hole in it the
357
- day it tightens.
358
-
359
- No `node:` import, no `Bun.` global, no `process` — asserted by `apiFloor.test.ts`, not
360
- by this paragraph — so it mounts unchanged inside a Cloudflare Worker, which is where
361
- `patterns` mounts it. `apiNotFoundBody` is the DEFAULT body and not the only one: an app
362
- whose API namespace is not behind a gate keeps a terser phrase of its own and takes the
363
- predicates.
364
-
365
- ## `cursedops/serve`
366
-
367
- ```ts
368
- import { contentTypeFor, fileWithin, installCrashHandlers, isHashedAsset } from "cursedops/serve";
369
-
370
- installCrashHandlers("myapp"); // one log line, then exit 1
371
- const file = fileWithin(clientDir, url.pathname); // null unless it is really inside
372
- if (!file) return c.notFound();
373
- return new Response(Bun.file(file), {
374
- headers: {
375
- "content-type": contentTypeFor(file),
376
- "cache-control": isHashedAsset(url.pathname) ? ONE_YEAR : "no-cache",
377
- },
378
- });
379
- ```
380
-
381
- 🔴 `fileWithin` is the reason this subpath exists rather than the convenience of the
382
- other three. It is the check that stops `%2e%2e%2f` reaching the filesystem, and on
383
- 2026-09-17 there were **eight** of it — one per app, character-identical, with nothing in
384
- the generation counting them. Eight copies of a security guarantee are eight guarantees:
385
- a fix in one is a fix in one.
386
-
387
- ### The verdict for every symbol in the `serve.ts` family
388
-
389
- Measured 2026-09-17 across `collections`, `family`, `flix`, `music`, `patterns`, `roms`,
390
- `station` and `vault` — the eight apps that have a `src/server/serve.ts`. The files are
391
- 247–387 lines and all eight differ, which is why no `diff` ever flagged them; the bodies
392
- below were compared with whitespace and comments normalised away.
393
-
394
- | symbol | identical in | verdict |
395
- |---|---:|---|
396
- | `fileWithin` | 8 of 8 | **moved.** The traversal guard. Entry rule 3 — a trap somebody paid for — in its purest form |
397
- | `installCrashHandlers` | 8 of 8 | **moved.** The app's name is a parameter; it is the only app-shaped thing the four touch |
398
- | `contentTypeFor` | 8 of 8 | **moved.** Extension → MIME, plus its private `CONTENT_TYPES` table, which was also 8 of 8 |
399
- | `isHashedAsset` | 8 of 8 | **moved.** Vite's own output convention, which is the bundler's identity and not the app's |
400
- | `clientDirOf` | 6 of 6 that have it | **stays.** One line, and six apps pin it with their own `clientDir.test.ts` against their own `vite.config.ts`. WHERE an app's build writes is app identity — entry rule 2 — and it is the line that once served `public/` and took a live app down |
401
- | `createServer` / `createApp` | 0 — all eight differ | **stays.** Route tables, health payloads, gates and body limits. This is the 277-file mistake's front door |
402
- | `parseRange` / `fileResponse` | `flix` and `station` only | **stays for now.** Two apps, so rule 1 is satisfiable, but rule 3 is not: nothing has been paid for yet, and a `Range` implementation shared by two apps that stream different things is a guess. The trigger to revisit is a THIRD app that needs byte ranges |
403
- | `API_PREFIX`, `isApiPath`, `canonicalApiPath`, `canonicalApiRequest`, `apiNotFoundBody` | `station` only | **stays.** One app, and rule 4 says an addition one app will use does not go in. `apiNotFoundBody` also writes a sentence the owner reads on a page — identity, not mechanism |
404
-
405
- 🔴 **The task that ordered this move recorded `contentTypeFor` and `isHashedAsset` as
406
- 7 of 8, with `station` differing. They did not differ.** All four bodies were identical
407
- in all eight apps when the move was made, so nothing had to converge and no app's
408
- behaviour changed on adoption. The count came from a census of the whole `serve.ts`
409
- family rather than of these two bodies; `bun tools/check-copies.ts` in the generation
410
- repo is the live answer either way.
411
-
412
- One behaviour is NOT a byte-for-byte carry, and `src/serve.ts`'s header argues it in
413
- full: the eight copies checked containment lexically and were therefore blind to a
414
- symlink under the root pointing out of it. `fileWithin` here resolves the real path of
415
- **both** sides after the lexical check, which refuses the escape while leaving every
416
- input the eight copies refused refused, at the same cost. A symlinked ancestor of the
417
- root cancels out — which is the failure a one-sided `realpath` would have shipped to
418
- every app on this machine, and is its own test.
419
-
420
- ## The Worker toolkit (0.5.0, task 2097)
421
-
422
- `cursedops/worker-deploy`, `/worker-secrets`, `/worker-rollback`, `/edge-fetch`, `/d1-import`.
423
- Rule 1, four times: `collections`, `vault`, `patterns` and `music` (copied from `vault` on
424
- 2026-09-23) each carried the toolkit in `scripts/`, which `check-copies` did not scan. Rule 3:
425
- the copies had drifted in ways that cost something — `patterns` never refused a dirty tree nor
426
- stamped the commit, `vault` and `collections` judged a deploy after one `/healthz` read,
427
- `collections`' secrets upload never deleted an extra, `patterns`' rollback typed its route id,
428
- and only `collections`' import normalised a REAL. Each module header names its incident.
429
- Everything that shells out (wrangler, git, curl, dig) is injectable; the suite never touches a
430
- network.
431
-
432
- 🔴 **`edge-fetch` is the fourth recorded exception.** Rule 1 asks for two INDEPENDENT
433
- implementations and this has one: `vault` wrote it and `music` copied it byte for byte. What
434
- admits it is that a verbatim copy is precisely what `check-copies` exists to refuse, the task
435
- that asked for this move named it, and every other part of the toolkit it serves is here — a
436
- Worker smoke that had to keep its transport in the app would keep a copy of the toolkit's
437
- most fragile part (a hand-rolled HTTP parser) in two places.
438
-
439
- | part | verdict |
440
- |---|---|
441
- | the deploy SEQUENCE (HEAD, clean tree, app refusals, stage first, build, schema, `--var` stamp, secrets, smoke) | **moved** — `runWorkerDeploy(spec, deps)`; the commands are data |
442
- | `parseWorkerEnv`, `wranglerEnvArgs`, `readWranglerJsonc`, `readEnvFile`, `cloudflareCredential` | **moved** — identical or converged in all four |
443
- | exact-set secrets over a pipe, read back | **moved** — `planSecrets` + `syncSecrets` + `wranglerSecretPut`; `envFileLines` carries `collections`' pre-quoted-seed fix; `--mint` carries the keys `mintCarries` names (0.10.13; default `FILE_TOKEN_PRIVATE_KEY`, flix's is `PLEX_ACCOUNT_TOKEN`) |
444
- | origin-first rollback, route found by pattern | **moved** — `rollbackToOrigin`, `findWorkerRoute`, `deleteWorkerRoute`; the whole `worker:rollback` main is `runWorkerRollback(spec, argv)` (0.10.13 — flix and music were one copied file) |
445
- | `edgeFetch` (curl `--resolve` past the negative DNS cache) | **moved** — `createEdgeFetch`; the Access header now THROWS on an incomplete token instead of sending empty headers |
446
- | the import's literal and row-for-row proof | **moved** — `sqlLiteral`, `rowDigest`, `sameRows`, `rowDifferences`, `wranglerRows` |
447
- | the steady `/healthz` wait | **moved**, into `cursedops/smoke` as `steadyHealth` |
448
- | the Worker CPU tail and its judgement (`worker-cpu.ts` + `workerCpu.ts`) | **moved** (0.7.0, task 2134) — `runWorkerCpuTail`, `judgeTailCapture`, `workerBudgets`; `runWorkerDeploy({ stageWalk, cpuTail })` runs the walk and the smoke under it |
449
- | the Worker BUDGET TABLE (routes, numbers, the unlock's ceiling) | **stays** — what a route may cost is the app |
450
- | the deployment table (`workerEnvs.ts`: names, database, URLs, secret map, which file) | **stays** — identity, rule 2 |
451
- | the smoke's CHECKS (gated-route census, health identity fields, shell/CSP) | **stays** — the same argument as the rest of `smoke` |
452
- | the walk (`workerWalk.ts`) and the stage walk's grant through `apps/auth-stage` | **stays** — what a signed-in owner does is the app |
453
- | the import's table PLAN (what is skipped, parents first, what is rebuilt: FTS, `art_lookups`) | **stays** — the app's schema |
454
- | minting a stage's secret FILE (which keys are copied from production, which are throwaway) | **stays** — `throwawaySecret` and `writeSecretsFile` are the mechanism it uses |
455
-
456
- ### The Worker CPU check, in `cursedops/worker-deploy` (0.7.0, task 2134)
457
-
458
- `collections` wrote it on 2026-09-23 after averaging 489 billed CPU-ms per request with a 15 s
459
- p99 and every check green; `vault` was measured in the same state the same day (argon2id at
460
- 4,089 CPU-ms per create, 4,458 per unlock, on its stage). The billed number exists only on the
461
- trace a tail receives, so this tails the Worker around the walk and the smoke, charges each
462
- request's `cpuTime` to its declared route with `cursedbelt-server/bench`, and fails on an
463
- undeclared route, one over its budget, or an EXEMPT route past its `noticeAboveMs` (the library
464
- only notices; a Worker is killed at 30 s of CPU, and a killed unlock reads as a wrong password).
465
- An exempt route with no ceiling is red on its own. `bench` is passed in (`import * as bench`),
466
- because `cursedbelt-server` depends on this package. It lives in `worker-deploy` rather than a subpath of its own because a shipped file may not
467
- import a sibling (`publishShape.test.ts`). An app adopts it with a budget table, a
468
- ten-line `scripts/worker-cpu.ts`, and `stageWalk` + `cpuTail` on its `runWorkerDeploy` spec.
469
-
470
- ## `cursedops/public-surface`
471
-
472
- ```sh
473
- # in the library's package.json — `bun run surface` / `bun run surface --prune`
474
- "surface": "public-surface" # cwip adds --peers
475
- ```
476
-
477
- ```ts
478
- import { run } from "cursedops/public-surface";
479
-
480
- const { fresh, grown, stale, peers } = await run({ root: packageDir }); // all four [] when clean
481
- ```
482
-
483
- `publicSurface.baseline` at the package root is one `<count> <subpath>` line per
484
- export subpath. It fails on a subpath the baseline does not list, on one that GREW, and
485
- on an entry that shrank or matches nothing — so a deleted module cannot leave an
486
- allowance behind. `--prune` only ever lowers; new surface is a line added by hand in a
487
- commit that says why. What a count includes and excludes, and the three measurements
488
- that each produced a copy of this, are in the header of `src/publicSurface.ts`.
489
-
490
- It is a check a library runs on ITSELF. It never asks who imports the library — that
491
- is cross-repo, and a repo's gate proves that repo.
492
-
493
45
  ## Verifying
494
46
 
495
47
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.13.2",
3
+ "version": "0.15.0",
4
4
  "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -152,6 +152,12 @@
152
152
  "source": "./src/relayStage.ts",
153
153
  "import": "./src/relayStage.ts"
154
154
  },
155
+ "./container-deploy": {
156
+ "types": "./src/containerDeploy.ts",
157
+ "bun": "./src/containerDeploy.ts",
158
+ "source": "./src/containerDeploy.ts",
159
+ "import": "./src/containerDeploy.ts"
160
+ },
155
161
  "./public-client": {
156
162
  "types": "./src/publicClient.ts",
157
163
  "bun": "./src/publicClient.ts",
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `cursedops/container-deploy` — the pieces of deploying a Cloudflare CONTAINER Worker (a Worker
3
+ * fronting a Docker image) and proving it landed, which binary-server's `runner:deploy` (task 2723)
4
+ * wrote and music's `agent:deploy` (task 2816) copied body for body.
5
+ *
6
+ * ```ts
7
+ * import { awaitRollout, dockerUp, healthz, newestRollout, readEnvLine, withDockerBin, workersDevUrl, writeEnvLine } from "cursedops/container-deploy";
8
+ *
9
+ * const env = { ...process.env, PATH: withDockerBin(process.env.PATH) };
10
+ * const url = await workersDevUrl(account, apiToken, "thing-runner");
11
+ * const app = { account, token: apiToken, application: "thing-runner-encoder" };
12
+ * const before = await newestRollout(app);
13
+ * await dockerUp(env, "the runner");
14
+ * // … wrangler deploy, then healthz(url, token) until it answers at a new version …
15
+ * const { verdict } = await awaitRollout({ ...app, before, poke: () => startAnInstance(url), label: "encoder" });
16
+ * ```
17
+ *
18
+ * 🔴 **A Worker answering is not the container being on the new image.** Cloudflare rolls the
19
+ * container out separately and gradually, and a rollout only moves while an instance starts — so
20
+ * `awaitRollout` pokes one every time it looks (measured 2026-10-05: the Worker's version was live
21
+ * while the instances still ran the previous image).
22
+ *
23
+ * 🔴 **An application the deploy CREATED has no rollout at all** — it is born at version 1 with the
24
+ * image (`/rollouts` answered `[]` on music's first deploy), so waiting for one would wait the whole
25
+ * twenty minutes and then call a good deploy failed. `rolloutVerdict` says `created`.
26
+ *
27
+ * 🔴 **A network blip while looking is a look that failed, not a deploy that failed** — one
28
+ * "unknown certificate verification error" threw a landed deploy red. `awaitRollout` looks again.
29
+ */
30
+ import { spawnSync } from "node:child_process";
31
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
32
+
33
+ /** The value of `key` in an env file's text, unquoted; `""` when it is not there. Pure. */
34
+ export function readEnvLine(text: string, key: string): string {
35
+ return new RegExp(`^${key}=(.*)$`, "m").exec(text)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
36
+ }
37
+
38
+ /** Set `key=value` in an env file's text, replacing a line already there. Pure. */
39
+ export function withEnvLine(text: string, key: string, value: string): string {
40
+ const line = `${key}=${value}`;
41
+ const re = new RegExp(`^${key}=.*$`, "m");
42
+ if (re.test(text)) return text.replace(re, line);
43
+ return `${text}${text.endsWith("\n") || text === "" ? "" : "\n"}${line}\n`;
44
+ }
45
+
46
+ /** Set `key=value` in the secrets file at `path` (created if missing), owner-only. */
47
+ export function writeEnvLine(path: string, key: string, value: string): void {
48
+ writeFileSync(path, withEnvLine(existsSync(path) ? readFileSync(path, "utf8") : "", key, value));
49
+ chmodSync(path, 0o600);
50
+ }
51
+
52
+ export const DOCKER_BUNDLE_BIN = "/Applications/Docker.app/Contents/Resources/bin";
53
+
54
+ /**
55
+ * Docker Desktop's own helpers (`docker-credential-desktop`, which every image pull asks for) live
56
+ * in its app bundle, which a non-login shell's PATH does not hold — measured 2026-10-05: the first
57
+ * build failed "executable file not found in $PATH" on its first `FROM`. Pure.
58
+ */
59
+ export function withDockerBin(path: string | undefined, bundleBin = DOCKER_BUNDLE_BIN): string {
60
+ const parts = (path ?? "").split(":").filter(Boolean);
61
+ return parts.includes(bundleBin) ? parts.join(":") : [...parts, bundleBin].join(":");
62
+ }
63
+
64
+ /** Docker up, opening Docker Desktop if it is not — wrangler builds the image with it. `what` names the image for the error. */
65
+ export async function dockerUp(env: NodeJS.ProcessEnv, what: string): Promise<void> {
66
+ const up = (): boolean => spawnSync("docker", ["info"], { stdio: "ignore", env }).status === 0;
67
+ if (up()) return;
68
+ console.log("Docker is not running — opening it…");
69
+ spawnSync("open", ["-a", "Docker"]);
70
+ for (let i = 0; i < 60 && !up(); i++) await Bun.sleep(2000);
71
+ if (!up()) throw new Error(`Docker did not come up in two minutes; wrangler builds ${what}'s image with it`);
72
+ }
73
+
74
+ /** `GET /healthz`, with the bearer token or without. Status 0 when nothing answered. */
75
+ export async function healthz(url: string, token: string | null): Promise<{ status: number; version: string | null }> {
76
+ try {
77
+ const res = await fetch(`${url}/healthz`, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(10_000) });
78
+ const body = (await res.json().catch(() => null)) as { version?: string } | null;
79
+ return { status: res.status, version: body?.version ?? null };
80
+ } catch {
81
+ return { status: 0, version: null };
82
+ }
83
+ }
84
+
85
+ /** The Worker's `https://<script>.<account subdomain>.workers.dev` address. */
86
+ export async function workersDevUrl(account: string, token: string, script: string): Promise<string> {
87
+ const sub = (await (
88
+ await fetch(`https://api.cloudflare.com/client/v4/accounts/${account}/workers/subdomain`, { headers: { authorization: `Bearer ${token}` } })
89
+ ).json()) as { result?: { subdomain?: string } };
90
+ if (!sub.result?.subdomain) throw new Error("could not read the account's workers.dev subdomain");
91
+ return `https://${script}.${sub.result.subdomain}.workers.dev`;
92
+ }
93
+
94
+ export type ContainerRollout = { id: string; created_at: string; status: string; target_configuration?: { image?: string } };
95
+
96
+ /** Which container application, on which account, read with which API token. */
97
+ export interface ContainerApplication {
98
+ account: string;
99
+ token: string;
100
+ /** The application's name — wrangler names it `<script>-<class name, lowercased>`. */
101
+ application: string;
102
+ }
103
+
104
+ /** The most recently created rollout. Pure. */
105
+ export function newestOf(rollouts: readonly ContainerRollout[]): ContainerRollout | null {
106
+ return [...rollouts].sort((a, b) => b.created_at.localeCompare(a.created_at))[0] ?? null;
107
+ }
108
+
109
+ /** The application's newest rollout: `undefined` before the application exists, `null` for one with none. */
110
+ export async function newestRollout(app: ContainerApplication, fetchImpl: typeof fetch = fetch): Promise<ContainerRollout | null | undefined> {
111
+ const api = `https://api.cloudflare.com/client/v4/accounts/${app.account}/containers/applications`;
112
+ const headers = { authorization: `Bearer ${app.token}` };
113
+ const apps = (await (await fetchImpl(api, { headers })).json()) as { result?: { id: string; name: string }[] };
114
+ const found = apps.result?.find((a) => a.name === app.application);
115
+ if (!found) return undefined;
116
+ const rollouts = (await (await fetchImpl(`${api}/${found.id}/rollouts`, { headers })).json()) as { result?: ContainerRollout[] };
117
+ return newestOf(rollouts.result ?? []);
118
+ }
119
+
120
+ export type RolloutVerdict = "new-image" | "unchanged" | "created" | "waiting";
121
+
122
+ /** Is the container on the image this deploy pushed, given the newest rollout before it and now? Pure. */
123
+ export function rolloutVerdict(before: ContainerRollout | null | undefined, now: ContainerRollout | null | undefined): RolloutVerdict {
124
+ if (before === undefined && now === null) return "created";
125
+ // An application that has never rolled out, and still has not: the deploy changed no image.
126
+ if (before === null && now === null) return "unchanged";
127
+ if (!now) return "waiting";
128
+ if (now.id === before?.id) return now.status === "completed" ? "unchanged" : "waiting";
129
+ return now.status === "completed" ? "new-image" : "waiting";
130
+ }
131
+
132
+ export interface AwaitRolloutOptions extends ContainerApplication {
133
+ /** The newest rollout read BEFORE `wrangler deploy`. */
134
+ before: ContainerRollout | null | undefined;
135
+ /** Start an instance — a rollout only moves while one starts. Must not throw. */
136
+ poke: () => Promise<void>;
137
+ /** What the container is called in the log and the error ("encoder", "recorder"). */
138
+ label: string;
139
+ timeoutMs?: number;
140
+ intervalMs?: number;
141
+ /** For tests. */
142
+ read?: (app: ContainerApplication) => Promise<ContainerRollout | null | undefined>;
143
+ sleep?: (ms: number) => Promise<void>;
144
+ now?: () => number;
145
+ log?: (line: string) => void;
146
+ }
147
+
148
+ /** Wait until the rollout settles; throws when it has not in `timeoutMs` (twenty minutes). */
149
+ export async function awaitRollout(o: AwaitRolloutOptions): Promise<{ verdict: Exclude<RolloutVerdict, "waiting">; rollout: ContainerRollout | null | undefined }> {
150
+ const read = o.read ?? ((app: ContainerApplication) => newestRollout(app));
151
+ const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
152
+ const now = o.now ?? Date.now;
153
+ const log = o.log ?? console.log;
154
+ const interval = o.intervalMs ?? 10_000;
155
+ const until = now() + (o.timeoutMs ?? 20 * 60_000);
156
+ let last = "";
157
+ while (now() < until) {
158
+ let r: ContainerRollout | null | undefined;
159
+ try {
160
+ r = await read(o);
161
+ } catch (e) {
162
+ log(` (could not read the ${o.label} rollout: ${e instanceof Error ? e.message : String(e)} — looking again)`);
163
+ await sleep(interval);
164
+ continue;
165
+ }
166
+ const verdict = rolloutVerdict(o.before, r);
167
+ if (verdict !== "waiting") return { verdict, rollout: r };
168
+ if (r && r.status !== last) log(` ${o.label} rollout ${r.id.slice(0, 8)}: ${r.status}`);
169
+ last = r?.status ?? "";
170
+ await o.poke();
171
+ await sleep(interval);
172
+ }
173
+ throw new Error(`the ${o.label}'s rollout did not complete in ${Math.round((o.timeoutMs ?? 20 * 60_000) / 60_000)} minutes — the instances may still run the previous image`);
174
+ }
@@ -36,6 +36,7 @@
36
36
  */
37
37
  import { spawnSync } from "node:child_process";
38
38
  import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
39
+ import { dirname, join } from "node:path";
39
40
  // The package's own subpath, never `./workerDeploy.ts`: shipped source may not import a sibling
40
41
  // by relative path (`publishShape.test.ts` says why), and a self-reference resolves through the
41
42
  // same `exports` map every consumer's does.
@@ -274,6 +275,43 @@ export const THROWAWAY_SECRETS = {
274
275
  FILE_TOKEN_PRIVATE_KEY: "FILE_TOKEN_PRIVATE_KEY",
275
276
  } as const;
276
277
 
278
+ /**
279
+ * binary-server's delete barrier for STAGE tenants (forge tasks 2795, 2797). `DELETE /media` takes
280
+ * binary-server's `STAGE_DELETE_SECRET` in place of the fleet's shared secret only when the token
281
+ * resolves to an `<app>-stage` tenant — so a stage holding it deletes its own bytes and none of
282
+ * production's. Without it every delete on a stage failed: `remove requires internalSecret`.
283
+ *
284
+ * · `worker` — the name the app's binary store reads it under, the same as production's.
285
+ * · `file` — the key it is kept under in `<app>-worker-stage.env`, so nobody mistakes it there for
286
+ * the fleet secret.
287
+ * · `source` — where a mint copies it from: `STAGE_DELETE_SECRET` in `binary-server.env`, the sibling
288
+ * of the app's own `<app>.env`. A copy, never a mint: binary-server must hold the same value.
289
+ */
290
+ export const STAGE_DELETE_SECRET = {
291
+ worker: "BINARY_SERVER_INTERNAL_SECRET",
292
+ file: "BINARY_SERVER_STAGE_DELETE_SECRET",
293
+ source: { file: "binary-server.env", key: "STAGE_DELETE_SECRET" },
294
+ } as const;
295
+
296
+ /** Every stage that deletes media on binary-server: the throwaway set, plus {@link STAGE_DELETE_SECRET}. */
297
+ export const STAGE_SECRETS = {
298
+ ...THROWAWAY_SECRETS,
299
+ [STAGE_DELETE_SECRET.worker]: STAGE_DELETE_SECRET.file,
300
+ } as const;
301
+
302
+ /**
303
+ * binary-server's stage delete secret, read from `binary-server.env` in `secretsDir`
304
+ * (`$FORGE_STATE/secrets`), or null when that file or the key is absent.
305
+ */
306
+ export function readStageDeleteSecret(secretsDir: string, read: (path: string) => string | null = readIfThere): string | null {
307
+ const value = readEnvFile(read(join(secretsDir, STAGE_DELETE_SECRET.source.file)) ?? "")[STAGE_DELETE_SECRET.source.key]?.trim();
308
+ return value || null;
309
+ }
310
+
311
+ function readIfThere(path: string): string | null {
312
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
313
+ }
314
+
277
315
  /** What {@link runWorkerSecrets} needs to know about ONE app's ONE deployment — data, no logic. */
278
316
  export interface WorkerSecretsSpec {
279
317
  /** `family`, `music` — prefixes every message and names the Mac's `<app>.env`. */
@@ -337,6 +375,18 @@ export async function runWorkerSecrets(
337
375
  }
338
376
  carried[key] = value;
339
377
  }
378
+ // A set that names the stage delete secret gets binary-server's own value, so a re-mint keeps
379
+ // the stage able to delete instead of dropping the key the upload then refuses without.
380
+ if (Object.values(spec.secrets).includes(STAGE_DELETE_SECRET.file)) {
381
+ const value = readStageDeleteSecret(dirname(spec.macSecretsFile), read);
382
+ if (!value) {
383
+ console.error(
384
+ `${tag} ${STAGE_DELETE_SECRET.source.file} carries no ${STAGE_DELETE_SECRET.source.key} — without it this stage cannot delete its own media.`,
385
+ );
386
+ return 2;
387
+ }
388
+ carried[STAGE_DELETE_SECRET.file] = value;
389
+ }
340
390
  const who = spec.env === "stage" ? "the STAGE" : "the PREVIEW";
341
391
  (deps.write ?? writeSecretsFile)(
342
392
  spec.secretsFile,