@openparachute/vault 0.4.0 → 0.4.4-rc.11
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 +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
|
@@ -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
|
+
}
|