getculpa 1.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 ADDED
@@ -0,0 +1,41 @@
1
+ Culpa — proprietary software licence
2
+ ====================================
3
+
4
+ Copyright (c) 2026 Myaigi AI Labs (Proprietary) Limited.
5
+ All rights reserved.
6
+
7
+ Culpa is proprietary software. It is licensed, not sold.
8
+
9
+ Your use of this package, the `getculpa` and `culpa` commands it installs, the
10
+ binaries it downloads, and the Culpa container images it runs is governed by
11
+ the Culpa End User Licence Agreement ("EULA") between you and Myaigi AI Labs
12
+ (Proprietary) Limited.
13
+
14
+ Licensor: Myaigi AI Labs (Proprietary) Limited
15
+ Registration number 2025/688531/07
16
+ 121 Senior Drive, Randburg, Gauteng, 2195, South Africa
17
+ Product: Culpa
18
+ Governing law: Republic of South Africa
19
+
20
+ To obtain the current EULA, contact the licensor at the address below.
21
+
22
+ Except as the EULA expressly permits, you may not copy, modify, merge,
23
+ publish, distribute, sublicense, sell, rent, lease, reverse engineer, or
24
+ create derivative works of the Software, and you may not remove or alter any
25
+ proprietary notice in it.
26
+
27
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28
+ IMPLIED, TO THE FULLEST EXTENT PERMITTED BY LAW. The EULA governs warranties,
29
+ liability, and your statutory rights, and prevails over this notice wherever
30
+ the two differ. Nothing here limits any right you have under applicable
31
+ consumer law that cannot lawfully be excluded.
32
+
33
+ This package downloads and installs additional Culpa components at install
34
+ time and at update time, from Culpa's official release channel, with each
35
+ artifact verified by checksum before it is used.
36
+
37
+ Third-party open-source components distributed with or downloaded by Culpa
38
+ remain under their own licences; those licences continue to apply to them and
39
+ nothing in this notice restricts rights granted by them.
40
+
41
+ Legal notices and licensing questions: info@myaigi.ai
@@ -5,7 +5,7 @@
5
5
  # name is used when present, which is how these pins were verified before
6
6
  # publication.
7
7
  #
8
- # ── RELEASE STATE: v1.0.1 PUBLISHED AND SIGNED (2026-08-16) ─────────
8
+ # ── RELEASE STATE: v1.0.2 PUBLISHED AND SIGNED (2026-08-17) ─────────
9
9
  # The line above is REWRITTEN BY installers/publish.sh at real-publish time —
10
10
  # this file has shipped a hand-edited, wrong publication claim twice, so the
11
11
  # claim is now mechanical, never prose. While it reads NOT PUBLISHED, the pins
@@ -26,7 +26,7 @@
26
26
  # cosign verify \
27
27
  # --certificate-identity 'info@myaigi.ai' \
28
28
  # --certificate-oidc-issuer 'https://github.com/login/oauth' \
29
- # ghcr.io/myaigidev/culpa-server:v1.0.1
29
+ # ghcr.io/myaigidev/culpa-server:v1.0.2
30
30
  # Repeat for culpa-dashboard. Both must report VERIFIED.
31
31
  #
32
32
  # If a pull fails with an authentication or "denied" error rather than a
@@ -55,7 +55,7 @@ services:
55
55
  restart: unless-stopped
56
56
 
57
57
  server:
58
- image: ghcr.io/myaigidev/culpa-server:v1.0.1
58
+ image: ghcr.io/myaigidev/culpa-server:v1.0.2
59
59
  container_name: culpa-server
60
60
  environment:
61
61
  DATABASE_URL: postgres://culpa:culpa@db:5432/culpa
@@ -115,8 +115,8 @@ services:
115
115
  # and keep it live. Only these two dirs, never the whole home.
116
116
  # NARROW scope (review M2): only the session-log subdirs — ~/.claude
117
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
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
120
  depends_on:
121
121
  db:
122
122
  condition: service_healthy
@@ -127,7 +127,7 @@ services:
127
127
  # black-surface palette — no server, migration or API change, so the server
128
128
  # above deliberately stays at v0.11.0 rather than being re-tagged for a
129
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
130
+ image: ghcr.io/myaigidev/culpa-dashboard:v1.0.2
131
131
  container_name: culpa-dashboard
132
132
  environment:
133
133
  CULPA_API_BASE: http://server:4545
@@ -8,7 +8,23 @@
8
8
  # destructive action - docker down, the data-volume prompt, and file removal.
9
9
  # A copy of this script run from anywhere else previously could stop the real
10
10
  # `culpa` project and offer to delete culpa_pgdata, the shared data volume.
11
- param([switch]$FromUninstaller)
11
+ # T-CF29-2 params:
12
+ # -Force proceed from THIS directory even when the
13
+ # canonical check disagrees (explicit opt-in;
14
+ # never automatic)
15
+ # -RecordedInstallLocation inject what the registry would return, so the
16
+ # resolution can be tested without touching HKCU
17
+ # (same -Probe precedent as install-culpa.ps1)
18
+ # -CanonicalOverrideForTest inject the fallback directory, for the same reason
19
+ # -PrintPlanOnly resolve, print the decision as JSON, exit 0
20
+ # BEFORE any destructive action
21
+ param(
22
+ [switch]$FromUninstaller,
23
+ [switch]$Force,
24
+ [string]$RecordedInstallLocation,
25
+ [string]$CanonicalOverrideForTest,
26
+ [switch]$PrintPlanOnly
27
+ )
12
28
 
13
29
  $ErrorActionPreference = "SilentlyContinue"
14
30
  $AppDir = $PSScriptRoot
@@ -19,23 +35,117 @@ $AppDir = $PSScriptRoot
19
35
  # The guard itself is unchanged and still load-bearing: a stray COPY of this
20
36
  # script must never stop containers or delete files belonging to the real
21
37
  # install.
22
- $Recorded = (Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1" -Name InstallLocation -ErrorAction SilentlyContinue).InstallLocation
23
- $Canonical = if ([string]::IsNullOrWhiteSpace($Recorded)) {
38
+ #
39
+ # T-CF29-2: a recorded value that POINTS AT A DELETED DIRECTORY used to be
40
+ # trusted anyway, so the guard below could never match and Culpa became
41
+ # impossible to uninstall. That is not hypothetical — the CF20 npm gate
42
+ # overwrote this shared value with a scratch dir which was then removed.
43
+ # A path that does not exist cannot be shadowing a real install, so it is
44
+ # ignored in favour of the default. Everything else about the guard stands.
45
+ # T-CF29-2: the guard compared raw strings, so two spellings of the SAME
46
+ # directory read as different installs — an 8.3 short path (C:\Users\ZOLANI~1)
47
+ # versus its long form, a trailing backslash, or different casing. Each of
48
+ # those turns the uninstaller into the same dead end a stale registry value
49
+ # does. Resolve-Path returns the canonical long form for a path that exists;
50
+ # a path that does not exist falls back to a trimmed, lowercased comparison.
51
+ function Normalize-Dir {
52
+ param([string]$Path)
53
+ if ([string]::IsNullOrWhiteSpace($Path)) { return "" }
54
+ $trimmed = $Path.TrimEnd('\', '/')
55
+ # GetFullPath expands an 8.3 short component (ZOLANI~1) to its long form;
56
+ # Resolve-Path does NOT (verified on this machine), so it cannot be relied
57
+ # on for that. Both are best-effort: a path that cannot be expanded falls
58
+ # through to the trimmed, lowercased comparison.
59
+ try { $trimmed = [System.IO.Path]::GetFullPath($trimmed).TrimEnd('\', '/') } catch { }
60
+ return $trimmed.ToLowerInvariant()
61
+ }
62
+
63
+ function Resolve-CanonicalDir {
64
+ param(
65
+ [string]$Recorded,
66
+ [string]$Default,
67
+ [scriptblock]$PathExists = { param($p) Test-Path -LiteralPath $p }
68
+ )
69
+ if ([string]::IsNullOrWhiteSpace($Recorded)) {
70
+ return [PSCustomObject]@{ Dir = $Default; Source = "default" }
71
+ }
72
+ $trimmed = $Recorded.TrimEnd('\')
73
+ if (-not (& $PathExists $trimmed)) {
74
+ return [PSCustomObject]@{ Dir = $Default; Source = "stale-registry" }
75
+ }
76
+ return [PSCustomObject]@{ Dir = $trimmed; Source = "registry" }
77
+ }
78
+
79
+ # SAFETY (review of 8da2b2e, CRITICAL): -RecordedInstallLocation and
80
+ # -CanonicalOverrideForTest are ordinary parameters on the SHIPPED script, so
81
+ # they are reachable through exactly the `-File` invocation surface Inno and
82
+ # the shortcuts use. As first written they fed the canonical decision directly,
83
+ # which let a stray copy pass -CanonicalOverrideForTest <its own dir> and reach
84
+ # `docker compose -p culpa down` and the culpa_pgdata prompt with NO -Force and
85
+ # none of the -Force warnings. Proven by probe: wouldProceed:true, forced:false.
86
+ # That is the precise scenario CR-PR5 round 2 exists to prevent, and the
87
+ # -Probe precedent cited above does not excuse it: that one is a parameter of
88
+ # an INTERNAL FUNCTION, reachable only by dot-sourcing, never a top-level flag.
89
+ #
90
+ # They are therefore honoured ONLY under -PrintPlanOnly, which cannot touch
91
+ # docker, the data volume, the registry or any file. In a run that could do
92
+ # something destructive they are ignored outright, so the canonical decision
93
+ # always comes from the real registry or the real default.
94
+ $InjectionRequested = $PSBoundParameters.ContainsKey('RecordedInstallLocation') -or
95
+ -not [string]::IsNullOrWhiteSpace($CanonicalOverrideForTest)
96
+ $InjectionHonoured = $InjectionRequested -and $PrintPlanOnly.IsPresent
97
+
98
+ if ($InjectionHonoured -and $PSBoundParameters.ContainsKey('RecordedInstallLocation')) {
99
+ $Recorded = $RecordedInstallLocation
100
+ } else {
101
+ $Recorded = (Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{B7A6F2C4-9D31-4E5A-A0C8-52C1E7D94F60}_is1" -Name InstallLocation -ErrorAction SilentlyContinue).InstallLocation
102
+ }
103
+ $DefaultDir = if (-not $InjectionHonoured -or [string]::IsNullOrWhiteSpace($CanonicalOverrideForTest)) {
24
104
  Join-Path $env:LOCALAPPDATA "Culpa"
25
105
  } else {
26
- $Recorded.TrimEnd('\')
106
+ $CanonicalOverrideForTest.TrimEnd('\')
27
107
  }
108
+ $Resolved = Resolve-CanonicalDir -Recorded $Recorded -Default $DefaultDir
109
+ $Canonical = $Resolved.Dir
28
110
  $ComposeDst = Join-Path $AppDir "docker-compose.yml"
29
111
 
30
- if ($AppDir -ne $Canonical) {
112
+ $Matches_ = ((Normalize-Dir $AppDir) -eq (Normalize-Dir $Canonical))
113
+ $WouldProceed = ($Matches_ -or $Force.IsPresent)
114
+
115
+ if ($PrintPlanOnly) {
116
+ [PSCustomObject]@{
117
+ appDir = $AppDir
118
+ canonical = $Canonical
119
+ canonicalSource = $Resolved.Source
120
+ wouldProceed = $WouldProceed
121
+ forced = ($Force.IsPresent -and -not $Matches_)
122
+ # so plan output can never be mistaken for a real registry read
123
+ injected = $InjectionHonoured
124
+ } | ConvertTo-Json -Compress
125
+ exit 0
126
+ }
127
+
128
+ if (-not $WouldProceed) {
31
129
  # non-canonical copy: touch NOTHING (no docker, no volume, no files)
32
130
  Write-Host "This copy is not the installed one - nothing was changed." -ForegroundColor Yellow
33
131
  Write-Host "To uninstall Culpa: Windows Settings -> Installed apps -> Culpa,"
34
132
  Write-Host "or run uninstall-culpa.ps1 in $Canonical."
133
+ if ($Resolved.Source -eq "stale-registry") {
134
+ Write-Host "(The recorded install location no longer exists, so the default was used.)"
135
+ }
136
+ # T-CF29-2: never leave the user with no way out. If the canonical
137
+ # directory is wrong or gone, this is the escape hatch — explicit, so it
138
+ # can never fire by accident on a stray copy.
139
+ Write-Host "If Culpa really is installed HERE, re-run with -Force:" -ForegroundColor Yellow
140
+ Write-Host " powershell -ExecutionPolicy Bypass -File `"$AppDir\uninstall-culpa.ps1`" -Force"
35
141
  if (-not $FromUninstaller) { Read-Host "Press Enter to close" }
36
142
  exit 0
37
143
  }
38
144
 
145
+ if ($Force.IsPresent -and -not $Matches_) {
146
+ Write-Host "-Force: uninstalling from $AppDir even though the recorded location is $Canonical." -ForegroundColor Yellow
147
+ }
148
+
39
149
  # CF19 (CodeRabbit PR#16 #289): collector shutdown runs AFTER the
40
150
  # canonical-path guard — a stray COPY of this script must not change the real
41
151
  # installation, and stopping its collector is a change. Stopping capture is
@@ -0,0 +1,25 @@
1
+ // Unlike docker.d.mts's SpawnSyncLike, this one carries `error`: a spawn that
2
+ // never ran (missing binary, timeout kill) reports it there, not via status.
3
+ export type LauncherSpawnSync = (
4
+ cmd: string,
5
+ args?: string[],
6
+ opts?: Record<string, unknown>,
7
+ ) => { status: number | null; error?: Error };
8
+
9
+ export const PAYLOAD_STAGE_TIMEOUT_MS: number;
10
+
11
+ export function vendoredLauncherPath(packageRoot: string, platform?: string): string | null;
12
+
13
+ export interface StageInitialPayloadOptions {
14
+ launcherPath: string | null;
15
+ spawnSync?: LauncherSpawnSync;
16
+ log?: (msg: string) => void;
17
+ env?: Record<string, string | undefined>;
18
+ }
19
+
20
+ export interface StageInitialPayloadResult {
21
+ staged: boolean;
22
+ reason: "no-launcher" | "spawn-failed" | "update-failed" | "staged";
23
+ }
24
+
25
+ export function stageInitialPayload(opts: StageInitialPayloadOptions): StageInitialPayloadResult;
@@ -0,0 +1,73 @@
1
+ // T-CF28-4b — stage the CLI payload during `npm i -g getculpa`.
2
+ //
3
+ // THE DEFECT. A fresh install fetched the LAUNCHER (lib/fetch.mjs) but never
4
+ // the PAYLOAD the launcher runs. `state.json` was left with `current: null`,
5
+ // so the very first `getculpa` reached
6
+ // native/culpa-launcher/src/main.rs:79-88, found nothing to exec, and printed
7
+ // the no-payload message. The Mac tester had to work out `getculpa update` for
8
+ // themselves and then run a SECOND command before anything happened. An
9
+ // install that leaves nothing runnable is not an install.
10
+ //
11
+ // WHY THIS IS NOT AN UNCONSENTED UPDATE. `culpa update` is an explicit,
12
+ // user-initiated verb and is not consent-gated: consent gates the BACKGROUND
13
+ // check (`cli.rs:421-452`, `run_background_check` `cli.rs:316-318`). The user
14
+ // ran `npm i -g getculpa`; downloading the program they just asked to install
15
+ // is completing that install. The auto-update consent model is untouched, and
16
+ // nothing here records a preference on the user's behalf.
17
+ //
18
+ // WHY IT SHELLS OUT RATHER THAN DOWNLOADING. The launcher already owns a
19
+ // verified path: manifest fetch, sha256 verification, self-test, atomic
20
+ // promotion. A second downloader in Node would be a second trust boundary to
21
+ // audit and keep in step. This calls the one that already exists.
22
+ //
23
+ // EVERY FAILURE IS SOFT. Offline, a stalled mirror, a missing binary — none of
24
+ // them fail the install. They leave the payload deferred, exactly as before
25
+ // this module existed, and lib/install-summary.mjs says so and names the
26
+ // command. The install never claims more than it did.
27
+
28
+ import { spawnSync as realSpawnSync } from "node:child_process";
29
+ import { existsSync } from "node:fs";
30
+ import path from "node:path";
31
+
32
+ // A real payload is tens of MB over a network Culpa does not control. Long
33
+ // enough not to abort a slow-but-working download; short enough that a hung
34
+ // mirror degrades to a clear deferred message instead of hanging `npm install`
35
+ // indefinitely, which is the failure mode that matters — npm gives no progress
36
+ // output of its own here.
37
+ export const PAYLOAD_STAGE_TIMEOUT_MS = 10 * 60 * 1000;
38
+
39
+ // Where lib/fetch.mjs puts the launcher (see stageStubLauncher in
40
+ // scripts/install.js for the same naming: `culpa-launcher` + platform suffix).
41
+ export function vendoredLauncherPath(packageRoot, platform = process.platform) {
42
+ const exe = platform === "win32" ? ".exe" : "";
43
+ const candidate = path.join(packageRoot, "vendor", `culpa-launcher${exe}`);
44
+ return existsSync(candidate) ? candidate : null;
45
+ }
46
+
47
+ export function stageInitialPayload(opts) {
48
+ const { launcherPath, spawnSync = realSpawnSync, log = console.log, env = process.env } = opts;
49
+
50
+ // No launcher means the fetch above already failed and said so; there is
51
+ // nothing to invoke and nothing further worth reporting here.
52
+ if (!launcherPath) return { staged: false, reason: "no-launcher" };
53
+
54
+ log("getculpa: downloading the Culpa CLI (this is the program `getculpa` runs).");
55
+ const result = spawnSync(launcherPath, ["update"], {
56
+ stdio: "inherit",
57
+ timeout: PAYLOAD_STAGE_TIMEOUT_MS,
58
+ // Inherit the install's own environment so CULPA_HOME / CULPA_APP_DIR
59
+ // resolve to the SAME root scripts/install.js provisioned into (D-108
60
+ // unified that chain across the launcher and the Node CLI).
61
+ env,
62
+ });
63
+
64
+ if (result.error) {
65
+ log(`getculpa: could not download the Culpa CLI (${result.error.message}) - run \`getculpa update\` to finish.`);
66
+ return { staged: false, reason: "spawn-failed" };
67
+ }
68
+ if (result.status !== 0) {
69
+ log("getculpa: could not download the Culpa CLI - run `getculpa update` to finish.");
70
+ return { staged: false, reason: "update-failed" };
71
+ }
72
+ return { staged: true, reason: "staged" };
73
+ }
package/lib/docker.d.mts CHANGED
@@ -20,6 +20,10 @@ export function waitForDockerEngine(opts?: {
20
20
  sleepFn?: (ms: number) => Promise<void>;
21
21
  }): Promise<boolean>;
22
22
 
23
+ export type DarwinDockerInstallOutcome = "installed" | "no-brew" | `failed:${string}`;
24
+
25
+ export function installDockerDarwin(spawnSync?: SpawnSyncLike): DarwinDockerInstallOutcome;
26
+
23
27
  export function startEngineIfPossible(
24
28
  platform: string,
25
29
  spawnSync?: SpawnSyncLike,
package/lib/docker.mjs CHANGED
@@ -80,6 +80,25 @@ export function startEngineIfPossible(platform, spawnSync = realSpawnSync, log =
80
80
  return { attempted: false, ok: false };
81
81
  }
82
82
 
83
+ // T-CF28-7 — the macOS half of the ask-and-install flow Windows already
84
+ // ships (installers/windows/launch-culpa.ps1:208-219). Called ONLY after the
85
+ // user has explicitly agreed; this function does not ask. It reports WHICH
86
+ // path it took rather than a bare boolean, for the same reason
87
+ // Install-DockerDesktop does: a bare false made the .ps1's caller claim a
88
+ // download page had been opened when the package manager was present and the
89
+ // install had simply failed. Returns "installed" | "no-brew" | "failed:<code>".
90
+ //
91
+ // Homebrew is the only automatic path offered on darwin: it is the user's own
92
+ // auditable package manager, and `--cask docker` is the official cask. There
93
+ // is deliberately no curl-and-run fallback.
94
+ export function installDockerDarwin(spawnSync = realSpawnSync) {
95
+ const brew = spawnSync("brew", ["--version"], { timeout: PROBE_TIMEOUT_MS });
96
+ if (brew.status !== 0) return "no-brew";
97
+ const result = spawnSync("brew", ["install", "--cask", "docker"], { stdio: "inherit" });
98
+ if (result.status === 0) return "installed";
99
+ return `failed:${result.status}`;
100
+ }
101
+
83
102
  // --- IDEMPOTENCY (spec sec 23) ----------------------------------------------
84
103
  //
85
104
  // `docker ps` (RUNNING containers only — a `docker compose stop`ped stack
package/lib/doctor.mjs CHANGED
@@ -20,7 +20,7 @@ import path from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { findPortOwner, probeHealth, PROBE_TIMEOUT_MS } from "./docker.mjs";
22
22
  import { checkDockerPresent, checkDockerEngineReachable, classifyUpgrade, parsePinnedServerVersion } from "./preflight.mjs";
23
- import { PARITY_ASSETS } from "./provision.mjs";
23
+ import { parityAssetsFor } from "./provision.mjs";
24
24
 
25
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
26
  const packageRoot = path.join(__dirname, "..");
@@ -101,7 +101,7 @@ function checkVendoredLauncher(vendorDir, platform) {
101
101
  );
102
102
  }
103
103
 
104
- function checkInstallationIntegrity(appDir) {
104
+ function checkInstallationIntegrity(appDir, platform) {
105
105
  const checks = [];
106
106
  const liveComposePath = path.join(appDir, "docker-compose.yml");
107
107
  const installed = existsSync(liveComposePath);
@@ -115,7 +115,11 @@ function checkInstallationIntegrity(appDir) {
115
115
 
116
116
  checks.push(check("Installation integrity", "Live compose", "ok", liveComposePath));
117
117
 
118
- for (const name of PARITY_ASSETS) {
118
+ // T-CF28-6: grade only the files this platform is supposed to have. On
119
+ // darwin/linux the four Windows .ps1 scripts are correctly absent — listing
120
+ // them at all (as [OK] before, as a failure after the staging fix) reports
121
+ // on an install that does not exist.
122
+ for (const name of parityAssetsFor(platform)) {
119
123
  const assetPath = path.join(appDir, name);
120
124
  const present = existsSync(assetPath);
121
125
  checks.push(
@@ -311,14 +315,23 @@ function checkUpdateState(appDir, currentVersion, installed) {
311
315
 
312
316
  const updateStatePath = path.join(appDir, "updates", "state.json");
313
317
  const launcherState = readJsonSafe(updateStatePath);
318
+ // Audit (flows) finding 2: this reported "ok" whenever the JSON merely
319
+ // PARSED, never looking at current. So on the exact stranded install
320
+ // T-CF28-4b exists to prevent - no payload ever staged - doctor printed
321
+ // "Overall: OK" while the very next getculpa dead-ended with "no installed
322
+ // version found". Diagnostics that reassure you about the broken thing are
323
+ // worse than none.
324
+ const noPayload = launcherState !== null && (launcherState.current ?? null) === null;
314
325
  checks.push(
315
326
  check(
316
327
  "Update state",
317
328
  "Launcher self-update state",
318
- launcherState ? "ok" : "info",
319
- launcherState
320
- ? `current=${launcherState.current ?? "n/a"}, pending=${launcherState.pending ?? "none"}, mode=${launcherState.mode ?? "n/a"}`
321
- : "unknown (no update state recorded yet)",
329
+ launcherState === null ? "info" : noPayload ? "fail" : "ok",
330
+ launcherState === null
331
+ ? "unknown (no update state recorded yet)"
332
+ : noPayload
333
+ ? "no CLI payload is staged (current=null) - run `getculpa update`, then `getculpa`"
334
+ : `current=${launcherState.current}, pending=${launcherState.pending ?? "none"}, mode=${launcherState.mode ?? "n/a"}`,
322
335
  ),
323
336
  );
324
337
  return checks;
@@ -339,7 +352,7 @@ export async function doctor(opts) {
339
352
  const checks = [];
340
353
  checks.push(...checkVersionPlatform(currentVersion, platform, arch));
341
354
 
342
- const integrity = checkInstallationIntegrity(appDir);
355
+ const integrity = checkInstallationIntegrity(appDir, platform);
343
356
  checks.push(...integrity.checks);
344
357
  checks.push(checkVendoredLauncher(vendorDir, platform));
345
358
 
@@ -0,0 +1,18 @@
1
+ import type { UpgradeClassification } from "./preflight.d.mts";
2
+ import type { DockerState } from "./provision.d.mts";
3
+
4
+ export interface InstallSummaryOptions {
5
+ classification: UpgradeClassification;
6
+ version: string;
7
+ previousVersion?: string | null;
8
+ appDir: string;
9
+ dockerState: DockerState;
10
+ imagesStaged?: boolean;
11
+ collectorStaged?: boolean;
12
+ payloadStaged?: boolean;
13
+ platform?: string;
14
+ isTTY?: boolean;
15
+ env?: Record<string, string | undefined>;
16
+ }
17
+
18
+ export function buildInstallSummary(opts: InstallSummaryOptions): string[];
@@ -0,0 +1,144 @@
1
+ // T-CF28-5 (postinstall output) + T-CF28-7 (the install-time half of the
2
+ // Docker ask) — the lines postinstall prints when it finishes.
3
+ //
4
+ // Extracted from scripts/install.js's main() as a PURE function. The words
5
+ // are the product here: the shipped 1.0.1 printed "Culpa installed
6
+ // successfully." on a fresh Mac with no Docker, where nothing could actually
7
+ // start. That line lived inline in a CommonJS script with no export, so no
8
+ // test could assert it. It is a line-builder now so the claims it makes are
9
+ // checked by tests/npm-core-install-summary.test.ts.
10
+ //
11
+ // TWO RULES THIS FILE EXISTS TO ENFORCE:
12
+ //
13
+ // 1. Never claim "ready" unless it IS ready. The founder's ruled headline is
14
+ // "Culpa is installed and ready." with `ready` in green — printed when
15
+ // Docker is genuinely usable. When Docker is missing or stopped, the
16
+ // headline drops to "Culpa is installed." and the Docker requirement is
17
+ // stated plainly. Printing the ruled line unconditionally would repeat the
18
+ // exact defect that created T-CF28-7.
19
+ //
20
+ // 2. Say what each platform will really do. postinstall CANNOT prompt
21
+ // (scripts/install.js:13-15 — npm installs run headless and a stdin read
22
+ // would hang), so this file only DETECTS and TELLS; the asking happens at
23
+ // first `getculpa`. win32 already ships that flow
24
+ // (installers/windows/launch-culpa.ps1:179-234); darwin gains it in
25
+ // lib/start.mjs; linux stays guidance-only, because installing a system
26
+ // package without explicit consent is out of bounds (the same rule
27
+ // lib/docker.mjs:73-79 already follows for starting the engine).
28
+
29
+ import { green } from "./tty.mjs";
30
+
31
+ const DOCKER_DESKTOP_URL = "https://www.docker.com/products/docker-desktop/";
32
+ const DOCKER_ENGINE_DOCS_URL = "https://docs.docker.com/engine/install/";
33
+
34
+ function headline(classification, version, previousVersion, dockerReady, colour) {
35
+ switch (classification) {
36
+ case "upgrade":
37
+ return `Culpa updated: ${previousVersion} -> ${version} (staged; applies at next \`getculpa\`).`;
38
+ case "downgrade-package":
39
+ return `Culpa ${previousVersion} is already installed; this package (${version}) is older and was not applied.`;
40
+ case "same-version":
41
+ case "repair":
42
+ return "Culpa installation repaired.";
43
+ default:
44
+ // "fresh" — the only case the founder's ruled wording describes.
45
+ return dockerReady ? `Culpa is installed and ${green("ready", colour)}.` : "Culpa is installed.";
46
+ }
47
+ }
48
+
49
+ function inventory(appDir, imagesStaged, collectorStaged, payloadStaged) {
50
+ return [
51
+ ` Location: ${appDir}`,
52
+ // T-CF28-4b: the CLI payload is the program `getculpa` actually runs. When
53
+ // it is missing the install is not usable at all, so this row names the
54
+ // one command that finishes the job rather than leaving a dead first run.
55
+ ` Culpa CLI: ${payloadStaged ? "installed" : "deferred - run `getculpa update` to finish"}`,
56
+ ` Images: ${imagesStaged ? "staged" : "deferred to first run"}`,
57
+ ` Capture: ${collectorStaged ? "collector installed" : "off (collector not installed)"}`,
58
+ ];
59
+ }
60
+
61
+ // Docker is MISSING. Each platform gets only what is true for it.
62
+ function dockerMissingLines(platform) {
63
+ const lines = [
64
+ "Culpa needs Docker to run: its database and services run in containers.",
65
+ "Docker was not found on this machine.",
66
+ ];
67
+ if (platform === "darwin") {
68
+ lines.push(
69
+ " Install it with: brew install --cask docker",
70
+ " Homebrew asks for your password, and you must open Docker once from",
71
+ " Applications before Culpa can start - it is not a one-click install.",
72
+ ` Or download it: ${DOCKER_DESKTOP_URL}`,
73
+ "Culpa will offer to do this for you the first time you run `getculpa`.",
74
+ );
75
+ } else if (platform === "win32") {
76
+ lines.push(
77
+ "Culpa will offer to install Docker for you the first time you run `getculpa`.",
78
+ ` Or download it: ${DOCKER_DESKTOP_URL}`,
79
+ );
80
+ } else {
81
+ lines.push(
82
+ ` Install Docker Engine: ${DOCKER_ENGINE_DOCS_URL}`,
83
+ "Culpa will not install system packages for you - install it yourself, then",
84
+ "run `getculpa`.",
85
+ );
86
+ }
87
+ return lines;
88
+ }
89
+
90
+ // Docker is PRESENT but the engine is stopped. Starting it is something Culpa
91
+ // really does do on darwin (lib/docker.mjs:69-72, `open -a Docker`) and win32
92
+ // (installers/windows/launch-culpa.ps1:236-244) — but never on linux.
93
+ function dockerStoppedLines(platform) {
94
+ const lines = ["Docker is installed but its engine is not running."];
95
+ if (platform === "linux") {
96
+ lines.push(" Start it yourself, for example: sudo systemctl start docker");
97
+ } else {
98
+ lines.push(" Culpa will start it for you when you run `getculpa`.");
99
+ }
100
+ return lines;
101
+ }
102
+
103
+ export function buildInstallSummary(opts) {
104
+ const {
105
+ classification,
106
+ version,
107
+ previousVersion = null,
108
+ appDir,
109
+ dockerState,
110
+ imagesStaged = false,
111
+ collectorStaged = false,
112
+ // T-CF28-4b. Fail-CLOSED, matching imagesStaged/collectorStaged above: a
113
+ // caller that cannot say whether the payload is there has no evidence for
114
+ // "ready", and this function exists precisely to stop readiness being
115
+ // claimed without evidence. scripts/install.js always passes the real value.
116
+ payloadStaged = false,
117
+ platform = process.platform,
118
+ isTTY = false,
119
+ env = {},
120
+ } = opts;
121
+
122
+ const dockerReady = dockerState === "ready";
123
+ // "ready" means the next command will work. A missing CLI payload breaks
124
+ // that just as completely as a missing Docker, so it gates the word too.
125
+ const ready = dockerReady && payloadStaged;
126
+ const colour = { isTTY, env };
127
+ const lines = [headline(classification, version, previousVersion, ready, colour)];
128
+
129
+ // A refused downgrade staged nothing — an inventory there would describe a
130
+ // state this run did not produce (lib/provision.mjs:175-183).
131
+ if (classification !== "downgrade-package") {
132
+ lines.push(...inventory(appDir, imagesStaged, collectorStaged, payloadStaged));
133
+ }
134
+
135
+ if (!dockerReady) {
136
+ lines.push("");
137
+ lines.push(...(dockerState === "missing" ? dockerMissingLines(platform) : dockerStoppedLines(platform)));
138
+ }
139
+
140
+ lines.push("");
141
+ lines.push(ready ? "Run:" : "Then run:");
142
+ lines.push(" getculpa");
143
+ return lines;
144
+ }
package/lib/paths.mjs CHANGED
@@ -19,6 +19,12 @@ import path from "node:path";
19
19
  // "a test set this" from "an operator set this" from inside the module, and
20
20
  // refusing a real operator's override would be a worse failure mode than
21
21
  // honoring it.
22
+ //
23
+ // D-108 (cli-v1.0.2): the vendored Rust launcher honors CULPA_APP_DIR too —
24
+ // as a fallback when its own CULPA_HOME is unset (native/culpa-launcher/
25
+ // src/home.rs). So relocating with this variable moves BOTH halves: the
26
+ // stack/licence state this module resolves AND the launcher's
27
+ // versions/updates state. Keep the two sides in step if either changes.
22
28
  export function getAppDir(opts = {}) {
23
29
  const env = opts.env ?? process.env;
24
30
  const platform = opts.platform ?? process.platform;