@openparachute/hub 0.6.3 → 0.6.4-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +880 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +217 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +381 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +121 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +345 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/cli.ts CHANGED
@@ -9,19 +9,16 @@
9
9
  import { MissingDependencyError } from "@openparachute/depcheck";
10
10
  import pkg from "../package.json" with { type: "json" };
11
11
  import { CloudflaredStateError } from "./cloudflare/state.ts";
12
- import { auth } from "./commands/auth.ts";
13
- import { exposePublic, exposeTailnet } from "./commands/expose.ts";
14
- import { init } from "./commands/init.ts";
15
- import { install } from "./commands/install.ts";
16
- import { logs, restart, start, stop } from "./commands/lifecycle.ts";
17
- import { cutoverToSupervised, teardownHubUnit } from "./commands/migrate-cutover.ts";
18
- import { migrate } from "./commands/migrate.ts";
19
- import { serve } from "./commands/serve.ts";
20
- import { setup } from "./commands/setup.ts";
21
- import { status } from "./commands/status.ts";
22
- import { upgrade } from "./commands/upgrade.ts";
23
- import { dispatchVault } from "./commands/vault.ts";
24
- import { runSetupWizardCommand } from "./commands/wizard.ts";
12
+ // Command-implementation modules are loaded LAZILY inside their switch arms (see
13
+ // `loadCommand` + each `case`), so a module that throws at eval-time is isolated
14
+ // to its own command instead of aborting the whole CLI at top-level import. The
15
+ // `import type`s below are erased at compile time (they trigger no module
16
+ // evaluation) and exist only so the arms can reference each command's options
17
+ // type for `Parameters<typeof …>`.
18
+ import type { init } from "./commands/init.ts";
19
+ import type { install } from "./commands/install.ts";
20
+ import type { setup } from "./commands/setup.ts";
21
+ import type { upgrade } from "./commands/upgrade.ts";
25
22
  import { ExposeStateError } from "./expose-state.ts";
26
23
  import {
27
24
  exposeHelp,
@@ -273,6 +270,34 @@ function extractExposeProviderFlags(args: string[]): {
273
270
  return out;
274
271
  }
275
272
 
273
+ /**
274
+ * Lazy-load a command-implementation module, isolating an eval-time throw to the
275
+ * command that asked for it.
276
+ *
277
+ * `cli.ts` used to eagerly `import` every command module at top-level. That made
278
+ * a single broken module (e.g. a half-built `migrate-cutover.ts` with a
279
+ * ReferenceError at eval) abort the *entire* CLI load — even `parachute --help`
280
+ * — because top-level import evaluation runs before `run()`'s try/catch is ever
281
+ * reached. Loading each module lazily inside its switch arm (the same pattern
282
+ * the expose subcommands already use, e.g. `await import("./commands/expose-
283
+ * cloudflare.ts")`) means an import rejection touches only its own command.
284
+ *
285
+ * On rejection we print `parachute <cmd>: failed to load (<err>)` and return
286
+ * `undefined`; the arm turns that into exit code 1. This keeps a broken module
287
+ * from surfacing as an unhandled promise rejection (which the top-level
288
+ * `run()` boundary doesn't shape — it wraps execution, not import).
289
+ */
290
+ async function loadCommand<T>(cmd: string, importer: () => Promise<T>): Promise<T | undefined> {
291
+ try {
292
+ return await importer();
293
+ } catch (err) {
294
+ console.error(
295
+ `parachute ${cmd}: failed to load (${err instanceof Error ? err.message : String(err)})`,
296
+ );
297
+ return undefined;
298
+ }
299
+ }
300
+
276
301
  async function main(argv: string[]): Promise<number> {
277
302
  const [command, ...rest] = argv;
278
303
 
@@ -309,7 +334,9 @@ async function main(argv: string[]): Promise<number> {
309
334
  const setupOpts: Parameters<typeof setup>[0] = {};
310
335
  if (tagExtract.tag) setupOpts.tag = tagExtract.tag;
311
336
  if (noStart) setupOpts.noStart = true;
312
- return await setup(setupOpts);
337
+ const mod = await loadCommand("setup", () => import("./commands/setup.ts"));
338
+ if (!mod) return 1;
339
+ return await mod.setup(setupOpts);
313
340
  }
314
341
 
315
342
  case "setup-wizard": {
@@ -323,7 +350,9 @@ async function main(argv: string[]): Promise<number> {
323
350
  console.log(setupWizardHelp());
324
351
  return 0;
325
352
  }
326
- return await runSetupWizardCommand(rest);
353
+ const mod = await loadCommand("setup-wizard", () => import("./commands/wizard.ts"));
354
+ if (!mod) return 1;
355
+ return await mod.runSetupWizardCommand(rest);
327
356
  }
328
357
 
329
358
  case "init": {
@@ -379,7 +408,9 @@ async function main(argv: string[]): Promise<number> {
379
408
  }
380
409
  if (cliWizard) initOpts.wizardChoice = "cli";
381
410
  else if (browserWizard) initOpts.wizardChoice = "browser";
382
- return await init(initOpts);
411
+ const mod = await loadCommand("init", () => import("./commands/init.ts"));
412
+ if (!mod) return 1;
413
+ return await mod.init(initOpts);
383
414
  }
384
415
 
385
416
  case "install": {
@@ -438,16 +469,18 @@ async function main(argv: string[]): Promise<number> {
438
469
  if (noStart) installOpts.noStart = true;
439
470
  if (providerExtract.value) installOpts.scribeProvider = providerExtract.value;
440
471
  if (keyExtract.value) installOpts.scribeKey = keyExtract.value;
472
+ const mod = await loadCommand("install", () => import("./commands/install.ts"));
473
+ if (!mod) return 1;
441
474
  if (service === "all") {
442
475
  // Bootstrap the whole ecosystem to one dist-tag — the RC-testing payload.
443
476
  // Bail on first failure so a broken channel doesn't mask a working tag.
444
477
  for (const svc of knownServices()) {
445
- const code = await install(svc, installOpts);
478
+ const code = await mod.install(svc, installOpts);
446
479
  if (code !== 0) return code;
447
480
  }
448
481
  return 0;
449
482
  }
450
- return await install(service, installOpts);
483
+ return await mod.install(service, installOpts);
451
484
  }
452
485
 
453
486
  case "status":
@@ -459,7 +492,11 @@ async function main(argv: string[]): Promise<number> {
459
492
  // dual-dispatch: on a box with a hub unit installed it reads the platform
460
493
  // manager + the running supervisor; on a legacy detached box it falls back
461
494
  // to the pidfile readout (design §6.4). Tests drive the seams directly.
462
- return await status({ supervisor: {} });
495
+ {
496
+ const mod = await loadCommand("status", () => import("./commands/status.ts"));
497
+ if (!mod) return 1;
498
+ return await mod.status({ supervisor: {} });
499
+ }
463
500
 
464
501
  case "expose": {
465
502
  const hubExtract = extractHubOrigin(rest);
@@ -585,6 +622,14 @@ async function main(argv: string[]): Promise<number> {
585
622
  ...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
586
623
  };
587
624
 
625
+ // Lazy-load the Tailscale-Funnel entry points the same way the Cloudflare /
626
+ // interactive / auto-pick paths above load theirs. Reaching here means we're
627
+ // past the early Cloudflare returns, so `exposePublic` / `exposeTailnet` are
628
+ // about to be needed by one of the branches below.
629
+ const exposeMod = await loadCommand("expose", () => import("./commands/expose.ts"));
630
+ if (!exposeMod) return 1;
631
+ const { exposePublic, exposeTailnet } = exposeMod;
632
+
588
633
  // `--tailnet` is the explicit Tailscale Funnel pin — bypass both the
589
634
  // interactive picker and the non-TTY auto-pick. Goes straight to
590
635
  // exposePublic so today's Funnel flow keeps working unchanged.
@@ -677,7 +722,9 @@ async function main(argv: string[]): Promise<number> {
677
722
  migrateOffer: { enabled: true },
678
723
  ...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
679
724
  };
680
- return await start(hubExtract.rest[0], startOpts);
725
+ const mod = await loadCommand("start", () => import("./commands/lifecycle.ts"));
726
+ if (!mod) return 1;
727
+ return await mod.start(hubExtract.rest[0], startOpts);
681
728
  }
682
729
 
683
730
  case "stop": {
@@ -685,7 +732,9 @@ async function main(argv: string[]): Promise<number> {
685
732
  console.log(stopHelp());
686
733
  return 0;
687
734
  }
688
- return await stop(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
735
+ const mod = await loadCommand("stop", () => import("./commands/lifecycle.ts"));
736
+ if (!mod) return 1;
737
+ return await mod.stop(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
689
738
  }
690
739
 
691
740
  case "restart": {
@@ -693,7 +742,9 @@ async function main(argv: string[]): Promise<number> {
693
742
  console.log(restartHelp());
694
743
  return 0;
695
744
  }
696
- return await restart(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
745
+ const mod = await loadCommand("restart", () => import("./commands/lifecycle.ts"));
746
+ if (!mod) return 1;
747
+ return await mod.restart(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
697
748
  }
698
749
 
699
750
  case "upgrade": {
@@ -744,7 +795,9 @@ async function main(argv: string[]): Promise<number> {
744
795
  upgradeOpts.channel = channelExtract.value;
745
796
  }
746
797
  if (allowDowngrade) upgradeOpts.allowDowngrade = true;
747
- return await upgrade(remaining[0], upgradeOpts);
798
+ const mod = await loadCommand("upgrade", () => import("./commands/upgrade.ts"));
799
+ if (!mod) return 1;
800
+ return await mod.upgrade(remaining[0], upgradeOpts);
748
801
  }
749
802
 
750
803
  case "logs": {
@@ -759,7 +812,9 @@ async function main(argv: string[]): Promise<number> {
759
812
  return 1;
760
813
  }
761
814
  const follow = rest.includes("-f") || rest.includes("--follow");
762
- return await logs(svc, { follow });
815
+ const mod = await loadCommand("logs", () => import("./commands/lifecycle.ts"));
816
+ if (!mod) return 1;
817
+ return await mod.logs(svc, { follow });
763
818
  }
764
819
 
765
820
  case "migrate": {
@@ -775,7 +830,31 @@ async function main(argv: string[]): Promise<number> {
775
830
  console.error("usage: parachute migrate --teardown");
776
831
  return 1;
777
832
  }
778
- teardownHubUnit();
833
+ const mod = await loadCommand("migrate", () => import("./commands/migrate-cutover.ts"));
834
+ if (!mod) return 1;
835
+ // teardownHubUnit logs the human-facing lines itself (the success
836
+ // guidance, or "nothing to tear down"). hub#534: the CLI must still
837
+ // own the EXIT CODE + surface any failure detail the function's
838
+ // false-branch doesn't print — pre-fix it ignored `removed` + `messages`
839
+ // and always exited 0, so a non-removal looked like success to a script.
840
+ const result = mod.teardownHubUnit();
841
+ if (result.removed) return 0;
842
+ // removed === false: either a clean no-op (nothing was installed —
843
+ // `messages` empty) or a real failure (the removal carried a reason in
844
+ // `messages` the internal log didn't surface). The no-op is informational
845
+ // (exit 0); a failure with detail is an error (print it, exit 1).
846
+ //
847
+ // DELIBERATE double-print on the failure path (not a bug): the function's
848
+ // own log() already wrote a human-readable summary ("Hub-unit teardown did
849
+ // not complete: …") to STDOUT; here we re-emit the raw reason(s) to STDERR.
850
+ // The split is intentional — a person reading the terminal sees the framed
851
+ // summary, while a script that captures `2>` gets the machine-parseable
852
+ // reason alongside the non-zero exit. Mirrors the streams convention the
853
+ // rest of the CLI uses (human guidance on stdout, error detail on stderr).
854
+ if (result.messages.length > 0) {
855
+ for (const line of result.messages) console.error(line);
856
+ return 1;
857
+ }
779
858
  return 0;
780
859
  }
781
860
  // §7.1 detached→supervised cutover. Opt-in surface (the archive sweep
@@ -788,7 +867,9 @@ async function main(argv: string[]): Promise<number> {
788
867
  console.error("usage: parachute migrate --to-supervised");
789
868
  return 1;
790
869
  }
791
- const result = await cutoverToSupervised();
870
+ const mod = await loadCommand("migrate", () => import("./commands/migrate-cutover.ts"));
871
+ if (!mod) return 1;
872
+ const result = await mod.cutoverToSupervised();
792
873
  for (const line of result.messages) console.log(line);
793
874
  // "already-migrated" / "migrated" are success; every other outcome is a
794
875
  // recoverable failure that should exit non-zero so scripts can retry.
@@ -807,7 +888,9 @@ async function main(argv: string[]): Promise<number> {
807
888
  );
808
889
  return 1;
809
890
  }
810
- return await migrate({ dryRun, list, yes });
891
+ const mod = await loadCommand("migrate", () => import("./commands/migrate.ts"));
892
+ if (!mod) return 1;
893
+ return await mod.migrate({ dryRun, list, yes });
811
894
  }
812
895
 
813
896
  case "serve": {
@@ -824,7 +907,9 @@ async function main(argv: string[]): Promise<number> {
824
907
  // event loop alive until SIGINT/SIGTERM, at which point we stop the
825
908
  // server cleanly and exit. Container supervisor (tini, Render, Docker)
826
909
  // reaps us once the event loop drains.
827
- const { stop: stopServer } = await serve();
910
+ const mod = await loadCommand("serve", () => import("./commands/serve.ts"));
911
+ if (!mod) return 1;
912
+ const { stop: stopServer } = await mod.serve();
828
913
  await new Promise<void>((resolve) => {
829
914
  const handler = async () => {
830
915
  await stopServer();
@@ -836,14 +921,19 @@ async function main(argv: string[]): Promise<number> {
836
921
  return 0;
837
922
  }
838
923
 
839
- case "auth":
840
- return await auth(rest);
924
+ case "auth": {
925
+ const mod = await loadCommand("auth", () => import("./commands/auth.ts"));
926
+ if (!mod) return 1;
927
+ return await mod.auth(rest);
928
+ }
841
929
 
842
930
  case "vault": {
931
+ const mod = await loadCommand("vault", () => import("./commands/vault.ts"));
932
+ if (!mod) return 1;
843
933
  // `parachute vault` with no args forwards --help to parachute-vault so
844
934
  // users see the actual vault surface, not a CLI-side stub. Anything
845
935
  // after `vault` (including --help) is passed through verbatim.
846
- if (rest.length === 0) return await dispatchVault(["--help"]);
936
+ if (rest.length === 0) return await mod.dispatchVault(["--help"]);
847
937
 
848
938
  // Everything under `vault` forwards transparently to `parachute-vault`.
849
939
  // `vault tokens create` used to route through a guided interactive
@@ -852,7 +942,7 @@ async function main(argv: string[]): Promise<number> {
852
942
  // hub-issued JWTs; mint them with `parachute auth mint-token` or the
853
943
  // admin SPA Connect card. We forward verbatim so the operator sees
854
944
  // vault's own migration error rather than a hub-side stub.
855
- return await dispatchVault(rest);
945
+ return await mod.dispatchVault(rest);
856
946
  }
857
947
 
858
948
  default:
@@ -6,12 +6,15 @@
6
6
  * is the second wall.
7
7
  *
8
8
  * 2FA at the hub login layer is real as of hub#473: "password +
9
- * something-you-have." This warning recommends `parachute auth 2fa enroll`
10
- * (which now gates hub `/login` for real) when the operator hasn't enrolled.
9
+ * something-you-have." This prints a STRONG RECOMMENDATION to enroll — via the
10
+ * browser at `/account/2fa` or `parachute auth 2fa enroll` (which now gates hub
11
+ * `/login` for real) — when the operator hasn't enrolled.
11
12
  *
12
- * Why this is a warning, not a hard gate: hard-gating would surprise operators
13
- * mid-flow they ran `parachute expose public` to expose, not to be told
14
- * "set up 2FA first." A loud, contextual warning + a clear remediation is the
13
+ * Why this is a recommendation, not a hard gate (Aaron's explicit call, 2026-06:
14
+ * "I don't think we need to require 2FA for public expose but we should strongly
15
+ * recommend it"): hard-gating would surprise operators mid-flow they ran
16
+ * `parachute expose public` to expose, not to be told "set up 2FA first." A
17
+ * clear, friendly, contextual recommendation + an obvious remediation is the
15
18
  * right shape; the operator decides whether to act now or later. The tunnel is
16
19
  * up regardless.
17
20
  *
@@ -68,16 +71,17 @@ export function printPublic2FAWarning(opts: Public2FAWarningOpts): boolean {
68
71
  return false;
69
72
  }
70
73
  log("");
71
- log(" /login is now reachable on the public internet");
72
- log(` (${opts.publicUrl}/login). Anyone who guesses your password is in.`);
74
+ log(" Strongly recommended: turn on two-factor authentication.");
75
+ log(" Your login page is now reachable from the public internet");
76
+ log(` (${opts.publicUrl}/login) — anyone online can reach it, so your`);
77
+ log(" password is the only wall. A second factor (a one-time code from");
78
+ log(" your authenticator app) materially raises the bar:");
73
79
  log("");
74
- log(" Turn on two-factor authentication it adds a second wall (a one-time");
75
- log(" code from your authenticator app) on top of your password:");
80
+ log(` ${opts.publicUrl}/account/2fa # scan a QR code in your browser`);
81
+ log(" parachute auth 2fa enroll # or enroll from the terminal");
76
82
  log("");
77
- log(" parachute auth 2fa enroll");
78
- log("");
79
- log(" (Or set it up in the browser at /account/2fa for a scannable QR code.)");
80
- log(" Either way, also make sure your owner password is a strong one:");
83
+ log(" It's a recommendation, not a requirement — your hub is up either way.");
84
+ log(" While you're at it, make sure your owner password is a strong one:");
81
85
  log("");
82
86
  log(" parachute auth set-password");
83
87
  return true;
@@ -86,6 +86,7 @@ import { type PortListeningFn, defaultPortListening } from "../port-probe.ts";
86
86
  import { type AliveFn, clearPid, readPid } from "../process-state.ts";
87
87
  import { shortNameForManifest } from "../service-spec.ts";
88
88
  import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
89
+ import { enrichedUnitPath } from "../spawn-path.ts";
89
90
  import {
90
91
  type DisableStaleModuleUnitsOpts,
91
92
  type DisableStaleModuleUnitsResult,
@@ -232,14 +233,14 @@ export interface WriteUnitResult {
232
233
  * and `installManagedUnit(start:false)` — daemon-reload / write-the-plist but
233
234
  * NEVER enable --now / bootstrap. The §7.1 step-2 race-avoider.
234
235
  */
235
- function defaultUnitPath(bunInstall: string): string {
236
- return `${bunInstall}/bin:/usr/local/bin:/usr/bin:/bin`;
237
- }
238
-
239
236
  export function defaultWriteUnitWithoutStarting(opts: WriteUnitOpts): WriteUnitResult {
240
237
  const { deps } = opts;
241
238
  const bunInstall = `${deps.homeDir()}/.bun`;
242
- const path = defaultUnitPath(bunInstall);
239
+ // Shared with the init-bringup path (hub-unit.ts) so the two unit-generation
240
+ // sites can't drift — enriches the unit PATH with operator-tool dirs
241
+ // (`$HOME/.local/bin`, brew bin) so a migrated launchd/systemd hub can find
242
+ // scribe's `parakeet-mlx` + `ffmpeg`. See `spawn-path.ts`.
243
+ const path = enrichedUnitPath(bunInstall, deps.homeDir(), deps.platform);
243
244
  const logPath = `${opts.parachuteHome}/hub/logs/hub.log`;
244
245
  let unit: ManagedUnit;
245
246
  try {
@@ -878,6 +879,12 @@ export function teardownHubUnit(opts: TeardownOpts = {}): { removed: boolean; me
878
879
  log("The supervised hub unit is gone. To run the hub now, either:");
879
880
  log(" - `parachute serve` (foreground), or");
880
881
  log(" - `parachute migrate --to-supervised` to reinstall the unit.");
882
+ } else if (res.messages.length > 0) {
883
+ // removed === false WITH detail: a real removal failure, not a clean
884
+ // no-op. Surface the reason rather than the misleading "nothing was
885
+ // installed" line (hub#534 — the CLI also maps this to a non-zero exit).
886
+ log("Hub-unit teardown did not complete:");
887
+ for (const m of res.messages) log(` ${m}`);
881
888
  } else {
882
889
  log("No hub unit was installed — nothing to tear down.");
883
890
  }
@@ -27,6 +27,7 @@ import {
27
27
  shortNameForManifest,
28
28
  } from "../service-spec.ts";
29
29
  import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
30
+ import { enrichedPath } from "../spawn-path.ts";
30
31
  import type { Supervisor } from "../supervisor.ts";
31
32
 
32
33
  export interface BootOpts {
@@ -75,9 +76,14 @@ export interface BuildSpawnRequestOpts {
75
76
  * Env layering (later wins):
76
77
  * 1. `PORT` from the services.json `entry.port` — overrides hub's own PORT
77
78
  * so supervised children honor their canonical port assignment
78
- * (hub#356/#357).
79
+ * (hub#356/#357). This is authoritative and is NOT overridable by a
80
+ * `.env` `PORT` (see below) — services.json is the single source of truth
81
+ * for the port (scribe#41 4-tier ladder; hub#206).
79
82
  * 2. per-service `.env` at `<configDir>/<short>/.env` — operator-configured
80
- * values (e.g. scribe provider keys) override the bare PORT.
83
+ * values (e.g. scribe provider keys) merge on top. A `PORT` key here is
84
+ * dropped: a stale pre-#206 `.env` `PORT` must not shadow `entry.port`
85
+ * (hub#537 — a leftover scribe `PORT=1944` ≠ services.json `1943` leaked
86
+ * into the injected PORT and broke the supervisor's readiness probe).
81
87
  * 3. `PARACHUTE_HUB_ORIGIN` = `opts.hubOrigin` — anchors the child's `iss`
82
88
  * expectation to the value hub mints with (hub#365).
83
89
  * 4. `opts.extraEnv` — test seam / first-boot pass-through; wins last.
@@ -93,7 +99,31 @@ export function buildModuleSpawnRequest(
93
99
  opts: BuildSpawnRequestOpts,
94
100
  ): SpawnReqShape {
95
101
  const fileEnv = readEnvFileValues(join(opts.configDir, short, ".env"));
96
- const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
102
+ // Drop a `PORT` from the per-service .env: services.json `entry.port` is the
103
+ // canonical port and must win (scribe#41 ladder; hub#206). A stale pre-#206
104
+ // `.env` PORT (e.g. scribe's `1944` vs services.json `1943`) would otherwise
105
+ // leak into the injected PORT and the supervisor's readiness probe would
106
+ // check the wrong port → false `started_but_unbound` (hub#537). The module's
107
+ // own resolvePort ladder already prefers services.json, so this keeps the
108
+ // injected PORT + probe in agreement with what the child actually binds.
109
+ const { PORT: _staleEnvPort, ...fileEnvSansPort } = fileEnv;
110
+ // PATH enrichment (hub launchd-PATH regression): the hub unit bakes a minimal
111
+ // PATH and `Bun.spawn` defaults to empty env, so without this the child only
112
+ // ever sees the unit's PATH — which omits `$HOME/.local/bin` (scribe's
113
+ // `parakeet-mlx`) + the Homebrew bin (`ffmpeg`), killing transcription on
114
+ // canonical installs. `enrichedPath` appends those dirs (when they exist) to
115
+ // the inherited PATH; inherited entries keep their order. A per-service `.env`
116
+ // PATH (operator intent) still wins via the spread below. See `spawn-path.ts`.
117
+ // The API-start path builds its own env — see `api-modules-ops.ts`
118
+ // `spawnSupervised`, which calls `enrichedPath()` too (keep the two in sync).
119
+ // `process.env.PATH` may ALREADY be enriched by serve startup (serve.ts);
120
+ // re-enriching here is a harmless no-op — `enrichedPath` is idempotent
121
+ // (dedupe + append-only), so double-enrichment can't duplicate or reorder.
122
+ const env: Record<string, string> = {
123
+ PATH: enrichedPath(),
124
+ PORT: String(entry.port),
125
+ ...fileEnvSansPort,
126
+ };
97
127
  if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
98
128
  if (opts.extraEnv) Object.assign(env, opts.extraEnv);
99
129