@openparachute/vault 0.4.3 → 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 +58 -2
- package/core/src/core.test.ts +116 -0
- package/core/src/mcp.ts +94 -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 +19 -3
- package/src/vault.test.ts +141 -0
package/src/mcp-install.test.ts
CHANGED
|
@@ -1,15 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Tests for `mcp-install` — three concerns:
|
|
3
|
+
*
|
|
4
|
+
* 1. Pure helpers in `mcp-install.ts` (URL picking, operator-token reading,
|
|
5
|
+
* install-target resolution, hub-mint client with mocked fetch).
|
|
6
|
+
* 2. CLI flag parsing on `parachute-vault mcp-install` — mutual exclusion,
|
|
7
|
+
* validation of `--scope` / `--install-scope` / `--client`.
|
|
8
|
+
* 3. End-to-end behavior for the modes that don't require a hub: `--token`
|
|
9
|
+
* paste, `--legacy-pat` mint, `--install-scope project` write,
|
|
10
|
+
* `--vault <name>` per-vault keying. The `--mint` default's happy path
|
|
11
|
+
* needs a hub, so we only assert on its failure modes (no operator
|
|
12
|
+
* token, no hub configured).
|
|
13
|
+
*
|
|
14
|
+
* The URL picker was the only piece with prior coverage; everything else
|
|
15
|
+
* lands in this PR alongside the cmdMcpInstall rewrite.
|
|
6
16
|
*/
|
|
7
17
|
|
|
8
18
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
9
19
|
import fs from "node:fs";
|
|
10
20
|
import os from "node:os";
|
|
11
21
|
import path from "node:path";
|
|
12
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
buildMcpEntryPlan,
|
|
24
|
+
chooseHubOrigin,
|
|
25
|
+
chooseMcpUrl,
|
|
26
|
+
mintHubJwt,
|
|
27
|
+
readOperatorToken,
|
|
28
|
+
removeMcpConfig,
|
|
29
|
+
resolveInstallTarget,
|
|
30
|
+
} from "./mcp-install.ts";
|
|
31
|
+
|
|
32
|
+
const CLI = path.resolve(import.meta.dir, "cli.ts");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Spawn the CLI under an isolated `PARACHUTE_HOME` and `HOME`. Cwd defaults
|
|
36
|
+
* to a tempdir too so `--install-scope project` writes don't leak into the
|
|
37
|
+
* real working tree.
|
|
38
|
+
*/
|
|
39
|
+
function runCli(
|
|
40
|
+
args: string[],
|
|
41
|
+
parachuteHome: string,
|
|
42
|
+
extraEnv: Record<string, string | undefined> = {},
|
|
43
|
+
cwd?: string,
|
|
44
|
+
): { exitCode: number; stdout: string; stderr: string } {
|
|
45
|
+
const proc = Bun.spawnSync({
|
|
46
|
+
cmd: ["bun", CLI, ...args],
|
|
47
|
+
cwd: cwd ?? parachuteHome,
|
|
48
|
+
env: {
|
|
49
|
+
...process.env,
|
|
50
|
+
PARACHUTE_HOME: parachuteHome,
|
|
51
|
+
HOME: parachuteHome,
|
|
52
|
+
...extraEnv,
|
|
53
|
+
},
|
|
54
|
+
stdout: "pipe",
|
|
55
|
+
stderr: "pipe",
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
exitCode: proc.exitCode ?? -1,
|
|
59
|
+
stdout: new TextDecoder().decode(proc.stdout),
|
|
60
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Unit tests: pure helpers
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
13
67
|
|
|
14
68
|
describe("chooseMcpUrl", () => {
|
|
15
69
|
let tmpHome: string;
|
|
@@ -123,3 +177,901 @@ describe("chooseMcpUrl", () => {
|
|
|
123
177
|
expect(res.url).toBe("https://hub.example/vault/work/mcp");
|
|
124
178
|
});
|
|
125
179
|
});
|
|
180
|
+
|
|
181
|
+
describe("chooseHubOrigin", () => {
|
|
182
|
+
let tmpHome: string;
|
|
183
|
+
let origHome: string | undefined;
|
|
184
|
+
|
|
185
|
+
beforeEach(() => {
|
|
186
|
+
origHome = process.env.PARACHUTE_HOME;
|
|
187
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-hub-origin-"));
|
|
188
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
afterEach(() => {
|
|
192
|
+
if (origHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
193
|
+
else process.env.PARACHUTE_HOME = origHome;
|
|
194
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("returns the bare origin (no /vault/<name>/mcp suffix)", () => {
|
|
198
|
+
const res = chooseHubOrigin(1940, { PARACHUTE_HUB_ORIGIN: "https://hub.example" });
|
|
199
|
+
expect(res).toEqual({ url: "https://hub.example", source: "hub-origin" });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("returns loopback origin when nothing is configured (caller treats as no-hub)", () => {
|
|
203
|
+
const res = chooseHubOrigin(1940, {});
|
|
204
|
+
expect(res).toEqual({ url: "http://127.0.0.1:1940", source: "loopback" });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("derives the hub origin from an active expose-state FQDN", () => {
|
|
208
|
+
// Tailnet/public exposure with no hub env: hub origin uses the
|
|
209
|
+
// exposure's canonical FQDN (no /vault/<name>/mcp suffix — that's
|
|
210
|
+
// chooseMcpUrl's job, this returns the bare origin for the
|
|
211
|
+
// mint-token API call).
|
|
212
|
+
fs.writeFileSync(
|
|
213
|
+
path.join(tmpHome, "expose-state.json"),
|
|
214
|
+
JSON.stringify({
|
|
215
|
+
version: 1,
|
|
216
|
+
layer: "tailnet",
|
|
217
|
+
mode: "path",
|
|
218
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
219
|
+
port: 1940,
|
|
220
|
+
funnel: false,
|
|
221
|
+
entries: [],
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
const res = chooseHubOrigin(1940, {});
|
|
225
|
+
expect(res).toEqual({
|
|
226
|
+
url: "https://parachute.taildf9ce2.ts.net",
|
|
227
|
+
source: "expose-state",
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("malformed expose-state.json gracefully falls through to loopback", () => {
|
|
232
|
+
fs.writeFileSync(path.join(tmpHome, "expose-state.json"), "{ not json");
|
|
233
|
+
const res = chooseHubOrigin(1940, {});
|
|
234
|
+
expect(res.source).toBe("loopback");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// vault#293 — pin that the preview-render and the writer go through the same
|
|
239
|
+
// helper. If either side stops calling `buildMcpEntryPlan` (or the helper's
|
|
240
|
+
// shape changes), the preview can disagree with what lands on disk; these
|
|
241
|
+
// tests catch the helper-shape drift directly. The walkthrough/preview test
|
|
242
|
+
// in mcp-install-interactive.test.ts asserts the consumer-side cross-check
|
|
243
|
+
// (preview's logged entry-key/url match `buildMcpEntryPlan`'s output).
|
|
244
|
+
describe("buildMcpEntryPlan", () => {
|
|
245
|
+
test("singular key + per-vault key + URL match what the writer would land", () => {
|
|
246
|
+
const env = { PARACHUTE_HUB_ORIGIN: "https://hub.example" };
|
|
247
|
+
|
|
248
|
+
const singular = buildMcpEntryPlan({ vaultName: "default", vaultExplicit: false, port: 1940, env });
|
|
249
|
+
expect(singular.entryKey).toBe("parachute-vault");
|
|
250
|
+
expect(singular.url).toBe("https://hub.example/vault/default/mcp");
|
|
251
|
+
|
|
252
|
+
const perVault = buildMcpEntryPlan({ vaultName: "work", vaultExplicit: true, port: 1940, env });
|
|
253
|
+
expect(perVault.entryKey).toBe("parachute-vault-work");
|
|
254
|
+
expect(perVault.url).toBe("https://hub.example/vault/work/mcp");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("existingEntryKey wins over the synthesized key (preserves an in-place update)", () => {
|
|
258
|
+
// The walkthrough's update-existing branch pins the slot the entry
|
|
259
|
+
// already occupies — even if `vaultExplicit` would otherwise produce a
|
|
260
|
+
// different synthesized key, the plan must honor the existing slot or
|
|
261
|
+
// the writer lands at a different key than the preview promised.
|
|
262
|
+
const env = { PARACHUTE_HUB_ORIGIN: "https://hub.example" };
|
|
263
|
+
const res = buildMcpEntryPlan({
|
|
264
|
+
vaultName: "default",
|
|
265
|
+
vaultExplicit: false,
|
|
266
|
+
port: 1940,
|
|
267
|
+
env,
|
|
268
|
+
existingEntryKey: "parachute-vault-legacy-name",
|
|
269
|
+
});
|
|
270
|
+
expect(res.entryKey).toBe("parachute-vault-legacy-name");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("URL shape tracks chooseMcpUrl's source order (loopback when nothing configured)", () => {
|
|
274
|
+
// Mirror chooseMcpUrl's contract: empty env + no expose-state ⇒ loopback.
|
|
275
|
+
// Using a separate PARACHUTE_HOME so the real one's expose-state doesn't leak in.
|
|
276
|
+
const origHome = process.env.PARACHUTE_HOME;
|
|
277
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-build-plan-"));
|
|
278
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
279
|
+
try {
|
|
280
|
+
const res = buildMcpEntryPlan({ vaultName: "default", vaultExplicit: false, port: 1940, env: {} });
|
|
281
|
+
expect(res.source).toBe("loopback");
|
|
282
|
+
expect(res.url).toBe("http://127.0.0.1:1940/vault/default/mcp");
|
|
283
|
+
} finally {
|
|
284
|
+
if (origHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
285
|
+
else process.env.PARACHUTE_HOME = origHome;
|
|
286
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("readOperatorToken", () => {
|
|
292
|
+
let tmpHome: string;
|
|
293
|
+
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-op-token-"));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
afterEach(() => {
|
|
299
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("returns null when operator.token doesn't exist", () => {
|
|
303
|
+
expect(readOperatorToken({ PARACHUTE_HOME: tmpHome })).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("reads and trims the operator.token file when present", () => {
|
|
307
|
+
fs.writeFileSync(path.join(tmpHome, "operator.token"), " eyJabc.def.ghi \n");
|
|
308
|
+
expect(readOperatorToken({ PARACHUTE_HOME: tmpHome })).toBe("eyJabc.def.ghi");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("returns null for an empty operator.token", () => {
|
|
312
|
+
fs.writeFileSync(path.join(tmpHome, "operator.token"), " \n");
|
|
313
|
+
expect(readOperatorToken({ PARACHUTE_HOME: tmpHome })).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("resolveInstallTarget", () => {
|
|
318
|
+
test("user scope → ~/.claude.json", () => {
|
|
319
|
+
const res = resolveInstallTarget("user");
|
|
320
|
+
expect(res.path).toBe(path.resolve(os.homedir(), ".claude.json"));
|
|
321
|
+
expect(res.scope).toBe("user");
|
|
322
|
+
expect(res.localProjectKey).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("project scope → <cwd>/.mcp.json", () => {
|
|
326
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vault-target-"));
|
|
327
|
+
try {
|
|
328
|
+
const res = resolveInstallTarget("project", tmp);
|
|
329
|
+
expect(res.path).toBe(path.resolve(tmp, ".mcp.json"));
|
|
330
|
+
expect(res.scope).toBe("project");
|
|
331
|
+
expect(res.localProjectKey).toBeUndefined();
|
|
332
|
+
} finally {
|
|
333
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("local scope → ~/.claude.json with absolute-cwd project key", () => {
|
|
338
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vault-target-local-"));
|
|
339
|
+
try {
|
|
340
|
+
const res = resolveInstallTarget("local", tmp);
|
|
341
|
+
// Same file as user scope — what differs is where inside the JSON
|
|
342
|
+
// the entry lands (top-level vs projects[<cwd>].mcpServers).
|
|
343
|
+
expect(res.path).toBe(path.resolve(os.homedir(), ".claude.json"));
|
|
344
|
+
expect(res.scope).toBe("local");
|
|
345
|
+
// Absolute path → matches Claude Code's own convention.
|
|
346
|
+
expect(res.localProjectKey).toBe(path.resolve(tmp));
|
|
347
|
+
} finally {
|
|
348
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe("mintHubJwt", () => {
|
|
354
|
+
test("happy path returns parsed JWT payload", async () => {
|
|
355
|
+
const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
|
|
356
|
+
const mockFetch: typeof fetch = async (url, init) => {
|
|
357
|
+
calls.push({ url: String(url), init });
|
|
358
|
+
return new Response(
|
|
359
|
+
JSON.stringify({
|
|
360
|
+
jti: "jti-abc",
|
|
361
|
+
token: "eyJ.signed.jwt",
|
|
362
|
+
expires_at: "2026-08-09T00:00:00.000Z",
|
|
363
|
+
scope: "vault:default:read",
|
|
364
|
+
}),
|
|
365
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
366
|
+
);
|
|
367
|
+
};
|
|
368
|
+
const res = await mintHubJwt({
|
|
369
|
+
hubOrigin: "https://hub.example",
|
|
370
|
+
operatorToken: "operator-bearer",
|
|
371
|
+
scope: "vault:default:read",
|
|
372
|
+
fetchImpl: mockFetch,
|
|
373
|
+
});
|
|
374
|
+
expect("token" in res).toBe(true);
|
|
375
|
+
if (!("token" in res)) return; // narrow for TS
|
|
376
|
+
expect(res.token).toBe("eyJ.signed.jwt");
|
|
377
|
+
expect(res.scope).toBe("vault:default:read");
|
|
378
|
+
expect(calls).toHaveLength(1);
|
|
379
|
+
expect(calls[0]!.url).toBe("https://hub.example/api/auth/mint-token");
|
|
380
|
+
const headers = new Headers(calls[0]!.init?.headers);
|
|
381
|
+
expect(headers.get("authorization")).toBe("Bearer operator-bearer");
|
|
382
|
+
const body = JSON.parse(String(calls[0]!.init?.body));
|
|
383
|
+
expect(body.scope).toBe("vault:default:read");
|
|
384
|
+
expect(body.expires_in).toBeGreaterThan(0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("network error returns { kind: 'network' } with the origin and cause", async () => {
|
|
388
|
+
const mockFetch: typeof fetch = async () => {
|
|
389
|
+
throw new Error("connection refused");
|
|
390
|
+
};
|
|
391
|
+
const res = await mintHubJwt({
|
|
392
|
+
hubOrigin: "https://hub.example",
|
|
393
|
+
operatorToken: "bearer",
|
|
394
|
+
scope: "vault:default:read",
|
|
395
|
+
fetchImpl: mockFetch,
|
|
396
|
+
});
|
|
397
|
+
expect(res).toEqual({ kind: "network", cause: "connection refused", origin: "https://hub.example" });
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("API error surfaces hub error + error_description", async () => {
|
|
401
|
+
const mockFetch: typeof fetch = async () =>
|
|
402
|
+
new Response(
|
|
403
|
+
JSON.stringify({ error: "insufficient_scope", error_description: "bearer lacks parachute:host:auth" }),
|
|
404
|
+
{ status: 403, headers: { "Content-Type": "application/json" } },
|
|
405
|
+
);
|
|
406
|
+
const res = await mintHubJwt({
|
|
407
|
+
hubOrigin: "https://hub.example",
|
|
408
|
+
operatorToken: "bearer",
|
|
409
|
+
scope: "vault:default:read",
|
|
410
|
+
fetchImpl: mockFetch,
|
|
411
|
+
});
|
|
412
|
+
expect(res).toEqual({
|
|
413
|
+
kind: "api-error",
|
|
414
|
+
status: 403,
|
|
415
|
+
error: "insufficient_scope",
|
|
416
|
+
description: "bearer lacks parachute:host:auth",
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("malformed success response degrades to api-error rather than crashing", async () => {
|
|
421
|
+
const mockFetch: typeof fetch = async () =>
|
|
422
|
+
new Response(JSON.stringify({ token: "x" }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
423
|
+
const res = await mintHubJwt({
|
|
424
|
+
hubOrigin: "https://hub.example",
|
|
425
|
+
operatorToken: "bearer",
|
|
426
|
+
scope: "vault:default:read",
|
|
427
|
+
fetchImpl: mockFetch,
|
|
428
|
+
});
|
|
429
|
+
expect("kind" in res && res.kind === "api-error").toBe(true);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// CLI flag-parsing tests — these don't need a vault DB
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
describe("mcp-install flag parsing", () => {
|
|
438
|
+
let tmp: string;
|
|
439
|
+
beforeEach(() => {
|
|
440
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-flags-"));
|
|
441
|
+
});
|
|
442
|
+
afterEach(() => {
|
|
443
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("rejects mutually exclusive --mint and --token", () => {
|
|
447
|
+
const res = runCli(["mcp-install", "--mint", "--token", "abc"], tmp);
|
|
448
|
+
expect(res.exitCode).toBe(1);
|
|
449
|
+
expect(res.stderr).toMatch(/mutually exclusive/);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("rejects mutually exclusive --token and --legacy-pat", () => {
|
|
453
|
+
const res = runCli(["mcp-install", "--token", "abc", "--legacy-pat"], tmp);
|
|
454
|
+
expect(res.exitCode).toBe(1);
|
|
455
|
+
expect(res.stderr).toMatch(/mutually exclusive/);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("rejects --token without a value", () => {
|
|
459
|
+
const res = runCli(["mcp-install", "--token"], tmp);
|
|
460
|
+
expect(res.exitCode).toBe(1);
|
|
461
|
+
expect(res.stderr).toMatch(/--token requires a value/);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("rejects an unknown --scope value", () => {
|
|
465
|
+
const res = runCli(["mcp-install", "--scope", "vault:bogus", "--token", "t"], tmp);
|
|
466
|
+
expect(res.exitCode).toBe(1);
|
|
467
|
+
expect(res.stderr).toMatch(/--scope must be one of/);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("rejects an unknown --install-scope value", () => {
|
|
471
|
+
const res = runCli(
|
|
472
|
+
["mcp-install", "--install-scope", "system-wide", "--token", "t"],
|
|
473
|
+
tmp,
|
|
474
|
+
);
|
|
475
|
+
expect(res.exitCode).toBe(1);
|
|
476
|
+
expect(res.stderr).toMatch(/--install-scope must be "local", "user", or "project"/);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("accepts --install-scope local", () => {
|
|
480
|
+
setupBareVault(tmp, "default");
|
|
481
|
+
const res = runCli(
|
|
482
|
+
["mcp-install", "--install-scope", "local", "--token", "t"],
|
|
483
|
+
tmp,
|
|
484
|
+
);
|
|
485
|
+
expect(res.exitCode).toBe(0);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("rejects a --client other than claude-code (Phase C is future work)", () => {
|
|
489
|
+
const res = runCli(
|
|
490
|
+
["mcp-install", "--client", "cursor", "--token", "t"],
|
|
491
|
+
tmp,
|
|
492
|
+
);
|
|
493
|
+
expect(res.exitCode).toBe(1);
|
|
494
|
+
expect(res.stderr).toMatch(/not yet supported|Phase C/);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("rejects an unknown vault", () => {
|
|
498
|
+
const res = runCli(
|
|
499
|
+
["mcp-install", "--vault", "ghost", "--token", "t"],
|
|
500
|
+
tmp,
|
|
501
|
+
);
|
|
502
|
+
expect(res.exitCode).toBe(1);
|
|
503
|
+
expect(res.stderr).toMatch(/Vault "ghost" not found/);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("rejects --mint when there's no operator.token", () => {
|
|
507
|
+
// Pre-create a vault directory so the existence check passes — the
|
|
508
|
+
// operator.token check fires before any network hit.
|
|
509
|
+
setupBareVault(tmp, "default");
|
|
510
|
+
const res = runCli(["mcp-install"], tmp);
|
|
511
|
+
expect(res.exitCode).toBe(1);
|
|
512
|
+
expect(res.stderr).toMatch(/No operator token found/);
|
|
513
|
+
expect(res.stderr).toMatch(/parachute auth rotate-operator/);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("rejects --mint when no hub is configured (loopback-only)", () => {
|
|
517
|
+
setupBareVault(tmp, "default");
|
|
518
|
+
fs.writeFileSync(path.join(tmp, "operator.token"), "operator-bearer-stub");
|
|
519
|
+
// No PARACHUTE_HUB_ORIGIN, no expose-state.json — chooseHubOrigin
|
|
520
|
+
// returns loopback, which is correctly rejected.
|
|
521
|
+
const res = runCli(["mcp-install"], tmp, { PARACHUTE_HUB_ORIGIN: "" });
|
|
522
|
+
expect(res.exitCode).toBe(1);
|
|
523
|
+
expect(res.stderr).toMatch(/No hub origin configured/);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// End-to-end: modes that don't need a hub (--token, --legacy-pat,
|
|
529
|
+
// --install-scope project, --vault per-vault keying)
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
describe("mcp-install end-to-end", () => {
|
|
533
|
+
let tmp: string;
|
|
534
|
+
beforeEach(() => {
|
|
535
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-e2e-"));
|
|
536
|
+
});
|
|
537
|
+
afterEach(() => {
|
|
538
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("--install-scope user writes top-level mcpServers in ~/.claude.json", () => {
|
|
542
|
+
setupBareVault(tmp, "default");
|
|
543
|
+
const res = runCli(
|
|
544
|
+
["mcp-install", "--install-scope", "user", "--token", "pasted-bearer"],
|
|
545
|
+
tmp,
|
|
546
|
+
);
|
|
547
|
+
expect(res.exitCode).toBe(0);
|
|
548
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
549
|
+
const entry = config.mcpServers["parachute-vault"];
|
|
550
|
+
expect(entry.type).toBe("http");
|
|
551
|
+
expect(entry.headers.Authorization).toBe("Bearer pasted-bearer");
|
|
552
|
+
expect(entry.url).toContain("/vault/default/mcp");
|
|
553
|
+
// No projects[*] entry written.
|
|
554
|
+
expect(config.projects).toBeUndefined();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("default (no --install-scope) writes ~/.claude.json under projects[<cwd>].mcpServers (local)", () => {
|
|
558
|
+
setupBareVault(tmp, "default");
|
|
559
|
+
// CLI runs with cwd=tmp (the parachute-home temp dir). Local-scope
|
|
560
|
+
// default keys the entry under projects[<cwd>] in the same file
|
|
561
|
+
// user-scope uses (~/.claude.json). On macOS /tmp is a symlink to
|
|
562
|
+
// /private/tmp, so the spawned process's cwd resolves through —
|
|
563
|
+
// use fs.realpathSync to compute the same key the CLI will write.
|
|
564
|
+
const res = runCli(
|
|
565
|
+
["mcp-install", "--token", "local-bearer"],
|
|
566
|
+
tmp,
|
|
567
|
+
);
|
|
568
|
+
expect(res.exitCode).toBe(0);
|
|
569
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
570
|
+
// The new default is `local` — top-level mcpServers should be empty
|
|
571
|
+
// or absent; the entry lives under projects[<absolute-cwd>].
|
|
572
|
+
const flatEntry = config.mcpServers?.["parachute-vault"];
|
|
573
|
+
expect(flatEntry).toBeUndefined();
|
|
574
|
+
const projectKey = fs.realpathSync(tmp);
|
|
575
|
+
const localServers = config.projects?.[projectKey]?.mcpServers ?? {};
|
|
576
|
+
expect(localServers["parachute-vault"]).toBeDefined();
|
|
577
|
+
expect(localServers["parachute-vault"].headers.Authorization).toBe("Bearer local-bearer");
|
|
578
|
+
expect(localServers["parachute-vault"].url).toContain("/vault/default/mcp");
|
|
579
|
+
// Stdout surfaces the consequence so operators know the install is
|
|
580
|
+
// scoped to this directory (and how to widen if they wanted global).
|
|
581
|
+
expect(res.stdout).toMatch(/this directory only/);
|
|
582
|
+
expect(res.stdout).toMatch(/--install-scope user/);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("--install-scope project writes <cwd>/.mcp.json instead of ~/.claude.json", () => {
|
|
586
|
+
setupBareVault(tmp, "default");
|
|
587
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-project-"));
|
|
588
|
+
try {
|
|
589
|
+
const res = runCli(
|
|
590
|
+
["mcp-install", "--install-scope", "project", "--token", "pasted"],
|
|
591
|
+
tmp,
|
|
592
|
+
{},
|
|
593
|
+
projectDir,
|
|
594
|
+
);
|
|
595
|
+
expect(res.exitCode).toBe(0);
|
|
596
|
+
// Wrote into the project, not the user.
|
|
597
|
+
expect(fs.existsSync(path.join(projectDir, ".mcp.json"))).toBe(true);
|
|
598
|
+
expect(fs.existsSync(path.join(tmp, ".claude.json"))).toBe(false);
|
|
599
|
+
const config = readJson(path.join(projectDir, ".mcp.json"));
|
|
600
|
+
expect(config.mcpServers["parachute-vault"].headers.Authorization).toBe(
|
|
601
|
+
"Bearer pasted",
|
|
602
|
+
);
|
|
603
|
+
} finally {
|
|
604
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("writer URL on disk matches buildMcpEntryPlan(env) — non-default PARACHUTE_HUB_ORIGIN (vault#302)", () => {
|
|
609
|
+
// The preview ⇄ writer invariant introduced in vault#301 closed
|
|
610
|
+
// `entryKey` but left `url` only-coincidentally-coherent: production
|
|
611
|
+
// both branches read `process.env`, so they agreed by accident.
|
|
612
|
+
// vault#302 closes the URL half too — `installMcpConfig` now receives
|
|
613
|
+
// the URL from the caller's `buildMcpEntryPlan({ env, ... })` rather
|
|
614
|
+
// than re-computing via a direct `chooseMcpUrl(vaultName, port)`. This
|
|
615
|
+
// test exercises a non-default env so a regression that reintroduces
|
|
616
|
+
// the direct `chooseMcpUrl` call (which would re-read `process.env`,
|
|
617
|
+
// possibly without the test's intended override) goes red.
|
|
618
|
+
setupBareVault(tmp, "default");
|
|
619
|
+
const hubOrigin = "https://hub-302.example";
|
|
620
|
+
const res = runCli(
|
|
621
|
+
["mcp-install", "--install-scope", "user", "--token", "pasted-302"],
|
|
622
|
+
tmp,
|
|
623
|
+
{ PARACHUTE_HUB_ORIGIN: hubOrigin },
|
|
624
|
+
);
|
|
625
|
+
expect(res.exitCode).toBe(0);
|
|
626
|
+
|
|
627
|
+
// Compute what `buildMcpEntryPlan` says the URL should be for the same
|
|
628
|
+
// env the writer ran under. The writer must land exactly this URL.
|
|
629
|
+
const plan = buildMcpEntryPlan({
|
|
630
|
+
vaultName: "default",
|
|
631
|
+
vaultExplicit: false,
|
|
632
|
+
port: 1940,
|
|
633
|
+
env: { PARACHUTE_HUB_ORIGIN: hubOrigin },
|
|
634
|
+
});
|
|
635
|
+
expect(plan.url).toBe(`${hubOrigin}/vault/default/mcp`);
|
|
636
|
+
expect(plan.source).toBe("hub-origin");
|
|
637
|
+
|
|
638
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
639
|
+
const entry = config.mcpServers[plan.entryKey];
|
|
640
|
+
expect(entry).toBeDefined();
|
|
641
|
+
expect(entry.url).toBe(plan.url);
|
|
642
|
+
expect(entry.headers.Authorization).toBe("Bearer pasted-302");
|
|
643
|
+
|
|
644
|
+
// Stdout's `MCP URL:` line surfaces the same URL so an operator
|
|
645
|
+
// reading the log sees what landed (was the same `url` variable
|
|
646
|
+
// pre-#302 by coincidence; this pins it explicitly).
|
|
647
|
+
expect(res.stdout).toContain(`MCP URL: ${plan.url}`);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("--install-scope local writes ~/.claude.json under projects[<cwd>].mcpServers", () => {
|
|
651
|
+
setupBareVault(tmp, "default");
|
|
652
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-local-"));
|
|
653
|
+
try {
|
|
654
|
+
const res = runCli(
|
|
655
|
+
["mcp-install", "--install-scope", "local", "--token", "local-bearer"],
|
|
656
|
+
tmp,
|
|
657
|
+
{},
|
|
658
|
+
projectDir,
|
|
659
|
+
);
|
|
660
|
+
expect(res.exitCode).toBe(0);
|
|
661
|
+
// Local writes to ~/.claude.json under projects[<absolute-cwd>].
|
|
662
|
+
// Not <cwd>/.mcp.json (that's project scope).
|
|
663
|
+
expect(fs.existsSync(path.join(projectDir, ".mcp.json"))).toBe(false);
|
|
664
|
+
expect(fs.existsSync(path.join(tmp, ".claude.json"))).toBe(true);
|
|
665
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
666
|
+
const projectKey = fs.realpathSync(projectDir);
|
|
667
|
+
const entry = config.projects[projectKey].mcpServers["parachute-vault"];
|
|
668
|
+
expect(entry.type).toBe("http");
|
|
669
|
+
expect(entry.headers.Authorization).toBe("Bearer local-bearer");
|
|
670
|
+
expect(entry.url).toContain("/vault/default/mcp");
|
|
671
|
+
// No top-level entry written for local scope.
|
|
672
|
+
expect(config.mcpServers?.["parachute-vault"]).toBeUndefined();
|
|
673
|
+
} finally {
|
|
674
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("--install-scope local preserves an existing top-level entry untouched", () => {
|
|
679
|
+
setupBareVault(tmp, "default");
|
|
680
|
+
// Pre-seed ~/.claude.json with a top-level user-scope entry from some
|
|
681
|
+
// other client. The local install must not clobber it.
|
|
682
|
+
fs.writeFileSync(
|
|
683
|
+
path.join(tmp, ".claude.json"),
|
|
684
|
+
JSON.stringify({
|
|
685
|
+
mcpServers: {
|
|
686
|
+
"some-other-server": { type: "http", url: "https://other.example/mcp" },
|
|
687
|
+
},
|
|
688
|
+
}),
|
|
689
|
+
);
|
|
690
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-local-pre-"));
|
|
691
|
+
try {
|
|
692
|
+
const res = runCli(
|
|
693
|
+
["mcp-install", "--install-scope", "local", "--token", "t"],
|
|
694
|
+
tmp,
|
|
695
|
+
{},
|
|
696
|
+
projectDir,
|
|
697
|
+
);
|
|
698
|
+
expect(res.exitCode).toBe(0);
|
|
699
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
700
|
+
// Pre-existing top-level entry preserved.
|
|
701
|
+
expect(config.mcpServers["some-other-server"]).toBeDefined();
|
|
702
|
+
// New local entry under projects[<cwd>].
|
|
703
|
+
const projectKey = fs.realpathSync(projectDir);
|
|
704
|
+
expect(config.projects[projectKey].mcpServers["parachute-vault"]).toBeDefined();
|
|
705
|
+
} finally {
|
|
706
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("--install-scope local preserves a pre-existing project's other mcp servers", () => {
|
|
711
|
+
setupBareVault(tmp, "default");
|
|
712
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-local-coexist-"));
|
|
713
|
+
const projectKey = fs.realpathSync(projectDir);
|
|
714
|
+
// Pre-seed a projects[cwd] entry with an unrelated MCP server +
|
|
715
|
+
// additional fields. The install must not blow them away.
|
|
716
|
+
fs.writeFileSync(
|
|
717
|
+
path.join(tmp, ".claude.json"),
|
|
718
|
+
JSON.stringify({
|
|
719
|
+
projects: {
|
|
720
|
+
[projectKey]: {
|
|
721
|
+
allowedTools: ["Bash(ls:*)"],
|
|
722
|
+
mcpServers: {
|
|
723
|
+
"glif": { type: "http", url: "https://glif.example/mcp" },
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
}),
|
|
728
|
+
);
|
|
729
|
+
try {
|
|
730
|
+
const res = runCli(
|
|
731
|
+
["mcp-install", "--install-scope", "local", "--token", "t"],
|
|
732
|
+
tmp,
|
|
733
|
+
{},
|
|
734
|
+
projectDir,
|
|
735
|
+
);
|
|
736
|
+
expect(res.exitCode).toBe(0);
|
|
737
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
738
|
+
// Pre-existing sibling preserved.
|
|
739
|
+
expect(config.projects[projectKey].mcpServers["glif"]).toBeDefined();
|
|
740
|
+
// Pre-existing non-mcpServers field preserved.
|
|
741
|
+
expect(config.projects[projectKey].allowedTools).toEqual(["Bash(ls:*)"]);
|
|
742
|
+
// New entry added.
|
|
743
|
+
expect(config.projects[projectKey].mcpServers["parachute-vault"]).toBeDefined();
|
|
744
|
+
} finally {
|
|
745
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("--vault <name> keys the entry as parachute-vault-<name> (user scope)", () => {
|
|
750
|
+
setupBareVault(tmp, "default");
|
|
751
|
+
setupBareVault(tmp, "work");
|
|
752
|
+
// Install for the second vault under user scope — singular slot
|
|
753
|
+
// stays free for the default. Multi-vault is the motivation.
|
|
754
|
+
const res = runCli(
|
|
755
|
+
["mcp-install", "--install-scope", "user", "--vault", "work", "--token", "work-bearer"],
|
|
756
|
+
tmp,
|
|
757
|
+
);
|
|
758
|
+
expect(res.exitCode).toBe(0);
|
|
759
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
760
|
+
expect(config.mcpServers["parachute-vault-work"]).toBeDefined();
|
|
761
|
+
expect(config.mcpServers["parachute-vault-work"].url).toContain("/vault/work/mcp");
|
|
762
|
+
expect(config.mcpServers["parachute-vault-work"].headers.Authorization).toBe(
|
|
763
|
+
"Bearer work-bearer",
|
|
764
|
+
);
|
|
765
|
+
// The singular slot is untouched — we'd preserve a pre-existing entry,
|
|
766
|
+
// but here there was none so it's just absent.
|
|
767
|
+
expect(config.mcpServers["parachute-vault"]).toBeUndefined();
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("--vault without an explicit name uses the default and keys the singular slot (user scope)", () => {
|
|
771
|
+
setupBareVault(tmp, "default");
|
|
772
|
+
const res = runCli(
|
|
773
|
+
["mcp-install", "--install-scope", "user", "--token", "default-bearer"],
|
|
774
|
+
tmp,
|
|
775
|
+
);
|
|
776
|
+
expect(res.exitCode).toBe(0);
|
|
777
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
778
|
+
expect(config.mcpServers["parachute-vault"]).toBeDefined();
|
|
779
|
+
// Per-vault key for the default vault is NOT written when --vault wasn't
|
|
780
|
+
// explicit; the singular slot is the canonical single-install shape.
|
|
781
|
+
expect(config.mcpServers["parachute-vault-default"]).toBeUndefined();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("--legacy-pat mints a vault-DB pvt_* token and prints deprecation warning", () => {
|
|
785
|
+
setupBareVault(tmp, "default");
|
|
786
|
+
const res = runCli(["mcp-install", "--install-scope", "user", "--legacy-pat"], tmp);
|
|
787
|
+
expect(res.exitCode).toBe(0);
|
|
788
|
+
// Deprecation warning lands on stderr (we used `console.error` for the
|
|
789
|
+
// notice so it's visible without polluting stdout). The message names
|
|
790
|
+
// the tracking issue + planned-removal milestone so operators can
|
|
791
|
+
// see what they're opting into.
|
|
792
|
+
expect(res.stderr).toMatch(/--legacy-pat mints a vault-DB pvt_/);
|
|
793
|
+
expect(res.stderr).toMatch(/canonical install going forward/);
|
|
794
|
+
expect(res.stderr).toMatch(/vault#288/);
|
|
795
|
+
expect(res.stderr).toMatch(/planned removal 0\.6\.0/);
|
|
796
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
797
|
+
const bearer = config.mcpServers["parachute-vault"].headers.Authorization;
|
|
798
|
+
expect(bearer).toMatch(/^Bearer pvt_/);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("subsequent --token install on top of an existing entry overwrites the bearer (user scope)", () => {
|
|
802
|
+
setupBareVault(tmp, "default");
|
|
803
|
+
runCli(["mcp-install", "--install-scope", "user", "--token", "first"], tmp);
|
|
804
|
+
const res = runCli(["mcp-install", "--install-scope", "user", "--token", "second"], tmp);
|
|
805
|
+
expect(res.exitCode).toBe(0);
|
|
806
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
807
|
+
expect(config.mcpServers["parachute-vault"].headers.Authorization).toBe("Bearer second");
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test("subsequent --token install in local scope overwrites the bearer at projects[<cwd>]", () => {
|
|
811
|
+
setupBareVault(tmp, "default");
|
|
812
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-local-update-"));
|
|
813
|
+
try {
|
|
814
|
+
runCli(["mcp-install", "--install-scope", "local", "--token", "first"], tmp, {}, projectDir);
|
|
815
|
+
const res = runCli(["mcp-install", "--install-scope", "local", "--token", "second"], tmp, {}, projectDir);
|
|
816
|
+
expect(res.exitCode).toBe(0);
|
|
817
|
+
const config = readJson(path.join(tmp, ".claude.json"));
|
|
818
|
+
const projectKey = fs.realpathSync(projectDir);
|
|
819
|
+
expect(config.projects[projectKey].mcpServers["parachute-vault"].headers.Authorization).toBe("Bearer second");
|
|
820
|
+
} finally {
|
|
821
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("removeMcpConfig walks every projects[*] slot regardless of cwd", () => {
|
|
826
|
+
// Pins the cwd-agnostic property of removeMcpConfig's local-scope walk.
|
|
827
|
+
// Called directly rather than via subprocess `uninstall --yes` because
|
|
828
|
+
// the CLI uninstall also runs `uninstallAgent()` against the real
|
|
829
|
+
// launchd label `computer.parachute.vault` — not isolated by
|
|
830
|
+
// PARACHUTE_HOME, so a subprocess test would nuke a real registered
|
|
831
|
+
// daemon. Direct call keeps the test focused on the cleanup walk,
|
|
832
|
+
// which is the property the reviewer flagged as untested.
|
|
833
|
+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-rmcfg-home-"));
|
|
834
|
+
const cwdA = "/Users/imaginary/projectA";
|
|
835
|
+
const cwdB = "/Users/imaginary/projectB";
|
|
836
|
+
const claudePath = path.join(fakeHome, ".claude.json");
|
|
837
|
+
fs.writeFileSync(
|
|
838
|
+
claudePath,
|
|
839
|
+
JSON.stringify({
|
|
840
|
+
mcpServers: { "parachute-vault": { type: "http", url: "user-scope" } },
|
|
841
|
+
projects: {
|
|
842
|
+
[cwdA]: {
|
|
843
|
+
allowedTools: ["Bash(ls:*)"],
|
|
844
|
+
mcpServers: {
|
|
845
|
+
"parachute-vault": { type: "http", url: "a" },
|
|
846
|
+
"some-other-server": { type: "http", url: "kept-a" },
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
[cwdB]: {
|
|
850
|
+
mcpServers: {
|
|
851
|
+
"parachute-vault-extra": { type: "http", url: "b1" },
|
|
852
|
+
"parachute-vault/legacy": { type: "http", url: "b2-legacy" },
|
|
853
|
+
"kept-server": { type: "http", url: "kept-b" },
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
}) + "\n",
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
// Bun's `os.homedir()` reads `process.env.HOME` first — swapping it for
|
|
861
|
+
// the duration of the call routes the cleanup at fakeHome's .claude.json,
|
|
862
|
+
// not the real user's. Restored in `finally`.
|
|
863
|
+
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), "vault-rmcfg-cwd-"));
|
|
864
|
+
const origHome = process.env.HOME;
|
|
865
|
+
const origCwd = process.cwd();
|
|
866
|
+
try {
|
|
867
|
+
process.env.HOME = fakeHome;
|
|
868
|
+
process.chdir(elsewhere);
|
|
869
|
+
removeMcpConfig();
|
|
870
|
+
} finally {
|
|
871
|
+
process.chdir(origCwd);
|
|
872
|
+
if (origHome !== undefined) process.env.HOME = origHome;
|
|
873
|
+
else delete process.env.HOME;
|
|
874
|
+
fs.rmSync(elsewhere, { recursive: true, force: true });
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const after = JSON.parse(fs.readFileSync(claudePath, "utf-8"));
|
|
878
|
+
|
|
879
|
+
// User-scope: stripped.
|
|
880
|
+
expect(after.mcpServers["parachute-vault"]).toBeUndefined();
|
|
881
|
+
|
|
882
|
+
// projectA: vault entry gone, sibling MCP + allowedTools preserved.
|
|
883
|
+
expect(after.projects[cwdA].mcpServers["parachute-vault"]).toBeUndefined();
|
|
884
|
+
expect(after.projects[cwdA].mcpServers["some-other-server"]).toBeDefined();
|
|
885
|
+
expect(after.projects[cwdA].allowedTools).toEqual(["Bash(ls:*)"]);
|
|
886
|
+
|
|
887
|
+
// projectB: per-vault `parachute-vault-*` + legacy slash-form gone,
|
|
888
|
+
// unrelated sibling preserved.
|
|
889
|
+
expect(after.projects[cwdB].mcpServers["parachute-vault-extra"]).toBeUndefined();
|
|
890
|
+
expect(after.projects[cwdB].mcpServers["parachute-vault/legacy"]).toBeUndefined();
|
|
891
|
+
expect(after.projects[cwdB].mcpServers["kept-server"]).toBeDefined();
|
|
892
|
+
|
|
893
|
+
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
// Interactive dispatch — CLI-level (subprocess) regression tests
|
|
899
|
+
// ---------------------------------------------------------------------------
|
|
900
|
+
|
|
901
|
+
describe("mcp-install interactive dispatch", () => {
|
|
902
|
+
let tmp: string;
|
|
903
|
+
beforeEach(() => {
|
|
904
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-dispatch-"));
|
|
905
|
+
});
|
|
906
|
+
afterEach(() => {
|
|
907
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
test("no flags + non-TTY stdin (piped) falls through to flag-driven path with defaults", () => {
|
|
911
|
+
// Subprocess with stdin piped from /dev/null: process.stdin.isTTY is
|
|
912
|
+
// false, so interactive must NOT engage. Defaults push us to --mint,
|
|
913
|
+
// which fails (no operator.token in the isolated PARACHUTE_HOME).
|
|
914
|
+
// The point of this test isn't the failure mode per se — it's that
|
|
915
|
+
// we exit on the existing non-interactive code path, not stall
|
|
916
|
+
// waiting for a prompt no one can answer.
|
|
917
|
+
setupBareVault(tmp, "default");
|
|
918
|
+
const res = runCli(["mcp-install"], tmp);
|
|
919
|
+
expect(res.exitCode).toBe(1);
|
|
920
|
+
expect(res.stderr).toMatch(/No operator token found/);
|
|
921
|
+
// Crucial: no prompt-shaped output. If we'd accidentally dispatched
|
|
922
|
+
// to interactive, we'd see "Setting up Parachute Vault" before
|
|
923
|
+
// hanging on a prompt.
|
|
924
|
+
expect(res.stdout).not.toMatch(/Setting up Parachute Vault/);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
test("any install-shaping flag bypasses the walkthrough", () => {
|
|
928
|
+
// --legacy-pat triggers the flag-driven path even on a TTY. The
|
|
929
|
+
// walkthrough mustn't fire when a flag is present, so its
|
|
930
|
+
// "Setting up…" banner must not appear in output.
|
|
931
|
+
setupBareVault(tmp, "default");
|
|
932
|
+
const res = runCli(["mcp-install", "--legacy-pat"], tmp);
|
|
933
|
+
expect(res.exitCode).toBe(0);
|
|
934
|
+
expect(res.stdout).not.toMatch(/Setting up Parachute Vault/);
|
|
935
|
+
// The deprecation banner *should* show — confirms flag-driven path
|
|
936
|
+
// ran end-to-end.
|
|
937
|
+
expect(res.stderr).toMatch(/--legacy-pat mints a vault-DB pvt_/);
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
// ---------------------------------------------------------------------------
|
|
942
|
+
// Test helpers
|
|
943
|
+
// ---------------------------------------------------------------------------
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Create the minimum vault state that `cmdMcpInstall` validates against:
|
|
947
|
+
* the vault directory + vault.yaml that `readVaultConfig` reads. Also seeds
|
|
948
|
+
* `config.yaml` so `default_vault` points at the named vault. Avoids the
|
|
949
|
+
* full `parachute-vault init` subprocess (slow + heavy) when all we need
|
|
950
|
+
* is "the vault exists by name."
|
|
951
|
+
*/
|
|
952
|
+
function setupBareVault(parachuteHome: string, name: string): void {
|
|
953
|
+
// Vault layout: <PARACHUTE_HOME>/vault/data/<name>/vault.yaml.
|
|
954
|
+
// `readVaultConfig` and `listVaults` both look here.
|
|
955
|
+
const vaultsDir = path.join(parachuteHome, "vault", "data");
|
|
956
|
+
fs.mkdirSync(path.join(vaultsDir, name), { recursive: true });
|
|
957
|
+
fs.writeFileSync(
|
|
958
|
+
path.join(vaultsDir, name, "vault.yaml"),
|
|
959
|
+
`name: ${name}\napi_keys: []\n`,
|
|
960
|
+
);
|
|
961
|
+
// First vault written becomes default_vault; subsequent vaults leave it
|
|
962
|
+
// alone. Keeps test setup terse — no separate global config writes.
|
|
963
|
+
// (The DB file gets created on first open by bun:sqlite, so we don't
|
|
964
|
+
// pre-seed it; --legacy-pat opens it lazily.)
|
|
965
|
+
const globalPath = path.join(parachuteHome, "vault", "config.yaml");
|
|
966
|
+
if (!fs.existsSync(globalPath)) {
|
|
967
|
+
fs.mkdirSync(path.dirname(globalPath), { recursive: true });
|
|
968
|
+
fs.writeFileSync(globalPath, `default_vault: ${name}\nport: 1940\n`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function readJson(p: string): any {
|
|
973
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// Uninstall flow — end-to-end with --skip-daemon (vault#296)
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
//
|
|
980
|
+
// `parachute-vault uninstall` calls `uninstallAgent()` which targets the
|
|
981
|
+
// hardcoded launchd label `computer.parachute.vault`. That label ignores
|
|
982
|
+
// `PARACHUTE_HOME`, so a naive subprocess test would `launchctl bootout`
|
|
983
|
+
// the real daemon on the developer's machine. `--skip-daemon` bypasses the
|
|
984
|
+
// launchd / systemd / backup-agent uninstall calls so tests can exercise
|
|
985
|
+
// the rest of the flow (wrapper removal, MCP cleanup, exit codes, ordering)
|
|
986
|
+
// without touching real operator state.
|
|
987
|
+
|
|
988
|
+
describe("cmdUninstall --skip-daemon (isolation flag for tests)", () => {
|
|
989
|
+
let tmp: string;
|
|
990
|
+
beforeEach(() => {
|
|
991
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vault-uninstall-"));
|
|
992
|
+
});
|
|
993
|
+
afterEach(() => {
|
|
994
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
test("removes wrapper + server-path pointer + MCP entry, never touches real launchd", () => {
|
|
998
|
+
// Stage the post-init artifacts uninstall is responsible for clearing:
|
|
999
|
+
// - start.sh wrapper (would otherwise need `bun:sqlite` + a real init)
|
|
1000
|
+
// - server-path pointer
|
|
1001
|
+
// - a vault MCP entry in the sandbox ~/.claude.json
|
|
1002
|
+
const vaultHome = path.join(tmp, "vault");
|
|
1003
|
+
fs.mkdirSync(vaultHome, { recursive: true });
|
|
1004
|
+
fs.writeFileSync(path.join(vaultHome, "start.sh"), "#!/bin/bash\necho stub\n", { mode: 0o755 });
|
|
1005
|
+
fs.writeFileSync(path.join(vaultHome, "server-path"), "/imaginary/server.ts\n");
|
|
1006
|
+
const claudePath = path.join(tmp, ".claude.json");
|
|
1007
|
+
fs.writeFileSync(
|
|
1008
|
+
claudePath,
|
|
1009
|
+
JSON.stringify({
|
|
1010
|
+
mcpServers: { "parachute-vault": { type: "http", url: "stub" } },
|
|
1011
|
+
}) + "\n",
|
|
1012
|
+
);
|
|
1013
|
+
|
|
1014
|
+
const res = runCli(["uninstall", "--yes", "--skip-daemon"], tmp);
|
|
1015
|
+
|
|
1016
|
+
expect(res.exitCode).toBe(0);
|
|
1017
|
+
|
|
1018
|
+
// The flag must surface in stdout so a future maintainer reading a CI
|
|
1019
|
+
// log can tell the daemon step was skipped intentionally (vs. silently
|
|
1020
|
+
// by a future regression).
|
|
1021
|
+
expect(res.stdout).toContain("Skipping daemon removal (--skip-daemon).");
|
|
1022
|
+
|
|
1023
|
+
// Wrapper + pointer gone — that's the "shared across platforms" step
|
|
1024
|
+
// immediately after daemon removal, so this pins the ordering invariant
|
|
1025
|
+
// that --skip-daemon doesn't short-circuit the rest of the flow.
|
|
1026
|
+
expect(fs.existsSync(path.join(vaultHome, "start.sh"))).toBe(false);
|
|
1027
|
+
expect(fs.existsSync(path.join(vaultHome, "server-path"))).toBe(false);
|
|
1028
|
+
|
|
1029
|
+
// MCP cleanup ran — the vault entry is gone from ~/.claude.json.
|
|
1030
|
+
const after = readJson(claudePath);
|
|
1031
|
+
expect(after.mcpServers).toBeDefined();
|
|
1032
|
+
expect(after.mcpServers["parachute-vault"]).toBeUndefined();
|
|
1033
|
+
|
|
1034
|
+
// "Done. To reinstall: `parachute-vault init`." is the closing
|
|
1035
|
+
// confirmation. Its presence means the function ran to completion;
|
|
1036
|
+
// its absence on a future regression would catch an early-return bug.
|
|
1037
|
+
expect(res.stdout).toContain("Done. To reinstall:");
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
test("--skip-daemon leaves user data alone without --wipe", () => {
|
|
1041
|
+
// The wipe-confirm branch is independent of the daemon branch; this
|
|
1042
|
+
// pins that --skip-daemon doesn't accidentally promote to a destructive
|
|
1043
|
+
// wipe. Seed a vaults dir, run uninstall without --wipe, assert it
|
|
1044
|
+
// still exists afterward.
|
|
1045
|
+
const vaultHome = path.join(tmp, "vault");
|
|
1046
|
+
const dataDir = path.join(vaultHome, "data", "default");
|
|
1047
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
1048
|
+
fs.writeFileSync(path.join(dataDir, "vault.db"), "stub\n");
|
|
1049
|
+
|
|
1050
|
+
const res = runCli(["uninstall", "--yes", "--skip-daemon"], tmp);
|
|
1051
|
+
|
|
1052
|
+
expect(res.exitCode).toBe(0);
|
|
1053
|
+
expect(fs.existsSync(path.join(dataDir, "vault.db"))).toBe(true);
|
|
1054
|
+
// No wipe banner should appear.
|
|
1055
|
+
expect(res.stdout).not.toMatch(/User data removed/);
|
|
1056
|
+
expect(res.stdout).toContain("User data (~/.parachute/vault/) is left alone");
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test("--skip-daemon + --wipe removes vault data (composes with destructive path)", () => {
|
|
1060
|
+
// Pin that --skip-daemon is purely the daemon-call escape hatch — it
|
|
1061
|
+
// does not gate or alter the --wipe semantics. A scripted destructive
|
|
1062
|
+
// test (CI cleanup, fresh-machine setup) must still see --wipe work.
|
|
1063
|
+
const vaultHome = path.join(tmp, "vault");
|
|
1064
|
+
const dataDir = path.join(vaultHome, "data", "default");
|
|
1065
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
1066
|
+
fs.writeFileSync(path.join(dataDir, "vault.db"), "stub\n");
|
|
1067
|
+
fs.writeFileSync(path.join(vaultHome, ".env"), "PORT=1940\n");
|
|
1068
|
+
|
|
1069
|
+
const res = runCli(["uninstall", "--yes", "--wipe", "--skip-daemon"], tmp);
|
|
1070
|
+
|
|
1071
|
+
expect(res.exitCode).toBe(0);
|
|
1072
|
+
expect(fs.existsSync(path.join(vaultHome, "data"))).toBe(false);
|
|
1073
|
+
expect(fs.existsSync(path.join(vaultHome, ".env"))).toBe(false);
|
|
1074
|
+
expect(res.stdout).toMatch(/scripted destructive wipe/);
|
|
1075
|
+
expect(res.stdout).toContain("User data removed");
|
|
1076
|
+
});
|
|
1077
|
+
});
|