getculpa 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -18
- package/assets/culpa-collector.ps1 +137 -0
- package/assets/culpa-compose.yml +144 -0
- package/assets/install-culpa.ps1 +310 -0
- package/assets/launch-culpa.ps1 +350 -0
- package/assets/register.mjs +713 -0
- package/assets/uninstall-culpa.ps1 +117 -0
- package/bin/culpa.js +21 -10
- package/bin/getculpa.js +147 -0
- package/lib/assets.d.mts +2 -0
- package/lib/assets.mjs +36 -0
- package/lib/docker.d.mts +72 -0
- package/lib/docker.mjs +251 -0
- package/lib/doctor.d.mts +29 -0
- package/lib/doctor.mjs +392 -0
- package/lib/fetch.d.mts +17 -0
- package/lib/fetch.mjs +211 -0
- package/lib/paths.d.mts +9 -0
- package/lib/paths.mjs +55 -0
- package/lib/preflight.d.mts +39 -0
- package/lib/preflight.mjs +169 -0
- package/lib/provision.d.mts +25 -0
- package/lib/provision.mjs +237 -0
- package/lib/repair.d.mts +20 -0
- package/lib/repair.mjs +135 -0
- package/lib/start.d.mts +42 -0
- package/lib/start.mjs +392 -0
- package/lib/status.d.mts +23 -0
- package/lib/status.mjs +74 -0
- package/lib/stop.d.mts +15 -0
- package/lib/stop.mjs +46 -0
- package/lib/uninstall.d.mts +17 -0
- package/lib/uninstall.mjs +99 -0
- package/package.json +24 -36
- package/scripts/install.js +135 -0
- package/scripts/prepack.js +41 -0
- package/index.js +0 -3
package/README.md
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
1
|
+
# getculpa
|
|
2
|
+
|
|
3
|
+
`packaging/npm-getculpa` supersedes the `installers/npm@0.0.1` name-claim
|
|
4
|
+
placeholder (that package's contents are frozen; this one is the real
|
|
5
|
+
package published as `getculpa` on npm).
|
|
6
|
+
|
|
7
|
+
**`npm i -g getculpa` provisions the full Culpa install — the same files,
|
|
8
|
+
shortcuts, and registration the Windows installer lays down — and starts
|
|
9
|
+
nothing.** `getculpa` is what wakes the stack up.
|
|
10
|
+
|
|
11
|
+
- `getculpa` / `getculpa start` — start (or resume) the Culpa stack
|
|
12
|
+
- `getculpa stop` — stop the running stack (`docker compose stop`; recorded data is kept)
|
|
13
|
+
- `getculpa restart` — stop, then start
|
|
14
|
+
- `getculpa status` — install/docker/stack/license status (never prints key material)
|
|
15
|
+
- `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
|
|
16
|
+
- `getculpa repair` — re-stage any missing/corrupt install files (never touches an existing `docker-compose.yml` or recorded data); idempotent
|
|
17
|
+
- `getculpa uninstall` — remove Culpa (recorded data is kept unless you confirm)
|
|
18
|
+
- `getculpa scan` — zero-install local scan, no Docker, no network
|
|
19
|
+
- `getculpa connect` — print the topology-appropriate capture recipe (`--fly` also emits a ready-to-deploy Fly.io shape)
|
|
20
|
+
- `getculpa update` — manage CLI launcher updates
|
|
21
|
+
|
|
22
|
+
Why two layers? The npm package installs two things: the platform's `culpa`
|
|
23
|
+
launcher binary (trust level: HTTPS to one hardcoded GitHub Releases origin +
|
|
24
|
+
a published SHA-256 checksum — the same trust as `npm install` itself) and
|
|
25
|
+
the capture collector (same trust model; its absence only turns capture off,
|
|
26
|
+
nothing else degrades). Every subsequent launcher update then flows through
|
|
27
|
+
a stricter path: an Ed25519-signed release manifest verified against a key
|
|
28
|
+
embedded in the launcher, staged installs with a self-test, next-launch
|
|
29
|
+
activation, and `culpa update rollback`.
|
|
30
|
+
|
|
31
|
+
- `culpa update --check` — check for a new version (never installs)
|
|
32
|
+
- `culpa update` — download, verify, stage, self-test; activates next launch
|
|
33
|
+
- `culpa update --status` / `rollback`
|
|
34
|
+
- `culpa config set updates.mode <auto|notify|off>` (default: notify)
|
|
35
|
+
|
|
36
|
+
The package name is `getculpa` because the `culpa` npm name is not ours; the
|
|
37
|
+
installed commands are `getculpa` and `culpa`.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# CF18 (D-088 items 4/5) - the capture collector's lifecycle on a Windows
|
|
2
|
+
# host install. ONE implementation; install-culpa.ps1, launch-culpa.ps1 and
|
|
3
|
+
# uninstall-culpa.ps1 each make a one-line call, so the three can never drift.
|
|
4
|
+
#
|
|
5
|
+
# culpa-collector.ps1 -Start [-MailboxUrl http://127.0.0.1:4545]
|
|
6
|
+
# culpa-collector.ps1 -Stop
|
|
7
|
+
# culpa-collector.ps1 -Status
|
|
8
|
+
#
|
|
9
|
+
# WHY A HOST PROCESS AND NOT A COMPOSE SERVICE: collectord binds 127.0.0.1
|
|
10
|
+
# ONLY, by design (D-082 privacy boundary - raw observation material crosses
|
|
11
|
+
# loopback IPC and nothing else). Docker port-publishing cannot expose a
|
|
12
|
+
# loopback-bound process, so a containerized collector beside the server is
|
|
13
|
+
# unreachable by an app running on the host - the common case this installer
|
|
14
|
+
# serves. The native binary on the host IS the loopback the app shares.
|
|
15
|
+
# (Containerized apps use deploy/capture/capture-compose.yml's netns-sharing
|
|
16
|
+
# sidecar instead; `culpa connect` prints which applies.)
|
|
17
|
+
#
|
|
18
|
+
# The token authenticates the collector to the mailbox target. Against the
|
|
19
|
+
# local culpa-api it is not currently checked (capture is never gated,
|
|
20
|
+
# D-031 section 7), but collectord requires one at boot and a future hosted target
|
|
21
|
+
# does check it - so a real one is generated once and persisted beside the
|
|
22
|
+
# install, never a hardcoded placeholder.
|
|
23
|
+
|
|
24
|
+
param(
|
|
25
|
+
[switch]$Start,
|
|
26
|
+
[switch]$Stop,
|
|
27
|
+
[switch]$Status,
|
|
28
|
+
[string]$MailboxUrl = "http://127.0.0.1:4545",
|
|
29
|
+
# CodeRabbit PR#16: overridable when 4752 is taken on the host
|
|
30
|
+
[int]$Port = 4752
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
$ErrorActionPreference = "Stop"
|
|
34
|
+
$AppDir = $PSScriptRoot
|
|
35
|
+
$Exe = Join-Path $AppDir "culpa-collectord-win32-x64.exe"
|
|
36
|
+
$RegisterMjs = Join-Path $AppDir "register.mjs"
|
|
37
|
+
$PidFile = Join-Path $AppDir "collector.pid"
|
|
38
|
+
$TokenFile = Join-Path $AppDir "collector-token.txt"
|
|
39
|
+
$LogFile = Join-Path $AppDir "collector.log"
|
|
40
|
+
|
|
41
|
+
function Get-RunningCollectorId {
|
|
42
|
+
if (-not (Test-Path $PidFile)) { return $null }
|
|
43
|
+
$savedPid = (Get-Content $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
|
|
44
|
+
# CodeRabbit PR#16: a corrupt PID file (partial write, manual edit) used to
|
|
45
|
+
# TERMINATE the script - Get-Process's [int] binding fails under
|
|
46
|
+
# ErrorActionPreference=Stop and SilentlyContinue does not cover binding
|
|
47
|
+
# errors. Parse defensively: garbage means "not running", never a crash.
|
|
48
|
+
$parsedPid = 0
|
|
49
|
+
if (-not [int]::TryParse("$savedPid", [ref]$parsedPid) -or $parsedPid -le 0) { return $null }
|
|
50
|
+
$proc = Get-Process -Id $parsedPid -ErrorAction SilentlyContinue
|
|
51
|
+
# PID reuse guard: only claim it is ours if the image name matches
|
|
52
|
+
if ($proc -and $proc.ProcessName -like "culpa-collectord*") { return $proc.Id }
|
|
53
|
+
return $null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if ($Status) {
|
|
57
|
+
$running = Get-RunningCollectorId
|
|
58
|
+
if ($running) { Write-Host "collector: running (pid $running, port $Port)" }
|
|
59
|
+
else { Write-Host "collector: not running" }
|
|
60
|
+
exit 0
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if ($Stop) {
|
|
64
|
+
$running = Get-RunningCollectorId
|
|
65
|
+
if ($running) {
|
|
66
|
+
Stop-Process -Id $running -Force -ErrorAction SilentlyContinue
|
|
67
|
+
Write-Host "collector: stopped (pid $running)"
|
|
68
|
+
} else {
|
|
69
|
+
Write-Host "collector: was not running"
|
|
70
|
+
}
|
|
71
|
+
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
|
|
72
|
+
exit 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (-not $Start) {
|
|
76
|
+
Write-Host "usage: culpa-collector.ps1 -Start [-MailboxUrl <url>] | -Stop | -Status"
|
|
77
|
+
exit 2
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# -- Start --------------------------------------------------------------------
|
|
81
|
+
if (-not (Test-Path $Exe)) {
|
|
82
|
+
# an older zip layout without the capture plane - say so, never fail the
|
|
83
|
+
# caller's install; the stack itself is unaffected
|
|
84
|
+
Write-Host "collector: $((Split-Path $Exe -Leaf)) not present in this package - skipping capture setup" -ForegroundColor Yellow
|
|
85
|
+
exit 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
$existing = Get-RunningCollectorId
|
|
89
|
+
if ($existing) {
|
|
90
|
+
Write-Host "collector: already running (pid $existing)"
|
|
91
|
+
exit 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (-not (Test-Path $TokenFile)) {
|
|
95
|
+
# 64 hex chars from the cryptographic RNG; persisted so restarts keep the
|
|
96
|
+
# same credential (a hosted mailbox target checks it)
|
|
97
|
+
$bytes = New-Object byte[] 32
|
|
98
|
+
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
|
|
99
|
+
($bytes | ForEach-Object { $_.ToString("x2") }) -join "" | Set-Content $TokenFile -NoNewline
|
|
100
|
+
}
|
|
101
|
+
$token = Get-Content $TokenFile -Raw
|
|
102
|
+
|
|
103
|
+
$env:CULPA_MAILBOX_URL = $MailboxUrl
|
|
104
|
+
$env:CULPA_MAILBOX_TOKEN = $token
|
|
105
|
+
$env:CULPA_COLLECTOR_PORT = "$Port"
|
|
106
|
+
$proc = Start-Process -FilePath $Exe -WindowStyle Hidden -PassThru `
|
|
107
|
+
-RedirectStandardOutput $LogFile -RedirectStandardError "$LogFile.err"
|
|
108
|
+
Set-Content $PidFile $proc.Id
|
|
109
|
+
|
|
110
|
+
# bounded readiness wait: collectord prints "LISTENING <port>" on stdout
|
|
111
|
+
$ready = $false
|
|
112
|
+
for ($i = 0; $i -lt 20; $i++) {
|
|
113
|
+
Start-Sleep -Milliseconds 250
|
|
114
|
+
if ((Test-Path $LogFile) -and (Select-String -Path $LogFile -Pattern "LISTENING" -Quiet)) { $ready = $true; break }
|
|
115
|
+
if ($proc.HasExited) { break }
|
|
116
|
+
}
|
|
117
|
+
if (-not $ready) {
|
|
118
|
+
$detail = if (Test-Path "$LogFile.err") { Get-Content "$LogFile.err" -Raw } else { "(no log)" }
|
|
119
|
+
Write-Host "collector: failed to start - capture is OFF, everything else still works." -ForegroundColor Yellow
|
|
120
|
+
Write-Host " $($detail.Trim())" -ForegroundColor Yellow
|
|
121
|
+
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
|
|
122
|
+
exit 0 # never fail the caller's install over capture
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
Write-Host "collector: running (pid $($proc.Id), 127.0.0.1:$Port -> $MailboxUrl)"
|
|
126
|
+
Write-Host ""
|
|
127
|
+
# The preload MUST be a file:// URL with forward slashes: on Windows, Node
|
|
128
|
+
# parses a bare `--import=C:\...` as a URL whose scheme is `c:` and the app
|
|
129
|
+
# CRASHES AT BOOT with ERR_UNSUPPORTED_ESM_URL_SCHEME. Proven live on this
|
|
130
|
+
# machine during the CF18 host-path gate - this line is why the guidance
|
|
131
|
+
# below is not simply $RegisterMjs.
|
|
132
|
+
$RegisterUrl = "file:///" + ($RegisterMjs -replace "\\", "/")
|
|
133
|
+
Write-Host "To capture a Node app on this machine, set on THAT app and restart it:" -ForegroundColor Cyan
|
|
134
|
+
Write-Host " NODE_OPTIONS=--import=$RegisterUrl"
|
|
135
|
+
Write-Host " CULPA_COLLECTOR_URL=http://127.0.0.1:$Port"
|
|
136
|
+
Write-Host "(containerized app, or 2+ machines? run: culpa connect --machines <N>)"
|
|
137
|
+
exit 0
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Culpa — release compose file shipped inside the installers (T-087/T-088).
|
|
2
|
+
# Net effect is identical to running the stack from the repository:
|
|
3
|
+
# same services, same container names, same ports, same privacy default.
|
|
4
|
+
# Images are pulled from ghcr.io; a locally built/tagged image with the same
|
|
5
|
+
# name is used when present, which is how these pins were verified before
|
|
6
|
+
# publication.
|
|
7
|
+
#
|
|
8
|
+
# ── RELEASE STATE: v1.0.1 PUBLISHED AND SIGNED (2026-08-16) ─────────
|
|
9
|
+
# The line above is REWRITTEN BY installers/publish.sh at real-publish time —
|
|
10
|
+
# this file has shipped a hand-edited, wrong publication claim twice, so the
|
|
11
|
+
# claim is now mechanical, never prose. While it reads NOT PUBLISHED, the pins
|
|
12
|
+
# below resolve only against locally built images and this file must not be
|
|
13
|
+
# distributed. v0.11.0 is the CF9-CF12 round (UI alignment fixes, forecast
|
|
14
|
+
# uncertainty-calibration repair + kappa bound + cohort/centring explain,
|
|
15
|
+
# cache coverage flags, relay-mesh W1/W2c groundwork — feature-gated out of
|
|
16
|
+
# shipped binaries) on top of v0.10.0, the CF7/CF8 round (forecast P50-anchor + cache
|
|
17
|
+
# counterfactual math repair, founder UI list U-1..U-8 + P-1, founder
|
|
18
|
+
# palette + card "i" tooltips, native CLI auto-update Option A, QW-1..4
|
|
19
|
+
# container-upgrade guards, CodeRabbit PR#13 triage) on top of v0.9.0.
|
|
20
|
+
# The relay and license-server carry NO changes since v0.9.0 and keep
|
|
21
|
+
# their v0.9.0 images — a version is only claimed where a real diff
|
|
22
|
+
# exists. A signed tag is never rebuilt (D-072), so new contents mean a
|
|
23
|
+
# new version.
|
|
24
|
+
#
|
|
25
|
+
# Once the state line reads PUBLISHED AND SIGNED, verify before you run:
|
|
26
|
+
# cosign verify \
|
|
27
|
+
# --certificate-identity 'info@myaigi.ai' \
|
|
28
|
+
# --certificate-oidc-issuer 'https://github.com/login/oauth' \
|
|
29
|
+
# ghcr.io/myaigidev/culpa-server:v1.0.1
|
|
30
|
+
# Repeat for culpa-dashboard. Both must report VERIFIED.
|
|
31
|
+
#
|
|
32
|
+
# If a pull fails with an authentication or "denied" error rather than a
|
|
33
|
+
# missing manifest, the package has not been made public yet — the images
|
|
34
|
+
# exist. Check with:
|
|
35
|
+
# gh api user/packages/container/culpa-server --jq .visibility
|
|
36
|
+
# Do NOT use `docker manifest inspect` with an emptied DOCKER_CONFIG to test
|
|
37
|
+
# this: on Docker Desktop for Windows the daemon authenticates through its own
|
|
38
|
+
# credential store, so that check reports a false PUBLIC.
|
|
39
|
+
|
|
40
|
+
services:
|
|
41
|
+
db:
|
|
42
|
+
image: postgres:16-alpine
|
|
43
|
+
container_name: culpa-db
|
|
44
|
+
environment:
|
|
45
|
+
POSTGRES_USER: culpa
|
|
46
|
+
POSTGRES_PASSWORD: culpa
|
|
47
|
+
POSTGRES_DB: culpa
|
|
48
|
+
volumes:
|
|
49
|
+
- culpa_pgdata:/var/lib/postgresql/data
|
|
50
|
+
healthcheck:
|
|
51
|
+
test: ["CMD-SHELL", "pg_isready -U culpa -d culpa"]
|
|
52
|
+
interval: 2s
|
|
53
|
+
timeout: 2s
|
|
54
|
+
retries: 30
|
|
55
|
+
restart: unless-stopped
|
|
56
|
+
|
|
57
|
+
server:
|
|
58
|
+
image: ghcr.io/myaigidev/culpa-server:v1.0.1
|
|
59
|
+
container_name: culpa-server
|
|
60
|
+
environment:
|
|
61
|
+
DATABASE_URL: postgres://culpa:culpa@db:5432/culpa
|
|
62
|
+
# full forensic capture is the default (A5); set metadata-only to opt out
|
|
63
|
+
# CF24-T1 (S2): the server binds 127.0.0.1 by DEFAULT
|
|
64
|
+
# (native/culpa-api/src/bind.rs — the binary the image actually runs).
|
|
65
|
+
# Inside a container that is unreachable through the published port, so
|
|
66
|
+
# containers must say 0.0.0.0 explicitly. The host-side `ports:` mapping
|
|
67
|
+
# below binds 127.0.0.1, so nothing OFF THIS MACHINE can reach it —
|
|
68
|
+
# reachable-by-design is always an operator's written choice, never a
|
|
69
|
+
# default.
|
|
70
|
+
#
|
|
71
|
+
# Say precisely what that does and does not cover (CF25, CodeRabbit
|
|
72
|
+
# PR #18): the `ports:` mapping governs HOST exposure only. Because the
|
|
73
|
+
# listener inside the container is 0.0.0.0, any other container attached
|
|
74
|
+
# to this Compose network reaches the server directly on 4545, without
|
|
75
|
+
# traversing the published port at all. That is ordinary Docker
|
|
76
|
+
# networking and it is fine for the services defined here — but adding a
|
|
77
|
+
# container to this network grants it API access, and the port mapping
|
|
78
|
+
# will not stop it.
|
|
79
|
+
CULPA_API_BIND: "0.0.0.0"
|
|
80
|
+
CULPA_PRIVACY_MODE: full
|
|
81
|
+
# T-120: the machine secret encrypting the customer key at rest lives
|
|
82
|
+
# here as a file — a VOLUME, so it survives container recreation
|
|
83
|
+
CULPA_SECRET_DIR: /var/lib/culpa
|
|
84
|
+
# T-M1/T-M2: scan the mounted host logs; live sweep every 5 minutes
|
|
85
|
+
CULPA_SCAN_HOME: /host
|
|
86
|
+
CULPA_SCAN_EVERY_MS: "300000"
|
|
87
|
+
# LAUNCH CONFIG (founder go, 2026-07-31 — D-041 enforcement flip;
|
|
88
|
+
# gating scope corrected 2026-08-03 by D-055): every customer-facing
|
|
89
|
+
# READ of the customer's own spend data requires a valid entitlement.
|
|
90
|
+
# Capture (ingest, scan, imports) and licensing lifecycle routes (claim,
|
|
91
|
+
# activation, license, checkout, billing-portal, entitlement, terms)
|
|
92
|
+
# are NEVER gated — a licensing failure must never break the
|
|
93
|
+
# customer's production traffic or block them from activating. Fail
|
|
94
|
+
# open ONLY on infrastructure failure (license server unreachable, DB
|
|
95
|
+
# blip, WASM missing), where the cached entitlement stands; an ABSENT
|
|
96
|
+
# or CANCELLED entitlement refuses. Activation: buy → claim in-app (or
|
|
97
|
+
# set CULPA_LICENSE_KEY, or paste the key in the dashboard under
|
|
98
|
+
# Account -> Plan & Billing -> Activate); the license server below is where the app
|
|
99
|
+
# syncs counts-only and refreshes the signed entitlement. Remove the
|
|
100
|
+
# license-server line and nothing ever leaves this machine — paid
|
|
101
|
+
# reads then stay degraded (that is the deal).
|
|
102
|
+
CULPA_TRUST_ENFORCE: "1"
|
|
103
|
+
CULPA_LICENSE_SERVER: https://culpa.fly.dev
|
|
104
|
+
# T-CF4-12 (Culpaflight4 #15): current model prices pull on first boot
|
|
105
|
+
# and refresh daily. This is a DOWNLOAD of the public price list —
|
|
106
|
+
# nothing about your usage goes anywhere (D-034). Remove the line and
|
|
107
|
+
# the committed snapshot + seeds keep pricing working offline.
|
|
108
|
+
CULPA_PRICING_SYNC: "1"
|
|
109
|
+
ports:
|
|
110
|
+
- "127.0.0.1:4545:4545"
|
|
111
|
+
volumes:
|
|
112
|
+
- culpa_data:/var/lib/culpa
|
|
113
|
+
# T-M3 ("+ See a cost"): the host's AI-tool session logs, READ-ONLY —
|
|
114
|
+
# this is what lets the in-dashboard scan see Claude Code/Codex history
|
|
115
|
+
# and keep it live. Only these two dirs, never the whole home.
|
|
116
|
+
# NARROW scope (review M2): only the session-log subdirs — ~/.claude
|
|
117
|
+
# also holds credentials/settings, which the container must never see
|
|
118
|
+
- ${CULPA_HOST_HOME:-${USERPROFILE:-${HOME}}}/.claude/projects:/host/.claude/projects:ro
|
|
119
|
+
- ${CULPA_HOST_HOME:-${USERPROFILE:-${HOME}}}/.codex/sessions:/host/.codex/sessions:ro
|
|
120
|
+
depends_on:
|
|
121
|
+
db:
|
|
122
|
+
condition: service_healthy
|
|
123
|
+
restart: unless-stopped
|
|
124
|
+
|
|
125
|
+
dashboard:
|
|
126
|
+
# CF14 (2026-08-12): dashboard-only bump. v0.11.1 is v0.11.0 plus the dark
|
|
127
|
+
# black-surface palette — no server, migration or API change, so the server
|
|
128
|
+
# above deliberately stays at v0.11.0 rather than being re-tagged for a
|
|
129
|
+
# release it has no diff in (D-072: never rebuild a tag that already exists).
|
|
130
|
+
image: ghcr.io/myaigidev/culpa-dashboard:v1.0.1
|
|
131
|
+
container_name: culpa-dashboard
|
|
132
|
+
environment:
|
|
133
|
+
CULPA_API_BASE: http://server:4545
|
|
134
|
+
ports:
|
|
135
|
+
- "127.0.0.1:3000:3000"
|
|
136
|
+
depends_on:
|
|
137
|
+
- server
|
|
138
|
+
restart: unless-stopped
|
|
139
|
+
|
|
140
|
+
volumes:
|
|
141
|
+
culpa_pgdata:
|
|
142
|
+
name: culpa_pgdata
|
|
143
|
+
culpa_data:
|
|
144
|
+
name: culpa_data
|
|
@@ -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" }
|