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.
@@ -0,0 +1,117 @@
1
+ # Culpa uninstaller for Windows (T-087, T-IX-01).
2
+ # Stops and removes the containers and shortcuts. Your recorded data is
3
+ # KEPT unless you answer Y to the final question.
4
+ # -FromUninstaller: run by the setup.exe's uninstaller - no interactive
5
+ # prompts, data always kept (never delete data without an explicit yes).
6
+ #
7
+ # CR-PR5 round 2 (SAFETY ORDERING): the canonical-path check now gates EVERY
8
+ # destructive action - docker down, the data-volume prompt, and file removal.
9
+ # A copy of this script run from anywhere else previously could stop the real
10
+ # `culpa` project and offer to delete culpa_pgdata, the shared data volume.
11
+ param([switch]$FromUninstaller)
12
+
13
+ $ErrorActionPreference = "SilentlyContinue"
14
+ $AppDir = $PSScriptRoot
15
+ # CF5-F11: the install directory is the founder's choice now, so "canonical"
16
+ # is what SETUP RECORDED, not a hardcoded path. Inno writes InstallLocation
17
+ # into its own uninstall key; fall back to %LOCALAPPDATA%\Culpa for installs
18
+ # made before the directory page existed, and for the zip layout.
19
+ # The guard itself is unchanged and still load-bearing: a stray COPY of this
20
+ # script must never stop containers or delete files belonging to the real
21
+ # install.
22
+ $Recorded = (Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1" -Name InstallLocation -ErrorAction SilentlyContinue).InstallLocation
23
+ $Canonical = if ([string]::IsNullOrWhiteSpace($Recorded)) {
24
+ Join-Path $env:LOCALAPPDATA "Culpa"
25
+ } else {
26
+ $Recorded.TrimEnd('\')
27
+ }
28
+ $ComposeDst = Join-Path $AppDir "docker-compose.yml"
29
+
30
+ if ($AppDir -ne $Canonical) {
31
+ # non-canonical copy: touch NOTHING (no docker, no volume, no files)
32
+ Write-Host "This copy is not the installed one - nothing was changed." -ForegroundColor Yellow
33
+ Write-Host "To uninstall Culpa: Windows Settings -> Installed apps -> Culpa,"
34
+ Write-Host "or run uninstall-culpa.ps1 in $Canonical."
35
+ if (-not $FromUninstaller) { Read-Host "Press Enter to close" }
36
+ exit 0
37
+ }
38
+
39
+ # CF19 (CodeRabbit PR#16 #289): collector shutdown runs AFTER the
40
+ # canonical-path guard — a stray COPY of this script must not change the real
41
+ # installation, and stopping its collector is a change. Stopping capture is
42
+ # otherwise always safe, so it precedes the container teardown.
43
+ $CollectorCtl = Join-Path $AppDir "culpa-collector.ps1"
44
+ if (Test-Path $CollectorCtl) { & $CollectorCtl -Stop }
45
+
46
+ Write-Host "==> Stopping Culpa"
47
+ # CR-PR5: judge the native exit code explicitly (SilentlyContinue does NOT
48
+ # catch it), and NEVER infer "stopped" from a missing compose file - a prior
49
+ # failed cleanup can leave containers running. Absent file => ask docker.
50
+ $stackStopped = $false
51
+ # CR-PR5 r4: check docker EXPLICITLY. SilentlyContinue swallows a missing
52
+ # executable and leaves $LASTEXITCODE at its PRIOR value - verified on
53
+ # Windows PowerShell 5.1: after any earlier successful native command that
54
+ # value is 0, so the old implicit test would have read "stopped" having
55
+ # stopped nothing, and deleted files while containers ran. Fail-closed now
56
+ # by construction, not by the accident of call ordering.
57
+ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
58
+ Write-Host "Docker was not found on PATH - cannot confirm Culpa is stopped." -ForegroundColor Yellow
59
+ } elseif (Test-Path $ComposeDst) {
60
+ docker compose -f $ComposeDst -p culpa down
61
+ $stackStopped = ($LASTEXITCODE -eq 0)
62
+ } else {
63
+ Write-Host "No compose file here - checking for running Culpa containers directly."
64
+ docker compose -p culpa down
65
+ $stackStopped = ($LASTEXITCODE -eq 0)
66
+ }
67
+ if (-not $stackStopped) {
68
+ Write-Host "Culpa did not stop cleanly - leaving files in place so you can retry:" -ForegroundColor Yellow
69
+ Write-Host " docker compose -p culpa down"
70
+ }
71
+
72
+ Write-Host "==> Removing shortcuts"
73
+ Remove-Item (Join-Path ([Environment]::GetFolderPath("Desktop")) "Culpa.lnk") -Force
74
+ Remove-Item (Join-Path ([Environment]::GetFolderPath("Desktop")) "Culpa Dashboard.url") -Force
75
+ Remove-Item (Join-Path ([Environment]::GetFolderPath("StartMenu")) "Programs\Culpa") -Recurse -Force
76
+
77
+ if (-not $FromUninstaller) {
78
+ $wipe = Read-Host "Also DELETE all recorded cost data? This cannot be undone. (y/N)"
79
+ if ($wipe -eq "y" -or $wipe -eq "Y") {
80
+ docker volume rm culpa_pgdata
81
+ Write-Host "Data volume removed."
82
+ } else {
83
+ Write-Host "Data kept (volume culpa_pgdata). Reinstalling later will find it again."
84
+ }
85
+ if ($stackStopped) {
86
+ $unins = Join-Path $AppDir "unins000.exe"
87
+ if (Test-Path $unins) {
88
+ # exe install: hand off so the Windows Settings entry is cleaned too
89
+ Start-Process $unins "/VERYSILENT"
90
+ } else {
91
+ # T-171 script install: WE own the Settings entry, so we remove it.
92
+ # An exe install never reaches here - Inno owns and removes its own
93
+ # key, and deleting it from this side would strand the wizard with
94
+ # a listing it can no longer clean up.
95
+ Remove-Item -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1" -Recurse -Force
96
+ Remove-Item $AppDir -Recurse -Force
97
+ }
98
+ Write-Host "Culpa removed."
99
+ } else {
100
+ Write-Host "Files kept because the stack is still running - stop it, then run this again."
101
+ }
102
+ Read-Host "Press Enter to close"
103
+ } else {
104
+ # setup.exe's uninstaller removes the installed files itself; we only
105
+ # clean up what install-culpa.ps1 created at runtime - and ONLY when the
106
+ # stack actually stopped
107
+ # CR-PR5 round 3: SIGNAL the failure. Printing alone let Inno delete the
108
+ # scripts while containers were still running; culpa-setup.iss now runs
109
+ # this from InitializeUninstall and CANCELS the uninstall on a nonzero
110
+ # exit, so the files stay put and the retry is possible.
111
+ if (-not $stackStopped) {
112
+ Write-Host "Refusing to remove Culpa while its containers are running." -ForegroundColor Red
113
+ exit 1
114
+ }
115
+ Remove-Item $ComposeDst -Force
116
+ Write-Host "Data kept (volume culpa_pgdata). Reinstalling later will find it again."
117
+ }
package/bin/culpa.js CHANGED
@@ -1,11 +1,22 @@
1
1
  #!/usr/bin/env node
2
- // Name-claim placeholder. It prints one message and exits 0 — deliberately
3
- // no flags, no network, no files touched. The real CLI (culpa/cli) replaces
4
- // this package's contents at its own release; until then anyone who runs
5
- // `npx getculpa` gets an honest answer instead of a 404.
6
-
7
- console.log("Culpa — LLM spend forensics and forecasting, local-first.");
8
- console.log("");
9
- console.log(" Coming soon.");
10
- console.log("");
11
- console.log(" https://getculpa.com");
2
+ // Thin shim: exec the vendored culpa launcher (installed by
3
+ // scripts/install.js at postinstall), forwarding argv/stdio/exit code. The
4
+ // launcher owns everything from here — versions/, updates, the payload.
5
+
6
+ "use strict";
7
+
8
+ const { spawnSync } = require("node:child_process");
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+
12
+ const exe = process.platform === "win32" ? ".exe" : "";
13
+ const launcher = path.join(__dirname, "..", "vendor", `culpa-launcher${exe}`);
14
+
15
+ if (!fs.existsSync(launcher)) {
16
+ console.error("culpa: the launcher is not installed (postinstall failed or was skipped).");
17
+ console.error("culpa: run `npm rebuild getculpa` (or reinstall) to fetch it.");
18
+ process.exit(1);
19
+ }
20
+
21
+ const result = spawnSync(launcher, process.argv.slice(2), { stdio: "inherit" });
22
+ process.exit(result.status === null ? 1 : result.status);
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // CF20-T3 — `getculpa` command router.
3
+ //
4
+ // getculpa | getculpa start -> wake the stack (CF20-T4)
5
+ // getculpa doctor / repair -> diagnostics + self-heal (CF20-T5)
6
+ // getculpa uninstall -> lib/uninstall.mjs
7
+ // getculpa --help / --version -> local, no launcher needed
8
+ // anything else (scan, connect, update, config, ...) -> passthrough to the
9
+ // vendored launcher, exactly like bin/culpa.js does today.
10
+
11
+ "use strict";
12
+
13
+ const { spawnSync } = require("node:child_process");
14
+ const fs = require("node:fs");
15
+ const path = require("node:path");
16
+
17
+ const HELP = `getculpa - Culpa, LLM spend forensics and forecasting, local-first
18
+
19
+ Usage:
20
+ getculpa Start (or resume) the Culpa stack
21
+ getculpa start Same as above
22
+ getculpa stop Stop the running stack (data is kept)
23
+ getculpa restart Stop, then start
24
+ getculpa status Show install/docker/stack/license status
25
+ getculpa doctor Diagnose this install
26
+ getculpa repair Re-stage missing/corrupt install files (no data touched)
27
+ getculpa uninstall Remove Culpa (data is kept unless you confirm)
28
+ getculpa scan Zero-install local scan (no Docker, no network)
29
+ getculpa connect Print the topology-appropriate capture recipe
30
+ getculpa update Manage CLI launcher updates
31
+ getculpa --help Show this help
32
+ getculpa --version Show the getculpa package version
33
+ `;
34
+
35
+ function printVersion() {
36
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
37
+ console.log(pkg.version);
38
+ }
39
+
40
+ function runLauncherPassthrough(args) {
41
+ const exe = process.platform === "win32" ? ".exe" : "";
42
+ const launcher = path.join(__dirname, "..", "vendor", `culpa-launcher${exe}`);
43
+ if (!fs.existsSync(launcher)) {
44
+ console.error("getculpa: the launcher is not installed (postinstall failed or was skipped).");
45
+ console.error("getculpa: run `npm rebuild getculpa` (or reinstall) to fetch it.");
46
+ process.exit(1);
47
+ }
48
+ const result = spawnSync(launcher, args, { stdio: "inherit" });
49
+ process.exit(result.status === null ? 1 : result.status);
50
+ }
51
+
52
+ function currentVersion() {
53
+ return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version;
54
+ }
55
+
56
+ function printResult(result) {
57
+ if (result.ok) return;
58
+ console.error("");
59
+ console.error(`Culpa couldn't start.`);
60
+ console.error(result.message ?? "(no further detail)");
61
+ if (result.recovery) console.error(result.recovery);
62
+ }
63
+
64
+ async function runStart() {
65
+ const { start } = await import("../lib/start.mjs");
66
+ const { getAppDir } = await import("../lib/paths.mjs");
67
+ const result = await start({ appDir: getAppDir(), currentVersion: currentVersion() });
68
+ if (!result.ok) {
69
+ printResult(result);
70
+ process.exit(1);
71
+ }
72
+ process.exit(0);
73
+ }
74
+
75
+ async function runStop() {
76
+ const { stop } = await import("../lib/stop.mjs");
77
+ const result = await stop();
78
+ process.exit(result.ok ? 0 : 1);
79
+ }
80
+
81
+ async function runRestart() {
82
+ const { stop } = await import("../lib/stop.mjs");
83
+ const { start } = await import("../lib/start.mjs");
84
+ const { getAppDir } = await import("../lib/paths.mjs");
85
+ await stop();
86
+ const result = await start({ appDir: getAppDir(), currentVersion: currentVersion() });
87
+ if (!result.ok) {
88
+ printResult(result);
89
+ process.exit(1);
90
+ }
91
+ process.exit(0);
92
+ }
93
+
94
+ async function runStatus() {
95
+ const { status, formatStatus } = await import("../lib/status.mjs");
96
+ const result = await status();
97
+ console.log(formatStatus(result));
98
+ process.exit(0);
99
+ }
100
+
101
+ async function runDoctor() {
102
+ const { doctor, formatDoctorReport } = await import("../lib/doctor.mjs");
103
+ const { getAppDir } = await import("../lib/paths.mjs");
104
+ const result = await doctor({ appDir: getAppDir(), currentVersion: currentVersion() });
105
+ console.log(formatDoctorReport(result));
106
+ process.exit(result.ok ? 0 : 1);
107
+ }
108
+
109
+ async function runRepair() {
110
+ const { repair } = await import("../lib/repair.mjs");
111
+ const { getAppDir } = await import("../lib/paths.mjs");
112
+ const result = await repair({ appDir: getAppDir(), currentVersion: currentVersion() });
113
+ process.exit(result.ok ? 0 : 1);
114
+ }
115
+
116
+ async function runUninstall() {
117
+ const { uninstall } = await import("../lib/uninstall.mjs");
118
+ const result = await uninstall();
119
+ process.exit(result.ok ? 0 : 1);
120
+ }
121
+
122
+ async function main(argv) {
123
+ const [cmd] = argv;
124
+ if (cmd === undefined || cmd === "start") return runStart();
125
+ if (cmd === "-h" || cmd === "--help" || cmd === "help") {
126
+ console.log(HELP);
127
+ return process.exit(0);
128
+ }
129
+ if (cmd === "-v" || cmd === "--version") {
130
+ printVersion();
131
+ return process.exit(0);
132
+ }
133
+ if (cmd === "stop") return runStop();
134
+ if (cmd === "restart") return runRestart();
135
+ if (cmd === "status") return runStatus();
136
+ if (cmd === "doctor") return runDoctor();
137
+ if (cmd === "repair") return runRepair();
138
+ if (cmd === "uninstall") return runUninstall();
139
+ // scan / connect / update / config / anything unrecognized: the launcher
140
+ // owns USAGE and exit-code semantics for its own surface, same as culpa.js.
141
+ return runLauncherPassthrough(argv);
142
+ }
143
+
144
+ main(process.argv.slice(2)).catch((e) => {
145
+ console.error(`getculpa: ${e.message}`);
146
+ process.exit(1);
147
+ });
@@ -0,0 +1,2 @@
1
+ export const ASSET_MANIFEST: Record<string, string>;
2
+ export function resolveAssetPath(name: string): string;
package/lib/assets.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // CF20-T3 — resolves the canonical shared install assets. Packed installs
2
+ // read from assets/ (staged by scripts/prepack.js at `npm pack`/publish
3
+ // time). Dev/test runs (no pack step) fall back to the repo-relative
4
+ // canonical paths, so nothing here is ever forked — there is exactly one
5
+ // source of truth for each file, and assets/ is a generated copy of it.
6
+
7
+ import { existsSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const packageRoot = path.join(__dirname, "..");
13
+ // packaging/npm-getculpa/lib -> packaging/npm-getculpa -> packaging -> repo root
14
+ const repoRoot = path.join(packageRoot, "..", "..");
15
+
16
+ // name -> repo-relative canonical source (dev/test fallback only)
17
+ export const ASSET_MANIFEST = {
18
+ "culpa-compose.yml": path.join(repoRoot, "installers", "culpa-compose.yml"),
19
+ "install-culpa.ps1": path.join(repoRoot, "installers", "windows", "install-culpa.ps1"),
20
+ "launch-culpa.ps1": path.join(repoRoot, "installers", "windows", "launch-culpa.ps1"),
21
+ "uninstall-culpa.ps1": path.join(repoRoot, "installers", "windows", "uninstall-culpa.ps1"),
22
+ "culpa-collector.ps1": path.join(repoRoot, "installers", "windows", "culpa-collector.ps1"),
23
+ "register.mjs": path.join(repoRoot, "collector", "node", "register.mjs"),
24
+ };
25
+
26
+ export function resolveAssetPath(name) {
27
+ const fallback = ASSET_MANIFEST[name];
28
+ if (!fallback) throw new Error(`unknown shared install asset: ${name}`);
29
+
30
+ const packed = path.join(packageRoot, "assets", name);
31
+ if (existsSync(packed)) return packed;
32
+ if (existsSync(fallback)) return fallback;
33
+ throw new Error(
34
+ `asset '${name}' not found in packed assets/ (${packed}) or the repo-relative fallback (${fallback})`,
35
+ );
36
+ }
@@ -0,0 +1,72 @@
1
+ export type SpawnSyncLike = (
2
+ cmd: string,
3
+ args?: string[],
4
+ opts?: unknown,
5
+ ) => { status: number | null; stdout?: string; stderr?: string };
6
+
7
+ export const PROBE_TIMEOUT_MS: number;
8
+
9
+ export function pollUntil(
10
+ probe: () => boolean | Promise<boolean>,
11
+ opts: { maxWaitMs: number; intervalMs: number; sleepFn?: (ms: number) => Promise<void> },
12
+ ): Promise<boolean>;
13
+
14
+ export function checkDockerEngineReachable(spawnSync?: SpawnSyncLike): boolean;
15
+
16
+ export function waitForDockerEngine(opts?: {
17
+ spawnSync?: SpawnSyncLike;
18
+ maxWaitMs?: number;
19
+ intervalMs?: number;
20
+ sleepFn?: (ms: number) => Promise<void>;
21
+ }): Promise<boolean>;
22
+
23
+ export function startEngineIfPossible(
24
+ platform: string,
25
+ spawnSync?: SpawnSyncLike,
26
+ log?: (msg: string) => void,
27
+ ): { attempted: boolean; ok: boolean };
28
+
29
+ export function isProjectRunning(spawnSync?: SpawnSyncLike): boolean;
30
+
31
+ export type FetchLike = typeof fetch;
32
+
33
+ export function probeHealth(url: string, opts?: { fetchFn?: FetchLike }): Promise<boolean>;
34
+
35
+ export function waitForHealth(
36
+ url: string,
37
+ opts?: { maxWaitMs?: number; intervalMs?: number; sleepFn?: (ms: number) => Promise<void>; fetchFn?: FetchLike },
38
+ ): Promise<boolean>;
39
+
40
+ export function dbContainerExists(spawnSync?: SpawnSyncLike): boolean;
41
+
42
+ export function waitForPgReady(opts?: {
43
+ spawnSync?: SpawnSyncLike;
44
+ maxWaitMs?: number;
45
+ intervalMs?: number;
46
+ sleepFn?: (ms: number) => Promise<void>;
47
+ }): Promise<boolean>;
48
+
49
+ export function resolveBackupBaseline(recorded: string | null | undefined, spawnSync?: SpawnSyncLike): string;
50
+
51
+ export function isBackupNeeded(
52
+ dbExists: boolean,
53
+ pinnedVersion: string | null | undefined,
54
+ baseline: string | null | undefined,
55
+ ): boolean;
56
+
57
+ export function preMigrationBackup(opts: {
58
+ backupDir: string;
59
+ tag: string;
60
+ spawnSync?: SpawnSyncLike;
61
+ now?: Date;
62
+ }): string;
63
+
64
+ export interface PortOwner {
65
+ pid: number;
66
+ name: string;
67
+ }
68
+
69
+ export function findPortOwner(
70
+ port: number,
71
+ opts?: { platform?: string; spawnSync?: SpawnSyncLike },
72
+ ): PortOwner | null;
package/lib/docker.mjs ADDED
@@ -0,0 +1,251 @@
1
+ // CF20-T4 — docker engine + running-stack + guard helpers shared by
2
+ // lib/start.mjs, lib/stop.mjs and lib/status.mjs. Every docker-touching
3
+ // function takes an injectable spawnSync (default: the real one) — the same
4
+ // pattern lib/preflight.mjs and lib/provision.mjs already use, and for the
5
+ // same reason: an explicit dependency is more reliable to stub in a test
6
+ // than a PATH shim (see tests/npm-core-provision.test.ts's header comment).
7
+ // NOTHING in this file touches a real container, port, or filesystem
8
+ // location outside a caller-supplied path — bounded polls only, no sleeps
9
+ // beyond the poll interval, and every poll's interval/budget is injectable
10
+ // so a test never actually waits.
11
+
12
+ import { spawnSync as realSpawnSync } from "node:child_process";
13
+ import { mkdirSync, statSync } from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const PROJECT_LABEL = "com.docker.compose.project=culpa";
17
+
18
+ // CF20-R: every docker CLI probe below is a `docker info`/`ps`/`inspect`-class
19
+ // call that normally returns in well under a second. Without a timeout, a
20
+ // hung docker CLI (Desktop mid-restart, a stuck named pipe, an orphaned
21
+ // process holding the socket) stalls npm install / `getculpa doctor`
22
+ // forever. 15s is generous headroom above any healthy probe's real latency
23
+ // while still being short enough that an install or doctor run visibly
24
+ // degrades instead of hanging. NOT applied to preMigrationBackup's pg_dump /
25
+ // docker cp calls below — those are genuinely long-running for a real
26
+ // database and a 15s cap would abort a legitimate backup, not just a hang;
27
+ // the bounded WAIT loops (waitForDockerEngine/waitForHealth/waitForPgReady)
28
+ // keep their own maxWaitMs/intervalMs budgets unchanged — this constant only
29
+ // bounds each individual spawnSync call inside them.
30
+ export const PROBE_TIMEOUT_MS = 15_000;
31
+
32
+ function realSleep(ms) {
33
+ return new Promise((resolve) => setTimeout(resolve, ms));
34
+ }
35
+
36
+ // Bounded poll: calls `probe()` up to `Math.ceil(maxWaitMs / intervalMs)`
37
+ // times, sleeping `intervalMs` BETWEEN attempts only (never after the last
38
+ // one) — mirrors launch-culpa.ps1's `for ($i = 0; $i -lt N; $i++)` loops
39
+ // (2s x 60 = 120s for the engine/dashboard, 2s x 30 = 60s for pg_isready).
40
+ export async function pollUntil(probe, { maxWaitMs, intervalMs, sleepFn = realSleep }) {
41
+ const attempts = Math.max(1, Math.ceil(maxWaitMs / intervalMs));
42
+ for (let i = 0; i < attempts; i++) {
43
+ if (await probe()) return true;
44
+ if (i < attempts - 1) await sleepFn(intervalMs);
45
+ }
46
+ return false;
47
+ }
48
+
49
+ // --- engine reachability + start -------------------------------------------
50
+
51
+ export function checkDockerEngineReachable(spawnSync = realSpawnSync) {
52
+ const r = spawnSync("docker", ["info", "--format", "ok"], { timeout: PROBE_TIMEOUT_MS });
53
+ return r.status === 0;
54
+ }
55
+
56
+ // 120s / 2s — identical budget to launch-culpa.ps1:246-251.
57
+ export async function waitForDockerEngine(opts = {}) {
58
+ const { spawnSync = realSpawnSync, maxWaitMs = 120_000, intervalMs = 2_000, sleepFn } = opts;
59
+ return pollUntil(() => checkDockerEngineReachable(spawnSync), { maxWaitMs, intervalMs, sleepFn });
60
+ }
61
+
62
+ // darwin: mirrors installers/macos/install-culpa.command's `open -a Docker`.
63
+ // linux: starting a system service without explicit consent is out of
64
+ // bounds for this task (no systemctl) — this reports the two common
65
+ // commands and returns unattempted; the caller's WAIT_FOR_DOCKER stage then
66
+ // times out with the standard "couldn't start Docker" message, same as if
67
+ // the founder had ignored the launch-culpa.ps1 prompt.
68
+ export function startEngineIfPossible(platform, spawnSync = realSpawnSync, log = console.log) {
69
+ if (platform === "darwin") {
70
+ const r = spawnSync("open", ["-a", "Docker"]);
71
+ return { attempted: true, ok: r.status === 0 };
72
+ }
73
+ if (platform === "linux") {
74
+ log("getculpa: Docker's engine is not running. Start it yourself, for example:");
75
+ log(" sudo systemctl start docker (systemd hosts)");
76
+ log(" or start Docker Desktop from your applications menu,");
77
+ log("then run `getculpa` again.");
78
+ return { attempted: false, ok: false };
79
+ }
80
+ return { attempted: false, ok: false };
81
+ }
82
+
83
+ // --- IDEMPOTENCY (spec sec 23) ----------------------------------------------
84
+ //
85
+ // `docker ps` (RUNNING containers only — a `docker compose stop`ped stack
86
+ // must read as "not running" so a later `getculpa` recreates it) filtered by
87
+ // the compose project label. Docker Compose stamps
88
+ // com.docker.compose.project=<project name> on every container it creates,
89
+ // independent of which compose file path was used to create it — this is
90
+ // why the check needs no `-f` and cannot be fooled by a QW-1 re-pin between
91
+ // checks. `docker compose -p culpa ps` was considered and rejected: current
92
+ // Docker Compose still needs a compose file (-f, or a matching working
93
+ // directory) to resolve service definitions for `ps`, which would
94
+ // re-introduce a path dependency this check does not need — raw `docker ps`
95
+ // with a label filter answers "is culpa's project running" without one.
96
+ export function isProjectRunning(spawnSync = realSpawnSync) {
97
+ const r = spawnSync("docker", ["ps", "--filter", `label=${PROJECT_LABEL}`, "--format", "{{.ID}}"], {
98
+ encoding: "utf8",
99
+ timeout: PROBE_TIMEOUT_MS,
100
+ });
101
+ if (r.status !== 0) return false;
102
+ return (r.stdout ?? "").trim().length > 0;
103
+ }
104
+
105
+ // --- HTTP health -------------------------------------------------------------
106
+
107
+ export async function probeHealth(url, { fetchFn = fetch } = {}) {
108
+ try {
109
+ const res = await fetchFn(url, { signal: AbortSignal.timeout(3_000) });
110
+ return res.status === 200;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ // 120s / 2s — identical budget to launch-culpa.ps1:331-340.
117
+ export async function waitForHealth(url, opts = {}) {
118
+ const { maxWaitMs = 120_000, intervalMs = 2_000, sleepFn, fetchFn } = opts;
119
+ return pollUntil(() => probeHealth(url, { fetchFn }), { maxWaitMs, intervalMs, sleepFn });
120
+ }
121
+
122
+ // --- QW-4 port: pg_isready + pg_dump backup, ported from
123
+ // launch-culpa.ps1:284-316 (Invoke-PreMigrationBackup / the pg_isready
124
+ // loop). Same fail-closed contract: no verified dump, no launch. -------------
125
+
126
+ export function dbContainerExists(spawnSync = realSpawnSync) {
127
+ const r = spawnSync("docker", ["ps", "-a", "--format", "{{.Names}}"], {
128
+ encoding: "utf8",
129
+ timeout: PROBE_TIMEOUT_MS,
130
+ });
131
+ if (r.status !== 0) return false;
132
+ return /culpa-db/.test(r.stdout ?? "");
133
+ }
134
+
135
+ // 60s / 2s — identical budget to launch-culpa.ps1:303-307. PROBE_TIMEOUT_MS
136
+ // bounds each individual `pg_isready` call inside the poll; it does not
137
+ // change the poll's own maxWaitMs/intervalMs budget.
138
+ export async function waitForPgReady(opts = {}) {
139
+ const { spawnSync = realSpawnSync, maxWaitMs = 60_000, intervalMs = 2_000, sleepFn } = opts;
140
+ return pollUntil(
141
+ () =>
142
+ spawnSync("docker", ["exec", "culpa-db", "pg_isready", "-U", "culpa"], { timeout: PROBE_TIMEOUT_MS }).status ===
143
+ 0,
144
+ { maxWaitMs, intervalMs, sleepFn },
145
+ );
146
+ }
147
+
148
+ export function resolveBackupBaseline(recorded, spawnSync = realSpawnSync) {
149
+ if (recorded && recorded.trim()) return recorded.trim();
150
+ const r = spawnSync("docker", ["inspect", "culpa-server", "--format", "{{.Config.Image}}"], {
151
+ encoding: "utf8",
152
+ timeout: PROBE_TIMEOUT_MS,
153
+ });
154
+ const image = (r.stdout ?? "").trim();
155
+ const m = /:(v[0-9][^:\s]*)$/.exec(image);
156
+ return m ? m[1] : "";
157
+ }
158
+
159
+ // Ported verbatim from Test-BackupNeeded (launch-culpa.ps1:160-165): data
160
+ // present + an UNKNOWN baseline means BACK UP, never skip; only a baseline
161
+ // that is known AND equal to the pin skips the backup.
162
+ export function isBackupNeeded(dbExists, pinnedVersion, baseline) {
163
+ if (!dbExists) return false;
164
+ if (!baseline || !baseline.trim()) return true;
165
+ if (!pinnedVersion || !pinnedVersion.trim()) return true;
166
+ return pinnedVersion !== baseline;
167
+ }
168
+
169
+ function formatStamp(d) {
170
+ const p = (n) => String(n).padStart(2, "0");
171
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
172
+ }
173
+
174
+ // Throws on any failure (fail-closed); returns the verified dump path on
175
+ // success. Same three checks as the .ps1: dump exit code, copy exit code,
176
+ // and a >=1024-byte size floor on the copied file.
177
+ export function preMigrationBackup(opts) {
178
+ const { backupDir, tag, spawnSync = realSpawnSync, now = new Date() } = opts;
179
+ mkdirSync(backupDir, { recursive: true });
180
+ const dest = path.join(backupDir, `pre-${tag}-${formatStamp(now)}.dump`);
181
+
182
+ const dump = spawnSync("docker", [
183
+ "exec",
184
+ "culpa-db",
185
+ "sh",
186
+ "-c",
187
+ "pg_dump -Fc -U culpa culpa > /tmp/culpa-pre-upgrade.dump",
188
+ ]);
189
+ if (dump.status !== 0) throw new Error("pg_dump inside culpa-db failed");
190
+
191
+ const copy = spawnSync("docker", ["cp", "culpa-db:/tmp/culpa-pre-upgrade.dump", dest]);
192
+ if (copy.status !== 0) throw new Error("copying the dump out of culpa-db failed");
193
+
194
+ let size = 0;
195
+ try {
196
+ size = statSync(dest).size;
197
+ } catch {
198
+ size = 0;
199
+ }
200
+ if (size < 1024) {
201
+ throw new Error(`the dump at '${dest}' is missing or implausibly small (${size} bytes)`);
202
+ }
203
+ return dest;
204
+ }
205
+
206
+ // --- PORT HANDLING (spec sec 24) --------------------------------------------
207
+ //
208
+ // Only ever called to NAME an owner — never to act on one. Returns
209
+ // { pid, name } for the process holding `port` in LISTEN state, or null when
210
+ // the port is free (or the lookup itself failed — a lookup failure must
211
+ // never be reported as a conflict).
212
+ export function findPortOwner(port, opts = {}) {
213
+ const { platform = process.platform, spawnSync = realSpawnSync } = opts;
214
+ return platform === "win32" ? findPortOwnerWin32(port, spawnSync) : findPortOwnerPosix(port, spawnSync);
215
+ }
216
+
217
+ function findPortOwnerWin32(port, spawnSync) {
218
+ const conn = spawnSync(
219
+ "powershell.exe",
220
+ [
221
+ "-NoProfile",
222
+ "-Command",
223
+ `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess)`,
224
+ ],
225
+ { encoding: "utf8" },
226
+ );
227
+ const pidText = (conn.stdout ?? "").trim();
228
+ const pid = Number(pidText.split(/\s+/)[0]);
229
+ if (!pidText || !Number.isFinite(pid) || pid <= 0) return null;
230
+
231
+ const proc = spawnSync(
232
+ "powershell.exe",
233
+ ["-NoProfile", "-Command", `(Get-Process -Id ${pid} -ErrorAction SilentlyContinue).ProcessName`],
234
+ { encoding: "utf8" },
235
+ );
236
+ const name = (proc.stdout ?? "").trim() || `pid ${pid}`;
237
+ return { pid, name };
238
+ }
239
+
240
+ function findPortOwnerPosix(port, spawnSync) {
241
+ const r = spawnSync("lsof", ["-i", `:${port}`, "-sTCP:LISTEN", "-P", "-n"], { encoding: "utf8" });
242
+ if (r.status !== 0) return null;
243
+ const lines = (r.stdout ?? "").split("\n").filter((l) => l.trim().length > 0);
244
+ const dataLine = lines[1]; // lines[0] is the COMMAND/PID/... header
245
+ if (!dataLine) return null;
246
+ const cols = dataLine.trim().split(/\s+/);
247
+ const name = cols[0];
248
+ const pid = Number(cols[1]);
249
+ if (!name || !Number.isFinite(pid) || pid <= 0) return null;
250
+ return { pid, name };
251
+ }