@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.
@@ -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
+ });