@openparachute/hub 0.6.4-rc.5 → 0.6.4-rc.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.4-rc.5",
3
+ "version": "0.6.4-rc.6",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -32,7 +32,6 @@
32
32
  "format": "biome format --write .",
33
33
  "typecheck": "tsc --noEmit",
34
34
  "build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
35
- "postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
36
35
  "prepack": "bun run build:spa"
37
36
  },
38
37
  "devDependencies": {
@@ -7,12 +7,15 @@ import {
7
7
  CloudflaredStateError,
8
8
  type CloudflaredTunnelRecord,
9
9
  clearCloudflaredState,
10
+ clearPendingHostname,
10
11
  findTunnelRecord,
11
12
  listTunnelRecords,
12
13
  readCloudflaredState,
14
+ readPendingHostname,
13
15
  withTunnelRecord,
14
16
  withoutTunnelRecord,
15
17
  writeCloudflaredState,
18
+ writePendingHostname,
16
19
  } from "../cloudflare/state.ts";
17
20
 
18
21
  function makeTempPath(): { path: string; cleanup: () => void } {
@@ -250,3 +253,104 @@ describe("cloudflared state — record helpers", () => {
250
253
  expect(listTunnelRecords(undefined)).toEqual([]);
251
254
  });
252
255
  });
256
+
257
+ describe("hub#567 pending hostname", () => {
258
+ test("read returns undefined when no state file / no pending hostname", () => {
259
+ const { path, cleanup } = makeTempPath();
260
+ try {
261
+ expect(readPendingHostname(path)).toBeUndefined();
262
+ writeCloudflaredState(sample, path);
263
+ expect(readPendingHostname(path)).toBeUndefined();
264
+ } finally {
265
+ cleanup();
266
+ }
267
+ });
268
+
269
+ test("write then read round-trips the pending hostname (seeds empty state)", () => {
270
+ const { path, cleanup } = makeTempPath();
271
+ try {
272
+ writePendingHostname("techne.parachute.computer", path);
273
+ expect(readPendingHostname(path)).toBe("techne.parachute.computer");
274
+ const state = readCloudflaredState(path);
275
+ expect(state?.pendingHostname).toBe("techne.parachute.computer");
276
+ expect(state?.tunnels).toEqual({});
277
+ } finally {
278
+ cleanup();
279
+ }
280
+ });
281
+
282
+ test("write preserves existing tunnel records", () => {
283
+ const { path, cleanup } = makeTempPath();
284
+ try {
285
+ writeCloudflaredState(sample, path);
286
+ writePendingHostname("techne.parachute.computer", path);
287
+ const state = readCloudflaredState(path);
288
+ expect(state?.pendingHostname).toBe("techne.parachute.computer");
289
+ expect(state?.tunnels.parachute).toEqual(sampleRecord);
290
+ } finally {
291
+ cleanup();
292
+ }
293
+ });
294
+
295
+ test("clear drops the pending hostname but keeps tunnel records", () => {
296
+ const { path, cleanup } = makeTempPath();
297
+ try {
298
+ writeCloudflaredState({ ...sample, pendingHostname: "techne.parachute.computer" }, path);
299
+ clearPendingHostname(path);
300
+ const state = readCloudflaredState(path);
301
+ expect(state?.pendingHostname).toBeUndefined();
302
+ expect(state?.tunnels.parachute).toEqual(sampleRecord);
303
+ } finally {
304
+ cleanup();
305
+ }
306
+ });
307
+
308
+ test("clear removes the state file entirely when no tunnels remain", () => {
309
+ const { path, cleanup } = makeTempPath();
310
+ try {
311
+ writePendingHostname("techne.parachute.computer", path);
312
+ expect(existsSync(path)).toBe(true);
313
+ clearPendingHostname(path);
314
+ expect(existsSync(path)).toBe(false);
315
+ } finally {
316
+ cleanup();
317
+ }
318
+ });
319
+
320
+ test("validate preserves a pending hostname round-tripped through the bytes", () => {
321
+ const { path, cleanup } = makeTempPath();
322
+ try {
323
+ const withPending: CloudflaredState = { ...sample, pendingHostname: "a.example.com" };
324
+ writeCloudflaredState(withPending, path);
325
+ expect(readCloudflaredState(path)).toEqual(withPending);
326
+ } finally {
327
+ cleanup();
328
+ }
329
+ });
330
+
331
+ test("withTunnelRecord preserves an existing pending hostname", () => {
332
+ const seed: CloudflaredState = { version: 2, tunnels: {}, pendingHostname: "a.example.com" };
333
+ const next = withTunnelRecord(seed, sampleRecord);
334
+ expect(next.pendingHostname).toBe("a.example.com");
335
+ expect(next.tunnels.parachute).toEqual(sampleRecord);
336
+ });
337
+
338
+ test("withoutTunnelRecord carries the pending hostname when it's the only thing left", () => {
339
+ const seed: CloudflaredState = {
340
+ version: 2,
341
+ tunnels: { parachute: sampleRecord },
342
+ pendingHostname: "a.example.com",
343
+ };
344
+ // Removing the last tunnel must NOT discard a typed-but-not-routed hostname.
345
+ expect(withoutTunnelRecord(seed, "parachute")).toEqual({
346
+ version: 2,
347
+ tunnels: {},
348
+ pendingHostname: "a.example.com",
349
+ });
350
+ });
351
+
352
+ test("withoutTunnelRecord returns undefined when no tunnels AND no pending hostname remain", () => {
353
+ const seed: CloudflaredState = { version: 2, tunnels: { parachute: sampleRecord } };
354
+ expect(withoutTunnelRecord(seed, "parachute")).toBeUndefined();
355
+ });
356
+ });
@@ -509,16 +509,31 @@ describe("exposeCloudflareUp", () => {
509
509
  }
510
510
  });
511
511
 
512
- test("errors out when vault isn't installed", async () => {
512
+ test("hub#564: continues (no vault gate) when vault isn't installed — routes the hub anyway", async () => {
513
513
  const env = makeEnv({ includeVault: false });
514
514
  try {
515
- const { runner } = queueRunner([{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }]);
516
- const { spawner } = fakeSpawner(0);
515
+ const uuid = "3d2b8d8f-2345-6789-abcd-ef0123456789";
516
+ const derived = "parachute-vault-example-com";
517
+ // The full chain must run now that the vault gate is gone: version,
518
+ // tunnel list, tunnel create, route dns.
519
+ const { runner } = queueRunner([
520
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
521
+ { code: 0, stdout: "[]", stderr: "" }, // tunnel list
522
+ {
523
+ code: 0,
524
+ stdout: `Created tunnel ${derived} with id ${uuid}\n`,
525
+ stderr: "",
526
+ }, // create
527
+ { code: 0, stdout: "", stderr: "" }, // route dns
528
+ ]);
529
+ const { spawner } = fakeSpawner(42000);
517
530
  const logs: string[] = [];
518
531
 
519
532
  const code = await exposeCloudflareUp("vault.example.com", {
520
533
  runner,
521
534
  spawner,
535
+ alive: () => false,
536
+ kill: () => {},
522
537
  log: (l) => logs.push(l),
523
538
  manifestPath: env.manifestPath,
524
539
  statePath: env.statePath,
@@ -528,10 +543,17 @@ describe("exposeCloudflareUp", () => {
528
543
  cloudflaredHome: env.cloudflaredHome,
529
544
  configDir: env.configDir,
530
545
  skipHub: true,
546
+ now: () => new Date("2026-04-22T12:00:00Z"),
531
547
  });
532
548
 
533
- expect(code).toBe(1);
534
- expect(logs.join("\n")).toContain("parachute install vault");
549
+ // The expose succeeds (the hub is what gets routed), with a courtesy
550
+ // note instead of a dead-end. No "install vault" gate, no Vault-URL
551
+ // footer (vault isn't there to point at).
552
+ expect(code).toBe(0);
553
+ const joined = logs.join("\n");
554
+ expect(joined).toContain("vault not installed yet");
555
+ expect(joined).not.toContain("nothing to route");
556
+ expect(joined).not.toMatch(/^\s*Vault:/m);
535
557
  } finally {
536
558
  env.cleanup();
537
559
  }
@@ -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 {
@@ -898,14 +898,15 @@ describe("init exposure chain", () => {
898
898
  }
899
899
  });
900
900
 
901
- test("exposure chain non-zero exit propagates", async () => {
901
+ test("hub#565: a failed exposure chain does NOT abort init — warns, continues, exits 0", async () => {
902
902
  const h = makeHarness();
903
903
  try {
904
904
  writeHubPort(1939, h.configDir);
905
+ const logs: string[] = [];
905
906
  const code = await init({
906
907
  configDir: h.configDir,
907
908
  manifestPath: h.manifestPath,
908
- log: () => {},
909
+ log: (l) => logs.push(l),
909
910
  alive: () => false,
910
911
  ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
911
912
  readExposeStateFn: () => undefined,
@@ -915,8 +916,46 @@ describe("init exposure chain", () => {
915
916
  exposeTailnetImpl: async () => 0,
916
917
  exposeCloudflareImpl: async () => 2,
917
918
  exposeChoice: "cloudflare",
919
+ installVaultModuleImpl: noopVaultInstall,
918
920
  });
919
- expect(code).toBe(2);
921
+ // Init reaches the admin-URL/wizard handoff regardless of the expose
922
+ // failure (exposure is an enhancement, not a prerequisite).
923
+ expect(code).toBe(0);
924
+ const joined = logs.join("\n");
925
+ // Warned about the failure + printed the exact retry command (the
926
+ // `--cloudflare` flag matters — bare `expose public` defaults to
927
+ // Tailscale, hub#566).
928
+ expect(joined).toContain("Couldn't finish setting up public access");
929
+ expect(joined).toContain("parachute expose public --cloudflare");
930
+ // Fell through to the loopback admin URL.
931
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
932
+ } finally {
933
+ h.cleanup();
934
+ }
935
+ });
936
+
937
+ test("hub#565: tailnet expose failure prints the --tailnet retry command", async () => {
938
+ const h = makeHarness();
939
+ try {
940
+ writeHubPort(1939, h.configDir);
941
+ const logs: string[] = [];
942
+ const code = await init({
943
+ configDir: h.configDir,
944
+ manifestPath: h.manifestPath,
945
+ log: (l) => logs.push(l),
946
+ alive: () => false,
947
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
948
+ readExposeStateFn: () => undefined,
949
+ isTty: false,
950
+ platform: "linux",
951
+ env: {},
952
+ exposeTailnetImpl: async () => 3,
953
+ exposeCloudflareImpl: async () => 0,
954
+ exposeChoice: "tailnet",
955
+ installVaultModuleImpl: noopVaultInstall,
956
+ });
957
+ expect(code).toBe(0);
958
+ expect(logs.join("\n")).toContain("parachute expose public --tailnet");
920
959
  } finally {
921
960
  h.cleanup();
922
961
  }
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { install } from "../commands/install.ts";
5
+ import { defaultStartLifecycleOpts, install } from "../commands/install.ts";
6
6
  import { findService, upsertService } from "../services-manifest.ts";
7
7
 
8
8
  function makeTempPath(): { path: string; configDir: string; cleanup: () => void } {
@@ -1721,3 +1721,26 @@ describe("install", () => {
1721
1721
  }
1722
1722
  });
1723
1723
  });
1724
+
1725
+ describe("hub#573 — install auto-start converges on supervised detection", () => {
1726
+ test("the default start opts opt into real supervisor detection + the migrate offer", () => {
1727
+ const log = () => {};
1728
+ const opts = defaultStartLifecycleOpts({
1729
+ manifestPath: "/tmp/services.json",
1730
+ configDir: "/tmp/cfg",
1731
+ log,
1732
+ });
1733
+ // `supervisor: {}` (present, even if empty) → lifecycle resolves
1734
+ // `unitInstalled` via the real `isHubUnitInstalled` probe instead of the
1735
+ // omitted-supervisor default of `false`. Pre-fix this block was absent, so
1736
+ // the auto-start ALWAYS concluded "no unit" and printed the spurious
1737
+ // "No supervised hub unit is installed" + "didn't start cleanly".
1738
+ expect(opts.supervisor).toEqual({});
1739
+ // The cutover offer is armed, matching `parachute start <svc>` (cli.ts).
1740
+ expect(opts.migrateOffer).toEqual({ enabled: true });
1741
+ // Plumbing preserved.
1742
+ expect(opts.manifestPath).toBe("/tmp/services.json");
1743
+ expect(opts.configDir).toBe("/tmp/cfg");
1744
+ expect(opts.log).toBe(log);
1745
+ });
1746
+ });
@@ -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. */
@@ -51,7 +51,7 @@ 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
56
  import type { VaultAuthStatus } from "../vault/auth-status.ts";
57
57
  import { printPublic2FAWarning } from "./expose-2fa-warning.ts";
@@ -91,6 +91,22 @@ export function isValidHostname(h: string): boolean {
91
91
  return h.split(".").every((label) => labelRe.test(label));
92
92
  }
93
93
 
94
+ /**
95
+ * Best-effort "is this module registered in services.json?" check, used only
96
+ * for the courtesy note in the Cloudflare expose path (hub#564). Uses the
97
+ * LENIENT manifest reader on purpose: the strict `readManifest` can reject an
98
+ * older manifest shape (the #564 diagnostic — a manifest written by 0.6.3-era
99
+ * registration that the strict reader bounced), and a courtesy note must never
100
+ * throw. Any read error is swallowed and treated as "not installed".
101
+ */
102
+ function serviceInstalled(manifestPath: string, name: string): boolean {
103
+ try {
104
+ return readManifestLenient(manifestPath).services.some((s) => s.name === name);
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
94
110
  export interface CloudflaredSpawner {
95
111
  spawn(cmd: readonly string[], logFile: string): number;
96
112
  }
@@ -606,12 +622,18 @@ export async function exposeCloudflareUp(
606
622
  return 1;
607
623
  }
608
624
 
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;
625
+ // No vault gate here (hub#564). cloudflared's ingress targets the HUB port
626
+ // (see `servicePort: hubPort` in `writeConfig` below + the long comment
627
+ // there) — the hub does ALL routing (discovery, admin, OAuth, well-known,
628
+ // per-vault proxy, generic /<svc>/* dispatch). Vault doesn't have to be
629
+ // installed for the tunnel to route anything. The old gate
630
+ // (`parachute-vault is not installed; nothing to route` → return 1) was a
631
+ // vestige of the pre-2026-05-27 vault-centric ingress, and it dead-ended
632
+ // fresh-server init: post-#168 init exposes (Step 2) BEFORE it installs the
633
+ // vault module (Step 2.5), so the gate always tripped on a fresh box and
634
+ // aborted the whole init. Courtesy note only — never block.
635
+ if (!serviceInstalled(r.manifestPath, "parachute-vault")) {
636
+ r.log("vault not installed yet — the admin wizard will set it up. Routing the hub anyway.");
615
637
  }
616
638
 
617
639
  // Resolve the public hub origin before spawning the hub server — it gets
@@ -897,45 +919,58 @@ export async function exposeCloudflareUp(
897
919
  // and makes the running vault re-read it immediately rather than waiting for
898
920
  // the next reboot.
899
921
  //
922
+ // hub#564: vault may not be installed yet (init exposes BEFORE installing the
923
+ // vault module). Resolve the entry once, here, and gate the vault-restart +
924
+ // the Vault-URL footer on it — restarting a not-installed vault would just
925
+ // fail and print a spurious warning. When present, a well-formed manifest
926
+ // always lists at least one mount path.
927
+ const vaultEntry = (() => {
928
+ try {
929
+ return readManifestLenient(r.manifestPath).services.find((s) => s.name === "parachute-vault");
930
+ } catch {
931
+ return undefined;
932
+ }
933
+ })();
934
+
900
935
  // §4.3c: drive the restart through the running Supervisor
901
936
  // (`driveModuleOp("vault", "restart")`), which re-injects the hub's current
902
937
  // origin; `restartHubDependentViaSupervisor` also persists the durable `.env`
903
938
  // + 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
- );
939
+ // `lifecycle.restart` arm. Skipped entirely when vault isn't installed.
940
+ if (vaultEntry) {
941
+ r.log("");
942
+ r.log("Restarting vault to pick up new hub origin…");
943
+ const rcode = await restartHubDependentViaSupervisor({
944
+ short: "vault",
945
+ hubOrigin,
946
+ configDir: r.configDir,
947
+ sup: r.sup,
948
+ log: r.log,
949
+ });
950
+ if (rcode !== 0) {
951
+ r.log(
952
+ "⚠ vault restart failed. Run manually once the issue is resolved: parachute restart vault",
953
+ );
954
+ }
918
955
  }
919
956
 
920
957
  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
- );
958
+ let vaultUrl: string | undefined;
959
+ if (vaultEntry) {
960
+ if (!vaultEntry.paths[0]) {
961
+ r.log(
962
+ `⚠ vault entry in services.json has no paths[]; defaulting to "/vault/default". Check the manifest.`,
963
+ );
964
+ }
965
+ vaultUrl = `${baseUrl}${vaultEntry.paths[0] ?? "/vault/default"}`;
929
966
  }
930
- const vaultMount = vaultEntry.paths[0] ?? "/vault/default";
931
- const vaultUrl = `${baseUrl}${vaultMount}`;
932
967
 
933
968
  r.log("");
934
969
  r.log(`✓ Cloudflare tunnel up (pid ${pid}).`);
935
970
  r.log(` Tunnel: ${r.tunnelName} (dedicated to this machine)`);
936
971
  r.log(` Open: ${baseUrl}/`);
937
972
  r.log(` Admin: ${baseUrl}/admin/`);
938
- r.log(` Vault: ${vaultUrl}`);
973
+ if (vaultUrl) r.log(` Vault: ${vaultUrl}`);
939
974
  r.log(` OAuth: ${hubOrigin}`);
940
975
  r.log(` Logs: ${r.logPath}`);
941
976
  r.log("");
@@ -954,10 +989,14 @@ export async function exposeCloudflareUp(
954
989
  r.log("background but does NOT survive a reboot. After a reboot, re-run:");
955
990
  r.log(` parachute expose public --cloudflare --domain ${hostname}`);
956
991
  }
957
- r.log("");
958
- r.log("Point a claude.ai / ChatGPT connector at:");
959
- r.log(` ${vaultUrl}`);
960
- printAuthGuidance(r.log, vaultUrl);
992
+ // The connector guidance points at a vault MCP URL — only meaningful once a
993
+ // vault is installed (hub#564: it may not be yet, when init exposes first).
994
+ if (vaultUrl) {
995
+ r.log("");
996
+ r.log("Point a claude.ai / ChatGPT connector at:");
997
+ r.log(` ${vaultUrl}`);
998
+ printAuthGuidance(r.log, vaultUrl);
999
+ }
961
1000
  // 2FA-enrollment warning when /admin/login is now reachable on the public
962
1001
  // internet but the operator hasn't enrolled TOTP. Cloudflare exposure is
963
1002
  // always public; tailnet/funnel mirrors this in `expose.ts`. See #186.
@@ -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
 
@@ -619,7 +619,12 @@ export async function init(opts: InitOpts = {}): Promise<number> {
619
619
  exposeTailnetImpl,
620
620
  exposeCloudflareImpl,
621
621
  });
622
- if (code !== 0) return code;
622
+ // hub#565: exposure is an ENHANCEMENT, not a prerequisite. A failed
623
+ // expose chain must NOT abort init — warn + print the exact retry
624
+ // command, then fall through to vault install + the admin-URL/wizard
625
+ // handoff on the loopback URL. Init's contract is hub up → vault module
626
+ // installed → admin URL → wizard, ALWAYS.
627
+ if (code !== 0) warnExposeFailedContinue(opts.exposeChoice, log);
623
628
  // Refresh state — the chain may have brought up an FQDN.
624
629
  exposeState = readExposeStateFn();
625
630
  } else if (opts.noExposePrompt) {
@@ -642,7 +647,9 @@ export async function init(opts: InitOpts = {}): Promise<number> {
642
647
  exposeTailnetImpl,
643
648
  exposeCloudflareImpl,
644
649
  });
645
- if (code !== 0) return code;
650
+ // hub#565: warn + continue on a failed expose chain rather than
651
+ // aborting init (same contract as the non-interactive branch above).
652
+ if (code !== 0) warnExposeFailedContinue(picked, log);
646
653
  exposeState = readExposeStateFn();
647
654
  }
648
655
  }
@@ -713,6 +720,13 @@ export async function init(opts: InitOpts = {}): Promise<number> {
713
720
  log("");
714
721
  log(` ${adminUrl}`);
715
722
  log("");
723
+ // hub#565: when we're on the loopback URL (no public exposure active),
724
+ // remind the operator they can expose later. Skipped once an FQDN is up.
725
+ if (!exposeState?.canonicalFqdn) {
726
+ log("(Reachable on this machine. To expose it publicly later, run");
727
+ log(" `parachute expose public --cloudflare` or `parachute expose public --tailnet`.)");
728
+ log("");
729
+ }
716
730
 
717
731
  // Step 4.5: offer the operator the CLI wizard vs. the browser wizard
718
732
  // (hub#168 Cut 4). Aaron's 2026-05-28 directive: "we should be able to
@@ -784,6 +798,32 @@ export async function init(opts: InitOpts = {}): Promise<number> {
784
798
  return 0;
785
799
  }
786
800
 
801
+ /** The exact retry command for a given exposure choice (hub#565 / #566). */
802
+ export function exposeRetryCommand(choice: ExposeChoice): string {
803
+ if (choice === "tailnet") return "parachute expose public --tailnet";
804
+ // `none` never reaches here in practice — `runExposureChoice("none")` always
805
+ // returns 0, so `warnExposeFailedContinue` (the only caller) is never invoked
806
+ // for it. It falls through to the `--cloudflare` branch below; harmless, and
807
+ // spelled out so the fallthrough isn't read as a bug.
808
+ // Cloudflare (and the unreachable `none`): default the bare command to
809
+ // `--cloudflare` so the operator who picked Cloudflare lands in the right
810
+ // provider on retry (bare `parachute expose public` defaults to Tailscale
811
+ // Funnel — hub#566).
812
+ return "parachute expose public --cloudflare";
813
+ }
814
+
815
+ /**
816
+ * hub#565: warn that the exposure chain failed but init is continuing anyway,
817
+ * and print the exact retry command. Exposure is an enhancement, not a
818
+ * prerequisite — init still installs the vault module and hands off to the
819
+ * wizard on the loopback URL.
820
+ */
821
+ function warnExposeFailedContinue(choice: ExposeChoice, log: (line: string) => void): void {
822
+ log("");
823
+ log("⚠ Couldn't finish setting up public access — continuing without it.");
824
+ log(` To expose publicly later, run: ${exposeRetryCommand(choice)}`);
825
+ }
826
+
787
827
  /**
788
828
  * Dispatch the chosen exposure path. Returns the exit code of the
789
829
  * downstream chain. `none` is a no-op (success).
@@ -27,7 +27,7 @@ import {
27
27
  } from "../service-spec.ts";
28
28
  import { findService, readManifest, upsertService } from "../services-manifest.ts";
29
29
  import { WELL_KNOWN_PATH } from "../well-known.ts";
30
- import { start as lifecycleStart } from "./lifecycle.ts";
30
+ import { type LifecycleOpts, start as lifecycleStart } from "./lifecycle.ts";
31
31
  import { migrateNotice } from "./migrate.ts";
32
32
  import {
33
33
  type InteractiveAvailability,
@@ -553,6 +553,39 @@ function resolveInstallTarget(
553
553
  return { kind: "npm", packageName: input };
554
554
  }
555
555
 
556
+ /**
557
+ * Build the LifecycleOpts the install auto-start uses (hub#573).
558
+ *
559
+ * The auto-start MUST thread the SAME supervisor + migrate-offer opts the
560
+ * production CLI dispatch passes for `parachute start <svc>` (cli.ts:
561
+ * `supervisor: {}` + `migrateOffer: { enabled: true }`). Without them, `start`
562
+ * resolved `unitInstalled` to its omitted-supervisor default of `false` and
563
+ * `migrateOffer.enabled` to `false` — so the auto-start ALWAYS took the no-unit
564
+ * path, printed "No supervised hub unit is installed. Run `parachute migrate
565
+ * --to-supervised`…", and returned non-zero → the "⚠ didn't start cleanly"
566
+ * warning. Meanwhile `parachute migrate` (which DOES run the real
567
+ * `isHubUnitInstalled` probe + /health) reported the unit already installed +
568
+ * healthy: the two paths disagreed because only `migrate` opted into real
569
+ * detection. `supervisor: {}` makes the auto-start run the same probe;
570
+ * `migrateOffer: { enabled: true }` makes it offer the cutover on a genuinely-
571
+ * unmigrated box instead of dumping a bare error mid-install.
572
+ *
573
+ * Exported so the convergence is unit-testable without driving a real start.
574
+ */
575
+ export function defaultStartLifecycleOpts(ctx: {
576
+ manifestPath: string;
577
+ configDir: string;
578
+ log: (line: string) => void;
579
+ }): LifecycleOpts {
580
+ return {
581
+ manifestPath: ctx.manifestPath,
582
+ configDir: ctx.configDir,
583
+ log: ctx.log,
584
+ supervisor: {},
585
+ migrateOffer: { enabled: true },
586
+ };
587
+ }
588
+
556
589
  export async function install(input: string, opts: InstallOpts = {}): Promise<number> {
557
590
  const runner = opts.runner ?? defaultRunner;
558
591
  const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
@@ -883,7 +916,8 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
883
916
  if (!opts.noStart && !opts.noCreate) {
884
917
  const startService =
885
918
  opts.startService ??
886
- ((short: string) => lifecycleStart(short, { manifestPath, configDir, log }));
919
+ ((short: string) =>
920
+ lifecycleStart(short, defaultStartLifecycleOpts({ manifestPath, configDir, log })));
887
921
  const startCode = await startService(short);
888
922
  if (startCode !== 0) {
889
923
  log(`⚠ ${short} didn't start cleanly. Run manually: parachute start ${short}`);