@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.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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
|
+
}
|