getculpa 1.0.7 → 1.0.10

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 CHANGED
@@ -5,7 +5,11 @@ money went, across models, features and users, on your own machine.
5
5
 
6
6
  **`npm i -g getculpa` provisions the full Culpa install — the same files,
7
7
  shortcuts, and registration the Windows installer lays down — and starts
8
- nothing.** `getculpa` is what wakes the stack up.
8
+ nothing.** `getculpa` is what wakes the stack up. On npm 12 or newer, npm
9
+ blocks install scripts by default: install with
10
+ `npm i -g --allow-scripts=getculpa getculpa` (or run
11
+ `npm config set allow-scripts=getculpa --location=user` once). If `getculpa`
12
+ says Culpa was never provisioned, that is why.
9
13
 
10
14
  - `getculpa` / `getculpa start` — start (or resume) the Culpa stack
11
15
  - `getculpa stop` — stop the running stack (`docker compose stop`; recorded data is kept)
@@ -14,7 +18,7 @@ nothing.** `getculpa` is what wakes the stack up.
14
18
  - `getculpa doctor` — diagnose this install (version/platform, file integrity, Docker, container state, ports, health, license presence — never prints key material); exits 1 if a check fails
15
19
  - `getculpa repair` — re-stage any missing/corrupt install files (never touches an existing `docker-compose.yml` or recorded data); idempotent
16
20
  - `getculpa uninstall` — remove Culpa (recorded data is kept unless you confirm)
17
- - `getculpa scan` — zero-install local scan, no Docker, no network
21
+ - `getculpa scan` — refresh the Culpa ledger scan (needs the running stack); `getculpa scan --preview` reads local logs offline: no Docker, no network
18
22
  - `getculpa connect` — print the topology-appropriate capture recipe (`--fly` also emits a ready-to-deploy Fly.io shape)
19
23
  - `getculpa update` — manage CLI launcher updates
20
24
 
@@ -5,7 +5,7 @@
5
5
  # name is used when present, which is how these pins were verified before
6
6
  # publication.
7
7
  #
8
- # ── RELEASE STATE: v1.0.7 PUBLISHED AND SIGNED (2026-08-29) ─────────
8
+ # ── RELEASE STATE: v1.0.10 PUBLISHED AND SIGNED (2026-09-05) ────────
9
9
  # The line above is REWRITTEN BY installers/publish.sh at real-publish time —
10
10
  # this file has shipped a hand-edited, wrong publication claim twice, so the
11
11
  # claim is now mechanical, never prose. While it reads NOT PUBLISHED, the pins
@@ -55,7 +55,7 @@ services:
55
55
  restart: unless-stopped
56
56
 
57
57
  server:
58
- image: ghcr.io/myaigidev/culpa-server:v1.0.7
58
+ image: ghcr.io/myaigidev/culpa-server:v1.0.10
59
59
  container_name: culpa-server
60
60
  environment:
61
61
  DATABASE_URL: postgres://culpa:culpa@db:5432/culpa
@@ -129,7 +129,7 @@ services:
129
129
  # (D-072: never rebuild a tag that already exists). The example this note
130
130
  # used to give was CF14's v0.11.0/v0.11.1 pair, which no longer matches
131
131
  # either pin in this file.
132
- image: ghcr.io/myaigidev/culpa-dashboard:v1.0.7
132
+ image: ghcr.io/myaigidev/culpa-dashboard:v1.0.10
133
133
  container_name: culpa-dashboard
134
134
  environment:
135
135
  CULPA_API_BASE: http://server:4545
@@ -1,227 +1,251 @@
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
- }
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). Inno
6
+ # removes the installed files and its own Add/Remove key itself, so this mode
7
+ # only cleans the runtime compose file and refuses (exit 1) while running.
8
+ # -Unattended: run by the npm `preuninstall` hook (D-126) - ALSO non-interactive
9
+ # and data-always-kept, but there is NO Inno wizard to clean up after us, so this
10
+ # mode removes the app dir and the (script-install) Add/Remove key itself.
11
+ # Exactly one of these is set by a non-interactive caller; a human CLI run sets
12
+ # neither and gets the prompts.
13
+ #
14
+ # CR-PR5 round 2 (SAFETY ORDERING): the canonical-path check now gates EVERY
15
+ # destructive action - docker down, the data-volume prompt, and file removal.
16
+ # A copy of this script run from anywhere else previously could stop the real
17
+ # `culpa` project and offer to delete culpa_pgdata, the shared data volume.
18
+ # T-CF29-2 params:
19
+ # -Force proceed from THIS directory even when the
20
+ # canonical check disagrees (explicit opt-in;
21
+ # never automatic)
22
+ # -RecordedInstallLocation inject what the registry would return, so the
23
+ # resolution can be tested without touching HKCU
24
+ # (same -Probe precedent as install-culpa.ps1)
25
+ # -CanonicalOverrideForTest inject the fallback directory, for the same reason
26
+ # -PrintPlanOnly resolve, print the decision as JSON, exit 0
27
+ # BEFORE any destructive action
28
+ param(
29
+ [switch]$FromUninstaller,
30
+ [switch]$Force,
31
+ [string]$RecordedInstallLocation,
32
+ [string]$CanonicalOverrideForTest,
33
+ [switch]$PrintPlanOnly,
34
+ [switch]$Unattended
35
+ )
36
+
37
+ $ErrorActionPreference = "SilentlyContinue"
38
+ $AppDir = $PSScriptRoot
39
+ # A human CLI uninstall is the only INTERACTIVE caller: both -FromUninstaller
40
+ # (Inno) and -Unattended (npm preuninstall hook) run without prompts. Every
41
+ # Read-Host below is gated on this so a non-interactive caller can never block.
42
+ $Interactive = (-not $FromUninstaller) -and (-not $Unattended)
43
+ # CF5-F11: the install directory is the founder's choice now, so "canonical"
44
+ # is what SETUP RECORDED, not a hardcoded path. Inno writes InstallLocation
45
+ # into its own uninstall key; fall back to %LOCALAPPDATA%\Culpa for installs
46
+ # made before the directory page existed, and for the zip layout.
47
+ # The guard itself is unchanged and still load-bearing: a stray COPY of this
48
+ # script must never stop containers or delete files belonging to the real
49
+ # install.
50
+ #
51
+ # T-CF29-2: a recorded value that POINTS AT A DELETED DIRECTORY used to be
52
+ # trusted anyway, so the guard below could never match and Culpa became
53
+ # impossible to uninstall. That is not hypothetical — the CF20 npm gate
54
+ # overwrote this shared value with a scratch dir which was then removed.
55
+ # A path that does not exist cannot be shadowing a real install, so it is
56
+ # ignored in favour of the default. Everything else about the guard stands.
57
+ # T-CF29-2: the guard compared raw strings, so two spellings of the SAME
58
+ # directory read as different installs — an 8.3 short path (C:\Users\ZOLANI~1)
59
+ # versus its long form, a trailing backslash, or different casing. Each of
60
+ # those turns the uninstaller into the same dead end a stale registry value
61
+ # does. Resolve-Path returns the canonical long form for a path that exists;
62
+ # a path that does not exist falls back to a trimmed, lowercased comparison.
63
+ function Normalize-Dir {
64
+ param([string]$Path)
65
+ if ([string]::IsNullOrWhiteSpace($Path)) { return "" }
66
+ $trimmed = $Path.TrimEnd('\', '/')
67
+ # GetFullPath expands an 8.3 short component (ZOLANI~1) to its long form;
68
+ # Resolve-Path does NOT (verified on this machine), so it cannot be relied
69
+ # on for that. Both are best-effort: a path that cannot be expanded falls
70
+ # through to the trimmed, lowercased comparison.
71
+ try { $trimmed = [System.IO.Path]::GetFullPath($trimmed).TrimEnd('\', '/') } catch { }
72
+ return $trimmed.ToLowerInvariant()
73
+ }
74
+
75
+ function Resolve-CanonicalDir {
76
+ param(
77
+ [string]$Recorded,
78
+ [string]$Default,
79
+ [scriptblock]$PathExists = { param($p) Test-Path -LiteralPath $p }
80
+ )
81
+ if ([string]::IsNullOrWhiteSpace($Recorded)) {
82
+ return [PSCustomObject]@{ Dir = $Default; Source = "default" }
83
+ }
84
+ $trimmed = $Recorded.TrimEnd('\')
85
+ if (-not (& $PathExists $trimmed)) {
86
+ return [PSCustomObject]@{ Dir = $Default; Source = "stale-registry" }
87
+ }
88
+ return [PSCustomObject]@{ Dir = $trimmed; Source = "registry" }
89
+ }
90
+
91
+ # SAFETY (review of 8da2b2e, CRITICAL): -RecordedInstallLocation and
92
+ # -CanonicalOverrideForTest are ordinary parameters on the SHIPPED script, so
93
+ # they are reachable through exactly the `-File` invocation surface Inno and
94
+ # the shortcuts use. As first written they fed the canonical decision directly,
95
+ # which let a stray copy pass -CanonicalOverrideForTest <its own dir> and reach
96
+ # `docker compose -p culpa down` and the culpa_pgdata prompt with NO -Force and
97
+ # none of the -Force warnings. Proven by probe: wouldProceed:true, forced:false.
98
+ # That is the precise scenario CR-PR5 round 2 exists to prevent, and the
99
+ # -Probe precedent cited above does not excuse it: that one is a parameter of
100
+ # an INTERNAL FUNCTION, reachable only by dot-sourcing, never a top-level flag.
101
+ #
102
+ # They are therefore honoured ONLY under -PrintPlanOnly, which cannot touch
103
+ # docker, the data volume, the registry or any file. In a run that could do
104
+ # something destructive they are ignored outright, so the canonical decision
105
+ # always comes from the real registry or the real default.
106
+ $InjectionRequested = $PSBoundParameters.ContainsKey('RecordedInstallLocation') -or
107
+ -not [string]::IsNullOrWhiteSpace($CanonicalOverrideForTest)
108
+ $InjectionHonoured = $InjectionRequested -and $PrintPlanOnly.IsPresent
109
+
110
+ if ($InjectionHonoured -and $PSBoundParameters.ContainsKey('RecordedInstallLocation')) {
111
+ $Recorded = $RecordedInstallLocation
112
+ } else {
113
+ $Recorded = (Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1" -Name InstallLocation -ErrorAction SilentlyContinue).InstallLocation
114
+ }
115
+ $DefaultDir = if (-not $InjectionHonoured -or [string]::IsNullOrWhiteSpace($CanonicalOverrideForTest)) {
116
+ Join-Path $env:LOCALAPPDATA "Culpa"
117
+ } else {
118
+ $CanonicalOverrideForTest.TrimEnd('\')
119
+ }
120
+ $Resolved = Resolve-CanonicalDir -Recorded $Recorded -Default $DefaultDir
121
+ $Canonical = $Resolved.Dir
122
+ $ComposeDst = Join-Path $AppDir "docker-compose.yml"
123
+
124
+ $Matches_ = ((Normalize-Dir $AppDir) -eq (Normalize-Dir $Canonical))
125
+ $WouldProceed = ($Matches_ -or $Force.IsPresent)
126
+
127
+ if ($PrintPlanOnly) {
128
+ [PSCustomObject]@{
129
+ appDir = $AppDir
130
+ canonical = $Canonical
131
+ canonicalSource = $Resolved.Source
132
+ wouldProceed = $WouldProceed
133
+ forced = ($Force.IsPresent -and -not $Matches_)
134
+ # so plan output can never be mistaken for a real registry read
135
+ injected = $InjectionHonoured
136
+ } | ConvertTo-Json -Compress
137
+ exit 0
138
+ }
139
+
140
+ if (-not $WouldProceed) {
141
+ # non-canonical copy: touch NOTHING (no docker, no volume, no files)
142
+ Write-Host "This copy is not the installed one - nothing was changed." -ForegroundColor Yellow
143
+ Write-Host "To uninstall Culpa: Windows Settings -> Installed apps -> Culpa,"
144
+ Write-Host "or run uninstall-culpa.ps1 in $Canonical."
145
+ if ($Resolved.Source -eq "stale-registry") {
146
+ Write-Host "(The recorded install location no longer exists, so the default was used.)"
147
+ }
148
+ # T-CF29-2: never leave the user with no way out. If the canonical
149
+ # directory is wrong or gone, this is the escape hatch — explicit, so it
150
+ # can never fire by accident on a stray copy.
151
+ Write-Host "If Culpa really is installed HERE, re-run with -Force:" -ForegroundColor Yellow
152
+ Write-Host " powershell -ExecutionPolicy Bypass -File `"$AppDir\uninstall-culpa.ps1`" -Force"
153
+ if ($Interactive) { Read-Host "Press Enter to close" }
154
+ exit 0
155
+ }
156
+
157
+ if ($Force.IsPresent -and -not $Matches_) {
158
+ Write-Host "-Force: uninstalling from $AppDir even though the recorded location is $Canonical." -ForegroundColor Yellow
159
+ }
160
+
161
+ # CF19 (CodeRabbit PR#16 #289): collector shutdown runs AFTER the
162
+ # canonical-path guard — a stray COPY of this script must not change the real
163
+ # installation, and stopping its collector is a change. Stopping capture is
164
+ # otherwise always safe, so it precedes the container teardown.
165
+ $CollectorCtl = Join-Path $AppDir "culpa-collector.ps1"
166
+ if (Test-Path $CollectorCtl) { & $CollectorCtl -Stop }
167
+
168
+ Write-Host "==> Stopping Culpa"
169
+ # CR-PR5: judge the native exit code explicitly (SilentlyContinue does NOT
170
+ # catch it), and NEVER infer "stopped" from a missing compose file - a prior
171
+ # failed cleanup can leave containers running. Absent file => ask docker.
172
+ $stackStopped = $false
173
+ # CR-PR5 r4: check docker EXPLICITLY. SilentlyContinue swallows a missing
174
+ # executable and leaves $LASTEXITCODE at its PRIOR value - verified on
175
+ # Windows PowerShell 5.1: after any earlier successful native command that
176
+ # value is 0, so the old implicit test would have read "stopped" having
177
+ # stopped nothing, and deleted files while containers ran. Fail-closed now
178
+ # by construction, not by the accident of call ordering.
179
+ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
180
+ Write-Host "Docker was not found on PATH - cannot confirm Culpa is stopped." -ForegroundColor Yellow
181
+ } elseif (Test-Path $ComposeDst) {
182
+ docker compose -f $ComposeDst -p culpa down
183
+ $stackStopped = ($LASTEXITCODE -eq 0)
184
+ } else {
185
+ Write-Host "No compose file here - checking for running Culpa containers directly."
186
+ docker compose -p culpa down
187
+ $stackStopped = ($LASTEXITCODE -eq 0)
188
+ }
189
+ if (-not $stackStopped) {
190
+ Write-Host "Culpa did not stop cleanly - leaving files in place so you can retry:" -ForegroundColor Yellow
191
+ Write-Host " docker compose -p culpa down"
192
+ }
193
+
194
+ Write-Host "==> Removing shortcuts"
195
+ Remove-Item (Join-Path ([Environment]::GetFolderPath("Desktop")) "Culpa.lnk") -Force
196
+ Remove-Item (Join-Path ([Environment]::GetFolderPath("Desktop")) "Culpa Dashboard.url") -Force
197
+ Remove-Item (Join-Path ([Environment]::GetFolderPath("StartMenu")) "Programs\Culpa") -Recurse -Force
198
+
199
+ # The data-wipe question is the ONLY place data can be deleted, and it is
200
+ # offered ONLY to an interactive human. Both non-interactive callers keep data.
201
+ if ($Interactive) {
202
+ $wipe = Read-Host "Also DELETE all recorded cost data? This cannot be undone. (y/N)"
203
+ if ($wipe -eq "y" -or $wipe -eq "Y") {
204
+ docker volume rm culpa_pgdata
205
+ Write-Host "Data volume removed."
206
+ } else {
207
+ Write-Host "Data kept (volume culpa_pgdata). Reinstalling later will find it again."
208
+ }
209
+ }
210
+
211
+ if ($FromUninstaller) {
212
+ # setup.exe's uninstaller removes the installed files AND its own Add/Remove
213
+ # key itself; we only clean up the runtime compose file - and ONLY when the
214
+ # stack actually stopped.
215
+ # CR-PR5 round 3: SIGNAL the failure. Printing alone let Inno delete the
216
+ # scripts while containers were still running; culpa-setup.iss now runs
217
+ # this from InitializeUninstall and CANCELS the uninstall on a nonzero
218
+ # exit, so the files stay put and the retry is possible.
219
+ if (-not $stackStopped) {
220
+ Write-Host "Refusing to remove Culpa while its containers are running." -ForegroundColor Red
221
+ exit 1
222
+ }
223
+ Remove-Item $ComposeDst -Force
224
+ Write-Host "Data kept (volume culpa_pgdata). Reinstalling later will find it again."
225
+ } else {
226
+ # An interactive CLI uninstall OR the npm preuninstall hook (-Unattended):
227
+ # there is NO Inno wizard to clean up after us, so WE remove the install dir
228
+ # and the (script-install) Add/Remove key - but ONLY when the stack stopped.
229
+ if (-not $stackStopped) {
230
+ Write-Host "Files kept because the stack is still running - stop it, then run this again."
231
+ # -Unattended (npm): nothing was removed, so report failure (exit 1) —
232
+ # lib/uninstall.mjs reads this and the hook then warns loudly; npm still
233
+ # removes the CLI. A human sees the message above and can retry.
234
+ if ($Unattended) { exit 1 }
235
+ } else {
236
+ $unins = Join-Path $AppDir "unins000.exe"
237
+ if (Test-Path $unins) {
238
+ # exe install: hand off so the Windows Settings entry is cleaned too
239
+ Start-Process $unins "/VERYSILENT"
240
+ } else {
241
+ # T-171 script/npm install: WE own the Settings entry, so we remove it.
242
+ # An exe install never reaches here - Inno owns and removes its own
243
+ # key, and deleting it from this side would strand the wizard with
244
+ # a listing it can no longer clean up.
245
+ Remove-Item -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1" -Recurse -Force
246
+ Remove-Item $AppDir -Recurse -Force
247
+ }
248
+ Write-Host "Culpa removed."
249
+ }
250
+ if ($Interactive) { Read-Host "Press Enter to close" }
251
+ }
package/bin/getculpa.js CHANGED
@@ -25,9 +25,11 @@ Usage:
25
25
  getculpa doctor Diagnose this install
26
26
  getculpa repair Re-stage missing/corrupt install files (no data touched)
27
27
  getculpa uninstall Remove Culpa (data is kept unless you confirm)
28
- getculpa scan Zero-install local scan (no Docker, no network)
28
+ getculpa scan Refresh the Culpa ledger (needs the running stack);
29
+ --preview reads local logs offline: no Docker, no network
29
30
  getculpa connect Print the topology-appropriate capture recipe
30
31
  getculpa update Manage CLI launcher updates
32
+ getculpa <verb> --help scan / connect / update / config: the culpa CLI's own help, under this name
31
33
  getculpa --help Show this help
32
34
  getculpa --version Show the getculpa package version
33
35
  `;
@@ -37,7 +39,7 @@ function printVersion() {
37
39
  console.log(pkg.version);
38
40
  }
39
41
 
40
- function runLauncherPassthrough(args) {
42
+ function runLauncherPassthrough(args, env) {
41
43
  const exe = process.platform === "win32" ? ".exe" : "";
42
44
  const launcher = path.join(__dirname, "..", "vendor", `culpa-launcher${exe}`);
43
45
  if (!fs.existsSync(launcher)) {
@@ -45,7 +47,9 @@ function runLauncherPassthrough(args) {
45
47
  console.error("getculpa: run `npm rebuild getculpa` (or reinstall) to fetch it.");
46
48
  process.exit(1);
47
49
  }
48
- const result = spawnSync(launcher, args, { stdio: "inherit" });
50
+ // T-182.8 (ISS-MAC-11): the launcher and the CLI spell their usage in
51
+ // the invoked name — the environment carries it (lib/dispatch.mjs)
52
+ const result = spawnSync(launcher, args, { stdio: "inherit", env });
49
53
  process.exit(result.status === null ? 1 : result.status);
50
54
  }
51
55
 
@@ -130,7 +134,7 @@ async function main(argv) {
130
134
  // uninstall-consent decisions now happen here, BEFORE anything is imported
131
135
  // or spawned. Passthrough verbs fall through untouched (the launcher owns
132
136
  // their help).
133
- const { decideDispatch, verbHelp } = await import("../lib/dispatch.mjs");
137
+ const { decideDispatch, verbHelp, passthroughEnv } = await import("../lib/dispatch.mjs");
134
138
  const decision = decideDispatch(argv, {
135
139
  isTTY: process.stdin.isTTY === true && process.stdout.isTTY === true,
136
140
  });
@@ -166,7 +170,7 @@ async function main(argv) {
166
170
  // `uninstall` never reaches here — every argv shape is decided above.
167
171
  // scan / connect / update / config / anything unrecognized: the launcher
168
172
  // owns USAGE and exit-code semantics for its own surface, same as culpa.js.
169
- return runLauncherPassthrough(argv);
173
+ return runLauncherPassthrough(argv, passthroughEnv(process.env));
170
174
  }
171
175
 
172
176
  main(process.argv.slice(2)).catch((e) => {
package/lib/dispatch.mjs CHANGED
@@ -29,6 +29,13 @@ const VERB_HELP = {
29
29
  "Stop and REMOVE Culpa's containers and network. Asks for confirmation first; pass --yes to consent from a script (a non-interactive run without --yes refuses). Recorded cost data is kept unless you separately confirm deleting it.",
30
30
  };
31
31
 
32
+ /** T-182.8 (ISS-MAC-11): the environment for a passthrough spawn — the
33
+ * vendored launcher and the culpa CLI spell their usage in the invoked name
34
+ * when CULPA_ARGV0 says `getculpa` (an allowlist on their side). Pure. */
35
+ export function passthroughEnv(base) {
36
+ return { ...base, CULPA_ARGV0: "getculpa" };
37
+ }
38
+
32
39
  /** One verb's help block, prefixed with its usage line. */
33
40
  export function verbHelp(verb) {
34
41
  const text = VERB_HELP[verb] ?? "No help recorded for this verb.";