getculpa 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/doctor.mjs CHANGED
@@ -1,420 +1,454 @@
1
- // CF20-T5 — `getculpa doctor`. Support-paste-friendly, plain text (no ANSI,
2
- // ever — that trivially satisfies "no ANSI when not TTY" without a second
3
- // TTY-conditional code path to test), sectioned diagnostics.
4
- //
5
- // Exit-code contract (spec sec 31, "doctor-on-dormant"): only STRUCTURAL
6
- // integrity problems (a missing parity file, an unreadable install-state.json,
7
- // a missing vendored launcher, or an actively-running-but-unreachable
8
- // database) are FAILures. Everything Docker/network/health-related degrades
9
- // to a WARNing — a fully-provisioned but not-yet-started ("dormant") install
10
- // must report ok:true. This mirrors status.mjs's own "never throws, always
11
- // degrades honestly" contract, extended with a pass/fail verdict.
12
- //
13
- // Every docker/http call is injectable (spawnSync/fetchFn), same pattern as
14
- // lib/docker.mjs, lib/start.mjs and lib/status.mjs — no PATH shim, no real
15
- // network, no real container.
16
-
17
- import { spawnSync as realSpawnSync } from "node:child_process";
18
- import { existsSync, readFileSync } from "node:fs";
19
- import path from "node:path";
20
- import { fileURLToPath } from "node:url";
21
- import { findPortOwner, probeHealth, PROBE_TIMEOUT_MS } from "./docker.mjs";
22
- import { checkDockerPresent, checkDockerEngineReachable, classifyUpgrade, parsePinnedServerVersion } from "./preflight.mjs";
23
- import { parityAssetsFor } from "./provision.mjs";
24
-
25
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
- const packageRoot = path.join(__dirname, "..");
27
-
28
- const PORTS_TO_CHECK = [3000, 4545];
29
- const DASHBOARD_URL = "http://127.0.0.1:3000";
30
- const API_HEALTH_URL = "http://127.0.0.1:4545/health";
31
- const ENTITLEMENT_URL = "http://127.0.0.1:4545/internal/entitlement";
32
-
33
- function check(section, label, status, detail) {
34
- return { section, label, status, detail };
35
- }
36
-
37
- function readJsonSafe(filePath) {
38
- try {
39
- return JSON.parse(readFileSync(filePath, "utf8"));
40
- } catch {
41
- return null;
42
- }
43
- }
44
-
45
- function readTextSafe(filePath) {
46
- try {
47
- return readFileSync(filePath, "utf8");
48
- } catch {
49
- return "";
50
- }
51
- }
52
-
53
- // `docker ps -a --filter label=com.docker.compose.project=culpa` with a
54
- // tab-separated format — one call serves the "runtime/service state"
55
- // section AND tells the rest of doctor whether the project (and its db
56
- // container specifically) is running, without three separate `docker ps`
57
- // invocations.
58
- function listProjectContainers(spawnSync) {
59
- const r = spawnSync(
60
- "docker",
61
- ["ps", "-a", "--filter", "label=com.docker.compose.project=culpa", "--format", "{{.Names}}\t{{.State}}\t{{.Status}}"],
62
- { encoding: "utf8", timeout: PROBE_TIMEOUT_MS },
63
- );
64
- if (r.status !== 0) return [];
65
- return (r.stdout ?? "")
66
- .split("\n")
67
- .map((l) => l.trim())
68
- .filter(Boolean)
69
- .map((line) => {
70
- const [name, state, ...statusParts] = line.split("\t");
71
- return { name: name ?? "", state: state ?? "", status: statusParts.join("\t") };
72
- });
73
- }
74
-
75
- function getDockerServerVersion(spawnSync) {
76
- const r = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
77
- encoding: "utf8",
78
- timeout: PROBE_TIMEOUT_MS,
79
- });
80
- if (r.status !== 0) return null;
81
- return (r.stdout ?? "").trim() || null;
82
- }
83
-
84
- function checkVersionPlatform(currentVersion, platform, arch) {
85
- return [
86
- check("Version / platform", "getculpa package version", "info", currentVersion),
87
- check("Version / platform", "Platform", "info", platform),
88
- check("Version / platform", "Architecture", "info", arch),
89
- ];
90
- }
91
-
92
- function checkVendoredLauncher(vendorDir, platform) {
93
- const exe = platform === "win32" ? ".exe" : "";
94
- const launcherPath = path.join(vendorDir, `culpa-launcher${exe}`);
95
- const present = existsSync(launcherPath);
96
- return check(
97
- "Installation integrity",
98
- "Vendored launcher",
99
- present ? "ok" : "fail",
100
- present ? launcherPath : `missing: ${launcherPath} (run \`npm rebuild getculpa\`, or reinstall)`,
101
- );
102
- }
103
-
104
- function checkInstallationIntegrity(appDir, platform) {
105
- const checks = [];
106
- const liveComposePath = path.join(appDir, "docker-compose.yml");
107
- const installed = existsSync(liveComposePath);
108
-
109
- checks.push(check("Installation integrity", "App directory", installed ? "ok" : "info", appDir));
110
-
111
- if (!installed) {
112
- checks.push(check("Installation integrity", "Live compose", "info", "not installed - nothing to verify"));
113
- return { checks, installed, state: null };
114
- }
115
-
116
- checks.push(check("Installation integrity", "Live compose", "ok", liveComposePath));
117
-
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)) {
123
- const assetPath = path.join(appDir, name);
124
- const present = existsSync(assetPath);
125
- checks.push(
126
- check(
127
- "Installation integrity",
128
- `Parity file: ${name}`,
129
- present ? "ok" : "fail",
130
- present ? assetPath : `missing: ${assetPath} (run \`getculpa repair\`)`,
131
- ),
132
- );
133
- }
134
-
135
- const state = readJsonSafe(path.join(appDir, "install-state.json"));
136
- checks.push(
137
- check(
138
- "Installation integrity",
139
- "install-state.json",
140
- state ? "ok" : "fail",
141
- state ? "readable" : "missing or unreadable (run `getculpa repair`)",
142
- ),
143
- );
144
-
145
- checks.push(
146
- check(
147
- "Installation integrity",
148
- "Capture collector",
149
- state?.collectorStaged ? "ok" : "warn",
150
- state?.collectorStaged ? "staged" : "not staged (capture is off on this install)",
151
- ),
152
- );
153
-
154
- return { checks, installed, state };
155
- }
156
-
157
- function checkDocker(spawnSync) {
158
- const checks = [];
159
- const present = checkDockerPresent(spawnSync);
160
- checks.push(
161
- check("Docker", "Docker CLI", present ? "ok" : "warn", present ? "found" : "not found - install from https://docs.docker.com/get-docker/"),
162
- );
163
-
164
- const reachable = present && checkDockerEngineReachable(spawnSync);
165
- checks.push(
166
- check(
167
- "Docker",
168
- "Docker engine",
169
- reachable ? "ok" : "warn",
170
- reachable ? "running" : present ? "installed, but the engine is not running" : "unavailable (Docker not installed)",
171
- ),
172
- );
173
-
174
- if (reachable) {
175
- const version = getDockerServerVersion(spawnSync);
176
- checks.push(check("Docker", "Docker version", version ? "ok" : "warn", version ?? "could not be determined"));
177
- }
178
-
179
- return { checks, reachable };
180
- }
181
-
182
- function checkRuntimeState(containers) {
183
- if (containers.length === 0) {
184
- return [check("Runtime / service state", "Containers", "info", "no containers for the culpa compose project (never started, or fully removed)")];
185
- }
186
- return containers.map((c) =>
187
- check("Runtime / service state", c.name, c.state === "running" ? "ok" : "info", `${c.state} - ${c.status}`),
188
- );
189
- }
190
-
191
- function checkExpectedDirectories(appDir, vendorDir) {
192
- const dirs = [
193
- ["App directory", appDir],
194
- ["Backups directory", path.join(appDir, "backups")],
195
- ["Vendor directory", vendorDir],
196
- ];
197
- return dirs.map(([label, dir]) => check("Expected directories", label, "info", `${existsSync(dir) ? "exists" : "missing"}: ${dir}`));
198
- }
199
-
200
- function checkDatabase(spawnSync, containers) {
201
- const dbContainer = containers.find((c) => /culpa-db/.test(c.name));
202
- if (!dbContainer || dbContainer.state !== "running") {
203
- return [check("Database", "Postgres (culpa-db)", "info", "skipped - db container not running")];
204
- }
205
- const r = spawnSync("docker", ["exec", "culpa-db", "pg_isready", "-U", "culpa"], {
206
- encoding: "utf8",
207
- timeout: PROBE_TIMEOUT_MS,
208
- });
209
- const ready = r.status === 0;
210
- return [check("Database", "Postgres (culpa-db)", ready ? "ok" : "fail", ready ? "accepting connections" : "container is running but not accepting connections")];
211
- }
212
-
213
- function checkMigrations(appDir) {
214
- const lastBoot = readTextSafe(path.join(appDir, "last-boot-version.txt")).trim();
215
- const pinned = parsePinnedServerVersion(readTextSafe(path.join(appDir, "docker-compose.yml")));
216
- return [
217
- check("Migrations", "Last booted server version", "info", lastBoot || "unknown (never recorded)"),
218
- check("Migrations", "Live compose pinned server tag", "info", pinned ?? "unknown"),
219
- ];
220
- }
221
-
222
- function checkPorts(spawnSync, platform, running) {
223
- return PORTS_TO_CHECK.map((port) => {
224
- const owner = findPortOwner(port, { platform, spawnSync });
225
- if (!owner) return check("Ports", `:${port}`, "ok", "free");
226
- if (running) return check("Ports", `:${port}`, "ok", `culpa-owned (${owner.name}, pid ${owner.pid})`);
227
- return check("Ports", `:${port}`, "warn", `in use by ${owner.name} (pid ${owner.pid})`);
228
- });
229
- }
230
-
231
- async function checkHealth(fetchFn, running) {
232
- if (!running) {
233
- return [
234
- check("Culpa health", "Dashboard (:3000)", "info", "not running"),
235
- check("Culpa health", "API (:4545)", "info", "not running"),
236
- ];
237
- }
238
- const dashboardHealthy = await probeHealth(DASHBOARD_URL, { fetchFn });
239
- const apiHealthy = await probeHealth(API_HEALTH_URL, { fetchFn });
240
- return [
241
- check("Culpa health", "Dashboard (:3000)", dashboardHealthy ? "ok" : "warn", dashboardHealthy ? "healthy" : "not responding"),
242
- check("Culpa health", "API (:4545)", apiHealthy ? "ok" : "warn", apiHealthy ? "healthy" : "not responding"),
243
- ];
244
- }
245
-
246
- function scanLicenseKeyPresence(env, appDir) {
247
- // CodeRabbit PR#19 [6]: " " is truthy — trim before believing the env
248
- if ((env.CULPA_LICENSE_KEY ?? "").trim() !== "") return true;
249
- const compose = readTextSafe(path.join(appDir, "docker-compose.yml"));
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
- });
265
- }
266
-
267
- // Extracts ONLY configured/state/plan — even if the (possibly stubbed, in
268
- // tests) response carries other fields, they are never read into the
269
- // report. This is the redaction boundary the F1 fact tests.
270
- async function checkLicense(fetchFn, running, env, appDir) {
271
- const checks = [];
272
- const keyPresent = scanLicenseKeyPresence(env, appDir);
273
- checks.push(check("License", "CULPA_LICENSE_KEY", keyPresent ? "ok" : "info", keyPresent ? "present (redacted)" : "not set"));
274
-
275
- if (!running) {
276
- checks.push(check("License", "Entitlement", "info", "unknown (server not running)"));
277
- return checks;
278
- }
279
- try {
280
- const res = await fetchFn(ENTITLEMENT_URL, { signal: AbortSignal.timeout(3_000) });
281
- if (res.status !== 200) {
282
- checks.push(check("License", "Entitlement", "warn", `server did not answer (status ${res.status})`));
283
- return checks;
284
- }
285
- const body = await res.json();
286
- const configured = typeof body?.configured === "boolean" ? body.configured : null;
287
- const state = typeof body?.state === "string" ? body.state : null;
288
- const plan = typeof body?.plan === "string" ? body.plan : null;
289
- checks.push(
290
- check(
291
- "License",
292
- "Entitlement",
293
- "ok",
294
- `configured=${configured ?? "unknown"}, state=${state ?? "n/a"}, plan=${plan ?? "n/a"}`,
295
- ),
296
- );
297
- } catch {
298
- checks.push(check("License", "Entitlement", "warn", "could not reach the server"));
299
- }
300
- return checks;
301
- }
302
-
303
- // CF20-R: classifyInstall alone cannot distinguish a package DOWNGRADE
304
- // (this package is older than what is already staged/live) from a
305
- // genuinely unclear install state — both collapsed into "repair" with the
306
- // same "run `getculpa repair`" message, which is misleading for a
307
- // downgrade (it is not unclear, it was a deliberate, correct refusal).
308
- // classifyUpgrade already makes that distinction (provision.mjs's own
309
- // caller uses it) — currentVersion was already threaded all the way from
310
- // bin/getculpa.js into this function; the gap was this function reading it
311
- // through the coarser classifier.
312
- function checkUpdateState(appDir, currentVersion, installed) {
313
- const checks = [];
314
- if (!installed) {
315
- checks.push(check("Update state", "Install vs shipped version", "info", "not installed - nothing to compare"));
316
- return checks;
317
- }
318
- const { classification: kind, previousVersion } = classifyUpgrade({ appDir, currentVersion });
319
- const label = {
320
- "same-version": ["ok", `up to date (v${currentVersion})`],
321
- upgrade: ["warn", `an update is staged - run \`getculpa\` to apply it`],
322
- repair: ["warn", "install state is unclear - run `getculpa repair`"],
323
- fresh: ["info", "not installed"],
324
- "downgrade-package": [
325
- "warn",
326
- `this package (v${currentVersion}) is OLDER than the installed Culpa v${previousVersion ?? "?"} - refusing to downgrade (set CULPA_ALLOW_DOWNGRADE=1 to override)`,
327
- ],
328
- }[kind] ?? ["info", kind];
329
- checks.push(check("Update state", "Install vs shipped version", label[0], label[1]));
330
-
331
- const updateStatePath = path.join(appDir, "updates", "state.json");
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;
340
- checks.push(
341
- check(
342
- "Update state",
343
- "Launcher self-update state",
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"}`,
350
- ),
351
- );
352
- return checks;
353
- }
354
-
355
- export async function doctor(opts) {
356
- const {
357
- appDir,
358
- currentVersion,
359
- platform = process.platform,
360
- arch = process.arch,
361
- spawnSync = realSpawnSync,
362
- fetchFn = fetch,
363
- env = process.env,
364
- vendorDir = path.join(packageRoot, "vendor"),
365
- } = opts;
366
-
367
- const checks = [];
368
- checks.push(...checkVersionPlatform(currentVersion, platform, arch));
369
-
370
- const integrity = checkInstallationIntegrity(appDir, platform);
371
- checks.push(...integrity.checks);
372
- checks.push(checkVendoredLauncher(vendorDir, platform));
373
-
374
- const docker = checkDocker(spawnSync);
375
- checks.push(...docker.checks);
376
-
377
- const containers = docker.reachable ? listProjectContainers(spawnSync) : [];
378
- checks.push(...checkRuntimeState(containers));
379
- checks.push(...checkExpectedDirectories(appDir, vendorDir));
380
- checks.push(...checkDatabase(spawnSync, containers));
381
- checks.push(...checkMigrations(appDir));
382
-
383
- const running = containers.some((c) => c.state === "running");
384
- checks.push(...checkPorts(spawnSync, platform, running));
385
- checks.push(...(await checkHealth(fetchFn, running)));
386
- checks.push(...(await checkLicense(fetchFn, running, env, appDir)));
387
- checks.push(...checkUpdateState(appDir, currentVersion, integrity.installed));
388
-
389
- const ok = !checks.some((c) => c.status === "fail");
390
- return { ok, checks };
391
- }
392
-
393
- export function formatDoctorReport(result) {
394
- const lines = [];
395
- lines.push("Culpa doctor report");
396
- lines.push("====================");
397
- lines.push("");
398
-
399
- const sections = [];
400
- for (const c of result.checks) {
401
- let section = sections.find((s) => s.name === c.section);
402
- if (!section) {
403
- section = { name: c.section, items: [] };
404
- sections.push(section);
405
- }
406
- section.items.push(c);
407
- }
408
-
409
- for (const section of sections) {
410
- lines.push(`-- ${section.name} --`);
411
- for (const item of section.items) {
412
- lines.push(`[${item.status.toUpperCase()}] ${item.label}: ${item.detail}`);
413
- }
414
- lines.push("");
415
- }
416
-
417
- const failureCount = result.checks.filter((c) => c.status === "fail").length;
418
- lines.push(result.ok ? "Overall: OK" : `Overall: ${failureCount} failure(s) - see above ([FAIL] lines).`);
419
- return lines.join("\n");
420
- }
1
+ // CF20-T5 — `getculpa doctor`. Support-paste-friendly, plain text (no ANSI,
2
+ // ever — that trivially satisfies "no ANSI when not TTY" without a second
3
+ // TTY-conditional code path to test), sectioned diagnostics.
4
+ //
5
+ // Exit-code contract (spec sec 31, "doctor-on-dormant"): only STRUCTURAL
6
+ // integrity problems (a missing parity file, an unreadable install-state.json,
7
+ // a missing vendored launcher, or an actively-running-but-unreachable
8
+ // database) are FAILures. Everything Docker/network/health-related degrades
9
+ // to a WARNing — a fully-provisioned but not-yet-started ("dormant") install
10
+ // must report ok:true. This mirrors status.mjs's own "never throws, always
11
+ // degrades honestly" contract, extended with a pass/fail verdict.
12
+ //
13
+ // Every docker/http call is injectable (spawnSync/fetchFn), same pattern as
14
+ // lib/docker.mjs, lib/start.mjs and lib/status.mjs — no PATH shim, no real
15
+ // network, no real container.
16
+
17
+ import { spawnSync as realSpawnSync } from "node:child_process";
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { findPortOwner, probeHealth, PROBE_TIMEOUT_MS, runningImageVersions } from "./docker.mjs";
22
+ import {
23
+ checkDockerPresent,
24
+ checkDockerEngineReachable,
25
+ classifyUpgrade,
26
+ parsePinnedServerVersion,
27
+ unprovisionedDiagnosis,
28
+ versionSkew,
29
+ } from "./preflight.mjs";
30
+ import { parityAssetsFor } from "./provision.mjs";
31
+
32
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
+ const packageRoot = path.join(__dirname, "..");
34
+
35
+ const PORTS_TO_CHECK = [3000, 4545];
36
+ const DASHBOARD_URL = "http://127.0.0.1:3000";
37
+ const API_HEALTH_URL = "http://127.0.0.1:4545/health";
38
+ const ENTITLEMENT_URL = "http://127.0.0.1:4545/internal/entitlement";
39
+
40
+ function check(section, label, status, detail) {
41
+ return { section, label, status, detail };
42
+ }
43
+
44
+ function readJsonSafe(filePath) {
45
+ try {
46
+ return JSON.parse(readFileSync(filePath, "utf8"));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function readTextSafe(filePath) {
53
+ try {
54
+ return readFileSync(filePath, "utf8");
55
+ } catch {
56
+ return "";
57
+ }
58
+ }
59
+
60
+ // `docker ps -a --filter label=com.docker.compose.project=culpa` with a
61
+ // tab-separated format — one call serves the "runtime/service state"
62
+ // section AND tells the rest of doctor whether the project (and its db
63
+ // container specifically) is running, without three separate `docker ps`
64
+ // invocations.
65
+ function listProjectContainers(spawnSync) {
66
+ const r = spawnSync(
67
+ "docker",
68
+ ["ps", "-a", "--filter", "label=com.docker.compose.project=culpa", "--format", "{{.Names}}\t{{.State}}\t{{.Status}}"],
69
+ { encoding: "utf8", timeout: PROBE_TIMEOUT_MS },
70
+ );
71
+ if (r.status !== 0) return [];
72
+ return (r.stdout ?? "")
73
+ .split("\n")
74
+ .map((l) => l.trim())
75
+ .filter(Boolean)
76
+ .map((line) => {
77
+ const [name, state, ...statusParts] = line.split("\t");
78
+ return { name: name ?? "", state: state ?? "", status: statusParts.join("\t") };
79
+ });
80
+ }
81
+
82
+ function getDockerServerVersion(spawnSync) {
83
+ const r = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
84
+ encoding: "utf8",
85
+ timeout: PROBE_TIMEOUT_MS,
86
+ });
87
+ if (r.status !== 0) return null;
88
+ return (r.stdout ?? "").trim() || null;
89
+ }
90
+
91
+ function checkVersionPlatform(currentVersion, platform, arch) {
92
+ return [
93
+ check("Version / platform", "getculpa package version", "info", currentVersion),
94
+ check("Version / platform", "Platform", "info", platform),
95
+ check("Version / platform", "Architecture", "info", arch),
96
+ ];
97
+ }
98
+
99
+ function checkVendoredLauncher(vendorDir, platform) {
100
+ const exe = platform === "win32" ? ".exe" : "";
101
+ const launcherPath = path.join(vendorDir, `culpa-launcher${exe}`);
102
+ const present = existsSync(launcherPath);
103
+ return check(
104
+ "Installation integrity",
105
+ "Vendored launcher",
106
+ present ? "ok" : "fail",
107
+ present ? launcherPath : `missing: ${launcherPath} (run \`npm rebuild getculpa\`, or reinstall)`,
108
+ );
109
+ }
110
+
111
+ function checkInstallationIntegrity(appDir, platform) {
112
+ const checks = [];
113
+ const liveComposePath = path.join(appDir, "docker-compose.yml");
114
+ const installed = existsSync(liveComposePath);
115
+
116
+ checks.push(check("Installation integrity", "App directory", installed ? "ok" : "info", appDir));
117
+
118
+ if (!installed) {
119
+ // T-179 (ISS-MAC-8): a package that runs but was never provisioned is
120
+ // not "nothing to verify" — it is the failure the customer is trying to
121
+ // diagnose. `info` here let the report end "Overall: OK".
122
+ const diagnosis = unprovisionedDiagnosis();
123
+ checks.push(check("Installation integrity", "Provisioning", "fail", `${diagnosis.message} ${diagnosis.recovery}`));
124
+ return { checks, installed, state: null };
125
+ }
126
+
127
+ checks.push(check("Installation integrity", "Live compose", "ok", liveComposePath));
128
+
129
+ // T-CF28-6: grade only the files this platform is supposed to have. On
130
+ // darwin/linux the four Windows .ps1 scripts are correctly absent — listing
131
+ // them at all (as [OK] before, as a failure after the staging fix) reports
132
+ // on an install that does not exist.
133
+ for (const name of parityAssetsFor(platform)) {
134
+ const assetPath = path.join(appDir, name);
135
+ const present = existsSync(assetPath);
136
+ checks.push(
137
+ check(
138
+ "Installation integrity",
139
+ `Parity file: ${name}`,
140
+ present ? "ok" : "fail",
141
+ present ? assetPath : `missing: ${assetPath} (run \`getculpa repair\`)`,
142
+ ),
143
+ );
144
+ }
145
+
146
+ const state = readJsonSafe(path.join(appDir, "install-state.json"));
147
+ checks.push(
148
+ check(
149
+ "Installation integrity",
150
+ "install-state.json",
151
+ state ? "ok" : "fail",
152
+ state ? "readable" : "missing or unreadable (run `getculpa repair`)",
153
+ ),
154
+ );
155
+
156
+ checks.push(
157
+ check(
158
+ "Installation integrity",
159
+ "Capture collector",
160
+ state?.collectorStaged ? "ok" : "warn",
161
+ state?.collectorStaged ? "staged" : "not staged (capture is off on this install)",
162
+ ),
163
+ );
164
+
165
+ return { checks, installed, state };
166
+ }
167
+
168
+ function checkDocker(spawnSync) {
169
+ const checks = [];
170
+ const present = checkDockerPresent(spawnSync);
171
+ checks.push(
172
+ check("Docker", "Docker CLI", present ? "ok" : "warn", present ? "found" : "not found - install from https://docs.docker.com/get-docker/"),
173
+ );
174
+
175
+ const reachable = present && checkDockerEngineReachable(spawnSync);
176
+ checks.push(
177
+ check(
178
+ "Docker",
179
+ "Docker engine",
180
+ reachable ? "ok" : "warn",
181
+ reachable ? "running" : present ? "installed, but the engine is not running" : "unavailable (Docker not installed)",
182
+ ),
183
+ );
184
+
185
+ if (reachable) {
186
+ const version = getDockerServerVersion(spawnSync);
187
+ checks.push(check("Docker", "Docker version", version ? "ok" : "warn", version ?? "could not be determined"));
188
+ }
189
+
190
+ return { checks, reachable };
191
+ }
192
+
193
+ function checkRuntimeState(containers) {
194
+ if (containers.length === 0) {
195
+ return [check("Runtime / service state", "Containers", "info", "no containers for the culpa compose project (never started, or fully removed)")];
196
+ }
197
+ return containers.map((c) =>
198
+ check("Runtime / service state", c.name, c.state === "running" ? "ok" : "info", `${c.state} - ${c.status}`),
199
+ );
200
+ }
201
+
202
+ function checkExpectedDirectories(appDir, vendorDir) {
203
+ const dirs = [
204
+ ["App directory", appDir],
205
+ ["Backups directory", path.join(appDir, "backups")],
206
+ ["Vendor directory", vendorDir],
207
+ ];
208
+ return dirs.map(([label, dir]) => check("Expected directories", label, "info", `${existsSync(dir) ? "exists" : "missing"}: ${dir}`));
209
+ }
210
+
211
+ function checkDatabase(spawnSync, containers) {
212
+ const dbContainer = containers.find((c) => /culpa-db/.test(c.name));
213
+ if (!dbContainer || dbContainer.state !== "running") {
214
+ return [check("Database", "Postgres (culpa-db)", "info", "skipped - db container not running")];
215
+ }
216
+ const r = spawnSync("docker", ["exec", "culpa-db", "pg_isready", "-U", "culpa"], {
217
+ encoding: "utf8",
218
+ timeout: PROBE_TIMEOUT_MS,
219
+ });
220
+ const ready = r.status === 0;
221
+ return [check("Database", "Postgres (culpa-db)", ready ? "ok" : "fail", ready ? "accepting connections" : "container is running but not accepting connections")];
222
+ }
223
+
224
+ function checkMigrations(appDir) {
225
+ const lastBoot = readTextSafe(path.join(appDir, "last-boot-version.txt")).trim();
226
+ const pinned = parsePinnedServerVersion(readTextSafe(path.join(appDir, "docker-compose.yml")));
227
+ return [
228
+ check("Migrations", "Last booted server version", "info", lastBoot || "unknown (never recorded)"),
229
+ check("Migrations", "Live compose pinned server tag", "info", pinned ?? "unknown"),
230
+ ];
231
+ }
232
+
233
+ // T-178 (ISS-MAC-7): a stack whose containers are behind the live pin, or a
234
+ // live compose behind the shipped one, is a FINDING — doctor used to print
235
+ // "Overall: OK" beside exactly that skew because nothing compared the three.
236
+ function checkVersionSkew(appDir, spawnSync, running) {
237
+ const shippedText = readTextSafe(path.join(appDir, "culpa-compose.yml"));
238
+ const liveText = readTextSafe(path.join(appDir, "docker-compose.yml"));
239
+ const runningVersions = running ? runningImageVersions(spawnSync) : {};
240
+ const skew = versionSkew({ shippedText, liveText, running: runningVersions });
241
+ if (skew !== null) {
242
+ const detail =
243
+ skew.kind === "running-behind"
244
+ ? `${skew.component} is running ${skew.from} but the compose pins ${skew.to} - run \`getculpa\` to apply it`
245
+ : `live compose pins ${skew.from}, this package ships ${skew.to} - run \`getculpa\` to apply the update`;
246
+ return [check("Version skew", "Containers vs compose vs package", "fail", detail)];
247
+ }
248
+ if (!running || Object.keys(runningVersions).length === 0) {
249
+ return [check("Version skew", "Containers vs compose vs package", "info", running ? "running images could not be read" : "not running - nothing to compare")];
250
+ }
251
+ const server = runningVersions["culpa-server"] ?? Object.values(runningVersions)[0];
252
+ return [check("Version skew", "Containers vs compose vs package", "ok", `containers match the compose pins (${server})`)];
253
+ }
254
+
255
+ function checkPorts(spawnSync, platform, running) {
256
+ return PORTS_TO_CHECK.map((port) => {
257
+ const owner = findPortOwner(port, { platform, spawnSync });
258
+ if (!owner) return check("Ports", `:${port}`, "ok", "free");
259
+ if (running) return check("Ports", `:${port}`, "ok", `culpa-owned (${owner.name}, pid ${owner.pid})`);
260
+ return check("Ports", `:${port}`, "warn", `in use by ${owner.name} (pid ${owner.pid})`);
261
+ });
262
+ }
263
+
264
+ async function checkHealth(fetchFn, running) {
265
+ if (!running) {
266
+ return [
267
+ check("Culpa health", "Dashboard (:3000)", "info", "not running"),
268
+ check("Culpa health", "API (:4545)", "info", "not running"),
269
+ ];
270
+ }
271
+ const dashboardHealthy = await probeHealth(DASHBOARD_URL, { fetchFn });
272
+ const apiHealthy = await probeHealth(API_HEALTH_URL, { fetchFn });
273
+ return [
274
+ check("Culpa health", "Dashboard (:3000)", dashboardHealthy ? "ok" : "warn", dashboardHealthy ? "healthy" : "not responding"),
275
+ check("Culpa health", "API (:4545)", apiHealthy ? "ok" : "warn", apiHealthy ? "healthy" : "not responding"),
276
+ ];
277
+ }
278
+
279
+ function scanLicenseKeyPresence(env, appDir) {
280
+ // CodeRabbit PR#19 [6]: " " is truthy — trim before believing the env
281
+ if ((env.CULPA_LICENSE_KEY ?? "").trim() !== "") return true;
282
+ const compose = readTextSafe(path.join(appDir, "docker-compose.yml"));
283
+ // V102 T-V102-15 (M6/W3, D-113): the old bare /CULPA_LICENSE_KEY/ regex
284
+ // matched a COMMENT line in the shipped compose, so doctor reported
285
+ // "[OK] present (redacted)" with no key set — on real hardware, both
286
+ // platforms, while `status` said "License: unknown" beside it. Only an
287
+ // uncommented env ENTRY carrying a non-empty value counts.
288
+ return compose.split(/\r?\n/).some((line) => {
289
+ const t = line.trim();
290
+ if (t.startsWith("#")) return false;
291
+ const m = /^-?\s*["']?CULPA_LICENSE_KEY["']?\s*[:=]\s*(.+)$/.exec(t);
292
+ if (m === null) return false;
293
+ // PR#19 [6]: "KEY: # configured elsewhere" is YAML for an EMPTY value
294
+ // plus a trailing comment — never a configured key
295
+ const value = m[1].trim().replace(/^["']|["']$/g, "").trim();
296
+ return value.length > 0 && !value.startsWith("#");
297
+ });
298
+ }
299
+
300
+ // Extracts ONLY configured/state/plan — even if the (possibly stubbed, in
301
+ // tests) response carries other fields, they are never read into the
302
+ // report. This is the redaction boundary the F1 fact tests.
303
+ async function checkLicense(fetchFn, running, env, appDir) {
304
+ const checks = [];
305
+ const keyPresent = scanLicenseKeyPresence(env, appDir);
306
+ checks.push(check("License", "CULPA_LICENSE_KEY", keyPresent ? "ok" : "info", keyPresent ? "present (redacted)" : "not set"));
307
+
308
+ if (!running) {
309
+ checks.push(check("License", "Entitlement", "info", "unknown (server not running)"));
310
+ return checks;
311
+ }
312
+ try {
313
+ const res = await fetchFn(ENTITLEMENT_URL, { signal: AbortSignal.timeout(3_000) });
314
+ if (res.status !== 200) {
315
+ checks.push(check("License", "Entitlement", "warn", `server did not answer (status ${res.status})`));
316
+ return checks;
317
+ }
318
+ const body = await res.json();
319
+ const configured = typeof body?.configured === "boolean" ? body.configured : null;
320
+ const state = typeof body?.state === "string" ? body.state : null;
321
+ const plan = typeof body?.plan === "string" ? body.plan : null;
322
+ checks.push(
323
+ check(
324
+ "License",
325
+ "Entitlement",
326
+ "ok",
327
+ `configured=${configured ?? "unknown"}, state=${state ?? "n/a"}, plan=${plan ?? "n/a"}`,
328
+ ),
329
+ );
330
+ } catch {
331
+ checks.push(check("License", "Entitlement", "warn", "could not reach the server"));
332
+ }
333
+ return checks;
334
+ }
335
+
336
+ // CF20-R: classifyInstall alone cannot distinguish a package DOWNGRADE
337
+ // (this package is older than what is already staged/live) from a
338
+ // genuinely unclear install state — both collapsed into "repair" with the
339
+ // same "run `getculpa repair`" message, which is misleading for a
340
+ // downgrade (it is not unclear, it was a deliberate, correct refusal).
341
+ // classifyUpgrade already makes that distinction (provision.mjs's own
342
+ // caller uses it) — currentVersion was already threaded all the way from
343
+ // bin/getculpa.js into this function; the gap was this function reading it
344
+ // through the coarser classifier.
345
+ function checkUpdateState(appDir, currentVersion, installed) {
346
+ const checks = [];
347
+ if (!installed) {
348
+ checks.push(check("Update state", "Install vs shipped version", "info", "not installed - nothing to compare"));
349
+ return checks;
350
+ }
351
+ const { classification: kind, previousVersion } = classifyUpgrade({ appDir, currentVersion });
352
+ const label = {
353
+ "same-version": ["ok", `up to date (v${currentVersion})`],
354
+ upgrade: ["warn", `an update is staged - run \`getculpa\` to apply it`],
355
+ repair: ["warn", "install state is unclear - run `getculpa repair`"],
356
+ fresh: ["info", "not installed"],
357
+ "downgrade-package": [
358
+ "warn",
359
+ `this package (v${currentVersion}) is OLDER than the installed Culpa v${previousVersion ?? "?"} - refusing to downgrade (set CULPA_ALLOW_DOWNGRADE=1 to override)`,
360
+ ],
361
+ }[kind] ?? ["info", kind];
362
+ checks.push(check("Update state", "Install vs shipped version", label[0], label[1]));
363
+
364
+ const updateStatePath = path.join(appDir, "updates", "state.json");
365
+ const launcherState = readJsonSafe(updateStatePath);
366
+ // Audit (flows) finding 2: this reported "ok" whenever the JSON merely
367
+ // PARSED, never looking at current. So on the exact stranded install
368
+ // T-CF28-4b exists to prevent - no payload ever staged - doctor printed
369
+ // "Overall: OK" while the very next getculpa dead-ended with "no installed
370
+ // version found". Diagnostics that reassure you about the broken thing are
371
+ // worse than none.
372
+ const noPayload = launcherState !== null && (launcherState.current ?? null) === null;
373
+ checks.push(
374
+ check(
375
+ "Update state",
376
+ "Launcher self-update state",
377
+ launcherState === null ? "info" : noPayload ? "fail" : "ok",
378
+ launcherState === null
379
+ ? "unknown (no update state recorded yet)"
380
+ : noPayload
381
+ ? "no CLI payload is staged (current=null) - run `getculpa update`, then `getculpa`"
382
+ : `current=${launcherState.current}, pending=${launcherState.pending ?? "none"}, mode=${launcherState.mode ?? "n/a"}`,
383
+ ),
384
+ );
385
+ return checks;
386
+ }
387
+
388
+ export async function doctor(opts) {
389
+ const {
390
+ appDir,
391
+ currentVersion,
392
+ platform = process.platform,
393
+ arch = process.arch,
394
+ spawnSync = realSpawnSync,
395
+ fetchFn = fetch,
396
+ env = process.env,
397
+ vendorDir = path.join(packageRoot, "vendor"),
398
+ } = opts;
399
+
400
+ const checks = [];
401
+ checks.push(...checkVersionPlatform(currentVersion, platform, arch));
402
+
403
+ const integrity = checkInstallationIntegrity(appDir, platform);
404
+ checks.push(...integrity.checks);
405
+ checks.push(checkVendoredLauncher(vendorDir, platform));
406
+
407
+ const docker = checkDocker(spawnSync);
408
+ checks.push(...docker.checks);
409
+
410
+ const containers = docker.reachable ? listProjectContainers(spawnSync) : [];
411
+ checks.push(...checkRuntimeState(containers));
412
+ checks.push(...checkExpectedDirectories(appDir, vendorDir));
413
+ checks.push(...checkDatabase(spawnSync, containers));
414
+ checks.push(...checkMigrations(appDir));
415
+
416
+ const running = containers.some((c) => c.state === "running");
417
+ checks.push(...checkVersionSkew(appDir, spawnSync, running));
418
+ checks.push(...checkPorts(spawnSync, platform, running));
419
+ checks.push(...(await checkHealth(fetchFn, running)));
420
+ checks.push(...(await checkLicense(fetchFn, running, env, appDir)));
421
+ checks.push(...checkUpdateState(appDir, currentVersion, integrity.installed));
422
+
423
+ const ok = !checks.some((c) => c.status === "fail");
424
+ return { ok, checks };
425
+ }
426
+
427
+ export function formatDoctorReport(result) {
428
+ const lines = [];
429
+ lines.push("Culpa doctor report");
430
+ lines.push("====================");
431
+ lines.push("");
432
+
433
+ const sections = [];
434
+ for (const c of result.checks) {
435
+ let section = sections.find((s) => s.name === c.section);
436
+ if (!section) {
437
+ section = { name: c.section, items: [] };
438
+ sections.push(section);
439
+ }
440
+ section.items.push(c);
441
+ }
442
+
443
+ for (const section of sections) {
444
+ lines.push(`-- ${section.name} --`);
445
+ for (const item of section.items) {
446
+ lines.push(`[${item.status.toUpperCase()}] ${item.label}: ${item.detail}`);
447
+ }
448
+ lines.push("");
449
+ }
450
+
451
+ const failureCount = result.checks.filter((c) => c.status === "fail").length;
452
+ lines.push(result.ok ? "Overall: OK" : `Overall: ${failureCount} failure(s) - see above ([FAIL] lines).`);
453
+ return lines.join("\n");
454
+ }