@openparachute/vault 0.4.3 → 0.4.4-rc.12

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.
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Interactive walkthrough for `parachute-vault mcp-install`.
3
+ *
4
+ * Fires when the operator runs the command with no install-shaping flags
5
+ * AND stdin is a TTY. Walks them through four decisions — vault, install
6
+ * location, auth mode + scope, final confirmation — with smart defaults
7
+ * informed by ambient context (number of vaults, hub reachability,
8
+ * operator-token presence, project-dir detection, existing entries).
9
+ *
10
+ * Design principle: every prompt has a default that's auto-selected on
11
+ * Enter. The reason for the default is visible so the operator can
12
+ * override informedly. The final preview shows the actual JSON shape that
13
+ * will be written (with a `<hub-jwt>` placeholder when minting — the live
14
+ * mint happens *after* the confirm so a cancellation skips the network
15
+ * call and avoids exposing the token in scrollback unnecessarily).
16
+ *
17
+ * The module is shaped around an injected `InteractiveIO` so tests can
18
+ * pin the prompt-by-prompt flow with canned answers; production wires
19
+ * the IO to `prompt.ts` + console.log.
20
+ */
21
+
22
+ import { buildMcpEntryPlan } from "./mcp-install.ts";
23
+ import type { InstallContext, ExistingMcpEntry, InstallScope } from "./mcp-install.ts";
24
+
25
+ /**
26
+ * I/O surface the walkthrough talks to. The interface keeps the prompts
27
+ * testable: production injects readline-backed implementations; tests
28
+ * inject pre-canned answer queues.
29
+ */
30
+ export interface InteractiveIO {
31
+ /** Print a line to the operator. */
32
+ log(line: string): void;
33
+ /**
34
+ * Ask a free-text question with a default. Returns the trimmed answer,
35
+ * or `defaultValue` if the operator pressed Enter on an empty input.
36
+ */
37
+ ask(question: string, defaultValue: string): Promise<string>;
38
+ /**
39
+ * Ask a yes/no question with a default. Returns true for yes.
40
+ */
41
+ confirm(question: string, defaultYes: boolean): Promise<boolean>;
42
+ }
43
+
44
+ /**
45
+ * The fully-resolved install decision the walkthrough produces. Same shape
46
+ * the flag-driven path computes — `cmdMcpInstall` hands it off to the
47
+ * shared backend `installMcpConfig`.
48
+ */
49
+ export interface InstallDecision {
50
+ mode: "mint" | "token" | "legacy-pat";
51
+ scope: "vault:read" | "vault:write" | "vault:admin";
52
+ installScope: InstallScope;
53
+ vaultName: string;
54
+ /**
55
+ * Whether the operator explicitly opted for a non-default vault. Controls
56
+ * entry-key shape (singular `parachute-vault` vs `parachute-vault-<name>`).
57
+ */
58
+ vaultExplicit: boolean;
59
+ /** Pasted bearer when `mode === "token"`. */
60
+ pastedToken?: string;
61
+ /**
62
+ * When the walkthrough decided to update an existing entry, the key of
63
+ * that entry — so the writer keys the new state at the same slot the
64
+ * preview promised. Absent on fresh installs. See vault#293.
65
+ */
66
+ existingEntryKey?: string;
67
+ }
68
+
69
+ /**
70
+ * Walk the operator through the install decision. Returns the resolved
71
+ * decision, or `"abort"` if they cancelled at the final preview / typed
72
+ * "no" on confirm.
73
+ */
74
+ export async function runInteractiveInstall(
75
+ ctx: InstallContext,
76
+ io: InteractiveIO,
77
+ ): Promise<InstallDecision | "abort"> {
78
+ if (ctx.vaults.length === 0) {
79
+ io.log("✗ No vaults found. Run `parachute-vault init` or `parachute-vault create <name>` first.");
80
+ return "abort";
81
+ }
82
+
83
+ io.log("Setting up Parachute Vault as an MCP server.");
84
+ io.log("");
85
+
86
+ // 1. Existing entry — strongest signal. If we find one, ask whether to
87
+ // update it. "Update" pins both the install location AND the entry
88
+ // key, so subsequent prompts can skip those questions.
89
+ const existing = pickExistingForPrompt(ctx);
90
+ let updateLocation: ExistingMcpEntry | null = null;
91
+ if (existing) {
92
+ io.log(`I see Parachute Vault is already installed at ${existing.label} ("${existing.entryKey}").`);
93
+ const update = await io.confirm("Update it (recommended)?", true);
94
+ if (update) {
95
+ updateLocation = existing;
96
+ io.log(` → Updating the existing entry at ${existing.label}.`);
97
+ } else {
98
+ io.log(" → Installing somewhere else.");
99
+ }
100
+ io.log("");
101
+ }
102
+
103
+ // 2. Vault target. Skipped when there's exactly one vault (no choice to
104
+ // make) or when we're updating an existing entry (the entry's URL
105
+ // already encodes the vault — we don't re-pick).
106
+ let vaultName: string;
107
+ let vaultExplicit: boolean;
108
+ if (updateLocation) {
109
+ vaultName = extractVaultFromUrl(updateLocation.url) ?? ctx.defaultVault;
110
+ vaultExplicit = updateLocation.entryKey !== "parachute-vault";
111
+ io.log(`Targeting vault "${vaultName}" (from the existing entry).`);
112
+ io.log("");
113
+ } else if (ctx.vaults.length === 1) {
114
+ vaultName = ctx.vaults[0]!;
115
+ vaultExplicit = false;
116
+ io.log(`Targeting your one vault: "${vaultName}".`);
117
+ io.log("");
118
+ } else {
119
+ io.log(`You have ${ctx.vaults.length} vaults: ${ctx.vaults.join(", ")}.`);
120
+ vaultName = await askPersistent(io, "Which vault?", ctx.defaultVault, {
121
+ help: `Type a vault name. Default "${ctx.defaultVault}" is your configured default_vault.`,
122
+ validate: (s) => (ctx.vaults.includes(s) ? null : `unknown vault "${s}" — pick one of: ${ctx.vaults.join(", ")}`),
123
+ });
124
+ vaultExplicit = vaultName !== ctx.defaultVault;
125
+ io.log("");
126
+ }
127
+
128
+ // 3. Install location. If we're updating, use that location. Otherwise
129
+ // always prompt with the three scopes available (local / project /
130
+ // user) — Claude Code reads ./.mcp.json regardless of git/package
131
+ // markers, so the prior "no markers → autopilot global" branch was
132
+ // wrong (silently overrode the operator's intent when they happened
133
+ // to be in a plain dir). Default tilts on the same marker signal:
134
+ // project markers → suggest `project`, else suggest `local`. The
135
+ // suggestion shapes the default; it does NOT decide.
136
+ let installScope: InstallScope;
137
+ if (updateLocation) {
138
+ installScope = updateLocation.scope;
139
+ } else {
140
+ const suggested: InstallScope = ctx.inProjectContext ? "project" : "local";
141
+ io.log(`Where to install? (CWD: ${pathTail(ctx.cwd)})`);
142
+ io.log(" local — ~/.claude.json under projects[<cwd>] (private, this dir only)");
143
+ io.log(" project — ./.mcp.json (checked into the repo, shared with team)");
144
+ io.log(" user — ~/.claude.json top-level (every project, every dir)");
145
+ const answer = await askPersistent(
146
+ io,
147
+ `Press Enter for "${suggested}", or type local / project / user`,
148
+ suggested,
149
+ {
150
+ help: [
151
+ "Install scopes (mirrors Claude Code's `claude mcp add --scope`):",
152
+ " local → ~/.claude.json under projects[<cwd>].mcpServers.",
153
+ " Private to your machine, scoped to this directory.",
154
+ " Claude Code only loads it when launched from here.",
155
+ " project → <cwd>/.mcp.json. Checked into the repo; shared with",
156
+ " anyone who clones it. Pick this when the vault",
157
+ " integration belongs to the team / project.",
158
+ " user → ~/.claude.json top-level mcpServers. Loaded for",
159
+ " every Claude Code session regardless of cwd.",
160
+ ].join("\n"),
161
+ validate: (s) => {
162
+ const ok = ["local", "project", "user"];
163
+ return ok.includes(s) ? null : `expected one of: ${ok.join(", ")}`;
164
+ },
165
+ },
166
+ );
167
+ installScope = answer as InstallScope;
168
+ if (installScope === "local") {
169
+ io.log(` → Writing to ~/.claude.json under projects["${ctx.cwd}"].mcpServers (local — this directory only).`);
170
+ } else if (installScope === "project") {
171
+ io.log(` → Writing to ${ctx.cwd}/.mcp.json (project-scoped — shared with the repo).`);
172
+ } else {
173
+ io.log(" → Writing to ~/.claude.json top-level (user-scoped — every project).");
174
+ }
175
+ io.log("");
176
+ }
177
+
178
+ // 4. Auth mode + scope. The branching point: hub-mint available, or
179
+ // not. When neither operator.token nor hub is configured, we fall
180
+ // through to paste/legacy.
181
+ const canMint = ctx.hubReachable && ctx.operatorTokenPresent;
182
+ let mode: InstallDecision["mode"];
183
+ let scope: InstallDecision["scope"] = "vault:read";
184
+ let pastedToken: string | undefined;
185
+
186
+ if (canMint) {
187
+ io.log(`I can mint a hub JWT for you (least-privilege: vault:${vaultName}:read).`);
188
+ const answer = await askPersistent(io, "Press Enter to accept, or type 'write', 'admin', or 'paste'", "mint", {
189
+ help: [
190
+ "Choices:",
191
+ " Enter → mint a hub JWT with vault:read scope (recommended).",
192
+ " write → mint with vault:write (mutations).",
193
+ " admin → mint with vault:admin (schema management).",
194
+ " paste → use an existing token instead of minting.",
195
+ " legacy → mint a vault-DB pvt_* (self-hosted-without-hub).",
196
+ ].join("\n"),
197
+ validate: (s) => {
198
+ const ok = ["mint", "write", "admin", "paste", "legacy"];
199
+ return ok.includes(s) ? null : `expected one of: ${ok.join(", ")}`;
200
+ },
201
+ });
202
+ if (answer === "paste") {
203
+ mode = "token";
204
+ pastedToken = await askToken(io);
205
+ } else if (answer === "legacy") {
206
+ mode = "legacy-pat";
207
+ // Legacy path mints a vault-DB pvt_* with scope narrowing — same
208
+ // verb choice as the mint path. Prompt for it explicitly so the
209
+ // operator gets the same control they get when widening a hub
210
+ // JWT's scope. (vault#292 review F2.)
211
+ scope = await askScope(io);
212
+ } else {
213
+ mode = "mint";
214
+ if (answer === "write") scope = "vault:write";
215
+ else if (answer === "admin") scope = "vault:admin";
216
+ }
217
+ } else {
218
+ // No hub-mint path available — explain why and offer the alternatives.
219
+ const reason = !ctx.hubReachable
220
+ ? "no hub origin configured (PARACHUTE_HUB_ORIGIN unset, no active expose-state)"
221
+ : "no operator token at ~/.parachute/operator.token";
222
+ io.log(`Hub-mint isn't available — ${reason}.`);
223
+ io.log("Two options: paste an existing token, or mint a vault-DB pvt_* (deprecated).");
224
+ const answer = await askPersistent(io, "Which? [paste / legacy]", "paste", {
225
+ help: [
226
+ " paste → use an existing bearer (hub JWT, pvt_*, anything).",
227
+ " legacy → mint a vault-DB pvt_* token (deprecated, vault#288).",
228
+ ].join("\n"),
229
+ validate: (s) => (s === "paste" || s === "legacy" ? null : "expected: paste or legacy"),
230
+ });
231
+ if (answer === "paste") {
232
+ mode = "token";
233
+ pastedToken = await askToken(io);
234
+ } else {
235
+ mode = "legacy-pat";
236
+ // Same scope prompt as the canMint legacy branch (F2).
237
+ scope = await askScope(io);
238
+ }
239
+ }
240
+ io.log("");
241
+
242
+ // 5. Preview + final confirm.
243
+ const targetLabel =
244
+ installScope === "project"
245
+ ? `${ctx.cwd}/.mcp.json`
246
+ : installScope === "local"
247
+ ? `~/.claude.json (projects["${ctx.cwd}"])`
248
+ : "~/.claude.json";
249
+ // Single source of truth for entry-key + URL — same function the writer
250
+ // will call when it actually lands the entry. See vault#293.
251
+ const { entryKey, url } = buildMcpEntryPlan({
252
+ vaultName,
253
+ vaultExplicit,
254
+ port: ctx.port,
255
+ env: ctx.env,
256
+ ...(updateLocation?.entryKey ? { existingEntryKey: updateLocation.entryKey } : {}),
257
+ });
258
+ const bearerPreview =
259
+ mode === "token" ? "<your token>" : mode === "mint" ? "<hub-jwt>" : "<pvt_*>";
260
+
261
+ io.log(`Here's what I'll write to ${targetLabel}:`);
262
+ io.log("");
263
+ io.log(` "${entryKey}": {`);
264
+ io.log(` "type": "http",`);
265
+ io.log(` "url": "${url}",`);
266
+ io.log(` "headers": { "Authorization": "Bearer ${bearerPreview}" }`);
267
+ io.log(` }`);
268
+ io.log("");
269
+ if (mode === "mint") {
270
+ io.log(` Scope: ${scope} → narrowed to vault:${vaultName}:${scope.split(":")[1]}.`);
271
+ } else if (mode === "legacy-pat") {
272
+ io.log(` Scope: ${scope}. The pvt_* token is vault-DB-resident (vault#288 deprecation).`);
273
+ } else {
274
+ // mode === "token" (paste). The pasted bearer carries its own scope
275
+ // claim — we don't inspect or override it; whatever scope the issuer
276
+ // baked in is what the vault will enforce. Surfacing this in the
277
+ // preview keeps the operator from assuming they're getting
278
+ // vault:read just because the walkthrough's default reads that way.
279
+ io.log(` Scope: determined by the pasted token (not validated here).`);
280
+ }
281
+ const proceed = await io.confirm("Proceed?", true);
282
+ if (!proceed) {
283
+ io.log("Aborted — nothing was written.");
284
+ return "abort";
285
+ }
286
+
287
+ return {
288
+ mode,
289
+ scope,
290
+ installScope,
291
+ vaultName,
292
+ vaultExplicit,
293
+ ...(pastedToken ? { pastedToken } : {}),
294
+ ...(updateLocation?.entryKey ? { existingEntryKey: updateLocation.entryKey } : {}),
295
+ };
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Helpers
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /**
303
+ * Pick which existing entry the prompt should lead with. Preference order:
304
+ * 1. local at this exact CWD — strongest signal the operator was just
305
+ * here and updated this very directory before.
306
+ * 2. user — the global install location, applies to every project.
307
+ * 3. project — the repo-shared install; lowest priority since it can
308
+ * drift independently of the operator's primary install.
309
+ */
310
+ function pickExistingForPrompt(ctx: InstallContext): ExistingMcpEntry | null {
311
+ return ctx.existing.local ?? ctx.existing.user ?? ctx.existing.project ?? null;
312
+ }
313
+
314
+ /**
315
+ * Extract the vault name from an MCP URL of the shape
316
+ * `…/vault/<name>/mcp`. Returns null if the URL doesn't match.
317
+ */
318
+ function extractVaultFromUrl(url: string): string | null {
319
+ const match = /\/vault\/([^/]+)\/mcp\b/.exec(url);
320
+ return match ? match[1]! : null;
321
+ }
322
+
323
+ /**
324
+ * Show only the last two path segments of CWD so the prompt is readable
325
+ * without leaking the operator's full home path in a screenshot.
326
+ */
327
+ function pathTail(p: string): string {
328
+ const parts = p.split("/").filter((s) => s.length > 0);
329
+ return parts.slice(-2).join("/") || p;
330
+ }
331
+
332
+ /**
333
+ * Prompt for scope when minting a vault-DB pvt_* (legacy-pat). Mirrors
334
+ * the mint path's "widen with write/admin" wording so the legacy and
335
+ * hub-mint branches surface scope as the same kind of choice. Mint's
336
+ * own scope prompt is inline at the auth-mode step (legacy is the
337
+ * extra round we couldn't fold there without ambiguity).
338
+ */
339
+ async function askScope(io: InteractiveIO): Promise<InstallDecision["scope"]> {
340
+ const answer = await askPersistent(io, "Press Enter for vault:read (least privilege), or type 'write' or 'admin' to widen", "read", {
341
+ help: [
342
+ "Scopes for the legacy pvt_* token:",
343
+ " read → vault:read (default — listing + reading only).",
344
+ " write → vault:write (mutations: create, update, delete notes).",
345
+ " admin → vault:admin (full, including schema management).",
346
+ ].join("\n"),
347
+ validate: (s) => {
348
+ const ok = ["read", "write", "admin"];
349
+ return ok.includes(s) ? null : `expected one of: ${ok.join(", ")}`;
350
+ },
351
+ });
352
+ return `vault:${answer}` as InstallDecision["scope"];
353
+ }
354
+
355
+ /**
356
+ * Prompt for a token. Uses `ask` rather than `askPassword` so the operator
357
+ * can see what they pasted (most clients show the token in plain text in
358
+ * their config anyway — masking here is theater, not security). Empty
359
+ * input re-prompts.
360
+ */
361
+ async function askToken(io: InteractiveIO): Promise<string> {
362
+ while (true) {
363
+ const t = (await io.ask("Paste your bearer token", "")).trim();
364
+ if (t.length > 0) return t;
365
+ io.log(" (empty input — paste a token, or Ctrl-C to abort.)");
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Wrapper around `io.ask` that re-prompts on validation failure and
371
+ * surfaces a help message on "help" / "?" / "/help". Keeps each prompt's
372
+ * call site terse without giving up the affordances.
373
+ */
374
+ async function askPersistent(
375
+ io: InteractiveIO,
376
+ question: string,
377
+ defaultValue: string,
378
+ opts: { help: string; validate: (s: string) => string | null },
379
+ ): Promise<string> {
380
+ while (true) {
381
+ const answerRaw = await io.ask(question, defaultValue);
382
+ const answer = answerRaw.trim();
383
+ if (answer === "help" || answer === "?" || answer === "/help") {
384
+ io.log(opts.help);
385
+ continue;
386
+ }
387
+ const err = opts.validate(answer);
388
+ if (err) {
389
+ io.log(` ${err}. (Type "help" for options.)`);
390
+ continue;
391
+ }
392
+ return answer;
393
+ }
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // Production IO wiring
398
+ // ---------------------------------------------------------------------------
399
+
400
+ /**
401
+ * Build an `InteractiveIO` backed by the readline-based prompt module.
402
+ * The standalone factory keeps `runInteractiveInstall` test-driveable
403
+ * without dragging readline into the test's mock graph.
404
+ */
405
+ export async function defaultInteractiveIO(): Promise<InteractiveIO> {
406
+ const { ask, confirm } = await import("./prompt.ts");
407
+ return {
408
+ log: (line) => console.log(line),
409
+ ask: (q, def) => ask(q, def),
410
+ confirm: (q, defYes) => confirm(q, defYes),
411
+ };
412
+ }