cursedops 0.11.2 → 0.12.1
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/ship.ts +161 -8
- package/src/workerDeploy.ts +56 -17
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ bun add cursedops
|
|
|
25
25
|
| `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 |
|
|
26
26
|
| `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) |
|
|
27
27
|
| `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) |
|
|
28
|
-
| `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) |
|
|
28
|
+
| `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) |
|
|
29
29
|
| `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) |
|
|
30
30
|
| `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) |
|
|
31
31
|
| `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) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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/ship.ts
CHANGED
|
@@ -38,10 +38,17 @@
|
|
|
38
38
|
* deploys, an outage fix, and a deploy the owner asked for in words (his sentence beats a switch he
|
|
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
|
+
*
|
|
42
|
+
* 🔴 **Under a batch (`$AUTOPILOT_BATCH` set) it is honoured only for the ship task or an incident**
|
|
43
|
+
* ({@link shipNowVerdict}), and only with a reason in `FORGE_SHIP_NOW_WHY`. Until task 2384 any agent
|
|
44
|
+
* could prefix one variable and turn the owner's switch off for itself — the thing
|
|
45
|
+
* `~/.claude/CLAUDE.md` §2 says an agent may never do — and no record said it had. Now every ask,
|
|
46
|
+
* honoured 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 honoured exactly as before, and recorded.
|
|
41
48
|
*/
|
|
42
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
49
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
43
50
|
import { dirname, join } from "node:path";
|
|
44
|
-
import { forgeState } from "cursedops/roots";
|
|
51
|
+
import { forgeCodeRoot, forgeState } from "cursedops/roots";
|
|
45
52
|
|
|
46
53
|
export const SHIP_MODES = ["fast", "each"] as const;
|
|
47
54
|
export type ShipMode = (typeof SHIP_MODES)[number];
|
|
@@ -85,12 +92,152 @@ export function readShipSetting(state: string | null): ShipSetting {
|
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
/** Is `FORGE_SHIP_NOW` set to something that means yes? */
|
|
95
|
+
/** Is `FORGE_SHIP_NOW` set to something that means yes? Asked is not honoured — see {@link shipNowVerdict}. */
|
|
89
96
|
export function shipNowAsked(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
90
97
|
const v = env[SHIP_NOW_VAR]?.trim().toLowerCase();
|
|
91
98
|
return v === "1" || v === "true" || v === "yes";
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/** Why ship-now was asked — required under a batch, recorded always. */
|
|
102
|
+
export const SHIP_NOW_WHY_VAR = "FORGE_SHIP_NOW_WHY";
|
|
103
|
+
|
|
104
|
+
/** The variable the runner sets on its batch agent — present means an agent, not the owner. */
|
|
105
|
+
export const BATCH_VAR = "AUTOPILOT_BATCH";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The `escalation-key:` values a claimed task may carry for its batch to ship now: autopilot's
|
|
109
|
+
* `SHIP_KEY` (`ship.ts`) and the prod watch's `prod-down:<host>` (`prod.ts`). The file shape is
|
|
110
|
+
* the interface, exactly as `ship.json` is — this module never imports the runner.
|
|
111
|
+
*/
|
|
112
|
+
export const SHIP_NOW_KEYS: readonly RegExp[] = [/^ship$/, /^prod-down:\S+$/];
|
|
113
|
+
|
|
114
|
+
export interface ShipNowVerdict {
|
|
115
|
+
/** `FORGE_SHIP_NOW` means yes. */
|
|
116
|
+
asked: boolean;
|
|
117
|
+
/** Ship now: asked, and (outside a batch, or on a claim that earns it). */
|
|
118
|
+
honoured: boolean;
|
|
119
|
+
/** One sentence: what made it honoured or refused. */
|
|
120
|
+
why: string;
|
|
121
|
+
/** `FORGE_SHIP_NOW_WHY`, trimmed — `""` when not given. */
|
|
122
|
+
reason: string;
|
|
123
|
+
batch?: string;
|
|
124
|
+
/** The claimed task file that earned it. */
|
|
125
|
+
task?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface BoardClaim {
|
|
129
|
+
kind?: unknown;
|
|
130
|
+
pid?: unknown;
|
|
131
|
+
task?: unknown;
|
|
132
|
+
batch?: unknown;
|
|
133
|
+
expiresAt?: unknown;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pidAlive(pid: number): boolean {
|
|
137
|
+
try {
|
|
138
|
+
process.kill(pid, 0);
|
|
139
|
+
return true;
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The `escalation-key:` in a task file's front matter, lower-cased, or `null`. */
|
|
146
|
+
export function escalationKey(text: string): string | null {
|
|
147
|
+
const head = /^---\n([\s\S]*?)\n---/.exec(text.replace(/\r\n/g, "\n"));
|
|
148
|
+
const m = head ? /^escalation-key:\s*(.+?)\s*$/im.exec(head[1] ?? "") : null;
|
|
149
|
+
return m?.[1] ? m[1].toLowerCase() : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ShipNowWhere {
|
|
153
|
+
/** `$FORGE_STATE` — the board lives at `state/board/`. */
|
|
154
|
+
state: string | null;
|
|
155
|
+
/** `$FORGE` — claimed task files live at `tasks/In_Progress/`. */
|
|
156
|
+
forge: string | null;
|
|
157
|
+
now?: number;
|
|
158
|
+
isAlive?: (pid: number) => boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Is `FORGE_SHIP_NOW` honoured here? Outside a batch, whenever it is asked: that is the owner's own
|
|
163
|
+
* shell. Under `$AUTOPILOT_BATCH`, only when that batch holds a live `kind: "task"` board claim
|
|
164
|
+
* (`$FORGE_STATE/state/board/*.json`, the pid alive and not expired) on a task in
|
|
165
|
+
* `$FORGE/tasks/In_Progress/` whose `escalation-key:` is one of {@link SHIP_NOW_KEYS}, AND
|
|
166
|
+
* `FORGE_SHIP_NOW_WHY` says why. Anything it cannot read is a refusal — a refused ship-now only
|
|
167
|
+
* makes the deploy later, which is the safe direction.
|
|
168
|
+
*/
|
|
169
|
+
export function shipNowVerdict(where: ShipNowWhere, env: NodeJS.ProcessEnv = process.env): ShipNowVerdict {
|
|
170
|
+
const asked = shipNowAsked(env);
|
|
171
|
+
const reason = env[SHIP_NOW_WHY_VAR]?.trim() ?? "";
|
|
172
|
+
const batch = env[BATCH_VAR]?.trim() || undefined;
|
|
173
|
+
const base = { asked, reason, ...(batch ? { batch } : {}) };
|
|
174
|
+
if (!asked) return { ...base, honoured: false, why: `${SHIP_NOW_VAR} is not set` };
|
|
175
|
+
if (!batch) return { ...base, honoured: true, why: `no ${BATCH_VAR} — the owner's own shell` };
|
|
176
|
+
const refuse = (why: string): ShipNowVerdict => ({ ...base, honoured: false, why });
|
|
177
|
+
if (!reason) return refuse(`under batch ${batch}, ${SHIP_NOW_VAR} needs ${SHIP_NOW_WHY_VAR}="<why this cannot wait for the ship>"`);
|
|
178
|
+
if (!where.state || !where.forge) return refuse(`under batch ${batch}, no generation to read the board from`);
|
|
179
|
+
const board = join(where.state, "state", "board");
|
|
180
|
+
const now = where.now ?? Date.now();
|
|
181
|
+
const isAlive = where.isAlive ?? pidAlive;
|
|
182
|
+
let files: string[] = [];
|
|
183
|
+
try {
|
|
184
|
+
files = readdirSync(board).filter((f) => f.startsWith("task-") && f.endsWith(".json"));
|
|
185
|
+
} catch {
|
|
186
|
+
/* no board → no claim */
|
|
187
|
+
}
|
|
188
|
+
const held: string[] = [];
|
|
189
|
+
for (const f of files) {
|
|
190
|
+
let c: BoardClaim;
|
|
191
|
+
try {
|
|
192
|
+
c = JSON.parse(readFileSync(join(board, f), "utf8")) as BoardClaim;
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (c.kind !== "task" || c.batch !== batch || typeof c.task !== "string" || typeof c.pid !== "number") continue;
|
|
197
|
+
if (typeof c.expiresAt === "string" && Date.parse(c.expiresAt) < now) continue;
|
|
198
|
+
if (!isAlive(c.pid)) continue;
|
|
199
|
+
held.push(c.task);
|
|
200
|
+
let key: string | null = null;
|
|
201
|
+
try {
|
|
202
|
+
key = escalationKey(readFileSync(join(where.forge, "tasks", "In_Progress", c.task), "utf8"));
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
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
|
+
}
|
|
208
|
+
return refuse(
|
|
209
|
+
`batch ${batch} holds ${held.length ? held.join(", ") : "no live task claim"} — ${SHIP_NOW_VAR} is honoured under a batch only for the ship task or a prod incident (escalation-key: ship | prod-down:<host>)`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The ledger line every ship-now ask leaves — honoured or refused. */
|
|
214
|
+
export interface ShipNowEventLine {
|
|
215
|
+
at: string;
|
|
216
|
+
kind: "ship-now";
|
|
217
|
+
/** The deploy or publish it was asked for. */
|
|
218
|
+
what: string;
|
|
219
|
+
ok: boolean;
|
|
220
|
+
reason: string;
|
|
221
|
+
detail: string;
|
|
222
|
+
batch?: string;
|
|
223
|
+
task?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Record one ship-now ask in the ledger. Never throws. */
|
|
227
|
+
export function recordShipNow(state: string | null, what: string, v: ShipNowVerdict, at: Date = new Date()): boolean {
|
|
228
|
+
const line: ShipNowEventLine = {
|
|
229
|
+
at: at.toISOString(),
|
|
230
|
+
kind: "ship-now",
|
|
231
|
+
what,
|
|
232
|
+
ok: v.honoured,
|
|
233
|
+
reason: v.reason,
|
|
234
|
+
detail: v.why,
|
|
235
|
+
...(v.batch ? { batch: v.batch } : {}),
|
|
236
|
+
...(v.task ? { task: v.task } : {}),
|
|
237
|
+
};
|
|
238
|
+
return appendLedger(state, line);
|
|
239
|
+
}
|
|
240
|
+
|
|
94
241
|
/**
|
|
95
242
|
* The sentence a production deploy prints INSTEAD of deploying, or `null` to deploy now.
|
|
96
243
|
*
|
|
@@ -98,16 +245,21 @@ export function shipNowAsked(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
98
245
|
* and the runner's ship task — filed when the queue empties or the oldest owed commit is too old —
|
|
99
246
|
* deploys every app that is behind, once. `autopilot ship` lists what is owed.
|
|
100
247
|
*/
|
|
101
|
-
export function deferral(what: string, setting: ShipSetting, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
102
|
-
if (setting.mode !== "fast"
|
|
248
|
+
export function deferral(what: string, setting: ShipSetting, env: NodeJS.ProcessEnv = process.env, where: ShipNowWhere = { state: null, forge: null }): string | null {
|
|
249
|
+
if (setting.mode !== "fast") return null;
|
|
250
|
+
const now = shipNowVerdict(where, env);
|
|
251
|
+
if (now.asked) recordShipNow(where.state, what, now);
|
|
252
|
+
if (now.honoured) return null;
|
|
253
|
+
const refused = now.asked ? [`🔴 ${SHIP_NOW_VAR} REFUSED: ${now.why}.`] : [];
|
|
103
254
|
const by = setting.setBy === "default" ? "the default" : `set by ${setting.setBy}${setting.at ? ` at ${setting.at}` : ""}`;
|
|
104
255
|
return [
|
|
256
|
+
...refused,
|
|
105
257
|
`🚢 ${what}: DEFERRED — fast shipping is on (${by}${setting.unreadable ? `; ship.json unreadable: ${setting.unreadable}` : ""}).`,
|
|
106
258
|
" Nothing was deployed, and that is the intended result, not a failure: this app is now owed to",
|
|
107
259
|
" the ship, which deploys every app behind its HEAD once, when the queue empties. Carry on with",
|
|
108
260
|
" the next task — file this one as done once it is landed and gated, saying the deploy is owed.",
|
|
109
|
-
` \`autopilot ship\` lists what is owed. To deploy now anyway (
|
|
110
|
-
` the owner
|
|
261
|
+
` \`autopilot ship\` lists what is owed. To deploy now anyway (the ship task or a prod incident`,
|
|
262
|
+
` your batch holds, or the owner in his own shell): ${SHIP_NOW_VAR}=1 ${SHIP_NOW_WHY_VAR}="<why>" <the same command>.`,
|
|
111
263
|
].join("\n");
|
|
112
264
|
}
|
|
113
265
|
|
|
@@ -118,7 +270,8 @@ export function deferral(what: string, setting: ShipSetting, env: NodeJS.Process
|
|
|
118
270
|
* if (deferred) { console.log(deferred); process.exit(0); }
|
|
119
271
|
*/
|
|
120
272
|
export function shipDeferral(cwd: string, what: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
121
|
-
|
|
273
|
+
const state = forgeState(cwd, env);
|
|
274
|
+
return deferral(what, readShipSetting(state), env, { state, forge: forgeCodeRoot(cwd, env) });
|
|
122
275
|
}
|
|
123
276
|
|
|
124
277
|
/** The ledger line a deferred production deploy leaves — what `autopilot ship` counts. */
|
package/src/workerDeploy.ts
CHANGED
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
import { spawn, spawnSync } from "node:child_process";
|
|
56
56
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
57
57
|
import { join } from "node:path";
|
|
58
|
-
import { forgeState } from "cursedops/roots";
|
|
59
|
-
import { appendLedger, deferral, readShipSetting, type ShipEventLine, type ShipSetting } from "cursedops/ship";
|
|
58
|
+
import { forgeCodeRoot, forgeState } from "cursedops/roots";
|
|
59
|
+
import { appendLedger, deferral, readShipSetting, type ShipEventLine, type ShipNowWhere, type ShipSetting } from "cursedops/ship";
|
|
60
60
|
|
|
61
61
|
/** The two deployments every Worker app here has. */
|
|
62
62
|
export type WorkerEnv = "production" | "stage";
|
|
@@ -327,6 +327,12 @@ export interface DeployDeps {
|
|
|
327
327
|
ship?: () => ShipSetting;
|
|
328
328
|
/** Where `FORGE_SHIP_NOW` is read from. Default `process.env`. */
|
|
329
329
|
env?: NodeJS.ProcessEnv;
|
|
330
|
+
/**
|
|
331
|
+
* The generation roots `FORGE_SHIP_NOW` is judged against under a batch — the board, the claimed
|
|
332
|
+
* task, and the ledger its `ship-now` row goes to (`shipNowVerdict` in `./ship.ts`). Absent, a batch's
|
|
333
|
+
* ship-now is refused and nothing is recorded.
|
|
334
|
+
*/
|
|
335
|
+
shipWhere?: ShipNowWhere;
|
|
330
336
|
/** Where a deferred production deploy's `ship` line goes. {@link workerDeployDeps} appends it to the ledger. */
|
|
331
337
|
recordShip?: (line: ShipEventLine) => void;
|
|
332
338
|
}
|
|
@@ -408,6 +414,7 @@ export function workerDeployDeps(cwd: string): DeployDeps {
|
|
|
408
414
|
recordDeployEvent(forgeState(cwd), line);
|
|
409
415
|
},
|
|
410
416
|
ship: () => readShipSetting(forgeState(cwd)),
|
|
417
|
+
shipWhere: { state: forgeState(cwd), forge: forgeCodeRoot(cwd) },
|
|
411
418
|
recordShip: (line) => {
|
|
412
419
|
appendLedger(forgeState(cwd), line);
|
|
413
420
|
},
|
|
@@ -523,6 +530,34 @@ export function schemaApplyArgv(databaseName: string, commands: readonly string[
|
|
|
523
530
|
return commands.map((command) => ["bunx", "wrangler", "d1", "execute", databaseName, "--remote", "--command", command, "--json", "-y", ...envArgs]);
|
|
524
531
|
}
|
|
525
532
|
|
|
533
|
+
/**
|
|
534
|
+
* The reason a non-zero `wrangler … --json` gave, or `null` when it printed no error document.
|
|
535
|
+
*
|
|
536
|
+
* With `--json`, wrangler writes its failure to STDOUT as `{"error":{"text":…,"notes":[{"text":…}],
|
|
537
|
+
* "kind":"error","name":"APIError","code":7403}}` (task 2427: vault's production deploy stopped on
|
|
538
|
+
* 2026-10-01 with only "wrangler exited 1", and the ship batch rebuilt the call by hand to learn it
|
|
539
|
+
* was a transient 7403). `api` is whether it is a Cloudflare API error — worth one retry, since the
|
|
540
|
+
* schema is idempotent and the same commands applied minutes later.
|
|
541
|
+
*/
|
|
542
|
+
export function wranglerError(stdout: string): { reason: string; api: boolean } | null {
|
|
543
|
+
const start = stdout.search(/\{\s*"error"\s*:/);
|
|
544
|
+
if (start < 0) return null;
|
|
545
|
+
let parsed: unknown;
|
|
546
|
+
try {
|
|
547
|
+
parsed = JSON.parse(stdout.slice(start, stdout.lastIndexOf("}") + 1));
|
|
548
|
+
} catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
const error = (parsed as { error?: unknown } | null)?.error;
|
|
552
|
+
if (!error || typeof error !== "object") return null;
|
|
553
|
+
const { text, notes, name, kind } = error as { text?: unknown; notes?: unknown; name?: unknown; kind?: unknown };
|
|
554
|
+
const parts = [typeof text === "string" ? text.trim() : ""];
|
|
555
|
+
if (Array.isArray(notes)) for (const note of notes) if (typeof (note as { text?: unknown })?.text === "string") parts.push((note as { text: string }).text.trim());
|
|
556
|
+
const reason = parts.filter(Boolean).join(" — ");
|
|
557
|
+
if (!reason) return null;
|
|
558
|
+
return { reason, api: name === "APIError" || (kind === "error" && /cloudflare api/i.test(reason)) };
|
|
559
|
+
}
|
|
560
|
+
|
|
526
561
|
/**
|
|
527
562
|
* Did one schema chunk APPLY? Its exit code alone does not say.
|
|
528
563
|
*
|
|
@@ -537,22 +572,26 @@ export function schemaApplyArgv(databaseName: string, commands: readonly string[
|
|
|
537
572
|
* one result per statement, every one `success: true`. Anything else — no JSON, an empty array,
|
|
538
573
|
* a failed result — is not applied. Pure, so its failure path is tested without wrangler.
|
|
539
574
|
*/
|
|
540
|
-
export function judgeSchemaApply(code: number, stdout: string): { applied: boolean; line: string } {
|
|
541
|
-
if (code !== 0)
|
|
575
|
+
export function judgeSchemaApply(code: number, stdout: string): { applied: boolean; line: string; retry: boolean } {
|
|
576
|
+
if (code !== 0) {
|
|
577
|
+
const failure = wranglerError(stdout);
|
|
578
|
+
if (!failure) return { applied: false, line: `wrangler exited ${code}`, retry: false };
|
|
579
|
+
return { applied: false, line: `wrangler exited ${code}: ${failure.reason}`, retry: failure.api };
|
|
580
|
+
}
|
|
542
581
|
// the first `[` that opens a JSON array, never the `[ERROR]`/`[WARNING]` of a banner — as `wranglerRows`
|
|
543
582
|
const start = stdout.search(/\[\s*[{\]]/);
|
|
544
583
|
const banner = stdout.trim().slice(0, 160).replace(/\s+/g, " ") || "(nothing)";
|
|
545
|
-
if (start < 0) return { applied: false, line: `wrangler exited 0 and printed no JSON result — it did not run the query. It printed: ${banner}` };
|
|
584
|
+
if (start < 0) return { applied: false, retry: true, line: `wrangler exited 0 and printed no JSON result — it did not run the query. It printed: ${banner}` };
|
|
546
585
|
let parsed: unknown;
|
|
547
586
|
try {
|
|
548
587
|
parsed = JSON.parse(stdout.slice(start));
|
|
549
588
|
} catch (cause) {
|
|
550
|
-
return { applied: false, line: `wrangler's JSON result did not parse (${(cause as Error).message})` };
|
|
589
|
+
return { applied: false, retry: true, line: `wrangler's JSON result did not parse (${(cause as Error).message})` };
|
|
551
590
|
}
|
|
552
|
-
if (!Array.isArray(parsed) || parsed.length === 0) return { applied: false, line: "wrangler printed an empty result — no statement ran" };
|
|
591
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return { applied: false, retry: true, line: "wrangler printed an empty result — no statement ran" };
|
|
553
592
|
const failed = parsed.filter((result) => (result as { success?: unknown } | null)?.success !== true);
|
|
554
|
-
if (failed.length > 0) return { applied: false, line: `${failed.length} of ${parsed.length} result(s) did not report success` };
|
|
555
|
-
return { applied: true, line: `applied — ${parsed.length} result(s), every one success` };
|
|
593
|
+
if (failed.length > 0) return { applied: false, retry: true, line: `${failed.length} of ${parsed.length} result(s) did not report success` };
|
|
594
|
+
return { applied: true, retry: false, line: `applied — ${parsed.length} result(s), every one success` };
|
|
556
595
|
}
|
|
557
596
|
|
|
558
597
|
/**
|
|
@@ -699,7 +738,7 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
699
738
|
// runner's ship instead — asked before anything installs or builds, so deferring costs nothing.
|
|
700
739
|
// Its `ship` line, not a `deploy` one: a deferral is not a deploy, and would drag every median.
|
|
701
740
|
if (spec.env === "production" && deps.ship) {
|
|
702
|
-
const deferred = deferral(`${spec.app} production deploy`, deps.ship(), deps.env ?? process.env);
|
|
741
|
+
const deferred = deferral(`${spec.app} production deploy`, deps.ship(), deps.env ?? process.env, deps.shipWhere);
|
|
703
742
|
if (deferred) {
|
|
704
743
|
log(`\n${deferred}`);
|
|
705
744
|
try {
|
|
@@ -785,16 +824,16 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
785
824
|
return stop("schema", `${schema} could not be read as statements (${(cause as Error).message}) — nothing was deployed.`);
|
|
786
825
|
}
|
|
787
826
|
for (const argv of schemaApplyArgv(spec.databaseName, commands, envArgs)) {
|
|
788
|
-
// a silent exit 0
|
|
789
|
-
// failure
|
|
790
|
-
const attempt = ():
|
|
827
|
+
// a silent exit 0 and a Cloudflare API error are intermittent and the schema is idempotent,
|
|
828
|
+
// so ONE retry; any other failure is not retried — it would fail the same way
|
|
829
|
+
const attempt = (): ReturnType<typeof judgeSchemaApply> => {
|
|
791
830
|
const { code, stdout } = deps.capture(argv, spec.credential);
|
|
792
|
-
return
|
|
831
|
+
return judgeSchemaApply(code, stdout);
|
|
793
832
|
};
|
|
794
|
-
let
|
|
795
|
-
if (!verdict.applied &&
|
|
833
|
+
let verdict = attempt();
|
|
834
|
+
if (!verdict.applied && verdict.retry) {
|
|
796
835
|
error(` 🟡 ${verdict.line} — retrying once`);
|
|
797
|
-
|
|
836
|
+
verdict = attempt();
|
|
798
837
|
}
|
|
799
838
|
if (!verdict.applied) return stop("schema", `the schema did not apply (${verdict.line}) — nothing was deployed.`);
|
|
800
839
|
log(` ${verdict.line}`);
|