cursedops 0.1.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 ADDED
@@ -0,0 +1,124 @@
1
+ # cursedops
2
+
3
+ Three build-and-ops answers that two apps independently wrote the same way.
4
+
5
+ ```sh
6
+ bun add cursedops
7
+ ```
8
+
9
+ | subpath | what it is |
10
+ |---|---|
11
+ | `cursedops/roots` | finding a generation's roots, and a checkout's package root, without knowing a path |
12
+ | `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent |
13
+ | `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code |
14
+
15
+ Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
16
+ name, a hostname, a port or a route.
17
+
18
+ ## šŸ”“ The ceiling, and why this library has one
19
+
20
+ This exists because `apps/desk` and `apps/flix` answered the same six build-and-ops
21
+ questions independently, and **three of the six came out the same**. It does not exist
22
+ because sharing is good.
23
+
24
+ The library it replaces reached **277 files** in a previous generation, on the reasoning
25
+ that a module used by 8 of 8 apps is a module worth sharing. What that produced was an
26
+ app whose entire `vite.config.ts` was one line it could not read, a build system no app
27
+ could opt out of, and a monorepo that could not be relocated. Every one of those 277
28
+ files was individually defensible.
29
+
30
+ So the entry rule here is arithmetic, not judgement:
31
+
32
+ 1. **Two apps wrote it independently.** Not one app and a plan for a second — two
33
+ working implementations, in the tree, that a `diff` says are the same.
34
+ 2. **It is mechanism, not identity.** If a function would need to know an app's name,
35
+ port, hostname, routes or health-payload shape, it belongs in the app. Every
36
+ function here takes those as arguments or does not see them at all.
37
+ 3. **It is a trap somebody paid for.** Each thing in here is a measured incident turned
38
+ into a check — the file headers carry the dates and the costs. A convenience that
39
+ has never prevented anything is not a reason to add a dependency to eight apps.
40
+
41
+ A fourth rule follows from the first three: **an addition that only one app will use
42
+ does not go in.** That is a library that exists to be refactored later.
43
+
44
+ ### What was deliberately left in the apps
45
+
46
+ The comparison that produced this library found three areas that are genuinely
47
+ different and must stay that way. `tasks/Procedures/graduate-an-app.md` in the
48
+ generation repo carries the full verdict table, because the next app to graduate is who
49
+ needs to read it:
50
+
51
+ - **the build** — `desk` compiles a single binary with `bun build --compile`; `flix` is
52
+ a Vite SPA with an asset budget. Not the same problem.
53
+ - **the supervisor** — `desk` runs a watcher that rebuilds on a commit; `flix`
54
+ deliberately has none, and its deploy reinstalls the agent instead. That difference
55
+ changes what a rollback *is* in each app.
56
+ - **the app config** — how an app decides it is deployed, and what that turns on, is the
57
+ app.
58
+
59
+ `deploy.ts` is a fourth: the two scripts share a shape and roughly thirty lines of
60
+ helpers, but they sequence genuinely different steps and a shared deploy driver is
61
+ exactly the 277-file mistake starting again. What they share instead is the **exit-code
62
+ contract** in `cursedops/smoke` — `0` keep, `1` roll back, `2` edge fault, do not roll
63
+ back — which is the only part both deploy scripts actually read.
64
+
65
+ ## `cursedops/roots`
66
+
67
+ ```ts
68
+ import { forgeRootFrom, packageRootFrom, requireForgeState } from "cursedops/roots";
69
+
70
+ const generation = forgeRootFrom(import.meta.url); // string | null
71
+ const checkout = packageRootFrom(import.meta.url); // string | null
72
+ const state = requireForgeState(import.meta.dir); // string, or a thrown message
73
+ ```
74
+
75
+ A generation is defined by a `forge.env` above the checkout — the same rule its
76
+ `check-paths` and its runner use, so the three cannot disagree. Everything returns
77
+ `null` rather than guessing, because a function that invented a root would write files
78
+ into a stranger's tree.
79
+
80
+ šŸ”“ The state root is **read out of `forge.env`**, never spelled here. A published
81
+ library that hardcoded `$HOME/.<name>` would be correct for exactly one generation and
82
+ silently wrong for its successor — which is the defect this replaced.
83
+
84
+ ## `cursedops/launchd`
85
+
86
+ ```ts
87
+ import { renderPlist, replaceAgent, answering, declaredBy } from "cursedops/launchd";
88
+
89
+ const conflict = await declaredBy(LABEL, "managed-by=olddeployer");
90
+ if (conflict) throw new Error(`a retired deployer still declares ${LABEL}: ${conflict}`);
91
+
92
+ const { boot } = await replaceAgent(LABEL, renderPlist(spec), { also: LEGACY_LABELS });
93
+ if (boot.code !== 0) throw new Error(boot.out);
94
+ if (!(await answering(`http://127.0.0.1:${port}/healthz`))) throw new Error("a pid is not a service");
95
+ ```
96
+
97
+ `RunAtLoad` and `KeepAlive` are **not defaulted** — a server wants both, a nightly
98
+ snapshot wants neither, and a kit that decides gets one of them wrong.
99
+
100
+ ## `cursedops/smoke`
101
+
102
+ ```ts
103
+ import { createSmoke, guarded } from "cursedops/smoke";
104
+
105
+ const smoke = createSmoke({ base: process.env.MYAPP_SMOKE_URL ?? "https://…" });
106
+ await guarded(smoke, "gate", async () => {
107
+ const response = await smoke.anonymous("/api/v1/private");
108
+ smoke.record("gate", response.status === 401, `anonymous → ${response.status}`);
109
+ });
110
+ smoke.finish(); // exits 0, 1 or 2
111
+ ```
112
+
113
+ No checks, ever. The checks are the part `desk` and `flix` wrote differently on purpose,
114
+ and a shared kit that starts absorbing route lists and health-payload shapes is how the
115
+ last one reached 277 files.
116
+
117
+ ## Verifying
118
+
119
+ ```sh
120
+ cd "$FORGE/libs/cursedops" && bun run verify
121
+ ```
122
+
123
+ `paths`, `typecheck`, `lint`, then the suite. It runs as `prepublishOnly`, so a publish
124
+ cannot go out around it.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "cursedops",
3
+ "version": "0.1.0",
4
+ "description": "The three build-and-ops answers two apps independently wrote the same way: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, and the scaffolding of a deployed smoke. Mechanism only — no app knows its name from here. Bun, zero dependencies, ships source.",
5
+ "type": "module",
6
+ "scripts": {
7
+ "typecheck": "tsc -p tsconfig.json --noEmit",
8
+ "lint": "biome check .",
9
+ "test": "bun test src",
10
+ "paths": "bun ../../tools/check-paths.ts .",
11
+ "verify": "bun run paths && bun run typecheck && bun run lint && bun run test",
12
+ "prepublishOnly": "bun run verify"
13
+ },
14
+ "exports": {
15
+ "./roots": {
16
+ "types": "./src/roots.ts",
17
+ "bun": "./src/roots.ts",
18
+ "source": "./src/roots.ts",
19
+ "import": "./src/roots.ts"
20
+ },
21
+ "./launchd": {
22
+ "types": "./src/launchd.ts",
23
+ "bun": "./src/launchd.ts",
24
+ "source": "./src/launchd.ts",
25
+ "import": "./src/launchd.ts"
26
+ },
27
+ "./smoke": {
28
+ "types": "./src/smoke.ts",
29
+ "bun": "./src/smoke.ts",
30
+ "source": "./src/smoke.ts",
31
+ "import": "./src/smoke.ts"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "!src/**/*.test.ts"
38
+ ],
39
+ "devDependencies": {
40
+ "@biomejs/biome": "^2.3.14",
41
+ "@types/bun": "^1.3.14",
42
+ "typescript": "^5.7.2"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "license": "MIT"
48
+ }
package/src/launchd.ts ADDED
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Installing, replacing and removing a macOS launchd user agent — the mechanism
3
+ * only, never the spec.
4
+ *
5
+ * ## What is here and what is deliberately not
6
+ *
7
+ * Two apps wrote this twice (`apps/desk/src/cli/service.ts`,
8
+ * `apps/flix/scripts/service.ts`). Measured 2026-09-16, {@link waitForLabelGone}
9
+ * was character-identical between them and {@link bootstrapWithRetry} differed
10
+ * only in how it spelled the same default. What was NOT the same — the labels, the
11
+ * environment, the command, which legacy names to sweep, whether there is a second
12
+ * job — is the part each app legitimately owns, and none of it is in here.
13
+ *
14
+ * šŸ”“ That split is the whole design. The previous generation's shared kit reached
15
+ * 277 files because "every app uses it" was treated as sufficient reason to share,
16
+ * and the result was an app whose entire deployment was one line it could not read.
17
+ * The line here: **launchd's traps are shared, an app's identity is not.** If a
18
+ * function in this file would need to know an app's name, it belongs in the app.
19
+ *
20
+ * ## The four traps, each of which was paid for
21
+ *
22
+ * šŸ”“ **A green install over a crash-looping agent.** `cursedesk service install`
23
+ * printed `running (pid 51073)` over an agent dying on every restart: launchd ran
24
+ * it as `…/node_modules/bun/bin/bun.exe`, a directory holding `bun.exe` and no
25
+ * `bun`, so every `Bun.spawn(["bun", …])` died with ENOENT. {@link answering} is
26
+ * why a pid is never reported as a service — and {@link launchPathEntries} is why
27
+ * the shim's real home goes on the agent's PATH rather than only `dirname(bun)`.
28
+ *
29
+ * šŸ”“ **`bootout` returning is not the job being gone.** launchd tears a job down
30
+ * asynchronously and bootstrapping into the gap answers `Bootstrap failed: 5:
31
+ * Input/output error`, a message about nothing. An `install` that reinstalls every
32
+ * time — which is every deploy — fails on its SECOND run without
33
+ * {@link waitForLabelGone}. Measured 2026-09-13 on a first real deploy.
34
+ *
35
+ * šŸ”“ **Deleting the plist before booting the job out** leaves launchd holding a job
36
+ * whose definition is gone, which nothing left on the machine can name. That is
37
+ * the one state here that needs a reboot to leave, and {@link clearAgents} is
38
+ * ordered so it cannot happen.
39
+ *
40
+ * šŸ”“ **A bootout that a sync job overrules is not a removal.** A retired
41
+ * generation's deployer re-renders every spec its apps declare and bootstraps
42
+ * whatever is missing — measured 2026-09-13, a label came back eleven minutes
43
+ * after being booted out and having its plist moved away. Only deleting the
44
+ * DECLARATION held, so {@link declaredBy} lets an install refuse to proceed while
45
+ * a foreign declaration for its label still exists.
46
+ *
47
+ * ## šŸ”“ Secrets are referenced, never inlined
48
+ *
49
+ * `~/Library/LaunchAgents` is an ordinary readable directory. Anything secret
50
+ * belongs in a 0600 file the agent SOURCES; {@link sourcedCommand} builds that
51
+ * one-liner, and a consumer should pin it with a test that fails if a secret value
52
+ * ever reaches the plist text.
53
+ */
54
+
55
+ import { spawnSync } from "node:child_process";
56
+ import { existsSync } from "node:fs";
57
+ import { readFile, rm, writeFile } from "node:fs/promises";
58
+ import { homedir } from "node:os";
59
+ import { dirname, join } from "node:path";
60
+
61
+ export interface CommandResult {
62
+ code: number;
63
+ out: string;
64
+ }
65
+
66
+ /**
67
+ * How this module reaches launchd and the LaunchAgents directory.
68
+ *
69
+ * Injectable as one object because the interesting behaviour — clearing a label
70
+ * and *waiting* for it to go — cannot otherwise be reached from a test that does
71
+ * not install a real login agent first, and a test nobody runs is not a test.
72
+ */
73
+ export interface LaunchdOps {
74
+ run: (...args: string[]) => CommandResult;
75
+ domain: string;
76
+ plistFor: (label: string) => string;
77
+ exists: (path: string) => boolean;
78
+ read: (path: string) => Promise<string>;
79
+ write: (path: string, body: string) => Promise<void>;
80
+ remove: (path: string) => Promise<void>;
81
+ sleep: (ms: number) => Promise<void>;
82
+ }
83
+
84
+ /** Where launchd insists on finding a user agent's declaration. */
85
+ export function plistPath(label: string): string {
86
+ return join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
87
+ }
88
+
89
+ /** The user launchd domain everything here talks to. */
90
+ export function guiDomain(): string {
91
+ return `gui/${process.getuid?.() ?? 501}`;
92
+ }
93
+
94
+ export function realOps(overrides: Partial<LaunchdOps> = {}): LaunchdOps {
95
+ return {
96
+ run: (...args) => {
97
+ const result = spawnSync("launchctl", args, { encoding: "utf8" });
98
+ return { code: result.status ?? 1, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
99
+ },
100
+ domain: guiDomain(),
101
+ plistFor: plistPath,
102
+ exists: existsSync,
103
+ read: (path) => readFile(path, "utf8"),
104
+ write: async (path, body) => {
105
+ await writeFile(path, body, "utf8");
106
+ },
107
+ remove: async (path) => {
108
+ await rm(path, { force: true });
109
+ },
110
+ sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
111
+ ...overrides,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * The PATH an agent needs, given the absolute runtime it will run.
117
+ *
118
+ * šŸ”“ `dirname(bun)` alone is NOT enough — see the ENOENT trap in the header. The
119
+ * shim's real home goes on the list too, and a caller's own spawns should use an
120
+ * absolute runtime regardless rather than merely being luckier.
121
+ */
122
+ export function launchPathEntries(runtime: string): string[] {
123
+ return [dirname(runtime), join(homedir(), ".bun", "bin"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
124
+ }
125
+
126
+ /** {@link launchPathEntries}, joined the way a `PATH` variable wants it. */
127
+ export function launchPath(runtime: string): string {
128
+ return launchPathEntries(runtime).join(":");
129
+ }
130
+
131
+ export function escapeXml(value: string): string {
132
+ return value.replace(/[<>&'"]/g, (char) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[char] as string);
133
+ }
134
+
135
+ /** Single-quote a string for `/bin/sh -c`, closing and reopening around any quote. */
136
+ export function shellQuote(value: string): string {
137
+ return `'${value.replace(/'/g, `'\\''`)}'`;
138
+ }
139
+
140
+ /**
141
+ * The one-liner for an agent whose credentials live in a 0600 file.
142
+ *
143
+ * `set -a` exports everything the file defines; `[ -f … ]` keeps a missing file
144
+ * from being a syntax error, because an app's OWN boot guard gives a far better
145
+ * message about a missing credential than `bash` does about a missing file. `exec`
146
+ * replaces the shell so launchd's `KeepAlive` supervises the real process rather
147
+ * than a wrapper that would outlive its crash.
148
+ */
149
+ export function sourcedCommand(secretsFile: string, argv: readonly string[]): string {
150
+ const command = argv.map(shellQuote).join(" ");
151
+ return `set -a; [ -f ${shellQuote(secretsFile)} ] && . ${shellQuote(secretsFile)}; set +a; exec ${command}`;
152
+ }
153
+
154
+ export interface CalendarInterval {
155
+ Minute?: number;
156
+ Hour?: number;
157
+ Day?: number;
158
+ Month?: number;
159
+ Weekday?: number;
160
+ }
161
+
162
+ export interface PlistSpec {
163
+ label: string;
164
+ programArguments: readonly string[];
165
+ workingDirectory: string;
166
+ environment?: Record<string, string>;
167
+ standardOutPath?: string;
168
+ standardErrorPath?: string;
169
+ runAtLoad?: boolean;
170
+ keepAlive?: boolean;
171
+ throttleInterval?: number;
172
+ processType?: string;
173
+ startCalendarInterval?: CalendarInterval;
174
+ /**
175
+ * The provenance comment written at the top of the file.
176
+ *
177
+ * šŸ”“ Not decoration. A machine that has run more than one generation carries
178
+ * plists from each, and the ONLY way to tell whose a file is — before deciding
179
+ * whether deleting it is safe — is a marker in its text. {@link declaredBy}
180
+ * reads exactly this.
181
+ */
182
+ managedBy?: string;
183
+ /** Recorded beside `managedBy`, so `grep` over LaunchAgents answers "whose is this". */
184
+ app?: string;
185
+ }
186
+
187
+ function entries(record: Record<string, string>, indent: string): string {
188
+ return Object.entries(record)
189
+ .map(([key, value]) => `${indent}<key>${escapeXml(key)}</key>\n${indent}<string>${escapeXml(value)}</string>`)
190
+ .join("\n");
191
+ }
192
+
193
+ function integers(record: Record<string, number>, indent: string): string {
194
+ return Object.entries(record)
195
+ .map(([key, value]) => `${indent}<key>${escapeXml(key)}</key>\n${indent}<integer>${value}</integer>`)
196
+ .join("\n");
197
+ }
198
+
199
+ /**
200
+ * Render a launchd agent declaration.
201
+ *
202
+ * Every key is optional except the three that make a job a job, because the
203
+ * alternative — a spec object with defaults for `KeepAlive` and `RunAtLoad` —
204
+ * quietly decides the most consequential property of a job on a consumer's behalf.
205
+ * A long-running server wants both; a nightly snapshot wants neither, and one of
206
+ * those two being wrong is an app that either never starts or never stops.
207
+ */
208
+ export function renderPlist(spec: PlistSpec): string {
209
+ const lines: string[] = [];
210
+ lines.push(`\t<key>Label</key>`, `\t<string>${escapeXml(spec.label)}</string>`);
211
+ lines.push(`\t<key>ProgramArguments</key>`, `\t<array>`);
212
+ for (const argument of spec.programArguments) lines.push(`\t\t<string>${escapeXml(argument)}</string>`);
213
+ lines.push(`\t</array>`);
214
+ lines.push(`\t<key>WorkingDirectory</key>`, `\t<string>${escapeXml(spec.workingDirectory)}</string>`);
215
+ if (spec.environment && Object.keys(spec.environment).length > 0) {
216
+ lines.push(`\t<key>EnvironmentVariables</key>`, `\t<dict>`, entries(spec.environment, "\t\t"), `\t</dict>`);
217
+ }
218
+ if (spec.startCalendarInterval) {
219
+ const defined = Object.fromEntries(Object.entries(spec.startCalendarInterval).filter(([, value]) => typeof value === "number")) as Record<string, number>;
220
+ lines.push(`\t<key>StartCalendarInterval</key>`, `\t<dict>`, integers(defined, "\t\t"), `\t</dict>`);
221
+ }
222
+ if (spec.runAtLoad !== undefined) lines.push(`\t<key>RunAtLoad</key>`, `\t<${spec.runAtLoad}/>`);
223
+ if (spec.keepAlive !== undefined) lines.push(`\t<key>KeepAlive</key>`, `\t<${spec.keepAlive}/>`);
224
+ if (spec.throttleInterval !== undefined) lines.push(`\t<key>ThrottleInterval</key>`, `\t<integer>${spec.throttleInterval}</integer>`);
225
+ if (spec.standardOutPath) lines.push(`\t<key>StandardOutPath</key>`, `\t<string>${escapeXml(spec.standardOutPath)}</string>`);
226
+ if (spec.standardErrorPath) lines.push(`\t<key>StandardErrorPath</key>`, `\t<string>${escapeXml(spec.standardErrorPath)}</string>`);
227
+ if (spec.processType) lines.push(`\t<key>ProcessType</key>`, `\t<string>${escapeXml(spec.processType)}</string>`);
228
+
229
+ const provenance = spec.managedBy ? `<!-- managed-by=${escapeXml(spec.managedBy)} label=${escapeXml(spec.label)}${spec.app ? ` app=${escapeXml(spec.app)}` : ""} -->\n` : "";
230
+ return `<?xml version="1.0" encoding="UTF-8"?>
231
+ ${provenance}<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
232
+ <plist version="1.0">
233
+ <dict>
234
+ ${lines.join("\n")}
235
+ </dict>
236
+ </plist>
237
+ `;
238
+ }
239
+
240
+ /**
241
+ * The agent's pid, or `null` when launchd has the job but nothing is running.
242
+ *
243
+ * A pid is the only honest evidence that a job STARTED: an agent can be loaded and
244
+ * failing to start on every attempt, and `launchctl print` reports that without
245
+ * complaining. It is still not evidence that the app is SERVING — that is
246
+ * {@link answering}.
247
+ */
248
+ export function agentPid(label: string, ops: Partial<LaunchdOps> = {}): string | null {
249
+ const { run, domain } = realOps(ops);
250
+ const printed = run("print", `${domain}/${label}`);
251
+ if (printed.code !== 0) return null;
252
+ return /\bpid = (\d+)/.exec(printed.out)?.[1] ?? null;
253
+ }
254
+
255
+ /** What a machine actually carries for one label. */
256
+ export interface AgentPresence {
257
+ label: string;
258
+ path: string;
259
+ /** The plist is in `~/Library/LaunchAgents`. */
260
+ onDisk: boolean;
261
+ /** launchd has the job, whether or not anything is running under it. */
262
+ loaded: boolean;
263
+ }
264
+
265
+ /**
266
+ * Every label in `labels` this machine carries an agent under, in the order given.
267
+ *
268
+ * Both halves matter and they come apart: a hand-edited machine can have a plist
269
+ * with nothing loaded, and an `rm` without a `bootout` leaves a job loaded with no
270
+ * file behind it. Either one is an agent, and either one has to be cleared before
271
+ * a new one is bootstrapped.
272
+ */
273
+ export function findAgents(labels: readonly string[], ops: Partial<LaunchdOps> = {}): AgentPresence[] {
274
+ const { run, domain, exists, plistFor } = realOps(ops);
275
+ return labels
276
+ .map((label) => {
277
+ const path = plistFor(label);
278
+ return { label, path, onDisk: exists(path), loaded: run("print", `${domain}/${label}`).code === 0 };
279
+ })
280
+ .filter((found) => found.onDisk || found.loaded);
281
+ }
282
+
283
+ /**
284
+ * Wait for a booted-out label to actually leave launchd's domain.
285
+ *
286
+ * `launchctl print` failing is the only honest evidence: while the job is still
287
+ * shutting down it keeps printing, and `bootstrap` keeps failing with an I/O error
288
+ * that says nothing about why.
289
+ */
290
+ export async function waitForLabelGone(label: string, ops: Partial<LaunchdOps> = {}, { timeoutMs = 10_000, stepMs = 200 } = {}): Promise<boolean> {
291
+ const { run, domain, sleep } = realOps(ops);
292
+ for (let waited = 0; waited <= timeoutMs; waited += stepMs) {
293
+ if (run("print", `${domain}/${label}`).code !== 0) return true;
294
+ if (waited + stepMs > timeoutMs) break;
295
+ await sleep(stepMs);
296
+ }
297
+ return false;
298
+ }
299
+
300
+ /**
301
+ * Remove every agent the machine carries under any of `labels`.
302
+ *
303
+ * The order inside one label is the whole of it: boot it out, wait for launchd to
304
+ * let go, THEN delete the file. See the header for what the other order costs.
305
+ *
306
+ * šŸ”“ Pass the legacy names too, not just the current one. An agent left loaded
307
+ * under a name a build no longer knows is a second copy of the app at the next
308
+ * login, fighting over one database and one port — strictly worse than the name
309
+ * being wrong, and invisible to the command whose whole job is removing it.
310
+ */
311
+ export async function clearAgents(labels: readonly string[], ops: Partial<LaunchdOps> = {}): Promise<AgentPresence[]> {
312
+ const resolved = realOps(ops);
313
+ const cleared: AgentPresence[] = [];
314
+ for (const found of findAgents(labels, resolved)) {
315
+ if (found.loaded) {
316
+ resolved.run("bootout", `${resolved.domain}/${found.label}`);
317
+ await waitForLabelGone(found.label, resolved);
318
+ }
319
+ if (found.onDisk) await resolved.remove(found.path);
320
+ cleared.push(found);
321
+ }
322
+ return cleared;
323
+ }
324
+
325
+ /**
326
+ * Bootstrap, retrying the one error that means "ask again in a moment".
327
+ *
328
+ * Error 5 is launchd's answer while a previous incarnation of the label is still
329
+ * going away. Every other failure is real — a malformed plist, a missing program —
330
+ * and is returned on the first try rather than retried five times into a slower
331
+ * version of the same message.
332
+ */
333
+ export async function bootstrapWithRetry(
334
+ path: string,
335
+ { attempts = 5, stepMs = 500, ops = {} as Partial<LaunchdOps> } = {},
336
+ ): Promise<CommandResult & { tries: number }> {
337
+ const { run, domain, sleep } = realOps(ops);
338
+ const boot = (target: string) => run("bootstrap", domain, target);
339
+ let last = boot(path);
340
+ let tries = 1;
341
+ while (tries < attempts && last.code !== 0 && /Input\/output error|Bootstrap failed: 5/.test(last.out)) {
342
+ await sleep(stepMs);
343
+ last = boot(path);
344
+ tries++;
345
+ }
346
+ return { ...last, tries };
347
+ }
348
+
349
+ /**
350
+ * Clear, write, bootstrap — in that order, and the order is the point.
351
+ *
352
+ * Replacing a running agent means removing the old one first: launchd will not
353
+ * reload a label that is already bootstrapped, and the failure it reports for that
354
+ * is not obviously about a stale agent.
355
+ */
356
+ export async function replaceAgent(
357
+ label: string,
358
+ contents: string,
359
+ { also = [] as readonly string[], ops = {} as Partial<LaunchdOps>, attempts = 5 } = {},
360
+ ): Promise<{ path: string; cleared: AgentPresence[]; boot: CommandResult & { tries: number } }> {
361
+ const resolved = realOps(ops);
362
+ const path = resolved.plistFor(label);
363
+ const cleared = await clearAgents([label, ...also], resolved);
364
+ await resolved.write(path, contents);
365
+ return { path, cleared, boot: await bootstrapWithRetry(path, { attempts, ops: resolved }) };
366
+ }
367
+
368
+ /**
369
+ * Is somebody ELSE still declaring this label? Returns the offending path, or `null`.
370
+ *
371
+ * `marker` is the provenance string a foreign deployer stamps into its plists —
372
+ * see {@link PlistSpec.managedBy}. This is checked at install time rather than
373
+ * documented, because a warning in a document is exactly what the 2026-09-13
374
+ * measurement in the header disproved.
375
+ */
376
+ export async function declaredBy(label: string, marker: string, ops: Partial<LaunchdOps> = {}): Promise<string | null> {
377
+ const { plistFor, exists, read } = realOps(ops);
378
+ const path = plistFor(label);
379
+ if (!exists(path)) return null;
380
+ const body = await read(path).catch(() => "");
381
+ return body.includes(marker) ? path : null;
382
+ }
383
+
384
+ /**
385
+ * Does the URL answer? A pid is not a service — see the header.
386
+ *
387
+ * `< 500` rather than `ok`, because a deployed app behind its own sign-in gate
388
+ * answers `401` to an unauthenticated probe and that is a SERVING app. A caller
389
+ * that has a health endpoint should name it and tighten the predicate.
390
+ */
391
+ export async function answering(
392
+ url: string,
393
+ { timeoutMs = 45_000, stepMs = 500, requestTimeoutMs = 2_000, accept = (response: Response) => response.status < 500, sleep = (ms: number) => new Promise<void>((done) => setTimeout(done, ms)) } = {},
394
+ ): Promise<boolean> {
395
+ const deadline = Date.now() + timeoutMs;
396
+ for (;;) {
397
+ try {
398
+ if (accept(await fetch(url, { signal: AbortSignal.timeout(requestTimeoutMs) }))) return true;
399
+ } catch {
400
+ // Not listening yet.
401
+ }
402
+ if (Date.now() + stepMs >= deadline) return false;
403
+ await sleep(stepMs);
404
+ }
405
+ }
package/src/roots.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Finding the generation — and this checkout's package — without knowing a path.
3
+ *
4
+ * ## Why this is a library rather than four lines in each app
5
+ *
6
+ * It was four lines in each app, twice, and the two copies had already drifted.
7
+ * `apps/desk/scripts/forgeRoot.ts` and `apps/flix/scripts/forgeRoot.ts` were
8
+ * written independently against the same problem; measured 2026-09-16 the
9
+ * `findForgeRoot` bodies were character-identical and the surrounding halves were
10
+ * not, which is the definition of an extraction that is measurable rather than
11
+ * speculative. Two implementations, one behaviour.
12
+ *
13
+ * ## The rule this serves
14
+ *
15
+ * Exactly ONE file in a generation may contain a literal path: its `forge.env`.
16
+ * Every script, doc, task and launchd job reaches the two roots through the
17
+ * variables that file exports, and the generation's `check-paths` fails the moment
18
+ * a second file knows a path. The measurement behind that rule: across previous
19
+ * generations 233 files hardcoded a state root, so no migration could ever be
20
+ * finished and a decommissioned host was still being cited weeks after it died.
21
+ *
22
+ * So nothing here knows a path either. `forge.env`'s presence above a directory is
23
+ * the definition of "inside a generation" — the same rule `check-paths` and the
24
+ * runner use, so the three cannot disagree — and the state root is read OUT of
25
+ * that file rather than spelled again here.
26
+ *
27
+ * šŸ”“ **Everything returns `null` rather than guessing.** A copy of a tree outside a
28
+ * generation — an archive, a clone somebody took, a `node_modules` from back when
29
+ * something published — has no `forge.env` above it, and a function that invented
30
+ * one would write files into a stranger's tree. Every caller must handle `null` by
31
+ * doing nothing and saying so; {@link requireForgeState} is the one-line way to do
32
+ * that with a message worth reading.
33
+ *
34
+ * šŸ”“ **This library is published, so it may not know a generation's NAME either.**
35
+ * `apps/flix/scripts/forgeRoot.ts` fell back to `$HOME/.cursedforge` when the
36
+ * environment carried no `FORGE_STATE`, which is correct for exactly one
37
+ * generation and silently wrong for its successor. {@link forgeState} parses the
38
+ * marker file instead, which is the only spelling that survives the next
39
+ * generation being called something else.
40
+ */
41
+
42
+ import { existsSync, readFileSync } from "node:fs";
43
+ import { dirname, join, resolve } from "node:path";
44
+ import { fileURLToPath } from "node:url";
45
+
46
+ /** The marker file whose presence defines "inside a generation". */
47
+ export const MARKER = "forge.env";
48
+
49
+ /**
50
+ * How far up either walk will look before giving up.
51
+ *
52
+ * A bound rather than "until `/`" so a misuse is a `null` in milliseconds instead
53
+ * of a stat of every ancestor of a path that was never going to match.
54
+ */
55
+ const MAX_HOPS = 16;
56
+
57
+ /** The directory holding the module at `fromUrl`. Pass `import.meta.url`. */
58
+ export function fileDir(fromUrl: string): string {
59
+ return dirname(fileURLToPath(fromUrl));
60
+ }
61
+
62
+ /** Walk up from `from` looking for a directory that contains `marker`. */
63
+ function walkUp(from: string, marker: string): string | null {
64
+ let dir = resolve(from);
65
+ for (let hops = 0; hops < MAX_HOPS; hops++) {
66
+ if (existsSync(join(dir, marker))) return dir;
67
+ const up = dirname(dir);
68
+ if (up === dir) return null;
69
+ dir = up;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /** The directory holding `forge.env`, or `null` when `from` is not inside a generation. */
75
+ export function findForgeRoot(from: string): string | null {
76
+ return walkUp(from, MARKER);
77
+ }
78
+
79
+ /** {@link findForgeRoot} from a module's own location. Pass `import.meta.url`. */
80
+ export function forgeRootFrom(fromUrl: string): string | null {
81
+ return findForgeRoot(fileDir(fromUrl));
82
+ }
83
+
84
+ /**
85
+ * The checkout a module belongs to — the nearest ancestor holding `package.json`.
86
+ *
87
+ * šŸ”“ Derived by walking, never by counting `..` from the caller. Both apps spelled
88
+ * their app root as `new URL("..", import.meta.url)`, which is correct only while
89
+ * the file stays exactly one directory deep: `desk` keeps this machinery in
90
+ * `src/cli/` and `flix` in `scripts/`, so the same expression means two different
91
+ * things in the two trees and neither survives the file being moved. Walking is
92
+ * also what makes it right in a worktree, where the checkout is not the one under
93
+ * the generation root at all.
94
+ */
95
+ export function findPackageRoot(from: string): string | null {
96
+ return walkUp(from, "package.json");
97
+ }
98
+
99
+ /** {@link findPackageRoot} from a module's own location. Pass `import.meta.url`. */
100
+ export function packageRootFrom(fromUrl: string): string | null {
101
+ return findPackageRoot(fileDir(fromUrl));
102
+ }
103
+
104
+ /**
105
+ * Read one `export NAME="…"` assignment out of a `forge.env`, expanding `$HOME`
106
+ * and any variable the file has already defined above the line being read.
107
+ *
108
+ * Deliberately not a shell: sourcing an arbitrary file to learn one value is a
109
+ * far larger permission than reading it, and every assignment in a `forge.env` is
110
+ * this shape by construction. A line the parser does not understand is skipped
111
+ * rather than guessed at.
112
+ */
113
+ export function readForgeVar(marker: string, name: string, env: NodeJS.ProcessEnv = process.env): string | null {
114
+ let text: string;
115
+ try {
116
+ text = readFileSync(marker, "utf8");
117
+ } catch {
118
+ return null;
119
+ }
120
+ const known: Record<string, string> = { HOME: env.HOME?.trim() ?? "" };
121
+ for (const line of text.split("\n")) {
122
+ const match = /^\s*export\s+([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line);
123
+ if (!match) continue;
124
+ const [, key, rawValue] = match as unknown as [string, string, string];
125
+ const unquoted = rawValue.trim().replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
126
+ const expanded = unquoted.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (_whole, ref: string) => known[ref] ?? "");
127
+ known[key] = expanded;
128
+ if (key === name) return expanded || null;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * The generation's state root — secrets, data, logs, locks, binaries.
135
+ *
136
+ * `$FORGE_STATE` when the environment carries it, because that is both cheaper and
137
+ * the only answer available to a process started somewhere else entirely.
138
+ * Otherwise the `forge.env` above `from` is read, which is what makes this work
139
+ * inside a launchd job handed almost no environment at all, and inside a worktree
140
+ * whose parent generation is not the one the caller expected.
141
+ *
142
+ * `null` when neither is available. See the header: guessing writes into a
143
+ * stranger's tree.
144
+ */
145
+ export function forgeState(from: string, env: NodeJS.ProcessEnv = process.env): string | null {
146
+ const named = env.FORGE_STATE?.trim();
147
+ if (named) return named;
148
+ const root = findForgeRoot(from);
149
+ if (!root) return null;
150
+ return readForgeVar(join(root, MARKER), "FORGE_STATE", env);
151
+ }
152
+
153
+ /** The generation's code root. Same resolution order as {@link forgeState}. */
154
+ export function forgeCodeRoot(from: string, env: NodeJS.ProcessEnv = process.env): string | null {
155
+ const named = env.FORGE?.trim();
156
+ if (named) return named;
157
+ return findForgeRoot(from);
158
+ }
159
+
160
+ /**
161
+ * {@link forgeState}, or a thrown error naming both things the caller could do
162
+ * about it.
163
+ *
164
+ * For the call sites — a deploy, a service install — where `null` has no sensible
165
+ * handling and the alternative to throwing is a path built out of `undefined`.
166
+ */
167
+ export function requireForgeState(from: string, env: NodeJS.ProcessEnv = process.env): string {
168
+ const state = forgeState(from, env);
169
+ if (state) return state;
170
+ throw new Error(
171
+ `No generation state root: $FORGE_STATE is unset and no ${MARKER} was found above ${from}.\n` +
172
+ ` Source the generation's ${MARKER} first, or run this from inside its checkout.`,
173
+ );
174
+ }
package/src/smoke.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The scaffolding a deployed smoke needs — and none of the checks.
3
+ *
4
+ * ## šŸ”“ A smoke that asserts `200` on `/` passes every incident this fleet has had
5
+ *
6
+ * Six of them, and all six have the same shape: **the deploy reported success and
7
+ * the app did not work.**
8
+ *
9
+ * 1. `npm publish` printed `+ name@version` and exited 0 without publishing. The
10
+ * registry 404d for an hour and the retry 409d against the stage.
11
+ * 2. A first deploy landed with no body-size declaration. Every upload became a
12
+ * 413 raised by the layer in front, so the app never saw one and nothing in
13
+ * its logs was wrong.
14
+ * 3. A deploy *succeeded* while every byte of media was unreachable, because the
15
+ * failing layer answered before the thing being health-checked ran.
16
+ * 4. An app was up, green, and serving a catalogue a MONTH out of date.
17
+ * 5. Two apps went live answering `no built client at …/public` at `/` while the
18
+ * edge returned 200 out of its cache of the RETIRED deployment. Gate green,
19
+ * deploy green, smoke green, app down; the owner found it, not a check.
20
+ * 6. A launchd swap "succeeded" onto a process still running the previous
21
+ * commit, which serves perfectly and is indistinguishable from a release.
22
+ *
23
+ * So none of the checks an app writes on top of this is a liveness check, and this
24
+ * module holds **no checks at all**. What two apps wrote twice is the harness —
25
+ * the ledger, the timeout-and-header-carrying fetch, the DNS hint, and the exit
26
+ * code — and that is exactly what is here.
27
+ *
28
+ * ## šŸ”“ The exit code is a CONTRACT, not a detail
29
+ *
30
+ * | code | meaning | what a deploy script does |
31
+ * |---|---|---|
32
+ * | `0` | every check passed | keep the release |
33
+ * | `1` | the APP is wrong | roll back |
34
+ * | `2` | the EDGE is wrong — DNS, tunnel, hostname, policy | do **not** roll back |
35
+ *
36
+ * Two is the load-bearing one and it is why {@link Smoke.edgeFault} exists.
37
+ * Reverting good application code cannot restore a DNS record, a tunnel route or
38
+ * an access policy: a rollback there discards work while leaving the real fault
39
+ * exactly where it is. {@link Smoke.finish} is the single place that code is
40
+ * decided, so the two apps reading it cannot disagree about what a `2` meant.
41
+ *
42
+ * ## What this module will never grow
43
+ *
44
+ * No route lists, no health-payload shapes, no upload sizes, no titles. Those are
45
+ * the parts `desk` and `flix` wrote DIFFERENTLY, on purpose, because they are
46
+ * different apps — and a shared kit that starts absorbing them is how the previous
47
+ * generation's reached 277 files.
48
+ *
49
+ * ```ts
50
+ * const smoke = createSmoke({ base: process.env.MYAPP_SMOKE_URL ?? "https://…" });
51
+ * const response = await smoke.get("/healthz");
52
+ * smoke.record("health", response.status === 200, `GET /healthz → ${response.status}`);
53
+ * smoke.finish();
54
+ * ```
55
+ */
56
+
57
+ import { spawnSync } from "node:child_process";
58
+
59
+ export interface Failure {
60
+ check: string;
61
+ detail: string;
62
+ }
63
+
64
+ export interface SmokeOptions {
65
+ /** The origin every relative path is asked against. A trailing slash is trimmed. */
66
+ base: string;
67
+ /** Per-request ceiling. A smoke that hangs is a deploy that hangs. */
68
+ timeoutMs?: number;
69
+ /**
70
+ * Headers added to {@link Smoke.get} and to nothing else.
71
+ *
72
+ * A function rather than a value so a credential is read at call time, and so
73
+ * an app whose outer gate needs no credential can simply not pass one.
74
+ */
75
+ headers?: () => Record<string, string>;
76
+ /** Injected for tests. Resolves a hostname against a public resolver. */
77
+ resolve?: (host: string) => string;
78
+ log?: (line: string) => void;
79
+ error?: (line: string) => void;
80
+ }
81
+
82
+ export interface Smoke {
83
+ readonly base: string;
84
+ readonly passed: readonly string[];
85
+ readonly failures: readonly Failure[];
86
+ /** True once something could not be reached AT ALL. Drives the exit code. */
87
+ readonly edgeFault: boolean;
88
+ /** Ledger one result. Returns `ok`, so it can be the tail of an `if`. */
89
+ record(check: string, ok: boolean, detail: string): boolean;
90
+ /** Ask `path` WITH whatever {@link SmokeOptions.headers} returns. */
91
+ get(path: string, init?: RequestInit): Promise<Response>;
92
+ /** Ask `path` with NO credentials — the shape every gate check needs. */
93
+ anonymous(path: string, init?: RequestInit): Promise<Response>;
94
+ /** Mark the run as an edge fault: reached nothing, so code 2 rather than 1. */
95
+ markEdgeFault(): void;
96
+ /** A sentence to append to a fetch failure, or `""`. See {@link createSmoke}. */
97
+ dnsHint(error: Error): string;
98
+ /** Print the ledger and return the exit code. Does not exit. */
99
+ report(): number;
100
+ /** {@link report}, then `process.exit` with it. */
101
+ finish(): never;
102
+ }
103
+
104
+ /**
105
+ * šŸ”“ Tell "the hostname does not exist" apart from "this Mac remembers that it
106
+ * didn't", which are the same error message and completely different problems.
107
+ *
108
+ * Measured 2026-09-13, and it cost half an hour of a first deploy: curling a
109
+ * hostname *before* its DNS record exists puts an NXDOMAIN in mDNSResponder's
110
+ * negative cache for the zone's SOA minimum — half an hour on the zone this fleet
111
+ * uses. For all of that time `dig` answers correctly, the record is live, the app
112
+ * is serving, and every program on the Mac that uses the system resolver says the
113
+ * host does not exist. Nothing about that reads as a caching problem while it is
114
+ * happening, which is exactly why the question is asked in code instead of left in
115
+ * a document somebody has to remember to go and read.
116
+ */
117
+ function publicResolve(host: string): string {
118
+ const dig = spawnSync("dig", ["+short", host, "@1.1.1.1"], { encoding: "utf8" });
119
+ return (dig.stdout ?? "").trim();
120
+ }
121
+
122
+ const LOOKS_LIKE_DNS = /ENOTFOUND|getaddrinfo|Could not resolve|dns/i;
123
+
124
+ export function createSmoke(options: SmokeOptions): Smoke {
125
+ const base = options.base.replace(/\/+$/, "");
126
+ const timeoutMs = options.timeoutMs ?? 20_000;
127
+ const headers = options.headers ?? (() => ({}));
128
+ const resolve = options.resolve ?? publicResolve;
129
+ const log = options.log ?? ((line: string) => console.log(line));
130
+ const error = options.error ?? ((line: string) => console.error(line));
131
+
132
+ const passed: string[] = [];
133
+ const failures: Failure[] = [];
134
+ let edgeFault = false;
135
+
136
+ const smoke: Smoke = {
137
+ base,
138
+ get passed() {
139
+ return passed;
140
+ },
141
+ get failures() {
142
+ return failures;
143
+ },
144
+ get edgeFault() {
145
+ return edgeFault;
146
+ },
147
+
148
+ record(check, ok, detail) {
149
+ if (ok) passed.push(`${check} — ${detail}`);
150
+ else failures.push({ check, detail });
151
+ return ok;
152
+ },
153
+
154
+ async get(path, init = {}) {
155
+ return await fetch(`${base}${path}`, {
156
+ ...init,
157
+ // šŸ”“ Never `follow`. A redirect to a sign-in page is the single most
158
+ // common way a gate check turns into a green 200 on somebody else's
159
+ // HTML, and following it destroys the evidence.
160
+ redirect: "manual",
161
+ signal: AbortSignal.timeout(timeoutMs),
162
+ headers: { ...headers(), ...(init.headers ?? {}) },
163
+ });
164
+ },
165
+
166
+ async anonymous(path, init = {}) {
167
+ return await fetch(`${base}${path}`, { ...init, redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
168
+ },
169
+
170
+ markEdgeFault() {
171
+ edgeFault = true;
172
+ },
173
+
174
+ dnsHint(failed: Error) {
175
+ if (!LOOKS_LIKE_DNS.test(failed.message)) return "";
176
+ const host = new URL(base).hostname;
177
+ const resolved = resolve(host);
178
+ if (!resolved) return `\n ${host} does not resolve publicly either — the DNS record is genuinely missing.`;
179
+ return [
180
+ `\n šŸ”“ But ${host} DOES resolve publicly (${resolved.split("\n").join(", ")}).`,
181
+ " This is the macOS negative DNS cache, not a broken deploy — something asked for the",
182
+ " name before its record existed, and mDNSResponder holds that for the zone's SOA",
183
+ " minimum. Clear it and re-run:",
184
+ " sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder",
185
+ ].join("\n");
186
+ },
187
+
188
+ report() {
189
+ for (const line of passed) log(` āœ“ ${line}`);
190
+ for (const failure of failures) error(` āœ— ${failure.check} — ${failure.detail}`);
191
+ const total = passed.length + failures.length;
192
+ if (failures.length === 0) {
193
+ log(`\nāœ“ deploy smoke passed — ${total} checks against ${base}`);
194
+ return 0;
195
+ }
196
+ error(`\nāœ— deploy smoke FAILED — ${failures.length} of ${total} checks against ${base}`);
197
+ if (edgeFault) error(" (exit 2 — an EDGE fault. Rolling the app back would not change it.)");
198
+ return edgeFault ? 2 : 1;
199
+ },
200
+
201
+ finish() {
202
+ process.exit(smoke.report());
203
+ },
204
+ };
205
+
206
+ return smoke;
207
+ }
208
+
209
+ /**
210
+ * Wrap a check so a fetch that throws becomes a recorded edge fault rather than an
211
+ * unhandled rejection.
212
+ *
213
+ * šŸ”“ The failure this closes: an uncaught throw exits non-zero with a stack trace
214
+ * and NO ledger, so a deploy script reads `1` — "the app is wrong, roll back" —
215
+ * for a hostname that was never reachable. That is the exact case code 2 exists
216
+ * for, arriving as a 1.
217
+ */
218
+ export async function guarded(smoke: Smoke, check: string, body: () => Promise<void>): Promise<void> {
219
+ try {
220
+ await body();
221
+ } catch (thrown) {
222
+ const failed = thrown as Error;
223
+ smoke.markEdgeFault();
224
+ smoke.record(check, false, `could not complete against ${smoke.base}: ${failed.message}${smoke.dnsHint(failed)}`);
225
+ }
226
+ }