@openparachute/hub 0.6.2 → 0.6.3-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 (58) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/api-modules-ops.test.ts +359 -3
  5. package/src/__tests__/api-modules.test.ts +54 -0
  6. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  7. package/src/__tests__/expose-off-auto.test.ts +26 -1
  8. package/src/__tests__/expose.test.ts +260 -240
  9. package/src/__tests__/hub-control.test.ts +1 -242
  10. package/src/__tests__/hub-server.test.ts +64 -0
  11. package/src/__tests__/hub-unit.test.ts +574 -0
  12. package/src/__tests__/init.test.ts +219 -2
  13. package/src/__tests__/lifecycle.test.ts +416 -1448
  14. package/src/__tests__/managed-unit.test.ts +575 -0
  15. package/src/__tests__/migrate-cutover.test.ts +840 -0
  16. package/src/__tests__/migrate-offer.test.ts +240 -0
  17. package/src/__tests__/migrate.test.ts +132 -0
  18. package/src/__tests__/module-ops-client.test.ts +556 -0
  19. package/src/__tests__/port-probe.test.ts +23 -0
  20. package/src/__tests__/setup-wizard.test.ts +130 -0
  21. package/src/__tests__/status-supervisor.test.ts +504 -0
  22. package/src/__tests__/status.test.ts +157 -708
  23. package/src/__tests__/supervisor.test.ts +471 -6
  24. package/src/__tests__/upgrade.test.ts +351 -5
  25. package/src/api-hub-upgrade.ts +384 -0
  26. package/src/api-hub.ts +2 -1
  27. package/src/api-modules-ops.ts +221 -0
  28. package/src/api-modules.ts +18 -2
  29. package/src/cli.ts +97 -12
  30. package/src/cloudflare/connector-service.ts +117 -322
  31. package/src/commands/expose-cloudflare.ts +63 -71
  32. package/src/commands/expose-supervisor.ts +247 -0
  33. package/src/commands/expose.ts +59 -48
  34. package/src/commands/init.ts +225 -12
  35. package/src/commands/lifecycle.ts +455 -816
  36. package/src/commands/migrate-cutover.ts +837 -0
  37. package/src/commands/migrate.ts +71 -2
  38. package/src/commands/serve-boot.ts +71 -25
  39. package/src/commands/status.ts +535 -235
  40. package/src/commands/upgrade.ts +100 -2
  41. package/src/help.ts +128 -68
  42. package/src/hub-control.ts +23 -162
  43. package/src/hub-server.ts +39 -0
  44. package/src/hub-unit.ts +735 -0
  45. package/src/hub-upgrade-helper.ts +306 -0
  46. package/src/hub-upgrade-mode.ts +209 -0
  47. package/src/hub-upgrade-status.ts +150 -0
  48. package/src/managed-unit.ts +692 -0
  49. package/src/migrate-offer.ts +186 -0
  50. package/src/module-ops-client.ts +457 -0
  51. package/src/port-probe.ts +50 -0
  52. package/src/process-state.ts +19 -3
  53. package/src/setup-wizard.ts +80 -1
  54. package/src/supervisor.ts +389 -38
  55. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  56. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -42,8 +42,13 @@ import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
42
42
  // still required) and the latter for vault/scribe/runner (post-FALLBACK
43
43
  // retirement, hub#310). The local helper hides the split from the rest of
44
44
  // this file.
45
- import { type UiSubUnit, type UiSubUnitStatus, readManifest, readManifestLenient } from "./services-manifest.ts";
46
- import type { ModuleState, Supervisor } from "./supervisor.ts";
45
+ import {
46
+ type UiSubUnit,
47
+ type UiSubUnitStatus,
48
+ readManifest,
49
+ readManifestLenient,
50
+ } from "./services-manifest.ts";
51
+ import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts";
47
52
 
48
53
  /**
49
54
  * Resolve a curated module to the display + install bootstrap data the
@@ -174,6 +179,16 @@ interface ModuleWireShape {
174
179
  latest_version: string | null;
175
180
  supervisor_status: ModuleState["status"] | null;
176
181
  pid: number | null;
182
+ /**
183
+ * Structured supervisor start-failure detail (§6.5 / §6.4), when the
184
+ * supervisor recorded one for this module — a preflight `MissingDependencyError`
185
+ * or the alive-but-never-bound shape (hub#487). Mirrors the services.json
186
+ * `lastStartError` the detached path persists, so `parachute status` and the
187
+ * SPA keep the SAME friendly missing-dependency surface (#188) whether a
188
+ * module was started via the supervisor or the detached path. Null when the
189
+ * module started cleanly (or the hub is in pidfile/CLI mode with no supervisor).
190
+ */
191
+ supervisor_start_error: ModuleStartError | null;
177
192
  /**
178
193
  * The path on disk where the module is installed, if known. Surfaces
179
194
  * the BUN_INSTALL or bun-link install location for operator debug —
@@ -477,6 +492,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
477
492
  latest_version: latestByShort.get(short) ?? null,
478
493
  supervisor_status: state?.status ?? null,
479
494
  pid: state?.pid ?? null,
495
+ supervisor_start_error: state?.startError ?? null,
480
496
  install_dir: installed?.installDir ?? null,
481
497
  uis: toUisWireShape(installed?.uis),
482
498
  management_url: managementUrlByShort.get(short) ?? null,
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ import { exposePublic, exposeTailnet } from "./commands/expose.ts";
14
14
  import { init } from "./commands/init.ts";
15
15
  import { install } from "./commands/install.ts";
16
16
  import { logs, restart, start, stop } from "./commands/lifecycle.ts";
17
+ import { cutoverToSupervised, teardownHubUnit } from "./commands/migrate-cutover.ts";
17
18
  import { migrate } from "./commands/migrate.ts";
18
19
  import { serve } from "./commands/serve.ts";
19
20
  import { setup } from "./commands/setup.ts";
@@ -454,7 +455,11 @@ async function main(argv: string[]): Promise<number> {
454
455
  console.log(statusHelp());
455
456
  return 0;
456
457
  }
457
- return await status();
458
+ // Pass an empty `supervisor` block so `status` takes the Phase 3c
459
+ // dual-dispatch: on a box with a hub unit installed it reads the platform
460
+ // manager + the running supervisor; on a legacy detached box it falls back
461
+ // to the pidfile readout (design §6.4). Tests drive the seams directly.
462
+ return await status({ supervisor: {} });
458
463
 
459
464
  case "expose": {
460
465
  const hubExtract = extractHubOrigin(rest);
@@ -528,7 +533,14 @@ async function main(argv: string[]): Promise<number> {
528
533
  const { exposeCloudflareUp, exposeCloudflareOff } = await import(
529
534
  "./commands/expose-cloudflare.ts"
530
535
  );
531
- const cfOpts = flagExtract.tunnelName ? { tunnelName: flagExtract.tunnelName } : {};
536
+ // `supervisor: {}` opts into the Phase 4 dual-dispatch (design §4.3): on
537
+ // a box with a hub unit installed, ensure the unit + drive the running
538
+ // supervisor for the post-expose vault restart, and leave the hub running
539
+ // on `off`; on a legacy detached box (no unit), the unchanged path.
540
+ const cfOpts = {
541
+ supervisor: {},
542
+ ...(flagExtract.tunnelName ? { tunnelName: flagExtract.tunnelName } : {}),
543
+ };
532
544
  if (action === "off") {
533
545
  return await exposeCloudflareOff(cfOpts);
534
546
  }
@@ -540,7 +552,14 @@ async function main(argv: string[]): Promise<number> {
540
552
  // doesn't block on an invisible prompt.
541
553
  if (isTtyInteractive()) {
542
554
  const { exposePublicInteractive } = await import("./commands/expose-interactive.ts");
543
- return await exposePublicInteractive({ preselect: "cloudflare" });
555
+ // Thread `supervisor: {}` into BOTH provider opts so the interactive
556
+ // path takes the Phase 4 unit-arm on a unit-managed box regardless of
557
+ // which provider the operator picks (design §4.3).
558
+ return await exposePublicInteractive({
559
+ preselect: "cloudflare",
560
+ exposeOpts: { supervisor: {} },
561
+ cloudflareOpts: { supervisor: {} },
562
+ });
544
563
  }
545
564
  console.error("parachute expose public --cloudflare: --domain <hostname> is required.");
546
565
  console.error("Example: parachute expose public --cloudflare --domain vault.example.com");
@@ -557,7 +576,14 @@ async function main(argv: string[]): Promise<number> {
557
576
  return await exposeCloudflareUp(flagExtract.domain, cfOpts);
558
577
  }
559
578
 
560
- const exposeOpts = hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {};
579
+ // `supervisor: {}` opts into the Phase 4 dual-dispatch (design §4.3): on a
580
+ // box with a hub unit installed, ensure the unit (not a detached spawn) +
581
+ // drive the running supervisor for the post-expose vault restart, and leave
582
+ // the hub running on `off`; on a legacy detached box, the unchanged path.
583
+ const exposeOpts = {
584
+ supervisor: {},
585
+ ...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
586
+ };
561
587
 
562
588
  // `--tailnet` is the explicit Tailscale Funnel pin — bypass both the
563
589
  // interactive picker and the non-TTY auto-pick. Goes straight to
@@ -572,7 +598,9 @@ async function main(argv: string[]): Promise<number> {
572
598
  // hands back to the flag-driven entry points.
573
599
  if (layer === "public" && action === "up" && isTtyInteractive()) {
574
600
  const { exposePublicInteractive } = await import("./commands/expose-interactive.ts");
575
- return await exposePublicInteractive({ exposeOpts });
601
+ // `exposeOpts` already carries `supervisor: {}`; thread it into the
602
+ // Cloudflare branch too so the unit-arm applies regardless of pick.
603
+ return await exposePublicInteractive({ exposeOpts, cloudflareOpts: { supervisor: {} } });
576
604
  }
577
605
 
578
606
  // Non-TTY auto-pick: detect which provider is configured and run it.
@@ -591,7 +619,12 @@ async function main(argv: string[]): Promise<number> {
591
619
  // guessing intent.
592
620
  if (layer === "public" && action === "up" && !flagExtract.skipProviderCheck) {
593
621
  const { exposePublicAutoPick } = await import("./commands/expose-public-auto.ts");
594
- return await exposePublicAutoPick({ tailscaleOpts: exposeOpts });
622
+ // `tailscaleOpts` carries `supervisor: {}`; thread it into the Cloudflare
623
+ // branch too so the Phase 4 unit-arm applies regardless of auto-pick.
624
+ return await exposePublicAutoPick({
625
+ tailscaleOpts: exposeOpts,
626
+ cloudflareOpts: { supervisor: {} },
627
+ });
595
628
  }
596
629
 
597
630
  // `expose public off` (no `--cloudflare`) auto-detects which provider is
@@ -600,7 +633,16 @@ async function main(argv: string[]): Promise<number> {
600
633
  // remember which provider they brought up last.
601
634
  if (layer === "public" && action === "off") {
602
635
  const { runExposePublicOffAutoDetect } = await import("./commands/expose-off-auto.ts");
603
- return await runExposePublicOffAutoDetect({ tailscaleOffOpts: exposeOpts });
636
+ // `tailscaleOffOpts` carries `supervisor: {}`; thread it into the
637
+ // Cloudflare teardown leg too so the Phase 4 supervisor resolution is
638
+ // consistent across both providers on the auto-detect path (matching
639
+ // the explicit `--cloudflare off` branch above). Harmless today
640
+ // (exposeCloudflareOff has no stopHub call) but keeps `supervisor: {}`
641
+ // threaded everywhere.
642
+ return await runExposePublicOffAutoDetect({
643
+ tailscaleOffOpts: exposeOpts,
644
+ cloudflareOffOpts: { supervisor: {} },
645
+ });
604
646
  }
605
647
 
606
648
  // `--skip-provider-check` fallthrough: pin to today's Tailscale-Funnel
@@ -624,7 +666,17 @@ async function main(argv: string[]): Promise<number> {
624
666
  console.error(`parachute start: ${hubExtract.error}`);
625
667
  return 1;
626
668
  }
627
- const startOpts = hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {};
669
+ // `supervisor: {}` opts into the Phase 3b dual-dispatch: on a box with a
670
+ // hub unit installed, drive the running supervisor; on a legacy detached
671
+ // box (no unit), fall through to the unchanged detached path (design §3.3).
672
+ // `migrateOffer: { enabled: true }` arms the §7.5 detect-and-offer on the
673
+ // detached arm (offers the supervised cutover when a prior detached
674
+ // install is found; never auto-migrates).
675
+ const startOpts = {
676
+ supervisor: {},
677
+ migrateOffer: { enabled: true },
678
+ ...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
679
+ };
628
680
  return await start(hubExtract.rest[0], startOpts);
629
681
  }
630
682
 
@@ -633,7 +685,7 @@ async function main(argv: string[]): Promise<number> {
633
685
  console.log(stopHelp());
634
686
  return 0;
635
687
  }
636
- return await stop(rest[0]);
688
+ return await stop(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
637
689
  }
638
690
 
639
691
  case "restart": {
@@ -641,7 +693,7 @@ async function main(argv: string[]): Promise<number> {
641
693
  console.log(restartHelp());
642
694
  return 0;
643
695
  }
644
- return await restart(rest[0]);
696
+ return await restart(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
645
697
  }
646
698
 
647
699
  case "upgrade": {
@@ -682,7 +734,11 @@ async function main(argv: string[]): Promise<number> {
682
734
  );
683
735
  return 1;
684
736
  }
685
- const upgradeOpts: Parameters<typeof upgrade>[1] = {};
737
+ // `supervisor: {}` opts into the Phase 4 dual-dispatch (design §5): on a
738
+ // box with a hub unit installed, `upgrade hub` rewrites the binary then
739
+ // restarts the UNIT via the platform manager (children re-boot from
740
+ // services.json); on a legacy detached box, the unchanged restart path.
741
+ const upgradeOpts: Parameters<typeof upgrade>[1] = { supervisor: {} };
686
742
  if (tagExtract.tag) upgradeOpts.tag = tagExtract.tag;
687
743
  if (channelExtract.value === "rc" || channelExtract.value === "latest") {
688
744
  upgradeOpts.channel = channelExtract.value;
@@ -711,6 +767,33 @@ async function main(argv: string[]): Promise<number> {
711
767
  console.log(migrateHelp());
712
768
  return 0;
713
769
  }
770
+ // §7.4 teardown — remove the hub unit (the cutover rollback path).
771
+ if (rest.includes("--teardown")) {
772
+ const teardownUnknown = rest.find((a) => a !== "--teardown");
773
+ if (teardownUnknown !== undefined) {
774
+ console.error(`parachute migrate: unknown argument "${teardownUnknown}"`);
775
+ console.error("usage: parachute migrate --teardown");
776
+ return 1;
777
+ }
778
+ teardownHubUnit();
779
+ return 0;
780
+ }
781
+ // §7.1 detached→supervised cutover. Opt-in surface (the archive sweep
782
+ // below stays the bare `migrate` default — the cutover is destructive-
783
+ // adjacent and must be asked for, not implicit).
784
+ if (rest.includes("--to-supervised")) {
785
+ const cutoverUnknown = rest.find((a) => a !== "--to-supervised");
786
+ if (cutoverUnknown !== undefined) {
787
+ console.error(`parachute migrate: unknown argument "${cutoverUnknown}"`);
788
+ console.error("usage: parachute migrate --to-supervised");
789
+ return 1;
790
+ }
791
+ const result = await cutoverToSupervised();
792
+ for (const line of result.messages) console.log(line);
793
+ // "already-migrated" / "migrated" are success; every other outcome is a
794
+ // recoverable failure that should exit non-zero so scripts can retry.
795
+ return result.outcome === "migrated" || result.outcome === "already-migrated" ? 0 : 1;
796
+ }
714
797
  const dryRun = rest.includes("--dry-run");
715
798
  const list = rest.includes("--list");
716
799
  const yes = rest.includes("--yes") || rest.includes("-y");
@@ -719,7 +802,9 @@ async function main(argv: string[]): Promise<number> {
719
802
  );
720
803
  if (unknown !== undefined) {
721
804
  console.error(`parachute migrate: unknown argument "${unknown}"`);
722
- console.error("usage: parachute migrate [--list] [--dry-run] [--yes]");
805
+ console.error(
806
+ "usage: parachute migrate [--list] [--dry-run] [--yes] [--to-supervised] [--teardown]",
807
+ );
723
808
  return 1;
724
809
  }
725
810
  return await migrate({ dryRun, list, yes });