@openparachute/hub 0.5.14-rc.3 → 0.5.14-rc.5

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.5.14-rc.3",
3
+ "version": "0.5.14-rc.5",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -59,10 +59,65 @@ describe("cloudflare detect", () => {
59
59
  }
60
60
  });
61
61
 
62
- test("install hint names brew on darwin and a URL elsewhere", () => {
63
- expect(cloudflaredInstallHint("darwin")).toContain("brew install cloudflared");
64
- expect(cloudflaredInstallHint("linux")).toContain(
65
- "developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads",
66
- );
62
+ describe("cloudflaredInstallHint", () => {
63
+ test("darwin: names brew + points at GitHub releases as fallback", () => {
64
+ const hint = cloudflaredInstallHint("darwin", "arm64");
65
+ expect(hint).toContain("brew install cloudflared");
66
+ expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
67
+ });
68
+
69
+ test("linux x64: writes the curl line with the amd64 artifact suffix", () => {
70
+ // Refresh of stale URLs (2026-05-27). Aaron hit this on a fresh
71
+ // Amazon Linux 2023 install — `sudo dnf install cloudflared`
72
+ // returned 'No match for argument: cloudflared', and the hub's
73
+ // hint pointed at developers.cloudflare.com paths that 404. The
74
+ // GitHub release is the reliable cross-distro path.
75
+ const hint = cloudflaredInstallHint("linux", "x64");
76
+ expect(hint).toContain("curl -L -o /usr/local/bin/cloudflared");
77
+ expect(hint).toContain(
78
+ "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64",
79
+ );
80
+ expect(hint).toContain("sudo chmod +x /usr/local/bin/cloudflared");
81
+ });
82
+
83
+ test("linux arm64: writes the arm64 artifact suffix", () => {
84
+ const hint = cloudflaredInstallHint("linux", "arm64");
85
+ expect(hint).toContain("cloudflared-linux-arm64");
86
+ });
87
+
88
+ test("linux arm (32-bit): writes the arm artifact suffix", () => {
89
+ const hint = cloudflaredInstallHint("linux", "arm");
90
+ expect(hint).toContain("cloudflared-linux-arm");
91
+ });
92
+
93
+ test("linux exotic arch: falls back to a generic GitHub releases pointer", () => {
94
+ // riscv64 / ppc64 / mips64 — no cloudflared artifact published, so
95
+ // we don't fabricate a 404-bound download URL; we point the user at
96
+ // the releases page and surface what their arch is so they can pick.
97
+ const hint = cloudflaredInstallHint("linux", "riscv64");
98
+ expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
99
+ expect(hint).toContain("riscv64");
100
+ expect(hint).not.toContain("curl -L -o /usr/local/bin/cloudflared");
101
+ });
102
+
103
+ test("no stale developers.cloudflare.com or pkg.cloudflare.com paths anywhere", () => {
104
+ // Aaron caught both URL shapes returning HTML/404 on 2026-05-27 —
105
+ // they had been the hub's installer instructions for months.
106
+ // Hard-assert they're gone so they don't regress.
107
+ for (const platform of ["darwin", "linux"] as const) {
108
+ for (const arch of ["x64", "arm64"] as const) {
109
+ const hint = cloudflaredInstallHint(platform, arch);
110
+ expect(hint).not.toContain("developers.cloudflare.com");
111
+ expect(hint).not.toContain("pkg.cloudflare.com");
112
+ }
113
+ }
114
+ });
115
+
116
+ test("non-Linux, non-darwin platform: GitHub releases pointer with no curl line", () => {
117
+ const hint = cloudflaredInstallHint("win32", "x64");
118
+ expect(hint).toContain("https://github.com/cloudflare/cloudflared/releases/latest");
119
+ expect(hint).not.toContain("brew install");
120
+ expect(hint).not.toContain("curl -L -o");
121
+ });
67
122
  });
68
123
  });
@@ -14,8 +14,13 @@ import {
14
14
  exposeCloudflareOff,
15
15
  exposeCloudflareUp,
16
16
  } from "../commands/expose-cloudflare.ts";
17
+ import { writeHubPort } from "../hub-control.ts";
17
18
  import type { CommandResult, Runner } from "../tailscale/run.ts";
18
19
 
20
+ // Default seeded hub port used by tests with `skipHub: true`. The cloudflared
21
+ // path reads `<configDir>/hub/run/hub.port` instead of spawning a real hub.
22
+ const TEST_HUB_PORT = 1939;
23
+
19
24
  interface TestEnv {
20
25
  configDir: string;
21
26
  manifestPath: string;
@@ -41,6 +46,11 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
41
46
  require("node:fs").mkdirSync(configDir, { recursive: true });
42
47
  require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
43
48
 
49
+ // Seed the hub port so `skipHub: true` invocations can resolve a port
50
+ // without spawning the actual hub process. Matches the seam pattern used
51
+ // by expose.test.ts (which threads `hubEnsureOpts` for the same purpose).
52
+ writeHubPort(TEST_HUB_PORT, configDir);
53
+
44
54
  if (loggedIn) {
45
55
  writeFileSync(join(cloudflaredHome, "cert.pem"), "---");
46
56
  }
@@ -128,6 +138,8 @@ describe("exposeCloudflareUp", () => {
128
138
  configPath: env.configPath,
129
139
  logPath: env.logPath,
130
140
  cloudflaredHome: env.cloudflaredHome,
141
+ configDir: env.configDir,
142
+ skipHub: true,
131
143
  now: () => new Date("2026-04-22T12:00:00Z"),
132
144
  });
133
145
 
@@ -170,7 +182,12 @@ describe("exposeCloudflareUp", () => {
170
182
  const yaml = readFileSync(env.configPath, "utf8");
171
183
  expect(yaml).toContain(`tunnel: ${uuid}`);
172
184
  expect(yaml).toContain("- hostname: vault.example.com");
173
- expect(yaml).toContain("service: http://localhost:1940");
185
+ // Routes through the hub (not directly at vault). The hub dispatches
186
+ // discovery / admin / OAuth / per-vault proxy / generic /<svc>/* —
187
+ // same shape Tailscale Funnel uses. Pre-2026-05-27 this was
188
+ // http://localhost:1940 (vault's port), which served vault's own 404
189
+ // page on every request that wasn't /vault/<name>/...
190
+ expect(yaml).toContain(`service: http://localhost:${TEST_HUB_PORT}`);
174
191
 
175
192
  // Security copy surfaces both paths plus a pointer to the auth doc.
176
193
  const joined = logs.join("\n");
@@ -209,6 +226,8 @@ describe("exposeCloudflareUp", () => {
209
226
  configPath: env.configPath,
210
227
  logPath: env.logPath,
211
228
  cloudflaredHome: env.cloudflaredHome,
229
+ configDir: env.configDir,
230
+ skipHub: true,
212
231
  });
213
232
  expect(code).toBe(0);
214
233
  // No `tunnel create` — only list + route.
@@ -236,6 +255,8 @@ describe("exposeCloudflareUp", () => {
236
255
  configPath: env.configPath,
237
256
  logPath: env.logPath,
238
257
  cloudflaredHome: env.cloudflaredHome,
258
+ configDir: env.configDir,
259
+ skipHub: true,
239
260
  });
240
261
 
241
262
  expect(code).toBe(1);
@@ -262,6 +283,8 @@ describe("exposeCloudflareUp", () => {
262
283
  configPath: env.configPath,
263
284
  logPath: env.logPath,
264
285
  cloudflaredHome: env.cloudflaredHome,
286
+ configDir: env.configDir,
287
+ skipHub: true,
265
288
  tunnelName: "bad name with spaces",
266
289
  });
267
290
 
@@ -291,6 +314,8 @@ describe("exposeCloudflareUp", () => {
291
314
  configPath: env.configPath,
292
315
  logPath: env.logPath,
293
316
  cloudflaredHome: env.cloudflaredHome,
317
+ configDir: env.configDir,
318
+ skipHub: true,
294
319
  });
295
320
 
296
321
  expect(code).toBe(1);
@@ -317,6 +342,8 @@ describe("exposeCloudflareUp", () => {
317
342
  configPath: env.configPath,
318
343
  logPath: env.logPath,
319
344
  cloudflaredHome: env.cloudflaredHome,
345
+ configDir: env.configDir,
346
+ skipHub: true,
320
347
  });
321
348
 
322
349
  expect(code).toBe(1);
@@ -342,6 +369,8 @@ describe("exposeCloudflareUp", () => {
342
369
  configPath: env.configPath,
343
370
  logPath: env.logPath,
344
371
  cloudflaredHome: env.cloudflaredHome,
372
+ configDir: env.configDir,
373
+ skipHub: true,
345
374
  });
346
375
 
347
376
  expect(code).toBe(1);
@@ -376,6 +405,8 @@ describe("exposeCloudflareUp", () => {
376
405
  configPath: env.configPath,
377
406
  logPath: env.logPath,
378
407
  cloudflaredHome: env.cloudflaredHome,
408
+ configDir: env.configDir,
409
+ skipHub: true,
379
410
  });
380
411
 
381
412
  expect(code).toBe(1);
@@ -425,6 +456,8 @@ describe("exposeCloudflareUp", () => {
425
456
  configPath: env.configPath,
426
457
  logPath: env.logPath,
427
458
  cloudflaredHome: env.cloudflaredHome,
459
+ configDir: env.configDir,
460
+ skipHub: true,
428
461
  });
429
462
 
430
463
  expect(code).toBe(0);
@@ -462,6 +495,8 @@ describe("exposeCloudflareUp", () => {
462
495
  manifestPath: env.manifestPath,
463
496
  statePath: env.statePath,
464
497
  cloudflaredHome: env.cloudflaredHome,
498
+ configDir: env.configDir,
499
+ skipHub: true,
465
500
  // Use defaults for configPath/logPath so they're per-tunnel-derived.
466
501
  });
467
502
  expect(code1).toBe(0);
@@ -487,6 +522,8 @@ describe("exposeCloudflareUp", () => {
487
522
  manifestPath: env.manifestPath,
488
523
  statePath: env.statePath,
489
524
  cloudflaredHome: env.cloudflaredHome,
525
+ configDir: env.configDir,
526
+ skipHub: true,
490
527
  tunnelName: "second",
491
528
  });
492
529
  expect(code2).toBe(0);
@@ -544,6 +581,8 @@ describe("exposeCloudflareUp", () => {
544
581
  configPath: env.configPath,
545
582
  logPath: env.logPath,
546
583
  cloudflaredHome: env.cloudflaredHome,
584
+ configDir: env.configDir,
585
+ skipHub: true,
547
586
  // No password, no 2FA — fully wide open. The warning should still
548
587
  // fire; password-recovery copy already lives in `printAuthGuidance`.
549
588
  vaultAuthStatus: {
@@ -592,6 +631,8 @@ describe("exposeCloudflareUp", () => {
592
631
  configPath: env.configPath,
593
632
  logPath: env.logPath,
594
633
  cloudflaredHome: env.cloudflaredHome,
634
+ configDir: env.configDir,
635
+ skipHub: true,
595
636
  vaultAuthStatus: {
596
637
  hasOwnerPassword: true,
597
638
  hasTotp: true,
@@ -612,6 +653,68 @@ describe("exposeCloudflareUp", () => {
612
653
  }
613
654
  });
614
655
  });
656
+
657
+ describe("routes through hub, not vault", () => {
658
+ test("config.yml targets the hub port; success log mentions Admin + OAuth URLs", async () => {
659
+ // Regression guard for the 2026-05-27 cut. Aaron ran `parachute expose
660
+ // public` on a fresh EC2 box, configured Cloudflare with a custom
661
+ // domain, and hit it — and got vault's 404 page rather than the hub's
662
+ // discovery / admin. The pre-fix cloudflared config routed straight at
663
+ // vault's port; the fix routes at the hub, mirroring the Tailscale
664
+ // Funnel shape (single mount → hub catchall; hub dispatches per-request).
665
+ const env = makeEnv();
666
+ try {
667
+ // Re-seed hub port to a non-default value so the assertion is
668
+ // unambiguous about *which* port got into the yaml.
669
+ writeHubPort(1949, env.configDir);
670
+
671
+ const uuid = "ffff0000-0000-0000-0000-00000000beef";
672
+ const { runner } = queueRunner([
673
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
674
+ { code: 0, stdout: "[]", stderr: "" },
675
+ {
676
+ code: 0,
677
+ stdout: `Created tunnel parachute with id ${uuid}\n`,
678
+ stderr: "",
679
+ },
680
+ { code: 0, stdout: "", stderr: "" },
681
+ ]);
682
+ const { spawner } = fakeSpawner(60001);
683
+ const logs: string[] = [];
684
+
685
+ const code = await exposeCloudflareUp("gitcoin.parachute.computer", {
686
+ runner,
687
+ spawner,
688
+ alive: () => false,
689
+ kill: () => {},
690
+ log: (l) => logs.push(l),
691
+ manifestPath: env.manifestPath,
692
+ statePath: env.statePath,
693
+ configPath: env.configPath,
694
+ logPath: env.logPath,
695
+ cloudflaredHome: env.cloudflaredHome,
696
+ configDir: env.configDir,
697
+ skipHub: true,
698
+ });
699
+
700
+ expect(code).toBe(0);
701
+ const yaml = readFileSync(env.configPath, "utf8");
702
+ // Routes through the hub on its loopback port.
703
+ expect(yaml).toContain("service: http://localhost:1949");
704
+ // Does NOT route directly at vault's port (1940 per makeEnv default).
705
+ expect(yaml).not.toContain("service: http://localhost:1940");
706
+
707
+ const joined = logs.join("\n");
708
+ // Discoverable surfaces: open / admin / vault / OAuth all surfaced.
709
+ expect(joined).toContain("https://gitcoin.parachute.computer/");
710
+ expect(joined).toContain("Admin: https://gitcoin.parachute.computer/admin/");
711
+ expect(joined).toContain("Vault: https://gitcoin.parachute.computer/vault/default");
712
+ expect(joined).toContain("OAuth: https://gitcoin.parachute.computer");
713
+ } finally {
714
+ env.cleanup();
715
+ }
716
+ });
717
+ });
615
718
  });
616
719
 
617
720
  describe("exposeCloudflareOff", () => {
@@ -469,10 +469,16 @@ describe("exposePublicInteractive — neither ready", () => {
469
469
  expect(interactiveCalled).toBe(false);
470
470
  expect(cloudflareCalled).toBe(false);
471
471
  const joined = logs.join("\n");
472
- expect(joined).toMatch(/apt-get|dnf/);
473
- expect(joined).toContain(
474
- "developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads",
475
- );
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.
477
+ expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
478
+ expect(joined).toContain("curl -L -o /usr/local/bin/cloudflared");
479
+ expect(joined).not.toContain("developers.cloudflare.com");
480
+ expect(joined).not.toContain("pkg.cloudflare.com");
481
+ expect(joined).not.toContain("sudo dnf install cloudflared");
476
482
  } finally {
477
483
  env.cleanup();
478
484
  }
@@ -126,7 +126,11 @@ describe("exposePublicAutoPick — neither ready", () => {
126
126
  expect(code).toBe(1);
127
127
  const joined = logs.join("\n");
128
128
  expect(joined).toContain("tailscale.com/download");
129
- expect(joined).toContain("developers.cloudflare.com");
129
+ // Post 2026-05-27 cloudflared-URL refresh: the install hint now points
130
+ // at GitHub releases (developers.cloudflare.com / pkg.cloudflare.com
131
+ // both returned HTML/404 on Aaron's fresh AL2023 EC2 box).
132
+ expect(joined).toContain("github.com/cloudflare/cloudflared/releases/latest");
133
+ expect(joined).not.toContain("developers.cloudflare.com");
130
134
  expect(joined).toContain("--skip-provider-check");
131
135
  });
132
136
 
@@ -0,0 +1,344 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { init, resolveAdminUrl } from "../commands/init.ts";
6
+ import type { ExposeState } from "../expose-state.ts";
7
+ import { writeHubPort } from "../hub-control.ts";
8
+ import { writePid } from "../process-state.ts";
9
+
10
+ interface Harness {
11
+ configDir: string;
12
+ manifestPath: string;
13
+ cleanup: () => void;
14
+ }
15
+
16
+ function makeHarness(): Harness {
17
+ const dir = mkdtempSync(join(tmpdir(), "pcli-init-"));
18
+ const manifestPath = join(dir, "services.json");
19
+ writeFileSync(manifestPath, JSON.stringify({ services: [] }));
20
+ return {
21
+ configDir: dir,
22
+ manifestPath,
23
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
24
+ };
25
+ }
26
+
27
+ function seedVault(manifestPath: string): void {
28
+ writeFileSync(
29
+ manifestPath,
30
+ JSON.stringify({
31
+ services: [
32
+ {
33
+ name: "parachute-vault",
34
+ version: "0.5.0",
35
+ port: 1940,
36
+ paths: ["/vault/default"],
37
+ health: "/health",
38
+ icon: "/icon.svg",
39
+ auth: { type: "none" },
40
+ mcp: {},
41
+ },
42
+ ],
43
+ }),
44
+ );
45
+ }
46
+
47
+ describe("resolveAdminUrl", () => {
48
+ test("prefers expose-state FQDN when present", () => {
49
+ const state: ExposeState = {
50
+ version: 1,
51
+ layer: "tailnet",
52
+ mode: "path",
53
+ canonicalFqdn: "box-1.tailnet.ts.net",
54
+ port: 443,
55
+ funnel: false,
56
+ entries: [],
57
+ hubOrigin: "https://box-1.tailnet.ts.net",
58
+ };
59
+ expect(resolveAdminUrl(state, 1939)).toBe("https://box-1.tailnet.ts.net/admin/");
60
+ });
61
+
62
+ test("falls back to loopback + hub port when no exposure", () => {
63
+ expect(resolveAdminUrl(undefined, 1939)).toBe("http://127.0.0.1:1939/admin/");
64
+ });
65
+
66
+ test("undefined when neither expose-state nor a hub port is known", () => {
67
+ expect(resolveAdminUrl(undefined, undefined)).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ describe("init", () => {
72
+ test("starts the hub when not running and prints the loopback admin URL", async () => {
73
+ const h = makeHarness();
74
+ try {
75
+ const calls: string[] = [];
76
+ const logs: string[] = [];
77
+ const code = await init({
78
+ configDir: h.configDir,
79
+ manifestPath: h.manifestPath,
80
+ log: (l) => logs.push(l),
81
+ alive: () => false,
82
+ ensureHub: async () => {
83
+ calls.push("ensureHub");
84
+ // Seed the port file as a real ensureHubRunning would.
85
+ writeHubPort(1939, h.configDir);
86
+ return { pid: 5555, port: 1939, started: true };
87
+ },
88
+ readExposeStateFn: () => undefined,
89
+ isTty: false,
90
+ platform: "linux",
91
+ });
92
+ expect(code).toBe(0);
93
+ expect(calls).toEqual(["ensureHub"]);
94
+ const joined = logs.join("\n");
95
+ expect(joined).toContain("Hub not running — starting it now");
96
+ expect(joined).toContain("Hub started (pid 5555, port 1939)");
97
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
98
+ expect(joined).toContain("finish setup in the admin wizard");
99
+ } finally {
100
+ h.cleanup();
101
+ }
102
+ });
103
+
104
+ test("idempotent: skips ensureHub and confirms 'looks good' when hub up + vault configured", async () => {
105
+ const h = makeHarness();
106
+ try {
107
+ // Seed: hub running + vault row exists.
108
+ mkdirSync(join(h.configDir, "hub", "run"), { recursive: true });
109
+ writePid("hub", 1234, h.configDir);
110
+ writeHubPort(1939, h.configDir);
111
+ seedVault(h.manifestPath);
112
+
113
+ const calls: string[] = [];
114
+ const logs: string[] = [];
115
+ const code = await init({
116
+ configDir: h.configDir,
117
+ manifestPath: h.manifestPath,
118
+ log: (l) => logs.push(l),
119
+ alive: () => true,
120
+ ensureHub: async () => {
121
+ calls.push("ensureHub");
122
+ return { pid: 1234, port: 1939, started: false };
123
+ },
124
+ readExposeStateFn: () => undefined,
125
+ isTty: false,
126
+ platform: "linux",
127
+ });
128
+ expect(code).toBe(0);
129
+ // Hub was already running — ensureHub should not have been called.
130
+ expect(calls).toEqual([]);
131
+ const joined = logs.join("\n");
132
+ expect(joined).toContain("Hub already running (pid 1234, port 1939)");
133
+ expect(joined).toContain("Looks good");
134
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
135
+ } finally {
136
+ h.cleanup();
137
+ }
138
+ });
139
+
140
+ test("prefers the exposed FQDN over loopback", async () => {
141
+ const h = makeHarness();
142
+ try {
143
+ mkdirSync(join(h.configDir, "hub", "run"), { recursive: true });
144
+ writePid("hub", 4321, h.configDir);
145
+ writeHubPort(1939, h.configDir);
146
+
147
+ const state: ExposeState = {
148
+ version: 1,
149
+ layer: "public",
150
+ mode: "path",
151
+ canonicalFqdn: "gitcoin.parachute.computer",
152
+ port: 443,
153
+ funnel: false,
154
+ entries: [],
155
+ hubOrigin: "https://gitcoin.parachute.computer",
156
+ };
157
+
158
+ const logs: string[] = [];
159
+ const code = await init({
160
+ configDir: h.configDir,
161
+ manifestPath: h.manifestPath,
162
+ log: (l) => logs.push(l),
163
+ alive: () => true,
164
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
165
+ readExposeStateFn: () => state,
166
+ isTty: false,
167
+ platform: "linux",
168
+ });
169
+ expect(code).toBe(0);
170
+ expect(logs.join("\n")).toContain("https://gitcoin.parachute.computer/admin/");
171
+ // Not the loopback fallback.
172
+ expect(logs.join("\n")).not.toContain("http://127.0.0.1");
173
+ } finally {
174
+ h.cleanup();
175
+ }
176
+ });
177
+
178
+ test("offers to open the browser in a TTY; 'y' invokes openBrowser", async () => {
179
+ const h = makeHarness();
180
+ try {
181
+ writeHubPort(1939, h.configDir);
182
+ const opened: string[] = [];
183
+ const code = await init({
184
+ configDir: h.configDir,
185
+ manifestPath: h.manifestPath,
186
+ log: () => {},
187
+ alive: () => false,
188
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
189
+ readExposeStateFn: () => undefined,
190
+ isTty: true,
191
+ platform: "darwin",
192
+ prompt: async () => "y",
193
+ openBrowser: (url) => {
194
+ opened.push(url);
195
+ return true;
196
+ },
197
+ });
198
+ expect(code).toBe(0);
199
+ expect(opened).toEqual(["http://127.0.0.1:1939/admin/"]);
200
+ } finally {
201
+ h.cleanup();
202
+ }
203
+ });
204
+
205
+ test("offers to open the browser in a TTY; 'n' skips openBrowser", async () => {
206
+ const h = makeHarness();
207
+ try {
208
+ writeHubPort(1939, h.configDir);
209
+ const opened: string[] = [];
210
+ const code = await init({
211
+ configDir: h.configDir,
212
+ manifestPath: h.manifestPath,
213
+ log: () => {},
214
+ alive: () => false,
215
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
216
+ readExposeStateFn: () => undefined,
217
+ isTty: true,
218
+ platform: "darwin",
219
+ prompt: async () => "n",
220
+ openBrowser: (url) => {
221
+ opened.push(url);
222
+ return true;
223
+ },
224
+ });
225
+ expect(code).toBe(0);
226
+ expect(opened).toEqual([]);
227
+ } finally {
228
+ h.cleanup();
229
+ }
230
+ });
231
+
232
+ test("--no-browser skips the prompt and openBrowser entirely", async () => {
233
+ const h = makeHarness();
234
+ try {
235
+ writeHubPort(1939, h.configDir);
236
+ const opened: string[] = [];
237
+ let prompted = false;
238
+ const code = await init({
239
+ configDir: h.configDir,
240
+ manifestPath: h.manifestPath,
241
+ log: () => {},
242
+ alive: () => false,
243
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
244
+ readExposeStateFn: () => undefined,
245
+ isTty: true,
246
+ platform: "darwin",
247
+ prompt: async () => {
248
+ prompted = true;
249
+ return "y";
250
+ },
251
+ openBrowser: (url) => {
252
+ opened.push(url);
253
+ return true;
254
+ },
255
+ noBrowser: true,
256
+ });
257
+ expect(code).toBe(0);
258
+ expect(prompted).toBe(false);
259
+ expect(opened).toEqual([]);
260
+ } finally {
261
+ h.cleanup();
262
+ }
263
+ });
264
+
265
+ test("non-TTY prints the URL and exits without prompting", async () => {
266
+ const h = makeHarness();
267
+ try {
268
+ writeHubPort(1939, h.configDir);
269
+ let prompted = false;
270
+ const code = await init({
271
+ configDir: h.configDir,
272
+ manifestPath: h.manifestPath,
273
+ log: () => {},
274
+ alive: () => false,
275
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
276
+ readExposeStateFn: () => undefined,
277
+ isTty: false,
278
+ platform: "darwin",
279
+ prompt: async () => {
280
+ prompted = true;
281
+ return "y";
282
+ },
283
+ openBrowser: () => true,
284
+ });
285
+ expect(code).toBe(0);
286
+ expect(prompted).toBe(false);
287
+ } finally {
288
+ h.cleanup();
289
+ }
290
+ });
291
+
292
+ test("Windows / unsupported platform: skip browser launch, just print", async () => {
293
+ const h = makeHarness();
294
+ try {
295
+ writeHubPort(1939, h.configDir);
296
+ const opened: string[] = [];
297
+ const code = await init({
298
+ configDir: h.configDir,
299
+ manifestPath: h.manifestPath,
300
+ log: () => {},
301
+ alive: () => false,
302
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
303
+ readExposeStateFn: () => undefined,
304
+ isTty: true,
305
+ platform: "win32",
306
+ prompt: async () => "y",
307
+ openBrowser: (url) => {
308
+ opened.push(url);
309
+ return true;
310
+ },
311
+ });
312
+ expect(code).toBe(0);
313
+ // No prompt offered on Windows — just URL printed.
314
+ expect(opened).toEqual([]);
315
+ } finally {
316
+ h.cleanup();
317
+ }
318
+ });
319
+
320
+ test("ensureHub failure exits 1 with an actionable hint", async () => {
321
+ const h = makeHarness();
322
+ try {
323
+ const logs: string[] = [];
324
+ const code = await init({
325
+ configDir: h.configDir,
326
+ manifestPath: h.manifestPath,
327
+ log: (l) => logs.push(l),
328
+ alive: () => false,
329
+ ensureHub: async () => {
330
+ throw new Error("port 1939 is in use");
331
+ },
332
+ readExposeStateFn: () => undefined,
333
+ isTty: false,
334
+ platform: "linux",
335
+ });
336
+ expect(code).toBe(1);
337
+ const joined = logs.join("\n");
338
+ expect(joined).toContain("Hub failed to start: port 1939 is in use");
339
+ expect(joined).toContain("parachute logs hub");
340
+ } finally {
341
+ h.cleanup();
342
+ }
343
+ });
344
+ });