cursedops 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,8 @@ bun add cursedops
21
21
  | `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 |
22
22
  | `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 |
23
23
  | `cursedops/bound-lists` | the static check that no SQL builds an `IN (…)` list one `?` per value — the shape D1 500s on past 100 ids. Test-time only; the recorded third exception, see below |
24
+ | `cursedops/staged-client` | the IMMUTABLE client a checkout-served app serves — a build staged into `<APP_DATA_DIR>/client/<commit>/` behind `CURRENT`, so another agent's `vite build` in the checkout never changes live bytes — and its `forge-client stamp` / `forge-client stage` bin (0.6.0, lifted from family, flix, roms, station) |
25
+ | `cursedops/deploy-tree` | the two refusals a checkout-served deploy needs — never deploy a dirty tree, never `git revert` in one (`readTreeStatus`, `refuseToDeploy`, `refuseToRevert`; 0.6.0, lifted from nine apps) |
24
26
  | `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 |
25
27
 
26
28
  Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof), 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": {
@@ -97,11 +97,24 @@
97
97
  "source": "./src/d1Import.ts",
98
98
  "import": "./src/d1Import.ts"
99
99
  },
100
- "./package.json": "./package.json"
100
+ "./package.json": "./package.json",
101
+ "./staged-client": {
102
+ "types": "./src/stagedClient.ts",
103
+ "bun": "./src/stagedClient.ts",
104
+ "source": "./src/stagedClient.ts",
105
+ "import": "./src/stagedClient.ts"
106
+ },
107
+ "./deploy-tree": {
108
+ "types": "./src/deployTree.ts",
109
+ "bun": "./src/deployTree.ts",
110
+ "source": "./src/deployTree.ts",
111
+ "import": "./src/deployTree.ts"
112
+ }
101
113
  },
102
114
  "bin": {
103
115
  "public-surface": "./src/publicSurface.ts",
104
- "forge-paths": "./src/paths.ts"
116
+ "forge-paths": "./src/paths.ts",
117
+ "forge-client": "./src/stagedClient.ts"
105
118
  },
106
119
  "files": [
107
120
  "src",
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `cursedops/deploy-tree` — is this checkout in a state where a deploy's rollback would be a rollback?
3
+ *
4
+ * Lifted 2026-09-23 (0.6.0) from the nine apps that each carried `scripts/deployTree.ts` —
5
+ * collections, desk, family, flix, music, patterns, roms, station and vault; identical in logic,
6
+ * differing only in comments and indentation. vault's `deployTree.test.ts` came with it.
7
+ *
8
+ * ```ts
9
+ * import { readTreeStatus, refuseToDeploy, refuseToRevert } from "cursedops/deploy-tree";
10
+ * ```
11
+ *
12
+ * ## The incident this exists because of — 2026-09-18, six of eight apps
13
+ *
14
+ * A fleet deploy ran `bun run deploy` in eight app checkouts. Six failed, all on the same
15
+ * smoke check and none on their own code:
16
+ *
17
+ * ```
18
+ * ✗ clean-tree — 🔴 the served checkout has uncommitted changes — `git revert` would not undo this deploy
19
+ * ```
20
+ *
21
+ * Every other check passed in every app; the apps installed and served correctly. The trees
22
+ * were dirty because somebody else's in-flight sweep was sitting in them, uncommitted.
23
+ *
24
+ * 🔴 **Then the failure did something far worse than fail.** A non-edge smoke failure rolls
25
+ * back with `git revert`, and the rollback ran in that same dirty tree. Both outcomes were
26
+ * bad, and both happened inside four minutes:
27
+ *
28
+ * · **It succeeded** in `family`, `flix`, `music` and `roms` — landing a commit that reverted
29
+ * work a stranger had written and had never been told was touched. (`706d1106`, `50aa95ed`,
30
+ * `c6ef8cb6`, `88fececc`; each reverted-back the same session.)
31
+ * · **It failed** in `station` — `error: Your local changes to src/api/console.ts would be
32
+ * overwritten by merge … fatal: revert failed`, then `🔴 THE ROLLBACK DID NOT LAND.`, which
33
+ * leaves a live hostname on a deploy the script had already decided to undo.
34
+ *
35
+ * ## The shape of the bug, which is the reason this is a module and not an `if`
36
+ *
37
+ * 🔴 **The rollback was unsafe in exactly the condition that triggered it.** `clean-tree` fires
38
+ * only when the tree is dirty; `git revert` is only safe when it is not. The two met on every
39
+ * run. Nothing else in this generation can commit on an agent's behalf, and this did it to a
40
+ * stranger's work.
41
+ *
42
+ * So the fix is two refusals, not one, and they are here rather than inline in `deploy.ts`
43
+ * because a refusal nobody can test is a comment:
44
+ *
45
+ * 1. {@link refuseToDeploy} — run BEFORE the build, so the deploy never starts. A dirty tree
46
+ * means the thing that would deploy is not the thing `HEAD` names, so there is no commit a
47
+ * revert could undo. A pre-flight refusal costs a second; not having one cost four commits.
48
+ * 2. {@link refuseToRevert} — run immediately before `git revert`, because the pre-flight
49
+ * passing is not a promise about ten minutes later. A build, a stage run, or another agent
50
+ * working in this same checkout can all dirty the tree between the two, and one agent per
51
+ * checkout is a convention, not a lock.
52
+ *
53
+ * Neither takes an override flag, deliberately. `deploy.ts`'s own `--allow-empty` note records
54
+ * what an escape hatch on a gate that fires every day turns into: the flag becomes the habit,
55
+ * and the habit is what lets the real case through.
56
+ */
57
+
58
+ /** One uncommitted path, with the two-letter porcelain code git gave it. */
59
+ export type DirtyPath = {
60
+ /** The index/worktree code, e.g. `" M"`, `"D "`, `"??"`. */
61
+ code: string;
62
+ path: string;
63
+ };
64
+
65
+ export type TreeStatus = {
66
+ clean: boolean;
67
+ paths: DirtyPath[];
68
+ /**
69
+ * The subset that are DELETIONS. Called out separately because a rollback landing one is
70
+ * the worst case of all: `apps/music` and `apps/station` both sat with
71
+ * `D scripts/guardrails.ts` uncommitted — a deleted guardrails script, one blanket commit
72
+ * away from being deleted for real, by a script whose whole job was to be safe.
73
+ */
74
+ deletions: DirtyPath[];
75
+ };
76
+
77
+ /**
78
+ * One porcelain v1 line: `XY<space><path>`, `X` the index state and `Y` the worktree state.
79
+ *
80
+ * 🔴 The second branch is not hypothetical — it is the bug this module shipped with for about
81
+ * ten minutes. `deploy.ts`'s `shell()` helper `.trim()`s the output it returns, which strips
82
+ * the LEADING SPACE off the first line of ` M scripts/deploy.ts` and nothing else. The
83
+ * fixed-offset parse then read one character late and reported `M cripts/deploy.ts` — a path
84
+ * that does not exist, in a refusal whose whole job is to name paths a person can go and look
85
+ * at. `deploy.ts` now reads the status untrimmed; this branch means a caller that forgets
86
+ * still gets the right answer rather than a plausible wrong one.
87
+ */
88
+ function parseLine(line: string): DirtyPath {
89
+ const [code, rest] = line[2] === " " ? [line.slice(0, 2), line.slice(3)] : [` ${line.slice(0, 1)}`, line.slice(2)];
90
+ const path = rest.trim();
91
+ // A rename is `R old -> new`; keep the destination, which is the path that exists on disk.
92
+ const arrow = path.indexOf(" -> ");
93
+ return { code, path: arrow >= 0 ? path.slice(arrow + 4).trim() : path };
94
+ }
95
+
96
+ /** Parse `git status --porcelain` output. */
97
+ export function readTreeStatus(porcelain: string): TreeStatus {
98
+ const paths: DirtyPath[] = [];
99
+ for (const line of porcelain.split("\n")) {
100
+ if (line.trim() === "") continue;
101
+ paths.push(parseLine(line));
102
+ }
103
+ return {
104
+ clean: paths.length === 0,
105
+ paths,
106
+ deletions: paths.filter((p) => p.code.includes("D")),
107
+ };
108
+ }
109
+
110
+ /** `git status --porcelain`'s own rendering of a path, for an error message. */
111
+ function listed(paths: DirtyPath[], limit = 8): string {
112
+ const shown = paths.slice(0, limit).map((p) => ` ${p.code} ${p.path}`);
113
+ if (paths.length > limit) shown.push(` … and ${paths.length - limit} more`);
114
+ return shown.join("\n");
115
+ }
116
+
117
+ /**
118
+ * The pre-flight. Returns the refusal to print, or `null` if the deploy may proceed.
119
+ *
120
+ * 🔴 This runs before the build — before anything is built, staged, installed or swapped — so
121
+ * that the deploy simply does not start. The alternative, which is what happened, is that the
122
+ * deploy runs all the way to the smoke, the smoke's `clean-tree` check fails on this exact
123
+ * condition, and the rollback then commits against a stranger.
124
+ */
125
+ export function refuseToDeploy(status: TreeStatus): string | null {
126
+ if (status.clean) return null;
127
+ const lines = [
128
+ `\n✗ clean-tree — this checkout has ${status.paths.length} uncommitted path(s), so it must not deploy.`,
129
+ "",
130
+ " What deploys here is the WORKING TREE — there is no artifact to swap back. With",
131
+ " uncommitted changes in it, the code that would go live is not the code HEAD names,",
132
+ " so there is no commit a `git revert` could undo. The rollback this script would reach",
133
+ " for is not a rollback, and on 2026-09-18 it committed reverts of four other agents'",
134
+ " work rather than admit that. Nothing has been built, installed or swapped.",
135
+ "",
136
+ listed(status.paths),
137
+ ];
138
+ if (status.deletions.length > 0) {
139
+ lines.push(
140
+ "",
141
+ ` 🔴 ${status.deletions.length} of those is a DELETION. If this is not your work, do not commit it —`,
142
+ " two app checkouts on this fleet sat with a guardrails script deleted and uncommitted,",
143
+ " one blanket commit away from losing the file. This module's header names them.",
144
+ );
145
+ }
146
+ lines.push(
147
+ "",
148
+ " Commit them, or find whoever owns them and let them land — then deploy. `git status`",
149
+ " and `git diff` say whose they are; the runner commits by name, so a tree it left dirty",
150
+ " is somebody's live work, not litter.",
151
+ );
152
+ return lines.join("\n");
153
+ }
154
+
155
+ /**
156
+ * The rollback guard. Returns the refusal to print, or `null` if `git revert` is safe to run.
157
+ *
158
+ * 🔴 Never `git revert` in a dirty tree AT ALL — not only when `clean-tree` was the failing
159
+ * check. `station` proves the other half: its revert hit `error: Your local changes to
160
+ * src/api/console.ts would be overwritten by merge`, aborted, and left the live host on a
161
+ * deploy the script had decided to undo. A revert that half-lands is worse than one that never
162
+ * started, because the second one still has an honest error message.
163
+ */
164
+ export function refuseToRevert(status: TreeStatus, sha: string): string | null {
165
+ if (status.clean) return null;
166
+ return [
167
+ `\n🔴 NOT ROLLING BACK. The deploy failed, and ${sha.slice(0, 8)} is still what this checkout serves.`,
168
+ "",
169
+ ` This tree has ${status.paths.length} uncommitted path(s), and \`git revert\` in a dirty tree either`,
170
+ " commits somebody else's work as reverted or aborts halfway and leaves the host on the",
171
+ " code it was told to undo. Both happened on 2026-09-18. Neither is a rollback, so this",
172
+ " script will not do it — a deploy that needs a person is a better outcome than a commit",
173
+ " nobody asked for.",
174
+ "",
175
+ listed(status.paths),
176
+ "",
177
+ " A person: land or set aside the paths above, then `git revert --no-edit " +
178
+ `${sha.slice(0, 8)}\`, then`,
179
+ " `bun run build && bun run deploy` — the app's own deploy doc, § 'by hand', is the long form.",
180
+ ].join("\n");
181
+ }
package/src/roots.ts CHANGED
@@ -163,15 +163,29 @@ export function readForgeVar(marker: string, name: string, env: NodeJS.ProcessEn
163
163
  } catch {
164
164
  return null;
165
165
  }
166
- const known: Record<string, string> = { HOME: env.HOME?.trim() ?? "" };
166
+ // 🔴 `null` is UNRESOLVABLE, and it propagates: a value that references an unset or empty
167
+ // variable — `$HOME` from an env that has none — is not a path with a hole in it. Until 0.5.4
168
+ // an empty HOME expanded `"$HOME/.code/$FORGE_NAME"` to `/.code/cursedforge`, a root-relative
169
+ // state root that exists on no machine, and `assertTestSafeDbPath(…, { env: {} })` compared
170
+ // against it — the guard against a test opening the owner's database was silently OFF (task 2130).
171
+ const home = env.HOME?.trim();
172
+ const known: Record<string, string | null> = { HOME: home ? home : null };
167
173
  for (const line of text.split("\n")) {
168
174
  const match = /^\s*export\s+([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line);
169
175
  if (!match) continue;
170
176
  const [, key, rawValue] = match as unknown as [string, string, string];
171
177
  const unquoted = rawValue.trim().replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
172
- const expanded = unquoted.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (_whole, ref: string) => known[ref] ?? "");
173
- known[key] = expanded;
174
- if (key === name) return expanded || null;
178
+ let unresolved = false;
179
+ const expanded = unquoted.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (_whole, ref: string) => {
180
+ const value = known[ref];
181
+ if (value === null || value === undefined || value === "") {
182
+ unresolved = true;
183
+ return "";
184
+ }
185
+ return value;
186
+ });
187
+ known[key] = unresolved ? null : expanded;
188
+ if (key === name) return unresolved ? null : expanded || null;
175
189
  }
176
190
  return null;
177
191
  }
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `cursedops/staged-client` — the IMMUTABLE client a checkout-served app serves: a build copied
4
+ * into `<APP_DATA_DIR>/client/<commit>/`, named by `CURRENT`, so another agent's `vite build` in
5
+ * the shared checkout can never change the bytes production is serving.
6
+ *
7
+ * ── Why (tasks 163-314, 2026-09-17 → 23) ────────────────────────────────────
8
+ * The launchd agent runs `server.ts` out of `$FORGE/apps/<app>`, and until this module it also
9
+ * served that checkout's `dist/` — so any `bun run verify` or `bun run build` by anybody in the
10
+ * checkout rewrote the live client under a server built from a different commit. Measured on
11
+ * station first (`/healthz` said one commit, `dist/index.html` another, a page asking for routes
12
+ * its server had never heard of); family, flix and roms had the same shape and took the same
13
+ * module. It lived as a byte-identical `src/server/clientDir.ts` in those four apps, with
14
+ * `scripts/stamp-build.ts` (four) and `scripts/stage-client.ts` (family, roms); lifted here 0.6.0.
15
+ *
16
+ * ```ts
17
+ * import { resolveClientDir, stageClient } from "cursedops/staged-client";
18
+ * const client = resolveClientDir({ appDir, dataDir: config.dataDir }); // serve client.dir
19
+ * ```
20
+ *
21
+ * ```jsonc
22
+ * "build": "vite build && forge-client stamp" // writes dist/build.json — the commit
23
+ * // deploy: stageClient({ from: dist, dataDir, commit }), or `forge-client stage`
24
+ * ```
25
+ *
26
+ * `stageClient` copies FULLY, verifies the shell arrived, and only then moves `CURRENT`, so a
27
+ * failed stage leaves the previous release serving. The directory is keyed on the commit (a
28
+ * dirty build gets `-dirty`), so a rollback is a directory that is still there, and
29
+ * {@link pruneStagedClients} keeps the newest few plus whatever `CURRENT` names. Nothing staged
30
+ * falls back to `<appDir>/dist` and SAYS so (`staged: false`), because dev, preview and e2e serve
31
+ * the bytes vite just wrote, and a production instance falling back is worth a log line.
32
+ *
33
+ * Standalone by construction: node builtins only, and `cursedops/roots` by its package name for
34
+ * the one CLI default that needs the state root (a shipped file imports no sibling by path).
35
+ */
36
+ import { spawnSync } from "node:child_process";
37
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
38
+ import { dirname, join, resolve } from "node:path";
39
+
40
+ /** The directory under the app's state root that holds every staged build. */
41
+ export const CLIENT_ROOT = "client";
42
+
43
+ /** The file inside {@link CLIENT_ROOT} naming the build that should be served. */
44
+ export const CURRENT_FILE = "CURRENT";
45
+
46
+ /** What `bun run build` stamps beside the shell so a build can name its own commit. */
47
+ const BUILD_STAMP = "build.json";
48
+
49
+ /** How many staged builds are kept. Enough for a rollback, not a disk leak. */
50
+ const KEEP_BUILDS = 3;
51
+
52
+ export interface BuildStamp {
53
+ /** The full 40-char commit the client was built from, or `null` if unreadable. */
54
+ commit: string | null;
55
+ /** Whether the tree was dirty when it was built. */
56
+ dirty: boolean | null;
57
+ /** When it was built — ISO 8601. */
58
+ builtAt: string;
59
+ }
60
+
61
+ /** A directory is a usable client only if it actually holds the shell. */
62
+ export const holdsShell = (dir: string): boolean => existsSync(join(dir, "index.html"));
63
+
64
+ /** The stamp a build wrote, or `null`. Never throws: an unreadable stamp is a `null` commit. */
65
+ export function readBuildStamp(dir: string): BuildStamp | null {
66
+ try {
67
+ const parsed = JSON.parse(readFileSync(join(dir, BUILD_STAMP), "utf8")) as Partial<BuildStamp>;
68
+ if (typeof parsed !== "object" || parsed === null) return null;
69
+ return {
70
+ commit: typeof parsed.commit === "string" && /^[0-9a-f]{40}$/.test(parsed.commit) ? parsed.commit : null,
71
+ dirty: typeof parsed.dirty === "boolean" ? parsed.dirty : null,
72
+ builtAt: typeof parsed.builtAt === "string" ? parsed.builtAt : "",
73
+ };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** Write the stamp. `forge-client stamp` calls it immediately after `vite build`. */
80
+ export function writeBuildStamp(dir: string, stamp: BuildStamp): void {
81
+ writeFileSync(join(dir, BUILD_STAMP), `${JSON.stringify(stamp, null, "\t")}\n`);
82
+ }
83
+
84
+ /**
85
+ * The staged build this app should serve, or `null` when nothing is staged.
86
+ *
87
+ * 🔴 A `CURRENT` naming a directory that is missing or shell-less answers `null`
88
+ * rather than throwing. The fallback to `dist/` keeps the app UP, and the smoke's
89
+ * `client-commit` row is what makes the miss loud — an app that refuses to boot
90
+ * because a pointer file is stale is a worse outcome than one serving the previous
91
+ * client and saying so.
92
+ */
93
+ export function stagedClientDir(dataDir: string): string | null {
94
+ const root = join(dataDir, CLIENT_ROOT);
95
+ let named: string;
96
+ try {
97
+ named = readFileSync(join(root, CURRENT_FILE), "utf8").trim();
98
+ } catch {
99
+ return null;
100
+ }
101
+ // One path segment, never joined from anything a request can influence — but the
102
+ // file is on disk and a hand edit is exactly how this would become a traversal.
103
+ if (!/^[A-Za-z0-9._-]+$/.test(named) || named === "." || named === "..") return null;
104
+ const dir = join(root, named);
105
+ return holdsShell(dir) ? dir : null;
106
+ }
107
+
108
+ export interface ResolvedClient {
109
+ /** The directory to serve. */
110
+ dir: string;
111
+ /** True when it came from the state root, false when it is the checkout's `dist/`. */
112
+ staged: boolean;
113
+ /** The commit the served client was BUILT from, full sha, or `null`. */
114
+ commit: string | null;
115
+ }
116
+
117
+ /**
118
+ * Where this process's client lives. `dataDir` is `null` in a context with no state
119
+ * root at all, which resolves to the checkout exactly as before.
120
+ */
121
+ export function resolveClientDir(options: { appDir: string; dataDir: string | null }): ResolvedClient {
122
+ const staged = options.dataDir ? stagedClientDir(options.dataDir) : null;
123
+ const dir = staged ?? join(options.appDir, "dist");
124
+ return { dir, staged: staged !== null, commit: readBuildStamp(dir)?.commit ?? null };
125
+ }
126
+
127
+ /**
128
+ * Copy a build into the state root and point `CURRENT` at it. Returns the directory.
129
+ *
130
+ * 🔴 The order is the whole safety property: copy fully, verify the shell arrived,
131
+ * and only THEN write `CURRENT`. A pointer is never updated to a half-copied build,
132
+ * so the worst a failed stage can do is leave the previous release serving.
133
+ *
134
+ * 🔴 The directory is keyed on the commit, so re-staging the same commit is
135
+ * idempotent and a rollback is a directory that is still there. `dirty` builds get a
136
+ * suffix, because two dirty builds of the same commit are genuinely different bytes
137
+ * and must not collide.
138
+ */
139
+ export function stageClient(options: {
140
+ from: string;
141
+ dataDir: string;
142
+ commit: string;
143
+ dirty?: boolean;
144
+ keep?: number;
145
+ }): string {
146
+ if (!holdsShell(options.from)) {
147
+ throw new Error(`${options.from} holds no index.html — there is no built client to stage`);
148
+ }
149
+ const root = join(options.dataDir, CLIENT_ROOT);
150
+ mkdirSync(root, { recursive: true });
151
+ const name = options.dirty ? `${options.commit.slice(0, 12)}-dirty` : options.commit.slice(0, 12);
152
+ const dir = join(root, name);
153
+ rmSync(dir, { recursive: true, force: true });
154
+ cpSync(options.from, dir, { recursive: true });
155
+ if (!holdsShell(dir)) throw new Error(`staging ${options.from} into ${dir} did not produce an index.html`);
156
+ writeFileSync(join(root, CURRENT_FILE), `${name}\n`);
157
+ pruneStagedClients(options.dataDir, options.keep ?? KEEP_BUILDS);
158
+ return dir;
159
+ }
160
+
161
+ /** Keep the newest `keep` staged builds plus whatever `CURRENT` names. */
162
+ export function pruneStagedClients(dataDir: string, keep: number = KEEP_BUILDS): string[] {
163
+ const root = join(dataDir, CLIENT_ROOT);
164
+ if (!existsSync(root)) return [];
165
+ let current = "";
166
+ try {
167
+ current = readFileSync(join(root, CURRENT_FILE), "utf8").trim();
168
+ } catch {
169
+ // No pointer — every directory here is a candidate but the newest still stay.
170
+ }
171
+ const dirs = readdirSync(root, { withFileTypes: true })
172
+ .filter((entry) => entry.isDirectory())
173
+ .map((entry) => ({ name: entry.name, at: statSync(join(root, entry.name)).mtimeMs }))
174
+ .sort((a, b) => b.at - a.at);
175
+ const removed: string[] = [];
176
+ for (const [index, entry] of dirs.entries()) {
177
+ if (index < keep || entry.name === current) continue;
178
+ rmSync(join(root, entry.name), { recursive: true, force: true });
179
+ removed.push(entry.name);
180
+ }
181
+ return removed;
182
+ }
183
+
184
+
185
+ // ── the `forge-client` bin ────────────────────────────────────────────────────────────────
186
+
187
+ /** The commit and dirtiness of the checkout at `root`, read from git; `null`s when unreadable. */
188
+ export function checkoutState(root: string): { commit: string | null; dirty: boolean | null } {
189
+ const git = (args: string[]): string | null => {
190
+ try {
191
+ const result = spawnSync("git", args, { cwd: root, encoding: "utf8", timeout: 5_000 });
192
+ return result.status === 0 ? (result.stdout ?? "").trim() : null;
193
+ } catch {
194
+ return null;
195
+ }
196
+ };
197
+ const sha = git(["rev-parse", "HEAD"]);
198
+ const status = git(["status", "--porcelain"]);
199
+ return { commit: sha && /^[0-9a-f]{40}$/.test(sha) ? sha : null, dirty: status === null ? null : status.length > 0 };
200
+ }
201
+
202
+ /** The nearest directory at or above `from` holding a `package.json` — the app. */
203
+ function packageRoot(from: string): string | null {
204
+ let dir = resolve(from);
205
+ for (let hops = 0; hops < 16; hops++) {
206
+ if (existsSync(join(dir, "package.json"))) return dir;
207
+ const up = dirname(dir);
208
+ if (up === dir) return null;
209
+ dir = up;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * `forge-client stamp [dir]` writes `build.json` into a build (default `<app>/dist`);
216
+ * `forge-client stage [--data-dir <dir>]` stages `<app>/dist` into the app's data directory —
217
+ * `--data-dir`, else `APP_DATA_DIR`, else `<FORGE_STATE>/apps/<package name>` — and refuses a
218
+ * build whose stamp is not this checkout's HEAD. Returns the exit code.
219
+ */
220
+ export async function runClientCli(argv: readonly string[], cwd: string = process.cwd(), env: NodeJS.ProcessEnv = process.env): Promise<number> {
221
+ const [command, ...rest] = argv;
222
+ const app = packageRoot(cwd);
223
+ if (!app) {
224
+ console.error(`✗ no package.json above ${cwd} — run this from an app.`);
225
+ return 2;
226
+ }
227
+ if (command === "stamp") {
228
+ const dir = rest[0] ? resolve(cwd, rest[0]) : join(app, "dist");
229
+ if (!holdsShell(dir)) {
230
+ console.error(`✗ ${dir} holds no index.html — nothing to stamp. Did the build run?`);
231
+ return 1;
232
+ }
233
+ const { commit, dirty } = checkoutState(app);
234
+ writeBuildStamp(dir, { commit, dirty, builtAt: new Date().toISOString() });
235
+ console.log(` stamped ${dir}/build.json — ${commit?.slice(0, 8) ?? "no commit"}${dirty ? " (dirty)" : ""}`);
236
+ return 0;
237
+ }
238
+ if (command === "stage") {
239
+ const at = rest.indexOf("--data-dir");
240
+ let dataDir = at >= 0 ? rest[at + 1] : env.APP_DATA_DIR?.trim() || undefined;
241
+ if (!dataDir) {
242
+ const { forgeState } = await import("cursedops/roots");
243
+ const state = forgeState(app, env);
244
+ const name = (JSON.parse(readFileSync(join(app, "package.json"), "utf8")) as { name?: string }).name;
245
+ if (!state || !name) {
246
+ console.error("✗ no --data-dir, no APP_DATA_DIR, and no state root (or no package name) to derive one from.");
247
+ return 2;
248
+ }
249
+ dataDir = join(state, "apps", name);
250
+ }
251
+ const dist = join(app, "dist");
252
+ const stamp = readBuildStamp(dist);
253
+ const head = checkoutState(app).commit;
254
+ if (!stamp?.commit) {
255
+ console.error(`✗ ${dist} carries no build.json commit — build first (\`forge-client stamp\` runs after vite).`);
256
+ return 1;
257
+ }
258
+ if (stamp.commit !== head) {
259
+ console.error(`✗ ${dist} was built from ${stamp.commit.slice(0, 8)}, but HEAD is ${head?.slice(0, 8) ?? "unreadable"}.`);
260
+ console.error(" Staging it would serve a client that is not this commit. Build again first.");
261
+ return 1;
262
+ }
263
+ const where = stageClient({ from: dist, dataDir, commit: stamp.commit, dirty: stamp.dirty !== false });
264
+ console.log(`✓ staged ${where}${stamp.dirty !== false ? " (from a DIRTY tree)" : ""}`);
265
+ return 0;
266
+ }
267
+ console.error("usage: forge-client stamp [dir] | forge-client stage [--data-dir <dir>]");
268
+ return 2;
269
+ }
270
+
271
+ if (import.meta.main) {
272
+ process.exit(await runClientCli(process.argv.slice(2)));
273
+ }