@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 +1 -1
- package/src/__tests__/init.test.ts +451 -1
- package/src/cli.ts +27 -3
- package/src/commands/init.ts +204 -21
- package/src/help.ts +31 -14
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";
|
|
@@ -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
|
|
316
|
-
|
|
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(
|
|
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
|
|
package/src/commands/init.ts
CHANGED
|
@@ -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-
|
|
5
|
-
* wizard
|
|
6
|
-
*
|
|
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
|
|
12
|
-
* scribe install + first-boot bootstrap
|
|
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.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
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
|
|
27
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
128
|
-
confirms "looks good — here's your URL" and
|
|
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
|
|
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
|
|
135
|
-
parachute init
|
|
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
|
|