@openparachute/hub 0.7.4-rc.14 → 0.7.4-rc.16

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.
@@ -0,0 +1,894 @@
1
+ /**
2
+ * `parachute doctor` — health / diagnostics for a Parachute install.
3
+ *
4
+ * The one command that answers "is my Parachute healthy, and if not, what's
5
+ * the one thing to fix?" Today operators piece that together from `parachute
6
+ * status`, log tailing, and tribal knowledge; `doctor` is the single readout.
7
+ *
8
+ * ## The load-bearing constraint (#717)
9
+ *
10
+ * Doctor MUST NOT false-positive on a fresh or fully-current install. In the
11
+ * past the migration checker suggested "things need migrating" on a clean box
12
+ * purely because it hadn't been taught about newer features — an "anything I
13
+ * don't recognize = broken" design. That class of bug is unacceptable here.
14
+ *
15
+ * Design rule, enforced check-by-check below: **positively detect a known-bad
16
+ * condition; never treat "unfamiliar" or "not configured" as a failure.**
17
+ * - Distinguish *feature-not-configured* (→ PASS / benign info) from
18
+ * *configured-but-broken* (→ FAIL).
19
+ * - Migration checks reuse the existing ALLOWLIST detectors (`migrateNotice`
20
+ * / `hasPriorDetachedInstall`) which only flag explicitly-known cruft — a
21
+ * fresh root flags nothing.
22
+ * - Version drift (services.json cached vs live package.json, hub#243) is
23
+ * WARN at most, never FAIL, and labeled cosmetic.
24
+ * - Exposure checks only run when expose-state says the box is exposed;
25
+ * a loopback-only box reads "loopback only" as benign info (PASS), never
26
+ * a warning.
27
+ *
28
+ * The headline guarantee is the fresh-install fixture test: a sandboxed
29
+ * PARACHUTE_HOME with a minimal-but-current services.json + a valid
30
+ * operator.token → ALL GREEN, zero WARN/FAIL.
31
+ *
32
+ * ## Reuse, not reinvention
33
+ *
34
+ * Doctor stitches together primitives the rest of the hub already owns rather
35
+ * than re-deriving them: `status.ts`'s liveness-probe shape (2xx OR 401 = live,
36
+ * #700), `migrate.ts`'s allowlist detectors, `operator-token.ts`'s known-issuer
37
+ * set, `services-manifest.ts`'s strict parse, `service-spec.ts`'s startCmd
38
+ * resolution, and depcheck's `ensureExecutable` for the +x check (channel#41).
39
+ *
40
+ * Every external read (network probe, manager query, fs) is bounded + degrades
41
+ * gracefully and is behind an injectable seam so tests drive it without a real
42
+ * network/manager/db call — same discipline as `status.ts`.
43
+ */
44
+
45
+ import { readFileSync } from "node:fs";
46
+ import {
47
+ type MissingDependencyError,
48
+ NonExecutableError,
49
+ ensureExecutable,
50
+ } from "@openparachute/depcheck";
51
+ import { decodeJwt } from "jose";
52
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
53
+ import { type ExposeState, readExposeState } from "../expose-state.ts";
54
+ import { HUB_SVC, readHubPort } from "../hub-control.ts";
55
+ import {
56
+ HUB_UNIT_DEFAULT_PORT,
57
+ type HubUnitDeps,
58
+ type HubUnitStateResult,
59
+ defaultHubUnitDeps,
60
+ queryHubUnitState as queryHubUnitStateImpl,
61
+ } from "../hub-unit.ts";
62
+ import { hasPriorDetachedInstall } from "../migrate-offer.ts";
63
+ import { buildKnownIssuersForOperatorToken, operatorTokenPath } from "../operator-token.ts";
64
+ import { getSpec, getSpecFromInstallDir, shortNameForManifest } from "../service-spec.ts";
65
+ import {
66
+ type ServiceEntry,
67
+ ServicesManifestError,
68
+ readManifest,
69
+ readManifestLenient,
70
+ } from "../services-manifest.ts";
71
+ import { migrateNotice } from "./migrate.ts";
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Check model
75
+ // ---------------------------------------------------------------------------
76
+
77
+ export type CheckStatus = "pass" | "warn" | "fail";
78
+
79
+ export interface CheckResult {
80
+ /** Stable identifier (e.g. "hub-reachable") — what `--json` consumers key on. */
81
+ name: string;
82
+ /** Human-readable check title for the grouped report. */
83
+ title: string;
84
+ status: CheckStatus;
85
+ /** One-line detail explaining the verdict. */
86
+ detail: string;
87
+ /** A copy-pasteable fix-it command, when there is one. */
88
+ fix?: string;
89
+ }
90
+
91
+ /** A logical group of checks in the human report. */
92
+ const GROUP_ORDER = ["Hub", "Modules", "Configuration", "Migration", "Exposure"] as const;
93
+ type Group = (typeof GROUP_ORDER)[number];
94
+
95
+ interface GroupedCheck extends CheckResult {
96
+ group: Group;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Injectable deps (test seam) — mirrors status.ts's `supervisor` block. Every
101
+ // real-world side effect (network probe, manager query, fs read of the token,
102
+ // PATH resolution) is injectable so the whole command runs deterministically
103
+ // in tests with no network / launchd / systemd / real ~/.parachute touched.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export interface DoctorDeps {
107
+ /**
108
+ * Probe the loopback hub `/health`. True on any answer that proves the hub is
109
+ * serving. Production reuses the bounded 1.5s fetch shape from status.ts.
110
+ */
111
+ probeHubHealth?: (port: number) => Promise<boolean>;
112
+ /**
113
+ * Unauthenticated module-liveness probe (#700): `http://127.0.0.1:<port><health>`.
114
+ * Treats 2xx AND 401 as live (auth-gated health = healthy, #423). Bounded;
115
+ * never throws.
116
+ */
117
+ probeModuleHealth?: (port: number, health: string) => Promise<boolean>;
118
+ /**
119
+ * Probe a public origin's `/health` (Tier-2 exposure reachability). Bounded;
120
+ * follows redirects off-box is NOT chased — we only care that the hub answers.
121
+ * Returns false on any network error / non-live status.
122
+ */
123
+ probePublicHealth?: (origin: string) => Promise<boolean>;
124
+ /** Query the platform manager for the hub unit's run-state. */
125
+ queryHubUnitState?: (deps: HubUnitDeps) => HubUnitStateResult;
126
+ /** Deps passed to `queryHubUnitState`. Default production. */
127
+ hubUnitDeps?: HubUnitDeps;
128
+ /**
129
+ * PATH resolver for the module-bin exec-bit check (`ensureExecutable`).
130
+ * Production is `Bun.which`; tests inject a stub so the check runs without
131
+ * the real module binaries on the test host's PATH.
132
+ */
133
+ which?: (binary: string) => string | null;
134
+ /**
135
+ * #634 secondary probe for the exec-bit check: when `which` returns null,
136
+ * find a present-but-non-executable file on PATH. Production lets depcheck's
137
+ * real PATH walk run; tests inject to drive the non-executable branch.
138
+ */
139
+ findNonExecutable?: (binary: string) => string | null;
140
+ /** Clock seam for date-stamped detectors (migrate). */
141
+ now?: () => Date;
142
+ }
143
+
144
+ export interface DoctorOpts {
145
+ configDir?: string;
146
+ manifestPath?: string;
147
+ print?: (line: string) => void;
148
+ /** Emit a single JSON object instead of the human report. */
149
+ json?: boolean;
150
+ deps?: DoctorDeps;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Default real-world deps
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /** Bounded loopback `/health` probe — 2xx is live. Mirrors hub-unit's default. */
158
+ async function defaultProbeHubHealth(port: number): Promise<boolean> {
159
+ try {
160
+ const res = await fetch(`http://127.0.0.1:${port}/health`, {
161
+ signal: AbortSignal.timeout(1500),
162
+ });
163
+ return res.ok;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /** Bounded module `/health` probe — 2xx OR 401 is live (#700 / #423). */
170
+ async function defaultProbeModuleHealth(port: number, health: string): Promise<boolean> {
171
+ try {
172
+ const res = await fetch(`http://127.0.0.1:${port}${health}`, {
173
+ signal: AbortSignal.timeout(1500),
174
+ redirect: "manual",
175
+ });
176
+ return res.ok || res.status === 401;
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ /** Bounded public-origin `/health` probe — 2xx OR 401 is live (auth-gated hub). */
183
+ async function defaultProbePublicHealth(origin: string): Promise<boolean> {
184
+ try {
185
+ const res = await fetch(`${origin.replace(/\/+$/, "")}/health`, {
186
+ signal: AbortSignal.timeout(4000),
187
+ });
188
+ return res.ok || res.status === 401;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ interface ResolvedDeps {
195
+ probeHubHealth: (port: number) => Promise<boolean>;
196
+ probeModuleHealth: (port: number, health: string) => Promise<boolean>;
197
+ probePublicHealth: (origin: string) => Promise<boolean>;
198
+ queryHubUnitState: (deps: HubUnitDeps) => HubUnitStateResult;
199
+ hubUnitDeps: HubUnitDeps;
200
+ which: (binary: string) => string | null;
201
+ findNonExecutable: ((binary: string) => string | null) | undefined;
202
+ now: () => Date;
203
+ }
204
+
205
+ function resolveDeps(d: DoctorDeps | undefined): ResolvedDeps {
206
+ return {
207
+ probeHubHealth: d?.probeHubHealth ?? defaultProbeHubHealth,
208
+ probeModuleHealth: d?.probeModuleHealth ?? defaultProbeModuleHealth,
209
+ probePublicHealth: d?.probePublicHealth ?? defaultProbePublicHealth,
210
+ queryHubUnitState: d?.queryHubUnitState ?? queryHubUnitStateImpl,
211
+ hubUnitDeps: d?.hubUnitDeps ?? defaultHubUnitDeps,
212
+ which: d?.which ?? Bun.which,
213
+ findNonExecutable: d?.findNonExecutable,
214
+ now: d?.now ?? (() => new Date()),
215
+ };
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Tier 1 checks
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Hub supervisor reachable on :1939. The hub is the substrate every module
224
+ * runs under, so a down hub is the single most actionable failure. We compose
225
+ * the platform-manager view (`queryHubUnitState`) with the `/health` probe the
226
+ * same way `status.ts` does, but render a doctor verdict:
227
+ * - `/health` answers → PASS (it's serving; manager nuance is informational).
228
+ * - manager says `failed` → FAIL (surface the exit code).
229
+ * - manager says `active`/`activating` but no `/health` → FAIL (wedged/starting).
230
+ * - no manager (container) + no `/health` → FAIL.
231
+ * - manager `inactive`/`no-unit` + no `/health` → FAIL ("hub is not running").
232
+ * Never throws — a manager-query failure degrades to the `/health` verdict.
233
+ */
234
+ async function checkHubReachable(configDir: string, deps: ResolvedDeps): Promise<CheckResult> {
235
+ const port = readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT;
236
+ let healthy = false;
237
+ try {
238
+ healthy = await deps.probeHubHealth(port);
239
+ } catch {
240
+ healthy = false;
241
+ }
242
+
243
+ if (healthy) {
244
+ return {
245
+ name: "hub-reachable",
246
+ title: `Hub supervisor reachable on :${port}`,
247
+ status: "pass",
248
+ detail: `hub answered /health on http://127.0.0.1:${port}`,
249
+ };
250
+ }
251
+
252
+ // Not answering /health — consult the manager for a more specific verdict.
253
+ let managerState: HubUnitStateResult["state"] = "unknown";
254
+ let lastExitCode: number | undefined;
255
+ try {
256
+ const q = deps.queryHubUnitState(deps.hubUnitDeps);
257
+ managerState = q.state;
258
+ lastExitCode = q.lastExitCode;
259
+ } catch {
260
+ managerState = "unknown";
261
+ }
262
+
263
+ const fix = "parachute start hub";
264
+ if (managerState === "failed") {
265
+ return {
266
+ name: "hub-reachable",
267
+ title: `Hub supervisor reachable on :${port}`,
268
+ status: "fail",
269
+ detail:
270
+ lastExitCode !== undefined
271
+ ? `service manager reports the hub unit failed (last exit code ${lastExitCode})`
272
+ : "service manager reports the hub unit failed",
273
+ fix,
274
+ };
275
+ }
276
+ if (managerState === "active" || managerState === "activating") {
277
+ return {
278
+ name: "hub-reachable",
279
+ title: `Hub supervisor reachable on :${port}`,
280
+ status: "fail",
281
+ detail:
282
+ "service manager reports the hub unit up, but /health isn't answering (starting or wedged)",
283
+ fix: "parachute restart hub",
284
+ };
285
+ }
286
+ return {
287
+ name: "hub-reachable",
288
+ title: `Hub supervisor reachable on :${port}`,
289
+ status: "fail",
290
+ detail: `hub is not running — nothing answered /health on http://127.0.0.1:${port}`,
291
+ fix,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Each CONFIGURED module alive via its own loopback `/health` (2xx OR 401).
297
+ * Only modules present in services.json are checked — an absent module is
298
+ * "feature-not-configured," never a failure. When the hub itself is down every
299
+ * child is down with it, so we surface a single WARN pointing at the hub fix
300
+ * rather than N module FAILs that are all really the one hub problem.
301
+ *
302
+ * A configured-but-not-answering module on a healthy hub is a real FAIL.
303
+ */
304
+ async function checkModulesAlive(
305
+ manifest: { services: ServiceEntry[] },
306
+ hubHealthy: boolean,
307
+ deps: ResolvedDeps,
308
+ ): Promise<CheckResult[]> {
309
+ const modules = manifest.services;
310
+ if (modules.length === 0) {
311
+ return [
312
+ {
313
+ name: "modules-alive",
314
+ title: "Configured modules alive",
315
+ status: "pass",
316
+ detail: "no modules installed yet — nothing to check",
317
+ },
318
+ ];
319
+ }
320
+
321
+ if (!hubHealthy) {
322
+ // The hub is down → every supervised child is down WITH it. Don't pile N
323
+ // module FAILs on top of the one real problem (the hub check already FAILed).
324
+ return [
325
+ {
326
+ name: "modules-alive",
327
+ title: "Configured modules alive",
328
+ status: "warn",
329
+ detail: "skipped — the hub is down, so its modules are stopped too (fix the hub first)",
330
+ fix: "parachute start hub",
331
+ },
332
+ ];
333
+ }
334
+
335
+ const results = await Promise.all(
336
+ modules.map(async (entry): Promise<CheckResult> => {
337
+ let alive = false;
338
+ try {
339
+ alive = await deps.probeModuleHealth(entry.port, entry.health);
340
+ } catch {
341
+ alive = false;
342
+ }
343
+ const short = shortNameForManifest(entry.name) ?? entry.name;
344
+ if (alive) {
345
+ return {
346
+ name: `module-alive:${short}`,
347
+ title: `Module ${short} alive`,
348
+ status: "pass",
349
+ detail: `answered ${entry.health} on http://127.0.0.1:${entry.port}`,
350
+ };
351
+ }
352
+ return {
353
+ name: `module-alive:${short}`,
354
+ title: `Module ${short} alive`,
355
+ status: "fail",
356
+ detail: `${short} is configured (services.json) but didn't answer ${entry.health} on :${entry.port}`,
357
+ fix: `parachute restart ${short}`,
358
+ };
359
+ }),
360
+ );
361
+ return results;
362
+ }
363
+
364
+ /**
365
+ * services.json parses + required fields valid. A MISSING manifest is the fresh
366
+ * pre-install state, not a failure → PASS with benign info. A PRESENT but
367
+ * malformed manifest is configured-but-broken → FAIL with the parser's own
368
+ * diagnostic (we read strictly here precisely to surface the error; the rest of
369
+ * doctor reads leniently so one bad row doesn't sink every other check).
370
+ */
371
+ function checkServicesManifest(manifestPath: string): CheckResult {
372
+ // Probe presence FIRST so we can tell apart absent (→ fresh-install PASS)
373
+ // from present-but-malformed (→ FAIL below). Without this split a missing
374
+ // services.json would reach readManifest's throw and be reported as
375
+ // "malformed" — a false positive on the fresh install we must never flag.
376
+ try {
377
+ readFileSync(manifestPath, "utf8");
378
+ } catch {
379
+ // ENOENT (or unreadable) — treat absence as the fresh, pre-install state.
380
+ return {
381
+ name: "services-manifest",
382
+ title: "services.json parses + valid",
383
+ status: "pass",
384
+ detail: "no services.json yet — fresh install (nothing configured)",
385
+ };
386
+ }
387
+ try {
388
+ const manifest = readManifest(manifestPath);
389
+ return {
390
+ name: "services-manifest",
391
+ title: "services.json parses + valid",
392
+ status: "pass",
393
+ detail: `parsed ${manifest.services.length} service${manifest.services.length === 1 ? "" : "s"}, all required fields valid`,
394
+ };
395
+ } catch (err) {
396
+ const message =
397
+ err instanceof ServicesManifestError
398
+ ? err.message
399
+ : err instanceof Error
400
+ ? err.message
401
+ : String(err);
402
+ return {
403
+ name: "services-manifest",
404
+ title: "services.json parses + valid",
405
+ status: "fail",
406
+ detail: `services.json is malformed: ${message}`,
407
+ fix: `edit ${manifestPath} to fix the offending entry`,
408
+ };
409
+ }
410
+ }
411
+
412
+ /**
413
+ * operator.token exists, parses, and its `iss` matches a hub-legitimate issuer.
414
+ *
415
+ * Absent token → PASS/info (a box that hasn't created its first admin yet, or
416
+ * one that never minted an operator token — feature-not-configured, NOT broken;
417
+ * `parachute status` / `auth set-password` is the path, doctor doesn't force it).
418
+ *
419
+ * Present token:
420
+ * - undecodable / no `iss` claim → FAIL (corrupt credential).
421
+ * - `iss` matches the hub's known-issuer SET (loopback aliases ∪ expose-state
422
+ * public origin ∪ platform origin — the SAME set the live auth path uses,
423
+ * `buildKnownIssuersForOperatorToken`) → PASS.
424
+ * - `iss` is foreign to that set → FAIL: the recurring "not signed in to the
425
+ * hub" / issuer-mismatch class (hub#481). Fix is `start hub` (self-heals)
426
+ * or `auth rotate-operator`.
427
+ *
428
+ * Deliberately a DECODE-only `iss` check, not a full signature/JWKS validation:
429
+ * doctor must run without a live hub or DB, and an unsigned-but-decodable token
430
+ * still tells us the issuer-mismatch story. The known-issuer set is the same
431
+ * one the real validation layers `iss` against on top of the signature check.
432
+ */
433
+ function checkOperatorToken(configDir: string): CheckResult {
434
+ const path = operatorTokenPath(configDir);
435
+ let token: string;
436
+ try {
437
+ token = readFileSync(path, "utf8").trim();
438
+ } catch {
439
+ return {
440
+ name: "operator-token",
441
+ title: "operator.token valid + issuer matches",
442
+ status: "pass",
443
+ detail:
444
+ "no operator.token yet — fine for a box that hasn't created its first admin (run `parachute auth set-password` when ready)",
445
+ };
446
+ }
447
+ if (token.length === 0) {
448
+ return {
449
+ name: "operator-token",
450
+ title: "operator.token valid + issuer matches",
451
+ status: "pass",
452
+ detail: "operator.token is empty — treated as not configured",
453
+ };
454
+ }
455
+
456
+ let iss: string | undefined;
457
+ try {
458
+ const payload = decodeJwt(token);
459
+ iss = typeof payload.iss === "string" ? payload.iss : undefined;
460
+ } catch {
461
+ return {
462
+ name: "operator-token",
463
+ title: "operator.token valid + issuer matches",
464
+ status: "fail",
465
+ detail: `operator.token at ${path} is not a decodable JWT`,
466
+ fix: "parachute auth rotate-operator",
467
+ };
468
+ }
469
+ if (!iss) {
470
+ return {
471
+ name: "operator-token",
472
+ title: "operator.token valid + issuer matches",
473
+ status: "fail",
474
+ detail: "operator.token has no `iss` claim",
475
+ fix: "parachute auth rotate-operator",
476
+ };
477
+ }
478
+
479
+ // Build the issuer set the live auth path validates against (loopback aliases
480
+ // ∪ expose-state public origin ∪ platform origin). Seed with the resolved
481
+ // loopback issuer so a never-exposed box still has its own loopback in the set.
482
+ const seedIssuer = `http://127.0.0.1:${readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT}`;
483
+ let knownIssuers: readonly string[] = [];
484
+ try {
485
+ knownIssuers = buildKnownIssuersForOperatorToken(configDir, seedIssuer);
486
+ } catch {
487
+ knownIssuers = [seedIssuer];
488
+ }
489
+ if (knownIssuers.includes(iss)) {
490
+ return {
491
+ name: "operator-token",
492
+ title: "operator.token valid + issuer matches",
493
+ status: "pass",
494
+ detail: `operator.token issuer (${iss}) matches the hub`,
495
+ };
496
+ }
497
+ return {
498
+ name: "operator-token",
499
+ title: "operator.token valid + issuer matches",
500
+ status: "fail",
501
+ detail: `operator.token issuer (${iss}) doesn't match any origin this hub answers on — the "not signed in to the hub" class. Expected one of: ${knownIssuers.join(", ")}`,
502
+ fix: "parachute start hub # self-heals the issuer; or `parachute auth rotate-operator`",
503
+ };
504
+ }
505
+
506
+ /**
507
+ * Module bin resolvable via `Bun.which` (exec bit present) — the 100644
508
+ * start-failure class (channel#41): a module whose `bin` lost its +x bit
509
+ * resolves to null under `Bun.which` (which requires X_OK), so the supervisor
510
+ * reports "<binary> not installed" despite an intact symlink + services.json.
511
+ *
512
+ * For each configured module we resolve its startCmd binary the SAME way the
513
+ * supervisor does (spec.startCmd over the entry; module.json wins when
514
+ * installDir is stamped) and run depcheck's `ensureExecutable`:
515
+ * - resolves cleanly → PASS.
516
+ * - `NonExecutableError` (present but no +x) → FAIL with the `chmod +x` fix —
517
+ * the exact bug this check exists for.
518
+ * - `MissingDependencyError` (genuinely not on PATH) → FAIL (reinstall).
519
+ *
520
+ * A module whose spec has no resolvable startCmd (CLI-only module, unreadable
521
+ * module.json) is SKIPPED — there's no bin to check, and "no startCmd" is not a
522
+ * broken-bin condition. Modules with no spec at all (third-party, no fallback)
523
+ * are likewise skipped — we can't know their bin name, and absence of knowledge
524
+ * is never a failure (the #717 rule).
525
+ */
526
+ async function checkModuleBins(
527
+ manifest: { services: ServiceEntry[] },
528
+ deps: ResolvedDeps,
529
+ ): Promise<CheckResult[]> {
530
+ const checks = await Promise.all(
531
+ manifest.services.map(async (entry): Promise<CheckResult | undefined> => {
532
+ const short = shortNameForManifest(entry.name);
533
+ if (!short) return undefined; // third-party / unknown — no bin to reason about.
534
+ const binary = await resolveStartBinary(short, entry);
535
+ if (!binary) return undefined; // CLI-only module / unreadable module.json — nothing to check.
536
+
537
+ try {
538
+ const ensureOpts: Parameters<typeof ensureExecutable>[1] = { which: deps.which };
539
+ if (deps.findNonExecutable) ensureOpts.findNonExecutable = deps.findNonExecutable;
540
+ ensureExecutable(binary, ensureOpts);
541
+ return {
542
+ name: `module-bin:${short}`,
543
+ title: `Module ${short} bin executable`,
544
+ status: "pass",
545
+ detail: `${binary} resolves on PATH with the exec bit set`,
546
+ };
547
+ } catch (err) {
548
+ if (err instanceof NonExecutableError) {
549
+ return {
550
+ name: `module-bin:${short}`,
551
+ title: `Module ${short} bin executable`,
552
+ status: "fail",
553
+ detail: `${binary} is present at ${err.path} but is NOT executable (lost its +x bit) — the supervisor will report it "not installed"`,
554
+ fix: `chmod +x ${err.path}`,
555
+ };
556
+ }
557
+ // MissingDependencyError (or anything else) → the bin isn't resolvable.
558
+ const missing = err as MissingDependencyError;
559
+ const why =
560
+ typeof missing?.message === "string"
561
+ ? missing.message.split("\n")[0]
562
+ : `${binary} not found on PATH`;
563
+ return {
564
+ name: `module-bin:${short}`,
565
+ title: `Module ${short} bin executable`,
566
+ status: "fail",
567
+ detail: `${binary} for module ${short} isn't resolvable on PATH: ${why}`,
568
+ fix: `parachute install ${short} # reinstall the module`,
569
+ };
570
+ }
571
+ }),
572
+ );
573
+ const present = checks.filter((c): c is CheckResult => c !== undefined);
574
+ if (present.length === 0) {
575
+ return [
576
+ {
577
+ name: "module-bins",
578
+ title: "Module bins executable",
579
+ status: "pass",
580
+ detail: "no first-party module bins to check",
581
+ },
582
+ ];
583
+ }
584
+ return present;
585
+ }
586
+
587
+ /**
588
+ * Resolve the startCmd binary (`cmd[0]`) for a configured module, mirroring the
589
+ * supervisor's resolution: module.json wins when installDir is stamped, else the
590
+ * imperative spec startCmd. Returns undefined when there's no spec or no
591
+ * resolvable startCmd (CLI-only / unreadable manifest). Never throws.
592
+ */
593
+ async function resolveStartBinary(short: string, entry: ServiceEntry): Promise<string | undefined> {
594
+ let spec = getSpec(short);
595
+ if (entry.installDir) {
596
+ try {
597
+ const resolved = await getSpecFromInstallDir(entry.installDir, entry.name);
598
+ if (resolved) spec = resolved;
599
+ } catch {
600
+ // Unreadable / malformed module.json — fall back to the imperative spec.
601
+ }
602
+ }
603
+ if (!spec?.startCmd) return undefined;
604
+ let cmd: readonly string[] | undefined;
605
+ try {
606
+ cmd = spec.startCmd(entry);
607
+ } catch {
608
+ return undefined;
609
+ }
610
+ return cmd && cmd.length > 0 ? cmd[0] : undefined;
611
+ }
612
+
613
+ /**
614
+ * Migration via the SAFE detectors only (never a "scan for unfamiliar files"
615
+ * approach — that's the exact false-positive class #717 forbids):
616
+ * - `hasPriorDetachedInstall` — a pidfile (the detached-era fingerprint).
617
+ * A supervised/fresh box writes none → no warning. WARN (not FAIL): the
618
+ * box still works; doctor nudges toward the supervised cutover.
619
+ * - `migrateNotice` — the allowlist archive detector. Only flags entries
620
+ * matching an explicit KNOWN_CRUFT rule; a fresh root flags nothing. WARN.
621
+ *
622
+ * Both clean → a single PASS.
623
+ */
624
+ function checkMigration(
625
+ configDir: string,
626
+ manifestPath: string,
627
+ deps: ResolvedDeps,
628
+ ): CheckResult[] {
629
+ const out: CheckResult[] = [];
630
+
631
+ let priorDetached = false;
632
+ try {
633
+ priorDetached = hasPriorDetachedInstall(configDir, manifestPath);
634
+ } catch {
635
+ priorDetached = false;
636
+ }
637
+ if (priorDetached) {
638
+ out.push({
639
+ name: "migration-detached",
640
+ title: "Legacy detached install detected",
641
+ status: "warn",
642
+ detail:
643
+ "this box has a prior detached-model install (pidfiles present) — the current hub runs supervised under a process manager",
644
+ fix: "parachute migrate --to-supervised",
645
+ });
646
+ }
647
+
648
+ let notice: string | undefined;
649
+ try {
650
+ notice = migrateNotice(configDir, deps.now());
651
+ } catch {
652
+ notice = undefined;
653
+ }
654
+ if (notice) {
655
+ out.push({
656
+ name: "migration-cruft",
657
+ title: "Archivable cruft at ecosystem root",
658
+ status: "warn",
659
+ detail: notice.replace(/^parachute migrate: /, "").replace(/ — run.*$/, ""),
660
+ fix: "parachute migrate",
661
+ });
662
+ }
663
+
664
+ if (out.length === 0) {
665
+ out.push({
666
+ name: "migration",
667
+ title: "Migration",
668
+ status: "pass",
669
+ detail: "no legacy detached install, no archivable cruft at the ecosystem root",
670
+ });
671
+ }
672
+ return out;
673
+ }
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // Tier 2 checks (guarded hard — never FAIL on not-configured)
677
+ // ---------------------------------------------------------------------------
678
+
679
+ /**
680
+ * Exposure reachability + issuer consistency — ONLY when expose-state says the
681
+ * box is exposed. If NOT exposed → benign "loopback only" info (PASS, never
682
+ * WARN): a box reachable only on loopback is a legitimate, common configuration.
683
+ *
684
+ * When exposed:
685
+ * - missing `hubOrigin` in expose-state → WARN (cosmetic — we can't verify
686
+ * reachability without it, but the box may well be fine).
687
+ * - public origin answers `/health` → PASS.
688
+ * - public origin doesn't answer → WARN (not FAIL): the tunnel may be mid-
689
+ * bring-up, or an upstream CDN/bot-protection may shape server-to-server
690
+ * probes (the known Cloudflare-bot-protection class) — doctor flags it for
691
+ * attention without declaring the install broken.
692
+ */
693
+ async function checkExposure(configDir: string, deps: ResolvedDeps): Promise<CheckResult> {
694
+ let state: ExposeState | undefined;
695
+ try {
696
+ state = readExposeState(`${configDir}/expose-state.json`);
697
+ } catch {
698
+ // A malformed expose-state.json must not crash doctor; treat as not-exposed
699
+ // info (the malformed-file case is the operator's to clear, and the CLI's
700
+ // own ExposeStateError surfaces it elsewhere).
701
+ return {
702
+ name: "exposure",
703
+ title: "Exposure",
704
+ status: "pass",
705
+ detail: "expose-state.json is unreadable — treating as loopback only",
706
+ };
707
+ }
708
+
709
+ if (!state) {
710
+ return {
711
+ name: "exposure",
712
+ title: "Exposure",
713
+ status: "pass",
714
+ detail: "loopback only — not exposed to a tailnet or the public internet (this is fine)",
715
+ };
716
+ }
717
+
718
+ const origin = state.hubOrigin;
719
+ if (!origin) {
720
+ return {
721
+ name: "exposure",
722
+ title: "Exposure reachable",
723
+ status: "warn",
724
+ detail: `exposed (${state.layer}) but expose-state has no hubOrigin to verify reachability`,
725
+ };
726
+ }
727
+
728
+ let reachable = false;
729
+ try {
730
+ reachable = await deps.probePublicHealth(origin);
731
+ } catch {
732
+ reachable = false;
733
+ }
734
+ if (reachable) {
735
+ return {
736
+ name: "exposure",
737
+ title: "Exposure reachable",
738
+ status: "pass",
739
+ detail: `public origin ${origin} answers /health`,
740
+ };
741
+ }
742
+ return {
743
+ name: "exposure",
744
+ title: "Exposure reachable",
745
+ status: "warn",
746
+ detail: `exposed at ${origin} but it didn't answer /health — the tunnel may be starting, or upstream bot-protection may be shaping the probe`,
747
+ fix: "parachute expose <layer> # re-bring-up the exposure if it's down",
748
+ };
749
+ }
750
+
751
+ /**
752
+ * Version drift (hub#243) — WARN at most, never FAIL, and labeled cosmetic.
753
+ * services.json caches each module's version string; on a bun-linked checkout
754
+ * that can lag the live package.json after a rebuild. Purely cosmetic — the
755
+ * running code is whatever the bundle is, not the cached string. We only flag
756
+ * the obvious shape (cached `0.0.0-linked` stopgap) so the check stays
757
+ * positive-detection: a cached real version we have no live value to compare
758
+ * against is NOT flagged (we don't have status.ts's install-source machinery
759
+ * wired here, and guessing would risk a false WARN — #717).
760
+ */
761
+ function checkVersionDrift(manifest: { services: ServiceEntry[] }): CheckResult {
762
+ const stopgaps = manifest.services.filter((s) => s.version === "0.0.0-linked");
763
+ if (stopgaps.length === 0) {
764
+ return {
765
+ name: "version-drift",
766
+ title: "Version freshness (cosmetic)",
767
+ status: "pass",
768
+ detail: "no obvious version-drift markers in services.json",
769
+ };
770
+ }
771
+ const names = stopgaps.map((s) => shortNameForManifest(s.name) ?? s.name).join(", ");
772
+ return {
773
+ name: "version-drift",
774
+ title: "Version freshness (cosmetic)",
775
+ status: "warn",
776
+ detail: `services.json still has the install-time stopgap version "0.0.0-linked" for: ${names} (cosmetic — the running code is the live bundle)`,
777
+ fix: "parachute restart <module> # lets the module re-stamp its real version",
778
+ };
779
+ }
780
+
781
+ // ---------------------------------------------------------------------------
782
+ // Orchestration + rendering
783
+ // ---------------------------------------------------------------------------
784
+
785
+ /**
786
+ * Run every check and return them grouped, in report order. Pure-ish: reads fs
787
+ * + (stubbable) network, never mutates. The hub-reachability probe runs once
788
+ * and gates the module-liveness check (a down hub means every child is down).
789
+ */
790
+ async function runChecks(
791
+ configDir: string,
792
+ manifestPath: string,
793
+ deps: ResolvedDeps,
794
+ ): Promise<GroupedCheck[]> {
795
+ // Lenient manifest read for the checks that iterate modules — a single bad
796
+ // row must not sink hub/operator/migration checks. The STRICT parse is the
797
+ // services-manifest check's own job (it WANTS to surface the parse error).
798
+ const manifest = readManifestLenient(manifestPath);
799
+
800
+ const hub = await checkHubReachable(configDir, deps);
801
+ const hubHealthy = hub.status === "pass";
802
+
803
+ const [modules, bins, exposure] = await Promise.all([
804
+ checkModulesAlive(manifest, hubHealthy, deps),
805
+ checkModuleBins(manifest, deps),
806
+ checkExposure(configDir, deps),
807
+ ]);
808
+ const manifestCheck = checkServicesManifest(manifestPath);
809
+ const operator = checkOperatorToken(configDir);
810
+ const migration = checkMigration(configDir, manifestPath, deps);
811
+ const versionDrift = checkVersionDrift(manifest);
812
+
813
+ const grouped: GroupedCheck[] = [];
814
+ const add = (group: Group, checks: CheckResult[]) => {
815
+ for (const c of checks) grouped.push({ ...c, group });
816
+ };
817
+ add("Hub", [hub]);
818
+ add("Modules", [...modules, ...bins]);
819
+ add("Configuration", [manifestCheck, operator]);
820
+ add("Migration", migration);
821
+ add("Exposure", [exposure, versionDrift]);
822
+ return grouped;
823
+ }
824
+
825
+ const MARK: Record<CheckStatus, string> = { pass: "✓", warn: "⚠", fail: "✗" };
826
+
827
+ function renderHuman(checks: GroupedCheck[], print: (line: string) => void): void {
828
+ print("parachute doctor — health check");
829
+ print("");
830
+ for (const group of GROUP_ORDER) {
831
+ const inGroup = checks.filter((c) => c.group === group);
832
+ if (inGroup.length === 0) continue;
833
+ print(`${group}:`);
834
+ for (const c of inGroup) {
835
+ print(` ${MARK[c.status]} ${c.title}`);
836
+ print(` ${c.detail}`);
837
+ if (c.fix) print(` fix: ${c.fix}`);
838
+ }
839
+ print("");
840
+ }
841
+ const fails = checks.filter((c) => c.status === "fail").length;
842
+ const warns = checks.filter((c) => c.status === "warn").length;
843
+ const passes = checks.filter((c) => c.status === "pass").length;
844
+ if (fails === 0 && warns === 0) {
845
+ print(`All clear — ${passes} check${passes === 1 ? "" : "s"} passed.`);
846
+ } else {
847
+ const parts: string[] = [`${passes} ok`];
848
+ if (warns > 0) parts.push(`${warns} warning${warns === 1 ? "" : "s"}`);
849
+ if (fails > 0) parts.push(`${fails} failure${fails === 1 ? "" : "s"}`);
850
+ print(`Summary: ${parts.join(", ")}.`);
851
+ if (fails === 0) print("No failures — warnings are advisory.");
852
+ }
853
+ }
854
+
855
+ function renderJson(checks: GroupedCheck[], print: (line: string) => void): void {
856
+ const fails = checks.filter((c) => c.status === "fail").length;
857
+ const warns = checks.filter((c) => c.status === "warn").length;
858
+ const passes = checks.filter((c) => c.status === "pass").length;
859
+ const payload = {
860
+ ok: fails === 0,
861
+ summary: { pass: passes, warn: warns, fail: fails },
862
+ checks: checks.map((c) => ({
863
+ name: c.name,
864
+ group: c.group,
865
+ title: c.title,
866
+ status: c.status,
867
+ detail: c.detail,
868
+ ...(c.fix ? { fix: c.fix } : {}),
869
+ })),
870
+ };
871
+ print(JSON.stringify(payload, null, 2));
872
+ }
873
+
874
+ /**
875
+ * `parachute doctor`. Returns the process exit code: 0 when no check FAILs
876
+ * (WARN is allowed), non-zero on any FAIL. Never throws — every check is
877
+ * individually wrapped + degrades gracefully, so doctor is itself a reliable
878
+ * diagnostic regardless of the box's state.
879
+ */
880
+ export async function doctor(opts: DoctorOpts = {}): Promise<number> {
881
+ const configDir = opts.configDir ?? CONFIG_DIR;
882
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
883
+ const print = opts.print ?? ((line) => console.log(line));
884
+ const deps = resolveDeps(opts.deps);
885
+
886
+ const checks = await runChecks(configDir, manifestPath, deps);
887
+ if (opts.json) {
888
+ renderJson(checks, print);
889
+ } else {
890
+ renderHuman(checks, print);
891
+ }
892
+ const anyFail = checks.some((c) => c.status === "fail");
893
+ return anyFail ? 1 : 0;
894
+ }