@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
@@ -0,0 +1,847 @@
1
+ /**
2
+ * `parachute setup-wizard` — the in-terminal mirror of `/admin/setup`
3
+ * (hub#168 Cut 3 of the wizard-parity work; Aaron's 2026-05-28 directive:
4
+ * "we should be able to move through a setup wizard on the command line").
5
+ *
6
+ * The CLI wizard is a thin terminal-prompt frontend over the SAME backend
7
+ * the browser wizard hits. There is exactly one source of truth for what
8
+ * "set up an admin account" / "create or import a vault" / "pick an
9
+ * expose mode" mean — `src/setup-wizard.ts`'s handlers — and both surfaces
10
+ * drive it.
11
+ *
12
+ * Why no parallel logic on the CLI side: the failure modes the wizard
13
+ * surfaces (username taken, weak password, vault-name validation, mirror
14
+ * import errors) are already exercised through the HTTP path with rich
15
+ * messages and state derivation. Re-implementing them on the CLI side
16
+ * would let the two surfaces drift; threading the CLI through the same
17
+ * endpoints means the next bug fix on the browser flow lands for both
18
+ * automatically.
19
+ *
20
+ * The CLI POSTs `application/json` bodies; the browser POSTs
21
+ * `application/x-www-form-urlencoded`. The wizard handlers accept both
22
+ * shapes after this PR (see setup-wizard.ts: when content-type is
23
+ * `application/json`, the body is parsed as JSON and projected into the
24
+ * same field-string shape `req.formData()` produces — keeps every
25
+ * branch downstream of body parsing identical).
26
+ *
27
+ * Cookie jar: setup-wizard's handlers mint a session cookie on the
28
+ * `/admin/setup/account` 303 redirect, plus a CSRF cookie on the GETs.
29
+ * The CLI uses a tiny in-memory jar to carry both forward across
30
+ * subsequent POSTs. The CSRF token is also embedded in the JSON body of
31
+ * each POST (matching the form-field name `_csrf`), since the
32
+ * verifyCsrfToken helper checks the body shape rather than a header.
33
+ *
34
+ * Run-from-flag escape: every prompt accepts a paired CLI flag so a
35
+ * fully non-interactive `parachute setup-wizard --account-username X
36
+ * --account-password Y --vault-name Z` works for CI / scripted setup.
37
+ * Mirrors the env-var seeding path that already exists for
38
+ * `PARACHUTE_INITIAL_ADMIN_*`.
39
+ */
40
+
41
+ import { createInterface } from "node:readline/promises";
42
+ import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
43
+ import { SESSION_COOKIE_NAME } from "../sessions.ts";
44
+
45
+ const POLL_INTERVAL_MS = 2000;
46
+ const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — generous enough for a slow `bun add` over a flaky connection.
47
+ const VALID_VAULT_MODES = ["create", "import", "skip"] as const;
48
+ export type VaultMode = (typeof VALID_VAULT_MODES)[number];
49
+
50
+ export interface RunCliWizardOpts {
51
+ /**
52
+ * Base URL of the hub — e.g. `http://127.0.0.1:1939`. No trailing slash.
53
+ * Init passes this in; callers can supply an exposed URL too.
54
+ */
55
+ hubUrl: string;
56
+ /** Log shim — production prints to stdout; tests capture into an array. */
57
+ log: (line: string) => void;
58
+ /**
59
+ * Test seam: replace the readline prompt. Production uses
60
+ * `node:readline/promises`. Tests inject a scripted queue.
61
+ */
62
+ prompt?: (question: string) => Promise<string>;
63
+ /**
64
+ * Test seam: replace `globalThis.fetch`. Production uses Bun's built-in
65
+ * fetch; tests inject a request-router that fake-responds to each
66
+ * `/admin/setup/*` path without standing up a real hub. Loose signature
67
+ * (rather than `typeof fetch`) so callers don't have to match Bun's
68
+ * extended fetch shape (the `preconnect` method etc.) — every internal
69
+ * caller uses fetch as a function-of-(url, init), nothing more.
70
+ */
71
+ fetchImpl?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
72
+ /**
73
+ * Test seam: replace `setTimeout` used by op-polling so tests don't
74
+ * actually wait 2 seconds per tick.
75
+ */
76
+ sleep?: (ms: number) => Promise<void>;
77
+ /**
78
+ * Non-interactive escape hatch: pre-supply the account-step answers.
79
+ * Username defaults to `owner` when unset (aligned with
80
+ * `parachute auth set-password` + the operator.token convention); password
81
+ * is required (no default, no prompt → exit-with-error). Mirrors the
82
+ * `PARACHUTE_INITIAL_ADMIN_*` env-seed shape.
83
+ */
84
+ accountUsername?: string;
85
+ accountPassword?: string;
86
+ /**
87
+ * Bootstrap token (when the hub is in serve-mode and minted one on
88
+ * boot). Optional — the on-box CLI surface typically runs the hub via
89
+ * `bun src/hub-server.ts` which doesn't mint a token, so the wizard's
90
+ * `requireBootstrapToken` flag stays false and we never prompt for it.
91
+ * When the hub IS in container/serve mode and the token field is
92
+ * required, pass `--bootstrap-token <value>` or set
93
+ * `PARACHUTE_BOOTSTRAP_TOKEN` in the environment.
94
+ */
95
+ bootstrapToken?: string;
96
+ /**
97
+ * Vault step pre-answers. `vaultMode` is one of `create | import | skip`;
98
+ * `vaultName` is required for `create` and `import`; the import-specific
99
+ * fields apply only when `vaultMode === "import"`.
100
+ */
101
+ vaultMode?: VaultMode;
102
+ vaultName?: string;
103
+ vaultImportRemoteUrl?: string;
104
+ vaultImportPat?: string;
105
+ vaultImportReplace?: boolean;
106
+ /**
107
+ * Pre-supply the expose-mode answer. One of `localhost | tailnet |
108
+ * public`. The on-box CLI surface defaults to `localhost` because
109
+ * that's what an operator running `parachute init` typically wants;
110
+ * the public/tailnet paths are the `parachute expose` chain's job, not
111
+ * the wizard's.
112
+ */
113
+ exposeMode?: "localhost" | "tailnet" | "public";
114
+ }
115
+
116
+ /** Cookie jar — tiny, in-memory, no persistence across runs. */
117
+ interface CookieJar {
118
+ session?: string;
119
+ csrf?: string;
120
+ }
121
+
122
+ interface FetchSetupResult {
123
+ status: number;
124
+ bodyText: string;
125
+ setCookies: string[];
126
+ location?: string;
127
+ // Parsed JSON body when the response advertised application/json. Otherwise null.
128
+ json?: unknown;
129
+ }
130
+
131
+ /**
132
+ * Default readline prompt. Lives at module scope so tests can inject a
133
+ * deterministic alternative through `opts.prompt`.
134
+ */
135
+ async function defaultPrompt(question: string): Promise<string> {
136
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
137
+ try {
138
+ return await rl.question(question);
139
+ } finally {
140
+ rl.close();
141
+ }
142
+ }
143
+
144
+ function defaultSleep(ms: number): Promise<void> {
145
+ return new Promise((resolve) => setTimeout(resolve, ms));
146
+ }
147
+
148
+ /**
149
+ * Extract a Set-Cookie's value by name. Mirrors the helper in
150
+ * setup-wizard.test.ts — same regex, same caveats (Bun joins multiple
151
+ * Set-Cookie values with `, ` between cookies, so we anchor on
152
+ * `(?:^|, )<name>=…` rather than splitting on commas naively, which would
153
+ * break `expires=Mon, 01 Jan …` values).
154
+ */
155
+ function extractCookie(setCookies: string[], name: string): string | undefined {
156
+ // Most hub Set-Cookie writes are one header per cookie. Walk each
157
+ // header value first; fall back to the joined-line shape on the off
158
+ // chance a Bun version returns the combined form.
159
+ for (const raw of setCookies) {
160
+ const re = new RegExp(`(?:^|;\\s*|,\\s*)${name}=([^;,]+)`);
161
+ const m = raw.match(re);
162
+ if (m?.[1]) return m[1];
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ /**
168
+ * One-stop HTTP helper. Builds a request against `hubUrl`, threads the
169
+ * cookie jar, optionally posts a JSON body, parses the response and
170
+ * extracts cookies. The `Set-Cookie` collection differs slightly across
171
+ * runtimes — Bun exposes each header as a separate entry via
172
+ * `headers.getAll`, which is what we use; the fallback to
173
+ * `headers.get("set-cookie")` covers test-injected `Response` instances.
174
+ */
175
+ async function setupFetch(
176
+ hubUrl: string,
177
+ path: string,
178
+ jar: CookieJar,
179
+ fetchImpl: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
180
+ init: { method?: string; jsonBody?: unknown } = {},
181
+ ): Promise<FetchSetupResult> {
182
+ const url = `${hubUrl.replace(/\/+$/, "")}${path}`;
183
+ const headers: Record<string, string> = {
184
+ accept: "application/json",
185
+ };
186
+ const cookies: string[] = [];
187
+ if (jar.session) cookies.push(`${SESSION_COOKIE_NAME}=${jar.session}`);
188
+ if (jar.csrf) cookies.push(`${CSRF_COOKIE_NAME}=${jar.csrf}`);
189
+ if (cookies.length > 0) headers.cookie = cookies.join("; ");
190
+ let body: string | undefined;
191
+ if (init.jsonBody !== undefined) {
192
+ headers["content-type"] = "application/json";
193
+ body = JSON.stringify(init.jsonBody);
194
+ }
195
+ const res = await fetchImpl(url, {
196
+ method: init.method ?? "GET",
197
+ headers,
198
+ body,
199
+ // Don't auto-follow — the wizard returns 303 to /admin/setup, and
200
+ // we want to inspect the Location header rather than chase it.
201
+ redirect: "manual",
202
+ });
203
+ const bodyText = await res.text();
204
+ // Collect Set-Cookie. Try `getAll` first (Bun + node18+ supported it
205
+ // patchily), then `getSetCookie` (web-standard since 2023), then fall
206
+ // back to splitting the joined header by cookie boundaries.
207
+ let setCookies: string[];
208
+ // Bun's headers expose `getSetCookie()` per WHATWG; defensively check.
209
+ const headersWithGetSetCookie = res.headers as Headers & { getSetCookie?: () => string[] };
210
+ if (typeof headersWithGetSetCookie.getSetCookie === "function") {
211
+ setCookies = headersWithGetSetCookie.getSetCookie();
212
+ } else {
213
+ const raw = res.headers.get("set-cookie") ?? "";
214
+ // Bun-pre-1.2 joins with ", " between cookies; split conservatively
215
+ // on cookie-name boundaries (e.g. `, sessionId=` / `, csrf=`). The
216
+ // names we care about are known up front.
217
+ setCookies = raw ? raw.split(/, (?=[A-Za-z_][A-Za-z0-9_-]*=)/) : [];
218
+ }
219
+ // Update the jar from this response so the next request rides the
220
+ // freshly-minted cookies.
221
+ const sessionId = extractCookie(setCookies, SESSION_COOKIE_NAME);
222
+ if (sessionId !== undefined) jar.session = sessionId;
223
+ const csrf = extractCookie(setCookies, CSRF_COOKIE_NAME);
224
+ if (csrf !== undefined) jar.csrf = csrf;
225
+ const result: FetchSetupResult = {
226
+ status: res.status,
227
+ bodyText,
228
+ setCookies,
229
+ };
230
+ const location = res.headers.get("location");
231
+ if (location !== null) result.location = location;
232
+ const contentType = res.headers.get("content-type") ?? "";
233
+ if (contentType.includes("application/json")) {
234
+ try {
235
+ result.json = JSON.parse(bodyText);
236
+ } catch {
237
+ // Malformed JSON — leave json undefined; caller surfaces bodyText
238
+ // instead. Shouldn't happen on production responses but defends
239
+ // against transient proxy errors.
240
+ }
241
+ }
242
+ return result;
243
+ }
244
+
245
+ /**
246
+ * Read the wizard's current state via GET /admin/setup with `accept:
247
+ * application/json`. The setup-wizard's GET handler returns a JSON
248
+ * envelope with `step`, `hasAdmin`, `hasVault`, `hasExposeMode`, the
249
+ * current CSRF token, and a `requireBootstrapToken` flag when the
250
+ * account step needs a token.
251
+ */
252
+ interface WizardStateSnapshot {
253
+ step: "welcome" | "account" | "vault" | "expose" | "done";
254
+ hasAdmin: boolean;
255
+ hasVault: boolean;
256
+ hasExposeMode: boolean;
257
+ requireBootstrapToken: boolean;
258
+ csrfToken: string;
259
+ /** Optional URL to redirect to (when state is fully done — 301 to /login). */
260
+ redirectTo?: string;
261
+ }
262
+
263
+ async function fetchWizardState(
264
+ hubUrl: string,
265
+ jar: CookieJar,
266
+ fetchImpl: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
267
+ ): Promise<WizardStateSnapshot> {
268
+ const res = await setupFetch(hubUrl, "/admin/setup", jar, fetchImpl);
269
+ // The HTTP layer responds with a 301 → /login when setup is already
270
+ // complete. Surface that as a done state.
271
+ if (res.status === 301 || res.status === 302) {
272
+ return {
273
+ step: "done",
274
+ hasAdmin: true,
275
+ hasVault: true,
276
+ hasExposeMode: true,
277
+ requireBootstrapToken: false,
278
+ csrfToken: jar.csrf ?? "",
279
+ ...(res.location !== undefined ? { redirectTo: res.location } : {}),
280
+ };
281
+ }
282
+ if (res.status !== 200) {
283
+ throw new Error(
284
+ `Unexpected status ${res.status} from GET /admin/setup. Body: ${res.bodyText.slice(0, 200)}`,
285
+ );
286
+ }
287
+ if (
288
+ typeof res.json !== "object" ||
289
+ res.json === null ||
290
+ typeof (res.json as { step?: unknown }).step !== "string"
291
+ ) {
292
+ throw new Error(
293
+ `Expected JSON envelope from GET /admin/setup (CLI wizard), got: ${res.bodyText.slice(0, 200)}`,
294
+ );
295
+ }
296
+ const body = res.json as Partial<WizardStateSnapshot> & { csrfToken?: string };
297
+ return {
298
+ step: body.step ?? "welcome",
299
+ hasAdmin: Boolean(body.hasAdmin),
300
+ hasVault: Boolean(body.hasVault),
301
+ hasExposeMode: Boolean(body.hasExposeMode),
302
+ requireBootstrapToken: Boolean(body.requireBootstrapToken),
303
+ csrfToken: typeof body.csrfToken === "string" ? body.csrfToken : (jar.csrf ?? ""),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Op-poll. The wizard's vault step returns `{ op_id }` on success and
309
+ * the install proceeds asynchronously. We tick once per
310
+ * `POLL_INTERVAL_MS` until the op reaches a terminal state or we hit
311
+ * `POLL_TIMEOUT_MS`.
312
+ *
313
+ * Each tick logs a single-line status: `[op-<short>] running (last:
314
+ * <last log line>)`. On success we log a `succeeded` line; on failure we
315
+ * surface the error message and return non-zero.
316
+ */
317
+ interface OperationSnapshot {
318
+ id: string;
319
+ status: "pending" | "running" | "succeeded" | "failed";
320
+ log: readonly string[];
321
+ error?: string;
322
+ }
323
+
324
+ async function pollOperation(
325
+ hubUrl: string,
326
+ opId: string,
327
+ shortLabel: string,
328
+ jar: CookieJar,
329
+ fetchImpl: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
330
+ sleep: (ms: number) => Promise<void>,
331
+ log: (l: string) => void,
332
+ ): Promise<OperationSnapshot> {
333
+ const start = Date.now();
334
+ let lastLogIndex = 0;
335
+ for (;;) {
336
+ const res = await setupFetch(
337
+ hubUrl,
338
+ `/api/modules/operations/${encodeURIComponent(opId)}`,
339
+ jar,
340
+ fetchImpl,
341
+ );
342
+ if (res.status !== 200) {
343
+ throw new Error(
344
+ `op-poll failed (${res.status}) for ${shortLabel} op ${opId}: ${res.bodyText.slice(0, 200)}`,
345
+ );
346
+ }
347
+ const body = res.json as Partial<OperationSnapshot> | undefined;
348
+ if (!body || typeof body !== "object" || typeof body.id !== "string") {
349
+ throw new Error(
350
+ `op-poll returned unexpected body for ${shortLabel} op ${opId}: ${res.bodyText.slice(0, 200)}`,
351
+ );
352
+ }
353
+ // Print any new log lines since the last tick so the operator sees
354
+ // progress in real time rather than a silent spinner.
355
+ const opLog = body.log ?? [];
356
+ for (let i = lastLogIndex; i < opLog.length; i++) {
357
+ log(` [${shortLabel}] ${opLog[i]}`);
358
+ }
359
+ lastLogIndex = opLog.length;
360
+ if (body.status === "succeeded" || body.status === "failed") {
361
+ return {
362
+ id: body.id,
363
+ status: body.status,
364
+ log: opLog,
365
+ ...(body.error !== undefined ? { error: body.error } : {}),
366
+ };
367
+ }
368
+ if (Date.now() - start > POLL_TIMEOUT_MS) {
369
+ throw new Error(
370
+ `op-poll for ${shortLabel} op ${opId} timed out after ${POLL_TIMEOUT_MS / 1000}s`,
371
+ );
372
+ }
373
+ await sleep(POLL_INTERVAL_MS);
374
+ }
375
+ }
376
+
377
+ /** Validate password length up front so we don't bounce off a 400 the operator can't see. */
378
+ function validatePassword(pw: string): string | undefined {
379
+ if (pw.length < 8) return "Password must be at least 8 characters.";
380
+ return undefined;
381
+ }
382
+
383
+ /**
384
+ * Account step. Walks the username + password prompts (unless pre-supplied
385
+ * via flags), POSTs `/admin/setup/account`, threads the resulting session
386
+ * cookie into the jar. Returns 0 on success, non-zero on validation
387
+ * failure / unrecoverable POST error.
388
+ */
389
+ async function walkAccountStep(
390
+ hubUrl: string,
391
+ jar: CookieJar,
392
+ state: WizardStateSnapshot,
393
+ opts: RunCliWizardOpts & {
394
+ prompt: (q: string) => Promise<string>;
395
+ fetchImpl: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
396
+ },
397
+ ): Promise<number> {
398
+ const log = opts.log;
399
+ log("");
400
+ log("Step 1/3 — Admin account");
401
+ log(" Set up the operator account that owns this hub.");
402
+ let username = opts.accountUsername;
403
+ if (username === undefined) {
404
+ // Default to "owner" — aligns with `parachute auth set-password` and the
405
+ // operator.token convention (the earliest-created user is the operator).
406
+ // The web wizard lets the operator name it freely; so does this prompt.
407
+ const raw = (await opts.prompt(" username [owner]: ")).trim();
408
+ username = raw === "" ? "owner" : raw;
409
+ }
410
+ let password = opts.accountPassword;
411
+ if (password === undefined) {
412
+ log(" password — min 12 chars; will be hashed with argon2.");
413
+ password = await opts.prompt(" password: ");
414
+ const confirm = await opts.prompt(" confirm: ");
415
+ if (password !== confirm) {
416
+ log(" ✗ passwords don't match.");
417
+ return 1;
418
+ }
419
+ }
420
+ const pwErr = validatePassword(password);
421
+ if (pwErr) {
422
+ log(` ✗ ${pwErr}`);
423
+ return 1;
424
+ }
425
+ let bootstrap = opts.bootstrapToken ?? process.env.PARACHUTE_BOOTSTRAP_TOKEN;
426
+ if (state.requireBootstrapToken && !bootstrap) {
427
+ log("");
428
+ log(" This hub is in container/serve mode and minted a one-time");
429
+ log(" bootstrap token at boot. Find the `parachute-bootstrap-…` line");
430
+ log(" in the hub's startup logs.");
431
+ bootstrap = (await opts.prompt(" bootstrap token: ")).trim();
432
+ }
433
+ const jsonBody: Record<string, string> = {
434
+ [CSRF_FIELD_NAME]: state.csrfToken,
435
+ username,
436
+ password,
437
+ password_confirm: password,
438
+ };
439
+ if (bootstrap) jsonBody.bootstrap_token = bootstrap;
440
+ const res = await setupFetch(hubUrl, "/admin/setup/account", jar, opts.fetchImpl, {
441
+ method: "POST",
442
+ jsonBody,
443
+ });
444
+ // The handler issues a 303 redirect on the browser path + a 200 JSON
445
+ // envelope on the CLI path (Content-Type: application/json branch);
446
+ // on 400/401 we surface a structured error. With the JSON request
447
+ // path we expect 200; the 303 branch is kept for robustness in case
448
+ // a future handler tweak short-circuits to the browser shape.
449
+ if (res.status === 200 || res.status === 303 || res.status === 302) {
450
+ log(" ✓ admin account created.");
451
+ return 0;
452
+ }
453
+ if (res.status === 401) {
454
+ log(" ✗ bootstrap token rejected. Double-check the value in the hub's startup logs.");
455
+ return 1;
456
+ }
457
+ if (res.status === 400 || res.status === 410) {
458
+ const message =
459
+ (res.json as { message?: string } | undefined)?.message ?? res.bodyText.slice(0, 200);
460
+ log(` ✗ account creation failed: ${message}`);
461
+ return 1;
462
+ }
463
+ log(` ✗ unexpected response ${res.status}: ${res.bodyText.slice(0, 200)}`);
464
+ return 1;
465
+ }
466
+
467
+ /**
468
+ * Vault step. Three modes:
469
+ * * create — name input → POST /admin/setup/vault with mode=create
470
+ * * import — name + remote_url + (optional) pat + mode (merge/replace)
471
+ * * skip — no instance created (just module installed earlier)
472
+ *
473
+ * On create / import the handler returns `{ op_id }` for the in-flight
474
+ * install / import. We poll it and surface per-tick log lines.
475
+ */
476
+ async function walkVaultStep(
477
+ hubUrl: string,
478
+ jar: CookieJar,
479
+ state: WizardStateSnapshot,
480
+ opts: RunCliWizardOpts & {
481
+ prompt: (q: string) => Promise<string>;
482
+ fetchImpl: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
483
+ sleep: (ms: number) => Promise<void>;
484
+ },
485
+ ): Promise<number> {
486
+ const log = opts.log;
487
+ log("");
488
+ log("Step 2/3 — Vault");
489
+ log(" A vault is the per-workspace SQLite + MCP store. You can also");
490
+ log(" import one from a git repo, or skip and create one later from");
491
+ log(" the admin UI.");
492
+ let mode: VaultMode | undefined = opts.vaultMode;
493
+ if (mode === undefined) {
494
+ log("");
495
+ log(" 1) Create a new vault (default)");
496
+ log(" 2) Import from a git repo");
497
+ log(" 3) Skip — don't create a vault now");
498
+ for (let attempt = 0; attempt < 5; attempt++) {
499
+ const raw = (await opts.prompt(" Pick [1]: ")).trim().toLowerCase();
500
+ if (raw === "" || raw === "1" || raw === "create" || raw === "c") {
501
+ mode = "create";
502
+ break;
503
+ }
504
+ if (raw === "2" || raw === "import" || raw === "i") {
505
+ mode = "import";
506
+ break;
507
+ }
508
+ if (raw === "3" || raw === "skip" || raw === "s") {
509
+ mode = "skip";
510
+ break;
511
+ }
512
+ log(` Sorry — expected 1, 2, or 3 (got "${raw}"). Try again.`);
513
+ }
514
+ if (mode === undefined) {
515
+ log(" ✗ Too many invalid entries; aborting vault step.");
516
+ return 1;
517
+ }
518
+ }
519
+ const jsonBody: Record<string, unknown> = {
520
+ [CSRF_FIELD_NAME]: state.csrfToken,
521
+ mode,
522
+ };
523
+ if (mode === "create" || mode === "import") {
524
+ let vaultName = opts.vaultName;
525
+ if (vaultName === undefined) {
526
+ const raw = (await opts.prompt(" vault name [default]: ")).trim();
527
+ vaultName = raw === "" ? "default" : raw;
528
+ }
529
+ jsonBody.vault_name = vaultName;
530
+ }
531
+ if (mode === "import") {
532
+ let remoteUrl = opts.vaultImportRemoteUrl;
533
+ if (remoteUrl === undefined) {
534
+ remoteUrl = (await opts.prompt(" remote URL (https://… or git@…): ")).trim();
535
+ }
536
+ if (!remoteUrl) {
537
+ log(" ✗ Remote URL required for import.");
538
+ return 1;
539
+ }
540
+ jsonBody.remote_url = remoteUrl;
541
+ let pat = opts.vaultImportPat;
542
+ if (pat === undefined) {
543
+ pat = (await opts.prompt(" PAT (or Enter to skip — public repos work without): ")).trim();
544
+ }
545
+ if (pat) jsonBody.pat = pat;
546
+ if (opts.vaultImportReplace !== undefined) {
547
+ jsonBody.import_mode = opts.vaultImportReplace ? "replace" : "merge";
548
+ } else {
549
+ const raw = (await opts.prompt(" Replace existing notes? [y/N]: ")).trim().toLowerCase();
550
+ jsonBody.import_mode = raw === "y" || raw === "yes" ? "replace" : "merge";
551
+ }
552
+ }
553
+ const res = await setupFetch(hubUrl, "/admin/setup/vault", jar, opts.fetchImpl, {
554
+ method: "POST",
555
+ jsonBody,
556
+ });
557
+ if (mode === "skip") {
558
+ if (res.status === 303 || res.status === 302 || res.status === 200) {
559
+ log(" ✓ Vault step skipped (you can create or import a vault later from /admin).");
560
+ return 0;
561
+ }
562
+ log(` ✗ Skip-step POST failed (${res.status}): ${res.bodyText.slice(0, 200)}`);
563
+ return 1;
564
+ }
565
+ if (res.status !== 303 && res.status !== 302 && res.status !== 200) {
566
+ log(` ✗ Vault POST failed (${res.status}): ${res.bodyText.slice(0, 200)}`);
567
+ return 1;
568
+ }
569
+ // Successful POSTs surface an op_id either in the redirect query
570
+ // (`Location: /admin/setup?op=<id>`) or in the JSON envelope. Prefer
571
+ // the JSON shape (it's unambiguous and survives proxies that munge
572
+ // location headers); fall back to the Location query.
573
+ let opId: string | undefined;
574
+ const bodyOpId =
575
+ (res.json as { op_id?: string; opId?: string } | undefined)?.op_id ??
576
+ (res.json as { op_id?: string; opId?: string } | undefined)?.opId;
577
+ if (typeof bodyOpId === "string" && bodyOpId.length > 0) {
578
+ opId = bodyOpId;
579
+ } else if (res.location) {
580
+ try {
581
+ const u = new URL(res.location, hubUrl);
582
+ const fromQuery = u.searchParams.get("op");
583
+ if (fromQuery) opId = fromQuery;
584
+ } catch {
585
+ // ignore malformed location
586
+ }
587
+ }
588
+ if (!opId) {
589
+ log(
590
+ " ✓ Vault step POSTed; no op_id surfaced (the wizard may have short-circuited an idempotent run).",
591
+ );
592
+ return 0;
593
+ }
594
+ log("");
595
+ log(` Provisioning vault (op ${opId}) — this usually takes 10–60 seconds…`);
596
+ const finalState = await pollOperation(
597
+ hubUrl,
598
+ opId,
599
+ "vault",
600
+ jar,
601
+ opts.fetchImpl,
602
+ opts.sleep,
603
+ log,
604
+ );
605
+ if (finalState.status === "succeeded") {
606
+ log(mode === "import" ? " ✓ Vault imported." : " ✓ Vault ready.");
607
+ return 0;
608
+ }
609
+ log(` ✗ Vault ${mode} failed: ${finalState.error ?? "(no detail)"}.`);
610
+ return 1;
611
+ }
612
+
613
+ /**
614
+ * Expose step. The browser wizard's expose step also auto-mints the
615
+ * single-use operator token surfaced on the done screen; the CLI mirror
616
+ * here just POSTs the mode and trusts the handler to do that work.
617
+ */
618
+ async function walkExposeStep(
619
+ hubUrl: string,
620
+ jar: CookieJar,
621
+ state: WizardStateSnapshot,
622
+ opts: RunCliWizardOpts & {
623
+ prompt: (q: string) => Promise<string>;
624
+ fetchImpl: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
625
+ },
626
+ ): Promise<number> {
627
+ const log = opts.log;
628
+ log("");
629
+ log("Step 3/3 — Expose mode");
630
+ log(" Where is this hub reachable from?");
631
+ let mode = opts.exposeMode;
632
+ if (mode === undefined) {
633
+ log(" 1) localhost — just this machine (default)");
634
+ log(" 2) tailnet — Tailscale network");
635
+ log(" 3) public — custom domain / reverse proxy");
636
+ for (let attempt = 0; attempt < 5; attempt++) {
637
+ const raw = (await opts.prompt(" Pick [1]: ")).trim().toLowerCase();
638
+ if (raw === "" || raw === "1" || raw === "localhost" || raw === "l") {
639
+ mode = "localhost";
640
+ break;
641
+ }
642
+ if (raw === "2" || raw === "tailnet" || raw === "t") {
643
+ mode = "tailnet";
644
+ break;
645
+ }
646
+ if (raw === "3" || raw === "public" || raw === "p") {
647
+ mode = "public";
648
+ break;
649
+ }
650
+ log(` Sorry — expected 1, 2, or 3 (got "${raw}").`);
651
+ }
652
+ if (mode === undefined) {
653
+ log(" ✗ Too many invalid entries; aborting expose step.");
654
+ return 1;
655
+ }
656
+ }
657
+ const res = await setupFetch(hubUrl, "/admin/setup/expose", jar, opts.fetchImpl, {
658
+ method: "POST",
659
+ jsonBody: {
660
+ [CSRF_FIELD_NAME]: state.csrfToken,
661
+ expose_mode: mode,
662
+ },
663
+ });
664
+ if (res.status === 303 || res.status === 302 || res.status === 200) {
665
+ log(` ✓ Expose mode set to ${mode}.`);
666
+ return 0;
667
+ }
668
+ log(` ✗ Expose POST failed (${res.status}): ${res.bodyText.slice(0, 200)}`);
669
+ return 1;
670
+ }
671
+
672
+ /**
673
+ * The CLI wizard entry point. Walks Account → Vault → Expose in order,
674
+ * skipping any step that's already complete (idempotent re-runs land on
675
+ * the next undone step, just like the browser wizard).
676
+ */
677
+ export async function runCliWizard(opts: RunCliWizardOpts): Promise<number> {
678
+ const prompt = opts.prompt ?? defaultPrompt;
679
+ const fetchImpl = opts.fetchImpl ?? fetch;
680
+ const sleep = opts.sleep ?? defaultSleep;
681
+ const log = opts.log;
682
+ const hubUrl = opts.hubUrl.replace(/\/+$/, "");
683
+ const ctx = { ...opts, prompt, fetchImpl, sleep };
684
+ const jar: CookieJar = {};
685
+
686
+ log("");
687
+ log("Parachute setup wizard (CLI).");
688
+ log(` Hub: ${hubUrl}`);
689
+ // Initial state probe to find where to resume. Walks every step in
690
+ // sequence; each step's pre-condition reads from the freshly-fetched
691
+ // state so an idempotent re-run picks up at the right place.
692
+ let state = await fetchWizardState(hubUrl, jar, fetchImpl);
693
+ if (state.step === "welcome" || state.step === "account") {
694
+ const code = await walkAccountStep(hubUrl, jar, state, ctx);
695
+ if (code !== 0) return code;
696
+ // Refresh state — the account POST set the session cookie + advanced
697
+ // the wizard. The next GET picks up the new step.
698
+ state = await fetchWizardState(hubUrl, jar, fetchImpl);
699
+ }
700
+ if (state.step === "vault") {
701
+ const code = await walkVaultStep(hubUrl, jar, state, ctx);
702
+ if (code !== 0) return code;
703
+ state = await fetchWizardState(hubUrl, jar, fetchImpl);
704
+ }
705
+ if (state.step === "expose") {
706
+ const code = await walkExposeStep(hubUrl, jar, state, ctx);
707
+ if (code !== 0) return code;
708
+ state = await fetchWizardState(hubUrl, jar, fetchImpl);
709
+ }
710
+ // Done screen — fetch + show a brief summary. The browser wizard's
711
+ // done screen surfaces the MCP install command + auto-minted token;
712
+ // we mirror that by reading the same `setup_minted_token` row via
713
+ // the GET endpoint's JSON envelope. Best-effort: a missing token
714
+ // just means we point the operator at /admin/tokens instead.
715
+ log("");
716
+ log("✓ Setup complete.");
717
+ log(` Visit ${hubUrl}/admin/ to open the admin SPA.`);
718
+ log(` Mint MCP / operator tokens at ${hubUrl}/admin/tokens.`);
719
+ return 0;
720
+ }
721
+
722
+ /**
723
+ * Argv parser for `parachute setup-wizard`. Accepts the same shape the
724
+ * browser wizard supports plus the run-from-flag escape hatch.
725
+ *
726
+ * Exported so tests (and the cli.ts dispatcher) can drive it directly.
727
+ * Returns either a parsed-options object or an error string.
728
+ */
729
+ export interface ParsedWizardArgs {
730
+ noBrowser: boolean;
731
+ hubUrl?: string;
732
+ opts: Omit<RunCliWizardOpts, "log">;
733
+ }
734
+
735
+ export function parseWizardArgs(args: readonly string[]): ParsedWizardArgs | { error: string } {
736
+ const out: ParsedWizardArgs = {
737
+ noBrowser: false,
738
+ opts: { hubUrl: "" },
739
+ };
740
+ for (let i = 0; i < args.length; i++) {
741
+ const a = args[i] ?? "";
742
+ if (a === "--no-browser") {
743
+ out.noBrowser = true;
744
+ continue;
745
+ }
746
+ const eq = a.indexOf("=");
747
+ let key: string;
748
+ let value: string | undefined;
749
+ if (eq > 0) {
750
+ key = a.slice(0, eq);
751
+ value = a.slice(eq + 1);
752
+ } else {
753
+ key = a;
754
+ value = args[i + 1];
755
+ }
756
+ const consumeValue = (): boolean => {
757
+ if (value === undefined || value === "") return false;
758
+ if (eq <= 0) i++;
759
+ return true;
760
+ };
761
+ switch (key) {
762
+ case "--hub-url":
763
+ case "--hub":
764
+ if (!consumeValue()) return { error: `${key} requires a URL` };
765
+ out.hubUrl = value;
766
+ out.opts.hubUrl = value as string;
767
+ break;
768
+ case "--account-username":
769
+ if (!consumeValue()) return { error: `${key} requires a value` };
770
+ out.opts.accountUsername = value;
771
+ break;
772
+ case "--account-password":
773
+ if (!consumeValue()) return { error: `${key} requires a value` };
774
+ out.opts.accountPassword = value;
775
+ break;
776
+ case "--bootstrap-token":
777
+ if (!consumeValue()) return { error: `${key} requires a value` };
778
+ out.opts.bootstrapToken = value;
779
+ break;
780
+ case "--vault-mode":
781
+ if (!consumeValue()) return { error: `${key} requires a value` };
782
+ if (!VALID_VAULT_MODES.includes(value as VaultMode)) {
783
+ return { error: `${key} must be one of ${VALID_VAULT_MODES.join(", ")}` };
784
+ }
785
+ out.opts.vaultMode = value as VaultMode;
786
+ break;
787
+ case "--vault-name":
788
+ if (!consumeValue()) return { error: `${key} requires a value` };
789
+ out.opts.vaultName = value;
790
+ break;
791
+ case "--vault-import-url":
792
+ if (!consumeValue()) return { error: `${key} requires a URL` };
793
+ out.opts.vaultImportRemoteUrl = value;
794
+ // Implied vault-mode unless the caller already chose another:
795
+ if (out.opts.vaultMode === undefined) out.opts.vaultMode = "import";
796
+ break;
797
+ case "--vault-import-pat":
798
+ if (!consumeValue()) return { error: `${key} requires a value` };
799
+ out.opts.vaultImportPat = value;
800
+ break;
801
+ case "--vault-import-replace":
802
+ out.opts.vaultImportReplace = true;
803
+ break;
804
+ case "--skip-vault":
805
+ out.opts.vaultMode = "skip";
806
+ break;
807
+ case "--expose-mode":
808
+ if (!consumeValue()) return { error: `${key} requires a value` };
809
+ if (value !== "localhost" && value !== "tailnet" && value !== "public") {
810
+ return { error: `${key} must be one of localhost, tailnet, public` };
811
+ }
812
+ out.opts.exposeMode = value;
813
+ break;
814
+ default:
815
+ if (a.startsWith("--")) return { error: `unknown argument "${a}"` };
816
+ return { error: `unexpected positional argument "${a}"` };
817
+ }
818
+ }
819
+ if (!out.opts.hubUrl) {
820
+ return { error: "--hub-url is required (e.g. http://127.0.0.1:1939)" };
821
+ }
822
+ return out;
823
+ }
824
+
825
+ /**
826
+ * Top-level entry point invoked by cli.ts for `parachute setup-wizard`.
827
+ * Parses argv, runs the wizard, returns the exit code.
828
+ */
829
+ export async function runSetupWizardCommand(args: readonly string[]): Promise<number> {
830
+ const parsed = parseWizardArgs(args);
831
+ if ("error" in parsed) {
832
+ console.error(`parachute setup-wizard: ${parsed.error}`);
833
+ console.error(
834
+ "usage: parachute setup-wizard --hub-url <url>\n" +
835
+ " [--account-username <name>] [--account-password <pw>]\n" +
836
+ " [--bootstrap-token <token>]\n" +
837
+ " [--vault-mode create|import|skip] [--vault-name <name>]\n" +
838
+ " [--vault-import-url <url>] [--vault-import-pat <pat>] [--vault-import-replace]\n" +
839
+ " [--expose-mode localhost|tailnet|public]",
840
+ );
841
+ return 1;
842
+ }
843
+ return await runCliWizard({
844
+ ...parsed.opts,
845
+ log: (line) => console.log(line),
846
+ });
847
+ }