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/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
+ }
@@ -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[];