@openparachute/hub 0.3.0-rc.1

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.
Files changed (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
@@ -0,0 +1,428 @@
1
+ /**
2
+ * `parachute expose public` (no flags, in a TTY) — guided provider picker.
3
+ *
4
+ * The same command scripted (`--cloudflare --domain …` or running under a
5
+ * non-TTY stdin) keeps today's flag-driven behavior unchanged; this module is
6
+ * only reached via the explicit TTY+no-flags route from `cli.ts`.
7
+ *
8
+ * Shape mirrors `expose-cloudflare.ts`: every side-effectful edge (runner,
9
+ * prompt, platform detection, interactive stdio commands, last-provider
10
+ * storage) is an injectable seam so the prompt tree is testable end-to-end.
11
+ */
12
+
13
+ import { createInterface } from "node:readline/promises";
14
+ import {
15
+ DEFAULT_CLOUDFLARED_HOME,
16
+ isCloudflaredInstalled,
17
+ isCloudflaredLoggedIn,
18
+ } from "../cloudflare/detect.ts";
19
+ import {
20
+ EXPOSE_LAST_PROVIDER_PATH,
21
+ type ExposeProvider,
22
+ readLastProvider,
23
+ writeLastProvider,
24
+ } from "../expose-last-provider.ts";
25
+ import { getTailscaleStatus, isTailscaleInstalled } from "../tailscale/detect.ts";
26
+ import { type Runner, defaultRunner } from "../tailscale/run.ts";
27
+ import { type AuthPreflightOpts, runAuthPreflight } from "./expose-auth-preflight.ts";
28
+ import {
29
+ type ExposeCloudflareOpts,
30
+ exposeCloudflareUp,
31
+ isValidHostname,
32
+ } from "./expose-cloudflare.ts";
33
+ import { type ExposeOpts, exposePublic } from "./expose.ts";
34
+
35
+ /**
36
+ * Runs a command with inherited stdio, returning only the exit code. Used for
37
+ * interactive bits like `brew install cloudflared` and `cloudflared tunnel
38
+ * login` where we want the user to see the live output (brew progress bar,
39
+ * the login URL cloudflared prints, etc.).
40
+ */
41
+ export type InteractiveRunner = (cmd: readonly string[]) => Promise<number>;
42
+
43
+ const defaultInteractiveRunner: InteractiveRunner = async (cmd) => {
44
+ const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
45
+ return await proc.exited;
46
+ };
47
+
48
+ async function defaultPrompt(question: string): Promise<string> {
49
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
50
+ try {
51
+ return await rl.question(question);
52
+ } finally {
53
+ rl.close();
54
+ }
55
+ }
56
+
57
+ export interface ExposeInteractiveOpts {
58
+ runner?: Runner;
59
+ /** Inherit-stdio runner for brew/cloudflared-login. */
60
+ interactiveRunner?: InteractiveRunner;
61
+ prompt?: (question: string) => Promise<string>;
62
+ cloudflaredHome?: string;
63
+ platform?: NodeJS.Platform;
64
+ lastProviderPath?: string;
65
+ now?: () => Date;
66
+ log?: (line: string) => void;
67
+ /** Passthrough opts for the Tailscale Funnel path (`exposePublic`). */
68
+ exposeOpts?: ExposeOpts;
69
+ /** Passthrough opts for the Cloudflare path (`exposeCloudflareUp`). */
70
+ cloudflareOpts?: ExposeCloudflareOpts;
71
+ /**
72
+ * Skip the provider picker — the caller has already chosen. Used when the
73
+ * user typed a provider flag but left a required piece out (e.g.
74
+ * `--cloudflare` without `--domain` in a TTY): we've got their choice, we
75
+ * just need to prompt for what's missing.
76
+ */
77
+ preselect?: ExposeProvider;
78
+ /**
79
+ * Options passed through to the post-exposure auth preflight. Set
80
+ * `authPreflight.status` in tests to bypass the real on-disk probe; leave
81
+ * unset in production. Only consulted when the handoff returns 0.
82
+ */
83
+ authPreflight?: AuthPreflightOpts;
84
+ /**
85
+ * Test seams for the downstream entry points — lets us exercise the
86
+ * interactive branches without standing up a full tailscale/cloudflared
87
+ * stub stack. Production code never sets these.
88
+ */
89
+ exposePublicImpl?: (action: "up" | "off", opts: ExposeOpts) => Promise<number>;
90
+ exposeCloudflareUpImpl?: (hostname: string, opts: ExposeCloudflareOpts) => Promise<number>;
91
+ /** Test seam for the preflight itself. Defaults to {@link runAuthPreflight}. */
92
+ runAuthPreflightImpl?: (opts: AuthPreflightOpts) => Promise<void>;
93
+ }
94
+
95
+ interface Resolved {
96
+ runner: Runner;
97
+ interactiveRunner: InteractiveRunner;
98
+ prompt: (question: string) => Promise<string>;
99
+ cloudflaredHome: string;
100
+ platform: NodeJS.Platform;
101
+ lastProviderPath: string;
102
+ now: () => Date;
103
+ log: (line: string) => void;
104
+ exposeOpts: ExposeOpts;
105
+ cloudflareOpts: ExposeCloudflareOpts;
106
+ preselect: ExposeProvider | undefined;
107
+ authPreflight: AuthPreflightOpts;
108
+ exposePublicImpl: (action: "up" | "off", opts: ExposeOpts) => Promise<number>;
109
+ exposeCloudflareUpImpl: (hostname: string, opts: ExposeCloudflareOpts) => Promise<number>;
110
+ runAuthPreflightImpl: (opts: AuthPreflightOpts) => Promise<void>;
111
+ }
112
+
113
+ function resolve(opts: ExposeInteractiveOpts): Resolved {
114
+ return {
115
+ runner: opts.runner ?? defaultRunner,
116
+ interactiveRunner: opts.interactiveRunner ?? defaultInteractiveRunner,
117
+ prompt: opts.prompt ?? defaultPrompt,
118
+ cloudflaredHome: opts.cloudflaredHome ?? DEFAULT_CLOUDFLARED_HOME,
119
+ platform: opts.platform ?? process.platform,
120
+ lastProviderPath: opts.lastProviderPath ?? EXPOSE_LAST_PROVIDER_PATH,
121
+ now: opts.now ?? (() => new Date()),
122
+ log: opts.log ?? ((line) => console.log(line)),
123
+ exposeOpts: opts.exposeOpts ?? {},
124
+ cloudflareOpts: opts.cloudflareOpts ?? {},
125
+ preselect: opts.preselect,
126
+ authPreflight: opts.authPreflight ?? {},
127
+ exposePublicImpl: opts.exposePublicImpl ?? exposePublic,
128
+ exposeCloudflareUpImpl: opts.exposeCloudflareUpImpl ?? exposeCloudflareUp,
129
+ runAuthPreflightImpl: opts.runAuthPreflightImpl ?? runAuthPreflight,
130
+ };
131
+ }
132
+
133
+ interface Readiness {
134
+ tailscaleInstalled: boolean;
135
+ tailscaleLoggedIn: boolean;
136
+ tailscaleFunnelCap: boolean;
137
+ cloudflareInstalled: boolean;
138
+ cloudflareLoggedIn: boolean;
139
+ }
140
+
141
+ async function detectReadiness(r: Resolved): Promise<Readiness> {
142
+ const tailscaleInstalled = await isTailscaleInstalled(r.runner);
143
+ // One `tailscale status --json` covers both login state and Funnel cap.
144
+ // Skipped when the binary's missing — the call would just fail.
145
+ const { loggedIn: tailscaleLoggedIn, funnelCapable: tailscaleFunnelCap } = tailscaleInstalled
146
+ ? await getTailscaleStatus(r.runner)
147
+ : { loggedIn: false, funnelCapable: false };
148
+
149
+ const cloudflareInstalled = await isCloudflaredInstalled(r.runner);
150
+ const cloudflareLoggedIn = cloudflareInstalled ? isCloudflaredLoggedIn(r.cloudflaredHome) : false;
151
+
152
+ return {
153
+ tailscaleInstalled,
154
+ tailscaleLoggedIn,
155
+ tailscaleFunnelCap,
156
+ cloudflareInstalled,
157
+ cloudflareLoggedIn,
158
+ };
159
+ }
160
+
161
+ function isTailscaleReady(r: Readiness): boolean {
162
+ return r.tailscaleInstalled && r.tailscaleLoggedIn && r.tailscaleFunnelCap;
163
+ }
164
+
165
+ function isCloudflareReady(r: Readiness): boolean {
166
+ return r.cloudflareInstalled && r.cloudflareLoggedIn;
167
+ }
168
+
169
+ type PickResult = ExposeProvider | "quit";
170
+
171
+ /**
172
+ * Prompt loop tolerant to blank/whitespace/unexpected input: reprompts on
173
+ * garbage rather than failing. Empty string picks the default.
174
+ */
175
+ async function pickProvider(
176
+ r: Resolved,
177
+ opts: { defaultProvider: ExposeProvider; context: "both-ready" | "neither-ready" },
178
+ ): Promise<PickResult> {
179
+ const defaultLabel = opts.defaultProvider === "tailscale" ? "[1] default" : "[2] default";
180
+ const intro =
181
+ opts.context === "both-ready"
182
+ ? "Which provider?"
183
+ : "Neither Tailscale nor Cloudflare is set up. Which would you like to use?";
184
+ r.log("");
185
+ r.log(intro);
186
+ r.log(" [1] Tailscale Funnel (free, *.ts.net URL, no domain needed)");
187
+ r.log(" [2] Cloudflare Tunnel (your own domain, Cloudflare DNS)");
188
+ r.log(` [q] quit (default on enter: ${defaultLabel})`);
189
+
190
+ // Bounded retries — a stuck prompt (non-TTY stdin that slipped through,
191
+ // piped `/dev/null`, etc.) shouldn't spin forever.
192
+ for (let attempt = 0; attempt < 5; attempt++) {
193
+ const raw = (await r.prompt("> ")).trim().toLowerCase();
194
+ if (raw === "" || raw === opts.defaultProvider[0]) return opts.defaultProvider;
195
+ if (raw === "1" || raw === "tailscale") return "tailscale";
196
+ if (raw === "2" || raw === "cloudflare") return "cloudflare";
197
+ if (raw === "q" || raw === "quit" || raw === "exit") return "quit";
198
+ r.log(`Sorry — expected 1, 2, or q (got "${raw}"). Try again.`);
199
+ }
200
+ r.log("Too many invalid entries; aborting.");
201
+ return "quit";
202
+ }
203
+
204
+ async function promptHostname(r: Resolved): Promise<string | undefined> {
205
+ r.log("");
206
+ r.log("Cloudflare needs a hostname under a domain you've added to your Cloudflare account.");
207
+ r.log('Example: vault.example.com (apex "example.com" must be a Cloudflare zone)');
208
+ for (let attempt = 0; attempt < 5; attempt++) {
209
+ const raw = (await r.prompt("Hostname (or blank to quit): ")).trim();
210
+ if (raw === "") return undefined;
211
+ if (isValidHostname(raw)) return raw;
212
+ r.log(`"${raw}" doesn't look like a hostname. Expected something like vault.example.com.`);
213
+ }
214
+ r.log("Too many invalid entries; aborting.");
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * Print guidance for getting Tailscale ready. We do *not* automate any of
220
+ * this: `tailscale up` requires a browser auth flow, and the Funnel ACL is
221
+ * an admin-console change scoped to the tailnet — the CLI impersonating
222
+ * either would be presumptuous. User re-runs after fixing.
223
+ */
224
+ function printTailscaleSetupGuidance(r: Resolved, readiness: Readiness): void {
225
+ r.log("");
226
+ r.log("Tailscale Funnel needs three things:");
227
+ r.log("");
228
+ if (!readiness.tailscaleInstalled) {
229
+ r.log(" 1. Install Tailscale:");
230
+ if (r.platform === "darwin") {
231
+ r.log(" brew install tailscale");
232
+ } else {
233
+ r.log(" https://tailscale.com/download");
234
+ }
235
+ } else {
236
+ r.log(" 1. ✓ Tailscale is installed.");
237
+ }
238
+ if (!readiness.tailscaleLoggedIn) {
239
+ r.log(" 2. Log this machine into your tailnet:");
240
+ r.log(" tailscale up");
241
+ } else {
242
+ r.log(" 2. ✓ This machine is logged in.");
243
+ }
244
+ if (!readiness.tailscaleFunnelCap) {
245
+ r.log(" 3. Enable Funnel for this node in your tailnet ACLs:");
246
+ r.log(" https://login.tailscale.com/admin/acls");
247
+ r.log(" Add (or merge) this block under the ACL's top-level object:");
248
+ r.log("");
249
+ r.log(' "nodeAttrs": [');
250
+ r.log(' { "target": ["*"], "attr": ["funnel"] }');
251
+ r.log(" ]");
252
+ r.log("");
253
+ r.log(" (Scope `target` tighter — tag:server, a user, etc. — if you prefer.)");
254
+ } else {
255
+ r.log(" 3. ✓ Funnel is enabled for this node.");
256
+ }
257
+ r.log("");
258
+ r.log("Once those are done, re-run: parachute expose public");
259
+ }
260
+
261
+ /**
262
+ * Walks the user through installing and logging in cloudflared. On macOS we
263
+ * auto-install via brew (with confirmation); on Linux we print manual-install
264
+ * pointers and bail so the user can pick apt/dnf/tarball. Returns true only
265
+ * when cloudflared is both present and logged in afterwards.
266
+ */
267
+ async function guideCloudflareSetup(r: Resolved, readiness: Readiness): Promise<boolean> {
268
+ let installed = readiness.cloudflareInstalled;
269
+ let loggedIn = readiness.cloudflareLoggedIn;
270
+
271
+ if (!installed) {
272
+ if (r.platform === "darwin") {
273
+ r.log("");
274
+ r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
275
+ const answer = (await r.prompt("OK to run `brew install cloudflared`? [Y/n] "))
276
+ .trim()
277
+ .toLowerCase();
278
+ if (answer === "n" || answer === "no") {
279
+ r.log("Skipped auto-install. Install manually, then re-run: parachute expose public");
280
+ return false;
281
+ }
282
+ const code = await r.interactiveRunner(["brew", "install", "cloudflared"]);
283
+ if (code !== 0) {
284
+ r.log(`\`brew install cloudflared\` exited ${code}. Fix the error above, then re-run.`);
285
+ return false;
286
+ }
287
+ installed = await isCloudflaredInstalled(r.runner);
288
+ if (!installed) {
289
+ r.log("Installation reported success, but `cloudflared` still isn't on PATH.");
290
+ r.log("Open a fresh shell (so PATH picks up the new binary) and re-run.");
291
+ return false;
292
+ }
293
+ } else {
294
+ r.log("");
295
+ r.log("Cloudflare Tunnel uses the `cloudflared` binary, which isn't installed yet.");
296
+ r.log("Install one way:");
297
+ r.log(" Debian / Ubuntu:");
298
+ r.log(
299
+ " curl -L https://pkg.cloudflare.com/install.sh | sudo bash && sudo apt-get install -y cloudflared",
300
+ );
301
+ r.log(" RHEL / Fedora:");
302
+ r.log(" sudo dnf install cloudflared");
303
+ r.log(" Tarball / other:");
304
+ r.log(
305
+ " https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
306
+ );
307
+ r.log("");
308
+ r.log("After install, re-run: parachute expose public");
309
+ return false;
310
+ }
311
+ }
312
+
313
+ if (!loggedIn) {
314
+ r.log("");
315
+ r.log("cloudflared needs to be authenticated with your Cloudflare account first.");
316
+ r.log("The next step opens a browser so you can pick the domain to use.");
317
+ const answer = (await r.prompt("Run `cloudflared tunnel login` now? [Y/n] "))
318
+ .trim()
319
+ .toLowerCase();
320
+ if (answer === "n" || answer === "no") {
321
+ r.log("Skipped login. Run `cloudflared tunnel login` manually, then re-run.");
322
+ return false;
323
+ }
324
+ const code = await r.interactiveRunner(["cloudflared", "tunnel", "login"]);
325
+ if (code !== 0) {
326
+ r.log(`\`cloudflared tunnel login\` exited ${code}. Fix the error above, then re-run.`);
327
+ return false;
328
+ }
329
+ loggedIn = isCloudflaredLoggedIn(r.cloudflaredHome);
330
+ if (!loggedIn) {
331
+ r.log("Login ran but cert.pem didn't appear in ~/.cloudflared.");
332
+ r.log("Check the browser flow completed, then re-run: parachute expose public");
333
+ return false;
334
+ }
335
+ }
336
+
337
+ return true;
338
+ }
339
+
340
+ /**
341
+ * Default provider when both are ready: prefer whatever the user picked last
342
+ * time, falling back to Tailscale (spec: free + no domain needed — the more
343
+ * accessible starting point).
344
+ */
345
+ function defaultProviderFrom(lastPath: string): ExposeProvider {
346
+ const last = readLastProvider(lastPath);
347
+ return last?.provider ?? "tailscale";
348
+ }
349
+
350
+ export async function exposePublicInteractive(opts: ExposeInteractiveOpts = {}): Promise<number> {
351
+ const r = resolve(opts);
352
+ const readiness = await detectReadiness(r);
353
+ const tsReady = isTailscaleReady(readiness);
354
+ const cfReady = isCloudflareReady(readiness);
355
+
356
+ let provider: ExposeProvider;
357
+ if (r.preselect) {
358
+ // Caller passed a provider flag but is missing a required piece — skip
359
+ // the picker entirely and resume at the setup / hostname prompt.
360
+ provider = r.preselect;
361
+ } else if (tsReady && cfReady) {
362
+ const picked = await pickProvider(r, {
363
+ defaultProvider: defaultProviderFrom(r.lastProviderPath),
364
+ context: "both-ready",
365
+ });
366
+ if (picked === "quit") {
367
+ r.log("Nothing exposed.");
368
+ return 0;
369
+ }
370
+ provider = picked;
371
+ } else if (tsReady) {
372
+ r.log("Using Tailscale Funnel (Cloudflare Tunnel is also available with `--cloudflare`).");
373
+ provider = "tailscale";
374
+ } else if (cfReady) {
375
+ r.log("Using Cloudflare Tunnel.");
376
+ r.log("You'll need your own domain added to your Cloudflare account.");
377
+ provider = "cloudflare";
378
+ } else {
379
+ const picked = await pickProvider(r, {
380
+ defaultProvider: "tailscale",
381
+ context: "neither-ready",
382
+ });
383
+ if (picked === "quit") {
384
+ r.log("Nothing exposed.");
385
+ return 0;
386
+ }
387
+ provider = picked;
388
+ }
389
+
390
+ if (provider === "tailscale") {
391
+ if (!tsReady) {
392
+ printTailscaleSetupGuidance(r, readiness);
393
+ return 1;
394
+ }
395
+ writeLastProvider("tailscale", { path: r.lastProviderPath, now: r.now });
396
+ const code = await r.exposePublicImpl("up", r.exposeOpts);
397
+ if (code === 0) await runPreflightSafely(r);
398
+ return code;
399
+ }
400
+
401
+ // Cloudflare path.
402
+ if (!cfReady) {
403
+ const ok = await guideCloudflareSetup(r, readiness);
404
+ if (!ok) return 1;
405
+ }
406
+ const hostname = await promptHostname(r);
407
+ if (!hostname) {
408
+ r.log("Nothing exposed.");
409
+ return 0;
410
+ }
411
+ writeLastProvider("cloudflare", { path: r.lastProviderPath, now: r.now });
412
+ const code = await r.exposeCloudflareUpImpl(hostname, r.cloudflareOpts);
413
+ if (code === 0) await runPreflightSafely(r);
414
+ return code;
415
+ }
416
+
417
+ /**
418
+ * Catch anything the preflight throws and log it — the tunnel is already
419
+ * up, so an advisory module crashing must never swallow the user's success.
420
+ */
421
+ async function runPreflightSafely(r: Resolved): Promise<void> {
422
+ try {
423
+ await r.runAuthPreflightImpl(r.authPreflight);
424
+ } catch (err) {
425
+ r.log("");
426
+ r.log(`(auth preflight check skipped: ${err instanceof Error ? err.message : String(err)})`);
427
+ }
428
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * `parachute expose public off` (no `--cloudflare`) — auto-detect which
3
+ * provider is live and tear that one down. Layer 4 of the interactive arc.
4
+ *
5
+ * The CLI now has two teardown paths under `expose public off`:
6
+ * - Tailscale Funnel — state in expose-state.json, torn down by exposeOff.
7
+ * - Cloudflare Tunnel — state in cloudflared-state.json, torn down by
8
+ * exposeCloudflareOff.
9
+ *
10
+ * Users shouldn't need to remember which provider they brought up last just
11
+ * to turn it off. This wrapper reads both state files and routes:
12
+ *
13
+ * - Neither live → quiet no-op, exit 0.
14
+ * - Exactly one → tear it down, print a single-line summary.
15
+ * - Both live → prompt (TTY) for which to tear down, or `both`;
16
+ * non-TTY tears down both (off means off).
17
+ *
18
+ * `--cloudflare` still works as an explicit override and skips this module
19
+ * entirely (see cli.ts). Shape mirrors the other Layer-N modules — every
20
+ * side-effectful edge is an injectable seam so the full decision tree is
21
+ * testable without touching real state files or spawning teardown.
22
+ */
23
+
24
+ import { createInterface } from "node:readline/promises";
25
+ import {
26
+ CLOUDFLARED_STATE_PATH,
27
+ type CloudflaredState,
28
+ readCloudflaredState,
29
+ } from "../cloudflare/state.ts";
30
+ import { EXPOSE_STATE_PATH, type ExposeState, readExposeState } from "../expose-state.ts";
31
+ import {
32
+ type ExposeCloudflareOpts,
33
+ exposeCloudflareOff as defaultExposeCloudflareOff,
34
+ } from "./expose-cloudflare.ts";
35
+ import { type ExposeOpts, exposePublic as defaultExposePublic } from "./expose.ts";
36
+
37
+ async function defaultPrompt(question: string): Promise<string> {
38
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
39
+ try {
40
+ return await rl.question(question);
41
+ } finally {
42
+ rl.close();
43
+ }
44
+ }
45
+
46
+ export interface ExposePublicOffAutoOpts {
47
+ /**
48
+ * Forwarded to the tailscale teardown (`exposePublic("off", …)`). Tests use
49
+ * this to inject a fake runner / log sink / statePath.
50
+ */
51
+ tailscaleOffOpts?: ExposeOpts;
52
+ /**
53
+ * Forwarded to the cloudflare teardown. Tests use it the same way.
54
+ */
55
+ cloudflareOffOpts?: ExposeCloudflareOpts;
56
+
57
+ prompt?: (question: string) => Promise<string>;
58
+ log?: (line: string) => void;
59
+ isTty?: boolean;
60
+
61
+ readTailscaleState?: (path?: string) => ExposeState | undefined;
62
+ readCloudflaredState?: (path?: string) => CloudflaredState | undefined;
63
+ exposePublicImpl?: (action: "off", opts: ExposeOpts) => Promise<number>;
64
+ exposeCloudflareOffImpl?: (opts: ExposeCloudflareOpts) => Promise<number>;
65
+ }
66
+
67
+ interface Resolved {
68
+ tailscaleOffOpts: ExposeOpts;
69
+ cloudflareOffOpts: ExposeCloudflareOpts;
70
+ prompt: (question: string) => Promise<string>;
71
+ log: (line: string) => void;
72
+ isTty: boolean;
73
+ readTailscaleState: (path?: string) => ExposeState | undefined;
74
+ readCloudflaredState: (path?: string) => CloudflaredState | undefined;
75
+ exposePublicImpl: (action: "off", opts: ExposeOpts) => Promise<number>;
76
+ exposeCloudflareOffImpl: (opts: ExposeCloudflareOpts) => Promise<number>;
77
+ }
78
+
79
+ function resolve(opts: ExposePublicOffAutoOpts): Resolved {
80
+ return {
81
+ tailscaleOffOpts: opts.tailscaleOffOpts ?? {},
82
+ cloudflareOffOpts: opts.cloudflareOffOpts ?? {},
83
+ prompt: opts.prompt ?? defaultPrompt,
84
+ log: opts.log ?? ((line) => console.log(line)),
85
+ isTty: opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY),
86
+ readTailscaleState: opts.readTailscaleState ?? readExposeState,
87
+ readCloudflaredState: opts.readCloudflaredState ?? readCloudflaredState,
88
+ exposePublicImpl: opts.exposePublicImpl ?? defaultExposePublic,
89
+ exposeCloudflareOffImpl: opts.exposeCloudflareOffImpl ?? defaultExposeCloudflareOff,
90
+ };
91
+ }
92
+
93
+ function tailscalePublicIsLive(state: ExposeState | undefined): state is ExposeState {
94
+ return !!state && state.layer === "public" && state.funnel === true && state.entries.length > 0;
95
+ }
96
+
97
+ function cloudflareIsLive(state: CloudflaredState | undefined): state is CloudflaredState {
98
+ return !!state;
99
+ }
100
+
101
+ function tailscaleUrl(state: ExposeState): string {
102
+ return `https://${state.canonicalFqdn}`;
103
+ }
104
+
105
+ function cloudflareUrl(state: CloudflaredState): string {
106
+ return `https://${state.hostname}`;
107
+ }
108
+
109
+ type BothChoice = "tailscale" | "cloudflare" | "both" | "cancel";
110
+
111
+ async function promptBothLive(
112
+ r: Resolved,
113
+ tsState: ExposeState,
114
+ cfState: CloudflaredState,
115
+ ): Promise<BothChoice> {
116
+ r.log("Two public exposures are currently live:");
117
+ r.log(` [1] Tailscale Funnel — ${tailscaleUrl(tsState)}`);
118
+ r.log(` [2] Cloudflare Tunnel — ${cloudflareUrl(cfState)}`);
119
+ r.log(" [3] both");
120
+ r.log(" [4] cancel");
121
+ while (true) {
122
+ const raw = (await r.prompt("Tear down which? [3]: ")).trim().toLowerCase();
123
+ if (raw === "" || raw === "3" || raw === "both") return "both";
124
+ if (raw === "1" || raw === "tailscale" || raw === "ts" || raw === "funnel") return "tailscale";
125
+ if (raw === "2" || raw === "cloudflare" || raw === "cf") return "cloudflare";
126
+ if (raw === "4" || raw === "cancel" || raw === "q") return "cancel";
127
+ r.log(`(didn't understand "${raw}" — please pick 1, 2, 3, or 4)`);
128
+ }
129
+ }
130
+
131
+ async function tearDownTailscale(r: Resolved, state: ExposeState): Promise<number> {
132
+ const url = tailscaleUrl(state);
133
+ const code = await r.exposePublicImpl("off", r.tailscaleOffOpts);
134
+ if (code === 0) r.log(`✓ Tore down Tailscale Funnel (was: ${url})`);
135
+ return code;
136
+ }
137
+
138
+ async function tearDownCloudflare(r: Resolved, state: CloudflaredState): Promise<number> {
139
+ const url = cloudflareUrl(state);
140
+ const code = await r.exposeCloudflareOffImpl(r.cloudflareOffOpts);
141
+ if (code === 0) r.log(`✓ Tore down Cloudflare Tunnel (was: ${url})`);
142
+ return code;
143
+ }
144
+
145
+ export async function runExposePublicOffAutoDetect(
146
+ opts: ExposePublicOffAutoOpts = {},
147
+ ): Promise<number> {
148
+ const r = resolve(opts);
149
+
150
+ const tsStatePath = r.tailscaleOffOpts.statePath ?? EXPOSE_STATE_PATH;
151
+ const cfStatePath = r.cloudflareOffOpts.statePath ?? CLOUDFLARED_STATE_PATH;
152
+ const tsState = r.readTailscaleState(tsStatePath);
153
+ const cfState = r.readCloudflaredState(cfStatePath);
154
+
155
+ const tsLive = tailscalePublicIsLive(tsState);
156
+ const cfLive = cloudflareIsLive(cfState);
157
+
158
+ if (!tsLive && !cfLive) {
159
+ r.log("No public exposure active. Nothing to tear down.");
160
+ return 0;
161
+ }
162
+
163
+ if (tsLive && !cfLive) {
164
+ return await tearDownTailscale(r, tsState);
165
+ }
166
+
167
+ if (!tsLive && cfLive) {
168
+ return await tearDownCloudflare(r, cfState);
169
+ }
170
+
171
+ // Both live. Unusual (typical flow brings one up at a time) but possible
172
+ // when a prior bring-up raced or a teardown was skipped. Off means off, so
173
+ // the non-TTY default is to clear both rather than refuse.
174
+ const choice: BothChoice = r.isTty
175
+ ? await promptBothLive(r, tsState as ExposeState, cfState as CloudflaredState)
176
+ : "both";
177
+
178
+ if (!r.isTty) {
179
+ r.log("Two public exposures are live (Tailscale Funnel + Cloudflare Tunnel).");
180
+ r.log("(non-TTY: tearing down both.)");
181
+ }
182
+
183
+ if (choice === "cancel") {
184
+ r.log("Cancelled — no teardown.");
185
+ return 0;
186
+ }
187
+
188
+ if (choice === "tailscale") {
189
+ return await tearDownTailscale(r, tsState as ExposeState);
190
+ }
191
+ if (choice === "cloudflare") {
192
+ return await tearDownCloudflare(r, cfState as CloudflaredState);
193
+ }
194
+
195
+ // both
196
+ const tsCode = await tearDownTailscale(r, tsState as ExposeState);
197
+ const cfCode = await tearDownCloudflare(r, cfState as CloudflaredState);
198
+ return tsCode !== 0 ? tsCode : cfCode;
199
+ }