@openparachute/hub 0.7.4-rc.17 → 0.7.4-rc.19
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 +1 -1
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/doctor.test.ts +292 -1
- package/src/api-modules.ts +105 -0
- package/src/cli.ts +14 -3
- package/src/commands/doctor.ts +358 -2
- package/src/help.ts +17 -2
- package/web/ui/dist/assets/{index-CkKBaPaO.js → index-CVqK1cV5.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/src/commands/doctor.ts
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { readFileSync } from "node:fs";
|
|
46
|
+
import { createInterface } from "node:readline/promises";
|
|
46
47
|
import {
|
|
47
48
|
type MissingDependencyError,
|
|
48
49
|
NonExecutableError,
|
|
@@ -61,12 +62,18 @@ import {
|
|
|
61
62
|
} from "../hub-unit.ts";
|
|
62
63
|
import { hasPriorDetachedInstall } from "../migrate-offer.ts";
|
|
63
64
|
import { buildKnownIssuersForOperatorToken, operatorTokenPath } from "../operator-token.ts";
|
|
64
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
canonicalPortForManifest,
|
|
67
|
+
getSpec,
|
|
68
|
+
getSpecFromInstallDir,
|
|
69
|
+
shortNameForManifest,
|
|
70
|
+
} from "../service-spec.ts";
|
|
65
71
|
import {
|
|
66
72
|
type ServiceEntry,
|
|
67
73
|
ServicesManifestError,
|
|
68
74
|
readManifest,
|
|
69
75
|
readManifestLenient,
|
|
76
|
+
writeManifest,
|
|
70
77
|
} from "../services-manifest.ts";
|
|
71
78
|
import { migrateNotice } from "./migrate.ts";
|
|
72
79
|
|
|
@@ -139,6 +146,17 @@ export interface DoctorDeps {
|
|
|
139
146
|
findNonExecutable?: (binary: string) => string | null;
|
|
140
147
|
/** Clock seam for date-stamped detectors (migrate). */
|
|
141
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>;
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
export interface DoctorOpts {
|
|
@@ -147,6 +165,14 @@ export interface DoctorOpts {
|
|
|
147
165
|
print?: (line: string) => void;
|
|
148
166
|
/** Emit a single JSON object instead of the human report. */
|
|
149
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;
|
|
150
176
|
deps?: DoctorDeps;
|
|
151
177
|
}
|
|
152
178
|
|
|
@@ -191,6 +217,21 @@ async function defaultProbePublicHealth(origin: string): Promise<boolean> {
|
|
|
191
217
|
}
|
|
192
218
|
}
|
|
193
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
|
+
|
|
194
235
|
interface ResolvedDeps {
|
|
195
236
|
probeHubHealth: (port: number) => Promise<boolean>;
|
|
196
237
|
probeModuleHealth: (port: number, health: string) => Promise<boolean>;
|
|
@@ -200,6 +241,8 @@ interface ResolvedDeps {
|
|
|
200
241
|
which: (binary: string) => string | null;
|
|
201
242
|
findNonExecutable: ((binary: string) => string | null) | undefined;
|
|
202
243
|
now: () => Date;
|
|
244
|
+
isInteractive: () => boolean;
|
|
245
|
+
readLine: (prompt: string) => Promise<string>;
|
|
203
246
|
}
|
|
204
247
|
|
|
205
248
|
function resolveDeps(d: DoctorDeps | undefined): ResolvedDeps {
|
|
@@ -212,6 +255,8 @@ function resolveDeps(d: DoctorDeps | undefined): ResolvedDeps {
|
|
|
212
255
|
which: d?.which ?? Bun.which,
|
|
213
256
|
findNonExecutable: d?.findNonExecutable,
|
|
214
257
|
now: d?.now ?? (() => new Date()),
|
|
258
|
+
isInteractive: d?.isInteractive ?? defaultIsInteractive,
|
|
259
|
+
readLine: d?.readLine ?? defaultReadLine,
|
|
215
260
|
};
|
|
216
261
|
}
|
|
217
262
|
|
|
@@ -409,6 +454,178 @@ function checkServicesManifest(manifestPath: string): CheckResult {
|
|
|
409
454
|
}
|
|
410
455
|
}
|
|
411
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
|
+
|
|
412
629
|
/**
|
|
413
630
|
* operator.token exists, parses, and its `iss` matches a hub-legitimate issuer.
|
|
414
631
|
*
|
|
@@ -806,6 +1023,7 @@ async function runChecks(
|
|
|
806
1023
|
checkExposure(configDir, deps),
|
|
807
1024
|
]);
|
|
808
1025
|
const manifestCheck = checkServicesManifest(manifestPath);
|
|
1026
|
+
const portDrift = checkPortDrift(manifestPath);
|
|
809
1027
|
const operator = checkOperatorToken(configDir);
|
|
810
1028
|
const migration = checkMigration(configDir, manifestPath, deps);
|
|
811
1029
|
const versionDrift = checkVersionDrift(manifest);
|
|
@@ -816,7 +1034,7 @@ async function runChecks(
|
|
|
816
1034
|
};
|
|
817
1035
|
add("Hub", [hub]);
|
|
818
1036
|
add("Modules", [...modules, ...bins]);
|
|
819
|
-
add("Configuration", [manifestCheck, operator]);
|
|
1037
|
+
add("Configuration", [manifestCheck, portDrift, operator]);
|
|
820
1038
|
add("Migration", migration);
|
|
821
1039
|
add("Exposure", [exposure, versionDrift]);
|
|
822
1040
|
return grouped;
|
|
@@ -871,11 +1089,145 @@ function renderJson(checks: GroupedCheck[], print: (line: string) => void): void
|
|
|
871
1089
|
print(JSON.stringify(payload, null, 2));
|
|
872
1090
|
}
|
|
873
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
|
+
|
|
874
1222
|
/**
|
|
875
1223
|
* `parachute doctor`. Returns the process exit code: 0 when no check FAILs
|
|
876
1224
|
* (WARN is allowed), non-zero on any FAIL. Never throws — every check is
|
|
877
1225
|
* individually wrapped + degrades gracefully, so doctor is itself a reliable
|
|
878
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.
|
|
879
1231
|
*/
|
|
880
1232
|
export async function doctor(opts: DoctorOpts = {}): Promise<number> {
|
|
881
1233
|
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
@@ -883,6 +1235,10 @@ export async function doctor(opts: DoctorOpts = {}): Promise<number> {
|
|
|
883
1235
|
const print = opts.print ?? ((line) => console.log(line));
|
|
884
1236
|
const deps = resolveDeps(opts.deps);
|
|
885
1237
|
|
|
1238
|
+
if (opts.fix) {
|
|
1239
|
+
return await fixPortDrift(manifestPath, { yes: opts.yes ?? false, print, deps });
|
|
1240
|
+
}
|
|
1241
|
+
|
|
886
1242
|
const checks = await runChecks(configDir, manifestPath, deps);
|
|
887
1243
|
if (opts.json) {
|
|
888
1244
|
renderJson(checks, print);
|
package/src/help.ts
CHANGED
|
@@ -373,6 +373,7 @@ export function doctorHelp(): string {
|
|
|
373
373
|
|
|
374
374
|
Usage:
|
|
375
375
|
parachute doctor [--json]
|
|
376
|
+
parachute doctor --fix [--yes]
|
|
376
377
|
|
|
377
378
|
What it does:
|
|
378
379
|
Runs a set of independent health checks and prints a grouped report
|
|
@@ -386,6 +387,10 @@ What it does:
|
|
|
386
387
|
- Each CONFIGURED module alive via its loopback /health (2xx or 401 = live).
|
|
387
388
|
- services.json parses + required fields valid (a missing file is the
|
|
388
389
|
fresh pre-install state, not a failure).
|
|
390
|
+
- Services on canonical ports — flags any KNOWN module whose port has
|
|
391
|
+
drifted off its canonical slot, or two services sharing one port
|
|
392
|
+
(legacy services.json written before the validation gate). A
|
|
393
|
+
third-party service with no canonical port is never flagged.
|
|
389
394
|
- operator.token exists, parses, and its issuer matches the hub (the
|
|
390
395
|
recurring "not signed in to the hub" / issuer-mismatch class).
|
|
391
396
|
- Each first-party module bin is executable (catches the lost-+x-bit
|
|
@@ -398,10 +403,20 @@ What it does:
|
|
|
398
403
|
|
|
399
404
|
Flags:
|
|
400
405
|
--json emit a single JSON object instead of the human report
|
|
406
|
+
--fix repair canonical-port drift in services.json — and ONLY that.
|
|
407
|
+
It is NOT a "fix everything" flag; every other check stays
|
|
408
|
+
report-only. Shows the old→new diff first, then confirms before
|
|
409
|
+
writing (a TTY prompts; --yes skips the prompt; a non-TTY without
|
|
410
|
+
--yes bails without writing). Idempotent: a clean file is a no-op.
|
|
411
|
+
Duplicate-port collisions are reported, not auto-resolved.
|
|
412
|
+
--yes skip the --fix confirmation prompt (required to apply in a
|
|
413
|
+
non-interactive shell)
|
|
401
414
|
|
|
402
415
|
Exit codes:
|
|
403
|
-
0 no failures (warnings are advisory and still exit 0)
|
|
404
|
-
|
|
416
|
+
0 no failures (warnings are advisory and still exit 0); --fix: applied or
|
|
417
|
+
nothing-to-fix
|
|
418
|
+
1 one or more checks failed; or --fix bailed (non-TTY without --yes /
|
|
419
|
+
aborted at the prompt / unreadable services.json)
|
|
405
420
|
`;
|
|
406
421
|
}
|
|
407
422
|
|