@openparachute/hub 0.5.14-rc.6 → 0.5.14-rc.7

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.7",
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";
@@ -194,6 +194,8 @@ describe("init", () => {
194
194
  opened.push(url);
195
195
  return true;
196
196
  },
197
+ // Skip the new exposure prompt — this test is about the browser prompt only.
198
+ noExposePrompt: true,
197
199
  });
198
200
  expect(code).toBe(0);
199
201
  expect(opened).toEqual(["http://127.0.0.1:1939/admin/"]);
@@ -221,6 +223,7 @@ describe("init", () => {
221
223
  opened.push(url);
222
224
  return true;
223
225
  },
226
+ noExposePrompt: true,
224
227
  });
225
228
  expect(code).toBe(0);
226
229
  expect(opened).toEqual([]);
@@ -253,6 +256,7 @@ describe("init", () => {
253
256
  return true;
254
257
  },
255
258
  noBrowser: true,
259
+ noExposePrompt: true,
256
260
  });
257
261
  expect(code).toBe(0);
258
262
  expect(prompted).toBe(false);
@@ -308,6 +312,7 @@ describe("init", () => {
308
312
  opened.push(url);
309
313
  return true;
310
314
  },
315
+ noExposePrompt: true,
311
316
  });
312
317
  expect(code).toBe(0);
313
318
  // No prompt offered on Windows — just URL printed.
@@ -342,3 +347,448 @@ describe("init", () => {
342
347
  }
343
348
  });
344
349
  });
350
+
351
+ describe("looksLikeServer heuristic", () => {
352
+ test("macOS is never a server", () => {
353
+ expect(looksLikeServer("darwin", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(false);
354
+ expect(looksLikeServer("darwin", {})).toBe(false);
355
+ });
356
+
357
+ test("Linux desktop with DISPLAY is a laptop", () => {
358
+ expect(looksLikeServer("linux", { DISPLAY: ":0" })).toBe(false);
359
+ expect(looksLikeServer("linux", { WAYLAND_DISPLAY: "wayland-0" })).toBe(false);
360
+ });
361
+
362
+ test("Linux + SSH session → server", () => {
363
+ expect(looksLikeServer("linux", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(true);
364
+ expect(looksLikeServer("linux", { SSH_CLIENT: "1.2.3.4 22 5.6.7.8" })).toBe(true);
365
+ expect(looksLikeServer("linux", { SSH_TTY: "/dev/pts/0" })).toBe(true);
366
+ });
367
+
368
+ test("Linux + no DISPLAY → server (headless)", () => {
369
+ expect(looksLikeServer("linux", {})).toBe(true);
370
+ });
371
+
372
+ test("Windows is not a server (init doesn't auto-pick on win32 anyway)", () => {
373
+ expect(looksLikeServer("win32", {})).toBe(false);
374
+ });
375
+ });
376
+
377
+ describe("init exposure chain", () => {
378
+ test("TTY + no exposure + no flags → prompt is shown", async () => {
379
+ const h = makeHarness();
380
+ try {
381
+ writeHubPort(1939, h.configDir);
382
+ const promptCalls: string[] = [];
383
+ const code = await init({
384
+ configDir: h.configDir,
385
+ manifestPath: h.manifestPath,
386
+ log: () => {},
387
+ alive: () => false,
388
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
389
+ readExposeStateFn: () => undefined,
390
+ isTty: true,
391
+ platform: "darwin",
392
+ env: {},
393
+ prompt: async (q) => {
394
+ promptCalls.push(q);
395
+ // First prompt is the exposure picker → pick "none"; second
396
+ // is the browser-open question → say no.
397
+ if (promptCalls.length === 1) return "1";
398
+ return "n";
399
+ },
400
+ openBrowser: () => true,
401
+ });
402
+ expect(code).toBe(0);
403
+ // The exposure prompt was shown.
404
+ expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(true);
405
+ } finally {
406
+ h.cleanup();
407
+ }
408
+ });
409
+
410
+ test("--no-expose-prompt skips the prompt entirely", async () => {
411
+ const h = makeHarness();
412
+ try {
413
+ writeHubPort(1939, h.configDir);
414
+ let exposureChained = false;
415
+ const promptCalls: string[] = [];
416
+ const code = await init({
417
+ configDir: h.configDir,
418
+ manifestPath: h.manifestPath,
419
+ log: () => {},
420
+ alive: () => false,
421
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
422
+ readExposeStateFn: () => undefined,
423
+ isTty: true,
424
+ platform: "darwin",
425
+ env: {},
426
+ prompt: async (q) => {
427
+ promptCalls.push(q);
428
+ return "n";
429
+ },
430
+ openBrowser: () => true,
431
+ exposeTailnetImpl: async () => {
432
+ exposureChained = true;
433
+ return 0;
434
+ },
435
+ exposeCloudflareImpl: async () => {
436
+ exposureChained = true;
437
+ return 0;
438
+ },
439
+ noExposePrompt: true,
440
+ });
441
+ expect(code).toBe(0);
442
+ expect(exposureChained).toBe(false);
443
+ // No exposure prompt; only the browser-open prompt.
444
+ expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(false);
445
+ } finally {
446
+ h.cleanup();
447
+ }
448
+ });
449
+
450
+ test("--expose tailnet chains into tailnet without prompting", async () => {
451
+ const h = makeHarness();
452
+ try {
453
+ writeHubPort(1939, h.configDir);
454
+ let tailnetCalls = 0;
455
+ let cloudflareCalls = 0;
456
+ const promptCalls: string[] = [];
457
+ const code = await init({
458
+ configDir: h.configDir,
459
+ manifestPath: h.manifestPath,
460
+ log: () => {},
461
+ alive: () => false,
462
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
463
+ readExposeStateFn: () => undefined,
464
+ isTty: true,
465
+ platform: "linux",
466
+ env: {},
467
+ prompt: async (q) => {
468
+ promptCalls.push(q);
469
+ return "n";
470
+ },
471
+ openBrowser: () => true,
472
+ exposeTailnetImpl: async () => {
473
+ tailnetCalls += 1;
474
+ return 0;
475
+ },
476
+ exposeCloudflareImpl: async () => {
477
+ cloudflareCalls += 1;
478
+ return 0;
479
+ },
480
+ exposeChoice: "tailnet",
481
+ });
482
+ expect(code).toBe(0);
483
+ expect(tailnetCalls).toBe(1);
484
+ expect(cloudflareCalls).toBe(0);
485
+ // No exposure prompt — the flag pre-empted it.
486
+ expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(false);
487
+ } finally {
488
+ h.cleanup();
489
+ }
490
+ });
491
+
492
+ test("--expose cloudflare chains into cloudflare without prompting", async () => {
493
+ const h = makeHarness();
494
+ try {
495
+ writeHubPort(1939, h.configDir);
496
+ let tailnetCalls = 0;
497
+ let cloudflareCalls = 0;
498
+ const code = await init({
499
+ configDir: h.configDir,
500
+ manifestPath: h.manifestPath,
501
+ log: () => {},
502
+ alive: () => false,
503
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
504
+ readExposeStateFn: () => undefined,
505
+ isTty: false,
506
+ platform: "linux",
507
+ env: {},
508
+ exposeTailnetImpl: async () => {
509
+ tailnetCalls += 1;
510
+ return 0;
511
+ },
512
+ exposeCloudflareImpl: async () => {
513
+ cloudflareCalls += 1;
514
+ return 0;
515
+ },
516
+ exposeChoice: "cloudflare",
517
+ });
518
+ expect(code).toBe(0);
519
+ expect(cloudflareCalls).toBe(1);
520
+ expect(tailnetCalls).toBe(0);
521
+ } finally {
522
+ h.cleanup();
523
+ }
524
+ });
525
+
526
+ test("--expose none skips exposure", async () => {
527
+ const h = makeHarness();
528
+ try {
529
+ writeHubPort(1939, h.configDir);
530
+ let tailnetCalls = 0;
531
+ let cloudflareCalls = 0;
532
+ const code = await init({
533
+ configDir: h.configDir,
534
+ manifestPath: h.manifestPath,
535
+ log: () => {},
536
+ alive: () => false,
537
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
538
+ readExposeStateFn: () => undefined,
539
+ isTty: false,
540
+ platform: "linux",
541
+ env: {},
542
+ exposeTailnetImpl: async () => {
543
+ tailnetCalls += 1;
544
+ return 0;
545
+ },
546
+ exposeCloudflareImpl: async () => {
547
+ cloudflareCalls += 1;
548
+ return 0;
549
+ },
550
+ exposeChoice: "none",
551
+ });
552
+ expect(code).toBe(0);
553
+ expect(tailnetCalls).toBe(0);
554
+ expect(cloudflareCalls).toBe(0);
555
+ } finally {
556
+ h.cleanup();
557
+ }
558
+ });
559
+
560
+ test("default selection differs by SSH heuristic (laptop → 1, server → 3)", async () => {
561
+ const h = makeHarness();
562
+ try {
563
+ writeHubPort(1939, h.configDir);
564
+
565
+ // Laptop: macOS, no SSH → default is "1" (none).
566
+ let promptLog: string[] = [];
567
+ // Array-based holder defeats TS control-flow narrowing — element
568
+ // reads on an array typed as ExposeChoice[] always come back as the
569
+ // declared element type, not narrowed to the last assigned literal.
570
+ const chained: ExposeChoice[] = ["none"];
571
+ const setChained = (v: ExposeChoice) => {
572
+ chained[0] = v;
573
+ };
574
+ await init({
575
+ configDir: h.configDir,
576
+ manifestPath: h.manifestPath,
577
+ log: (l) => promptLog.push(l),
578
+ alive: () => false,
579
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
580
+ readExposeStateFn: () => undefined,
581
+ isTty: true,
582
+ platform: "darwin",
583
+ env: {},
584
+ prompt: async (q) => {
585
+ promptLog.push(`Q: ${q}`);
586
+ // Empty == confirm default.
587
+ if (q.toLowerCase().includes("pick")) return "";
588
+ return "n";
589
+ },
590
+ openBrowser: () => true,
591
+ exposeTailnetImpl: async () => {
592
+ setChained("tailnet");
593
+ return 0;
594
+ },
595
+ exposeCloudflareImpl: async () => {
596
+ setChained("cloudflare");
597
+ return 0;
598
+ },
599
+ });
600
+ // Default on laptop is "none" → no chain.
601
+ expect(chained[0]).toBe("none");
602
+ // The "Pick [1]" prompt was shown (loopback as default).
603
+ expect(promptLog.some((l) => l.includes("Pick [1]"))).toBe(true);
604
+
605
+ // Server: Linux + SSH → default is "3" (cloudflare).
606
+ promptLog = [];
607
+ setChained("none");
608
+ await init({
609
+ configDir: h.configDir,
610
+ manifestPath: h.manifestPath,
611
+ log: (l) => promptLog.push(l),
612
+ alive: () => true,
613
+ ensureHub: async () => ({ pid: 7, port: 1939, started: false }),
614
+ readExposeStateFn: () => undefined,
615
+ isTty: true,
616
+ platform: "linux",
617
+ env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
618
+ prompt: async (q) => {
619
+ promptLog.push(`Q: ${q}`);
620
+ if (q.toLowerCase().includes("pick")) return "";
621
+ return "n";
622
+ },
623
+ openBrowser: () => true,
624
+ exposeTailnetImpl: async () => {
625
+ setChained("tailnet");
626
+ return 0;
627
+ },
628
+ exposeCloudflareImpl: async () => {
629
+ setChained("cloudflare");
630
+ return 0;
631
+ },
632
+ });
633
+ expect(chained[0]).toBe("cloudflare");
634
+ expect(promptLog.some((l) => l.includes("Pick [3]"))).toBe(true);
635
+ } finally {
636
+ h.cleanup();
637
+ }
638
+ });
639
+
640
+ test("hub already exposed → no prompt, FQDN URL printed", async () => {
641
+ const h = makeHarness();
642
+ try {
643
+ writeHubPort(1939, h.configDir);
644
+ const state: ExposeState = {
645
+ version: 1,
646
+ layer: "public",
647
+ mode: "path",
648
+ canonicalFqdn: "ec2-example.parachute.computer",
649
+ port: 443,
650
+ funnel: false,
651
+ entries: [],
652
+ hubOrigin: "https://ec2-example.parachute.computer",
653
+ };
654
+ const promptCalls: string[] = [];
655
+ let chained = false;
656
+ const logs: string[] = [];
657
+ const code = await init({
658
+ configDir: h.configDir,
659
+ manifestPath: h.manifestPath,
660
+ log: (l) => logs.push(l),
661
+ alive: () => false,
662
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
663
+ readExposeStateFn: () => state,
664
+ isTty: true,
665
+ platform: "linux",
666
+ env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
667
+ prompt: async (q) => {
668
+ promptCalls.push(q);
669
+ return "n";
670
+ },
671
+ openBrowser: () => true,
672
+ exposeTailnetImpl: async () => {
673
+ chained = true;
674
+ return 0;
675
+ },
676
+ exposeCloudflareImpl: async () => {
677
+ chained = true;
678
+ return 0;
679
+ },
680
+ });
681
+ expect(code).toBe(0);
682
+ // No exposure chain ran, no exposure prompt asked.
683
+ expect(chained).toBe(false);
684
+ expect(promptCalls.some((q) => q.toLowerCase().includes("pick"))).toBe(false);
685
+ // The FQDN URL is printed.
686
+ expect(logs.join("\n")).toContain("https://ec2-example.parachute.computer/admin/");
687
+ expect(logs.join("\n")).toContain("already exposed");
688
+ } finally {
689
+ h.cleanup();
690
+ }
691
+ });
692
+
693
+ test("non-TTY → no exposure prompt, falls through to localhost", async () => {
694
+ const h = makeHarness();
695
+ try {
696
+ writeHubPort(1939, h.configDir);
697
+ let chained = false;
698
+ const code = await init({
699
+ configDir: h.configDir,
700
+ manifestPath: h.manifestPath,
701
+ log: () => {},
702
+ alive: () => false,
703
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
704
+ readExposeStateFn: () => undefined,
705
+ isTty: false,
706
+ platform: "linux",
707
+ env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
708
+ exposeTailnetImpl: async () => {
709
+ chained = true;
710
+ return 0;
711
+ },
712
+ exposeCloudflareImpl: async () => {
713
+ chained = true;
714
+ return 0;
715
+ },
716
+ });
717
+ expect(code).toBe(0);
718
+ expect(chained).toBe(false);
719
+ } finally {
720
+ h.cleanup();
721
+ }
722
+ });
723
+
724
+ test("exposure chain non-zero exit propagates", async () => {
725
+ const h = makeHarness();
726
+ try {
727
+ writeHubPort(1939, h.configDir);
728
+ const code = await init({
729
+ configDir: h.configDir,
730
+ manifestPath: h.manifestPath,
731
+ log: () => {},
732
+ alive: () => false,
733
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
734
+ readExposeStateFn: () => undefined,
735
+ isTty: false,
736
+ platform: "linux",
737
+ env: {},
738
+ exposeTailnetImpl: async () => 0,
739
+ exposeCloudflareImpl: async () => 2,
740
+ exposeChoice: "cloudflare",
741
+ });
742
+ expect(code).toBe(2);
743
+ } finally {
744
+ h.cleanup();
745
+ }
746
+ });
747
+
748
+ test("after exposure runs, the admin URL re-reads expose state for the FQDN", async () => {
749
+ const h = makeHarness();
750
+ try {
751
+ writeHubPort(1939, h.configDir);
752
+ let exposedYet = false;
753
+ const exposed: ExposeState = {
754
+ version: 1,
755
+ layer: "tailnet",
756
+ mode: "path",
757
+ canonicalFqdn: "box.tailnet.ts.net",
758
+ port: 443,
759
+ funnel: false,
760
+ entries: [],
761
+ hubOrigin: "https://box.tailnet.ts.net",
762
+ };
763
+ const logs: string[] = [];
764
+ const code = await init({
765
+ configDir: h.configDir,
766
+ manifestPath: h.manifestPath,
767
+ log: (l) => logs.push(l),
768
+ alive: () => false,
769
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
770
+ // Reader returns undefined the first time, then the exposed state
771
+ // after the chain ran. Mirrors the real on-disk flow where
772
+ // exposeTailnet writes expose-state.json.
773
+ readExposeStateFn: () => (exposedYet ? exposed : undefined),
774
+ isTty: false,
775
+ platform: "linux",
776
+ env: {},
777
+ exposeTailnetImpl: async () => {
778
+ exposedYet = true;
779
+ return 0;
780
+ },
781
+ exposeCloudflareImpl: async () => 0,
782
+ exposeChoice: "tailnet",
783
+ });
784
+ expect(code).toBe(0);
785
+ expect(logs.join("\n")).toContain("https://box.tailnet.ts.net/admin/");
786
+ expect(logs.join("\n")).not.toContain("http://127.0.0.1");
787
+ } finally {
788
+ h.cleanup();
789
+ }
790
+ });
791
+ });
792
+
793
+ // Type alias used only inside this test file for the heuristic test.
794
+ type ExposeChoice = "none" | "tailnet" | "cloudflare";
package/src/cli.ts CHANGED
@@ -312,15 +312,39 @@ async function main(argv: string[]): Promise<number> {
312
312
  console.log(initHelp());
313
313
  return 0;
314
314
  }
315
- const noBrowser = rest.includes("--no-browser");
316
- const unknown = rest.find((a) => a !== "--no-browser");
315
+ const exposeExtract = extractNamedFlag(rest, "--expose");
316
+ if (exposeExtract.error) {
317
+ console.error(`parachute init: ${exposeExtract.error}`);
318
+ return 1;
319
+ }
320
+ if (
321
+ exposeExtract.value !== undefined &&
322
+ exposeExtract.value !== "none" &&
323
+ exposeExtract.value !== "tailnet" &&
324
+ exposeExtract.value !== "cloudflare"
325
+ ) {
326
+ console.error(
327
+ `parachute init: --expose must be one of none|tailnet|cloudflare (got "${exposeExtract.value}")`,
328
+ );
329
+ return 1;
330
+ }
331
+ const noBrowser = exposeExtract.rest.includes("--no-browser");
332
+ const noExposePrompt = exposeExtract.rest.includes("--no-expose-prompt");
333
+ const known = new Set(["--no-browser", "--no-expose-prompt"]);
334
+ const unknown = exposeExtract.rest.find((a) => !known.has(a));
317
335
  if (unknown !== undefined) {
318
336
  console.error(`parachute init: unknown argument "${unknown}"`);
319
- console.error("usage: parachute init [--no-browser]");
337
+ console.error(
338
+ "usage: parachute init [--no-browser] [--no-expose-prompt] [--expose none|tailnet|cloudflare]",
339
+ );
320
340
  return 1;
321
341
  }
322
342
  const initOpts: Parameters<typeof init>[0] = {};
323
343
  if (noBrowser) initOpts.noBrowser = true;
344
+ if (noExposePrompt) initOpts.noExposePrompt = true;
345
+ if (exposeExtract.value) {
346
+ initOpts.exposeChoice = exposeExtract.value as "none" | "tailnet" | "cloudflare";
347
+ }
324
348
  return await init(initOpts);
325
349
  }
326
350
 
@@ -1,30 +1,35 @@
1
1
  /**
2
- * `parachute init` — fresh-install front door.
2
+ * `parachute init` — fresh-install front door, single entry point for both
3
+ * laptops and remote servers (EC2, DigitalOcean, Hetzner, any VPS).
3
4
  *
4
- * Aaron's framing (2026-05-27): "On render I just install it and then the
5
- * wizard walks me through all that including installing vault; I think
6
- * that should be similar here. One quick thing to set it up then I can
7
- * walk through in CLI (probably parachute init or something) or ideally
8
- * I can visit the page and go through the wizard."
5
+ * Aaron's framing (2026-05-28): "orient local install more to leveraging the
6
+ * wizard if possible. Local install in this case should be extremely similar
7
+ * to ec2, except that perhaps we get parachute expose set up first."
9
8
  *
10
9
  * The job: get the user from a fresh install to the admin SPA setup
11
- * wizard with one command. The wizard already handles vault install +
12
- * scribe install + first-boot bootstrap (`/admin/setup`). `init`'s
13
- * responsibility is narrower:
10
+ * wizard with one command, regardless of where the box lives. The wizard
11
+ * already handles vault install + scribe install + first-boot bootstrap
12
+ * (`/admin/setup`). `init`'s responsibility is narrower:
14
13
  *
15
14
  * 1. The hub binary is already on PATH (you can't `parachute init`
16
15
  * without it). So "is hub installed" is always yes here.
17
16
  * 2. Is the hub *running* on this box? If not, start it.
18
- * 3. Print the canonical admin URL local loopback if we're not
19
- * exposed, the tailnet / cloudflare FQDN if we are.
20
- * 4. Offer to open the URL in a browser (macOS: `open`, Linux:
17
+ * 3. Is the hub already exposed (`expose-state.json` present)? If so,
18
+ * skip straight to printing the FQDN. Otherwise, in a TTY, ask
19
+ * whether the operator wants to expose it now defaulting to
20
+ * "no, loopback" on a laptop and pre-selecting Cloudflare on a
21
+ * server (SSH session detected). Same command both paths.
22
+ * 4. After any exposure chain, re-resolve and print the canonical
23
+ * admin URL — local loopback if we're not exposed, the tailnet /
24
+ * cloudflare FQDN if we are.
25
+ * 5. Offer to open the URL in a browser (macOS: `open`, Linux:
21
26
  * `xdg-open`). Skip in non-TTY shells.
22
- * 5. If a vault is already configured, just confirm "looks good" and
27
+ * 6. If a vault is already configured, confirm "looks good" and
23
28
  * point at the URL. The wizard surfaces install-state internally —
24
29
  * no need to duplicate that logic here.
25
30
  *
26
- * Idempotent: every re-run is safe. If hub is up and a vault row exists,
27
- * we print the URL and exit 0 without touching anything.
31
+ * Idempotent: every re-run is safe. If hub is up and exposed (or the user
32
+ * picked "no expose" once), the chain short-circuits.
28
33
  */
29
34
 
30
35
  import { spawnSync } from "node:child_process";
@@ -40,6 +45,9 @@ import {
40
45
  import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
41
46
  import { readManifestLenient } from "../services-manifest.ts";
42
47
 
48
+ /** The three options the exposure prompt offers — also the `--expose` flag's domain. */
49
+ export type ExposeChoice = "none" | "tailnet" | "cloudflare";
50
+
43
51
  export interface InitOpts {
44
52
  configDir?: string;
45
53
  manifestPath?: string;
@@ -55,7 +63,7 @@ export interface InitOpts {
55
63
  readExposeStateFn?: () => ExposeState | undefined;
56
64
  /** Test seam: TTY check (production reads `process.stdin.isTTY`). */
57
65
  isTty?: boolean;
58
- /** Test seam: prompt for "open in browser?". */
66
+ /** Test seam: prompt for "open in browser?" and exposure choice. */
59
67
  prompt?: (question: string) => Promise<string>;
60
68
  /**
61
69
  * Test seam: browser-open shim. Receives `url`; production shells out
@@ -64,11 +72,35 @@ export interface InitOpts {
64
72
  openBrowser?: (url: string) => boolean;
65
73
  /** Test seam: `process.platform`. */
66
74
  platform?: NodeJS.Platform;
75
+ /** Test seam: process.env for SSH / DISPLAY detection. */
76
+ env?: NodeJS.ProcessEnv;
67
77
  /**
68
78
  * If true, don't even ask about opening the browser. Convenient flag for
69
79
  * CI / scripts that just want the URL printed and exit 0.
70
80
  */
71
81
  noBrowser?: boolean;
82
+ /**
83
+ * Non-interactive exposure choice. Skips the prompt entirely:
84
+ * - "none" — no-op (laptop default)
85
+ * - "tailnet" — chain into `exposeTailnet("up", {})`
86
+ * - "cloudflare" — chain into the cloudflare interactive flow
87
+ * For CI / scripted deploys.
88
+ */
89
+ exposeChoice?: ExposeChoice;
90
+ /** Skip the exposure prompt; fall through to "here's localhost URL". */
91
+ noExposePrompt?: boolean;
92
+ /**
93
+ * Test seam: shim for the tailnet exposure chain. Production imports
94
+ * `exposeTailnet` lazily. Tests pass a stub to record the call without
95
+ * shelling out to `tailscale serve`.
96
+ */
97
+ exposeTailnetImpl?: () => Promise<number>;
98
+ /**
99
+ * Test seam: shim for the cloudflare exposure chain. Production imports
100
+ * `exposePublicInteractive` with `preselect: "cloudflare"`. Tests pass a
101
+ * stub to record the call without shelling out to `cloudflared`.
102
+ */
103
+ exposeCloudflareImpl?: () => Promise<number>;
72
104
  }
73
105
 
74
106
  /**
@@ -84,7 +116,7 @@ export function resolveAdminUrl(
84
116
  exposeState: ExposeState | undefined,
85
117
  hubPort: number | undefined,
86
118
  ): string | undefined {
87
- if (exposeState && exposeState.canonicalFqdn) {
119
+ if (exposeState?.canonicalFqdn) {
88
120
  return `https://${exposeState.canonicalFqdn}/admin/`;
89
121
  }
90
122
  if (hubPort !== undefined) {
@@ -93,6 +125,30 @@ export function resolveAdminUrl(
93
125
  return undefined;
94
126
  }
95
127
 
128
+ /**
129
+ * Heuristic: is this likely a server (vs. a laptop)?
130
+ *
131
+ * Servers default-highlight Cloudflare in the prompt; laptops default to
132
+ * "no expose". We don't auto-pick — always prompt — but pre-select the
133
+ * sensible default so an operator can confirm with Enter.
134
+ *
135
+ * Signals:
136
+ * - Linux platform AND ($SSH_CONNECTION set OR no $DISPLAY)
137
+ *
138
+ * macOS / Windows / Linux desktop → laptop.
139
+ */
140
+ export function looksLikeServer(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
141
+ if (platform !== "linux") return false;
142
+ // WSL2 is Linux + headless from $DISPLAY's perspective but is in fact a
143
+ // developer's laptop. Detect via WSL-specific env vars (set in every WSL
144
+ // distro) so we don't pre-select Cloudflare for someone running Parachute
145
+ // inside WSL on Windows. Reviewer-flagged on #445.
146
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return false;
147
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true;
148
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return true;
149
+ return false;
150
+ }
151
+
96
152
  /**
97
153
  * Default browser-opener. Tries `open` on macOS, `xdg-open` on Linux, and
98
154
  * returns false when neither is available (Windows / WSL fallthrough +
@@ -118,6 +174,64 @@ async function defaultPrompt(question: string): Promise<string> {
118
174
  }
119
175
  }
120
176
 
177
+ /**
178
+ * Default chain into Tailscale exposure. Lazy-imports so tests don't pull
179
+ * tailscale wiring into the init module's surface.
180
+ */
181
+ async function defaultExposeTailnet(): Promise<number> {
182
+ const { exposeTailnet } = await import("./expose.ts");
183
+ return await exposeTailnet("up", {});
184
+ }
185
+
186
+ /**
187
+ * Default chain into Cloudflare exposure. Goes through the interactive
188
+ * flow with `preselect: "cloudflare"` so the operator gets walked
189
+ * through install / login / hostname-prompt as needed.
190
+ */
191
+ async function defaultExposeCloudflare(): Promise<number> {
192
+ const { exposePublicInteractive } = await import("./expose-interactive.ts");
193
+ return await exposePublicInteractive({ preselect: "cloudflare" });
194
+ }
195
+
196
+ /**
197
+ * Prompt for the exposure choice. Returns the picked option, or
198
+ * `undefined` if the operator quit / bailed.
199
+ *
200
+ * Default is whichever option matches the platform heuristic — laptops
201
+ * default to "none", servers to "cloudflare". Empty input picks the
202
+ * default (so Enter == confirm).
203
+ */
204
+ async function promptExposeChoice(
205
+ prompt: (q: string) => Promise<string>,
206
+ log: (line: string) => void,
207
+ defaultChoice: ExposeChoice,
208
+ ): Promise<ExposeChoice | undefined> {
209
+ log("Do you want to expose it publicly so you can reach it from other devices?");
210
+ const mark = (c: ExposeChoice) => (c === defaultChoice ? " (default)" : "");
211
+ log(` 1) No — keep it loopback-only${mark("none")}`);
212
+ log(` 2) Yes via Tailscale Funnel (private to your devices)${mark("tailnet")}`);
213
+ log(` 3) Yes via Cloudflare Tunnel (public HTTPS, your own domain)${mark("cloudflare")}`);
214
+ log("");
215
+
216
+ const defaultDigit = defaultChoice === "none" ? "1" : defaultChoice === "tailnet" ? "2" : "3";
217
+
218
+ // Bounded retries — a stuck prompt (non-TTY stdin that slipped through,
219
+ // piped /dev/null, etc.) shouldn't spin forever.
220
+ for (let attempt = 0; attempt < 5; attempt++) {
221
+ const raw = (await prompt(`Pick [${defaultDigit}]: `)).trim().toLowerCase();
222
+ if (raw === "") {
223
+ return defaultChoice;
224
+ }
225
+ if (raw === "1" || raw === "no" || raw === "none") return "none";
226
+ if (raw === "2" || raw === "tailnet" || raw === "tailscale") return "tailnet";
227
+ if (raw === "3" || raw === "cloudflare") return "cloudflare";
228
+ if (raw === "q" || raw === "quit" || raw === "exit") return undefined;
229
+ log(`Sorry — expected 1, 2, 3, or q (got "${raw}"). Try again.`);
230
+ }
231
+ log("Too many invalid entries; falling back to default.");
232
+ return defaultChoice;
233
+ }
234
+
121
235
  export async function init(opts: InitOpts = {}): Promise<number> {
122
236
  const configDir = opts.configDir ?? CONFIG_DIR;
123
237
  const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
@@ -128,7 +242,10 @@ export async function init(opts: InitOpts = {}): Promise<number> {
128
242
  const isTty = opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
129
243
  const prompt = opts.prompt ?? defaultPrompt;
130
244
  const platform = opts.platform ?? process.platform;
245
+ const env = opts.env ?? process.env;
131
246
  const openBrowser = opts.openBrowser ?? ((url: string) => defaultOpenBrowser(url, platform));
247
+ const exposeTailnetImpl = opts.exposeTailnetImpl ?? defaultExposeTailnet;
248
+ const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
132
249
 
133
250
  log("Parachute init — getting your hub set up.");
134
251
  log("");
@@ -160,7 +277,52 @@ export async function init(opts: InitOpts = {}): Promise<number> {
160
277
  // overridden, so the fallback is almost always correct.
161
278
  if (hubPort === undefined) hubPort = HUB_DEFAULT_PORT;
162
279
 
163
- // Step 2: vault configured?
280
+ // Step 2: exposure chain. Skipped when already exposed, in non-TTY,
281
+ // or when --no-expose-prompt was passed. `--expose <choice>` jumps
282
+ // straight to the corresponding chain without asking.
283
+ let exposeState = readExposeStateFn();
284
+ const alreadyExposed = Boolean(exposeState?.canonicalFqdn);
285
+
286
+ if (alreadyExposed) {
287
+ // Already-exposed short-circuit: don't prompt. The admin URL printed
288
+ // later will be the FQDN.
289
+ log(`✓ Hub is already exposed at ${exposeState?.canonicalFqdn}.`);
290
+ } else if (opts.exposeChoice !== undefined) {
291
+ // Non-interactive override.
292
+ const code = await runExposureChoice(opts.exposeChoice, {
293
+ log,
294
+ exposeTailnetImpl,
295
+ exposeCloudflareImpl,
296
+ });
297
+ if (code !== 0) return code;
298
+ // Refresh state — the chain may have brought up an FQDN.
299
+ exposeState = readExposeStateFn();
300
+ } else if (opts.noExposePrompt) {
301
+ // Skip the question; fall through to localhost URL.
302
+ } else if (!isTty) {
303
+ // Non-TTY: don't prompt. Operator can re-run with --expose if needed.
304
+ } else {
305
+ log(`Hub is running locally at http://127.0.0.1:${hubPort}.`);
306
+ log("");
307
+ const isServer = looksLikeServer(platform, env);
308
+ const defaultChoice: ExposeChoice = isServer ? "cloudflare" : "none";
309
+ const picked = await promptExposeChoice(prompt, log, defaultChoice);
310
+ if (picked === undefined) {
311
+ log("");
312
+ log("Skipped exposure. Re-run `parachute expose public` later if you want to.");
313
+ } else if (picked !== "none") {
314
+ log("");
315
+ const code = await runExposureChoice(picked, {
316
+ log,
317
+ exposeTailnetImpl,
318
+ exposeCloudflareImpl,
319
+ });
320
+ if (code !== 0) return code;
321
+ exposeState = readExposeStateFn();
322
+ }
323
+ }
324
+
325
+ // Step 3: vault configured?
164
326
  let hasVault = false;
165
327
  try {
166
328
  const manifest = readManifestLenient(manifestPath);
@@ -171,8 +333,7 @@ export async function init(opts: InitOpts = {}): Promise<number> {
171
333
  hasVault = false;
172
334
  }
173
335
 
174
- // Step 3: resolve the admin URL.
175
- const exposeState = readExposeStateFn();
336
+ // Step 4: resolve the admin URL.
176
337
  const adminUrl = resolveAdminUrl(exposeState, hubPort);
177
338
  if (!adminUrl) {
178
339
  log("");
@@ -191,7 +352,7 @@ export async function init(opts: InitOpts = {}): Promise<number> {
191
352
  log(` ${adminUrl}`);
192
353
  log("");
193
354
 
194
- // Step 4: offer to open the browser. Skip in non-TTY shells (CI),
355
+ // Step 5: offer to open the browser. Skip in non-TTY shells (CI),
195
356
  // honor `--no-browser`.
196
357
  if (opts.noBrowser) return 0;
197
358
  if (!isTty) {
@@ -211,3 +372,25 @@ export async function init(opts: InitOpts = {}): Promise<number> {
211
372
  }
212
373
  return 0;
213
374
  }
375
+
376
+ /**
377
+ * Dispatch the chosen exposure path. Returns the exit code of the
378
+ * downstream chain. `none` is a no-op (success).
379
+ */
380
+ async function runExposureChoice(
381
+ choice: ExposeChoice,
382
+ ctx: {
383
+ log: (line: string) => void;
384
+ exposeTailnetImpl: () => Promise<number>;
385
+ exposeCloudflareImpl: () => Promise<number>;
386
+ },
387
+ ): Promise<number> {
388
+ if (choice === "none") return 0;
389
+ if (choice === "tailnet") {
390
+ ctx.log("Setting up Tailscale Funnel…");
391
+ return await ctx.exposeTailnetImpl();
392
+ }
393
+ // cloudflare
394
+ ctx.log("Setting up Cloudflare Tunnel…");
395
+ return await ctx.exposeCloudflareImpl();
396
+ }
package/src/help.ts CHANGED
@@ -5,11 +5,12 @@ export function topLevelHelp(): string {
5
5
  const services = knownServices().join(" | ");
6
6
  return `parachute ${pkg.version} — top-level CLI for the Parachute ecosystem
7
7
 
8
- Fresh install? Start here:
8
+ Fresh install? Start here — works the same on a laptop or a remote server:
9
9
  parachute init one quick step → admin wizard in your browser
10
+ (offers optional exposure on remote boxes)
10
11
 
11
12
  Usage:
12
- parachute init bring hub up + open admin wizard (idempotent)
13
+ parachute init bring hub up, offer exposure, open admin wizard
13
14
  parachute setup interactive walk-through: install services + configure
14
15
  parachute install <service> install and register a service
15
16
  services: ${services}
@@ -108,31 +109,47 @@ export function initHelp(): string {
108
109
  return `parachute init — get the admin wizard open in one step
109
110
 
110
111
  Usage:
111
- parachute init [--no-browser]
112
+ parachute init [--no-browser] [--no-expose-prompt] [--expose none|tailnet|cloudflare]
112
113
 
113
114
  What it does:
114
- Fresh-install front door. The admin SPA already walks operators through
115
- the rest (install vault, set up the admin user, install scribe / runner
116
- / app); this command's only job is to get you to that wizard with one
117
- command.
115
+ Fresh-install front door, one command for both laptops AND remote
116
+ servers (EC2, DigitalOcean, Hetzner, any VPS). The admin SPA already
117
+ walks operators through the rest (install vault, set up the admin
118
+ user, install scribe / runner / app); this command's only job is to
119
+ get you to that wizard.
118
120
 
119
121
  Idempotent — every re-run is safe:
120
122
  1. If the hub isn't running, start it.
121
- 2. Print the canonical admin URL (loopback when not exposed, the
123
+ 2. If the hub isn't already exposed, in a terminal, offer to set up
124
+ exposure (Tailscale Funnel, Cloudflare Tunnel, or stay loopback).
125
+ The default highlights "no thanks" on laptops and Cloudflare on
126
+ servers (SSH session detected). Skip with --no-expose-prompt or
127
+ pin non-interactively with --expose.
128
+ 3. Print the canonical admin URL (loopback when not exposed, the
122
129
  tailnet / cloudflare FQDN when exposure is active).
123
- 3. In a terminal, offer to open the URL in your browser
130
+ 4. In a terminal, offer to open the URL in your browser
124
131
  (macOS \`open\`, Linux \`xdg-open\`). Skip with --no-browser or
125
132
  run from a non-TTY shell.
126
133
 
127
- If your hub is up and a vault is already configured, init just
128
- confirms "looks good — here's your URL" and exits 0.
134
+ If your hub is up + exposure is already set up + a vault is already
135
+ configured, init just confirms "looks good — here's your URL" and
136
+ exits 0.
129
137
 
130
138
  Flags:
131
- --no-browser just print the URL; don't offer to launch a browser
139
+ --no-browser just print the URL; don't offer to launch a browser
140
+ --no-expose-prompt skip the exposure question; fall through to localhost URL
141
+ --expose <choice> non-interactive exposure override:
142
+ none — stay loopback-only
143
+ tailnet — set up Tailscale Funnel (private to your tailnet)
144
+ cloudflare — set up Cloudflare Tunnel (your own domain)
132
145
 
133
146
  Examples:
134
- parachute init bring up hub + open the wizard
135
- parachute init --no-browser same, but don't shell out to open / xdg-open
147
+ parachute init # laptop: prompts, defaults to "no expose"
148
+ parachute init # ssh'd server: prompts, defaults to Cloudflare
149
+ parachute init --no-expose-prompt # skip the question; just print localhost URL
150
+ parachute init --expose cloudflare # CI/scripted: chain straight into Cloudflare
151
+ parachute init --expose tailnet # CI/scripted: chain straight into Tailscale
152
+ parachute init --no-browser # don't shell out to open / xdg-open
136
153
  `;
137
154
  }
138
155