@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
@@ -51,8 +51,9 @@ import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
51
51
  import { deriveHubOrigin } from "../hub-origin.ts";
52
52
  import { HUB_UNIT_DEFAULT_PORT } from "../hub-unit.ts";
53
53
  import { type AliveFn, defaultAlive } from "../process-state.ts";
54
- import { readManifest } from "../services-manifest.ts";
54
+ import { readManifestLenient } from "../services-manifest.ts";
55
55
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
56
+ import { clearVaultHubOrigin } from "../vault-hub-origin-env.ts";
56
57
  import type { VaultAuthStatus } from "../vault/auth-status.ts";
57
58
  import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
58
59
  import {
@@ -91,6 +92,22 @@ export function isValidHostname(h: string): boolean {
91
92
  return h.split(".").every((label) => labelRe.test(label));
92
93
  }
93
94
 
95
+ /**
96
+ * Best-effort "is this module registered in services.json?" check, used only
97
+ * for the courtesy note in the Cloudflare expose path (hub#564). Uses the
98
+ * LENIENT manifest reader on purpose: the strict `readManifest` can reject an
99
+ * older manifest shape (the #564 diagnostic — a manifest written by 0.6.3-era
100
+ * registration that the strict reader bounced), and a courtesy note must never
101
+ * throw. Any read error is swallowed and treated as "not installed".
102
+ */
103
+ function serviceInstalled(manifestPath: string, name: string): boolean {
104
+ try {
105
+ return readManifestLenient(manifestPath).services.some((s) => s.name === name);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
94
111
  export interface CloudflaredSpawner {
95
112
  spawn(cmd: readonly string[], logFile: string): number;
96
113
  }
@@ -606,12 +623,18 @@ export async function exposeCloudflareUp(
606
623
  return 1;
607
624
  }
608
625
 
609
- const manifest = readManifest(r.manifestPath);
610
- const vaultEntry = manifest.services.find((s) => s.name === "parachute-vault");
611
- if (!vaultEntry) {
612
- r.log("parachute-vault is not installed; nothing to route.");
613
- r.log("Run: parachute install vault");
614
- return 1;
626
+ // No vault gate here (hub#564). cloudflared's ingress targets the HUB port
627
+ // (see `servicePort: hubPort` in `writeConfig` below + the long comment
628
+ // there) — the hub does ALL routing (discovery, admin, OAuth, well-known,
629
+ // per-vault proxy, generic /<svc>/* dispatch). Vault doesn't have to be
630
+ // installed for the tunnel to route anything. The old gate
631
+ // (`parachute-vault is not installed; nothing to route` → return 1) was a
632
+ // vestige of the pre-2026-05-27 vault-centric ingress, and it dead-ended
633
+ // fresh-server init: post-#168 init exposes (Step 2) BEFORE it installs the
634
+ // vault module (Step 2.5), so the gate always tripped on a fresh box and
635
+ // aborted the whole init. Courtesy note only — never block.
636
+ if (!serviceInstalled(r.manifestPath, "parachute-vault")) {
637
+ r.log("vault not installed yet — the admin wizard will set it up. Routing the hub anyway.");
615
638
  }
616
639
 
617
640
  // Resolve the public hub origin before spawning the hub server — it gets
@@ -897,45 +920,58 @@ export async function exposeCloudflareUp(
897
920
  // and makes the running vault re-read it immediately rather than waiting for
898
921
  // the next reboot.
899
922
  //
923
+ // hub#564: vault may not be installed yet (init exposes BEFORE installing the
924
+ // vault module). Resolve the entry once, here, and gate the vault-restart +
925
+ // the Vault-URL footer on it — restarting a not-installed vault would just
926
+ // fail and print a spurious warning. When present, a well-formed manifest
927
+ // always lists at least one mount path.
928
+ const vaultEntry = (() => {
929
+ try {
930
+ return readManifestLenient(r.manifestPath).services.find((s) => s.name === "parachute-vault");
931
+ } catch {
932
+ return undefined;
933
+ }
934
+ })();
935
+
900
936
  // §4.3c: drive the restart through the running Supervisor
901
937
  // (`driveModuleOp("vault", "restart")`), which re-injects the hub's current
902
938
  // origin; `restartHubDependentViaSupervisor` also persists the durable `.env`
903
939
  // + self-heals the operator-token issuer. Phase 5b retired the detached
904
- // `lifecycle.restart` arm.
905
- r.log("");
906
- r.log("Restarting vault to pick up new hub origin…");
907
- const rcode = await restartHubDependentViaSupervisor({
908
- short: "vault",
909
- hubOrigin,
910
- configDir: r.configDir,
911
- sup: r.sup,
912
- log: r.log,
913
- });
914
- if (rcode !== 0) {
915
- r.log(
916
- "⚠ vault restart failed. Run manually once the issue is resolved: parachute restart vault",
917
- );
940
+ // `lifecycle.restart` arm. Skipped entirely when vault isn't installed.
941
+ if (vaultEntry) {
942
+ r.log("");
943
+ r.log("Restarting vault to pick up new hub origin…");
944
+ const rcode = await restartHubDependentViaSupervisor({
945
+ short: "vault",
946
+ hubOrigin,
947
+ configDir: r.configDir,
948
+ sup: r.sup,
949
+ log: r.log,
950
+ });
951
+ if (rcode !== 0) {
952
+ r.log(
953
+ "⚠ vault restart failed. Run manually once the issue is resolved: parachute restart vault",
954
+ );
955
+ }
918
956
  }
919
957
 
920
958
  const baseUrl = `https://${hostname}`;
921
- // A well-formed vault manifest always lists at least one mount path. If
922
- // it's empty, something went sideways in `parachute install vault` — warn
923
- // so the user can fix services.json rather than chasing a phantom 404 on
924
- // /vault/default that may or may not exist.
925
- if (!vaultEntry.paths[0]) {
926
- r.log(
927
- `⚠ vault entry in services.json has no paths[]; defaulting to "/vault/default". Check the manifest.`,
928
- );
959
+ let vaultUrl: string | undefined;
960
+ if (vaultEntry) {
961
+ if (!vaultEntry.paths[0]) {
962
+ r.log(
963
+ `⚠ vault entry in services.json has no paths[]; defaulting to "/vault/default". Check the manifest.`,
964
+ );
965
+ }
966
+ vaultUrl = `${baseUrl}${vaultEntry.paths[0] ?? "/vault/default"}`;
929
967
  }
930
- const vaultMount = vaultEntry.paths[0] ?? "/vault/default";
931
- const vaultUrl = `${baseUrl}${vaultMount}`;
932
968
 
933
969
  r.log("");
934
970
  r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
935
971
  r.log(` Tunnel: ${r.tunnelName} (dedicated to this machine)`);
936
972
  r.log(` Open: ${baseUrl}/`);
937
973
  r.log(` Admin: ${baseUrl}/admin/`);
938
- r.log(` Vault: ${vaultUrl}`);
974
+ if (vaultUrl) r.log(` Vault: ${vaultUrl}`);
939
975
  r.log(` OAuth: ${hubOrigin}`);
940
976
  r.log(` Logs: ${r.logPath}`);
941
977
  r.log("");
@@ -954,10 +990,14 @@ export async function exposeCloudflareUp(
954
990
  r.log("background but does NOT survive a reboot. After a reboot, re-run:");
955
991
  r.log(` parachute expose public --cloudflare --domain ${hostname}`);
956
992
  }
957
- r.log("");
958
- r.log("Point a claude.ai / ChatGPT connector at:");
959
- r.log(` ${vaultUrl}`);
960
- printAuthGuidance(r.log, vaultUrl);
993
+ // The connector guidance points at a vault MCP URL — only meaningful once a
994
+ // vault is installed (hub#564: it may not be yet, when init exposes first).
995
+ if (vaultUrl) {
996
+ r.log("");
997
+ r.log("Point a claude.ai / ChatGPT connector at:");
998
+ r.log(` ${vaultUrl}`);
999
+ printAuthGuidance(r.log, vaultUrl);
1000
+ }
961
1001
  // 2FA-enrollment warning when /admin/login is now reachable on the public
962
1002
  // internet but the operator hasn't enrolled TOTP. Cloudflare exposure is
963
1003
  // always public; tailnet/funnel mirrors this in `expose.ts`. See #186.
@@ -1098,8 +1138,35 @@ export async function exposeCloudflareOff(opts: ExposeCloudflareOpts = {}): Prom
1098
1138
  // downstream consumers stop resolving the now-dead public URL (mirrors the
1099
1139
  // up-path write above + the Tailscale off-path's expose-state teardown). When
1100
1140
  // other tunnels survive we leave it — a later off for the last one clears it.
1141
+ //
1142
+ // TODO(multi-tunnel) #588: with TWO CF tunnels up, tearing down the
1143
+ // last-written-up one (whose hostname is what's in vault's `.env`) while the
1144
+ // other survives leaves `.env` carrying the dead tunnel's origin while the
1145
+ // surviving tunnel serves a different one → stale-iss on the next vault
1146
+ // restart. Retention is still the only SAFE choice here: a single
1147
+ // `PARACHUTE_HUB_ORIGIN` field can't represent "which surviving tunnel wins,"
1148
+ // and clearing it would break the survivor's iss check. Properly fixing it
1149
+ // needs re-resolving the effective origin from the survivor (or multi-origin
1150
+ // issuer acceptance vault-side) — larger than the #503 single-tunnel fix, and
1151
+ // multi-CF-tunnel-on-one-box is rare. See #588.
1101
1152
  if (!state) {
1102
1153
  clearExposeState(r.exposeStatePath);
1154
+ // Drop the persisted PARACHUTE_HUB_ORIGIN from vault's `.env` (#503). With
1155
+ // the last Cloudflare tunnel gone, the hub is loopback-only and mints
1156
+ // loopback-`iss` tokens; a stale public origin left in `vault/.env` would
1157
+ // pin a public expected issuer and 401 every request on the next vault
1158
+ // daemon restart ("not signed in to the hub" — the inverse of the bug
1159
+ // selfHealVaultHubOrigin closed). This mirrors exactly what the Tailscale
1160
+ // off-path does (`exposeOff` in expose.ts) — the Cloudflare path had been
1161
+ // the asymmetric gap. expose-state's own `hubOrigin` is cleared above via
1162
+ // clearExposeState, so hub's per-request `resolveIssuer`/`exposeIssuerOrigin`
1163
+ // (which read expose-state) also stop minting the public iss after teardown.
1164
+ // No restart needed for the gap this closes — the next vault restart picks
1165
+ // up the cleared `.env` — but tell the operator so an already-running vault
1166
+ // doesn't keep validating against the now-dead public origin.
1167
+ if (clearVaultHubOrigin(r.configDir, r.log)) {
1168
+ r.log(" Restart vault to apply the loopback issuer now: `parachute restart vault`.");
1169
+ }
1103
1170
  }
1104
1171
  return failed ? 1 : 0;
1105
1172
  }
@@ -14,9 +14,16 @@ import { createInterface } from "node:readline/promises";
14
14
  import {
15
15
  DEFAULT_CLOUDFLARED_HOME,
16
16
  cloudflaredInstallHint,
17
+ cloudflaredLinuxDownloadUrl,
17
18
  isCloudflaredInstalled,
18
19
  isCloudflaredLoggedIn,
19
20
  } from "../cloudflare/detect.ts";
21
+ import {
22
+ CLOUDFLARED_STATE_PATH,
23
+ clearPendingHostname,
24
+ readPendingHostname,
25
+ writePendingHostname,
26
+ } from "../cloudflare/state.ts";
20
27
  import {
21
28
  EXPOSE_LAST_PROVIDER_PATH,
22
29
  type ExposeProvider,
@@ -73,6 +80,19 @@ export interface ExposeInteractiveOpts {
73
80
  prompt?: (question: string) => Promise<string>;
74
81
  cloudflaredHome?: string;
75
82
  platform?: NodeJS.Platform;
83
+ /** Test seam: `process.arch` — drives the Linux cloudflared download URL. */
84
+ arch?: NodeJS.Architecture;
85
+ /**
86
+ * Test seam: `process.getuid` — root (uid 0) can write
87
+ * /usr/local/bin/cloudflared directly; non-root needs `sudo`. Defaults to
88
+ * `process.getuid` (undefined on platforms without it → treated non-root).
89
+ */
90
+ getuid?: () => number;
91
+ /**
92
+ * Path to cloudflared-state.json (hub#567 pending-hostname persistence).
93
+ * Defaults to the canonical `CLOUDFLARED_STATE_PATH`.
94
+ */
95
+ statePath?: string;
76
96
  lastProviderPath?: string;
77
97
  now?: () => Date;
78
98
  log?: (line: string) => void;
@@ -110,6 +130,9 @@ interface Resolved {
110
130
  prompt: (question: string) => Promise<string>;
111
131
  cloudflaredHome: string;
112
132
  platform: NodeJS.Platform;
133
+ arch: NodeJS.Architecture;
134
+ getuid: () => number;
135
+ statePath: string;
113
136
  lastProviderPath: string;
114
137
  now: () => Date;
115
138
  log: (line: string) => void;
@@ -129,6 +152,10 @@ function resolve(opts: ExposeInteractiveOpts): Resolved {
129
152
  prompt: opts.prompt ?? defaultPrompt,
130
153
  cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
131
154
  platform: opts.platform ?? process.platform,
155
+ arch: opts.arch ?? process.arch,
156
+ // `process.getuid` is absent on Windows; treat missing as non-root (uid 1).
157
+ getuid: opts.getuid ?? (() => process.getuid?.() ?? 1),
158
+ statePath: opts.statePath ?? CLOUDFLARED_STATE_PATH,
132
159
  lastProviderPath: opts.lastProviderPath ?? EXPOSE_LAST_PROVIDER_PATH,
133
160
  now: opts.now ?? (() => new Date()),
134
161
  log: opts.log ?? ((line) => console.log(line)),
@@ -185,10 +212,25 @@ async function promptHostname(r: Resolved): Promise<string | undefined> {
185
212
  r.log("");
186
213
  r.log("Cloudflare needs a hostname under a domain you've added to your Cloudflare account.");
187
214
  r.log('Example: vault.example.com (apex "example.com" must be a Cloudflare zone)');
215
+ // hub#567: pre-fill with a hostname the operator typed on a prior (failed)
216
+ // run so a retry is "press Enter", not "redo the whole interview".
217
+ const pending = readPendingHostname(r.statePath);
218
+ const promptText = pending
219
+ ? `Hostname [${pending}] (or blank to quit): `
220
+ : "Hostname (or blank to quit): ";
188
221
  for (let attempt = 0; attempt < 5; attempt++) {
189
- const raw = (await r.prompt("Hostname (or blank to quit): ")).trim();
222
+ const raw = (await r.prompt(promptText)).trim();
223
+ // Enter on a pre-filled prompt accepts the stashed hostname.
224
+ if (raw === "" && pending) {
225
+ return pending;
226
+ }
190
227
  if (raw === "") return undefined;
191
- if (isValidHostname(raw)) return raw;
228
+ if (isValidHostname(raw)) {
229
+ // Stash it the moment it validates so a downstream failure (cloudflared
230
+ // login, tunnel/DNS error) doesn't discard it. Cleared on success.
231
+ writePendingHostname(raw, r.statePath);
232
+ return raw;
233
+ }
192
234
  r.log(`"${raw}" doesn't look like a hostname. Expected something like vault.example.com.`);
193
235
  }
194
236
  r.log("Too many invalid entries; aborting.");
@@ -238,11 +280,99 @@ function printTailscaleSetupGuidance(r: Resolved, readiness: ProviderAvailabilit
238
280
  r.log("Once those are done, re-run: parachute expose public");
239
281
  }
240
282
 
283
+ /**
284
+ * hub#566: offer to install cloudflared on Linux in place (instead of printing
285
+ * the command and bailing). The install is a single static-binary download
286
+ * (curl + chmod) we already know how to do.
287
+ *
288
+ * - Confirm with `Install cloudflared now? [Y/n]` (Enter accepts).
289
+ * - Run the curl into /usr/local/bin/cloudflared + chmod +x. Root writes
290
+ * directly; non-root wraps each step in `sudo -n` (non-interactive — only
291
+ * succeeds when sudo creds are already cached / passwordless). We use `-n`
292
+ * deliberately: init often runs detached/unattended on a fresh server (SSH
293
+ * + tmux, a cloud-init script), where a blocking interactive sudo password
294
+ * prompt would hang the whole flow. `-n` fails fast instead, and we fall
295
+ * back to printing the manual command.
296
+ * - Verify with `cloudflared --version`.
297
+ *
298
+ * Returns true only when cloudflared is on PATH afterward. On decline, missing
299
+ * download URL (unknown arch), or any install/verify failure, prints the
300
+ * canonical manual instructions + the `--cloudflare` re-run hint and returns
301
+ * false. Per hub#565 the caller's `false` does NOT abort init.
302
+ */
303
+ async function offerLinuxCloudflaredInstall(r: Resolved): Promise<boolean> {
304
+ r.log("");
305
+ r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
306
+ const downloadUrl = cloudflaredLinuxDownloadUrl(r.arch);
307
+
308
+ const printManualAndBail = () => {
309
+ r.log("");
310
+ for (const line of cloudflaredInstallHint("linux", r.arch).split("\n")) r.log(line);
311
+ r.log("");
312
+ r.log("After install, re-run: parachute expose public --cloudflare");
313
+ };
314
+
315
+ // No published artifact for this arch → can't auto-install; print + bail.
316
+ if (!downloadUrl) {
317
+ printManualAndBail();
318
+ return false;
319
+ }
320
+
321
+ const answer = (await r.prompt("Install cloudflared now? [Y/n] ")).trim().toLowerCase();
322
+ if (answer === "n" || answer === "no") {
323
+ r.log("Skipped auto-install.");
324
+ printManualAndBail();
325
+ return false;
326
+ }
327
+
328
+ const isRoot = r.getuid() === 0;
329
+ const dest = "/usr/local/bin/cloudflared";
330
+ // Root writes directly; non-root prefixes each privileged step with `sudo -n`
331
+ // (non-interactive). `-n` never prompts for a password: it exits non-zero
332
+ // when creds aren't cached, so a detached/unattended init (SSH + tmux, a
333
+ // cloud-init script) fails fast and falls back to the printed instructions
334
+ // rather than hanging on a password prompt nobody's there to answer.
335
+ const sudo = isRoot ? [] : ["sudo", "-n"];
336
+ const curlCmd = [...sudo, "curl", "-L", "-o", dest, downloadUrl];
337
+ const chmodCmd = [...sudo, "chmod", "+x", dest];
338
+
339
+ r.log("");
340
+ r.log(`Downloading cloudflared → ${dest} …`);
341
+ const curlCode = await r.interactiveRunner(curlCmd);
342
+ if (curlCode !== 0) {
343
+ r.log(`Download failed (exit ${curlCode}).`);
344
+ if (!isRoot) {
345
+ r.log(
346
+ "(`sudo -n` needs cached credentials; run `sudo -v` first, or use the commands below.)",
347
+ );
348
+ }
349
+ printManualAndBail();
350
+ return false;
351
+ }
352
+ const chmodCode = await r.interactiveRunner(chmodCmd);
353
+ if (chmodCode !== 0) {
354
+ r.log(`chmod failed (exit ${chmodCode}).`);
355
+ printManualAndBail();
356
+ return false;
357
+ }
358
+
359
+ if (!(await isCloudflaredInstalled(r.runner))) {
360
+ r.log("Install ran but `cloudflared` still isn't on PATH.");
361
+ r.log(
362
+ "Open a fresh shell (so PATH picks up the new binary), then re-run: parachute expose public --cloudflare",
363
+ );
364
+ return false;
365
+ }
366
+ r.log("✓ cloudflared installed.");
367
+ return true;
368
+ }
369
+
241
370
  /**
242
371
  * Walks the user through installing and logging in cloudflared. On macOS we
243
- * auto-install via brew (with confirmation); on Linux we print manual-install
244
- * pointers and bail so the user can pick apt/dnf/tarball. Returns true only
245
- * when cloudflared is both present and logged in afterwards.
372
+ * auto-install via brew (with confirmation); on Linux we auto-install the
373
+ * static binary (hub#566) with confirmation; everywhere else we print
374
+ * manual-install pointers and bail. Returns true only when cloudflared is
375
+ * both present and logged in afterwards.
246
376
  */
247
377
  async function guideCloudflareSetup(
248
378
  r: Resolved,
@@ -259,7 +389,11 @@ async function guideCloudflareSetup(
259
389
  .trim()
260
390
  .toLowerCase();
261
391
  if (answer === "n" || answer === "no") {
262
- r.log("Skipped auto-install. Install manually, then re-run: parachute expose public");
392
+ // hub#566: re-run with `--cloudflare` (bare `expose public` defaults
393
+ // to Tailscale Funnel, the wrong provider for someone who chose CF).
394
+ r.log(
395
+ "Skipped auto-install. Install manually, then re-run: parachute expose public --cloudflare",
396
+ );
263
397
  return false;
264
398
  }
265
399
  const code = await r.interactiveRunner(["brew", "install", "cloudflared"]);
@@ -273,20 +407,26 @@ async function guideCloudflareSetup(
273
407
  r.log("Open a fresh shell (so PATH picks up the new binary) and re-run.");
274
408
  return false;
275
409
  }
410
+ } else if (r.platform === "linux") {
411
+ // hub#566: on Linux the install is a single static-binary download we
412
+ // already know how to do — offer to run it in place instead of dumping
413
+ // the operator back to a shell. Auto-install requires root (write to
414
+ // /usr/local/bin) or a working passwordless `sudo -n`. If we can't, or
415
+ // the operator declines, fall back to printing the instructions (and
416
+ // per hub#565 init continues regardless of the `false` return here).
417
+ installed = await offerLinuxCloudflaredInstall(r);
418
+ if (!installed) return false;
276
419
  } else {
277
- // 2026-05-27 refresh: distro-package paths (`apt-get`, `dnf`) are
278
- // unreliable across versions Aaron hit `No match for argument:
279
- // cloudflared` on Amazon Linux 2023 — and the
280
- // pkg.cloudflare.com / developers.cloudflare.com paths the old hint
281
- // pointed at now serve HTML/404. Defer to `cloudflaredInstallHint`,
282
- // which writes the canonical GitHub-release static-binary path
283
- // matching the host's architecture.
420
+ // Non-darwin/linux (e.g. Windows / misc): no auto-install path. Print
421
+ // the canonical pointer and bail (init continues per hub#565).
284
422
  r.log("");
285
423
  r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
286
424
  r.log("");
287
- for (const line of cloudflaredInstallHint(r.platform).split("\n")) r.log(line);
425
+ for (const line of cloudflaredInstallHint(r.platform, r.arch).split("\n")) r.log(line);
288
426
  r.log("");
289
- r.log("After install, re-run: parachute expose public");
427
+ // hub#566: the bare `parachute expose public` defaults to Tailscale
428
+ // Funnel — an operator who chose Cloudflare must re-run with the flag.
429
+ r.log("After install, re-run: parachute expose public --cloudflare");
290
430
  return false;
291
431
  }
292
432
  }
@@ -310,7 +450,7 @@ async function guideCloudflareSetup(
310
450
  loggedIn = isCloudflaredLoggedIn(r.cloudflaredHome);
311
451
  if (!loggedIn) {
312
452
  r.log("Login ran but cert.pem didn't appear in ~/.cloudflared.");
313
- r.log("Check the browser flow completed, then re-run: parachute expose public");
453
+ r.log("Check the browser flow completed, then re-run: parachute expose public --cloudflare");
314
454
  return false;
315
455
  }
316
456
  }
@@ -391,7 +531,13 @@ export async function exposePublicInteractive(opts: ExposeInteractiveOpts = {}):
391
531
  }
392
532
  writeLastProvider("cloudflare", { path: r.lastProviderPath, now: r.now });
393
533
  const code = await r.exposeCloudflareUpImpl(hostname, r.cloudflareOpts);
394
- if (code === 0) await runPreflightSafely(r);
534
+ if (code === 0) {
535
+ // hub#567: routing succeeded — the tunnel record now carries the live
536
+ // hostname, so drop the pending one (a retry shouldn't pre-fill a
537
+ // hostname that's already exposed).
538
+ clearPendingHostname(r.statePath);
539
+ await runPreflightSafely(r);
540
+ }
395
541
  return code;
396
542
  }
397
543
 
@@ -16,15 +16,18 @@
16
16
  * `expose-cloudflare.ts` (cloudflared) use so the two paths can't drift.
17
17
  */
18
18
 
19
+ import pkg from "../../package.json" with { type: "json" };
19
20
  import { readHubPort } from "../hub-control.ts";
20
21
  import { hubDbPath, openHubDb } from "../hub-db.ts";
21
22
  import {
22
23
  type EnsureHubUnitOpts,
23
24
  type EnsureHubUnitResult,
25
+ type EnsureHubVersionMatchesResult,
24
26
  HUB_UNIT_DEFAULT_PORT,
25
27
  type HubUnitDeps,
26
28
  defaultHubUnitDeps,
27
29
  ensureHubUnit as ensureHubUnitImpl,
30
+ ensureHubVersionMatches as ensureHubVersionMatchesImpl,
28
31
  } from "../hub-unit.ts";
29
32
  import {
30
33
  type DriveModuleOpDeps,
@@ -54,6 +57,17 @@ export interface ExposeSupervisorOpts {
54
57
  hubUnitDeps?: HubUnitDeps;
55
58
  /** Ensure the hub unit is up before / during expose (§3.2 / §4.3a). */
56
59
  ensureHubUnit?: (opts: EnsureHubUnitOpts) => Promise<EnsureHubUnitResult>;
60
+ /**
61
+ * Version-check-and-restart at the expose adoption point (#590). After the
62
+ * hub unit is confirmed up, compare the RUNNING hub's `/health` version to the
63
+ * installed package version; restart the managed unit on mismatch so an expose
64
+ * never wires a tunnel to a stale zombie. Production wires
65
+ * `ensureHubVersionMatches`; tests inject a stub.
66
+ */
67
+ ensureHubVersion?: (ctx: {
68
+ port: number;
69
+ log: (line: string) => void;
70
+ }) => Promise<EnsureHubVersionMatchesResult>;
57
71
  /** Drive a per-module op against the running hub (reads operator.token). */
58
72
  driveModuleOp?: (short: string, op: ModuleOp, deps: DriveModuleOpDeps) => Promise<ModuleOpResult>;
59
73
  /**
@@ -83,6 +97,10 @@ export interface ExposeSupervisorOpts {
83
97
  export interface ResolvedExposeSupervisor {
84
98
  hubUnitDeps: HubUnitDeps;
85
99
  ensureHubUnit: (opts: EnsureHubUnitOpts) => Promise<EnsureHubUnitResult>;
100
+ ensureHubVersion: (ctx: {
101
+ port: number;
102
+ log: (line: string) => void;
103
+ }) => Promise<EnsureHubVersionMatchesResult>;
86
104
  driveModuleOp: (short: string, op: ModuleOp, deps: DriveModuleOpDeps) => Promise<ModuleOpResult>;
87
105
  openDb: (configDir: string) => import("bun:sqlite").Database;
88
106
  selfHealOperatorTokenIssuer: (
@@ -105,6 +123,15 @@ export function resolveExposeSupervisor(
105
123
  return {
106
124
  hubUnitDeps,
107
125
  ensureHubUnit: opts?.ensureHubUnit ?? ensureHubUnitImpl,
126
+ ensureHubVersion:
127
+ opts?.ensureHubVersion ??
128
+ ((ctx) =>
129
+ ensureHubVersionMatchesImpl({
130
+ installedVersion: pkg.version,
131
+ port: ctx.port,
132
+ deps: hubUnitDeps,
133
+ log: ctx.log,
134
+ })),
108
135
  driveModuleOp: opts?.driveModuleOp ?? driveModuleOpImpl,
109
136
  openDb: opts?.openDb ?? ((configDir) => openHubDb(hubDbPath(configDir))),
110
137
  selfHealOperatorTokenIssuer:
@@ -145,6 +172,24 @@ export async function ensureHubUnitForExpose(
145
172
  ): Promise<{ ok: boolean; port: number }> {
146
173
  const ensured = await sup.ensureHubUnit({ port, deps: sup.hubUnitDeps, log });
147
174
  if (ensured.outcome === "already-up" || ensured.outcome === "started") {
175
+ // #590: the hub is up — but is it the version we installed? A zombie that
176
+ // merely answers /health must not become the target of a fresh tunnel.
177
+ // Compare + restart-on-mismatch (once). A non-unit-managed mismatch is NOT
178
+ // killed: surface it + fail the expose so the operator resolves it; a
179
+ // still-mismatched-after-restart (bun-linked branch) warns + continues.
180
+ try {
181
+ const versionResult = await sup.ensureHubVersion({ port: ensured.port, log });
182
+ for (const m of versionResult.messages) log(m);
183
+ if (
184
+ versionResult.outcome === "not-unit-managed" ||
185
+ versionResult.outcome === "restart-failed"
186
+ ) {
187
+ return { ok: false, port: ensured.port };
188
+ }
189
+ } catch (err) {
190
+ // A version-check failure must never block expose — degrade to a note.
191
+ log(`note: hub version check skipped (${err instanceof Error ? err.message : String(err)})`);
192
+ }
148
193
  return { ok: true, port: ensured.port };
149
194
  }
150
195
  for (const m of ensured.messages) log(m);