cursedops 0.10.5 → 0.10.7
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 +2 -1
- package/package.json +8 -2
- package/src/relayStage.ts +231 -0
- package/src/roots.ts +77 -17
- package/src/workerDeploy.ts +89 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ bun add cursedops
|
|
|
8
8
|
|
|
9
9
|
| subpath | what it is |
|
|
10
10
|
|---|---|
|
|
11
|
-
| `cursedops/roots` | finding a generation's roots, and a checkout's package root, without knowing a path — and the `cd` prefix of a printed command that RUNS when pasted (`repoCd`, 0.5.0) |
|
|
11
|
+
| `cursedops/roots` | finding a generation's roots, and a checkout's package root, without knowing a path — and the `cd` prefix of a printed command that RUNS when pasted (`repoCd`, 0.5.0); a worktree is followed to its primary from its `.git` file, once per directory, never a `git` per call (0.10.7) |
|
|
12
12
|
| `cursedops/paths` | the generation's whole-tree laws (`check-paths`, `check-doc-citations`, plus any `--also`) run over ONE repo, from a checkout or a worktree — and its `forge-paths` bin (0.5.0) |
|
|
13
13
|
| `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent, and the port a LIVE job serves on (`livePort`, 0.5.0) |
|
|
14
14
|
| `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code — the one check no app owns (every address of a deployment serving the same built client), and since 0.5.0 its VERDICTS: the origin asked on loopback, the smoke's own environment, a network that lies about DNS, and a version that has settled |
|
|
@@ -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.7",
|
|
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": {
|
|
@@ -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/roots.ts
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
56
|
import { spawnSync } from "node:child_process";
|
|
57
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
57
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
58
58
|
import { dirname, join, resolve } from "node:path";
|
|
59
59
|
import { fileURLToPath } from "node:url";
|
|
60
60
|
|
|
@@ -86,30 +86,90 @@ function walkUp(from: string, marker: string): string | null {
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The primary checkout's git directory for the repo `from` is in, read from DISK — or `undefined`
|
|
91
|
+
* when the files do not say, and only `git` can.
|
|
92
|
+
*
|
|
93
|
+
* A worktree's `.git` is a FILE, `gitdir: <primary>/.git/worktrees/<slug>`, and that directory's
|
|
94
|
+
* `commondir` names the primary's `.git` relative to it. A primary checkout or a clone has a `.git`
|
|
95
|
+
* DIRECTORY, which is its own common directory. Both are what `git rev-parse --git-common-dir`
|
|
96
|
+
* answers, without a process.
|
|
97
|
+
*/
|
|
98
|
+
function commonGitDirOnDisk(from: string): string | undefined {
|
|
99
|
+
let dir = resolve(from);
|
|
100
|
+
for (let hops = 0; hops < MAX_HOPS; hops++) {
|
|
101
|
+
const dotGit = join(dir, ".git");
|
|
102
|
+
if (existsSync(dotGit)) {
|
|
103
|
+
if (statSync(dotGit).isDirectory()) return realpathSync(dotGit);
|
|
104
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"))?.[1]?.trim();
|
|
105
|
+
if (!pointer) return undefined;
|
|
106
|
+
const gitDir = resolve(dir, pointer);
|
|
107
|
+
const commonFile = join(gitDir, "commondir");
|
|
108
|
+
// A submodule's `.git` points at `.git/modules/<name>`, which has no `commondir`: it IS its own.
|
|
109
|
+
const common = existsSync(commonFile) ? resolve(gitDir, readFileSync(commonFile, "utf8").trim()) : gitDir;
|
|
110
|
+
return existsSync(common) ? realpathSync(common) : undefined;
|
|
111
|
+
}
|
|
112
|
+
const up = dirname(dir);
|
|
113
|
+
if (up === dir) return undefined;
|
|
114
|
+
dir = up;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One answer per starting directory, for the life of the process. Which primary a worktree was cut
|
|
121
|
+
* from cannot change while a process runs, and every `forgeState(from)` in a worktree lands here.
|
|
122
|
+
*/
|
|
123
|
+
const viaWorktree = new Map<string, string | null>();
|
|
124
|
+
|
|
125
|
+
/** For a test that must watch the lookup happen again. */
|
|
126
|
+
export function __forgetWorktreeRoots(): void {
|
|
127
|
+
viaWorktree.clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
89
130
|
/**
|
|
90
131
|
* The generation above the checkout a git WORKTREE at `from` was cut from, or `null`.
|
|
91
132
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
133
|
+
* The primary checkout's git directory names the checkout; the ordinary walk runs from the
|
|
134
|
+
* directory holding it. For a primary checkout, a clone or a plain repo that is its own `.git`,
|
|
135
|
+
* so the answer is exactly what the walk up from `from` already said. `null` on anything
|
|
136
|
+
* unexpected — no git, not a repo, a directory that is not there — because inventing a root is
|
|
137
|
+
* the failure this module is against.
|
|
138
|
+
*
|
|
139
|
+
* 🔴 Read from the files first, and remembered, because `git` here was a HANG. Until 0.10.7 this
|
|
140
|
+
* spawned `git rev-parse --git-common-dir` on EVERY call, and in a worktree every call reaches it:
|
|
141
|
+
* roms' suite made 593 of them per run (`forgeState` ← `assertTestSafeDbPath`, `requireState`, …).
|
|
142
|
+
* Measured 2026-09-24 in a roms worktree: under `bun test --parallel=6`, about one run in three
|
|
143
|
+
* left a test worker spinning at 99% CPU inside `Bun.spawnSync` beside a `<defunct>` git child —
|
|
144
|
+
* Bun had missed the exit, and the 5 s `timeout` never fired. One gate stood 23 minutes. The
|
|
145
|
+
* primary checkout never saw it, because its walk up finds `forge.env` and never asks git. The
|
|
146
|
+
* same run from files: zero spawns. `git` remains only for a layout the files do not settle.
|
|
98
147
|
*/
|
|
99
148
|
export function findForgeRootViaWorktree(from: string): string | null {
|
|
149
|
+
const key = resolve(from);
|
|
150
|
+
const remembered = viaWorktree.get(key);
|
|
151
|
+
if (remembered !== undefined) return remembered;
|
|
152
|
+
let answer: string | null = null;
|
|
100
153
|
try {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
encoding: "utf8",
|
|
104
|
-
timeout: 5_000,
|
|
105
|
-
});
|
|
106
|
-
if (git.status !== 0) return null;
|
|
107
|
-
const common = (git.stdout ?? "").trim();
|
|
108
|
-
if (!common.startsWith("/")) return null;
|
|
109
|
-
return walkUp(dirname(common), MARKER);
|
|
154
|
+
const common = commonGitDirOnDisk(key) ?? commonGitDirFromGit(key);
|
|
155
|
+
answer = common ? walkUp(dirname(common), MARKER) : null;
|
|
110
156
|
} catch {
|
|
111
|
-
|
|
157
|
+
answer = null;
|
|
112
158
|
}
|
|
159
|
+
viaWorktree.set(key, answer);
|
|
160
|
+
return answer;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `git rev-parse --git-common-dir` — the fallback for a layout {@link commonGitDirOnDisk} cannot read. */
|
|
164
|
+
function commonGitDirFromGit(from: string): string | null {
|
|
165
|
+
const git = spawnSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
166
|
+
cwd: from,
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
timeout: 5_000,
|
|
169
|
+
});
|
|
170
|
+
if (git.status !== 0) return null;
|
|
171
|
+
const common = (git.stdout ?? "").trim();
|
|
172
|
+
return common.startsWith("/") ? common : null;
|
|
113
173
|
}
|
|
114
174
|
|
|
115
175
|
/**
|
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
|