@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -0,0 +1,928 @@
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 { hasNoDisplay, init, looksLikeServer, 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
+ /**
28
+ * Default test-stub for the vault-module install step (hub#168 Cut 1).
29
+ * The real `installVaultModuleImpl` shells out to `bun add -g
30
+ * @openparachute/vault` + seeds services.json — neither is appropriate in
31
+ * a unit test (slow + side-effectful + leaks state across runs). Tests
32
+ * that want to observe install-flow side-effects (services.json shape,
33
+ * etc.) can override this with their own stub.
34
+ */
35
+ const noopVaultInstall = async (_configDir: string, _manifestPath: string): Promise<number> => 0;
36
+
37
+ function seedVault(manifestPath: string): void {
38
+ writeFileSync(
39
+ manifestPath,
40
+ JSON.stringify({
41
+ services: [
42
+ {
43
+ name: "parachute-vault",
44
+ version: "0.5.0",
45
+ port: 1940,
46
+ paths: ["/vault/default"],
47
+ health: "/health",
48
+ icon: "/icon.svg",
49
+ auth: { type: "none" },
50
+ mcp: {},
51
+ },
52
+ ],
53
+ }),
54
+ );
55
+ }
56
+
57
+ describe("resolveAdminUrl", () => {
58
+ test("prefers expose-state FQDN when present", () => {
59
+ const state: ExposeState = {
60
+ version: 1,
61
+ layer: "tailnet",
62
+ mode: "path",
63
+ canonicalFqdn: "box-1.tailnet.ts.net",
64
+ port: 443,
65
+ funnel: false,
66
+ entries: [],
67
+ hubOrigin: "https://box-1.tailnet.ts.net",
68
+ };
69
+ expect(resolveAdminUrl(state, 1939)).toBe("https://box-1.tailnet.ts.net/admin/");
70
+ });
71
+
72
+ test("falls back to loopback + hub port when no exposure", () => {
73
+ expect(resolveAdminUrl(undefined, 1939)).toBe("http://127.0.0.1:1939/admin/");
74
+ });
75
+
76
+ test("undefined when neither expose-state nor a hub port is known", () => {
77
+ expect(resolveAdminUrl(undefined, undefined)).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ describe("init", () => {
82
+ test("starts the hub when not running and prints the loopback admin URL", async () => {
83
+ const h = makeHarness();
84
+ try {
85
+ const calls: string[] = [];
86
+ const logs: string[] = [];
87
+ const code = await init({
88
+ configDir: h.configDir,
89
+ manifestPath: h.manifestPath,
90
+ log: (l) => logs.push(l),
91
+ alive: () => false,
92
+ ensureHub: async () => {
93
+ calls.push("ensureHub");
94
+ // Seed the port file as a real ensureHubRunning would.
95
+ writeHubPort(1939, h.configDir);
96
+ return { pid: 5555, port: 1939, started: true };
97
+ },
98
+ readExposeStateFn: () => undefined,
99
+ isTty: false,
100
+ platform: "linux",
101
+ installVaultModuleImpl: noopVaultInstall,
102
+ });
103
+ expect(code).toBe(0);
104
+ expect(calls).toEqual(["ensureHub"]);
105
+ const joined = logs.join("\n");
106
+ expect(joined).toContain("Hub not running — starting it now");
107
+ expect(joined).toContain("Hub started (pid 5555, port 1939)");
108
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
109
+ expect(joined).toContain("finish setup in the admin wizard");
110
+ } finally {
111
+ h.cleanup();
112
+ }
113
+ });
114
+
115
+ test("idempotent: skips ensureHub and confirms 'looks good' when hub up + vault configured", async () => {
116
+ const h = makeHarness();
117
+ try {
118
+ // Seed: hub running + vault row exists.
119
+ mkdirSync(join(h.configDir, "hub", "run"), { recursive: true });
120
+ writePid("hub", 1234, h.configDir);
121
+ writeHubPort(1939, h.configDir);
122
+ seedVault(h.manifestPath);
123
+
124
+ const calls: string[] = [];
125
+ const logs: string[] = [];
126
+ const code = await init({
127
+ configDir: h.configDir,
128
+ manifestPath: h.manifestPath,
129
+ log: (l) => logs.push(l),
130
+ alive: () => true,
131
+ ensureHub: async () => {
132
+ calls.push("ensureHub");
133
+ return { pid: 1234, port: 1939, started: false };
134
+ },
135
+ readExposeStateFn: () => undefined,
136
+ isTty: false,
137
+ platform: "linux",
138
+ installVaultModuleImpl: noopVaultInstall,
139
+ });
140
+ expect(code).toBe(0);
141
+ // Hub was already running — ensureHub should not have been called.
142
+ expect(calls).toEqual([]);
143
+ const joined = logs.join("\n");
144
+ expect(joined).toContain("Hub already running (pid 1234, port 1939)");
145
+ expect(joined).toContain("Looks good");
146
+ expect(joined).toContain("http://127.0.0.1:1939/admin/");
147
+ } finally {
148
+ h.cleanup();
149
+ }
150
+ });
151
+
152
+ test("prefers the exposed FQDN over loopback", async () => {
153
+ const h = makeHarness();
154
+ try {
155
+ mkdirSync(join(h.configDir, "hub", "run"), { recursive: true });
156
+ writePid("hub", 4321, h.configDir);
157
+ writeHubPort(1939, h.configDir);
158
+
159
+ const state: ExposeState = {
160
+ version: 1,
161
+ layer: "public",
162
+ mode: "path",
163
+ canonicalFqdn: "gitcoin.parachute.computer",
164
+ port: 443,
165
+ funnel: false,
166
+ entries: [],
167
+ hubOrigin: "https://gitcoin.parachute.computer",
168
+ };
169
+
170
+ const logs: string[] = [];
171
+ const code = await init({
172
+ configDir: h.configDir,
173
+ manifestPath: h.manifestPath,
174
+ log: (l) => logs.push(l),
175
+ alive: () => true,
176
+ ensureHub: async () => ({ pid: 4321, port: 1939, started: false }),
177
+ readExposeStateFn: () => state,
178
+ isTty: false,
179
+ platform: "linux",
180
+ });
181
+ expect(code).toBe(0);
182
+ expect(logs.join("\n")).toContain("https://gitcoin.parachute.computer/admin/");
183
+ // Not the loopback fallback.
184
+ expect(logs.join("\n")).not.toContain("http://127.0.0.1");
185
+ } finally {
186
+ h.cleanup();
187
+ }
188
+ });
189
+
190
+ test("offers to open the browser in a TTY; 'y' invokes openBrowser", async () => {
191
+ const h = makeHarness();
192
+ try {
193
+ writeHubPort(1939, h.configDir);
194
+ const opened: string[] = [];
195
+ const code = await init({
196
+ configDir: h.configDir,
197
+ manifestPath: h.manifestPath,
198
+ log: () => {},
199
+ alive: () => false,
200
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
201
+ readExposeStateFn: () => undefined,
202
+ isTty: true,
203
+ platform: "darwin",
204
+ prompt: async () => "y",
205
+ openBrowser: (url) => {
206
+ opened.push(url);
207
+ return true;
208
+ },
209
+ // Skip the new exposure prompt — this test is about the browser prompt only.
210
+ noExposePrompt: true,
211
+ // Pre-pick the browser wizard so the new (hub#168 Cut 4) "browser
212
+ // or CLI?" prompt doesn't fire — this test predates that step.
213
+ wizardChoice: "browser",
214
+ installVaultModuleImpl: noopVaultInstall,
215
+ });
216
+ expect(code).toBe(0);
217
+ expect(opened).toEqual(["http://127.0.0.1:1939/admin/"]);
218
+ } finally {
219
+ h.cleanup();
220
+ }
221
+ });
222
+
223
+ test("offers to open the browser in a TTY; 'n' skips openBrowser", async () => {
224
+ const h = makeHarness();
225
+ try {
226
+ writeHubPort(1939, h.configDir);
227
+ const opened: string[] = [];
228
+ const code = await init({
229
+ configDir: h.configDir,
230
+ manifestPath: h.manifestPath,
231
+ log: () => {},
232
+ alive: () => false,
233
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
234
+ readExposeStateFn: () => undefined,
235
+ isTty: true,
236
+ platform: "darwin",
237
+ prompt: async () => "n",
238
+ openBrowser: (url) => {
239
+ opened.push(url);
240
+ return true;
241
+ },
242
+ noExposePrompt: true,
243
+ // No wizardChoice set — falls into the back-compat Y/n confirm,
244
+ // where 'n' skips the browser open (the original semantic this
245
+ // test was written to assert). Suppress the new (hub#168 Cut 4)
246
+ // wizard-choice prompt so this test stays focused on the Y/n
247
+ // confirm path.
248
+ noWizardPrompt: true,
249
+ installVaultModuleImpl: noopVaultInstall,
250
+ });
251
+ expect(code).toBe(0);
252
+ expect(opened).toEqual([]);
253
+ } finally {
254
+ h.cleanup();
255
+ }
256
+ });
257
+
258
+ test("--no-browser skips the prompt and openBrowser entirely", async () => {
259
+ const h = makeHarness();
260
+ try {
261
+ writeHubPort(1939, h.configDir);
262
+ const opened: string[] = [];
263
+ let prompted = false;
264
+ const code = await init({
265
+ configDir: h.configDir,
266
+ manifestPath: h.manifestPath,
267
+ log: () => {},
268
+ alive: () => false,
269
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
270
+ readExposeStateFn: () => undefined,
271
+ isTty: true,
272
+ platform: "darwin",
273
+ prompt: async () => {
274
+ prompted = true;
275
+ return "y";
276
+ },
277
+ openBrowser: (url) => {
278
+ opened.push(url);
279
+ return true;
280
+ },
281
+ noBrowser: true,
282
+ noExposePrompt: true,
283
+ });
284
+ expect(code).toBe(0);
285
+ expect(prompted).toBe(false);
286
+ expect(opened).toEqual([]);
287
+ } finally {
288
+ h.cleanup();
289
+ }
290
+ });
291
+
292
+ test("non-TTY prints the URL and exits without prompting", async () => {
293
+ const h = makeHarness();
294
+ try {
295
+ writeHubPort(1939, h.configDir);
296
+ let prompted = false;
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: false,
305
+ platform: "darwin",
306
+ prompt: async () => {
307
+ prompted = true;
308
+ return "y";
309
+ },
310
+ openBrowser: () => true,
311
+ });
312
+ expect(code).toBe(0);
313
+ expect(prompted).toBe(false);
314
+ } finally {
315
+ h.cleanup();
316
+ }
317
+ });
318
+
319
+ test("Windows / unsupported platform: skip browser launch, just print", async () => {
320
+ const h = makeHarness();
321
+ try {
322
+ writeHubPort(1939, h.configDir);
323
+ const opened: string[] = [];
324
+ const code = await init({
325
+ configDir: h.configDir,
326
+ manifestPath: h.manifestPath,
327
+ log: () => {},
328
+ alive: () => false,
329
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
330
+ readExposeStateFn: () => undefined,
331
+ isTty: true,
332
+ platform: "win32",
333
+ prompt: async () => "y",
334
+ openBrowser: (url) => {
335
+ opened.push(url);
336
+ return true;
337
+ },
338
+ noExposePrompt: true,
339
+ });
340
+ expect(code).toBe(0);
341
+ // No prompt offered on Windows — just URL printed.
342
+ expect(opened).toEqual([]);
343
+ } finally {
344
+ h.cleanup();
345
+ }
346
+ });
347
+
348
+ test("linux SSH (no display): prints the link, does NOT spawn a browser (Fix 2)", async () => {
349
+ // A TTY isn't enough — an SSH session is a TTY with no display, so
350
+ // `xdg-open` fails/blocks. Aaron hit this on EC2: init tried to open a
351
+ // browser and failed with "Couldn't launch a browser." We now skip the
352
+ // spawn on a server-shaped box and just print the URL.
353
+ const h = makeHarness();
354
+ try {
355
+ writeHubPort(1939, h.configDir);
356
+ const opened: string[] = [];
357
+ const logs: string[] = [];
358
+ const code = await init({
359
+ configDir: h.configDir,
360
+ manifestPath: h.manifestPath,
361
+ log: (l) => logs.push(l),
362
+ alive: () => false,
363
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
364
+ readExposeStateFn: () => undefined,
365
+ isTty: true,
366
+ platform: "linux",
367
+ env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
368
+ // Pre-pick the browser wizard so a real desktop would spawn — proves
369
+ // the display guard (not the prompt) is what suppresses the spawn.
370
+ wizardChoice: "browser",
371
+ prompt: async () => "y",
372
+ openBrowser: (url) => {
373
+ opened.push(url);
374
+ return true;
375
+ },
376
+ noExposePrompt: true,
377
+ installVaultModuleImpl: noopVaultInstall,
378
+ });
379
+ expect(code).toBe(0);
380
+ expect(opened).toEqual([]); // never spawned
381
+ expect(logs.join("\n")).toContain("No display detected");
382
+ } finally {
383
+ h.cleanup();
384
+ }
385
+ });
386
+
387
+ test("linux WITH a display still spawns the browser (desktop unchanged)", async () => {
388
+ const h = makeHarness();
389
+ try {
390
+ writeHubPort(1939, h.configDir);
391
+ const opened: string[] = [];
392
+ const code = await init({
393
+ configDir: h.configDir,
394
+ manifestPath: h.manifestPath,
395
+ log: () => {},
396
+ alive: () => false,
397
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
398
+ readExposeStateFn: () => undefined,
399
+ isTty: true,
400
+ platform: "linux",
401
+ env: { DISPLAY: ":0" },
402
+ wizardChoice: "browser",
403
+ prompt: async () => "y",
404
+ openBrowser: (url) => {
405
+ opened.push(url);
406
+ return true;
407
+ },
408
+ noExposePrompt: true,
409
+ installVaultModuleImpl: noopVaultInstall,
410
+ });
411
+ expect(code).toBe(0);
412
+ expect(opened).toEqual(["http://127.0.0.1:1939/admin/"]);
413
+ } finally {
414
+ h.cleanup();
415
+ }
416
+ });
417
+
418
+ test("ensureHub failure exits 1 with an actionable hint", async () => {
419
+ const h = makeHarness();
420
+ try {
421
+ const logs: string[] = [];
422
+ const code = await init({
423
+ configDir: h.configDir,
424
+ manifestPath: h.manifestPath,
425
+ log: (l) => logs.push(l),
426
+ alive: () => false,
427
+ ensureHub: async () => {
428
+ throw new Error("port 1939 is in use");
429
+ },
430
+ readExposeStateFn: () => undefined,
431
+ isTty: false,
432
+ platform: "linux",
433
+ installVaultModuleImpl: noopVaultInstall,
434
+ });
435
+ expect(code).toBe(1);
436
+ const joined = logs.join("\n");
437
+ expect(joined).toContain("Hub failed to start: port 1939 is in use");
438
+ expect(joined).toContain("parachute logs hub");
439
+ } finally {
440
+ h.cleanup();
441
+ }
442
+ });
443
+ });
444
+
445
+ describe("looksLikeServer heuristic", () => {
446
+ test("macOS is never a server", () => {
447
+ expect(looksLikeServer("darwin", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(false);
448
+ expect(looksLikeServer("darwin", {})).toBe(false);
449
+ });
450
+
451
+ test("Linux desktop with DISPLAY is a laptop", () => {
452
+ expect(looksLikeServer("linux", { DISPLAY: ":0" })).toBe(false);
453
+ expect(looksLikeServer("linux", { WAYLAND_DISPLAY: "wayland-0" })).toBe(false);
454
+ });
455
+
456
+ test("Linux + SSH session → server", () => {
457
+ expect(looksLikeServer("linux", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(true);
458
+ expect(looksLikeServer("linux", { SSH_CLIENT: "1.2.3.4 22 5.6.7.8" })).toBe(true);
459
+ expect(looksLikeServer("linux", { SSH_TTY: "/dev/pts/0" })).toBe(true);
460
+ });
461
+
462
+ test("Linux + no DISPLAY → server (headless)", () => {
463
+ expect(looksLikeServer("linux", {})).toBe(true);
464
+ });
465
+
466
+ test("Windows is not a server (init doesn't auto-pick on win32 anyway)", () => {
467
+ expect(looksLikeServer("win32", {})).toBe(false);
468
+ });
469
+ });
470
+
471
+ describe("hasNoDisplay heuristic (Fix 2 — headless browser-open guard)", () => {
472
+ test("macOS / Windows always have a display", () => {
473
+ expect(hasNoDisplay("darwin", {})).toBe(false);
474
+ expect(hasNoDisplay("darwin", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(false);
475
+ expect(hasNoDisplay("win32", {})).toBe(false);
476
+ });
477
+
478
+ test("linux SSH session (a TTY, but no display) → no display", () => {
479
+ expect(hasNoDisplay("linux", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(true);
480
+ expect(hasNoDisplay("linux", { SSH_TTY: "/dev/pts/0" })).toBe(true);
481
+ });
482
+
483
+ test("linux headless console (no SSH, no DISPLAY) → no display", () => {
484
+ expect(hasNoDisplay("linux", {})).toBe(true);
485
+ });
486
+
487
+ test("linux desktop with DISPLAY / WAYLAND_DISPLAY → has a display", () => {
488
+ expect(hasNoDisplay("linux", { DISPLAY: ":0" })).toBe(false);
489
+ expect(hasNoDisplay("linux", { WAYLAND_DISPLAY: "wayland-0" })).toBe(false);
490
+ });
491
+
492
+ test("WSL (linux + DISPLAY-less but a dev laptop) is treated as having a display via looksLikeServer exclusion", () => {
493
+ // WSL with no DISPLAY would otherwise look headless; looksLikeServer
494
+ // excludes it, but the bare no-DISPLAY fallback still trips. This documents
495
+ // that a WSL user without an X server set won't auto-spawn — acceptable,
496
+ // since xdg-open would fail there anyway. WSL WITH an X server (DISPLAY set)
497
+ // correctly resolves to has-a-display.
498
+ expect(hasNoDisplay("linux", { WSL_DISTRO_NAME: "Ubuntu", DISPLAY: ":0" })).toBe(false);
499
+ });
500
+ });
501
+
502
+ describe("init exposure chain", () => {
503
+ test("TTY + no exposure + no flags → prompt is shown", async () => {
504
+ const h = makeHarness();
505
+ try {
506
+ writeHubPort(1939, h.configDir);
507
+ const promptCalls: string[] = [];
508
+ const code = await init({
509
+ configDir: h.configDir,
510
+ manifestPath: h.manifestPath,
511
+ log: () => {},
512
+ alive: () => false,
513
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
514
+ readExposeStateFn: () => undefined,
515
+ isTty: true,
516
+ platform: "darwin",
517
+ env: {},
518
+ prompt: async (q) => {
519
+ promptCalls.push(q);
520
+ // First prompt is the exposure picker → pick "none"; second
521
+ // is the browser-open question → say no.
522
+ if (promptCalls.length === 1) return "1";
523
+ return "n";
524
+ },
525
+ openBrowser: () => true,
526
+ });
527
+ expect(code).toBe(0);
528
+ // The exposure prompt was shown.
529
+ expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(true);
530
+ } finally {
531
+ h.cleanup();
532
+ }
533
+ });
534
+
535
+ test("--no-expose-prompt skips the prompt entirely", async () => {
536
+ const h = makeHarness();
537
+ try {
538
+ writeHubPort(1939, h.configDir);
539
+ let exposureChained = false;
540
+ const promptCalls: string[] = [];
541
+ const code = await init({
542
+ configDir: h.configDir,
543
+ manifestPath: h.manifestPath,
544
+ log: () => {},
545
+ alive: () => false,
546
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
547
+ readExposeStateFn: () => undefined,
548
+ isTty: true,
549
+ platform: "darwin",
550
+ env: {},
551
+ prompt: async (q) => {
552
+ promptCalls.push(q);
553
+ return "n";
554
+ },
555
+ openBrowser: () => true,
556
+ exposeTailnetImpl: async () => {
557
+ exposureChained = true;
558
+ return 0;
559
+ },
560
+ exposeCloudflareImpl: async () => {
561
+ exposureChained = true;
562
+ return 0;
563
+ },
564
+ noExposePrompt: true,
565
+ // Suppress the new wizard-choice prompt + stub the vault-module
566
+ // install (hub#168 Cuts 1/4) so this pre-existing test stays
567
+ // focused on the exposure-prompt-skipped assertion.
568
+ noWizardPrompt: true,
569
+ installVaultModuleImpl: noopVaultInstall,
570
+ });
571
+ expect(code).toBe(0);
572
+ expect(exposureChained).toBe(false);
573
+ // No exposure prompt; only the browser-open prompt.
574
+ expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(false);
575
+ } finally {
576
+ h.cleanup();
577
+ }
578
+ });
579
+
580
+ test("--expose tailnet chains into tailnet without prompting", async () => {
581
+ const h = makeHarness();
582
+ try {
583
+ writeHubPort(1939, h.configDir);
584
+ let tailnetCalls = 0;
585
+ let cloudflareCalls = 0;
586
+ const promptCalls: string[] = [];
587
+ const code = await init({
588
+ configDir: h.configDir,
589
+ manifestPath: h.manifestPath,
590
+ log: () => {},
591
+ alive: () => false,
592
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
593
+ readExposeStateFn: () => undefined,
594
+ isTty: true,
595
+ platform: "linux",
596
+ env: {},
597
+ prompt: async (q) => {
598
+ promptCalls.push(q);
599
+ return "n";
600
+ },
601
+ openBrowser: () => true,
602
+ exposeTailnetImpl: async () => {
603
+ tailnetCalls += 1;
604
+ return 0;
605
+ },
606
+ exposeCloudflareImpl: async () => {
607
+ cloudflareCalls += 1;
608
+ return 0;
609
+ },
610
+ exposeChoice: "tailnet",
611
+ noWizardPrompt: true,
612
+ installVaultModuleImpl: noopVaultInstall,
613
+ });
614
+ expect(code).toBe(0);
615
+ expect(tailnetCalls).toBe(1);
616
+ expect(cloudflareCalls).toBe(0);
617
+ // No exposure prompt — the flag pre-empted it.
618
+ expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(false);
619
+ } finally {
620
+ h.cleanup();
621
+ }
622
+ });
623
+
624
+ test("--expose cloudflare chains into cloudflare without prompting", async () => {
625
+ const h = makeHarness();
626
+ try {
627
+ writeHubPort(1939, h.configDir);
628
+ let tailnetCalls = 0;
629
+ let cloudflareCalls = 0;
630
+ const code = await init({
631
+ configDir: h.configDir,
632
+ manifestPath: h.manifestPath,
633
+ log: () => {},
634
+ alive: () => false,
635
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
636
+ readExposeStateFn: () => undefined,
637
+ isTty: false,
638
+ platform: "linux",
639
+ env: {},
640
+ exposeTailnetImpl: async () => {
641
+ tailnetCalls += 1;
642
+ return 0;
643
+ },
644
+ exposeCloudflareImpl: async () => {
645
+ cloudflareCalls += 1;
646
+ return 0;
647
+ },
648
+ exposeChoice: "cloudflare",
649
+ });
650
+ expect(code).toBe(0);
651
+ expect(cloudflareCalls).toBe(1);
652
+ expect(tailnetCalls).toBe(0);
653
+ } finally {
654
+ h.cleanup();
655
+ }
656
+ });
657
+
658
+ test("--expose none skips exposure", async () => {
659
+ const h = makeHarness();
660
+ try {
661
+ writeHubPort(1939, h.configDir);
662
+ let tailnetCalls = 0;
663
+ let cloudflareCalls = 0;
664
+ const code = await init({
665
+ configDir: h.configDir,
666
+ manifestPath: h.manifestPath,
667
+ log: () => {},
668
+ alive: () => false,
669
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
670
+ readExposeStateFn: () => undefined,
671
+ isTty: false,
672
+ platform: "linux",
673
+ env: {},
674
+ exposeTailnetImpl: async () => {
675
+ tailnetCalls += 1;
676
+ return 0;
677
+ },
678
+ exposeCloudflareImpl: async () => {
679
+ cloudflareCalls += 1;
680
+ return 0;
681
+ },
682
+ exposeChoice: "none",
683
+ });
684
+ expect(code).toBe(0);
685
+ expect(tailnetCalls).toBe(0);
686
+ expect(cloudflareCalls).toBe(0);
687
+ } finally {
688
+ h.cleanup();
689
+ }
690
+ });
691
+
692
+ test("default selection differs by SSH heuristic (laptop → 1, server → 3)", async () => {
693
+ const h = makeHarness();
694
+ try {
695
+ writeHubPort(1939, h.configDir);
696
+
697
+ // Laptop: macOS, no SSH → default is "1" (none).
698
+ let promptLog: string[] = [];
699
+ // Array-based holder defeats TS control-flow narrowing — element
700
+ // reads on an array typed as ExposeChoice[] always come back as the
701
+ // declared element type, not narrowed to the last assigned literal.
702
+ const chained: ExposeChoice[] = ["none"];
703
+ const setChained = (v: ExposeChoice) => {
704
+ chained[0] = v;
705
+ };
706
+ await init({
707
+ configDir: h.configDir,
708
+ manifestPath: h.manifestPath,
709
+ log: (l) => promptLog.push(l),
710
+ alive: () => false,
711
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
712
+ readExposeStateFn: () => undefined,
713
+ isTty: true,
714
+ platform: "darwin",
715
+ env: {},
716
+ prompt: async (q) => {
717
+ promptLog.push(`Q: ${q}`);
718
+ // Empty == confirm default.
719
+ if (q.toLowerCase().includes("pick")) return "";
720
+ return "n";
721
+ },
722
+ openBrowser: () => true,
723
+ exposeTailnetImpl: async () => {
724
+ setChained("tailnet");
725
+ return 0;
726
+ },
727
+ exposeCloudflareImpl: async () => {
728
+ setChained("cloudflare");
729
+ return 0;
730
+ },
731
+ });
732
+ // Default on laptop is "none" → no chain.
733
+ expect(chained[0]).toBe("none");
734
+ // The "Pick [1]" prompt was shown (loopback as default).
735
+ expect(promptLog.some((l) => l.includes("Pick [1]"))).toBe(true);
736
+
737
+ // Server: Linux + SSH → default is "3" (cloudflare).
738
+ promptLog = [];
739
+ setChained("none");
740
+ await init({
741
+ configDir: h.configDir,
742
+ manifestPath: h.manifestPath,
743
+ log: (l) => promptLog.push(l),
744
+ alive: () => true,
745
+ ensureHub: async () => ({ pid: 7, port: 1939, started: false }),
746
+ readExposeStateFn: () => undefined,
747
+ isTty: true,
748
+ platform: "linux",
749
+ env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
750
+ prompt: async (q) => {
751
+ promptLog.push(`Q: ${q}`);
752
+ if (q.toLowerCase().includes("pick")) return "";
753
+ return "n";
754
+ },
755
+ openBrowser: () => true,
756
+ exposeTailnetImpl: async () => {
757
+ setChained("tailnet");
758
+ return 0;
759
+ },
760
+ exposeCloudflareImpl: async () => {
761
+ setChained("cloudflare");
762
+ return 0;
763
+ },
764
+ });
765
+ expect(chained[0]).toBe("cloudflare");
766
+ expect(promptLog.some((l) => l.includes("Pick [3]"))).toBe(true);
767
+ } finally {
768
+ h.cleanup();
769
+ }
770
+ });
771
+
772
+ test("hub already exposed → no prompt, FQDN URL printed", async () => {
773
+ const h = makeHarness();
774
+ try {
775
+ writeHubPort(1939, h.configDir);
776
+ const state: ExposeState = {
777
+ version: 1,
778
+ layer: "public",
779
+ mode: "path",
780
+ canonicalFqdn: "ec2-example.parachute.computer",
781
+ port: 443,
782
+ funnel: false,
783
+ entries: [],
784
+ hubOrigin: "https://ec2-example.parachute.computer",
785
+ };
786
+ const promptCalls: string[] = [];
787
+ let chained = false;
788
+ const logs: string[] = [];
789
+ const code = await init({
790
+ configDir: h.configDir,
791
+ manifestPath: h.manifestPath,
792
+ log: (l) => logs.push(l),
793
+ alive: () => false,
794
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
795
+ readExposeStateFn: () => state,
796
+ isTty: true,
797
+ platform: "linux",
798
+ env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
799
+ prompt: async (q) => {
800
+ promptCalls.push(q);
801
+ return "n";
802
+ },
803
+ openBrowser: () => true,
804
+ exposeTailnetImpl: async () => {
805
+ chained = true;
806
+ return 0;
807
+ },
808
+ exposeCloudflareImpl: async () => {
809
+ chained = true;
810
+ return 0;
811
+ },
812
+ noWizardPrompt: true,
813
+ installVaultModuleImpl: noopVaultInstall,
814
+ });
815
+ expect(code).toBe(0);
816
+ // No exposure chain ran, no exposure prompt asked.
817
+ expect(chained).toBe(false);
818
+ expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(false);
819
+ // The FQDN URL is printed.
820
+ expect(logs.join("\n")).toContain("https://ec2-example.parachute.computer/admin/");
821
+ expect(logs.join("\n")).toContain("already exposed");
822
+ } finally {
823
+ h.cleanup();
824
+ }
825
+ });
826
+
827
+ test("non-TTY → no exposure prompt, falls through to localhost", async () => {
828
+ const h = makeHarness();
829
+ try {
830
+ writeHubPort(1939, h.configDir);
831
+ let chained = false;
832
+ const code = await init({
833
+ configDir: h.configDir,
834
+ manifestPath: h.manifestPath,
835
+ log: () => {},
836
+ alive: () => false,
837
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
838
+ readExposeStateFn: () => undefined,
839
+ isTty: false,
840
+ platform: "linux",
841
+ env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
842
+ exposeTailnetImpl: async () => {
843
+ chained = true;
844
+ return 0;
845
+ },
846
+ exposeCloudflareImpl: async () => {
847
+ chained = true;
848
+ return 0;
849
+ },
850
+ });
851
+ expect(code).toBe(0);
852
+ expect(chained).toBe(false);
853
+ } finally {
854
+ h.cleanup();
855
+ }
856
+ });
857
+
858
+ test("exposure chain non-zero exit propagates", async () => {
859
+ const h = makeHarness();
860
+ try {
861
+ writeHubPort(1939, h.configDir);
862
+ const code = await init({
863
+ configDir: h.configDir,
864
+ manifestPath: h.manifestPath,
865
+ log: () => {},
866
+ alive: () => false,
867
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
868
+ readExposeStateFn: () => undefined,
869
+ isTty: false,
870
+ platform: "linux",
871
+ env: {},
872
+ exposeTailnetImpl: async () => 0,
873
+ exposeCloudflareImpl: async () => 2,
874
+ exposeChoice: "cloudflare",
875
+ });
876
+ expect(code).toBe(2);
877
+ } finally {
878
+ h.cleanup();
879
+ }
880
+ });
881
+
882
+ test("after exposure runs, the admin URL re-reads expose state for the FQDN", async () => {
883
+ const h = makeHarness();
884
+ try {
885
+ writeHubPort(1939, h.configDir);
886
+ let exposedYet = false;
887
+ const exposed: ExposeState = {
888
+ version: 1,
889
+ layer: "tailnet",
890
+ mode: "path",
891
+ canonicalFqdn: "box.tailnet.ts.net",
892
+ port: 443,
893
+ funnel: false,
894
+ entries: [],
895
+ hubOrigin: "https://box.tailnet.ts.net",
896
+ };
897
+ const logs: string[] = [];
898
+ const code = await init({
899
+ configDir: h.configDir,
900
+ manifestPath: h.manifestPath,
901
+ log: (l) => logs.push(l),
902
+ alive: () => false,
903
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
904
+ // Reader returns undefined the first time, then the exposed state
905
+ // after the chain ran. Mirrors the real on-disk flow where
906
+ // exposeTailnet writes expose-state.json.
907
+ readExposeStateFn: () => (exposedYet ? exposed : undefined),
908
+ isTty: false,
909
+ platform: "linux",
910
+ env: {},
911
+ exposeTailnetImpl: async () => {
912
+ exposedYet = true;
913
+ return 0;
914
+ },
915
+ exposeCloudflareImpl: async () => 0,
916
+ exposeChoice: "tailnet",
917
+ });
918
+ expect(code).toBe(0);
919
+ expect(logs.join("\n")).toContain("https://box.tailnet.ts.net/admin/");
920
+ expect(logs.join("\n")).not.toContain("http://127.0.0.1");
921
+ } finally {
922
+ h.cleanup();
923
+ }
924
+ });
925
+ });
926
+
927
+ // Type alias used only inside this test file for the heuristic test.
928
+ type ExposeChoice = "none" | "tailnet" | "cloudflare";