@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.14-rc.6",
3
+ "version": "0.5.14-rc.8",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -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";