@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.
- package/README.md +58 -2
- package/core/src/core.test.ts +232 -0
- package/core/src/mcp.ts +104 -4
- 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 +19 -1
- package/core/src/store.ts +13 -0
- package/core/src/types.ts +15 -0
- package/package.json +1 -1
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- 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/routes.ts +75 -5
- package/src/vault.test.ts +215 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the interactive walkthrough.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
*
|
|
6
|
+
* 1. Unit tests for `runInteractiveInstall` driven by a mock `InteractiveIO`
|
|
7
|
+
* with pre-canned answers. These pin the prompt-by-prompt flow without
|
|
8
|
+
* touching readline / stdin — every branch of the decision tree gets a
|
|
9
|
+
* direct fixture.
|
|
10
|
+
*
|
|
11
|
+
* 2. Integration tests for `detectInstallContext` + project / existing-entry
|
|
12
|
+
* detection helpers — the context the walkthrough reads from.
|
|
13
|
+
*
|
|
14
|
+
* CLI-level dispatch (TTY-detect, no-flag → interactive) is covered in
|
|
15
|
+
* `mcp-install.test.ts` since spawning a subprocess is the right shape
|
|
16
|
+
* for "what does `mcp-install` actually do."
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
buildMcpEntryPlan,
|
|
25
|
+
detectExistingEntries,
|
|
26
|
+
detectInstallContext,
|
|
27
|
+
detectProjectContext,
|
|
28
|
+
type ExistingMcpEntry,
|
|
29
|
+
type InstallContext,
|
|
30
|
+
} from "./mcp-install.ts";
|
|
31
|
+
import {
|
|
32
|
+
runInteractiveInstall,
|
|
33
|
+
type InteractiveIO,
|
|
34
|
+
} from "./mcp-install-interactive.ts";
|
|
35
|
+
|
|
36
|
+
const CLI = path.resolve(import.meta.dir, "cli.ts");
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Mock IO: queue answers + capture log output
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
interface MockIOState {
|
|
43
|
+
/** Pre-canned answers, consumed in order. `null` means "press Enter on default". */
|
|
44
|
+
answers: (string | boolean | null)[];
|
|
45
|
+
/** Captured log lines, in order. */
|
|
46
|
+
logs: string[];
|
|
47
|
+
/** Captured (question, default) pairs from ask + confirm, in order. */
|
|
48
|
+
prompts: { kind: "ask" | "confirm"; question: string; default: string | boolean }[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function mockIO(answers: (string | boolean | null)[]): { io: InteractiveIO; state: MockIOState } {
|
|
52
|
+
const state: MockIOState = { answers: [...answers], logs: [], prompts: [] };
|
|
53
|
+
const io: InteractiveIO = {
|
|
54
|
+
log: (line) => state.logs.push(line),
|
|
55
|
+
ask: async (question, defaultValue) => {
|
|
56
|
+
state.prompts.push({ kind: "ask", question, default: defaultValue });
|
|
57
|
+
if (state.answers.length === 0) {
|
|
58
|
+
throw new Error(`mock IO exhausted on ask("${question}", "${defaultValue}")`);
|
|
59
|
+
}
|
|
60
|
+
const next = state.answers.shift();
|
|
61
|
+
if (next === null) return defaultValue;
|
|
62
|
+
if (typeof next !== "string") {
|
|
63
|
+
throw new Error(`mock IO got non-string for ask: ${String(next)}`);
|
|
64
|
+
}
|
|
65
|
+
return next;
|
|
66
|
+
},
|
|
67
|
+
confirm: async (question, defaultYes) => {
|
|
68
|
+
state.prompts.push({ kind: "confirm", question, default: defaultYes });
|
|
69
|
+
if (state.answers.length === 0) {
|
|
70
|
+
throw new Error(`mock IO exhausted on confirm("${question}", ${defaultYes})`);
|
|
71
|
+
}
|
|
72
|
+
const next = state.answers.shift();
|
|
73
|
+
if (next === null) return defaultYes;
|
|
74
|
+
if (typeof next !== "boolean") {
|
|
75
|
+
throw new Error(`mock IO got non-boolean for confirm: ${String(next)}`);
|
|
76
|
+
}
|
|
77
|
+
return next;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return { io, state };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Build a baseline context the walkthrough can chew on. Override fields per-test. */
|
|
84
|
+
function baseCtx(overrides: Partial<InstallContext> = {}): InstallContext {
|
|
85
|
+
return {
|
|
86
|
+
vaults: ["default"],
|
|
87
|
+
defaultVault: "default",
|
|
88
|
+
hubReachable: true,
|
|
89
|
+
hubOrigin: "https://hub.example",
|
|
90
|
+
port: 1940,
|
|
91
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://hub.example" },
|
|
92
|
+
operatorTokenPresent: true,
|
|
93
|
+
inProjectContext: false,
|
|
94
|
+
cwd: "/tmp/cwd",
|
|
95
|
+
existing: {},
|
|
96
|
+
...overrides,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Decision-tree tests for the walkthrough
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
describe("runInteractiveInstall — decision tree", () => {
|
|
105
|
+
test("single vault + no project context + can mint: prompts for install scope (defaults local), then mint at vault:read", async () => {
|
|
106
|
+
const { io, state } = mockIO([
|
|
107
|
+
null, // accept install-scope default ("local")
|
|
108
|
+
null, // accept "mint" with vault:read scope
|
|
109
|
+
true, // proceed
|
|
110
|
+
]);
|
|
111
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
112
|
+
expect(result).not.toBe("abort");
|
|
113
|
+
if (result === "abort") return;
|
|
114
|
+
expect(result.mode).toBe("mint");
|
|
115
|
+
expect(result.scope).toBe("vault:read");
|
|
116
|
+
// No project markers → suggested default is `local` (matches Claude
|
|
117
|
+
// Code's `claude mcp add` default). The walkthrough always prompts.
|
|
118
|
+
expect(result.installScope).toBe("local");
|
|
119
|
+
expect(result.vaultName).toBe("default");
|
|
120
|
+
expect(result.vaultExplicit).toBe(false);
|
|
121
|
+
// Vault prompt skipped (single vault). Install-scope is now an ask
|
|
122
|
+
// (not a confirm) — always fires. Then auth ask + final confirm.
|
|
123
|
+
expect(state.prompts.map((p) => p.kind)).toEqual(["ask", "ask", "confirm"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("multi-vault: walkthrough asks which vault and respects the choice", async () => {
|
|
127
|
+
const { io, state } = mockIO([
|
|
128
|
+
"boulder", // pick vault "boulder"
|
|
129
|
+
null, // accept install-scope default
|
|
130
|
+
null, // accept mint with vault:read
|
|
131
|
+
true, // proceed
|
|
132
|
+
]);
|
|
133
|
+
const result = await runInteractiveInstall(
|
|
134
|
+
baseCtx({ vaults: ["default", "boulder", "techne"] }),
|
|
135
|
+
io,
|
|
136
|
+
);
|
|
137
|
+
expect(result).not.toBe("abort");
|
|
138
|
+
if (result === "abort") return;
|
|
139
|
+
expect(result.vaultName).toBe("boulder");
|
|
140
|
+
// vaultExplicit only true when picking a non-default vault; here
|
|
141
|
+
// default_vault="default" but we picked "boulder" → explicit.
|
|
142
|
+
expect(result.vaultExplicit).toBe(true);
|
|
143
|
+
expect(state.prompts[0]!.question).toMatch(/Which vault/i);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("multi-vault: pressing Enter accepts the default_vault and is not marked explicit", async () => {
|
|
147
|
+
const { io } = mockIO([null, null, null, true]); // vault, install-scope, mint, proceed
|
|
148
|
+
const result = await runInteractiveInstall(
|
|
149
|
+
baseCtx({ vaults: ["default", "boulder"] }),
|
|
150
|
+
io,
|
|
151
|
+
);
|
|
152
|
+
expect(result).not.toBe("abort");
|
|
153
|
+
if (result === "abort") return;
|
|
154
|
+
expect(result.vaultName).toBe("default");
|
|
155
|
+
expect(result.vaultExplicit).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("project context: walkthrough suggests project-scope install as the default", async () => {
|
|
159
|
+
const { io, state } = mockIO([
|
|
160
|
+
null, // accept install-scope default (project, because markers detected)
|
|
161
|
+
null, // accept mint with vault:read
|
|
162
|
+
true, // proceed
|
|
163
|
+
]);
|
|
164
|
+
const result = await runInteractiveInstall(
|
|
165
|
+
baseCtx({ inProjectContext: true, cwd: "/home/user/code/myproject" }),
|
|
166
|
+
io,
|
|
167
|
+
);
|
|
168
|
+
expect(result).not.toBe("abort");
|
|
169
|
+
if (result === "abort") return;
|
|
170
|
+
expect(result.installScope).toBe("project");
|
|
171
|
+
// First prompt is the install-scope ask; default tilts to "project"
|
|
172
|
+
// when project markers are present in cwd.
|
|
173
|
+
expect(state.prompts[0]!.kind).toBe("ask");
|
|
174
|
+
expect(state.prompts[0]!.default).toBe("project");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("project context but operator picks user: install scope falls to user", async () => {
|
|
178
|
+
const { io } = mockIO([
|
|
179
|
+
"user", // override the project-scope default → user
|
|
180
|
+
null, // accept mint
|
|
181
|
+
true, // proceed
|
|
182
|
+
]);
|
|
183
|
+
const result = await runInteractiveInstall(
|
|
184
|
+
baseCtx({ inProjectContext: true }),
|
|
185
|
+
io,
|
|
186
|
+
);
|
|
187
|
+
expect(result).not.toBe("abort");
|
|
188
|
+
if (result === "abort") return;
|
|
189
|
+
expect(result.installScope).toBe("user");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("no project markers: install-scope prompt still fires and defaults to local", async () => {
|
|
193
|
+
const { io, state } = mockIO([
|
|
194
|
+
null, // accept install-scope default (local)
|
|
195
|
+
null, // accept mint
|
|
196
|
+
true, // proceed
|
|
197
|
+
]);
|
|
198
|
+
const result = await runInteractiveInstall(
|
|
199
|
+
baseCtx({ inProjectContext: false, cwd: "/Gitcoin" }),
|
|
200
|
+
io,
|
|
201
|
+
);
|
|
202
|
+
expect(result).not.toBe("abort");
|
|
203
|
+
if (result === "abort") return;
|
|
204
|
+
// This is the dogfood-feedback case: operator is in a plain
|
|
205
|
+
// directory (no .git, no package.json). The walkthrough must NOT
|
|
206
|
+
// silently autopilot to user scope — always prompt.
|
|
207
|
+
expect(result.installScope).toBe("local");
|
|
208
|
+
expect(state.prompts.find((p) => p.kind === "ask" && p.default === "local")).toBeDefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("no project markers: operator can choose user explicitly", async () => {
|
|
212
|
+
const { io } = mockIO([
|
|
213
|
+
"user", // override default
|
|
214
|
+
null, // accept mint
|
|
215
|
+
true, // proceed
|
|
216
|
+
]);
|
|
217
|
+
const result = await runInteractiveInstall(
|
|
218
|
+
baseCtx({ inProjectContext: false }),
|
|
219
|
+
io,
|
|
220
|
+
);
|
|
221
|
+
expect(result).not.toBe("abort");
|
|
222
|
+
if (result === "abort") return;
|
|
223
|
+
expect(result.installScope).toBe("user");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("install-scope prompt: invalid value re-prompts with help affordance", async () => {
|
|
227
|
+
const { io, state } = mockIO([
|
|
228
|
+
"everywhere", // invalid
|
|
229
|
+
"local", // valid
|
|
230
|
+
null, // accept mint
|
|
231
|
+
true, // proceed
|
|
232
|
+
]);
|
|
233
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
234
|
+
expect(result).not.toBe("abort");
|
|
235
|
+
if (result === "abort") return;
|
|
236
|
+
expect(result.installScope).toBe("local");
|
|
237
|
+
expect(state.logs.some((l) => /expected one of/.test(l))).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("hub not reachable: walkthrough offers paste vs legacy (no mint option)", async () => {
|
|
241
|
+
const { io, state } = mockIO([
|
|
242
|
+
null, // accept install-scope default
|
|
243
|
+
"legacy", // pick legacy-pat
|
|
244
|
+
null, // accept default scope (read) on the F2 scope prompt
|
|
245
|
+
true, // proceed
|
|
246
|
+
]);
|
|
247
|
+
const result = await runInteractiveInstall(
|
|
248
|
+
baseCtx({ hubReachable: false }),
|
|
249
|
+
io,
|
|
250
|
+
);
|
|
251
|
+
expect(result).not.toBe("abort");
|
|
252
|
+
if (result === "abort") return;
|
|
253
|
+
expect(result.mode).toBe("legacy-pat");
|
|
254
|
+
expect(result.scope).toBe("vault:read");
|
|
255
|
+
// Auth prompt should explain the no-hub state.
|
|
256
|
+
expect(state.logs.some((l) => /Hub-mint isn't available/.test(l))).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("hub reachable but no operator.token: also offers paste vs legacy", async () => {
|
|
260
|
+
const { io, state } = mockIO([
|
|
261
|
+
null, // accept install-scope default
|
|
262
|
+
"paste", // pick paste
|
|
263
|
+
"pasted-jwt", // the bearer
|
|
264
|
+
true, // proceed
|
|
265
|
+
]);
|
|
266
|
+
const result = await runInteractiveInstall(
|
|
267
|
+
baseCtx({ operatorTokenPresent: false }),
|
|
268
|
+
io,
|
|
269
|
+
);
|
|
270
|
+
expect(result).not.toBe("abort");
|
|
271
|
+
if (result === "abort") return;
|
|
272
|
+
expect(result.mode).toBe("token");
|
|
273
|
+
expect(result.pastedToken).toBe("pasted-jwt");
|
|
274
|
+
expect(state.logs.some((l) => /no operator token/i.test(l))).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("scope widening: typing 'write' produces vault:write mint", async () => {
|
|
278
|
+
const { io } = mockIO([
|
|
279
|
+
null, // accept install-scope default
|
|
280
|
+
"write", // widen scope
|
|
281
|
+
true, // proceed
|
|
282
|
+
]);
|
|
283
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
284
|
+
expect(result).not.toBe("abort");
|
|
285
|
+
if (result === "abort") return;
|
|
286
|
+
expect(result.mode).toBe("mint");
|
|
287
|
+
expect(result.scope).toBe("vault:write");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("scope widening: typing 'admin' produces vault:admin mint", async () => {
|
|
291
|
+
const { io } = mockIO([
|
|
292
|
+
null, // accept install-scope default
|
|
293
|
+
"admin",
|
|
294
|
+
true,
|
|
295
|
+
]);
|
|
296
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
297
|
+
expect(result).not.toBe("abort");
|
|
298
|
+
if (result === "abort") return;
|
|
299
|
+
expect(result.scope).toBe("vault:admin");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("typing 'paste' at the auth prompt switches to token mode + asks for token", async () => {
|
|
303
|
+
const { io } = mockIO([
|
|
304
|
+
null, // accept install-scope default
|
|
305
|
+
"paste", // switch to paste
|
|
306
|
+
"my-existing-jwt", // the bearer
|
|
307
|
+
true, // proceed
|
|
308
|
+
]);
|
|
309
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
310
|
+
expect(result).not.toBe("abort");
|
|
311
|
+
if (result === "abort") return;
|
|
312
|
+
expect(result.mode).toBe("token");
|
|
313
|
+
expect(result.pastedToken).toBe("my-existing-jwt");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("typing 'legacy' at the auth prompt switches to legacy-pat with default scope", async () => {
|
|
317
|
+
const { io } = mockIO([
|
|
318
|
+
null, // accept install-scope default
|
|
319
|
+
"legacy",
|
|
320
|
+
null, // accept default scope (read) on the F2 scope prompt
|
|
321
|
+
true,
|
|
322
|
+
]);
|
|
323
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
324
|
+
expect(result).not.toBe("abort");
|
|
325
|
+
if (result === "abort") return;
|
|
326
|
+
expect(result.mode).toBe("legacy-pat");
|
|
327
|
+
expect(result.scope).toBe("vault:read");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("legacy-pat path: typing 'write' on the scope prompt widens to vault:write (F2)", async () => {
|
|
331
|
+
const { io, state } = mockIO([
|
|
332
|
+
null, // accept install-scope default
|
|
333
|
+
"legacy", // pick legacy-pat
|
|
334
|
+
"write", // widen scope
|
|
335
|
+
true, // proceed
|
|
336
|
+
]);
|
|
337
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
338
|
+
expect(result).not.toBe("abort");
|
|
339
|
+
if (result === "abort") return;
|
|
340
|
+
expect(result.mode).toBe("legacy-pat");
|
|
341
|
+
expect(result.scope).toBe("vault:write");
|
|
342
|
+
// Scope-prompt wording must match the mint path's "least privilege" framing.
|
|
343
|
+
expect(state.prompts.some((p) => /least privilege/.test(p.question))).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("legacy-pat path (no-hub branch): scope prompt also fires (F2)", async () => {
|
|
347
|
+
const { io } = mockIO([
|
|
348
|
+
null, // accept install-scope default
|
|
349
|
+
"legacy", // pick legacy (no-hub branch)
|
|
350
|
+
"admin", // widen scope
|
|
351
|
+
true, // proceed
|
|
352
|
+
]);
|
|
353
|
+
const result = await runInteractiveInstall(baseCtx({ hubReachable: false }), io);
|
|
354
|
+
expect(result).not.toBe("abort");
|
|
355
|
+
if (result === "abort") return;
|
|
356
|
+
expect(result.mode).toBe("legacy-pat");
|
|
357
|
+
expect(result.scope).toBe("vault:admin");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("paste path: preview clarifies scope is determined by the pasted token (F2)", async () => {
|
|
361
|
+
const { io, state } = mockIO([
|
|
362
|
+
null, // accept install-scope default
|
|
363
|
+
"paste", // pick paste
|
|
364
|
+
"my-existing-jwt", // bearer
|
|
365
|
+
true, // proceed
|
|
366
|
+
]);
|
|
367
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
368
|
+
expect(result).not.toBe("abort");
|
|
369
|
+
if (result === "abort") return;
|
|
370
|
+
expect(result.mode).toBe("token");
|
|
371
|
+
// Preview should explicitly note that scope is the pasted token's,
|
|
372
|
+
// not the walkthrough's vault:read default. Operators shouldn't
|
|
373
|
+
// infer scope from the walkthrough's framing when they're pasting.
|
|
374
|
+
expect(state.logs.some((l) => /determined by the pasted token/.test(l))).toBe(true);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("existing entry at user scope: walkthrough leads with 'update it?' (default Y)", async () => {
|
|
378
|
+
const existing: ExistingMcpEntry = {
|
|
379
|
+
path: "/home/user/.claude.json",
|
|
380
|
+
label: "~/.claude.json",
|
|
381
|
+
scope: "user",
|
|
382
|
+
entryKey: "parachute-vault",
|
|
383
|
+
url: "https://hub.example/vault/default/mcp",
|
|
384
|
+
hasAuth: true,
|
|
385
|
+
};
|
|
386
|
+
const { io, state } = mockIO([
|
|
387
|
+
true, // update it
|
|
388
|
+
null, // accept mint
|
|
389
|
+
true, // proceed
|
|
390
|
+
]);
|
|
391
|
+
const result = await runInteractiveInstall(
|
|
392
|
+
baseCtx({ existing: { user: existing } }),
|
|
393
|
+
io,
|
|
394
|
+
);
|
|
395
|
+
expect(result).not.toBe("abort");
|
|
396
|
+
if (result === "abort") return;
|
|
397
|
+
// Update path pins install scope + vault from existing entry, so the
|
|
398
|
+
// walkthrough should NOT re-prompt for those.
|
|
399
|
+
expect(result.installScope).toBe("user");
|
|
400
|
+
expect(result.vaultName).toBe("default");
|
|
401
|
+
expect(state.prompts[0]!.question).toMatch(/Update it/i);
|
|
402
|
+
expect(state.prompts[0]!.default).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("existing entry: declining the update prompt continues to fresh-pick", async () => {
|
|
406
|
+
const existing: ExistingMcpEntry = {
|
|
407
|
+
path: "/home/user/.claude.json",
|
|
408
|
+
label: "~/.claude.json",
|
|
409
|
+
scope: "user",
|
|
410
|
+
entryKey: "parachute-vault",
|
|
411
|
+
url: "https://hub.example/vault/default/mcp",
|
|
412
|
+
hasAuth: true,
|
|
413
|
+
};
|
|
414
|
+
const { io } = mockIO([
|
|
415
|
+
false, // decline update
|
|
416
|
+
null, // accept install-scope default (local)
|
|
417
|
+
null, // accept mint
|
|
418
|
+
true, // proceed
|
|
419
|
+
]);
|
|
420
|
+
const result = await runInteractiveInstall(
|
|
421
|
+
baseCtx({ existing: { user: existing } }),
|
|
422
|
+
io,
|
|
423
|
+
);
|
|
424
|
+
expect(result).not.toBe("abort");
|
|
425
|
+
if (result === "abort") return;
|
|
426
|
+
// Default flow resumed — local scope (no project markers), default vault.
|
|
427
|
+
expect(result.installScope).toBe("local");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("aborts cleanly when the operator declines the final confirm", async () => {
|
|
431
|
+
const { io, state } = mockIO([
|
|
432
|
+
null, // accept install-scope default
|
|
433
|
+
null, // accept mint
|
|
434
|
+
false, // decline final confirm
|
|
435
|
+
]);
|
|
436
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
437
|
+
expect(result).toBe("abort");
|
|
438
|
+
expect(state.logs.some((l) => /Aborted/.test(l))).toBe(true);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("no vaults at all: aborts immediately with remediation", async () => {
|
|
442
|
+
const { io, state } = mockIO([]); // no answers needed; should bail before prompting
|
|
443
|
+
const result = await runInteractiveInstall(baseCtx({ vaults: [] }), io);
|
|
444
|
+
expect(result).toBe("abort");
|
|
445
|
+
expect(state.logs.some((l) => /No vaults found/.test(l))).toBe(true);
|
|
446
|
+
expect(state.prompts).toHaveLength(0);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("'help' input on the auth prompt re-prompts after showing explanation", async () => {
|
|
450
|
+
const { io, state } = mockIO([
|
|
451
|
+
null, // accept install-scope default
|
|
452
|
+
"help", // ask for help
|
|
453
|
+
null, // then accept mint
|
|
454
|
+
true, // proceed
|
|
455
|
+
]);
|
|
456
|
+
const result = await runInteractiveInstall(baseCtx(), io);
|
|
457
|
+
expect(result).not.toBe("abort");
|
|
458
|
+
// The help text should have been logged between the two ask calls.
|
|
459
|
+
// It enumerates the choices (mint / write / admin / paste / legacy);
|
|
460
|
+
// matching on the "Choices:" header keeps the assertion stable
|
|
461
|
+
// against future re-wording of individual lines.
|
|
462
|
+
const helpLogged = state.logs.some((l) => /Choices:/.test(l) && /paste/.test(l) && /legacy/.test(l));
|
|
463
|
+
expect(helpLogged).toBe(true);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("invalid input at vault prompt re-prompts with the error", async () => {
|
|
467
|
+
const { io, state } = mockIO([
|
|
468
|
+
"ghost", // unknown vault
|
|
469
|
+
"boulder", // pick a real one
|
|
470
|
+
null, // accept install-scope default
|
|
471
|
+
null, // accept mint
|
|
472
|
+
true, // proceed
|
|
473
|
+
]);
|
|
474
|
+
const result = await runInteractiveInstall(
|
|
475
|
+
baseCtx({ vaults: ["default", "boulder"] }),
|
|
476
|
+
io,
|
|
477
|
+
);
|
|
478
|
+
expect(result).not.toBe("abort");
|
|
479
|
+
if (result === "abort") return;
|
|
480
|
+
expect(result.vaultName).toBe("boulder");
|
|
481
|
+
// Error message should have been logged.
|
|
482
|
+
const errLogged = state.logs.some((l) => /unknown vault/i.test(l));
|
|
483
|
+
expect(errLogged).toBe(true);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Context-detection helpers
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
|
|
491
|
+
describe("detectProjectContext", () => {
|
|
492
|
+
let tmp: string;
|
|
493
|
+
beforeEach(() => {
|
|
494
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vault-project-ctx-"));
|
|
495
|
+
});
|
|
496
|
+
afterEach(() => {
|
|
497
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("empty directory: not a project", () => {
|
|
501
|
+
expect(detectProjectContext(tmp)).toBe(false);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("directory with .git: is a project", () => {
|
|
505
|
+
fs.mkdirSync(path.join(tmp, ".git"));
|
|
506
|
+
expect(detectProjectContext(tmp)).toBe(true);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("directory with package.json: is a project", () => {
|
|
510
|
+
fs.writeFileSync(path.join(tmp, "package.json"), "{}");
|
|
511
|
+
expect(detectProjectContext(tmp)).toBe(true);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("directory with pyproject.toml: is a project", () => {
|
|
515
|
+
fs.writeFileSync(path.join(tmp, "pyproject.toml"), "[project]\n");
|
|
516
|
+
expect(detectProjectContext(tmp)).toBe(true);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("directory with Cargo.toml: is a project", () => {
|
|
520
|
+
fs.writeFileSync(path.join(tmp, "Cargo.toml"), "[package]\n");
|
|
521
|
+
expect(detectProjectContext(tmp)).toBe(true);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("does NOT walk up to find a marker — shallow only", () => {
|
|
525
|
+
// .git in tmp, but we ask about tmp/subdir.
|
|
526
|
+
fs.mkdirSync(path.join(tmp, ".git"));
|
|
527
|
+
fs.mkdirSync(path.join(tmp, "subdir"));
|
|
528
|
+
expect(detectProjectContext(path.join(tmp, "subdir"))).toBe(false);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe("detectExistingEntries", () => {
|
|
533
|
+
let tmpHome: string;
|
|
534
|
+
let tmpCwd: string;
|
|
535
|
+
let origHome: string | undefined;
|
|
536
|
+
|
|
537
|
+
beforeEach(() => {
|
|
538
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-existing-home-"));
|
|
539
|
+
tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), "vault-existing-cwd-"));
|
|
540
|
+
origHome = process.env.HOME;
|
|
541
|
+
process.env.HOME = tmpHome;
|
|
542
|
+
});
|
|
543
|
+
afterEach(() => {
|
|
544
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
545
|
+
else process.env.HOME = origHome;
|
|
546
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
547
|
+
fs.rmSync(tmpCwd, { recursive: true, force: true });
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("no files present: returns empty", () => {
|
|
551
|
+
expect(detectExistingEntries(tmpCwd)).toEqual({});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("detects singular parachute-vault entry at user scope", () => {
|
|
555
|
+
fs.writeFileSync(
|
|
556
|
+
path.join(tmpHome, ".claude.json"),
|
|
557
|
+
JSON.stringify({
|
|
558
|
+
mcpServers: {
|
|
559
|
+
"parachute-vault": {
|
|
560
|
+
type: "http",
|
|
561
|
+
url: "https://hub.example/vault/default/mcp",
|
|
562
|
+
headers: { Authorization: "Bearer x" },
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
567
|
+
const found = detectExistingEntries(tmpCwd);
|
|
568
|
+
expect(found.user).toBeDefined();
|
|
569
|
+
expect(found.user!.scope).toBe("user");
|
|
570
|
+
expect(found.user!.entryKey).toBe("parachute-vault");
|
|
571
|
+
expect(found.user!.hasAuth).toBe(true);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("detects per-vault parachute-vault-<name> entry at project scope", () => {
|
|
575
|
+
fs.writeFileSync(
|
|
576
|
+
path.join(tmpCwd, ".mcp.json"),
|
|
577
|
+
JSON.stringify({
|
|
578
|
+
mcpServers: {
|
|
579
|
+
"parachute-vault-work": {
|
|
580
|
+
type: "http",
|
|
581
|
+
url: "https://hub.example/vault/work/mcp",
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
const found = detectExistingEntries(tmpCwd);
|
|
587
|
+
expect(found.project).toBeDefined();
|
|
588
|
+
expect(found.project!.scope).toBe("project");
|
|
589
|
+
expect(found.project!.entryKey).toBe("parachute-vault-work");
|
|
590
|
+
expect(found.project!.hasAuth).toBe(false);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("prefers singular slot over per-vault entries when both exist in one file", () => {
|
|
594
|
+
fs.writeFileSync(
|
|
595
|
+
path.join(tmpHome, ".claude.json"),
|
|
596
|
+
JSON.stringify({
|
|
597
|
+
mcpServers: {
|
|
598
|
+
"parachute-vault-other": {
|
|
599
|
+
type: "http",
|
|
600
|
+
url: "https://hub.example/vault/other/mcp",
|
|
601
|
+
},
|
|
602
|
+
"parachute-vault": {
|
|
603
|
+
type: "http",
|
|
604
|
+
url: "https://hub.example/vault/default/mcp",
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
}),
|
|
608
|
+
);
|
|
609
|
+
const found = detectExistingEntries(tmpCwd);
|
|
610
|
+
expect(found.user!.entryKey).toBe("parachute-vault");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("malformed JSON does not throw — silently skipped", () => {
|
|
614
|
+
fs.writeFileSync(path.join(tmpHome, ".claude.json"), "{ not json");
|
|
615
|
+
expect(detectExistingEntries(tmpCwd)).toEqual({});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("detects local-scope entry under projects[<cwd>].mcpServers", () => {
|
|
619
|
+
const projectKey = path.resolve(tmpCwd);
|
|
620
|
+
fs.writeFileSync(
|
|
621
|
+
path.join(tmpHome, ".claude.json"),
|
|
622
|
+
JSON.stringify({
|
|
623
|
+
projects: {
|
|
624
|
+
[projectKey]: {
|
|
625
|
+
mcpServers: {
|
|
626
|
+
"parachute-vault": {
|
|
627
|
+
type: "http",
|
|
628
|
+
url: "https://hub.example/vault/default/mcp",
|
|
629
|
+
headers: { Authorization: "Bearer x" },
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
}),
|
|
635
|
+
);
|
|
636
|
+
const found = detectExistingEntries(tmpCwd);
|
|
637
|
+
expect(found.local).toBeDefined();
|
|
638
|
+
expect(found.local!.scope).toBe("local");
|
|
639
|
+
expect(found.local!.entryKey).toBe("parachute-vault");
|
|
640
|
+
expect(found.local!.label).toContain(projectKey);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("local-scope entry at a different cwd is not surfaced", () => {
|
|
644
|
+
// Operator did `mcp-install --install-scope local` from /Other/Project
|
|
645
|
+
// some time ago. The walkthrough running from tmpCwd today shouldn't
|
|
646
|
+
// misread that as an "existing here" entry — local scope is per-cwd.
|
|
647
|
+
fs.writeFileSync(
|
|
648
|
+
path.join(tmpHome, ".claude.json"),
|
|
649
|
+
JSON.stringify({
|
|
650
|
+
projects: {
|
|
651
|
+
"/Other/Project": {
|
|
652
|
+
mcpServers: {
|
|
653
|
+
"parachute-vault": {
|
|
654
|
+
type: "http",
|
|
655
|
+
url: "https://hub.example/vault/default/mcp",
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
}),
|
|
661
|
+
);
|
|
662
|
+
const found = detectExistingEntries(tmpCwd);
|
|
663
|
+
expect(found.local).toBeUndefined();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("user + local + project entries coexist in one detect call", () => {
|
|
667
|
+
const projectKey = path.resolve(tmpCwd);
|
|
668
|
+
fs.writeFileSync(
|
|
669
|
+
path.join(tmpHome, ".claude.json"),
|
|
670
|
+
JSON.stringify({
|
|
671
|
+
mcpServers: {
|
|
672
|
+
"parachute-vault": { type: "http", url: "https://hub.example/vault/u/mcp" },
|
|
673
|
+
},
|
|
674
|
+
projects: {
|
|
675
|
+
[projectKey]: {
|
|
676
|
+
mcpServers: {
|
|
677
|
+
"parachute-vault": { type: "http", url: "https://hub.example/vault/l/mcp" },
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
}),
|
|
682
|
+
);
|
|
683
|
+
fs.writeFileSync(
|
|
684
|
+
path.join(tmpCwd, ".mcp.json"),
|
|
685
|
+
JSON.stringify({
|
|
686
|
+
mcpServers: {
|
|
687
|
+
"parachute-vault": { type: "http", url: "https://hub.example/vault/p/mcp" },
|
|
688
|
+
},
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
const found = detectExistingEntries(tmpCwd);
|
|
692
|
+
expect(found.user).toBeDefined();
|
|
693
|
+
expect(found.local).toBeDefined();
|
|
694
|
+
expect(found.project).toBeDefined();
|
|
695
|
+
expect(found.user!.url).toContain("/vault/u/");
|
|
696
|
+
expect(found.local!.url).toContain("/vault/l/");
|
|
697
|
+
expect(found.project!.url).toContain("/vault/p/");
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe("detectInstallContext", () => {
|
|
702
|
+
let tmpHome: string;
|
|
703
|
+
let origHome: string | undefined;
|
|
704
|
+
let origParachuteHome: string | undefined;
|
|
705
|
+
|
|
706
|
+
beforeEach(() => {
|
|
707
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-detect-ctx-"));
|
|
708
|
+
origHome = process.env.HOME;
|
|
709
|
+
origParachuteHome = process.env.PARACHUTE_HOME;
|
|
710
|
+
process.env.HOME = tmpHome;
|
|
711
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
712
|
+
});
|
|
713
|
+
afterEach(() => {
|
|
714
|
+
if (origHome === undefined) delete process.env.HOME;
|
|
715
|
+
else process.env.HOME = origHome;
|
|
716
|
+
if (origParachuteHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
717
|
+
else process.env.PARACHUTE_HOME = origParachuteHome;
|
|
718
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("with hub origin env + operator.token: hubReachable + operatorTokenPresent both true", () => {
|
|
722
|
+
fs.writeFileSync(path.join(tmpHome, "operator.token"), "operator-bearer");
|
|
723
|
+
const ctx = detectInstallContext({
|
|
724
|
+
vaults: ["default"],
|
|
725
|
+
defaultVault: "default",
|
|
726
|
+
port: 1940,
|
|
727
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://hub.example", PARACHUTE_HOME: tmpHome, HOME: tmpHome },
|
|
728
|
+
});
|
|
729
|
+
expect(ctx.hubReachable).toBe(true);
|
|
730
|
+
expect(ctx.hubOrigin).toBe("https://hub.example");
|
|
731
|
+
expect(ctx.operatorTokenPresent).toBe(true);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test("without hub origin: hubReachable is false and origin is loopback", () => {
|
|
735
|
+
const ctx = detectInstallContext({
|
|
736
|
+
vaults: ["default"],
|
|
737
|
+
defaultVault: "default",
|
|
738
|
+
port: 1940,
|
|
739
|
+
env: { PARACHUTE_HOME: tmpHome, HOME: tmpHome },
|
|
740
|
+
});
|
|
741
|
+
expect(ctx.hubReachable).toBe(false);
|
|
742
|
+
expect(ctx.hubOrigin).toBe("http://127.0.0.1:1940");
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("without operator.token: operatorTokenPresent is false", () => {
|
|
746
|
+
const ctx = detectInstallContext({
|
|
747
|
+
vaults: ["default"],
|
|
748
|
+
defaultVault: "default",
|
|
749
|
+
port: 1940,
|
|
750
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://hub.example", PARACHUTE_HOME: tmpHome, HOME: tmpHome },
|
|
751
|
+
});
|
|
752
|
+
expect(ctx.operatorTokenPresent).toBe(false);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
// Preview-accuracy: what the walkthrough renders === what the install writes.
|
|
758
|
+
// ---------------------------------------------------------------------------
|
|
759
|
+
//
|
|
760
|
+
// vault#293. The walkthrough's preview shows the operator a JSON shape; the
|
|
761
|
+
// writer then constructs the actual entry. If the two compute the entry-key
|
|
762
|
+
// or URL by different recipes, the operator confirms a shape that doesn't
|
|
763
|
+
// land on disk. The seam is `buildMcpEntryPlan` — both call sites must use
|
|
764
|
+
// it. These tests pin the seam directly + assert the preview's logged
|
|
765
|
+
// entry-key matches what `buildMcpEntryPlan` produces for the equivalent
|
|
766
|
+
// decision shape.
|
|
767
|
+
|
|
768
|
+
// Direct unit tests for `buildMcpEntryPlan` live in `mcp-install.test.ts`
|
|
769
|
+
// alongside the rest of the `mcp-install.ts` module tests. The cross-check
|
|
770
|
+
// below asserts the consumer side (preview render) matches the helper's
|
|
771
|
+
// output for the equivalent decision shape.
|
|
772
|
+
|
|
773
|
+
describe("preview accuracy — what the walkthrough logs === buildMcpEntryPlan(decision)", () => {
|
|
774
|
+
// The walkthrough's preview render and the writer's entry-construction
|
|
775
|
+
// share `buildMcpEntryPlan` post-vault#293. These tests run the
|
|
776
|
+
// walkthrough end-to-end with a mock IO, capture the preview's logged
|
|
777
|
+
// entry-key + URL lines, and assert they match what `buildMcpEntryPlan`
|
|
778
|
+
// would produce for the decision shape the walkthrough returned. If
|
|
779
|
+
// either path drifts, this test goes red — without needing to spawn the
|
|
780
|
+
// CLI or touch the filesystem.
|
|
781
|
+
|
|
782
|
+
test("default-vault paste flow at user scope: preview matches buildMcpEntryPlan", async () => {
|
|
783
|
+
const { io } = mockIO([
|
|
784
|
+
null, // accept install-scope default ("user" — no project ctx, no existing)
|
|
785
|
+
"paste", // auth mode → paste
|
|
786
|
+
"PASTED-BEARER", // token prompt
|
|
787
|
+
true, // proceed at final confirm
|
|
788
|
+
]);
|
|
789
|
+
const ctx = baseCtx({
|
|
790
|
+
inProjectContext: false,
|
|
791
|
+
port: 1940,
|
|
792
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://hub.example" },
|
|
793
|
+
hubOrigin: "https://hub.example",
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const logs: string[] = [];
|
|
797
|
+
const captured: InteractiveIO = {
|
|
798
|
+
...io,
|
|
799
|
+
log: (line) => {
|
|
800
|
+
logs.push(line);
|
|
801
|
+
io.log(line);
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const decision = await runInteractiveInstall(ctx, captured);
|
|
806
|
+
expect(decision).not.toBe("abort");
|
|
807
|
+
if (decision === "abort") return;
|
|
808
|
+
|
|
809
|
+
// Extract the preview's entry-key + URL from captured logs.
|
|
810
|
+
const entryKeyLine = logs.find((l) => /^\s+"parachute-vault[^"]*":/.test(l));
|
|
811
|
+
const urlLine = logs.find((l) => /^\s+"url":/.test(l));
|
|
812
|
+
expect(entryKeyLine).toBeDefined();
|
|
813
|
+
expect(urlLine).toBeDefined();
|
|
814
|
+
const previewEntryKey = /"(parachute-vault[^"]*)"/.exec(entryKeyLine!)![1]!;
|
|
815
|
+
const previewUrl = /"url":\s*"([^"]+)"/.exec(urlLine!)![1]!;
|
|
816
|
+
|
|
817
|
+
// What the seam says the writer will produce for the same decision:
|
|
818
|
+
const plan = buildMcpEntryPlan({
|
|
819
|
+
vaultName: decision.vaultName,
|
|
820
|
+
vaultExplicit: decision.vaultExplicit,
|
|
821
|
+
port: ctx.port,
|
|
822
|
+
env: ctx.env,
|
|
823
|
+
...(decision.existingEntryKey ? { existingEntryKey: decision.existingEntryKey } : {}),
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
expect(previewEntryKey).toBe(plan.entryKey);
|
|
827
|
+
expect(previewUrl).toBe(plan.url);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test("update-existing flow reuses the existing entry-key in both preview and plan", async () => {
|
|
831
|
+
// Existing entry sits at `parachute-vault-legacy-name`. The
|
|
832
|
+
// walkthrough's "update where it is" branch should keep that key in
|
|
833
|
+
// the preview AND propagate it on the decision so the writer agrees.
|
|
834
|
+
const existing: ExistingMcpEntry = {
|
|
835
|
+
path: "/tmp/.claude.json",
|
|
836
|
+
label: "~/.claude.json",
|
|
837
|
+
scope: "user",
|
|
838
|
+
entryKey: "parachute-vault-legacy-name",
|
|
839
|
+
url: "https://hub.example/vault/default/mcp",
|
|
840
|
+
hasAuth: true,
|
|
841
|
+
};
|
|
842
|
+
const ctx = baseCtx({
|
|
843
|
+
existing: { user: existing },
|
|
844
|
+
port: 1940,
|
|
845
|
+
env: { PARACHUTE_HUB_ORIGIN: "https://hub.example" },
|
|
846
|
+
hubOrigin: "https://hub.example",
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const { io } = mockIO([
|
|
850
|
+
null, // accept "update where it is" (default true)
|
|
851
|
+
"paste",
|
|
852
|
+
"PASTED-BEARER",
|
|
853
|
+
true, // proceed
|
|
854
|
+
]);
|
|
855
|
+
const logs: string[] = [];
|
|
856
|
+
const captured: InteractiveIO = {
|
|
857
|
+
...io,
|
|
858
|
+
log: (line) => {
|
|
859
|
+
logs.push(line);
|
|
860
|
+
io.log(line);
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
const decision = await runInteractiveInstall(ctx, captured);
|
|
864
|
+
expect(decision).not.toBe("abort");
|
|
865
|
+
if (decision === "abort") return;
|
|
866
|
+
|
|
867
|
+
expect(decision.existingEntryKey).toBe("parachute-vault-legacy-name");
|
|
868
|
+
|
|
869
|
+
const previewEntryKeyLine = logs.find((l) => /^\s+"parachute-vault[^"]*":/.test(l));
|
|
870
|
+
expect(previewEntryKeyLine).toBeDefined();
|
|
871
|
+
const previewEntryKey = /"(parachute-vault[^"]*)"/.exec(previewEntryKeyLine!)![1]!;
|
|
872
|
+
expect(previewEntryKey).toBe("parachute-vault-legacy-name");
|
|
873
|
+
|
|
874
|
+
const plan = buildMcpEntryPlan({
|
|
875
|
+
vaultName: decision.vaultName,
|
|
876
|
+
vaultExplicit: decision.vaultExplicit,
|
|
877
|
+
port: ctx.port,
|
|
878
|
+
env: ctx.env,
|
|
879
|
+
existingEntryKey: decision.existingEntryKey!,
|
|
880
|
+
});
|
|
881
|
+
expect(plan.entryKey).toBe("parachute-vault-legacy-name");
|
|
882
|
+
});
|
|
883
|
+
});
|