@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.
- package/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -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__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- 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__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/src/setup-wizard.ts
CHANGED
|
@@ -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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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),
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
?
|
|
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
|
|
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,
|
|
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">
|
|
929
|
-
|
|
930
|
-
|
|
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 <token>"</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
|
-
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
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
|
-
*
|
|
953
|
-
*
|
|
954
|
-
*
|
|
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,
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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>
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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 & 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
|
-
|
|
1083
|
-
notes
|
|
1084
|
-
//
|
|
1085
|
-
// (
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
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
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
//
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
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 ??
|
|
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
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
|
|
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
|
|
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`.
|
|
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
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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)
|
|
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
|
-
})
|
|
1601
|
-
|
|
1602
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1884
|
-
*
|
|
1885
|
-
*
|
|
1886
|
-
*
|
|
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
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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; }
|