@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -28,7 +28,7 @@
28
28
  * tailscale/cloudflared.
29
29
  */
30
30
 
31
- import { DEFAULT_CLOUDFLARED_HOME } from "../cloudflare/detect.ts";
31
+ import { DEFAULT_CLOUDFLARED_HOME, cloudflaredInstallHint } from "../cloudflare/detect.ts";
32
32
  import {
33
33
  type DetectProvidersOpts,
34
34
  type ProviderAvailability,
@@ -109,9 +109,11 @@ function reportNeitherReady(r: Resolved, p: ProviderAvailability): number {
109
109
  r.log("");
110
110
  r.log(" Option B — Cloudflare Tunnel (your own domain, Cloudflare DNS):");
111
111
  if (!p.cloudflare.available) {
112
- r.log(
113
- " 1. Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
114
- );
112
+ // 2026-05-27 refresh: defer to the shared install-hint helper so the
113
+ // canonical install path stays in one place. Pre-refresh this surface
114
+ // hard-coded a developers.cloudflare.com URL that now serves HTML/404.
115
+ r.log(" 1. Install cloudflared:");
116
+ for (const line of cloudflaredInstallHint().split("\n")) r.log(` ${line}`);
115
117
  r.log(" 2. Log in: cloudflared tunnel login");
116
118
  r.log(" 3. Re-run with --domain: parachute expose public --cloudflare --domain <hostname>");
117
119
  } else {
@@ -24,6 +24,7 @@ import { type ServiceEntry, readManifest } from "../services-manifest.ts";
24
24
  import { type ServeEntry, bringupCommand, teardownCommand } from "../tailscale/commands.ts";
25
25
  import { getFqdn, isTailscaleInstalled } from "../tailscale/detect.ts";
26
26
  import { type Runner, defaultRunner } from "../tailscale/run.ts";
27
+ import { clearVaultHubOrigin } from "../vault-hub-origin-env.ts";
27
28
  import type { VaultAuthStatus } from "../vault/auth-status.ts";
28
29
  import {
29
30
  WELL_KNOWN_DIR,
@@ -438,6 +439,13 @@ export async function exposeOff(layer: ExposeLayer, opts: ExposeOpts = {}): Prom
438
439
  }
439
440
 
440
441
  clearExposeState(statePath);
442
+ // Drop the persisted PARACHUTE_HUB_ORIGIN from vault's `.env`. `expose up`
443
+ // (via the vault restart) persisted the public origin so the launchd /
444
+ // systemd daemon validates `iss` against it. With exposure gone, a
445
+ // local-only hub mints loopback-`iss` tokens, so a stale public origin left
446
+ // in `.env` would itself cause the mismatch on the next daemon restart.
447
+ // Reverting to vault's loopback default (`getHubOrigin`) keeps them aligned.
448
+ clearVaultHubOrigin(configDir, log);
441
449
  // Pair to the debug-only write at expose-up — clean up the inspection artifact
442
450
  // on teardown so it doesn't outlive the layer it described.
443
451
  if (existsSync(wellKnownFilePath)) {
@@ -0,0 +1,594 @@
1
+ /**
2
+ * `parachute init` — fresh-install front door, single entry point for both
3
+ * laptops and remote servers (EC2, DigitalOcean, Hetzner, any VPS).
4
+ *
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."
8
+ *
9
+ * The job: get the user from a fresh install to the admin SPA setup
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:
13
+ *
14
+ * 1. The hub binary is already on PATH (you can't `parachute init`
15
+ * without it). So "is hub installed" is always yes here.
16
+ * 2. Is the hub *running* on this box? If not, start it.
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:
26
+ * `xdg-open`). Skip in non-TTY shells.
27
+ * 6. If a vault is already configured, confirm "looks good" and
28
+ * point at the URL. The wizard surfaces install-state internally —
29
+ * no need to duplicate that logic here.
30
+ *
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.
33
+ */
34
+
35
+ import { spawnSync } from "node:child_process";
36
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
37
+ import { type ExposeState, readExposeState } from "../expose-state.ts";
38
+ import {
39
+ type EnsureHubOpts,
40
+ HUB_DEFAULT_PORT,
41
+ HUB_SVC,
42
+ ensureHubRunning,
43
+ readHubPort,
44
+ } from "../hub-control.ts";
45
+ import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
46
+ import { findService, readManifestLenient } from "../services-manifest.ts";
47
+ import { type InstallOpts, install as defaultInstall } from "./install.ts";
48
+
49
+ /** The three options the exposure prompt offers — also the `--expose` flag's domain. */
50
+ export type ExposeChoice = "none" | "tailnet" | "cloudflare";
51
+
52
+ /** Where to continue setup after init finishes. CLI walks prompts in the terminal; browser opens /admin/setup. */
53
+ export type WizardChoice = "browser" | "cli";
54
+
55
+ export interface InitOpts {
56
+ configDir?: string;
57
+ manifestPath?: string;
58
+ log?: (line: string) => void;
59
+ /** Test seam: `processState` liveness check. */
60
+ alive?: AliveFn;
61
+ /**
62
+ * Test seam: `ensureHubRunning` shim. Production uses the real one;
63
+ * tests pass a stub that records calls without spawning.
64
+ */
65
+ ensureHub?: (opts: EnsureHubOpts) => Promise<{ pid: number; port: number; started: boolean }>;
66
+ /** Test seam: expose-state reader. */
67
+ readExposeStateFn?: () => ExposeState | undefined;
68
+ /** Test seam: TTY check (production reads `process.stdin.isTTY`). */
69
+ isTty?: boolean;
70
+ /** Test seam: prompt for "open in browser?" and exposure choice. */
71
+ prompt?: (question: string) => Promise<string>;
72
+ /**
73
+ * Test seam: browser-open shim. Receives `url`; production shells out
74
+ * to `open` (darwin) / `xdg-open` (linux). Returns true on success.
75
+ */
76
+ openBrowser?: (url: string) => boolean;
77
+ /** Test seam: `process.platform`. */
78
+ platform?: NodeJS.Platform;
79
+ /** Test seam: process.env for SSH / DISPLAY detection. */
80
+ env?: NodeJS.ProcessEnv;
81
+ /**
82
+ * If true, don't even ask about opening the browser. Convenient flag for
83
+ * CI / scripts that just want the URL printed and exit 0.
84
+ */
85
+ noBrowser?: boolean;
86
+ /**
87
+ * Non-interactive exposure choice. Skips the prompt entirely:
88
+ * - "none" — no-op (laptop default)
89
+ * - "tailnet" — chain into `exposeTailnet("up", {})`
90
+ * - "cloudflare" — chain into the cloudflare interactive flow
91
+ * For CI / scripted deploys.
92
+ */
93
+ exposeChoice?: ExposeChoice;
94
+ /** Skip the exposure prompt; fall through to "here's localhost URL". */
95
+ noExposePrompt?: boolean;
96
+ /**
97
+ * Test seam: shim for the tailnet exposure chain. Production imports
98
+ * `exposeTailnet` lazily. Tests pass a stub to record the call without
99
+ * shelling out to `tailscale serve`.
100
+ */
101
+ exposeTailnetImpl?: () => Promise<number>;
102
+ /**
103
+ * Test seam: shim for the cloudflare exposure chain. Production imports
104
+ * `exposePublicInteractive` with `preselect: "cloudflare"`. Tests pass a
105
+ * stub to record the call without shelling out to `cloudflared`.
106
+ */
107
+ exposeCloudflareImpl?: () => Promise<number>;
108
+ /**
109
+ * Test seam: shim for the vault-module install step (hub#168 Cut 1).
110
+ * Production calls `install("vault", { noCreate: true, noStart: true, …})`
111
+ * to put `@openparachute/vault` on PATH without creating a first-vault
112
+ * instance — the wizard's vault step decides Create/Import/Skip. Tests
113
+ * pass a stub to record the call without shelling out.
114
+ */
115
+ installVaultModuleImpl?: (configDir: string, manifestPath: string) => Promise<number>;
116
+ /**
117
+ * Override the wizard-choice prompt (hub#168 Cut 4). When set, the
118
+ * "Continue setup in the browser or CLI?" question is answered without
119
+ * a prompt; otherwise default is `browser`. Non-interactive shells
120
+ * (`!isTty`) skip the prompt entirely and print the admin URL.
121
+ */
122
+ wizardChoice?: WizardChoice;
123
+ /**
124
+ * Test seam: shim for the CLI wizard chain (hub#168 Cut 3). Production
125
+ * lazy-imports `runCliWizard` from `./wizard.ts`. Tests pass a stub.
126
+ */
127
+ runCliWizardImpl?: (opts: { hubUrl: string; log: (l: string) => void }) => Promise<number>;
128
+ /**
129
+ * Skip the "browser or CLI?" wizard-choice prompt (hub#168 Cut 4). Used
130
+ * by pre-Cut-4 tests that don't expect the new prompt + by the
131
+ * `--no-browser` / explicit-`--cli-wizard` paths (where the answer is
132
+ * already known so there's no question to ask).
133
+ */
134
+ noWizardPrompt?: boolean;
135
+ }
136
+
137
+ /**
138
+ * Compute the canonical admin URL. Prefers the live expose state (so a
139
+ * user with an exposed hub sees the public URL, not the loopback) and
140
+ * falls back to localhost when nothing is exposed.
141
+ *
142
+ * Returns `undefined` only when the hub port can't be determined and no
143
+ * exposure is active — caller treats that as "hub didn't start" and
144
+ * surfaces an actionable error.
145
+ */
146
+ export function resolveAdminUrl(
147
+ exposeState: ExposeState | undefined,
148
+ hubPort: number | undefined,
149
+ ): string | undefined {
150
+ if (exposeState?.canonicalFqdn) {
151
+ return `https://${exposeState.canonicalFqdn}/admin/`;
152
+ }
153
+ if (hubPort !== undefined) {
154
+ return `http://127.0.0.1:${hubPort}/admin/`;
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ /**
160
+ * Heuristic: is this likely a server (vs. a laptop)?
161
+ *
162
+ * Servers default-highlight Cloudflare in the prompt; laptops default to
163
+ * "no expose". We don't auto-pick — always prompt — but pre-select the
164
+ * sensible default so an operator can confirm with Enter.
165
+ *
166
+ * Signals:
167
+ * - Linux platform AND ($SSH_CONNECTION set OR no $DISPLAY)
168
+ *
169
+ * macOS / Windows / Linux desktop → laptop.
170
+ */
171
+ export function looksLikeServer(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
172
+ if (platform !== "linux") return false;
173
+ // WSL2 is Linux + headless from $DISPLAY's perspective but is in fact a
174
+ // developer's laptop. Detect via WSL-specific env vars (set in every WSL
175
+ // distro) so we don't pre-select Cloudflare for someone running Parachute
176
+ // inside WSL on Windows. Reviewer-flagged on #445.
177
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return false;
178
+ if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true;
179
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY) return true;
180
+ return false;
181
+ }
182
+
183
+ /**
184
+ * Heuristic: would a browser-spawn fail because there's no display?
185
+ *
186
+ * A TTY guard alone is insufficient — an SSH session is a TTY with no display,
187
+ * so `xdg-open` fails (or blocks). We treat a box as display-less when:
188
+ * - it's a server per {@link looksLikeServer} (linux + SSH or no X/Wayland,
189
+ * excluding WSL which is a dev laptop), OR
190
+ * - it's linux with neither $DISPLAY nor $WAYLAND_DISPLAY (covers a local
191
+ * headless linux console that isn't over SSH).
192
+ *
193
+ * macOS / Windows always have a window server, so they're never display-less
194
+ * here (someone SSH'd into a Mac is a rare enough edge that we keep the happy
195
+ * path — `open` no-ops gracefully there anyway).
196
+ */
197
+ export function hasNoDisplay(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
198
+ if (platform !== "linux") return false;
199
+ if (looksLikeServer(platform, env)) return true;
200
+ return !env.DISPLAY && !env.WAYLAND_DISPLAY;
201
+ }
202
+
203
+ /**
204
+ * Default browser-opener. Tries `open` on macOS, `xdg-open` on Linux, and
205
+ * returns false when neither is available (Windows / WSL fallthrough +
206
+ * misc Unixes ship `xdg-open` so coverage is decent without bringing in
207
+ * a dependency).
208
+ */
209
+ function defaultOpenBrowser(url: string, platform: NodeJS.Platform): boolean {
210
+ const cmd = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : undefined;
211
+ if (!cmd) return false;
212
+ // spawnSync's `stdio: "ignore"` keeps the launcher quiet; we'll log the
213
+ // outcome ourselves.
214
+ const result = spawnSync(cmd, [url], { stdio: "ignore" });
215
+ return result.status === 0;
216
+ }
217
+
218
+ async function defaultPrompt(question: string): Promise<string> {
219
+ const { createInterface } = await import("node:readline/promises");
220
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
221
+ try {
222
+ return await rl.question(question);
223
+ } finally {
224
+ rl.close();
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Default chain into Tailscale exposure. Lazy-imports so tests don't pull
230
+ * tailscale wiring into the init module's surface.
231
+ */
232
+ async function defaultExposeTailnet(): Promise<number> {
233
+ const { exposeTailnet } = await import("./expose.ts");
234
+ return await exposeTailnet("up", {});
235
+ }
236
+
237
+ /**
238
+ * Default chain into Cloudflare exposure. Goes through the interactive
239
+ * flow with `preselect: "cloudflare"` so the operator gets walked
240
+ * through install / login / hostname-prompt as needed.
241
+ */
242
+ async function defaultExposeCloudflare(): Promise<number> {
243
+ const { exposePublicInteractive } = await import("./expose-interactive.ts");
244
+ return await exposePublicInteractive({ preselect: "cloudflare" });
245
+ }
246
+
247
+ /**
248
+ * Default impl for the vault-module install step (hub#168 Cut 1). Calls
249
+ * install("vault", { noCreate: true, noStart: true, …}) with a quiet log
250
+ * shim that re-emits each line under an `[install vault] ` prefix so the
251
+ * init log stays grep-able. Idempotent — `install` short-circuits the
252
+ * bun-add when vault is already linked / installed.
253
+ */
254
+ async function defaultInstallVaultModule(configDir: string, manifestPath: string): Promise<number> {
255
+ const installOpts: InstallOpts = {
256
+ configDir,
257
+ manifestPath,
258
+ noCreate: true,
259
+ noStart: true,
260
+ log: (line) => console.log(`[install vault] ${line}`),
261
+ };
262
+ return await defaultInstall("vault", installOpts);
263
+ }
264
+
265
+ /**
266
+ * Default impl for the CLI wizard chain (hub#168 Cut 3). Lazy-imports
267
+ * `runCliWizard` from `./wizard.ts`. Tests pass a stub via
268
+ * `runCliWizardImpl` rather than triggering the real HTTP-to-localhost
269
+ * flow.
270
+ */
271
+ async function defaultRunCliWizard(opts: {
272
+ hubUrl: string;
273
+ log: (l: string) => void;
274
+ }): Promise<number> {
275
+ const { runCliWizard } = await import("./wizard.ts");
276
+ return await runCliWizard(opts);
277
+ }
278
+
279
+ /**
280
+ * Prompt for the wizard-choice question (hub#168 Cut 4). Returns the
281
+ * picked option, or `undefined` if the operator quit. Default is
282
+ * `browser` because (a) the browser flow is the canonical post-launch
283
+ * experience, and (b) it works without re-asking the operator about
284
+ * their password aloud on the terminal.
285
+ */
286
+ async function promptWizardChoice(
287
+ prompt: (q: string) => Promise<string>,
288
+ log: (line: string) => void,
289
+ ): Promise<WizardChoice | undefined> {
290
+ log("Continue setup here in the CLI, or in your browser?");
291
+ log(" 1) Browser (opens /admin/setup) (default)");
292
+ log(" 2) CLI (walks you through it in this terminal)");
293
+ log("");
294
+ for (let attempt = 0; attempt < 5; attempt++) {
295
+ const raw = (await prompt("Pick [1]: ")).trim().toLowerCase();
296
+ if (raw === "") return "browser";
297
+ if (raw === "1" || raw === "browser" || raw === "b") return "browser";
298
+ if (raw === "2" || raw === "cli" || raw === "c") return "cli";
299
+ if (raw === "q" || raw === "quit" || raw === "exit") return undefined;
300
+ log(`Sorry — expected 1, 2, or q (got "${raw}"). Try again.`);
301
+ }
302
+ log("Too many invalid entries; defaulting to browser.");
303
+ return "browser";
304
+ }
305
+
306
+ /**
307
+ * Prompt for the exposure choice. Returns the picked option, or
308
+ * `undefined` if the operator quit / bailed.
309
+ *
310
+ * Default is whichever option matches the platform heuristic — laptops
311
+ * default to "none", servers to "cloudflare". Empty input picks the
312
+ * default (so Enter == confirm).
313
+ */
314
+ async function promptExposeChoice(
315
+ prompt: (q: string) => Promise<string>,
316
+ log: (line: string) => void,
317
+ defaultChoice: ExposeChoice,
318
+ ): Promise<ExposeChoice | undefined> {
319
+ log("Do you want to expose it publicly so you can reach it from other devices?");
320
+ const mark = (c: ExposeChoice) => (c === defaultChoice ? " (default)" : "");
321
+ log(` 1) No — keep it loopback-only${mark("none")}`);
322
+ log(` 2) Yes, private to your tailnet (Tailscale \`serve\`)${mark("tailnet")}`);
323
+ log(` 3) Yes via Cloudflare Tunnel (public HTTPS, your own domain)${mark("cloudflare")}`);
324
+ log("");
325
+
326
+ const defaultDigit = defaultChoice === "none" ? "1" : defaultChoice === "tailnet" ? "2" : "3";
327
+
328
+ // Bounded retries — a stuck prompt (non-TTY stdin that slipped through,
329
+ // piped /dev/null, etc.) shouldn't spin forever.
330
+ for (let attempt = 0; attempt < 5; attempt++) {
331
+ const raw = (await prompt(`Pick [${defaultDigit}]: `)).trim().toLowerCase();
332
+ if (raw === "") {
333
+ return defaultChoice;
334
+ }
335
+ if (raw === "1" || raw === "no" || raw === "none") return "none";
336
+ if (raw === "2" || raw === "tailnet" || raw === "tailscale") return "tailnet";
337
+ if (raw === "3" || raw === "cloudflare") return "cloudflare";
338
+ if (raw === "q" || raw === "quit" || raw === "exit") return undefined;
339
+ log(`Sorry — expected 1, 2, 3, or q (got "${raw}"). Try again.`);
340
+ }
341
+ log("Too many invalid entries; falling back to default.");
342
+ return defaultChoice;
343
+ }
344
+
345
+ export async function init(opts: InitOpts = {}): Promise<number> {
346
+ const configDir = opts.configDir ?? CONFIG_DIR;
347
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
348
+ const log = opts.log ?? ((line) => console.log(line));
349
+ const alive = opts.alive ?? defaultAlive;
350
+ const ensureHub = opts.ensureHub ?? ensureHubRunning;
351
+ const readExposeStateFn = opts.readExposeStateFn ?? (() => readExposeState());
352
+ const isTty = opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
353
+ const prompt = opts.prompt ?? defaultPrompt;
354
+ const platform = opts.platform ?? process.platform;
355
+ const env = opts.env ?? process.env;
356
+ const openBrowser = opts.openBrowser ?? ((url: string) => defaultOpenBrowser(url, platform));
357
+ const exposeTailnetImpl = opts.exposeTailnetImpl ?? defaultExposeTailnet;
358
+ const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
359
+ const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
360
+ const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
361
+
362
+ log("Parachute init — getting your hub set up.");
363
+ log("");
364
+
365
+ // Step 1: hub running?
366
+ const hubState = processState(HUB_SVC, configDir, alive);
367
+ let hubPort: number | undefined;
368
+ if (hubState.status === "running") {
369
+ hubPort = readHubPort(configDir);
370
+ log(`✓ Hub already running (pid ${hubState.pid}${hubPort ? `, port ${hubPort}` : ""}).`);
371
+ } else {
372
+ log("Hub not running — starting it now…");
373
+ try {
374
+ const result = await ensureHub({ configDir, log: () => {} });
375
+ hubPort = result.port;
376
+ log(`✓ Hub started (pid ${result.pid}, port ${result.port}).`);
377
+ } catch (err) {
378
+ log(`✗ Hub failed to start: ${err instanceof Error ? err.message : String(err)}`);
379
+ log("");
380
+ log("Try checking the logs:");
381
+ log(" parachute logs hub");
382
+ return 1;
383
+ }
384
+ }
385
+
386
+ // Fall back to the default canonical port if `readHubPort` returned
387
+ // undefined (which can happen if the hub was started by some prior tool
388
+ // that didn't write a port file). The hub binds 1939 unless explicitly
389
+ // overridden, so the fallback is almost always correct.
390
+ if (hubPort === undefined) hubPort = HUB_DEFAULT_PORT;
391
+
392
+ // Step 2: exposure chain. Skipped when already exposed, in non-TTY,
393
+ // or when --no-expose-prompt was passed. `--expose <choice>` jumps
394
+ // straight to the corresponding chain without asking.
395
+ let exposeState = readExposeStateFn();
396
+ const alreadyExposed = Boolean(exposeState?.canonicalFqdn);
397
+
398
+ if (alreadyExposed) {
399
+ // Already-exposed short-circuit: don't prompt. The admin URL printed
400
+ // later will be the FQDN.
401
+ log(`✓ Hub is already exposed at ${exposeState?.canonicalFqdn}.`);
402
+ } else if (opts.exposeChoice !== undefined) {
403
+ // Non-interactive override.
404
+ const code = await runExposureChoice(opts.exposeChoice, {
405
+ log,
406
+ exposeTailnetImpl,
407
+ exposeCloudflareImpl,
408
+ });
409
+ if (code !== 0) return code;
410
+ // Refresh state — the chain may have brought up an FQDN.
411
+ exposeState = readExposeStateFn();
412
+ } else if (opts.noExposePrompt) {
413
+ // Skip the question; fall through to localhost URL.
414
+ } else if (!isTty) {
415
+ // Non-TTY: don't prompt. Operator can re-run with --expose if needed.
416
+ } else {
417
+ log(`Hub is running locally at http://127.0.0.1:${hubPort}.`);
418
+ log("");
419
+ const isServer = looksLikeServer(platform, env);
420
+ const defaultChoice: ExposeChoice = isServer ? "cloudflare" : "none";
421
+ const picked = await promptExposeChoice(prompt, log, defaultChoice);
422
+ if (picked === undefined) {
423
+ log("");
424
+ log("Skipped exposure. Re-run `parachute expose public` later if you want to.");
425
+ } else if (picked !== "none") {
426
+ log("");
427
+ const code = await runExposureChoice(picked, {
428
+ log,
429
+ exposeTailnetImpl,
430
+ exposeCloudflareImpl,
431
+ });
432
+ if (code !== 0) return code;
433
+ exposeState = readExposeStateFn();
434
+ }
435
+ }
436
+
437
+ // Step 2.5: always install the vault module (hub#168 Cut 1). Aaron's
438
+ // 2026-05-28 directive: "it should always install the vault module"
439
+ // even though "creating a vault should be optional." We split the
440
+ // module install (always) from the first-vault create (deferred to
441
+ // the wizard) by passing `noCreate: true` to install — bun add -g
442
+ // runs, services.json gets seeded, but `parachute-vault init` (which
443
+ // would auto-create a `default` vault) is skipped. The wizard's
444
+ // vault step then either Creates / Imports / Skips.
445
+ //
446
+ // Idempotent: install short-circuits the bun-add when vault is
447
+ // already linked (`bun link`) or already globally installed. If the
448
+ // operator already has a vault row, this is a no-op past the
449
+ // already-installed log line. We don't block init on this step;
450
+ // a non-zero exit code is logged but treated as a warning, since the
451
+ // wizard can re-attempt the install itself from /admin/setup.
452
+ const findVaultEntry = (): boolean => {
453
+ try {
454
+ return findService("parachute-vault", manifestPath) !== undefined;
455
+ } catch {
456
+ return false;
457
+ }
458
+ };
459
+ const vaultAlreadyInstalled = findVaultEntry();
460
+ if (!vaultAlreadyInstalled) {
461
+ log("");
462
+ log("Installing the vault module so the wizard can offer create / import / skip…");
463
+ const installCode = await installVaultModuleImpl(configDir, manifestPath);
464
+ if (installCode !== 0) {
465
+ log(
466
+ `⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
467
+ );
468
+ }
469
+ }
470
+
471
+ // Step 3: vault configured? (After the module install above, this may
472
+ // have flipped from false to true on a fresh box. The wizard reads
473
+ // services.json on every request, so the "configured" answer here is
474
+ // best-effort — it only shapes the next-step log message below.)
475
+ let hasVault = false;
476
+ try {
477
+ const manifest = readManifestLenient(manifestPath);
478
+ hasVault = manifest.services.some((s) => s.name.startsWith("parachute-vault"));
479
+ } catch {
480
+ // Lenient reader doesn't throw on most shapes, but be defensive — a
481
+ // malformed services.json shouldn't crash init. The wizard handles it.
482
+ hasVault = false;
483
+ }
484
+
485
+ // Step 4: resolve the admin URL.
486
+ const adminUrl = resolveAdminUrl(exposeState, hubPort);
487
+ if (!adminUrl) {
488
+ log("");
489
+ log("✗ Couldn't resolve an admin URL (no hub port, no exposure state).");
490
+ log(" This shouldn't happen if hub started successfully — file an issue.");
491
+ return 1;
492
+ }
493
+
494
+ log("");
495
+ if (hasVault) {
496
+ log("Looks good — your hub is up and a vault is configured.");
497
+ } else {
498
+ log("Next: finish setup in the admin wizard (installs vault, configures admin user).");
499
+ }
500
+ log("");
501
+ log(` ${adminUrl}`);
502
+ log("");
503
+
504
+ // Step 4.5: offer the operator the CLI wizard vs. the browser wizard
505
+ // (hub#168 Cut 4). Aaron's 2026-05-28 directive: "we should be able to
506
+ // move through a setup wizard on the command line or (and this is the
507
+ // default experience) run it through on the web." Browser remains the
508
+ // default — the in-terminal CLI walk is opt-in (`2)` at the prompt,
509
+ // `--cli-wizard` flag non-interactively).
510
+ //
511
+ // The CLI wizard chain only fires when:
512
+ // - explicit `wizardChoice === "cli"` (flag-driven), or
513
+ // - interactive TTY + the operator picked CLI at the prompt.
514
+ //
515
+ // In every other case (non-TTY, --no-browser, explicit
516
+ // `wizardChoice === "browser"`, or interactive default), we fall
517
+ // through to the existing browser-open flow below.
518
+ let choice: WizardChoice | undefined = opts.wizardChoice;
519
+ // `noWizardPrompt` (or pre-existing `noExposePrompt` for back-compat
520
+ // with the smaller pre-hub#168 test surface) suppresses the
521
+ // browser-or-CLI question. Tests written before Cut 4 don't expect a
522
+ // new prompt; without this flag they would see the wizard-choice
523
+ // prompt fire and timeout on a `'n'` answer (which means "no" to the
524
+ // historical Y/n browser-open confirm, not "exit" to the new prompt).
525
+ if (choice === undefined && isTty && !opts.noBrowser && !opts.noWizardPrompt) {
526
+ log("");
527
+ choice = await promptWizardChoice(prompt, log);
528
+ }
529
+ if (choice === "cli") {
530
+ log("");
531
+ log("Launching the CLI wizard. (You can also visit the URL above in a browser any time.)");
532
+ return await runCliWizardImpl({ hubUrl: adminUrl.replace(/\/admin\/?$/, ""), log });
533
+ }
534
+
535
+ // Step 5: offer to open the browser. Skip in non-TTY shells (CI),
536
+ // honor `--no-browser`.
537
+ if (opts.noBrowser) return 0;
538
+ if (!isTty) {
539
+ log("(Open the URL above in a browser to continue.)");
540
+ return 0;
541
+ }
542
+ if (platform !== "darwin" && platform !== "linux") {
543
+ log("(Open the URL above in your browser to continue.)");
544
+ return 0;
545
+ }
546
+ // Headless guard: a TTY isn't enough — an SSH session is a TTY but has no
547
+ // display, so `xdg-open` either fails noisily or (worse) blocks. Skip the
548
+ // spawn entirely on a server-shaped box (linux + no $DISPLAY/$WAYLAND_DISPLAY,
549
+ // or SSH) and just print the link. Aaron hit this on EC2: init tried to open
550
+ // a browser, failed with "Couldn't launch a browser," and (pre-Fix-1) showed
551
+ // the loopback URL. With Fix 1 the printed link is now the public Cloudflare
552
+ // URL. Keep spawning on a real desktop (macOS, Linux-with-display).
553
+ if (hasNoDisplay(platform, env)) {
554
+ log("(No display detected — open the URL above in a browser to continue.)");
555
+ return 0;
556
+ }
557
+ // `choice === "browser"` (either flag-driven or the operator picked
558
+ // browser at the prompt) goes straight to openBrowser — skip the
559
+ // back-compat "Open in your browser now?" Y/n confirm. If choice is
560
+ // undefined (no prompt, no flag), keep the historical Y/n confirm
561
+ // for back-compat with existing tests + scripted callers.
562
+ if (choice !== "browser") {
563
+ const answer = (await prompt("Open in your browser now? [Y/n] ")).trim().toLowerCase();
564
+ if (answer === "n" || answer === "no") return 0;
565
+ }
566
+ const ok = openBrowser(adminUrl);
567
+ if (!ok) {
568
+ log("");
569
+ log("(Couldn't launch a browser — open the URL above manually.)");
570
+ }
571
+ return 0;
572
+ }
573
+
574
+ /**
575
+ * Dispatch the chosen exposure path. Returns the exit code of the
576
+ * downstream chain. `none` is a no-op (success).
577
+ */
578
+ async function runExposureChoice(
579
+ choice: ExposeChoice,
580
+ ctx: {
581
+ log: (line: string) => void;
582
+ exposeTailnetImpl: () => Promise<number>;
583
+ exposeCloudflareImpl: () => Promise<number>;
584
+ },
585
+ ): Promise<number> {
586
+ if (choice === "none") return 0;
587
+ if (choice === "tailnet") {
588
+ ctx.log("Setting up private tailnet access (Tailscale `serve`)…");
589
+ return await ctx.exposeTailnetImpl();
590
+ }
591
+ // cloudflare
592
+ ctx.log("Setting up Cloudflare Tunnel…");
593
+ return await ctx.exposeCloudflareImpl();
594
+ }