@openparachute/hub 0.5.14-rc.6 → 0.5.14-rc.8
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__/init.test.ts +484 -1
- package/src/__tests__/setup-wizard.test.ts +227 -2
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/cli.ts +58 -3
- package/src/commands/init.ts +374 -24
- package/src/commands/install.ts +33 -2
- package/src/commands/wizard.ts +843 -0
- package/src/help.ts +103 -14
- package/src/hub-settings.ts +11 -0
- package/src/setup-wizard.ts +629 -33
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { init, resolveAdminUrl } from "../commands/init.ts";
|
|
5
|
+
import { init, looksLikeServer, resolveAdminUrl } from "../commands/init.ts";
|
|
6
6
|
import type { ExposeState } from "../expose-state.ts";
|
|
7
7
|
import { writeHubPort } from "../hub-control.ts";
|
|
8
8
|
import { writePid } from "../process-state.ts";
|
|
@@ -24,6 +24,16 @@ function makeHarness(): Harness {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
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
|
+
|
|
27
37
|
function seedVault(manifestPath: string): void {
|
|
28
38
|
writeFileSync(
|
|
29
39
|
manifestPath,
|
|
@@ -88,6 +98,7 @@ describe("init", () => {
|
|
|
88
98
|
readExposeStateFn: () => undefined,
|
|
89
99
|
isTty: false,
|
|
90
100
|
platform: "linux",
|
|
101
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
91
102
|
});
|
|
92
103
|
expect(code).toBe(0);
|
|
93
104
|
expect(calls).toEqual(["ensureHub"]);
|
|
@@ -124,6 +135,7 @@ describe("init", () => {
|
|
|
124
135
|
readExposeStateFn: () => undefined,
|
|
125
136
|
isTty: false,
|
|
126
137
|
platform: "linux",
|
|
138
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
127
139
|
});
|
|
128
140
|
expect(code).toBe(0);
|
|
129
141
|
// Hub was already running — ensureHub should not have been called.
|
|
@@ -194,6 +206,12 @@ describe("init", () => {
|
|
|
194
206
|
opened.push(url);
|
|
195
207
|
return true;
|
|
196
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,
|
|
197
215
|
});
|
|
198
216
|
expect(code).toBe(0);
|
|
199
217
|
expect(opened).toEqual(["http://127.0.0.1:1939/admin/"]);
|
|
@@ -221,6 +239,14 @@ describe("init", () => {
|
|
|
221
239
|
opened.push(url);
|
|
222
240
|
return true;
|
|
223
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,
|
|
224
250
|
});
|
|
225
251
|
expect(code).toBe(0);
|
|
226
252
|
expect(opened).toEqual([]);
|
|
@@ -253,6 +279,7 @@ describe("init", () => {
|
|
|
253
279
|
return true;
|
|
254
280
|
},
|
|
255
281
|
noBrowser: true,
|
|
282
|
+
noExposePrompt: true,
|
|
256
283
|
});
|
|
257
284
|
expect(code).toBe(0);
|
|
258
285
|
expect(prompted).toBe(false);
|
|
@@ -308,6 +335,7 @@ describe("init", () => {
|
|
|
308
335
|
opened.push(url);
|
|
309
336
|
return true;
|
|
310
337
|
},
|
|
338
|
+
noExposePrompt: true,
|
|
311
339
|
});
|
|
312
340
|
expect(code).toBe(0);
|
|
313
341
|
// No prompt offered on Windows — just URL printed.
|
|
@@ -332,6 +360,7 @@ describe("init", () => {
|
|
|
332
360
|
readExposeStateFn: () => undefined,
|
|
333
361
|
isTty: false,
|
|
334
362
|
platform: "linux",
|
|
363
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
335
364
|
});
|
|
336
365
|
expect(code).toBe(1);
|
|
337
366
|
const joined = logs.join("\n");
|
|
@@ -342,3 +371,457 @@ describe("init", () => {
|
|
|
342
371
|
}
|
|
343
372
|
});
|
|
344
373
|
});
|
|
374
|
+
|
|
375
|
+
describe("looksLikeServer heuristic", () => {
|
|
376
|
+
test("macOS is never a server", () => {
|
|
377
|
+
expect(looksLikeServer("darwin", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(false);
|
|
378
|
+
expect(looksLikeServer("darwin", {})).toBe(false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("Linux desktop with DISPLAY is a laptop", () => {
|
|
382
|
+
expect(looksLikeServer("linux", { DISPLAY: ":0" })).toBe(false);
|
|
383
|
+
expect(looksLikeServer("linux", { WAYLAND_DISPLAY: "wayland-0" })).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("Linux + SSH session → server", () => {
|
|
387
|
+
expect(looksLikeServer("linux", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(true);
|
|
388
|
+
expect(looksLikeServer("linux", { SSH_CLIENT: "1.2.3.4 22 5.6.7.8" })).toBe(true);
|
|
389
|
+
expect(looksLikeServer("linux", { SSH_TTY: "/dev/pts/0" })).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("Linux + no DISPLAY → server (headless)", () => {
|
|
393
|
+
expect(looksLikeServer("linux", {})).toBe(true);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("Windows is not a server (init doesn't auto-pick on win32 anyway)", () => {
|
|
397
|
+
expect(looksLikeServer("win32", {})).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("init exposure chain", () => {
|
|
402
|
+
test("TTY + no exposure + no flags → prompt is shown", async () => {
|
|
403
|
+
const h = makeHarness();
|
|
404
|
+
try {
|
|
405
|
+
writeHubPort(1939, h.configDir);
|
|
406
|
+
const promptCalls: string[] = [];
|
|
407
|
+
const code = await init({
|
|
408
|
+
configDir: h.configDir,
|
|
409
|
+
manifestPath: h.manifestPath,
|
|
410
|
+
log: () => {},
|
|
411
|
+
alive: () => false,
|
|
412
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
413
|
+
readExposeStateFn: () => undefined,
|
|
414
|
+
isTty: true,
|
|
415
|
+
platform: "darwin",
|
|
416
|
+
env: {},
|
|
417
|
+
prompt: async (q) => {
|
|
418
|
+
promptCalls.push(q);
|
|
419
|
+
// First prompt is the exposure picker → pick "none"; second
|
|
420
|
+
// is the browser-open question → say no.
|
|
421
|
+
if (promptCalls.length === 1) return "1";
|
|
422
|
+
return "n";
|
|
423
|
+
},
|
|
424
|
+
openBrowser: () => true,
|
|
425
|
+
});
|
|
426
|
+
expect(code).toBe(0);
|
|
427
|
+
// The exposure prompt was shown.
|
|
428
|
+
expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(true);
|
|
429
|
+
} finally {
|
|
430
|
+
h.cleanup();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("--no-expose-prompt skips the prompt entirely", async () => {
|
|
435
|
+
const h = makeHarness();
|
|
436
|
+
try {
|
|
437
|
+
writeHubPort(1939, h.configDir);
|
|
438
|
+
let exposureChained = false;
|
|
439
|
+
const promptCalls: string[] = [];
|
|
440
|
+
const code = await init({
|
|
441
|
+
configDir: h.configDir,
|
|
442
|
+
manifestPath: h.manifestPath,
|
|
443
|
+
log: () => {},
|
|
444
|
+
alive: () => false,
|
|
445
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
446
|
+
readExposeStateFn: () => undefined,
|
|
447
|
+
isTty: true,
|
|
448
|
+
platform: "darwin",
|
|
449
|
+
env: {},
|
|
450
|
+
prompt: async (q) => {
|
|
451
|
+
promptCalls.push(q);
|
|
452
|
+
return "n";
|
|
453
|
+
},
|
|
454
|
+
openBrowser: () => true,
|
|
455
|
+
exposeTailnetImpl: async () => {
|
|
456
|
+
exposureChained = true;
|
|
457
|
+
return 0;
|
|
458
|
+
},
|
|
459
|
+
exposeCloudflareImpl: async () => {
|
|
460
|
+
exposureChained = true;
|
|
461
|
+
return 0;
|
|
462
|
+
},
|
|
463
|
+
noExposePrompt: true,
|
|
464
|
+
// Suppress the new wizard-choice prompt + stub the vault-module
|
|
465
|
+
// install (hub#168 Cuts 1/4) so this pre-existing test stays
|
|
466
|
+
// focused on the exposure-prompt-skipped assertion.
|
|
467
|
+
noWizardPrompt: true,
|
|
468
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
469
|
+
});
|
|
470
|
+
expect(code).toBe(0);
|
|
471
|
+
expect(exposureChained).toBe(false);
|
|
472
|
+
// No exposure prompt; only the browser-open prompt.
|
|
473
|
+
expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(false);
|
|
474
|
+
} finally {
|
|
475
|
+
h.cleanup();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("--expose tailnet chains into tailnet without prompting", async () => {
|
|
480
|
+
const h = makeHarness();
|
|
481
|
+
try {
|
|
482
|
+
writeHubPort(1939, h.configDir);
|
|
483
|
+
let tailnetCalls = 0;
|
|
484
|
+
let cloudflareCalls = 0;
|
|
485
|
+
const promptCalls: string[] = [];
|
|
486
|
+
const code = await init({
|
|
487
|
+
configDir: h.configDir,
|
|
488
|
+
manifestPath: h.manifestPath,
|
|
489
|
+
log: () => {},
|
|
490
|
+
alive: () => false,
|
|
491
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
492
|
+
readExposeStateFn: () => undefined,
|
|
493
|
+
isTty: true,
|
|
494
|
+
platform: "linux",
|
|
495
|
+
env: {},
|
|
496
|
+
prompt: async (q) => {
|
|
497
|
+
promptCalls.push(q);
|
|
498
|
+
return "n";
|
|
499
|
+
},
|
|
500
|
+
openBrowser: () => true,
|
|
501
|
+
exposeTailnetImpl: async () => {
|
|
502
|
+
tailnetCalls += 1;
|
|
503
|
+
return 0;
|
|
504
|
+
},
|
|
505
|
+
exposeCloudflareImpl: async () => {
|
|
506
|
+
cloudflareCalls += 1;
|
|
507
|
+
return 0;
|
|
508
|
+
},
|
|
509
|
+
exposeChoice: "tailnet",
|
|
510
|
+
noWizardPrompt: true,
|
|
511
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
512
|
+
});
|
|
513
|
+
expect(code).toBe(0);
|
|
514
|
+
expect(tailnetCalls).toBe(1);
|
|
515
|
+
expect(cloudflareCalls).toBe(0);
|
|
516
|
+
// No exposure prompt — the flag pre-empted it.
|
|
517
|
+
expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(false);
|
|
518
|
+
} finally {
|
|
519
|
+
h.cleanup();
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("--expose cloudflare chains into cloudflare without prompting", async () => {
|
|
524
|
+
const h = makeHarness();
|
|
525
|
+
try {
|
|
526
|
+
writeHubPort(1939, h.configDir);
|
|
527
|
+
let tailnetCalls = 0;
|
|
528
|
+
let cloudflareCalls = 0;
|
|
529
|
+
const code = await init({
|
|
530
|
+
configDir: h.configDir,
|
|
531
|
+
manifestPath: h.manifestPath,
|
|
532
|
+
log: () => {},
|
|
533
|
+
alive: () => false,
|
|
534
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
535
|
+
readExposeStateFn: () => undefined,
|
|
536
|
+
isTty: false,
|
|
537
|
+
platform: "linux",
|
|
538
|
+
env: {},
|
|
539
|
+
exposeTailnetImpl: async () => {
|
|
540
|
+
tailnetCalls += 1;
|
|
541
|
+
return 0;
|
|
542
|
+
},
|
|
543
|
+
exposeCloudflareImpl: async () => {
|
|
544
|
+
cloudflareCalls += 1;
|
|
545
|
+
return 0;
|
|
546
|
+
},
|
|
547
|
+
exposeChoice: "cloudflare",
|
|
548
|
+
});
|
|
549
|
+
expect(code).toBe(0);
|
|
550
|
+
expect(cloudflareCalls).toBe(1);
|
|
551
|
+
expect(tailnetCalls).toBe(0);
|
|
552
|
+
} finally {
|
|
553
|
+
h.cleanup();
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("--expose none skips exposure", async () => {
|
|
558
|
+
const h = makeHarness();
|
|
559
|
+
try {
|
|
560
|
+
writeHubPort(1939, h.configDir);
|
|
561
|
+
let tailnetCalls = 0;
|
|
562
|
+
let cloudflareCalls = 0;
|
|
563
|
+
const code = await init({
|
|
564
|
+
configDir: h.configDir,
|
|
565
|
+
manifestPath: h.manifestPath,
|
|
566
|
+
log: () => {},
|
|
567
|
+
alive: () => false,
|
|
568
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
569
|
+
readExposeStateFn: () => undefined,
|
|
570
|
+
isTty: false,
|
|
571
|
+
platform: "linux",
|
|
572
|
+
env: {},
|
|
573
|
+
exposeTailnetImpl: async () => {
|
|
574
|
+
tailnetCalls += 1;
|
|
575
|
+
return 0;
|
|
576
|
+
},
|
|
577
|
+
exposeCloudflareImpl: async () => {
|
|
578
|
+
cloudflareCalls += 1;
|
|
579
|
+
return 0;
|
|
580
|
+
},
|
|
581
|
+
exposeChoice: "none",
|
|
582
|
+
});
|
|
583
|
+
expect(code).toBe(0);
|
|
584
|
+
expect(tailnetCalls).toBe(0);
|
|
585
|
+
expect(cloudflareCalls).toBe(0);
|
|
586
|
+
} finally {
|
|
587
|
+
h.cleanup();
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("default selection differs by SSH heuristic (laptop → 1, server → 3)", async () => {
|
|
592
|
+
const h = makeHarness();
|
|
593
|
+
try {
|
|
594
|
+
writeHubPort(1939, h.configDir);
|
|
595
|
+
|
|
596
|
+
// Laptop: macOS, no SSH → default is "1" (none).
|
|
597
|
+
let promptLog: string[] = [];
|
|
598
|
+
// Array-based holder defeats TS control-flow narrowing — element
|
|
599
|
+
// reads on an array typed as ExposeChoice[] always come back as the
|
|
600
|
+
// declared element type, not narrowed to the last assigned literal.
|
|
601
|
+
const chained: ExposeChoice[] = ["none"];
|
|
602
|
+
const setChained = (v: ExposeChoice) => {
|
|
603
|
+
chained[0] = v;
|
|
604
|
+
};
|
|
605
|
+
await init({
|
|
606
|
+
configDir: h.configDir,
|
|
607
|
+
manifestPath: h.manifestPath,
|
|
608
|
+
log: (l) => promptLog.push(l),
|
|
609
|
+
alive: () => false,
|
|
610
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
611
|
+
readExposeStateFn: () => undefined,
|
|
612
|
+
isTty: true,
|
|
613
|
+
platform: "darwin",
|
|
614
|
+
env: {},
|
|
615
|
+
prompt: async (q) => {
|
|
616
|
+
promptLog.push(`Q: ${q}`);
|
|
617
|
+
// Empty == confirm default.
|
|
618
|
+
if (q.toLowerCase().includes("pick")) return "";
|
|
619
|
+
return "n";
|
|
620
|
+
},
|
|
621
|
+
openBrowser: () => true,
|
|
622
|
+
exposeTailnetImpl: async () => {
|
|
623
|
+
setChained("tailnet");
|
|
624
|
+
return 0;
|
|
625
|
+
},
|
|
626
|
+
exposeCloudflareImpl: async () => {
|
|
627
|
+
setChained("cloudflare");
|
|
628
|
+
return 0;
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
// Default on laptop is "none" → no chain.
|
|
632
|
+
expect(chained[0]).toBe("none");
|
|
633
|
+
// The "Pick [1]" prompt was shown (loopback as default).
|
|
634
|
+
expect(promptLog.some((l) => l.includes("Pick [1]"))).toBe(true);
|
|
635
|
+
|
|
636
|
+
// Server: Linux + SSH → default is "3" (cloudflare).
|
|
637
|
+
promptLog = [];
|
|
638
|
+
setChained("none");
|
|
639
|
+
await init({
|
|
640
|
+
configDir: h.configDir,
|
|
641
|
+
manifestPath: h.manifestPath,
|
|
642
|
+
log: (l) => promptLog.push(l),
|
|
643
|
+
alive: () => true,
|
|
644
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: false }),
|
|
645
|
+
readExposeStateFn: () => undefined,
|
|
646
|
+
isTty: true,
|
|
647
|
+
platform: "linux",
|
|
648
|
+
env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
|
|
649
|
+
prompt: async (q) => {
|
|
650
|
+
promptLog.push(`Q: ${q}`);
|
|
651
|
+
if (q.toLowerCase().includes("pick")) return "";
|
|
652
|
+
return "n";
|
|
653
|
+
},
|
|
654
|
+
openBrowser: () => true,
|
|
655
|
+
exposeTailnetImpl: async () => {
|
|
656
|
+
setChained("tailnet");
|
|
657
|
+
return 0;
|
|
658
|
+
},
|
|
659
|
+
exposeCloudflareImpl: async () => {
|
|
660
|
+
setChained("cloudflare");
|
|
661
|
+
return 0;
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
expect(chained[0]).toBe("cloudflare");
|
|
665
|
+
expect(promptLog.some((l) => l.includes("Pick [3]"))).toBe(true);
|
|
666
|
+
} finally {
|
|
667
|
+
h.cleanup();
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("hub already exposed → no prompt, FQDN URL printed", async () => {
|
|
672
|
+
const h = makeHarness();
|
|
673
|
+
try {
|
|
674
|
+
writeHubPort(1939, h.configDir);
|
|
675
|
+
const state: ExposeState = {
|
|
676
|
+
version: 1,
|
|
677
|
+
layer: "public",
|
|
678
|
+
mode: "path",
|
|
679
|
+
canonicalFqdn: "ec2-example.parachute.computer",
|
|
680
|
+
port: 443,
|
|
681
|
+
funnel: false,
|
|
682
|
+
entries: [],
|
|
683
|
+
hubOrigin: "https://ec2-example.parachute.computer",
|
|
684
|
+
};
|
|
685
|
+
const promptCalls: string[] = [];
|
|
686
|
+
let chained = false;
|
|
687
|
+
const logs: string[] = [];
|
|
688
|
+
const code = await init({
|
|
689
|
+
configDir: h.configDir,
|
|
690
|
+
manifestPath: h.manifestPath,
|
|
691
|
+
log: (l) => logs.push(l),
|
|
692
|
+
alive: () => false,
|
|
693
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
694
|
+
readExposeStateFn: () => state,
|
|
695
|
+
isTty: true,
|
|
696
|
+
platform: "linux",
|
|
697
|
+
env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
|
|
698
|
+
prompt: async (q) => {
|
|
699
|
+
promptCalls.push(q);
|
|
700
|
+
return "n";
|
|
701
|
+
},
|
|
702
|
+
openBrowser: () => true,
|
|
703
|
+
exposeTailnetImpl: async () => {
|
|
704
|
+
chained = true;
|
|
705
|
+
return 0;
|
|
706
|
+
},
|
|
707
|
+
exposeCloudflareImpl: async () => {
|
|
708
|
+
chained = true;
|
|
709
|
+
return 0;
|
|
710
|
+
},
|
|
711
|
+
noWizardPrompt: true,
|
|
712
|
+
installVaultModuleImpl: noopVaultInstall,
|
|
713
|
+
});
|
|
714
|
+
expect(code).toBe(0);
|
|
715
|
+
// No exposure chain ran, no exposure prompt asked.
|
|
716
|
+
expect(chained).toBe(false);
|
|
717
|
+
expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(false);
|
|
718
|
+
// The FQDN URL is printed.
|
|
719
|
+
expect(logs.join("\n")).toContain("https://ec2-example.parachute.computer/admin/");
|
|
720
|
+
expect(logs.join("\n")).toContain("already exposed");
|
|
721
|
+
} finally {
|
|
722
|
+
h.cleanup();
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("non-TTY → no exposure prompt, falls through to localhost", async () => {
|
|
727
|
+
const h = makeHarness();
|
|
728
|
+
try {
|
|
729
|
+
writeHubPort(1939, h.configDir);
|
|
730
|
+
let chained = false;
|
|
731
|
+
const code = await init({
|
|
732
|
+
configDir: h.configDir,
|
|
733
|
+
manifestPath: h.manifestPath,
|
|
734
|
+
log: () => {},
|
|
735
|
+
alive: () => false,
|
|
736
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
737
|
+
readExposeStateFn: () => undefined,
|
|
738
|
+
isTty: false,
|
|
739
|
+
platform: "linux",
|
|
740
|
+
env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
|
|
741
|
+
exposeTailnetImpl: async () => {
|
|
742
|
+
chained = true;
|
|
743
|
+
return 0;
|
|
744
|
+
},
|
|
745
|
+
exposeCloudflareImpl: async () => {
|
|
746
|
+
chained = true;
|
|
747
|
+
return 0;
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
expect(code).toBe(0);
|
|
751
|
+
expect(chained).toBe(false);
|
|
752
|
+
} finally {
|
|
753
|
+
h.cleanup();
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
test("exposure chain non-zero exit propagates", async () => {
|
|
758
|
+
const h = makeHarness();
|
|
759
|
+
try {
|
|
760
|
+
writeHubPort(1939, h.configDir);
|
|
761
|
+
const code = await init({
|
|
762
|
+
configDir: h.configDir,
|
|
763
|
+
manifestPath: h.manifestPath,
|
|
764
|
+
log: () => {},
|
|
765
|
+
alive: () => false,
|
|
766
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
767
|
+
readExposeStateFn: () => undefined,
|
|
768
|
+
isTty: false,
|
|
769
|
+
platform: "linux",
|
|
770
|
+
env: {},
|
|
771
|
+
exposeTailnetImpl: async () => 0,
|
|
772
|
+
exposeCloudflareImpl: async () => 2,
|
|
773
|
+
exposeChoice: "cloudflare",
|
|
774
|
+
});
|
|
775
|
+
expect(code).toBe(2);
|
|
776
|
+
} finally {
|
|
777
|
+
h.cleanup();
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("after exposure runs, the admin URL re-reads expose state for the FQDN", async () => {
|
|
782
|
+
const h = makeHarness();
|
|
783
|
+
try {
|
|
784
|
+
writeHubPort(1939, h.configDir);
|
|
785
|
+
let exposedYet = false;
|
|
786
|
+
const exposed: ExposeState = {
|
|
787
|
+
version: 1,
|
|
788
|
+
layer: "tailnet",
|
|
789
|
+
mode: "path",
|
|
790
|
+
canonicalFqdn: "box.tailnet.ts.net",
|
|
791
|
+
port: 443,
|
|
792
|
+
funnel: false,
|
|
793
|
+
entries: [],
|
|
794
|
+
hubOrigin: "https://box.tailnet.ts.net",
|
|
795
|
+
};
|
|
796
|
+
const logs: string[] = [];
|
|
797
|
+
const code = await init({
|
|
798
|
+
configDir: h.configDir,
|
|
799
|
+
manifestPath: h.manifestPath,
|
|
800
|
+
log: (l) => logs.push(l),
|
|
801
|
+
alive: () => false,
|
|
802
|
+
ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
|
|
803
|
+
// Reader returns undefined the first time, then the exposed state
|
|
804
|
+
// after the chain ran. Mirrors the real on-disk flow where
|
|
805
|
+
// exposeTailnet writes expose-state.json.
|
|
806
|
+
readExposeStateFn: () => (exposedYet ? exposed : undefined),
|
|
807
|
+
isTty: false,
|
|
808
|
+
platform: "linux",
|
|
809
|
+
env: {},
|
|
810
|
+
exposeTailnetImpl: async () => {
|
|
811
|
+
exposedYet = true;
|
|
812
|
+
return 0;
|
|
813
|
+
},
|
|
814
|
+
exposeCloudflareImpl: async () => 0,
|
|
815
|
+
exposeChoice: "tailnet",
|
|
816
|
+
});
|
|
817
|
+
expect(code).toBe(0);
|
|
818
|
+
expect(logs.join("\n")).toContain("https://box.tailnet.ts.net/admin/");
|
|
819
|
+
expect(logs.join("\n")).not.toContain("http://127.0.0.1");
|
|
820
|
+
} finally {
|
|
821
|
+
h.cleanup();
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// Type alias used only inside this test file for the heuristic test.
|
|
827
|
+
type ExposeChoice = "none" | "tailnet" | "cloudflare";
|