cursedops 0.19.0 → 0.19.2
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 +1 -1
- package/package.json +1 -1
- package/src/apiFloor.ts +4 -4
- package/src/d1Import.ts +6 -6
- package/src/edgeFetch.ts +1 -1
- package/src/launchd.ts +1 -1
- package/src/relay.ts +4 -4
- package/src/relayStage.ts +1 -1
- package/src/roots.ts +2 -2
- package/src/serve.ts +3 -3
- package/src/ship.ts +8 -8
- package/src/smoke.ts +4 -4
- package/src/workerDeploy.ts +23 -2
- package/src/workerRollback.ts +2 -2
- package/src/workerSecrets.ts +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ import shape, worked example, and the incident each one is pinned to.
|
|
|
25
25
|
| `cursedops/backups` | `BACKUP_KEEP`, the fleet's one retention for dated snapshots; `BACKUP_STAMP_PATTERN`, `backupStamp` and `stampTime` |
|
|
26
26
|
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
|
|
27
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
|
|
28
|
+
| `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normalizer, the default 404 body |
|
|
29
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
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
31
|
| `cursedops/staged-client` | the IMMUTABLE client a checkout-served app serves — a build staged into `<APP_DATA_DIR>/client/<commit>/` behind `CURRENT` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.2",
|
|
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": {
|
package/src/apiFloor.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* > this commit and reload"*, which sent the owner at a deploy that could not have
|
|
20
20
|
* > helped.
|
|
21
21
|
*
|
|
22
|
-
* Both halves of that are here, because a floor without the
|
|
22
|
+
* Both halves of that are here, because a floor without the normalizer fixes the symptom
|
|
23
23
|
* the owner did not have: {@link canonicalApiPath} makes the trailing slash a non-event,
|
|
24
24
|
* and {@link isApiPath} + {@link apiNotFoundBody} make every remaining miss a JSON 404
|
|
25
25
|
* that names the route, the app and the commit answering. A wrong path says "wrong path";
|
|
@@ -73,7 +73,7 @@ export const isApiPath = (urlPath: string): boolean =>
|
|
|
73
73
|
* 🔴 One rule, and it is the one that broke the Openclaw page: a trailing slash on an API
|
|
74
74
|
* path is never meaningful. Hono mounts a sub-app's `get("/")` at the mount point WITHOUT
|
|
75
75
|
* a trailing slash and then matches strictly, so `/api/openclaw/` is a different path
|
|
76
|
-
* from `/api/openclaw` and matched nothing at all.
|
|
76
|
+
* from `/api/openclaw` and matched nothing at all. Normalizing once, in front of the
|
|
77
77
|
* router, means no caller anywhere can spell it the losing way again — which a fix in the
|
|
78
78
|
* one client module that happened to do it would not have bought.
|
|
79
79
|
*
|
|
@@ -86,8 +86,8 @@ export function canonicalApiPath(urlPath: string): string | null {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
* The same
|
|
90
|
-
* there is nothing to
|
|
89
|
+
* The same normalization, applied to a whole request. Returns the request unchanged when
|
|
90
|
+
* there is nothing to normalize, so a caller that guards on {@link canonicalApiPath}
|
|
91
91
|
* allocates nothing.
|
|
92
92
|
*
|
|
93
93
|
* 🔴 **Call it from INSIDE the floor handler and re-dispatch, not from the host's
|
package/src/d1Import.ts
CHANGED
|
@@ -14,17 +14,17 @@
|
|
|
14
14
|
* ## 🔴 Row for row, never a count
|
|
15
15
|
*
|
|
16
16
|
* Two tables can hold the same NUMBER of different rows, and a count is exactly the check that
|
|
17
|
-
* cannot tell — on `collections`'
|
|
17
|
+
* cannot tell — on `collections`' catalog that would have been a caption, a rotation or a
|
|
18
18
|
* checksum silently replaced by another file's. `collections`, `vault`, `patterns` and `music`
|
|
19
19
|
* each wrote this comparison (task 2097); the `rowDigest` bodies had drifted:
|
|
20
20
|
*
|
|
21
|
-
* · `collections`
|
|
21
|
+
* · `collections` normalized numbers — `bun:sqlite` hands back `0` where D1's JSON carries
|
|
22
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
|
|
24
|
-
* corrupt on every run. `vault` and `music` did not
|
|
23
|
+
* on 39 of its rows and a comparison that called those different reported the catalog
|
|
24
|
+
* corrupt on every run. `vault` and `music` did not normalize, and would have.
|
|
25
25
|
* · `vault` and `music` spelled a BLOB as `X'…'` for the insert; `patterns` could not carry one.
|
|
26
26
|
*
|
|
27
|
-
* The body here is the union: numbers
|
|
27
|
+
* The body here is the union: numbers normalized, blobs compared as hex whichever side they came
|
|
28
28
|
* from (a `Uint8Array` from `bun:sqlite`, an array of bytes from D1's JSON), booleans as 0/1.
|
|
29
29
|
*/
|
|
30
30
|
|
|
@@ -54,7 +54,7 @@ function cell(value: unknown): string | number | null {
|
|
|
54
54
|
return String(value);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/** One row flattened to a comparable string over `columns` — see the header for the
|
|
57
|
+
/** One row flattened to a comparable string over `columns` — see the header for the normalization. */
|
|
58
58
|
export function rowDigest(row: Readonly<Record<string, unknown>>, columns: readonly string[]): string {
|
|
59
59
|
return JSON.stringify(columns.map((column) => cell(row[column])));
|
|
60
60
|
}
|
package/src/edgeFetch.ts
CHANGED
|
@@ -271,7 +271,7 @@ export const NO_ANSWER_STATUS = 599;
|
|
|
271
271
|
* {@link NO_ANSWER_STATUS} `Response` whose body and `x-edge-error` header carry the reason, and
|
|
272
272
|
* `onThrow` hears it first so the smoke's ledger can name it.
|
|
273
273
|
*
|
|
274
|
-
* 🔴 Why (2026-09-25): roms' `worker:smoke` asked a relayed path while the Mac's link
|
|
274
|
+
* 🔴 Why (2026-09-25): roms' `worker:smoke` asked a relayed path while the Mac's link redialed,
|
|
275
275
|
* `edgeFetch` threw on curl exit 28, and the process died with a stack trace and no ledger — so a
|
|
276
276
|
* healthy release exited 1. Every leg of a smoke is supposed to be RECORDED: a throw is a failed
|
|
277
277
|
* leg naming its path, never the end of the run.
|
package/src/launchd.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface CommandResult {
|
|
|
66
66
|
/**
|
|
67
67
|
* How this module reaches launchd and the LaunchAgents directory.
|
|
68
68
|
*
|
|
69
|
-
* Injectable as one object because the interesting
|
|
69
|
+
* Injectable as one object because the interesting behavior — clearing a label
|
|
70
70
|
* and *waiting* for it to go — cannot otherwise be reached from a test that does
|
|
71
71
|
* not install a real login agent first, and a test nobody runs is not a test.
|
|
72
72
|
*/
|
package/src/relay.ts
CHANGED
|
@@ -38,7 +38,7 @@ export const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
|
38
38
|
/** How long the Worker waits for the Mac to answer one request before it says so. */
|
|
39
39
|
export const DEFAULT_RELAY_TIMEOUT_MS = 30_000;
|
|
40
40
|
|
|
41
|
-
/** A request
|
|
41
|
+
/** A request traveling DOWN the link. `path` includes the query string. */
|
|
42
42
|
export interface RequestFrame {
|
|
43
43
|
t: "req";
|
|
44
44
|
id: string;
|
|
@@ -48,7 +48,7 @@ export interface RequestFrame {
|
|
|
48
48
|
body: string | null;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** The Mac's answer,
|
|
51
|
+
/** The Mac's answer, traveling UP. Multiple `set-cookie` lines stay separate pairs. */
|
|
52
52
|
export interface ResponseFrame {
|
|
53
53
|
t: "res";
|
|
54
54
|
id: string;
|
|
@@ -459,7 +459,7 @@ export class RelayLink {
|
|
|
459
459
|
if (body.length <= (cache.maxKeptBody ?? 512 * 1024)) {
|
|
460
460
|
const at = this.now();
|
|
461
461
|
await this.state.storage.put(`c:${owner}:${keyed}`, { body, contentType: response.headers.get("content-type") ?? "application/json", at } satisfies Kept);
|
|
462
|
-
// The newest cookie the Mac
|
|
462
|
+
// The newest cookie the Mac honored is the one a `warm` frame refreshes under.
|
|
463
463
|
await this.state.storage.put("warm", { cookie, at });
|
|
464
464
|
}
|
|
465
465
|
}
|
|
@@ -474,7 +474,7 @@ export class RelayLink {
|
|
|
474
474
|
if (keys.length > 0) await this.state.storage.delete(keys);
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
-
/** Refresh every read the Mac named, under the newest cookie it
|
|
477
|
+
/** Refresh every read the Mac named, under the newest cookie it honored — if that is still fresh. */
|
|
478
478
|
private async warm(paths: readonly string[]): Promise<void> {
|
|
479
479
|
const cache = this.options.offline;
|
|
480
480
|
if (!cache) return;
|
package/src/relayStage.ts
CHANGED
|
@@ -163,7 +163,7 @@ export const realRun: Run = (argv, options) => {
|
|
|
163
163
|
/**
|
|
164
164
|
* 🔴 How many rounds in a row every `/healthz` must name a just-put link digest before a walk uses it.
|
|
165
165
|
* A digest put is a new Worker version, and the edge takes it up instance by instance: the link
|
|
166
|
-
* staying up for 15 s proves only that the instance the link
|
|
166
|
+
* staying up for 15 s proves only that the instance the link dialed has it. The Worker signs the
|
|
167
167
|
* Mac's identity with that digest, so a request answered by an instance still on the old one is the
|
|
168
168
|
* Mac's own 401 (and station's kept read a 503). Measured 2026-10-15 (forge task 3235): station's
|
|
169
169
|
* fast-lane walk at baf8495, the link steady for 15 s, then four writes 401 at the Mac; on the real
|
package/src/roots.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* written independently against the same problem; measured 2026-09-16 the
|
|
9
9
|
* `findForgeRoot` bodies were character-identical and the surrounding halves were
|
|
10
10
|
* not, which is the definition of an extraction that is measurable rather than
|
|
11
|
-
* speculative. Two implementations, one
|
|
11
|
+
* speculative. Two implementations, one behavior.
|
|
12
12
|
*
|
|
13
13
|
* ## The rule this serves
|
|
14
14
|
*
|
|
@@ -332,7 +332,7 @@ function shellDoubleQuoted(value: string): string {
|
|
|
332
332
|
* {@link findForgeRoot}).
|
|
333
333
|
*
|
|
334
334
|
* 🔴 **Verified, never trusted.** A `$FORGE` pointing at a directory with no `forge.env` is a
|
|
335
|
-
* stale export from a shell that outlived a move, and
|
|
335
|
+
* stale export from a shell that outlived a move, and honoring it re-creates the exact defect
|
|
336
336
|
* {@link repoCd} exists to end: a command naming a directory that is not there.
|
|
337
337
|
* {@link forgeCodeRoot} deliberately keeps its older contract (the variable wins unchecked); this
|
|
338
338
|
* is the one for text a person will paste.
|
package/src/serve.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `station` and `vault` each have a `src/server/serve.ts`. The FILES differ — 247
|
|
10
10
|
* to 387 lines, eight distinct versions, which is why no `diff` ever flagged them —
|
|
11
11
|
* but these four bodies are character-identical in **eight of eight**, with the
|
|
12
|
-
* whitespace and comments
|
|
12
|
+
* whitespace and comments normalized away.
|
|
13
13
|
*
|
|
14
14
|
* 🔴 **`fileWithin` is why this was urgent rather than tidy.** It is the check that
|
|
15
15
|
* stops `%2e%2e%2f` reaching the filesystem. Eight copies of a security guarantee
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* `8 <symbol> src/server/serve.ts` and is the census that will notice if a copy
|
|
29
29
|
* comes back.
|
|
30
30
|
*
|
|
31
|
-
* ## 🔴 The one
|
|
31
|
+
* ## 🔴 The one behavior that is NOT a byte-for-byte carry
|
|
32
32
|
*
|
|
33
33
|
* The eight copies resolved containment LEXICALLY — `resolve(join(root, …))` and a
|
|
34
34
|
* `startsWith` — which is correct for `..` and for `%2e%2e%2f`, and blind to a
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* the same way — the failure mode a naive `realpath` on one side only would
|
|
43
43
|
* have introduced, and the reason this is not one line;
|
|
44
44
|
* - every caller today passes a Vite `dist/`, and all eight were checked for
|
|
45
|
-
* symlinks on 2026-09-17: there are none. So no app's
|
|
45
|
+
* symlinks on 2026-09-17: there are none. So no app's behavior moves, and the
|
|
46
46
|
* day one of them serves a directory it did not build, the guard already holds.
|
|
47
47
|
*
|
|
48
48
|
* `serve.test.ts` drives both halves, including the symlink escape, because a guard
|
package/src/ship.ts
CHANGED
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
* flipped yesterday — the precedence the retired `fastMode.ts` settled on 2026-08-13). A stage
|
|
40
40
|
* deploy is never deferred: it IS the fast way to see a change on a real edge.
|
|
41
41
|
*
|
|
42
|
-
* 🔴 **Under a batch (`$AUTOPILOT_BATCH` set) it is
|
|
42
|
+
* 🔴 **Under a batch (`$AUTOPILOT_BATCH` set) it is honored only for the ship task or an incident**
|
|
43
43
|
* ({@link shipNowVerdict}), and only with a reason in `FORGE_SHIP_NOW_WHY`. Until task 2384 any agent
|
|
44
44
|
* could prefix one variable and turn the owner's switch off for itself — the thing
|
|
45
45
|
* `~/.claude/CLAUDE.md` §2 says an agent may never do — and no record said it had. Now every ask,
|
|
46
|
-
*
|
|
47
|
-
* with no reason. In the owner's own shell (no batch) it is
|
|
46
|
+
* honored or refused, is a `kind: "ship-now"` ledger row, and `autopilot doctor` lists the ones
|
|
47
|
+
* with no reason. In the owner's own shell (no batch) it is honored exactly as before, and recorded.
|
|
48
48
|
*/
|
|
49
49
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
50
50
|
import { dirname, join } from "node:path";
|
|
@@ -92,7 +92,7 @@ export function readShipSetting(state: string | null): ShipSetting {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/** Is `FORGE_SHIP_NOW` set to something that means yes? Asked is not
|
|
95
|
+
/** Is `FORGE_SHIP_NOW` set to something that means yes? Asked is not honored — see {@link shipNowVerdict}. */
|
|
96
96
|
export function shipNowAsked(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
97
97
|
const v = env[SHIP_NOW_VAR]?.trim().toLowerCase();
|
|
98
98
|
return v === "1" || v === "true" || v === "yes";
|
|
@@ -116,7 +116,7 @@ export interface ShipNowVerdict {
|
|
|
116
116
|
asked: boolean;
|
|
117
117
|
/** Ship now: asked, and (outside a batch, or on a claim that earns it). */
|
|
118
118
|
honoured: boolean;
|
|
119
|
-
/** One sentence: what made it
|
|
119
|
+
/** One sentence: what made it honored or refused. */
|
|
120
120
|
why: string;
|
|
121
121
|
/** `FORGE_SHIP_NOW_WHY`, trimmed — `""` when not given. */
|
|
122
122
|
reason: string;
|
|
@@ -159,7 +159,7 @@ export interface ShipNowWhere {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
|
-
* Is `FORGE_SHIP_NOW`
|
|
162
|
+
* Is `FORGE_SHIP_NOW` honored here? Outside a batch, whenever it is asked: that is the owner's own
|
|
163
163
|
* shell. Under `$AUTOPILOT_BATCH`, only when that batch holds a live `kind: "task"` board claim
|
|
164
164
|
* (`$FORGE_STATE/state/board/*.json`, the pid alive and not expired) on a task in
|
|
165
165
|
* `$FORGE/tasks/In_Progress/` whose `escalation-key:` is one of {@link SHIP_NOW_KEYS}, AND
|
|
@@ -206,11 +206,11 @@ export function shipNowVerdict(where: ShipNowWhere, env: NodeJS.ProcessEnv = pro
|
|
|
206
206
|
if (key && SHIP_NOW_KEYS.some((k) => k.test(key))) return { ...base, honoured: true, why: `batch ${batch} holds ${c.task} (escalation-key: ${key})`, task: c.task };
|
|
207
207
|
}
|
|
208
208
|
return refuse(
|
|
209
|
-
`batch ${batch} holds ${held.length ? held.join(", ") : "no live task claim"} — ${SHIP_NOW_VAR} is
|
|
209
|
+
`batch ${batch} holds ${held.length ? held.join(", ") : "no live task claim"} — ${SHIP_NOW_VAR} is honored under a batch only for the ship task or a prod incident (escalation-key: ship | prod-down:<host>)`,
|
|
210
210
|
);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
/** The ledger line every ship-now ask leaves —
|
|
213
|
+
/** The ledger line every ship-now ask leaves — honored or refused. */
|
|
214
214
|
export interface ShipNowEventLine {
|
|
215
215
|
at: string;
|
|
216
216
|
kind: "ship-now";
|
package/src/smoke.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* its logs was wrong.
|
|
14
14
|
* 3. A deploy *succeeded* while every byte of media was unreachable, because the
|
|
15
15
|
* failing layer answered before the thing being health-checked ran.
|
|
16
|
-
* 4. An app was up, green, and serving a
|
|
16
|
+
* 4. An app was up, green, and serving a catalog a MONTH out of date.
|
|
17
17
|
* 5. Two apps went live answering `no built client at …/public` at `/` while the
|
|
18
18
|
* edge returned 200 out of its cache of the RETIRED deployment. Gate green,
|
|
19
19
|
* deploy green, smoke green, app down; the owner found it, not a check.
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
* built, `patterns.cursedalchemy.com` served one built on 2026-09-12 out of a
|
|
63
63
|
* Worker nobody knew about, and **every automated surface was green for six days.**
|
|
64
64
|
* The app-local implementation that found it — `apps/patterns/scripts/worker-smoke.ts`,
|
|
65
|
-
* `CROSS_CHECKS` — is what this
|
|
65
|
+
* `CROSS_CHECKS` — is what this generalizes, because five more apps are moving
|
|
66
66
|
* behind an edge Worker and a check told ONE NAME is a check about that name.
|
|
67
67
|
*
|
|
68
68
|
* ## What this module will never grow
|
|
@@ -501,7 +501,7 @@ export async function compareDeployedShells(addresses: string[], options: ShellO
|
|
|
501
501
|
url.searchParams.set("cb", cacheBust());
|
|
502
502
|
// 🔴 Re-thrown WITH the address and with the original message intact: which
|
|
503
503
|
// of the addresses could not be reached is the whole diagnosis, and
|
|
504
|
-
// {@link Smoke.dnsHint} still has to
|
|
504
|
+
// {@link Smoke.dnsHint} still has to recognize an ENOTFOUND inside it.
|
|
505
505
|
const response = await send(url.toString(), {
|
|
506
506
|
redirect: "manual",
|
|
507
507
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -929,7 +929,7 @@ export async function steadyHealth<T = Record<string, unknown>>(
|
|
|
929
929
|
*/
|
|
930
930
|
export const HEALTH_READ_MAX_TIME_SEC = 10;
|
|
931
931
|
|
|
932
|
-
/** The init a smoke's `/healthz` read carries — `cursedops/edge-fetch`
|
|
932
|
+
/** The init a smoke's `/healthz` read carries — `cursedops/edge-fetch` honors it; a plain `fetch` ignores it. */
|
|
933
933
|
const healthRead: EdgeRequestInit = { maxTimeSec: HEALTH_READ_MAX_TIME_SEC };
|
|
934
934
|
|
|
935
935
|
/** One question a deploy must answer the NEW way — `null` when it does, else why not. */
|
package/src/workerDeploy.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function parseWorkerEnv(argv: readonly string[]): WorkerEnv {
|
|
|
82
82
|
const value = (joined !== undefined ? joined.slice("--env=".length) : argv[index + 1])?.trim();
|
|
83
83
|
if (value === "production" || value === "stage") return value;
|
|
84
84
|
throw new Error(
|
|
85
|
-
`
|
|
85
|
+
`unrecognized --env ${JSON.stringify(value ?? "")} — production (no flag) or stage. ` +
|
|
86
86
|
"This refuses rather than defaulting: a typo that meant production would report success against the owner's data.",
|
|
87
87
|
);
|
|
88
88
|
}
|
|
@@ -802,6 +802,23 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
802
802
|
return finish({ code: 1, step, detail, commit, dirty });
|
|
803
803
|
};
|
|
804
804
|
const step = (message: string): void => log(`\n▸ ${message}`);
|
|
805
|
+
/**
|
|
806
|
+
* 🔴 The clean-tree refusal, asked again where production READS the tree (forge task 3601). It
|
|
807
|
+
* was asked once, at the start — and the stage leg is minutes long. On 2026-10-23 the ship's
|
|
808
|
+
* station lane passed it clean at 9ab4e676, deployed the stage clean, then built production from
|
|
809
|
+
* a session's half-written edit landed in between: `build.json` stamped `9ab4e676 (dirty)`, and
|
|
810
|
+
* only the asset budget stopped a production that would have reported one commit and run
|
|
811
|
+
* another. So before the production build, and again before anything is uploaded, HEAD must
|
|
812
|
+
* still be the commit stamped and the tree must still be clean.
|
|
813
|
+
*/
|
|
814
|
+
const moved = (during: string): DeployResult | null => {
|
|
815
|
+
if (spec.env !== "production") return null;
|
|
816
|
+
const head = deps.git(["rev-parse", "HEAD"]);
|
|
817
|
+
const now = deps.git(["status", "--porcelain"]);
|
|
818
|
+
if (head === commit && now.length === 0) return null;
|
|
819
|
+
const what = head !== commit ? `HEAD is now ${head.slice(0, 8) || "unreadable"}, not ${commit.slice(0, 8)}` : `it has uncommitted changes:\n${now}`;
|
|
820
|
+
return stop("tree-moved", `the tree moved ${during} — ${what}\nProduction would report ${commit.slice(0, 8)} and run something else, so nothing was deployed to it. Deploy again once the tree is still.`);
|
|
821
|
+
};
|
|
805
822
|
/** What passed under a CPU tail that never attached — decision 6 of the CPU check. */
|
|
806
823
|
const unmeasured: string[] = [];
|
|
807
824
|
|
|
@@ -879,6 +896,8 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
879
896
|
else if (walked !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
|
|
880
897
|
}
|
|
881
898
|
}
|
|
899
|
+
const beforeBuild = moved(spec.stageFirst?.length || spec.stageWalk?.length ? "while the stage deployed" : "before the build");
|
|
900
|
+
if (beforeBuild) return beforeBuild;
|
|
882
901
|
if (spec.build) {
|
|
883
902
|
step("build");
|
|
884
903
|
enter("build");
|
|
@@ -889,6 +908,8 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
889
908
|
const why = refusal();
|
|
890
909
|
if (why) return stop("refusal", `${why} — nothing was deployed.`);
|
|
891
910
|
}
|
|
911
|
+
const beforeUpload = moved("while it built");
|
|
912
|
+
if (beforeUpload) return beforeUpload;
|
|
892
913
|
const envArgs = wranglerEnvArgs(spec.env);
|
|
893
914
|
if (spec.databaseName) {
|
|
894
915
|
const schema = spec.schemaFile ?? "db/schema.sql";
|
|
@@ -1264,7 +1285,7 @@ export interface JudgeOpts {
|
|
|
1264
1285
|
scriptName: string;
|
|
1265
1286
|
/** Paths that are never the static tier. Default {@link DEFAULT_API_PREFIXES}. */
|
|
1266
1287
|
apiPrefixes?: readonly string[];
|
|
1267
|
-
/** Replace the
|
|
1288
|
+
/** Replace the labeler entirely. Default {@link tierRoute}. */
|
|
1268
1289
|
route?: (method: string, path: string) => string | null;
|
|
1269
1290
|
}
|
|
1270
1291
|
|
package/src/workerRollback.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* ## Why this order, and why it is a library entry
|
|
17
17
|
*
|
|
18
18
|
* `patterns` wrote it first (2026-09-19) because the owner required the rollback to be ONE
|
|
19
|
-
* command — a launchd invocation nobody runs monthly and a route id nobody has
|
|
19
|
+
* command — a launchd invocation nobody runs monthly and a route id nobody has memorized are not
|
|
20
20
|
* cheap at the moment somebody needs them — and `collections`, `vault` and `music` copied it.
|
|
21
21
|
* Deleting the route while nothing listens on the Mac points the household's hostname at a dead
|
|
22
22
|
* box; so this starts the origin, WAITS for it to answer, and refuses to touch the route if it
|
|
@@ -184,7 +184,7 @@ export async function runWorkerRollback(spec: WorkerRollbackSpec, argv: readonly
|
|
|
184
184
|
const found = await findWorkerRoute(spec.api, host);
|
|
185
185
|
log(`route ${found.pattern} → ${found.route ? `${found.script} (${found.route})` : "(none — the hostname already falls through to the Mac)"}`);
|
|
186
186
|
|
|
187
|
-
log("\n▸ what D1 holds against the Mac's frozen
|
|
187
|
+
log("\n▸ what D1 holds against the Mac's frozen catalog (the writes a rollback loses):");
|
|
188
188
|
bun(["run", "scripts/worker-import.ts", "--verify-only"], spec.root);
|
|
189
189
|
|
|
190
190
|
if (argv.includes("--check")) {
|
package/src/workerSecrets.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* never noticed an extra — the drift this module ends.
|
|
31
31
|
*
|
|
32
32
|
* 🔴 **A value that is already single-quoted is written as is.** {@link envFileLines}:
|
|
33
|
-
* `collections`' seed
|
|
33
|
+
* `collections`' seed serializer returns `'{"kdf":…}'`, quoting it again wrote an escaped pair
|
|
34
34
|
* around it, `readEnvFile` stripped one layer, and the stage's master lock came up asking to
|
|
35
35
|
* ENROLL a new password (found 2026-09-22 by the stage walk, one step past sign-in).
|
|
36
36
|
*/
|