@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
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": {
@@ -418,11 +449,14 @@ async function main(argv: string[]): Promise<number> {
418
449
  return 1;
419
450
  }
420
451
  const noStart = keyExtract.rest.includes("--no-start");
421
- const installArgs = keyExtract.rest.filter((a) => a !== "--no-start");
452
+ const interactive = keyExtract.rest.includes("--interactive");
453
+ const installArgs = keyExtract.rest.filter(
454
+ (a) => a !== "--no-start" && a !== "--interactive",
455
+ );
422
456
  const service = installArgs[0];
423
457
  if (!service) {
424
458
  console.error(
425
- "usage: parachute install <service|all> [--channel rc|latest] [--tag <name>] [--no-start]",
459
+ "usage: parachute install <service|all> [--channel rc|latest] [--tag <name>] [--no-start] [--interactive]",
426
460
  );
427
461
  console.error(
428
462
  " parachute install scribe [--scribe-provider <name>] [--scribe-key <key>]",
@@ -436,18 +470,21 @@ async function main(argv: string[]): Promise<number> {
436
470
  installOpts.channel = channelExtract.value;
437
471
  }
438
472
  if (noStart) installOpts.noStart = true;
473
+ if (interactive) installOpts.interactive = true;
439
474
  if (providerExtract.value) installOpts.scribeProvider = providerExtract.value;
440
475
  if (keyExtract.value) installOpts.scribeKey = keyExtract.value;
476
+ const mod = await loadCommand("install", () => import("./commands/install.ts"));
477
+ if (!mod) return 1;
441
478
  if (service === "all") {
442
479
  // Bootstrap the whole ecosystem to one dist-tag — the RC-testing payload.
443
480
  // Bail on first failure so a broken channel doesn't mask a working tag.
444
481
  for (const svc of knownServices()) {
445
- const code = await install(svc, installOpts);
482
+ const code = await mod.install(svc, installOpts);
446
483
  if (code !== 0) return code;
447
484
  }
448
485
  return 0;
449
486
  }
450
- return await install(service, installOpts);
487
+ return await mod.install(service, installOpts);
451
488
  }
452
489
 
453
490
  case "status":
@@ -459,7 +496,11 @@ async function main(argv: string[]): Promise<number> {
459
496
  // dual-dispatch: on a box with a hub unit installed it reads the platform
460
497
  // manager + the running supervisor; on a legacy detached box it falls back
461
498
  // to the pidfile readout (design §6.4). Tests drive the seams directly.
462
- return await status({ supervisor: {} });
499
+ {
500
+ const mod = await loadCommand("status", () => import("./commands/status.ts"));
501
+ if (!mod) return 1;
502
+ return await mod.status({ supervisor: {} });
503
+ }
463
504
 
464
505
  case "expose": {
465
506
  const hubExtract = extractHubOrigin(rest);
@@ -585,6 +626,14 @@ async function main(argv: string[]): Promise<number> {
585
626
  ...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
586
627
  };
587
628
 
629
+ // Lazy-load the Tailscale-Funnel entry points the same way the Cloudflare /
630
+ // interactive / auto-pick paths above load theirs. Reaching here means we're
631
+ // past the early Cloudflare returns, so `exposePublic` / `exposeTailnet` are
632
+ // about to be needed by one of the branches below.
633
+ const exposeMod = await loadCommand("expose", () => import("./commands/expose.ts"));
634
+ if (!exposeMod) return 1;
635
+ const { exposePublic, exposeTailnet } = exposeMod;
636
+
588
637
  // `--tailnet` is the explicit Tailscale Funnel pin — bypass both the
589
638
  // interactive picker and the non-TTY auto-pick. Goes straight to
590
639
  // exposePublic so today's Funnel flow keeps working unchanged.
@@ -677,7 +726,9 @@ async function main(argv: string[]): Promise<number> {
677
726
  migrateOffer: { enabled: true },
678
727
  ...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
679
728
  };
680
- return await start(hubExtract.rest[0], startOpts);
729
+ const mod = await loadCommand("start", () => import("./commands/lifecycle.ts"));
730
+ if (!mod) return 1;
731
+ return await mod.start(hubExtract.rest[0], startOpts);
681
732
  }
682
733
 
683
734
  case "stop": {
@@ -685,7 +736,9 @@ async function main(argv: string[]): Promise<number> {
685
736
  console.log(stopHelp());
686
737
  return 0;
687
738
  }
688
- return await stop(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
739
+ const mod = await loadCommand("stop", () => import("./commands/lifecycle.ts"));
740
+ if (!mod) return 1;
741
+ return await mod.stop(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
689
742
  }
690
743
 
691
744
  case "restart": {
@@ -693,7 +746,9 @@ async function main(argv: string[]): Promise<number> {
693
746
  console.log(restartHelp());
694
747
  return 0;
695
748
  }
696
- return await restart(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
749
+ const mod = await loadCommand("restart", () => import("./commands/lifecycle.ts"));
750
+ if (!mod) return 1;
751
+ return await mod.restart(rest[0], { supervisor: {}, migrateOffer: { enabled: true } });
697
752
  }
698
753
 
699
754
  case "upgrade": {
@@ -744,7 +799,9 @@ async function main(argv: string[]): Promise<number> {
744
799
  upgradeOpts.channel = channelExtract.value;
745
800
  }
746
801
  if (allowDowngrade) upgradeOpts.allowDowngrade = true;
747
- return await upgrade(remaining[0], upgradeOpts);
802
+ const mod = await loadCommand("upgrade", () => import("./commands/upgrade.ts"));
803
+ if (!mod) return 1;
804
+ return await mod.upgrade(remaining[0], upgradeOpts);
748
805
  }
749
806
 
750
807
  case "logs": {
@@ -759,7 +816,9 @@ async function main(argv: string[]): Promise<number> {
759
816
  return 1;
760
817
  }
761
818
  const follow = rest.includes("-f") || rest.includes("--follow");
762
- return await logs(svc, { follow });
819
+ const mod = await loadCommand("logs", () => import("./commands/lifecycle.ts"));
820
+ if (!mod) return 1;
821
+ return await mod.logs(svc, { follow });
763
822
  }
764
823
 
765
824
  case "migrate": {
@@ -775,7 +834,31 @@ async function main(argv: string[]): Promise<number> {
775
834
  console.error("usage: parachute migrate --teardown");
776
835
  return 1;
777
836
  }
778
- teardownHubUnit();
837
+ const mod = await loadCommand("migrate", () => import("./commands/migrate-cutover.ts"));
838
+ if (!mod) return 1;
839
+ // teardownHubUnit logs the human-facing lines itself (the success
840
+ // guidance, or "nothing to tear down"). hub#534: the CLI must still
841
+ // own the EXIT CODE + surface any failure detail the function's
842
+ // false-branch doesn't print — pre-fix it ignored `removed` + `messages`
843
+ // and always exited 0, so a non-removal looked like success to a script.
844
+ const result = mod.teardownHubUnit();
845
+ if (result.removed) return 0;
846
+ // removed === false: either a clean no-op (nothing was installed —
847
+ // `messages` empty) or a real failure (the removal carried a reason in
848
+ // `messages` the internal log didn't surface). The no-op is informational
849
+ // (exit 0); a failure with detail is an error (print it, exit 1).
850
+ //
851
+ // DELIBERATE double-print on the failure path (not a bug): the function's
852
+ // own log() already wrote a human-readable summary ("Hub-unit teardown did
853
+ // not complete: …") to STDOUT; here we re-emit the raw reason(s) to STDERR.
854
+ // The split is intentional — a person reading the terminal sees the framed
855
+ // summary, while a script that captures `2>` gets the machine-parseable
856
+ // reason alongside the non-zero exit. Mirrors the streams convention the
857
+ // rest of the CLI uses (human guidance on stdout, error detail on stderr).
858
+ if (result.messages.length > 0) {
859
+ for (const line of result.messages) console.error(line);
860
+ return 1;
861
+ }
779
862
  return 0;
780
863
  }
781
864
  // §7.1 detached→supervised cutover. Opt-in surface (the archive sweep
@@ -788,7 +871,9 @@ async function main(argv: string[]): Promise<number> {
788
871
  console.error("usage: parachute migrate --to-supervised");
789
872
  return 1;
790
873
  }
791
- const result = await cutoverToSupervised();
874
+ const mod = await loadCommand("migrate", () => import("./commands/migrate-cutover.ts"));
875
+ if (!mod) return 1;
876
+ const result = await mod.cutoverToSupervised();
792
877
  for (const line of result.messages) console.log(line);
793
878
  // "already-migrated" / "migrated" are success; every other outcome is a
794
879
  // recoverable failure that should exit non-zero so scripts can retry.
@@ -807,7 +892,9 @@ async function main(argv: string[]): Promise<number> {
807
892
  );
808
893
  return 1;
809
894
  }
810
- return await migrate({ dryRun, list, yes });
895
+ const mod = await loadCommand("migrate", () => import("./commands/migrate.ts"));
896
+ if (!mod) return 1;
897
+ return await mod.migrate({ dryRun, list, yes });
811
898
  }
812
899
 
813
900
  case "serve": {
@@ -824,7 +911,9 @@ async function main(argv: string[]): Promise<number> {
824
911
  // event loop alive until SIGINT/SIGTERM, at which point we stop the
825
912
  // server cleanly and exit. Container supervisor (tini, Render, Docker)
826
913
  // reaps us once the event loop drains.
827
- const { stop: stopServer } = await serve();
914
+ const mod = await loadCommand("serve", () => import("./commands/serve.ts"));
915
+ if (!mod) return 1;
916
+ const { stop: stopServer } = await mod.serve();
828
917
  await new Promise<void>((resolve) => {
829
918
  const handler = async () => {
830
919
  await stopServer();
@@ -836,14 +925,19 @@ async function main(argv: string[]): Promise<number> {
836
925
  return 0;
837
926
  }
838
927
 
839
- case "auth":
840
- return await auth(rest);
928
+ case "auth": {
929
+ const mod = await loadCommand("auth", () => import("./commands/auth.ts"));
930
+ if (!mod) return 1;
931
+ return await mod.auth(rest);
932
+ }
841
933
 
842
934
  case "vault": {
935
+ const mod = await loadCommand("vault", () => import("./commands/vault.ts"));
936
+ if (!mod) return 1;
843
937
  // `parachute vault` with no args forwards --help to parachute-vault so
844
938
  // users see the actual vault surface, not a CLI-side stub. Anything
845
939
  // after `vault` (including --help) is passed through verbatim.
846
- if (rest.length === 0) return await dispatchVault(["--help"]);
940
+ if (rest.length === 0) return await mod.dispatchVault(["--help"]);
847
941
 
848
942
  // Everything under `vault` forwards transparently to `parachute-vault`.
849
943
  // `vault tokens create` used to route through a guided interactive
@@ -852,7 +946,7 @@ async function main(argv: string[]): Promise<number> {
852
946
  // hub-issued JWTs; mint them with `parachute auth mint-token` or the
853
947
  // admin SPA Connect card. We forward verbatim so the operator sees
854
948
  // vault's own migration error rather than a hub-side stub.
855
- return await dispatchVault(rest);
949
+ return await mod.dispatchVault(rest);
856
950
  }
857
951
 
858
952
  default:
@@ -109,7 +109,7 @@ export function cloudflaredInstallHint(
109
109
  * artifact (registry recipe is undefined) — the caller then uses the generic
110
110
  * pointer. Keeps the arch→suffix mapping in exactly one place (the registry).
111
111
  */
112
- function cloudflaredLinuxDownloadUrl(arch: NodeJS.Architecture): string | undefined {
112
+ export function cloudflaredLinuxDownloadUrl(arch: NodeJS.Architecture): string | undefined {
113
113
  const recipe = lookupDep("cloudflared")?.install.linuxBinaryUrl?.(arch);
114
114
  if (!recipe) return undefined;
115
115
  const urlLine = recipe
@@ -45,6 +45,15 @@ export interface CloudflaredTunnelRecord {
45
45
  export interface CloudflaredState {
46
46
  version: 2;
47
47
  tunnels: Record<string, CloudflaredTunnelRecord>;
48
+ /**
49
+ * A hostname the operator typed in the interactive Cloudflare flow that
50
+ * hasn't been routed yet (hub#567). Persisted as soon as it validates so a
51
+ * mid-chain failure (cloudflared missing, login, tunnel/DNS error) doesn't
52
+ * discard it — the next interactive run pre-fills the hostname prompt with
53
+ * it. Cleared once routing succeeds (the tunnel record then carries the live
54
+ * hostname). Optional + free-floating from the per-tunnel records.
55
+ */
56
+ pendingHostname?: string;
48
57
  }
49
58
 
50
59
  export class CloudflaredStateError extends Error {
@@ -91,11 +100,21 @@ function validate(raw: unknown, path: string): CloudflaredState {
91
100
  throw new CloudflaredStateError(`${path}: root must be an object`);
92
101
  }
93
102
  const r = raw as Record<string, unknown>;
103
+ // hub#567: an optional top-level `pendingHostname` (a typed-but-not-yet-routed
104
+ // hostname). Non-string / empty values read as absent so older state files
105
+ // keep validating.
106
+ const pendingHostname =
107
+ typeof r.pendingHostname === "string" && r.pendingHostname.length > 0
108
+ ? r.pendingHostname
109
+ : undefined;
110
+ const withPending = (state: CloudflaredState): CloudflaredState =>
111
+ pendingHostname ? { ...state, pendingHostname } : state;
112
+
94
113
  if (r.version === 1) {
95
114
  // v1 — single record at top level. Migrate by wrapping it under its
96
115
  // tunnelName. Disk isn't rewritten until the next write.
97
116
  const record = validateRecord(r, path);
98
- return { version: 2, tunnels: { [record.tunnelName]: record } };
117
+ return withPending({ version: 2, tunnels: { [record.tunnelName]: record } });
99
118
  }
100
119
  if (r.version !== 2) {
101
120
  throw new CloudflaredStateError(`${path}: unsupported version ${String(r.version)}`);
@@ -113,7 +132,7 @@ function validate(raw: unknown, path: string): CloudflaredState {
113
132
  }
114
133
  tunnels[key] = record;
115
134
  }
116
- return { version: 2, tunnels };
135
+ return withPending({ version: 2, tunnels });
117
136
  }
118
137
 
119
138
  export function readCloudflaredState(
@@ -161,13 +180,88 @@ export function withTunnelRecord(
161
180
  record: CloudflaredTunnelRecord,
162
181
  ): CloudflaredState {
163
182
  const tunnels = { ...(state?.tunnels ?? {}), [record.tunnelName]: record };
164
- return { version: 2, tunnels };
183
+ // Preserve any pending hostname (hub#567); the caller clears it explicitly
184
+ // via `clearPendingHostname` once routing fully succeeds.
185
+ return state?.pendingHostname
186
+ ? { version: 2, tunnels, pendingHostname: state.pendingHostname }
187
+ : { version: 2, tunnels };
188
+ }
189
+
190
+ /**
191
+ * Pure: set the pending (typed-but-not-routed) hostname on the state (hub#567).
192
+ * Seeds an empty v2 state when none exists yet.
193
+ */
194
+ export function withPendingHostname(
195
+ state: CloudflaredState | undefined,
196
+ hostname: string,
197
+ ): CloudflaredState {
198
+ return { version: 2, tunnels: state?.tunnels ?? {}, pendingHostname: hostname };
199
+ }
200
+
201
+ /**
202
+ * Pure: drop the pending hostname (hub#567). Returns undefined when the result
203
+ * would carry no tunnels either, so the caller can `clearCloudflaredState`
204
+ * rather than write an empty file.
205
+ */
206
+ export function withoutPendingHostname(
207
+ state: CloudflaredState | undefined,
208
+ ): CloudflaredState | undefined {
209
+ if (!state) return undefined;
210
+ if (Object.keys(state.tunnels).length === 0) return undefined;
211
+ return { version: 2, tunnels: state.tunnels };
212
+ }
213
+
214
+ /**
215
+ * Read the pending hostname from the on-disk state (hub#567). Returns undefined
216
+ * when there's no state file or no pending hostname. Swallows read/parse errors
217
+ * (a corrupt state file must not abort the prompt — we just don't pre-fill).
218
+ */
219
+ export function readPendingHostname(path: string = CLOUDFLARED_STATE_PATH): string | undefined {
220
+ try {
221
+ return readCloudflaredState(path)?.pendingHostname;
222
+ } catch {
223
+ return undefined;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Persist a typed-but-not-yet-routed hostname (hub#567), preserving existing
229
+ * tunnel records. Best-effort: a write failure must not abort the expose flow.
230
+ */
231
+ export function writePendingHostname(
232
+ hostname: string,
233
+ path: string = CLOUDFLARED_STATE_PATH,
234
+ ): void {
235
+ try {
236
+ const state = readCloudflaredState(path);
237
+ writeCloudflaredState(withPendingHostname(state, hostname), path);
238
+ } catch {
239
+ // Non-fatal — persistence is a convenience, not a correctness requirement.
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Clear the pending hostname once routing succeeds (hub#567). If no tunnel
245
+ * records remain, removes the state file entirely. Best-effort.
246
+ */
247
+ export function clearPendingHostname(path: string = CLOUDFLARED_STATE_PATH): void {
248
+ try {
249
+ const state = readCloudflaredState(path);
250
+ if (!state?.pendingHostname) return;
251
+ const next = withoutPendingHostname(state);
252
+ if (next) writeCloudflaredState(next, path);
253
+ else clearCloudflaredState(path);
254
+ } catch {
255
+ // Non-fatal.
256
+ }
165
257
  }
166
258
 
167
259
  /**
168
- * Pure: drop the named tunnel from state. Returns undefined when the result
169
- * would be empty so callers can `clearCloudflaredState` instead of writing
170
- * an empty file.
260
+ * Pure: drop the named tunnel from state. Returns undefined when NO tunnels AND
261
+ * no pending hostname remain, so callers can `clearCloudflaredState` instead of
262
+ * writing an empty file. A pending hostname (hub#567) is carried forward — both
263
+ * when other tunnels survive and when it's the only thing left — so removing a
264
+ * tunnel never discards a typed-but-not-routed hostname awaiting retry.
171
265
  */
172
266
  export function withoutTunnelRecord(
173
267
  state: CloudflaredState | undefined,
@@ -175,8 +269,10 @@ export function withoutTunnelRecord(
175
269
  ): CloudflaredState | undefined {
176
270
  if (!state) return undefined;
177
271
  const { [tunnelName]: _dropped, ...rest } = state.tunnels;
178
- if (Object.keys(rest).length === 0) return undefined;
179
- return { version: 2, tunnels: rest };
272
+ if (Object.keys(rest).length === 0 && !state.pendingHostname) return undefined;
273
+ return state.pendingHostname
274
+ ? { version: 2, tunnels: rest, pendingHostname: state.pendingHostname }
275
+ : { version: 2, tunnels: rest };
180
276
  }
181
277
 
182
278
  /** All tunnel records, in name-sorted order so output is deterministic. */
@@ -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;