@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
@@ -38,21 +38,24 @@
38
38
  */
39
39
 
40
40
  import type { Database } from "bun:sqlite";
41
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
42
+ import { join } from "node:path";
41
43
  import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
42
44
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
43
- import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
44
45
  import {
45
46
  BOOTSTRAP_TOKEN_PREFIX,
46
47
  consumeBootstrapToken,
47
48
  getBootstrapToken,
48
49
  verifyBootstrapToken,
49
50
  } from "./bootstrap-token.ts";
51
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
50
52
  import {
51
53
  CSRF_FIELD_NAME,
52
54
  ensureCsrfToken,
53
55
  renderCsrfHiddenInput,
54
56
  verifyCsrfToken,
55
57
  } from "./csrf.ts";
58
+ import { type ExposeState, readExposeState } from "./expose-state.ts";
56
59
  import {
57
60
  SETUP_EXPOSE_MODES,
58
61
  type SetupExposeMode,
@@ -62,6 +65,7 @@ import {
62
65
  openFirstClientAutoApproveWindow,
63
66
  setSetting,
64
67
  } from "./hub-settings.ts";
68
+ import { signAccessToken } from "./jwt-sign.ts";
65
69
  import { escapeHtml } from "./oauth-ui.ts";
66
70
  import { mintOperatorToken } from "./operator-token.ts";
67
71
  import { isHttpsRequest } from "./request-protocol.ts";
@@ -104,6 +108,71 @@ function escapeAttr(s: string): string {
104
108
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
105
109
  }
106
110
 
111
+ /**
112
+ * The CLI wizard (hub#168 Cut 3) sends `Accept: application/json` on
113
+ * every GET; the browser sends `Accept: text/html, …`. We branch the GET
114
+ * handler's response shape on this header. Same DB / state-derivation
115
+ * path both ways — only the rendering forks.
116
+ *
117
+ * POSTs from the CLI wizard send `Content-Type: application/json`;
118
+ * browser POSTs send `application/x-www-form-urlencoded`. The POST
119
+ * handlers parse-into-the-same-shape with `readBodyFields` below.
120
+ */
121
+ function wantsJsonResponse(req: Request): boolean {
122
+ const accept = req.headers.get("accept") ?? "";
123
+ return accept.includes("application/json");
124
+ }
125
+
126
+ /**
127
+ * Best-effort body parser shared by every wizard POST handler. Branches
128
+ * on Content-Type:
129
+ * * `application/json` → parses the body as JSON, projects each
130
+ * top-level field into a `Map<string, string>` (matches the
131
+ * FormData getter shape the rest of the handlers use).
132
+ * * Anything else → standard `req.formData()` (the historical browser
133
+ * path).
134
+ *
135
+ * Returns a tuple of `[fields, isJson]`. `isJson` lets the handler
136
+ * decide between a 303 redirect (browser) and a 200 JSON envelope
137
+ * (CLI). The fields-getter API is intentionally lossy on JSON arrays /
138
+ * nested objects — every wizard field today is a plain string, so the
139
+ * Map<string, string> shape is sufficient. If we ever need arrays here,
140
+ * extend with a `getAll(name)` shim.
141
+ */
142
+ async function readBodyFields(req: Request): Promise<{
143
+ get: (name: string) => string | null;
144
+ isJson: boolean;
145
+ }> {
146
+ const contentType = req.headers.get("content-type") ?? "";
147
+ if (contentType.includes("application/json")) {
148
+ let parsed: Record<string, unknown> = {};
149
+ try {
150
+ parsed = (await req.json()) as Record<string, unknown>;
151
+ } catch {
152
+ // Malformed JSON falls through to an empty map — the handlers'
153
+ // existing field-validation surfaces the right error message.
154
+ parsed = {};
155
+ }
156
+ return {
157
+ isJson: true,
158
+ get: (name: string) => {
159
+ const v = parsed[name];
160
+ if (typeof v === "string") return v;
161
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
162
+ return null;
163
+ },
164
+ };
165
+ }
166
+ const form = await req.formData();
167
+ return {
168
+ isJson: false,
169
+ get: (name: string) => {
170
+ const v = form.get(name);
171
+ return typeof v === "string" ? v : null;
172
+ },
173
+ };
174
+ }
175
+
107
176
  // --- state derivation ----------------------------------------------------
108
177
 
109
178
  /**
@@ -139,13 +208,41 @@ export interface DerivedWizardState {
139
208
  */
140
209
  export const FIRST_VAULT_SHORT: CuratedModuleShort = "vault";
141
210
 
211
+ /**
212
+ * Map a live exposure layer (`expose-state.json`) onto the wizard's
213
+ * `setup_expose_mode`. The two enums overlap exactly on the layers the
214
+ * exposure file can carry: `ExposeLayer` is `tailnet | public`, both of
215
+ * which are valid `SetupExposeMode` values. (There's no `localhost`
216
+ * exposure layer — running nothing is the absence of a state file, which
217
+ * `readExposeState` reports as `undefined`, so a missing/unexposed hub
218
+ * never seeds and the wizard still asks.)
219
+ *
220
+ * Returns `undefined` when no exposure is live (or the reader throws on
221
+ * a malformed file — we swallow that and fall through to "still ask"
222
+ * rather than crashing the wizard GET).
223
+ */
224
+ function exposeModeFromLiveState(read: () => ExposeState | undefined): SetupExposeMode | undefined {
225
+ let state: ExposeState | undefined;
226
+ try {
227
+ state = read();
228
+ } catch {
229
+ // A corrupt expose-state.json shouldn't brick the wizard. Treat it
230
+ // as "no live exposure" and let the operator answer the step.
231
+ return undefined;
232
+ }
233
+ if (!state) return undefined;
234
+ // `ExposeLayer` ⊆ `SetupExposeMode` ("tailnet" | "public").
235
+ return state.layer;
236
+ }
237
+
142
238
  /**
143
239
  * Read DB + services.json to decide which step the wizard should render.
144
240
  * Idempotent — re-running after partial setup picks up where it left
145
241
  * off. Mostly read-only, with one specific write: on Render (or any
146
- * platform `detectAutoExposeMode` recognizes), the first call auto-
147
- * seeds `setup_expose_mode = "public"` so the wizard skips the expose
148
- * step. Subsequent calls find the setting present and are read-only.
242
+ * platform `detectAutoExposeMode` recognizes), OR when a live tailscale
243
+ * exposure (`expose-state.json`) is already up, the first call auto-
244
+ * seeds `setup_expose_mode` so the wizard skips the expose step.
245
+ * Subsequent calls find the setting present and are read-only.
149
246
  */
150
247
  export function deriveWizardState(deps: {
151
248
  db: Database;
@@ -156,13 +253,28 @@ export function deriveWizardState(deps: {
156
253
  * SetupWizardDeps.env.
157
254
  */
158
255
  env?: Record<string, string | undefined>;
256
+ /**
257
+ * Optional injected reader for the live exposure state
258
+ * (`~/.parachute/expose-state.json`). Defaults to the real
259
+ * `readExposeState`. Mirrors the `init.ts` seam (`readExposeStateFn`)
260
+ * so tests can drive the "a tailnet layer is already live" branch
261
+ * without writing a real state file. When `setup_expose_mode` is
262
+ * unset, the live exposure layer auto-seeds the setting (see below).
263
+ */
264
+ readExposeStateFn?: () => ExposeState | undefined;
159
265
  }): DerivedWizardState {
160
266
  const hasAdmin = userCount(deps.db) > 0;
161
267
  // The wizard's first-vault provisioning uses the curated `vault` short,
162
268
  // which maps to `parachute-vault` in services.json.
163
269
  const vaultSpec = specFor(FIRST_VAULT_SHORT);
164
270
  const vaultEntry = findService(vaultSpec.manifestName, deps.manifestPath);
165
- const hasVault = vaultEntry !== undefined;
271
+ // hub#168 Cut 2: `setup_vault_skipped === "true"` advances the wizard
272
+ // past the vault step even when no vault row exists. The operator
273
+ // explicitly chose Skip; the module is installed (Cut 1) but no
274
+ // instance was provisioned. Treat as "vault step is done" for the
275
+ // purposes of state-derivation so the wizard moves to expose.
276
+ const vaultSkipped = getSetting(deps.db, "setup_vault_skipped") === "true";
277
+ const hasVault = vaultEntry !== undefined || vaultSkipped;
166
278
  // Expose-mode is the operator's "how will this hub be reached?" answer
167
279
  // (hub#268 Item 2). Stored as a hub_setting; the wizard's expose step
168
280
  // sets it; absence means we should still ask. EXCEPT — if we're
@@ -178,6 +290,23 @@ export function deriveWizardState(deps: {
178
290
  ) {
179
291
  setSetting(deps.db, "setup_expose_mode", "public");
180
292
  }
293
+ // hub#406 team-onboarding bug: `setup_expose_mode` (the wizard's
294
+ // answer) and `expose-state.json` (the live tailscale exposure) are
295
+ // orthogonal axes. An operator who ran `parachute expose tailnet`
296
+ // before opening the wizard has a live tailnet layer but no
297
+ // `setup_expose_mode` setting — so the wizard re-asked "how will this
298
+ // hub be reached?" even though tailnet was already up. Auto-seed the
299
+ // setting from the live exposure layer (tailnet→"tailnet",
300
+ // public→"public") so the answered-by-action case is treated as
301
+ // satisfied, mirroring the Render/Fly auto-seed above. Reading the
302
+ // live state is injected for testability (defaults to the real
303
+ // reader); a malformed/missing file falls through to "still ask."
304
+ if (getSetting(deps.db, "setup_expose_mode") === undefined) {
305
+ const seeded = exposeModeFromLiveState(deps.readExposeStateFn ?? readExposeState);
306
+ if (seeded !== undefined) {
307
+ setSetting(deps.db, "setup_expose_mode", seeded);
308
+ }
309
+ }
181
310
  const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
182
311
  let step: WizardStep;
183
312
  // Note: `"account"` is a visual-only step in the progress header —
@@ -221,6 +350,18 @@ export interface SetupWizardDeps {
221
350
  registry?: OperationsRegistry;
222
351
  /** Test seam: stub `bun add` / `bun remove` runner. */
223
352
  run?: (cmd: readonly string[]) => Promise<number>;
353
+ /**
354
+ * Test seam: stub the bun-link detection used by `runInstall` to
355
+ * short-circuit `bun add -g` when a package is already linked
356
+ * locally (smoke 2026-05-27 finding 1). Production omits this and
357
+ * the production detection at `src/bun-link.ts` probes the real
358
+ * filesystem. Tests that need to assert "bun add -g WAS called"
359
+ * pass `() => false`; tests asserting the skip path pass `() => true`.
360
+ *
361
+ * Threaded through to `ApiModulesOpsDeps.isLinked` on every
362
+ * `runInstall` call from the wizard.
363
+ */
364
+ isLinked?: (pkg: string) => boolean;
224
365
  /**
225
366
  * Test seam: override the process env that `detectAutoExposeMode`
226
367
  * consults. Production omits this and the helper reads `process.env`
@@ -228,6 +369,14 @@ export interface SetupWizardDeps {
228
369
  * without mutating the real process env.
229
370
  */
230
371
  env?: Record<string, string | undefined>;
372
+ /**
373
+ * Test seam: inject the live-exposure reader `deriveWizardState`
374
+ * consults to auto-seed `setup_expose_mode` from a live
375
+ * `parachute expose tailnet|public` (hub#406). Production omits this
376
+ * and the real `readExposeState` is used. Mirrors `init.ts`'s
377
+ * `readExposeStateFn` seam.
378
+ */
379
+ readExposeStateFn?: () => ExposeState | undefined;
231
380
  }
232
381
 
233
382
  /**
@@ -247,7 +396,9 @@ export interface SetupWizardDeps {
247
396
  * (RAILWAY_ENVIRONMENT), DigitalOcean App Platform (DIGITALOCEAN_APP_*),
248
397
  * etc. Each only auto-detects when the platform clearly owns the public URL.
249
398
  */
250
- export function detectAutoExposeMode(env: Record<string, string | undefined>): "public" | undefined {
399
+ export function detectAutoExposeMode(
400
+ env: Record<string, string | undefined>,
401
+ ): "public" | undefined {
251
402
  // Render always sets `RENDER_EXTERNAL_URL` to a real `https://` URL on
252
403
  // any web service. `startsWith("https://")` is the precise shape; we
253
404
  // also accept `http://` as a defensive fallback in case Render ever
@@ -449,6 +600,14 @@ export interface RenderVaultStepProps {
449
600
  errorMessage?: string;
450
601
  /** Pre-fill the vault name input after a validation failure. */
451
602
  vaultName?: string;
603
+ /**
604
+ * When the runtime is a hosted container (Render / Fly), the scribe
605
+ * sub-form hides the "local provider" option — Whisper / parakeet
606
+ * don't run usefully in the constrained container. Defaults to false
607
+ * (treat as self-host, show local option) — production wizard renders
608
+ * always pass an explicit value via detectAutoExposeMode.
609
+ */
610
+ cloudHost?: boolean;
452
611
  /**
453
612
  * When an install op is in progress, render the polling shape: no
454
613
  * form, just the op log + auto-refresh.
@@ -458,13 +617,28 @@ export interface RenderVaultStepProps {
458
617
  status: "pending" | "running" | "succeeded" | "failed";
459
618
  log: readonly string[];
460
619
  error?: string;
620
+ /**
621
+ * Optional scribe install op_id, threaded through so the success
622
+ * redirect carries `&op_scribe=<id>` and the done step picks up the
623
+ * in-flight scribe install via the existing per-tile op-poll
624
+ * mechanism (`buildInstallTiles` reads `op_<short>` query param).
625
+ */
626
+ scribeOpId?: string;
461
627
  };
462
628
  }
463
629
 
464
630
  export function renderVaultStep(props: RenderVaultStepProps): string {
465
- const { csrfToken, errorMessage, operation, vaultName } = props;
631
+ const { csrfToken, errorMessage, operation, vaultName, cloudHost } = props;
466
632
  if (operation) return renderVaultOpStep({ operation });
467
633
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
634
+ // hub#168 Cut 2: three-branch vault step. The browser form now sends
635
+ // `mode=create|import|skip` along with the existing vault_name. Defaults
636
+ // to create when nothing's selected (back-compat with pre-#168 form
637
+ // posts that didn't ship a mode field — still works through the same
638
+ // handler). The radio's `data-shows` attribute drives an inline
639
+ // <script> block that hides import-specific fields when create/skip
640
+ // is selected. No SPA bundle, no module deps — same posture as the
641
+ // existing scribe sub-form's mode-switching JS.
468
642
  // hub#267: the typed name now flows end-to-end via
469
643
  // `PARACHUTE_VAULT_NAME`. Vault#342 added the env var read on
470
644
  // first-boot — hub spawns vault with the env var set and vault's
@@ -513,7 +687,25 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
513
687
  ${error}
514
688
  <form method="POST" action="/admin/setup/vault" class="auth-form">
515
689
  ${renderCsrfHiddenInput(csrfToken)}
516
- <label class="field">
690
+ <fieldset class="vault-mode-block">
691
+ <legend class="field-label">How do you want to start?</legend>
692
+ <label class="vault-mode-option">
693
+ <input type="radio" name="mode" value="create" checked data-shows="name" />
694
+ <span class="vault-mode-title">Create a new vault</span>
695
+ <span class="vault-mode-desc">Start fresh. The wizard creates an empty vault under the name below.</span>
696
+ </label>
697
+ <label class="vault-mode-option">
698
+ <input type="radio" name="mode" value="import" data-shows="name,import" />
699
+ <span class="vault-mode-title">Import from a git repo</span>
700
+ <span class="vault-mode-desc">Clone a previously-exported vault from GitHub / GitLab / any HTTPS git remote.</span>
701
+ </label>
702
+ <label class="vault-mode-option">
703
+ <input type="radio" name="mode" value="skip" data-shows="" />
704
+ <span class="vault-mode-title">Skip — create a vault later</span>
705
+ <span class="vault-mode-desc">The vault module is installed; create or import a vault any time from the admin UI.</span>
706
+ </label>
707
+ </fieldset>
708
+ <label class="field vault-name-field">
517
709
  <span class="field-label">Vault name</span>
518
710
  <input type="text" name="vault_name"
519
711
  autofocus minlength="2" maxlength="32"
@@ -523,12 +715,201 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
523
715
  <span class="field-hint">lowercase letters, digits, <code>-</code>, <code>_</code>;
524
716
  2–32 chars. Leave blank for <code>${DEFAULT_VAULT_NAME}</code>.</span>
525
717
  </label>
526
- <button type="submit" class="btn btn-primary">Create vault & finish</button>
718
+ <fieldset class="vault-import-block" style="display: none;">
719
+ <legend class="field-label">Import source</legend>
720
+ <label class="field">
721
+ <span class="field-label">Remote URL</span>
722
+ <input type="text" name="remote_url" spellcheck="false" autocomplete="off"
723
+ placeholder="https://github.com/you/your-vault.git" />
724
+ <span class="field-hint">HTTPS or SSH clone URL. The repo must be a Parachute vault export — i.e. it carries a <code>.parachute/vault.yaml</code> at the root.</span>
725
+ </label>
726
+ <label class="field">
727
+ <span class="field-label">Personal access token (optional)</span>
728
+ <input type="password" name="pat" autocomplete="off"
729
+ placeholder="ghp_… / glpat-… / etc." />
730
+ <span class="field-hint">Required for private repos. Used in-memory for this import only — not stored. Set up push credentials later from the vault's mirror settings.</span>
731
+ </label>
732
+ <label class="vault-mode-option">
733
+ <input type="radio" name="import_mode" value="merge" checked />
734
+ <span class="vault-mode-title">Merge into a fresh vault (default)</span>
735
+ <span class="vault-mode-desc">Recommended on a brand-new install — the vault starts empty, so merge is effectively "import everything."</span>
736
+ </label>
737
+ <label class="vault-mode-option">
738
+ <input type="radio" name="import_mode" value="replace" />
739
+ <span class="vault-mode-title">Replace (wipes any existing notes first)</span>
740
+ <span class="vault-mode-desc">Only useful if you re-ran the wizard on an existing vault. Otherwise picks the same shape as merge.</span>
741
+ </label>
742
+ </fieldset>
743
+ ${renderScribeSubForm(cloudHost === true)}
744
+ <button type="submit" class="btn btn-primary">Continue</button>
527
745
  </form>
746
+ <script>
747
+ (function () {
748
+ // Show/hide vault-name + import block based on the picked mode.
749
+ // The radio carries data-shows listing the visible block suffixes
750
+ // (name, import); the show/hide loop reads them and flips display
751
+ // on the matching block. Skip mode hides everything below the
752
+ // mode picker.
753
+ var radios = document.querySelectorAll('input[name="mode"]');
754
+ var nameField = document.querySelector('.vault-name-field');
755
+ var importBlock = document.querySelector('.vault-import-block');
756
+ function sync() {
757
+ var picked = document.querySelector('input[name="mode"]:checked');
758
+ var shows = picked ? (picked.dataset.shows || '') : '';
759
+ var nameVisible = shows.indexOf('name') !== -1;
760
+ var importVisible = shows.indexOf('import') !== -1;
761
+ if (nameField) nameField.style.display = nameVisible ? '' : 'none';
762
+ if (importBlock) importBlock.style.display = importVisible ? '' : 'none';
763
+ }
764
+ radios.forEach(function (r) { r.addEventListener('change', sync); });
765
+ sync();
766
+ })();
767
+ </script>
528
768
  </div>`;
529
769
  return baseDocument("Set up your Parachute hub — vault", body);
530
770
  }
531
771
 
772
+ /**
773
+ * Scribe install sub-form embedded in the vault step (folded in
774
+ * 2026-05-27 per Aaron's team-meeting directive: "folding the scribe
775
+ * question into the vault step is a good idea"). Operator answers
776
+ * scribe-related questions in the same form as vault name, the POST
777
+ * handler kicks both installs in parallel, and the done screen polls
778
+ * scribe's progress via the existing per-tile op-poll mechanism.
779
+ *
780
+ * The provider list adapts to the runtime context:
781
+ * - Cloud container (Render / Fly): local transcribers (parakeet,
782
+ * whisper) don't fit in 512MB + can't reach hardware acceleration.
783
+ * We hide them. Groq is the default (fast cloud Whisper, ~$0.04/hr
784
+ * of audio); OpenAI is the alternative.
785
+ * - Local (Mac / Linux): parakeet-mlx is the default on Mac (silicon
786
+ * MLX); falls back to onnx-asr cross-platform. Cloud providers
787
+ * stay available as choices for operators who'd rather pay than
788
+ * run local inference.
789
+ *
790
+ * The API key input shows conditionally — only when a cloud provider
791
+ * is selected. It's a plain text input (no `type=password`) because
792
+ * (a) the operator just pasted it from their provider's dashboard, and
793
+ * (b) showing it lets them verify they pasted correctly before submit.
794
+ * Mode-switching between providers via the radio is handled by an
795
+ * inline `<script>` block — no SPA bundle, no module deps.
796
+ *
797
+ * The "Skip — no transcription" option is third and unchecked by
798
+ * default. Most operators want voice transcription once they know
799
+ * they can; the default-on posture matches the auto-transcribe default
800
+ * flip that landed in vault#373.
801
+ */
802
+ function renderScribeSubForm(cloudHost: boolean): string {
803
+ const localBlock = cloudHost
804
+ ? ""
805
+ : `
806
+ <label class="scribe-provider-option">
807
+ <input type="radio" name="scribe_provider" value="local"${cloudHost ? "" : " checked"} data-needs-key="false" />
808
+ <span class="provider-name">Local <small>(Mac MLX or ONNX — no API key needed)</small></span>
809
+ </label>`;
810
+ const groqDefault = cloudHost ? " checked" : "";
811
+ // Cleanup providers that need a host-side binary or local server
812
+ // (claude-code → `claude` CLI + `claude setup-token`; ollama → local
813
+ // Ollama server) are hidden on cloud hosts (Render / Fly). The
814
+ // remaining cloud-friendly choices (anthropic / openai / groq /
815
+ // gemini) stay visible — they only need an API key.
816
+ const claudeCodeCleanupBlock = cloudHost
817
+ ? ""
818
+ : `
819
+ <label class="scribe-provider-option">
820
+ <input type="radio" name="scribe_cleanup_provider" value="claude-code" data-needs-key="false" />
821
+ <span class="provider-name">Claude Code <small>(subscription auth — run <code>claude setup-token</code> on this host)</small></span>
822
+ </label>`;
823
+ const ollamaCleanupBlock = cloudHost
824
+ ? ""
825
+ : `
826
+ <label class="scribe-provider-option">
827
+ <input type="radio" name="scribe_cleanup_provider" value="ollama" data-needs-key="false" />
828
+ <span class="provider-name">Ollama <small>(local LLM — requires Ollama running on this machine)</small></span>
829
+ </label>`;
830
+ return `
831
+ <details class="scribe-suboptions" open>
832
+ <summary class="cursor-pointer">
833
+ <span class="field-label">Enable voice transcription</span>
834
+ <span class="field-hint"> · Scribe installs alongside vault, transcribes audio attachments automatically</span>
835
+ </summary>
836
+ <div class="scribe-provider-block">
837
+ <p class="field-hint">Pick a transcription provider. You can change this later in <code>/admin/modules</code>.</p>
838
+ <div class="scribe-provider-list">
839
+ ${localBlock}
840
+ <label class="scribe-provider-option">
841
+ <input type="radio" name="scribe_provider" value="groq"${groqDefault} data-needs-key="true" />
842
+ <span class="provider-name">Groq <small>(~\$0.04/hr of audio, fast)</small></span>
843
+ </label>
844
+ <label class="scribe-provider-option">
845
+ <input type="radio" name="scribe_provider" value="openai" data-needs-key="true" />
846
+ <span class="provider-name">OpenAI Whisper <small>(~\$0.36/hr of audio)</small></span>
847
+ </label>
848
+ <label class="scribe-provider-option">
849
+ <input type="radio" name="scribe_provider" value="none" data-needs-key="false" />
850
+ <span class="provider-name">Skip — no transcription</span>
851
+ </label>
852
+ </div>
853
+ <label class="field scribe-api-key-field" data-shows-on="cloud">
854
+ <span class="field-label">API key</span>
855
+ <input type="password" name="scribe_api_key" autocomplete="off" placeholder="gsk_… or sk-…" />
856
+ <span class="field-hint">Pasted directly into <code>~/.parachute/scribe/config.json</code> on this hub (file mode 0o600). Leave blank to skip and set later in the admin SPA.</span>
857
+ </label>
858
+ <fieldset class="scribe-cleanup-block">
859
+ <legend class="field-label">Cleanup <small>(optional LLM polish pass on transcripts)</small></legend>
860
+ <p class="field-hint">After transcription, scribe can run a cleanup pass to fix punctuation, capitalization, and obvious transcription glitches. Pick a provider, or skip.</p>
861
+ <div class="scribe-provider-list">
862
+ <label class="scribe-provider-option">
863
+ <input type="radio" name="scribe_cleanup_provider" value="none" checked data-needs-key="false" />
864
+ <span class="provider-name">Skip cleanup <small>(default — raw transcripts only)</small></span>
865
+ </label>
866
+ ${claudeCodeCleanupBlock}
867
+ <label class="scribe-provider-option">
868
+ <input type="radio" name="scribe_cleanup_provider" value="anthropic" data-needs-key="true" />
869
+ <span class="provider-name">Anthropic API <small>(needs ANTHROPIC_API_KEY)</small></span>
870
+ </label>
871
+ ${ollamaCleanupBlock}
872
+ <label class="scribe-provider-option">
873
+ <input type="radio" name="scribe_cleanup_provider" value="openai" data-needs-key="true" />
874
+ <span class="provider-name">OpenAI <small>(needs OPENAI_API_KEY)</small></span>
875
+ </label>
876
+ <label class="scribe-provider-option">
877
+ <input type="radio" name="scribe_cleanup_provider" value="groq" data-needs-key="true" />
878
+ <span class="provider-name">Groq <small>(needs GROQ_API_KEY)</small></span>
879
+ </label>
880
+ <label class="scribe-provider-option">
881
+ <input type="radio" name="scribe_cleanup_provider" value="gemini" data-needs-key="true" />
882
+ <span class="provider-name">Google Gemini <small>(needs GOOGLE_API_KEY)</small></span>
883
+ </label>
884
+ </div>
885
+ <label class="field scribe-cleanup-api-key-field" style="display: none;">
886
+ <span class="field-label">Cleanup API key</span>
887
+ <input type="password" name="scribe_cleanup_api_key" autocomplete="off" placeholder="sk-ant-… or sk-… or gsk-…" />
888
+ <span class="field-hint">Pasted directly into <code>~/.parachute/scribe/config.json</code> on this hub (file mode 0o600). Leave blank to skip and paste later in the admin SPA.</span>
889
+ </label>
890
+ </fieldset>
891
+ </div>
892
+ </details>
893
+ <script>
894
+ (function () {
895
+ function toggle(radioName, keySelector) {
896
+ var radios = document.querySelectorAll('input[name="' + radioName + '"]');
897
+ var keyField = document.querySelector(keySelector);
898
+ function sync() {
899
+ var selected = document.querySelector('input[name="' + radioName + '"]:checked');
900
+ var needsKey = selected && selected.dataset.needsKey === "true";
901
+ if (keyField) keyField.style.display = needsKey ? "" : "none";
902
+ }
903
+ radios.forEach(function (r) { r.addEventListener("change", sync); });
904
+ sync();
905
+ }
906
+ toggle("scribe_provider", ".scribe-api-key-field");
907
+ toggle("scribe_cleanup_provider", ".scribe-cleanup-api-key-field");
908
+ })();
909
+ </script>
910
+ `;
911
+ }
912
+
532
913
  function renderVaultOpStep(props: {
533
914
  operation: NonNullable<RenderVaultStepProps["operation"]>;
534
915
  }): string {
@@ -567,7 +948,7 @@ function renderVaultOpStep(props: {
567
948
  </section>
568
949
  ${
569
950
  operation.status === "succeeded"
570
- ? '<meta http-equiv="refresh" content="1; url=/admin/setup?just_finished=1" />'
951
+ ? `<meta http-equiv="refresh" content="1; url=/admin/setup?just_finished=1${operation.scribeOpId ? `&op_scribe=${encodeURIComponent(operation.scribeOpId)}` : ""}" />`
571
952
  : ""
572
953
  }
573
954
  </div>`;
@@ -718,27 +1099,15 @@ export interface RenderDoneStepProps {
718
1099
  * shape.
719
1100
  */
720
1101
  installTiles?: readonly ModuleInstallTileState[];
721
- /**
722
- * Whether parachute-app is installed alongside the vault. Drives the
723
- * "Start using your vault" lead tile (hub#342): when true, the tile
724
- * links to `/app/notes/` (the canonical user-facing surface — App
725
- * auto-bootstraps Notes-as-UI per the 2026-05-21 migration). When
726
- * false, it falls back to the vault's own admin UI at
727
- * `/vault/<name>/admin/` so the operator still has a single obvious
728
- * "start using parachute" target. Omitted = back-compat with tests
729
- * that render the done step without dependency-checking; defaults to
730
- * false (vault-admin fallback).
731
- */
732
- appInstalled?: boolean;
733
1102
  }
734
1103
 
735
1104
  export function renderDoneStep(props: RenderDoneStepProps): string {
736
- const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles, appInstalled } = props;
1105
+ const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles } = props;
737
1106
  const reachable = exposeMode ? renderReachableTile(exposeMode, hubOrigin) : "";
738
1107
  const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
739
1108
  const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
740
1109
  const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
741
- const startTile = renderStartUsingTile(vaultName, appInstalled === true);
1110
+ const startTile = renderStartUsingTile(vaultName, hubOrigin);
742
1111
  // The done-grid hosts the MCP-connect tile + the admin-UI fallback.
743
1112
  // The install tiles sit above it as a "what's next?" surface (curated
744
1113
  // catalog of modules an operator might want next). The "Start using
@@ -762,6 +1131,7 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
762
1131
  </div>
763
1132
  ${reachable}
764
1133
  ${startTile}
1134
+ ${renderStarterPromptsSection()}
765
1135
  ${installSection}
766
1136
  <section class="done-grid">
767
1137
  ${mcpTile}
@@ -925,14 +1295,18 @@ function renderMcpTile(
925
1295
  <h2>Connect Claude Code (MCP)</h2>
926
1296
  <p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
927
1297
  <pre>${escapeHtml(bareCmd)}</pre>
928
- <p class="fine">Mint an operator token at
929
- <a href="/admin/tokens"><code>/admin/tokens</code></a> and append
930
- <code>--header "Authorization: Bearer pvt_..."</code> on first use.</p>
1298
+ <p class="fine">No token needed the command triggers browser OAuth on
1299
+ first use (you sign in to this hub and approve access). For headless
1300
+ clients that can't do the browser flow, mint a hub token at
1301
+ <a href="/admin/tokens"><code>/admin/tokens</code></a> (or with
1302
+ <code>parachute auth mint-token</code>) and append
1303
+ <code>--header "Authorization: Bearer &lt;token&gt;"</code>.</p>
931
1304
  </div>`;
932
1305
  }
933
1306
 
934
1307
  /**
935
- * The "Start using your vault" lead tile on the done step (hub#342).
1308
+ * The "Start using your vault" lead tile on the done step (hub#342,
1309
+ * Aaron 2026-05-27 simplification).
936
1310
  *
937
1311
  * Closes Aaron's "no clear way to go from setting up parachute to
938
1312
  * actually using parachute" friction. Sits above the MCP / install
@@ -940,39 +1314,72 @@ function renderMcpTile(
940
1314
  * everything else on the done screen is operator-flavored (MCP
941
1315
  * command, admin UI, additional module installs).
942
1316
  *
943
- * Two shapes:
944
- * - **App installed** primary tile targets `/app/notes/` (the
945
- * Notes app reading the just-created vault). This is the
946
- * canonical surface post-Notes-as-app migration (parachute-app §17).
947
- * - **App NOT installed** → primary tile targets the vault's own
948
- * admin UI at `/vault/<name>/admin/`. The copy explains that
949
- * installing App + Notes is the recommended next step for a
950
- * content-browsing surface, and points at the install tile below.
1317
+ * Points at the canonical notes.parachute.computer hosted PWA as the
1318
+ * primary CTA with the operator's own hub URL pre-filled via
1319
+ * `?url=` so the connect screen auto-populates + auto-focuses
1320
+ * (notes-ui AddVault route, see
1321
+ * parachute-surface/packages/notes-ui/src/app/routes/AddVault.tsx).
1322
+ * Secondary CTA: "Open vault admin" (the vault's own admin UI on this
1323
+ * hub) for operators who want to look at raw vault state.
951
1324
  *
952
- * Either way, the operator has ONE obvious click target that says
953
- * "start using parachute" not three competing tiles where the
954
- * "real" entry point is buried under the MCP command pre-hub#342.
1325
+ * Previously varied by whether `parachute-surface` was installed
1326
+ * locally pointing at `/surface/notes/` in that case. Dropped
1327
+ * 2026-05-27: hub+vault+scribe is the focus; notes.parachute.computer
1328
+ * is canonical regardless of local surface install state.
955
1329
  */
956
- function renderStartUsingTile(vaultName: string, appInstalled: boolean): string {
1330
+ function renderStartUsingTile(vaultName: string, hubOrigin: string): string {
957
1331
  const safeVault = escapeHtml(vaultName);
958
1332
  // Vault names pass `/^[a-z0-9][a-z0-9-]*$/i` so URL-encoding is mostly
959
1333
  // a no-op today, but use encodeURIComponent defensively to match hub.ts:505.
960
1334
  const urlVault = encodeURIComponent(vaultName);
961
- if (appInstalled) {
962
- return `<section class="start-using" data-testid="start-using-tile">
963
- <h2>Start using your vault</h2>
964
- <p>Notes is installed and ready. Capture your first note in the
965
- Notes app — it reads from <code>${safeVault}</code> directly.</p>
966
- <p><a class="btn btn-primary" href="/app/notes/">Open Notes</a></p>
967
- </section>`;
968
- }
1335
+ // The `?url=` query param is consumed by notes-ui's AddVault route
1336
+ // (packages/notes-ui/src/app/routes/AddVault.tsx) — it pre-fills the
1337
+ // vault URL input + auto-focuses Submit.
1338
+ const vaultUrlForAdd = encodeURIComponent(`${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}`);
969
1339
  return `<section class="start-using" data-testid="start-using-tile">
970
1340
  <h2>Start using your vault</h2>
971
- <p>Your vault <code>${safeVault}</code> is provisioned. Install
972
- <strong>App</strong> below (it bundles the Notes UI) to start
973
- capturing or open the vault's admin UI now to see what's
974
- inside.</p>
975
- <p><a class="btn btn-primary" href="/vault/${urlVault}/admin/">Open vault admin</a></p>
1341
+ <p>Open Notes — the canonical browser UI for your vault <code>${safeVault}</code>.
1342
+ It connects to your hub over HTTPS and remembers your URL after the first OAuth.</p>
1343
+ <p><a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}" target="_blank" rel="noopener">Open Notes ↗</a></p>
1344
+ <p class="start-using-secondary">
1345
+ <a href="/vault/${urlVault}/admin/">Or browse the vault's admin UI →</a>
1346
+ </p>
1347
+ </section>`;
1348
+ }
1349
+
1350
+ /**
1351
+ * Starter-prompts tile on the done screen. Surfaces the two
1352
+ * interview-style prompts hosted at parachute.computer:
1353
+ *
1354
+ * 1. "Help me set up my vault" — AI interviews the operator about
1355
+ * where their data lives + proposes a tag/path structure
1356
+ * (parachute.computer/onboarding/vault-setup/).
1357
+ * 2. "Build a custom UI" — AI builds a static SPA against the vault's
1358
+ * HTTP API, hosted on the operator's own GitHub Pages
1359
+ * (parachute.computer/onboarding/surface-build/).
1360
+ *
1361
+ * Aaron 2026-05-27 directive: ship these as the "first AI assist"
1362
+ * surface so freshly-onboarded operators have a clear next thing to
1363
+ * do beyond clicking around the admin UI. The prompts live on
1364
+ * parachute.computer rather than embedded in the wizard so they can
1365
+ * be iterated without a hub release; the wizard just links.
1366
+ */
1367
+ function renderStarterPromptsSection(): string {
1368
+ return `<section class="starter-prompts" data-testid="starter-prompts">
1369
+ <h2>Get help from your AI</h2>
1370
+ <p class="starter-prompts-subtitle">Two interview-style prompts to paste into Claude Code or Codex once your vault's MCP is wired up.</p>
1371
+ <div class="starter-prompts-grid">
1372
+ <a class="starter-prompt-tile" href="https://parachute.computer/onboarding/vault-setup/" target="_blank" rel="noopener">
1373
+ <h3>Set up your vault</h3>
1374
+ <p>Interview-style. AI asks where your notes live now + proposes a tag &amp; path structure that fits how you actually think.</p>
1375
+ <p class="starter-prompt-cta">Open prompt ↗</p>
1376
+ </a>
1377
+ <a class="starter-prompt-tile" href="https://parachute.computer/onboarding/surface-build/" target="_blank" rel="noopener">
1378
+ <h3>Build a custom UI</h3>
1379
+ <p>AI generates a static SPA hosted on your own GitHub Pages — talks to your vault over HTTP. Notes UI works as a reference.</p>
1380
+ <p class="starter-prompt-cta">Open prompt ↗</p>
1381
+ </a>
1382
+ </div>
976
1383
  </section>`;
977
1384
  }
978
1385
 
@@ -1079,13 +1486,12 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
1079
1486
  * surface decision.
1080
1487
  */
1081
1488
  const USE_IT_NOW_URLS: Partial<Record<CuratedModuleShort, string>> = {
1082
- app: "/app/notes/",
1083
- notes: "/notes/",
1084
- // Omitted: scribe + runner. They don't ship an admin SPA yet
1085
- // (scribe#53, runner#8 track). Pointing "Use it now" at /scribe/admin
1086
- // or /runner/admin today would 404 better to fall through to the
1087
- // "Manage modules" link than to send the operator into a dead end.
1088
- // Add the entry here once those modules ship their admin UI.
1489
+ // Empty: vault has its own lead "Start using" tile (the
1490
+ // notes.parachute.computer CTA), so it doesn't appear here. Scribe
1491
+ // doesn't ship an admin SPA at /scribe/admin/ that's useful for
1492
+ // first-time operators (the page exists but it's config-management;
1493
+ // not "use it"). Re-add per-module entries here if/when a module
1494
+ // ships a user-facing landing surface worth pointing at.
1089
1495
  };
1090
1496
 
1091
1497
  /**
@@ -1162,6 +1568,29 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1162
1568
  const url = new URL(req.url);
1163
1569
  const state = deriveWizardState(deps);
1164
1570
  const csrf = ensureCsrfToken(req);
1571
+ const wantsJson = wantsJsonResponse(req);
1572
+ // CLI wizard surface (hub#168 Cut 3): the GET endpoint doubles as a
1573
+ // state-probe API. Same state-derivation, same DB read; only the
1574
+ // response shape forks on Accept. Returning the JSON envelope before
1575
+ // the HTML rendering branches means the CLI gets the answer it needs
1576
+ // without the wizard having to render a 30KB HTML page per poll.
1577
+ if (wantsJson) {
1578
+ const requireToken = getBootstrapToken() !== undefined;
1579
+ const envelope = {
1580
+ step: state.step,
1581
+ hasAdmin: state.hasAdmin,
1582
+ hasVault: state.hasVault,
1583
+ hasExposeMode: state.hasExposeMode,
1584
+ requireBootstrapToken: requireToken,
1585
+ csrfToken: csrf.token,
1586
+ };
1587
+ const jsonHeaders: Record<string, string> = {
1588
+ "content-type": "application/json; charset=utf-8",
1589
+ "cache-control": "no-store",
1590
+ };
1591
+ if (csrf.setCookie) jsonHeaders["set-cookie"] = csrf.setCookie;
1592
+ return new Response(JSON.stringify(envelope), { status: 200, headers: jsonHeaders });
1593
+ }
1165
1594
  const extraHeaders: Record<string, string> = {
1166
1595
  "content-type": "text/html; charset=utf-8",
1167
1596
  };
@@ -1215,28 +1644,41 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1215
1644
  // /admin/tokens.
1216
1645
  const mintedToken = getSetting(deps.db, "setup_minted_token");
1217
1646
  if (mintedToken) deleteSetting(deps.db, "setup_minted_token");
1218
- // hub#267: the operator-typed vault name lives in hub_settings
1219
- // (persisted by handleSetupVaultPost). Fall back to scanning
1220
- // services.json covers wizard runs from before this PR where
1221
- // setup_vault_name wasn't written. The services.json read
1222
- // returns the path-tail; vault's own first-boot write produces
1223
- // the canonical name so the two should agree once the vault
1224
- // boots authoritatively.
1647
+ // Prefer the LIVE vault name from services.json over the
1648
+ // operator-typed value cached in hub_settings (smoke
1649
+ // 2026-05-27, finding 2). The cached value is what the
1650
+ // operator typed into the wizard form — fine on the happy
1651
+ // path, but stale if the vault install failed and the
1652
+ // operator worked around it (e.g. installed vault under a
1653
+ // different name via the CLI). The "static-write + stale-
1654
+ // read" pattern Aaron's flagged repeatedly:
1655
+ // `feedback_static_vs_dynamic_state.md`. Read state
1656
+ // dynamically when it can change.
1657
+ //
1658
+ // Fall back to the DB setting only if services.json has no
1659
+ // vault entry — covers a transient "wizard hit done but
1660
+ // vault is still pending" race where the operator-typed
1661
+ // value is the only signal we have. Final fallback is
1662
+ // "default" so the rendered name is always something the
1663
+ // operator can act on.
1664
+ const liveName = firstVaultNameOrNull(deps.manifestPath);
1225
1665
  const storedName = getSetting(deps.db, "setup_vault_name");
1226
- const vaultName = storedName ?? firstVaultName(deps.manifestPath);
1666
+ const vaultName = liveName ?? storedName ?? "default";
1227
1667
  // Module install tiles (hub#272 Item B). One per curated module
1228
1668
  // other than vault (which the wizard already provisioned).
1229
1669
  const installTiles = buildInstallTiles(url, deps);
1230
- // hub#342: drive the lead "Start using your vault" tile's target.
1231
- // When parachute-app is installed alongside vault, the tile links
1232
- // to `/app/notes/` (auto-bootstrapped Notes-as-UI per parachute-app
1233
- // §17). Otherwise it falls back to the vault's own admin UI.
1234
- const appInstalled = isModuleInstalled("app", deps.manifestPath);
1670
+ // The lead "Start using your vault" tile points at
1671
+ // notes.parachute.computer/add always, regardless of any
1672
+ // local module install state. Prior versions of this code
1673
+ // checked `isModuleInstalled("surface", ...)` to switch to a
1674
+ // local `/surface/notes/` link, but the launch focus is
1675
+ // hub+vault+scribe and notes.parachute.computer is the
1676
+ // canonical Notes UI (Aaron-directed 2026-05-27). Dropped the
1677
+ // local-fallback branch.
1235
1678
  const doneProps: RenderDoneStepProps = {
1236
1679
  vaultName,
1237
1680
  hubOrigin: deps.issuer,
1238
1681
  installTiles,
1239
- appInstalled,
1240
1682
  };
1241
1683
  if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
1242
1684
  if (mintedToken) doneProps.mintedToken = mintedToken;
@@ -1270,25 +1712,33 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1270
1712
  // Step 3 (vault) with an op in flight — render the poll page.
1271
1713
  if (state.hasAdmin && !state.hasVault) {
1272
1714
  const opId = url.searchParams.get("op");
1715
+ const cloudHost = detectAutoExposeMode(deps.env ?? process.env) === "public";
1273
1716
  if (opId) {
1274
1717
  const registry = deps.registry;
1275
1718
  const op = registry?.get(opId);
1276
1719
  if (op) {
1720
+ // Carry the scribe op_id forward via the query param so the
1721
+ // op-poll page's success-redirect threads it into the done
1722
+ // step's URL (where buildInstallTiles picks it up via the
1723
+ // existing per-tile `op_scribe` mechanism).
1724
+ const scribeOpIdParam = url.searchParams.get("op_scribe") ?? undefined;
1277
1725
  return new Response(
1278
1726
  renderVaultStep({
1279
1727
  csrfToken: csrf.token,
1728
+ cloudHost,
1280
1729
  operation: {
1281
1730
  id: op.id,
1282
1731
  status: op.status,
1283
1732
  log: op.log,
1284
1733
  ...(op.error !== undefined ? { error: op.error } : {}),
1734
+ ...(scribeOpIdParam !== undefined ? { scribeOpId: scribeOpIdParam } : {}),
1285
1735
  },
1286
1736
  }),
1287
1737
  { status: 200, headers: extraHeaders },
1288
1738
  );
1289
1739
  }
1290
1740
  }
1291
- return new Response(renderVaultStep({ csrfToken: csrf.token }), {
1741
+ return new Response(renderVaultStep({ csrfToken: csrf.token, cloudHost }), {
1292
1742
  status: 200,
1293
1743
  headers: extraHeaders,
1294
1744
  });
@@ -1327,9 +1777,19 @@ export async function handleSetupAccountPost(
1327
1777
  req: Request,
1328
1778
  deps: SetupWizardDeps,
1329
1779
  ): Promise<Response> {
1330
- const form = await req.formData();
1780
+ const form = await readBodyFields(req);
1781
+ // JSON callers (CLI wizard, hub#168 Cut 3) generally don't have a
1782
+ // pre-existing CSRF cookie because the GET that returned the JSON
1783
+ // envelope just set one — the CLI's fetch is the first request and
1784
+ // the verifyCsrfToken's double-submit check needs the cookie + body
1785
+ // value to match. The wizard's GET surface sets the cookie; the CLI
1786
+ // reads it back from `Set-Cookie` and threads it on subsequent POSTs,
1787
+ // matching the browser behavior. CSRF verification is shared.
1331
1788
  const formCsrf = form.get(CSRF_FIELD_NAME);
1332
1789
  if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
1790
+ if (form.isJson) {
1791
+ return jsonErrorResponse(400, "Invalid form submission", "Reload and try again.");
1792
+ }
1333
1793
  return badRequestPage("Invalid form submission", "Reload and try again.");
1334
1794
  }
1335
1795
  // Already-bootstrapped: bounce. The wizard's GET state will resolve to
@@ -1342,10 +1802,16 @@ export async function handleSetupAccountPost(
1342
1802
  const requireToken = getBootstrapToken() !== undefined;
1343
1803
  if (userCount(deps.db) > 0) {
1344
1804
  if (!requireToken) {
1805
+ if (form.isJson) {
1806
+ return jsonOkResponse({ step: "vault", message: "admin already exists" });
1807
+ }
1345
1808
  return redirect("/admin/setup");
1346
1809
  }
1347
1810
  // Defense in depth: a token was active but an admin already exists.
1348
1811
  // Treat as consumed.
1812
+ if (form.isJson) {
1813
+ return jsonErrorResponse(410, "Admin already claimed", "Bootstrap token was already used.");
1814
+ }
1349
1815
  return new Response(renderClaimAlreadyHappenedPage(), {
1350
1816
  status: 410,
1351
1817
  headers: { "content-type": "text/html; charset=utf-8" },
@@ -1359,6 +1825,13 @@ export async function handleSetupAccountPost(
1359
1825
  if (requireToken) {
1360
1826
  const suppliedToken = String(form.get("bootstrap_token") ?? "").trim();
1361
1827
  if (!verifyBootstrapToken(suppliedToken)) {
1828
+ if (form.isJson) {
1829
+ return jsonErrorResponse(
1830
+ 401,
1831
+ "Bootstrap token rejected",
1832
+ "Re-check the `parachute-bootstrap-…` line in your hub's startup logs.",
1833
+ );
1834
+ }
1362
1835
  const username = String(form.get("username") ?? "").trim();
1363
1836
  return htmlResponse(
1364
1837
  renderAccountStep({
@@ -1380,6 +1853,9 @@ export async function handleSetupAccountPost(
1380
1853
  const confirm = String(form.get("password_confirm") ?? "");
1381
1854
  const fieldErr = validateAccountFields({ username, password, confirm });
1382
1855
  if (fieldErr) {
1856
+ if (form.isJson) {
1857
+ return jsonErrorResponse(400, "Invalid account fields", fieldErr);
1858
+ }
1383
1859
  return htmlResponse(
1384
1860
  renderAccountStep({
1385
1861
  csrfToken,
@@ -1412,6 +1888,9 @@ export async function handleSetupAccountPost(
1412
1888
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
1413
1889
  secure: isHttpsRequest(req),
1414
1890
  });
1891
+ if (form.isJson) {
1892
+ return jsonOkResponse({ step: "vault", message: "admin created" }, { "set-cookie": cookie });
1893
+ }
1415
1894
  return redirect("/admin/setup", { "set-cookie": cookie });
1416
1895
  } catch (err) {
1417
1896
  // Log the raw error server-side for the operator's debugging, but
@@ -1425,6 +1904,13 @@ export async function handleSetupAccountPost(
1425
1904
  // shell.
1426
1905
  const msg = err instanceof Error ? err.message : String(err);
1427
1906
  console.warn(`[setup-wizard] createUser failed for "${username}": ${msg}`);
1907
+ if (form.isJson) {
1908
+ return jsonErrorResponse(
1909
+ 400,
1910
+ "Account creation failed",
1911
+ "Failed to create account. The username may already be taken.",
1912
+ );
1913
+ }
1428
1914
  return htmlResponse(
1429
1915
  renderAccountStep({
1430
1916
  csrfToken,
@@ -1466,7 +1952,26 @@ function renderClaimAlreadyHappenedPage(): string {
1466
1952
  }
1467
1953
 
1468
1954
  /**
1469
- * POST `/admin/setup/vault`. Form-encoded.
1955
+ * POST `/admin/setup/vault`. Accepts `application/x-www-form-urlencoded`
1956
+ * (browser) and `application/json` (CLI wizard).
1957
+ *
1958
+ * Three modes (hub#168 Cut 2 — Aaron's 2026-05-28 directive): `mode`
1959
+ * field is the discriminant.
1960
+ * * `create` (default if absent — back-compat with the pre-hub#168
1961
+ * browser flow that didn't send `mode`): provision a new vault under
1962
+ * the typed name.
1963
+ * * `import`: provision an empty vault under the typed name, then
1964
+ * POST to vault's `/vault/<name>/.parachute/mirror/import` endpoint
1965
+ * with the supplied remote URL + optional PAT. Surfaces import
1966
+ * progress through the same op-poll machinery used by the create
1967
+ * path.
1968
+ * * `skip`: don't create or import anything. The wizard advances to
1969
+ * the expose step. The "vault module installed" signal is still
1970
+ * true (init.ts pre-installed it under hub#168 Cut 1), but no
1971
+ * instance exists — `deriveWizardState`'s `hasVault` reflects the
1972
+ * services.json shape, which `skip` leaves untouched. To make the
1973
+ * wizard *advance past* the vault step on skip we persist a
1974
+ * `setup_vault_skipped` flag that `deriveWizardState` consults.
1470
1975
  *
1471
1976
  * Gated by the admin session cookie set at step 2 — a stale tab without
1472
1977
  * the cookie won't accidentally try to provision a vault. The session is
@@ -1474,31 +1979,33 @@ function renderClaimAlreadyHappenedPage(): string {
1474
1979
  * same one driving step 3 (they're necessarily the only user in
1475
1980
  * single-user mode).
1476
1981
  *
1477
- * Drives `runInstall` directly (not the bearer-gated `handleInstall`).
1478
- * The bearer check exists to keep narrow `:auth`-scope automation
1479
- * tokens from hitting destructive endpoints; the wizard is already
1480
- * gated on session + on "no vault exists yet," so a separate
1481
- * bearer-mint dance would be pure ceremony.
1482
- *
1483
- * Returns a 303-redirect to `/admin/setup?op=<id>` so the wizard's
1484
- * polling GET shape kicks in. The actual `bun add` runs in the
1485
- * background; failures surface in the op log.
1982
+ * Browser shape: returns 303 to `/admin/setup?op=<id>` (create/import) or
1983
+ * `/admin/setup` (skip).
1984
+ * CLI shape: returns 200 JSON `{ op_id?, step }`.
1486
1985
  */
1487
1986
  export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps): Promise<Response> {
1488
- if (!deps.supervisor) {
1489
- return badRequestPage(
1490
- "Module supervisor unavailable",
1491
- "The first-boot wizard needs container-mode `parachute serve` to install modules. " +
1492
- "On the on-box CLI surface, run `parachute install vault` directly.",
1493
- );
1494
- }
1495
- const form = await req.formData();
1987
+ // Note: supervisor gate moved BELOW the mode check (hub#168 Cut 2) so
1988
+ // that `mode=skip` doesn't fail on the CLI hub surface (which doesn't
1989
+ // wire a supervisor — operators install vault via `parachute install
1990
+ // vault` on the on-box CLI path; the wizard's role there is the
1991
+ // account + skip + expose decisions only).
1992
+ const form = await readBodyFields(req);
1496
1993
  const formCsrf = form.get(CSRF_FIELD_NAME);
1497
1994
  if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
1995
+ if (form.isJson) {
1996
+ return jsonErrorResponse(400, "Invalid form submission", "Reload and try again.");
1997
+ }
1498
1998
  return badRequestPage("Invalid form submission", "Reload and try again.");
1499
1999
  }
1500
2000
  const session = findActiveSession(deps.db, req);
1501
2001
  if (!session) {
2002
+ if (form.isJson) {
2003
+ return jsonErrorResponse(
2004
+ 401,
2005
+ "No admin session",
2006
+ "Sign in to continue setup. The session cookie was set on step 2.",
2007
+ );
2008
+ }
1502
2009
  return badRequestPage(
1503
2010
  "No admin session",
1504
2011
  "Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
@@ -1506,7 +2013,66 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1506
2013
  }
1507
2014
  // Already done — short-circuit to the done step.
1508
2015
  const state = deriveWizardState(deps);
1509
- if (state.hasVault) return redirect("/admin/setup?just_finished=1");
2016
+ if (state.hasVault) {
2017
+ if (form.isJson) {
2018
+ return jsonOkResponse({ step: "expose", message: "vault already provisioned" });
2019
+ }
2020
+ return redirect("/admin/setup?just_finished=1");
2021
+ }
2022
+
2023
+ // Mode discriminant (hub#168 Cut 2). Default is "create" for back-
2024
+ // compat with the existing browser form — it doesn't send `mode`.
2025
+ const rawMode = String(form.get("mode") ?? "create").trim();
2026
+ if (rawMode !== "create" && rawMode !== "import" && rawMode !== "skip") {
2027
+ if (form.isJson) {
2028
+ return jsonErrorResponse(
2029
+ 400,
2030
+ "Invalid vault mode",
2031
+ `mode must be one of create, import, skip (got "${rawMode}")`,
2032
+ );
2033
+ }
2034
+ return badRequestPage("Invalid vault mode", "mode must be one of create, import, skip.");
2035
+ }
2036
+
2037
+ // Skip path (hub#168 Cut 2): module is already installed (init.ts
2038
+ // ran `install vault --no-create`); we just persist a flag that
2039
+ // `deriveWizardState` consults to skip the vault step on subsequent
2040
+ // GETs. No supervisor work, no op_id — runs without the supervisor.
2041
+ if (rawMode === "skip") {
2042
+ setSetting(deps.db, "setup_vault_skipped", "true");
2043
+ if (form.isJson) {
2044
+ return jsonOkResponse({ step: "expose", message: "vault step skipped" });
2045
+ }
2046
+ return redirect("/admin/setup");
2047
+ }
2048
+
2049
+ // Operator picked create or import — if they previously skipped (in
2050
+ // another tab / via back button), the skip flag would still claim
2051
+ // "vault step done" even after the vault row appears. Clear it
2052
+ // defensively so `deriveWizardState` consults the real vault entry
2053
+ // going forward.
2054
+ deleteSetting(deps.db, "setup_vault_skipped");
2055
+
2056
+ // Create / import paths need the supervisor — they spawn vault and
2057
+ // (for import) call vault's mirror endpoint. The CLI hub surface
2058
+ // doesn't wire a supervisor; operators are expected to use
2059
+ // `parachute install vault` directly there. Container/serve-mode
2060
+ // hub has one.
2061
+ if (!deps.supervisor) {
2062
+ if (form.isJson) {
2063
+ return jsonErrorResponse(
2064
+ 503,
2065
+ "Module supervisor unavailable",
2066
+ "The wizard's create/import paths need container-mode `parachute serve` to spawn vault. " +
2067
+ "On the on-box CLI surface, run `parachute install vault` first, then re-run the wizard with --vault-mode skip.",
2068
+ );
2069
+ }
2070
+ return badRequestPage(
2071
+ "Module supervisor unavailable",
2072
+ "The first-boot wizard needs container-mode `parachute serve` to install modules. " +
2073
+ "On the on-box CLI surface, run `parachute install vault` directly.",
2074
+ );
2075
+ }
1510
2076
 
1511
2077
  // hub#267: the operator-typed vault name is now threaded all the way
1512
2078
  // through to vault's first-boot via `PARACHUTE_VAULT_NAME` (vault#342
@@ -1521,6 +2087,9 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1521
2087
  } else {
1522
2088
  const v = validateVaultName(rawName);
1523
2089
  if (!v.ok) {
2090
+ if (form.isJson) {
2091
+ return jsonErrorResponse(400, "Invalid vault name", v.error);
2092
+ }
1524
2093
  return htmlResponse(
1525
2094
  renderVaultStep({
1526
2095
  csrfToken: csrfTokenStr,
@@ -1532,6 +2101,51 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1532
2101
  }
1533
2102
  vaultName = v.name;
1534
2103
  }
2104
+
2105
+ // Import path (hub#168 Cut 2): collect the remote URL + optional PAT
2106
+ // + replace flag up front so a malformed input fails fast before we
2107
+ // spawn the vault. The actual import POST to vault's
2108
+ // `/vault/<name>/.parachute/mirror/import` happens AFTER vault has
2109
+ // come up under the supervisor; the params are captured by closure
2110
+ // into the post-install `.then()` (see `importToRun` below).
2111
+ let importParams: { remoteUrl: string; pat?: string; mode: "merge" | "replace" } | undefined;
2112
+ if (rawMode === "import") {
2113
+ const remoteUrl = String(form.get("remote_url") ?? "").trim();
2114
+ if (remoteUrl === "") {
2115
+ if (form.isJson) {
2116
+ return jsonErrorResponse(
2117
+ 400,
2118
+ "Remote URL required",
2119
+ 'remote_url must be a non-empty HTTPS or SSH clone URL when mode="import".',
2120
+ );
2121
+ }
2122
+ return htmlResponse(
2123
+ renderVaultStep({
2124
+ csrfToken: csrfTokenStr,
2125
+ vaultName: rawName,
2126
+ errorMessage: "Remote URL is required to import a vault. Paste a git clone URL.",
2127
+ }),
2128
+ 400,
2129
+ );
2130
+ }
2131
+ const importMode = String(form.get("import_mode") ?? "merge").trim();
2132
+ if (importMode !== "merge" && importMode !== "replace") {
2133
+ const err = `import_mode must be "merge" or "replace" (got "${importMode}").`;
2134
+ if (form.isJson) {
2135
+ return jsonErrorResponse(400, "Invalid import_mode", err);
2136
+ }
2137
+ return htmlResponse(
2138
+ renderVaultStep({ csrfToken: csrfTokenStr, vaultName: rawName, errorMessage: err }),
2139
+ 400,
2140
+ );
2141
+ }
2142
+ const pat = String(form.get("pat") ?? "").trim();
2143
+ importParams = {
2144
+ remoteUrl,
2145
+ mode: importMode,
2146
+ ...(pat ? { pat } : {}),
2147
+ };
2148
+ }
1535
2149
  // Persist for the done-step renderer. Vault overwrites services.json
1536
2150
  // on its first authoritative boot, but until that completes the wizard
1537
2151
  // needs a stable source of truth for the typed name — both for the
@@ -1564,8 +2178,14 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1564
2178
  { status: "succeeded" },
1565
2179
  `${FIRST_VAULT_SHORT} already supervised (status=${supervisorState.status})`,
1566
2180
  );
2181
+ if (form.isJson) {
2182
+ return jsonOkResponse({ op_id: op.id, step: "vault", message: "vault already supervised" });
2183
+ }
1567
2184
  return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
1568
2185
  }
2186
+ if (form.isJson) {
2187
+ return jsonOkResponse({ step: "vault", message: "vault already supervised" });
2188
+ }
1569
2189
  return redirect("/admin/setup");
1570
2190
  }
1571
2191
 
@@ -1588,6 +2208,17 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1588
2208
  if (vaultName !== DEFAULT_VAULT_NAME) {
1589
2209
  spawnEnv.PARACHUTE_VAULT_NAME = vaultName;
1590
2210
  }
2211
+ // Capture importParams + deps in the runInstall promise chain — when
2212
+ // mode === "import", run the vault-side `/.parachute/mirror/import`
2213
+ // POST as a follow-up step once the supervised vault has come up
2214
+ // and confirmed healthy. The hub-side op_id stays the same so the
2215
+ // CLI / browser sees a single progress stream; we just append more
2216
+ // log lines while the import runs. On import error, the op is
2217
+ // marked failed so the caller surfaces a usable message.
2218
+ const importToRun = importParams;
2219
+ const vaultIssuer = deps.issuer;
2220
+ const importerUserId = session.userId;
2221
+ const vaultPort = vaultSpec.seedEntry?.().port ?? 1940;
1591
2222
  void runInstall(op.id, FIRST_VAULT_SHORT, vaultSpec, {
1592
2223
  db: deps.db,
1593
2224
  issuer: deps.issuer,
@@ -1596,11 +2227,63 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1596
2227
  supervisor: deps.supervisor,
1597
2228
  registry,
1598
2229
  ...(deps.run ? { run: deps.run } : {}),
2230
+ ...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
1599
2231
  ...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
1600
- }).catch((err) => {
1601
- const msg = err instanceof Error ? err.message : String(err);
1602
- registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
1603
- });
2232
+ })
2233
+ .then(async () => {
2234
+ if (!importToRun) return;
2235
+ const opState = registry.get(op.id);
2236
+ if (!opState || opState.status !== "succeeded") return;
2237
+ // Import is a follow-up step: mark op back to running, POST to
2238
+ // vault, surface the result in the op log.
2239
+ registry.update(
2240
+ op.id,
2241
+ { status: "running" },
2242
+ `vault up — starting import from ${importToRun.remoteUrl} (mode=${importToRun.mode})`,
2243
+ );
2244
+ try {
2245
+ // Mint a short-lived per-vault admin Bearer for the import POST.
2246
+ // Vault validates audience `vault.<name>` + scope `vault:<name>:admin`
2247
+ // (see admin-vault-admin-token.ts for the canonical shape — same
2248
+ // contract the SPA Manage link uses). The token only needs to
2249
+ // live until vault accepts the HTTP request (the clone itself
2250
+ // happens inside vault after the auth check passes); 5 min is
2251
+ // a generous safety net covering the supervisor's boot-grace
2252
+ // retries on a sluggish host. Deliberate divergence from the
2253
+ // SPA's 10-min TTL because this token is one-shot, not refreshed.
2254
+ const minted = await signAccessToken(deps.db, {
2255
+ sub: importerUserId,
2256
+ scopes: [`vault:${vaultName}:admin`],
2257
+ audience: `vault.${vaultName}`,
2258
+ clientId: "parachute-hub-setup-wizard",
2259
+ issuer: vaultIssuer,
2260
+ ttlSeconds: 5 * 60,
2261
+ vaultScope: [vaultName],
2262
+ });
2263
+ const result = await postVaultImportImpl({
2264
+ vaultName,
2265
+ vaultPort,
2266
+ bearerToken: minted.token,
2267
+ remoteUrl: importToRun.remoteUrl,
2268
+ mode: importToRun.mode,
2269
+ ...(importToRun.pat ? { pat: importToRun.pat } : {}),
2270
+ });
2271
+ registry.update(
2272
+ op.id,
2273
+ { status: "succeeded" },
2274
+ `import succeeded — notes_imported=${result.notes_imported ?? 0}, tags_imported=${
2275
+ result.tags_imported ?? 0
2276
+ }, attachments_imported=${result.attachments_imported ?? 0}`,
2277
+ );
2278
+ } catch (err) {
2279
+ const msg = err instanceof Error ? err.message : String(err);
2280
+ registry.update(op.id, { status: "failed", error: msg }, `import failed: ${msg}`);
2281
+ }
2282
+ })
2283
+ .catch((err) => {
2284
+ const msg = err instanceof Error ? err.message : String(err);
2285
+ registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
2286
+ });
1604
2287
  } else {
1605
2288
  // No registry wired (test-only path; production always passes one).
1606
2289
  // Log a visible warning so future mis-wirings are debuggable —
@@ -1609,7 +2292,281 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1609
2292
  "[setup-wizard] handleSetupVaultPost called with no operations registry — install will NOT run. Wire deps.registry in the dispatcher.",
1610
2293
  );
1611
2294
  }
1612
- return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
2295
+ // Scribe sub-form fold (2026-05-27). The vault step's form lets
2296
+ // the operator answer "do you also want voice transcription?" +
2297
+ // "do you also want LLM cleanup?" in the same submission. If they
2298
+ // asked for either, we (a) write the chosen provider(s) + API
2299
+ // key(s) to `~/.parachute/scribe/config.json` so scribe finds
2300
+ // them on first boot, and (b) kick a scribe install op in
2301
+ // parallel with vault install. The vault op-poll page threads the
2302
+ // scribe op_id through its success-redirect so the done step can
2303
+ // poll scribe progress via the existing per-tile mechanism.
2304
+ //
2305
+ // Cleanup-without-transcribe is a valid combo: the operator can
2306
+ // hit scribe's REST cleanup endpoint directly with their own raw
2307
+ // text. We install scribe + write the cleanup block in that case.
2308
+ const scribeProvider = String(form.get("scribe_provider") ?? "").trim();
2309
+ const scribeCleanupProvider = String(form.get("scribe_cleanup_provider") ?? "").trim();
2310
+ const wantsTranscribe = scribeProvider !== "" && scribeProvider !== "none";
2311
+ const wantsCleanup = scribeCleanupProvider !== "" && scribeCleanupProvider !== "none";
2312
+ let scribeOpId: string | undefined;
2313
+ if (wantsTranscribe || wantsCleanup) {
2314
+ const scribeApiKey = String(form.get("scribe_api_key") ?? "").trim();
2315
+ const scribeCleanupApiKey = String(form.get("scribe_cleanup_api_key") ?? "").trim();
2316
+ // Write scribe config FIRST so scribe's first boot picks up the
2317
+ // provider(s) + key(s) without a second config edit. We don't
2318
+ // fail the wizard on a config-write error — log it + carry on;
2319
+ // scribe will boot with defaults + the operator can fix via
2320
+ // /scribe/admin.
2321
+ try {
2322
+ writeScribeConfigForWizard(deps.configDir, {
2323
+ ...(wantsTranscribe
2324
+ ? { transcribe: { provider: scribeProvider, apiKey: scribeApiKey } }
2325
+ : {}),
2326
+ ...(wantsCleanup
2327
+ ? { cleanup: { provider: scribeCleanupProvider, apiKey: scribeCleanupApiKey } }
2328
+ : {}),
2329
+ });
2330
+ } catch (err) {
2331
+ console.warn(
2332
+ `[setup-wizard] failed to write scribe config: ${err instanceof Error ? err.message : String(err)} — kicking install anyway, operator can configure later.`,
2333
+ );
2334
+ }
2335
+ // Kick scribe install in parallel. Don't block on it; the done
2336
+ // step's per-tile op-poll surfaces progress.
2337
+ if (registry) {
2338
+ const scribeSpec = specFor("scribe");
2339
+ const scribeOp = registry.create("install", "scribe");
2340
+ scribeOpId = scribeOp.id;
2341
+ void runInstall(scribeOp.id, "scribe", scribeSpec, {
2342
+ db: deps.db,
2343
+ issuer: deps.issuer,
2344
+ manifestPath: deps.manifestPath,
2345
+ configDir: deps.configDir,
2346
+ supervisor: deps.supervisor,
2347
+ registry,
2348
+ ...(deps.run ? { run: deps.run } : {}),
2349
+ ...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
2350
+ }).catch((err) => {
2351
+ const msg = err instanceof Error ? err.message : String(err);
2352
+ registry.update(
2353
+ scribeOp.id,
2354
+ { status: "failed", error: msg },
2355
+ `scribe install failed: ${msg}`,
2356
+ );
2357
+ });
2358
+ }
2359
+ }
2360
+ const redirectUrl = scribeOpId
2361
+ ? `/admin/setup?op=${encodeURIComponent(op.id)}&op_scribe=${encodeURIComponent(scribeOpId)}`
2362
+ : `/admin/setup?op=${encodeURIComponent(op.id)}`;
2363
+ if (form.isJson) {
2364
+ return jsonOkResponse({
2365
+ op_id: op.id,
2366
+ ...(scribeOpId ? { scribe_op_id: scribeOpId } : {}),
2367
+ step: "vault",
2368
+ mode: rawMode,
2369
+ });
2370
+ }
2371
+ return redirect(redirectUrl);
2372
+ }
2373
+
2374
+ /**
2375
+ * POST the wizard-collected import params to vault's
2376
+ * `/vault/<name>/.parachute/mirror/import` endpoint. The caller mints
2377
+ * the per-vault admin Bearer (see `signAccessToken` use in the
2378
+ * `runInstall().then(...)` block above) and passes it in; vault gates
2379
+ * the endpoint on `vault:<name>:admin` upstream. Returns vault's
2380
+ * structured response or throws with a usable message.
2381
+ *
2382
+ * Lives in setup-wizard.ts (not as a vault-internal helper) because
2383
+ * vault doesn't import hub-internal code; the import POST is naturally
2384
+ * the wizard's job — it's the only caller until vault ships its own
2385
+ * admin SPA flow. Shape mirrors vault#390's contract:
2386
+ * POST /vault/<name>/.parachute/mirror/import
2387
+ * { remote_url, mode: "merge"|"replace", credentials: {kind, token}|null }
2388
+ * 200 { notes_imported, tags_imported, attachments_imported, warnings }
2389
+ *
2390
+ * Exported (with the `Impl` suffix) so tests can inject a stub fetcher
2391
+ * and assert the Authorization header without standing up a real vault.
2392
+ */
2393
+ export async function postVaultImportImpl(args: {
2394
+ vaultName: string;
2395
+ vaultPort: number;
2396
+ bearerToken: string;
2397
+ remoteUrl: string;
2398
+ mode: "merge" | "replace";
2399
+ pat?: string;
2400
+ fetcher?: typeof fetch;
2401
+ }): Promise<{
2402
+ notes_imported?: number;
2403
+ tags_imported?: number;
2404
+ attachments_imported?: number;
2405
+ warnings?: readonly string[];
2406
+ }> {
2407
+ const fetcher = args.fetcher ?? fetch;
2408
+ // Vault listens on its supervised port — talk directly to 127.0.0.1
2409
+ // rather than going through hub's path-routing proxy. Cuts one
2410
+ // network hop and avoids the operator-session/CSRF dance.
2411
+ const url = `http://127.0.0.1:${args.vaultPort}/vault/${encodeURIComponent(args.vaultName)}/.parachute/mirror/import`;
2412
+ const body: Record<string, unknown> = {
2413
+ remote_url: args.remoteUrl,
2414
+ mode: args.mode,
2415
+ };
2416
+ if (args.pat) {
2417
+ body.credentials = { kind: "pat", token: args.pat };
2418
+ } else {
2419
+ body.credentials = null;
2420
+ }
2421
+ // Best-effort retry — the supervisor's `start` returns before vault
2422
+ // accepts traffic; a tiny grace window covers the boot lag without
2423
+ // a tight poll loop. Three attempts spaced 1s apart.
2424
+ let lastErr: Error | undefined;
2425
+ for (let attempt = 0; attempt < 5; attempt++) {
2426
+ try {
2427
+ const res = await fetcher(url, {
2428
+ method: "POST",
2429
+ headers: {
2430
+ "content-type": "application/json",
2431
+ // Vault's `authenticateVaultRequest` rejects 401 before scope
2432
+ // check when no Bearer is present. The token must carry
2433
+ // `vault:<name>:admin` + audience `vault.<name>` — minted at
2434
+ // the call site via `signAccessToken` so this function stays
2435
+ // pure (no db / userId capture).
2436
+ authorization: `Bearer ${args.bearerToken}`,
2437
+ },
2438
+ body: JSON.stringify(body),
2439
+ });
2440
+ if (res.status === 200) {
2441
+ return (await res.json()) as Awaited<ReturnType<typeof postVaultImportImpl>>;
2442
+ }
2443
+ // Vault returns structured JSON errors per mirror-routes.ts:
2444
+ // 400 (validation), 409 (concurrent), 502 (clone failed), 500.
2445
+ const errBody = (await res.json().catch(() => ({}))) as { message?: string; error?: string };
2446
+ throw new Error(
2447
+ `vault import returned ${res.status}: ${errBody.message ?? errBody.error ?? "unknown"}`,
2448
+ );
2449
+ } catch (err) {
2450
+ lastErr = err instanceof Error ? err : new Error(String(err));
2451
+ // ECONNREFUSED / fetch failure → vault hasn't bound yet. Retry.
2452
+ if (lastErr.message.includes("ECONNREFUSED") || lastErr.message.includes("Failed to fetch")) {
2453
+ await new Promise((resolve) => setTimeout(resolve, 1000));
2454
+ continue;
2455
+ }
2456
+ throw lastErr;
2457
+ }
2458
+ }
2459
+ throw lastErr ?? new Error("vault import: exhausted retries");
2460
+ }
2461
+
2462
+ /**
2463
+ * Write a minimal scribe config that selects the operator's chosen
2464
+ * transcribe + cleanup providers + API keys (when applicable).
2465
+ * Idempotent: reads any existing config, merges, writes back. File
2466
+ * mode 0o600 — the config holds API keys, owner-only.
2467
+ *
2468
+ * Lives in setup-wizard.ts (not scribe's own config-write.ts) because
2469
+ * (a) it's a one-time wizard write — the SPA's PUT /.parachute/config
2470
+ * surface is the canonical post-setup path, and (b) hub doesn't
2471
+ * import scribe-internal modules. The shape of `scribe-config.json`
2472
+ * is documented in parachute-scribe/src/config.ts; the fields we set
2473
+ * (`transcribe.provider`, `transcribeProviders.<name>.apiKey`,
2474
+ * `cleanup.provider`, `cleanup.default`, `cleanupProviders.<name>.apiKey`)
2475
+ * are stable. Cleanup block extended 2026-05-27 — scribe boots with
2476
+ * `cleanup: none` otherwise, so first-install operators got "raw
2477
+ * transcript only" until they hand-edited the config.
2478
+ *
2479
+ * Signature changed 2026-05-27 from `(configDir, provider, apiKey)` to
2480
+ * the options-object shape so the caller can express "cleanup only,
2481
+ * no transcribe" without smuggling sentinel strings.
2482
+ */
2483
+ interface WizardScribeConfig {
2484
+ /** Set when the operator chose a transcription provider (anything other than "none"). */
2485
+ transcribe?: { provider: string; apiKey: string };
2486
+ /** Set when the operator chose a cleanup provider (anything other than "none"). */
2487
+ cleanup?: { provider: string; apiKey: string };
2488
+ }
2489
+ function writeScribeConfigForWizard(configDir: string, config: WizardScribeConfig): void {
2490
+ const update: Record<string, unknown> = {};
2491
+
2492
+ if (config.transcribe) {
2493
+ const { provider, apiKey } = config.transcribe;
2494
+ // For `local` (Mac MLX / cross-platform ONNX), just set the
2495
+ // provider name — no key needed.
2496
+ if (provider === "local") {
2497
+ update.transcribe = { provider: "parakeet-mlx" };
2498
+ } else {
2499
+ // Cloud providers need a key. Empty key → just set provider;
2500
+ // the operator can paste the key later via /scribe/admin
2501
+ // without a restart (per provider-config.ts's per-request
2502
+ // precedence).
2503
+ update.transcribe = { provider };
2504
+ if (apiKey !== "") {
2505
+ update.transcribeProviders = { [provider]: { apiKey } };
2506
+ }
2507
+ }
2508
+ }
2509
+
2510
+ if (config.cleanup) {
2511
+ const { provider, apiKey } = config.cleanup;
2512
+ // Always set `cleanup.default: true` when the operator opted in to
2513
+ // cleanup — they want polished output as the default; the per-
2514
+ // request `cleanup` flag on each transcribe request can still
2515
+ // opt out individually.
2516
+ update.cleanup = { provider, default: true };
2517
+ // `claude-code` (host CLI auth) and `ollama` (local server)
2518
+ // don't need an API key. Everything else (anthropic, openai,
2519
+ // groq, gemini) takes a key. Empty key → just set the provider;
2520
+ // the operator can paste the key later via the admin SPA without
2521
+ // a restart.
2522
+ const needsKey = provider !== "claude-code" && provider !== "ollama";
2523
+ if (needsKey && apiKey !== "") {
2524
+ update.cleanupProviders = { [provider]: { apiKey } };
2525
+ }
2526
+ }
2527
+
2528
+ if (Object.keys(update).length === 0) return;
2529
+ persistScribeConfig(configDir, update);
2530
+ }
2531
+
2532
+ /**
2533
+ * Merge-write to scribe's config file at `<configDir>/scribe/config.json`.
2534
+ * Reads existing JSON when present, deep-merges `update`, writes back at
2535
+ * mode 0o600. Creates the parent dir if missing.
2536
+ */
2537
+ function persistScribeConfig(configDir: string, update: Record<string, unknown>): void {
2538
+ const scribeDir = join(configDir, "scribe");
2539
+ const configPath = join(scribeDir, "config.json");
2540
+ mkdirSync(scribeDir, { recursive: true });
2541
+ let existing: Record<string, unknown> = {};
2542
+ if (existsSync(configPath)) {
2543
+ try {
2544
+ existing = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
2545
+ } catch {
2546
+ // Malformed existing config — treat as empty + overwrite.
2547
+ existing = {};
2548
+ }
2549
+ }
2550
+ // Shallow merge at top level, deep merge for the sub-blocks we touch
2551
+ // (transcribe + transcribeProviders + cleanup + cleanupProviders). The
2552
+ // merge logic is generic and handles any nested object — it doesn't
2553
+ // hard-code the block names.
2554
+ const merged: Record<string, unknown> = { ...existing };
2555
+ for (const [key, value] of Object.entries(update)) {
2556
+ if (
2557
+ typeof value === "object" &&
2558
+ value !== null &&
2559
+ !Array.isArray(value) &&
2560
+ typeof merged[key] === "object" &&
2561
+ merged[key] !== null &&
2562
+ !Array.isArray(merged[key])
2563
+ ) {
2564
+ merged[key] = { ...(merged[key] as Record<string, unknown>), ...value };
2565
+ } else {
2566
+ merged[key] = value;
2567
+ }
2568
+ }
2569
+ writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
1613
2570
  }
1614
2571
 
1615
2572
  /**
@@ -1633,13 +2590,19 @@ export async function handleSetupExposePost(
1633
2590
  req: Request,
1634
2591
  deps: SetupWizardDeps,
1635
2592
  ): Promise<Response> {
1636
- const form = await req.formData();
2593
+ const form = await readBodyFields(req);
1637
2594
  const formCsrf = form.get(CSRF_FIELD_NAME);
1638
2595
  if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
2596
+ if (form.isJson) {
2597
+ return jsonErrorResponse(400, "Invalid form submission", "Reload and try again.");
2598
+ }
1639
2599
  return badRequestPage("Invalid form submission", "Reload and try again.");
1640
2600
  }
1641
2601
  const session = findActiveSession(deps.db, req);
1642
2602
  if (!session) {
2603
+ if (form.isJson) {
2604
+ return jsonErrorResponse(401, "No admin session", "Sign in to continue setup.");
2605
+ }
1643
2606
  return badRequestPage(
1644
2607
  "No admin session",
1645
2608
  "Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
@@ -1649,10 +2612,20 @@ export async function handleSetupExposePost(
1649
2612
  // the wizard's GET shape catches this case too, but a direct POST
1650
2613
  // (curl, tab race) shouldn't double-fire the auto-approve window.
1651
2614
  if (getSetting(deps.db, "setup_expose_mode") !== undefined) {
2615
+ if (form.isJson) {
2616
+ return jsonOkResponse({ step: "done", message: "expose mode already set" });
2617
+ }
1652
2618
  return redirect("/admin/setup?just_finished=1");
1653
2619
  }
1654
2620
  const rawMode = form.get("expose_mode");
1655
2621
  if (!isSetupExposeMode(rawMode)) {
2622
+ if (form.isJson) {
2623
+ return jsonErrorResponse(
2624
+ 400,
2625
+ "Invalid expose_mode",
2626
+ `Pick one of: ${SETUP_EXPOSE_MODES.join(", ")}.`,
2627
+ );
2628
+ }
1656
2629
  return htmlResponse(
1657
2630
  renderExposeStep({
1658
2631
  csrfToken: typeof formCsrf === "string" ? formCsrf : "",
@@ -1678,12 +2651,14 @@ export async function handleSetupExposePost(
1678
2651
  // registry so revocation via the admin UI works as usual. Failures
1679
2652
  // are non-fatal: the done page falls back to the un-headered MCP
1680
2653
  // command + a "mint manually at /admin/tokens" hint.
2654
+ let mintedTokenForJson: string | undefined;
1681
2655
  try {
1682
2656
  const minted = await mintOperatorToken(deps.db, session.userId, {
1683
2657
  issuer: deps.issuer,
1684
2658
  scopeSet: "admin",
1685
2659
  });
1686
2660
  setSetting(deps.db, "setup_minted_token", minted.token);
2661
+ mintedTokenForJson = minted.token;
1687
2662
  console.log(
1688
2663
  `[setup-wizard] auto-minted operator token (jti=${minted.jti}, scope-set=admin) for done-screen MCP command`,
1689
2664
  );
@@ -1691,6 +2666,13 @@ export async function handleSetupExposePost(
1691
2666
  const msg = err instanceof Error ? err.message : String(err);
1692
2667
  console.warn(`[setup-wizard] failed to auto-mint operator token: ${msg}`);
1693
2668
  }
2669
+ if (form.isJson) {
2670
+ return jsonOkResponse({
2671
+ step: "done",
2672
+ message: "expose mode set",
2673
+ ...(mintedTokenForJson ? { minted_token: mintedTokenForJson } : {}),
2674
+ });
2675
+ }
1694
2676
  return redirect("/admin/setup?just_finished=1");
1695
2677
  }
1696
2678
 
@@ -1710,11 +2692,6 @@ const INSTALL_TILE_PROPS: ReadonlyArray<{
1710
2692
  displayName: string;
1711
2693
  tagline: string;
1712
2694
  }> = [
1713
- {
1714
- short: "app",
1715
- displayName: "App",
1716
- tagline: "Host module for Parachute UIs — auto-installs Notes on first boot.",
1717
- },
1718
2695
  {
1719
2696
  short: "scribe",
1720
2697
  displayName: "Scribe",
@@ -1844,6 +2821,7 @@ export async function handleSetupInstallPost(
1844
2821
  supervisor: deps.supervisor,
1845
2822
  registry,
1846
2823
  ...(deps.run ? { run: deps.run } : {}),
2824
+ ...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
1847
2825
  }).catch((err) => {
1848
2826
  const msg = err instanceof Error ? err.message : String(err);
1849
2827
  registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
@@ -1880,11 +2858,10 @@ function validateAccountFields(input: {
1880
2858
 
1881
2859
  /**
1882
2860
  * Whether a given curated module is currently installed (has a row in
1883
- * services.json keyed by its canonical `manifestName`). Used by the
1884
- * done-step renderer (hub#342) to decide whether to point the "Start
1885
- * using your vault" tile at `/app/notes/` (App installed Notes UI
1886
- * auto-bootstrapped) vs the vault's own admin UI. Cheap manifest read
1887
- * shared with `buildInstallTiles`.
2861
+ * services.json keyed by its canonical `manifestName`). Used by
2862
+ * `buildInstallTiles` to decide whether an install-tile row renders
2863
+ * the "install" form or the "already installed" state. Cheap manifest
2864
+ * read (no network).
1888
2865
  */
1889
2866
  function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boolean {
1890
2867
  const manifest = readManifestLenient(manifestPath);
@@ -1893,17 +2870,21 @@ function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boo
1893
2870
  }
1894
2871
 
1895
2872
  /**
1896
- * Read the first vault's display name from services.json for the
1897
- * step-4 success page. Falls back to "default" if for any reason the
1898
- * entry's metadata isn't present.
2873
+ * Read the first vault's display name from services.json. Returns
2874
+ * null when services.json has no vault entry or the entry has no
2875
+ * `/vault/<name>` path used by the done step to detect "no live
2876
+ * vault, fall back to the operator-typed value." Distinguishing
2877
+ * "no live vault" from "live vault named default" matters: the
2878
+ * former should defer to the DB-cached name; the latter should
2879
+ * win over a possibly-stale DB cache (smoke 2026-05-27 finding 2).
1899
2880
  */
1900
- function firstVaultName(manifestPath: string): string {
2881
+ function firstVaultNameOrNull(manifestPath: string): string | null {
1901
2882
  const manifest = readManifestLenient(manifestPath);
1902
2883
  // Match on the canonical vault manifestName from the curated spec.
1903
2884
  // (`CURATED_MODULES.includes("vault")` was a dead guard — vault is a
1904
2885
  // tuple-literal member, so the conjunct is always true.)
1905
2886
  const entry = manifest.services.find((s) => s.name === specFor("vault").manifestName);
1906
- if (!entry) return "default";
2887
+ if (!entry) return null;
1907
2888
  // services.json entries store the mount path (e.g. `/vault/default`).
1908
2889
  // Strip the canonical prefix to surface the display name.
1909
2890
  for (const p of entry.paths ?? []) {
@@ -1912,7 +2893,7 @@ function firstVaultName(manifestPath: string): string {
1912
2893
  if (tail.length > 0) return tail;
1913
2894
  }
1914
2895
  }
1915
- return "default";
2896
+ return null;
1916
2897
  }
1917
2898
 
1918
2899
  function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
@@ -1926,6 +2907,32 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
1926
2907
  return new Response(null, { status: 303, headers: { location, ...extra } });
1927
2908
  }
1928
2909
 
2910
+ /**
2911
+ * Structured JSON-200 helper for the CLI wizard surface (hub#168 Cut 3).
2912
+ * Mirrors the browser-redirect responses' header shape (extra cookies
2913
+ * pass through) without the 303 status that would force the CLI's
2914
+ * `fetch` to chase a non-existent location.
2915
+ */
2916
+ function jsonOkResponse(body: unknown, extra: Record<string, string> = {}): Response {
2917
+ return new Response(JSON.stringify(body), {
2918
+ status: 200,
2919
+ headers: { "content-type": "application/json; charset=utf-8", ...extra },
2920
+ });
2921
+ }
2922
+
2923
+ /**
2924
+ * Structured JSON-error helper for the CLI wizard surface (hub#168 Cut 3).
2925
+ * The browser path renders a full HTML error page; the CLI wants a
2926
+ * machine-parseable envelope with the same fields the rendered page
2927
+ * shows. Status code is the same as the HTML branch (400/401/410/etc).
2928
+ */
2929
+ function jsonErrorResponse(status: number, title: string, message: string): Response {
2930
+ return new Response(JSON.stringify({ error: title, message, status }), {
2931
+ status,
2932
+ headers: { "content-type": "application/json; charset=utf-8" },
2933
+ });
2934
+ }
2935
+
1929
2936
  function badRequestPage(title: string, message: string): Response {
1930
2937
  return htmlResponse(renderBadRequestPage(title, message), 400);
1931
2938
  }
@@ -2416,6 +3423,55 @@ const STYLES = `
2416
3423
  }
2417
3424
  .done-tile .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; }
2418
3425
 
3426
+ /* Vault-mode picker (hub#168 Cut 2). Three-option radio block at the
3427
+ top of the vault step, plus a collapsible import-only sub-form
3428
+ below. Shape mirrors .expose-option for visual consistency. */
3429
+ .vault-mode-block, .vault-import-block {
3430
+ border: 1px solid ${PALETTE.border};
3431
+ border-radius: 8px;
3432
+ padding: 0.75rem 0.9rem;
3433
+ margin: 0.4rem 0;
3434
+ background: ${PALETTE.cardBg};
3435
+ display: flex;
3436
+ flex-direction: column;
3437
+ gap: 0.6rem;
3438
+ }
3439
+ .vault-mode-block legend, .vault-import-block legend {
3440
+ padding: 0 0.4rem;
3441
+ font-size: 0.85rem;
3442
+ color: ${PALETTE.fgMuted};
3443
+ font-family: ${FONT_MONO};
3444
+ }
3445
+ .vault-mode-option {
3446
+ display: flex;
3447
+ align-items: flex-start;
3448
+ gap: 0.6rem;
3449
+ padding: 0.6rem 0.8rem;
3450
+ border: 1px solid ${PALETTE.borderLight};
3451
+ border-radius: 6px;
3452
+ cursor: pointer;
3453
+ transition: border-color 0.15s ease, background 0.15s ease;
3454
+ }
3455
+ .vault-mode-option:hover { border-color: ${PALETTE.accent}; }
3456
+ .vault-mode-option input[type=radio] {
3457
+ margin-top: 0.25rem;
3458
+ accent-color: ${PALETTE.accent};
3459
+ flex-shrink: 0;
3460
+ }
3461
+ .vault-mode-title {
3462
+ font-weight: 600;
3463
+ color: ${PALETTE.fg};
3464
+ font-size: 0.95rem;
3465
+ margin-left: 0.3rem;
3466
+ }
3467
+ .vault-mode-desc {
3468
+ color: ${PALETTE.fgMuted};
3469
+ font-size: 0.85rem;
3470
+ line-height: 1.45;
3471
+ flex-basis: 100%;
3472
+ margin-left: 1.7rem;
3473
+ }
3474
+
2419
3475
  /* expose step (hub#268 Item 2). Vertical stack of radio cards;
2420
3476
  each label is the full clickable hit target. */
2421
3477
  .expose-form { gap: 0.65rem; }