@openparachute/hub 0.5.13 → 0.5.14-rc.2

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 (47) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +163 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules-ops.test.ts +97 -0
  8. package/src/__tests__/api-modules.test.ts +32 -32
  9. package/src/__tests__/api-users.test.ts +383 -11
  10. package/src/__tests__/chrome-strip.test.ts +15 -15
  11. package/src/__tests__/hub-db.test.ts +194 -29
  12. package/src/__tests__/hub-server.test.ts +23 -23
  13. package/src/__tests__/notes-redirect.test.ts +20 -20
  14. package/src/__tests__/oauth-handlers.test.ts +722 -28
  15. package/src/__tests__/serve.test.ts +9 -9
  16. package/src/__tests__/services-manifest.test.ts +40 -40
  17. package/src/__tests__/setup-wizard.test.ts +493 -25
  18. package/src/__tests__/setup.test.ts +1 -1
  19. package/src/__tests__/status.test.ts +39 -0
  20. package/src/__tests__/users.test.ts +396 -9
  21. package/src/__tests__/well-known.test.ts +9 -9
  22. package/src/account-home-ui.ts +434 -0
  23. package/src/admin-handlers.ts +49 -17
  24. package/src/admin-host-admin-token.ts +25 -0
  25. package/src/admin-vault-admin-token.ts +17 -0
  26. package/src/api-account.ts +72 -6
  27. package/src/api-modules-ops.ts +52 -16
  28. package/src/api-modules.ts +3 -3
  29. package/src/api-users.ts +468 -55
  30. package/src/bun-link.ts +55 -0
  31. package/src/chrome-strip.ts +6 -6
  32. package/src/commands/install.ts +8 -21
  33. package/src/commands/status.ts +10 -1
  34. package/src/help.ts +2 -2
  35. package/src/hub-db.ts +42 -0
  36. package/src/hub-server.ts +69 -10
  37. package/src/hub-settings.ts +2 -2
  38. package/src/hub.ts +6 -6
  39. package/src/notes-redirect.ts +5 -5
  40. package/src/oauth-handlers.ts +278 -173
  41. package/src/oauth-ui.ts +18 -2
  42. package/src/service-spec.ts +39 -18
  43. package/src/setup-wizard.ts +489 -42
  44. package/src/users.ts +307 -29
  45. package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
  46. package/web/ui/dist/index.html +1 -1
  47. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -38,6 +38,8 @@
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
45
  import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
@@ -221,6 +223,18 @@ export interface SetupWizardDeps {
221
223
  registry?: OperationsRegistry;
222
224
  /** Test seam: stub `bun add` / `bun remove` runner. */
223
225
  run?: (cmd: readonly string[]) => Promise<number>;
226
+ /**
227
+ * Test seam: stub the bun-link detection used by `runInstall` to
228
+ * short-circuit `bun add -g` when a package is already linked
229
+ * locally (smoke 2026-05-27 finding 1). Production omits this and
230
+ * the production detection at `src/bun-link.ts` probes the real
231
+ * filesystem. Tests that need to assert "bun add -g WAS called"
232
+ * pass `() => false`; tests asserting the skip path pass `() => true`.
233
+ *
234
+ * Threaded through to `ApiModulesOpsDeps.isLinked` on every
235
+ * `runInstall` call from the wizard.
236
+ */
237
+ isLinked?: (pkg: string) => boolean;
224
238
  /**
225
239
  * Test seam: override the process env that `detectAutoExposeMode`
226
240
  * consults. Production omits this and the helper reads `process.env`
@@ -449,6 +463,14 @@ export interface RenderVaultStepProps {
449
463
  errorMessage?: string;
450
464
  /** Pre-fill the vault name input after a validation failure. */
451
465
  vaultName?: string;
466
+ /**
467
+ * When the runtime is a hosted container (Render / Fly), the scribe
468
+ * sub-form hides the "local provider" option — Whisper / parakeet
469
+ * don't run usefully in the constrained container. Defaults to false
470
+ * (treat as self-host, show local option) — production wizard renders
471
+ * always pass an explicit value via detectAutoExposeMode.
472
+ */
473
+ cloudHost?: boolean;
452
474
  /**
453
475
  * When an install op is in progress, render the polling shape: no
454
476
  * form, just the op log + auto-refresh.
@@ -458,11 +480,18 @@ export interface RenderVaultStepProps {
458
480
  status: "pending" | "running" | "succeeded" | "failed";
459
481
  log: readonly string[];
460
482
  error?: string;
483
+ /**
484
+ * Optional scribe install op_id, threaded through so the success
485
+ * redirect carries `&op_scribe=<id>` and the done step picks up the
486
+ * in-flight scribe install via the existing per-tile op-poll
487
+ * mechanism (`buildInstallTiles` reads `op_<short>` query param).
488
+ */
489
+ scribeOpId?: string;
461
490
  };
462
491
  }
463
492
 
464
493
  export function renderVaultStep(props: RenderVaultStepProps): string {
465
- const { csrfToken, errorMessage, operation, vaultName } = props;
494
+ const { csrfToken, errorMessage, operation, vaultName, cloudHost } = props;
466
495
  if (operation) return renderVaultOpStep({ operation });
467
496
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
468
497
  // hub#267: the typed name now flows end-to-end via
@@ -523,12 +552,154 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
523
552
  <span class="field-hint">lowercase letters, digits, <code>-</code>, <code>_</code>;
524
553
  2–32 chars. Leave blank for <code>${DEFAULT_VAULT_NAME}</code>.</span>
525
554
  </label>
555
+ ${renderScribeSubForm(cloudHost === true)}
526
556
  <button type="submit" class="btn btn-primary">Create vault & finish</button>
527
557
  </form>
528
558
  </div>`;
529
559
  return baseDocument("Set up your Parachute hub — vault", body);
530
560
  }
531
561
 
562
+ /**
563
+ * Scribe install sub-form embedded in the vault step (folded in
564
+ * 2026-05-27 per Aaron's team-meeting directive: "folding the scribe
565
+ * question into the vault step is a good idea"). Operator answers
566
+ * scribe-related questions in the same form as vault name, the POST
567
+ * handler kicks both installs in parallel, and the done screen polls
568
+ * scribe's progress via the existing per-tile op-poll mechanism.
569
+ *
570
+ * The provider list adapts to the runtime context:
571
+ * - Cloud container (Render / Fly): local transcribers (parakeet,
572
+ * whisper) don't fit in 512MB + can't reach hardware acceleration.
573
+ * We hide them. Groq is the default (fast cloud Whisper, ~$0.04/hr
574
+ * of audio); OpenAI is the alternative.
575
+ * - Local (Mac / Linux): parakeet-mlx is the default on Mac (silicon
576
+ * MLX); falls back to onnx-asr cross-platform. Cloud providers
577
+ * stay available as choices for operators who'd rather pay than
578
+ * run local inference.
579
+ *
580
+ * The API key input shows conditionally — only when a cloud provider
581
+ * is selected. It's a plain text input (no `type=password`) because
582
+ * (a) the operator just pasted it from their provider's dashboard, and
583
+ * (b) showing it lets them verify they pasted correctly before submit.
584
+ * Mode-switching between providers via the radio is handled by an
585
+ * inline `<script>` block — no SPA bundle, no module deps.
586
+ *
587
+ * The "Skip — no transcription" option is third and unchecked by
588
+ * default. Most operators want voice transcription once they know
589
+ * they can; the default-on posture matches the auto-transcribe default
590
+ * flip that landed in vault#373.
591
+ */
592
+ function renderScribeSubForm(cloudHost: boolean): string {
593
+ const localBlock = cloudHost
594
+ ? ""
595
+ : `
596
+ <label class="scribe-provider-option">
597
+ <input type="radio" name="scribe_provider" value="local"${cloudHost ? "" : " checked"} data-needs-key="false" />
598
+ <span class="provider-name">Local <small>(Mac MLX or ONNX — no API key needed)</small></span>
599
+ </label>`;
600
+ const groqDefault = cloudHost ? " checked" : "";
601
+ // Cleanup providers that need a host-side binary or local server
602
+ // (claude-code → `claude` CLI + `claude setup-token`; ollama → local
603
+ // Ollama server) are hidden on cloud hosts (Render / Fly). The
604
+ // remaining cloud-friendly choices (anthropic / openai / groq /
605
+ // gemini) stay visible — they only need an API key.
606
+ const claudeCodeCleanupBlock = cloudHost
607
+ ? ""
608
+ : `
609
+ <label class="scribe-provider-option">
610
+ <input type="radio" name="scribe_cleanup_provider" value="claude-code" data-needs-key="false" />
611
+ <span class="provider-name">Claude Code <small>(subscription auth — run <code>claude setup-token</code> on this host)</small></span>
612
+ </label>`;
613
+ const ollamaCleanupBlock = cloudHost
614
+ ? ""
615
+ : `
616
+ <label class="scribe-provider-option">
617
+ <input type="radio" name="scribe_cleanup_provider" value="ollama" data-needs-key="false" />
618
+ <span class="provider-name">Ollama <small>(local LLM — requires Ollama running on this machine)</small></span>
619
+ </label>`;
620
+ return `
621
+ <details class="scribe-suboptions" open>
622
+ <summary class="cursor-pointer">
623
+ <span class="field-label">Enable voice transcription</span>
624
+ <span class="field-hint"> · Scribe installs alongside vault, transcribes audio attachments automatically</span>
625
+ </summary>
626
+ <div class="scribe-provider-block">
627
+ <p class="field-hint">Pick a transcription provider. You can change this later in <code>/admin/modules</code>.</p>
628
+ <div class="scribe-provider-list">
629
+ ${localBlock}
630
+ <label class="scribe-provider-option">
631
+ <input type="radio" name="scribe_provider" value="groq"${groqDefault} data-needs-key="true" />
632
+ <span class="provider-name">Groq <small>(~\$0.04/hr of audio, fast)</small></span>
633
+ </label>
634
+ <label class="scribe-provider-option">
635
+ <input type="radio" name="scribe_provider" value="openai" data-needs-key="true" />
636
+ <span class="provider-name">OpenAI Whisper <small>(~\$0.36/hr of audio)</small></span>
637
+ </label>
638
+ <label class="scribe-provider-option">
639
+ <input type="radio" name="scribe_provider" value="none" data-needs-key="false" />
640
+ <span class="provider-name">Skip — no transcription</span>
641
+ </label>
642
+ </div>
643
+ <label class="field scribe-api-key-field" data-shows-on="cloud">
644
+ <span class="field-label">API key</span>
645
+ <input type="password" name="scribe_api_key" autocomplete="off" placeholder="gsk_… or sk-…" />
646
+ <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>
647
+ </label>
648
+ <fieldset class="scribe-cleanup-block">
649
+ <legend class="field-label">Cleanup <small>(optional LLM polish pass on transcripts)</small></legend>
650
+ <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>
651
+ <div class="scribe-provider-list">
652
+ <label class="scribe-provider-option">
653
+ <input type="radio" name="scribe_cleanup_provider" value="none" checked data-needs-key="false" />
654
+ <span class="provider-name">Skip cleanup <small>(default — raw transcripts only)</small></span>
655
+ </label>
656
+ ${claudeCodeCleanupBlock}
657
+ <label class="scribe-provider-option">
658
+ <input type="radio" name="scribe_cleanup_provider" value="anthropic" data-needs-key="true" />
659
+ <span class="provider-name">Anthropic API <small>(needs ANTHROPIC_API_KEY)</small></span>
660
+ </label>
661
+ ${ollamaCleanupBlock}
662
+ <label class="scribe-provider-option">
663
+ <input type="radio" name="scribe_cleanup_provider" value="openai" data-needs-key="true" />
664
+ <span class="provider-name">OpenAI <small>(needs OPENAI_API_KEY)</small></span>
665
+ </label>
666
+ <label class="scribe-provider-option">
667
+ <input type="radio" name="scribe_cleanup_provider" value="groq" data-needs-key="true" />
668
+ <span class="provider-name">Groq <small>(needs GROQ_API_KEY)</small></span>
669
+ </label>
670
+ <label class="scribe-provider-option">
671
+ <input type="radio" name="scribe_cleanup_provider" value="gemini" data-needs-key="true" />
672
+ <span class="provider-name">Google Gemini <small>(needs GOOGLE_API_KEY)</small></span>
673
+ </label>
674
+ </div>
675
+ <label class="field scribe-cleanup-api-key-field" style="display: none;">
676
+ <span class="field-label">Cleanup API key</span>
677
+ <input type="password" name="scribe_cleanup_api_key" autocomplete="off" placeholder="sk-ant-… or sk-… or gsk-…" />
678
+ <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>
679
+ </label>
680
+ </fieldset>
681
+ </div>
682
+ </details>
683
+ <script>
684
+ (function () {
685
+ function toggle(radioName, keySelector) {
686
+ var radios = document.querySelectorAll('input[name="' + radioName + '"]');
687
+ var keyField = document.querySelector(keySelector);
688
+ function sync() {
689
+ var selected = document.querySelector('input[name="' + radioName + '"]:checked');
690
+ var needsKey = selected && selected.dataset.needsKey === "true";
691
+ if (keyField) keyField.style.display = needsKey ? "" : "none";
692
+ }
693
+ radios.forEach(function (r) { r.addEventListener("change", sync); });
694
+ sync();
695
+ }
696
+ toggle("scribe_provider", ".scribe-api-key-field");
697
+ toggle("scribe_cleanup_provider", ".scribe-cleanup-api-key-field");
698
+ })();
699
+ </script>
700
+ `;
701
+ }
702
+
532
703
  function renderVaultOpStep(props: {
533
704
  operation: NonNullable<RenderVaultStepProps["operation"]>;
534
705
  }): string {
@@ -567,7 +738,7 @@ function renderVaultOpStep(props: {
567
738
  </section>
568
739
  ${
569
740
  operation.status === "succeeded"
570
- ? '<meta http-equiv="refresh" content="1; url=/admin/setup?just_finished=1" />'
741
+ ? `<meta http-equiv="refresh" content="1; url=/admin/setup?just_finished=1${operation.scribeOpId ? `&op_scribe=${encodeURIComponent(operation.scribeOpId)}` : ""}" />`
571
742
  : ""
572
743
  }
573
744
  </div>`;
@@ -721,7 +892,7 @@ export interface RenderDoneStepProps {
721
892
  /**
722
893
  * Whether parachute-app is installed alongside the vault. Drives the
723
894
  * "Start using your vault" lead tile (hub#342): when true, the tile
724
- * links to `/app/notes/` (the canonical user-facing surface — App
895
+ * links to `/surface/notes/` (the canonical user-facing surface — App
725
896
  * auto-bootstraps Notes-as-UI per the 2026-05-21 migration). When
726
897
  * false, it falls back to the vault's own admin UI at
727
898
  * `/vault/<name>/admin/` so the operator still has a single obvious
@@ -738,7 +909,7 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
738
909
  const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
739
910
  const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
740
911
  const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
741
- const startTile = renderStartUsingTile(vaultName, appInstalled === true);
912
+ const startTile = renderStartUsingTile(vaultName, appInstalled === true, hubOrigin);
742
913
  // The done-grid hosts the MCP-connect tile + the admin-UI fallback.
743
914
  // The install tiles sit above it as a "what's next?" surface (curated
744
915
  // catalog of modules an operator might want next). The "Start using
@@ -762,6 +933,7 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
762
933
  </div>
763
934
  ${reachable}
764
935
  ${startTile}
936
+ ${renderStarterPromptsSection()}
765
937
  ${installSection}
766
938
  <section class="done-grid">
767
939
  ${mcpTile}
@@ -941,7 +1113,7 @@ function renderMcpTile(
941
1113
  * command, admin UI, additional module installs).
942
1114
  *
943
1115
  * Two shapes:
944
- * - **App installed** → primary tile targets `/app/notes/` (the
1116
+ * - **App installed** → primary tile targets `/surface/notes/` (the
945
1117
  * Notes app reading the just-created vault). This is the
946
1118
  * canonical surface post-Notes-as-app migration (parachute-app §17).
947
1119
  * - **App NOT installed** → primary tile targets the vault's own
@@ -953,26 +1125,98 @@ function renderMcpTile(
953
1125
  * "start using parachute" — not three competing tiles where the
954
1126
  * "real" entry point is buried under the MCP command pre-hub#342.
955
1127
  */
956
- function renderStartUsingTile(vaultName: string, appInstalled: boolean): string {
1128
+ /**
1129
+ * Lead "Start using your vault" tile. Points at the canonical
1130
+ * notes.parachute.computer hosted PWA as the primary CTA — with the
1131
+ * operator's own hub URL pre-filled via `?url=` so the connect screen
1132
+ * auto-populates + auto-focuses (notes-ui AddVault route, see
1133
+ * parachute-app/packages/notes-ui/src/app/routes/AddVault.tsx).
1134
+ *
1135
+ * Aaron 2026-05-27 directive: "skipping the local surface install for
1136
+ * most operators is good ... showing notes.parachute.computer more
1137
+ * prominently is a good idea." The notes.parachute.computer PWA is the
1138
+ * canonical user-facing UI; operators no longer need to install the
1139
+ * Surface module locally to use Notes. They still can (local install
1140
+ * works the same way), but the wizard doesn't push them toward it as
1141
+ * the default.
1142
+ *
1143
+ * Secondary CTA: "Open vault admin" (the vault's own admin UI on this
1144
+ * hub) for operators who want to look at raw vault state.
1145
+ *
1146
+ * `appInstalled` is no longer load-bearing for the primary path —
1147
+ * notes.parachute.computer works regardless of whether Surface is
1148
+ * installed locally. Kept in the signature so the older test fixtures
1149
+ * + the boolean flag stay coherent; only the secondary fallback message
1150
+ * differs based on it.
1151
+ */
1152
+ function renderStartUsingTile(
1153
+ vaultName: string,
1154
+ appInstalled: boolean,
1155
+ hubOrigin: string,
1156
+ ): string {
957
1157
  const safeVault = escapeHtml(vaultName);
958
1158
  // Vault names pass `/^[a-z0-9][a-z0-9-]*$/i` so URL-encoding is mostly
959
1159
  // a no-op today, but use encodeURIComponent defensively to match hub.ts:505.
960
1160
  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
- }
1161
+ // The `?url=` query param is consumed by notes-ui's AddVault route
1162
+ // (packages/notes-ui/src/app/routes/AddVault.tsx) — it pre-fills the
1163
+ // vault URL input + auto-focuses Submit.
1164
+ const vaultUrlForAdd = encodeURIComponent(
1165
+ `${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}`,
1166
+ );
1167
+ // For appInstalled=false case (Surface NOT installed locally),
1168
+ // notes.parachute.computer is the recommended path. For appInstalled=true,
1169
+ // we mention the local option as a secondary affordance.
1170
+ const localNotesFallback = appInstalled
1171
+ ? `<p class="start-using-secondary">
1172
+ <a href="/surface/notes/">Or use Notes installed locally on this hub →</a>
1173
+ </p>`
1174
+ : "";
969
1175
  return `<section class="start-using" data-testid="start-using-tile">
970
1176
  <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>
1177
+ <p>Open Notes — the canonical browser UI for your vault <code>${safeVault}</code>.
1178
+ It connects to your hub over HTTPS and remembers your URL after the first OAuth.</p>
1179
+ <p><a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}" target="_blank" rel="noopener">Open Notes ↗</a></p>
1180
+ <p class="start-using-secondary">
1181
+ <a href="/vault/${urlVault}/admin/">Or browse the vault's admin UI →</a>
1182
+ </p>
1183
+ ${localNotesFallback}
1184
+ </section>`;
1185
+ }
1186
+
1187
+ /**
1188
+ * Starter-prompts tile on the done screen. Surfaces the two
1189
+ * interview-style prompts hosted at parachute.computer:
1190
+ *
1191
+ * 1. "Help me set up my vault" — AI interviews the operator about
1192
+ * where their data lives + proposes a tag/path structure
1193
+ * (parachute.computer/onboarding/vault-setup/).
1194
+ * 2. "Build a custom UI" — AI builds a static SPA against the vault's
1195
+ * HTTP API, hosted on the operator's own GitHub Pages
1196
+ * (parachute.computer/onboarding/surface-build/).
1197
+ *
1198
+ * Aaron 2026-05-27 directive: ship these as the "first AI assist"
1199
+ * surface so freshly-onboarded operators have a clear next thing to
1200
+ * do beyond clicking around the admin UI. The prompts live on
1201
+ * parachute.computer rather than embedded in the wizard so they can
1202
+ * be iterated without a hub release; the wizard just links.
1203
+ */
1204
+ function renderStarterPromptsSection(): string {
1205
+ return `<section class="starter-prompts" data-testid="starter-prompts">
1206
+ <h2>Get help from your AI</h2>
1207
+ <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>
1208
+ <div class="starter-prompts-grid">
1209
+ <a class="starter-prompt-tile" href="https://parachute.computer/onboarding/vault-setup/" target="_blank" rel="noopener">
1210
+ <h3>Set up your vault</h3>
1211
+ <p>Interview-style. AI asks where your notes live now + proposes a tag &amp; path structure that fits how you actually think.</p>
1212
+ <p class="starter-prompt-cta">Open prompt ↗</p>
1213
+ </a>
1214
+ <a class="starter-prompt-tile" href="https://parachute.computer/onboarding/surface-build/" target="_blank" rel="noopener">
1215
+ <h3>Build a custom UI</h3>
1216
+ <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>
1217
+ <p class="starter-prompt-cta">Open prompt ↗</p>
1218
+ </a>
1219
+ </div>
976
1220
  </section>`;
977
1221
  }
978
1222
 
@@ -1079,7 +1323,7 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
1079
1323
  * surface decision.
1080
1324
  */
1081
1325
  const USE_IT_NOW_URLS: Partial<Record<CuratedModuleShort, string>> = {
1082
- app: "/app/notes/",
1326
+ surface: "/surface/notes/",
1083
1327
  notes: "/notes/",
1084
1328
  // Omitted: scribe + runner. They don't ship an admin SPA yet
1085
1329
  // (scribe#53, runner#8 track). Pointing "Use it now" at /scribe/admin
@@ -1215,23 +1459,34 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1215
1459
  // /admin/tokens.
1216
1460
  const mintedToken = getSetting(deps.db, "setup_minted_token");
1217
1461
  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.
1462
+ // Prefer the LIVE vault name from services.json over the
1463
+ // operator-typed value cached in hub_settings (smoke
1464
+ // 2026-05-27, finding 2). The cached value is what the
1465
+ // operator typed into the wizard form — fine on the happy
1466
+ // path, but stale if the vault install failed and the
1467
+ // operator worked around it (e.g. installed vault under a
1468
+ // different name via the CLI). The "static-write + stale-
1469
+ // read" pattern Aaron's flagged repeatedly:
1470
+ // `feedback_static_vs_dynamic_state.md`. Read state
1471
+ // dynamically when it can change.
1472
+ //
1473
+ // Fall back to the DB setting only if services.json has no
1474
+ // vault entry — covers a transient "wizard hit done but
1475
+ // vault is still pending" race where the operator-typed
1476
+ // value is the only signal we have. Final fallback is
1477
+ // "default" so the rendered name is always something the
1478
+ // operator can act on.
1479
+ const liveName = firstVaultNameOrNull(deps.manifestPath);
1225
1480
  const storedName = getSetting(deps.db, "setup_vault_name");
1226
- const vaultName = storedName ?? firstVaultName(deps.manifestPath);
1481
+ const vaultName = liveName ?? storedName ?? "default";
1227
1482
  // Module install tiles (hub#272 Item B). One per curated module
1228
1483
  // other than vault (which the wizard already provisioned).
1229
1484
  const installTiles = buildInstallTiles(url, deps);
1230
1485
  // hub#342: drive the lead "Start using your vault" tile's target.
1231
1486
  // When parachute-app is installed alongside vault, the tile links
1232
- // to `/app/notes/` (auto-bootstrapped Notes-as-UI per parachute-app
1487
+ // to `/surface/notes/` (auto-bootstrapped Notes-as-UI per parachute-app
1233
1488
  // §17). Otherwise it falls back to the vault's own admin UI.
1234
- const appInstalled = isModuleInstalled("app", deps.manifestPath);
1489
+ const appInstalled = isModuleInstalled("surface", deps.manifestPath);
1235
1490
  const doneProps: RenderDoneStepProps = {
1236
1491
  vaultName,
1237
1492
  hubOrigin: deps.issuer,
@@ -1270,25 +1525,33 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1270
1525
  // Step 3 (vault) with an op in flight — render the poll page.
1271
1526
  if (state.hasAdmin && !state.hasVault) {
1272
1527
  const opId = url.searchParams.get("op");
1528
+ const cloudHost = detectAutoExposeMode(deps.env ?? process.env) === "public";
1273
1529
  if (opId) {
1274
1530
  const registry = deps.registry;
1275
1531
  const op = registry?.get(opId);
1276
1532
  if (op) {
1533
+ // Carry the scribe op_id forward via the query param so the
1534
+ // op-poll page's success-redirect threads it into the done
1535
+ // step's URL (where buildInstallTiles picks it up via the
1536
+ // existing per-tile `op_scribe` mechanism).
1537
+ const scribeOpIdParam = url.searchParams.get("op_scribe") ?? undefined;
1277
1538
  return new Response(
1278
1539
  renderVaultStep({
1279
1540
  csrfToken: csrf.token,
1541
+ cloudHost,
1280
1542
  operation: {
1281
1543
  id: op.id,
1282
1544
  status: op.status,
1283
1545
  log: op.log,
1284
1546
  ...(op.error !== undefined ? { error: op.error } : {}),
1547
+ ...(scribeOpIdParam !== undefined ? { scribeOpId: scribeOpIdParam } : {}),
1285
1548
  },
1286
1549
  }),
1287
1550
  { status: 200, headers: extraHeaders },
1288
1551
  );
1289
1552
  }
1290
1553
  }
1291
- return new Response(renderVaultStep({ csrfToken: csrf.token }), {
1554
+ return new Response(renderVaultStep({ csrfToken: csrf.token, cloudHost }), {
1292
1555
  status: 200,
1293
1556
  headers: extraHeaders,
1294
1557
  });
@@ -1596,6 +1859,7 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1596
1859
  supervisor: deps.supervisor,
1597
1860
  registry,
1598
1861
  ...(deps.run ? { run: deps.run } : {}),
1862
+ ...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
1599
1863
  ...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
1600
1864
  }).catch((err) => {
1601
1865
  const msg = err instanceof Error ? err.message : String(err);
@@ -1609,7 +1873,185 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1609
1873
  "[setup-wizard] handleSetupVaultPost called with no operations registry — install will NOT run. Wire deps.registry in the dispatcher.",
1610
1874
  );
1611
1875
  }
1612
- return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
1876
+ // Scribe sub-form fold (2026-05-27). The vault step's form lets
1877
+ // the operator answer "do you also want voice transcription?" +
1878
+ // "do you also want LLM cleanup?" in the same submission. If they
1879
+ // asked for either, we (a) write the chosen provider(s) + API
1880
+ // key(s) to `~/.parachute/scribe/config.json` so scribe finds
1881
+ // them on first boot, and (b) kick a scribe install op in
1882
+ // parallel with vault install. The vault op-poll page threads the
1883
+ // scribe op_id through its success-redirect so the done step can
1884
+ // poll scribe progress via the existing per-tile mechanism.
1885
+ //
1886
+ // Cleanup-without-transcribe is a valid combo: the operator can
1887
+ // hit scribe's REST cleanup endpoint directly with their own raw
1888
+ // text. We install scribe + write the cleanup block in that case.
1889
+ const scribeProvider = String(form.get("scribe_provider") ?? "").trim();
1890
+ const scribeCleanupProvider = String(form.get("scribe_cleanup_provider") ?? "").trim();
1891
+ const wantsTranscribe = scribeProvider !== "" && scribeProvider !== "none";
1892
+ const wantsCleanup = scribeCleanupProvider !== "" && scribeCleanupProvider !== "none";
1893
+ let scribeOpId: string | undefined;
1894
+ if (wantsTranscribe || wantsCleanup) {
1895
+ const scribeApiKey = String(form.get("scribe_api_key") ?? "").trim();
1896
+ const scribeCleanupApiKey = String(form.get("scribe_cleanup_api_key") ?? "").trim();
1897
+ // Write scribe config FIRST so scribe's first boot picks up the
1898
+ // provider(s) + key(s) without a second config edit. We don't
1899
+ // fail the wizard on a config-write error — log it + carry on;
1900
+ // scribe will boot with defaults + the operator can fix via
1901
+ // /scribe/admin.
1902
+ try {
1903
+ writeScribeConfigForWizard(deps.configDir, {
1904
+ ...(wantsTranscribe
1905
+ ? { transcribe: { provider: scribeProvider, apiKey: scribeApiKey } }
1906
+ : {}),
1907
+ ...(wantsCleanup
1908
+ ? { cleanup: { provider: scribeCleanupProvider, apiKey: scribeCleanupApiKey } }
1909
+ : {}),
1910
+ });
1911
+ } catch (err) {
1912
+ console.warn(
1913
+ `[setup-wizard] failed to write scribe config: ${err instanceof Error ? err.message : String(err)} — kicking install anyway, operator can configure later.`,
1914
+ );
1915
+ }
1916
+ // Kick scribe install in parallel. Don't block on it; the done
1917
+ // step's per-tile op-poll surfaces progress.
1918
+ if (registry) {
1919
+ const scribeSpec = specFor("scribe");
1920
+ const scribeOp = registry.create("install", "scribe");
1921
+ scribeOpId = scribeOp.id;
1922
+ void runInstall(scribeOp.id, "scribe", scribeSpec, {
1923
+ db: deps.db,
1924
+ issuer: deps.issuer,
1925
+ manifestPath: deps.manifestPath,
1926
+ configDir: deps.configDir,
1927
+ supervisor: deps.supervisor,
1928
+ registry,
1929
+ ...(deps.run ? { run: deps.run } : {}),
1930
+ ...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
1931
+ }).catch((err) => {
1932
+ const msg = err instanceof Error ? err.message : String(err);
1933
+ registry.update(
1934
+ scribeOp.id,
1935
+ { status: "failed", error: msg },
1936
+ `scribe install failed: ${msg}`,
1937
+ );
1938
+ });
1939
+ }
1940
+ }
1941
+ const redirectUrl = scribeOpId
1942
+ ? `/admin/setup?op=${encodeURIComponent(op.id)}&op_scribe=${encodeURIComponent(scribeOpId)}`
1943
+ : `/admin/setup?op=${encodeURIComponent(op.id)}`;
1944
+ return redirect(redirectUrl);
1945
+ }
1946
+
1947
+ /**
1948
+ * Write a minimal scribe config that selects the operator's chosen
1949
+ * transcribe + cleanup providers + API keys (when applicable).
1950
+ * Idempotent: reads any existing config, merges, writes back. File
1951
+ * mode 0o600 — the config holds API keys, owner-only.
1952
+ *
1953
+ * Lives in setup-wizard.ts (not scribe's own config-write.ts) because
1954
+ * (a) it's a one-time wizard write — the SPA's PUT /.parachute/config
1955
+ * surface is the canonical post-setup path, and (b) hub doesn't
1956
+ * import scribe-internal modules. The shape of `scribe-config.json`
1957
+ * is documented in parachute-scribe/src/config.ts; the fields we set
1958
+ * (`transcribe.provider`, `transcribeProviders.<name>.apiKey`,
1959
+ * `cleanup.provider`, `cleanup.default`, `cleanupProviders.<name>.apiKey`)
1960
+ * are stable. Cleanup block extended 2026-05-27 — scribe boots with
1961
+ * `cleanup: none` otherwise, so first-install operators got "raw
1962
+ * transcript only" until they hand-edited the config.
1963
+ *
1964
+ * Signature changed 2026-05-27 from `(configDir, provider, apiKey)` to
1965
+ * the options-object shape so the caller can express "cleanup only,
1966
+ * no transcribe" without smuggling sentinel strings.
1967
+ */
1968
+ interface WizardScribeConfig {
1969
+ /** Set when the operator chose a transcription provider (anything other than "none"). */
1970
+ transcribe?: { provider: string; apiKey: string };
1971
+ /** Set when the operator chose a cleanup provider (anything other than "none"). */
1972
+ cleanup?: { provider: string; apiKey: string };
1973
+ }
1974
+ function writeScribeConfigForWizard(configDir: string, config: WizardScribeConfig): void {
1975
+ const update: Record<string, unknown> = {};
1976
+
1977
+ if (config.transcribe) {
1978
+ const { provider, apiKey } = config.transcribe;
1979
+ // For `local` (Mac MLX / cross-platform ONNX), just set the
1980
+ // provider name — no key needed.
1981
+ if (provider === "local") {
1982
+ update.transcribe = { provider: "parakeet-mlx" };
1983
+ } else {
1984
+ // Cloud providers need a key. Empty key → just set provider;
1985
+ // the operator can paste the key later via /scribe/admin
1986
+ // without a restart (per provider-config.ts's per-request
1987
+ // precedence).
1988
+ update.transcribe = { provider };
1989
+ if (apiKey !== "") {
1990
+ update.transcribeProviders = { [provider]: { apiKey } };
1991
+ }
1992
+ }
1993
+ }
1994
+
1995
+ if (config.cleanup) {
1996
+ const { provider, apiKey } = config.cleanup;
1997
+ // Always set `cleanup.default: true` when the operator opted in to
1998
+ // cleanup — they want polished output as the default; the per-
1999
+ // request `cleanup` flag on each transcribe request can still
2000
+ // opt out individually.
2001
+ update.cleanup = { provider, default: true };
2002
+ // `claude-code` (host CLI auth) and `ollama` (local server)
2003
+ // don't need an API key. Everything else (anthropic, openai,
2004
+ // groq, gemini) takes a key. Empty key → just set the provider;
2005
+ // the operator can paste the key later via the admin SPA without
2006
+ // a restart.
2007
+ const needsKey = provider !== "claude-code" && provider !== "ollama";
2008
+ if (needsKey && apiKey !== "") {
2009
+ update.cleanupProviders = { [provider]: { apiKey } };
2010
+ }
2011
+ }
2012
+
2013
+ if (Object.keys(update).length === 0) return;
2014
+ persistScribeConfig(configDir, update);
2015
+ }
2016
+
2017
+ /**
2018
+ * Merge-write to scribe's config file at `<configDir>/scribe/config.json`.
2019
+ * Reads existing JSON when present, deep-merges `update`, writes back at
2020
+ * mode 0o600. Creates the parent dir if missing.
2021
+ */
2022
+ function persistScribeConfig(configDir: string, update: Record<string, unknown>): void {
2023
+ const scribeDir = join(configDir, "scribe");
2024
+ const configPath = join(scribeDir, "config.json");
2025
+ mkdirSync(scribeDir, { recursive: true });
2026
+ let existing: Record<string, unknown> = {};
2027
+ if (existsSync(configPath)) {
2028
+ try {
2029
+ existing = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
2030
+ } catch {
2031
+ // Malformed existing config — treat as empty + overwrite.
2032
+ existing = {};
2033
+ }
2034
+ }
2035
+ // Shallow merge at top level, deep merge for the sub-blocks we touch
2036
+ // (transcribe + transcribeProviders + cleanup + cleanupProviders). The
2037
+ // merge logic is generic and handles any nested object — it doesn't
2038
+ // hard-code the block names.
2039
+ const merged: Record<string, unknown> = { ...existing };
2040
+ for (const [key, value] of Object.entries(update)) {
2041
+ if (
2042
+ typeof value === "object" &&
2043
+ value !== null &&
2044
+ !Array.isArray(value) &&
2045
+ typeof merged[key] === "object" &&
2046
+ merged[key] !== null &&
2047
+ !Array.isArray(merged[key])
2048
+ ) {
2049
+ merged[key] = { ...(merged[key] as Record<string, unknown>), ...value };
2050
+ } else {
2051
+ merged[key] = value;
2052
+ }
2053
+ }
2054
+ writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
1613
2055
  }
1614
2056
 
1615
2057
  /**
@@ -1711,9 +2153,9 @@ const INSTALL_TILE_PROPS: ReadonlyArray<{
1711
2153
  tagline: string;
1712
2154
  }> = [
1713
2155
  {
1714
- short: "app",
1715
- displayName: "App",
1716
- tagline: "Host module for Parachute UIs — auto-installs Notes on first boot.",
2156
+ short: "surface",
2157
+ displayName: "Surface",
2158
+ tagline: "Host module for Parachute surfaces — auto-installs Notes on first boot.",
1717
2159
  },
1718
2160
  {
1719
2161
  short: "scribe",
@@ -1844,6 +2286,7 @@ export async function handleSetupInstallPost(
1844
2286
  supervisor: deps.supervisor,
1845
2287
  registry,
1846
2288
  ...(deps.run ? { run: deps.run } : {}),
2289
+ ...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
1847
2290
  }).catch((err) => {
1848
2291
  const msg = err instanceof Error ? err.message : String(err);
1849
2292
  registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
@@ -1882,7 +2325,7 @@ function validateAccountFields(input: {
1882
2325
  * Whether a given curated module is currently installed (has a row in
1883
2326
  * services.json keyed by its canonical `manifestName`). Used by the
1884
2327
  * done-step renderer (hub#342) to decide whether to point the "Start
1885
- * using your vault" tile at `/app/notes/` (App installed → Notes UI
2328
+ * using your vault" tile at `/surface/notes/` (App installed → Notes UI
1886
2329
  * auto-bootstrapped) vs the vault's own admin UI. Cheap manifest read
1887
2330
  * shared with `buildInstallTiles`.
1888
2331
  */
@@ -1893,17 +2336,21 @@ function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boo
1893
2336
  }
1894
2337
 
1895
2338
  /**
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.
2339
+ * Read the first vault's display name from services.json. Returns
2340
+ * null when services.json has no vault entry or the entry has no
2341
+ * `/vault/<name>` path used by the done step to detect "no live
2342
+ * vault, fall back to the operator-typed value." Distinguishing
2343
+ * "no live vault" from "live vault named default" matters: the
2344
+ * former should defer to the DB-cached name; the latter should
2345
+ * win over a possibly-stale DB cache (smoke 2026-05-27 finding 2).
1899
2346
  */
1900
- function firstVaultName(manifestPath: string): string {
2347
+ function firstVaultNameOrNull(manifestPath: string): string | null {
1901
2348
  const manifest = readManifestLenient(manifestPath);
1902
2349
  // Match on the canonical vault manifestName from the curated spec.
1903
2350
  // (`CURATED_MODULES.includes("vault")` was a dead guard — vault is a
1904
2351
  // tuple-literal member, so the conjunct is always true.)
1905
2352
  const entry = manifest.services.find((s) => s.name === specFor("vault").manifestName);
1906
- if (!entry) return "default";
2353
+ if (!entry) return null;
1907
2354
  // services.json entries store the mount path (e.g. `/vault/default`).
1908
2355
  // Strip the canonical prefix to surface the display name.
1909
2356
  for (const p of entry.paths ?? []) {
@@ -1912,7 +2359,7 @@ function firstVaultName(manifestPath: string): string {
1912
2359
  if (tail.length > 0) return tail;
1913
2360
  }
1914
2361
  }
1915
- return "default";
2362
+ return null;
1916
2363
  }
1917
2364
 
1918
2365
  function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {