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/README.md +37 -18
- package/assets/culpa-collector.ps1 +137 -0
- package/assets/culpa-compose.yml +144 -0
- package/assets/install-culpa.ps1 +310 -0
- package/assets/launch-culpa.ps1 +350 -0
- package/assets/register.mjs +713 -0
- package/assets/uninstall-culpa.ps1 +117 -0
- package/bin/culpa.js +21 -10
- package/bin/getculpa.js +147 -0
- package/lib/assets.d.mts +2 -0
- package/lib/assets.mjs +36 -0
- package/lib/docker.d.mts +72 -0
- package/lib/docker.mjs +251 -0
- package/lib/doctor.d.mts +29 -0
- package/lib/doctor.mjs +392 -0
- package/lib/fetch.d.mts +17 -0
- package/lib/fetch.mjs +211 -0
- package/lib/paths.d.mts +9 -0
- package/lib/paths.mjs +55 -0
- package/lib/preflight.d.mts +39 -0
- package/lib/preflight.mjs +169 -0
- package/lib/provision.d.mts +25 -0
- package/lib/provision.mjs +237 -0
- package/lib/repair.d.mts +20 -0
- package/lib/repair.mjs +135 -0
- package/lib/start.d.mts +42 -0
- package/lib/start.mjs +392 -0
- package/lib/status.d.mts +23 -0
- package/lib/status.mjs +74 -0
- package/lib/stop.d.mts +15 -0
- package/lib/stop.mjs +46 -0
- package/lib/uninstall.d.mts +17 -0
- package/lib/uninstall.mjs +99 -0
- package/package.json +24 -36
- package/scripts/install.js +135 -0
- package/scripts/prepack.js +41 -0
- package/index.js +0 -3
|
@@ -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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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);
|
package/bin/getculpa.js
ADDED
|
@@ -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
|
+
});
|
package/lib/assets.d.mts
ADDED
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
|
+
}
|
package/lib/docker.d.mts
ADDED
|
@@ -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
|
+
}
|