getculpa 1.0.1 → 1.0.3

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/repair.mjs CHANGED
@@ -1,135 +1,174 @@
1
- // CF20-T5 — `getculpa repair`. Re-stages missing/corrupt parity files from
2
- // the packaged assets, regenerates install-state.json if unreadable,
3
- // re-fetches a missing launcher/collectord, and does none of this
4
- // destructively: an EXISTING docker-compose.yml (the live pin) is never
5
- // touched (the edcf97f invariant this task's reviewer condition names), no
6
- // `docker volume` command is ever issued (data is never touched), and a
7
- // second run changes nothing that was already healthy (idempotent).
8
- //
9
- // Reuses lib/provision.mjs's own stageParityFiles/stageLiveComposeIfAbsent
10
- // (exported additively for this purpose — see that file's comment) instead
11
- // of re-implementing file staging here.
12
-
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
- import path from "node:path";
15
- import { fileURLToPath } from "node:url";
16
- import { spawnSync as realSpawnSync } from "node:child_process";
17
- import { stageLiveComposeIfAbsent, stageParityFiles, PARITY_ASSETS } from "./provision.mjs";
18
- import { fetchCollectord, fetchLauncher } from "./fetch.mjs";
19
- import { checkDockerEngineReachable, checkDockerPresent } from "./preflight.mjs";
20
-
21
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
- const packageRoot = path.join(__dirname, "..");
23
-
24
- function readJsonSafe(filePath) {
25
- try {
26
- return JSON.parse(readFileSync(filePath, "utf8"));
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
- function detectDockerState(dockerSpawnSync) {
33
- if (!checkDockerPresent(dockerSpawnSync)) return "missing";
34
- if (!checkDockerEngineReachable(dockerSpawnSync)) return "installed-not-running";
35
- return "ready";
36
- }
37
-
38
- function vendorBinaryPath(vendorDir, name, platform) {
39
- const exe = platform === "win32" ? ".exe" : "";
40
- return path.join(vendorDir, `${name}${exe}`);
41
- }
42
-
43
- export async function repair(opts) {
44
- const {
45
- appDir,
46
- currentVersion,
47
- platform = process.platform,
48
- vendorDir = path.join(packageRoot, "vendor"),
49
- dockerSpawnSync = realSpawnSync,
50
- fetchLauncherFn = fetchLauncher,
51
- fetchCollectordFn = fetchCollectord,
52
- log = console.log,
53
- } = opts;
54
-
55
- const actions = [];
56
-
57
- if (!existsSync(appDir)) {
58
- mkdirSync(appDir, { recursive: true });
59
- actions.push(`created the missing app directory (${appDir})`);
60
- }
61
-
62
- // 1. Parity files — re-stage anything missing. stageParityFiles never
63
- // touches docker-compose.yml (it doesn't reference that name at all),
64
- // so this step alone already satisfies "never touch a live compose".
65
- const missingBefore = PARITY_ASSETS.filter((name) => !existsSync(path.join(appDir, name)));
66
- stageParityFiles(appDir);
67
- for (const name of missingBefore) {
68
- actions.push(`restored missing parity file: ${name}`);
69
- }
70
-
71
- // 2. Live compose — created ONLY if entirely absent; an existing one is
72
- // never overwritten (stageLiveComposeIfAbsent's own contract).
73
- const composeCreated = stageLiveComposeIfAbsent(appDir);
74
- if (composeCreated) {
75
- actions.push("live docker-compose.yml was missing - recreated from the shipped culpa-compose.yml");
76
- }
77
-
78
- // 3. install-state.json — regenerate only when missing/corrupt. The
79
- // regenerated state is a conservative snapshot of what is actually on
80
- // disk right now: dockerState/collectorStaged are re-detected,
81
- // imagesStaged is set false (safe default — worst case is one extra
82
- // `docker compose pull` message at the next `getculpa`, never data
83
- // loss, mirroring deferredPullCatchUp's own contract in lib/start.mjs).
84
- const existingState = readJsonSafe(path.join(appDir, "install-state.json"));
85
- if (!existingState) {
86
- const dockerState = detectDockerState(dockerSpawnSync);
87
- const collectorStaged = existsSync(vendorBinaryPath(vendorDir, "culpa-collectord", platform));
88
- const regenerated = {
89
- packageVersion: currentVersion,
90
- provisionedAt: new Date().toISOString(),
91
- dockerState,
92
- imagesStaged: false,
93
- collectorStaged,
94
- platform,
95
- };
96
- writeFileSync(path.join(appDir, "install-state.json"), `${JSON.stringify(regenerated, null, 2)}\n`);
97
- actions.push("install-state.json was missing or unreadable - regenerated");
98
- }
99
-
100
- // 4. Launcher / collectord — re-fetch only when the vendored binary is
101
- // genuinely absent. Never re-fetches when present (idempotent, and the
102
- // task's explicit invocation-recording fact). The launcher is REQUIRED
103
- // (doctor.mjs's checkVendoredLauncher fails the same way when it's
104
- // missing) — a failed re-fetch must make repair's own result say so,
105
- // not just log and report ok:true (CF20-R review). The collector stays
106
- // optional/non-fatal: capture-off is a valid degraded state.
107
- const launcherPath = vendorBinaryPath(vendorDir, "culpa-launcher", platform);
108
- if (!existsSync(launcherPath)) {
109
- try {
110
- await fetchLauncherFn({ vendorDir });
111
- actions.push("the launcher was missing - re-fetched and verified");
112
- } catch (e) {
113
- log(`getculpa repair: could not re-fetch the launcher (${e.message}). Run \`getculpa repair\` again once online.`);
114
- }
115
- }
116
-
117
- const collectordPath = vendorBinaryPath(vendorDir, "culpa-collectord", platform);
118
- if (!existsSync(collectordPath)) {
119
- try {
120
- await fetchCollectordFn({ vendorDir });
121
- actions.push("the capture collector was missing - re-fetched and verified");
122
- } catch (e) {
123
- // fetchCollectord's own contract: "not published for this release yet"
124
- // is an expected, non-fatal outcome (see lib/fetch.mjs) — repair must
125
- // degrade the same way, never abort over an optional component.
126
- log(`getculpa repair: capture collector not re-fetched (${e.message}).`);
127
- }
128
- }
129
-
130
- if (actions.length === 0) log("getculpa repair: nothing to do - this install is already healthy.");
131
- else for (const a of actions) log(`getculpa repair: ${a}`);
132
-
133
- const launcherOk = existsSync(launcherPath);
134
- return { ok: launcherOk, actions };
135
- }
1
+ // CF20-T5 — `getculpa repair`. Re-stages missing/corrupt parity files from
2
+ // the packaged assets, regenerates install-state.json if unreadable,
3
+ // re-fetches a missing launcher/collectord, and does none of this
4
+ // destructively: an EXISTING docker-compose.yml (the live pin) is never
5
+ // touched (the edcf97f invariant this task's reviewer condition names), no
6
+ // `docker volume` command is ever issued (data is never touched), and a
7
+ // second run changes nothing that was already healthy (idempotent).
8
+ //
9
+ // Reuses lib/provision.mjs's own stageParityFiles/stageLiveComposeIfAbsent
10
+ // (exported additively for this purpose — see that file's comment) instead
11
+ // of re-implementing file staging here.
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { spawnSync as realSpawnSync } from "node:child_process";
17
+ import {
18
+ collectorBinaryName,
19
+ parityAssetsFor,
20
+ stageCollectorBinary,
21
+ stageLiveComposeIfAbsent,
22
+ stageParityFiles,
23
+ } from "./provision.mjs";
24
+ import { fetchCollectord, fetchLauncher } from "./fetch.mjs";
25
+ import { checkDockerEngineReachable, checkDockerPresent } from "./preflight.mjs";
26
+
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
+ const packageRoot = path.join(__dirname, "..");
29
+
30
+ function readJsonSafe(filePath) {
31
+ try {
32
+ return JSON.parse(readFileSync(filePath, "utf8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function detectDockerState(dockerSpawnSync) {
39
+ if (!checkDockerPresent(dockerSpawnSync)) return "missing";
40
+ if (!checkDockerEngineReachable(dockerSpawnSync)) return "installed-not-running";
41
+ return "ready";
42
+ }
43
+
44
+ function vendorBinaryPath(vendorDir, name, platform) {
45
+ const exe = platform === "win32" ? ".exe" : "";
46
+ return path.join(vendorDir, `${name}${exe}`);
47
+ }
48
+
49
+ export async function repair(opts) {
50
+ const {
51
+ appDir,
52
+ currentVersion,
53
+ platform = process.platform,
54
+ arch = process.arch,
55
+ vendorDir = path.join(packageRoot, "vendor"),
56
+ dockerSpawnSync = realSpawnSync,
57
+ fetchLauncherFn = fetchLauncher,
58
+ fetchCollectordFn = fetchCollectord,
59
+ log = console.log,
60
+ } = opts;
61
+
62
+ const actions = [];
63
+
64
+ if (!existsSync(appDir)) {
65
+ mkdirSync(appDir, { recursive: true });
66
+ actions.push(`created the missing app directory (${appDir})`);
67
+ }
68
+
69
+ // 1. Parity files — re-stage anything missing. stageParityFiles never
70
+ // touches docker-compose.yml (it doesn't reference that name at all),
71
+ // so this step alone already satisfies "never touch a live compose".
72
+ // T-CF28-6: platform-scoped, or repair would restore the four Windows .ps1
73
+ // files provisioning deliberately skips on darwin/linux — and report each
74
+ // one as a "restored missing parity file" action while doing it.
75
+ const missingBefore = parityAssetsFor(platform).filter((name) => !existsSync(path.join(appDir, name)));
76
+ stageParityFiles(appDir, process.env, platform);
77
+ for (const name of missingBefore) {
78
+ actions.push(`restored missing parity file: ${name}`);
79
+ }
80
+
81
+ // 2. Live compose — created ONLY if entirely absent; an existing one is
82
+ // never overwritten (stageLiveComposeIfAbsent's own contract).
83
+ const composeCreated = stageLiveComposeIfAbsent(appDir);
84
+ if (composeCreated) {
85
+ actions.push("live docker-compose.yml was missing - recreated from the shipped culpa-compose.yml");
86
+ }
87
+
88
+ // 3. install-state.json — regenerate only when missing/corrupt. The
89
+ // regenerated state is a conservative snapshot of what is actually on
90
+ // disk right now: dockerState/collectorStaged are re-detected,
91
+ // imagesStaged is set false (safe default — worst case is one extra
92
+ // `docker compose pull` message at the next `getculpa`, never data
93
+ // loss, mirroring deferredPullCatchUp's own contract in lib/start.mjs).
94
+ const existingState = readJsonSafe(path.join(appDir, "install-state.json"));
95
+ if (!existingState) {
96
+ const dockerState = detectDockerState(dockerSpawnSync);
97
+ // Review of 0e4b2e2: this derived collectorStaged from the PACKAGE
98
+ // vendor dir, which is not what culpa-collector.ps1:35 reads. It could
99
+ // therefore regenerate install-state.json saying capture was staged
100
+ // while the app dir held no collector at all — the same false positive
101
+ // T-CF29-8 removed from provision(). One invariant, both writers.
102
+ const collectorStaged = existsSync(path.join(appDir, collectorBinaryName(platform, arch)));
103
+ const regenerated = {
104
+ packageVersion: currentVersion,
105
+ provisionedAt: new Date().toISOString(),
106
+ dockerState,
107
+ imagesStaged: false,
108
+ collectorStaged,
109
+ platform,
110
+ };
111
+ writeFileSync(path.join(appDir, "install-state.json"), `${JSON.stringify(regenerated, null, 2)}\n`);
112
+ actions.push("install-state.json was missing or unreadable - regenerated");
113
+ }
114
+
115
+ // 4. Launcher / collectord — re-fetch only when the vendored binary is
116
+ // genuinely absent. Never re-fetches when present (idempotent, and the
117
+ // task's explicit invocation-recording fact). The launcher is REQUIRED
118
+ // (doctor.mjs's checkVendoredLauncher fails the same way when it's
119
+ // missing) — a failed re-fetch must make repair's own result say so,
120
+ // not just log and report ok:true (CF20-R review). The collector stays
121
+ // optional/non-fatal: capture-off is a valid degraded state.
122
+ const launcherPath = vendorBinaryPath(vendorDir, "culpa-launcher", platform);
123
+ if (!existsSync(launcherPath)) {
124
+ try {
125
+ await fetchLauncherFn({ vendorDir });
126
+ actions.push("the launcher was missing - re-fetched and verified");
127
+ } catch (e) {
128
+ log(`getculpa repair: could not re-fetch the launcher (${e.message}). Run \`getculpa repair\` again once online.`);
129
+ }
130
+ }
131
+
132
+ const collectordPath = vendorBinaryPath(vendorDir, "culpa-collectord", platform);
133
+ if (!existsSync(collectordPath)) {
134
+ try {
135
+ await fetchCollectordFn({ vendorDir });
136
+ actions.push("the capture collector was missing - re-fetched and verified");
137
+ } catch (e) {
138
+ // fetchCollectord's own contract: "not published for this release yet"
139
+ // is an expected, non-fatal outcome (see lib/fetch.mjs) — repair must
140
+ // degrade the same way, never abort over an optional component.
141
+ log(`getculpa repair: capture collector not re-fetched (${e.message}).`);
142
+ }
143
+ }
144
+
145
+ // Review of 0e4b2e2: re-fetching only refilled the PACKAGE vendor dir, so
146
+ // "re-fetched and verified" claimed a repair that left capture exactly as
147
+ // broken as it found it. The app-dir copy is the one that matters.
148
+ const appCollector = path.join(appDir, collectorBinaryName(platform, arch));
149
+ if (!existsSync(appCollector) && stageCollectorBinary({ appDir, vendorDir, platform, arch })) {
150
+ actions.push("the capture collector was missing from the app directory - staged it");
151
+ }
152
+
153
+ // The state file may have been regenerated BEFORE the staging above, so
154
+ // correct it to what is now true rather than leaving a stale answer.
155
+ const statePath = path.join(appDir, "install-state.json");
156
+ const finalState = readJsonSafe(statePath);
157
+ const collectorTruth = existsSync(appCollector);
158
+ // Review of 4e45f81: this used to correct the file and say NOTHING, so a
159
+ // run that really did change something reported "nothing to do - this
160
+ // install is already healthy". Same class of false claim the rest of this
161
+ // commit removes, just relocated into the action log.
162
+ if (finalState && finalState.collectorStaged !== collectorTruth) {
163
+ actions.push(
164
+ `install-state.json claimed capture was ${finalState.collectorStaged ? "staged" : "absent"} - corrected to match the app directory`,
165
+ );
166
+ writeFileSync(statePath, `${JSON.stringify({ ...finalState, collectorStaged: collectorTruth }, null, 2)}\n`);
167
+ }
168
+
169
+ if (actions.length === 0) log("getculpa repair: nothing to do - this install is already healthy.");
170
+ else for (const a of actions) log(`getculpa repair: ${a}`);
171
+
172
+ const launcherOk = existsSync(launcherPath);
173
+ return { ok: launcherOk, actions };
174
+ }
package/lib/start.d.mts CHANGED
@@ -28,6 +28,8 @@ export interface StartOptions {
28
28
  openBrowser?: (url: string) => void;
29
29
  isTTY?: boolean;
30
30
  delegateWindowsLaunch?: (appDir: string) => { ok: boolean; output?: string };
31
+ isInteractive?: boolean;
32
+ askYesNo?: (question: string) => Promise<boolean>;
31
33
  }
32
34
 
33
35
  export interface StartResult {
package/lib/start.mjs CHANGED
@@ -34,12 +34,14 @@ import {
34
34
  preMigrationBackup,
35
35
  probeHealth,
36
36
  resolveBackupBaseline,
37
+ installDockerDarwin,
37
38
  startEngineIfPossible,
38
39
  waitForDockerEngine,
39
40
  waitForHealth,
40
41
  waitForPgReady,
41
42
  } from "./docker.mjs";
42
43
  import { checkDockerPresent, compareVersions, parsePinnedServerVersion } from "./preflight.mjs";
44
+ import { askYesNo as realAskYesNo } from "./tty.mjs";
43
45
 
44
46
  export const STAGES = Object.freeze({
45
47
  CHECK_INSTALLATION: "CHECK_INSTALLATION",
@@ -186,6 +188,85 @@ async function runVersionGuards({ appDir, liveCompose, spawnSync, env, log, warn
186
188
  return { pinnedVersion };
187
189
  }
188
190
 
191
+ // T-CF28-7 — "The install fails to ask for docker and other dependencies. IT
192
+ // MUST ASK AND INSTALL THESE" (founder). This is the ASK half; the DETECT half
193
+ // runs at npm-install time (lib/install-summary.mjs), because postinstall
194
+ // cannot prompt (scripts/install.js:13-15).
195
+ //
196
+ // Reached only when Docker is genuinely absent on a non-win32 platform. Every
197
+ // branch ends in a fail() — even a SUCCESSFUL install, because Docker Desktop
198
+ // still needs one manual launch before its engine answers. That mirrors
199
+ // launch-culpa.ps1:233, which likewise stops after installing rather than
200
+ // pretending the stack is coming up.
201
+ const DOCKER_DESKTOP_URL = "https://www.docker.com/products/docker-desktop/";
202
+ const DOCKER_ENGINE_DOCS_URL = "https://docs.docker.com/engine/install/";
203
+ const DOCKER_MISSING = "Culpa couldn't start: Docker is not installed.";
204
+
205
+ // The consent text IS the disclosure. It names the exact command, that a
206
+ // password will be asked for, and that a manual first launch is still
207
+ // required — so nobody agrees to a "one-click install" that isn't one.
208
+ const DARWIN_CONSENT_QUESTION =
209
+ "Culpa needs Docker to run, and it was not found on this machine.\n" +
210
+ "Install it now with `brew install --cask docker`?\n" +
211
+ " Homebrew will ask for your password, and afterwards you must open Docker once\n" +
212
+ " from Applications - this cannot be a fully hands-off install.\n" +
213
+ " Nothing is downloaded unless you answer yes.\n" +
214
+ "Install Docker now? (y/N) ";
215
+
216
+ async function offerDockerInstall({ platform, spawnSync, log, askYesNo, isInteractive }) {
217
+ // linux: installing a system package without explicit consent is out of
218
+ // bounds — the same rule startEngineIfPossible already follows for merely
219
+ // STARTING the engine (docker.mjs:73-79). Guidance only, and no prompt: a
220
+ // yes here could not be honoured anyway.
221
+ if (platform !== "darwin") {
222
+ return fail(
223
+ STAGES.CHECK_DOCKER,
224
+ DOCKER_MISSING,
225
+ `Culpa runs its database and services in containers. Install Docker Engine (${DOCKER_ENGINE_DOCS_URL}), then run \`getculpa\` again. Culpa will not install system packages for you.`,
226
+ );
227
+ }
228
+
229
+ if (!isInteractive) {
230
+ return fail(
231
+ STAGES.CHECK_DOCKER,
232
+ DOCKER_MISSING,
233
+ `Culpa runs its database and services in containers. Install it with \`brew install --cask docker\` (or download it from ${DOCKER_DESKTOP_URL}), open Docker once, then run \`getculpa\` again.`,
234
+ );
235
+ }
236
+
237
+ if (!(await askYesNo(DARWIN_CONSENT_QUESTION))) {
238
+ return fail(
239
+ STAGES.CHECK_DOCKER,
240
+ DOCKER_MISSING,
241
+ `The Docker install was not authorized, so nothing was installed and nothing was changed. Install it yourself with \`brew install --cask docker\` or from ${DOCKER_DESKTOP_URL}, then run \`getculpa\` again.`,
242
+ );
243
+ }
244
+
245
+ log("==> Installing Docker Desktop with Homebrew (brew install --cask docker)");
246
+ const outcome = installDockerDarwin(spawnSync);
247
+
248
+ if (outcome === "no-brew") {
249
+ return fail(
250
+ STAGES.CHECK_DOCKER,
251
+ DOCKER_MISSING,
252
+ `Homebrew is not available, so Docker could not be installed automatically. Nothing was changed. Download Docker Desktop from ${DOCKER_DESKTOP_URL}, open it once, then run \`getculpa\` again.`,
253
+ );
254
+ }
255
+ if (outcome !== "installed") {
256
+ const code = outcome.replace(/^failed:/, "");
257
+ return fail(
258
+ STAGES.CHECK_DOCKER,
259
+ DOCKER_MISSING,
260
+ `Homebrew could not install Docker (exit code ${code}). Nothing else was changed. Install Docker Desktop from ${DOCKER_DESKTOP_URL}, open it once, then run \`getculpa\` again.`,
261
+ );
262
+ }
263
+ return fail(
264
+ STAGES.CHECK_DOCKER,
265
+ "Docker was installed. Culpa has not started yet.",
266
+ "Open Docker once from Applications and wait for it to finish starting, then run `getculpa` again.",
267
+ );
268
+ }
269
+
189
270
  function maybeOpenBrowser({ env, isTTY, openBrowser }) {
190
271
  if (!isTTY) return;
191
272
  if (env.CULPA_NO_BROWSER) return;
@@ -334,10 +415,15 @@ async function runPosixStateMachine({ appDir, liveCompose, platform, spawnSync,
334
415
  };
335
416
  }
336
417
 
337
- // No collectord build/staging exists for macOS/Linux yet (task scope) —
338
- // degrade honestly rather than fail the whole launch over capture.
418
+ // V102 T-V102-14 (M4, D-113): the old line here said "no collector build
419
+ // ships for macOS/Linux yet" — verified FALSE on real hardware (the arm64
420
+ // collectord ships, was staged executable, install-state recorded
421
+ // collectorStaged: true). What IS true: the collector is not auto-started
422
+ // on this platform yet, while the server-side coding-tool log sweep works
423
+ // (proven live on the Mac: calls landed unattended, ~45 min cadence). Say
424
+ // exactly that — neither direction of the old overclaim.
339
425
  stagesRun.push(STAGES.START_CULPA);
340
- log("getculpa: capture is OFF on this platform for now (no collector build ships for macOS/Linux yet) - everything else works normally.");
426
+ log("getculpa: your local coding-tool usage is captured by the built-in log sweep. The production traffic collector is not started automatically on this platform yet - everything else works normally.");
341
427
 
342
428
  stagesRun.push(STAGES.OPEN_UI);
343
429
  if (pinnedVersion) writeFileSync(path.join(appDir, "last-boot-version.txt"), `${pinnedVersion}\n`);
@@ -359,6 +445,11 @@ export async function start(opts) {
359
445
  openBrowser = defaultOpenBrowser,
360
446
  isTTY = process.stdout.isTTY,
361
447
  delegateWindowsLaunch = defaultDelegateWindowsLaunch,
448
+ // T-CF28-7: consent needs BOTH ends of the terminal — stdin to read the
449
+ // answer, stdout for the question. isTTY above is stdout-only (it gates
450
+ // the browser open), so it cannot stand in for this.
451
+ isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
452
+ askYesNo = (question) => realAskYesNo(question, { isInteractive }),
362
453
  } = opts;
363
454
 
364
455
  const stagesRun = [STAGES.CHECK_INSTALLATION];
@@ -369,10 +460,11 @@ export async function start(opts) {
369
460
 
370
461
  stagesRun.push(STAGES.CHECK_DOCKER);
371
462
  if (platform !== "win32" && !checkDockerPresent(spawnSync)) {
372
- return {
373
- ...fail(STAGES.CHECK_DOCKER, "Culpa couldn't start Docker.", "Install Docker (https://docs.docker.com/get-docker/), then run `getculpa` again."),
374
- stagesRun,
375
- };
463
+ // T-CF28-7: was a dead end with a bare URL. Still a hard gate — nothing
464
+ // below can work without Docker — but on darwin it now offers to install
465
+ // it first. win32 is deliberately excluded (see this file's header):
466
+ // launch-culpa.ps1:179-234 owns that flow and must not be short-circuited.
467
+ return { ...(await offerDockerInstall({ platform, spawnSync, log, askYesNo, isInteractive })), stagesRun };
376
468
  }
377
469
 
378
470
  stagesRun.push(STAGES.IDEMPOTENCY_CHECK);
package/lib/tty.d.mts ADDED
@@ -0,0 +1,18 @@
1
+ export interface ColourOptions {
2
+ isTTY?: boolean;
3
+ env?: Record<string, string | undefined>;
4
+ }
5
+
6
+ export function colourEnabled(opts?: ColourOptions): boolean;
7
+ export function green(text: string, opts?: ColourOptions): string;
8
+
9
+ export interface AskYesNoOptions {
10
+ isInteractive?: boolean;
11
+ createInterface?: (opts: { input: unknown; output: unknown }) => {
12
+ question: (q: string, cb: (answer: string) => void) => void;
13
+ once: (event: string, cb: () => void) => void;
14
+ close: () => void;
15
+ };
16
+ }
17
+
18
+ export function askYesNo(question: string, opts?: AskYesNoOptions): Promise<boolean>;
package/lib/tty.mjs ADDED
@@ -0,0 +1,58 @@
1
+ // T-CF28-5 / T-CF28-7 — terminal helpers: colour, and the yes/no consent
2
+ // prompt. Both are gated on there actually being a human attached.
3
+ //
4
+ // The founder's ruling for the postinstall message is "Culpa is installed and
5
+ // ready." with "ready" in GREEN. Colour is emitted ONLY when the destination
6
+ // is a real terminal and NO_COLOR is unset: `npm i -g` output is routinely
7
+ // piped, redirected to a log, or captured by CI, and a raw ANSI escape there
8
+ // is visible garbage in the one place a human later reads to find out what
9
+ // went wrong. Plain text is the fallback, never a degraded message.
10
+ //
11
+ // isTTY and env are parameters rather than reads of the live process, for the
12
+ // same reason lib/start.mjs:360 and lib/uninstall.mjs:77 inject theirs: a test
13
+ // asserts both branches deliberately instead of depending on how the runner
14
+ // happens to be attached.
15
+ //
16
+ // NO_COLOR follows the https://no-color.org convention: any value, including
17
+ // an empty string, disables colour. Only an ABSENT variable allows it.
18
+
19
+ // String.fromCharCode(27) rather than a literal escape: this file is edited by
20
+ // tools that have silently converted the six-character sequence into a raw
21
+ // control byte, which is invisible in review and breaks on the next edit.
22
+ const ESC = String.fromCharCode(27);
23
+ const GREEN = `${ESC}[32m`;
24
+ const DEFAULT_FG = `${ESC}[39m`;
25
+
26
+ export function colourEnabled({ isTTY, env = {} } = {}) {
27
+ if (!isTTY) return false;
28
+ return env.NO_COLOR === undefined;
29
+ }
30
+
31
+ export function green(text, opts = {}) {
32
+ if (!colourEnabled(opts)) return text;
33
+ return `${GREEN}${text}${DEFAULT_FG}`;
34
+ }
35
+
36
+ // T-CF28-7 — explicit consent before Culpa installs anything on the user's
37
+ // machine. Same safety posture as lib/uninstall.mjs:61-69: a non-interactive
38
+ // caller is never prompted and always gets NO, so a scripted or CI run can
39
+ // neither hang on a stdin read nor have an install decided for it by default.
40
+ // The caller supplies the whole question, including the "(y/N)" — the wording
41
+ // of what is about to happen is part of the consent, not decoration.
42
+ export async function askYesNo(question, opts = {}) {
43
+ const { isInteractive = false, createInterface } = opts;
44
+ if (!isInteractive) return false;
45
+ const { createInterface: realCreateInterface } = await import("node:readline");
46
+ const rl = (createInterface ?? realCreateInterface)({ input: process.stdin, output: process.stdout });
47
+ const answer = await new Promise((resolve) => {
48
+ // EOF (Ctrl-D, or a stdin that ends mid-prompt) fires `close` and NEVER
49
+ // invokes question()'s callback. Without this listener the promise stays
50
+ // pending and `getculpa` hangs forever at the consent prompt with no
51
+ // error and no way out. An unanswered question is a NO, exactly as the
52
+ // non-interactive path above already decides.
53
+ rl.once("close", () => resolve(""));
54
+ rl.question(question, resolve);
55
+ });
56
+ rl.close();
57
+ return answer.trim().toLowerCase() === "y";
58
+ }
package/lib/uninstall.mjs CHANGED
@@ -62,12 +62,36 @@ async function promptPgdataRemoval(createInterface, isTTY) {
62
62
  if (!isTTY) return false;
63
63
  const rl = createInterface({ input: process.stdin, output: process.stdout });
64
64
  const answer = await new Promise((resolve) => {
65
+ // V102 T-V102-16 (D-113): EOF (Ctrl-D, or a stdin that ends mid-prompt)
66
+ // fires `close` and NEVER invokes question()'s callback — this promise
67
+ // used to hang forever at the one prompt guarding DELETION of the cost
68
+ // database. An unanswered question is a NO, exactly like tty.mjs.
69
+ // Optional-call: older injected fakes without `once` keep working.
70
+ rl.once?.("close", () => resolve(""));
65
71
  rl.question("Also DELETE all recorded cost data? This cannot be undone. (y/N) ", resolve);
66
72
  });
67
73
  rl.close();
68
74
  return answer.trim().toLowerCase() === "y";
69
75
  }
70
76
 
77
+ // V102 T-V102-16 (M11/W2, D-113): macOS uninstall exited 0 leaving ~7.3 MB
78
+ // (launcher, collector, compose, register.mjs) silently behind — the Windows
79
+ // ps1 removes the app dir and is "the more complete of the two" (W2). Remove
80
+ // the dir ONLY when it is provably a Culpa install dir (install-state.json,
81
+ // which every install writes); a mistargeted CULPA_APP_DIR is kept and said.
82
+ // Truthful beats a surprise rm -rf in both directions.
83
+ function removeAppDir(appDir) {
84
+ if (!existsSync(appDir)) return;
85
+ if (existsSync(path.join(appDir, "install-state.json"))) {
86
+ rmSync(appDir, { recursive: true, force: true });
87
+ console.log(`Removed: ${appDir} (launcher, collector, compose and capture shims).`);
88
+ return;
89
+ }
90
+ console.log(
91
+ `Kept: ${appDir} - no install-state.json found there, so it is not provably a Culpa install directory. Remove it yourself if you are sure.`,
92
+ );
93
+ }
94
+
71
95
  export async function uninstall(opts = {}) {
72
96
  const appDir = opts.appDir ?? getAppDir();
73
97
  const platform = opts.platform ?? process.platform;
@@ -82,13 +106,28 @@ export async function uninstall(opts = {}) {
82
106
  if (!ok) console.error("getculpa: uninstall did not complete cleanly - see the output above.");
83
107
  } else {
84
108
  ok = composeDown(appDir, spawnSync);
85
- removeShortcutEquivalents(homedir);
86
- const wipe = await promptPgdataRemoval(createInterface, isTTY);
87
- if (wipe) {
88
- spawnSync("docker", ["volume", "rm", "culpa_pgdata"], { stdio: "inherit" });
89
- console.log("Data volume removed.");
109
+ // Review-4 IMPORTANT + CodeRabbit PR#19 [7] (Major): a FAILED stop skips
110
+ // EVERY destructive step — the ps1's $stackStopped gate, whose comment
111
+ // records the prior real incident (CR-PR5 round 3). Not even the pgdata
112
+ // PROMPT is offered: after a partial teardown the volume can be
113
+ // unreferenced, so a user confirming deletion here could lose the cost
114
+ // database while being told the stack is still running. Nothing is
115
+ // removed; the one honest instruction is to stop the stack and rerun.
116
+ if (!ok) {
117
+ console.log(
118
+ `Files kept: ${appDir} - the stack is still running (\`docker compose down\` failed above). Nothing was removed. Stop the stack, then run this again.`,
119
+ );
90
120
  } else {
91
- console.log("Data kept (volume culpa_pgdata). Reinstalling later will find it again.");
121
+ removeShortcutEquivalents(homedir);
122
+ const wipe = await promptPgdataRemoval(createInterface, isTTY);
123
+ if (wipe) {
124
+ spawnSync("docker", ["volume", "rm", "culpa_pgdata"], { stdio: "inherit" });
125
+ console.log("Data volume removed.");
126
+ } else {
127
+ console.log("Data kept (volume culpa_pgdata). Reinstalling later will find it again.");
128
+ }
129
+ // M11/W2: after the data decision, remove the app dir itself (ps1 parity)
130
+ removeAppDir(appDir);
92
131
  }
93
132
  }
94
133