cursedops 0.4.0 → 0.5.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 +138 -5
- package/package.json +41 -4
- package/src/d1Import.ts +124 -0
- package/src/edgeFetch.ts +152 -0
- package/src/launchd.ts +40 -0
- package/src/paths.ts +173 -0
- package/src/roots.ts +126 -0
- package/src/smoke.ts +476 -4
- package/src/workerDeploy.ts +268 -0
- package/src/workerRollback.ts +133 -0
- package/src/workerSecrets.ts +195 -0
package/README.md
CHANGED
|
@@ -8,9 +8,15 @@ bun add cursedops
|
|
|
8
8
|
|
|
9
9
|
| subpath | what it is |
|
|
10
10
|
|---|---|
|
|
11
|
-
| `cursedops/roots` | finding a generation's roots, and a checkout's package root, without knowing a path |
|
|
12
|
-
| `cursedops/
|
|
13
|
-
| `cursedops/
|
|
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) |
|
|
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) |
|
|
16
|
+
| `cursedops/worker-secrets` | a Worker holding EXACTLY its deployment's secrets, uploaded over a pipe, read back (0.5.0) |
|
|
17
|
+
| `cursedops/worker-rollback` | a hostname back on its Mac origin: origin first, route second, the route found rather than typed (0.5.0) |
|
|
18
|
+
| `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) |
|
|
19
|
+
| `cursedops/d1-import` | a cutover's data proof — SQL literals and the row-for-row comparison, never a count (0.5.0) |
|
|
14
20
|
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
|
|
15
21
|
| `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 |
|
|
16
22
|
| `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 |
|
|
@@ -99,8 +105,15 @@ needs to read it:
|
|
|
99
105
|
`deploy.ts` is a fourth: the two scripts share a shape and roughly thirty lines of
|
|
100
106
|
helpers, but they sequence genuinely different steps and a shared deploy driver is
|
|
101
107
|
exactly the 277-file mistake starting again. What they share instead is the **exit-code
|
|
102
|
-
contract** in `cursedops/smoke` — `0` keep, `1` roll back, `2`
|
|
103
|
-
back — which is the only part both
|
|
108
|
+
contract** in `cursedops/smoke` — `0` keep, `1` roll back, `2` not the app (the edge, the
|
|
109
|
+
network, or the smoke's own environment), do not roll back — which is the only part both
|
|
110
|
+
deploy scripts actually read.
|
|
111
|
+
|
|
112
|
+
🔴 **The WORKER deploy is the other way round, and `cursedops/worker-deploy` is not this
|
|
113
|
+
mistake made again.** The Mac `deploy.ts` scripts sequence genuinely different steps; the
|
|
114
|
+
four Worker `worker-deploy.ts` scripts sequence the SAME nine, and a `diff` says so — what
|
|
115
|
+
differed between them was which of the nine each copy had forgotten. So the sequence is
|
|
116
|
+
shared and every step's command is data the app passes; see "The Worker toolkit" below.
|
|
104
117
|
|
|
105
118
|
## `cursedops/roots`
|
|
106
119
|
|
|
@@ -127,6 +140,54 @@ runs from there. A clone is its own primary and still gets `null`.
|
|
|
127
140
|
library that hardcoded `$HOME/.<name>` would be correct for exactly one generation and
|
|
128
141
|
silently wrong for its successor — which is the defect this replaced.
|
|
129
142
|
|
|
143
|
+
### `repoCd` — a printed command that RUNS (0.5.0, task 092)
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { otherRepoCommand, repoCommand } from "cursedops/roots";
|
|
147
|
+
|
|
148
|
+
const REPO_IN_FORGE = "apps/auth"; // the ONE thing the app keeps
|
|
149
|
+
repoCommand(REPO_IN_FORGE, "bun run geo:build", import.meta.dir);
|
|
150
|
+
// → source "…/forge.env" && cd "$FORGE/apps/auth" && bun run geo:build
|
|
151
|
+
otherRepoCommand("apps/station", REPO_IN_FORGE, "bun run geo", import.meta.dir);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Rule 1: four repos wrote it — `apps/binary-server/src/repoCommand.ts` (the original,
|
|
155
|
+
2026-09-18), `apps/auth/src/kit/repoCommand.ts`, and `scripts/forgeRoot.ts` in `family` and
|
|
156
|
+
`roms`. Rule 3: `check-paths`' `deadCd` rule measured twelve printed commands in five repos
|
|
157
|
+
`cd`-ing into directories two generations gone, each read at a moment something was already
|
|
158
|
+
wrong. The segment is always the CALLER's (the library may not know a repo's name) and is
|
|
159
|
+
refused if it is not a plain relative path; `from` is REQUIRED, because a default would be this
|
|
160
|
+
library's own directory inside `node_modules`. A `$FORGE` with no `forge.env` in it is stale and
|
|
161
|
+
is not honoured (`verifiedForgeRoot`; `forgeCodeRoot` keeps its older contract). Every fallback is
|
|
162
|
+
a directory that EXISTS, and nothing composed here ever spells `cd ~/`. `repoCommand.test.ts`
|
|
163
|
+
asserts on the COMPOSED string, which is the only thing `check-paths` cannot see.
|
|
164
|
+
|
|
165
|
+
### The state root (task 160)
|
|
166
|
+
|
|
167
|
+
Nothing new was needed: `forgeState(from, env)` / `requireForgeState(from, env)` already read
|
|
168
|
+
`FORGE_STATE` out of the `forge.env` above the calling module, which is what a launchd job with
|
|
169
|
+
no environment and a worktree both need. An app's hand-spelled `join(HOME, ".code", "<name>")`
|
|
170
|
+
becomes `requireForgeState(import.meta.dir, env)`. Text generated for ANOTHER machine (a systemd
|
|
171
|
+
unit, a shell installer) interpolates the VALUE — `relative(homedir(), requireForgeState(…))`
|
|
172
|
+
after `%h/` or `$HOME/` — never the generation's name.
|
|
173
|
+
|
|
174
|
+
## `cursedops/paths` (0.5.0, task 2089)
|
|
175
|
+
|
|
176
|
+
```jsonc
|
|
177
|
+
"paths": "forge-paths" // check-paths + check-doc-citations
|
|
178
|
+
"paths": "forge-paths --also check-gate-graph.ts" // cursedbelt's extra law, as an OPTION
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Rule 1, eighteen times: `scripts/paths.ts` was in every app, `autopilot` and every library,
|
|
182
|
+
sixteen byte-identical and three already forked (`cursedbelt` +`check-gate-graph.ts`, `station`
|
|
183
|
+
+`check-no-clock.ts`, `autopilot` split into its own `generation.ts`). `check-copies` prices
|
|
184
|
+
exported definitions and this file exports nothing, so eighteen copies read as zero. Rule 3: the
|
|
185
|
+
wrapper exists because `bun ../../tools/check-paths.ts .` died on its first step in every
|
|
186
|
+
worktree (2026-09-17). The walk and the worktree follow-back are the copies' own; **no
|
|
187
|
+
generation is still no pass** — a lone clone exits 1 saying which walk came back empty. Every
|
|
188
|
+
argument that is not `--also` passes through to the laws (`bun run paths --prune`). This repo
|
|
189
|
+
dogfoods it: `"paths": "bun src/paths.ts"`, and its own copy is gone.
|
|
190
|
+
|
|
130
191
|
## `cursedops/launchd`
|
|
131
192
|
|
|
132
193
|
```ts
|
|
@@ -140,6 +201,10 @@ if (boot.code !== 0) throw new Error(boot.out);
|
|
|
140
201
|
if (!(await answering(`http://127.0.0.1:${port}/healthz`))) throw new Error("a pid is not a service");
|
|
141
202
|
```
|
|
142
203
|
|
|
204
|
+
`livePort(label)` (0.5.0) reads `PORT` out of the LIVE job's own `environment` block — the
|
|
205
|
+
declaration the tunnel routes to — never `package.json`, which advertised the dev pair in eight
|
|
206
|
+
of eight apps (`autopilot doctor`'s `ports` section, 2026-09-15, lifted with its fixture).
|
|
207
|
+
|
|
143
208
|
`RunAtLoad` and `KeepAlive` are **not defaulted** — a server wants both, a nightly
|
|
144
209
|
snapshot wants neither, and a kit that decides gets one of them wrong.
|
|
145
210
|
|
|
@@ -175,6 +240,40 @@ Only same-origin `.js`/`.css` references are compared, because the zone injects
|
|
|
175
240
|
Analytics beacon in front of the edge and not on loopback — a check that reds on a
|
|
176
241
|
healthy deploy is one somebody turns off.
|
|
177
242
|
|
|
243
|
+
### The verdicts (0.5.0, task 069)
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { livePort } from "cursedops/launchd";
|
|
247
|
+
import { createSmoke, probeOrigin } from "cursedops/smoke";
|
|
248
|
+
|
|
249
|
+
const smoke = createSmoke({ base: PUBLIC_URL });
|
|
250
|
+
if (!smoke.requireEnvironment({ CF_ACCESS_CLIENT_ID: env.CF_ACCESS_CLIENT_ID }, "source $FORGE_STATE/secrets/cloudflare-access.env")) {
|
|
251
|
+
process.exit(smoke.report()); // 2 — the smoke could not judge; not the app
|
|
252
|
+
}
|
|
253
|
+
await probeOrigin(smoke, livePort(LABEL), { label: LABEL }); // a dead origin is 1, always
|
|
254
|
+
// … the app's own checks against the public hostname …
|
|
255
|
+
process.exit(await smoke.settle()); // asks the NETWORK before charging the app
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
A deployed smoke lied three ways, and each is now a verdict with a test of its failure path
|
|
259
|
+
(`src/smokeVerdict.test.ts`):
|
|
260
|
+
|
|
261
|
+
| lie | measured | verdict | exit |
|
|
262
|
+
|---|---|---|---:|
|
|
263
|
+
| 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 |
|
|
264
|
+
| 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 |
|
|
265
|
+
| 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 |
|
|
266
|
+
|
|
267
|
+
🔴 **No new exit code**, on purpose: every deploy script reads `0` keep, `2` stop without
|
|
268
|
+
reverting, anything else REVERT (`apps/collections/scripts/deploy.ts:413-424` and four
|
|
269
|
+
siblings). A `3` for "credential missing" would be reverted by all five. The distinction is in
|
|
270
|
+
`verdict()` and in the sentence printed. And the network diagnosis **never retries and never
|
|
271
|
+
pins an address** — the deliverable is the distinction, not a way around the network.
|
|
272
|
+
|
|
273
|
+
`steadyHealth(ask, ready)` is the Worker smoke's wait: `ready` must hold for THREE reads in a
|
|
274
|
+
row, because on `music`'s first deploy (2026-09-23, `a54ef71`) one good `/healthz` was followed
|
|
275
|
+
by thirteen routes answering the pre-secrets version. It returns what answered and never judges.
|
|
276
|
+
|
|
178
277
|
Nothing else. The checks are the part `desk` and `flix` wrote differently on purpose,
|
|
179
278
|
and a shared kit that starts absorbing route lists and health-payload shapes is how the
|
|
180
279
|
last one reached 277 files.
|
|
@@ -274,6 +373,40 @@ input the eight copies refused refused, at the same cost. A symlinked ancestor o
|
|
|
274
373
|
root cancels out — which is the failure a one-sided `realpath` would have shipped to
|
|
275
374
|
every app on this machine, and is its own test.
|
|
276
375
|
|
|
376
|
+
## The Worker toolkit (0.5.0, task 2097)
|
|
377
|
+
|
|
378
|
+
`cursedops/worker-deploy`, `/worker-secrets`, `/worker-rollback`, `/edge-fetch`, `/d1-import`.
|
|
379
|
+
Rule 1, four times: `collections`, `vault`, `patterns` and `music` (copied from `vault` on
|
|
380
|
+
2026-09-23) each carried the toolkit in `scripts/`, which `check-copies` did not scan. Rule 3:
|
|
381
|
+
the copies had drifted in ways that cost something — `patterns` never refused a dirty tree nor
|
|
382
|
+
stamped the commit, `vault` and `collections` judged a deploy after one `/healthz` read,
|
|
383
|
+
`collections`' secrets upload never deleted an extra, `patterns`' rollback typed its route id,
|
|
384
|
+
and only `collections`' import normalised a REAL. Each module header names its incident.
|
|
385
|
+
Everything that shells out (wrangler, git, curl, dig) is injectable; the suite never touches a
|
|
386
|
+
network.
|
|
387
|
+
|
|
388
|
+
🔴 **`edge-fetch` is the fourth recorded exception.** Rule 1 asks for two INDEPENDENT
|
|
389
|
+
implementations and this has one: `vault` wrote it and `music` copied it byte for byte. What
|
|
390
|
+
admits it is that a verbatim copy is precisely what `check-copies` exists to refuse, the task
|
|
391
|
+
that asked for this move named it, and every other part of the toolkit it serves is here — a
|
|
392
|
+
Worker smoke that had to keep its transport in the app would keep a copy of the toolkit's
|
|
393
|
+
most fragile part (a hand-rolled HTTP parser) in two places.
|
|
394
|
+
|
|
395
|
+
| part | verdict |
|
|
396
|
+
|---|---|
|
|
397
|
+
| the deploy SEQUENCE (HEAD, clean tree, app refusals, stage first, build, schema, `--var` stamp, secrets, smoke) | **moved** — `runWorkerDeploy(spec, deps)`; the commands are data |
|
|
398
|
+
| `parseWorkerEnv`, `wranglerEnvArgs`, `readWranglerJsonc`, `readEnvFile`, `cloudflareCredential` | **moved** — identical or converged in all four |
|
|
399
|
+
| exact-set secrets over a pipe, read back | **moved** — `planSecrets` + `syncSecrets` + `wranglerSecretPut`; `envFileLines` carries `collections`' pre-quoted-seed fix |
|
|
400
|
+
| origin-first rollback, route found by pattern | **moved** — `rollbackToOrigin`, `findWorkerRoute`, `deleteWorkerRoute` |
|
|
401
|
+
| `edgeFetch` (curl `--resolve` past the negative DNS cache) | **moved** — `createEdgeFetch`; the Access header now THROWS on an incomplete token instead of sending empty headers |
|
|
402
|
+
| the import's literal and row-for-row proof | **moved** — `sqlLiteral`, `rowDigest`, `sameRows`, `rowDifferences`, `wranglerRows` |
|
|
403
|
+
| the steady `/healthz` wait | **moved**, into `cursedops/smoke` as `steadyHealth` |
|
|
404
|
+
| the deployment table (`workerEnvs.ts`: names, database, URLs, secret map, which file) | **stays** — identity, rule 2 |
|
|
405
|
+
| the smoke's CHECKS (gated-route census, health identity fields, shell/CSP) | **stays** — the same argument as the rest of `smoke` |
|
|
406
|
+
| the walk (`workerWalk.ts`) and the stage walk's grant through `apps/auth-stage` | **stays** — what a signed-in owner does is the app |
|
|
407
|
+
| the import's table PLAN (what is skipped, parents first, what is rebuilt: FTS, `art_lookups`) | **stays** — the app's schema |
|
|
408
|
+
| minting a stage's secret FILE (which keys are copied from production, which are throwaway) | **stays** — `throwawaySecret` and `writeSecretsFile` are the mechanism it uses |
|
|
409
|
+
|
|
277
410
|
## `cursedops/public-surface`
|
|
278
411
|
|
|
279
412
|
```sh
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, the scaffolding of a deployed smoke,
|
|
3
|
+
"version": "0.5.0",
|
|
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), 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": {
|
|
7
7
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
8
8
|
"lint": "biome check .",
|
|
9
9
|
"test": "bun test src",
|
|
10
|
-
"paths": "bun
|
|
10
|
+
"paths": "bun src/paths.ts",
|
|
11
11
|
"verify": "bun run paths && bun run typecheck && bun run lint && bun run test",
|
|
12
12
|
"prepublishOnly": "bun run verify"
|
|
13
13
|
},
|
|
@@ -60,10 +60,47 @@
|
|
|
60
60
|
"source": "./src/publicSurface.ts",
|
|
61
61
|
"import": "./src/publicSurface.ts"
|
|
62
62
|
},
|
|
63
|
+
"./paths": {
|
|
64
|
+
"types": "./src/paths.ts",
|
|
65
|
+
"bun": "./src/paths.ts",
|
|
66
|
+
"source": "./src/paths.ts",
|
|
67
|
+
"import": "./src/paths.ts"
|
|
68
|
+
},
|
|
69
|
+
"./worker-deploy": {
|
|
70
|
+
"types": "./src/workerDeploy.ts",
|
|
71
|
+
"bun": "./src/workerDeploy.ts",
|
|
72
|
+
"source": "./src/workerDeploy.ts",
|
|
73
|
+
"import": "./src/workerDeploy.ts"
|
|
74
|
+
},
|
|
75
|
+
"./worker-secrets": {
|
|
76
|
+
"types": "./src/workerSecrets.ts",
|
|
77
|
+
"bun": "./src/workerSecrets.ts",
|
|
78
|
+
"source": "./src/workerSecrets.ts",
|
|
79
|
+
"import": "./src/workerSecrets.ts"
|
|
80
|
+
},
|
|
81
|
+
"./worker-rollback": {
|
|
82
|
+
"types": "./src/workerRollback.ts",
|
|
83
|
+
"bun": "./src/workerRollback.ts",
|
|
84
|
+
"source": "./src/workerRollback.ts",
|
|
85
|
+
"import": "./src/workerRollback.ts"
|
|
86
|
+
},
|
|
87
|
+
"./edge-fetch": {
|
|
88
|
+
"types": "./src/edgeFetch.ts",
|
|
89
|
+
"bun": "./src/edgeFetch.ts",
|
|
90
|
+
"source": "./src/edgeFetch.ts",
|
|
91
|
+
"import": "./src/edgeFetch.ts"
|
|
92
|
+
},
|
|
93
|
+
"./d1-import": {
|
|
94
|
+
"types": "./src/d1Import.ts",
|
|
95
|
+
"bun": "./src/d1Import.ts",
|
|
96
|
+
"source": "./src/d1Import.ts",
|
|
97
|
+
"import": "./src/d1Import.ts"
|
|
98
|
+
},
|
|
63
99
|
"./package.json": "./package.json"
|
|
64
100
|
},
|
|
65
101
|
"bin": {
|
|
66
|
-
"public-surface": "./src/publicSurface.ts"
|
|
102
|
+
"public-surface": "./src/publicSurface.ts",
|
|
103
|
+
"forge-paths": "./src/paths.ts"
|
|
67
104
|
},
|
|
68
105
|
"files": [
|
|
69
106
|
"src",
|
package/src/d1Import.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedops/d1-import` — the half of a cutover's data step that is the same in every app: turn
|
|
3
|
+
* a SQLite row into a SQL literal, and prove, ROW FOR ROW, that the source and D1 hold the same
|
|
4
|
+
* thing. Which tables move, in what order, and what is rebuilt rather than copied stay the app's.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { rowDifferences, sameRows, sqlLiteral, wranglerRows } from "cursedops/d1-import";
|
|
8
|
+
*
|
|
9
|
+
* const there = wranglerRows(d1(["--command", `SELECT ${cols} FROM "${table}"`]));
|
|
10
|
+
* const verdict = sameRows(here, there, columns); // { same, here, there }
|
|
11
|
+
* const which = rowDifferences(here, there, columns, "id"); // the rows, not "some differ"
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* ## 🔴 Row for row, never a count
|
|
15
|
+
*
|
|
16
|
+
* Two tables can hold the same NUMBER of different rows, and a count is exactly the check that
|
|
17
|
+
* cannot tell — on `collections`' catalogue that would have been a caption, a rotation or a
|
|
18
|
+
* checksum silently replaced by another file's. `collections`, `vault`, `patterns` and `music`
|
|
19
|
+
* each wrote this comparison (task 2097); the `rowDigest` bodies had drifted:
|
|
20
|
+
*
|
|
21
|
+
* · `collections` normalised numbers — `bun:sqlite` hands back `0` where D1's JSON carries
|
|
22
|
+
* `0.0`, and float noise past twelve significant digits — because `duration_seconds` is REAL
|
|
23
|
+
* on 39 of its rows and a comparison that called those different reported the catalogue
|
|
24
|
+
* corrupt on every run. `vault` and `music` did not normalise, and would have.
|
|
25
|
+
* · `vault` and `music` spelled a BLOB as `X'…'` for the insert; `patterns` could not carry one.
|
|
26
|
+
*
|
|
27
|
+
* The body here is the union: numbers normalised, blobs compared as hex whichever side they came
|
|
28
|
+
* from (a `Uint8Array` from `bun:sqlite`, an array of bytes from D1's JSON), booleans as 0/1.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A SQL literal for a value read out of our OWN database. Not an escaping library for input. */
|
|
32
|
+
export function sqlLiteral(value: unknown): string {
|
|
33
|
+
if (value === null || value === undefined) return "NULL";
|
|
34
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
35
|
+
if (typeof value === "bigint") return String(value);
|
|
36
|
+
if (typeof value === "number") {
|
|
37
|
+
if (Number.isNaN(value)) return "NULL";
|
|
38
|
+
if (!Number.isFinite(value)) return value > 0 ? "9e999" : "-9e999";
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString("hex")}'`;
|
|
42
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cell(value: unknown): string | number | null {
|
|
46
|
+
if (value === null || value === undefined) return null;
|
|
47
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
48
|
+
if (typeof value === "bigint") return Number.isSafeInteger(Number(value)) ? Number(value) : String(value);
|
|
49
|
+
if (typeof value === "number") return Number.isInteger(value) ? value : Number(value.toPrecision(12));
|
|
50
|
+
if (value instanceof Uint8Array) return `blob:${Buffer.from(value).toString("hex")}`;
|
|
51
|
+
if (Array.isArray(value) && value.every((b) => typeof b === "number" && b >= 0 && b <= 255 && Number.isInteger(b))) {
|
|
52
|
+
return `blob:${Buffer.from(value as number[]).toString("hex")}`;
|
|
53
|
+
}
|
|
54
|
+
return String(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One row flattened to a comparable string over `columns` — see the header for the normalisation. */
|
|
58
|
+
export function rowDigest(row: Readonly<Record<string, unknown>>, columns: readonly string[]): string {
|
|
59
|
+
return JSON.stringify(columns.map((column) => cell(row[column])));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Are the two sides the same MULTISET of rows over `columns`? Order-insensitive. */
|
|
63
|
+
export function sameRows(
|
|
64
|
+
here: readonly Readonly<Record<string, unknown>>[],
|
|
65
|
+
there: readonly Readonly<Record<string, unknown>>[],
|
|
66
|
+
columns: readonly string[],
|
|
67
|
+
): { same: boolean; here: number; there: number } {
|
|
68
|
+
const a = here.map((row) => rowDigest(row, columns)).sort();
|
|
69
|
+
const b = there.map((row) => rowDigest(row, columns)).sort();
|
|
70
|
+
return { same: a.length === b.length && a.every((digest, i) => digest === b[i]), here: a.length, there: b.length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* WHICH rows differ, by `key` — a row missing from D1, a column that differs, and (unless
|
|
75
|
+
* `allowExtras`) a row D1 has that the source does not. A cutover that has to go looking is a
|
|
76
|
+
* cutover that gets abandoned, so this names them rather than saying that some do.
|
|
77
|
+
*
|
|
78
|
+
* `allowExtras` is `collections`' SEED mode: a stage seeded from fixtures is then USED by its
|
|
79
|
+
* browser walk, so rows it gained are the stage working, not corruption. A MIRROR (production)
|
|
80
|
+
* never allows them — a row in production that is not in the source is a row from somewhere.
|
|
81
|
+
*/
|
|
82
|
+
export function rowDifferences(
|
|
83
|
+
here: readonly Readonly<Record<string, unknown>>[],
|
|
84
|
+
there: readonly Readonly<Record<string, unknown>>[],
|
|
85
|
+
columns: readonly string[],
|
|
86
|
+
key: string | readonly string[],
|
|
87
|
+
{ allowExtras = false } = {},
|
|
88
|
+
): string[] {
|
|
89
|
+
const keys = typeof key === "string" ? [key] : [...key];
|
|
90
|
+
const id = (row: Readonly<Record<string, unknown>>) => rowDigest(row, keys);
|
|
91
|
+
const label = (row: Readonly<Record<string, unknown>>) => keys.map((k) => `${k}=${String(row[k])}`).join(",");
|
|
92
|
+
const byKey = new Map(there.map((row) => [id(row), row]));
|
|
93
|
+
const seen = new Set<string>();
|
|
94
|
+
const out: string[] = [];
|
|
95
|
+
for (const row of here) {
|
|
96
|
+
const digest = id(row);
|
|
97
|
+
seen.add(digest);
|
|
98
|
+
const other = byKey.get(digest);
|
|
99
|
+
if (!other) {
|
|
100
|
+
out.push(`${label(row)} is in the source and not in D1`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const column of columns) {
|
|
104
|
+
if (rowDigest(row, [column]) !== rowDigest(other, [column])) {
|
|
105
|
+
out.push(`${label(row)} · ${column}: source ${rowDigest(row, [column])} ≠ D1 ${rowDigest(other, [column])}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!allowExtras) for (const row of there) if (!seen.has(id(row))) out.push(`${label(row)} is in D1 and not in the source`);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The rows out of `wrangler d1 execute --json` stdout. wrangler prints banner text before the
|
|
115
|
+
* JSON on some versions, so the parse starts at the first `[` that opens an array; none at all THROWS — an empty
|
|
116
|
+
* read reported as "no rows" is how a comparison passes against a database it never saw.
|
|
117
|
+
*/
|
|
118
|
+
export function wranglerRows(stdout: string): Array<Record<string, unknown>> {
|
|
119
|
+
// The first `[` that opens a JSON array — `[{` or `[]` — never the `[ERROR]` of a failure banner.
|
|
120
|
+
const start = stdout.search(/\[\s*[{\]]/);
|
|
121
|
+
if (start < 0) throw new Error(`wrangler returned no JSON: ${stdout.slice(0, 200)}`);
|
|
122
|
+
const parsed = JSON.parse(stdout.slice(start)) as Array<{ results?: Array<Record<string, unknown>> }>;
|
|
123
|
+
return parsed.flatMap((result) => result.results ?? []);
|
|
124
|
+
}
|
package/src/edgeFetch.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedops/edge-fetch` — a request to a deployed Worker as a SCRIPT must make it: `curl` with
|
|
3
|
+
* `--resolve` pinned to what Cloudflare's own resolver answers, plus whatever headers the app
|
|
4
|
+
* says a host needs (the fleet's Access service token on `*-stage`).
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { cloudflareAccessHeaders, createEdgeFetch } from "cursedops/edge-fetch";
|
|
8
|
+
*
|
|
9
|
+
* export const edgeFetch = createEdgeFetch({
|
|
10
|
+
* headersFor: (host) => (/-stage\./.test(host) ? cloudflareAccessHeaders(readFileSync(accessFile, "utf8")) : {}),
|
|
11
|
+
* });
|
|
12
|
+
* const response = await edgeFetch("https://thing-stage.example.com/healthz");
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* ## 🔴 Why not `fetch`
|
|
16
|
+
*
|
|
17
|
+
* This Mac caches a NEGATIVE DNS answer, for the zone's SOA minimum, for a hostname asked before
|
|
18
|
+
* its record existed — and a stage's custom domain is created by its own first deploy, seconds
|
|
19
|
+
* before the smoke asks it. Flushing that cache needs sudo. The smoke and the walk are about the
|
|
20
|
+
* Worker, not this laptop's resolver, so they ask `1.1.1.1` directly. `vault` wrote it; `music`
|
|
21
|
+
* copied it byte for byte on 2026-09-23 (task 2097).
|
|
22
|
+
*
|
|
23
|
+
* 🔴 **This is a transport for proving a WORKER, not a verdict on a network.** Pinning the address
|
|
24
|
+
* is exactly why it cannot tell you whether the network a person is sitting on is lying — that
|
|
25
|
+
* question is `diagnoseNetwork` in `cursedops/smoke`, which never pins and never retries. Pass
|
|
26
|
+
* `resolve: null` to ask through the system resolver instead.
|
|
27
|
+
*
|
|
28
|
+
* Bytes go through a temporary file, so a binary body (ciphertext, audio) arrives unmangled, and
|
|
29
|
+
* the response body after the LAST header block is bytes — an interim `1xx` block precedes it.
|
|
30
|
+
*/
|
|
31
|
+
import { spawnSync } from "node:child_process";
|
|
32
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { tmpdir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
export interface CurlRun {
|
|
37
|
+
status: number | null;
|
|
38
|
+
stdout: Buffer;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EdgeFetchOptions {
|
|
42
|
+
/** Extra headers for a host — an Access service token, say. Default: none. */
|
|
43
|
+
headersFor?: (host: string) => Record<string, string>;
|
|
44
|
+
/**
|
|
45
|
+
* The IPv4 address to pin a host to, or `null`/`""` for none. Default: `dig +short <host> @1.1.1.1`.
|
|
46
|
+
* Pass `null` (not a function) to never pin.
|
|
47
|
+
*/
|
|
48
|
+
resolve?: ((host: string) => string | null) | null;
|
|
49
|
+
/** Injected for tests. Runs curl with `args`. */
|
|
50
|
+
curl?: (args: readonly string[]) => CurlRun;
|
|
51
|
+
/** Where a request body is staged. Default: the OS temp dir. */
|
|
52
|
+
tmpDir?: string;
|
|
53
|
+
/** curl's `--max-time`. Default 60. */
|
|
54
|
+
maxTimeSec?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function digCloudflare(host: string): string | null {
|
|
58
|
+
const dig = spawnSync("dig", ["+short", host, "@1.1.1.1"], { encoding: "utf8" });
|
|
59
|
+
return (
|
|
60
|
+
(dig.stdout ?? "")
|
|
61
|
+
.split("\n")
|
|
62
|
+
.map((line) => line.trim())
|
|
63
|
+
.find((line) => /^\d+\.\d+\.\d+\.\d+$/.test(line)) ?? null
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `curl -s -i` output → a `Response`. THROWS on empty output (nothing answered), which a caller
|
|
69
|
+
* must see as a failure and never as a status.
|
|
70
|
+
*/
|
|
71
|
+
export function parseCurlResponse(raw: Buffer, origin = "the edge", exit: number | null = null): Response {
|
|
72
|
+
if (raw.length === 0) throw new Error(`no answer from ${origin} (curl exit ${exit})`);
|
|
73
|
+
let offset = 0;
|
|
74
|
+
let head: string[] = [];
|
|
75
|
+
for (;;) {
|
|
76
|
+
const end = raw.indexOf("\r\n\r\n", offset);
|
|
77
|
+
const stop = end < 0 ? raw.length : end;
|
|
78
|
+
head = raw.subarray(offset, stop).toString("latin1").split("\r\n");
|
|
79
|
+
offset = end < 0 ? raw.length : end + 4;
|
|
80
|
+
if (!/^HTTP\/\S+ 1\d\d/.test(head[0] ?? "") || raw.subarray(offset, offset + 5).toString("latin1") !== "HTTP/") break;
|
|
81
|
+
}
|
|
82
|
+
const status = Number(/HTTP\/\S+ (\d+)/.exec(head[0] ?? "")?.[1] ?? 0);
|
|
83
|
+
const headers = new Headers();
|
|
84
|
+
for (const line of head.slice(1)) {
|
|
85
|
+
const i = line.indexOf(":");
|
|
86
|
+
if (i > 0) headers.append(line.slice(0, i).trim(), line.slice(i + 1).trim());
|
|
87
|
+
}
|
|
88
|
+
const body = raw.subarray(offset);
|
|
89
|
+
return new Response(status === 204 || status === 304 ? null : new Uint8Array(body), { status: status || 599, headers });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A `fetch`-shaped function over curl — see the header. Each host is resolved once per instance. */
|
|
93
|
+
export function createEdgeFetch(options: EdgeFetchOptions = {}): (url: string, init?: RequestInit) => Promise<Response> {
|
|
94
|
+
const pinned = new Map<string, string>();
|
|
95
|
+
const resolve = options.resolve === undefined ? digCloudflare : options.resolve;
|
|
96
|
+
const curl =
|
|
97
|
+
options.curl ??
|
|
98
|
+
((args: readonly string[]): CurlRun => {
|
|
99
|
+
const out = spawnSync("curl", [...args], { maxBuffer: 256 * 1024 * 1024 });
|
|
100
|
+
return { status: out.status, stdout: out.stdout ?? Buffer.alloc(0) };
|
|
101
|
+
});
|
|
102
|
+
let sequence = 0;
|
|
103
|
+
return async (url, init = {}) => {
|
|
104
|
+
const target = new URL(url);
|
|
105
|
+
if (resolve && !pinned.has(target.hostname)) pinned.set(target.hostname, resolve(target.hostname) ?? "");
|
|
106
|
+
const ip = pinned.get(target.hostname);
|
|
107
|
+
const port = target.port || (target.protocol === "http:" ? "80" : "443");
|
|
108
|
+
const args = ["-s", "-i", "--max-time", String(options.maxTimeSec ?? 60), ...(ip ? ["--resolve", `${target.hostname}:${port}:${ip}`] : [])];
|
|
109
|
+
for (const [k, v] of Object.entries(options.headersFor?.(target.hostname) ?? {})) args.push("-H", `${k}: ${v}`);
|
|
110
|
+
new Headers(init.headers).forEach((v, k) => {
|
|
111
|
+
args.push("-H", `${k}: ${v}`);
|
|
112
|
+
});
|
|
113
|
+
if (init.method) args.push("-X", init.method);
|
|
114
|
+
let bodyFile: string | null = null;
|
|
115
|
+
if (init.body !== undefined && init.body !== null) {
|
|
116
|
+
bodyFile = join(options.tmpDir ?? tmpdir(), `edge-fetch-${process.pid}-${++sequence}-${Date.now()}.bin`);
|
|
117
|
+
const body = init.body;
|
|
118
|
+
writeFileSync(
|
|
119
|
+
bodyFile,
|
|
120
|
+
typeof body === "string" ? body : body instanceof Uint8Array ? body : new Uint8Array(await new Response(body as BodyInit).arrayBuffer()),
|
|
121
|
+
);
|
|
122
|
+
args.push("--data-binary", `@${bodyFile}`);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const out = curl([...args, target.toString()]);
|
|
126
|
+
return parseCurlResponse(out.stdout, target.origin, out.status);
|
|
127
|
+
} finally {
|
|
128
|
+
if (bodyFile) rmSync(bodyFile, { force: true });
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The Access service-token headers out of an env file holding `CF_ACCESS_CLIENT_ID` and
|
|
135
|
+
* `CF_ACCESS_CLIENT_SECRET`. THROWS, naming what is missing, rather than sending empty headers —
|
|
136
|
+
* an empty token reads at the far end as "Access refused the app", which is the exact
|
|
137
|
+
* misattribution a smoke must not make.
|
|
138
|
+
*/
|
|
139
|
+
export function cloudflareAccessHeaders(envText: string): Record<string, string> {
|
|
140
|
+
const value = (key: string) =>
|
|
141
|
+
(envText.split("\n").find((line) => line.trim().replace(/^export\s+/, "").startsWith(`${key}=`)) ?? "")
|
|
142
|
+
.trim()
|
|
143
|
+
.replace(/^export\s+/, "")
|
|
144
|
+
.slice(key.length + 1)
|
|
145
|
+
.trim()
|
|
146
|
+
.replace(/^['"]|['"]$/g, "");
|
|
147
|
+
const id = value("CF_ACCESS_CLIENT_ID");
|
|
148
|
+
const secret = value("CF_ACCESS_CLIENT_SECRET");
|
|
149
|
+
const missing = [...(id ? [] : ["CF_ACCESS_CLIENT_ID"]), ...(secret ? [] : ["CF_ACCESS_CLIENT_SECRET"])];
|
|
150
|
+
if (missing.length > 0) throw new Error(`the Access service token is incomplete: no ${missing.join(" and no ")}`);
|
|
151
|
+
return { "CF-Access-Client-Id": id, "CF-Access-Client-Secret": secret };
|
|
152
|
+
}
|
package/src/launchd.ts
CHANGED
|
@@ -403,3 +403,43 @@ export async function answering(
|
|
|
403
403
|
await sleep(stepMs);
|
|
404
404
|
}
|
|
405
405
|
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The `PORT` a `launchctl print` dump says the job is handed, or `null`.
|
|
409
|
+
*
|
|
410
|
+
* 🔴 Only the job's OWN `environment = {` block. The same dump carries `inherited environment`
|
|
411
|
+
* and `default environment`, and a `PORT` leaking in from the session that bootstrapped the job
|
|
412
|
+
* is not what launchd hands the next start — reading one of those reports a number nothing
|
|
413
|
+
* serves on. Lifted from `autopilot/src/ports.ts` (`portFromPrint`), whose fixture this carries.
|
|
414
|
+
*/
|
|
415
|
+
export function portFromPrint(printed: string): number | null {
|
|
416
|
+
let inside = false;
|
|
417
|
+
for (const line of printed.split("\n")) {
|
|
418
|
+
if (/^[ \t]*environment[ \t]*=[ \t]*\{[ \t]*$/.test(line)) {
|
|
419
|
+
inside = true;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (!inside) continue;
|
|
423
|
+
if (/^[ \t]*\}[ \t]*$/.test(line)) break;
|
|
424
|
+
const m = line.match(/^[ \t]*PORT[ \t]*=>[ \t]*(\d+)[ \t]*$/);
|
|
425
|
+
if (m?.[1]) return Number(m[1]);
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The port the LIVE job `label` serves on — read off launchd, never off the repo — or `null`
|
|
432
|
+
* when the job is not loaded or declares no `PORT`.
|
|
433
|
+
*
|
|
434
|
+
* 🔴 Measured 2026-09-15 by `autopilot doctor`'s `ports` section: `package.json` advertised the
|
|
435
|
+
* wrong port in **eight of eight** apps (it is the dev pair), and a smoke that asked it would
|
|
436
|
+
* prove nothing about the process traffic reaches. The declaration launchd holds is what the
|
|
437
|
+
* tunnel routes to and what the next start is handed, so it is the one a deployed smoke's
|
|
438
|
+
* origin probe asks. A `null` is not a pass: the caller records it as "there is no origin".
|
|
439
|
+
*/
|
|
440
|
+
export function livePort(label: string, ops: Partial<LaunchdOps> = {}): number | null {
|
|
441
|
+
const { run, domain } = realOps(ops);
|
|
442
|
+
const printed = run("print", `${domain}/${label}`);
|
|
443
|
+
if (printed.code !== 0) return null;
|
|
444
|
+
return portFromPrint(printed.out);
|
|
445
|
+
}
|