cursedops 0.10.25 → 0.11.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 +8 -0
- package/package.json +7 -1
- package/src/relayLink.ts +1 -1
- package/src/relayStage.ts +34 -2
- package/src/ship.ts +149 -0
- package/src/workerDeploy.ts +132 -15
package/README.md
CHANGED
|
@@ -25,6 +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
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) |
|
|
29
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) |
|
|
30
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) |
|
|
@@ -95,6 +96,13 @@ Workers — so the choice was two more copies of the scanner or one here. Copies
|
|
|
95
96
|
It is mechanism only: each app passes its own root and directories and keeps its own
|
|
96
97
|
"not vacuous" assertion about which files the scan must reach.
|
|
97
98
|
|
|
99
|
+
🔴 **`ship` is the fourth recorded exception: no app wrote it, because it is the switch the
|
|
100
|
+
deploys themselves obey.** The owner's ruling (2026-09-28) was that agents run the same commands
|
|
101
|
+
whatever the switch says and the commands deal with it — so it has to live in the one seam every
|
|
102
|
+
production deploy passes through, which is `runWorkerDeploy` here and `deploy-tree`'s callers.
|
|
103
|
+
It reads a file the runner owns and knows no app: rule 2 holds, and the header of `src/ship.ts`
|
|
104
|
+
says why every failure reads as `fast`.
|
|
105
|
+
|
|
98
106
|
### What was deliberately left in the apps
|
|
99
107
|
|
|
100
108
|
The comparison that produced this library found three areas that are genuinely
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -122,6 +122,12 @@
|
|
|
122
122
|
"source": "./src/deployTree.ts",
|
|
123
123
|
"import": "./src/deployTree.ts"
|
|
124
124
|
},
|
|
125
|
+
"./ship": {
|
|
126
|
+
"types": "./src/ship.ts",
|
|
127
|
+
"bun": "./src/ship.ts",
|
|
128
|
+
"source": "./src/ship.ts",
|
|
129
|
+
"import": "./src/ship.ts"
|
|
130
|
+
},
|
|
125
131
|
"./relay": {
|
|
126
132
|
"types": "./src/relay.ts",
|
|
127
133
|
"bun": "./src/relay.ts",
|
package/src/relayLink.ts
CHANGED
|
@@ -318,7 +318,7 @@ export async function rotateLinkKey(spec: RotateSpec): Promise<{ ok: true; file:
|
|
|
318
318
|
const put =
|
|
319
319
|
spec.putSecret ??
|
|
320
320
|
((name: string, value: string) => {
|
|
321
|
-
const r = spawnSync("bunx", ["wrangler
|
|
321
|
+
const r = spawnSync("bunx", ["wrangler", "secret", "put", name, ...(spec.env ? ["--env", spec.env] : [])], {
|
|
322
322
|
cwd: spec.appDir,
|
|
323
323
|
input: value,
|
|
324
324
|
env: { ...process.env, ...spec.credential },
|
package/src/relayStage.ts
CHANGED
|
@@ -54,6 +54,37 @@ export async function until(ms: number, ok: () => Promise<boolean>, clock: Clock
|
|
|
54
54
|
return false;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* True once `isLinked` has said yes on every ask for a whole `windowMs` (default 15 s) — false if that
|
|
59
|
+
* never happens within `maxMs` (default 2 min). A throw counts as no, and restarts the window.
|
|
60
|
+
*
|
|
61
|
+
* 🔴 Why not a sleep and then {@link until}: a digest put is a new Worker version that reaches the
|
|
62
|
+
* edge in its own time, and `until` passes the first instant the link is up. Measured 2026-09-28 on
|
|
63
|
+
* station, beside three other deploys: the walk slept its fixed 10 s, the link came up, then flapped
|
|
64
|
+
* (`1002 Expected 101` — an edge still on the old digest refusing the new key) and the first
|
|
65
|
+
* signed-in read got the offline 503. `oldKeyRefused` already records that 15 s was not always
|
|
66
|
+
* enough. So the walk waits for a link that STAYS up, and how long that took is printed.
|
|
67
|
+
*/
|
|
68
|
+
export async function linkedSteadily(spec: {
|
|
69
|
+
isLinked: () => Promise<boolean>;
|
|
70
|
+
windowMs?: number;
|
|
71
|
+
maxMs?: number;
|
|
72
|
+
clock?: Clock;
|
|
73
|
+
}): Promise<{ steady: boolean; seconds: number }> {
|
|
74
|
+
const clock = spec.clock ?? realClock;
|
|
75
|
+
const window = spec.windowMs ?? 15_000;
|
|
76
|
+
const started = clock.now();
|
|
77
|
+
let upSince: number | null = null;
|
|
78
|
+
while (clock.now() - started < (spec.maxMs ?? 120_000)) {
|
|
79
|
+
const up = await spec.isLinked().catch(() => false);
|
|
80
|
+
if (!up) upSince = null;
|
|
81
|
+
else if (upSince === null) upSince = clock.now();
|
|
82
|
+
else if (clock.now() - upSince >= window) return { steady: true, seconds: Math.round((clock.now() - started) / 1000) };
|
|
83
|
+
await clock.sleep(1_000);
|
|
84
|
+
}
|
|
85
|
+
return { steady: false, seconds: Math.round((clock.now() - started) / 1000) };
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
/** The Access service token, as the pair a Mac end's two variables and a roster socket carry. */
|
|
58
89
|
export function accessPair(headers: Record<string, string>): { id: string; secret: string } {
|
|
59
90
|
return { id: headers["CF-Access-Client-Id"] ?? "", secret: headers["CF-Access-Client-Secret"] ?? "" };
|
|
@@ -142,10 +173,11 @@ export interface DigestPut {
|
|
|
142
173
|
/**
|
|
143
174
|
* Put `key`'s digest on the STAGE Worker (`--env stage`, always — this module has no production
|
|
144
175
|
* path). A per-run key for the walk, and again for the rotation leg. `ok: false` carries wrangler's
|
|
145
|
-
* stderr. A secret put is a new version
|
|
176
|
+
* stderr. A secret put is a new version that reaches the edge in its own time: the caller waits for
|
|
177
|
+
* a link that stays up ({@link linkedSteadily}), never a fixed sleep.
|
|
146
178
|
*/
|
|
147
179
|
export async function putStageLinkDigest(put: DigestPut): Promise<{ ok: boolean; stderr: string }> {
|
|
148
|
-
const done = (put.run ?? realRun)(["bunx", "wrangler
|
|
180
|
+
const done = (put.run ?? realRun)(["bunx", "wrangler", "secret", "put", put.secretName, "--env", "stage"], {
|
|
149
181
|
cwd: put.cwd,
|
|
150
182
|
input: await sha256Hex(put.key),
|
|
151
183
|
env: put.credential,
|
package/src/ship.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🚢 FAST SHIPPING — the owner's switch for when a production deploy happens: after each task
|
|
3
|
+
* (`each`), or once, at the end of a run of tasks (`fast`, the default).
|
|
4
|
+
*
|
|
5
|
+
* The owner's ruling, 2026-09-28:
|
|
6
|
+
*
|
|
7
|
+
* > *"While on, agents do not publish or do long running things at all if they block task
|
|
8
|
+
* > completions. Instead they keep doing tasks until the end and do a final test run and if
|
|
9
|
+
* > everything is perfect then they publish … While the toggle is off, things work like today …
|
|
10
|
+
* > I will generally want it on because a few hours of a broken deploy is generally not a deal
|
|
11
|
+
* > breaker if we can get a big increase in tasks completed."*
|
|
12
|
+
*
|
|
13
|
+
* and, on how agents should meet it: *"the same commands no matter what the toggle is set to and
|
|
14
|
+
* the automation commands deal with the changes automatically."* So this module is read by the
|
|
15
|
+
* COMMANDS — `runWorkerDeploy` and every Mac-side `bun run deploy` — not by an agent deciding
|
|
16
|
+
* whether to call them. Measured 2026-09-28 (`autopilot phases`): deploys ~110 min/day, publishes
|
|
17
|
+
* 45–120 min/day and the waits on them ~115 min/day had become the slowest things a task does,
|
|
18
|
+
* and a batch that touches one app three times deployed it three times.
|
|
19
|
+
*
|
|
20
|
+
* ## The contract — one small file the runner owns
|
|
21
|
+
*
|
|
22
|
+
* `$FORGE_STATE/state/autopilot/ship.json`, written only by `autopilot ship-mode` (the station
|
|
23
|
+
* console's switch runs it). This module only READS it, the same way `recordDeployEvent` writes the
|
|
24
|
+
* runner's `events.jsonl` without importing the runner: the file shape is the interface.
|
|
25
|
+
*
|
|
26
|
+
* { "mode": "fast" | "each", "setBy": "owner", "at": "<ISO>", "reason"?: "…" }
|
|
27
|
+
*
|
|
28
|
+
* - **Absent → `fast`.** The owner asked for it on by default.
|
|
29
|
+
* - **Unreadable or a mode it does not know → `fast`**, the same answer as absent. Fast never puts
|
|
30
|
+
* anything unproven on a production URL — it only makes the deploy LATER — so there is no
|
|
31
|
+
* direction of a bad read that ships something it should not.
|
|
32
|
+
* - **No generation at all → `each`.** A checkout outside any `forge.env` has no runner to ship
|
|
33
|
+
* for it later, so deferring there would defer for ever.
|
|
34
|
+
*
|
|
35
|
+
* ## The overrides, both directions
|
|
36
|
+
*
|
|
37
|
+
* `FORGE_SHIP_NOW=1` in the environment ships now whatever the switch says: the ship task's own
|
|
38
|
+
* deploys, an outage fix, and a deploy the owner asked for in words (his sentence beats a switch he
|
|
39
|
+
* flipped yesterday — the precedence the retired `fastMode.ts` settled on 2026-08-13). A stage
|
|
40
|
+
* deploy is never deferred: it IS the fast way to see a change on a real edge.
|
|
41
|
+
*/
|
|
42
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
43
|
+
import { dirname, join } from "node:path";
|
|
44
|
+
import { forgeState } from "cursedops/roots";
|
|
45
|
+
|
|
46
|
+
export const SHIP_MODES = ["fast", "each"] as const;
|
|
47
|
+
export type ShipMode = (typeof SHIP_MODES)[number];
|
|
48
|
+
|
|
49
|
+
/** The mode when the file is absent or unreadable — the owner's ruling, see the header. */
|
|
50
|
+
export const DEFAULT_SHIP_MODE: ShipMode = "fast";
|
|
51
|
+
|
|
52
|
+
/** `$FORGE_STATE/<this>` is the switch. The runner's `paths.ts` names the same file. */
|
|
53
|
+
export const SHIP_FILE = join("state", "autopilot", "ship.json");
|
|
54
|
+
|
|
55
|
+
/** The variable that ships now regardless of the switch. */
|
|
56
|
+
export const SHIP_NOW_VAR = "FORGE_SHIP_NOW";
|
|
57
|
+
|
|
58
|
+
export interface ShipSetting {
|
|
59
|
+
mode: ShipMode;
|
|
60
|
+
/** Who set it — `default` when no file says. */
|
|
61
|
+
setBy: string;
|
|
62
|
+
/** ISO, or `null` when nothing was ever written. */
|
|
63
|
+
at: string | null;
|
|
64
|
+
/** Why it reads as it does when the file could not be believed — shown, never thrown. */
|
|
65
|
+
unreadable?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The switch as the file says it, with every failure read as {@link DEFAULT_SHIP_MODE}. */
|
|
69
|
+
export function readShipSetting(state: string | null): ShipSetting {
|
|
70
|
+
if (state === null) return { mode: "each", setBy: "no generation", at: null };
|
|
71
|
+
const file = join(state, SHIP_FILE);
|
|
72
|
+
if (!existsSync(file)) return { mode: DEFAULT_SHIP_MODE, setBy: "default", at: null };
|
|
73
|
+
try {
|
|
74
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as { mode?: unknown; setBy?: unknown; at?: unknown };
|
|
75
|
+
if (!SHIP_MODES.includes(raw.mode as ShipMode)) {
|
|
76
|
+
return { mode: DEFAULT_SHIP_MODE, setBy: "default", at: null, unreadable: `mode ${JSON.stringify(raw.mode)} is not one of ${SHIP_MODES.join(", ")}` };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
mode: raw.mode as ShipMode,
|
|
80
|
+
setBy: typeof raw.setBy === "string" && raw.setBy ? raw.setBy : "unknown",
|
|
81
|
+
at: typeof raw.at === "string" ? raw.at : null,
|
|
82
|
+
};
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
return { mode: DEFAULT_SHIP_MODE, setBy: "default", at: null, unreadable: (cause as Error).message };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Is `FORGE_SHIP_NOW` set to something that means yes? */
|
|
89
|
+
export function shipNowAsked(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
90
|
+
const v = env[SHIP_NOW_VAR]?.trim().toLowerCase();
|
|
91
|
+
return v === "1" || v === "true" || v === "yes";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The sentence a production deploy prints INSTEAD of deploying, or `null` to deploy now.
|
|
96
|
+
*
|
|
97
|
+
* The deploy then exits 0: it did what the mode asks of it. The app stays behind prod on purpose,
|
|
98
|
+
* and the runner's ship task — filed when the queue empties or the oldest owed commit is too old —
|
|
99
|
+
* deploys every app that is behind, once. `autopilot ship` lists what is owed.
|
|
100
|
+
*/
|
|
101
|
+
export function deferral(what: string, setting: ShipSetting, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
102
|
+
if (setting.mode !== "fast" || shipNowAsked(env)) return null;
|
|
103
|
+
const by = setting.setBy === "default" ? "the default" : `set by ${setting.setBy}${setting.at ? ` at ${setting.at}` : ""}`;
|
|
104
|
+
return [
|
|
105
|
+
`🚢 ${what}: DEFERRED — fast shipping is on (${by}${setting.unreadable ? `; ship.json unreadable: ${setting.unreadable}` : ""}).`,
|
|
106
|
+
" Nothing was deployed, and that is the intended result, not a failure: this app is now owed to",
|
|
107
|
+
" the ship, which deploys every app behind its HEAD once, when the queue empties. Carry on with",
|
|
108
|
+
" 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 (an outage fix, the ship itself, or`,
|
|
110
|
+
` the owner asked for it): ${SHIP_NOW_VAR}=1 <the same command>.`,
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* {@link deferral} for the checkout at `cwd` — what a Mac-side `bun run deploy` asks first:
|
|
116
|
+
*
|
|
117
|
+
* const deferred = shipDeferral(appRoot(), "roms production deploy");
|
|
118
|
+
* if (deferred) { console.log(deferred); process.exit(0); }
|
|
119
|
+
*/
|
|
120
|
+
export function shipDeferral(cwd: string, what: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
121
|
+
return deferral(what, readShipSetting(forgeState(cwd, env)), env);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The ledger line a deferred production deploy leaves — what `autopilot ship` counts. */
|
|
125
|
+
export interface ShipEventLine {
|
|
126
|
+
at: string;
|
|
127
|
+
kind: "ship";
|
|
128
|
+
what: "deferred";
|
|
129
|
+
repo: string;
|
|
130
|
+
env: string;
|
|
131
|
+
commit: string;
|
|
132
|
+
dirty: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Append one JSON line to the generation's ledger (`$FORGE_STATE/state/autopilot/events.jsonl`).
|
|
137
|
+
* Never throws; `false` when it did not write — a ledger that can fail a deploy is worse than none.
|
|
138
|
+
*/
|
|
139
|
+
export function appendLedger(state: string | null, line: object): boolean {
|
|
140
|
+
if (!state) return false;
|
|
141
|
+
try {
|
|
142
|
+
const file = join(state, "state", "autopilot", "events.jsonl");
|
|
143
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
144
|
+
appendFileSync(file, `${JSON.stringify(line)}\n`);
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/workerDeploy.ts
CHANGED
|
@@ -53,9 +53,10 @@
|
|
|
53
53
|
* refusal without wrangler, git or a network.
|
|
54
54
|
*/
|
|
55
55
|
import { spawn, spawnSync } from "node:child_process";
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
56
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
57
|
+
import { join } from "node:path";
|
|
58
58
|
import { forgeState } from "cursedops/roots";
|
|
59
|
+
import { appendLedger, deferral, readShipSetting, type ShipEventLine, type ShipSetting } from "cursedops/ship";
|
|
59
60
|
|
|
60
61
|
/** The two deployments every Worker app here has. */
|
|
61
62
|
export type WorkerEnv = "production" | "stage";
|
|
@@ -308,12 +309,26 @@ export interface DeployDeps {
|
|
|
308
309
|
error?: (line: string) => void;
|
|
309
310
|
/** The clock the legs are timed on. Default `Date.now`. */
|
|
310
311
|
now?: () => number;
|
|
312
|
+
/** Whether a path in the checkout exists — the local wrangler. Default `existsSync` against the process's cwd. */
|
|
313
|
+
exists?: (path: string) => boolean;
|
|
314
|
+
/** The unpublished library builds in `node_modules` ({@link previewMarks}). Default: read the process's cwd. */
|
|
315
|
+
previews?: () => PreviewMark[];
|
|
311
316
|
/**
|
|
312
317
|
* Where the run's {@link DeployEventLine} goes, on EVERY exit — refused, red or green.
|
|
313
318
|
* {@link workerDeployDeps} appends it to the generation's ledger; absent, nothing is recorded.
|
|
314
319
|
* A throw here is swallowed: a ledger that can fail a deploy is worse than none.
|
|
315
320
|
*/
|
|
316
321
|
record?: (line: DeployEventLine) => void;
|
|
322
|
+
/**
|
|
323
|
+
* The owner's fast-shipping switch ({@link readShipSetting}). {@link workerDeployDeps} reads the
|
|
324
|
+
* generation's `ship.json`; absent here, the deploy behaves as it always did — so a suite that
|
|
325
|
+
* builds its own deps never reads this Mac's switch.
|
|
326
|
+
*/
|
|
327
|
+
ship?: () => ShipSetting;
|
|
328
|
+
/** Where `FORGE_SHIP_NOW` is read from. Default `process.env`. */
|
|
329
|
+
env?: NodeJS.ProcessEnv;
|
|
330
|
+
/** Where a deferred production deploy's `ship` line goes. {@link workerDeployDeps} appends it to the ledger. */
|
|
331
|
+
recordShip?: (line: ShipEventLine) => void;
|
|
317
332
|
}
|
|
318
333
|
|
|
319
334
|
/**
|
|
@@ -349,15 +364,7 @@ export interface DeployEventLine {
|
|
|
349
364
|
|
|
350
365
|
/** Append one line to the ledger under a state root. Never throws; `false` when it did not write. */
|
|
351
366
|
export function recordDeployEvent(state: string | null, line: DeployEventLine): boolean {
|
|
352
|
-
|
|
353
|
-
try {
|
|
354
|
-
const file = join(state, "state", "autopilot", "events.jsonl");
|
|
355
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
356
|
-
appendFileSync(file, `${JSON.stringify(line)}\n`);
|
|
357
|
-
return true;
|
|
358
|
-
} catch {
|
|
359
|
-
return false;
|
|
360
|
-
}
|
|
367
|
+
return appendLedger(state, line);
|
|
361
368
|
}
|
|
362
369
|
|
|
363
370
|
export interface DeployResult {
|
|
@@ -400,6 +407,10 @@ export function workerDeployDeps(cwd: string): DeployDeps {
|
|
|
400
407
|
record: (line) => {
|
|
401
408
|
recordDeployEvent(forgeState(cwd), line);
|
|
402
409
|
},
|
|
410
|
+
ship: () => readShipSetting(forgeState(cwd)),
|
|
411
|
+
recordShip: (line) => {
|
|
412
|
+
appendLedger(forgeState(cwd), line);
|
|
413
|
+
},
|
|
403
414
|
};
|
|
404
415
|
}
|
|
405
416
|
|
|
@@ -548,6 +559,90 @@ export function judgeSchemaApply(code: number, stdout: string): { applied: boole
|
|
|
548
559
|
* Run the sequence in the header's order, stopping at the first red with a sentence. Never
|
|
549
560
|
* exits — the caller does, with {@link DeployResult.code}.
|
|
550
561
|
*/
|
|
562
|
+
/** Where an app's OWN wrangler is, once `bun install` has run. */
|
|
563
|
+
export const LOCAL_WRANGLER = "node_modules/.bin/wrangler";
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Refuse a deploy whose app has no wrangler of its own — `null` when it has one.
|
|
567
|
+
*
|
|
568
|
+
* 🔴 Why (task 2260): with no local install, `bunx wrangler` resolves into ONE directory shared by
|
|
569
|
+
* every process on the Mac (`$TMPDIR/bunx-<uid>-wrangler@latest`), and bunx refreshes it in place.
|
|
570
|
+
* On 2026-09-28 it was rewritten at 12:17 while four deploys ran, and station's stage walk died on
|
|
571
|
+
* `Cannot find module './lib/websocket-server'` — a half-extracted `ws`. A version spec
|
|
572
|
+
* (`wrangler@4`) is worse: bunx then skips a local install even when there is one. So every app
|
|
573
|
+
* declares `wrangler` in its own `devDependencies`, locked by its own `bun.lock`, and nothing here
|
|
574
|
+
* passes bunx a version (`workerDeploy.test.ts` scans `src/` for one).
|
|
575
|
+
*/
|
|
576
|
+
export function localWranglerRefusal(exists: (path: string) => boolean): string | null {
|
|
577
|
+
if (exists(LOCAL_WRANGLER)) return null;
|
|
578
|
+
return `this app has no wrangler of its own (${LOCAL_WRANGLER} is missing), so \`bunx wrangler\` would use the one install every deploy on this Mac shares — and concurrent deploys have corrupted it mid-run. Run \`bun add -d wrangler@^4\` in the app and commit package.json and bun.lock. Nothing was deployed.`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** The file `tools/lib-preview.ts` writes INSIDE a package it overlaid with an unpublished build. */
|
|
582
|
+
export const PREVIEW_MARK = ".lib-preview.json";
|
|
583
|
+
|
|
584
|
+
/** One unpublished library build sitting in `node_modules` — what its {@link PREVIEW_MARK} says. */
|
|
585
|
+
export interface PreviewMark {
|
|
586
|
+
lib: string;
|
|
587
|
+
/** The version in the library's own tree — never the one `bun.lock` names. */
|
|
588
|
+
version: string;
|
|
589
|
+
/** The library tree's HEAD when it was packed, `-dirty` when it had uncommitted changes. */
|
|
590
|
+
commit: string;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Every package under `nodeModules` (top level and `@scope/name`) carrying a {@link PREVIEW_MARK}.
|
|
595
|
+
*
|
|
596
|
+
* The mark lives inside the package's own directory, never beside it, so it cannot outlive the bytes
|
|
597
|
+
* it describes: an install that replaces the package (a lockfile bump, `--force`) removes it too.
|
|
598
|
+
* `bun install --frozen-lockfile` leaves an overlay whose `package.json` version matches the lock, so
|
|
599
|
+
* the preview writes the locked version there and its real one here (measured 2026-09-28).
|
|
600
|
+
*/
|
|
601
|
+
export function previewMarks(nodeModules = "node_modules"): PreviewMark[] {
|
|
602
|
+
const marks: PreviewMark[] = [];
|
|
603
|
+
const read = (dir: string): void => {
|
|
604
|
+
const file = join(dir, PREVIEW_MARK);
|
|
605
|
+
if (!existsSync(file)) return;
|
|
606
|
+
try {
|
|
607
|
+
const mark = JSON.parse(readFileSync(file, "utf8")) as Partial<PreviewMark>;
|
|
608
|
+
marks.push({ lib: mark.lib ?? dir, version: mark.version ?? "?", commit: mark.commit ?? "?" });
|
|
609
|
+
} catch {
|
|
610
|
+
marks.push({ lib: dir, version: "?", commit: "unreadable mark" });
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
let names: string[] = [];
|
|
614
|
+
try {
|
|
615
|
+
names = readdirSync(nodeModules);
|
|
616
|
+
} catch {
|
|
617
|
+
return marks;
|
|
618
|
+
}
|
|
619
|
+
for (const name of names) {
|
|
620
|
+
if (name.startsWith("@")) {
|
|
621
|
+
let scoped: string[] = [];
|
|
622
|
+
try {
|
|
623
|
+
scoped = readdirSync(join(nodeModules, name));
|
|
624
|
+
} catch {}
|
|
625
|
+
for (const inner of scoped) read(join(nodeModules, name, inner));
|
|
626
|
+
} else if (!name.startsWith(".")) read(join(nodeModules, name));
|
|
627
|
+
}
|
|
628
|
+
return marks;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** `<lib>@<version> (<commit>)`, joined — how a deploy names what it would ship unpublished. */
|
|
632
|
+
export function describePreviews(marks: readonly PreviewMark[]): string {
|
|
633
|
+
return marks.map((m) => `${m.lib}@${m.version} (${m.commit})`).join(", ");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Production never ships an unpublished library (task 2252) — `null` when nothing is previewed. A
|
|
638
|
+
* stage deploy ships it, says so, and stamps itself dirty: `/healthz` then says the bytes are not
|
|
639
|
+
* the commit's, which is true.
|
|
640
|
+
*/
|
|
641
|
+
export function previewRefusal(env: WorkerEnv, marks: readonly PreviewMark[]): string | null {
|
|
642
|
+
if (env !== "production" || marks.length === 0) return null;
|
|
643
|
+
return `node_modules holds an UNPUBLISHED build of ${describePreviews(marks)} — production ships only published libraries. Publish it (\`tools/publish-libs.sh\`), bump this app to it, or put the locked version back with \`bun $FORGE/tools/lib-preview.ts --restore\`. Nothing was deployed.`;
|
|
644
|
+
}
|
|
645
|
+
|
|
551
646
|
/** What {@link runWorkerDeploy} runs first: `node_modules` exactly as `bun.lock` says. */
|
|
552
647
|
export const INSTALL: readonly string[] = ["bun", "install", "--frozen-lockfile"];
|
|
553
648
|
|
|
@@ -600,6 +695,21 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
600
695
|
const unmeasured: string[] = [];
|
|
601
696
|
|
|
602
697
|
if (!commit) return stop("head", "this checkout has no readable HEAD — there is no commit to stamp, so nothing could prove the deploy.");
|
|
698
|
+
// 🚢 Fast shipping (`./ship.ts`): a production deploy made while the switch is on is owed to the
|
|
699
|
+
// runner's ship instead — asked before anything installs or builds, so deferring costs nothing.
|
|
700
|
+
// Its `ship` line, not a `deploy` one: a deferral is not a deploy, and would drag every median.
|
|
701
|
+
if (spec.env === "production" && deps.ship) {
|
|
702
|
+
const deferred = deferral(`${spec.app} production deploy`, deps.ship(), deps.env ?? process.env);
|
|
703
|
+
if (deferred) {
|
|
704
|
+
log(`\n${deferred}`);
|
|
705
|
+
try {
|
|
706
|
+
deps.recordShip?.({ at: new Date(started).toISOString(), kind: "ship", what: "deferred", repo: spec.app, env: spec.env, commit: commit.slice(0, 8), dirty });
|
|
707
|
+
} catch {
|
|
708
|
+
// never the deploy's verdict — see DeployDeps.record
|
|
709
|
+
}
|
|
710
|
+
return { code: 0, step: "deferred", detail: deferred, commit, dirty };
|
|
711
|
+
}
|
|
712
|
+
}
|
|
603
713
|
if (spec.env === "production" && dirty) {
|
|
604
714
|
return stop("clean-tree", `the tree is dirty — production would report ${commit.slice(0, 8)} and run something else:\n${porcelain}`);
|
|
605
715
|
}
|
|
@@ -631,6 +741,13 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
631
741
|
if (deps.run(INSTALL, {}) !== 0) {
|
|
632
742
|
return stop("install", `\`${INSTALL.join(" ")}\` failed — node_modules cannot be made to match bun.lock, so nothing was built or deployed.`);
|
|
633
743
|
}
|
|
744
|
+
const noWrangler = localWranglerRefusal(deps.exists ?? existsSync);
|
|
745
|
+
if (noWrangler) return stop("wrangler", noWrangler);
|
|
746
|
+
const previews = (deps.previews ?? previewMarks)();
|
|
747
|
+
const previewed = previewRefusal(spec.env, previews);
|
|
748
|
+
if (previewed) return stop("preview", previewed);
|
|
749
|
+
if (previews.length > 0) log(`\n🟡 ${tag} this stage deploy ships an UNPUBLISHED ${describePreviews(previews)} — stamped dirty; production will refuse it until it is published`);
|
|
750
|
+
const shipsDirty = dirty || previews.length > 0;
|
|
634
751
|
if (spec.env === "production") {
|
|
635
752
|
for (const command of spec.stageFirst ?? []) {
|
|
636
753
|
step(`the stage first — production waits on it: ${command.join(" ")}`);
|
|
@@ -683,9 +800,9 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
683
800
|
log(` ${verdict.line}`);
|
|
684
801
|
}
|
|
685
802
|
}
|
|
686
|
-
step(`deploy ${spec.workerName} @ ${commit.slice(0, 8)}${
|
|
803
|
+
step(`deploy ${spec.workerName} @ ${commit.slice(0, 8)}${shipsDirty ? " (dirty — stage only)" : ""}`);
|
|
687
804
|
const stamp = spec.stampVars
|
|
688
|
-
? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${
|
|
805
|
+
? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${shipsDirty ? "true" : "false"}`]
|
|
689
806
|
: [];
|
|
690
807
|
enter("upload");
|
|
691
808
|
if (deps.run(["bunx", "wrangler", "deploy", ...envArgs, ...stamp], spec.credential) !== 0) return stop("deploy", "wrangler deploy failed.");
|
|
@@ -1144,13 +1261,13 @@ export interface WorkerCpuDeps {
|
|
|
1144
1261
|
error: (line: string) => void;
|
|
1145
1262
|
}
|
|
1146
1263
|
|
|
1147
|
-
/** The real {@link WorkerCpuDeps}: `bunx wrangler
|
|
1264
|
+
/** The real {@link WorkerCpuDeps}: `bunx wrangler tail` — the APP's own wrangler, whose major (4) its `devDependencies` pin: {@link localWranglerRefusal}. */
|
|
1148
1265
|
export function workerCpuDeps(): WorkerCpuDeps {
|
|
1149
1266
|
return {
|
|
1150
1267
|
startTail: (workerName, cwd, env) => {
|
|
1151
1268
|
let out = "";
|
|
1152
1269
|
let err = "";
|
|
1153
|
-
const child = spawn("bunx", ["wrangler
|
|
1270
|
+
const child = spawn("bunx", ["wrangler", "tail", workerName, "--format", "json"], {
|
|
1154
1271
|
cwd,
|
|
1155
1272
|
env: { ...process.env, ...env },
|
|
1156
1273
|
stdio: ["ignore", "pipe", "pipe"],
|