cursedops 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,7 @@ import shape, worked example, and the incident each one is pinned to.
35
35
  | `cursedops/relay-link` | the MAC half — `dialLink`, `readLinkSettings`, `rotateLinkKey` |
36
36
  | `cursedops/relay-stage` | the signed-in STAGE WALK's skeleton both relay apps shared |
37
37
  | `cursedops/public-client` | does a relay app's PUBLIC hostname serve the client this release built? — `comparePublicClient`, `recordPublicClient`, `shipClientIfBehind` |
38
+ | `cursedops/container-deploy` | a Cloudflare CONTAINER Worker deployed and proven — the env-file secret lines, Docker on PATH and up, `/healthz`, and `awaitRollout`: the container on the new image, not just the Worker answering |
38
39
  | `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 |
39
40
 
40
41
  Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -152,6 +152,12 @@
152
152
  "source": "./src/relayStage.ts",
153
153
  "import": "./src/relayStage.ts"
154
154
  },
155
+ "./container-deploy": {
156
+ "types": "./src/containerDeploy.ts",
157
+ "bun": "./src/containerDeploy.ts",
158
+ "source": "./src/containerDeploy.ts",
159
+ "import": "./src/containerDeploy.ts"
160
+ },
155
161
  "./public-client": {
156
162
  "types": "./src/publicClient.ts",
157
163
  "bun": "./src/publicClient.ts",
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `cursedops/container-deploy` — the pieces of deploying a Cloudflare CONTAINER Worker (a Worker
3
+ * fronting a Docker image) and proving it landed, which binary-server's `runner:deploy` (task 2723)
4
+ * wrote and music's `agent:deploy` (task 2816) copied body for body.
5
+ *
6
+ * ```ts
7
+ * import { awaitRollout, dockerUp, healthz, newestRollout, readEnvLine, withDockerBin, workersDevUrl, writeEnvLine } from "cursedops/container-deploy";
8
+ *
9
+ * const env = { ...process.env, PATH: withDockerBin(process.env.PATH) };
10
+ * const url = await workersDevUrl(account, apiToken, "thing-runner");
11
+ * const app = { account, token: apiToken, application: "thing-runner-encoder" };
12
+ * const before = await newestRollout(app);
13
+ * await dockerUp(env, "the runner");
14
+ * // … wrangler deploy, then healthz(url, token) until it answers at a new version …
15
+ * const { verdict } = await awaitRollout({ ...app, before, poke: () => startAnInstance(url), label: "encoder" });
16
+ * ```
17
+ *
18
+ * 🔴 **A Worker answering is not the container being on the new image.** Cloudflare rolls the
19
+ * container out separately and gradually, and a rollout only moves while an instance starts — so
20
+ * `awaitRollout` pokes one every time it looks (measured 2026-10-05: the Worker's version was live
21
+ * while the instances still ran the previous image).
22
+ *
23
+ * 🔴 **An application the deploy CREATED has no rollout at all** — it is born at version 1 with the
24
+ * image (`/rollouts` answered `[]` on music's first deploy), so waiting for one would wait the whole
25
+ * twenty minutes and then call a good deploy failed. `rolloutVerdict` says `created`.
26
+ *
27
+ * 🔴 **A network blip while looking is a look that failed, not a deploy that failed** — one
28
+ * "unknown certificate verification error" threw a landed deploy red. `awaitRollout` looks again.
29
+ */
30
+ import { spawnSync } from "node:child_process";
31
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
32
+
33
+ /** The value of `key` in an env file's text, unquoted; `""` when it is not there. Pure. */
34
+ export function readEnvLine(text: string, key: string): string {
35
+ return new RegExp(`^${key}=(.*)$`, "m").exec(text)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
36
+ }
37
+
38
+ /** Set `key=value` in an env file's text, replacing a line already there. Pure. */
39
+ export function withEnvLine(text: string, key: string, value: string): string {
40
+ const line = `${key}=${value}`;
41
+ const re = new RegExp(`^${key}=.*$`, "m");
42
+ if (re.test(text)) return text.replace(re, line);
43
+ return `${text}${text.endsWith("\n") || text === "" ? "" : "\n"}${line}\n`;
44
+ }
45
+
46
+ /** Set `key=value` in the secrets file at `path` (created if missing), owner-only. */
47
+ export function writeEnvLine(path: string, key: string, value: string): void {
48
+ writeFileSync(path, withEnvLine(existsSync(path) ? readFileSync(path, "utf8") : "", key, value));
49
+ chmodSync(path, 0o600);
50
+ }
51
+
52
+ export const DOCKER_BUNDLE_BIN = "/Applications/Docker.app/Contents/Resources/bin";
53
+
54
+ /**
55
+ * Docker Desktop's own helpers (`docker-credential-desktop`, which every image pull asks for) live
56
+ * in its app bundle, which a non-login shell's PATH does not hold — measured 2026-10-05: the first
57
+ * build failed "executable file not found in $PATH" on its first `FROM`. Pure.
58
+ */
59
+ export function withDockerBin(path: string | undefined, bundleBin = DOCKER_BUNDLE_BIN): string {
60
+ const parts = (path ?? "").split(":").filter(Boolean);
61
+ return parts.includes(bundleBin) ? parts.join(":") : [...parts, bundleBin].join(":");
62
+ }
63
+
64
+ /** Docker up, opening Docker Desktop if it is not — wrangler builds the image with it. `what` names the image for the error. */
65
+ export async function dockerUp(env: NodeJS.ProcessEnv, what: string): Promise<void> {
66
+ const up = (): boolean => spawnSync("docker", ["info"], { stdio: "ignore", env }).status === 0;
67
+ if (up()) return;
68
+ console.log("Docker is not running — opening it…");
69
+ spawnSync("open", ["-a", "Docker"]);
70
+ for (let i = 0; i < 60 && !up(); i++) await Bun.sleep(2000);
71
+ if (!up()) throw new Error(`Docker did not come up in two minutes; wrangler builds ${what}'s image with it`);
72
+ }
73
+
74
+ /** `GET /healthz`, with the bearer token or without. Status 0 when nothing answered. */
75
+ export async function healthz(url: string, token: string | null): Promise<{ status: number; version: string | null }> {
76
+ try {
77
+ const res = await fetch(`${url}/healthz`, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(10_000) });
78
+ const body = (await res.json().catch(() => null)) as { version?: string } | null;
79
+ return { status: res.status, version: body?.version ?? null };
80
+ } catch {
81
+ return { status: 0, version: null };
82
+ }
83
+ }
84
+
85
+ /** The Worker's `https://<script>.<account subdomain>.workers.dev` address. */
86
+ export async function workersDevUrl(account: string, token: string, script: string): Promise<string> {
87
+ const sub = (await (
88
+ await fetch(`https://api.cloudflare.com/client/v4/accounts/${account}/workers/subdomain`, { headers: { authorization: `Bearer ${token}` } })
89
+ ).json()) as { result?: { subdomain?: string } };
90
+ if (!sub.result?.subdomain) throw new Error("could not read the account's workers.dev subdomain");
91
+ return `https://${script}.${sub.result.subdomain}.workers.dev`;
92
+ }
93
+
94
+ export type ContainerRollout = { id: string; created_at: string; status: string; target_configuration?: { image?: string } };
95
+
96
+ /** Which container application, on which account, read with which API token. */
97
+ export interface ContainerApplication {
98
+ account: string;
99
+ token: string;
100
+ /** The application's name — wrangler names it `<script>-<class name, lowercased>`. */
101
+ application: string;
102
+ }
103
+
104
+ /** The most recently created rollout. Pure. */
105
+ export function newestOf(rollouts: readonly ContainerRollout[]): ContainerRollout | null {
106
+ return [...rollouts].sort((a, b) => b.created_at.localeCompare(a.created_at))[0] ?? null;
107
+ }
108
+
109
+ /** The application's newest rollout: `undefined` before the application exists, `null` for one with none. */
110
+ export async function newestRollout(app: ContainerApplication, fetchImpl: typeof fetch = fetch): Promise<ContainerRollout | null | undefined> {
111
+ const api = `https://api.cloudflare.com/client/v4/accounts/${app.account}/containers/applications`;
112
+ const headers = { authorization: `Bearer ${app.token}` };
113
+ const apps = (await (await fetchImpl(api, { headers })).json()) as { result?: { id: string; name: string }[] };
114
+ const found = apps.result?.find((a) => a.name === app.application);
115
+ if (!found) return undefined;
116
+ const rollouts = (await (await fetchImpl(`${api}/${found.id}/rollouts`, { headers })).json()) as { result?: ContainerRollout[] };
117
+ return newestOf(rollouts.result ?? []);
118
+ }
119
+
120
+ export type RolloutVerdict = "new-image" | "unchanged" | "created" | "waiting";
121
+
122
+ /** Is the container on the image this deploy pushed, given the newest rollout before it and now? Pure. */
123
+ export function rolloutVerdict(before: ContainerRollout | null | undefined, now: ContainerRollout | null | undefined): RolloutVerdict {
124
+ if (before === undefined && now === null) return "created";
125
+ // An application that has never rolled out, and still has not: the deploy changed no image.
126
+ if (before === null && now === null) return "unchanged";
127
+ if (!now) return "waiting";
128
+ if (now.id === before?.id) return now.status === "completed" ? "unchanged" : "waiting";
129
+ return now.status === "completed" ? "new-image" : "waiting";
130
+ }
131
+
132
+ export interface AwaitRolloutOptions extends ContainerApplication {
133
+ /** The newest rollout read BEFORE `wrangler deploy`. */
134
+ before: ContainerRollout | null | undefined;
135
+ /** Start an instance — a rollout only moves while one starts. Must not throw. */
136
+ poke: () => Promise<void>;
137
+ /** What the container is called in the log and the error ("encoder", "recorder"). */
138
+ label: string;
139
+ timeoutMs?: number;
140
+ intervalMs?: number;
141
+ /** For tests. */
142
+ read?: (app: ContainerApplication) => Promise<ContainerRollout | null | undefined>;
143
+ sleep?: (ms: number) => Promise<void>;
144
+ now?: () => number;
145
+ log?: (line: string) => void;
146
+ }
147
+
148
+ /** Wait until the rollout settles; throws when it has not in `timeoutMs` (twenty minutes). */
149
+ export async function awaitRollout(o: AwaitRolloutOptions): Promise<{ verdict: Exclude<RolloutVerdict, "waiting">; rollout: ContainerRollout | null | undefined }> {
150
+ const read = o.read ?? ((app: ContainerApplication) => newestRollout(app));
151
+ const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
152
+ const now = o.now ?? Date.now;
153
+ const log = o.log ?? console.log;
154
+ const interval = o.intervalMs ?? 10_000;
155
+ const until = now() + (o.timeoutMs ?? 20 * 60_000);
156
+ let last = "";
157
+ while (now() < until) {
158
+ let r: ContainerRollout | null | undefined;
159
+ try {
160
+ r = await read(o);
161
+ } catch (e) {
162
+ log(` (could not read the ${o.label} rollout: ${e instanceof Error ? e.message : String(e)} — looking again)`);
163
+ await sleep(interval);
164
+ continue;
165
+ }
166
+ const verdict = rolloutVerdict(o.before, r);
167
+ if (verdict !== "waiting") return { verdict, rollout: r };
168
+ if (r && r.status !== last) log(` ${o.label} rollout ${r.id.slice(0, 8)}: ${r.status}`);
169
+ last = r?.status ?? "";
170
+ await o.poke();
171
+ await sleep(interval);
172
+ }
173
+ throw new Error(`the ${o.label}'s rollout did not complete in ${Math.round((o.timeoutMs ?? 20 * 60_000) / 60_000)} minutes — the instances may still run the previous image`);
174
+ }