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 +124 -0
- package/package.json +48 -0
- package/src/launchd.ts +405 -0
- package/src/roots.ts +174 -0
- package/src/smoke.ts +226 -0
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) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[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
|
+
}
|