@openparachute/hub 0.6.3 → 0.6.4-rc.10

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 (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -86,6 +86,13 @@ describe("init", () => {
86
86
  const logs: string[] = [];
87
87
  const code = await init({
88
88
  configDir: h.configDir,
89
+ // #590: keep the version-check a no-op in init tests — NEVER let the
90
+ // production default fire a real /health fetch + restart the live unit.
91
+ ensureHubVersion: async () => ({
92
+ outcome: "match" as const,
93
+ installedVersion: "test",
94
+ messages: [],
95
+ }),
89
96
  manifestPath: h.manifestPath,
90
97
  log: (l) => logs.push(l),
91
98
  alive: () => false,
@@ -127,6 +134,13 @@ describe("init", () => {
127
134
  const logs: string[] = [];
128
135
  const code = await init({
129
136
  configDir: h.configDir,
137
+ // #590: keep the version-check a no-op in init tests — NEVER let the
138
+ // production default fire a real /health fetch + restart the live unit.
139
+ ensureHubVersion: async () => ({
140
+ outcome: "match" as const,
141
+ installedVersion: "test",
142
+ messages: [],
143
+ }),
130
144
  manifestPath: h.manifestPath,
131
145
  log: (l) => logs.push(l),
132
146
  // No pidfile (unit-managed) → processState reports not-running.
@@ -168,6 +182,13 @@ describe("init", () => {
168
182
  const logs: string[] = [];
169
183
  const code = await init({
170
184
  configDir: h.configDir,
185
+ // #590: keep the version-check a no-op in init tests — NEVER let the
186
+ // production default fire a real /health fetch + restart the live unit.
187
+ ensureHubVersion: async () => ({
188
+ outcome: "match" as const,
189
+ installedVersion: "test",
190
+ messages: [],
191
+ }),
171
192
  manifestPath: h.manifestPath,
172
193
  log: (l) => logs.push(l),
173
194
  alive: () => true,
@@ -213,6 +234,13 @@ describe("init", () => {
213
234
  const logs: string[] = [];
214
235
  const code = await init({
215
236
  configDir: h.configDir,
237
+ // #590: keep the version-check a no-op in init tests — NEVER let the
238
+ // production default fire a real /health fetch + restart the live unit.
239
+ ensureHubVersion: async () => ({
240
+ outcome: "match" as const,
241
+ installedVersion: "test",
242
+ messages: [],
243
+ }),
216
244
  manifestPath: h.manifestPath,
217
245
  log: (l) => logs.push(l),
218
246
  alive: () => true,
@@ -237,6 +265,13 @@ describe("init", () => {
237
265
  const opened: string[] = [];
238
266
  const code = await init({
239
267
  configDir: h.configDir,
268
+ // #590: keep the version-check a no-op in init tests — NEVER let the
269
+ // production default fire a real /health fetch + restart the live unit.
270
+ ensureHubVersion: async () => ({
271
+ outcome: "match" as const,
272
+ installedVersion: "test",
273
+ messages: [],
274
+ }),
240
275
  manifestPath: h.manifestPath,
241
276
  log: () => {},
242
277
  alive: () => false,
@@ -270,6 +305,13 @@ describe("init", () => {
270
305
  const opened: string[] = [];
271
306
  const code = await init({
272
307
  configDir: h.configDir,
308
+ // #590: keep the version-check a no-op in init tests — NEVER let the
309
+ // production default fire a real /health fetch + restart the live unit.
310
+ ensureHubVersion: async () => ({
311
+ outcome: "match" as const,
312
+ installedVersion: "test",
313
+ messages: [],
314
+ }),
273
315
  manifestPath: h.manifestPath,
274
316
  log: () => {},
275
317
  alive: () => false,
@@ -306,6 +348,13 @@ describe("init", () => {
306
348
  let prompted = false;
307
349
  const code = await init({
308
350
  configDir: h.configDir,
351
+ // #590: keep the version-check a no-op in init tests — NEVER let the
352
+ // production default fire a real /health fetch + restart the live unit.
353
+ ensureHubVersion: async () => ({
354
+ outcome: "match" as const,
355
+ installedVersion: "test",
356
+ messages: [],
357
+ }),
309
358
  manifestPath: h.manifestPath,
310
359
  log: () => {},
311
360
  alive: () => false,
@@ -339,6 +388,13 @@ describe("init", () => {
339
388
  let prompted = false;
340
389
  const code = await init({
341
390
  configDir: h.configDir,
391
+ // #590: keep the version-check a no-op in init tests — NEVER let the
392
+ // production default fire a real /health fetch + restart the live unit.
393
+ ensureHubVersion: async () => ({
394
+ outcome: "match" as const,
395
+ installedVersion: "test",
396
+ messages: [],
397
+ }),
342
398
  manifestPath: h.manifestPath,
343
399
  log: () => {},
344
400
  alive: () => false,
@@ -366,6 +422,13 @@ describe("init", () => {
366
422
  const opened: string[] = [];
367
423
  const code = await init({
368
424
  configDir: h.configDir,
425
+ // #590: keep the version-check a no-op in init tests — NEVER let the
426
+ // production default fire a real /health fetch + restart the live unit.
427
+ ensureHubVersion: async () => ({
428
+ outcome: "match" as const,
429
+ installedVersion: "test",
430
+ messages: [],
431
+ }),
369
432
  manifestPath: h.manifestPath,
370
433
  log: () => {},
371
434
  alive: () => false,
@@ -400,6 +463,13 @@ describe("init", () => {
400
463
  const logs: string[] = [];
401
464
  const code = await init({
402
465
  configDir: h.configDir,
466
+ // #590: keep the version-check a no-op in init tests — NEVER let the
467
+ // production default fire a real /health fetch + restart the live unit.
468
+ ensureHubVersion: async () => ({
469
+ outcome: "match" as const,
470
+ installedVersion: "test",
471
+ messages: [],
472
+ }),
403
473
  manifestPath: h.manifestPath,
404
474
  log: (l) => logs.push(l),
405
475
  alive: () => false,
@@ -434,6 +504,13 @@ describe("init", () => {
434
504
  const opened: string[] = [];
435
505
  const code = await init({
436
506
  configDir: h.configDir,
507
+ // #590: keep the version-check a no-op in init tests — NEVER let the
508
+ // production default fire a real /health fetch + restart the live unit.
509
+ ensureHubVersion: async () => ({
510
+ outcome: "match" as const,
511
+ installedVersion: "test",
512
+ messages: [],
513
+ }),
437
514
  manifestPath: h.manifestPath,
438
515
  log: () => {},
439
516
  alive: () => false,
@@ -464,6 +541,13 @@ describe("init", () => {
464
541
  const logs: string[] = [];
465
542
  const code = await init({
466
543
  configDir: h.configDir,
544
+ // #590: keep the version-check a no-op in init tests — NEVER let the
545
+ // production default fire a real /health fetch + restart the live unit.
546
+ ensureHubVersion: async () => ({
547
+ outcome: "match" as const,
548
+ installedVersion: "test",
549
+ messages: [],
550
+ }),
467
551
  manifestPath: h.manifestPath,
468
552
  log: (l) => logs.push(l),
469
553
  alive: () => false,
@@ -485,6 +569,340 @@ describe("init", () => {
485
569
  });
486
570
  });
487
571
 
572
+ describe("init — version-check-and-restart at the hub adoption point (#590)", () => {
573
+ test("invokes the version check with the resolved hub port after the hub is up", async () => {
574
+ const h = makeHarness();
575
+ try {
576
+ const logs: string[] = [];
577
+ let seenPort: number | undefined;
578
+ const code = await init({
579
+ configDir: h.configDir,
580
+ manifestPath: h.manifestPath,
581
+ log: (l) => logs.push(l),
582
+ alive: () => false,
583
+ ensureHub: async () => {
584
+ writeHubPort(1939, h.configDir);
585
+ return { pid: 0, port: 1939, started: false };
586
+ },
587
+ ensureHubVersion: async ({ port, log }) => {
588
+ seenPort = port;
589
+ log(
590
+ "⚠ the running hub is 0.5.14-rc.4 but 0.6.4-rc.9 is installed — restarting the hub unit to pick up the new code.",
591
+ );
592
+ return {
593
+ outcome: "restarted" as const,
594
+ runningVersion: "0.6.4-rc.9",
595
+ installedVersion: "0.6.4-rc.9",
596
+ messages: ["✓ hub unit restarted; now running 0.6.4-rc.9."],
597
+ };
598
+ },
599
+ readExposeStateFn: () => undefined,
600
+ isTty: false,
601
+ platform: "linux",
602
+ installVaultModuleImpl: noopVaultInstall,
603
+ });
604
+ expect(code).toBe(0);
605
+ expect(seenPort).toBe(1939);
606
+ const joined = logs.join("\n");
607
+ // The mismatch notice + the restart confirmation both surface.
608
+ expect(joined).toContain("restarting the hub unit to pick up the new code");
609
+ expect(joined).toContain("now running 0.6.4-rc.9");
610
+ } finally {
611
+ h.cleanup();
612
+ }
613
+ });
614
+
615
+ test("a not-unit-managed mismatch bails init with exit 1 (don't wire a tunnel to a zombie)", async () => {
616
+ const h = makeHarness();
617
+ try {
618
+ const logs: string[] = [];
619
+ let exposeRan = false;
620
+ const code = await init({
621
+ configDir: h.configDir,
622
+ manifestPath: h.manifestPath,
623
+ log: (l) => logs.push(l),
624
+ alive: () => false,
625
+ ensureHub: async () => {
626
+ writeHubPort(1939, h.configDir);
627
+ return { pid: 0, port: 1939, started: false };
628
+ },
629
+ ensureHubVersion: async () => ({
630
+ outcome: "not-unit-managed" as const,
631
+ runningVersion: "0.5.14-rc.4",
632
+ installedVersion: "0.6.4-rc.9",
633
+ messages: [
634
+ "⚠ the running hub is 0.5.14-rc.4 but 0.6.4-rc.9 is installed.",
635
+ " The running hub is NOT managed by a Parachute service unit (a detached process or a foreground `parachute serve`), so it won't be restarted automatically.",
636
+ ],
637
+ }),
638
+ // If init wrongly continued, this would run — assert it does NOT.
639
+ exposeChoice: "tailnet",
640
+ exposeTailnetImpl: async () => {
641
+ exposeRan = true;
642
+ return 0;
643
+ },
644
+ readExposeStateFn: () => undefined,
645
+ isTty: false,
646
+ platform: "linux",
647
+ installVaultModuleImpl: noopVaultInstall,
648
+ });
649
+ expect(code).toBe(1);
650
+ expect(exposeRan).toBe(false);
651
+ const joined = logs.join("\n");
652
+ expect(joined).toContain("NOT managed by a Parachute service unit");
653
+ expect(joined).toContain("re-run `parachute init`");
654
+ } finally {
655
+ h.cleanup();
656
+ }
657
+ });
658
+
659
+ test("a restart-failed outcome bails init with exit 1 + an orienting line (don't continue past a failed restart)", async () => {
660
+ const h = makeHarness();
661
+ try {
662
+ const logs: string[] = [];
663
+ let exposeRan = false;
664
+ const code = await init({
665
+ configDir: h.configDir,
666
+ manifestPath: h.manifestPath,
667
+ log: (l) => logs.push(l),
668
+ alive: () => false,
669
+ ensureHub: async () => {
670
+ writeHubPort(1939, h.configDir);
671
+ return { pid: 0, port: 1939, started: false };
672
+ },
673
+ ensureHubVersion: async () => ({
674
+ outcome: "restart-failed" as const,
675
+ runningVersion: "0.5.14-rc.4",
676
+ installedVersion: "0.6.4-rc.9",
677
+ messages: [
678
+ "⚠ the running hub is 0.5.14-rc.4 but 0.6.4-rc.9 is installed, and the hub unit restart failed.",
679
+ "failed to restart the hub unit via the service manager (Unit parachute-hub.service not found.)",
680
+ ],
681
+ }),
682
+ // If init wrongly continued past the failed restart, this would run.
683
+ exposeChoice: "tailnet",
684
+ exposeTailnetImpl: async () => {
685
+ exposeRan = true;
686
+ return 0;
687
+ },
688
+ readExposeStateFn: () => undefined,
689
+ isTty: false,
690
+ platform: "linux",
691
+ installVaultModuleImpl: noopVaultInstall,
692
+ });
693
+ expect(code).toBe(1);
694
+ expect(exposeRan).toBe(false);
695
+ const joined = logs.join("\n");
696
+ // The version-check messages + the orienting line + the logs hint surface.
697
+ expect(joined).toContain("the hub unit restart failed");
698
+ expect(joined).toContain("The hub service manager rejected the restart command.");
699
+ expect(joined).toContain("parachute logs hub");
700
+ } finally {
701
+ h.cleanup();
702
+ }
703
+ });
704
+
705
+ test("a still-mismatched outcome warns but continues (bun-linked branch)", async () => {
706
+ const h = makeHarness();
707
+ try {
708
+ const logs: string[] = [];
709
+ const code = await init({
710
+ configDir: h.configDir,
711
+ manifestPath: h.manifestPath,
712
+ log: (l) => logs.push(l),
713
+ alive: () => false,
714
+ ensureHub: async () => {
715
+ writeHubPort(1939, h.configDir);
716
+ return { pid: 0, port: 1939, started: false };
717
+ },
718
+ ensureHubVersion: async () => ({
719
+ outcome: "still-mismatched" as const,
720
+ runningVersion: "0.6.4-rc.8",
721
+ installedVersion: "0.6.4-rc.9",
722
+ messages: [
723
+ "⚠ restarted the hub unit, but it is still not reporting 0.6.4-rc.9 (reports 0.6.4-rc.8).",
724
+ ],
725
+ }),
726
+ readExposeStateFn: () => undefined,
727
+ isTty: false,
728
+ platform: "linux",
729
+ installVaultModuleImpl: noopVaultInstall,
730
+ });
731
+ // Continues (does NOT bail) — the warning surfaces but init reaches the URL.
732
+ expect(code).toBe(0);
733
+ const joined = logs.join("\n");
734
+ expect(joined).toContain("still not reporting 0.6.4-rc.9");
735
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
736
+ } finally {
737
+ h.cleanup();
738
+ }
739
+ });
740
+ });
741
+
742
+ describe("init bootstrap-token first-claim (hub#576)", () => {
743
+ function publicState(): ExposeState {
744
+ return {
745
+ version: 1,
746
+ layer: "public",
747
+ mode: "path",
748
+ canonicalFqdn: "demo.parachute.computer",
749
+ port: 443,
750
+ funnel: false,
751
+ entries: [],
752
+ hubOrigin: "https://demo.parachute.computer",
753
+ };
754
+ }
755
+
756
+ test("publicly-exposed + wizard mode: prints the bootstrap token in the terminal", async () => {
757
+ const h = makeHarness();
758
+ try {
759
+ writeHubPort(1939, h.configDir);
760
+ const probed: string[] = [];
761
+ const logs: string[] = [];
762
+ const code = await init({
763
+ configDir: h.configDir,
764
+ // #590: keep the version-check a no-op in init tests — NEVER let the
765
+ // production default fire a real /health fetch + restart the live unit.
766
+ ensureHubVersion: async () => ({
767
+ outcome: "match" as const,
768
+ installedVersion: "test",
769
+ messages: [],
770
+ }),
771
+ manifestPath: h.manifestPath,
772
+ log: (l) => logs.push(l),
773
+ alive: () => true,
774
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
775
+ readExposeStateFn: () => publicState(),
776
+ isTty: false,
777
+ platform: "linux",
778
+ installVaultModuleImpl: noopVaultInstall,
779
+ fetchBootstrapTokenImpl: async (loopbackUrl) => {
780
+ probed.push(loopbackUrl);
781
+ return "parachute-bootstrap-XYZ";
782
+ },
783
+ });
784
+ expect(code).toBe(0);
785
+ // Probed the LOOPBACK hub, not the public FQDN.
786
+ expect(probed).toEqual(["http://127.0.0.1:1939"]);
787
+ const joined = logs.join("\n");
788
+ expect(joined).toContain("parachute-bootstrap-XYZ");
789
+ expect(joined).toContain("bootstrap token");
790
+ // Still prints the public admin URL.
791
+ expect(joined).toContain("https://demo.parachute.computer/admin/");
792
+ } finally {
793
+ h.cleanup();
794
+ }
795
+ });
796
+
797
+ test("loopback-only install: does NOT probe or print a token", async () => {
798
+ const h = makeHarness();
799
+ try {
800
+ writeHubPort(1939, h.configDir);
801
+ let probedCount = 0;
802
+ const logs: string[] = [];
803
+ const code = await init({
804
+ configDir: h.configDir,
805
+ // #590: keep the version-check a no-op in init tests — NEVER let the
806
+ // production default fire a real /health fetch + restart the live unit.
807
+ ensureHubVersion: async () => ({
808
+ outcome: "match" as const,
809
+ installedVersion: "test",
810
+ messages: [],
811
+ }),
812
+ manifestPath: h.manifestPath,
813
+ log: (l) => logs.push(l),
814
+ alive: () => true,
815
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
816
+ readExposeStateFn: () => undefined, // no public exposure
817
+ isTty: false,
818
+ platform: "linux",
819
+ installVaultModuleImpl: noopVaultInstall,
820
+ fetchBootstrapTokenImpl: async () => {
821
+ probedCount++;
822
+ return "parachute-bootstrap-XYZ";
823
+ },
824
+ });
825
+ expect(code).toBe(0);
826
+ expect(probedCount).toBe(0);
827
+ expect(logs.join("\n")).not.toContain("parachute-bootstrap-");
828
+ } finally {
829
+ h.cleanup();
830
+ }
831
+ });
832
+
833
+ test("admin already exists (no token): prints the URL without a token block", async () => {
834
+ const h = makeHarness();
835
+ try {
836
+ writeHubPort(1939, h.configDir);
837
+ const logs: string[] = [];
838
+ const code = await init({
839
+ configDir: h.configDir,
840
+ // #590: keep the version-check a no-op in init tests — NEVER let the
841
+ // production default fire a real /health fetch + restart the live unit.
842
+ ensureHubVersion: async () => ({
843
+ outcome: "match" as const,
844
+ installedVersion: "test",
845
+ messages: [],
846
+ }),
847
+ manifestPath: h.manifestPath,
848
+ log: (l) => logs.push(l),
849
+ alive: () => true,
850
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
851
+ readExposeStateFn: () => publicState(),
852
+ isTty: false,
853
+ platform: "linux",
854
+ installVaultModuleImpl: noopVaultInstall,
855
+ // Hub returns undefined → already-claimed / no token to surface.
856
+ fetchBootstrapTokenImpl: async () => undefined,
857
+ });
858
+ expect(code).toBe(0);
859
+ const joined = logs.join("\n");
860
+ expect(joined).toContain("https://demo.parachute.computer/admin/");
861
+ expect(joined).not.toContain("bootstrap token");
862
+ } finally {
863
+ h.cleanup();
864
+ }
865
+ });
866
+
867
+ test("CLI wizard is driven against the LOOPBACK hub, not the public FQDN", async () => {
868
+ const h = makeHarness();
869
+ try {
870
+ writeHubPort(1939, h.configDir);
871
+ const wizardUrls: string[] = [];
872
+ const code = await init({
873
+ configDir: h.configDir,
874
+ // #590: keep the version-check a no-op in init tests — NEVER let the
875
+ // production default fire a real /health fetch + restart the live unit.
876
+ ensureHubVersion: async () => ({
877
+ outcome: "match" as const,
878
+ installedVersion: "test",
879
+ messages: [],
880
+ }),
881
+ manifestPath: h.manifestPath,
882
+ log: () => {},
883
+ alive: () => true,
884
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
885
+ readExposeStateFn: () => publicState(),
886
+ isTty: false,
887
+ platform: "linux",
888
+ installVaultModuleImpl: noopVaultInstall,
889
+ wizardChoice: "cli",
890
+ fetchBootstrapTokenImpl: async () => "parachute-bootstrap-XYZ",
891
+ runCliWizardImpl: async ({ hubUrl }) => {
892
+ wizardUrls.push(hubUrl);
893
+ return 0;
894
+ },
895
+ });
896
+ expect(code).toBe(0);
897
+ // The CLI wizard runs on-box → must use loopback (where the hub hands it
898
+ // the token transparently), never the public FQDN.
899
+ expect(wizardUrls).toEqual(["http://127.0.0.1:1939"]);
900
+ } finally {
901
+ h.cleanup();
902
+ }
903
+ });
904
+ });
905
+
488
906
  describe("looksLikeServer heuristic", () => {
489
907
  test("macOS is never a server", () => {
490
908
  expect(looksLikeServer("darwin", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(false);
@@ -550,6 +968,13 @@ describe("init exposure chain", () => {
550
968
  const promptCalls: string[] = [];
551
969
  const code = await init({
552
970
  configDir: h.configDir,
971
+ // #590: keep the version-check a no-op in init tests — NEVER let the
972
+ // production default fire a real /health fetch + restart the live unit.
973
+ ensureHubVersion: async () => ({
974
+ outcome: "match" as const,
975
+ installedVersion: "test",
976
+ messages: [],
977
+ }),
553
978
  manifestPath: h.manifestPath,
554
979
  log: () => {},
555
980
  alive: () => false,
@@ -583,6 +1008,13 @@ describe("init exposure chain", () => {
583
1008
  const promptCalls: string[] = [];
584
1009
  const code = await init({
585
1010
  configDir: h.configDir,
1011
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1012
+ // production default fire a real /health fetch + restart the live unit.
1013
+ ensureHubVersion: async () => ({
1014
+ outcome: "match" as const,
1015
+ installedVersion: "test",
1016
+ messages: [],
1017
+ }),
586
1018
  manifestPath: h.manifestPath,
587
1019
  log: () => {},
588
1020
  alive: () => false,
@@ -629,6 +1061,13 @@ describe("init exposure chain", () => {
629
1061
  const promptCalls: string[] = [];
630
1062
  const code = await init({
631
1063
  configDir: h.configDir,
1064
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1065
+ // production default fire a real /health fetch + restart the live unit.
1066
+ ensureHubVersion: async () => ({
1067
+ outcome: "match" as const,
1068
+ installedVersion: "test",
1069
+ messages: [],
1070
+ }),
632
1071
  manifestPath: h.manifestPath,
633
1072
  log: () => {},
634
1073
  alive: () => false,
@@ -672,6 +1111,13 @@ describe("init exposure chain", () => {
672
1111
  let cloudflareCalls = 0;
673
1112
  const code = await init({
674
1113
  configDir: h.configDir,
1114
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1115
+ // production default fire a real /health fetch + restart the live unit.
1116
+ ensureHubVersion: async () => ({
1117
+ outcome: "match" as const,
1118
+ installedVersion: "test",
1119
+ messages: [],
1120
+ }),
675
1121
  manifestPath: h.manifestPath,
676
1122
  log: () => {},
677
1123
  alive: () => false,
@@ -706,6 +1152,13 @@ describe("init exposure chain", () => {
706
1152
  let cloudflareCalls = 0;
707
1153
  const code = await init({
708
1154
  configDir: h.configDir,
1155
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1156
+ // production default fire a real /health fetch + restart the live unit.
1157
+ ensureHubVersion: async () => ({
1158
+ outcome: "match" as const,
1159
+ installedVersion: "test",
1160
+ messages: [],
1161
+ }),
709
1162
  manifestPath: h.manifestPath,
710
1163
  log: () => {},
711
1164
  alive: () => false,
@@ -748,6 +1201,13 @@ describe("init exposure chain", () => {
748
1201
  };
749
1202
  await init({
750
1203
  configDir: h.configDir,
1204
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1205
+ // production default fire a real /health fetch + restart the live unit.
1206
+ ensureHubVersion: async () => ({
1207
+ outcome: "match" as const,
1208
+ installedVersion: "test",
1209
+ messages: [],
1210
+ }),
751
1211
  manifestPath: h.manifestPath,
752
1212
  log: (l) => promptLog.push(l),
753
1213
  alive: () => false,
@@ -782,6 +1242,13 @@ describe("init exposure chain", () => {
782
1242
  setChained("none");
783
1243
  await init({
784
1244
  configDir: h.configDir,
1245
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1246
+ // production default fire a real /health fetch + restart the live unit.
1247
+ ensureHubVersion: async () => ({
1248
+ outcome: "match" as const,
1249
+ installedVersion: "test",
1250
+ messages: [],
1251
+ }),
785
1252
  manifestPath: h.manifestPath,
786
1253
  log: (l) => promptLog.push(l),
787
1254
  alive: () => true,
@@ -831,6 +1298,13 @@ describe("init exposure chain", () => {
831
1298
  const logs: string[] = [];
832
1299
  const code = await init({
833
1300
  configDir: h.configDir,
1301
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1302
+ // production default fire a real /health fetch + restart the live unit.
1303
+ ensureHubVersion: async () => ({
1304
+ outcome: "match" as const,
1305
+ installedVersion: "test",
1306
+ messages: [],
1307
+ }),
834
1308
  manifestPath: h.manifestPath,
835
1309
  log: (l) => logs.push(l),
836
1310
  alive: () => false,
@@ -874,6 +1348,13 @@ describe("init exposure chain", () => {
874
1348
  let chained = false;
875
1349
  const code = await init({
876
1350
  configDir: h.configDir,
1351
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1352
+ // production default fire a real /health fetch + restart the live unit.
1353
+ ensureHubVersion: async () => ({
1354
+ outcome: "match" as const,
1355
+ installedVersion: "test",
1356
+ messages: [],
1357
+ }),
877
1358
  manifestPath: h.manifestPath,
878
1359
  log: () => {},
879
1360
  alive: () => false,
@@ -898,14 +1379,22 @@ describe("init exposure chain", () => {
898
1379
  }
899
1380
  });
900
1381
 
901
- test("exposure chain non-zero exit propagates", async () => {
1382
+ test("hub#565: a failed exposure chain does NOT abort init — warns, continues, exits 0", async () => {
902
1383
  const h = makeHarness();
903
1384
  try {
904
1385
  writeHubPort(1939, h.configDir);
1386
+ const logs: string[] = [];
905
1387
  const code = await init({
906
1388
  configDir: h.configDir,
1389
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1390
+ // production default fire a real /health fetch + restart the live unit.
1391
+ ensureHubVersion: async () => ({
1392
+ outcome: "match" as const,
1393
+ installedVersion: "test",
1394
+ messages: [],
1395
+ }),
907
1396
  manifestPath: h.manifestPath,
908
- log: () => {},
1397
+ log: (l) => logs.push(l),
909
1398
  alive: () => false,
910
1399
  ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
911
1400
  readExposeStateFn: () => undefined,
@@ -915,8 +1404,53 @@ describe("init exposure chain", () => {
915
1404
  exposeTailnetImpl: async () => 0,
916
1405
  exposeCloudflareImpl: async () => 2,
917
1406
  exposeChoice: "cloudflare",
1407
+ installVaultModuleImpl: noopVaultInstall,
918
1408
  });
919
- expect(code).toBe(2);
1409
+ // Init reaches the admin-URL/wizard handoff regardless of the expose
1410
+ // failure (exposure is an enhancement, not a prerequisite).
1411
+ expect(code).toBe(0);
1412
+ const joined = logs.join("\n");
1413
+ // Warned about the failure + printed the exact retry command (the
1414
+ // `--cloudflare` flag matters — bare `expose public` defaults to
1415
+ // Tailscale, hub#566).
1416
+ expect(joined).toContain("Couldn't finish setting up public access");
1417
+ expect(joined).toContain("parachute expose public --cloudflare");
1418
+ // Fell through to the loopback admin URL.
1419
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
1420
+ } finally {
1421
+ h.cleanup();
1422
+ }
1423
+ });
1424
+
1425
+ test("hub#565: tailnet expose failure prints the --tailnet retry command", async () => {
1426
+ const h = makeHarness();
1427
+ try {
1428
+ writeHubPort(1939, h.configDir);
1429
+ const logs: string[] = [];
1430
+ const code = await init({
1431
+ configDir: h.configDir,
1432
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1433
+ // production default fire a real /health fetch + restart the live unit.
1434
+ ensureHubVersion: async () => ({
1435
+ outcome: "match" as const,
1436
+ installedVersion: "test",
1437
+ messages: [],
1438
+ }),
1439
+ manifestPath: h.manifestPath,
1440
+ log: (l) => logs.push(l),
1441
+ alive: () => false,
1442
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
1443
+ readExposeStateFn: () => undefined,
1444
+ isTty: false,
1445
+ platform: "linux",
1446
+ env: {},
1447
+ exposeTailnetImpl: async () => 3,
1448
+ exposeCloudflareImpl: async () => 0,
1449
+ exposeChoice: "tailnet",
1450
+ installVaultModuleImpl: noopVaultInstall,
1451
+ });
1452
+ expect(code).toBe(0);
1453
+ expect(logs.join("\n")).toContain("parachute expose public --tailnet");
920
1454
  } finally {
921
1455
  h.cleanup();
922
1456
  }
@@ -936,6 +1470,13 @@ describe("init exposure chain", () => {
936
1470
  const order: string[] = [];
937
1471
  const code = await init({
938
1472
  configDir: h.configDir,
1473
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1474
+ // production default fire a real /health fetch + restart the live unit.
1475
+ ensureHubVersion: async () => ({
1476
+ outcome: "match" as const,
1477
+ installedVersion: "test",
1478
+ messages: [],
1479
+ }),
939
1480
  manifestPath: h.manifestPath,
940
1481
  log: () => {},
941
1482
  alive: () => false,
@@ -981,6 +1522,13 @@ describe("init exposure chain", () => {
981
1522
 
982
1523
  const code = await init({
983
1524
  configDir: h.configDir,
1525
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1526
+ // production default fire a real /health fetch + restart the live unit.
1527
+ ensureHubVersion: async () => ({
1528
+ outcome: "match" as const,
1529
+ installedVersion: "test",
1530
+ messages: [],
1531
+ }),
984
1532
  manifestPath: h.manifestPath,
985
1533
  log: () => {},
986
1534
  alive: () => false,
@@ -1018,6 +1566,13 @@ describe("init exposure chain", () => {
1018
1566
 
1019
1567
  const code = await init({
1020
1568
  configDir: h.configDir,
1569
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1570
+ // production default fire a real /health fetch + restart the live unit.
1571
+ ensureHubVersion: async () => ({
1572
+ outcome: "match" as const,
1573
+ installedVersion: "test",
1574
+ messages: [],
1575
+ }),
1021
1576
  manifestPath: h.manifestPath,
1022
1577
  log: () => {},
1023
1578
  alive: () => false,
@@ -1046,6 +1601,13 @@ describe("init exposure chain", () => {
1046
1601
  // wizard creates first-admin.
1047
1602
  const code = await init({
1048
1603
  configDir: h.configDir,
1604
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1605
+ // production default fire a real /health fetch + restart the live unit.
1606
+ ensureHubVersion: async () => ({
1607
+ outcome: "match" as const,
1608
+ installedVersion: "test",
1609
+ messages: [],
1610
+ }),
1049
1611
  manifestPath: h.manifestPath,
1050
1612
  log: () => {},
1051
1613
  alive: () => false,
@@ -1072,6 +1634,13 @@ describe("init exposure chain", () => {
1072
1634
  const logs: string[] = [];
1073
1635
  const code = await init({
1074
1636
  configDir: h.configDir,
1637
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1638
+ // production default fire a real /health fetch + restart the live unit.
1639
+ ensureHubVersion: async () => ({
1640
+ outcome: "match" as const,
1641
+ installedVersion: "test",
1642
+ messages: [],
1643
+ }),
1075
1644
  manifestPath: h.manifestPath,
1076
1645
  log: (l) => logs.push(l),
1077
1646
  alive: () => false,
@@ -1114,6 +1683,13 @@ describe("init exposure chain", () => {
1114
1683
  const logs: string[] = [];
1115
1684
  const code = await init({
1116
1685
  configDir: h.configDir,
1686
+ // #590: keep the version-check a no-op in init tests — NEVER let the
1687
+ // production default fire a real /health fetch + restart the live unit.
1688
+ ensureHubVersion: async () => ({
1689
+ outcome: "match" as const,
1690
+ installedVersion: "test",
1691
+ messages: [],
1692
+ }),
1117
1693
  manifestPath: h.manifestPath,
1118
1694
  log: (l) => logs.push(l),
1119
1695
  alive: () => false,