@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
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { readPendingHostname, writePendingHostname } from "../cloudflare/state.ts";
5
6
  import { exposePublicInteractive } from "../commands/expose-interactive.ts";
6
7
  import { readLastProvider, writeLastProvider } from "../expose-last-provider.ts";
7
8
  import type { CommandResult, Runner } from "../tailscale/run.ts";
@@ -15,6 +16,7 @@ const noopPreflight = async () => {};
15
16
  interface TestEnv {
16
17
  cloudflaredHome: string;
17
18
  lastProviderPath: string;
19
+ statePath: string;
18
20
  cleanup: () => void;
19
21
  }
20
22
 
@@ -28,6 +30,7 @@ function makeEnv(opts: { cloudflaredLoggedIn?: boolean } = {}): TestEnv {
28
30
  return {
29
31
  cloudflaredHome,
30
32
  lastProviderPath: join(dir, "expose-last-provider.json"),
33
+ statePath: join(dir, "cloudflared-state.json"),
31
34
  cleanup: () => rmSync(dir, { recursive: true, force: true }),
32
35
  };
33
36
  }
@@ -194,6 +197,75 @@ describe("exposePublicInteractive — both ready", () => {
194
197
  }
195
198
  });
196
199
 
200
+ test("hub#567: persists the typed hostname as soon as it validates", async () => {
201
+ const env = makeEnv({ cloudflaredLoggedIn: true });
202
+ try {
203
+ const { runner } = fixedRunner({
204
+ tailscaleInstalled: true,
205
+ tailscaleLoggedIn: true,
206
+ tailscaleFunnelCap: true,
207
+ cloudflaredInstalled: true,
208
+ });
209
+ const { prompt } = queuePrompt(["2", "vault.example.com"]);
210
+ // exposeCloudflareUpImpl FAILS — so the hostname must survive for a retry.
211
+ const code = await exposePublicInteractive({
212
+ runner,
213
+ prompt,
214
+ cloudflaredHome: env.cloudflaredHome,
215
+ lastProviderPath: env.lastProviderPath,
216
+ statePath: env.statePath,
217
+ log: () => {},
218
+ exposePublicImpl: async () => 0,
219
+ exposeCloudflareUpImpl: async () => 1,
220
+ runAuthPreflightImpl: noopPreflight,
221
+ });
222
+ expect(code).toBe(1);
223
+ // Stashed despite the downstream failure.
224
+ expect(readPendingHostname(env.statePath)).toBe("vault.example.com");
225
+ } finally {
226
+ env.cleanup();
227
+ }
228
+ });
229
+
230
+ test("hub#567: pre-fills the hostname prompt from a stashed value; Enter accepts it", async () => {
231
+ const env = makeEnv({ cloudflaredLoggedIn: true });
232
+ try {
233
+ writePendingHostname("techne.parachute.computer", env.statePath);
234
+ const { runner } = fixedRunner({
235
+ tailscaleInstalled: true,
236
+ tailscaleLoggedIn: true,
237
+ tailscaleFunnelCap: true,
238
+ cloudflaredInstalled: true,
239
+ });
240
+ // Pick cloudflare, then press Enter (blank) at the hostname prompt.
241
+ const { prompt, asked } = queuePrompt(["2", ""]);
242
+ let cloudflareHostname: string | undefined;
243
+ const code = await exposePublicInteractive({
244
+ runner,
245
+ prompt,
246
+ cloudflaredHome: env.cloudflaredHome,
247
+ lastProviderPath: env.lastProviderPath,
248
+ statePath: env.statePath,
249
+ log: () => {},
250
+ exposePublicImpl: async () => 0,
251
+ exposeCloudflareUpImpl: async (h) => {
252
+ cloudflareHostname = h;
253
+ return 0;
254
+ },
255
+ runAuthPreflightImpl: noopPreflight,
256
+ });
257
+ expect(code).toBe(0);
258
+ // Enter accepted the stashed hostname.
259
+ expect(cloudflareHostname).toBe("techne.parachute.computer");
260
+ // The prompt surfaced the default in brackets.
261
+ expect(asked.some((q) => q.includes("[techne.parachute.computer]"))).toBe(true);
262
+ // Cleared once routing succeeded.
263
+ expect(readPendingHostname(env.statePath)).toBeUndefined();
264
+ } finally {
265
+ env.cleanup();
266
+ }
267
+ });
268
+
197
269
  test("'q' aborts cleanly with exit 0 and no downstream calls", async () => {
198
270
  const env = makeEnv({ cloudflaredLoggedIn: true });
199
271
  try {
@@ -440,11 +512,12 @@ describe("exposePublicInteractive — neither ready", () => {
440
512
  }
441
513
  });
442
514
 
443
- test("user picks cloudflare on linux: prints manual install pointers and exits 1", async () => {
515
+ test("hub#566: cloudflare on linux, user DECLINES auto-install: prints manual + --cloudflare hint, exits 1", async () => {
444
516
  const env = makeEnv();
445
517
  try {
446
518
  const { runner } = fixedRunner({});
447
- const { prompt } = queuePrompt(["2"]);
519
+ // "2" cloudflare; "n" → decline the auto-install offer.
520
+ const { prompt } = queuePrompt(["2", "n"]);
448
521
  const logs: string[] = [];
449
522
  let interactiveCalled = false;
450
523
  let cloudflareCalled = false;
@@ -456,8 +529,10 @@ describe("exposePublicInteractive — neither ready", () => {
456
529
  },
457
530
  prompt,
458
531
  platform: "linux",
532
+ arch: "x64",
459
533
  cloudflaredHome: env.cloudflaredHome,
460
534
  lastProviderPath: env.lastProviderPath,
535
+ statePath: env.statePath,
461
536
  log: (l) => logs.push(l),
462
537
  exposePublicImpl: async () => 0,
463
538
  exposeCloudflareUpImpl: async () => {
@@ -466,16 +541,16 @@ describe("exposePublicInteractive — neither ready", () => {
466
541
  },
467
542
  });
468
543
  expect(code).toBe(1);
544
+ // Declining means no curl/chmod ran and we never reached the expose.
469
545
  expect(interactiveCalled).toBe(false);
470
546
  expect(cloudflareCalled).toBe(false);
471
547
  const joined = logs.join("\n");
472
- // Post 2026-05-27 cloudflared-URL refresh: the install hint moved
473
- // off apt-get / dnf / developers.cloudflare.com (all unreliable —
474
- // Aaron hit `No match for argument: cloudflared` on AL2023 and
475
- // 404s from the docs URL on the same box) onto the static binary
476
- // from GitHub releases.
548
+ expect(joined).toContain("Skipped auto-install");
477
549
  expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
478
550
  expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
551
+ // hub#566: re-run hint carries the --cloudflare flag (bare `expose
552
+ // public` defaults to Tailscale).
553
+ expect(joined).toContain("parachute expose public --cloudflare");
479
554
  expect(joined).not.toContain("developers.cloudflare.com");
480
555
  expect(joined).not.toContain("pkg.cloudflare.com");
481
556
  expect(joined).not.toContain("sudo dnf install cloudflared");
@@ -484,6 +559,158 @@ describe("exposePublicInteractive — neither ready", () => {
484
559
  }
485
560
  });
486
561
 
562
+ test("hub#566: cloudflare on linux as ROOT, accepts auto-install: runs bare curl+chmod (no sudo), then exposes", async () => {
563
+ const env = makeEnv();
564
+ try {
565
+ // cloudflared starts absent (so the install offer fires), then present
566
+ // after the install runs (so the verify probe + flow continue).
567
+ let cloudflaredPresent = false;
568
+ const runner: Runner = async (cmd) => {
569
+ if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
570
+ return cloudflaredPresent
571
+ ? { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }
572
+ : { code: 127, stdout: "", stderr: "not found" };
573
+ }
574
+ if (cmd[0] === "tailscale") {
575
+ // Detection: tailscale absent (forces the cloudflare-only path).
576
+ return { code: 127, stdout: "", stderr: "not found" };
577
+ }
578
+ throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
579
+ };
580
+ // "2" cloudflare → "Y" install → "Y" login → hostname. The login prompt
581
+ // fires because detection reported cloudflared absent (so loggedIn=false)
582
+ // even though cert.pem appears once login "runs".
583
+ const { prompt } = queuePrompt(["2", "y", "y", "vault.example.com"]);
584
+ const interactiveCmds: string[][] = [];
585
+ const logs: string[] = [];
586
+ let cloudflareHostname = "";
587
+ const code = await exposePublicInteractive({
588
+ runner,
589
+ interactiveRunner: async (cmd) => {
590
+ interactiveCmds.push([...cmd]);
591
+ // Install "succeeds": flip cloudflared to present. Login "succeeds":
592
+ // drop the cert so `isCloudflaredLoggedIn` reads true afterward.
593
+ if (cmd.includes("login")) writeFileSync(join(env.cloudflaredHome, "cert.pem"), "---");
594
+ else cloudflaredPresent = true;
595
+ return 0;
596
+ },
597
+ prompt,
598
+ platform: "linux",
599
+ arch: "x64",
600
+ getuid: () => 0, // root
601
+ cloudflaredHome: env.cloudflaredHome,
602
+ lastProviderPath: env.lastProviderPath,
603
+ statePath: env.statePath,
604
+ log: (l) => logs.push(l),
605
+ exposePublicImpl: async () => 0,
606
+ exposeCloudflareUpImpl: async (hostname) => {
607
+ cloudflareHostname = hostname;
608
+ return 0;
609
+ },
610
+ runAuthPreflightImpl: noopPreflight,
611
+ });
612
+ expect(code).toBe(0);
613
+ expect(cloudflareHostname).toBe("vault.example.com");
614
+ // Root runs curl + chmod WITHOUT a sudo prefix.
615
+ expect(interactiveCmds[0]?.[0]).toBe("curl");
616
+ expect(interactiveCmds[0]).toContain("/usr/local/bin/cloudflared");
617
+ expect(interactiveCmds[1]?.[0]).toBe("chmod");
618
+ expect(interactiveCmds.some((c) => c[0] === "sudo")).toBe(false);
619
+ expect(logs.join("\n")).toContain("✓ cloudflared installed.");
620
+ } finally {
621
+ env.cleanup();
622
+ }
623
+ });
624
+
625
+ test("hub#566: cloudflare on linux NON-root, accepts auto-install: wraps curl+chmod in sudo", async () => {
626
+ const env = makeEnv();
627
+ try {
628
+ let cloudflaredPresent = false;
629
+ const runner: Runner = async (cmd) => {
630
+ if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
631
+ return cloudflaredPresent
632
+ ? { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }
633
+ : { code: 127, stdout: "", stderr: "not found" };
634
+ }
635
+ if (cmd[0] === "tailscale") return { code: 127, stdout: "", stderr: "not found" };
636
+ throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
637
+ };
638
+ const { prompt } = queuePrompt(["2", "y", "y", "vault.example.com"]);
639
+ const interactiveCmds: string[][] = [];
640
+ const code = await exposePublicInteractive({
641
+ runner,
642
+ interactiveRunner: async (cmd) => {
643
+ interactiveCmds.push([...cmd]);
644
+ if (cmd.includes("login")) writeFileSync(join(env.cloudflaredHome, "cert.pem"), "---");
645
+ else cloudflaredPresent = true;
646
+ return 0;
647
+ },
648
+ prompt,
649
+ platform: "linux",
650
+ arch: "arm64",
651
+ getuid: () => 1000, // non-root
652
+ cloudflaredHome: env.cloudflaredHome,
653
+ lastProviderPath: env.lastProviderPath,
654
+ statePath: env.statePath,
655
+ log: () => {},
656
+ exposePublicImpl: async () => 0,
657
+ exposeCloudflareUpImpl: async () => 0,
658
+ runAuthPreflightImpl: noopPreflight,
659
+ });
660
+ expect(code).toBe(0);
661
+ // Non-root prefixes both privileged steps with non-interactive `sudo -n`
662
+ // (fails fast instead of hanging on a password prompt under a detached
663
+ // init).
664
+ expect(interactiveCmds[0]?.slice(0, 2)).toEqual(["sudo", "-n"]);
665
+ expect(interactiveCmds[0]).toContain("curl");
666
+ expect(interactiveCmds[1]?.slice(0, 2)).toEqual(["sudo", "-n"]);
667
+ expect(interactiveCmds[1]).toContain("chmod");
668
+ } finally {
669
+ env.cleanup();
670
+ }
671
+ });
672
+
673
+ test("hub#566: cloudflare on linux, sudo curl FAILS: prints manual + --cloudflare hint, exits 1", async () => {
674
+ const env = makeEnv();
675
+ try {
676
+ const runner: Runner = async (cmd) => {
677
+ if (cmd.slice(0, 2).join(" ") === "cloudflared --version") {
678
+ return { code: 127, stdout: "", stderr: "not found" };
679
+ }
680
+ if (cmd[0] === "tailscale") return { code: 127, stdout: "", stderr: "not found" };
681
+ throw new Error(`unexpected runner call: ${cmd.join(" ")}`);
682
+ };
683
+ const { prompt } = queuePrompt(["2", "y"]);
684
+ const logs: string[] = [];
685
+ let cloudflareCalled = false;
686
+ const code = await exposePublicInteractive({
687
+ runner,
688
+ // Simulate sudo failing (no cached creds, no tty).
689
+ interactiveRunner: async () => 1,
690
+ prompt,
691
+ platform: "linux",
692
+ arch: "x64",
693
+ getuid: () => 1000,
694
+ cloudflaredHome: env.cloudflaredHome,
695
+ lastProviderPath: env.lastProviderPath,
696
+ statePath: env.statePath,
697
+ log: (l) => logs.push(l),
698
+ exposePublicImpl: async () => 0,
699
+ exposeCloudflareUpImpl: async () => {
700
+ cloudflareCalled = true;
701
+ return 0;
702
+ },
703
+ });
704
+ expect(code).toBe(1);
705
+ expect(cloudflareCalled).toBe(false);
706
+ const joined = logs.join("\n");
707
+ expect(joined).toContain("Download failed");
708
+ expect(joined).toContain("parachute expose public --cloudflare");
709
+ } finally {
710
+ env.cleanup();
711
+ }
712
+ });
713
+
487
714
  test("user picks cloudflare on macos but declines brew: exits 1, no install attempted", async () => {
488
715
  const env = makeEnv();
489
716
  try {
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ensureHubUnitForExpose, resolveExposeSupervisor } from "../commands/expose-supervisor.ts";
3
+ import type { EnsureHubVersionMatchesResult } from "../hub-unit.ts";
4
+
5
+ /**
6
+ * #590: `ensureHubUnitForExpose` must run the version-check-and-restart at the
7
+ * expose adoption point, so an expose never wires a tunnel to a stale zombie
8
+ * that merely answers /health on the canonical port. These tests drive the
9
+ * version-check seam directly (no real launchctl / live hub).
10
+ */
11
+ describe("ensureHubUnitForExpose — version-check at the expose adoption point (#590)", () => {
12
+ function sup(
13
+ ensureHubUnitOutcome: "already-up" | "started" | "no-unit",
14
+ versionResult: EnsureHubVersionMatchesResult,
15
+ versionSpy?: (port: number) => void,
16
+ ) {
17
+ return resolveExposeSupervisor({
18
+ ensureHubUnit: async ({ port }) => ({
19
+ outcome: ensureHubUnitOutcome,
20
+ port: port ?? 1939,
21
+ messages: ensureHubUnitOutcome === "no-unit" ? ["no hub unit installed"] : [],
22
+ }),
23
+ ensureHubVersion: async ({ port }) => {
24
+ versionSpy?.(port);
25
+ return versionResult;
26
+ },
27
+ });
28
+ }
29
+
30
+ test("hub up + version matches → ok, version check ran with the probed port", async () => {
31
+ const logs: string[] = [];
32
+ let checkedPort: number | undefined;
33
+ const s = sup(
34
+ "already-up",
35
+ {
36
+ outcome: "match",
37
+ runningVersion: "0.6.4-rc.9",
38
+ installedVersion: "0.6.4-rc.9",
39
+ messages: [],
40
+ },
41
+ (p) => {
42
+ checkedPort = p;
43
+ },
44
+ );
45
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
46
+ expect(res.ok).toBe(true);
47
+ expect(checkedPort).toBe(1939);
48
+ });
49
+
50
+ test("hub up but a stale zombie → restarted → ok (tunnel binds to NEW code)", async () => {
51
+ const logs: string[] = [];
52
+ const s = sup("already-up", {
53
+ outcome: "restarted",
54
+ runningVersion: "0.6.4-rc.9",
55
+ installedVersion: "0.6.4-rc.9",
56
+ messages: ["✓ hub unit restarted; now running 0.6.4-rc.9."],
57
+ });
58
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
59
+ expect(res.ok).toBe(true);
60
+ expect(logs.join("\n")).toContain("now running 0.6.4-rc.9");
61
+ });
62
+
63
+ test("hub up but mismatch + NOT unit-managed → expose FAILS (don't tunnel to a zombie)", async () => {
64
+ const logs: string[] = [];
65
+ const s = sup("already-up", {
66
+ outcome: "not-unit-managed",
67
+ runningVersion: "0.5.14-rc.4",
68
+ installedVersion: "0.6.4-rc.9",
69
+ messages: ["⚠ the running hub is 0.5.14-rc.4 but 0.6.4-rc.9 is installed."],
70
+ });
71
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
72
+ expect(res.ok).toBe(false);
73
+ expect(logs.join("\n")).toContain("0.5.14-rc.4");
74
+ });
75
+
76
+ test("still-mismatched after restart → expose CONTINUES (warn, don't block)", async () => {
77
+ const logs: string[] = [];
78
+ const s = sup("already-up", {
79
+ outcome: "still-mismatched",
80
+ runningVersion: "0.6.4-rc.8",
81
+ installedVersion: "0.6.4-rc.9",
82
+ messages: ["⚠ restarted the hub unit, but it is still not reporting 0.6.4-rc.9."],
83
+ });
84
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
85
+ expect(res.ok).toBe(true);
86
+ expect(logs.join("\n")).toContain("still not reporting");
87
+ });
88
+
89
+ test("hub NOT up (no unit) → fails BEFORE the version check (no false adoption)", async () => {
90
+ const logs: string[] = [];
91
+ let versionRan = false;
92
+ const s = sup(
93
+ "no-unit",
94
+ { outcome: "match", installedVersion: "0.6.4-rc.9", messages: [] },
95
+ () => {
96
+ versionRan = true;
97
+ },
98
+ );
99
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
100
+ expect(res.ok).toBe(false);
101
+ // The version check only runs once the hub is confirmed up.
102
+ expect(versionRan).toBe(false);
103
+ });
104
+ });
@@ -558,7 +558,9 @@ describe("expose tailnet up", () => {
558
558
  },
559
559
  });
560
560
  expect(code).toBe(0);
561
- expect(logs.join("\n")).not.toContain("2FA is not enrolled");
561
+ expect(logs.join("\n")).not.toContain(
562
+ "Strongly recommended: turn on two-factor authentication",
563
+ );
562
564
  } finally {
563
565
  h.cleanup();
564
566
  }
@@ -931,9 +933,10 @@ describe("expose public up", () => {
931
933
  });
932
934
  expect(code).toBe(0);
933
935
  const joined = logs.join("\n");
934
- // hub#473: real hub-login 2FA. The warning now recommends the real
935
- // `parachute auth 2fa enroll` path.
936
- expect(joined).toContain("/login is now reachable on the public internet");
936
+ // hub#473: real hub-login 2FA. The recommendation now leads with the
937
+ // friendly "strongly recommended" framing and the real `parachute auth
938
+ // 2fa enroll` path.
939
+ expect(joined).toContain("Strongly recommended: turn on two-factor authentication");
937
940
  expect(joined).toContain("parachute auth 2fa enroll");
938
941
  // /login pointer uses the canonical https://<fqdn> origin.
939
942
  expect(joined).toContain("https://parachute.taildf9ce2.ts.net/login");
@@ -966,7 +969,9 @@ describe("expose public up", () => {
966
969
  },
967
970
  });
968
971
  expect(code).toBe(0);
969
- expect(logs.join("\n")).not.toContain("2FA is not enrolled");
972
+ expect(logs.join("\n")).not.toContain(
973
+ "Strongly recommended: turn on two-factor authentication",
974
+ );
970
975
  } finally {
971
976
  h.cleanup();
972
977
  }