@openparachute/hub 0.5.14-rc.4 → 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 +1 -1
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-cloudflare.test.ts +104 -1
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/init.test.ts +344 -0
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/cli.ts +31 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-cloudflare.ts +102 -5
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/init.ts +213 -0
- package/src/commands/migrate.ts +293 -41
- package/src/help.ts +66 -15
package/package.json
CHANGED
|
@@ -59,10 +59,65 @@ describe("cloudflare detect", () => {
|
|
|
59
59
|
}
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
+
});
|