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/LICENSE +43 -43
- package/README.md +9 -2
- package/assets/culpa-collector.ps1 +137 -137
- package/assets/culpa-compose.yml +161 -146
- package/assets/launch-culpa.ps1 +415 -415
- package/bin/culpa.js +22 -22
- package/bin/getculpa.js +186 -175
- package/lib/assets.d.mts +2 -2
- package/lib/bootstrap.d.mts +48 -29
- package/lib/bootstrap.mjs +163 -97
- package/lib/dispatch.d.mts +12 -12
- package/lib/dispatch.mjs +66 -59
- package/lib/docker.d.mts +76 -76
- package/lib/docker.mjs +290 -270
- package/lib/doctor.d.mts +29 -29
- package/lib/doctor.mjs +454 -420
- package/lib/fetch.d.mts +17 -17
- package/lib/fetch.mjs +211 -211
- package/lib/install-summary.d.mts +20 -18
- package/lib/install-summary.mjs +184 -156
- package/lib/paths.d.mts +9 -9
- package/lib/paths.mjs +61 -61
- package/lib/preflight.d.mts +45 -45
- package/lib/preflight.mjs +322 -274
- package/lib/provision.d.mts +54 -54
- package/lib/provision.mjs +47 -5
- package/lib/repair.d.mts +21 -21
- package/lib/repair.mjs +7 -7
- package/lib/start.d.mts +50 -44
- package/lib/start.mjs +575 -487
- package/lib/status.d.mts +23 -23
- package/lib/status.mjs +74 -74
- package/lib/stop.d.mts +15 -15
- package/lib/stop.mjs +46 -46
- package/lib/tty.d.mts +18 -18
- package/lib/tty.mjs +58 -58
- package/package.json +1 -1
- package/scripts/install.js +9 -2
- package/scripts/prepack.js +53 -53
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 {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
checks.push(check("Installation integrity", "
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
checks
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
check("
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
["
|
|
196
|
-
|
|
197
|
-
return
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
);
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
checks
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
+
}
|