@openparachute/hub 0.3.0-rc.1 → 0.5.1

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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { readCloudflaredState, writeCloudflaredState } from "../cloudflare/state.ts";
5
+ import {
6
+ type CloudflaredTunnelRecord,
7
+ findTunnelRecord,
8
+ readCloudflaredState,
9
+ withTunnelRecord,
10
+ writeCloudflaredState,
11
+ } from "../cloudflare/state.ts";
6
12
  import {
7
13
  type CloudflaredSpawner,
8
14
  exposeCloudflareOff,
@@ -29,8 +35,8 @@ function makeEnv(opts: { includeVault?: boolean; loggedIn?: boolean } = {}): Tes
29
35
  const cloudflaredHome = join(dir, "cloudflared");
30
36
  const manifestPath = join(configDir, "services.json");
31
37
  const statePath = join(configDir, "cloudflared-state.json");
32
- const configPath = join(configDir, "cloudflared", "config.yml");
33
- const logPath = join(configDir, "cloudflared", "cloudflared.log");
38
+ const configPath = join(configDir, "cloudflared", "parachute", "config.yml");
39
+ const logPath = join(configDir, "cloudflared", "parachute", "cloudflared.log");
34
40
 
35
41
  require("node:fs").mkdirSync(configDir, { recursive: true });
36
42
  require("node:fs").mkdirSync(cloudflaredHome, { recursive: true });
@@ -148,13 +154,17 @@ describe("exposeCloudflareUp", () => {
148
154
 
149
155
  const state = readCloudflaredState(env.statePath);
150
156
  expect(state).toEqual({
151
- version: 1,
152
- pid: 42000,
153
- tunnelUuid: uuid,
154
- tunnelName: "parachute",
155
- hostname: "vault.example.com",
156
- startedAt: "2026-04-22T12:00:00.000Z",
157
- configPath: env.configPath,
157
+ version: 2,
158
+ tunnels: {
159
+ parachute: {
160
+ pid: 42000,
161
+ tunnelUuid: uuid,
162
+ tunnelName: "parachute",
163
+ hostname: "vault.example.com",
164
+ startedAt: "2026-04-22T12:00:00.000Z",
165
+ configPath: env.configPath,
166
+ },
167
+ },
158
168
  });
159
169
 
160
170
  const yaml = readFileSync(env.configPath, "utf8");
@@ -236,6 +246,33 @@ describe("exposeCloudflareUp", () => {
236
246
  }
237
247
  });
238
248
 
249
+ test("rejects invalid tunnel names up front", async () => {
250
+ const env = makeEnv();
251
+ try {
252
+ const { runner, calls } = queueRunner([]);
253
+ const { spawner } = fakeSpawner(0);
254
+ const logs: string[] = [];
255
+
256
+ const code = await exposeCloudflareUp("vault.example.com", {
257
+ runner,
258
+ spawner,
259
+ log: (l) => logs.push(l),
260
+ manifestPath: env.manifestPath,
261
+ statePath: env.statePath,
262
+ configPath: env.configPath,
263
+ logPath: env.logPath,
264
+ cloudflaredHome: env.cloudflaredHome,
265
+ tunnelName: "bad name with spaces",
266
+ });
267
+
268
+ expect(code).toBe(1);
269
+ expect(calls).toHaveLength(0);
270
+ expect(logs.join("\n")).toContain("--tunnel-name must be alphanumeric");
271
+ } finally {
272
+ env.cleanup();
273
+ }
274
+ });
275
+
239
276
  test("prints install hint when cloudflared is missing", async () => {
240
277
  const env = makeEnv();
241
278
  try {
@@ -353,18 +390,15 @@ describe("exposeCloudflareUp", () => {
353
390
  test("stops a prior cloudflared process before spawning a new one", async () => {
354
391
  const env = makeEnv();
355
392
  try {
356
- writeCloudflaredState(
357
- {
358
- version: 1,
359
- pid: 99999,
360
- tunnelUuid: "old-tunnel-uuid",
361
- tunnelName: "parachute",
362
- hostname: "vault.example.com",
363
- startedAt: "2026-04-21T00:00:00.000Z",
364
- configPath: env.configPath,
365
- },
366
- env.statePath,
367
- );
393
+ const priorRecord: CloudflaredTunnelRecord = {
394
+ pid: 99999,
395
+ tunnelUuid: "old-tunnel-uuid",
396
+ tunnelName: "parachute",
397
+ hostname: "vault.example.com",
398
+ startedAt: "2026-04-21T00:00:00.000Z",
399
+ configPath: env.configPath,
400
+ };
401
+ writeCloudflaredState({ version: 2, tunnels: { parachute: priorRecord } }, env.statePath);
368
402
 
369
403
  const { runner } = queueRunner([
370
404
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
@@ -396,7 +430,83 @@ describe("exposeCloudflareUp", () => {
396
430
  expect(code).toBe(0);
397
431
  expect(killed).toEqual([{ pid: 99999, sig: "SIGTERM" }]);
398
432
  const state = readCloudflaredState(env.statePath);
399
- expect(state?.pid).toBe(42010);
433
+ expect(findTunnelRecord(state, "parachute")?.pid).toBe(42010);
434
+ } finally {
435
+ env.cleanup();
436
+ }
437
+ });
438
+
439
+ test("two tunnels with different --tunnel-name coexist in state", async () => {
440
+ const env = makeEnv();
441
+ try {
442
+ const uuidA = "aaaa1111-aaaa-1111-aaaa-111111111111";
443
+ const uuidB = "bbbb2222-bbbb-2222-bbbb-222222222222";
444
+ // Up #1 — default name "parachute"
445
+ const r1 = queueRunner([
446
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
447
+ { code: 0, stdout: "[]", stderr: "" },
448
+ {
449
+ code: 0,
450
+ stdout: `Created tunnel parachute with id ${uuidA}\n`,
451
+ stderr: "",
452
+ },
453
+ { code: 0, stdout: "", stderr: "" },
454
+ ]);
455
+ const s1 = fakeSpawner(50001);
456
+ const code1 = await exposeCloudflareUp("alpha.example.com", {
457
+ runner: r1.runner,
458
+ spawner: s1.spawner,
459
+ alive: () => false,
460
+ kill: () => {},
461
+ log: () => {},
462
+ manifestPath: env.manifestPath,
463
+ statePath: env.statePath,
464
+ cloudflaredHome: env.cloudflaredHome,
465
+ // Use defaults for configPath/logPath so they're per-tunnel-derived.
466
+ });
467
+ expect(code1).toBe(0);
468
+
469
+ // Up #2 — explicit --tunnel-name "second"
470
+ const r2 = queueRunner([
471
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
472
+ { code: 0, stdout: "[]", stderr: "" },
473
+ {
474
+ code: 0,
475
+ stdout: `Created tunnel second with id ${uuidB}\n`,
476
+ stderr: "",
477
+ },
478
+ { code: 0, stdout: "", stderr: "" },
479
+ ]);
480
+ const s2 = fakeSpawner(50002);
481
+ const code2 = await exposeCloudflareUp("beta.example.com", {
482
+ runner: r2.runner,
483
+ spawner: s2.spawner,
484
+ alive: () => false,
485
+ kill: () => {},
486
+ log: () => {},
487
+ manifestPath: env.manifestPath,
488
+ statePath: env.statePath,
489
+ cloudflaredHome: env.cloudflaredHome,
490
+ tunnelName: "second",
491
+ });
492
+ expect(code2).toBe(0);
493
+
494
+ // Both tunnels should be present in state, keyed by tunnel name.
495
+ const state = readCloudflaredState(env.statePath);
496
+ expect(Object.keys(state?.tunnels ?? {}).sort()).toEqual(["parachute", "second"]);
497
+ expect(findTunnelRecord(state, "parachute")?.hostname).toBe("alpha.example.com");
498
+ expect(findTunnelRecord(state, "second")?.hostname).toBe("beta.example.com");
499
+ expect(findTunnelRecord(state, "second")?.pid).toBe(50002);
500
+
501
+ // Each tunnel should have written its own config file at the per-tunnel
502
+ // path under `~/.parachute/cloudflared/<tunnelName>/config.yml`.
503
+ const cfgA = findTunnelRecord(state, "parachute")?.configPath ?? "";
504
+ const cfgB = findTunnelRecord(state, "second")?.configPath ?? "";
505
+ expect(cfgA).not.toBe(cfgB);
506
+ expect(cfgA.endsWith("/parachute/config.yml")).toBe(true);
507
+ expect(cfgB.endsWith("/second/config.yml")).toBe(true);
508
+ expect(existsSync(cfgA)).toBe(true);
509
+ expect(existsSync(cfgB)).toBe(true);
400
510
  } finally {
401
511
  env.cleanup();
402
512
  }
@@ -424,13 +534,17 @@ describe("exposeCloudflareOff", () => {
424
534
  try {
425
535
  writeCloudflaredState(
426
536
  {
427
- version: 1,
428
- pid: 55555,
429
- tunnelUuid: "dddddddd-0000-0000-0000-000000000004",
430
- tunnelName: "parachute",
431
- hostname: "vault.example.com",
432
- startedAt: "2026-04-22T12:00:00.000Z",
433
- configPath: env.configPath,
537
+ version: 2,
538
+ tunnels: {
539
+ parachute: {
540
+ pid: 55555,
541
+ tunnelUuid: "dddddddd-0000-0000-0000-000000000004",
542
+ tunnelName: "parachute",
543
+ hostname: "vault.example.com",
544
+ startedAt: "2026-04-22T12:00:00.000Z",
545
+ configPath: env.configPath,
546
+ },
547
+ },
434
548
  },
435
549
  env.statePath,
436
550
  );
@@ -457,13 +571,17 @@ describe("exposeCloudflareOff", () => {
457
571
  try {
458
572
  writeCloudflaredState(
459
573
  {
460
- version: 1,
461
- pid: 55556,
462
- tunnelUuid: "eeeeeeee-0000-0000-0000-000000000005",
463
- tunnelName: "parachute",
464
- hostname: "vault.example.com",
465
- startedAt: "2026-04-22T12:00:00.000Z",
466
- configPath: env.configPath,
574
+ version: 2,
575
+ tunnels: {
576
+ parachute: {
577
+ pid: 55556,
578
+ tunnelUuid: "eeeeeeee-0000-0000-0000-000000000005",
579
+ tunnelName: "parachute",
580
+ hostname: "vault.example.com",
581
+ startedAt: "2026-04-22T12:00:00.000Z",
582
+ configPath: env.configPath,
583
+ },
584
+ },
467
585
  },
468
586
  env.statePath,
469
587
  );
@@ -481,4 +599,81 @@ describe("exposeCloudflareOff", () => {
481
599
  env.cleanup();
482
600
  }
483
601
  });
602
+
603
+ test("targets the named tunnel and leaves siblings intact", async () => {
604
+ const env = makeEnv();
605
+ try {
606
+ const recordA: CloudflaredTunnelRecord = {
607
+ pid: 60001,
608
+ tunnelUuid: "aaaa-uuid",
609
+ tunnelName: "alpha",
610
+ hostname: "alpha.example.com",
611
+ startedAt: "2026-04-23T10:00:00.000Z",
612
+ configPath: "/tmp/alpha/config.yml",
613
+ };
614
+ const recordB: CloudflaredTunnelRecord = {
615
+ pid: 60002,
616
+ tunnelUuid: "bbbb-uuid",
617
+ tunnelName: "beta",
618
+ hostname: "beta.example.com",
619
+ startedAt: "2026-04-23T11:00:00.000Z",
620
+ configPath: "/tmp/beta/config.yml",
621
+ };
622
+ writeCloudflaredState(
623
+ withTunnelRecord(withTunnelRecord(undefined, recordA), recordB),
624
+ env.statePath,
625
+ );
626
+
627
+ const killed: number[] = [];
628
+ const code = await exposeCloudflareOff({
629
+ statePath: env.statePath,
630
+ alive: () => true,
631
+ kill: (pid) => killed.push(pid),
632
+ log: () => {},
633
+ tunnelName: "alpha",
634
+ });
635
+ expect(code).toBe(0);
636
+ // Only alpha's pid is killed.
637
+ expect(killed).toEqual([60001]);
638
+
639
+ // beta is still recorded; alpha is gone.
640
+ const state = readCloudflaredState(env.statePath);
641
+ expect(findTunnelRecord(state, "alpha")).toBeUndefined();
642
+ expect(findTunnelRecord(state, "beta")).toEqual(recordB);
643
+ } finally {
644
+ env.cleanup();
645
+ }
646
+ });
647
+
648
+ test("reports tunnel-name mismatch and lists known tunnels", async () => {
649
+ const env = makeEnv();
650
+ try {
651
+ const recordA: CloudflaredTunnelRecord = {
652
+ pid: 60001,
653
+ tunnelUuid: "aaaa-uuid",
654
+ tunnelName: "alpha",
655
+ hostname: "alpha.example.com",
656
+ startedAt: "2026-04-23T10:00:00.000Z",
657
+ configPath: "/tmp/alpha/config.yml",
658
+ };
659
+ writeCloudflaredState({ version: 2, tunnels: { alpha: recordA } }, env.statePath);
660
+
661
+ const logs: string[] = [];
662
+ const code = await exposeCloudflareOff({
663
+ statePath: env.statePath,
664
+ alive: () => true,
665
+ kill: () => {},
666
+ log: (l) => logs.push(l),
667
+ tunnelName: "ghost",
668
+ });
669
+ expect(code).toBe(0);
670
+ expect(logs.join("\n")).toContain('No Cloudflare exposure recorded for tunnel "ghost"');
671
+ expect(logs.join("\n")).toContain("alpha");
672
+ // alpha is untouched.
673
+ const state = readCloudflaredState(env.statePath);
674
+ expect(findTunnelRecord(state, "alpha")).toEqual(recordA);
675
+ } finally {
676
+ env.cleanup();
677
+ }
678
+ });
484
679
  });
@@ -26,16 +26,22 @@ function tailscaleState(overrides: Partial<ExposeState> = {}): ExposeState {
26
26
  };
27
27
  }
28
28
 
29
- function cloudflaredState(overrides: Partial<CloudflaredState> = {}): CloudflaredState {
29
+ function cloudflaredState(
30
+ overrides: { hostname?: string; tunnelName?: string; pid?: number } = {},
31
+ ): CloudflaredState {
32
+ const tunnelName = overrides.tunnelName ?? "vault-tunnel";
30
33
  return {
31
- version: 1,
32
- pid: 4242,
33
- tunnelUuid: "11111111-2222-3333-4444-555555555555",
34
- tunnelName: "vault-tunnel",
35
- hostname: "vault.example.com",
36
- startedAt: "2026-04-23T10:00:00.000Z",
37
- configPath: "/tmp/config.yml",
38
- ...overrides,
34
+ version: 2,
35
+ tunnels: {
36
+ [tunnelName]: {
37
+ pid: overrides.pid ?? 4242,
38
+ tunnelUuid: "11111111-2222-3333-4444-555555555555",
39
+ tunnelName,
40
+ hostname: overrides.hostname ?? "vault.example.com",
41
+ startedAt: "2026-04-23T10:00:00.000Z",
42
+ configPath: "/tmp/config.yml",
43
+ },
44
+ },
39
45
  };
40
46
  }
41
47
 
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { exposePublicAutoPick } from "../commands/expose-public-auto.ts";
3
+ import type { ProviderAvailability } from "../providers/detect.ts";
4
+
5
+ function availability(opts: {
6
+ tailnet?: { available?: boolean; loggedIn?: boolean; funnelEnabled?: boolean };
7
+ cloudflare?: { available?: boolean; loggedIn?: boolean };
8
+ }): ProviderAvailability {
9
+ return {
10
+ tailnet: {
11
+ available: opts.tailnet?.available ?? false,
12
+ loggedIn: opts.tailnet?.loggedIn ?? false,
13
+ funnelEnabled: opts.tailnet?.funnelEnabled ?? false,
14
+ },
15
+ cloudflare: {
16
+ available: opts.cloudflare?.available ?? false,
17
+ loggedIn: opts.cloudflare?.loggedIn ?? false,
18
+ },
19
+ };
20
+ }
21
+
22
+ describe("exposePublicAutoPick — exactly one ready", () => {
23
+ test("only tailnet ready → runs exposePublic('up') without prompting", async () => {
24
+ let called: { action: string; opts: unknown } | undefined;
25
+ const code = await exposePublicAutoPick({
26
+ tailscaleOpts: { hubOrigin: "https://override.example" },
27
+ log: () => {},
28
+ detectProvidersImpl: async () =>
29
+ availability({ tailnet: { available: true, loggedIn: true, funnelEnabled: true } }),
30
+ exposePublicImpl: async (action, opts) => {
31
+ called = { action, opts };
32
+ return 0;
33
+ },
34
+ exposeCloudflareUpImpl: async () => {
35
+ throw new Error("must not be called when only tailnet is ready");
36
+ },
37
+ });
38
+ expect(code).toBe(0);
39
+ expect(called).toEqual({
40
+ action: "up",
41
+ opts: { hubOrigin: "https://override.example" },
42
+ });
43
+ });
44
+
45
+ test("only cloudflare ready + --domain given → runs exposeCloudflareUp", async () => {
46
+ let receivedHostname: string | undefined;
47
+ let receivedOpts: unknown;
48
+ const code = await exposePublicAutoPick({
49
+ domain: "vault.example.com",
50
+ tunnelName: "vault",
51
+ log: () => {},
52
+ detectProvidersImpl: async () =>
53
+ availability({ cloudflare: { available: true, loggedIn: true } }),
54
+ exposePublicImpl: async () => {
55
+ throw new Error("must not be called when only cloudflare is ready");
56
+ },
57
+ exposeCloudflareUpImpl: async (hostname, opts) => {
58
+ receivedHostname = hostname;
59
+ receivedOpts = opts;
60
+ return 0;
61
+ },
62
+ });
63
+ expect(code).toBe(0);
64
+ expect(receivedHostname).toBe("vault.example.com");
65
+ expect(receivedOpts).toEqual({ tunnelName: "vault" });
66
+ });
67
+
68
+ test("only cloudflare ready + no --domain → exits 1 with hostname-required hint", async () => {
69
+ const logs: string[] = [];
70
+ const code = await exposePublicAutoPick({
71
+ log: (l) => logs.push(l),
72
+ detectProvidersImpl: async () =>
73
+ availability({ cloudflare: { available: true, loggedIn: true } }),
74
+ exposePublicImpl: async () => {
75
+ throw new Error("must not be called");
76
+ },
77
+ exposeCloudflareUpImpl: async () => {
78
+ throw new Error("must not be called");
79
+ },
80
+ });
81
+ expect(code).toBe(1);
82
+ const joined = logs.join("\n");
83
+ expect(joined).toMatch(/--domain <hostname> is required|--domain.+required/);
84
+ expect(joined).toContain("--cloudflare --domain vault.example.com");
85
+ });
86
+ });
87
+
88
+ describe("exposePublicAutoPick — ambiguous", () => {
89
+ test("both ready → exits 1 pointing to --tailnet/--cloudflare", async () => {
90
+ const logs: string[] = [];
91
+ const code = await exposePublicAutoPick({
92
+ log: (l) => logs.push(l),
93
+ detectProvidersImpl: async () =>
94
+ availability({
95
+ tailnet: { available: true, loggedIn: true, funnelEnabled: true },
96
+ cloudflare: { available: true, loggedIn: true },
97
+ }),
98
+ exposePublicImpl: async () => {
99
+ throw new Error("must not be called when ambiguous");
100
+ },
101
+ exposeCloudflareUpImpl: async () => {
102
+ throw new Error("must not be called when ambiguous");
103
+ },
104
+ });
105
+ expect(code).toBe(1);
106
+ const joined = logs.join("\n");
107
+ expect(joined).toContain("--tailnet");
108
+ expect(joined).toContain("--cloudflare");
109
+ expect(joined).toContain("--skip-provider-check");
110
+ });
111
+ });
112
+
113
+ describe("exposePublicAutoPick — neither ready", () => {
114
+ test("no providers → exits 1 with install pointers for both", async () => {
115
+ const logs: string[] = [];
116
+ const code = await exposePublicAutoPick({
117
+ log: (l) => logs.push(l),
118
+ detectProvidersImpl: async () => availability({}),
119
+ exposePublicImpl: async () => {
120
+ throw new Error("must not be called");
121
+ },
122
+ exposeCloudflareUpImpl: async () => {
123
+ throw new Error("must not be called");
124
+ },
125
+ });
126
+ expect(code).toBe(1);
127
+ const joined = logs.join("\n");
128
+ expect(joined).toContain("tailscale.com/download");
129
+ expect(joined).toContain("developers.cloudflare.com");
130
+ expect(joined).toContain("--skip-provider-check");
131
+ });
132
+
133
+ test("partial readiness (installed but not logged in) lists the next step", async () => {
134
+ const logs: string[] = [];
135
+ const code = await exposePublicAutoPick({
136
+ log: (l) => logs.push(l),
137
+ detectProvidersImpl: async () =>
138
+ availability({
139
+ tailnet: { available: true, loggedIn: false, funnelEnabled: false },
140
+ cloudflare: { available: true, loggedIn: false },
141
+ }),
142
+ exposePublicImpl: async () => 0,
143
+ exposeCloudflareUpImpl: async () => 0,
144
+ });
145
+ expect(code).toBe(1);
146
+ const joined = logs.join("\n");
147
+ // Both binaries marked installed; both still need login.
148
+ expect(joined).toContain("✓ tailscale installed");
149
+ expect(joined).toContain("✓ cloudflared installed");
150
+ expect(joined).toContain("tailscale up");
151
+ expect(joined).toContain("cloudflared tunnel login");
152
+ });
153
+ });