getculpa 1.0.9 → 1.1.0

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.
@@ -1,415 +1,415 @@
1
- # Culpa launcher (Culpaflight #2, 2026-07-27). The Desktop/Start-Menu shortcut
2
- # runs THIS instead of a bare URL: a bare .url dead-ends at "connection
3
- # refused" whenever Docker Desktop isn't running. This launcher makes launch
4
- # mean launch: ensure Docker is up (starting it if installed, asking if not),
5
- # ensure the Culpa stack is up, then open the dashboard. Bounded waits, loud
6
- # failures - same posture as the installer.
7
-
8
- $ErrorActionPreference = "Stop"
9
- # IWR's progress rendering throws in hidden/redirected consoles (PS 5.1) and
10
- # slows every request - silence it globally
11
- $ProgressPreference = "SilentlyContinue"
12
-
13
- # CF5-F11: the shortcut runs this from the install directory, wherever the
14
- # founder put it; LOCALAPPDATA stays the fallback for the zip layout.
15
- $AppDir = if (Test-Path (Join-Path $PSScriptRoot "docker-compose.yml")) {
16
- $PSScriptRoot
17
- } else {
18
- Join-Path $env:LOCALAPPDATA "Culpa"
19
- }
20
- $ComposeDst = Join-Path $AppDir "docker-compose.yml"
21
- $DashboardUrl = "http://localhost:3000"
22
- # probe by literal IPv4 - "localhost" can resolve to ::1 where nothing listens
23
- $ProbeUrl = "http://127.0.0.1:3000"
24
-
25
- function Write-Step([string]$msg) { Write-Host "==> $msg" }
26
-
27
- function Fail([string]$msg) {
28
- Write-Host ""
29
- Write-Host "CULPA COULD NOT START: $msg" -ForegroundColor Red
30
- Read-Host "Press Enter to close"
31
- exit 1
32
- }
33
-
34
- function Test-DockerUp {
35
- try { $null = docker info --format "ok" 2>&1; return $LASTEXITCODE -eq 0 } catch { return $false }
36
- }
37
-
38
- # --- QW-1..QW-4 (D-073 aftermath): the container-upgrade guards the v0.8.1
39
- # --- upgrade had to do by hand. Each is a plain function with injectable
40
- # --- inputs so scripts/cf8-qw-verify-launch-guards.ps1 can execute every
41
- # --- branch without Docker or a real install (the repo's AST-probe pattern).
42
-
43
- # The server tag pinned in a compose text; $null when absent (QW-1/2/3 all
44
- # read the pin through this ONE function so they can never disagree).
45
- # Full-line YAML comments, removed before ANY pin is parsed. Review finding
46
- # (2026-08-23): the unanchored parser below matched the cosign-verify EXAMPLE
47
- # at installers/culpa-compose.yml:29 - a COMMENT - which precedes the real
48
- # image: pin at line 58 and so won the first-match race. Both read v1.0.3
49
- # today, but publish.sh rewrites the example and the pin with SEPARATE seds,
50
- # so a server release whose example sed missed would leave QW-3 comparing a
51
- # stale version and REFUSING to launch a perfectly good pin.
52
- function Remove-ComposeComments([string]$ComposeText) {
53
- if ([string]::IsNullOrEmpty($ComposeText)) { return "" }
54
- return ((($ComposeText -split "`r?`n") | Where-Object { $_ -notmatch '^\s*#' }) -join "`n")
55
- }
56
-
57
- # Anchored on image: as well as comment-stripped - a pin is something docker
58
- # would actually pull, never prose that happens to name the image.
59
- function Get-PinnedServerVersion([string]$ComposeText) {
60
- # Comment-stripping is what cures the defect; the image: anchor is preferred
61
- # but NOT required, because callers legitimately pass a bare
62
- # "culpa-server:vX.Y.Z" fragment. Anchored first, then an unanchored fallback
63
- # over the SAME comment-stripped text.
64
- $text = Remove-ComposeComments $ComposeText
65
- if ($text -match '(?m)^[ ]*image:\s*\S*?culpa-server:([^"''\s]+)') { return $Matches[1] }
66
- if ($text -match 'culpa-server:([^"''\s]+)') { return $Matches[1] }
67
- return $null
68
- }
69
-
70
- # vX.Y.Z comparison: -1 (A older), 0 (equal/unparsable), 1 (A newer).
71
- # Unparsable tags return 0 deliberately - the guards must fail OPEN on exotic
72
- # tags (a dev tag must not brick launch), and QW-4 still backs up on ANY
73
- # difference, so an unparsable tag still gets the backup.
74
- function Compare-CulpaVersions([string]$A, [string]$B) {
75
- $rx = '^v?(\d+)\.(\d+)\.(\d+)'
76
- if ($A -notmatch $rx) { return 0 }
77
- $pa = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
78
- if ($B -notmatch $rx) { return 0 }
79
- $pb = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
80
- for ($i = 0; $i -lt 3; $i++) {
81
- if ($pa[$i] -lt $pb[$i]) { return -1 }
82
- if ($pa[$i] -gt $pb[$i]) { return 1 }
83
- }
84
- return 0
85
- }
86
-
87
- # ISS-V103-12 - every pinned CULPA image in a compose text, as a hashtable
88
- # @{ "culpa-server" = "v1.0.3"; "culpa-dashboard" = "v1.0.5" }. Scoped to
89
- # culpa-* deliberately: the compose also pins postgres, and a postgres bump
90
- # must never read as a Culpa release.
91
- function Get-PinnedImageVersions([string]$ComposeText) {
92
- $out = @{}
93
- if ($null -eq $ComposeText) { return $out }
94
- foreach ($m in [regex]::Matches((Remove-ComposeComments $ComposeText), '(?m)^[ ]*image:\s*\S*?(culpa-[A-Za-z0-9_-]+):([^"''\s]+)')) {
95
- $out[$m.Groups[1].Value] = $m.Groups[2].Value
96
- }
97
- return $out
98
- }
99
-
100
- # ISS-V103-12 - is the SHIPPED compose a release the LIVE one has not had?
101
- #
102
- # This used to be Compare-CulpaVersions on the culpa-server pin and nothing
103
- # else, so any per-component release (D-087) that did not bump culpa-server
104
- # never re-pinned: new npm files over old containers. Observed on v1.0.4.
105
- #
106
- # Rule: at least one pinned Culpa image is NEWER, and none is OLDER.
107
- # - an image in shipped but not live counts as NEWER (a release that ADDS a
108
- # service must reach the live file, or that service never starts);
109
- # - an image in live but not shipped is ignored, not "older";
110
- # - unparsable tags still compare 0, so exotic dev tags contribute neither
111
- # direction and can never brick a launch.
112
- #
113
- # MUST stay behaviourally identical to isComposeUpgrade in
114
- # packaging/npm-getculpa/lib/preflight.mjs - tests/npm-ps1-repin-parity.test.ts
115
- # executes both against one shared table of cases.
116
- function Test-ComposeUpgrade([string]$ShippedText, [string]$LiveText) {
117
- $shipped = Get-PinnedImageVersions $ShippedText
118
- $live = Get-PinnedImageVersions $LiveText
119
- if ($shipped.Count -eq 0 -or $live.Count -eq 0) { return $false }
120
-
121
- $anyNewer = $false
122
- foreach ($name in $shipped.Keys) {
123
- if (-not $live.ContainsKey($name)) { $anyNewer = $true; continue }
124
- $cmp = Compare-CulpaVersions $shipped[$name] $live[$name]
125
- if ($cmp -eq -1) {
126
- # not silent: an ambiguous file must not look like "nothing to do"
127
- Write-Warning "The shipped compose moves $name BACKWARD ($($live[$name]) -> $($shipped[$name])) while moving others forward; refusing to re-pin from an ambiguous file."
128
- return $false
129
- }
130
- if ($cmp -eq 1) { $anyNewer = $true }
131
- }
132
- return $anyNewer
133
- }
134
-
135
- # QW-1 - ONE canonical compose. The live file ($LivePath) owns the pin; the
136
- # shipped culpa-compose.yml beside the install ($ShippedPath) is only a
137
- # SOURCE: when an upgrade drops a NEWER shipped file, the live one is
138
- # re-pinned from it here, on launch - the step the v0.8.1 upgrade needed a
139
- # human for, three files at a time. An OLDER shipped file never wins.
140
- # Returns: "synced" | "kept" | "no-shipped" | "no-live".
141
- function Sync-CanonicalCompose([string]$ShippedPath, [string]$LivePath) {
142
- if (-not (Test-Path $LivePath)) { return "no-live" }
143
- if (-not (Test-Path $ShippedPath)) { return "no-shipped" }
144
- if (Test-ComposeUpgrade (Get-Content $ShippedPath -Raw) (Get-Content $LivePath -Raw)) {
145
- Copy-Item $ShippedPath $LivePath -Force
146
- return "synced"
147
- }
148
- return "kept"
149
- }
150
-
151
- # QW-3 - refuse-and-explain on a backward pin. $true = refuse. A pinned
152
- # server OLDER than the version that last booted healthily means its known
153
- # migrations disagree with the database's history - culpa-migrate would die
154
- # with a cryptic error AFTER the recreate. Refuse BEFORE touching anything.
155
- #
156
- # CodeRabbit PR13 #6: Compare-CulpaVersions fails OPEN (0, "equal") on any
157
- # unparsable tag so exotic dev tags never brick launch - but that means a
158
- # MALFORMED pin against a well-formed, known baseline used to read as
159
- # "equal" and launch anyway. That pin cannot be proven newer, so it must
160
- # fail CLOSED instead: refuse (the CULPA_ALLOW_DOWNGRADE escape still
161
- # applies). Both-unparsable and no-baseline stay permissive (dev installs).
162
- function Test-BackwardPin([string]$PinnedVersion, [string]$LastBootVersion) {
163
- if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { return $false }
164
- if ([string]::IsNullOrWhiteSpace($LastBootVersion)) { return $false }
165
- $rx = '^v?(\d+)\.(\d+)\.(\d+)'
166
- if (($PinnedVersion -notmatch $rx) -and ($LastBootVersion -match $rx)) { return $true }
167
- return ((Compare-CulpaVersions $PinnedVersion $LastBootVersion) -eq -1)
168
- }
169
-
170
- # QW-4 - automatic pg_dump before any launch that can migrate (pin differs
171
- # from the last healthy boot). Fails CLOSED: no verified dump, no launch -
172
- # unless the caller passed an explicit skip. $DumpExec/$CopyExec are
173
- # injectable for the probe script. Returns the dump path on success; throws
174
- # with a human-readable reason on failure.
175
- function Invoke-PreMigrationBackup {
176
- param(
177
- [string]$BackupDir,
178
- [string]$Tag,
179
- [scriptblock]$DumpExec = {
180
- docker exec culpa-db sh -c "pg_dump -Fc -U culpa culpa > /tmp/culpa-pre-upgrade.dump" 2>&1 | Out-Null
181
- return $LASTEXITCODE
182
- },
183
- [scriptblock]$CopyExec = {
184
- param($Dest)
185
- docker cp culpa-db:/tmp/culpa-pre-upgrade.dump $Dest 2>&1 | Out-Null
186
- return $LASTEXITCODE
187
- }
188
- )
189
- New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
190
- $stamp = Get-Date -Format "yyyyMMdd-HHmmss"
191
- $dest = Join-Path $BackupDir "pre-$Tag-$stamp.dump"
192
- if ((& $DumpExec) -ne 0) { throw "pg_dump inside culpa-db failed" }
193
- if ((& $CopyExec $dest) -ne 0) { throw "copying the dump out of culpa-db failed" }
194
- $item = Get-Item -Path $dest -ErrorAction SilentlyContinue
195
- if ($null -eq $item -or $item.Length -lt 1024) {
196
- throw "the dump at '$dest' is missing or implausibly small ($(if ($item) { $item.Length } else { 0 }) bytes)"
197
- }
198
- return $dest
199
- }
200
-
201
- # QW-4's baseline resolver (CF8 review Critical-2): the recorded last
202
- # healthy boot wins; when NO record exists yet (first run of this guard on
203
- # an existing install - exactly the rollout population) the baseline is
204
- # derived from the culpa-server container's ACTUAL running image tag; if
205
- # neither source yields a version the baseline is UNKNOWN ("").
206
- function Resolve-BackupBaseline(
207
- [string]$Recorded,
208
- [scriptblock]$InspectExec = {
209
- $old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
210
- try { docker inspect culpa-server --format "{{.Config.Image}}" 2>&1 } finally { $ErrorActionPreference = $old }
211
- }
212
- ) {
213
- if (-not [string]::IsNullOrWhiteSpace($Recorded)) { return $Recorded.Trim() }
214
- $image = ""
215
- try { $image = ("$(& $InspectExec)").Trim() } catch { $image = "" }
216
- if ($image -match ':(v[0-9][^:\s]*)$') { return $Matches[1] }
217
- return ""
218
- }
219
-
220
- # QW-4's decision, extracted so the probe can execute the WIRED condition
221
- # (the review proved the old inline gate was silently false at
222
- # baseline="" + data present - the exact first-activation state): data
223
- # present + an UNKNOWN baseline means BACK UP, never skip; only a baseline
224
- # that is known AND equal to the pin skips the backup.
225
- function Test-BackupNeeded([bool]$DbExists, [string]$PinnedVersion, [string]$Baseline) {
226
- if (-not $DbExists) { return $false }
227
- if ([string]::IsNullOrWhiteSpace($Baseline)) { return $true }
228
- if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { return $true }
229
- return ($PinnedVersion -ne $Baseline)
230
- }
231
-
232
- # QW-2 - reconcile the Add/Remove Programs DisplayVersion from the pin that
233
- # is ACTUALLY about to run, so Settings can never report a stale version
234
- # again (it read v0.8.0 while v0.8.1 ran). No-op when the key is absent.
235
- function Sync-RegistryDisplayVersion([string]$PinnedVersion, [string]$ArpKey) {
236
- if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { return "no-pin" }
237
- if (-not (Test-Path $ArpKey)) { return "no-key" }
238
- $current = (Get-ItemProperty -Path $ArpKey -Name "DisplayVersion" -ErrorAction SilentlyContinue).DisplayVersion
239
- if ($current -eq $PinnedVersion) { return "unchanged" }
240
- New-ItemProperty -Path $ArpKey -Name "DisplayVersion" -Value $PinnedVersion -PropertyType String -Force | Out-Null
241
- return "updated"
242
- }
243
-
244
- # --- 1. Docker present? (CF6-9: scan, then authorize before any download) ---
245
- # Test-DockerInstalled takes an overridable -Probe so this exact detection can
246
- # be exercised with a fake "not found" result without touching a real install.
247
- function Test-DockerInstalled {
248
- param([scriptblock]$Probe = { Get-Command docker -ErrorAction SilentlyContinue })
249
- return ($null -ne (& $Probe))
250
- }
251
-
252
- # Real WPF dialog - not a console prompt. Names what's missing, what would be
253
- # downloaded, and from where; returns $false unless the user explicitly agrees.
254
- function Show-DockerAuthorizationModal {
255
- Add-Type -AssemblyName PresentationFramework
256
- $msg = "Culpa needs Docker Desktop and it was not found on this machine.`n`n" +
257
- "If you authorize, Culpa will install it via winget (Windows Package Manager) " +
258
- "using the official package 'Docker.DockerDesktop', or open the official " +
259
- "download page at https://www.docker.com/products/docker-desktop/ if winget " +
260
- "is unavailable.`n`nNothing is downloaded until you click Yes."
261
- $btns = [System.Windows.MessageBoxButton]::YesNo
262
- $icon = [System.Windows.MessageBoxImage]::Warning
263
- $result = [System.Windows.MessageBox]::Show($msg, "Culpa - Docker Desktop required", $btns, $icon)
264
- return ($result -eq [System.Windows.MessageBoxResult]::Yes)
265
- }
266
-
267
- # Only called after explicit authorization. Prefers winget (the user's own,
268
- # auditable package manager); falls back to opening the official vendor page.
269
- # Returns WHICH path it took (see install-culpa.ps1 for the full rationale):
270
- # "installed" | "page-opened" | "failed:<exit code>". A bare $false made the
271
- # caller claim a download page had been opened when winget was present and the
272
- # install simply failed - no page is opened on that path.
273
- function Install-DockerDesktop {
274
- $winget = Get-Command winget -ErrorAction SilentlyContinue
275
- if ($null -eq $winget) {
276
- Start-Process "https://www.docker.com/products/docker-desktop/"
277
- return "page-opened"
278
- }
279
- Write-Step "Installing Docker Desktop via winget (Docker.DockerDesktop)"
280
- $wingetArgs = @("install", "--id", "Docker.DockerDesktop", "-e", "--accept-package-agreements", "--accept-source-agreements")
281
- $p = Start-Process -FilePath "winget" -ArgumentList $wingetArgs -Wait -PassThru -NoNewWindow
282
- if ($p.ExitCode -eq 0) { return "installed" }
283
- return "failed:$($p.ExitCode)"
284
- }
285
-
286
- if (-not (Test-DockerInstalled)) {
287
- if (-not (Show-DockerAuthorizationModal)) {
288
- Fail "Docker Desktop download was not authorized. Nothing was changed."
289
- }
290
- $outcome = Install-DockerDesktop
291
- if ($outcome -eq "page-opened") {
292
- Fail "winget is not available, so Docker Desktop could not be installed automatically. The official download page has been opened - install it, start it once, then launch Culpa again."
293
- }
294
- if ($outcome -ne "installed") {
295
- $code = $outcome -replace "^failed:", ""
296
- Fail "winget could not install Docker Desktop (exit code $code). No download page was opened and nothing was changed. Install Docker Desktop from https://www.docker.com/products/docker-desktop/, start it once, then launch Culpa again."
297
- }
298
- Fail "Docker Desktop was installed. Close this window and open Culpa again to finish starting the stack."
299
- }
300
-
301
- # --- 2. Docker running? Start it if not (the whole point of this launcher) ---
302
- if (-not (Test-DockerUp)) {
303
- Write-Step "Docker Desktop is not running - starting it"
304
- $desktop = Join-Path $env:ProgramFiles "Docker\Docker\Docker Desktop.exe"
305
- if (Test-Path $desktop) {
306
- Start-Process $desktop
307
- } else {
308
- Fail "Docker Desktop is installed but its executable was not found at '$desktop'. Start Docker Desktop yourself, then launch Culpa again."
309
- }
310
- # bounded wait: up to 120s for the engine
311
- $up = $false
312
- for ($i = 0; $i -lt 60; $i++) {
313
- Start-Sleep -Seconds 2
314
- if (Test-DockerUp) { $up = $true; break }
315
- }
316
- if (-not $up) { Fail "Docker Desktop did not come up within 2 minutes. Wait for the whale icon to settle, then launch Culpa again." }
317
- }
318
-
319
- # --- 3. Stack up (idempotent; also recovers a stopped stack) ---
320
- if (-not (Test-Path $ComposeDst)) { Fail "Culpa's compose file is missing at '$ComposeDst'. Re-run the installer." }
321
-
322
- # --- 3a. QW-1: one canonical compose - a newer shipped culpa-compose.yml
323
- # --- re-pins the live file here, never by hand across three copies
324
- $ShippedCompose = Join-Path $AppDir "culpa-compose.yml"
325
- $syncOutcome = Sync-CanonicalCompose -ShippedPath $ShippedCompose -LivePath $ComposeDst
326
- if ($syncOutcome -eq "synced") { Write-Step "Upgrade detected: live compose re-pinned from the shipped culpa-compose.yml" }
327
-
328
- # --- 3b. QW-3: refuse-and-explain when the pin goes BACKWARD vs the last
329
- # --- healthy boot (a stale pin against a migrated DB dies cryptically in
330
- # --- culpa-migrate - refuse before touching anything)
331
- $PinnedVersion = Get-PinnedServerVersion (Get-Content $ComposeDst -Raw)
332
- $LastBootFile = Join-Path $AppDir "last-boot-version.txt"
333
- $LastBootVersion = if (Test-Path $LastBootFile) { (Get-Content $LastBootFile -Raw).Trim() } else { "" }
334
- if ((Test-BackwardPin $PinnedVersion $LastBootVersion) -and ($env:CULPA_ALLOW_DOWNGRADE -eq "1")) {
335
- Write-Host "WARNING: CULPA_ALLOW_DOWNGRADE=1 - launching an OLDER server ($PinnedVersion) against a database last used by $LastBootVersion." -ForegroundColor Yellow
336
- }
337
- if ((Test-BackwardPin $PinnedVersion $LastBootVersion) -and ($env:CULPA_ALLOW_DOWNGRADE -ne "1")) {
338
- Fail @"
339
- The compose file pins culpa-server $PinnedVersion, but this machine last ran $LastBootVersion.
340
- Starting an OLDER server against a newer database would fail inside its migration step with a
341
- cryptic error. Nothing was started.
342
-
343
- Fix: re-run the current installer (which lays down the right compose), or restore the
344
- $LastBootVersion pin in '$ComposeDst'. To force this downgrade anyway (only after restoring a
345
- matching database backup), set CULPA_ALLOW_DOWNGRADE=1 and launch again.
346
- "@
347
- }
348
-
349
- # --- 3c. QW-4: automatic pg_dump before any launch that can migrate. Fails
350
- # --- closed: no verified backup, no launch (CULPA_SKIP_BACKUP=1 overrides,
351
- # --- loudly). Only runs when the DB container already exists - a first
352
- # --- install has nothing to back up.
353
- $oldEapQw = $ErrorActionPreference
354
- $ErrorActionPreference = "Continue"
355
- $containerNames = docker ps -a --format "{{.Names}}" 2>&1
356
- $dbExists = ($LASTEXITCODE -eq 0 -and "$containerNames" -match "culpa-db")
357
- $ErrorActionPreference = $oldEapQw
358
- $BackupBaseline = Resolve-BackupBaseline $LastBootVersion
359
- $BaselineLabel = if ([string]::IsNullOrWhiteSpace($BackupBaseline)) { "unknown" } else { $BackupBaseline }
360
- if (Test-BackupNeeded $dbExists $PinnedVersion $BackupBaseline) {
361
- if ($env:CULPA_SKIP_BACKUP -eq "1") {
362
- Write-Host "WARNING: CULPA_SKIP_BACKUP=1 - upgrading $BaselineLabel -> $PinnedVersion WITHOUT a database backup." -ForegroundColor Yellow
363
- } else {
364
- Write-Step "Version change ($BaselineLabel -> $PinnedVersion): backing up the database first"
365
- & { $ErrorActionPreference = "Continue"; docker compose -f $ComposeDst -p culpa up -d db 2>&1 | ForEach-Object { "$_" } }
366
- if ($LASTEXITCODE -ne 0) { Fail "Could not start the database container to take the pre-upgrade backup. Nothing else was started." }
367
- $pgReady = $false
368
- for ($i = 0; $i -lt 30; $i++) {
369
- & { $ErrorActionPreference = "Continue"; $null = docker exec culpa-db pg_isready -U culpa 2>&1 }
370
- if ($LASTEXITCODE -eq 0) { $pgReady = $true; break }
371
- Start-Sleep -Seconds 2
372
- }
373
- if (-not $pgReady) { Fail "The database did not become ready within 60s, so the pre-upgrade backup could not be taken. Nothing was upgraded." }
374
- try {
375
- $dump = Invoke-PreMigrationBackup -BackupDir (Join-Path $AppDir "backups") -Tag $BaselineLabel
376
- Write-Step "Backup verified: $dump"
377
- } catch {
378
- Fail "Pre-upgrade backup failed ($($_.Exception.Message)). Nothing was upgraded. Set CULPA_SKIP_BACKUP=1 to launch without a backup (not recommended)."
379
- }
380
- }
381
- }
382
-
383
- Write-Step "Starting Culpa"
384
- # compose writes PROGRESS to stderr; under EAP=Stop with redirected streams
385
- # (PS 5.1) that becomes a terminating NativeCommandError - stringify the
386
- # records and judge by the exit code, which is the real signal
387
- & { $ErrorActionPreference = "Continue"; docker compose -f $ComposeDst -p culpa up -d 2>&1 | ForEach-Object { "$_" } }
388
- if ($LASTEXITCODE -ne 0) { Fail "Docker could not start the Culpa services. The message above says why." }
389
-
390
- # --- 3b. Capture collector (CF18): idempotent restart with the stack ---
391
- # The collector is a host process (loopback-bound by design), so the compose
392
- # restart policy cannot revive it after a reboot; this launcher is what does.
393
- $CollectorCtl = Join-Path $PSScriptRoot "culpa-collector.ps1"
394
- if (Test-Path $CollectorCtl) { & $CollectorCtl -Start }
395
-
396
- # --- 4. Wait for the dashboard (bounded: 120s), then open it ---
397
- Write-Step "Waiting for the dashboard"
398
- $ready = $false
399
- for ($i = 0; $i -lt 60; $i++) {
400
- try {
401
- $resp = Invoke-WebRequest -Uri $ProbeUrl -UseBasicParsing -TimeoutSec 3
402
- if ($resp.StatusCode -eq 200) { $ready = $true; break }
403
- } catch { Start-Sleep -Seconds 2 }
404
- }
405
- if (-not $ready) { Fail "The services started but the dashboard did not answer within 2 minutes. Run 'docker logs culpa-dashboard' to see why." }
406
-
407
- # --- 5. Healthy boot: record it (QW-3's baseline) + reconcile the registry
408
- # --- version from the pin that actually runs (QW-2)
409
- if ($PinnedVersion) {
410
- Set-Content -Path $LastBootFile -Value $PinnedVersion
411
- $ArpKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1"
412
- $null = Sync-RegistryDisplayVersion -PinnedVersion $PinnedVersion -ArpKey $ArpKey
413
- }
414
-
415
- Start-Process $DashboardUrl
1
+ # Culpa launcher (Culpaflight #2, 2026-07-27). The Desktop/Start-Menu shortcut
2
+ # runs THIS instead of a bare URL: a bare .url dead-ends at "connection
3
+ # refused" whenever Docker Desktop isn't running. This launcher makes launch
4
+ # mean launch: ensure Docker is up (starting it if installed, asking if not),
5
+ # ensure the Culpa stack is up, then open the dashboard. Bounded waits, loud
6
+ # failures - same posture as the installer.
7
+
8
+ $ErrorActionPreference = "Stop"
9
+ # IWR's progress rendering throws in hidden/redirected consoles (PS 5.1) and
10
+ # slows every request - silence it globally
11
+ $ProgressPreference = "SilentlyContinue"
12
+
13
+ # CF5-F11: the shortcut runs this from the install directory, wherever the
14
+ # founder put it; LOCALAPPDATA stays the fallback for the zip layout.
15
+ $AppDir = if (Test-Path (Join-Path $PSScriptRoot "docker-compose.yml")) {
16
+ $PSScriptRoot
17
+ } else {
18
+ Join-Path $env:LOCALAPPDATA "Culpa"
19
+ }
20
+ $ComposeDst = Join-Path $AppDir "docker-compose.yml"
21
+ $DashboardUrl = "http://localhost:3000"
22
+ # probe by literal IPv4 - "localhost" can resolve to ::1 where nothing listens
23
+ $ProbeUrl = "http://127.0.0.1:3000"
24
+
25
+ function Write-Step([string]$msg) { Write-Host "==> $msg" }
26
+
27
+ function Fail([string]$msg) {
28
+ Write-Host ""
29
+ Write-Host "CULPA COULD NOT START: $msg" -ForegroundColor Red
30
+ Read-Host "Press Enter to close"
31
+ exit 1
32
+ }
33
+
34
+ function Test-DockerUp {
35
+ try { $null = docker info --format "ok" 2>&1; return $LASTEXITCODE -eq 0 } catch { return $false }
36
+ }
37
+
38
+ # --- QW-1..QW-4 (D-073 aftermath): the container-upgrade guards the v0.8.1
39
+ # --- upgrade had to do by hand. Each is a plain function with injectable
40
+ # --- inputs so scripts/cf8-qw-verify-launch-guards.ps1 can execute every
41
+ # --- branch without Docker or a real install (the repo's AST-probe pattern).
42
+
43
+ # The server tag pinned in a compose text; $null when absent (QW-1/2/3 all
44
+ # read the pin through this ONE function so they can never disagree).
45
+ # Full-line YAML comments, removed before ANY pin is parsed. Review finding
46
+ # (2026-08-23): the unanchored parser below matched the cosign-verify EXAMPLE
47
+ # at installers/culpa-compose.yml:29 - a COMMENT - which precedes the real
48
+ # image: pin at line 58 and so won the first-match race. Both read v1.0.3
49
+ # today, but publish.sh rewrites the example and the pin with SEPARATE seds,
50
+ # so a server release whose example sed missed would leave QW-3 comparing a
51
+ # stale version and REFUSING to launch a perfectly good pin.
52
+ function Remove-ComposeComments([string]$ComposeText) {
53
+ if ([string]::IsNullOrEmpty($ComposeText)) { return "" }
54
+ return ((($ComposeText -split "`r?`n") | Where-Object { $_ -notmatch '^\s*#' }) -join "`n")
55
+ }
56
+
57
+ # Anchored on image: as well as comment-stripped - a pin is something docker
58
+ # would actually pull, never prose that happens to name the image.
59
+ function Get-PinnedServerVersion([string]$ComposeText) {
60
+ # Comment-stripping is what cures the defect; the image: anchor is preferred
61
+ # but NOT required, because callers legitimately pass a bare
62
+ # "culpa-server:vX.Y.Z" fragment. Anchored first, then an unanchored fallback
63
+ # over the SAME comment-stripped text.
64
+ $text = Remove-ComposeComments $ComposeText
65
+ if ($text -match '(?m)^[ ]*image:\s*\S*?culpa-server:([^"''\s]+)') { return $Matches[1] }
66
+ if ($text -match 'culpa-server:([^"''\s]+)') { return $Matches[1] }
67
+ return $null
68
+ }
69
+
70
+ # vX.Y.Z comparison: -1 (A older), 0 (equal/unparsable), 1 (A newer).
71
+ # Unparsable tags return 0 deliberately - the guards must fail OPEN on exotic
72
+ # tags (a dev tag must not brick launch), and QW-4 still backs up on ANY
73
+ # difference, so an unparsable tag still gets the backup.
74
+ function Compare-CulpaVersions([string]$A, [string]$B) {
75
+ $rx = '^v?(\d+)\.(\d+)\.(\d+)'
76
+ if ($A -notmatch $rx) { return 0 }
77
+ $pa = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
78
+ if ($B -notmatch $rx) { return 0 }
79
+ $pb = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
80
+ for ($i = 0; $i -lt 3; $i++) {
81
+ if ($pa[$i] -lt $pb[$i]) { return -1 }
82
+ if ($pa[$i] -gt $pb[$i]) { return 1 }
83
+ }
84
+ return 0
85
+ }
86
+
87
+ # ISS-V103-12 - every pinned CULPA image in a compose text, as a hashtable
88
+ # @{ "culpa-server" = "v1.0.3"; "culpa-dashboard" = "v1.0.5" }. Scoped to
89
+ # culpa-* deliberately: the compose also pins postgres, and a postgres bump
90
+ # must never read as a Culpa release.
91
+ function Get-PinnedImageVersions([string]$ComposeText) {
92
+ $out = @{}
93
+ if ($null -eq $ComposeText) { return $out }
94
+ foreach ($m in [regex]::Matches((Remove-ComposeComments $ComposeText), '(?m)^[ ]*image:\s*\S*?(culpa-[A-Za-z0-9_-]+):([^"''\s]+)')) {
95
+ $out[$m.Groups[1].Value] = $m.Groups[2].Value
96
+ }
97
+ return $out
98
+ }
99
+
100
+ # ISS-V103-12 - is the SHIPPED compose a release the LIVE one has not had?
101
+ #
102
+ # This used to be Compare-CulpaVersions on the culpa-server pin and nothing
103
+ # else, so any per-component release (D-087) that did not bump culpa-server
104
+ # never re-pinned: new npm files over old containers. Observed on v1.0.4.
105
+ #
106
+ # Rule: at least one pinned Culpa image is NEWER, and none is OLDER.
107
+ # - an image in shipped but not live counts as NEWER (a release that ADDS a
108
+ # service must reach the live file, or that service never starts);
109
+ # - an image in live but not shipped is ignored, not "older";
110
+ # - unparsable tags still compare 0, so exotic dev tags contribute neither
111
+ # direction and can never brick a launch.
112
+ #
113
+ # MUST stay behaviourally identical to isComposeUpgrade in
114
+ # packaging/npm-getculpa/lib/preflight.mjs - tests/npm-ps1-repin-parity.test.ts
115
+ # executes both against one shared table of cases.
116
+ function Test-ComposeUpgrade([string]$ShippedText, [string]$LiveText) {
117
+ $shipped = Get-PinnedImageVersions $ShippedText
118
+ $live = Get-PinnedImageVersions $LiveText
119
+ if ($shipped.Count -eq 0 -or $live.Count -eq 0) { return $false }
120
+
121
+ $anyNewer = $false
122
+ foreach ($name in $shipped.Keys) {
123
+ if (-not $live.ContainsKey($name)) { $anyNewer = $true; continue }
124
+ $cmp = Compare-CulpaVersions $shipped[$name] $live[$name]
125
+ if ($cmp -eq -1) {
126
+ # not silent: an ambiguous file must not look like "nothing to do"
127
+ Write-Warning "The shipped compose moves $name BACKWARD ($($live[$name]) -> $($shipped[$name])) while moving others forward; refusing to re-pin from an ambiguous file."
128
+ return $false
129
+ }
130
+ if ($cmp -eq 1) { $anyNewer = $true }
131
+ }
132
+ return $anyNewer
133
+ }
134
+
135
+ # QW-1 - ONE canonical compose. The live file ($LivePath) owns the pin; the
136
+ # shipped culpa-compose.yml beside the install ($ShippedPath) is only a
137
+ # SOURCE: when an upgrade drops a NEWER shipped file, the live one is
138
+ # re-pinned from it here, on launch - the step the v0.8.1 upgrade needed a
139
+ # human for, three files at a time. An OLDER shipped file never wins.
140
+ # Returns: "synced" | "kept" | "no-shipped" | "no-live".
141
+ function Sync-CanonicalCompose([string]$ShippedPath, [string]$LivePath) {
142
+ if (-not (Test-Path $LivePath)) { return "no-live" }
143
+ if (-not (Test-Path $ShippedPath)) { return "no-shipped" }
144
+ if (Test-ComposeUpgrade (Get-Content $ShippedPath -Raw) (Get-Content $LivePath -Raw)) {
145
+ Copy-Item $ShippedPath $LivePath -Force
146
+ return "synced"
147
+ }
148
+ return "kept"
149
+ }
150
+
151
+ # QW-3 - refuse-and-explain on a backward pin. $true = refuse. A pinned
152
+ # server OLDER than the version that last booted healthily means its known
153
+ # migrations disagree with the database's history - culpa-migrate would die
154
+ # with a cryptic error AFTER the recreate. Refuse BEFORE touching anything.
155
+ #
156
+ # CodeRabbit PR13 #6: Compare-CulpaVersions fails OPEN (0, "equal") on any
157
+ # unparsable tag so exotic dev tags never brick launch - but that means a
158
+ # MALFORMED pin against a well-formed, known baseline used to read as
159
+ # "equal" and launch anyway. That pin cannot be proven newer, so it must
160
+ # fail CLOSED instead: refuse (the CULPA_ALLOW_DOWNGRADE escape still
161
+ # applies). Both-unparsable and no-baseline stay permissive (dev installs).
162
+ function Test-BackwardPin([string]$PinnedVersion, [string]$LastBootVersion) {
163
+ if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { return $false }
164
+ if ([string]::IsNullOrWhiteSpace($LastBootVersion)) { return $false }
165
+ $rx = '^v?(\d+)\.(\d+)\.(\d+)'
166
+ if (($PinnedVersion -notmatch $rx) -and ($LastBootVersion -match $rx)) { return $true }
167
+ return ((Compare-CulpaVersions $PinnedVersion $LastBootVersion) -eq -1)
168
+ }
169
+
170
+ # QW-4 - automatic pg_dump before any launch that can migrate (pin differs
171
+ # from the last healthy boot). Fails CLOSED: no verified dump, no launch -
172
+ # unless the caller passed an explicit skip. $DumpExec/$CopyExec are
173
+ # injectable for the probe script. Returns the dump path on success; throws
174
+ # with a human-readable reason on failure.
175
+ function Invoke-PreMigrationBackup {
176
+ param(
177
+ [string]$BackupDir,
178
+ [string]$Tag,
179
+ [scriptblock]$DumpExec = {
180
+ docker exec culpa-db sh -c "pg_dump -Fc -U culpa culpa > /tmp/culpa-pre-upgrade.dump" 2>&1 | Out-Null
181
+ return $LASTEXITCODE
182
+ },
183
+ [scriptblock]$CopyExec = {
184
+ param($Dest)
185
+ docker cp culpa-db:/tmp/culpa-pre-upgrade.dump $Dest 2>&1 | Out-Null
186
+ return $LASTEXITCODE
187
+ }
188
+ )
189
+ New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
190
+ $stamp = Get-Date -Format "yyyyMMdd-HHmmss"
191
+ $dest = Join-Path $BackupDir "pre-$Tag-$stamp.dump"
192
+ if ((& $DumpExec) -ne 0) { throw "pg_dump inside culpa-db failed" }
193
+ if ((& $CopyExec $dest) -ne 0) { throw "copying the dump out of culpa-db failed" }
194
+ $item = Get-Item -Path $dest -ErrorAction SilentlyContinue
195
+ if ($null -eq $item -or $item.Length -lt 1024) {
196
+ throw "the dump at '$dest' is missing or implausibly small ($(if ($item) { $item.Length } else { 0 }) bytes)"
197
+ }
198
+ return $dest
199
+ }
200
+
201
+ # QW-4's baseline resolver (CF8 review Critical-2): the recorded last
202
+ # healthy boot wins; when NO record exists yet (first run of this guard on
203
+ # an existing install - exactly the rollout population) the baseline is
204
+ # derived from the culpa-server container's ACTUAL running image tag; if
205
+ # neither source yields a version the baseline is UNKNOWN ("").
206
+ function Resolve-BackupBaseline(
207
+ [string]$Recorded,
208
+ [scriptblock]$InspectExec = {
209
+ $old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
210
+ try { docker inspect culpa-server --format "{{.Config.Image}}" 2>&1 } finally { $ErrorActionPreference = $old }
211
+ }
212
+ ) {
213
+ if (-not [string]::IsNullOrWhiteSpace($Recorded)) { return $Recorded.Trim() }
214
+ $image = ""
215
+ try { $image = ("$(& $InspectExec)").Trim() } catch { $image = "" }
216
+ if ($image -match ':(v[0-9][^:\s]*)$') { return $Matches[1] }
217
+ return ""
218
+ }
219
+
220
+ # QW-4's decision, extracted so the probe can execute the WIRED condition
221
+ # (the review proved the old inline gate was silently false at
222
+ # baseline="" + data present - the exact first-activation state): data
223
+ # present + an UNKNOWN baseline means BACK UP, never skip; only a baseline
224
+ # that is known AND equal to the pin skips the backup.
225
+ function Test-BackupNeeded([bool]$DbExists, [string]$PinnedVersion, [string]$Baseline) {
226
+ if (-not $DbExists) { return $false }
227
+ if ([string]::IsNullOrWhiteSpace($Baseline)) { return $true }
228
+ if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { return $true }
229
+ return ($PinnedVersion -ne $Baseline)
230
+ }
231
+
232
+ # QW-2 - reconcile the Add/Remove Programs DisplayVersion from the pin that
233
+ # is ACTUALLY about to run, so Settings can never report a stale version
234
+ # again (it read v0.8.0 while v0.8.1 ran). No-op when the key is absent.
235
+ function Sync-RegistryDisplayVersion([string]$PinnedVersion, [string]$ArpKey) {
236
+ if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { return "no-pin" }
237
+ if (-not (Test-Path $ArpKey)) { return "no-key" }
238
+ $current = (Get-ItemProperty -Path $ArpKey -Name "DisplayVersion" -ErrorAction SilentlyContinue).DisplayVersion
239
+ if ($current -eq $PinnedVersion) { return "unchanged" }
240
+ New-ItemProperty -Path $ArpKey -Name "DisplayVersion" -Value $PinnedVersion -PropertyType String -Force | Out-Null
241
+ return "updated"
242
+ }
243
+
244
+ # --- 1. Docker present? (CF6-9: scan, then authorize before any download) ---
245
+ # Test-DockerInstalled takes an overridable -Probe so this exact detection can
246
+ # be exercised with a fake "not found" result without touching a real install.
247
+ function Test-DockerInstalled {
248
+ param([scriptblock]$Probe = { Get-Command docker -ErrorAction SilentlyContinue })
249
+ return ($null -ne (& $Probe))
250
+ }
251
+
252
+ # Real WPF dialog - not a console prompt. Names what's missing, what would be
253
+ # downloaded, and from where; returns $false unless the user explicitly agrees.
254
+ function Show-DockerAuthorizationModal {
255
+ Add-Type -AssemblyName PresentationFramework
256
+ $msg = "Culpa needs Docker Desktop and it was not found on this machine.`n`n" +
257
+ "If you authorize, Culpa will install it via winget (Windows Package Manager) " +
258
+ "using the official package 'Docker.DockerDesktop', or open the official " +
259
+ "download page at https://www.docker.com/products/docker-desktop/ if winget " +
260
+ "is unavailable.`n`nNothing is downloaded until you click Yes."
261
+ $btns = [System.Windows.MessageBoxButton]::YesNo
262
+ $icon = [System.Windows.MessageBoxImage]::Warning
263
+ $result = [System.Windows.MessageBox]::Show($msg, "Culpa - Docker Desktop required", $btns, $icon)
264
+ return ($result -eq [System.Windows.MessageBoxResult]::Yes)
265
+ }
266
+
267
+ # Only called after explicit authorization. Prefers winget (the user's own,
268
+ # auditable package manager); falls back to opening the official vendor page.
269
+ # Returns WHICH path it took (see install-culpa.ps1 for the full rationale):
270
+ # "installed" | "page-opened" | "failed:<exit code>". A bare $false made the
271
+ # caller claim a download page had been opened when winget was present and the
272
+ # install simply failed - no page is opened on that path.
273
+ function Install-DockerDesktop {
274
+ $winget = Get-Command winget -ErrorAction SilentlyContinue
275
+ if ($null -eq $winget) {
276
+ Start-Process "https://www.docker.com/products/docker-desktop/"
277
+ return "page-opened"
278
+ }
279
+ Write-Step "Installing Docker Desktop via winget (Docker.DockerDesktop)"
280
+ $wingetArgs = @("install", "--id", "Docker.DockerDesktop", "-e", "--accept-package-agreements", "--accept-source-agreements")
281
+ $p = Start-Process -FilePath "winget" -ArgumentList $wingetArgs -Wait -PassThru -NoNewWindow
282
+ if ($p.ExitCode -eq 0) { return "installed" }
283
+ return "failed:$($p.ExitCode)"
284
+ }
285
+
286
+ if (-not (Test-DockerInstalled)) {
287
+ if (-not (Show-DockerAuthorizationModal)) {
288
+ Fail "Docker Desktop download was not authorized. Nothing was changed."
289
+ }
290
+ $outcome = Install-DockerDesktop
291
+ if ($outcome -eq "page-opened") {
292
+ Fail "winget is not available, so Docker Desktop could not be installed automatically. The official download page has been opened - install it, start it once, then launch Culpa again."
293
+ }
294
+ if ($outcome -ne "installed") {
295
+ $code = $outcome -replace "^failed:", ""
296
+ Fail "winget could not install Docker Desktop (exit code $code). No download page was opened and nothing was changed. Install Docker Desktop from https://www.docker.com/products/docker-desktop/, start it once, then launch Culpa again."
297
+ }
298
+ Fail "Docker Desktop was installed. Close this window and open Culpa again to finish starting the stack."
299
+ }
300
+
301
+ # --- 2. Docker running? Start it if not (the whole point of this launcher) ---
302
+ if (-not (Test-DockerUp)) {
303
+ Write-Step "Docker Desktop is not running - starting it"
304
+ $desktop = Join-Path $env:ProgramFiles "Docker\Docker\Docker Desktop.exe"
305
+ if (Test-Path $desktop) {
306
+ Start-Process $desktop
307
+ } else {
308
+ Fail "Docker Desktop is installed but its executable was not found at '$desktop'. Start Docker Desktop yourself, then launch Culpa again."
309
+ }
310
+ # bounded wait: up to 120s for the engine
311
+ $up = $false
312
+ for ($i = 0; $i -lt 60; $i++) {
313
+ Start-Sleep -Seconds 2
314
+ if (Test-DockerUp) { $up = $true; break }
315
+ }
316
+ if (-not $up) { Fail "Docker Desktop did not come up within 2 minutes. Wait for the whale icon to settle, then launch Culpa again." }
317
+ }
318
+
319
+ # --- 3. Stack up (idempotent; also recovers a stopped stack) ---
320
+ if (-not (Test-Path $ComposeDst)) { Fail "Culpa's compose file is missing at '$ComposeDst'. Re-run the installer." }
321
+
322
+ # --- 3a. QW-1: one canonical compose - a newer shipped culpa-compose.yml
323
+ # --- re-pins the live file here, never by hand across three copies
324
+ $ShippedCompose = Join-Path $AppDir "culpa-compose.yml"
325
+ $syncOutcome = Sync-CanonicalCompose -ShippedPath $ShippedCompose -LivePath $ComposeDst
326
+ if ($syncOutcome -eq "synced") { Write-Step "Upgrade detected: live compose re-pinned from the shipped culpa-compose.yml" }
327
+
328
+ # --- 3b. QW-3: refuse-and-explain when the pin goes BACKWARD vs the last
329
+ # --- healthy boot (a stale pin against a migrated DB dies cryptically in
330
+ # --- culpa-migrate - refuse before touching anything)
331
+ $PinnedVersion = Get-PinnedServerVersion (Get-Content $ComposeDst -Raw)
332
+ $LastBootFile = Join-Path $AppDir "last-boot-version.txt"
333
+ $LastBootVersion = if (Test-Path $LastBootFile) { (Get-Content $LastBootFile -Raw).Trim() } else { "" }
334
+ if ((Test-BackwardPin $PinnedVersion $LastBootVersion) -and ($env:CULPA_ALLOW_DOWNGRADE -eq "1")) {
335
+ Write-Host "WARNING: CULPA_ALLOW_DOWNGRADE=1 - launching an OLDER server ($PinnedVersion) against a database last used by $LastBootVersion." -ForegroundColor Yellow
336
+ }
337
+ if ((Test-BackwardPin $PinnedVersion $LastBootVersion) -and ($env:CULPA_ALLOW_DOWNGRADE -ne "1")) {
338
+ Fail @"
339
+ The compose file pins culpa-server $PinnedVersion, but this machine last ran $LastBootVersion.
340
+ Starting an OLDER server against a newer database would fail inside its migration step with a
341
+ cryptic error. Nothing was started.
342
+
343
+ Fix: re-run the current installer (which lays down the right compose), or restore the
344
+ $LastBootVersion pin in '$ComposeDst'. To force this downgrade anyway (only after restoring a
345
+ matching database backup), set CULPA_ALLOW_DOWNGRADE=1 and launch again.
346
+ "@
347
+ }
348
+
349
+ # --- 3c. QW-4: automatic pg_dump before any launch that can migrate. Fails
350
+ # --- closed: no verified backup, no launch (CULPA_SKIP_BACKUP=1 overrides,
351
+ # --- loudly). Only runs when the DB container already exists - a first
352
+ # --- install has nothing to back up.
353
+ $oldEapQw = $ErrorActionPreference
354
+ $ErrorActionPreference = "Continue"
355
+ $containerNames = docker ps -a --format "{{.Names}}" 2>&1
356
+ $dbExists = ($LASTEXITCODE -eq 0 -and "$containerNames" -match "culpa-db")
357
+ $ErrorActionPreference = $oldEapQw
358
+ $BackupBaseline = Resolve-BackupBaseline $LastBootVersion
359
+ $BaselineLabel = if ([string]::IsNullOrWhiteSpace($BackupBaseline)) { "unknown" } else { $BackupBaseline }
360
+ if (Test-BackupNeeded $dbExists $PinnedVersion $BackupBaseline) {
361
+ if ($env:CULPA_SKIP_BACKUP -eq "1") {
362
+ Write-Host "WARNING: CULPA_SKIP_BACKUP=1 - upgrading $BaselineLabel -> $PinnedVersion WITHOUT a database backup." -ForegroundColor Yellow
363
+ } else {
364
+ Write-Step "Version change ($BaselineLabel -> $PinnedVersion): backing up the database first"
365
+ & { $ErrorActionPreference = "Continue"; docker compose -f $ComposeDst -p culpa up -d db 2>&1 | ForEach-Object { "$_" } }
366
+ if ($LASTEXITCODE -ne 0) { Fail "Could not start the database container to take the pre-upgrade backup. Nothing else was started." }
367
+ $pgReady = $false
368
+ for ($i = 0; $i -lt 30; $i++) {
369
+ & { $ErrorActionPreference = "Continue"; $null = docker exec culpa-db pg_isready -U culpa 2>&1 }
370
+ if ($LASTEXITCODE -eq 0) { $pgReady = $true; break }
371
+ Start-Sleep -Seconds 2
372
+ }
373
+ if (-not $pgReady) { Fail "The database did not become ready within 60s, so the pre-upgrade backup could not be taken. Nothing was upgraded." }
374
+ try {
375
+ $dump = Invoke-PreMigrationBackup -BackupDir (Join-Path $AppDir "backups") -Tag $BaselineLabel
376
+ Write-Step "Backup verified: $dump"
377
+ } catch {
378
+ Fail "Pre-upgrade backup failed ($($_.Exception.Message)). Nothing was upgraded. Set CULPA_SKIP_BACKUP=1 to launch without a backup (not recommended)."
379
+ }
380
+ }
381
+ }
382
+
383
+ Write-Step "Starting Culpa"
384
+ # compose writes PROGRESS to stderr; under EAP=Stop with redirected streams
385
+ # (PS 5.1) that becomes a terminating NativeCommandError - stringify the
386
+ # records and judge by the exit code, which is the real signal
387
+ & { $ErrorActionPreference = "Continue"; docker compose -f $ComposeDst -p culpa up -d 2>&1 | ForEach-Object { "$_" } }
388
+ if ($LASTEXITCODE -ne 0) { Fail "Docker could not start the Culpa services. The message above says why." }
389
+
390
+ # --- 3b. Capture collector (CF18): idempotent restart with the stack ---
391
+ # The collector is a host process (loopback-bound by design), so the compose
392
+ # restart policy cannot revive it after a reboot; this launcher is what does.
393
+ $CollectorCtl = Join-Path $PSScriptRoot "culpa-collector.ps1"
394
+ if (Test-Path $CollectorCtl) { & $CollectorCtl -Start }
395
+
396
+ # --- 4. Wait for the dashboard (bounded: 120s), then open it ---
397
+ Write-Step "Waiting for the dashboard"
398
+ $ready = $false
399
+ for ($i = 0; $i -lt 60; $i++) {
400
+ try {
401
+ $resp = Invoke-WebRequest -Uri $ProbeUrl -UseBasicParsing -TimeoutSec 3
402
+ if ($resp.StatusCode -eq 200) { $ready = $true; break }
403
+ } catch { Start-Sleep -Seconds 2 }
404
+ }
405
+ if (-not $ready) { Fail "The services started but the dashboard did not answer within 2 minutes. Run 'docker logs culpa-dashboard' to see why." }
406
+
407
+ # --- 5. Healthy boot: record it (QW-3's baseline) + reconcile the registry
408
+ # --- version from the pin that actually runs (QW-2)
409
+ if ($PinnedVersion) {
410
+ Set-Content -Path $LastBootFile -Value $PinnedVersion
411
+ $ArpKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1"
412
+ $null = Sync-RegistryDisplayVersion -PinnedVersion $PinnedVersion -ArpKey $ArpKey
413
+ }
414
+
415
+ Start-Process $DashboardUrl