getculpa 0.0.1 → 1.0.1

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/stop.d.mts ADDED
@@ -0,0 +1,15 @@
1
+ import type { SpawnSyncLike } from "./docker.d.mts";
2
+
3
+ export interface StopOptions {
4
+ appDir?: string;
5
+ platform?: string;
6
+ spawnSync?: SpawnSyncLike;
7
+ log?: (msg: string) => void;
8
+ }
9
+
10
+ export interface StopResult {
11
+ ok: boolean;
12
+ stopped: boolean;
13
+ }
14
+
15
+ export function stop(opts?: StopOptions): Promise<StopResult>;
package/lib/stop.mjs ADDED
@@ -0,0 +1,46 @@
1
+ // CF20-T4 — `getculpa stop`.
2
+ //
3
+ // DECISION (see task report): uses `docker compose stop`, NOT `down`.
4
+ // uninstall-culpa.ps1:60/65 uses `down` because uninstall is a full teardown
5
+ // (containers + the compose network removed; named volumes are kept — no
6
+ // `-v` flag there either). `stop` is a distinct, resumable verb: a founder
7
+ // pausing Culpa should get a fast `getculpa` back — Compose's own paired
8
+ // verb for `stop` is `start`/`up`, not a recreate — without losing the
9
+ // container-level state `down` discards. The collector is a host process
10
+ // (win32 only, D-082 — it binds 127.0.0.1 outside Docker) so it is always
11
+ // stopped explicitly first, exactly as uninstall-culpa.ps1:43-44 does.
12
+
13
+ import { spawnSync as realSpawnSync } from "node:child_process";
14
+ import { existsSync } from "node:fs";
15
+ import path from "node:path";
16
+ import { getAppDir } from "./paths.mjs";
17
+
18
+ export async function stop(opts = {}) {
19
+ const appDir = opts.appDir ?? getAppDir();
20
+ const platform = opts.platform ?? process.platform;
21
+ const spawnSync = opts.spawnSync ?? realSpawnSync;
22
+ const log = opts.log ?? console.log;
23
+
24
+ if (platform === "win32") {
25
+ const collectorCtl = path.join(appDir, "culpa-collector.ps1");
26
+ if (existsSync(collectorCtl)) {
27
+ spawnSync("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", collectorCtl, "-Stop"], {
28
+ stdio: "inherit",
29
+ });
30
+ }
31
+ }
32
+
33
+ const compose = path.join(appDir, "docker-compose.yml");
34
+ if (!existsSync(compose)) {
35
+ log("Culpa is not installed here - nothing to stop.");
36
+ return { ok: true, stopped: false };
37
+ }
38
+
39
+ const result = spawnSync("docker", ["compose", "-f", compose, "-p", "culpa", "stop"], { stdio: "inherit" });
40
+ if (result.status !== 0) {
41
+ log("getculpa: Culpa did not stop cleanly - see the output above.");
42
+ return { ok: false, stopped: false };
43
+ }
44
+ log("Culpa stopped.");
45
+ return { ok: true, stopped: true };
46
+ }
@@ -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,24 @@
1
- {
2
- "name": "getculpa",
3
- "version": "0.0.1",
4
- "description": "Culpa — LLM spend forensics and forecasting, local-first. Placeholder: the CLI is coming soon.",
5
- "bin": {
6
- "culpa": "bin/culpa.js"
7
- },
8
- "main": "index.js",
9
- "type": "commonjs",
10
- "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"
23
- ],
24
- "homepage": "https://getculpa.com",
25
- "repository": {
26
- "type": "git",
27
- "url": "git+https://github.com/MyaigiDev/culpa.git"
28
- },
29
- "license": "UNLICENSED",
30
- "engines": {
31
- "node": ">=20"
32
- },
33
- "publishConfig": {
34
- "access": "public"
35
- }
36
- }
1
+ {
2
+ "name": "getculpa",
3
+ "version": "1.0.1",
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 https://getculpa.com",
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
+ ],
21
+ "engines": {
22
+ "node": ">=20"
23
+ }
24
+ }
@@ -0,0 +1,135 @@
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 } = await provision(provisionOpts);
115
+
116
+ // CF20-T6: the completion line names what actually happened — an upgrade
117
+ // is STAGED only; the new version applies at the next `getculpa`, not here.
118
+ if (classification === "upgrade") {
119
+ console.log(`Culpa updated: ${previousVersion} -> ${pkg.version} (staged; applies at next \`getculpa\`).`);
120
+ } else if (classification === "downgrade-package") {
121
+ console.log(`Culpa ${previousVersion} is already installed; this package (${pkg.version}) is older and was not applied.`);
122
+ } else if (classification === "same-version" || classification === "repair") {
123
+ console.log("Culpa installation repaired.");
124
+ } else {
125
+ console.log("Culpa installed successfully.");
126
+ }
127
+ console.log("Run:");
128
+ console.log(" getculpa");
129
+ }
130
+
131
+ main().catch((e) => {
132
+ console.error(`getculpa: install failed — ${e.message}`);
133
+ console.error("getculpa: nothing was installed. The Culpa CLI is unavailable until this succeeds.");
134
+ process.exit(1);
135
+ });
@@ -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 };