@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.
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +163 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +32 -32
- package/src/__tests__/api-users.test.ts +383 -11
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +23 -23
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +722 -28
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +493 -25
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/account-home-ui.ts +434 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/api-account.ts +72 -6
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +3 -3
- package/src/api-users.ts +468 -55
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/commands/install.ts +8 -21
- package/src/commands/status.ts +10 -1
- package/src/help.ts +2 -2
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +69 -10
- package/src/hub-settings.ts +2 -2
- package/src/hub.ts +6 -6
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +278 -173
- package/src/oauth-ui.ts +18 -2
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +489 -42
- package/src/users.ts +307 -29
- package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/src/setup-wizard.ts
CHANGED
|
@@ -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
|
-
?
|
|
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 `/
|
|
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 `/
|
|
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
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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>
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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 & 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
|
-
|
|
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
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
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 ??
|
|
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 `/
|
|
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("
|
|
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
|
-
|
|
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: "
|
|
1715
|
-
displayName: "
|
|
1716
|
-
tagline: "Host module for Parachute
|
|
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 `/
|
|
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
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
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
|
|
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
|
|
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
|
|
2362
|
+
return null;
|
|
1916
2363
|
}
|
|
1917
2364
|
|
|
1918
2365
|
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|