getculpa 1.0.1 → 1.0.3
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/assets/culpa-compose.yml +6 -6
- package/assets/uninstall-culpa.ps1 +115 -5
- package/bin/getculpa.js +34 -6
- package/lib/bootstrap.d.mts +29 -0
- package/lib/bootstrap.mjs +97 -0
- package/lib/dispatch.d.mts +12 -0
- package/lib/dispatch.mjs +59 -0
- package/lib/docker.d.mts +4 -0
- package/lib/docker.mjs +19 -0
- package/lib/doctor.mjs +38 -10
- package/lib/install-summary.d.mts +18 -0
- package/lib/install-summary.mjs +156 -0
- package/lib/paths.mjs +6 -0
- package/lib/provision.d.mts +30 -1
- package/lib/provision.mjs +86 -5
- package/lib/repair.d.mts +1 -0
- package/lib/repair.mjs +174 -135
- package/lib/start.d.mts +2 -0
- package/lib/start.mjs +99 -7
- package/lib/tty.d.mts +18 -0
- package/lib/tty.mjs +58 -0
- package/lib/uninstall.mjs +45 -6
- package/package.json +25 -24
- package/scripts/install.js +35 -12
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
|
package/assets/culpa-compose.yml
CHANGED
|
@@ -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.
|
|
8
|
+
# ── RELEASE STATE: v1.0.3 PUBLISHED AND SIGNED (2026-08-20) ─────────
|
|
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.
|
|
29
|
+
# ghcr.io/myaigidev/culpa-server:v1.0.3
|
|
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.
|
|
58
|
+
image: ghcr.io/myaigidev/culpa-server:v1.0.3
|
|
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.
|
|
130
|
+
image: ghcr.io/myaigidev/culpa-dashboard:v1.0.3
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
$
|
|
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
|
-
|
|
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
|
package/bin/getculpa.js
CHANGED
|
@@ -121,21 +121,49 @@ async function runUninstall() {
|
|
|
121
121
|
|
|
122
122
|
async function main(argv) {
|
|
123
123
|
const [cmd] = argv;
|
|
124
|
-
if (cmd === undefined || cmd === "start") return runStart();
|
|
125
|
-
if (cmd === "-h" || cmd === "--help" || cmd === "help") {
|
|
126
|
-
console.log(HELP);
|
|
127
|
-
return process.exit(0);
|
|
128
|
-
}
|
|
129
124
|
if (cmd === "-v" || cmd === "--version") {
|
|
130
125
|
printVersion();
|
|
131
126
|
return process.exit(0);
|
|
132
127
|
}
|
|
128
|
+
// V102 T-V102-04 (M8, D-113): `getculpa uninstall --help` once EXECUTED the
|
|
129
|
+
// uninstall — the help flag was only recognised as argv[0]. All help and
|
|
130
|
+
// uninstall-consent decisions now happen here, BEFORE anything is imported
|
|
131
|
+
// or spawned. Passthrough verbs fall through untouched (the launcher owns
|
|
132
|
+
// their help).
|
|
133
|
+
const { decideDispatch, verbHelp } = await import("../lib/dispatch.mjs");
|
|
134
|
+
const decision = decideDispatch(argv, {
|
|
135
|
+
isTTY: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
136
|
+
});
|
|
137
|
+
if (decision.kind === "help") {
|
|
138
|
+
console.log(decision.verb === null ? HELP : verbHelp(decision.verb));
|
|
139
|
+
return process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
if (decision.kind === "refuse-uninstall") {
|
|
142
|
+
console.error(
|
|
143
|
+
"getculpa: uninstall removes Culpa's containers and network. Run it from a terminal to confirm interactively, or pass --yes to consent from a script. Nothing was changed.",
|
|
144
|
+
);
|
|
145
|
+
return process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
if (decision.kind === "confirm-uninstall") {
|
|
148
|
+
const { askYesNo } = await import("../lib/tty.mjs");
|
|
149
|
+
const consented = await askYesNo(
|
|
150
|
+
"This stops and removes Culpa's containers and network (recorded cost data is kept unless you separately confirm deleting it). Continue? (y/N) ",
|
|
151
|
+
{ isInteractive: true },
|
|
152
|
+
);
|
|
153
|
+
if (!consented) {
|
|
154
|
+
console.log("getculpa: uninstall cancelled. Nothing was changed.");
|
|
155
|
+
return process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
return runUninstall();
|
|
158
|
+
}
|
|
159
|
+
if (decision.kind === "run-uninstall") return runUninstall();
|
|
160
|
+
if (cmd === undefined || cmd === "start") return runStart();
|
|
133
161
|
if (cmd === "stop") return runStop();
|
|
134
162
|
if (cmd === "restart") return runRestart();
|
|
135
163
|
if (cmd === "status") return runStatus();
|
|
136
164
|
if (cmd === "doctor") return runDoctor();
|
|
137
165
|
if (cmd === "repair") return runRepair();
|
|
138
|
-
|
|
166
|
+
// `uninstall` never reaches here — every argv shape is decided above.
|
|
139
167
|
// scan / connect / update / config / anything unrecognized: the launcher
|
|
140
168
|
// owns USAGE and exit-code semantics for its own surface, same as culpa.js.
|
|
141
169
|
return runLauncherPassthrough(argv);
|
|
@@ -0,0 +1,29 @@
|
|
|
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;
|
|
26
|
+
|
|
27
|
+
export type PayloadState = "installed" | "staged" | "missing";
|
|
28
|
+
|
|
29
|
+
export function readPayloadState(opts?: { env?: Record<string, string | undefined> }): PayloadState;
|
|
@@ -0,0 +1,97 @@
|
|
|
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, readFileSync } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { getAppDir } from "./paths.mjs";
|
|
32
|
+
|
|
33
|
+
// A real payload is tens of MB over a network Culpa does not control. Long
|
|
34
|
+
// enough not to abort a slow-but-working download; short enough that a hung
|
|
35
|
+
// mirror degrades to a clear deferred message instead of hanging `npm install`
|
|
36
|
+
// indefinitely, which is the failure mode that matters — npm gives no progress
|
|
37
|
+
// output of its own here.
|
|
38
|
+
export const PAYLOAD_STAGE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
// Where lib/fetch.mjs puts the launcher (see stageStubLauncher in
|
|
41
|
+
// scripts/install.js for the same naming: `culpa-launcher` + platform suffix).
|
|
42
|
+
export function vendoredLauncherPath(packageRoot, platform = process.platform) {
|
|
43
|
+
const exe = platform === "win32" ? ".exe" : "";
|
|
44
|
+
const candidate = path.join(packageRoot, "vendor", `culpa-launcher${exe}`);
|
|
45
|
+
return existsSync(candidate) ? candidate : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** V102 T-V102-15 (M3, D-113): what the launcher's updates/state.json
|
|
49
|
+
* actually says — EVIDENCE for the install summary, never the updater's
|
|
50
|
+
* exit code (which claimed "Culpa CLI: installed" on a Mac whose state read
|
|
51
|
+
* current: null while doctor FAILed the same install).
|
|
52
|
+
* "installed": current is set. "staged": pending only — activates at the
|
|
53
|
+
* next `getculpa`. "missing": neither, no file, or unreadable.
|
|
54
|
+
* NB (review-3): getAppDir honours CULPA_APP_DIR but not CULPA_HOME — the
|
|
55
|
+
* known-open T-CF28-3c asymmetry (see paths.mjs:17-27 and open-loops
|
|
56
|
+
* §T-CF28-3c). A CULPA_HOME-only relocation therefore reads the default
|
|
57
|
+
* location and reports "missing" — underselling, never a false claim. */
|
|
58
|
+
export function readPayloadState(opts = {}) {
|
|
59
|
+
const { env = process.env } = opts;
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(path.join(getAppDir({ env }), "updates", "state.json"), "utf8");
|
|
62
|
+
const state = JSON.parse(raw);
|
|
63
|
+
if (typeof state.current === "string" && state.current.length > 0) return "installed";
|
|
64
|
+
if (typeof state.pending === "string" && state.pending.length > 0) return "staged";
|
|
65
|
+
return "missing";
|
|
66
|
+
} catch {
|
|
67
|
+
return "missing"; // absent or corrupt: no evidence, no claim
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function stageInitialPayload(opts) {
|
|
72
|
+
const { launcherPath, spawnSync = realSpawnSync, log = console.log, env = process.env } = opts;
|
|
73
|
+
|
|
74
|
+
// No launcher means the fetch above already failed and said so; there is
|
|
75
|
+
// nothing to invoke and nothing further worth reporting here.
|
|
76
|
+
if (!launcherPath) return { staged: false, reason: "no-launcher" };
|
|
77
|
+
|
|
78
|
+
log("getculpa: downloading the Culpa CLI (this is the program `getculpa` runs).");
|
|
79
|
+
const result = spawnSync(launcherPath, ["update"], {
|
|
80
|
+
stdio: "inherit",
|
|
81
|
+
timeout: PAYLOAD_STAGE_TIMEOUT_MS,
|
|
82
|
+
// Inherit the install's own environment so CULPA_HOME / CULPA_APP_DIR
|
|
83
|
+
// resolve to the SAME root scripts/install.js provisioned into (D-108
|
|
84
|
+
// unified that chain across the launcher and the Node CLI).
|
|
85
|
+
env,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (result.error) {
|
|
89
|
+
log(`getculpa: could not download the Culpa CLI (${result.error.message}) - run \`getculpa update\` to finish.`);
|
|
90
|
+
return { staged: false, reason: "spawn-failed" };
|
|
91
|
+
}
|
|
92
|
+
if (result.status !== 0) {
|
|
93
|
+
log("getculpa: could not download the Culpa CLI - run `getculpa update` to finish.");
|
|
94
|
+
return { staged: false, reason: "update-failed" };
|
|
95
|
+
}
|
|
96
|
+
return { staged: true, reason: "staged" };
|
|
97
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type DispatchDecision =
|
|
2
|
+
| { kind: "help"; verb: string | null }
|
|
3
|
+
| { kind: "refuse-uninstall" }
|
|
4
|
+
| { kind: "confirm-uninstall" }
|
|
5
|
+
| { kind: "run-uninstall" }
|
|
6
|
+
| { kind: "dispatch" };
|
|
7
|
+
|
|
8
|
+
export const LOCAL_VERBS: string[];
|
|
9
|
+
|
|
10
|
+
export function verbHelp(verb: string): string;
|
|
11
|
+
|
|
12
|
+
export function decideDispatch(argv: string[], opts?: { isTTY?: boolean }): DispatchDecision;
|
package/lib/dispatch.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// V102 T-V102-04 (M8/P0-2, D-113) — dispatch decisions for bin/getculpa.js.
|
|
2
|
+
//
|
|
3
|
+
// Executed on real hardware: `getculpa uninstall --help` stopped and removed
|
|
4
|
+
// all three containers and the network, because the router recognised a help
|
|
5
|
+
// flag only as argv[0] and dispatched on argv[0] without reading argv[1].
|
|
6
|
+
// `stop`, `restart` and `repair` shared the shape. The rule now: for every
|
|
7
|
+
// verb THIS bin routes locally, a help flag anywhere in argv decides HELP
|
|
8
|
+
// before anything is imported or spawned. Passthrough verbs (scan, connect,
|
|
9
|
+
// update, config, …) keep their argv untouched — the vendored launcher owns
|
|
10
|
+
// their help and exit-code semantics.
|
|
11
|
+
//
|
|
12
|
+
// Pure decisions, no side effects: the bin acts on the returned kind, and the
|
|
13
|
+
// facts in tests/npm-cli-dispatch.test.ts assert the decisions directly.
|
|
14
|
+
|
|
15
|
+
const HELP_FLAGS = new Set(["-h", "--help", "help"]);
|
|
16
|
+
|
|
17
|
+
/** Every verb bin/getculpa.js routes locally (everything else passes through). */
|
|
18
|
+
export const LOCAL_VERBS = ["start", "stop", "restart", "status", "doctor", "repair", "uninstall"];
|
|
19
|
+
const LOCAL = new Set(LOCAL_VERBS);
|
|
20
|
+
|
|
21
|
+
const VERB_HELP = {
|
|
22
|
+
start: "Start (or resume) the Culpa stack. Data is kept.",
|
|
23
|
+
stop: "Stop the running stack. Containers stop, nothing is removed, all data is kept. Start again with `getculpa`.",
|
|
24
|
+
restart: "Stop, then start the stack. No data is touched.",
|
|
25
|
+
status: "Show install/docker/stack/license status. Read-only.",
|
|
26
|
+
doctor: "Diagnose this install. Read-only.",
|
|
27
|
+
repair: "Re-stage missing or corrupt install files. No data touched.",
|
|
28
|
+
uninstall:
|
|
29
|
+
"Stop and REMOVE Culpa's containers and network. Asks for confirmation first; pass --yes to consent from a script (a non-interactive run without --yes refuses). Recorded cost data is kept unless you separately confirm deleting it.",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** One verb's help block, prefixed with its usage line. */
|
|
33
|
+
export function verbHelp(verb) {
|
|
34
|
+
const text = VERB_HELP[verb] ?? "No help recorded for this verb.";
|
|
35
|
+
return `Usage: getculpa ${verb}\n\n ${text}\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Decide what argv means BEFORE any dispatch. Returns one of:
|
|
40
|
+
* { kind: "help", verb: string | null } print help (general when verb null)
|
|
41
|
+
* { kind: "refuse-uninstall" } non-interactive, no --yes: refuse
|
|
42
|
+
* { kind: "confirm-uninstall" } interactive: ask, then uninstall
|
|
43
|
+
* { kind: "run-uninstall" } consent given via --yes/-y
|
|
44
|
+
* { kind: "dispatch" } fall through to the existing router
|
|
45
|
+
*/
|
|
46
|
+
export function decideDispatch(argv, { isTTY = false } = {}) {
|
|
47
|
+
const [cmd] = argv;
|
|
48
|
+
if (cmd === undefined) return { kind: "dispatch" };
|
|
49
|
+
if (HELP_FLAGS.has(cmd)) return { kind: "help", verb: null };
|
|
50
|
+
if (LOCAL.has(cmd) && argv.slice(1).some((a) => HELP_FLAGS.has(a))) {
|
|
51
|
+
return { kind: "help", verb: cmd };
|
|
52
|
+
}
|
|
53
|
+
if (cmd === "uninstall") {
|
|
54
|
+
if (argv.includes("--yes") || argv.includes("-y")) return { kind: "run-uninstall" };
|
|
55
|
+
if (!isTTY) return { kind: "refuse-uninstall" };
|
|
56
|
+
return { kind: "confirm-uninstall" };
|
|
57
|
+
}
|
|
58
|
+
return { kind: "dispatch" };
|
|
59
|
+
}
|
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 {
|
|
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
|
-
|
|
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(
|
|
@@ -240,9 +244,24 @@ async function checkHealth(fetchFn, running) {
|
|
|
240
244
|
}
|
|
241
245
|
|
|
242
246
|
function scanLicenseKeyPresence(env, appDir) {
|
|
243
|
-
|
|
247
|
+
// CodeRabbit PR#19 [6]: " " is truthy — trim before believing the env
|
|
248
|
+
if ((env.CULPA_LICENSE_KEY ?? "").trim() !== "") return true;
|
|
244
249
|
const compose = readTextSafe(path.join(appDir, "docker-compose.yml"));
|
|
245
|
-
|
|
250
|
+
// V102 T-V102-15 (M6/W3, D-113): the old bare /CULPA_LICENSE_KEY/ regex
|
|
251
|
+
// matched a COMMENT line in the shipped compose, so doctor reported
|
|
252
|
+
// "[OK] present (redacted)" with no key set — on real hardware, both
|
|
253
|
+
// platforms, while `status` said "License: unknown" beside it. Only an
|
|
254
|
+
// uncommented env ENTRY carrying a non-empty value counts.
|
|
255
|
+
return compose.split(/\r?\n/).some((line) => {
|
|
256
|
+
const t = line.trim();
|
|
257
|
+
if (t.startsWith("#")) return false;
|
|
258
|
+
const m = /^-?\s*["']?CULPA_LICENSE_KEY["']?\s*[:=]\s*(.+)$/.exec(t);
|
|
259
|
+
if (m === null) return false;
|
|
260
|
+
// PR#19 [6]: "KEY: # configured elsewhere" is YAML for an EMPTY value
|
|
261
|
+
// plus a trailing comment — never a configured key
|
|
262
|
+
const value = m[1].trim().replace(/^["']|["']$/g, "").trim();
|
|
263
|
+
return value.length > 0 && !value.startsWith("#");
|
|
264
|
+
});
|
|
246
265
|
}
|
|
247
266
|
|
|
248
267
|
// Extracts ONLY configured/state/plan — even if the (possibly stubbed, in
|
|
@@ -311,14 +330,23 @@ function checkUpdateState(appDir, currentVersion, installed) {
|
|
|
311
330
|
|
|
312
331
|
const updateStatePath = path.join(appDir, "updates", "state.json");
|
|
313
332
|
const launcherState = readJsonSafe(updateStatePath);
|
|
333
|
+
// Audit (flows) finding 2: this reported "ok" whenever the JSON merely
|
|
334
|
+
// PARSED, never looking at current. So on the exact stranded install
|
|
335
|
+
// T-CF28-4b exists to prevent - no payload ever staged - doctor printed
|
|
336
|
+
// "Overall: OK" while the very next getculpa dead-ended with "no installed
|
|
337
|
+
// version found". Diagnostics that reassure you about the broken thing are
|
|
338
|
+
// worse than none.
|
|
339
|
+
const noPayload = launcherState !== null && (launcherState.current ?? null) === null;
|
|
314
340
|
checks.push(
|
|
315
341
|
check(
|
|
316
342
|
"Update state",
|
|
317
343
|
"Launcher self-update state",
|
|
318
|
-
launcherState ? "
|
|
319
|
-
launcherState
|
|
320
|
-
?
|
|
321
|
-
:
|
|
344
|
+
launcherState === null ? "info" : noPayload ? "fail" : "ok",
|
|
345
|
+
launcherState === null
|
|
346
|
+
? "unknown (no update state recorded yet)"
|
|
347
|
+
: noPayload
|
|
348
|
+
? "no CLI payload is staged (current=null) - run `getculpa update`, then `getculpa`"
|
|
349
|
+
: `current=${launcherState.current}, pending=${launcherState.pending ?? "none"}, mode=${launcherState.mode ?? "n/a"}`,
|
|
322
350
|
),
|
|
323
351
|
);
|
|
324
352
|
return checks;
|
|
@@ -339,7 +367,7 @@ export async function doctor(opts) {
|
|
|
339
367
|
const checks = [];
|
|
340
368
|
checks.push(...checkVersionPlatform(currentVersion, platform, arch));
|
|
341
369
|
|
|
342
|
-
const integrity = checkInstallationIntegrity(appDir);
|
|
370
|
+
const integrity = checkInstallationIntegrity(appDir, platform);
|
|
343
371
|
checks.push(...integrity.checks);
|
|
344
372
|
checks.push(checkVendoredLauncher(vendorDir, platform));
|
|
345
373
|
|
|
@@ -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
|
+
payloadState?: "installed" | "staged" | "missing";
|
|
13
|
+
platform?: string;
|
|
14
|
+
isTTY?: boolean;
|
|
15
|
+
env?: Record<string, string | undefined>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildInstallSummary(opts: InstallSummaryOptions): string[];
|