@openparachute/hub 0.6.4-rc.4 → 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.
@@ -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
+ });