@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.
@@ -1,15 +1,69 @@
1
1
  /**
2
- * Tests for the MCP URL picker used by `mcp-install`. The picker must match
3
- * vault's advertised OAuth issuer for the origin a client will reach it on —
4
- * otherwise Claude Code (and any strict RFC 8414 client) rejects the
5
- * discovery response on issuer/origin mismatch.
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 { chooseMcpUrl } from "./mcp-install.ts";
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
+ });