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/LICENSE +41 -0
- 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 +227 -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/bootstrap.d.mts +25 -0
- package/lib/bootstrap.mjs +73 -0
- package/lib/docker.d.mts +76 -0
- package/lib/docker.mjs +270 -0
- package/lib/doctor.d.mts +29 -0
- package/lib/doctor.mjs +405 -0
- package/lib/fetch.d.mts +17 -0
- package/lib/fetch.mjs +211 -0
- package/lib/install-summary.d.mts +18 -0
- package/lib/install-summary.mjs +144 -0
- package/lib/paths.d.mts +9 -0
- package/lib/paths.mjs +61 -0
- package/lib/preflight.d.mts +39 -0
- package/lib/preflight.mjs +169 -0
- package/lib/provision.d.mts +54 -0
- package/lib/provision.mjs +318 -0
- package/lib/repair.d.mts +21 -0
- package/lib/repair.mjs +174 -0
- package/lib/start.d.mts +44 -0
- package/lib/start.mjs +479 -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/tty.d.mts +18 -0
- package/lib/tty.mjs +58 -0
- package/lib/uninstall.d.mts +17 -0
- package/lib/uninstall.mjs +99 -0
- package/package.json +14 -25
- package/scripts/install.js +154 -0
- package/scripts/prepack.js +41 -0
- package/index.js +0 -3
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
# T-CF29-2 params:
|
|
12
|
+
# -Force proceed from THIS directory even when the
|
|
13
|
+
# canonical check disagrees (explicit opt-in;
|
|
14
|
+
# never automatic)
|
|
15
|
+
# -RecordedInstallLocation inject what the registry would return, so the
|
|
16
|
+
# resolution can be tested without touching HKCU
|
|
17
|
+
# (same -Probe precedent as install-culpa.ps1)
|
|
18
|
+
# -CanonicalOverrideForTest inject the fallback directory, for the same reason
|
|
19
|
+
# -PrintPlanOnly resolve, print the decision as JSON, exit 0
|
|
20
|
+
# BEFORE any destructive action
|
|
21
|
+
param(
|
|
22
|
+
[switch]$FromUninstaller,
|
|
23
|
+
[switch]$Force,
|
|
24
|
+
[string]$RecordedInstallLocation,
|
|
25
|
+
[string]$CanonicalOverrideForTest,
|
|
26
|
+
[switch]$PrintPlanOnly
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
$ErrorActionPreference = "SilentlyContinue"
|
|
30
|
+
$AppDir = $PSScriptRoot
|
|
31
|
+
# CF5-F11: the install directory is the founder's choice now, so "canonical"
|
|
32
|
+
# is what SETUP RECORDED, not a hardcoded path. Inno writes InstallLocation
|
|
33
|
+
# into its own uninstall key; fall back to %LOCALAPPDATA%\Culpa for installs
|
|
34
|
+
# made before the directory page existed, and for the zip layout.
|
|
35
|
+
# The guard itself is unchanged and still load-bearing: a stray COPY of this
|
|
36
|
+
# script must never stop containers or delete files belonging to the real
|
|
37
|
+
# install.
|
|
38
|
+
#
|
|
39
|
+
# T-CF29-2: a recorded value that POINTS AT A DELETED DIRECTORY used to be
|
|
40
|
+
# trusted anyway, so the guard below could never match and Culpa became
|
|
41
|
+
# impossible to uninstall. That is not hypothetical — the CF20 npm gate
|
|
42
|
+
# overwrote this shared value with a scratch dir which was then removed.
|
|
43
|
+
# A path that does not exist cannot be shadowing a real install, so it is
|
|
44
|
+
# ignored in favour of the default. Everything else about the guard stands.
|
|
45
|
+
# T-CF29-2: the guard compared raw strings, so two spellings of the SAME
|
|
46
|
+
# directory read as different installs — an 8.3 short path (C:\Users\ZOLANI~1)
|
|
47
|
+
# versus its long form, a trailing backslash, or different casing. Each of
|
|
48
|
+
# those turns the uninstaller into the same dead end a stale registry value
|
|
49
|
+
# does. Resolve-Path returns the canonical long form for a path that exists;
|
|
50
|
+
# a path that does not exist falls back to a trimmed, lowercased comparison.
|
|
51
|
+
function Normalize-Dir {
|
|
52
|
+
param([string]$Path)
|
|
53
|
+
if ([string]::IsNullOrWhiteSpace($Path)) { return "" }
|
|
54
|
+
$trimmed = $Path.TrimEnd('\', '/')
|
|
55
|
+
# GetFullPath expands an 8.3 short component (ZOLANI~1) to its long form;
|
|
56
|
+
# Resolve-Path does NOT (verified on this machine), so it cannot be relied
|
|
57
|
+
# on for that. Both are best-effort: a path that cannot be expanded falls
|
|
58
|
+
# through to the trimmed, lowercased comparison.
|
|
59
|
+
try { $trimmed = [System.IO.Path]::GetFullPath($trimmed).TrimEnd('\', '/') } catch { }
|
|
60
|
+
return $trimmed.ToLowerInvariant()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Resolve-CanonicalDir {
|
|
64
|
+
param(
|
|
65
|
+
[string]$Recorded,
|
|
66
|
+
[string]$Default,
|
|
67
|
+
[scriptblock]$PathExists = { param($p) Test-Path -LiteralPath $p }
|
|
68
|
+
)
|
|
69
|
+
if ([string]::IsNullOrWhiteSpace($Recorded)) {
|
|
70
|
+
return [PSCustomObject]@{ Dir = $Default; Source = "default" }
|
|
71
|
+
}
|
|
72
|
+
$trimmed = $Recorded.TrimEnd('\')
|
|
73
|
+
if (-not (& $PathExists $trimmed)) {
|
|
74
|
+
return [PSCustomObject]@{ Dir = $Default; Source = "stale-registry" }
|
|
75
|
+
}
|
|
76
|
+
return [PSCustomObject]@{ Dir = $trimmed; Source = "registry" }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# SAFETY (review of 8da2b2e, CRITICAL): -RecordedInstallLocation and
|
|
80
|
+
# -CanonicalOverrideForTest are ordinary parameters on the SHIPPED script, so
|
|
81
|
+
# they are reachable through exactly the `-File` invocation surface Inno and
|
|
82
|
+
# the shortcuts use. As first written they fed the canonical decision directly,
|
|
83
|
+
# which let a stray copy pass -CanonicalOverrideForTest <its own dir> and reach
|
|
84
|
+
# `docker compose -p culpa down` and the culpa_pgdata prompt with NO -Force and
|
|
85
|
+
# none of the -Force warnings. Proven by probe: wouldProceed:true, forced:false.
|
|
86
|
+
# That is the precise scenario CR-PR5 round 2 exists to prevent, and the
|
|
87
|
+
# -Probe precedent cited above does not excuse it: that one is a parameter of
|
|
88
|
+
# an INTERNAL FUNCTION, reachable only by dot-sourcing, never a top-level flag.
|
|
89
|
+
#
|
|
90
|
+
# They are therefore honoured ONLY under -PrintPlanOnly, which cannot touch
|
|
91
|
+
# docker, the data volume, the registry or any file. In a run that could do
|
|
92
|
+
# something destructive they are ignored outright, so the canonical decision
|
|
93
|
+
# always comes from the real registry or the real default.
|
|
94
|
+
$InjectionRequested = $PSBoundParameters.ContainsKey('RecordedInstallLocation') -or
|
|
95
|
+
-not [string]::IsNullOrWhiteSpace($CanonicalOverrideForTest)
|
|
96
|
+
$InjectionHonoured = $InjectionRequested -and $PrintPlanOnly.IsPresent
|
|
97
|
+
|
|
98
|
+
if ($InjectionHonoured -and $PSBoundParameters.ContainsKey('RecordedInstallLocation')) {
|
|
99
|
+
$Recorded = $RecordedInstallLocation
|
|
100
|
+
} else {
|
|
101
|
+
$Recorded = (Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1" -Name InstallLocation -ErrorAction SilentlyContinue).InstallLocation
|
|
102
|
+
}
|
|
103
|
+
$DefaultDir = if (-not $InjectionHonoured -or [string]::IsNullOrWhiteSpace($CanonicalOverrideForTest)) {
|
|
104
|
+
Join-Path $env:LOCALAPPDATA "Culpa"
|
|
105
|
+
} else {
|
|
106
|
+
$CanonicalOverrideForTest.TrimEnd('\')
|
|
107
|
+
}
|
|
108
|
+
$Resolved = Resolve-CanonicalDir -Recorded $Recorded -Default $DefaultDir
|
|
109
|
+
$Canonical = $Resolved.Dir
|
|
110
|
+
$ComposeDst = Join-Path $AppDir "docker-compose.yml"
|
|
111
|
+
|
|
112
|
+
$Matches_ = ((Normalize-Dir $AppDir) -eq (Normalize-Dir $Canonical))
|
|
113
|
+
$WouldProceed = ($Matches_ -or $Force.IsPresent)
|
|
114
|
+
|
|
115
|
+
if ($PrintPlanOnly) {
|
|
116
|
+
[PSCustomObject]@{
|
|
117
|
+
appDir = $AppDir
|
|
118
|
+
canonical = $Canonical
|
|
119
|
+
canonicalSource = $Resolved.Source
|
|
120
|
+
wouldProceed = $WouldProceed
|
|
121
|
+
forced = ($Force.IsPresent -and -not $Matches_)
|
|
122
|
+
# so plan output can never be mistaken for a real registry read
|
|
123
|
+
injected = $InjectionHonoured
|
|
124
|
+
} | ConvertTo-Json -Compress
|
|
125
|
+
exit 0
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (-not $WouldProceed) {
|
|
129
|
+
# non-canonical copy: touch NOTHING (no docker, no volume, no files)
|
|
130
|
+
Write-Host "This copy is not the installed one - nothing was changed." -ForegroundColor Yellow
|
|
131
|
+
Write-Host "To uninstall Culpa: Windows Settings -> Installed apps -> Culpa,"
|
|
132
|
+
Write-Host "or run uninstall-culpa.ps1 in $Canonical."
|
|
133
|
+
if ($Resolved.Source -eq "stale-registry") {
|
|
134
|
+
Write-Host "(The recorded install location no longer exists, so the default was used.)"
|
|
135
|
+
}
|
|
136
|
+
# T-CF29-2: never leave the user with no way out. If the canonical
|
|
137
|
+
# directory is wrong or gone, this is the escape hatch — explicit, so it
|
|
138
|
+
# can never fire by accident on a stray copy.
|
|
139
|
+
Write-Host "If Culpa really is installed HERE, re-run with -Force:" -ForegroundColor Yellow
|
|
140
|
+
Write-Host " powershell -ExecutionPolicy Bypass -File `"$AppDir\uninstall-culpa.ps1`" -Force"
|
|
141
|
+
if (-not $FromUninstaller) { Read-Host "Press Enter to close" }
|
|
142
|
+
exit 0
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if ($Force.IsPresent -and -not $Matches_) {
|
|
146
|
+
Write-Host "-Force: uninstalling from $AppDir even though the recorded location is $Canonical." -ForegroundColor Yellow
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# CF19 (CodeRabbit PR#16 #289): collector shutdown runs AFTER the
|
|
150
|
+
# canonical-path guard — a stray COPY of this script must not change the real
|
|
151
|
+
# installation, and stopping its collector is a change. Stopping capture is
|
|
152
|
+
# otherwise always safe, so it precedes the container teardown.
|
|
153
|
+
$CollectorCtl = Join-Path $AppDir "culpa-collector.ps1"
|
|
154
|
+
if (Test-Path $CollectorCtl) { & $CollectorCtl -Stop }
|
|
155
|
+
|
|
156
|
+
Write-Host "==> Stopping Culpa"
|
|
157
|
+
# CR-PR5: judge the native exit code explicitly (SilentlyContinue does NOT
|
|
158
|
+
# catch it), and NEVER infer "stopped" from a missing compose file - a prior
|
|
159
|
+
# failed cleanup can leave containers running. Absent file => ask docker.
|
|
160
|
+
$stackStopped = $false
|
|
161
|
+
# CR-PR5 r4: check docker EXPLICITLY. SilentlyContinue swallows a missing
|
|
162
|
+
# executable and leaves $LASTEXITCODE at its PRIOR value - verified on
|
|
163
|
+
# Windows PowerShell 5.1: after any earlier successful native command that
|
|
164
|
+
# value is 0, so the old implicit test would have read "stopped" having
|
|
165
|
+
# stopped nothing, and deleted files while containers ran. Fail-closed now
|
|
166
|
+
# by construction, not by the accident of call ordering.
|
|
167
|
+
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
|
168
|
+
Write-Host "Docker was not found on PATH - cannot confirm Culpa is stopped." -ForegroundColor Yellow
|
|
169
|
+
} elseif (Test-Path $ComposeDst) {
|
|
170
|
+
docker compose -f $ComposeDst -p culpa down
|
|
171
|
+
$stackStopped = ($LASTEXITCODE -eq 0)
|
|
172
|
+
} else {
|
|
173
|
+
Write-Host "No compose file here - checking for running Culpa containers directly."
|
|
174
|
+
docker compose -p culpa down
|
|
175
|
+
$stackStopped = ($LASTEXITCODE -eq 0)
|
|
176
|
+
}
|
|
177
|
+
if (-not $stackStopped) {
|
|
178
|
+
Write-Host "Culpa did not stop cleanly - leaving files in place so you can retry:" -ForegroundColor Yellow
|
|
179
|
+
Write-Host " docker compose -p culpa down"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
Write-Host "==> Removing shortcuts"
|
|
183
|
+
Remove-Item (Join-Path ([Environment]::GetFolderPath("Desktop")) "Culpa.lnk") -Force
|
|
184
|
+
Remove-Item (Join-Path ([Environment]::GetFolderPath("Desktop")) "Culpa Dashboard.url") -Force
|
|
185
|
+
Remove-Item (Join-Path ([Environment]::GetFolderPath("StartMenu")) "Programs\Culpa") -Recurse -Force
|
|
186
|
+
|
|
187
|
+
if (-not $FromUninstaller) {
|
|
188
|
+
$wipe = Read-Host "Also DELETE all recorded cost data? This cannot be undone. (y/N)"
|
|
189
|
+
if ($wipe -eq "y" -or $wipe -eq "Y") {
|
|
190
|
+
docker volume rm culpa_pgdata
|
|
191
|
+
Write-Host "Data volume removed."
|
|
192
|
+
} else {
|
|
193
|
+
Write-Host "Data kept (volume culpa_pgdata). Reinstalling later will find it again."
|
|
194
|
+
}
|
|
195
|
+
if ($stackStopped) {
|
|
196
|
+
$unins = Join-Path $AppDir "unins000.exe"
|
|
197
|
+
if (Test-Path $unins) {
|
|
198
|
+
# exe install: hand off so the Windows Settings entry is cleaned too
|
|
199
|
+
Start-Process $unins "/VERYSILENT"
|
|
200
|
+
} else {
|
|
201
|
+
# T-171 script install: WE own the Settings entry, so we remove it.
|
|
202
|
+
# An exe install never reaches here - Inno owns and removes its own
|
|
203
|
+
# key, and deleting it from this side would strand the wizard with
|
|
204
|
+
# a listing it can no longer clean up.
|
|
205
|
+
Remove-Item -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1" -Recurse -Force
|
|
206
|
+
Remove-Item $AppDir -Recurse -Force
|
|
207
|
+
}
|
|
208
|
+
Write-Host "Culpa removed."
|
|
209
|
+
} else {
|
|
210
|
+
Write-Host "Files kept because the stack is still running - stop it, then run this again."
|
|
211
|
+
}
|
|
212
|
+
Read-Host "Press Enter to close"
|
|
213
|
+
} else {
|
|
214
|
+
# setup.exe's uninstaller removes the installed files itself; we only
|
|
215
|
+
# clean up what install-culpa.ps1 created at runtime - and ONLY when the
|
|
216
|
+
# stack actually stopped
|
|
217
|
+
# CR-PR5 round 3: SIGNAL the failure. Printing alone let Inno delete the
|
|
218
|
+
# scripts while containers were still running; culpa-setup.iss now runs
|
|
219
|
+
# this from InitializeUninstall and CANCELS the uninstall on a nonzero
|
|
220
|
+
# exit, so the files stay put and the retry is possible.
|
|
221
|
+
if (-not $stackStopped) {
|
|
222
|
+
Write-Host "Refusing to remove Culpa while its containers are running." -ForegroundColor Red
|
|
223
|
+
exit 1
|
|
224
|
+
}
|
|
225
|
+
Remove-Item $ComposeDst -Force
|
|
226
|
+
Write-Host "Data kept (volume culpa_pgdata). Reinstalling later will find it again."
|
|
227
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Unlike docker.d.mts's SpawnSyncLike, this one carries `error`: a spawn that
|
|
2
|
+
// never ran (missing binary, timeout kill) reports it there, not via status.
|
|
3
|
+
export type LauncherSpawnSync = (
|
|
4
|
+
cmd: string,
|
|
5
|
+
args?: string[],
|
|
6
|
+
opts?: Record<string, unknown>,
|
|
7
|
+
) => { status: number | null; error?: Error };
|
|
8
|
+
|
|
9
|
+
export const PAYLOAD_STAGE_TIMEOUT_MS: number;
|
|
10
|
+
|
|
11
|
+
export function vendoredLauncherPath(packageRoot: string, platform?: string): string | null;
|
|
12
|
+
|
|
13
|
+
export interface StageInitialPayloadOptions {
|
|
14
|
+
launcherPath: string | null;
|
|
15
|
+
spawnSync?: LauncherSpawnSync;
|
|
16
|
+
log?: (msg: string) => void;
|
|
17
|
+
env?: Record<string, string | undefined>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StageInitialPayloadResult {
|
|
21
|
+
staged: boolean;
|
|
22
|
+
reason: "no-launcher" | "spawn-failed" | "update-failed" | "staged";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stageInitialPayload(opts: StageInitialPayloadOptions): StageInitialPayloadResult;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// T-CF28-4b — stage the CLI payload during `npm i -g getculpa`.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT. A fresh install fetched the LAUNCHER (lib/fetch.mjs) but never
|
|
4
|
+
// the PAYLOAD the launcher runs. `state.json` was left with `current: null`,
|
|
5
|
+
// so the very first `getculpa` reached
|
|
6
|
+
// native/culpa-launcher/src/main.rs:79-88, found nothing to exec, and printed
|
|
7
|
+
// the no-payload message. The Mac tester had to work out `getculpa update` for
|
|
8
|
+
// themselves and then run a SECOND command before anything happened. An
|
|
9
|
+
// install that leaves nothing runnable is not an install.
|
|
10
|
+
//
|
|
11
|
+
// WHY THIS IS NOT AN UNCONSENTED UPDATE. `culpa update` is an explicit,
|
|
12
|
+
// user-initiated verb and is not consent-gated: consent gates the BACKGROUND
|
|
13
|
+
// check (`cli.rs:421-452`, `run_background_check` `cli.rs:316-318`). The user
|
|
14
|
+
// ran `npm i -g getculpa`; downloading the program they just asked to install
|
|
15
|
+
// is completing that install. The auto-update consent model is untouched, and
|
|
16
|
+
// nothing here records a preference on the user's behalf.
|
|
17
|
+
//
|
|
18
|
+
// WHY IT SHELLS OUT RATHER THAN DOWNLOADING. The launcher already owns a
|
|
19
|
+
// verified path: manifest fetch, sha256 verification, self-test, atomic
|
|
20
|
+
// promotion. A second downloader in Node would be a second trust boundary to
|
|
21
|
+
// audit and keep in step. This calls the one that already exists.
|
|
22
|
+
//
|
|
23
|
+
// EVERY FAILURE IS SOFT. Offline, a stalled mirror, a missing binary — none of
|
|
24
|
+
// them fail the install. They leave the payload deferred, exactly as before
|
|
25
|
+
// this module existed, and lib/install-summary.mjs says so and names the
|
|
26
|
+
// command. The install never claims more than it did.
|
|
27
|
+
|
|
28
|
+
import { spawnSync as realSpawnSync } from "node:child_process";
|
|
29
|
+
import { existsSync } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
// A real payload is tens of MB over a network Culpa does not control. Long
|
|
33
|
+
// enough not to abort a slow-but-working download; short enough that a hung
|
|
34
|
+
// mirror degrades to a clear deferred message instead of hanging `npm install`
|
|
35
|
+
// indefinitely, which is the failure mode that matters — npm gives no progress
|
|
36
|
+
// output of its own here.
|
|
37
|
+
export const PAYLOAD_STAGE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
// Where lib/fetch.mjs puts the launcher (see stageStubLauncher in
|
|
40
|
+
// scripts/install.js for the same naming: `culpa-launcher` + platform suffix).
|
|
41
|
+
export function vendoredLauncherPath(packageRoot, platform = process.platform) {
|
|
42
|
+
const exe = platform === "win32" ? ".exe" : "";
|
|
43
|
+
const candidate = path.join(packageRoot, "vendor", `culpa-launcher${exe}`);
|
|
44
|
+
return existsSync(candidate) ? candidate : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function stageInitialPayload(opts) {
|
|
48
|
+
const { launcherPath, spawnSync = realSpawnSync, log = console.log, env = process.env } = opts;
|
|
49
|
+
|
|
50
|
+
// No launcher means the fetch above already failed and said so; there is
|
|
51
|
+
// nothing to invoke and nothing further worth reporting here.
|
|
52
|
+
if (!launcherPath) return { staged: false, reason: "no-launcher" };
|
|
53
|
+
|
|
54
|
+
log("getculpa: downloading the Culpa CLI (this is the program `getculpa` runs).");
|
|
55
|
+
const result = spawnSync(launcherPath, ["update"], {
|
|
56
|
+
stdio: "inherit",
|
|
57
|
+
timeout: PAYLOAD_STAGE_TIMEOUT_MS,
|
|
58
|
+
// Inherit the install's own environment so CULPA_HOME / CULPA_APP_DIR
|
|
59
|
+
// resolve to the SAME root scripts/install.js provisioned into (D-108
|
|
60
|
+
// unified that chain across the launcher and the Node CLI).
|
|
61
|
+
env,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (result.error) {
|
|
65
|
+
log(`getculpa: could not download the Culpa CLI (${result.error.message}) - run \`getculpa update\` to finish.`);
|
|
66
|
+
return { staged: false, reason: "spawn-failed" };
|
|
67
|
+
}
|
|
68
|
+
if (result.status !== 0) {
|
|
69
|
+
log("getculpa: could not download the Culpa CLI - run `getculpa update` to finish.");
|
|
70
|
+
return { staged: false, reason: "update-failed" };
|
|
71
|
+
}
|
|
72
|
+
return { staged: true, reason: "staged" };
|
|
73
|
+
}
|
package/lib/docker.d.mts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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 type DarwinDockerInstallOutcome = "installed" | "no-brew" | `failed:${string}`;
|
|
24
|
+
|
|
25
|
+
export function installDockerDarwin(spawnSync?: SpawnSyncLike): DarwinDockerInstallOutcome;
|
|
26
|
+
|
|
27
|
+
export function startEngineIfPossible(
|
|
28
|
+
platform: string,
|
|
29
|
+
spawnSync?: SpawnSyncLike,
|
|
30
|
+
log?: (msg: string) => void,
|
|
31
|
+
): { attempted: boolean; ok: boolean };
|
|
32
|
+
|
|
33
|
+
export function isProjectRunning(spawnSync?: SpawnSyncLike): boolean;
|
|
34
|
+
|
|
35
|
+
export type FetchLike = typeof fetch;
|
|
36
|
+
|
|
37
|
+
export function probeHealth(url: string, opts?: { fetchFn?: FetchLike }): Promise<boolean>;
|
|
38
|
+
|
|
39
|
+
export function waitForHealth(
|
|
40
|
+
url: string,
|
|
41
|
+
opts?: { maxWaitMs?: number; intervalMs?: number; sleepFn?: (ms: number) => Promise<void>; fetchFn?: FetchLike },
|
|
42
|
+
): Promise<boolean>;
|
|
43
|
+
|
|
44
|
+
export function dbContainerExists(spawnSync?: SpawnSyncLike): boolean;
|
|
45
|
+
|
|
46
|
+
export function waitForPgReady(opts?: {
|
|
47
|
+
spawnSync?: SpawnSyncLike;
|
|
48
|
+
maxWaitMs?: number;
|
|
49
|
+
intervalMs?: number;
|
|
50
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
51
|
+
}): Promise<boolean>;
|
|
52
|
+
|
|
53
|
+
export function resolveBackupBaseline(recorded: string | null | undefined, spawnSync?: SpawnSyncLike): string;
|
|
54
|
+
|
|
55
|
+
export function isBackupNeeded(
|
|
56
|
+
dbExists: boolean,
|
|
57
|
+
pinnedVersion: string | null | undefined,
|
|
58
|
+
baseline: string | null | undefined,
|
|
59
|
+
): boolean;
|
|
60
|
+
|
|
61
|
+
export function preMigrationBackup(opts: {
|
|
62
|
+
backupDir: string;
|
|
63
|
+
tag: string;
|
|
64
|
+
spawnSync?: SpawnSyncLike;
|
|
65
|
+
now?: Date;
|
|
66
|
+
}): string;
|
|
67
|
+
|
|
68
|
+
export interface PortOwner {
|
|
69
|
+
pid: number;
|
|
70
|
+
name: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function findPortOwner(
|
|
74
|
+
port: number,
|
|
75
|
+
opts?: { platform?: string; spawnSync?: SpawnSyncLike },
|
|
76
|
+
): PortOwner | null;
|