cursedops 0.10.4 → 0.10.6
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 -0
- package/package.json +8 -2
- package/src/edgeFetch.ts +24 -6
- package/src/relayStage.ts +231 -0
- package/src/workerDeploy.ts +89 -0
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ bun add cursedops
|
|
|
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
28
|
| `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
29
|
| `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
|
+
| `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) |
|
|
30
31
|
| `cursedops/public-surface` | the ratchet on a LIBRARY's public surface — a symbol count per export subpath against a committed baseline that may only fall — and its `public-surface` bin. Not an app's: the one entry here admitted for three published libraries, see below |
|
|
31
32
|
|
|
32
33
|
Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.10.
|
|
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), 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.",
|
|
3
|
+
"version": "0.10.6",
|
|
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": {
|
|
7
7
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -133,6 +133,12 @@
|
|
|
133
133
|
"bun": "./src/relayLink.ts",
|
|
134
134
|
"source": "./src/relayLink.ts",
|
|
135
135
|
"import": "./src/relayLink.ts"
|
|
136
|
+
},
|
|
137
|
+
"./relay-stage": {
|
|
138
|
+
"types": "./src/relayStage.ts",
|
|
139
|
+
"bun": "./src/relayStage.ts",
|
|
140
|
+
"source": "./src/relayStage.ts",
|
|
141
|
+
"import": "./src/relayStage.ts"
|
|
136
142
|
}
|
|
137
143
|
},
|
|
138
144
|
"bin": {
|
package/src/edgeFetch.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function parseCurlResponse(raw: Buffer, origin = "the edge", exit: number
|
|
|
119
119
|
return new Response(status === 204 || status === 304 ? null : new Uint8Array(body), { status: status || 599, headers });
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/** A `fetch`-shaped function over curl — see the header. Each host is resolved once per instance. */
|
|
122
|
+
/** A `fetch`-shaped function over curl — see the header. Each host is resolved once per instance — once it has an answer. */
|
|
123
123
|
export function createEdgeFetch(options: EdgeFetchOptions = {}): (url: string, init?: RequestInit) => Promise<Response> {
|
|
124
124
|
const pinned = new Map<string, string>();
|
|
125
125
|
const resolve = options.resolve === undefined ? digCloudflare : options.resolve;
|
|
@@ -130,12 +130,29 @@ export function createEdgeFetch(options: EdgeFetchOptions = {}): (url: string, i
|
|
|
130
130
|
return { status: out.status, stdout: out.stdout ?? Buffer.alloc(0) };
|
|
131
131
|
});
|
|
132
132
|
let sequence = 0;
|
|
133
|
+
/**
|
|
134
|
+
* The host's pinned address, asked for until there IS one. 🔴 An empty answer is never cached:
|
|
135
|
+
* measured 2026-09-24 on roms' first stage deploy, the smoke asked `1.1.1.1` for a custom domain
|
|
136
|
+
* seconds after wrangler created it, got nothing, and pinned that NOTHING for the life of the
|
|
137
|
+
* process — so every request (the retries included) fell through to this Mac's resolver, which
|
|
138
|
+
* had just cached the NXDOMAIN for the zone's 1800 s negative TTL. A green Worker, a red smoke.
|
|
139
|
+
*/
|
|
140
|
+
const pinFor = (host: string): string => {
|
|
141
|
+
if (!resolve) return "";
|
|
142
|
+
const known = pinned.get(host);
|
|
143
|
+
if (known) return known;
|
|
144
|
+
const ip = resolve(host) ?? "";
|
|
145
|
+
if (ip) pinned.set(host, ip);
|
|
146
|
+
return ip;
|
|
147
|
+
};
|
|
133
148
|
return async (url, init = {}) => {
|
|
134
149
|
const target = new URL(url);
|
|
135
|
-
if (resolve && !pinned.has(target.hostname)) pinned.set(target.hostname, resolve(target.hostname) ?? "");
|
|
136
|
-
const ip = pinned.get(target.hostname);
|
|
137
150
|
const port = target.port || (target.protocol === "http:" ? "80" : "443");
|
|
138
|
-
const
|
|
151
|
+
const head = () => {
|
|
152
|
+
const ip = pinFor(target.hostname);
|
|
153
|
+
return ["-s", "-i", "--max-time", String(options.maxTimeSec ?? 60), ...(ip ? ["--resolve", `${target.hostname}:${port}:${ip}`] : [])];
|
|
154
|
+
};
|
|
155
|
+
const args: string[] = [];
|
|
139
156
|
for (const [k, v] of Object.entries(options.headersFor?.(target.hostname) ?? {})) args.push("-H", `${k}: ${v}`);
|
|
140
157
|
new Headers(init.headers).forEach((v, k) => {
|
|
141
158
|
args.push("-H", `${k}: ${v}`);
|
|
@@ -152,10 +169,11 @@ export function createEdgeFetch(options: EdgeFetchOptions = {}): (url: string, i
|
|
|
152
169
|
args.push("--data-binary", `@${bodyFile}`);
|
|
153
170
|
}
|
|
154
171
|
try {
|
|
155
|
-
let out = curl([...args, target.toString()]);
|
|
172
|
+
let out = curl([...head(), ...args, target.toString()]);
|
|
156
173
|
for (let retry = 1; retry <= EDGE_RETRIES && retryableCurlFailure(out.status, out.stdout, init.method); retry++) {
|
|
157
174
|
await new Promise((r) => setTimeout(r, options.retryDelayMs?.(retry) ?? 500 * retry));
|
|
158
|
-
|
|
175
|
+
// `head()` again: a host with no pin yet is asked again, so a retry can find the address.
|
|
176
|
+
out = curl([...head(), ...args, target.toString()]);
|
|
159
177
|
}
|
|
160
178
|
return parseCurlResponse(out.stdout, target.origin, out.status);
|
|
161
179
|
} finally {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedops/relay-stage` — the skeleton every RELAY app's signed-in stage walk shares (0.10.6).
|
|
3
|
+
* `cursedops/relay` is the Worker half, `cursedops/relay-link` the Mac half; this is how a deploy
|
|
4
|
+
* proves the two together on `<app>-stage` before production is touched.
|
|
5
|
+
*
|
|
6
|
+
* Lifted from `apps/station/scripts/worker-stage.ts` (task 048) and `apps/roms/scripts/worker-stage.ts`
|
|
7
|
+
* (roms-stage, 2026-09-24), which had become one walk with two Mac ends — and each had learned a
|
|
8
|
+
* lesson the other lacked: station the edge-propagation re-ask on the byte check, roms the wait for
|
|
9
|
+
* this Mac's own resolver. A fix to one copy was a fix to ONE of them.
|
|
10
|
+
*
|
|
11
|
+
* What stays in each app: how its Mac end starts (station runs `apps/station/scripts/link.ts`, roms
|
|
12
|
+
* boots its own `apps/roms/server.ts`), the environment allowlist that fences it from production, and its signed-in legs.
|
|
13
|
+
*
|
|
14
|
+
* Every step that shells out or waits takes its effect as a parameter with the real one as default,
|
|
15
|
+
* so the suite drives each red — a stale byte, a refused put, a short grant, a key that never stops
|
|
16
|
+
* working, a name that never resolves — without wrangler, a Worker or a network.
|
|
17
|
+
*/
|
|
18
|
+
import { type SpawnSyncReturns, spawnSync } from "node:child_process";
|
|
19
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
20
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
21
|
+
import { join, relative } from "node:path";
|
|
22
|
+
import { sha256Hex } from "cursedops/relay";
|
|
23
|
+
|
|
24
|
+
/** The accounts door every stage signs in at — `apps/auth` deployed a second time. */
|
|
25
|
+
export const STAGE_AUTH_URL = "https://auth-stage.cursedalchemy.com";
|
|
26
|
+
|
|
27
|
+
/** `assets/x.js` (relative to `dist/`) → `/assets/x.js`; `index.html` → `/` — the path the byte check asks for. */
|
|
28
|
+
export function servedPath(file: string): string {
|
|
29
|
+
return file === "index.html" ? "/" : `/${file.split("\\").join("/")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every file under `dir`, recursively, as absolute paths. */
|
|
33
|
+
export function filesUnder(dir: string): string[] {
|
|
34
|
+
return readdirSync(dir).flatMap((name) => {
|
|
35
|
+
const path = join(dir, name);
|
|
36
|
+
return statSync(path).isDirectory() ? filesUnder(path) : [path];
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Clock {
|
|
41
|
+
now: () => number;
|
|
42
|
+
sleep: (ms: number) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const realClock: Clock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
|
|
46
|
+
|
|
47
|
+
/** Ask `ok` once a second until it says yes (true) or `ms` passes (false). A throw counts as no. */
|
|
48
|
+
export async function until(ms: number, ok: () => Promise<boolean>, clock: Clock = realClock): Promise<boolean> {
|
|
49
|
+
const started = clock.now();
|
|
50
|
+
while (clock.now() - started < ms) {
|
|
51
|
+
if (await ok().catch(() => false)) return true;
|
|
52
|
+
await clock.sleep(1_000);
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The Access service token, as the pair a Mac end's two variables and a roster socket carry. */
|
|
58
|
+
export function accessPair(headers: Record<string, string>): { id: string; secret: string } {
|
|
59
|
+
return { id: headers["CF-Access-Client-Id"] ?? "", secret: headers["CF-Access-Client-Secret"] ?? "" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ByteCheckSpec {
|
|
63
|
+
/** The build the deploy uploaded. */
|
|
64
|
+
dist: string;
|
|
65
|
+
/** GET a served path on the stage — the app's `edgeFetch`, with the Access token. */
|
|
66
|
+
fetch: (path: string) => Promise<Response>;
|
|
67
|
+
/** Default four re-asks. */
|
|
68
|
+
retries?: number;
|
|
69
|
+
/** Default 10 s between re-asks. */
|
|
70
|
+
retryMs?: number;
|
|
71
|
+
clock?: Clock;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether the stage serves the EXACT bytes of every built file — SHA-256, not a health check, which a
|
|
76
|
+
* stale deploy passes. `differ` names each file still wrong (a non-2xx, other bytes, or no answer), empty when green;
|
|
77
|
+
* `files` 0 is red too (an empty `dist/` proves nothing).
|
|
78
|
+
*
|
|
79
|
+
* 🔴 A just-uploaded asset can read stale at the edge for a few seconds. Measured 2026-09-24 on
|
|
80
|
+
* station: the walk straight after `--env stage` found two unchanged chunks differing, and the same
|
|
81
|
+
* URLs served the exact bytes a minute later — the deploy was right and the walk refused production
|
|
82
|
+
* over propagation. So a mismatch is re-asked, and only one that PERSISTS is red. A stale deploy
|
|
83
|
+
* still fails: it never converges.
|
|
84
|
+
*/
|
|
85
|
+
export async function stageByteCheck(spec: ByteCheckSpec): Promise<{ files: number; differ: string[] }> {
|
|
86
|
+
const clock = spec.clock ?? realClock;
|
|
87
|
+
const files = filesUnder(spec.dist).map((path) => relative(spec.dist, path));
|
|
88
|
+
const differs = async (file: string): Promise<string | null> => {
|
|
89
|
+
const served = await spec.fetch(servedPath(file));
|
|
90
|
+
const want = new Bun.CryptoHasher("sha256").update(readFileSync(join(spec.dist, file))).digest("hex");
|
|
91
|
+
const got = new Bun.CryptoHasher("sha256").update(new Uint8Array(await served.arrayBuffer())).digest("hex");
|
|
92
|
+
return !served.ok || got !== want ? file : null;
|
|
93
|
+
};
|
|
94
|
+
/** A fetch that throws (the edge unreachable) is a file that differs, never a crash of the walk. */
|
|
95
|
+
const ask = (file: string): Promise<string | null> => differs(file).catch(() => file);
|
|
96
|
+
let differ: string[] = [];
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const d = await ask(file);
|
|
99
|
+
if (d) differ.push(d);
|
|
100
|
+
}
|
|
101
|
+
for (let retry = 1; retry <= (spec.retries ?? 4) && differ.length > 0; retry++) {
|
|
102
|
+
await clock.sleep(spec.retryMs ?? 10_000);
|
|
103
|
+
const again: string[] = [];
|
|
104
|
+
for (const file of differ) {
|
|
105
|
+
const still = await ask(file);
|
|
106
|
+
if (still) again.push(still);
|
|
107
|
+
}
|
|
108
|
+
differ = again;
|
|
109
|
+
}
|
|
110
|
+
return { files: files.length, differ };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type Run = (argv: readonly string[], options: { cwd: string; input?: string; env?: Record<string, string>; timeout?: number }) => {
|
|
114
|
+
status: number | null;
|
|
115
|
+
stdout: string;
|
|
116
|
+
stderr: string;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** The real {@link Run}: `spawnSync`, utf8, the env overlaid on this process's. */
|
|
120
|
+
export const realRun: Run = (argv, options) => {
|
|
121
|
+
const done: SpawnSyncReturns<string> = spawnSync(argv[0] as string, argv.slice(1), {
|
|
122
|
+
cwd: options.cwd,
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
...(options.input !== undefined ? { input: options.input } : {}),
|
|
125
|
+
...(options.timeout !== undefined ? { timeout: options.timeout } : {}),
|
|
126
|
+
env: { ...process.env, ...options.env },
|
|
127
|
+
});
|
|
128
|
+
return { status: done.status, stdout: done.stdout ?? "", stderr: done.stderr ?? "" };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export interface DigestPut {
|
|
132
|
+
/** The app's checkout, where `wrangler.jsonc` is. */
|
|
133
|
+
cwd: string;
|
|
134
|
+
/** The Worker secret holding the key's digest — the app's `LinkNames.digest`, spelled whole. */
|
|
135
|
+
secretName: string;
|
|
136
|
+
key: string;
|
|
137
|
+
/** `cloudflareCredential(...)`. */
|
|
138
|
+
credential: Record<string, string>;
|
|
139
|
+
run?: Run;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Put `key`'s digest on the STAGE Worker (`--env stage`, always — this module has no production
|
|
144
|
+
* 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: the caller gives it a few seconds to reach the edge.
|
|
146
|
+
*/
|
|
147
|
+
export async function putStageLinkDigest(put: DigestPut): Promise<{ ok: boolean; stderr: string }> {
|
|
148
|
+
const done = (put.run ?? realRun)(["bunx", "wrangler@4", "secret", "put", put.secretName, "--env", "stage"], {
|
|
149
|
+
cwd: put.cwd,
|
|
150
|
+
input: await sha256Hex(put.key),
|
|
151
|
+
env: put.credential,
|
|
152
|
+
});
|
|
153
|
+
return { ok: done.status === 0, stderr: done.stderr };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sign the stage owner in through auth-stage's emailed second factor and grant `app`'s stage —
|
|
158
|
+
* `apps/auth-stage/scripts/inbox.ts` `grant <app>`, whose last stdout line is the grant.
|
|
159
|
+
* Never production's accounts service: nothing under test signs in there (`tools/check-prod-auth.ts`).
|
|
160
|
+
*/
|
|
161
|
+
export function grantStageOwner(spec: { forge: string; app: string; run?: Run; stderr?: (text: string) => void }): { ok: boolean; grant: string } {
|
|
162
|
+
const done = (spec.run ?? realRun)(["bun", "run", "scripts/inbox.ts", "grant", spec.app], {
|
|
163
|
+
cwd: join(spec.forge, "apps", "auth-stage"),
|
|
164
|
+
timeout: 300_000,
|
|
165
|
+
});
|
|
166
|
+
(spec.stderr ?? ((text) => process.stderr.write(text)))(done.stderr);
|
|
167
|
+
const grant = done.stdout.trim().split("\n").pop() ?? "";
|
|
168
|
+
return { ok: done.status === 0 && grant.length > 100, grant };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface RotationSpec {
|
|
172
|
+
/** Stop the Mac end, resolving once it has exited. */
|
|
173
|
+
stop: () => Promise<void>;
|
|
174
|
+
/** Start the Mac end with the key it held BEFORE the rotation. */
|
|
175
|
+
start: () => void;
|
|
176
|
+
/** Whether the stage's `/healthz` says the link is connected. */
|
|
177
|
+
isLinked: () => Promise<boolean>;
|
|
178
|
+
/** How long the old key must stay refused to count. Default 20 s. */
|
|
179
|
+
windowMs?: number;
|
|
180
|
+
/** Past this a still-working old key is a real fault. Default 3 min. */
|
|
181
|
+
maxMs?: number;
|
|
182
|
+
clock?: Clock;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* After a new digest is put, prove the OLD key no longer connects: restart the Mac end on it until
|
|
187
|
+
* it stays unlinked for a whole {@link RotationSpec.windowMs} — never past {@link RotationSpec.maxMs}.
|
|
188
|
+
* A secret put reaches the edge in its own time (measured on station: 15 s was not always enough),
|
|
189
|
+
* so how long it took is the measurement. Leaves the Mac end STOPPED; the caller re-provisions it.
|
|
190
|
+
*/
|
|
191
|
+
export async function oldKeyRefused(spec: RotationSpec): Promise<{ refused: boolean; seconds: number }> {
|
|
192
|
+
const clock = spec.clock ?? realClock;
|
|
193
|
+
const rotatedAt = clock.now();
|
|
194
|
+
let refused = false;
|
|
195
|
+
while (!refused && clock.now() - rotatedAt < (spec.maxMs ?? 180_000)) {
|
|
196
|
+
await spec.stop();
|
|
197
|
+
await until(30_000, async () => !(await spec.isLinked()), clock);
|
|
198
|
+
spec.start();
|
|
199
|
+
// Every pass costs real time, whatever `isLinked` does: a loop whose answers all come back at
|
|
200
|
+
// once would otherwise spin without the ceiling ever arriving.
|
|
201
|
+
await clock.sleep(1_000);
|
|
202
|
+
refused = !(await until(spec.windowMs ?? 20_000, spec.isLinked, clock));
|
|
203
|
+
}
|
|
204
|
+
await spec.stop();
|
|
205
|
+
return { refused, seconds: Math.round((clock.now() - rotatedAt) / 1000) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Wait until `host` resolves through THIS Mac's resolver, which `edgeFetch`'s 1.1.1.1 pin does not
|
|
210
|
+
* reach — true at once when it already does, false after `maxMs` (default the zone's 1800 s negative
|
|
211
|
+
* TTL). Measured 2026-09-24 on the first deploy of `roms-stage`: the smoke asked for the custom domain
|
|
212
|
+
* seconds after wrangler created it, the upstream resolver cached the NXDOMAIN, and every Mac-end
|
|
213
|
+
* dial failed 1006 while every edgeFetch succeeded. So a walk waits here, saying why, instead of
|
|
214
|
+
* failing on a dial.
|
|
215
|
+
*/
|
|
216
|
+
export async function awaitSystemResolver(spec: {
|
|
217
|
+
host: string;
|
|
218
|
+
lookup?: (host: string) => Promise<{ address: string }>;
|
|
219
|
+
maxMs?: number;
|
|
220
|
+
log?: (line: string) => void;
|
|
221
|
+
clock?: Clock;
|
|
222
|
+
}): Promise<boolean> {
|
|
223
|
+
const lookup = spec.lookup ?? dnsLookup;
|
|
224
|
+
const resolves = async () => Boolean((await lookup(spec.host)).address);
|
|
225
|
+
if (await resolves().catch(() => false)) return true;
|
|
226
|
+
const maxMs = spec.maxMs ?? 1_800_000;
|
|
227
|
+
(spec.log ?? console.log)(
|
|
228
|
+
` · ${spec.host} does not resolve on this Mac yet (a cached NXDOMAIN from before the domain existed) — waiting up to ${Math.round(maxMs / 60_000)} min`,
|
|
229
|
+
);
|
|
230
|
+
return until(maxMs, resolves, spec.clock);
|
|
231
|
+
}
|
package/src/workerDeploy.ts
CHANGED
|
@@ -357,6 +357,95 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
357
357
|
return { code: 0, step: "done", detail, commit, dirty };
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
/**
|
|
361
|
+
* A RELAY app's whole `worker:deploy` (0.10.6) — the Worker holds no session and no D1 (the gate is
|
|
362
|
+
* the Mac's, answering down `cursedops/relay`), so the sequence is: the stage first, walked signed
|
|
363
|
+
* in, then build, ship with the `--var` stamp, smoke. `station` and `roms` each carried this as a
|
|
364
|
+
* 56-line script differing only in data (`tools/check-copies.baseline`, 2026-09-24).
|
|
365
|
+
*
|
|
366
|
+
* ```ts
|
|
367
|
+
* // the app's worker-deploy script, whole
|
|
368
|
+
* process.exit(relayWorkerDeployCli({ app: "roms", root: appRoot(), stateRoot: forgeState(), … }));
|
|
369
|
+
* ```
|
|
370
|
+
*
|
|
371
|
+
* bun run worker:deploy # the stage, walked signed in; then build, ship, smoke production
|
|
372
|
+
* bun run worker:deploy -- --env stage # the stage alone: build, ship, smoke
|
|
373
|
+
* bun run worker:deploy -- --no-build # ship what is already in dist/
|
|
374
|
+
*
|
|
375
|
+
* No secrets step: the one secret, the link key's digest, is the app's `link:key` to set, because
|
|
376
|
+
* minting it is also rotating it. Returns the exit code — 2 for a missing credential or a bad
|
|
377
|
+
* `--env`, before anything runs — and never exits itself.
|
|
378
|
+
*/
|
|
379
|
+
export interface RelayWorkerDeploySpec {
|
|
380
|
+
/** For the messages, and the production Worker's script name unless {@link productionWorker} says otherwise. */
|
|
381
|
+
app: string;
|
|
382
|
+
/** The app's checkout — where every command runs. */
|
|
383
|
+
root: string;
|
|
384
|
+
/** `$FORGE_STATE` — the credential is `<stateRoot>/secrets/cloudflare.env`. */
|
|
385
|
+
stateRoot: string;
|
|
386
|
+
stampVars: { commit: string; dirty: string };
|
|
387
|
+
/** The build, run unless `--no-build`. It must produce what the stage walk's byte check compares. */
|
|
388
|
+
build: readonly string[];
|
|
389
|
+
/** `wrangler.jsonc` `env.stage.name`. */
|
|
390
|
+
stageWorker: string;
|
|
391
|
+
/** Default {@link RelayWorkerDeploySpec.app}. */
|
|
392
|
+
productionWorker?: string;
|
|
393
|
+
/** Printed after a green production deploy. */
|
|
394
|
+
previewUrl: string;
|
|
395
|
+
/** Printed after a green stage deploy. */
|
|
396
|
+
stageUrl: string;
|
|
397
|
+
/** Default `process.argv.slice(2)`. */
|
|
398
|
+
argv?: readonly string[];
|
|
399
|
+
/** The app's own scripts, relative to {@link RelayWorkerDeploySpec.root}. Defaults: `scripts/worker-deploy.ts`, `scripts/worker-stage.ts`, `scripts/worker-smoke.ts`. check-doc-citations:ignore app-relative defaults, in the consuming app's tree */
|
|
400
|
+
scripts?: { deploy?: string; stageWalk?: string; smoke?: string };
|
|
401
|
+
beforeStage?: readonly Refusal[];
|
|
402
|
+
beforeShip?: readonly Refusal[];
|
|
403
|
+
cpuTail?: readonly string[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The {@link WorkerDeploySpec} a relay app's deploy runs — pure, so a suite can hold every field. */
|
|
407
|
+
export function relayWorkerDeploySpec(spec: RelayWorkerDeploySpec, env: WorkerEnv, noBuild: boolean, credential: Record<string, string>): WorkerDeploySpec {
|
|
408
|
+
const script = { deploy: "scripts/worker-deploy.ts", stageWalk: "scripts/worker-stage.ts", smoke: "scripts/worker-smoke.ts", ...spec.scripts };
|
|
409
|
+
return {
|
|
410
|
+
app: spec.app,
|
|
411
|
+
env,
|
|
412
|
+
workerName: env === "stage" ? spec.stageWorker : (spec.productionWorker ?? spec.app),
|
|
413
|
+
databaseName: null,
|
|
414
|
+
stampVars: spec.stampVars,
|
|
415
|
+
build: noBuild ? null : spec.build,
|
|
416
|
+
stageFirst: [["bun", "run", script.deploy, "--env", "stage", ...(noBuild ? ["--no-build"] : [])]],
|
|
417
|
+
stageWalk: [["bun", "run", script.stageWalk]],
|
|
418
|
+
secrets: null,
|
|
419
|
+
smoke: ["bun", "run", script.smoke, ...(env === "stage" ? ["--env", "stage"] : [])],
|
|
420
|
+
credential,
|
|
421
|
+
...(spec.beforeStage ? { beforeStage: spec.beforeStage } : {}),
|
|
422
|
+
...(spec.beforeShip ? { beforeShip: spec.beforeShip } : {}),
|
|
423
|
+
...(spec.cpuTail ? { cpuTail: spec.cpuTail } : {}),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function relayWorkerDeployCli(
|
|
428
|
+
spec: RelayWorkerDeploySpec,
|
|
429
|
+
deps: DeployDeps = workerDeployDeps(spec.root),
|
|
430
|
+
readCredential: (file: string) => Record<string, string> = cloudflareCredential,
|
|
431
|
+
): number {
|
|
432
|
+
const argv = spec.argv ?? process.argv.slice(2);
|
|
433
|
+
const error = deps.error ?? ((line: string) => console.error(line));
|
|
434
|
+
const log = deps.log ?? ((line: string) => console.log(line));
|
|
435
|
+
let env: WorkerEnv;
|
|
436
|
+
let credential: Record<string, string>;
|
|
437
|
+
try {
|
|
438
|
+
env = parseWorkerEnv(argv);
|
|
439
|
+
credential = readCredential(join(spec.stateRoot, "secrets", "cloudflare.env"));
|
|
440
|
+
} catch (why) {
|
|
441
|
+
error(`[${spec.app}] ${why instanceof Error ? why.message : why}`);
|
|
442
|
+
return 2;
|
|
443
|
+
}
|
|
444
|
+
const result = runWorkerDeploy(relayWorkerDeploySpec(spec, env, argv.includes("--no-build"), credential), deps);
|
|
445
|
+
if (result.code === 0) log(` ${env === "stage" ? spec.stageUrl : spec.previewUrl}`);
|
|
446
|
+
return result.code;
|
|
447
|
+
}
|
|
448
|
+
|
|
360
449
|
// ═════════════════════════════════════════════════════════════════════════════════════════════
|
|
361
450
|
/**
|
|
362
451
|
* THE WORKER CPU CHECK (0.7.0, task 2134) — what a Worker's requests REALLY cost, asserted on every deploy: a
|