cursedops 0.4.0 → 0.5.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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * `cursedops/worker-deploy` — the SEQUENCE that ships a Cloudflare Worker, and the small readers
3
+ * every step of it needs. Per-app things stay data: the Worker's name, its database, its stamp
4
+ * variables, and the commands that build, stage, attach secrets and smoke.
5
+ *
6
+ * ```ts
7
+ * import { cloudflareCredential, parseWorkerEnv, runWorkerDeploy, wranglerEnvArgs } from "cursedops/worker-deploy";
8
+ *
9
+ * const env = parseWorkerEnv(process.argv.slice(2));
10
+ * const result = runWorkerDeploy({
11
+ * app: "music", env, workerName: d.workerName, databaseName: d.databaseName,
12
+ * stampVars: { commit: "MUSIC_BUILD_COMMIT", dirty: "MUSIC_BUILD_DIRTY" },
13
+ * build: ["bun", "run", "build"],
14
+ * stageFirst: [["bun", "run", "scripts/worker-deploy.ts", "--env", "stage"], ["bun", "run", "scripts/worker-stage-walk.ts"]],
15
+ * secrets: ["bun", "run", "scripts/worker-secrets.ts", ...wranglerEnvArgs(env)],
16
+ * smoke: ["bun", "run", "scripts/worker-smoke.ts"],
17
+ * credential: cloudflareCredential(file),
18
+ * }, workerDeployDeps(ROOT));
19
+ * process.exit(result.code);
20
+ * ```
21
+ *
22
+ * ## Why a library entry
23
+ *
24
+ * Measured 2026-09-23 (task 2097): the Worker toolkit was in FOUR apps — `collections`, `vault`,
25
+ * `patterns`, and `music` (copied from `vault`'s that day) — ~3,000 lines each, in `scripts/`,
26
+ * which `check-copies` did not scan. The copies had already drifted in ways that cost something:
27
+ * `patterns`' deploy never refused a dirty tree and never stamped the commit, so its `/healthz`
28
+ * could not say what it was running; `music`'s smoke was the only one that waited for a settled
29
+ * version (see `steadyHealth` in `cursedops/smoke`). Each order below is a step some app shipped
30
+ * without and paid for:
31
+ *
32
+ * 1. **a readable HEAD** — nothing to stamp is nothing that could prove the deploy;
33
+ * 2. **a clean tree for production** — a dirty one would report one commit and run another;
34
+ * 3. **the app's own refusals** (data) — e.g. its Mac host still loaded while the config routes
35
+ * the hostname to the Worker: two writers, two databases;
36
+ * 4. **the STAGE first, for production** — `collections` went live 2026-09-22 with every anonymous
37
+ * check green and its owner unable to sign in; the stage walk is what found why, and a step
38
+ * in the sequence is one a person in a hurry cannot skip;
39
+ * 5. build → 6. **schema** (`IF NOT EXISTS`, the database named explicitly, never the binding a
40
+ * forgotten `--env` would resolve) → 7. **deploy with the `--var` stamp** (commit AND dirty
41
+ * flag, so `/healthz` publishes what is running) → 8. **secrets** (after the deploy: a secret
42
+ * needs a script to attach to) → 9. **smoke**.
43
+ *
44
+ * 🔴 A failed smoke does NOT roll back here: a Worker deploy is a version and `bunx wrangler
45
+ * rollback` puts the previous one back in one command — the message says so. The Mac-side
46
+ * rollback, which is a different thing, is `cursedops/worker-rollback`.
47
+ *
48
+ * Everything that shells out is behind {@link DeployDeps}, so the suite proves the order and every
49
+ * refusal without wrangler, git or a network.
50
+ */
51
+ import { spawnSync } from "node:child_process";
52
+ import { existsSync, readFileSync } from "node:fs";
53
+
54
+ /** The two deployments every Worker app here has. */
55
+ export type WorkerEnv = "production" | "stage";
56
+
57
+ /**
58
+ * `--env stage` / `--env=stage` → stage; no flag → production; anything else THROWS.
59
+ *
60
+ * 🔴 Both spellings, and the second is not politeness: wrangler itself takes `--env=stage`, so
61
+ * that is what a hand types after a week of wrangler — and an `indexOf("--env")` that only knew
62
+ * the two-argument form returned `production` for it. A typo that silently meant production
63
+ * points the command at the owner's data and reports success (`apps/collections/scripts/workerEnvs.ts`).
64
+ */
65
+ export function parseWorkerEnv(argv: readonly string[]): WorkerEnv {
66
+ const joined = argv.find((arg) => arg.startsWith("--env="));
67
+ const index = argv.indexOf("--env");
68
+ if (joined === undefined && index < 0) return "production";
69
+ const value = (joined !== undefined ? joined.slice("--env=".length) : argv[index + 1])?.trim();
70
+ if (value === "production" || value === "stage") return value;
71
+ throw new Error(
72
+ `unrecognised --env ${JSON.stringify(value ?? "")} — production (no flag) or stage. ` +
73
+ "This refuses rather than defaulting: a typo that meant production would report success against the owner's data.",
74
+ );
75
+ }
76
+
77
+ /** What wrangler needs on its command line: `--env=` (the top-level block) or `--env=stage`. */
78
+ export function wranglerEnvArgs(env: WorkerEnv): string[] {
79
+ return [env === "production" ? "--env=" : `--env=${env}`];
80
+ }
81
+
82
+ /** `wrangler.jsonc` as JSON — whole-line `//` comments removed, which is the shape every app's config has. */
83
+ export function readWranglerJsonc(text: string): Record<string, unknown> {
84
+ const stripped = text
85
+ .split("\n")
86
+ .map((line) => (/^\s*\/\//.test(line) ? "" : line))
87
+ .join("\n");
88
+ return JSON.parse(stripped) as Record<string, unknown>;
89
+ }
90
+
91
+ /**
92
+ * `KEY=value` lines — `export ` tolerated, one layer of surrounding quotes removed (and a POSIX
93
+ * `'\''` inside single quotes read back as `'`), `#` lines and blanks skipped. The reader every
94
+ * Worker app's secrets and credential files share.
95
+ */
96
+ export function readEnvFile(text: string): Record<string, string> {
97
+ const found: Record<string, string> = {};
98
+ for (const raw of text.split("\n")) {
99
+ const line = raw.trim();
100
+ if (!line || line.startsWith("#")) continue;
101
+ const eq = line.indexOf("=");
102
+ if (eq < 0) continue;
103
+ let value = line.slice(eq + 1).trim();
104
+ if (value.length > 1 && value.startsWith("'") && value.endsWith("'")) {
105
+ // `'\''` is how a writer spells a `'` inside single quotes (`envFileLines` in
106
+ // `cursedops/worker-secrets`); undoing it is what makes the two round-trip.
107
+ value = value.slice(1, -1).replaceAll("'\\''", "'");
108
+ } else if (value.length > 1 && value.startsWith('"') && value.endsWith('"')) {
109
+ value = value.slice(1, -1);
110
+ }
111
+ found[line.slice(0, eq).replace(/^export\s+/, "").trim()] = value;
112
+ }
113
+ return found;
114
+ }
115
+
116
+ /**
117
+ * The Cloudflare credential in `file` (usually `$FORGE_STATE/secrets/cloudflare.env`), as an env
118
+ * overlay for a spawned wrangler. THROWS, naming the file, when it or its token is missing.
119
+ *
120
+ * 🔴 **A file, never `wrangler login`.** An interactive OAuth session is a credential nobody can
121
+ * rotate, that expires while nobody looks, and that a launchd job does not have — a deploy path
122
+ * built on one works from a terminal a person sits at and from nowhere else. And a deploy must
123
+ * fail in FRONT of the work, not three minutes in with wrangler's own opaque auth error, or —
124
+ * worse — succeed against whatever account an inherited variable names.
125
+ */
126
+ export function cloudflareCredential(file: string, read: (path: string) => string | null = readIfThere): Record<string, string> {
127
+ const text = read(file);
128
+ if (text === null) throw new Error(`no Cloudflare credential at ${file}`);
129
+ const found = readEnvFile(text);
130
+ if (!found.CLOUDFLARE_API_TOKEN) throw new Error(`${file} has no CLOUDFLARE_API_TOKEN`);
131
+ return found;
132
+ }
133
+
134
+ function readIfThere(path: string): string | null {
135
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
136
+ }
137
+
138
+ /** A refusal an app adds to the sequence: a sentence saying why not, or `null` to proceed. */
139
+ export type Refusal = () => string | null;
140
+
141
+ export interface WorkerDeploySpec {
142
+ /** For the messages: `[music] …`. */
143
+ app: string;
144
+ env: WorkerEnv;
145
+ workerName: string;
146
+ /** The D1 database the schema is applied to, by NAME. `null` for a Worker with no D1. */
147
+ databaseName: string | null;
148
+ /** Default `db/schema.sql`. Must be `IF NOT EXISTS` throughout — it runs on every deploy. */
149
+ schemaFile?: string;
150
+ /** The two `vars` `/healthz` publishes as the build stamp. `null` only for a Worker that has none yet. */
151
+ stampVars: { commit: string; dirty: string } | null;
152
+ /** The build command, or `null` to skip (`--no-build`). */
153
+ build: readonly string[] | null;
154
+ /** Run in order BEFORE a production deploy; any red and production is not touched. Ignored for the stage. */
155
+ stageFirst?: readonly (readonly string[])[];
156
+ /** Before the stage (and before anything is built): the app's own refusals. */
157
+ beforeStage?: readonly Refusal[];
158
+ /** After the build, before anything ships — e.g. "the build output is really there". */
159
+ beforeShip?: readonly Refusal[];
160
+ /** Attaches this deployment's secrets. Run after the deploy. */
161
+ secrets: readonly string[] | null;
162
+ /** Smokes this deployment. */
163
+ smoke: readonly string[] | null;
164
+ /** The env overlay every wrangler-touching step is run with — {@link cloudflareCredential}. */
165
+ credential: Record<string, string>;
166
+ }
167
+
168
+ export interface DeployDeps {
169
+ /** Run a command, inheriting stdio; returns its exit code. */
170
+ run: (argv: readonly string[], env: Record<string, string>) => number;
171
+ /** `git <args>` in the checkout; trimmed stdout, `""` on failure. */
172
+ git: (args: readonly string[]) => string;
173
+ log?: (line: string) => void;
174
+ error?: (line: string) => void;
175
+ }
176
+
177
+ export interface DeployResult {
178
+ /** 0 shipped and smoked; 1 refused or failed — the step says where. */
179
+ code: 0 | 1;
180
+ /** The step it stopped at, or `"done"`. */
181
+ step: string;
182
+ /** Why it stopped, or the success line. */
183
+ detail: string;
184
+ commit: string;
185
+ dirty: boolean;
186
+ }
187
+
188
+ /** The real {@link DeployDeps}, rooted at the app's checkout. */
189
+ export function workerDeployDeps(cwd: string): DeployDeps {
190
+ return {
191
+ run: (argv, env) => {
192
+ console.log(` $ ${argv.join(" ")}`);
193
+ return spawnSync(argv[0] as string, argv.slice(1), { cwd, stdio: "inherit", env: { ...process.env, ...env } }).status ?? 1;
194
+ },
195
+ git: (args) => (spawnSync("git", [...args], { cwd, encoding: "utf8" }).stdout ?? "").trim(),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Run the sequence in the header's order, stopping at the first red with a sentence. Never
201
+ * exits — the caller does, with {@link DeployResult.code}.
202
+ */
203
+ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): DeployResult {
204
+ const log = deps.log ?? ((line: string) => console.log(line));
205
+ const error = deps.error ?? ((line: string) => console.error(line));
206
+ const tag = `[${spec.app}]`;
207
+ const commit = deps.git(["rev-parse", "HEAD"]);
208
+ const porcelain = deps.git(["status", "--porcelain"]);
209
+ const dirty = porcelain.length > 0;
210
+ const stop = (step: string, detail: string): DeployResult => {
211
+ error(`\n🔴 ${tag} ${detail}`);
212
+ return { code: 1, step, detail, commit, dirty };
213
+ };
214
+ const step = (message: string): void => log(`\n▸ ${message}`);
215
+
216
+ if (!commit) return stop("head", "this checkout has no readable HEAD — there is no commit to stamp, so nothing could prove the deploy.");
217
+ if (spec.env === "production" && dirty) {
218
+ return stop("clean-tree", `the tree is dirty — production would report ${commit.slice(0, 8)} and run something else:\n${porcelain}`);
219
+ }
220
+ for (const refusal of spec.beforeStage ?? []) {
221
+ const why = refusal();
222
+ if (why) return stop("refusal", why);
223
+ }
224
+ if (spec.env === "production") {
225
+ for (const command of spec.stageFirst ?? []) {
226
+ step(`the stage first — production waits on it: ${command.join(" ")}`);
227
+ if (deps.run(command, {}) !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
228
+ }
229
+ }
230
+ if (spec.build) {
231
+ step("build");
232
+ if (deps.run(spec.build, {}) !== 0) return stop("build", "the build failed — nothing was deployed.");
233
+ }
234
+ for (const refusal of spec.beforeShip ?? []) {
235
+ const why = refusal();
236
+ if (why) return stop("refusal", `${why} — nothing was deployed.`);
237
+ }
238
+ const envArgs = wranglerEnvArgs(spec.env);
239
+ if (spec.databaseName) {
240
+ const schema = spec.schemaFile ?? "db/schema.sql";
241
+ step(`apply ${schema} to D1 \`${spec.databaseName}\``);
242
+ const applied = deps.run(["bunx", "wrangler", "d1", "execute", spec.databaseName, "--remote", "--file", schema, "-y", ...envArgs], spec.credential);
243
+ if (applied !== 0) return stop("schema", "the schema did not apply — nothing was deployed.");
244
+ }
245
+ step(`deploy ${spec.workerName} @ ${commit.slice(0, 8)}${dirty ? " (dirty — stage only)" : ""}`);
246
+ const stamp = spec.stampVars
247
+ ? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${dirty ? "true" : "false"}`]
248
+ : [];
249
+ if (deps.run(["bunx", "wrangler", "deploy", ...envArgs, ...stamp], spec.credential) !== 0) return stop("deploy", "wrangler deploy failed.");
250
+ if (spec.secrets) {
251
+ step("attach the secrets — exactly this deployment's set, over a pipe");
252
+ if (deps.run(spec.secrets, spec.credential) !== 0) {
253
+ return stop("secrets", "the Worker is deployed and its secrets are NOT attached — it serves nothing until they are.");
254
+ }
255
+ }
256
+ if (spec.smoke) {
257
+ step("smoke every address this deployment answers on");
258
+ if (deps.run(spec.smoke, {}) !== 0) {
259
+ return stop(
260
+ "smoke",
261
+ "the Worker is deployed and its smoke FAILED. Nothing was rolled back: `bunx wrangler rollback` puts the previous version back in one command.",
262
+ );
263
+ }
264
+ }
265
+ const detail = `${spec.workerName} deployed${spec.secrets ? ", attached" : ""}${spec.smoke ? " and smoked" : ""} @ ${commit.slice(0, 8)}.`;
266
+ log(`\n✅ ${detail}`);
267
+ return { code: 0, step: "done", detail, commit, dirty };
268
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `cursedops/worker-rollback` — put a hostname served by a Worker back on its Mac origin, in the
3
+ * one order that cannot leave the hostname pointing at nothing: **origin first, route second.**
4
+ *
5
+ * ```ts
6
+ * import { findWorkerRoute, loopbackHealthy, rollbackToOrigin } from "cursedops/worker-rollback";
7
+ *
8
+ * const found = await findWorkerRoute(api, host); // read off the account, never typed
9
+ * const result = await rollbackToOrigin({
10
+ * startOrigin: () => spawnSync("bun", ["run", "scripts/service.ts", "install", "--rollback"], …).status ?? 1,
11
+ * originUp: () => loopbackHealthy(livePort(LABEL) ?? DEPLOY_PORT),
12
+ * removeRoute: () => deleteWorkerRoute(api, found),
13
+ * });
14
+ * ```
15
+ *
16
+ * ## Why this order, and why it is a library entry
17
+ *
18
+ * `patterns` wrote it first (2026-09-19) because the owner required the rollback to be ONE
19
+ * command — a launchd invocation nobody runs monthly and a route id nobody has memorised are not
20
+ * cheap at the moment somebody needs them — and `collections`, `vault` and `music` copied it.
21
+ * Deleting the route while nothing listens on the Mac points the household's hostname at a dead
22
+ * box; so this starts the origin, WAITS for it to answer, and refuses to touch the route if it
23
+ * never does. A hostname served by a Worker that works beats one pointing at a Mac that does not.
24
+ *
25
+ * 🔴 The route is FOUND, never typed. `patterns`' copy carried its zone and route ids as literals,
26
+ * which is correct exactly until the route is re-created — and the moment that matters is the one
27
+ * where somebody has just been changing routes. {@link findWorkerRoute} reads them off the account
28
+ * by the hostname's `<host>/*` pattern.
29
+ *
30
+ * 🔴 What a rollback does NOT undo is the app's to say, and each app's script says it: writes made
31
+ * to D1 since the cutover stay in D1. `cursedops/d1-import`'s row-for-row comparison is how an app
32
+ * prints the size of that loss before anybody decides.
33
+ */
34
+
35
+ /** The Cloudflare v4 API's envelope — only what is read here. Structural, so any caller fits. */
36
+ export interface ApiResult {
37
+ success?: boolean;
38
+ result?: unknown;
39
+ errors?: unknown;
40
+ }
41
+
42
+ export type CloudflareApi = (path: string, init?: RequestInit) => Promise<ApiResult>;
43
+
44
+ export interface WorkerRoute {
45
+ zone: string;
46
+ /** The route's id, or `null` when no route serves the hostname (it already falls through). */
47
+ route: string | null;
48
+ /** The Worker script the route names, when there is one. */
49
+ script: string | null;
50
+ pattern: string;
51
+ }
52
+
53
+ /**
54
+ * The zone and the route serving `<hostname>/*`, read off the account. THROWS when the account has
55
+ * no zone for the hostname — a rollback that cannot see the zone cannot know what it would change.
56
+ *
57
+ * The zone is the hostname's last two labels, which is every zone this fleet has; `zoneName`
58
+ * overrides it for one that is not.
59
+ */
60
+ export async function findWorkerRoute(api: CloudflareApi, hostname: string, zoneName?: string): Promise<WorkerRoute> {
61
+ const pattern = `${hostname}/*`;
62
+ const name = zoneName ?? hostname.split(".").slice(-2).join(".");
63
+ const zones = await api(`/zones?name=${encodeURIComponent(name)}`);
64
+ const zone = (zones.result as Array<{ id: string }> | undefined)?.[0]?.id;
65
+ if (!zone) throw new Error(`no zone ${name} for ${hostname} on this account: ${JSON.stringify(zones.errors ?? [])}`);
66
+ const routes = await api(`/zones/${zone}/workers/routes`);
67
+ const hit = (routes.result as Array<{ id: string; pattern: string; script?: string }> | undefined)?.find((r) => r.pattern === pattern);
68
+ return { zone, route: hit?.id ?? null, script: hit?.script ?? null, pattern };
69
+ }
70
+
71
+ /** Delete `found`'s route. `true` when it is gone or there was none to delete. */
72
+ export async function deleteWorkerRoute(api: CloudflareApi, found: WorkerRoute): Promise<boolean> {
73
+ if (!found.route) return true;
74
+ return (await api(`/zones/${found.zone}/workers/routes/${found.route}`, { method: "DELETE" })).success === true;
75
+ }
76
+
77
+ /** Does `http://127.0.0.1:<port><path>` answer `2xx` right now? Loopback, so no cache can answer for it. */
78
+ export async function loopbackHealthy(port: number, path = "/healthz", send: typeof fetch = fetch): Promise<boolean> {
79
+ try {
80
+ return (await send(`http://127.0.0.1:${port}${path}`, { signal: AbortSignal.timeout(2_000), redirect: "manual" })).ok;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ export interface RollbackSteps {
87
+ /** Start (reinstall) the Mac origin. Returns an exit code. */
88
+ startOrigin: () => number | Promise<number>;
89
+ /** Is the origin answering? Asked until it is, or the attempts run out. */
90
+ originUp: () => Promise<boolean>;
91
+ /** Take the Worker off the hostname. Only ever called once the origin answers. */
92
+ removeRoute: () => Promise<boolean>;
93
+ /** How many times to ask {@link originUp}. Default 60. */
94
+ attempts?: number;
95
+ /** Pause between asks. Default 1 s. */
96
+ intervalMs?: number;
97
+ sleep?: (ms: number) => Promise<void>;
98
+ }
99
+
100
+ export interface RollbackResult {
101
+ ok: boolean;
102
+ /** Where it stopped: the origin never came up, the route would not delete, or done. */
103
+ stage: "origin" | "route" | "done";
104
+ detail: string;
105
+ }
106
+
107
+ /**
108
+ * Origin first, route second. The route is NOT touched unless the start succeeded AND the origin
109
+ * answered — see the header.
110
+ */
111
+ export async function rollbackToOrigin(steps: RollbackSteps): Promise<RollbackResult> {
112
+ const sleep = steps.sleep ?? ((ms: number) => new Promise<void>((done) => setTimeout(done, ms)));
113
+ const attempts = steps.attempts ?? 60;
114
+ const started = await steps.startOrigin();
115
+ let up = false;
116
+ for (let attempt = 0; attempt < attempts && !up; attempt++) {
117
+ up = await steps.originUp();
118
+ if (!up && attempt + 1 < attempts) await sleep(steps.intervalMs ?? 1_000);
119
+ }
120
+ if (started !== 0 || !up) {
121
+ return {
122
+ ok: false,
123
+ stage: "origin",
124
+ detail:
125
+ `the origin ${started !== 0 ? `failed to start (exit ${started})` : "never answered"}. The route was NOT touched — ` +
126
+ "the hostname is still served by the Worker, which is the safer of the two. Fix the origin, then run this again.",
127
+ };
128
+ }
129
+ if (!(await steps.removeRoute())) {
130
+ return { ok: false, stage: "route", detail: "the origin is answering, and the route was NOT deleted — the Worker still serves the hostname." };
131
+ }
132
+ return { ok: true, stage: "done", detail: "the origin answers and the route is gone — prove it with a cache-busted /healthz, because a cached 200 is not a working origin." };
133
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * `cursedops/worker-secrets` — make a Worker hold EXACTLY the secrets its deployment names,
3
+ * uploaded over a pipe, and nothing else. The deployment's secret map, and which file it reads,
4
+ * stay data in the app.
5
+ *
6
+ * ```ts
7
+ * import { planSecrets, syncSecrets, restSecretOps, cloudflareApi, wranglerSecretPut } from "cursedops/worker-secrets";
8
+ *
9
+ * const api = cloudflareApi(cf.CLOUDFLARE_API_TOKEN);
10
+ * const ops = restSecretOps({ api, account: cf.CLOUDFLARE_ACCOUNT_ID, script: d.workerName,
11
+ * put: (name, value) => wranglerSecretPut(name, value, { cwd: ROOT, envArgs: wranglerEnvArgs(d.env), env: cf }) });
12
+ * const plan = planSecrets(d.secrets, await ops.held(), readEnvFile(readFileSync(file, "utf8")));
13
+ * if (plan.absent.length) refuse(`${file} has no ${plan.absent.join(", ")}`);
14
+ * const { ok, lines } = await syncSecrets(plan, ops);
15
+ * ```
16
+ *
17
+ * ## The two halves, and what each one cost
18
+ *
19
+ * 🔴 **Over a PIPE, never a command line.** `collections`' `MASTER_LOCK_SEED` is JSON carrying an
20
+ * argon2id PHC string (`$argon2id$v=19$m=65536,…`); anything that sources, interpolates or
21
+ * word-splits it produces valid JSON that verifies no password — the pilot lost its first upload
22
+ * to exactly that. A value on stdin has no shell, no `$` expansion, no argv limit, and stays out
23
+ * of `ps`. {@link wranglerSecretPut} is the only spelling here.
24
+ *
25
+ * 🔴 **"Nothing else" is the half that matters.** A secret left on the account from an earlier
26
+ * configuration is a credential nobody reads any more and anybody could still use — on `vault`
27
+ * it would have been a static SSO key beside the accounts service, a second door. So every name
28
+ * outside the set is DELETED and the run ends by READING THE ACCOUNT BACK and refusing unless it
29
+ * holds exactly the set. `vault` and `music` wrote that; `collections`' copy only ever put, and
30
+ * never noticed an extra — the drift this module ends.
31
+ *
32
+ * 🔴 **A value that is already single-quoted is written as is.** {@link envFileLines}:
33
+ * `collections`' seed serialiser returns `'{"kdf":…}'`, quoting it again wrote an escaped pair
34
+ * around it, `readEnvFile` stripped one layer, and the stage's master lock came up asking to
35
+ * ENROLL a new password (found 2026-09-22 by the stage walk, one step past sign-in).
36
+ */
37
+ import { spawnSync } from "node:child_process";
38
+ import { chmodSync, writeFileSync } from "node:fs";
39
+
40
+ /** Worker secret name → the key it is read from in the secrets file. A list means "same name". */
41
+ export type SecretMap = Readonly<Record<string, string>> | readonly string[];
42
+
43
+ export interface SecretPlan {
44
+ /** Every secret to put, with the value it will be given. */
45
+ put: Array<{ name: string; from: string; value: string }>;
46
+ /** Names the account holds that the set does not — to be DELETED. */
47
+ remove: string[];
48
+ /** Keys the file lacks. Non-empty means refuse before touching the account. */
49
+ absent: string[];
50
+ /** The set, by Worker name. */
51
+ wanted: string[];
52
+ }
53
+
54
+ function entries(map: SecretMap): Array<[name: string, from: string]> {
55
+ return Array.isArray(map) ? map.map((name) => [name, name]) : Object.entries(map as Record<string, string>);
56
+ }
57
+
58
+ /** What has to change for the account to hold exactly `map`, given what it `held` and the file's `values`. Pure. */
59
+ export function planSecrets(map: SecretMap, held: readonly string[], values: Readonly<Record<string, string>>): SecretPlan {
60
+ const pairs = entries(map);
61
+ const wanted = pairs.map(([name]) => name);
62
+ return {
63
+ put: pairs.filter(([, from]) => values[from]).map(([name, from]) => ({ name, from, value: values[from] as string })),
64
+ remove: held.filter((name) => !wanted.includes(name)),
65
+ absent: pairs.filter(([, from]) => !values[from]).map(([, from]) => from),
66
+ wanted,
67
+ };
68
+ }
69
+
70
+ /** How {@link syncSecrets} reaches the account. Injectable, so the exact-set rule is proved without one. */
71
+ export interface SecretOps {
72
+ /** The secret NAMES the deployed script holds — `[]` when there is no script yet. */
73
+ held: () => Promise<string[]>;
74
+ /** Put one secret, its value on stdin. */
75
+ put: (name: string, value: string) => boolean | Promise<boolean>;
76
+ /** Delete one secret. */
77
+ remove: (name: string) => Promise<boolean>;
78
+ }
79
+
80
+ export interface SecretSync {
81
+ ok: boolean;
82
+ /** One line per action, then the read-back verdict. */
83
+ lines: string[];
84
+ /** What the account holds afterwards. */
85
+ after: string[];
86
+ }
87
+
88
+ /**
89
+ * Apply `plan`: put every value, delete every extra, then READ THE ACCOUNT BACK and fail unless it
90
+ * holds exactly the set. Refuses without touching anything when the plan has `absent` keys.
91
+ */
92
+ export async function syncSecrets(plan: SecretPlan, ops: SecretOps): Promise<SecretSync> {
93
+ const lines: string[] = [];
94
+ if (plan.absent.length > 0) {
95
+ return { ok: false, lines: [`the secrets file has no ${plan.absent.join(" and no ")} — nothing was changed`], after: await ops.held() };
96
+ }
97
+ let bad = 0;
98
+ for (const { name, from, value } of plan.put) {
99
+ const ok = await ops.put(name, value);
100
+ lines.push(`put ${name}${from === name ? "" : ` (from ${from})`} (${value.length} bytes) — ${ok ? "ok" : "FAILED"}`);
101
+ if (!ok) bad++;
102
+ }
103
+ for (const name of plan.remove) {
104
+ const ok = await ops.remove(name);
105
+ lines.push(`DELETE ${name} — not in the set — ${ok ? "gone" : "FAILED"}`);
106
+ if (!ok) bad++;
107
+ }
108
+ const after = await ops.held();
109
+ const exact = after.length === plan.wanted.length && plan.wanted.every((name) => after.includes(name));
110
+ lines.push(exact ? `holds exactly: ${after.join(", ")}` : `🔴 holds ${after.join(", ") || "(nothing)"} — not exactly ${plan.wanted.join(", ")}`);
111
+ return { ok: exact && bad === 0, lines, after };
112
+ }
113
+
114
+ /** The Cloudflare v4 API's envelope — only what the callers here read. */
115
+ export interface ApiResult {
116
+ success?: boolean;
117
+ result?: unknown;
118
+ errors?: unknown;
119
+ }
120
+
121
+ export type CloudflareApi = (path: string, init?: RequestInit) => Promise<ApiResult>;
122
+
123
+ /** A Cloudflare v4 API caller for `token`. `fetch` injectable for tests. */
124
+ export function cloudflareApi(token: string, send: typeof fetch = fetch): CloudflareApi {
125
+ return async (path, init = {}) => {
126
+ const response = await send(`https://api.cloudflare.com/client/v4${path}`, {
127
+ ...init,
128
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
129
+ signal: init.signal ?? AbortSignal.timeout(30_000),
130
+ });
131
+ return (await response.json().catch(() => ({ success: false, errors: [`HTTP ${response.status}, not JSON`] }))) as ApiResult;
132
+ };
133
+ }
134
+
135
+ /**
136
+ * {@link SecretOps} over the REST API (list and delete) plus a `put` the caller supplies —
137
+ * normally {@link wranglerSecretPut}, because wrangler is what creates a version for a secret.
138
+ */
139
+ export function restSecretOps(options: { api: CloudflareApi; account: string; script: string; put: SecretOps["put"] }): SecretOps {
140
+ const base = `/accounts/${options.account}/workers/scripts/${options.script}/secrets`;
141
+ return {
142
+ held: async () => {
143
+ const res = await options.api(base);
144
+ return res.success ? ((res.result as Array<{ name: string }> | undefined) ?? []).map((s) => s.name) : [];
145
+ },
146
+ put: options.put,
147
+ remove: async (name) => (await options.api(`${base}/${encodeURIComponent(name)}`, { method: "DELETE" })).success === true,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * `wrangler secret put <name>` with the value on STDIN. See the header. `spawn` injectable.
153
+ */
154
+ export function wranglerSecretPut(
155
+ name: string,
156
+ value: string,
157
+ options: {
158
+ cwd: string;
159
+ envArgs: readonly string[];
160
+ env?: Record<string, string>;
161
+ spawn?: (argv: readonly string[], input: string) => { status: number | null; stderr?: string };
162
+ },
163
+ ): boolean {
164
+ const argv = ["bunx", "wrangler", "secret", "put", name, ...options.envArgs];
165
+ const spawn =
166
+ options.spawn ??
167
+ ((a: readonly string[], input: string) =>
168
+ spawnSync(a[0] as string, a.slice(1), { cwd: options.cwd, input, encoding: "utf8", env: { ...process.env, ...(options.env ?? {}) } }));
169
+ const ran = spawn(argv, value);
170
+ if ((ran.status ?? 1) !== 0 && ran.stderr) console.error(ran.stderr);
171
+ return (ran.status ?? 1) === 0;
172
+ }
173
+
174
+ /**
175
+ * `KEY='value'` lines for a 0600 env file. A value that already carries its own single quotes is
176
+ * written AS IS; any other `'` is escaped the POSIX way. See the header for what double-quoting cost.
177
+ */
178
+ export function envFileLines(values: Readonly<Record<string, string>>): string {
179
+ return `${Object.entries(values)
180
+ .map(([key, value]) =>
181
+ value.length > 1 && value.startsWith("'") && value.endsWith("'") ? `${key}=${value}` : `${key}='${value.replaceAll("'", "'\\''")}'`,
182
+ )
183
+ .join("\n")}\n`;
184
+ }
185
+
186
+ /** Write a secrets file 0600 — `header` lines become `# ` comments. Never committed, never world-readable. */
187
+ export function writeSecretsFile(path: string, header: readonly string[], values: Readonly<Record<string, string>>): void {
188
+ writeFileSync(path, `${header.map((line) => `# ${line}`.trimEnd()).join("\n")}\n${envFileLines(values)}`, { encoding: "utf8", mode: 0o600 });
189
+ chmodSync(path, 0o600);
190
+ }
191
+
192
+ /** A throwaway secret: 256 bits, hex. For a session key that opens nothing the owner owns. */
193
+ export function throwawaySecret(): string {
194
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex");
195
+ }