@openparachute/hub 0.5.13 → 0.5.14-rc.10

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 (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -0,0 +1,563 @@
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
+ * Default browser-opener. Tries `open` on macOS, `xdg-open` on Linux, and
185
+ * returns false when neither is available (Windows / WSL fallthrough +
186
+ * misc Unixes ship `xdg-open` so coverage is decent without bringing in
187
+ * a dependency).
188
+ */
189
+ function defaultOpenBrowser(url: string, platform: NodeJS.Platform): boolean {
190
+ const cmd = platform === "darwin" ? "open" : platform === "linux" ? "xdg-open" : undefined;
191
+ if (!cmd) return false;
192
+ // spawnSync's `stdio: "ignore"` keeps the launcher quiet; we'll log the
193
+ // outcome ourselves.
194
+ const result = spawnSync(cmd, [url], { stdio: "ignore" });
195
+ return result.status === 0;
196
+ }
197
+
198
+ async function defaultPrompt(question: string): Promise<string> {
199
+ const { createInterface } = await import("node:readline/promises");
200
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
201
+ try {
202
+ return await rl.question(question);
203
+ } finally {
204
+ rl.close();
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Default chain into Tailscale exposure. Lazy-imports so tests don't pull
210
+ * tailscale wiring into the init module's surface.
211
+ */
212
+ async function defaultExposeTailnet(): Promise<number> {
213
+ const { exposeTailnet } = await import("./expose.ts");
214
+ return await exposeTailnet("up", {});
215
+ }
216
+
217
+ /**
218
+ * Default chain into Cloudflare exposure. Goes through the interactive
219
+ * flow with `preselect: "cloudflare"` so the operator gets walked
220
+ * through install / login / hostname-prompt as needed.
221
+ */
222
+ async function defaultExposeCloudflare(): Promise<number> {
223
+ const { exposePublicInteractive } = await import("./expose-interactive.ts");
224
+ return await exposePublicInteractive({ preselect: "cloudflare" });
225
+ }
226
+
227
+ /**
228
+ * Default impl for the vault-module install step (hub#168 Cut 1). Calls
229
+ * install("vault", { noCreate: true, noStart: true, …}) with a quiet log
230
+ * shim that re-emits each line under an `[install vault] ` prefix so the
231
+ * init log stays grep-able. Idempotent — `install` short-circuits the
232
+ * bun-add when vault is already linked / installed.
233
+ */
234
+ async function defaultInstallVaultModule(configDir: string, manifestPath: string): Promise<number> {
235
+ const installOpts: InstallOpts = {
236
+ configDir,
237
+ manifestPath,
238
+ noCreate: true,
239
+ noStart: true,
240
+ log: (line) => console.log(`[install vault] ${line}`),
241
+ };
242
+ return await defaultInstall("vault", installOpts);
243
+ }
244
+
245
+ /**
246
+ * Default impl for the CLI wizard chain (hub#168 Cut 3). Lazy-imports
247
+ * `runCliWizard` from `./wizard.ts`. Tests pass a stub via
248
+ * `runCliWizardImpl` rather than triggering the real HTTP-to-localhost
249
+ * flow.
250
+ */
251
+ async function defaultRunCliWizard(opts: {
252
+ hubUrl: string;
253
+ log: (l: string) => void;
254
+ }): Promise<number> {
255
+ const { runCliWizard } = await import("./wizard.ts");
256
+ return await runCliWizard(opts);
257
+ }
258
+
259
+ /**
260
+ * Prompt for the wizard-choice question (hub#168 Cut 4). Returns the
261
+ * picked option, or `undefined` if the operator quit. Default is
262
+ * `browser` because (a) the browser flow is the canonical post-launch
263
+ * experience, and (b) it works without re-asking the operator about
264
+ * their password aloud on the terminal.
265
+ */
266
+ async function promptWizardChoice(
267
+ prompt: (q: string) => Promise<string>,
268
+ log: (line: string) => void,
269
+ ): Promise<WizardChoice | undefined> {
270
+ log("Continue setup here in the CLI, or in your browser?");
271
+ log(" 1) Browser (opens /admin/setup) (default)");
272
+ log(" 2) CLI (walks you through it in this terminal)");
273
+ log("");
274
+ for (let attempt = 0; attempt < 5; attempt++) {
275
+ const raw = (await prompt("Pick [1]: ")).trim().toLowerCase();
276
+ if (raw === "") return "browser";
277
+ if (raw === "1" || raw === "browser" || raw === "b") return "browser";
278
+ if (raw === "2" || raw === "cli" || raw === "c") return "cli";
279
+ if (raw === "q" || raw === "quit" || raw === "exit") return undefined;
280
+ log(`Sorry — expected 1, 2, or q (got "${raw}"). Try again.`);
281
+ }
282
+ log("Too many invalid entries; defaulting to browser.");
283
+ return "browser";
284
+ }
285
+
286
+ /**
287
+ * Prompt for the exposure choice. Returns the picked option, or
288
+ * `undefined` if the operator quit / bailed.
289
+ *
290
+ * Default is whichever option matches the platform heuristic — laptops
291
+ * default to "none", servers to "cloudflare". Empty input picks the
292
+ * default (so Enter == confirm).
293
+ */
294
+ async function promptExposeChoice(
295
+ prompt: (q: string) => Promise<string>,
296
+ log: (line: string) => void,
297
+ defaultChoice: ExposeChoice,
298
+ ): Promise<ExposeChoice | undefined> {
299
+ log("Do you want to expose it publicly so you can reach it from other devices?");
300
+ const mark = (c: ExposeChoice) => (c === defaultChoice ? " (default)" : "");
301
+ log(` 1) No — keep it loopback-only${mark("none")}`);
302
+ log(` 2) Yes, private to your tailnet (Tailscale \`serve\`)${mark("tailnet")}`);
303
+ log(` 3) Yes via Cloudflare Tunnel (public HTTPS, your own domain)${mark("cloudflare")}`);
304
+ log("");
305
+
306
+ const defaultDigit = defaultChoice === "none" ? "1" : defaultChoice === "tailnet" ? "2" : "3";
307
+
308
+ // Bounded retries — a stuck prompt (non-TTY stdin that slipped through,
309
+ // piped /dev/null, etc.) shouldn't spin forever.
310
+ for (let attempt = 0; attempt < 5; attempt++) {
311
+ const raw = (await prompt(`Pick [${defaultDigit}]: `)).trim().toLowerCase();
312
+ if (raw === "") {
313
+ return defaultChoice;
314
+ }
315
+ if (raw === "1" || raw === "no" || raw === "none") return "none";
316
+ if (raw === "2" || raw === "tailnet" || raw === "tailscale") return "tailnet";
317
+ if (raw === "3" || raw === "cloudflare") return "cloudflare";
318
+ if (raw === "q" || raw === "quit" || raw === "exit") return undefined;
319
+ log(`Sorry — expected 1, 2, 3, or q (got "${raw}"). Try again.`);
320
+ }
321
+ log("Too many invalid entries; falling back to default.");
322
+ return defaultChoice;
323
+ }
324
+
325
+ export async function init(opts: InitOpts = {}): Promise<number> {
326
+ const configDir = opts.configDir ?? CONFIG_DIR;
327
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
328
+ const log = opts.log ?? ((line) => console.log(line));
329
+ const alive = opts.alive ?? defaultAlive;
330
+ const ensureHub = opts.ensureHub ?? ensureHubRunning;
331
+ const readExposeStateFn = opts.readExposeStateFn ?? (() => readExposeState());
332
+ const isTty = opts.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
333
+ const prompt = opts.prompt ?? defaultPrompt;
334
+ const platform = opts.platform ?? process.platform;
335
+ const env = opts.env ?? process.env;
336
+ const openBrowser = opts.openBrowser ?? ((url: string) => defaultOpenBrowser(url, platform));
337
+ const exposeTailnetImpl = opts.exposeTailnetImpl ?? defaultExposeTailnet;
338
+ const exposeCloudflareImpl = opts.exposeCloudflareImpl ?? defaultExposeCloudflare;
339
+ const installVaultModuleImpl = opts.installVaultModuleImpl ?? defaultInstallVaultModule;
340
+ const runCliWizardImpl = opts.runCliWizardImpl ?? defaultRunCliWizard;
341
+
342
+ log("Parachute init — getting your hub set up.");
343
+ log("");
344
+
345
+ // Step 1: hub running?
346
+ const hubState = processState(HUB_SVC, configDir, alive);
347
+ let hubPort: number | undefined;
348
+ if (hubState.status === "running") {
349
+ hubPort = readHubPort(configDir);
350
+ log(`✓ Hub already running (pid ${hubState.pid}${hubPort ? `, port ${hubPort}` : ""}).`);
351
+ } else {
352
+ log("Hub not running — starting it now…");
353
+ try {
354
+ const result = await ensureHub({ configDir, log: () => {} });
355
+ hubPort = result.port;
356
+ log(`✓ Hub started (pid ${result.pid}, port ${result.port}).`);
357
+ } catch (err) {
358
+ log(`✗ Hub failed to start: ${err instanceof Error ? err.message : String(err)}`);
359
+ log("");
360
+ log("Try checking the logs:");
361
+ log(" parachute logs hub");
362
+ return 1;
363
+ }
364
+ }
365
+
366
+ // Fall back to the default canonical port if `readHubPort` returned
367
+ // undefined (which can happen if the hub was started by some prior tool
368
+ // that didn't write a port file). The hub binds 1939 unless explicitly
369
+ // overridden, so the fallback is almost always correct.
370
+ if (hubPort === undefined) hubPort = HUB_DEFAULT_PORT;
371
+
372
+ // Step 2: exposure chain. Skipped when already exposed, in non-TTY,
373
+ // or when --no-expose-prompt was passed. `--expose <choice>` jumps
374
+ // straight to the corresponding chain without asking.
375
+ let exposeState = readExposeStateFn();
376
+ const alreadyExposed = Boolean(exposeState?.canonicalFqdn);
377
+
378
+ if (alreadyExposed) {
379
+ // Already-exposed short-circuit: don't prompt. The admin URL printed
380
+ // later will be the FQDN.
381
+ log(`✓ Hub is already exposed at ${exposeState?.canonicalFqdn}.`);
382
+ } else if (opts.exposeChoice !== undefined) {
383
+ // Non-interactive override.
384
+ const code = await runExposureChoice(opts.exposeChoice, {
385
+ log,
386
+ exposeTailnetImpl,
387
+ exposeCloudflareImpl,
388
+ });
389
+ if (code !== 0) return code;
390
+ // Refresh state — the chain may have brought up an FQDN.
391
+ exposeState = readExposeStateFn();
392
+ } else if (opts.noExposePrompt) {
393
+ // Skip the question; fall through to localhost URL.
394
+ } else if (!isTty) {
395
+ // Non-TTY: don't prompt. Operator can re-run with --expose if needed.
396
+ } else {
397
+ log(`Hub is running locally at http://127.0.0.1:${hubPort}.`);
398
+ log("");
399
+ const isServer = looksLikeServer(platform, env);
400
+ const defaultChoice: ExposeChoice = isServer ? "cloudflare" : "none";
401
+ const picked = await promptExposeChoice(prompt, log, defaultChoice);
402
+ if (picked === undefined) {
403
+ log("");
404
+ log("Skipped exposure. Re-run `parachute expose public` later if you want to.");
405
+ } else if (picked !== "none") {
406
+ log("");
407
+ const code = await runExposureChoice(picked, {
408
+ log,
409
+ exposeTailnetImpl,
410
+ exposeCloudflareImpl,
411
+ });
412
+ if (code !== 0) return code;
413
+ exposeState = readExposeStateFn();
414
+ }
415
+ }
416
+
417
+ // Step 2.5: always install the vault module (hub#168 Cut 1). Aaron's
418
+ // 2026-05-28 directive: "it should always install the vault module"
419
+ // even though "creating a vault should be optional." We split the
420
+ // module install (always) from the first-vault create (deferred to
421
+ // the wizard) by passing `noCreate: true` to install — bun add -g
422
+ // runs, services.json gets seeded, but `parachute-vault init` (which
423
+ // would auto-create a `default` vault) is skipped. The wizard's
424
+ // vault step then either Creates / Imports / Skips.
425
+ //
426
+ // Idempotent: install short-circuits the bun-add when vault is
427
+ // already linked (`bun link`) or already globally installed. If the
428
+ // operator already has a vault row, this is a no-op past the
429
+ // already-installed log line. We don't block init on this step;
430
+ // a non-zero exit code is logged but treated as a warning, since the
431
+ // wizard can re-attempt the install itself from /admin/setup.
432
+ const findVaultEntry = (): boolean => {
433
+ try {
434
+ return findService("parachute-vault", manifestPath) !== undefined;
435
+ } catch {
436
+ return false;
437
+ }
438
+ };
439
+ const vaultAlreadyInstalled = findVaultEntry();
440
+ if (!vaultAlreadyInstalled) {
441
+ log("");
442
+ log("Installing the vault module so the wizard can offer create / import / skip…");
443
+ const installCode = await installVaultModuleImpl(configDir, manifestPath);
444
+ if (installCode !== 0) {
445
+ log(
446
+ `⚠ vault module install returned ${installCode}; the wizard can retry from /admin/setup.`,
447
+ );
448
+ }
449
+ }
450
+
451
+ // Step 3: vault configured? (After the module install above, this may
452
+ // have flipped from false to true on a fresh box. The wizard reads
453
+ // services.json on every request, so the "configured" answer here is
454
+ // best-effort — it only shapes the next-step log message below.)
455
+ let hasVault = false;
456
+ try {
457
+ const manifest = readManifestLenient(manifestPath);
458
+ hasVault = manifest.services.some((s) => s.name.startsWith("parachute-vault"));
459
+ } catch {
460
+ // Lenient reader doesn't throw on most shapes, but be defensive — a
461
+ // malformed services.json shouldn't crash init. The wizard handles it.
462
+ hasVault = false;
463
+ }
464
+
465
+ // Step 4: resolve the admin URL.
466
+ const adminUrl = resolveAdminUrl(exposeState, hubPort);
467
+ if (!adminUrl) {
468
+ log("");
469
+ log("✗ Couldn't resolve an admin URL (no hub port, no exposure state).");
470
+ log(" This shouldn't happen if hub started successfully — file an issue.");
471
+ return 1;
472
+ }
473
+
474
+ log("");
475
+ if (hasVault) {
476
+ log("Looks good — your hub is up and a vault is configured.");
477
+ } else {
478
+ log("Next: finish setup in the admin wizard (installs vault, configures admin user).");
479
+ }
480
+ log("");
481
+ log(` ${adminUrl}`);
482
+ log("");
483
+
484
+ // Step 4.5: offer the operator the CLI wizard vs. the browser wizard
485
+ // (hub#168 Cut 4). Aaron's 2026-05-28 directive: "we should be able to
486
+ // move through a setup wizard on the command line or (and this is the
487
+ // default experience) run it through on the web." Browser remains the
488
+ // default — the in-terminal CLI walk is opt-in (`2)` at the prompt,
489
+ // `--cli-wizard` flag non-interactively).
490
+ //
491
+ // The CLI wizard chain only fires when:
492
+ // - explicit `wizardChoice === "cli"` (flag-driven), or
493
+ // - interactive TTY + the operator picked CLI at the prompt.
494
+ //
495
+ // In every other case (non-TTY, --no-browser, explicit
496
+ // `wizardChoice === "browser"`, or interactive default), we fall
497
+ // through to the existing browser-open flow below.
498
+ let choice: WizardChoice | undefined = opts.wizardChoice;
499
+ // `noWizardPrompt` (or pre-existing `noExposePrompt` for back-compat
500
+ // with the smaller pre-hub#168 test surface) suppresses the
501
+ // browser-or-CLI question. Tests written before Cut 4 don't expect a
502
+ // new prompt; without this flag they would see the wizard-choice
503
+ // prompt fire and timeout on a `'n'` answer (which means "no" to the
504
+ // historical Y/n browser-open confirm, not "exit" to the new prompt).
505
+ if (choice === undefined && isTty && !opts.noBrowser && !opts.noWizardPrompt) {
506
+ log("");
507
+ choice = await promptWizardChoice(prompt, log);
508
+ }
509
+ if (choice === "cli") {
510
+ log("");
511
+ log("Launching the CLI wizard. (You can also visit the URL above in a browser any time.)");
512
+ return await runCliWizardImpl({ hubUrl: adminUrl.replace(/\/admin\/?$/, ""), log });
513
+ }
514
+
515
+ // Step 5: offer to open the browser. Skip in non-TTY shells (CI),
516
+ // honor `--no-browser`.
517
+ if (opts.noBrowser) return 0;
518
+ if (!isTty) {
519
+ log("(Open the URL above in a browser to continue.)");
520
+ return 0;
521
+ }
522
+ if (platform !== "darwin" && platform !== "linux") {
523
+ log("(Open the URL above in your browser to continue.)");
524
+ return 0;
525
+ }
526
+ // `choice === "browser"` (either flag-driven or the operator picked
527
+ // browser at the prompt) goes straight to openBrowser — skip the
528
+ // back-compat "Open in your browser now?" Y/n confirm. If choice is
529
+ // undefined (no prompt, no flag), keep the historical Y/n confirm
530
+ // for back-compat with existing tests + scripted callers.
531
+ if (choice !== "browser") {
532
+ const answer = (await prompt("Open in your browser now? [Y/n] ")).trim().toLowerCase();
533
+ if (answer === "n" || answer === "no") return 0;
534
+ }
535
+ const ok = openBrowser(adminUrl);
536
+ if (!ok) {
537
+ log("");
538
+ log("(Couldn't launch a browser — open the URL above manually.)");
539
+ }
540
+ return 0;
541
+ }
542
+
543
+ /**
544
+ * Dispatch the chosen exposure path. Returns the exit code of the
545
+ * downstream chain. `none` is a no-op (success).
546
+ */
547
+ async function runExposureChoice(
548
+ choice: ExposeChoice,
549
+ ctx: {
550
+ log: (line: string) => void;
551
+ exposeTailnetImpl: () => Promise<number>;
552
+ exposeCloudflareImpl: () => Promise<number>;
553
+ },
554
+ ): Promise<number> {
555
+ if (choice === "none") return 0;
556
+ if (choice === "tailnet") {
557
+ ctx.log("Setting up private tailnet access (Tailscale `serve`)…");
558
+ return await ctx.exposeTailnetImpl();
559
+ }
560
+ // cloudflare
561
+ ctx.log("Setting up Cloudflare Tunnel…");
562
+ return await ctx.exposeCloudflareImpl();
563
+ }
@@ -1,8 +1,8 @@
1
- import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { createConnection } from "node:net";
3
- import { homedir } from "node:os";
4
3
  import { dirname, join } from "node:path";
5
4
  import { autoWireScribeAuth } from "../auto-wire.ts";
5
+ import { bunGlobalPrefixes, isLinked as defaultIsLinkedShared } from "../bun-link.ts";
6
6
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
7
7
  import {
8
8
  type ModuleManifest,
@@ -190,6 +190,31 @@ export interface InstallOpts {
190
190
  * (#45) to pre-collect the answer up front. Ignored for non-vault installs.
191
191
  */
192
192
  vaultName?: string;
193
+ /**
194
+ * "Install the module, but don't create a first vault instance" (hub#168 — the
195
+ * wizard-parity work for Aaron's 2026-05-28 directive: "always install the
196
+ * vault module, but creating a vault should be optional").
197
+ *
198
+ * Default: false (today's behavior — install runs the service's `init` and
199
+ * starts the daemon, which for vault auto-creates a `default` row).
200
+ *
201
+ * When true:
202
+ * - The `bun add -g <pkg>` step still runs (puts the binary on PATH).
203
+ * - `spec.init` is SKIPPED. For vault this means no `parachute-vault init`
204
+ * → no default-vault row is created from this code path.
205
+ * - `lifecycle.start` is SKIPPED. The supervisor/wizard owns spawning;
206
+ * starting vault here would trigger its server-side auto-init (which
207
+ * creates a `default` vault on first boot when `listVaults().length === 0`).
208
+ * - services.json is still seeded (`spec.seedEntry`) + installDir stamped
209
+ * so subsequent supervisor spawns find the module + module.json.
210
+ *
211
+ * Intended for `parachute init` — install the module so the wizard can offer
212
+ * Create/Import/Skip without a follow-up bun-add round-trip, but defer
213
+ * vault-instance creation to whichever path the wizard's vault step takes.
214
+ * On the existing CLI surfaces (`parachute install vault`, `parachute setup`),
215
+ * leave it false so today's behavior is unchanged.
216
+ */
217
+ noCreate?: boolean;
193
218
  /**
194
219
  * `parachute install scribe` only: pre-pick the transcription provider so
195
220
  * the prompt doesn't fire. Validated against scribe's known providers — an
@@ -260,25 +285,12 @@ async function defaultRunner(cmd: readonly string[]): Promise<number> {
260
285
  return await proc.exited;
261
286
  }
262
287
 
263
- function bunGlobalPrefixes(): string[] {
264
- const prefixes: string[] = [];
265
- const fromEnv = process.env.BUN_INSTALL;
266
- if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
267
- prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
268
- return prefixes;
269
- }
270
-
271
- function defaultIsLinked(pkg: string): boolean {
272
- for (const prefix of bunGlobalPrefixes()) {
273
- const path = join(prefix, ...pkg.split("/"));
274
- try {
275
- if (lstatSync(path).isSymbolicLink()) return true;
276
- } catch {
277
- // Not present at this prefix; try the next.
278
- }
279
- }
280
- return false;
281
- }
288
+ // `bunGlobalPrefixes` + `defaultIsLinked` were extracted to `src/bun-link.ts`
289
+ // so the wizard's parallel install path (`api-modules-ops.ts:runInstall`) can
290
+ // reuse the same detection — the two paths diverging is the bug class hub#433
291
+ // fixed (smoke 2026-05-27, finding 1). `defaultIsLinkedShared` is imported at
292
+ // module scope; alias kept for the in-function local-shadow convention below.
293
+ const defaultIsLinked = defaultIsLinkedShared;
282
294
 
283
295
  function defaultLinkedPath(pkg: string): string | null {
284
296
  // bun has two install shapes for "linked-style" globals:
@@ -721,7 +733,7 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
721
733
  ? spec.manifestName
722
734
  : manifest.name;
723
735
 
724
- if (spec.init) {
736
+ if (spec.init && !opts.noCreate) {
725
737
  // Forward --vault-name from the InstallOpts when set so `parachute setup`
726
738
  // (and any future programmatic caller) can pre-answer the name prompt.
727
739
  const initCmd =
@@ -734,6 +746,8 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
734
746
  log(`${initCmd.join(" ")} exited ${initCode}`);
735
747
  return initCode;
736
748
  }
749
+ } else if (spec.init && opts.noCreate) {
750
+ log(`(skipping ${spec.init.join(" ")} — --no-create: module installed, no instance created)`);
737
751
  }
738
752
 
739
753
  // Hub-as-port-authority (#53): pick the service's port now and reflect it
@@ -862,7 +876,11 @@ export async function install(input: string, opts: InstallOpts = {}): Promise<nu
862
876
  // wondering why nothing happened. Always end with the daemon running unless
863
877
  // the caller opted out (CI / piped scripts). Idempotent: if the service is
864
878
  // already up, lifecycle.start no-ops via the existing PID-file check.
865
- if (!opts.noStart) {
879
+ //
880
+ // `noCreate` (hub#168) also suppresses auto-start: starting vault would
881
+ // trigger its server-side first-boot auto-init (creating a default vault),
882
+ // which is exactly what --no-create is supposed to defer.
883
+ if (!opts.noStart && !opts.noCreate) {
866
884
  const startService =
867
885
  opts.startService ??
868
886
  ((short: string) => lifecycleStart(short, { manifestPath, configDir, log }));