@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.20
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/package.json +4 -11
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-clients.ts +55 -3
- package/src/admin-vaults.ts +52 -25
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/api-modules.ts +105 -0
- package/src/api-settings-root-redirect.ts +188 -0
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +139 -24
- package/src/hub-settings.ts +163 -1
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +103 -6
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -0,0 +1,1250 @@
|
|
|
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 { createInterface } from "node:readline/promises";
|
|
47
|
+
import {
|
|
48
|
+
type MissingDependencyError,
|
|
49
|
+
NonExecutableError,
|
|
50
|
+
ensureExecutable,
|
|
51
|
+
} from "@openparachute/depcheck";
|
|
52
|
+
import { decodeJwt } from "jose";
|
|
53
|
+
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
54
|
+
import { type ExposeState, readExposeState } from "../expose-state.ts";
|
|
55
|
+
import { HUB_SVC, readHubPort } from "../hub-control.ts";
|
|
56
|
+
import {
|
|
57
|
+
HUB_UNIT_DEFAULT_PORT,
|
|
58
|
+
type HubUnitDeps,
|
|
59
|
+
type HubUnitStateResult,
|
|
60
|
+
defaultHubUnitDeps,
|
|
61
|
+
queryHubUnitState as queryHubUnitStateImpl,
|
|
62
|
+
} from "../hub-unit.ts";
|
|
63
|
+
import { hasPriorDetachedInstall } from "../migrate-offer.ts";
|
|
64
|
+
import { buildKnownIssuersForOperatorToken, operatorTokenPath } from "../operator-token.ts";
|
|
65
|
+
import {
|
|
66
|
+
canonicalPortForManifest,
|
|
67
|
+
getSpec,
|
|
68
|
+
getSpecFromInstallDir,
|
|
69
|
+
shortNameForManifest,
|
|
70
|
+
} from "../service-spec.ts";
|
|
71
|
+
import {
|
|
72
|
+
type ServiceEntry,
|
|
73
|
+
ServicesManifestError,
|
|
74
|
+
readManifest,
|
|
75
|
+
readManifestLenient,
|
|
76
|
+
writeManifest,
|
|
77
|
+
} from "../services-manifest.ts";
|
|
78
|
+
import { migrateNotice } from "./migrate.ts";
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Check model
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export type CheckStatus = "pass" | "warn" | "fail";
|
|
85
|
+
|
|
86
|
+
export interface CheckResult {
|
|
87
|
+
/** Stable identifier (e.g. "hub-reachable") — what `--json` consumers key on. */
|
|
88
|
+
name: string;
|
|
89
|
+
/** Human-readable check title for the grouped report. */
|
|
90
|
+
title: string;
|
|
91
|
+
status: CheckStatus;
|
|
92
|
+
/** One-line detail explaining the verdict. */
|
|
93
|
+
detail: string;
|
|
94
|
+
/** A copy-pasteable fix-it command, when there is one. */
|
|
95
|
+
fix?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A logical group of checks in the human report. */
|
|
99
|
+
const GROUP_ORDER = ["Hub", "Modules", "Configuration", "Migration", "Exposure"] as const;
|
|
100
|
+
type Group = (typeof GROUP_ORDER)[number];
|
|
101
|
+
|
|
102
|
+
interface GroupedCheck extends CheckResult {
|
|
103
|
+
group: Group;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Injectable deps (test seam) — mirrors status.ts's `supervisor` block. Every
|
|
108
|
+
// real-world side effect (network probe, manager query, fs read of the token,
|
|
109
|
+
// PATH resolution) is injectable so the whole command runs deterministically
|
|
110
|
+
// in tests with no network / launchd / systemd / real ~/.parachute touched.
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
export interface DoctorDeps {
|
|
114
|
+
/**
|
|
115
|
+
* Probe the loopback hub `/health`. True on any answer that proves the hub is
|
|
116
|
+
* serving. Production reuses the bounded 1.5s fetch shape from status.ts.
|
|
117
|
+
*/
|
|
118
|
+
probeHubHealth?: (port: number) => Promise<boolean>;
|
|
119
|
+
/**
|
|
120
|
+
* Unauthenticated module-liveness probe (#700): `http://127.0.0.1:<port><health>`.
|
|
121
|
+
* Treats 2xx AND 401 as live (auth-gated health = healthy, #423). Bounded;
|
|
122
|
+
* never throws.
|
|
123
|
+
*/
|
|
124
|
+
probeModuleHealth?: (port: number, health: string) => Promise<boolean>;
|
|
125
|
+
/**
|
|
126
|
+
* Probe a public origin's `/health` (Tier-2 exposure reachability). Bounded;
|
|
127
|
+
* follows redirects off-box is NOT chased — we only care that the hub answers.
|
|
128
|
+
* Returns false on any network error / non-live status.
|
|
129
|
+
*/
|
|
130
|
+
probePublicHealth?: (origin: string) => Promise<boolean>;
|
|
131
|
+
/** Query the platform manager for the hub unit's run-state. */
|
|
132
|
+
queryHubUnitState?: (deps: HubUnitDeps) => HubUnitStateResult;
|
|
133
|
+
/** Deps passed to `queryHubUnitState`. Default production. */
|
|
134
|
+
hubUnitDeps?: HubUnitDeps;
|
|
135
|
+
/**
|
|
136
|
+
* PATH resolver for the module-bin exec-bit check (`ensureExecutable`).
|
|
137
|
+
* Production is `Bun.which`; tests inject a stub so the check runs without
|
|
138
|
+
* the real module binaries on the test host's PATH.
|
|
139
|
+
*/
|
|
140
|
+
which?: (binary: string) => string | null;
|
|
141
|
+
/**
|
|
142
|
+
* #634 secondary probe for the exec-bit check: when `which` returns null,
|
|
143
|
+
* find a present-but-non-executable file on PATH. Production lets depcheck's
|
|
144
|
+
* real PATH walk run; tests inject to drive the non-executable branch.
|
|
145
|
+
*/
|
|
146
|
+
findNonExecutable?: (binary: string) => string | null;
|
|
147
|
+
/** Clock seam for date-stamped detectors (migrate). */
|
|
148
|
+
now?: () => Date;
|
|
149
|
+
/**
|
|
150
|
+
* TTY check for `--fix`'s confirmation gate. Production reads
|
|
151
|
+
* `process.stdin.isTTY && process.stdout.isTTY`; tests inject to drive both
|
|
152
|
+
* the interactive (confirm) and non-interactive (bail without `--yes`) paths.
|
|
153
|
+
*/
|
|
154
|
+
isInteractive?: () => boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Read a line of input for the `--fix` confirmation prompt. Production wraps
|
|
157
|
+
* readline; tests inject a canned answer.
|
|
158
|
+
*/
|
|
159
|
+
readLine?: (prompt: string) => Promise<string>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface DoctorOpts {
|
|
163
|
+
configDir?: string;
|
|
164
|
+
manifestPath?: string;
|
|
165
|
+
print?: (line: string) => void;
|
|
166
|
+
/** Emit a single JSON object instead of the human report. */
|
|
167
|
+
json?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Repair canonical-port drift in services.json (and ONLY that — every other
|
|
170
|
+
* check stays report-only). Shows the diff, confirms in a TTY (or `--yes`),
|
|
171
|
+
* bails in a non-TTY without `--yes`. Idempotent: a clean file is a no-op.
|
|
172
|
+
*/
|
|
173
|
+
fix?: boolean;
|
|
174
|
+
/** Skip the `--fix` confirmation prompt (required in a non-TTY). */
|
|
175
|
+
yes?: boolean;
|
|
176
|
+
deps?: DoctorDeps;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Default real-world deps
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** Bounded loopback `/health` probe — 2xx is live. Mirrors hub-unit's default. */
|
|
184
|
+
async function defaultProbeHubHealth(port: number): Promise<boolean> {
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
187
|
+
signal: AbortSignal.timeout(1500),
|
|
188
|
+
});
|
|
189
|
+
return res.ok;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Bounded module `/health` probe — 2xx OR 401 is live (#700 / #423). */
|
|
196
|
+
async function defaultProbeModuleHealth(port: number, health: string): Promise<boolean> {
|
|
197
|
+
try {
|
|
198
|
+
const res = await fetch(`http://127.0.0.1:${port}${health}`, {
|
|
199
|
+
signal: AbortSignal.timeout(1500),
|
|
200
|
+
redirect: "manual",
|
|
201
|
+
});
|
|
202
|
+
return res.ok || res.status === 401;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Bounded public-origin `/health` probe — 2xx OR 401 is live (auth-gated hub). */
|
|
209
|
+
async function defaultProbePublicHealth(origin: string): Promise<boolean> {
|
|
210
|
+
try {
|
|
211
|
+
const res = await fetch(`${origin.replace(/\/+$/, "")}/health`, {
|
|
212
|
+
signal: AbortSignal.timeout(4000),
|
|
213
|
+
});
|
|
214
|
+
return res.ok || res.status === 401;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Both ends of the pipe must be a TTY for an interactive confirm to make sense. */
|
|
221
|
+
function defaultIsInteractive(): boolean {
|
|
222
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Readline-backed line reader for the `--fix` confirmation prompt. */
|
|
226
|
+
async function defaultReadLine(prompt: string): Promise<string> {
|
|
227
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
228
|
+
try {
|
|
229
|
+
return await rl.question(prompt);
|
|
230
|
+
} finally {
|
|
231
|
+
rl.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface ResolvedDeps {
|
|
236
|
+
probeHubHealth: (port: number) => Promise<boolean>;
|
|
237
|
+
probeModuleHealth: (port: number, health: string) => Promise<boolean>;
|
|
238
|
+
probePublicHealth: (origin: string) => Promise<boolean>;
|
|
239
|
+
queryHubUnitState: (deps: HubUnitDeps) => HubUnitStateResult;
|
|
240
|
+
hubUnitDeps: HubUnitDeps;
|
|
241
|
+
which: (binary: string) => string | null;
|
|
242
|
+
findNonExecutable: ((binary: string) => string | null) | undefined;
|
|
243
|
+
now: () => Date;
|
|
244
|
+
isInteractive: () => boolean;
|
|
245
|
+
readLine: (prompt: string) => Promise<string>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveDeps(d: DoctorDeps | undefined): ResolvedDeps {
|
|
249
|
+
return {
|
|
250
|
+
probeHubHealth: d?.probeHubHealth ?? defaultProbeHubHealth,
|
|
251
|
+
probeModuleHealth: d?.probeModuleHealth ?? defaultProbeModuleHealth,
|
|
252
|
+
probePublicHealth: d?.probePublicHealth ?? defaultProbePublicHealth,
|
|
253
|
+
queryHubUnitState: d?.queryHubUnitState ?? queryHubUnitStateImpl,
|
|
254
|
+
hubUnitDeps: d?.hubUnitDeps ?? defaultHubUnitDeps,
|
|
255
|
+
which: d?.which ?? Bun.which,
|
|
256
|
+
findNonExecutable: d?.findNonExecutable,
|
|
257
|
+
now: d?.now ?? (() => new Date()),
|
|
258
|
+
isInteractive: d?.isInteractive ?? defaultIsInteractive,
|
|
259
|
+
readLine: d?.readLine ?? defaultReadLine,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Tier 1 checks
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Hub supervisor reachable on :1939. The hub is the substrate every module
|
|
269
|
+
* runs under, so a down hub is the single most actionable failure. We compose
|
|
270
|
+
* the platform-manager view (`queryHubUnitState`) with the `/health` probe the
|
|
271
|
+
* same way `status.ts` does, but render a doctor verdict:
|
|
272
|
+
* - `/health` answers → PASS (it's serving; manager nuance is informational).
|
|
273
|
+
* - manager says `failed` → FAIL (surface the exit code).
|
|
274
|
+
* - manager says `active`/`activating` but no `/health` → FAIL (wedged/starting).
|
|
275
|
+
* - no manager (container) + no `/health` → FAIL.
|
|
276
|
+
* - manager `inactive`/`no-unit` + no `/health` → FAIL ("hub is not running").
|
|
277
|
+
* Never throws — a manager-query failure degrades to the `/health` verdict.
|
|
278
|
+
*/
|
|
279
|
+
async function checkHubReachable(configDir: string, deps: ResolvedDeps): Promise<CheckResult> {
|
|
280
|
+
const port = readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT;
|
|
281
|
+
let healthy = false;
|
|
282
|
+
try {
|
|
283
|
+
healthy = await deps.probeHubHealth(port);
|
|
284
|
+
} catch {
|
|
285
|
+
healthy = false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (healthy) {
|
|
289
|
+
return {
|
|
290
|
+
name: "hub-reachable",
|
|
291
|
+
title: `Hub supervisor reachable on :${port}`,
|
|
292
|
+
status: "pass",
|
|
293
|
+
detail: `hub answered /health on http://127.0.0.1:${port}`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Not answering /health — consult the manager for a more specific verdict.
|
|
298
|
+
let managerState: HubUnitStateResult["state"] = "unknown";
|
|
299
|
+
let lastExitCode: number | undefined;
|
|
300
|
+
try {
|
|
301
|
+
const q = deps.queryHubUnitState(deps.hubUnitDeps);
|
|
302
|
+
managerState = q.state;
|
|
303
|
+
lastExitCode = q.lastExitCode;
|
|
304
|
+
} catch {
|
|
305
|
+
managerState = "unknown";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const fix = "parachute start hub";
|
|
309
|
+
if (managerState === "failed") {
|
|
310
|
+
return {
|
|
311
|
+
name: "hub-reachable",
|
|
312
|
+
title: `Hub supervisor reachable on :${port}`,
|
|
313
|
+
status: "fail",
|
|
314
|
+
detail:
|
|
315
|
+
lastExitCode !== undefined
|
|
316
|
+
? `service manager reports the hub unit failed (last exit code ${lastExitCode})`
|
|
317
|
+
: "service manager reports the hub unit failed",
|
|
318
|
+
fix,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (managerState === "active" || managerState === "activating") {
|
|
322
|
+
return {
|
|
323
|
+
name: "hub-reachable",
|
|
324
|
+
title: `Hub supervisor reachable on :${port}`,
|
|
325
|
+
status: "fail",
|
|
326
|
+
detail:
|
|
327
|
+
"service manager reports the hub unit up, but /health isn't answering (starting or wedged)",
|
|
328
|
+
fix: "parachute restart hub",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
name: "hub-reachable",
|
|
333
|
+
title: `Hub supervisor reachable on :${port}`,
|
|
334
|
+
status: "fail",
|
|
335
|
+
detail: `hub is not running — nothing answered /health on http://127.0.0.1:${port}`,
|
|
336
|
+
fix,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Each CONFIGURED module alive via its own loopback `/health` (2xx OR 401).
|
|
342
|
+
* Only modules present in services.json are checked — an absent module is
|
|
343
|
+
* "feature-not-configured," never a failure. When the hub itself is down every
|
|
344
|
+
* child is down with it, so we surface a single WARN pointing at the hub fix
|
|
345
|
+
* rather than N module FAILs that are all really the one hub problem.
|
|
346
|
+
*
|
|
347
|
+
* A configured-but-not-answering module on a healthy hub is a real FAIL.
|
|
348
|
+
*/
|
|
349
|
+
async function checkModulesAlive(
|
|
350
|
+
manifest: { services: ServiceEntry[] },
|
|
351
|
+
hubHealthy: boolean,
|
|
352
|
+
deps: ResolvedDeps,
|
|
353
|
+
): Promise<CheckResult[]> {
|
|
354
|
+
const modules = manifest.services;
|
|
355
|
+
if (modules.length === 0) {
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
name: "modules-alive",
|
|
359
|
+
title: "Configured modules alive",
|
|
360
|
+
status: "pass",
|
|
361
|
+
detail: "no modules installed yet — nothing to check",
|
|
362
|
+
},
|
|
363
|
+
];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!hubHealthy) {
|
|
367
|
+
// The hub is down → every supervised child is down WITH it. Don't pile N
|
|
368
|
+
// module FAILs on top of the one real problem (the hub check already FAILed).
|
|
369
|
+
return [
|
|
370
|
+
{
|
|
371
|
+
name: "modules-alive",
|
|
372
|
+
title: "Configured modules alive",
|
|
373
|
+
status: "warn",
|
|
374
|
+
detail: "skipped — the hub is down, so its modules are stopped too (fix the hub first)",
|
|
375
|
+
fix: "parachute start hub",
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const results = await Promise.all(
|
|
381
|
+
modules.map(async (entry): Promise<CheckResult> => {
|
|
382
|
+
let alive = false;
|
|
383
|
+
try {
|
|
384
|
+
alive = await deps.probeModuleHealth(entry.port, entry.health);
|
|
385
|
+
} catch {
|
|
386
|
+
alive = false;
|
|
387
|
+
}
|
|
388
|
+
const short = shortNameForManifest(entry.name) ?? entry.name;
|
|
389
|
+
if (alive) {
|
|
390
|
+
return {
|
|
391
|
+
name: `module-alive:${short}`,
|
|
392
|
+
title: `Module ${short} alive`,
|
|
393
|
+
status: "pass",
|
|
394
|
+
detail: `answered ${entry.health} on http://127.0.0.1:${entry.port}`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
name: `module-alive:${short}`,
|
|
399
|
+
title: `Module ${short} alive`,
|
|
400
|
+
status: "fail",
|
|
401
|
+
detail: `${short} is configured (services.json) but didn't answer ${entry.health} on :${entry.port}`,
|
|
402
|
+
fix: `parachute restart ${short}`,
|
|
403
|
+
};
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
return results;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* services.json parses + required fields valid. A MISSING manifest is the fresh
|
|
411
|
+
* pre-install state, not a failure → PASS with benign info. A PRESENT but
|
|
412
|
+
* malformed manifest is configured-but-broken → FAIL with the parser's own
|
|
413
|
+
* diagnostic (we read strictly here precisely to surface the error; the rest of
|
|
414
|
+
* doctor reads leniently so one bad row doesn't sink every other check).
|
|
415
|
+
*/
|
|
416
|
+
function checkServicesManifest(manifestPath: string): CheckResult {
|
|
417
|
+
// Probe presence FIRST so we can tell apart absent (→ fresh-install PASS)
|
|
418
|
+
// from present-but-malformed (→ FAIL below). Without this split a missing
|
|
419
|
+
// services.json would reach readManifest's throw and be reported as
|
|
420
|
+
// "malformed" — a false positive on the fresh install we must never flag.
|
|
421
|
+
try {
|
|
422
|
+
readFileSync(manifestPath, "utf8");
|
|
423
|
+
} catch {
|
|
424
|
+
// ENOENT (or unreadable) — treat absence as the fresh, pre-install state.
|
|
425
|
+
return {
|
|
426
|
+
name: "services-manifest",
|
|
427
|
+
title: "services.json parses + valid",
|
|
428
|
+
status: "pass",
|
|
429
|
+
detail: "no services.json yet — fresh install (nothing configured)",
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
const manifest = readManifest(manifestPath);
|
|
434
|
+
return {
|
|
435
|
+
name: "services-manifest",
|
|
436
|
+
title: "services.json parses + valid",
|
|
437
|
+
status: "pass",
|
|
438
|
+
detail: `parsed ${manifest.services.length} service${manifest.services.length === 1 ? "" : "s"}, all required fields valid`,
|
|
439
|
+
};
|
|
440
|
+
} catch (err) {
|
|
441
|
+
const message =
|
|
442
|
+
err instanceof ServicesManifestError
|
|
443
|
+
? err.message
|
|
444
|
+
: err instanceof Error
|
|
445
|
+
? err.message
|
|
446
|
+
: String(err);
|
|
447
|
+
return {
|
|
448
|
+
name: "services-manifest",
|
|
449
|
+
title: "services.json parses + valid",
|
|
450
|
+
status: "fail",
|
|
451
|
+
detail: `services.json is malformed: ${message}`,
|
|
452
|
+
fix: `edit ${manifestPath} to fix the offending entry`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Canonical-port-drift detection (read) + repair (`--fix`).
|
|
459
|
+
//
|
|
460
|
+
// Two drift shapes, both produced by legacy services.json files written before
|
|
461
|
+
// the duplicate-port validation gate (or hand-edits):
|
|
462
|
+
// 1. A KNOWN module whose row port ≠ its canonical port (SERVICE_SPECS).
|
|
463
|
+
// 2. Two services sharing one port (the silent-miswire class, hub#195) —
|
|
464
|
+
// with the multi-vault carve-out (one vault process, N mounts, one port).
|
|
465
|
+
//
|
|
466
|
+
// THE #717 RULE applies hard here: canonical ports are DERIVED from the
|
|
467
|
+
// service registry (`canonicalPortForManifest`, which reads SERVICE_SPECS /
|
|
468
|
+
// KNOWN_MODULES + FIRST_PARTY_FALLBACKS), never a hardcoded map — so adding a
|
|
469
|
+
// future service can't make this check false-positive on a fresh box. A
|
|
470
|
+
// third-party / unknown service with NO canonical port is benign info, never
|
|
471
|
+
// a drift WARN: we don't flag what has no canonical to drift from.
|
|
472
|
+
//
|
|
473
|
+
// IMPORTANT — why this reads RAW rows, not `readManifest` / `readManifestLenient`:
|
|
474
|
+
// the exact drift this command exists to repair (a duplicate-port pair in a
|
|
475
|
+
// legacy services.json) is precisely what those readers HEAL away before we'd
|
|
476
|
+
// ever see it — the strict reader THROWS on a duplicate port, the lenient
|
|
477
|
+
// reader DROPS one of the colliding rows. To detect (and let `--fix` repair)
|
|
478
|
+
// that pre-gate state, drift logic operates on the raw JSON rows, validating
|
|
479
|
+
// only the minimal `{name, port}` shape each row needs. The shape-level
|
|
480
|
+
// `services-manifest` check still surfaces a genuinely malformed file.
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
/** Minimal row shape drift logic needs — satisfied by both `ServiceEntry`
|
|
484
|
+
* and a raw parsed JSON row. */
|
|
485
|
+
interface PortRow {
|
|
486
|
+
name: string;
|
|
487
|
+
port: number;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** One service whose row port doesn't match its registry-canonical port. */
|
|
491
|
+
export interface PortDrift {
|
|
492
|
+
/** services.json row name (manifestName, e.g. `parachute-vault`). */
|
|
493
|
+
name: string;
|
|
494
|
+
/** Short name for display, when resolvable. */
|
|
495
|
+
short?: string;
|
|
496
|
+
/** The current (drifted) port in services.json. */
|
|
497
|
+
current: number;
|
|
498
|
+
/** The registry-canonical port this service should be on. */
|
|
499
|
+
canonical: number;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export interface PortDriftReport {
|
|
503
|
+
/** Services on a non-canonical port (KNOWN modules only — unknowns skipped). */
|
|
504
|
+
drifted: PortDrift[];
|
|
505
|
+
/**
|
|
506
|
+
* Ports claimed by ≥2 distinct (non-vault) services — a hard collision. Each
|
|
507
|
+
* entry lists the conflicting row names. Multi-vault rows sharing 1940 are
|
|
508
|
+
* NOT a collision and are excluded (the same carve-out the manifest gate uses).
|
|
509
|
+
*/
|
|
510
|
+
duplicates: { port: number; names: string[] }[];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function isVaultRowName(name: string): boolean {
|
|
514
|
+
return name === "parachute-vault" || name.startsWith("parachute-vault-");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Read services.json as raw `{name, port}` rows WITHOUT the manifest readers'
|
|
519
|
+
* duplicate-port heal (strict throws, lenient drops). Returns [] when the file
|
|
520
|
+
* is absent (fresh install) or can't be parsed into rows — drift logic then
|
|
521
|
+
* reports "no drift", and the shape-level `services-manifest` check owns the
|
|
522
|
+
* malformed-file FAIL. Only rows with a string name + integer port are
|
|
523
|
+
* returned; anything else isn't part of the drift bug class.
|
|
524
|
+
*/
|
|
525
|
+
function readRawPortRows(manifestPath: string): PortRow[] {
|
|
526
|
+
let text: string;
|
|
527
|
+
try {
|
|
528
|
+
text = readFileSync(manifestPath, "utf8");
|
|
529
|
+
} catch {
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
let raw: unknown;
|
|
533
|
+
try {
|
|
534
|
+
raw = JSON.parse(text);
|
|
535
|
+
} catch {
|
|
536
|
+
return [];
|
|
537
|
+
}
|
|
538
|
+
if (!raw || typeof raw !== "object") return [];
|
|
539
|
+
const services = (raw as Record<string, unknown>).services;
|
|
540
|
+
if (!Array.isArray(services)) return [];
|
|
541
|
+
const rows: PortRow[] = [];
|
|
542
|
+
for (const row of services) {
|
|
543
|
+
if (!row || typeof row !== "object") continue;
|
|
544
|
+
const name = (row as Record<string, unknown>).name;
|
|
545
|
+
const port = (row as Record<string, unknown>).port;
|
|
546
|
+
if (typeof name !== "string" || typeof port !== "number" || !Number.isInteger(port)) continue;
|
|
547
|
+
rows.push({ name, port });
|
|
548
|
+
}
|
|
549
|
+
return rows;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Pure drift computation over a manifest's rows. Derives every canonical port
|
|
554
|
+
* from the service registry (`canonicalPortForManifest`) — no hardcoded port
|
|
555
|
+
* map, so the source of truth can't drift from this check. Returns both
|
|
556
|
+
* non-canonical-port rows (KNOWN modules only) and duplicate-port collisions
|
|
557
|
+
* (with the multi-vault carve-out). Pure: no fs, no mutation.
|
|
558
|
+
*/
|
|
559
|
+
export function computePortDrift(services: readonly PortRow[]): PortDriftReport {
|
|
560
|
+
const drifted: PortDrift[] = [];
|
|
561
|
+
for (const entry of services) {
|
|
562
|
+
const canonical = canonicalPortForManifest(entry.name);
|
|
563
|
+
// No canonical port for this name → benign, skip. Covers unknown/third-party
|
|
564
|
+
// services AND named multi-vault rows (`parachute-vault-<name>`), which
|
|
565
|
+
// canonicalPortForManifest deliberately returns undefined for (documented
|
|
566
|
+
// gap in service-spec.ts → shortNameForManifest). So a named vault is never
|
|
567
|
+
// flagged as drifted; multi-vault-on-1940 is the carve-out handled below.
|
|
568
|
+
if (canonical === undefined) continue;
|
|
569
|
+
if (entry.port !== canonical) {
|
|
570
|
+
const drift: PortDrift = { name: entry.name, current: entry.port, canonical };
|
|
571
|
+
const short = shortNameForManifest(entry.name);
|
|
572
|
+
if (short !== undefined) drift.short = short;
|
|
573
|
+
drifted.push(drift);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Duplicate-port detection — group distinct service names by port, excluding
|
|
578
|
+
// the deliberate multi-vault case (N vault rows on one port is by design).
|
|
579
|
+
const byPort = new Map<number, string[]>();
|
|
580
|
+
for (const entry of services) {
|
|
581
|
+
const names = byPort.get(entry.port) ?? [];
|
|
582
|
+
if (!names.includes(entry.name)) names.push(entry.name);
|
|
583
|
+
byPort.set(entry.port, names);
|
|
584
|
+
}
|
|
585
|
+
const duplicates: { port: number; names: string[] }[] = [];
|
|
586
|
+
for (const [port, names] of byPort) {
|
|
587
|
+
if (names.length < 2) continue;
|
|
588
|
+
// All-vault rows on one port is the multi-vault carve-out, not a collision.
|
|
589
|
+
if (names.every(isVaultRowName)) continue;
|
|
590
|
+
duplicates.push({ port, names });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return { drifted, duplicates };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Canonical-port-drift check (read-only). WARN (advisory — the box may work,
|
|
598
|
+
* the operator may have moved a service deliberately) when any KNOWN module
|
|
599
|
+
* sits off its canonical port OR two services collide on one port. A clean
|
|
600
|
+
* file → PASS. Unknown/third-party services with no canonical port are never
|
|
601
|
+
* flagged (#717 — no canonical, no drift signal).
|
|
602
|
+
*/
|
|
603
|
+
function checkPortDrift(manifestPath: string): CheckResult {
|
|
604
|
+
const { drifted, duplicates } = computePortDrift(readRawPortRows(manifestPath));
|
|
605
|
+
if (drifted.length === 0 && duplicates.length === 0) {
|
|
606
|
+
return {
|
|
607
|
+
name: "port-drift",
|
|
608
|
+
title: "Services on canonical ports",
|
|
609
|
+
status: "pass",
|
|
610
|
+
detail: "all services are on their canonical ports — no drift",
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const parts: string[] = [];
|
|
614
|
+
for (const d of drifted) {
|
|
615
|
+
parts.push(`${d.short ?? d.name} is on :${d.current} (canonical :${d.canonical})`);
|
|
616
|
+
}
|
|
617
|
+
for (const dup of duplicates) {
|
|
618
|
+
parts.push(`port :${dup.port} is claimed by ${dup.names.join(" + ")}`);
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
name: "port-drift",
|
|
622
|
+
title: "Services on canonical ports",
|
|
623
|
+
status: "warn",
|
|
624
|
+
detail: `canonical-port drift: ${parts.join("; ")}`,
|
|
625
|
+
fix: "parachute doctor --fix",
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* operator.token exists, parses, and its `iss` matches a hub-legitimate issuer.
|
|
631
|
+
*
|
|
632
|
+
* Absent token → PASS/info (a box that hasn't created its first admin yet, or
|
|
633
|
+
* one that never minted an operator token — feature-not-configured, NOT broken;
|
|
634
|
+
* `parachute status` / `auth set-password` is the path, doctor doesn't force it).
|
|
635
|
+
*
|
|
636
|
+
* Present token:
|
|
637
|
+
* - undecodable / no `iss` claim → FAIL (corrupt credential).
|
|
638
|
+
* - `iss` matches the hub's known-issuer SET (loopback aliases ∪ expose-state
|
|
639
|
+
* public origin ∪ platform origin — the SAME set the live auth path uses,
|
|
640
|
+
* `buildKnownIssuersForOperatorToken`) → PASS.
|
|
641
|
+
* - `iss` is foreign to that set → FAIL: the recurring "not signed in to the
|
|
642
|
+
* hub" / issuer-mismatch class (hub#481). Fix is `start hub` (self-heals)
|
|
643
|
+
* or `auth rotate-operator`.
|
|
644
|
+
*
|
|
645
|
+
* Deliberately a DECODE-only `iss` check, not a full signature/JWKS validation:
|
|
646
|
+
* doctor must run without a live hub or DB, and an unsigned-but-decodable token
|
|
647
|
+
* still tells us the issuer-mismatch story. The known-issuer set is the same
|
|
648
|
+
* one the real validation layers `iss` against on top of the signature check.
|
|
649
|
+
*/
|
|
650
|
+
function checkOperatorToken(configDir: string): CheckResult {
|
|
651
|
+
const path = operatorTokenPath(configDir);
|
|
652
|
+
let token: string;
|
|
653
|
+
try {
|
|
654
|
+
token = readFileSync(path, "utf8").trim();
|
|
655
|
+
} catch {
|
|
656
|
+
return {
|
|
657
|
+
name: "operator-token",
|
|
658
|
+
title: "operator.token valid + issuer matches",
|
|
659
|
+
status: "pass",
|
|
660
|
+
detail:
|
|
661
|
+
"no operator.token yet — fine for a box that hasn't created its first admin (run `parachute auth set-password` when ready)",
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (token.length === 0) {
|
|
665
|
+
return {
|
|
666
|
+
name: "operator-token",
|
|
667
|
+
title: "operator.token valid + issuer matches",
|
|
668
|
+
status: "pass",
|
|
669
|
+
detail: "operator.token is empty — treated as not configured",
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
let iss: string | undefined;
|
|
674
|
+
try {
|
|
675
|
+
const payload = decodeJwt(token);
|
|
676
|
+
iss = typeof payload.iss === "string" ? payload.iss : undefined;
|
|
677
|
+
} catch {
|
|
678
|
+
return {
|
|
679
|
+
name: "operator-token",
|
|
680
|
+
title: "operator.token valid + issuer matches",
|
|
681
|
+
status: "fail",
|
|
682
|
+
detail: `operator.token at ${path} is not a decodable JWT`,
|
|
683
|
+
fix: "parachute auth rotate-operator",
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
if (!iss) {
|
|
687
|
+
return {
|
|
688
|
+
name: "operator-token",
|
|
689
|
+
title: "operator.token valid + issuer matches",
|
|
690
|
+
status: "fail",
|
|
691
|
+
detail: "operator.token has no `iss` claim",
|
|
692
|
+
fix: "parachute auth rotate-operator",
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Build the issuer set the live auth path validates against (loopback aliases
|
|
697
|
+
// ∪ expose-state public origin ∪ platform origin). Seed with the resolved
|
|
698
|
+
// loopback issuer so a never-exposed box still has its own loopback in the set.
|
|
699
|
+
const seedIssuer = `http://127.0.0.1:${readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT}`;
|
|
700
|
+
let knownIssuers: readonly string[] = [];
|
|
701
|
+
try {
|
|
702
|
+
knownIssuers = buildKnownIssuersForOperatorToken(configDir, seedIssuer);
|
|
703
|
+
} catch {
|
|
704
|
+
knownIssuers = [seedIssuer];
|
|
705
|
+
}
|
|
706
|
+
if (knownIssuers.includes(iss)) {
|
|
707
|
+
return {
|
|
708
|
+
name: "operator-token",
|
|
709
|
+
title: "operator.token valid + issuer matches",
|
|
710
|
+
status: "pass",
|
|
711
|
+
detail: `operator.token issuer (${iss}) matches the hub`,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
name: "operator-token",
|
|
716
|
+
title: "operator.token valid + issuer matches",
|
|
717
|
+
status: "fail",
|
|
718
|
+
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(", ")}`,
|
|
719
|
+
fix: "parachute start hub # self-heals the issuer; or `parachute auth rotate-operator`",
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Module bin resolvable via `Bun.which` (exec bit present) — the 100644
|
|
725
|
+
* start-failure class (channel#41): a module whose `bin` lost its +x bit
|
|
726
|
+
* resolves to null under `Bun.which` (which requires X_OK), so the supervisor
|
|
727
|
+
* reports "<binary> not installed" despite an intact symlink + services.json.
|
|
728
|
+
*
|
|
729
|
+
* For each configured module we resolve its startCmd binary the SAME way the
|
|
730
|
+
* supervisor does (spec.startCmd over the entry; module.json wins when
|
|
731
|
+
* installDir is stamped) and run depcheck's `ensureExecutable`:
|
|
732
|
+
* - resolves cleanly → PASS.
|
|
733
|
+
* - `NonExecutableError` (present but no +x) → FAIL with the `chmod +x` fix —
|
|
734
|
+
* the exact bug this check exists for.
|
|
735
|
+
* - `MissingDependencyError` (genuinely not on PATH) → FAIL (reinstall).
|
|
736
|
+
*
|
|
737
|
+
* A module whose spec has no resolvable startCmd (CLI-only module, unreadable
|
|
738
|
+
* module.json) is SKIPPED — there's no bin to check, and "no startCmd" is not a
|
|
739
|
+
* broken-bin condition. Modules with no spec at all (third-party, no fallback)
|
|
740
|
+
* are likewise skipped — we can't know their bin name, and absence of knowledge
|
|
741
|
+
* is never a failure (the #717 rule).
|
|
742
|
+
*/
|
|
743
|
+
async function checkModuleBins(
|
|
744
|
+
manifest: { services: ServiceEntry[] },
|
|
745
|
+
deps: ResolvedDeps,
|
|
746
|
+
): Promise<CheckResult[]> {
|
|
747
|
+
const checks = await Promise.all(
|
|
748
|
+
manifest.services.map(async (entry): Promise<CheckResult | undefined> => {
|
|
749
|
+
const short = shortNameForManifest(entry.name);
|
|
750
|
+
if (!short) return undefined; // third-party / unknown — no bin to reason about.
|
|
751
|
+
const binary = await resolveStartBinary(short, entry);
|
|
752
|
+
if (!binary) return undefined; // CLI-only module / unreadable module.json — nothing to check.
|
|
753
|
+
|
|
754
|
+
try {
|
|
755
|
+
const ensureOpts: Parameters<typeof ensureExecutable>[1] = { which: deps.which };
|
|
756
|
+
if (deps.findNonExecutable) ensureOpts.findNonExecutable = deps.findNonExecutable;
|
|
757
|
+
ensureExecutable(binary, ensureOpts);
|
|
758
|
+
return {
|
|
759
|
+
name: `module-bin:${short}`,
|
|
760
|
+
title: `Module ${short} bin executable`,
|
|
761
|
+
status: "pass",
|
|
762
|
+
detail: `${binary} resolves on PATH with the exec bit set`,
|
|
763
|
+
};
|
|
764
|
+
} catch (err) {
|
|
765
|
+
if (err instanceof NonExecutableError) {
|
|
766
|
+
return {
|
|
767
|
+
name: `module-bin:${short}`,
|
|
768
|
+
title: `Module ${short} bin executable`,
|
|
769
|
+
status: "fail",
|
|
770
|
+
detail: `${binary} is present at ${err.path} but is NOT executable (lost its +x bit) — the supervisor will report it "not installed"`,
|
|
771
|
+
fix: `chmod +x ${err.path}`,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
// MissingDependencyError (or anything else) → the bin isn't resolvable.
|
|
775
|
+
const missing = err as MissingDependencyError;
|
|
776
|
+
const why =
|
|
777
|
+
typeof missing?.message === "string"
|
|
778
|
+
? missing.message.split("\n")[0]
|
|
779
|
+
: `${binary} not found on PATH`;
|
|
780
|
+
return {
|
|
781
|
+
name: `module-bin:${short}`,
|
|
782
|
+
title: `Module ${short} bin executable`,
|
|
783
|
+
status: "fail",
|
|
784
|
+
detail: `${binary} for module ${short} isn't resolvable on PATH: ${why}`,
|
|
785
|
+
fix: `parachute install ${short} # reinstall the module`,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
}),
|
|
789
|
+
);
|
|
790
|
+
const present = checks.filter((c): c is CheckResult => c !== undefined);
|
|
791
|
+
if (present.length === 0) {
|
|
792
|
+
return [
|
|
793
|
+
{
|
|
794
|
+
name: "module-bins",
|
|
795
|
+
title: "Module bins executable",
|
|
796
|
+
status: "pass",
|
|
797
|
+
detail: "no first-party module bins to check",
|
|
798
|
+
},
|
|
799
|
+
];
|
|
800
|
+
}
|
|
801
|
+
return present;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Resolve the startCmd binary (`cmd[0]`) for a configured module, mirroring the
|
|
806
|
+
* supervisor's resolution: module.json wins when installDir is stamped, else the
|
|
807
|
+
* imperative spec startCmd. Returns undefined when there's no spec or no
|
|
808
|
+
* resolvable startCmd (CLI-only / unreadable manifest). Never throws.
|
|
809
|
+
*/
|
|
810
|
+
async function resolveStartBinary(short: string, entry: ServiceEntry): Promise<string | undefined> {
|
|
811
|
+
let spec = getSpec(short);
|
|
812
|
+
if (entry.installDir) {
|
|
813
|
+
try {
|
|
814
|
+
const resolved = await getSpecFromInstallDir(entry.installDir, entry.name);
|
|
815
|
+
if (resolved) spec = resolved;
|
|
816
|
+
} catch {
|
|
817
|
+
// Unreadable / malformed module.json — fall back to the imperative spec.
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (!spec?.startCmd) return undefined;
|
|
821
|
+
let cmd: readonly string[] | undefined;
|
|
822
|
+
try {
|
|
823
|
+
cmd = spec.startCmd(entry);
|
|
824
|
+
} catch {
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
return cmd && cmd.length > 0 ? cmd[0] : undefined;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Migration via the SAFE detectors only (never a "scan for unfamiliar files"
|
|
832
|
+
* approach — that's the exact false-positive class #717 forbids):
|
|
833
|
+
* - `hasPriorDetachedInstall` — a pidfile (the detached-era fingerprint).
|
|
834
|
+
* A supervised/fresh box writes none → no warning. WARN (not FAIL): the
|
|
835
|
+
* box still works; doctor nudges toward the supervised cutover.
|
|
836
|
+
* - `migrateNotice` — the allowlist archive detector. Only flags entries
|
|
837
|
+
* matching an explicit KNOWN_CRUFT rule; a fresh root flags nothing. WARN.
|
|
838
|
+
*
|
|
839
|
+
* Both clean → a single PASS.
|
|
840
|
+
*/
|
|
841
|
+
function checkMigration(
|
|
842
|
+
configDir: string,
|
|
843
|
+
manifestPath: string,
|
|
844
|
+
deps: ResolvedDeps,
|
|
845
|
+
): CheckResult[] {
|
|
846
|
+
const out: CheckResult[] = [];
|
|
847
|
+
|
|
848
|
+
let priorDetached = false;
|
|
849
|
+
try {
|
|
850
|
+
priorDetached = hasPriorDetachedInstall(configDir, manifestPath);
|
|
851
|
+
} catch {
|
|
852
|
+
priorDetached = false;
|
|
853
|
+
}
|
|
854
|
+
if (priorDetached) {
|
|
855
|
+
out.push({
|
|
856
|
+
name: "migration-detached",
|
|
857
|
+
title: "Legacy detached install detected",
|
|
858
|
+
status: "warn",
|
|
859
|
+
detail:
|
|
860
|
+
"this box has a prior detached-model install (pidfiles present) — the current hub runs supervised under a process manager",
|
|
861
|
+
fix: "parachute migrate --to-supervised",
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
let notice: string | undefined;
|
|
866
|
+
try {
|
|
867
|
+
notice = migrateNotice(configDir, deps.now());
|
|
868
|
+
} catch {
|
|
869
|
+
notice = undefined;
|
|
870
|
+
}
|
|
871
|
+
if (notice) {
|
|
872
|
+
out.push({
|
|
873
|
+
name: "migration-cruft",
|
|
874
|
+
title: "Archivable cruft at ecosystem root",
|
|
875
|
+
status: "warn",
|
|
876
|
+
detail: notice.replace(/^parachute migrate: /, "").replace(/ — run.*$/, ""),
|
|
877
|
+
fix: "parachute migrate",
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (out.length === 0) {
|
|
882
|
+
out.push({
|
|
883
|
+
name: "migration",
|
|
884
|
+
title: "Migration",
|
|
885
|
+
status: "pass",
|
|
886
|
+
detail: "no legacy detached install, no archivable cruft at the ecosystem root",
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// Tier 2 checks (guarded hard — never FAIL on not-configured)
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Exposure reachability + issuer consistency — ONLY when expose-state says the
|
|
898
|
+
* box is exposed. If NOT exposed → benign "loopback only" info (PASS, never
|
|
899
|
+
* WARN): a box reachable only on loopback is a legitimate, common configuration.
|
|
900
|
+
*
|
|
901
|
+
* When exposed:
|
|
902
|
+
* - missing `hubOrigin` in expose-state → WARN (cosmetic — we can't verify
|
|
903
|
+
* reachability without it, but the box may well be fine).
|
|
904
|
+
* - public origin answers `/health` → PASS.
|
|
905
|
+
* - public origin doesn't answer → WARN (not FAIL): the tunnel may be mid-
|
|
906
|
+
* bring-up, or an upstream CDN/bot-protection may shape server-to-server
|
|
907
|
+
* probes (the known Cloudflare-bot-protection class) — doctor flags it for
|
|
908
|
+
* attention without declaring the install broken.
|
|
909
|
+
*/
|
|
910
|
+
async function checkExposure(configDir: string, deps: ResolvedDeps): Promise<CheckResult> {
|
|
911
|
+
let state: ExposeState | undefined;
|
|
912
|
+
try {
|
|
913
|
+
state = readExposeState(`${configDir}/expose-state.json`);
|
|
914
|
+
} catch {
|
|
915
|
+
// A malformed expose-state.json must not crash doctor; treat as not-exposed
|
|
916
|
+
// info (the malformed-file case is the operator's to clear, and the CLI's
|
|
917
|
+
// own ExposeStateError surfaces it elsewhere).
|
|
918
|
+
return {
|
|
919
|
+
name: "exposure",
|
|
920
|
+
title: "Exposure",
|
|
921
|
+
status: "pass",
|
|
922
|
+
detail: "expose-state.json is unreadable — treating as loopback only",
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (!state) {
|
|
927
|
+
return {
|
|
928
|
+
name: "exposure",
|
|
929
|
+
title: "Exposure",
|
|
930
|
+
status: "pass",
|
|
931
|
+
detail: "loopback only — not exposed to a tailnet or the public internet (this is fine)",
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const origin = state.hubOrigin;
|
|
936
|
+
if (!origin) {
|
|
937
|
+
return {
|
|
938
|
+
name: "exposure",
|
|
939
|
+
title: "Exposure reachable",
|
|
940
|
+
status: "warn",
|
|
941
|
+
detail: `exposed (${state.layer}) but expose-state has no hubOrigin to verify reachability`,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
let reachable = false;
|
|
946
|
+
try {
|
|
947
|
+
reachable = await deps.probePublicHealth(origin);
|
|
948
|
+
} catch {
|
|
949
|
+
reachable = false;
|
|
950
|
+
}
|
|
951
|
+
if (reachable) {
|
|
952
|
+
return {
|
|
953
|
+
name: "exposure",
|
|
954
|
+
title: "Exposure reachable",
|
|
955
|
+
status: "pass",
|
|
956
|
+
detail: `public origin ${origin} answers /health`,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
name: "exposure",
|
|
961
|
+
title: "Exposure reachable",
|
|
962
|
+
status: "warn",
|
|
963
|
+
detail: `exposed at ${origin} but it didn't answer /health — the tunnel may be starting, or upstream bot-protection may be shaping the probe`,
|
|
964
|
+
fix: "parachute expose <layer> # re-bring-up the exposure if it's down",
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Version drift (hub#243) — WARN at most, never FAIL, and labeled cosmetic.
|
|
970
|
+
* services.json caches each module's version string; on a bun-linked checkout
|
|
971
|
+
* that can lag the live package.json after a rebuild. Purely cosmetic — the
|
|
972
|
+
* running code is whatever the bundle is, not the cached string. We only flag
|
|
973
|
+
* the obvious shape (cached `0.0.0-linked` stopgap) so the check stays
|
|
974
|
+
* positive-detection: a cached real version we have no live value to compare
|
|
975
|
+
* against is NOT flagged (we don't have status.ts's install-source machinery
|
|
976
|
+
* wired here, and guessing would risk a false WARN — #717).
|
|
977
|
+
*/
|
|
978
|
+
function checkVersionDrift(manifest: { services: ServiceEntry[] }): CheckResult {
|
|
979
|
+
const stopgaps = manifest.services.filter((s) => s.version === "0.0.0-linked");
|
|
980
|
+
if (stopgaps.length === 0) {
|
|
981
|
+
return {
|
|
982
|
+
name: "version-drift",
|
|
983
|
+
title: "Version freshness (cosmetic)",
|
|
984
|
+
status: "pass",
|
|
985
|
+
detail: "no obvious version-drift markers in services.json",
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
const names = stopgaps.map((s) => shortNameForManifest(s.name) ?? s.name).join(", ");
|
|
989
|
+
return {
|
|
990
|
+
name: "version-drift",
|
|
991
|
+
title: "Version freshness (cosmetic)",
|
|
992
|
+
status: "warn",
|
|
993
|
+
detail: `services.json still has the install-time stopgap version "0.0.0-linked" for: ${names} (cosmetic — the running code is the live bundle)`,
|
|
994
|
+
fix: "parachute restart <module> # lets the module re-stamp its real version",
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ---------------------------------------------------------------------------
|
|
999
|
+
// Orchestration + rendering
|
|
1000
|
+
// ---------------------------------------------------------------------------
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Run every check and return them grouped, in report order. Pure-ish: reads fs
|
|
1004
|
+
* + (stubbable) network, never mutates. The hub-reachability probe runs once
|
|
1005
|
+
* and gates the module-liveness check (a down hub means every child is down).
|
|
1006
|
+
*/
|
|
1007
|
+
async function runChecks(
|
|
1008
|
+
configDir: string,
|
|
1009
|
+
manifestPath: string,
|
|
1010
|
+
deps: ResolvedDeps,
|
|
1011
|
+
): Promise<GroupedCheck[]> {
|
|
1012
|
+
// Lenient manifest read for the checks that iterate modules — a single bad
|
|
1013
|
+
// row must not sink hub/operator/migration checks. The STRICT parse is the
|
|
1014
|
+
// services-manifest check's own job (it WANTS to surface the parse error).
|
|
1015
|
+
const manifest = readManifestLenient(manifestPath);
|
|
1016
|
+
|
|
1017
|
+
const hub = await checkHubReachable(configDir, deps);
|
|
1018
|
+
const hubHealthy = hub.status === "pass";
|
|
1019
|
+
|
|
1020
|
+
const [modules, bins, exposure] = await Promise.all([
|
|
1021
|
+
checkModulesAlive(manifest, hubHealthy, deps),
|
|
1022
|
+
checkModuleBins(manifest, deps),
|
|
1023
|
+
checkExposure(configDir, deps),
|
|
1024
|
+
]);
|
|
1025
|
+
const manifestCheck = checkServicesManifest(manifestPath);
|
|
1026
|
+
const portDrift = checkPortDrift(manifestPath);
|
|
1027
|
+
const operator = checkOperatorToken(configDir);
|
|
1028
|
+
const migration = checkMigration(configDir, manifestPath, deps);
|
|
1029
|
+
const versionDrift = checkVersionDrift(manifest);
|
|
1030
|
+
|
|
1031
|
+
const grouped: GroupedCheck[] = [];
|
|
1032
|
+
const add = (group: Group, checks: CheckResult[]) => {
|
|
1033
|
+
for (const c of checks) grouped.push({ ...c, group });
|
|
1034
|
+
};
|
|
1035
|
+
add("Hub", [hub]);
|
|
1036
|
+
add("Modules", [...modules, ...bins]);
|
|
1037
|
+
add("Configuration", [manifestCheck, portDrift, operator]);
|
|
1038
|
+
add("Migration", migration);
|
|
1039
|
+
add("Exposure", [exposure, versionDrift]);
|
|
1040
|
+
return grouped;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const MARK: Record<CheckStatus, string> = { pass: "✓", warn: "⚠", fail: "✗" };
|
|
1044
|
+
|
|
1045
|
+
function renderHuman(checks: GroupedCheck[], print: (line: string) => void): void {
|
|
1046
|
+
print("parachute doctor — health check");
|
|
1047
|
+
print("");
|
|
1048
|
+
for (const group of GROUP_ORDER) {
|
|
1049
|
+
const inGroup = checks.filter((c) => c.group === group);
|
|
1050
|
+
if (inGroup.length === 0) continue;
|
|
1051
|
+
print(`${group}:`);
|
|
1052
|
+
for (const c of inGroup) {
|
|
1053
|
+
print(` ${MARK[c.status]} ${c.title}`);
|
|
1054
|
+
print(` ${c.detail}`);
|
|
1055
|
+
if (c.fix) print(` fix: ${c.fix}`);
|
|
1056
|
+
}
|
|
1057
|
+
print("");
|
|
1058
|
+
}
|
|
1059
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
1060
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
1061
|
+
const passes = checks.filter((c) => c.status === "pass").length;
|
|
1062
|
+
if (fails === 0 && warns === 0) {
|
|
1063
|
+
print(`All clear — ${passes} check${passes === 1 ? "" : "s"} passed.`);
|
|
1064
|
+
} else {
|
|
1065
|
+
const parts: string[] = [`${passes} ok`];
|
|
1066
|
+
if (warns > 0) parts.push(`${warns} warning${warns === 1 ? "" : "s"}`);
|
|
1067
|
+
if (fails > 0) parts.push(`${fails} failure${fails === 1 ? "" : "s"}`);
|
|
1068
|
+
print(`Summary: ${parts.join(", ")}.`);
|
|
1069
|
+
if (fails === 0) print("No failures — warnings are advisory.");
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function renderJson(checks: GroupedCheck[], print: (line: string) => void): void {
|
|
1074
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
1075
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
1076
|
+
const passes = checks.filter((c) => c.status === "pass").length;
|
|
1077
|
+
const payload = {
|
|
1078
|
+
ok: fails === 0,
|
|
1079
|
+
summary: { pass: passes, warn: warns, fail: fails },
|
|
1080
|
+
checks: checks.map((c) => ({
|
|
1081
|
+
name: c.name,
|
|
1082
|
+
group: c.group,
|
|
1083
|
+
title: c.title,
|
|
1084
|
+
status: c.status,
|
|
1085
|
+
detail: c.detail,
|
|
1086
|
+
...(c.fix ? { fix: c.fix } : {}),
|
|
1087
|
+
})),
|
|
1088
|
+
};
|
|
1089
|
+
print(JSON.stringify(payload, null, 2));
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// ---------------------------------------------------------------------------
|
|
1093
|
+
// `doctor --fix` — the ONLY writing path. Repairs canonical-port drift in
|
|
1094
|
+
// services.json and nothing else (every other check stays report-only). The
|
|
1095
|
+
// guards, all load-bearing:
|
|
1096
|
+
// - SHOW THE DIFF first (old→new per service) so the operator sees the exact
|
|
1097
|
+
// change before it lands.
|
|
1098
|
+
// - CONFIRMATION-GATED: a TTY prompts y/N; `--yes` skips the prompt; a
|
|
1099
|
+
// non-TTY WITHOUT `--yes` bails (exit non-zero) with a hint, never writing.
|
|
1100
|
+
// - IDEMPOTENT: a clean file is "no drift, nothing to fix", exit 0.
|
|
1101
|
+
// - PRESERVES every field + the writer's formatting: it parses the RAW rows
|
|
1102
|
+
// (so a duplicate-port legacy file — which `readManifest` would THROW on
|
|
1103
|
+
// and `readManifestLenient` would DROP a row from — is repairable), mutates
|
|
1104
|
+
// ONLY the `port` of drifted rows, and writes back through `writeManifest`
|
|
1105
|
+
// (atomic tmp+rename, trailing-newline formatting). Optional/unknown fields
|
|
1106
|
+
// (displayName, tagline, stripPrefix, …) round-trip untouched.
|
|
1107
|
+
// - Duplicate-port collisions are REPORTED (not separately auto-resolved):
|
|
1108
|
+
// fixing canonical drift often clears the collision on its own (both rows
|
|
1109
|
+
// move to distinct canonical slots); any residual collision is surfaced for
|
|
1110
|
+
// the operator rather than guessed at.
|
|
1111
|
+
// ---------------------------------------------------------------------------
|
|
1112
|
+
|
|
1113
|
+
async function fixPortDrift(
|
|
1114
|
+
manifestPath: string,
|
|
1115
|
+
opts: { yes: boolean; print: (line: string) => void; deps: ResolvedDeps },
|
|
1116
|
+
): Promise<number> {
|
|
1117
|
+
const { print, deps } = opts;
|
|
1118
|
+
|
|
1119
|
+
// Read the RAW file — not through readManifest (throws on dup ports) or
|
|
1120
|
+
// readManifestLenient (drops a colliding row). We need the pre-gate shape to
|
|
1121
|
+
// repair it.
|
|
1122
|
+
let text: string;
|
|
1123
|
+
try {
|
|
1124
|
+
text = readFileSync(manifestPath, "utf8");
|
|
1125
|
+
} catch {
|
|
1126
|
+
// Absent (ENOENT) / unreadable services.json is the fresh pre-install
|
|
1127
|
+
// state, not a corrupt file — there's no drift to fix. Idempotent no-op.
|
|
1128
|
+
print("No canonical-port drift — nothing to fix.");
|
|
1129
|
+
return 0;
|
|
1130
|
+
}
|
|
1131
|
+
// A genuinely unparseable / wrong-shape file → bail (the read-only
|
|
1132
|
+
// `services-manifest` check surfaces the parse error in the report).
|
|
1133
|
+
let parsed: { services: Record<string, unknown>[] };
|
|
1134
|
+
try {
|
|
1135
|
+
const raw = JSON.parse(text) as unknown;
|
|
1136
|
+
if (
|
|
1137
|
+
!raw ||
|
|
1138
|
+
typeof raw !== "object" ||
|
|
1139
|
+
!Array.isArray((raw as { services?: unknown }).services)
|
|
1140
|
+
) {
|
|
1141
|
+
throw new Error('expected an object with a "services" array');
|
|
1142
|
+
}
|
|
1143
|
+
parsed = raw as { services: Record<string, unknown>[] };
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1146
|
+
print(`parachute doctor --fix: can't read services.json — ${message}`);
|
|
1147
|
+
print("Fix the file by hand first; --fix only rewrites canonical-port drift.");
|
|
1148
|
+
return 1;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Compute drift from the SAME parsed object we'll mutate below (one read —
|
|
1152
|
+
// no read-it-twice window where the file could change between detection and
|
|
1153
|
+
// rewrite). Filter to the minimal {name, port} rows computePortDrift needs.
|
|
1154
|
+
const portRows: PortRow[] = [];
|
|
1155
|
+
for (const row of parsed.services) {
|
|
1156
|
+
const name = row.name;
|
|
1157
|
+
const port = row.port;
|
|
1158
|
+
if (typeof name === "string" && typeof port === "number" && Number.isInteger(port)) {
|
|
1159
|
+
portRows.push({ name, port });
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
const { drifted, duplicates } = computePortDrift(portRows);
|
|
1163
|
+
|
|
1164
|
+
// Report any duplicate-port collisions up front (not separately auto-fixed —
|
|
1165
|
+
// canonical-drift repair below usually clears them by moving each row to its
|
|
1166
|
+
// own canonical slot).
|
|
1167
|
+
for (const dup of duplicates) {
|
|
1168
|
+
print(
|
|
1169
|
+
`note: port :${dup.port} is shared by ${dup.names.join(" + ")} — fixing canonical drift below; verify each ends on a unique port.`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Idempotent: clean (no off-canonical rows) → no-op, exit 0.
|
|
1174
|
+
if (drifted.length === 0) {
|
|
1175
|
+
print("No canonical-port drift — nothing to fix.");
|
|
1176
|
+
return 0;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Show the diff BEFORE applying — the operator sees exactly what changes.
|
|
1180
|
+
print("Canonical-port drift to repair:");
|
|
1181
|
+
for (const d of drifted) {
|
|
1182
|
+
print(` ${d.short ?? d.name}: :${d.current} → :${d.canonical}`);
|
|
1183
|
+
}
|
|
1184
|
+
print("");
|
|
1185
|
+
|
|
1186
|
+
// Confirmation gate. `--yes` skips it; otherwise a TTY prompts and a non-TTY
|
|
1187
|
+
// bails (never write without the operator seeing the change).
|
|
1188
|
+
if (!opts.yes) {
|
|
1189
|
+
if (!deps.isInteractive()) {
|
|
1190
|
+
print("Refusing to rewrite services.json without confirmation in a non-interactive shell.");
|
|
1191
|
+
print("Re-run with --yes to apply, or run interactively to confirm.");
|
|
1192
|
+
return 1;
|
|
1193
|
+
}
|
|
1194
|
+
const answer = (await deps.readLine("Apply these port changes? [y/N] ")).trim().toLowerCase();
|
|
1195
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1196
|
+
print("Aborted — services.json unchanged.");
|
|
1197
|
+
return 1;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Apply: mutate ONLY the port of drifted rows on the raw parsed object;
|
|
1202
|
+
// every other field round-trips verbatim. Write through `writeManifest`, which
|
|
1203
|
+
// JSON.stringifies the object as-is (no field filtering) + does the atomic
|
|
1204
|
+
// tmp+rename + trailing-newline formatting — so unknown/optional fields are
|
|
1205
|
+
// preserved. The cast is to satisfy writeManifest's parameter type; we never
|
|
1206
|
+
// rely on the raw rows actually being well-formed ServiceEntry objects (a
|
|
1207
|
+
// malformed sibling row round-trips untouched, same as it was on disk).
|
|
1208
|
+
const canonicalByName = new Map(drifted.map((d) => [d.name, d.canonical]));
|
|
1209
|
+
const next = {
|
|
1210
|
+
services: parsed.services.map((row) => {
|
|
1211
|
+
const canonical =
|
|
1212
|
+
typeof row.name === "string" ? canonicalByName.get(row.name) : undefined;
|
|
1213
|
+
return canonical === undefined ? row : { ...row, port: canonical };
|
|
1214
|
+
}),
|
|
1215
|
+
};
|
|
1216
|
+
writeManifest(next as unknown as { services: ServiceEntry[] }, manifestPath);
|
|
1217
|
+
print(`Rewrote ${drifted.length} service port${drifted.length === 1 ? "" : "s"} to canonical.`);
|
|
1218
|
+
print("Run `parachute doctor` to see the full health report.");
|
|
1219
|
+
return 0;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* `parachute doctor`. Returns the process exit code: 0 when no check FAILs
|
|
1224
|
+
* (WARN is allowed), non-zero on any FAIL. Never throws — every check is
|
|
1225
|
+
* individually wrapped + degrades gracefully, so doctor is itself a reliable
|
|
1226
|
+
* diagnostic regardless of the box's state.
|
|
1227
|
+
*
|
|
1228
|
+
* `--fix` is the one writing mode: it repairs canonical-port drift in
|
|
1229
|
+
* services.json (and ONLY that) behind a show-diff + confirmation gate, then
|
|
1230
|
+
* returns its own exit code without running the full diagnostic report.
|
|
1231
|
+
*/
|
|
1232
|
+
export async function doctor(opts: DoctorOpts = {}): Promise<number> {
|
|
1233
|
+
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
1234
|
+
const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
|
|
1235
|
+
const print = opts.print ?? ((line) => console.log(line));
|
|
1236
|
+
const deps = resolveDeps(opts.deps);
|
|
1237
|
+
|
|
1238
|
+
if (opts.fix) {
|
|
1239
|
+
return await fixPortDrift(manifestPath, { yes: opts.yes ?? false, print, deps });
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const checks = await runChecks(configDir, manifestPath, deps);
|
|
1243
|
+
if (opts.json) {
|
|
1244
|
+
renderJson(checks, print);
|
|
1245
|
+
} else {
|
|
1246
|
+
renderHuman(checks, print);
|
|
1247
|
+
}
|
|
1248
|
+
const anyFail = checks.some((c) => c.status === "fail");
|
|
1249
|
+
return anyFail ? 1 : 0;
|
|
1250
|
+
}
|