getculpa 1.0.2 → 1.0.4

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/lib/provision.mjs CHANGED
@@ -1,318 +1,329 @@
1
- // CF20-T3 — the defer-start provisioner. Lays down everything the Windows
2
- // installer lays down; starts nothing (compose up, collector start, and a
3
- // browser open are all launch-time work — CF20-T4).
4
- //
5
- // Docker interaction (presence, engine reachability, image pull) and the
6
- // win32 installer delegation both take injectable dependencies, defaulting
7
- // to the real ones. Tests MUST override them: this module runs on the
8
- // founder's own dev machine, and the real win32 delegate writes to the real
9
- // Desktop, Start Menu and HKCU — never acceptable as a side effect of a test
10
- // run. Docker-command recording (for the "nothing started" fact) happens
11
- // through the injected dockerSpawnSync rather than a PATH shim, for the same
12
- // reason install-culpa.ps1 injects -Probe into Test-DockerInstalled: an
13
- // explicit dependency is more reliable than relying on OS PATH resolution
14
- // rules for a fake executable.
15
-
16
- import { spawnSync as realSpawnSync } from "node:child_process";
17
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
- import path from "node:path";
19
- import { fileURLToPath } from "node:url";
20
- import { checkDockerEngineReachable, checkDockerPresent, classifyUpgrade, isIncomingComposeOlder } from "./preflight.mjs";
21
- import { resolveAssetPath } from "./assets.mjs";
22
- import { canonicalAppDir } from "./paths.mjs";
23
-
24
- // T-CF29-8: where lib/fetch.mjs stages the vendored binaries. Same derivation
25
- // lib/repair.mjs already uses, so the two agree on one vendor directory.
26
- const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
27
-
28
- // The full parity file-set (Windows inventory: culpa-setup.iss:43-54, minus
29
- // the collectord binary, which lib/fetch.mjs stages separately by triple).
30
- // This is the WINDOWS inventory and stays the canonical full list — use
31
- // parityAssetsFor(platform) to get what a given platform should actually
32
- // receive.
33
- export const PARITY_ASSETS = [
34
- "culpa-compose.yml",
35
- "install-culpa.ps1",
36
- "launch-culpa.ps1",
37
- "uninstall-culpa.ps1",
38
- "culpa-collector.ps1",
39
- "register.mjs",
40
- ];
41
-
42
- // T-CF28-6 four of the six are PowerShell scripts that only win32 can run:
43
- // lib/start.mjs delegates to launch-culpa.ps1 only when platform === "win32"
44
- // (start.mjs:457), lib/uninstall.mjs only delegates to uninstall-culpa.ps1 on
45
- // win32 (uninstall.mjs:80-82), and install-culpa.ps1/culpa-collector.ps1 are
46
- // invoked by the Windows installer path alone. Staging them on darwin/linux
47
- // put four inert files in the user's app dir and made `getculpa doctor` grade
48
- // them as part of a healthy install.
49
- export const WINDOWS_ONLY_ASSETS = Object.freeze([
50
- "install-culpa.ps1",
51
- "launch-culpa.ps1",
52
- "uninstall-culpa.ps1",
53
- "culpa-collector.ps1",
54
- ]);
55
-
56
- // The single source of truth for "which parity files belong on this platform".
57
- // provision(), repair() and doctor() ALL read it — if any one of them used the
58
- // flat list instead, repair would restore what provision skipped, or doctor
59
- // would fail an install for a file that is correctly absent.
60
- export function parityAssetsFor(platform = process.platform) {
61
- if (platform === "win32") return PARITY_ASSETS;
62
- return PARITY_ASSETS.filter((name) => !WINDOWS_ONLY_ASSETS.includes(name));
63
- }
64
-
65
- // CF20-T6 protects the SHIPPED culpa-compose.yml the same way
66
- // stageLiveComposeIfAbsent already protects the live one, mirroring
67
- // launch-culpa.ps1's QW-1 one-direction rule: an older incoming pin must
68
- // never silently overwrite a newer one already staged (the
69
- // downgrade-package case npm delivered an older getculpa than what is
70
- // already here). CULPA_ALLOW_DOWNGRADE=1 is the same escape hatch T4's
71
- // start.mjs already honors for QW-3 at launch time.
72
- function stageShippedCompose(appDir, env) {
73
- const src = resolveAssetPath("culpa-compose.yml");
74
- const dest = path.join(appDir, "culpa-compose.yml");
75
- const allowDowngrade = env.CULPA_ALLOW_DOWNGRADE === "1";
76
- if (existsSync(dest) && !allowDowngrade) {
77
- const incoming = readFileSync(src, "utf8");
78
- const staged = readFileSync(dest, "utf8");
79
- if (isIncomingComposeOlder(incoming, staged)) {
80
- console.warn(
81
- "getculpa: the incoming package's culpa-compose.yml pin is older than the one already staged - skipped (set CULPA_ALLOW_DOWNGRADE=1 to override).",
82
- );
83
- return;
84
- }
85
- }
86
- copyFileSync(src, dest);
87
- }
88
-
89
- // CF20-T5 (repair): exported so lib/repair.mjs can re-stage the parity
90
- // file-set and (only-if-absent) the live compose without duplicating this
91
- // logic — same reason the task instructions call out reuse over a second
92
- // copy. Purely additive: behavior and call sites inside this file are
93
- // unchanged.
94
- export function stageParityFiles(appDir, env = process.env, platform = process.platform) {
95
- mkdirSync(appDir, { recursive: true });
96
- stageShippedCompose(appDir, env);
97
- for (const name of parityAssetsFor(platform)) {
98
- if (name === "culpa-compose.yml") continue;
99
- copyFileSync(resolveAssetPath(name), path.join(appDir, name));
100
- }
101
- }
102
-
103
- // The live compose is staged ONLY when absent. An existing live compose
104
- // belongs to launch-time's QW-1 sync (launch-culpa.ps1) — provisioning must
105
- // never re-pin a running install out from under it.
106
- export function stageLiveComposeIfAbsent(appDir) {
107
- const live = path.join(appDir, "docker-compose.yml");
108
- if (existsSync(live)) return false;
109
- copyFileSync(path.join(appDir, "culpa-compose.yml"), live);
110
- return true;
111
- }
112
-
113
- // T-CF29-8 — the name culpa-collector.ps1:35 reads from the app dir, and the
114
- // same name culpa-setup.iss:52 installs there. NOT the Rust target triple
115
- // lib/fetch.mjs uses for the release asset (x86_64-pc-windows-msvc): this is
116
- // the Node-style platform-arch pair.
117
- export function collectorBinaryName(platform = process.platform, arch = process.arch) {
118
- const exe = platform === "win32" ? ".exe" : "";
119
- return `culpa-collectord-${platform}-${arch}${exe}`;
120
- }
121
-
122
- // T-CF29-8 — capture was silently OFF on every Windows npm install:
123
- // "culpa-collectord-win32-x64.exe not present in this package - skipping
124
- // capture setup". lib/fetch.mjs stages the binary into the PACKAGE's vendor/
125
- // as culpa-collectord.exe, the .ps1 reads it from the APP DIR under the
126
- // triple-suffixed name, and nothing bridged the two. The Windows installer
127
- // path has always put it in the app dir, so this is the npm path catching up
128
- // to installer parity, not a new convention.
129
- //
130
- // Returns whether the binary is really there afterwards — never a hopeful
131
- // echo of the caller's collectorFetched flag, because "capture is on" is
132
- // exactly the kind of claim that must be backed by a file on disk.
133
- export function stageCollectorBinary({ appDir, vendorDir, platform, arch }) {
134
- const exe = platform === "win32" ? ".exe" : "";
135
- const src = path.join(vendorDir, `culpa-collectord${exe}`);
136
- if (!existsSync(src)) return false;
137
- const dest = path.join(appDir, collectorBinaryName(platform, arch));
138
- try {
139
- copyFileSync(src, dest);
140
- } catch (e) {
141
- console.warn(`getculpa: capture collector could not be staged (${e.message}) - capture OFF, everything else works.`);
142
- return false;
143
- }
144
- return existsSync(dest);
145
- }
146
-
147
- function detectDockerState(dockerSpawnSync) {
148
- if (!checkDockerPresent(dockerSpawnSync)) return "missing";
149
- if (!checkDockerEngineReachable(dockerSpawnSync)) return "installed-not-running";
150
- return "ready";
151
- }
152
-
153
- // Staging only — `pull`, never `up`. This is the defer invariant's other
154
- // half: even with a reachable engine, provisioning must not start anything.
155
- function stageImages(appDir, dockerSpawnSync) {
156
- const shipped = path.join(appDir, "culpa-compose.yml");
157
- const result = dockerSpawnSync("docker", ["compose", "-f", shipped, "-p", "culpa", "pull"], {
158
- encoding: "utf8",
159
- });
160
- return result.status === 0;
161
- }
162
-
163
- // Real win32 delegate: -DeferStart -NonInteractive is what makes this safe
164
- // to run unattended from postinstall (no dialogs, no engine start, no
165
- // compose up, no browser) while still producing the same shortcuts + HKCU
166
- // registration the Windows installer produces.
167
- function defaultDelegateWindowsInstaller(appDir) {
168
- const script = path.join(appDir, "install-culpa.ps1");
169
- const result = realSpawnSync(
170
- "powershell.exe",
171
- ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-DeferStart", "-NonInteractive"],
172
- { stdio: "inherit" },
173
- );
174
- return result.status === 0;
175
- }
176
-
177
- function writeState(appDir, state) {
178
- writeFileSync(path.join(appDir, "install-state.json"), `${JSON.stringify(state, null, 2)}\n`);
179
- }
180
-
181
- function readExistingState(appDir) {
182
- try {
183
- return JSON.parse(readFileSync(path.join(appDir, "install-state.json"), "utf8"));
184
- } catch {
185
- return null;
186
- }
187
- }
188
-
189
- // CF20-T6 the honest, human-facing line for each classifyUpgrade outcome.
190
- // "upgrade" is explicit that the new version is STAGED, not running yet —
191
- // it applies at the next `getculpa` (start.mjs's QW-1-equivalent sync + T4's
192
- // launch orchestration), never here. Postinstall stays silent about
193
- // migrations by design: those are QW-4's job at that next `getculpa`, not
194
- // this one.
195
- function describeClassification(classification, previousVersion, currentVersion) {
196
- switch (classification) {
197
- case "fresh":
198
- return `getculpa: installing Culpa ${currentVersion}.`;
199
- case "upgrade":
200
- return `getculpa: upgrading Culpa ${previousVersion} -> ${currentVersion} (staged; applies at next \`getculpa\`).`;
201
- case "same-version":
202
- return `getculpa: repairing the existing Culpa ${currentVersion} installation (same version reinstalled).`;
203
- case "downgrade-package":
204
- return (
205
- `getculpa: this package (${currentVersion}) is older than the installed Culpa ${previousVersion} - ` +
206
- "refusing to downgrade (set CULPA_ALLOW_DOWNGRADE=1 to override)."
207
- );
208
- case "repair":
209
- default:
210
- return "getculpa: repairing the existing Culpa installation.";
211
- }
212
- }
213
-
214
- export async function provision(opts) {
215
- const {
216
- appDir,
217
- currentVersion,
218
- collectorFetched = false,
219
- platform = process.platform,
220
- arch = process.arch,
221
- vendorDir = path.join(packageRoot, "vendor"),
222
- dockerSpawnSync = realSpawnSync,
223
- delegateWindowsInstaller = defaultDelegateWindowsInstaller,
224
- env = process.env,
225
- } = opts;
226
-
227
- // Classify + capture the pre-existing state BEFORE anything below mutates
228
- // appDir — both read the install-state.json / live compose that staging
229
- // is about to overwrite.
230
- const { classification, previousVersion } = classifyUpgrade({ appDir, currentVersion });
231
- console.log(describeClassification(classification, previousVersion, currentVersion));
232
- const existingState = readExistingState(appDir);
233
-
234
- // CF20-R: a REFUSED downgrade (classification === "downgrade-package",
235
- // CULPA_ALLOW_DOWNGRADE not set) used to still restage every OTHER parity
236
- // file (the ps1 scripts, register.mjs) with the older package's copies —
237
- // only culpa-compose.yml was protected (stageShippedCompose's own guard).
238
- // That left a mixed-version app dir: a newer compose pin beside older
239
- // scripts. Skip the WHOLE parity re-stage in this case instead, so the app
240
- // dir stays wholly at the newer version; dir/state handling below is
241
- // unaffected.
242
- const allowDowngrade = env.CULPA_ALLOW_DOWNGRADE === "1";
243
- const refusedDowngrade = classification === "downgrade-package" && !allowDowngrade;
244
- if (refusedDowngrade) {
245
- console.warn(
246
- `getculpa: this package is older than the installed Culpa ${previousVersion} - skipping the parity file re-stage entirely (compose, scripts, register.mjs) to avoid a mixed-version app dir (set CULPA_ALLOW_DOWNGRADE=1 to override).`,
247
- );
248
- } else {
249
- stageParityFiles(appDir, env, platform);
250
- }
251
- stageLiveComposeIfAbsent(appDir);
252
-
253
- // T-CF29-8: only attempt it when the fetch reported success; the result is
254
- // what gets recorded, not the intent.
255
- const collectorStaged = collectorFetched && stageCollectorBinary({ appDir, vendorDir, platform, arch });
256
- if (collectorFetched && !collectorStaged) {
257
- console.warn(
258
- "getculpa: the capture collector was downloaded but could not be placed in the app directory - capture OFF, everything else works.",
259
- );
260
- }
261
-
262
- const dockerState = detectDockerState(dockerSpawnSync);
263
- let imagesStaged = false;
264
- if (dockerState === "ready") {
265
- imagesStaged = stageImages(appDir, dockerSpawnSync);
266
- if (!imagesStaged) {
267
- console.warn("getculpa: image staging failed - images will be pulled at first `getculpa`.");
268
- }
269
- } else {
270
- const why = dockerState === "missing" ? "Docker was not found" : "Docker is not running";
271
- console.warn(`getculpa: ${why} - image staging deferred to first \`getculpa\`.`);
272
- }
273
-
274
- if (platform === "win32") {
275
- // CF20-R: the Windows delegate registers the ONE fixed, per-machine HKCU
276
- // uninstall key (install-culpa.ps1 / culpa-setup.iss's AppId). A second
277
- // app dir on the same machine (CULPA_APP_DIR pointed somewhere
278
- // non-default) must never overwrite that shared key the gate's
279
- // historical incident class. Only delegate when this appDir IS the
280
- // canonical one (CULPA_APP_DIR's override stripped); otherwise skip
281
- // registration and say so, once, honestly.
282
- if (appDir === canonicalAppDir({ platform, env })) {
283
- const ok = delegateWindowsInstaller(appDir);
284
- if (!ok) {
285
- console.warn("getculpa: Windows shortcut/registry setup did not complete cleanly - shortcuts may be missing.");
286
- }
287
- } else {
288
- console.log("getculpa: non-canonical app dir: shortcuts/registry registration skipped.");
289
- }
290
- }
291
-
292
- // T6 review (Important): a REFUSED downgrade must not record the rejected,
293
- // older currentVersion as installed the protected artifacts still carry
294
- // the newer version, and this field is the ground truth the next run's
295
- // classification reads. Persist the true installed version instead.
296
- const recordedPackageVersion =
297
- classification === "downgrade-package" && previousVersion ? previousVersion : currentVersion;
298
- writeState(appDir, {
299
- packageVersion: recordedPackageVersion,
300
- previousVersion,
301
- classification,
302
- // provisionedAt is the FIRST install's timestamp, preserved across every
303
- // subsequent provision() run; updatedAt is this run's.
304
- provisionedAt: existingState?.provisionedAt ?? new Date().toISOString(),
305
- updatedAt: new Date().toISOString(),
306
- dockerState,
307
- imagesStaged,
308
- collectorStaged,
309
- platform,
310
- });
311
-
312
- // T-CF28-5/7: the Docker and staging facts were already computed above and
313
- // written to install-state.json, but the caller that has to REPORT them
314
- // (scripts/install.js) could not see them — which is how a Docker-less
315
- // install still printed "Culpa installed successfully." Returning them
316
- // changes no behavior here; it just stops postinstall from having to guess.
317
- return { classification, previousVersion, dockerState, imagesStaged, collectorStaged };
318
- }
1
+ // CF20-T3 — the defer-start provisioner. Lays down everything the Windows
2
+ // installer lays down; starts nothing (compose up, collector start, and a
3
+ // browser open are all launch-time work — CF20-T4).
4
+ //
5
+ // Docker interaction (presence, engine reachability, image pull) and the
6
+ // win32 installer delegation both take injectable dependencies, defaulting
7
+ // to the real ones. Tests MUST override them: this module runs on the
8
+ // founder's own dev machine, and the real win32 delegate writes to the real
9
+ // Desktop, Start Menu and HKCU — never acceptable as a side effect of a test
10
+ // run. Docker-command recording (for the "nothing started" fact) happens
11
+ // through the injected dockerSpawnSync rather than a PATH shim, for the same
12
+ // reason install-culpa.ps1 injects -Probe into Test-DockerInstalled: an
13
+ // explicit dependency is more reliable than relying on OS PATH resolution
14
+ // rules for a fake executable.
15
+
16
+ import { spawnSync as realSpawnSync } from "node:child_process";
17
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { checkDockerEngineReachable, checkDockerPresent, classifyUpgrade, isIncomingComposeOlder } from "./preflight.mjs";
21
+ import { resolveAssetPath } from "./assets.mjs";
22
+ import { canonicalAppDir } from "./paths.mjs";
23
+
24
+ // T-CF29-8: where lib/fetch.mjs stages the vendored binaries. Same derivation
25
+ // lib/repair.mjs already uses, so the two agree on one vendor directory.
26
+ const packageRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
27
+
28
+ // The full parity file-set (Windows inventory: culpa-setup.iss:43-54, minus
29
+ // the collectord binary, which lib/fetch.mjs stages separately by triple).
30
+ // This is the WINDOWS inventory and stays the canonical full list — use
31
+ // parityAssetsFor(platform) to get what a given platform should actually
32
+ // receive.
33
+ export const PARITY_ASSETS = [
34
+ "culpa-compose.yml",
35
+ "install-culpa.ps1",
36
+ "launch-culpa.ps1",
37
+ "uninstall-culpa.ps1",
38
+ "culpa-collector.ps1",
39
+ "register.mjs",
40
+ ];
41
+
42
+ // Shipped, but NOT parity and NOT graded by doctor see stageParityFiles.
43
+ export const RELAY_FLY_CONFIG = "culpa-relay-fly.toml";
44
+
45
+ // T-CF28-6 four of the six are PowerShell scripts that only win32 can run:
46
+ // lib/start.mjs delegates to launch-culpa.ps1 only when platform === "win32"
47
+ // (start.mjs:457), lib/uninstall.mjs only delegates to uninstall-culpa.ps1 on
48
+ // win32 (uninstall.mjs:80-82), and install-culpa.ps1/culpa-collector.ps1 are
49
+ // invoked by the Windows installer path alone. Staging them on darwin/linux
50
+ // put four inert files in the user's app dir and made `getculpa doctor` grade
51
+ // them as part of a healthy install.
52
+ export const WINDOWS_ONLY_ASSETS = Object.freeze([
53
+ "install-culpa.ps1",
54
+ "launch-culpa.ps1",
55
+ "uninstall-culpa.ps1",
56
+ "culpa-collector.ps1",
57
+ ]);
58
+
59
+ // The single source of truth for "which parity files belong on this platform".
60
+ // provision(), repair() and doctor() ALL read it — if any one of them used the
61
+ // flat list instead, repair would restore what provision skipped, or doctor
62
+ // would fail an install for a file that is correctly absent.
63
+ export function parityAssetsFor(platform = process.platform) {
64
+ if (platform === "win32") return PARITY_ASSETS;
65
+ return PARITY_ASSETS.filter((name) => !WINDOWS_ONLY_ASSETS.includes(name));
66
+ }
67
+
68
+ // CF20-T6 protects the SHIPPED culpa-compose.yml the same way
69
+ // stageLiveComposeIfAbsent already protects the live one, mirroring
70
+ // launch-culpa.ps1's QW-1 one-direction rule: an older incoming pin must
71
+ // never silently overwrite a newer one already staged (the
72
+ // downgrade-package case — npm delivered an older getculpa than what is
73
+ // already here). CULPA_ALLOW_DOWNGRADE=1 is the same escape hatch T4's
74
+ // start.mjs already honors for QW-3 at launch time.
75
+ function stageShippedCompose(appDir, env) {
76
+ const src = resolveAssetPath("culpa-compose.yml");
77
+ const dest = path.join(appDir, "culpa-compose.yml");
78
+ const allowDowngrade = env.CULPA_ALLOW_DOWNGRADE === "1";
79
+ if (existsSync(dest) && !allowDowngrade) {
80
+ const incoming = readFileSync(src, "utf8");
81
+ const staged = readFileSync(dest, "utf8");
82
+ if (isIncomingComposeOlder(incoming, staged)) {
83
+ console.warn(
84
+ "getculpa: the incoming package's culpa-compose.yml pin is older than the one already staged - skipped (set CULPA_ALLOW_DOWNGRADE=1 to override).",
85
+ );
86
+ return;
87
+ }
88
+ }
89
+ copyFileSync(src, dest);
90
+ }
91
+
92
+ // CF20-T5 (repair): exported so lib/repair.mjs can re-stage the parity
93
+ // file-set and (only-if-absent) the live compose without duplicating this
94
+ // logic same reason the task instructions call out reuse over a second
95
+ // copy. Purely additive: behavior and call sites inside this file are
96
+ // unchanged.
97
+ export function stageParityFiles(appDir, env = process.env, platform = process.platform) {
98
+ mkdirSync(appDir, { recursive: true });
99
+ stageShippedCompose(appDir, env);
100
+ for (const name of parityAssetsFor(platform)) {
101
+ if (name === "culpa-compose.yml") continue;
102
+ copyFileSync(resolveAssetPath(name), path.join(appDir, name));
103
+ }
104
+ // ISS-V103-9: the Cloud Relay page's Fly tab prints
105
+ // `flyctl deploy --config culpa-relay-fly.toml` and tells the customer to
106
+ // run it from THIS folder, so the file has to be here. Staged OUTSIDE
107
+ // PARITY_ASSETS deliberately: that list is the Windows-installer parity
108
+ // set and doctor grades every member as load-bearing, but a relay config
109
+ // is optional — most installs never deploy one, and a missing relay
110
+ // config must never make `getculpa doctor` unhappy.
111
+ copyFileSync(resolveAssetPath(RELAY_FLY_CONFIG), path.join(appDir, RELAY_FLY_CONFIG));
112
+ }
113
+
114
+ // The live compose is staged ONLY when absent. An existing live compose
115
+ // belongs to launch-time's QW-1 sync (launch-culpa.ps1) provisioning must
116
+ // never re-pin a running install out from under it.
117
+ export function stageLiveComposeIfAbsent(appDir) {
118
+ const live = path.join(appDir, "docker-compose.yml");
119
+ if (existsSync(live)) return false;
120
+ copyFileSync(path.join(appDir, "culpa-compose.yml"), live);
121
+ return true;
122
+ }
123
+
124
+ // T-CF29-8 the name culpa-collector.ps1:35 reads from the app dir, and the
125
+ // same name culpa-setup.iss:52 installs there. NOT the Rust target triple
126
+ // lib/fetch.mjs uses for the release asset (x86_64-pc-windows-msvc): this is
127
+ // the Node-style platform-arch pair.
128
+ export function collectorBinaryName(platform = process.platform, arch = process.arch) {
129
+ const exe = platform === "win32" ? ".exe" : "";
130
+ return `culpa-collectord-${platform}-${arch}${exe}`;
131
+ }
132
+
133
+ // T-CF29-8 capture was silently OFF on every Windows npm install:
134
+ // "culpa-collectord-win32-x64.exe not present in this package - skipping
135
+ // capture setup". lib/fetch.mjs stages the binary into the PACKAGE's vendor/
136
+ // as culpa-collectord.exe, the .ps1 reads it from the APP DIR under the
137
+ // triple-suffixed name, and nothing bridged the two. The Windows installer
138
+ // path has always put it in the app dir, so this is the npm path catching up
139
+ // to installer parity, not a new convention.
140
+ //
141
+ // Returns whether the binary is really there afterwards never a hopeful
142
+ // echo of the caller's collectorFetched flag, because "capture is on" is
143
+ // exactly the kind of claim that must be backed by a file on disk.
144
+ export function stageCollectorBinary({ appDir, vendorDir, platform, arch }) {
145
+ const exe = platform === "win32" ? ".exe" : "";
146
+ const src = path.join(vendorDir, `culpa-collectord${exe}`);
147
+ if (!existsSync(src)) return false;
148
+ const dest = path.join(appDir, collectorBinaryName(platform, arch));
149
+ try {
150
+ copyFileSync(src, dest);
151
+ } catch (e) {
152
+ console.warn(`getculpa: capture collector could not be staged (${e.message}) - capture OFF, everything else works.`);
153
+ return false;
154
+ }
155
+ return existsSync(dest);
156
+ }
157
+
158
+ function detectDockerState(dockerSpawnSync) {
159
+ if (!checkDockerPresent(dockerSpawnSync)) return "missing";
160
+ if (!checkDockerEngineReachable(dockerSpawnSync)) return "installed-not-running";
161
+ return "ready";
162
+ }
163
+
164
+ // Staging only `pull`, never `up`. This is the defer invariant's other
165
+ // half: even with a reachable engine, provisioning must not start anything.
166
+ function stageImages(appDir, dockerSpawnSync) {
167
+ const shipped = path.join(appDir, "culpa-compose.yml");
168
+ const result = dockerSpawnSync("docker", ["compose", "-f", shipped, "-p", "culpa", "pull"], {
169
+ encoding: "utf8",
170
+ });
171
+ return result.status === 0;
172
+ }
173
+
174
+ // Real win32 delegate: -DeferStart -NonInteractive is what makes this safe
175
+ // to run unattended from postinstall (no dialogs, no engine start, no
176
+ // compose up, no browser) while still producing the same shortcuts + HKCU
177
+ // registration the Windows installer produces.
178
+ function defaultDelegateWindowsInstaller(appDir) {
179
+ const script = path.join(appDir, "install-culpa.ps1");
180
+ const result = realSpawnSync(
181
+ "powershell.exe",
182
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-DeferStart", "-NonInteractive"],
183
+ { stdio: "inherit" },
184
+ );
185
+ return result.status === 0;
186
+ }
187
+
188
+ function writeState(appDir, state) {
189
+ writeFileSync(path.join(appDir, "install-state.json"), `${JSON.stringify(state, null, 2)}\n`);
190
+ }
191
+
192
+ function readExistingState(appDir) {
193
+ try {
194
+ return JSON.parse(readFileSync(path.join(appDir, "install-state.json"), "utf8"));
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ // CF20-T6 the honest, human-facing line for each classifyUpgrade outcome.
201
+ // "upgrade" is explicit that the new version is STAGED, not running yet —
202
+ // it applies at the next `getculpa` (start.mjs's QW-1-equivalent sync + T4's
203
+ // launch orchestration), never here. Postinstall stays silent about
204
+ // migrations by design: those are QW-4's job at that next `getculpa`, not
205
+ // this one.
206
+ function describeClassification(classification, previousVersion, currentVersion) {
207
+ switch (classification) {
208
+ case "fresh":
209
+ return `getculpa: installing Culpa ${currentVersion}.`;
210
+ case "upgrade":
211
+ return `getculpa: upgrading Culpa ${previousVersion} -> ${currentVersion} (staged; applies at next \`getculpa\`).`;
212
+ case "same-version":
213
+ return `getculpa: repairing the existing Culpa ${currentVersion} installation (same version reinstalled).`;
214
+ case "downgrade-package":
215
+ return (
216
+ `getculpa: this package (${currentVersion}) is older than the installed Culpa ${previousVersion} - ` +
217
+ "refusing to downgrade (set CULPA_ALLOW_DOWNGRADE=1 to override)."
218
+ );
219
+ case "repair":
220
+ default:
221
+ return "getculpa: repairing the existing Culpa installation.";
222
+ }
223
+ }
224
+
225
+ export async function provision(opts) {
226
+ const {
227
+ appDir,
228
+ currentVersion,
229
+ collectorFetched = false,
230
+ platform = process.platform,
231
+ arch = process.arch,
232
+ vendorDir = path.join(packageRoot, "vendor"),
233
+ dockerSpawnSync = realSpawnSync,
234
+ delegateWindowsInstaller = defaultDelegateWindowsInstaller,
235
+ env = process.env,
236
+ } = opts;
237
+
238
+ // Classify + capture the pre-existing state BEFORE anything below mutates
239
+ // appDir both read the install-state.json / live compose that staging
240
+ // is about to overwrite.
241
+ const { classification, previousVersion } = classifyUpgrade({ appDir, currentVersion });
242
+ console.log(describeClassification(classification, previousVersion, currentVersion));
243
+ const existingState = readExistingState(appDir);
244
+
245
+ // CF20-R: a REFUSED downgrade (classification === "downgrade-package",
246
+ // CULPA_ALLOW_DOWNGRADE not set) used to still restage every OTHER parity
247
+ // file (the ps1 scripts, register.mjs) with the older package's copies —
248
+ // only culpa-compose.yml was protected (stageShippedCompose's own guard).
249
+ // That left a mixed-version app dir: a newer compose pin beside older
250
+ // scripts. Skip the WHOLE parity re-stage in this case instead, so the app
251
+ // dir stays wholly at the newer version; dir/state handling below is
252
+ // unaffected.
253
+ const allowDowngrade = env.CULPA_ALLOW_DOWNGRADE === "1";
254
+ const refusedDowngrade = classification === "downgrade-package" && !allowDowngrade;
255
+ if (refusedDowngrade) {
256
+ console.warn(
257
+ `getculpa: this package is older than the installed Culpa ${previousVersion} - skipping the parity file re-stage entirely (compose, scripts, register.mjs) to avoid a mixed-version app dir (set CULPA_ALLOW_DOWNGRADE=1 to override).`,
258
+ );
259
+ } else {
260
+ stageParityFiles(appDir, env, platform);
261
+ }
262
+ stageLiveComposeIfAbsent(appDir);
263
+
264
+ // T-CF29-8: only attempt it when the fetch reported success; the result is
265
+ // what gets recorded, not the intent.
266
+ const collectorStaged = collectorFetched && stageCollectorBinary({ appDir, vendorDir, platform, arch });
267
+ if (collectorFetched && !collectorStaged) {
268
+ console.warn(
269
+ "getculpa: the capture collector was downloaded but could not be placed in the app directory - capture OFF, everything else works.",
270
+ );
271
+ }
272
+
273
+ const dockerState = detectDockerState(dockerSpawnSync);
274
+ let imagesStaged = false;
275
+ if (dockerState === "ready") {
276
+ imagesStaged = stageImages(appDir, dockerSpawnSync);
277
+ if (!imagesStaged) {
278
+ console.warn("getculpa: image staging failed - images will be pulled at first `getculpa`.");
279
+ }
280
+ } else {
281
+ const why = dockerState === "missing" ? "Docker was not found" : "Docker is not running";
282
+ console.warn(`getculpa: ${why} - image staging deferred to first \`getculpa\`.`);
283
+ }
284
+
285
+ if (platform === "win32") {
286
+ // CF20-R: the Windows delegate registers the ONE fixed, per-machine HKCU
287
+ // uninstall key (install-culpa.ps1 / culpa-setup.iss's AppId). A second
288
+ // app dir on the same machine (CULPA_APP_DIR pointed somewhere
289
+ // non-default) must never overwrite that shared key — the gate's
290
+ // historical incident class. Only delegate when this appDir IS the
291
+ // canonical one (CULPA_APP_DIR's override stripped); otherwise skip
292
+ // registration and say so, once, honestly.
293
+ if (appDir === canonicalAppDir({ platform, env })) {
294
+ const ok = delegateWindowsInstaller(appDir);
295
+ if (!ok) {
296
+ console.warn("getculpa: Windows shortcut/registry setup did not complete cleanly - shortcuts may be missing.");
297
+ }
298
+ } else {
299
+ console.log("getculpa: non-canonical app dir: shortcuts/registry registration skipped.");
300
+ }
301
+ }
302
+
303
+ // T6 review (Important): a REFUSED downgrade must not record the rejected,
304
+ // older currentVersion as installed — the protected artifacts still carry
305
+ // the newer version, and this field is the ground truth the next run's
306
+ // classification reads. Persist the true installed version instead.
307
+ const recordedPackageVersion =
308
+ classification === "downgrade-package" && previousVersion ? previousVersion : currentVersion;
309
+ writeState(appDir, {
310
+ packageVersion: recordedPackageVersion,
311
+ previousVersion,
312
+ classification,
313
+ // provisionedAt is the FIRST install's timestamp, preserved across every
314
+ // subsequent provision() run; updatedAt is this run's.
315
+ provisionedAt: existingState?.provisionedAt ?? new Date().toISOString(),
316
+ updatedAt: new Date().toISOString(),
317
+ dockerState,
318
+ imagesStaged,
319
+ collectorStaged,
320
+ platform,
321
+ });
322
+
323
+ // T-CF28-5/7: the Docker and staging facts were already computed above and
324
+ // written to install-state.json, but the caller that has to REPORT them
325
+ // (scripts/install.js) could not see them — which is how a Docker-less
326
+ // install still printed "Culpa installed successfully." Returning them
327
+ // changes no behavior here; it just stops postinstall from having to guess.
328
+ return { classification, previousVersion, dockerState, imagesStaged, collectorStaged };
329
+ }