getculpa 0.0.1 → 1.0.2

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/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
+ }
@@ -0,0 +1,17 @@
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>;
@@ -0,0 +1,99 @@
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
+ rl.question("Also DELETE all recorded cost data? This cannot be undone. (y/N) ", resolve);
66
+ });
67
+ rl.close();
68
+ return answer.trim().toLowerCase() === "y";
69
+ }
70
+
71
+ export async function uninstall(opts = {}) {
72
+ const appDir = opts.appDir ?? getAppDir();
73
+ const platform = opts.platform ?? process.platform;
74
+ const spawnSync = opts.spawnSync ?? realSpawnSync;
75
+ const homedir = opts.homedir ?? os.homedir();
76
+ const createInterface = opts.createInterface ?? readline.createInterface;
77
+ const isTTY = opts.isTTY ?? process.stdin.isTTY;
78
+
79
+ let ok;
80
+ if (platform === "win32") {
81
+ ok = delegateWindowsUninstall(appDir, spawnSync);
82
+ if (!ok) console.error("getculpa: uninstall did not complete cleanly - see the output above.");
83
+ } else {
84
+ 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.");
90
+ } else {
91
+ console.log("Data kept (volume culpa_pgdata). Reinstalling later will find it again.");
92
+ }
93
+ }
94
+
95
+ console.log("");
96
+ console.log("To finish removing the Culpa CLI: npm uninstall -g getculpa");
97
+
98
+ return { ok };
99
+ }
package/package.json CHANGED
@@ -1,36 +1,25 @@
1
1
  {
2
2
  "name": "getculpa",
3
- "version": "0.0.1",
4
- "description": "Culpa — LLM spend forensics and forecasting, local-first. Placeholder: the CLI is coming soon.",
3
+ "version": "1.0.2",
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",
5
6
  "bin": {
7
+ "getculpa": "bin/getculpa.js",
6
8
  "culpa": "bin/culpa.js"
7
9
  },
8
- "main": "index.js",
9
- "type": "commonjs",
10
+ "scripts": {
11
+ "postinstall": "node scripts/install.js",
12
+ "prepack": "node scripts/prepack.js"
13
+ },
10
14
  "files": [
11
- "bin/culpa.js",
12
- "index.js",
13
- "README.md"
14
- ],
15
- "keywords": [
16
- "llm",
17
- "cost",
18
- "spend",
19
- "forecasting",
20
- "openai",
21
- "anthropic",
22
- "observability"
15
+ "bin/",
16
+ "lib/",
17
+ "scripts/",
18
+ "assets/",
19
+ "README.md",
20
+ "LICENSE"
23
21
  ],
24
- "homepage": "https://getculpa.com",
25
- "repository": {
26
- "type": "git",
27
- "url": "git+https://github.com/MyaigiDev/culpa.git"
28
- },
29
- "license": "UNLICENSED",
30
22
  "engines": {
31
23
  "node": ">=20"
32
- },
33
- "publishConfig": {
34
- "access": "public"
35
24
  }
36
25
  }
@@ -0,0 +1,154 @@
1
+ // AU-011 / CF20-T3 — npm postinstall for `getculpa`.
2
+ //
3
+ // This provisions the FULL Culpa install (Windows-installer parity) and
4
+ // starts NOTHING: no `docker compose up`, no collector start, no browser.
5
+ // "npm installation leaves Culpa fully installed but dormant." `getculpa`
6
+ // (bin/getculpa.js) is what wakes it — that orchestration is CF20-T4, not
7
+ // this file.
8
+ //
9
+ // Order: preflight (fail-closed on an unsupported platform) -> fetch the CLI
10
+ // launcher (fail-closed: the same trust model as before, see lib/fetch.mjs)
11
+ // -> fetch the capture collector (best-effort: absence degrades capture,
12
+ // never aborts the install) -> provision (stage files, best-effort image
13
+ // pull, win32 shortcuts/registry). Non-interactive by construction: nothing
14
+ // here reads stdin or opens a dialog. Terminates on its own — no daemons,
15
+ // no background children.
16
+
17
+ "use strict";
18
+
19
+ const fs = require("node:fs");
20
+ const path = require("node:path");
21
+ const pkg = require("../package.json");
22
+
23
+ // TEST SEAM (CF20-T7, scripts/cf20-npm-gate.mjs ONLY) — never set this for a
24
+ // real install. When CULPA_GATE_SKIP_FETCH=1, the launcher/collector network
25
+ // fetch below is replaced with an inert local stub so the gate can prove the
26
+ // npm install route offline/deterministically (this machine's GitHub Releases
27
+ // may not carry 0.12.0 assets yet, and the gate must never depend on that).
28
+ // The stub is obviously fake (it errors if ever executed) and is written the
29
+ // same way lib/fetch.mjs writes a real launcher (mkdir + write to vendor/),
30
+ // so downstream commands that only check for the launcher's PRESENCE (e.g.
31
+ // `getculpa doctor`) see the same shape a real install leaves behind. The
32
+ // default path — this file's `else` branch below — is byte-for-byte what
33
+ // shipped before this task: it still fetches from GitHub Releases and still
34
+ // verifies the sha256 checksum before writing anything.
35
+ function stageStubLauncher() {
36
+ const vendorDir = path.join(__dirname, "..", "vendor");
37
+ fs.mkdirSync(vendorDir, { recursive: true });
38
+ const exe = process.platform === "win32" ? ".exe" : "";
39
+ const dest = path.join(vendorDir, `culpa-launcher${exe}`);
40
+ fs.writeFileSync(
41
+ dest,
42
+ "#!/bin/sh\necho 'CULPA GATE STUB - NOT A REAL LAUNCHER (CULPA_GATE_SKIP_FETCH=1)' 1>&2\nexit 1\n",
43
+ );
44
+ if (process.platform !== "win32") fs.chmodSync(dest, 0o755);
45
+ }
46
+
47
+ async function main() {
48
+ const { checkPlatformSupport } = await import("../lib/preflight.mjs");
49
+ const { getAppDir } = await import("../lib/paths.mjs");
50
+ const { fetchLauncher, fetchCollectord } = await import("../lib/fetch.mjs");
51
+ const { provision } = await import("../lib/provision.mjs");
52
+
53
+ const platformCheck = checkPlatformSupport();
54
+ if (!platformCheck.supported) {
55
+ console.error(`getculpa: ${platformCheck.message}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ const appDir = getAppDir();
60
+
61
+ let collectorFetched = false;
62
+ if (process.env.CULPA_GATE_SKIP_FETCH === "1") {
63
+ console.warn(
64
+ "getculpa: CULPA_GATE_SKIP_FETCH=1 - TEST SEAM ACTIVE (scripts/cf20-npm-gate.mjs only). " +
65
+ "Skipping the real launcher/collector fetch and staging an inert stub launcher instead. " +
66
+ "NEVER set this for a real install.",
67
+ );
68
+ stageStubLauncher();
69
+ } else {
70
+ // Fatal: without the launcher, `culpa scan`/`connect`/`update` have
71
+ // nothing to run — same posture as the pre-CF20 bootstrap.
72
+ await fetchLauncher();
73
+
74
+ // Best-effort: absence degrades capture only ("capture OFF, everything
75
+ // else works" — culpa-collector.ps1's own wording, mirrored here).
76
+ try {
77
+ await fetchCollectord();
78
+ collectorFetched = true;
79
+ } catch (e) {
80
+ console.warn(`getculpa: capture collector not fetched (${e.message}) - capture OFF, everything else works.`);
81
+ }
82
+ }
83
+
84
+ const provisionOpts = { appDir, currentVersion: pkg.version, collectorFetched };
85
+ // TEST SEAM (CF20-T7, scripts/cf20-npm-gate.mjs ONLY) — never set this for
86
+ // a real install. On win32, provision() always delegates to the STAGED
87
+ // install-culpa.ps1 for Desktop/Start Menu shortcuts and Add/Remove
88
+ // Programs registration (installers/windows/install-culpa.ps1's own T-171
89
+ // section — this is intentionally OUTSIDE what -DeferStart skips, since it
90
+ // is real Windows-installer parity, not launch-time work). That script
91
+ // writes to the HKCU registry key
92
+ // "Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-
93
+ // A0C8-52C1E7D94F60}_is1" — DELIBERATELY the SAME key name the real Inno
94
+ // installer uses, so the two paths can never both be listed. That dedup
95
+ // only works when both paths target the SAME real app dir (the script
96
+ // finds the Inno unins000.exe there and skips re-registering); a gate that
97
+ // provisions to a SCRATCH app dir has no unins000.exe there and so does
98
+ // NOT hit the dedup skip - it silently overwrites the real Inno-managed
99
+ // entry with values pointing at a scratch directory the gate deletes
100
+ // afterward. CULPA_GATE_SKIP_WIN_DELEGATE=1 uses provision()'s own
101
+ // already-injectable delegateWindowsInstaller parameter (see
102
+ // lib/provision.mjs) to skip that call entirely - zero writes to the real
103
+ // Desktop, Start Menu, or registry. The default path (this variable unset)
104
+ // is byte-for-byte unchanged: provision() still calls the real delegate.
105
+ if (process.env.CULPA_GATE_SKIP_WIN_DELEGATE === "1") {
106
+ console.warn(
107
+ "getculpa: CULPA_GATE_SKIP_WIN_DELEGATE=1 - TEST SEAM ACTIVE (scripts/cf20-npm-gate.mjs only). " +
108
+ "Skipping the real install-culpa.ps1 delegate (shortcuts + the shared Add/Remove Programs " +
109
+ "registry key) so this run cannot collide with a real Windows install's registration. " +
110
+ "NEVER set this for a real install.",
111
+ );
112
+ provisionOpts.delegateWindowsInstaller = () => true;
113
+ }
114
+ const { classification, previousVersion, dockerState, imagesStaged, collectorStaged } =
115
+ await provision(provisionOpts);
116
+
117
+ // T-CF28-4b: stage the CLI payload the launcher actually runs. Without this
118
+ // a fresh install leaves state.json at `current: null` and the first
119
+ // `getculpa` dead-ends — the Mac tester's exact experience. Best-effort by
120
+ // construction: any failure leaves the payload deferred and the summary
121
+ // below names `getculpa update`, which is where we already were.
122
+ const { stageInitialPayload, vendoredLauncherPath } = await import("../lib/bootstrap.mjs");
123
+ const { staged: payloadStaged } = stageInitialPayload({
124
+ launcherPath: vendoredLauncherPath(path.join(__dirname, "..")),
125
+ env: process.env,
126
+ });
127
+
128
+ // CF20-T6: the completion line names what actually happened — an upgrade
129
+ // is STAGED only; the new version applies at the next `getculpa`, not here.
130
+ // T-CF28-5/7: those words now live in lib/install-summary.mjs so they are
131
+ // testable, and they no longer report success without saying whether Docker
132
+ // — which Culpa cannot run without — is actually present.
133
+ const { buildInstallSummary } = await import("../lib/install-summary.mjs");
134
+ const summary = buildInstallSummary({
135
+ classification,
136
+ version: pkg.version,
137
+ previousVersion,
138
+ appDir,
139
+ dockerState,
140
+ imagesStaged,
141
+ collectorStaged,
142
+ payloadStaged,
143
+ platform: process.platform,
144
+ isTTY: Boolean(process.stdout.isTTY),
145
+ env: process.env,
146
+ });
147
+ for (const line of summary) console.log(line);
148
+ }
149
+
150
+ main().catch((e) => {
151
+ console.error(`getculpa: install failed — ${e.message}`);
152
+ console.error("getculpa: nothing was installed. The Culpa CLI is unavailable until this succeeds.");
153
+ process.exit(1);
154
+ });
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // CF20-T3 — prepack: copy the canonical shared install assets into assets/
3
+ // so the packed tarball is self-contained. assets/ is GENERATED — never
4
+ // edit it directly, never fork these files; this script is its only writer.
5
+ // lib/assets.mjs falls back to these same repo-relative sources when
6
+ // assets/ is absent (dev/test runs that never ran `npm pack`).
7
+
8
+ "use strict";
9
+
10
+ const fs = require("node:fs");
11
+ const path = require("node:path");
12
+
13
+ const pkgRoot = path.join(__dirname, "..");
14
+ const repoRoot = path.join(pkgRoot, "..", "..");
15
+ const assetsDir = path.join(pkgRoot, "assets");
16
+
17
+ const SOURCES = [
18
+ ["installers", "culpa-compose.yml"],
19
+ ["installers", "windows", "install-culpa.ps1"],
20
+ ["installers", "windows", "launch-culpa.ps1"],
21
+ ["installers", "windows", "uninstall-culpa.ps1"],
22
+ ["installers", "windows", "culpa-collector.ps1"],
23
+ ["collector", "node", "register.mjs"],
24
+ ];
25
+
26
+ // CF20-R: clear assets/ before restaging — otherwise a canonical source file
27
+ // that gets removed or renamed from SOURCES leaves a STALE copy behind
28
+ // forever, and that stale file still ships in the tarball (assets/ is never
29
+ // hand-edited, so nothing else would ever remove it).
30
+ fs.rmSync(assetsDir, { recursive: true, force: true });
31
+ fs.mkdirSync(assetsDir, { recursive: true });
32
+ for (const parts of SOURCES) {
33
+ const src = path.join(repoRoot, ...parts);
34
+ if (!fs.existsSync(src)) {
35
+ console.error(`prepack: missing canonical source ${src}`);
36
+ process.exit(1);
37
+ }
38
+ const dest = path.join(assetsDir, path.basename(src));
39
+ fs.copyFileSync(src, dest);
40
+ console.log(`prepack: staged ${path.basename(src)}`);
41
+ }
package/index.js DELETED
@@ -1,3 +0,0 @@
1
- // Placeholder entry point, so `require("getculpa")` resolves instead of
2
- // throwing MODULE_NOT_FOUND. The real API is not published yet.
3
- module.exports = { comingSoon: true };