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,310 @@
|
|
|
1
|
+
# Culpa installer for Windows (T-087).
|
|
2
|
+
# CR: Compose on Windows is brittle with backslashed bind sources - hand it a
|
|
3
|
+
# forward-slashed home explicitly
|
|
4
|
+
#
|
|
5
|
+
# CF20-T3: -DeferStart / -NonInteractive let `getculpa` (npm) delegate to
|
|
6
|
+
# this EXACT script for shortcuts + HKCU registration, without ever showing
|
|
7
|
+
# a dialog, starting anything, or overwriting an existing live
|
|
8
|
+
# docker-compose.yml (the live file owns the pin - QW-1; see section 3).
|
|
9
|
+
# The Inno [Run] line passes neither switch, so the interactive installer's
|
|
10
|
+
# own behavior is unchanged (culpa-setup.iss:60).
|
|
11
|
+
param(
|
|
12
|
+
[switch]$DeferStart,
|
|
13
|
+
[switch]$NonInteractive
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
$env:CULPA_HOST_HOME = $env:USERPROFILE -replace "\\", "/"
|
|
17
|
+
|
|
18
|
+
# Net effect = the CLI install: docker compose up on the release stack.
|
|
19
|
+
# Non-technical flow: checks Docker Desktop, starts the stack, adds
|
|
20
|
+
# shortcuts, opens the dashboard. Bounded waits everywhere; loud failures.
|
|
21
|
+
|
|
22
|
+
$ErrorActionPreference = "Stop"
|
|
23
|
+
|
|
24
|
+
$AppName = "Culpa"
|
|
25
|
+
# CF5-F11: install where the founder chose. When this script was INSTALLED
|
|
26
|
+
# (setup.exe drops it into {app} beside the compose and the uninstaller), the
|
|
27
|
+
# install directory is simply where it lives. The LOCALAPPDATA default is kept
|
|
28
|
+
# for the zip layout, where the script runs from a scratch folder.
|
|
29
|
+
$InstalledHere = (Test-Path (Join-Path $PSScriptRoot "culpa-compose.yml")) -or
|
|
30
|
+
(Test-Path (Join-Path $PSScriptRoot "unins000.exe"))
|
|
31
|
+
$AppDir = if ($InstalledHere) { $PSScriptRoot } else { Join-Path $env:LOCALAPPDATA $AppName }
|
|
32
|
+
# zip layout keeps the compose one level up; the setup.exe installs it beside us
|
|
33
|
+
$ComposeSrc = Join-Path $PSScriptRoot "..\culpa-compose.yml"
|
|
34
|
+
if (-not (Test-Path $ComposeSrc)) { $ComposeSrc = Join-Path $PSScriptRoot "culpa-compose.yml" }
|
|
35
|
+
$ComposeDst = Join-Path $AppDir "docker-compose.yml"
|
|
36
|
+
$DashboardUrl = "http://localhost:3000"
|
|
37
|
+
|
|
38
|
+
function Write-Step([string]$msg) { Write-Host "==> $msg" }
|
|
39
|
+
|
|
40
|
+
function Fail([string]$msg) {
|
|
41
|
+
Write-Host ""
|
|
42
|
+
Write-Host "INSTALL STOPPED: $msg" -ForegroundColor Red
|
|
43
|
+
Write-Host "Nothing was broken. Fix the item above and run the installer again."
|
|
44
|
+
# -NonInteractive (CF20-T3): never block an automated caller on a keypress.
|
|
45
|
+
if (-not $NonInteractive) { Read-Host "Press Enter to close" }
|
|
46
|
+
exit 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Write-Host ""
|
|
50
|
+
Write-Host " CULPA --- local install" -ForegroundColor Cyan
|
|
51
|
+
Write-Host " Your prompts and responses never leave this machine."
|
|
52
|
+
Write-Host ""
|
|
53
|
+
|
|
54
|
+
# --- 1. Docker Desktop present? (CF6-9: scan, then authorize before any download) ---
|
|
55
|
+
# Test-DockerInstalled takes an overridable -Probe so this exact detection can
|
|
56
|
+
# be exercised with a fake "not found" result without touching a real install.
|
|
57
|
+
function Test-DockerInstalled {
|
|
58
|
+
param([scriptblock]$Probe = { Get-Command docker -ErrorAction SilentlyContinue })
|
|
59
|
+
return ($null -ne (& $Probe))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Real WPF dialog - not a console prompt. Names what's missing, what would be
|
|
63
|
+
# downloaded, and from where; returns $false unless the user explicitly agrees.
|
|
64
|
+
function Show-DockerAuthorizationModal {
|
|
65
|
+
Add-Type -AssemblyName PresentationFramework
|
|
66
|
+
$msg = "Culpa needs Docker Desktop and it was not found on this machine.`n`n" +
|
|
67
|
+
"If you authorize, Culpa will install it via winget (Windows Package Manager) " +
|
|
68
|
+
"using the official package 'Docker.DockerDesktop', or open the official " +
|
|
69
|
+
"download page at https://www.docker.com/products/docker-desktop/ if winget " +
|
|
70
|
+
"is unavailable.`n`nNothing is downloaded until you click Yes."
|
|
71
|
+
$btns = [System.Windows.MessageBoxButton]::YesNo
|
|
72
|
+
$icon = [System.Windows.MessageBoxImage]::Warning
|
|
73
|
+
$result = [System.Windows.MessageBox]::Show($msg, "Culpa - Docker Desktop required", $btns, $icon)
|
|
74
|
+
return ($result -eq [System.Windows.MessageBoxResult]::Yes)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Only called after explicit authorization. Prefers winget (the user's own,
|
|
78
|
+
# auditable package manager); falls back to opening the official vendor page.
|
|
79
|
+
#
|
|
80
|
+
# Returns WHICH path it took, not just pass/fail. Review finding: the caller used
|
|
81
|
+
# to print "the official download page has been opened" for every failure, but
|
|
82
|
+
# the page is only opened when winget is ABSENT. With winget present and the
|
|
83
|
+
# install failing (UAC declined, no network, a transient winget error) the user
|
|
84
|
+
# was sent to look for a browser tab that was never opened — during exactly the
|
|
85
|
+
# moment this flow exists to make trustworthy.
|
|
86
|
+
# "installed" - winget reported success
|
|
87
|
+
# "page-opened" - no winget; the vendor page was opened instead
|
|
88
|
+
# "failed:<n>" - winget ran and exited <n>; nothing else was opened
|
|
89
|
+
function Install-DockerDesktop {
|
|
90
|
+
$winget = Get-Command winget -ErrorAction SilentlyContinue
|
|
91
|
+
if ($null -eq $winget) {
|
|
92
|
+
Start-Process "https://www.docker.com/products/docker-desktop/"
|
|
93
|
+
return "page-opened"
|
|
94
|
+
}
|
|
95
|
+
Write-Step "Installing Docker Desktop via winget (Docker.DockerDesktop)"
|
|
96
|
+
$wingetArgs = @("install", "--id", "Docker.DockerDesktop", "-e", "--accept-package-agreements", "--accept-source-agreements")
|
|
97
|
+
$p = Start-Process -FilePath "winget" -ArgumentList $wingetArgs -Wait -PassThru -NoNewWindow
|
|
98
|
+
if ($p.ExitCode -eq 0) { return "installed" }
|
|
99
|
+
return "failed:$($p.ExitCode)"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Write-Step "Checking Docker Desktop"
|
|
103
|
+
if (-not (Test-DockerInstalled)) {
|
|
104
|
+
if ($NonInteractive -and $DeferStart) {
|
|
105
|
+
# CF20-T3: no dialog, no winget without a TTY consent - refuse and
|
|
106
|
+
# instruct instead, then let the rest of provisioning proceed. Only
|
|
107
|
+
# safe with -DeferStart: provisioning never reaches the engine check
|
|
108
|
+
# below. Without -DeferStart, "continue" would walk straight into
|
|
109
|
+
# Test-DockerEngine with no `docker` binary on PATH - fail here
|
|
110
|
+
# instead (CF20-R review).
|
|
111
|
+
Write-Host "Docker Desktop was not found. Culpa is installed but needs Docker Desktop to run." -ForegroundColor Yellow
|
|
112
|
+
Write-Host "Install it from https://www.docker.com/products/docker-desktop/, then run 'getculpa' (or this installer) again."
|
|
113
|
+
} elseif ($NonInteractive) {
|
|
114
|
+
Fail "Docker Desktop was not found. Install it from https://www.docker.com/products/docker-desktop/, then run this installer again."
|
|
115
|
+
} else {
|
|
116
|
+
if (-not (Show-DockerAuthorizationModal)) {
|
|
117
|
+
Fail "Docker Desktop download was not authorized. Nothing was changed. Install Docker Desktop yourself, then run this installer again."
|
|
118
|
+
}
|
|
119
|
+
$outcome = Install-DockerDesktop
|
|
120
|
+
if ($outcome -eq "page-opened") {
|
|
121
|
+
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 run this installer again."
|
|
122
|
+
}
|
|
123
|
+
if ($outcome -ne "installed") {
|
|
124
|
+
$code = $outcome -replace "^failed:", ""
|
|
125
|
+
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 run this installer again."
|
|
126
|
+
}
|
|
127
|
+
Fail "Docker Desktop was installed. Close this window, open a NEW PowerShell window (so PATH picks it up), then run this installer again to finish setup."
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# --- 2. Docker engine running? (start it if not, wait up to 3 minutes) ---
|
|
132
|
+
function Test-DockerEngine {
|
|
133
|
+
# docker prints warnings to stderr even when healthy; merge streams so
|
|
134
|
+
# strict error mode never mistakes a warning for a dead engine.
|
|
135
|
+
$old = $ErrorActionPreference
|
|
136
|
+
$ErrorActionPreference = "Continue"
|
|
137
|
+
try {
|
|
138
|
+
$null = docker info --format "ok" 2>&1
|
|
139
|
+
return ($LASTEXITCODE -eq 0)
|
|
140
|
+
} finally {
|
|
141
|
+
$ErrorActionPreference = $old
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# -DeferStart (CF20-T3): provisioning-only, never starts the engine - that
|
|
146
|
+
# is getculpa's / launch-culpa.ps1's job at actual start time.
|
|
147
|
+
if (-not $DeferStart) {
|
|
148
|
+
$engineUp = Test-DockerEngine
|
|
149
|
+
if (-not $engineUp) {
|
|
150
|
+
Write-Step "Starting Docker Desktop (this can take a minute)"
|
|
151
|
+
$desktop = Join-Path $env:ProgramFiles "Docker\Docker\Docker Desktop.exe"
|
|
152
|
+
if (Test-Path $desktop) { Start-Process $desktop } else { Fail "Docker Desktop is installed but could not be started automatically. Start it yourself, then run this installer again." }
|
|
153
|
+
for ($i = 0; $i -lt 36; $i++) {
|
|
154
|
+
Start-Sleep -Seconds 5
|
|
155
|
+
if (Test-DockerEngine) { $engineUp = $true; break }
|
|
156
|
+
}
|
|
157
|
+
if (-not $engineUp) { Fail "Docker Desktop did not become ready within 3 minutes. Wait for its whale icon to settle, then run this installer again." }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# --- 3. Install files (always - -DeferStart still performs dir/compose) ---
|
|
162
|
+
Write-Step "Installing to $AppDir"
|
|
163
|
+
New-Item -ItemType Directory -Force -Path $AppDir | Out-Null
|
|
164
|
+
# CF20-T3 review (Critical): under -DeferStart an EXISTING live compose is
|
|
165
|
+
# never overwritten. The live file owns the pin (QW-1); version deltas are
|
|
166
|
+
# reconciled by launch-culpa.ps1 at next start, where QW-3 can refuse a
|
|
167
|
+
# downgrade and QW-4 takes a backup first. A blind copy here would silently
|
|
168
|
+
# re-pin (or downgrade) a customer's stack on every npm reinstall/update with
|
|
169
|
+
# no refusal, no backup, and no trace. The interactive installer path (no
|
|
170
|
+
# -DeferStart) keeps its historical refresh behavior unchanged.
|
|
171
|
+
if ($DeferStart -and (Test-Path $ComposeDst)) {
|
|
172
|
+
Write-Step "Existing live docker-compose.yml preserved (version reconciliation happens at next launch)"
|
|
173
|
+
} else {
|
|
174
|
+
Copy-Item $ComposeSrc $ComposeDst -Force
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# --- 4/5. Start the stack + wait for the dashboard (skipped under -DeferStart) ---
|
|
178
|
+
if (-not $DeferStart) {
|
|
179
|
+
Write-Step "Starting Culpa (docker compose up -d)"
|
|
180
|
+
# Culpa's images are PRIVATE during the testing phase, so an unauthenticated
|
|
181
|
+
# machine dies at the pull with a bare "denied" that means nothing to a
|
|
182
|
+
# founder. Capture the output and name that case explicitly.
|
|
183
|
+
# EAP note (Culpaflight #2 lesson): native stderr under Stop kills compose
|
|
184
|
+
# progress - drop to Continue for the call and judge the exit code instead.
|
|
185
|
+
$composeOutput = ""
|
|
186
|
+
$oldEap = $ErrorActionPreference
|
|
187
|
+
$ErrorActionPreference = "Continue"
|
|
188
|
+
try {
|
|
189
|
+
$composeOutput = (docker compose -f $ComposeDst -p culpa up -d 2>&1 | Out-String)
|
|
190
|
+
} finally {
|
|
191
|
+
$ErrorActionPreference = $oldEap
|
|
192
|
+
}
|
|
193
|
+
Write-Host $composeOutput
|
|
194
|
+
if ($LASTEXITCODE -ne 0) {
|
|
195
|
+
if ($composeOutput -match "denied|unauthorized|403|authentication required") {
|
|
196
|
+
Fail @"
|
|
197
|
+
Culpa's images are private while it is in testing, and this machine is not signed in to the registry.
|
|
198
|
+
|
|
199
|
+
One-time fix:
|
|
200
|
+
1. Create a GitHub token with ONLY the 'read:packages' scope:
|
|
201
|
+
https://github.com/settings/tokens/new?scopes=read:packages
|
|
202
|
+
2. Sign in (paste the token when it asks for a password):
|
|
203
|
+
docker login ghcr.io -u YOUR_GITHUB_USERNAME
|
|
204
|
+
3. Run this installer again.
|
|
205
|
+
|
|
206
|
+
Nothing was changed on this machine.
|
|
207
|
+
"@
|
|
208
|
+
}
|
|
209
|
+
Fail "Docker could not start the Culpa services. The message above says why (most often: no internet to pull images, or ports 3000/4545 already in use)."
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
Write-Step "Waiting for the dashboard to come up"
|
|
213
|
+
$ready = $false
|
|
214
|
+
for ($i = 0; $i -lt 24; $i++) {
|
|
215
|
+
Start-Sleep -Seconds 5
|
|
216
|
+
try {
|
|
217
|
+
$resp = Invoke-WebRequest -Uri $DashboardUrl -UseBasicParsing -TimeoutSec 5
|
|
218
|
+
if ($resp.StatusCode -eq 200) { $ready = $true; break }
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
if (-not $ready) { Fail "The services started but the dashboard did not answer within 2 minutes. Run 'docker logs culpa-server' and 'docker logs culpa-dashboard' to see why." }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# --- 6. Shortcuts (Desktop + Start Menu) ---
|
|
225
|
+
# Culpaflight #2: shortcuts run the LAUNCHER, not a bare URL — a .url dead-ends
|
|
226
|
+
# at "connection refused" whenever Docker Desktop is off; the launcher starts
|
|
227
|
+
# Docker (bounded wait), brings the stack up, THEN opens the dashboard.
|
|
228
|
+
Write-Step "Creating shortcuts"
|
|
229
|
+
# setup.exe already installed the scripts into $AppDir — copying a file onto
|
|
230
|
+
# itself is an error under Stop, so only copy from the zip layout
|
|
231
|
+
if ($PSScriptRoot -ne $AppDir) { Copy-Item (Join-Path $PSScriptRoot "launch-culpa.ps1") (Join-Path $AppDir "launch-culpa.ps1") -Force }
|
|
232
|
+
$launcher = Join-Path $AppDir "launch-culpa.ps1"
|
|
233
|
+
$shell = New-Object -ComObject WScript.Shell
|
|
234
|
+
foreach ($dir in @([Environment]::GetFolderPath("Desktop"), (Join-Path ([Environment]::GetFolderPath("StartMenu")) "Programs\Culpa"))) {
|
|
235
|
+
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
236
|
+
$lnk = $shell.CreateShortcut((Join-Path $dir "Culpa.lnk"))
|
|
237
|
+
$lnk.TargetPath = "powershell.exe"
|
|
238
|
+
$lnk.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$launcher`""
|
|
239
|
+
$lnk.WorkingDirectory = $AppDir
|
|
240
|
+
$lnk.Description = "Culpa - LLM spend forensics (starts Docker if needed)"
|
|
241
|
+
$lnk.Save()
|
|
242
|
+
# the old bare-URL shortcut from earlier installs is superseded
|
|
243
|
+
Remove-Item -Path (Join-Path $dir "Culpa Dashboard.url") -ErrorAction SilentlyContinue
|
|
244
|
+
}
|
|
245
|
+
if ($PSScriptRoot -ne $AppDir) { Copy-Item (Join-Path $PSScriptRoot "uninstall-culpa.ps1") (Join-Path $AppDir "uninstall-culpa.ps1") -Force }
|
|
246
|
+
|
|
247
|
+
# --- 7. Register in Add/Remove Programs (T-171) ---
|
|
248
|
+
# This path wrote NO registry key at all, so a machine installed by the script
|
|
249
|
+
# (or by the documented CLI/compose route) never appeared in Windows Settings
|
|
250
|
+
# -> Installed apps - even though installers/README.md names that as THE
|
|
251
|
+
# uninstall route. setup.exe installs are already listed, because Inno writes
|
|
252
|
+
# its own key; a second key would show two Culpas whose uninstallers each
|
|
253
|
+
# delete the other's files. The wizard's own uninstaller is the signal to skip.
|
|
254
|
+
$UninsExe = Join-Path $AppDir "unins000.exe"
|
|
255
|
+
if (Test-Path $UninsExe) {
|
|
256
|
+
Write-Step "Add/Remove Programs entry belongs to the installer - not adding a second"
|
|
257
|
+
} else {
|
|
258
|
+
Write-Step "Registering in Add/Remove Programs"
|
|
259
|
+
# the SAME key name Inno uses ({AppId}_is1), so the two paths can never
|
|
260
|
+
# both be listed: a later exe install overwrites this entry instead of
|
|
261
|
+
# racing it, and its uninstaller then removes the only one there is
|
|
262
|
+
$ArpKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1"
|
|
263
|
+
# the version is the PINNED image tag in the compose just installed - the
|
|
264
|
+
# release this machine actually runs. A literal here would drift at every
|
|
265
|
+
# release and then report the wrong version forever, silently.
|
|
266
|
+
$installedVersion = "unknown"
|
|
267
|
+
$composeText = Get-Content $ComposeDst -Raw
|
|
268
|
+
if ($composeText -match 'culpa-server:([^"\s]+)') { $installedVersion = $Matches[1] }
|
|
269
|
+
$uninstaller = Join-Path $AppDir "uninstall-culpa.ps1"
|
|
270
|
+
$psRun = "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$uninstaller`""
|
|
271
|
+
New-Item -Path $ArpKey -Force | Out-Null
|
|
272
|
+
New-ItemProperty -Path $ArpKey -Name "DisplayName" -Value "Culpa - LLM spend forensics" -PropertyType String -Force | Out-Null
|
|
273
|
+
New-ItemProperty -Path $ArpKey -Name "DisplayVersion" -Value $installedVersion -PropertyType String -Force | Out-Null
|
|
274
|
+
New-ItemProperty -Path $ArpKey -Name "Publisher" -Value "Myaigi" -PropertyType String -Force | Out-Null
|
|
275
|
+
New-ItemProperty -Path $ArpKey -Name "InstallLocation" -Value $AppDir -PropertyType String -Force | Out-Null
|
|
276
|
+
New-ItemProperty -Path $ArpKey -Name "UninstallString" -Value $psRun -PropertyType String -Force | Out-Null
|
|
277
|
+
# what Settings runs for its unattended path. -FromUninstaller is the
|
|
278
|
+
# branch that ALWAYS keeps the data volume, so a click in Settings can
|
|
279
|
+
# never silently destroy recorded spend.
|
|
280
|
+
New-ItemProperty -Path $ArpKey -Name "QuietUninstallString" -Value "$psRun -FromUninstaller" -PropertyType String -Force | Out-Null
|
|
281
|
+
New-ItemProperty -Path $ArpKey -Name "NoModify" -Value 1 -PropertyType DWord -Force | Out-Null
|
|
282
|
+
New-ItemProperty -Path $ArpKey -Name "NoRepair" -Value 1 -PropertyType DWord -Force | Out-Null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
# --- 7b. Start the capture collector (CF18, D-088 items 4/5; skipped under -DeferStart) ---
|
|
286
|
+
# The capture plane wires itself: no hand-assembly. Never fails the install —
|
|
287
|
+
# the helper degrades to "capture OFF, everything else works" loudly.
|
|
288
|
+
if (-not $DeferStart) {
|
|
289
|
+
$CollectorCtl = Join-Path $PSScriptRoot "culpa-collector.ps1"
|
|
290
|
+
if (Test-Path $CollectorCtl) {
|
|
291
|
+
Write-Step "Starting the capture collector"
|
|
292
|
+
& $CollectorCtl -Start
|
|
293
|
+
} else {
|
|
294
|
+
Write-Step "Capture collector not in this package - skipping (zip layout pre-CF18)"
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# --- 8. Open it (skipped under -DeferStart: provisioning leaves Culpa dormant) ---
|
|
299
|
+
if ($DeferStart) {
|
|
300
|
+
Write-Step "Done. Culpa is installed but not started (deferred)."
|
|
301
|
+
Write-Host ""
|
|
302
|
+
Write-Host "Culpa is installed. Run 'getculpa' (or launch-culpa.ps1 in $AppDir) to start it." -ForegroundColor Green
|
|
303
|
+
} else {
|
|
304
|
+
Write-Step "Done. Opening the dashboard."
|
|
305
|
+
Start-Process $DashboardUrl
|
|
306
|
+
Write-Host ""
|
|
307
|
+
Write-Host "Culpa is running. It starts again automatically when Docker Desktop starts." -ForegroundColor Green
|
|
308
|
+
}
|
|
309
|
+
Write-Host "To remove it later: run uninstall-culpa.ps1 in $AppDir"
|
|
310
|
+
if (-not $NonInteractive) { Read-Host "Press Enter to close" }
|
|
@@ -0,0 +1,350 @@
|
|
|
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
|
+
function Get-PinnedServerVersion([string]$ComposeText) {
|
|
46
|
+
if ($ComposeText -match 'culpa-server:([^"\s]+)') { return $Matches[1] }
|
|
47
|
+
return $null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# vX.Y.Z comparison: -1 (A older), 0 (equal/unparsable), 1 (A newer).
|
|
51
|
+
# Unparsable tags return 0 deliberately - the guards must fail OPEN on exotic
|
|
52
|
+
# tags (a dev tag must not brick launch), and QW-4 still backs up on ANY
|
|
53
|
+
# difference, so an unparsable tag still gets the backup.
|
|
54
|
+
function Compare-CulpaVersions([string]$A, [string]$B) {
|
|
55
|
+
$rx = '^v?(\d+)\.(\d+)\.(\d+)'
|
|
56
|
+
if ($A -notmatch $rx) { return 0 }
|
|
57
|
+
$pa = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
|
|
58
|
+
if ($B -notmatch $rx) { return 0 }
|
|
59
|
+
$pb = @([int]$Matches[1], [int]$Matches[2], [int]$Matches[3])
|
|
60
|
+
for ($i = 0; $i -lt 3; $i++) {
|
|
61
|
+
if ($pa[$i] -lt $pb[$i]) { return -1 }
|
|
62
|
+
if ($pa[$i] -gt $pb[$i]) { return 1 }
|
|
63
|
+
}
|
|
64
|
+
return 0
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# QW-1 - ONE canonical compose. The live file ($LivePath) owns the pin; the
|
|
68
|
+
# shipped culpa-compose.yml beside the install ($ShippedPath) is only a
|
|
69
|
+
# SOURCE: when an upgrade drops a NEWER shipped file, the live one is
|
|
70
|
+
# re-pinned from it here, on launch - the step the v0.8.1 upgrade needed a
|
|
71
|
+
# human for, three files at a time. An OLDER shipped file never wins.
|
|
72
|
+
# Returns: "synced" | "kept" | "no-shipped" | "no-live".
|
|
73
|
+
function Sync-CanonicalCompose([string]$ShippedPath, [string]$LivePath) {
|
|
74
|
+
if (-not (Test-Path $LivePath)) { return "no-live" }
|
|
75
|
+
if (-not (Test-Path $ShippedPath)) { return "no-shipped" }
|
|
76
|
+
$shipped = Get-PinnedServerVersion (Get-Content $ShippedPath -Raw)
|
|
77
|
+
$live = Get-PinnedServerVersion (Get-Content $LivePath -Raw)
|
|
78
|
+
if ($null -eq $shipped -or $null -eq $live) { return "kept" }
|
|
79
|
+
if ((Compare-CulpaVersions $shipped $live) -eq 1) {
|
|
80
|
+
Copy-Item $ShippedPath $LivePath -Force
|
|
81
|
+
return "synced"
|
|
82
|
+
}
|
|
83
|
+
return "kept"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# QW-3 - refuse-and-explain on a backward pin. $true = refuse. A pinned
|
|
87
|
+
# server OLDER than the version that last booted healthily means its known
|
|
88
|
+
# migrations disagree with the database's history - culpa-migrate would die
|
|
89
|
+
# with a cryptic error AFTER the recreate. Refuse BEFORE touching anything.
|
|
90
|
+
#
|
|
91
|
+
# CodeRabbit PR13 #6: Compare-CulpaVersions fails OPEN (0, "equal") on any
|
|
92
|
+
# unparsable tag so exotic dev tags never brick launch - but that means a
|
|
93
|
+
# MALFORMED pin against a well-formed, known baseline used to read as
|
|
94
|
+
# "equal" and launch anyway. That pin cannot be proven newer, so it must
|
|
95
|
+
# fail CLOSED instead: refuse (the CULPA_ALLOW_DOWNGRADE escape still
|
|
96
|
+
# applies). Both-unparsable and no-baseline stay permissive (dev installs).
|
|
97
|
+
function Test-BackwardPin([string]$PinnedVersion, [string]$LastBootVersion) {
|
|
98
|
+
if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { return $false }
|
|
99
|
+
if ([string]::IsNullOrWhiteSpace($LastBootVersion)) { return $false }
|
|
100
|
+
$rx = '^v?(\d+)\.(\d+)\.(\d+)'
|
|
101
|
+
if (($PinnedVersion -notmatch $rx) -and ($LastBootVersion -match $rx)) { return $true }
|
|
102
|
+
return ((Compare-CulpaVersions $PinnedVersion $LastBootVersion) -eq -1)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# QW-4 - automatic pg_dump before any launch that can migrate (pin differs
|
|
106
|
+
# from the last healthy boot). Fails CLOSED: no verified dump, no launch -
|
|
107
|
+
# unless the caller passed an explicit skip. $DumpExec/$CopyExec are
|
|
108
|
+
# injectable for the probe script. Returns the dump path on success; throws
|
|
109
|
+
# with a human-readable reason on failure.
|
|
110
|
+
function Invoke-PreMigrationBackup {
|
|
111
|
+
param(
|
|
112
|
+
[string]$BackupDir,
|
|
113
|
+
[string]$Tag,
|
|
114
|
+
[scriptblock]$DumpExec = {
|
|
115
|
+
docker exec culpa-db sh -c "pg_dump -Fc -U culpa culpa > /tmp/culpa-pre-upgrade.dump" 2>&1 | Out-Null
|
|
116
|
+
return $LASTEXITCODE
|
|
117
|
+
},
|
|
118
|
+
[scriptblock]$CopyExec = {
|
|
119
|
+
param($Dest)
|
|
120
|
+
docker cp culpa-db:/tmp/culpa-pre-upgrade.dump $Dest 2>&1 | Out-Null
|
|
121
|
+
return $LASTEXITCODE
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
|
|
125
|
+
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
126
|
+
$dest = Join-Path $BackupDir "pre-$Tag-$stamp.dump"
|
|
127
|
+
if ((& $DumpExec) -ne 0) { throw "pg_dump inside culpa-db failed" }
|
|
128
|
+
if ((& $CopyExec $dest) -ne 0) { throw "copying the dump out of culpa-db failed" }
|
|
129
|
+
$item = Get-Item -Path $dest -ErrorAction SilentlyContinue
|
|
130
|
+
if ($null -eq $item -or $item.Length -lt 1024) {
|
|
131
|
+
throw "the dump at '$dest' is missing or implausibly small ($(if ($item) { $item.Length } else { 0 }) bytes)"
|
|
132
|
+
}
|
|
133
|
+
return $dest
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# QW-4's baseline resolver (CF8 review Critical-2): the recorded last
|
|
137
|
+
# healthy boot wins; when NO record exists yet (first run of this guard on
|
|
138
|
+
# an existing install - exactly the rollout population) the baseline is
|
|
139
|
+
# derived from the culpa-server container's ACTUAL running image tag; if
|
|
140
|
+
# neither source yields a version the baseline is UNKNOWN ("").
|
|
141
|
+
function Resolve-BackupBaseline(
|
|
142
|
+
[string]$Recorded,
|
|
143
|
+
[scriptblock]$InspectExec = {
|
|
144
|
+
$old = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
|
145
|
+
try { docker inspect culpa-server --format "{{.Config.Image}}" 2>&1 } finally { $ErrorActionPreference = $old }
|
|
146
|
+
}
|
|
147
|
+
) {
|
|
148
|
+
if (-not [string]::IsNullOrWhiteSpace($Recorded)) { return $Recorded.Trim() }
|
|
149
|
+
$image = ""
|
|
150
|
+
try { $image = ("$(& $InspectExec)").Trim() } catch { $image = "" }
|
|
151
|
+
if ($image -match ':(v[0-9][^:\s]*)$') { return $Matches[1] }
|
|
152
|
+
return ""
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# QW-4's decision, extracted so the probe can execute the WIRED condition
|
|
156
|
+
# (the review proved the old inline gate was silently false at
|
|
157
|
+
# baseline="" + data present - the exact first-activation state): data
|
|
158
|
+
# present + an UNKNOWN baseline means BACK UP, never skip; only a baseline
|
|
159
|
+
# that is known AND equal to the pin skips the backup.
|
|
160
|
+
function Test-BackupNeeded([bool]$DbExists, [string]$PinnedVersion, [string]$Baseline) {
|
|
161
|
+
if (-not $DbExists) { return $false }
|
|
162
|
+
if ([string]::IsNullOrWhiteSpace($Baseline)) { return $true }
|
|
163
|
+
if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { return $true }
|
|
164
|
+
return ($PinnedVersion -ne $Baseline)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# QW-2 - reconcile the Add/Remove Programs DisplayVersion from the pin that
|
|
168
|
+
# is ACTUALLY about to run, so Settings can never report a stale version
|
|
169
|
+
# again (it read v0.8.0 while v0.8.1 ran). No-op when the key is absent.
|
|
170
|
+
function Sync-RegistryDisplayVersion([string]$PinnedVersion, [string]$ArpKey) {
|
|
171
|
+
if ([string]::IsNullOrWhiteSpace($PinnedVersion)) { return "no-pin" }
|
|
172
|
+
if (-not (Test-Path $ArpKey)) { return "no-key" }
|
|
173
|
+
$current = (Get-ItemProperty -Path $ArpKey -Name "DisplayVersion" -ErrorAction SilentlyContinue).DisplayVersion
|
|
174
|
+
if ($current -eq $PinnedVersion) { return "unchanged" }
|
|
175
|
+
New-ItemProperty -Path $ArpKey -Name "DisplayVersion" -Value $PinnedVersion -PropertyType String -Force | Out-Null
|
|
176
|
+
return "updated"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# --- 1. Docker present? (CF6-9: scan, then authorize before any download) ---
|
|
180
|
+
# Test-DockerInstalled takes an overridable -Probe so this exact detection can
|
|
181
|
+
# be exercised with a fake "not found" result without touching a real install.
|
|
182
|
+
function Test-DockerInstalled {
|
|
183
|
+
param([scriptblock]$Probe = { Get-Command docker -ErrorAction SilentlyContinue })
|
|
184
|
+
return ($null -ne (& $Probe))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Real WPF dialog - not a console prompt. Names what's missing, what would be
|
|
188
|
+
# downloaded, and from where; returns $false unless the user explicitly agrees.
|
|
189
|
+
function Show-DockerAuthorizationModal {
|
|
190
|
+
Add-Type -AssemblyName PresentationFramework
|
|
191
|
+
$msg = "Culpa needs Docker Desktop and it was not found on this machine.`n`n" +
|
|
192
|
+
"If you authorize, Culpa will install it via winget (Windows Package Manager) " +
|
|
193
|
+
"using the official package 'Docker.DockerDesktop', or open the official " +
|
|
194
|
+
"download page at https://www.docker.com/products/docker-desktop/ if winget " +
|
|
195
|
+
"is unavailable.`n`nNothing is downloaded until you click Yes."
|
|
196
|
+
$btns = [System.Windows.MessageBoxButton]::YesNo
|
|
197
|
+
$icon = [System.Windows.MessageBoxImage]::Warning
|
|
198
|
+
$result = [System.Windows.MessageBox]::Show($msg, "Culpa - Docker Desktop required", $btns, $icon)
|
|
199
|
+
return ($result -eq [System.Windows.MessageBoxResult]::Yes)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# Only called after explicit authorization. Prefers winget (the user's own,
|
|
203
|
+
# auditable package manager); falls back to opening the official vendor page.
|
|
204
|
+
# Returns WHICH path it took (see install-culpa.ps1 for the full rationale):
|
|
205
|
+
# "installed" | "page-opened" | "failed:<exit code>". A bare $false made the
|
|
206
|
+
# caller claim a download page had been opened when winget was present and the
|
|
207
|
+
# install simply failed - no page is opened on that path.
|
|
208
|
+
function Install-DockerDesktop {
|
|
209
|
+
$winget = Get-Command winget -ErrorAction SilentlyContinue
|
|
210
|
+
if ($null -eq $winget) {
|
|
211
|
+
Start-Process "https://www.docker.com/products/docker-desktop/"
|
|
212
|
+
return "page-opened"
|
|
213
|
+
}
|
|
214
|
+
Write-Step "Installing Docker Desktop via winget (Docker.DockerDesktop)"
|
|
215
|
+
$wingetArgs = @("install", "--id", "Docker.DockerDesktop", "-e", "--accept-package-agreements", "--accept-source-agreements")
|
|
216
|
+
$p = Start-Process -FilePath "winget" -ArgumentList $wingetArgs -Wait -PassThru -NoNewWindow
|
|
217
|
+
if ($p.ExitCode -eq 0) { return "installed" }
|
|
218
|
+
return "failed:$($p.ExitCode)"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (-not (Test-DockerInstalled)) {
|
|
222
|
+
if (-not (Show-DockerAuthorizationModal)) {
|
|
223
|
+
Fail "Docker Desktop download was not authorized. Nothing was changed."
|
|
224
|
+
}
|
|
225
|
+
$outcome = Install-DockerDesktop
|
|
226
|
+
if ($outcome -eq "page-opened") {
|
|
227
|
+
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."
|
|
228
|
+
}
|
|
229
|
+
if ($outcome -ne "installed") {
|
|
230
|
+
$code = $outcome -replace "^failed:", ""
|
|
231
|
+
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."
|
|
232
|
+
}
|
|
233
|
+
Fail "Docker Desktop was installed. Close this window and open Culpa again to finish starting the stack."
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# --- 2. Docker running? Start it if not (the whole point of this launcher) ---
|
|
237
|
+
if (-not (Test-DockerUp)) {
|
|
238
|
+
Write-Step "Docker Desktop is not running - starting it"
|
|
239
|
+
$desktop = Join-Path $env:ProgramFiles "Docker\Docker\Docker Desktop.exe"
|
|
240
|
+
if (Test-Path $desktop) {
|
|
241
|
+
Start-Process $desktop
|
|
242
|
+
} else {
|
|
243
|
+
Fail "Docker Desktop is installed but its executable was not found at '$desktop'. Start Docker Desktop yourself, then launch Culpa again."
|
|
244
|
+
}
|
|
245
|
+
# bounded wait: up to 120s for the engine
|
|
246
|
+
$up = $false
|
|
247
|
+
for ($i = 0; $i -lt 60; $i++) {
|
|
248
|
+
Start-Sleep -Seconds 2
|
|
249
|
+
if (Test-DockerUp) { $up = $true; break }
|
|
250
|
+
}
|
|
251
|
+
if (-not $up) { Fail "Docker Desktop did not come up within 2 minutes. Wait for the whale icon to settle, then launch Culpa again." }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# --- 3. Stack up (idempotent; also recovers a stopped stack) ---
|
|
255
|
+
if (-not (Test-Path $ComposeDst)) { Fail "Culpa's compose file is missing at '$ComposeDst'. Re-run the installer." }
|
|
256
|
+
|
|
257
|
+
# --- 3a. QW-1: one canonical compose - a newer shipped culpa-compose.yml
|
|
258
|
+
# --- re-pins the live file here, never by hand across three copies
|
|
259
|
+
$ShippedCompose = Join-Path $AppDir "culpa-compose.yml"
|
|
260
|
+
$syncOutcome = Sync-CanonicalCompose -ShippedPath $ShippedCompose -LivePath $ComposeDst
|
|
261
|
+
if ($syncOutcome -eq "synced") { Write-Step "Upgrade detected: live compose re-pinned from the shipped culpa-compose.yml" }
|
|
262
|
+
|
|
263
|
+
# --- 3b. QW-3: refuse-and-explain when the pin goes BACKWARD vs the last
|
|
264
|
+
# --- healthy boot (a stale pin against a migrated DB dies cryptically in
|
|
265
|
+
# --- culpa-migrate - refuse before touching anything)
|
|
266
|
+
$PinnedVersion = Get-PinnedServerVersion (Get-Content $ComposeDst -Raw)
|
|
267
|
+
$LastBootFile = Join-Path $AppDir "last-boot-version.txt"
|
|
268
|
+
$LastBootVersion = if (Test-Path $LastBootFile) { (Get-Content $LastBootFile -Raw).Trim() } else { "" }
|
|
269
|
+
if ((Test-BackwardPin $PinnedVersion $LastBootVersion) -and ($env:CULPA_ALLOW_DOWNGRADE -eq "1")) {
|
|
270
|
+
Write-Host "WARNING: CULPA_ALLOW_DOWNGRADE=1 - launching an OLDER server ($PinnedVersion) against a database last used by $LastBootVersion." -ForegroundColor Yellow
|
|
271
|
+
}
|
|
272
|
+
if ((Test-BackwardPin $PinnedVersion $LastBootVersion) -and ($env:CULPA_ALLOW_DOWNGRADE -ne "1")) {
|
|
273
|
+
Fail @"
|
|
274
|
+
The compose file pins culpa-server $PinnedVersion, but this machine last ran $LastBootVersion.
|
|
275
|
+
Starting an OLDER server against a newer database would fail inside its migration step with a
|
|
276
|
+
cryptic error. Nothing was started.
|
|
277
|
+
|
|
278
|
+
Fix: re-run the current installer (which lays down the right compose), or restore the
|
|
279
|
+
$LastBootVersion pin in '$ComposeDst'. To force this downgrade anyway (only after restoring a
|
|
280
|
+
matching database backup), set CULPA_ALLOW_DOWNGRADE=1 and launch again.
|
|
281
|
+
"@
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
# --- 3c. QW-4: automatic pg_dump before any launch that can migrate. Fails
|
|
285
|
+
# --- closed: no verified backup, no launch (CULPA_SKIP_BACKUP=1 overrides,
|
|
286
|
+
# --- loudly). Only runs when the DB container already exists - a first
|
|
287
|
+
# --- install has nothing to back up.
|
|
288
|
+
$oldEapQw = $ErrorActionPreference
|
|
289
|
+
$ErrorActionPreference = "Continue"
|
|
290
|
+
$containerNames = docker ps -a --format "{{.Names}}" 2>&1
|
|
291
|
+
$dbExists = ($LASTEXITCODE -eq 0 -and "$containerNames" -match "culpa-db")
|
|
292
|
+
$ErrorActionPreference = $oldEapQw
|
|
293
|
+
$BackupBaseline = Resolve-BackupBaseline $LastBootVersion
|
|
294
|
+
$BaselineLabel = if ([string]::IsNullOrWhiteSpace($BackupBaseline)) { "unknown" } else { $BackupBaseline }
|
|
295
|
+
if (Test-BackupNeeded $dbExists $PinnedVersion $BackupBaseline) {
|
|
296
|
+
if ($env:CULPA_SKIP_BACKUP -eq "1") {
|
|
297
|
+
Write-Host "WARNING: CULPA_SKIP_BACKUP=1 - upgrading $BaselineLabel -> $PinnedVersion WITHOUT a database backup." -ForegroundColor Yellow
|
|
298
|
+
} else {
|
|
299
|
+
Write-Step "Version change ($BaselineLabel -> $PinnedVersion): backing up the database first"
|
|
300
|
+
& { $ErrorActionPreference = "Continue"; docker compose -f $ComposeDst -p culpa up -d db 2>&1 | ForEach-Object { "$_" } }
|
|
301
|
+
if ($LASTEXITCODE -ne 0) { Fail "Could not start the database container to take the pre-upgrade backup. Nothing else was started." }
|
|
302
|
+
$pgReady = $false
|
|
303
|
+
for ($i = 0; $i -lt 30; $i++) {
|
|
304
|
+
& { $ErrorActionPreference = "Continue"; $null = docker exec culpa-db pg_isready -U culpa 2>&1 }
|
|
305
|
+
if ($LASTEXITCODE -eq 0) { $pgReady = $true; break }
|
|
306
|
+
Start-Sleep -Seconds 2
|
|
307
|
+
}
|
|
308
|
+
if (-not $pgReady) { Fail "The database did not become ready within 60s, so the pre-upgrade backup could not be taken. Nothing was upgraded." }
|
|
309
|
+
try {
|
|
310
|
+
$dump = Invoke-PreMigrationBackup -BackupDir (Join-Path $AppDir "backups") -Tag $BaselineLabel
|
|
311
|
+
Write-Step "Backup verified: $dump"
|
|
312
|
+
} catch {
|
|
313
|
+
Fail "Pre-upgrade backup failed ($($_.Exception.Message)). Nothing was upgraded. Set CULPA_SKIP_BACKUP=1 to launch without a backup (not recommended)."
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
Write-Step "Starting Culpa"
|
|
319
|
+
# compose writes PROGRESS to stderr; under EAP=Stop with redirected streams
|
|
320
|
+
# (PS 5.1) that becomes a terminating NativeCommandError - stringify the
|
|
321
|
+
# records and judge by the exit code, which is the real signal
|
|
322
|
+
& { $ErrorActionPreference = "Continue"; docker compose -f $ComposeDst -p culpa up -d 2>&1 | ForEach-Object { "$_" } }
|
|
323
|
+
if ($LASTEXITCODE -ne 0) { Fail "Docker could not start the Culpa services. The message above says why." }
|
|
324
|
+
|
|
325
|
+
# --- 3b. Capture collector (CF18): idempotent restart with the stack ---
|
|
326
|
+
# The collector is a host process (loopback-bound by design), so the compose
|
|
327
|
+
# restart policy cannot revive it after a reboot; this launcher is what does.
|
|
328
|
+
$CollectorCtl = Join-Path $PSScriptRoot "culpa-collector.ps1"
|
|
329
|
+
if (Test-Path $CollectorCtl) { & $CollectorCtl -Start }
|
|
330
|
+
|
|
331
|
+
# --- 4. Wait for the dashboard (bounded: 120s), then open it ---
|
|
332
|
+
Write-Step "Waiting for the dashboard"
|
|
333
|
+
$ready = $false
|
|
334
|
+
for ($i = 0; $i -lt 60; $i++) {
|
|
335
|
+
try {
|
|
336
|
+
$resp = Invoke-WebRequest -Uri $ProbeUrl -UseBasicParsing -TimeoutSec 3
|
|
337
|
+
if ($resp.StatusCode -eq 200) { $ready = $true; break }
|
|
338
|
+
} catch { Start-Sleep -Seconds 2 }
|
|
339
|
+
}
|
|
340
|
+
if (-not $ready) { Fail "The services started but the dashboard did not answer within 2 minutes. Run 'docker logs culpa-dashboard' to see why." }
|
|
341
|
+
|
|
342
|
+
# --- 5. Healthy boot: record it (QW-3's baseline) + reconcile the registry
|
|
343
|
+
# --- version from the pin that actually runs (QW-2)
|
|
344
|
+
if ($PinnedVersion) {
|
|
345
|
+
Set-Content -Path $LastBootFile -Value $PinnedVersion
|
|
346
|
+
$ArpKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1"
|
|
347
|
+
$null = Sync-RegistryDisplayVersion -PinnedVersion $PinnedVersion -ArpKey $ArpKey
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
Start-Process $DashboardUrl
|