@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.
@@ -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 { getSpec, getSpecFromInstallDir, shortNameForManifest } from "../service-spec.ts";
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
- 1 one or more checks failed
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