getculpa 1.0.7 → 1.0.10

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.
@@ -1,17 +1,20 @@
1
- export interface UninstallOptions {
2
- appDir?: string;
3
- platform?: string;
4
- spawnSync?: (cmd: string, args?: string[], opts?: unknown) => { status: number | null };
5
- homedir?: string;
6
- createInterface?: (opts: { input: NodeJS.ReadStream; output: NodeJS.WriteStream }) => {
7
- question: (prompt: string, cb: (answer: string) => void) => void;
8
- close: () => void;
9
- };
10
- isTTY?: boolean;
11
- }
12
-
13
- export interface UninstallResult {
14
- ok: boolean;
15
- }
16
-
17
- export function uninstall(opts?: UninstallOptions): Promise<UninstallResult>;
1
+ export interface UninstallOptions {
2
+ appDir?: string;
3
+ platform?: string;
4
+ spawnSync?: (cmd: string, args?: string[], opts?: unknown) => { status: number | null };
5
+ homedir?: string;
6
+ createInterface?: (opts: { input: NodeJS.ReadStream; output: NodeJS.WriteStream }) => {
7
+ question: (prompt: string, cb: (answer: string) => void) => void;
8
+ close: () => void;
9
+ };
10
+ isTTY?: boolean;
11
+ /** Set by the npm `preuninstall` hook: suppress the "npm uninstall -g getculpa"
12
+ * next-step hint (we are already inside that command). D-126 / v1.0.9. */
13
+ fromNpmHook?: boolean;
14
+ }
15
+
16
+ export interface UninstallResult {
17
+ ok: boolean;
18
+ }
19
+
20
+ export function uninstall(opts?: UninstallOptions): Promise<UninstallResult>;
package/lib/uninstall.mjs CHANGED
@@ -1,138 +1,158 @@
1
- // CF20-T3 — `getculpa uninstall`. win32 delegates to the same
2
- // uninstall-culpa.ps1 the Windows installer ships (canonical-path guard,
3
- // collector stop, compose down, pgdata prompt all live there already).
4
- // macOS/Linux implement the same net effect once: compose down, remove the
5
- // shortcut equivalents this core lays down, prompt for pgdata removal
6
- // (TTY only — never destructive without an interactive terminal), then
7
- // point at `npm uninstall -g getculpa` to remove the CLI itself.
8
-
9
- import { spawnSync as realSpawnSync } from "node:child_process";
10
- import { existsSync, rmSync } from "node:fs";
11
- import os from "node:os";
12
- import path from "node:path";
13
- import readline from "node:readline";
14
- import { getAppDir } from "./paths.mjs";
15
-
16
- function delegateWindowsUninstall(appDir, spawnSync) {
17
- const script = path.join(appDir, "uninstall-culpa.ps1");
18
- const result = spawnSync("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script], {
19
- stdio: "inherit",
20
- });
21
- // uninstall-culpa.ps1 stops the capture collector itself before stopping
22
- // the stack (CF19 CodeRabbit PR#16 #289) — non-fatally, with no exit-code
23
- // gate on that one step, so a collector-stop hiccup alone never fails this
24
- // delegate. This return value IS the delegate's true overall outcome
25
- // (stack stop + everything else the script does), inherited straight to
26
- // the console via stdio:"inherit" either way.
27
- return result.status === 0;
28
- }
29
-
30
- // CF20-R: guard the case where there is nothing to bring down (a fresh app
31
- // dir, or one already fully torn down) — running `docker compose -f
32
- // <nonexistent file> ... down` would just fail loudly for no real reason.
33
- // A genuine compose-down failure (the file exists, the command ran, and
34
- // docker refused) DOES surface as a failure — see the caller.
35
- function composeDown(appDir, spawnSync) {
36
- const compose = path.join(appDir, "docker-compose.yml");
37
- if (!existsSync(compose)) {
38
- console.log("getculpa: no docker-compose.yml found - nothing to stop.");
39
- return true;
40
- }
41
- const result = spawnSync("docker", ["compose", "-f", compose, "-p", "culpa", "down"], { stdio: "inherit" });
42
- if (result.status !== 0) {
43
- console.error("getculpa: `docker compose down` did not complete cleanly - see the output above.");
44
- }
45
- return result.status === 0;
46
- }
47
-
48
- // The only shortcut this core lays down outside the app dir on macOS is the
49
- // Desktop .webloc (installers/macos/install-culpa.command:132-142); Linux
50
- // ships no shortcut convention yet, so there is nothing else to remove.
51
- function removeShortcutEquivalents(homedir) {
52
- rmSync(path.join(homedir, "Desktop", "Culpa Dashboard.webloc"), { force: true });
53
- }
54
-
55
- // Never destructive without a real interactive terminal — a non-TTY caller
56
- // (CI, a script, npm's own postinstall/preuninstall hooks) always gets NO.
57
- // isTTY is injectable (same pattern as lib/start.mjs's maybeOpenBrowser)
58
- // rather than read from the live process.stdin.isTTY directly, so tests
59
- // assert the safe default deliberately instead of relying on vitest's own
60
- // stdin happening to be non-TTY.
61
- async function promptPgdataRemoval(createInterface, isTTY) {
62
- if (!isTTY) return false;
63
- const rl = createInterface({ input: process.stdin, output: process.stdout });
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(""));
71
- rl.question("Also DELETE all recorded cost data? This cannot be undone. (y/N) ", resolve);
72
- });
73
- rl.close();
74
- return answer.trim().toLowerCase() === "y";
75
- }
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
-
95
- export async function uninstall(opts = {}) {
96
- const appDir = opts.appDir ?? getAppDir();
97
- const platform = opts.platform ?? process.platform;
98
- const spawnSync = opts.spawnSync ?? realSpawnSync;
99
- const homedir = opts.homedir ?? os.homedir();
100
- const createInterface = opts.createInterface ?? readline.createInterface;
101
- const isTTY = opts.isTTY ?? process.stdin.isTTY;
102
-
103
- let ok;
104
- if (platform === "win32") {
105
- ok = delegateWindowsUninstall(appDir, spawnSync);
106
- if (!ok) console.error("getculpa: uninstall did not complete cleanly - see the output above.");
107
- } else {
108
- ok = composeDown(appDir, spawnSync);
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
- );
120
- } else {
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);
131
- }
132
- }
133
-
134
- console.log("");
135
- console.log("To finish removing the Culpa CLI: npm uninstall -g getculpa");
136
-
137
- return { ok };
138
- }
1
+ // CF20-T3 — `getculpa uninstall`. win32 delegates to the same
2
+ // uninstall-culpa.ps1 the Windows installer ships (canonical-path guard,
3
+ // collector stop, compose down, pgdata prompt all live there already).
4
+ // macOS/Linux implement the same net effect once: compose down, remove the
5
+ // shortcut equivalents this core lays down, prompt for pgdata removal
6
+ // (TTY only — never destructive without an interactive terminal), then
7
+ // point at `npm uninstall -g getculpa` to remove the CLI itself.
8
+
9
+ import { spawnSync as realSpawnSync } from "node:child_process";
10
+ import { existsSync, rmSync } from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import readline from "node:readline";
14
+ import { getAppDir } from "./paths.mjs";
15
+
16
+ function delegateWindowsUninstall(appDir, spawnSync, isTTY) {
17
+ const script = path.join(appDir, "uninstall-culpa.ps1");
18
+ // D-126: a non-interactive caller (the npm `preuninstall` hook, or any non-TTY
19
+ // run) MUST pass -Unattended so uninstall-culpa.ps1 skips every Read-Host — it
20
+ // would otherwise PROMPT to delete the cost database and BLOCK on "Press Enter"
21
+ // inside a silent `npm uninstall -g`. -Unattended still does the full
22
+ // self-teardown (remove the app dir + the script-install Add/Remove key), which
23
+ // -FromUninstaller (Inno's mode) deliberately does not. powershell's own
24
+ // -NonInteractive is added as a fail-fast: any stray prompt errors, never hangs.
25
+ const nonInteractive = !isTTY;
26
+ const args = ["-NoProfile"];
27
+ if (nonInteractive) args.push("-NonInteractive");
28
+ args.push("-ExecutionPolicy", "Bypass", "-File", script);
29
+ if (nonInteractive) args.push("-Unattended");
30
+ const result = spawnSync("powershell.exe", args, {
31
+ stdio: "inherit",
32
+ });
33
+ // uninstall-culpa.ps1 stops the capture collector itself before stopping
34
+ // the stack (CF19 CodeRabbit PR#16 #289) — non-fatally, with no exit-code
35
+ // gate on that one step, so a collector-stop hiccup alone never fails this
36
+ // delegate. This return value IS the delegate's true overall outcome
37
+ // (stack stop + everything else the script does), inherited straight to
38
+ // the console via stdio:"inherit" either way.
39
+ return result.status === 0;
40
+ }
41
+
42
+ // CF20-R: guard the case where there is nothing to bring down (a fresh app
43
+ // dir, or one already fully torn down) — running `docker compose -f
44
+ // <nonexistent file> ... down` would just fail loudly for no real reason.
45
+ // A genuine compose-down failure (the file exists, the command ran, and
46
+ // docker refused) DOES surface as a failure — see the caller.
47
+ function composeDown(appDir, spawnSync) {
48
+ const compose = path.join(appDir, "docker-compose.yml");
49
+ if (!existsSync(compose)) {
50
+ console.log("getculpa: no docker-compose.yml found - nothing to stop.");
51
+ return true;
52
+ }
53
+ const result = spawnSync("docker", ["compose", "-f", compose, "-p", "culpa", "down"], { stdio: "inherit" });
54
+ if (result.status !== 0) {
55
+ console.error("getculpa: `docker compose down` did not complete cleanly - see the output above.");
56
+ }
57
+ return result.status === 0;
58
+ }
59
+
60
+ // The only shortcut this core lays down outside the app dir on macOS is the
61
+ // Desktop .webloc (installers/macos/install-culpa.command:132-142); Linux
62
+ // ships no shortcut convention yet, so there is nothing else to remove.
63
+ function removeShortcutEquivalents(homedir) {
64
+ rmSync(path.join(homedir, "Desktop", "Culpa Dashboard.webloc"), { force: true });
65
+ }
66
+
67
+ // Never destructive without a real interactive terminal — a non-TTY caller
68
+ // (CI, a script, npm's own postinstall/preuninstall hooks) always gets NO.
69
+ // isTTY is injectable (same pattern as lib/start.mjs's maybeOpenBrowser)
70
+ // rather than read from the live process.stdin.isTTY directly, so tests
71
+ // assert the safe default deliberately instead of relying on vitest's own
72
+ // stdin happening to be non-TTY.
73
+ async function promptPgdataRemoval(createInterface, isTTY) {
74
+ if (!isTTY) return false;
75
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
76
+ const answer = await new Promise((resolve) => {
77
+ // V102 T-V102-16 (D-113): EOF (Ctrl-D, or a stdin that ends mid-prompt)
78
+ // fires `close` and NEVER invokes question()'s callback — this promise
79
+ // used to hang forever at the one prompt guarding DELETION of the cost
80
+ // database. An unanswered question is a NO, exactly like tty.mjs.
81
+ // Optional-call: older injected fakes without `once` keep working.
82
+ rl.once?.("close", () => resolve(""));
83
+ rl.question("Also DELETE all recorded cost data? This cannot be undone. (y/N) ", resolve);
84
+ });
85
+ rl.close();
86
+ return answer.trim().toLowerCase() === "y";
87
+ }
88
+
89
+ // V102 T-V102-16 (M11/W2, D-113): macOS uninstall exited 0 leaving ~7.3 MB
90
+ // (launcher, collector, compose, register.mjs) silently behind — the Windows
91
+ // ps1 removes the app dir and is "the more complete of the two" (W2). Remove
92
+ // the dir ONLY when it is provably a Culpa install dir (install-state.json,
93
+ // which every install writes); a mistargeted CULPA_APP_DIR is kept and said.
94
+ // Truthful beats a surprise rm -rf in both directions.
95
+ function removeAppDir(appDir) {
96
+ if (!existsSync(appDir)) return;
97
+ if (existsSync(path.join(appDir, "install-state.json"))) {
98
+ rmSync(appDir, { recursive: true, force: true });
99
+ console.log(`Removed: ${appDir} (launcher, collector, compose and capture shims).`);
100
+ return;
101
+ }
102
+ console.log(
103
+ `Kept: ${appDir} - no install-state.json found there, so it is not provably a Culpa install directory. Remove it yourself if you are sure.`,
104
+ );
105
+ }
106
+
107
+ export async function uninstall(opts = {}) {
108
+ const appDir = opts.appDir ?? getAppDir();
109
+ const platform = opts.platform ?? process.platform;
110
+ const spawnSync = opts.spawnSync ?? realSpawnSync;
111
+ const homedir = opts.homedir ?? os.homedir();
112
+ const createInterface = opts.createInterface ?? readline.createInterface;
113
+ const isTTY = opts.isTTY ?? process.stdin.isTTY;
114
+ // D-126 / v1.0.9: set by the npm `preuninstall` hook (scripts/preuninstall.js).
115
+ // When true we are ALREADY inside `npm uninstall -g getculpa`, so the trailing
116
+ // "To finish removing the Culpa CLI: npm uninstall -g getculpa" hint below is
117
+ // wrong/confusing — the hook owns the closing "what's left behind" summary.
118
+ // Teardown behaviour is otherwise identical.
119
+ const fromNpmHook = opts.fromNpmHook ?? false;
120
+
121
+ let ok;
122
+ if (platform === "win32") {
123
+ ok = delegateWindowsUninstall(appDir, spawnSync, isTTY);
124
+ if (!ok) console.error("getculpa: uninstall did not complete cleanly - see the output above.");
125
+ } else {
126
+ ok = composeDown(appDir, spawnSync);
127
+ // Review-4 IMPORTANT + CodeRabbit PR#19 [7] (Major): a FAILED stop skips
128
+ // EVERY destructive step — the ps1's $stackStopped gate, whose comment
129
+ // records the prior real incident (CR-PR5 round 3). Not even the pgdata
130
+ // PROMPT is offered: after a partial teardown the volume can be
131
+ // unreferenced, so a user confirming deletion here could lose the cost
132
+ // database while being told the stack is still running. Nothing is
133
+ // removed; the one honest instruction is to stop the stack and rerun.
134
+ if (!ok) {
135
+ console.log(
136
+ `Files kept: ${appDir} - the stack is still running (\`docker compose down\` failed above). Nothing was removed. Stop the stack, then run this again.`,
137
+ );
138
+ } else {
139
+ removeShortcutEquivalents(homedir);
140
+ const wipe = await promptPgdataRemoval(createInterface, isTTY);
141
+ if (wipe) {
142
+ spawnSync("docker", ["volume", "rm", "culpa_pgdata"], { stdio: "inherit" });
143
+ console.log("Data volume removed.");
144
+ } else {
145
+ console.log("Data kept (volume culpa_pgdata). Reinstalling later will find it again.");
146
+ }
147
+ // M11/W2: after the data decision, remove the app dir itself (ps1 parity)
148
+ removeAppDir(appDir);
149
+ }
150
+ }
151
+
152
+ if (!fromNpmHook) {
153
+ console.log("");
154
+ console.log("To finish removing the Culpa CLI: npm uninstall -g getculpa");
155
+ }
156
+
157
+ return { ok };
158
+ }
package/package.json CHANGED
@@ -1,25 +1,26 @@
1
- {
2
- "name": "getculpa",
3
- "version": "1.0.7",
4
- "description": "Culpa CLI: `npm i -g getculpa` provisions the full Culpa install (Windows-installer parity) and leaves it dormant. `getculpa` wakes the stack.",
5
- "license": "SEE LICENSE IN LICENSE",
6
- "bin": {
7
- "getculpa": "bin/getculpa.js",
8
- "culpa": "bin/culpa.js"
9
- },
10
- "scripts": {
11
- "postinstall": "node scripts/install.js",
12
- "prepack": "node scripts/prepack.js"
13
- },
14
- "files": [
15
- "bin/",
16
- "lib/",
17
- "scripts/",
18
- "assets/",
19
- "README.md",
20
- "LICENSE"
21
- ],
22
- "engines": {
23
- "node": ">=20"
24
- }
25
- }
1
+ {
2
+ "name": "getculpa",
3
+ "version": "1.0.10",
4
+ "description": "Culpa CLI: `npm i -g getculpa` provisions the full Culpa install (Windows-installer parity) and leaves it dormant. `getculpa` wakes the stack.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "bin": {
7
+ "getculpa": "bin/getculpa.js",
8
+ "culpa": "bin/culpa.js"
9
+ },
10
+ "scripts": {
11
+ "postinstall": "node scripts/install.js",
12
+ "preuninstall": "node scripts/preuninstall.js",
13
+ "prepack": "node scripts/prepack.js"
14
+ },
15
+ "files": [
16
+ "bin/",
17
+ "lib/",
18
+ "scripts/",
19
+ "assets/",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20"
25
+ }
26
+ }
@@ -0,0 +1,82 @@
1
+ // v1.0.9 (findings: ~/Downloads/10-uninstall-findings.md) — npm `preuninstall`
2
+ // hook so `npm uninstall -g getculpa` actually DEPROVISIONS instead of leaving a
3
+ // running stack, ~400MB of images and the stored credentials behind (the P1
4
+ // findings: the CLI that could clean up was the very thing being deleted).
5
+ //
6
+ // npm runs this NON-INTERACTIVELY (no TTY), so it drives the same teardown as
7
+ // `getculpa uninstall` in its safe, data-preserving default: stop the stack +
8
+ // remove the `culpa` network, the Desktop shortcut and the app dir (which also
9
+ // clears the stale install-state.json — findings P3). It NEVER deletes the data
10
+ // volumes: a silent hook must not destroy the cost database or the stored
11
+ // licence. It then prints exactly what is left behind, with the one-line removal
12
+ // command (the findings' suggested fix).
13
+ //
14
+ // A nonzero exit does NOT stop `npm uninstall -g` (verified: node 20.19.6 /
15
+ // npm 11.x, D-126), so the Windows Inno "cancel-on-nonzero" control cannot be
16
+ // replicated here. The honest ceiling npm allows is a loud warning on a failed
17
+ // stop; we give it and still exit 0 (a nonzero exit only adds "npm error" noise
18
+ // while npm removes the package anyway).
19
+
20
+ "use strict";
21
+
22
+ // Dependency-injected so the summary / stop-failure messaging is unit-testable in
23
+ // isolation (a fake `uninstall`, captured log/err) — mirrors the injection style
24
+ // of tests/npm-core-uninstall.test.ts. The npm entry (bottom) wires the real deps.
25
+ async function runPreuninstall(deps = {}) {
26
+ const importUninstall = deps.importUninstall ?? (() => import("../lib/uninstall.mjs"));
27
+ const log = deps.log ?? console.log;
28
+ const err = deps.err ?? console.error;
29
+
30
+ const { uninstall } = await importUninstall();
31
+
32
+ log("");
33
+ log("getculpa: uninstalling — stopping the Culpa stack and removing install files...");
34
+
35
+ let ok = false;
36
+ try {
37
+ ({ ok } = await uninstall({ isTTY: false, fromNpmHook: true }));
38
+ } catch (e) {
39
+ // A teardown error must never crash the hook or abort the package removal.
40
+ err(`getculpa: preuninstall teardown hit an error: ${(e && e.message) || e}`);
41
+ ok = false;
42
+ }
43
+
44
+ if (!ok) {
45
+ // "Refuse to leave a running stack silently" — the ceiling npm allows (D-126):
46
+ // npm removes getculpa regardless, so warn loudly with the manual command.
47
+ err("");
48
+ err("getculpa: WARNING — the Culpa stack may still be RUNNING (its stop did not complete cleanly).");
49
+ err("getculpa: getculpa is about to be removed, so stop the stack yourself with:");
50
+ err(" docker compose -p culpa down");
51
+ err("");
52
+ return { ok: false };
53
+ }
54
+
55
+ // Data + credentials are KEPT on purpose (never deleted by a silent hook).
56
+ // culpa_pgdata holds the recorded cost data AND the stored licence key + sync
57
+ // secret (culpa/api/claim_client.ts stores them in the SQL settings table);
58
+ // culpa_data holds the machine secret. No host-side licence file exists (D-126).
59
+ log("");
60
+ log("getculpa: Culpa's containers and network are stopped and the install files are removed.");
61
+ log("Two data volumes were KEPT on purpose (reinstalling later reuses them):");
62
+ log(" - culpa_pgdata : your recorded cost data AND your stored licence key + sync secret");
63
+ log(" - culpa_data : the machine secret");
64
+ log("To remove them too (this DELETES your recorded data and stored licence, and cannot be undone):");
65
+ log(" docker volume rm culpa_pgdata culpa_data");
66
+ log("");
67
+ return { ok: true };
68
+ }
69
+
70
+ module.exports = { runPreuninstall };
71
+
72
+ // Always exit 0: a failed teardown is surfaced as a loud warning above, not as a
73
+ // nonzero exit (which npm ignores for uninstall while still removing the package).
74
+ if (require.main === module) {
75
+ runPreuninstall().then(
76
+ () => process.exit(0),
77
+ (e) => {
78
+ console.error(`getculpa: preuninstall hook error (uninstall still proceeds): ${(e && e.message) || e}`);
79
+ process.exit(0);
80
+ },
81
+ );
82
+ }