cursedops 0.2.7 → 0.3.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 +1 -0
- package/package.json +8 -2
- package/src/buildInfo.ts +174 -0
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ bun add cursedops
|
|
|
13
13
|
| `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code — and the one check no app owns: every address of a deployment serving the same built client |
|
|
14
14
|
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
|
|
15
15
|
| `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normaliser and the default 404 body. No `node:` import, so it mounts inside a Worker |
|
|
16
|
+
| `cursedops/build-info` | which commit a checkout-served process is running and whether its tree was dirty — read once at load, and a `null` retried in the background rather than cached for the life of the process. `node:child_process`, so never in a Worker |
|
|
16
17
|
| `cursedops/public-surface` | the ratchet on a LIBRARY's public surface — a symbol count per export subpath against a committed baseline that may only fall — and its `public-surface` bin. Not an app's: the one entry here admitted for three published libraries, see below |
|
|
17
18
|
|
|
18
19
|
Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, the scaffolding of a deployed smoke, and the static-serving helpers eight apps copied — the path-traversal guard among them — and the API floor that keeps an unmatched /api/... from ever being answered with the app shell — and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots without knowing a path, macOS launchd agent install/replace/remove, the scaffolding of a deployed smoke, and the static-serving helpers eight apps copied — the path-traversal guard among them — and the API floor that keeps an unmatched /api/... from ever being answered with the app shell — the commit and dirty flag a checkout-served process reports, retried rather than cached when git loses a boot race — and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -42,6 +42,12 @@
|
|
|
42
42
|
"source": "./src/smoke.ts",
|
|
43
43
|
"import": "./src/smoke.ts"
|
|
44
44
|
},
|
|
45
|
+
"./build-info": {
|
|
46
|
+
"types": "./src/buildInfo.ts",
|
|
47
|
+
"bun": "./src/buildInfo.ts",
|
|
48
|
+
"source": "./src/buildInfo.ts",
|
|
49
|
+
"import": "./src/buildInfo.ts"
|
|
50
|
+
},
|
|
45
51
|
"./public-surface": {
|
|
46
52
|
"types": "./src/publicSurface.ts",
|
|
47
53
|
"bun": "./src/publicSurface.ts",
|
package/src/buildInfo.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which commit a checkout-served process is running, and whether its tree was dirty.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a library and not six copies
|
|
5
|
+
*
|
|
6
|
+
* `family`, `flix`, `music`, `roms`, `station` and `vault` each carried a
|
|
7
|
+
* `src/server/buildInfo.ts`, measured 2026-09-22: six files, three distinct
|
|
8
|
+
* `md5`s, and the CODE character-identical in all six — only the prose differed.
|
|
9
|
+
* `/healthz` publishes the answer, `apiNotFoundBody` (`./apiFloor.ts`) puts it in
|
|
10
|
+
* every unmatched `/api/...` 404, and each app's `smoke:deployed` compares it
|
|
11
|
+
* against HEAD. A deploy on this Mac is a launchd swap over a working tree — there
|
|
12
|
+
* is no artifact, only source — so "did the new code take?" has no other
|
|
13
|
+
* observable answer.
|
|
14
|
+
*
|
|
15
|
+
* ## 🔴 The defect this fixes: a failure cached as if it were an answer
|
|
16
|
+
*
|
|
17
|
+
* All six read `git rev-parse HEAD` and `git status --porcelain` ONCE, at module
|
|
18
|
+
* load, and cached the result for the life of the process — `null` included. On
|
|
19
|
+
* 2026-09-22 all nine host processes started in the same second at login, against
|
|
20
|
+
* a cold page cache, each with a 5 s `spawnSync` timeout, and SEVEN of nine then
|
|
21
|
+
* answered `/healthz` with `"commit": null` until someone restarted them. `family`
|
|
22
|
+
* won the commit read and lost the dirty one; `music` lost the commit and won
|
|
23
|
+
* dirty — opposite halves, which is what a race looks like and what a missing
|
|
24
|
+
* binary or a bad `PATH` cannot do. One `launchctl kickstart` of `flix`, with no
|
|
25
|
+
* code change, turned `null` into its sha. The cost: `smoke:deployed` red on
|
|
26
|
+
* healthy apps after every reboot (a check that cries wolf gets skipped), and the
|
|
27
|
+
* API floor's 404 naming "an unreadable commit" on six apps.
|
|
28
|
+
*
|
|
29
|
+
* So: the eager read stays — it is right about WHEN to read — and a `null` is no
|
|
30
|
+
* longer final. Whatever half did not answer is retried in the BACKGROUND, with a
|
|
31
|
+
* capped backoff, until it does. A success is read once and never spawns again.
|
|
32
|
+
*
|
|
33
|
+
* 🔴 **The request path never spawns.** The accessors are plain reads of what is
|
|
34
|
+
* known. The obvious fix — "retry on the next access while null" — puts a
|
|
35
|
+
* synchronous 5 s `spawnSync` inside `/healthz` exactly when git is broken, and
|
|
36
|
+
* `/healthz` is the probe a deploy polls once a second. Retries use the async
|
|
37
|
+
* runner and an `unref`'d timer, so they cannot block a request and cannot hold a
|
|
38
|
+
* process open.
|
|
39
|
+
*
|
|
40
|
+
* 🔴 **Never throws and never blocks boot beyond the one eager read.** A checkout
|
|
41
|
+
* without git, a `git` not on the launchd job's `PATH`, a broken repo — all answer
|
|
42
|
+
* `null` until they stop being true. An app that will not start is far worse than
|
|
43
|
+
* one that cannot yet name its commit, and a smoke should still treat a `null`
|
|
44
|
+
* that PERSISTS as a failure.
|
|
45
|
+
*
|
|
46
|
+
* Entry rule (`../README.md`): rule 1, six apps; rule 2, it is handed the checkout
|
|
47
|
+
* directory and knows nothing else; rule 3, the 2026-09-22 reboot above.
|
|
48
|
+
* `buildInfo.test.ts` drives the failure path — a first read that loses and a
|
|
49
|
+
* retry that wins — because the six copies' own suites could only ever see HEAD.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
53
|
+
|
|
54
|
+
/** Runs `git <args>` in `cwd`; the stdout on exit 0, otherwise `null`. Never throws. */
|
|
55
|
+
export type GitRun = (args: readonly string[], cwd: string) => string | null;
|
|
56
|
+
/** {@link GitRun}, without blocking. Never rejects. */
|
|
57
|
+
export type GitRunAsync = (args: readonly string[], cwd: string) => Promise<string | null>;
|
|
58
|
+
|
|
59
|
+
/** Per git call — the six copies' figure, kept. */
|
|
60
|
+
export const GIT_TIMEOUT_MS = 5_000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Delays between background retries of a half that has not answered. The last is
|
|
64
|
+
* repeated for as long as it keeps failing: once a minute costs nothing, and a
|
|
65
|
+
* process that ran for a week after a bad boot should still recover by itself.
|
|
66
|
+
*/
|
|
67
|
+
export const RETRY_DELAYS_MS: readonly number[] = [1_000, 2_000, 5_000, 10_000, 30_000, 60_000];
|
|
68
|
+
|
|
69
|
+
const HEAD_ARGS = ["rev-parse", "HEAD"] as const;
|
|
70
|
+
const STATUS_ARGS = ["status", "--porcelain"] as const;
|
|
71
|
+
|
|
72
|
+
export const runGitSync: GitRun = (args, cwd) => {
|
|
73
|
+
try {
|
|
74
|
+
const result = spawnSync("git", [...args], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS });
|
|
75
|
+
return result.status === 0 ? (result.stdout ?? "") : null;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const runGitAsync: GitRunAsync = (args, cwd) =>
|
|
82
|
+
new Promise((resolve) => {
|
|
83
|
+
try {
|
|
84
|
+
execFile("git", [...args], { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS }, (error, stdout) =>
|
|
85
|
+
resolve(error ? null : stdout),
|
|
86
|
+
);
|
|
87
|
+
} catch {
|
|
88
|
+
resolve(null);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** A full 40-hex sha, or `null` for anything else — a placeholder is not a commit. */
|
|
93
|
+
export function parseCommit(stdout: string | null): string | null {
|
|
94
|
+
const sha = (stdout ?? "").trim();
|
|
95
|
+
return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Any porcelain output means dirty; no output at all (`null`) means unknown. */
|
|
99
|
+
export function parseDirty(stdout: string | null): boolean | null {
|
|
100
|
+
return stdout === null ? null : stdout.trim().length > 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface BuildInfo {
|
|
104
|
+
/** The full sha, or `null` while git has not answered. */
|
|
105
|
+
commit(): string | null;
|
|
106
|
+
/** The 8-character form `/healthz` publishes. */
|
|
107
|
+
commitShort(): string | null;
|
|
108
|
+
/** Was the tree dirty when it was read? `null` while git has not answered. */
|
|
109
|
+
dirty(): boolean | null;
|
|
110
|
+
/**
|
|
111
|
+
* One async attempt at whatever is still unknown. The background retry calls
|
|
112
|
+
* this; it is exported for tests and for a caller that wants to force one.
|
|
113
|
+
*/
|
|
114
|
+
refresh(): Promise<void>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface BuildInfoOptions {
|
|
118
|
+
runSync?: GitRun;
|
|
119
|
+
runAsync?: GitRunAsync;
|
|
120
|
+
retryDelaysMs?: readonly number[];
|
|
121
|
+
/** `setTimeout`, `unref`'d, by default. Injected so a test can step it. */
|
|
122
|
+
schedule?: (fn: () => void, ms: number) => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const unrefTimeout = (fn: () => void, ms: number): void => {
|
|
126
|
+
const timer = setTimeout(fn, ms) as { unref?: () => void };
|
|
127
|
+
timer.unref?.();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Read `repoDir`'s commit and dirty flag now, and keep retrying in the background
|
|
132
|
+
* whichever half did not answer. Call once, at module load, with the checkout the
|
|
133
|
+
* process serves — `findPackageRoot` from `cursedops/roots` finds it.
|
|
134
|
+
*/
|
|
135
|
+
export function buildInfo(repoDir: string, options: BuildInfoOptions = {}): BuildInfo {
|
|
136
|
+
const runSync = options.runSync ?? runGitSync;
|
|
137
|
+
const runAsync = options.runAsync ?? runGitAsync;
|
|
138
|
+
const delays = options.retryDelaysMs?.length ? options.retryDelaysMs : RETRY_DELAYS_MS;
|
|
139
|
+
const schedule = options.schedule ?? unrefTimeout;
|
|
140
|
+
|
|
141
|
+
let commit = parseCommit(runSync(HEAD_ARGS, repoDir));
|
|
142
|
+
let dirty = parseDirty(runSync(STATUS_ARGS, repoDir));
|
|
143
|
+
let attempt = 0;
|
|
144
|
+
|
|
145
|
+
const unanswered = (): boolean => commit === null || dirty === null;
|
|
146
|
+
|
|
147
|
+
async function refresh(): Promise<void> {
|
|
148
|
+
const [nextCommit, nextDirty] = await Promise.all([
|
|
149
|
+
commit === null ? runAsync(HEAD_ARGS, repoDir).then(parseCommit) : commit,
|
|
150
|
+
dirty === null ? runAsync(STATUS_ARGS, repoDir).then(parseDirty) : dirty,
|
|
151
|
+
]);
|
|
152
|
+
// A half that has answered is final — never overwritten, never re-read.
|
|
153
|
+
commit ??= nextCommit;
|
|
154
|
+
dirty ??= nextDirty;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function arm(): void {
|
|
158
|
+
if (!unanswered()) return;
|
|
159
|
+
const delay = delays[Math.min(attempt, delays.length - 1)] as number;
|
|
160
|
+
attempt++;
|
|
161
|
+
schedule(() => {
|
|
162
|
+
refresh().then(arm, arm);
|
|
163
|
+
}, delay);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
arm();
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
commit: () => commit,
|
|
170
|
+
commitShort: () => (commit ? commit.slice(0, 8) : null),
|
|
171
|
+
dirty: () => dirty,
|
|
172
|
+
refresh,
|
|
173
|
+
};
|
|
174
|
+
}
|