getculpa 0.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +41 -0
- package/README.md +37 -18
- package/assets/culpa-collector.ps1 +137 -0
- package/assets/culpa-compose.yml +144 -0
- package/assets/install-culpa.ps1 +310 -0
- package/assets/launch-culpa.ps1 +350 -0
- package/assets/register.mjs +713 -0
- package/assets/uninstall-culpa.ps1 +227 -0
- package/bin/culpa.js +21 -10
- package/bin/getculpa.js +147 -0
- package/lib/assets.d.mts +2 -0
- package/lib/assets.mjs +36 -0
- package/lib/bootstrap.d.mts +25 -0
- package/lib/bootstrap.mjs +73 -0
- package/lib/docker.d.mts +76 -0
- package/lib/docker.mjs +270 -0
- package/lib/doctor.d.mts +29 -0
- package/lib/doctor.mjs +405 -0
- package/lib/fetch.d.mts +17 -0
- package/lib/fetch.mjs +211 -0
- package/lib/install-summary.d.mts +18 -0
- package/lib/install-summary.mjs +144 -0
- package/lib/paths.d.mts +9 -0
- package/lib/paths.mjs +61 -0
- package/lib/preflight.d.mts +39 -0
- package/lib/preflight.mjs +169 -0
- package/lib/provision.d.mts +54 -0
- package/lib/provision.mjs +318 -0
- package/lib/repair.d.mts +21 -0
- package/lib/repair.mjs +174 -0
- package/lib/start.d.mts +44 -0
- package/lib/start.mjs +479 -0
- package/lib/status.d.mts +23 -0
- package/lib/status.mjs +74 -0
- package/lib/stop.d.mts +15 -0
- package/lib/stop.mjs +46 -0
- package/lib/tty.d.mts +18 -0
- package/lib/tty.mjs +58 -0
- package/lib/uninstall.d.mts +17 -0
- package/lib/uninstall.mjs +99 -0
- package/package.json +14 -25
- package/scripts/install.js +154 -0
- package/scripts/prepack.js +41 -0
- package/index.js +0 -3
package/lib/doctor.mjs
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
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
|
+
if (env.CULPA_LICENSE_KEY) return true;
|
|
248
|
+
const compose = readTextSafe(path.join(appDir, "docker-compose.yml"));
|
|
249
|
+
return /CULPA_LICENSE_KEY/.test(compose);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Extracts ONLY configured/state/plan — even if the (possibly stubbed, in
|
|
253
|
+
// tests) response carries other fields, they are never read into the
|
|
254
|
+
// report. This is the redaction boundary the F1 fact tests.
|
|
255
|
+
async function checkLicense(fetchFn, running, env, appDir) {
|
|
256
|
+
const checks = [];
|
|
257
|
+
const keyPresent = scanLicenseKeyPresence(env, appDir);
|
|
258
|
+
checks.push(check("License", "CULPA_LICENSE_KEY", keyPresent ? "ok" : "info", keyPresent ? "present (redacted)" : "not set"));
|
|
259
|
+
|
|
260
|
+
if (!running) {
|
|
261
|
+
checks.push(check("License", "Entitlement", "info", "unknown (server not running)"));
|
|
262
|
+
return checks;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const res = await fetchFn(ENTITLEMENT_URL, { signal: AbortSignal.timeout(3_000) });
|
|
266
|
+
if (res.status !== 200) {
|
|
267
|
+
checks.push(check("License", "Entitlement", "warn", `server did not answer (status ${res.status})`));
|
|
268
|
+
return checks;
|
|
269
|
+
}
|
|
270
|
+
const body = await res.json();
|
|
271
|
+
const configured = typeof body?.configured === "boolean" ? body.configured : null;
|
|
272
|
+
const state = typeof body?.state === "string" ? body.state : null;
|
|
273
|
+
const plan = typeof body?.plan === "string" ? body.plan : null;
|
|
274
|
+
checks.push(
|
|
275
|
+
check(
|
|
276
|
+
"License",
|
|
277
|
+
"Entitlement",
|
|
278
|
+
"ok",
|
|
279
|
+
`configured=${configured ?? "unknown"}, state=${state ?? "n/a"}, plan=${plan ?? "n/a"}`,
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
} catch {
|
|
283
|
+
checks.push(check("License", "Entitlement", "warn", "could not reach the server"));
|
|
284
|
+
}
|
|
285
|
+
return checks;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// CF20-R: classifyInstall alone cannot distinguish a package DOWNGRADE
|
|
289
|
+
// (this package is older than what is already staged/live) from a
|
|
290
|
+
// genuinely unclear install state — both collapsed into "repair" with the
|
|
291
|
+
// same "run `getculpa repair`" message, which is misleading for a
|
|
292
|
+
// downgrade (it is not unclear, it was a deliberate, correct refusal).
|
|
293
|
+
// classifyUpgrade already makes that distinction (provision.mjs's own
|
|
294
|
+
// caller uses it) — currentVersion was already threaded all the way from
|
|
295
|
+
// bin/getculpa.js into this function; the gap was this function reading it
|
|
296
|
+
// through the coarser classifier.
|
|
297
|
+
function checkUpdateState(appDir, currentVersion, installed) {
|
|
298
|
+
const checks = [];
|
|
299
|
+
if (!installed) {
|
|
300
|
+
checks.push(check("Update state", "Install vs shipped version", "info", "not installed - nothing to compare"));
|
|
301
|
+
return checks;
|
|
302
|
+
}
|
|
303
|
+
const { classification: kind, previousVersion } = classifyUpgrade({ appDir, currentVersion });
|
|
304
|
+
const label = {
|
|
305
|
+
"same-version": ["ok", `up to date (v${currentVersion})`],
|
|
306
|
+
upgrade: ["warn", `an update is staged - run \`getculpa\` to apply it`],
|
|
307
|
+
repair: ["warn", "install state is unclear - run `getculpa repair`"],
|
|
308
|
+
fresh: ["info", "not installed"],
|
|
309
|
+
"downgrade-package": [
|
|
310
|
+
"warn",
|
|
311
|
+
`this package (v${currentVersion}) is OLDER than the installed Culpa v${previousVersion ?? "?"} - refusing to downgrade (set CULPA_ALLOW_DOWNGRADE=1 to override)`,
|
|
312
|
+
],
|
|
313
|
+
}[kind] ?? ["info", kind];
|
|
314
|
+
checks.push(check("Update state", "Install vs shipped version", label[0], label[1]));
|
|
315
|
+
|
|
316
|
+
const updateStatePath = path.join(appDir, "updates", "state.json");
|
|
317
|
+
const launcherState = readJsonSafe(updateStatePath);
|
|
318
|
+
// Audit (flows) finding 2: this reported "ok" whenever the JSON merely
|
|
319
|
+
// PARSED, never looking at current. So on the exact stranded install
|
|
320
|
+
// T-CF28-4b exists to prevent - no payload ever staged - doctor printed
|
|
321
|
+
// "Overall: OK" while the very next getculpa dead-ended with "no installed
|
|
322
|
+
// version found". Diagnostics that reassure you about the broken thing are
|
|
323
|
+
// worse than none.
|
|
324
|
+
const noPayload = launcherState !== null && (launcherState.current ?? null) === null;
|
|
325
|
+
checks.push(
|
|
326
|
+
check(
|
|
327
|
+
"Update state",
|
|
328
|
+
"Launcher self-update state",
|
|
329
|
+
launcherState === null ? "info" : noPayload ? "fail" : "ok",
|
|
330
|
+
launcherState === null
|
|
331
|
+
? "unknown (no update state recorded yet)"
|
|
332
|
+
: noPayload
|
|
333
|
+
? "no CLI payload is staged (current=null) - run `getculpa update`, then `getculpa`"
|
|
334
|
+
: `current=${launcherState.current}, pending=${launcherState.pending ?? "none"}, mode=${launcherState.mode ?? "n/a"}`,
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
return checks;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function doctor(opts) {
|
|
341
|
+
const {
|
|
342
|
+
appDir,
|
|
343
|
+
currentVersion,
|
|
344
|
+
platform = process.platform,
|
|
345
|
+
arch = process.arch,
|
|
346
|
+
spawnSync = realSpawnSync,
|
|
347
|
+
fetchFn = fetch,
|
|
348
|
+
env = process.env,
|
|
349
|
+
vendorDir = path.join(packageRoot, "vendor"),
|
|
350
|
+
} = opts;
|
|
351
|
+
|
|
352
|
+
const checks = [];
|
|
353
|
+
checks.push(...checkVersionPlatform(currentVersion, platform, arch));
|
|
354
|
+
|
|
355
|
+
const integrity = checkInstallationIntegrity(appDir, platform);
|
|
356
|
+
checks.push(...integrity.checks);
|
|
357
|
+
checks.push(checkVendoredLauncher(vendorDir, platform));
|
|
358
|
+
|
|
359
|
+
const docker = checkDocker(spawnSync);
|
|
360
|
+
checks.push(...docker.checks);
|
|
361
|
+
|
|
362
|
+
const containers = docker.reachable ? listProjectContainers(spawnSync) : [];
|
|
363
|
+
checks.push(...checkRuntimeState(containers));
|
|
364
|
+
checks.push(...checkExpectedDirectories(appDir, vendorDir));
|
|
365
|
+
checks.push(...checkDatabase(spawnSync, containers));
|
|
366
|
+
checks.push(...checkMigrations(appDir));
|
|
367
|
+
|
|
368
|
+
const running = containers.some((c) => c.state === "running");
|
|
369
|
+
checks.push(...checkPorts(spawnSync, platform, running));
|
|
370
|
+
checks.push(...(await checkHealth(fetchFn, running)));
|
|
371
|
+
checks.push(...(await checkLicense(fetchFn, running, env, appDir)));
|
|
372
|
+
checks.push(...checkUpdateState(appDir, currentVersion, integrity.installed));
|
|
373
|
+
|
|
374
|
+
const ok = !checks.some((c) => c.status === "fail");
|
|
375
|
+
return { ok, checks };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function formatDoctorReport(result) {
|
|
379
|
+
const lines = [];
|
|
380
|
+
lines.push("Culpa doctor report");
|
|
381
|
+
lines.push("====================");
|
|
382
|
+
lines.push("");
|
|
383
|
+
|
|
384
|
+
const sections = [];
|
|
385
|
+
for (const c of result.checks) {
|
|
386
|
+
let section = sections.find((s) => s.name === c.section);
|
|
387
|
+
if (!section) {
|
|
388
|
+
section = { name: c.section, items: [] };
|
|
389
|
+
sections.push(section);
|
|
390
|
+
}
|
|
391
|
+
section.items.push(c);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const section of sections) {
|
|
395
|
+
lines.push(`-- ${section.name} --`);
|
|
396
|
+
for (const item of section.items) {
|
|
397
|
+
lines.push(`[${item.status.toUpperCase()}] ${item.label}: ${item.detail}`);
|
|
398
|
+
}
|
|
399
|
+
lines.push("");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const failureCount = result.checks.filter((c) => c.status === "fail").length;
|
|
403
|
+
lines.push(result.ok ? "Overall: OK" : `Overall: ${failureCount} failure(s) - see above ([FAIL] lines).`);
|
|
404
|
+
return lines.join("\n");
|
|
405
|
+
}
|
package/lib/fetch.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const ORIGIN: string;
|
|
2
|
+
export function targetTriple(platform?: string, arch?: string): string;
|
|
3
|
+
|
|
4
|
+
export interface FetchOptions {
|
|
5
|
+
vendorDir?: string;
|
|
6
|
+
origin?: string;
|
|
7
|
+
trustedHosts?: Set<string>;
|
|
8
|
+
allowInsecureHttp?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FetchResult {
|
|
12
|
+
dest: string;
|
|
13
|
+
sha256: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fetchLauncher(opts?: FetchOptions): Promise<FetchResult>;
|
|
17
|
+
export function fetchCollectord(opts?: FetchOptions): Promise<FetchResult>;
|
package/lib/fetch.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// CF20-T3 — checksum-verified GitHub-release fetch, extracted from the
|
|
2
|
+
// original scripts/install.js (AU-011) so it can be reused for the
|
|
3
|
+
// collector binary and unit-tested against a local fixture server. Every
|
|
4
|
+
// security property of the original is kept byte-for-byte:
|
|
5
|
+
// - host allowlist enforced on every redirect hop (github.com,
|
|
6
|
+
// objects.githubusercontent.com, release-assets.githubusercontent.com)
|
|
7
|
+
// - https only
|
|
8
|
+
// - bounded redirects (MAX_REDIRECTS)
|
|
9
|
+
// - size caps enforced WHILE streaming, not after buffering
|
|
10
|
+
// - write-temp + rename (never a half-written executable at the dest)
|
|
11
|
+
// - sha256 verified against the release's published checksums file before
|
|
12
|
+
// the file is ever written to its final path
|
|
13
|
+
//
|
|
14
|
+
// The one behavioral extension: fetchCollectord() treats "no checksum line
|
|
15
|
+
// published for this asset" as an EXPECTED, non-fatal outcome (the release
|
|
16
|
+
// process may ship without a collectord for a given triple yet) — everything
|
|
17
|
+
// else, including a checksum MISMATCH, still fails closed exactly like the
|
|
18
|
+
// launcher fetch.
|
|
19
|
+
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const packageRoot = path.join(__dirname, "..");
|
|
27
|
+
|
|
28
|
+
// The ONE origin — mirrors native/culpa-launcher/src/origin.rs. Never read
|
|
29
|
+
// from env or config. D-094: repointed to the public releases-only mirror
|
|
30
|
+
// (MyaigiDev/culpa-releases); the source repo (myaigidev/culpa) stays
|
|
31
|
+
// private.
|
|
32
|
+
// Exported so tests can pin the production origin (CF21-T5 review: a revert
|
|
33
|
+
// of this constant would 404 every fresh install and no injected-fixture test
|
|
34
|
+
// can see it).
|
|
35
|
+
export const ORIGIN = "https://github.com/MyaigiDev/culpa-releases/releases/latest/download";
|
|
36
|
+
const TRUSTED_HOSTS = new Set([
|
|
37
|
+
"github.com",
|
|
38
|
+
"objects.githubusercontent.com",
|
|
39
|
+
"release-assets.githubusercontent.com",
|
|
40
|
+
]);
|
|
41
|
+
const MAX_REDIRECTS = 5;
|
|
42
|
+
|
|
43
|
+
export function targetTriple(platform = process.platform, arch = process.arch) {
|
|
44
|
+
if (platform === "win32" && arch === "x64") return "x86_64-pc-windows-msvc";
|
|
45
|
+
if (platform === "linux" && arch === "x64") return "x86_64-unknown-linux-gnu";
|
|
46
|
+
if (platform === "darwin" && arch === "x64") return "x86_64-apple-darwin";
|
|
47
|
+
if (platform === "darwin" && arch === "arm64") return "aarch64-apple-darwin";
|
|
48
|
+
throw new Error(`unsupported platform: ${platform}/${arch}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// allowInsecureHttp defaults false and is ONLY ever set by the fixture-server
|
|
52
|
+
// tests (see tests/npm-core-fetch.test.ts) — no production call site (the
|
|
53
|
+
// fetchLauncher()/fetchCollectord() callers in scripts/install.js) passes
|
|
54
|
+
// it, so the real https-only enforcement is unchanged.
|
|
55
|
+
function assertTrustedUrl(u, trustedHosts, allowInsecureHttp) {
|
|
56
|
+
const protocolOk = u.protocol === "https:" || (allowInsecureHttp && u.protocol === "http:");
|
|
57
|
+
if (!protocolOk) throw new Error(`${u}: only https is trusted (got ${u.protocol})`);
|
|
58
|
+
if (!trustedHosts.has(u.hostname)) throw new Error(`${u}: untrusted redirect host refused (${u.hostname})`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Follows redirects manually so every hop is pinned to https + a trusted
|
|
62
|
+
// host, up to MAX_REDIRECTS hops. `timeoutMs` bounds each hop individually.
|
|
63
|
+
async function fetchTrusted(url, timeoutMs, trustedHosts, allowInsecureHttp) {
|
|
64
|
+
let current = new URL(url);
|
|
65
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
66
|
+
assertTrustedUrl(current, trustedHosts, allowInsecureHttp);
|
|
67
|
+
const res = await fetch(current, { redirect: "manual", signal: AbortSignal.timeout(timeoutMs) });
|
|
68
|
+
if (res.status >= 300 && res.status < 400) {
|
|
69
|
+
const location = res.headers.get("location");
|
|
70
|
+
if (!location) throw new Error(`${current}: redirect with no Location header`);
|
|
71
|
+
current = new URL(location, current);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (res.status === 404) {
|
|
75
|
+
const err = new Error(`GET ${current} -> 404`);
|
|
76
|
+
err.notFound = true;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
if (!res.ok) throw new Error(`GET ${current} -> ${res.status}`);
|
|
80
|
+
return res;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`${url}: too many redirects (> ${MAX_REDIRECTS})`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Enforces maxBytes WHILE STREAMING so a hostile/misconfigured response with
|
|
86
|
+
// no (or a lying) Content-Length cannot be buffered past the cap before it
|
|
87
|
+
// is checked.
|
|
88
|
+
async function fetchBytes(url, maxBytes, timeoutMs, trustedHosts, allowInsecureHttp) {
|
|
89
|
+
const res = await fetchTrusted(url, timeoutMs, trustedHosts, allowInsecureHttp);
|
|
90
|
+
if (!res.body) throw new Error(`${url}: response has no body`);
|
|
91
|
+
const reader = res.body.getReader();
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done) break;
|
|
97
|
+
total += value.length;
|
|
98
|
+
if (total > maxBytes) {
|
|
99
|
+
await reader.cancel();
|
|
100
|
+
throw new Error(`${url}: response exceeds ${maxBytes} bytes`);
|
|
101
|
+
}
|
|
102
|
+
chunks.push(value);
|
|
103
|
+
}
|
|
104
|
+
return Buffer.concat(chunks);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sha256Hex(buf) {
|
|
108
|
+
return crypto.createHash("sha256").update(buf).digest("hex");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// CF20-R: the checksums file is `sha256sum`'s own output — one line per
|
|
112
|
+
// asset, "<sha256> <filename>" (verified format: release-cli.yml runs
|
|
113
|
+
// `sha256sum culpa-launcher-* culpa-collectord-*` on ubuntu-latest, GNU
|
|
114
|
+
// coreutils' default text-mode two-space separator; tests/npm-core-fetch.test.ts's
|
|
115
|
+
// fixtures construct lines the same way). `endsWith(assetName)` was a
|
|
116
|
+
// substring match on the WHOLE line, not the filename field — a shorter
|
|
117
|
+
// asset name that happens to be a suffix of an unrelated line (or a
|
|
118
|
+
// malformed/commented line ending in the same text) could match the wrong
|
|
119
|
+
// entry. Split on whitespace and compare the filename field exactly.
|
|
120
|
+
function findChecksumLine(checksumsText, assetName) {
|
|
121
|
+
const line = checksumsText
|
|
122
|
+
.split("\n")
|
|
123
|
+
.map((l) => l.trim())
|
|
124
|
+
.find((l) => l.split(/\s+/)[1] === assetName);
|
|
125
|
+
if (!line) return null;
|
|
126
|
+
const expected = line.split(/\s+/)[0];
|
|
127
|
+
if (!/^[0-9a-f]{64}$/.test(expected)) throw new Error("malformed checksum line");
|
|
128
|
+
return expected;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeVerified(destDir, destName, binary) {
|
|
132
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
133
|
+
const dest = path.join(destDir, destName);
|
|
134
|
+
const tmp = `${dest}.tmp.${process.pid}`;
|
|
135
|
+
fs.writeFileSync(tmp, binary);
|
|
136
|
+
fs.renameSync(tmp, dest);
|
|
137
|
+
if (process.platform !== "win32") fs.chmodSync(dest, 0o755);
|
|
138
|
+
return dest;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// origin/trustedHosts/allowInsecureHttp are all injectable — this is what
|
|
142
|
+
// makes the fixture-server tests possible without touching the real GitHub
|
|
143
|
+
// origin or weakening production's https-only enforcement.
|
|
144
|
+
async function fetchVerifiedAsset({
|
|
145
|
+
assetName,
|
|
146
|
+
destDir,
|
|
147
|
+
destName,
|
|
148
|
+
maxBytes,
|
|
149
|
+
origin = ORIGIN,
|
|
150
|
+
trustedHosts = TRUSTED_HOSTS,
|
|
151
|
+
allowInsecureHttp = false,
|
|
152
|
+
}) {
|
|
153
|
+
const checksumsBuf = await fetchBytes(
|
|
154
|
+
`${origin}/culpa-launcher-checksums.txt`,
|
|
155
|
+
64 * 1024,
|
|
156
|
+
120_000,
|
|
157
|
+
trustedHosts,
|
|
158
|
+
allowInsecureHttp,
|
|
159
|
+
);
|
|
160
|
+
const expected = findChecksumLine(checksumsBuf.toString("utf8"), assetName);
|
|
161
|
+
if (!expected) {
|
|
162
|
+
const err = new Error(`no checksum published for ${assetName}`);
|
|
163
|
+
err.notFound = true;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
const binary = await fetchBytes(`${origin}/${assetName}`, maxBytes, 15 * 60_000, trustedHosts, allowInsecureHttp);
|
|
167
|
+
const actual = sha256Hex(binary);
|
|
168
|
+
if (actual !== expected) {
|
|
169
|
+
throw new Error(`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`);
|
|
170
|
+
}
|
|
171
|
+
const dest = writeVerified(destDir, destName, binary);
|
|
172
|
+
return { dest, sha256: actual };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function fetchLauncher(opts = {}) {
|
|
176
|
+
const triple = targetTriple();
|
|
177
|
+
const exe = process.platform === "win32" ? ".exe" : "";
|
|
178
|
+
const assetName = `culpa-launcher-${triple}${exe}`;
|
|
179
|
+
const vendorDir = opts.vendorDir ?? path.join(packageRoot, "vendor");
|
|
180
|
+
const result = await fetchVerifiedAsset({
|
|
181
|
+
assetName,
|
|
182
|
+
destDir: vendorDir,
|
|
183
|
+
destName: `culpa-launcher${exe}`,
|
|
184
|
+
maxBytes: 64 * 1024 * 1024,
|
|
185
|
+
origin: opts.origin,
|
|
186
|
+
trustedHosts: opts.trustedHosts,
|
|
187
|
+
allowInsecureHttp: opts.allowInsecureHttp,
|
|
188
|
+
});
|
|
189
|
+
console.log(`getculpa: installed culpa launcher (${triple}, sha256 ${result.sha256.slice(0, 12)}…)`);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Graceful-degrade case lives here, not in the caller: "not published for
|
|
194
|
+
// this release yet" is the ONE outcome that must not abort the install.
|
|
195
|
+
export async function fetchCollectord(opts = {}) {
|
|
196
|
+
const triple = targetTriple();
|
|
197
|
+
const exe = process.platform === "win32" ? ".exe" : "";
|
|
198
|
+
const assetName = `culpa-collectord-${triple}${exe}`;
|
|
199
|
+
const vendorDir = opts.vendorDir ?? path.join(packageRoot, "vendor");
|
|
200
|
+
const result = await fetchVerifiedAsset({
|
|
201
|
+
assetName,
|
|
202
|
+
destDir: vendorDir,
|
|
203
|
+
destName: `culpa-collectord${exe}`,
|
|
204
|
+
maxBytes: 64 * 1024 * 1024,
|
|
205
|
+
origin: opts.origin,
|
|
206
|
+
trustedHosts: opts.trustedHosts,
|
|
207
|
+
allowInsecureHttp: opts.allowInsecureHttp,
|
|
208
|
+
});
|
|
209
|
+
console.log(`getculpa: installed capture collector (${triple}, sha256 ${result.sha256.slice(0, 12)}…)`);
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { UpgradeClassification } from "./preflight.d.mts";
|
|
2
|
+
import type { DockerState } from "./provision.d.mts";
|
|
3
|
+
|
|
4
|
+
export interface InstallSummaryOptions {
|
|
5
|
+
classification: UpgradeClassification;
|
|
6
|
+
version: string;
|
|
7
|
+
previousVersion?: string | null;
|
|
8
|
+
appDir: string;
|
|
9
|
+
dockerState: DockerState;
|
|
10
|
+
imagesStaged?: boolean;
|
|
11
|
+
collectorStaged?: boolean;
|
|
12
|
+
payloadStaged?: boolean;
|
|
13
|
+
platform?: string;
|
|
14
|
+
isTTY?: boolean;
|
|
15
|
+
env?: Record<string, string | undefined>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildInstallSummary(opts: InstallSummaryOptions): string[];
|