claudeup 4.29.0 → 4.31.0
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 +98 -92
- package/package.json +4 -4
- package/src/__tests__/plugin-setup.test.ts +502 -6
- package/src/__tests__/profile-materializer.test.ts +87 -0
- package/src/cli/install.ts +36 -5
- package/src/cli/router.ts +17 -9
- package/src/services/plugin-setup.ts +413 -59
- package/src/services/profile-materializer.ts +55 -4
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* and `required` setup keys.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
9
|
+
import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test";
|
|
10
|
+
import { mkdtemp, mkdir, writeFile, chmod, rm } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
10
13
|
// Real modules captured before the mock.module() overrides, so the mocks spread
|
|
11
14
|
// the full surface instead of stripping exports (e.g. resolveExecutable) that
|
|
12
15
|
// bun's global, non-restored mock.module would otherwise remove for later files.
|
|
@@ -81,7 +84,13 @@ mock.module("node:child_process", () => ({
|
|
|
81
84
|
const {
|
|
82
85
|
extractGoBinaryName,
|
|
83
86
|
extractGoVersion,
|
|
87
|
+
extractBinaryName,
|
|
88
|
+
parseAtSpec,
|
|
89
|
+
parsePipSpec,
|
|
84
90
|
goDepNeedsInstall,
|
|
91
|
+
goDepDecision,
|
|
92
|
+
parseGoEnv,
|
|
93
|
+
classifyInstallReach,
|
|
85
94
|
installPluginDeps,
|
|
86
95
|
checkMissingDeps,
|
|
87
96
|
} = await import("../services/plugin-setup.js");
|
|
@@ -296,7 +305,10 @@ describe("goDepNeedsInstall — version awareness", () => {
|
|
|
296
305
|
|
|
297
306
|
it("reinstalls when the pinned version differs from the installed one", async () => {
|
|
298
307
|
availableBinaries.add("tmux-mcp");
|
|
299
|
-
execResults.set("tmux-mcp --version", {
|
|
308
|
+
execResults.set("/usr/bin/tmux-mcp --version", {
|
|
309
|
+
stdout: "v1.6.1",
|
|
310
|
+
stderr: "",
|
|
311
|
+
});
|
|
300
312
|
|
|
301
313
|
expect(
|
|
302
314
|
await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
|
|
@@ -305,7 +317,10 @@ describe("goDepNeedsInstall — version awareness", () => {
|
|
|
305
317
|
|
|
306
318
|
it("skips when the installed version matches the pin (v-prefix insensitive)", async () => {
|
|
307
319
|
availableBinaries.add("tmux-mcp");
|
|
308
|
-
execResults.set("tmux-mcp --version", {
|
|
320
|
+
execResults.set("/usr/bin/tmux-mcp --version", {
|
|
321
|
+
stdout: "1.6.2",
|
|
322
|
+
stderr: "",
|
|
323
|
+
});
|
|
309
324
|
|
|
310
325
|
expect(
|
|
311
326
|
await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
|
|
@@ -314,7 +329,7 @@ describe("goDepNeedsInstall — version awareness", () => {
|
|
|
314
329
|
|
|
315
330
|
it("reinstalls when the binary can't report its version", async () => {
|
|
316
331
|
availableBinaries.add("tmux-mcp");
|
|
317
|
-
execResults.set("tmux-mcp --version", {
|
|
332
|
+
execResults.set("/usr/bin/tmux-mcp --version", {
|
|
318
333
|
stdout: "",
|
|
319
334
|
stderr: "flag provided but not defined: -version",
|
|
320
335
|
error: true,
|
|
@@ -346,7 +361,10 @@ describe("checkMissingDeps — pinned go version mismatch", () => {
|
|
|
346
361
|
});
|
|
347
362
|
|
|
348
363
|
it("reports a present-but-outdated pinned binary as missing", async () => {
|
|
349
|
-
execResults.set("tmux-mcp --version", {
|
|
364
|
+
execResults.set("/usr/bin/tmux-mcp --version", {
|
|
365
|
+
stdout: "v1.6.1",
|
|
366
|
+
stderr: "",
|
|
367
|
+
});
|
|
350
368
|
|
|
351
369
|
const missing = await checkMissingDeps({
|
|
352
370
|
go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
|
|
@@ -363,7 +381,10 @@ describe("installPluginDeps — reinstalls an outdated pinned go binary", () =>
|
|
|
363
381
|
});
|
|
364
382
|
|
|
365
383
|
it("runs go install when the pinned version is newer than installed", async () => {
|
|
366
|
-
execResults.set("tmux-mcp --version", {
|
|
384
|
+
execResults.set("/usr/bin/tmux-mcp --version", {
|
|
385
|
+
stdout: "v1.6.1",
|
|
386
|
+
stderr: "",
|
|
387
|
+
});
|
|
367
388
|
execResults.set("/usr/bin/go install", { stdout: "", stderr: "" });
|
|
368
389
|
|
|
369
390
|
const result = await installPluginDeps({
|
|
@@ -377,6 +398,481 @@ describe("installPluginDeps — reinstalls an outdated pinned go binary", () =>
|
|
|
377
398
|
});
|
|
378
399
|
});
|
|
379
400
|
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// 4c. Install reach — a version check is worthless if the install lands
|
|
403
|
+
// somewhere PATH never looks. Pure ordering rules, no mocking needed.
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
describe("parseGoEnv", () => {
|
|
407
|
+
it("reads GOBIN and GOPATH", () => {
|
|
408
|
+
expect(
|
|
409
|
+
parseGoEnv('{"GOBIN": "/custom/gobin", "GOPATH": "/home/u/go"}'),
|
|
410
|
+
).toEqual({ gobin: "/custom/gobin", gopath: "/home/u/go" });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// The line-oriented `go env GOBIN GOPATH` puts an EMPTY line first when GOBIN
|
|
414
|
+
// is unset. run() trims stdout, which used to shift GOPATH into GOBIN's slot
|
|
415
|
+
// and aim the install dir at ~/go instead of ~/go/bin. Keyed JSON can't shift.
|
|
416
|
+
it("keeps an unset GOBIN empty even though output gets trimmed", () => {
|
|
417
|
+
expect(parseGoEnv('{"GOBIN": "", "GOPATH": "/Users/jack/go"}')).toEqual({
|
|
418
|
+
gobin: "",
|
|
419
|
+
gopath: "/Users/jack/go",
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("survives empty output", () => {
|
|
424
|
+
expect(parseGoEnv("")).toEqual({ gobin: "", gopath: "" });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("survives non-JSON output", () => {
|
|
428
|
+
expect(parseGoEnv("go: unknown flag -json")).toEqual({
|
|
429
|
+
gobin: "",
|
|
430
|
+
gopath: "",
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("ignores non-string values", () => {
|
|
435
|
+
expect(parseGoEnv('{"GOBIN": null, "GOPATH": 7}')).toEqual({
|
|
436
|
+
gobin: "",
|
|
437
|
+
gopath: "",
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("classifyInstallReach", () => {
|
|
443
|
+
it("wins when the install dir is the first PATH entry holding it", () => {
|
|
444
|
+
expect(
|
|
445
|
+
classifyInstallReach(
|
|
446
|
+
["/home/u/go/bin", "/usr/bin"],
|
|
447
|
+
[],
|
|
448
|
+
"/home/u/go/bin",
|
|
449
|
+
),
|
|
450
|
+
).toEqual({ reach: "wins" });
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("wins when the install dir precedes another copy", () => {
|
|
454
|
+
expect(
|
|
455
|
+
classifyInstallReach(
|
|
456
|
+
["/home/u/go/bin", "/opt/homebrew/bin"],
|
|
457
|
+
["/opt/homebrew/bin"],
|
|
458
|
+
"/home/u/go/bin",
|
|
459
|
+
),
|
|
460
|
+
).toEqual({ reach: "wins" });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("is shadowed when another copy precedes the install dir", () => {
|
|
464
|
+
expect(
|
|
465
|
+
classifyInstallReach(
|
|
466
|
+
["/home/u/.local/bin", "/home/u/go/bin"],
|
|
467
|
+
["/home/u/.local/bin"],
|
|
468
|
+
"/home/u/go/bin",
|
|
469
|
+
),
|
|
470
|
+
).toEqual({ reach: "shadowed", byDir: "/home/u/.local/bin" });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// The generalisation of the tmux-mcp incident: no foreign package needed,
|
|
474
|
+
// just Homebrew ahead of GOPATH/bin. go install writes one file, the version
|
|
475
|
+
// probe reads another, and the mismatch never resolves.
|
|
476
|
+
it("is shadowed by a homebrew copy ordered ahead of the go bin dir", () => {
|
|
477
|
+
expect(
|
|
478
|
+
classifyInstallReach(
|
|
479
|
+
["/opt/homebrew/bin", "/home/u/go/bin"],
|
|
480
|
+
["/opt/homebrew/bin"],
|
|
481
|
+
"/home/u/go/bin",
|
|
482
|
+
),
|
|
483
|
+
).toEqual({ reach: "shadowed", byDir: "/opt/homebrew/bin" });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("reports off-path when the install dir is absent from PATH", () => {
|
|
487
|
+
expect(
|
|
488
|
+
classifyInstallReach(["/usr/bin", "/bin"], [], "/home/u/go/bin"),
|
|
489
|
+
).toEqual({ reach: "off-path" });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("normalises trailing slashes and redundant segments", () => {
|
|
493
|
+
expect(
|
|
494
|
+
classifyInstallReach(["/home/u/go/bin/"], [], "/home/u/go/./bin"),
|
|
495
|
+
).toEqual({ reach: "wins" });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("lets the first of several duplicate PATH entries decide", () => {
|
|
499
|
+
expect(
|
|
500
|
+
classifyInstallReach(
|
|
501
|
+
["/home/u/.local/bin", "/home/u/go/bin", "/home/u/.local/bin"],
|
|
502
|
+
["/home/u/.local/bin"],
|
|
503
|
+
"/home/u/go/bin",
|
|
504
|
+
),
|
|
505
|
+
).toEqual({ reach: "shadowed", byDir: "/home/u/.local/bin" });
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
// 4d. End-to-end shadowing — real directories on a real PATH, so the fs scan
|
|
511
|
+
// is exercised rather than stubbed. Mirrors the tmux-mcp incident: a
|
|
512
|
+
// foreign binary earlier in PATH that cannot report a version.
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
describe("go deps shadowed by an earlier PATH entry", () => {
|
|
516
|
+
let tmpRoot: string;
|
|
517
|
+
let shadowDir: string;
|
|
518
|
+
let goBinDir: string;
|
|
519
|
+
let originalPath: string | undefined;
|
|
520
|
+
|
|
521
|
+
beforeEach(async () => {
|
|
522
|
+
availableBinaries = new Set(["go", "tmux-mcp"]);
|
|
523
|
+
execResults = new Map();
|
|
524
|
+
|
|
525
|
+
tmpRoot = await mkdtemp(path.join(tmpdir(), "claudeup-shadow-"));
|
|
526
|
+
shadowDir = path.join(tmpRoot, "shadow");
|
|
527
|
+
goBinDir = path.join(tmpRoot, "gobin");
|
|
528
|
+
await mkdir(shadowDir);
|
|
529
|
+
await mkdir(goBinDir);
|
|
530
|
+
|
|
531
|
+
// A foreign `tmux-mcp` earlier in PATH than the go install target.
|
|
532
|
+
const shim = path.join(shadowDir, "tmux-mcp");
|
|
533
|
+
await writeFile(shim, "#!/bin/sh\nexit 1\n");
|
|
534
|
+
await chmod(shim, 0o755);
|
|
535
|
+
|
|
536
|
+
originalPath = process.env.PATH;
|
|
537
|
+
process.env.PATH = [shadowDir, goBinDir].join(path.delimiter);
|
|
538
|
+
|
|
539
|
+
// GOBIN points at the (later) go bin dir.
|
|
540
|
+
execResults.set("/usr/bin/go env -json GOBIN GOPATH", {
|
|
541
|
+
stdout: JSON.stringify({ GOBIN: goBinDir, GOPATH: tmpRoot }),
|
|
542
|
+
stderr: "",
|
|
543
|
+
});
|
|
544
|
+
// The shim chokes on --version, exactly like the npm package did.
|
|
545
|
+
execResults.set("/usr/bin/tmux-mcp --version", {
|
|
546
|
+
stdout: "",
|
|
547
|
+
stderr: "ERR_PARSE_ARGS_UNKNOWN_OPTION",
|
|
548
|
+
error: true,
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
afterEach(async () => {
|
|
553
|
+
process.env.PATH = originalPath ?? "";
|
|
554
|
+
await rm(tmpRoot, { recursive: true, force: true });
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("reports blocked instead of asking for a futile install", async () => {
|
|
558
|
+
const decision = await goDepDecision(
|
|
559
|
+
"github.com/MadAppGang/tmux-mcp@v1.6.2",
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
expect(decision.action).toBe("blocked");
|
|
563
|
+
if (decision.action !== "blocked") return;
|
|
564
|
+
expect(decision.reason).toBe("shadowed");
|
|
565
|
+
expect(decision.detail).toContain(path.join(shadowDir, "tmux-mcp"));
|
|
566
|
+
expect(decision.detail).toContain(goBinDir);
|
|
567
|
+
expect(decision.detail).toContain("cannot change what runs");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("fails the install rather than reporting a success that changes nothing", async () => {
|
|
571
|
+
execResults.set("/usr/bin/go install", { stdout: "", stderr: "" });
|
|
572
|
+
|
|
573
|
+
const result = await installPluginDeps({
|
|
574
|
+
go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
expect(result.installed).toHaveLength(0);
|
|
578
|
+
expect(result.skipped).toHaveLength(0);
|
|
579
|
+
expect(result.failed).toHaveLength(1);
|
|
580
|
+
expect(result.failed[0].pkg).toBe(
|
|
581
|
+
"go:github.com/MadAppGang/tmux-mcp@v1.6.2",
|
|
582
|
+
);
|
|
583
|
+
expect(result.failed[0].error).toContain("cannot change what runs");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("still counts as needing attention, so the UI surfaces it", async () => {
|
|
587
|
+
expect(
|
|
588
|
+
await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
|
|
589
|
+
).toBe(true);
|
|
590
|
+
|
|
591
|
+
const missing = await checkMissingDeps({
|
|
592
|
+
go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
|
|
593
|
+
});
|
|
594
|
+
expect(missing.go).toEqual(["github.com/MadAppGang/tmux-mcp@v1.6.2"]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("installs normally once the shadow is out of the way", async () => {
|
|
598
|
+
process.env.PATH = [goBinDir, shadowDir].join(path.delimiter);
|
|
599
|
+
execResults.set("/usr/bin/go install", { stdout: "", stderr: "" });
|
|
600
|
+
|
|
601
|
+
const result = await installPluginDeps({
|
|
602
|
+
go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
expect(result.installed).toContain(
|
|
606
|
+
"go:github.com/MadAppGang/tmux-mcp@v1.6.2",
|
|
607
|
+
);
|
|
608
|
+
expect(result.failed).toHaveLength(0);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("reports off-path when the go bin dir is not in PATH at all", async () => {
|
|
612
|
+
process.env.PATH = path.join(tmpRoot, "elsewhere");
|
|
613
|
+
availableBinaries.delete("tmux-mcp");
|
|
614
|
+
|
|
615
|
+
const decision = await goDepDecision(
|
|
616
|
+
"github.com/MadAppGang/tmux-mcp@v1.6.2",
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
expect(decision.action).toBe("blocked");
|
|
620
|
+
if (decision.action !== "blocked") return;
|
|
621
|
+
expect(decision.reason).toBe("install-dir-off-path");
|
|
622
|
+
expect(decision.detail).toContain("not in PATH");
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
// 4e. Spec parsing — the root of both bug directions. Go never reinstalled
|
|
628
|
+
// because the pin was never compared; npm/cargo/pip always reinstalled
|
|
629
|
+
// because the raw spec was used as a lookup key.
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
describe("parseAtSpec", () => {
|
|
633
|
+
it("splits name and pinned version", () => {
|
|
634
|
+
expect(parseAtSpec("ripgrep@14.1.0")).toEqual({
|
|
635
|
+
spec: "ripgrep@14.1.0",
|
|
636
|
+
name: "ripgrep",
|
|
637
|
+
version: "14.1.0",
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("treats a leading @ as an npm scope, not a version", () => {
|
|
642
|
+
expect(parseAtSpec("@babel/cli")).toEqual({
|
|
643
|
+
spec: "@babel/cli",
|
|
644
|
+
name: "cli",
|
|
645
|
+
version: null,
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("pins a scoped npm package", () => {
|
|
650
|
+
expect(parseAtSpec("@babel/cli@7.0.0")).toEqual({
|
|
651
|
+
spec: "@babel/cli@7.0.0",
|
|
652
|
+
name: "cli",
|
|
653
|
+
version: "7.0.0",
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("keeps go module paths working", () => {
|
|
658
|
+
expect(parseAtSpec("github.com/MadAppGang/tmux-mcp@v1.6.2")).toEqual({
|
|
659
|
+
spec: "github.com/MadAppGang/tmux-mcp@v1.6.2",
|
|
660
|
+
name: "tmux-mcp",
|
|
661
|
+
version: "v1.6.2",
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("treats @latest as unpinned", () => {
|
|
666
|
+
expect(parseAtSpec("tool@latest").version).toBeNull();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("treats a bare name as unpinned", () => {
|
|
670
|
+
expect(parseAtSpec("tool")).toEqual({
|
|
671
|
+
spec: "tool",
|
|
672
|
+
name: "tool",
|
|
673
|
+
version: null,
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
describe("parsePipSpec", () => {
|
|
679
|
+
it("reads an == pin", () => {
|
|
680
|
+
expect(parsePipSpec("mcp==1.2.3")).toEqual({
|
|
681
|
+
spec: "mcp==1.2.3",
|
|
682
|
+
name: "mcp",
|
|
683
|
+
version: "1.2.3",
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("tolerates whitespace around ==", () => {
|
|
688
|
+
expect(parsePipSpec("mcp == 1.2.3").version).toBe("1.2.3");
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// A range is not a single version, so there is nothing to verify against.
|
|
692
|
+
it("treats >= as unpinned but still reads the name", () => {
|
|
693
|
+
expect(parsePipSpec("browser-use>=0.1.0")).toEqual({
|
|
694
|
+
spec: "browser-use>=0.1.0",
|
|
695
|
+
name: "browser-use",
|
|
696
|
+
version: null,
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("strips extras from the name", () => {
|
|
701
|
+
expect(parsePipSpec("uvicorn[standard]").name).toBe("uvicorn");
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("handles a bare distribution name", () => {
|
|
705
|
+
expect(parsePipSpec("browser-use")).toEqual({
|
|
706
|
+
spec: "browser-use",
|
|
707
|
+
name: "browser-use",
|
|
708
|
+
version: null,
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe("extractBinaryName — brew", () => {
|
|
714
|
+
it("drops the tap prefix", () => {
|
|
715
|
+
expect(extractBinaryName("memextech/tap/ht-mcp")).toBe("ht-mcp");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// brew's @ is part of the formula name, but the binary it provides is not
|
|
719
|
+
// called "node@18" — the old code looked for exactly that and never found it.
|
|
720
|
+
it("drops a versioned-formula suffix from the binary name", () => {
|
|
721
|
+
expect(extractBinaryName("node@18")).toBe("node");
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("leaves a plain formula alone", () => {
|
|
725
|
+
expect(extractBinaryName("ripgrep")).toBe("ripgrep");
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
// 4f. Version awareness across the other managers
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
describe("pipDepNeedsInstall — via installPluginDeps", () => {
|
|
734
|
+
const PIP_VERSION = /^python3 -c import importlib\.metadata/;
|
|
735
|
+
|
|
736
|
+
beforeEach(() => {
|
|
737
|
+
availableBinaries = new Set(["uv"]);
|
|
738
|
+
execResults = new Map();
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
function pipVersion(stdout: string, error = false) {
|
|
742
|
+
// The probe is `python3 -c "...m.version('mcp')"`, matched by prefix.
|
|
743
|
+
execResults.set("python3 -c import importlib.metadata as m", {
|
|
744
|
+
stdout,
|
|
745
|
+
stderr: error ? "no such distribution" : "",
|
|
746
|
+
error,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
it("skips a pinned package already at that version", async () => {
|
|
751
|
+
pipVersion("1.2.3");
|
|
752
|
+
const result = await installPluginDeps({ pip: ["mcp==1.2.3"] });
|
|
753
|
+
|
|
754
|
+
expect(result.skipped).toContain("pip:mcp==1.2.3");
|
|
755
|
+
expect(result.installed).toHaveLength(0);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("reinstalls a pinned package sitting at another version", async () => {
|
|
759
|
+
pipVersion("1.2.2");
|
|
760
|
+
execResults.set("/usr/bin/uv pip install", { stdout: "", stderr: "" });
|
|
761
|
+
|
|
762
|
+
const result = await installPluginDeps({ pip: ["mcp==1.2.3"] });
|
|
763
|
+
|
|
764
|
+
expect(result.installed).toContain("pip:mcp==1.2.3");
|
|
765
|
+
expect(result.skipped).toHaveLength(0);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("skips an unpinned package that is present", async () => {
|
|
769
|
+
pipVersion("0.9.0");
|
|
770
|
+
const result = await installPluginDeps({ pip: ["browser-use"] });
|
|
771
|
+
|
|
772
|
+
expect(result.skipped).toContain("pip:browser-use");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("installs when the distribution is absent", async () => {
|
|
776
|
+
pipVersion("", true);
|
|
777
|
+
execResults.set("/usr/bin/uv pip install", { stdout: "", stderr: "" });
|
|
778
|
+
|
|
779
|
+
const result = await installPluginDeps({ pip: ["browser-use"] });
|
|
780
|
+
|
|
781
|
+
expect(result.installed).toContain("pip:browser-use");
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("reports a pinned mismatch through checkMissingDeps too", async () => {
|
|
785
|
+
pipVersion("1.2.2");
|
|
786
|
+
const missing = await checkMissingDeps({ pip: ["mcp==1.2.3"] });
|
|
787
|
+
expect(missing.pip).toEqual(["mcp==1.2.3"]);
|
|
788
|
+
expect(PIP_VERSION.test("python3 -c import importlib.metadata")).toBe(true);
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe("npm and cargo version awareness", () => {
|
|
793
|
+
// The reach check consults the real PATH and filesystem, so both are pinned
|
|
794
|
+
// to a temp dir here. Without that these tests would pass or fail depending
|
|
795
|
+
// on whether the machine running them happens to have ~/.cargo/bin on PATH.
|
|
796
|
+
let envRoot: string;
|
|
797
|
+
let savedPath: string | undefined;
|
|
798
|
+
let savedCargoRoot: string | undefined;
|
|
799
|
+
|
|
800
|
+
beforeEach(async () => {
|
|
801
|
+
availableBinaries = new Set(["bun", "cargo", "ripgrep"]);
|
|
802
|
+
execResults = new Map();
|
|
803
|
+
|
|
804
|
+
envRoot = await mkdtemp(path.join(tmpdir(), "claudeup-env-"));
|
|
805
|
+
await mkdir(path.join(envRoot, "bin"));
|
|
806
|
+
savedPath = process.env.PATH;
|
|
807
|
+
savedCargoRoot = process.env.CARGO_INSTALL_ROOT;
|
|
808
|
+
process.env.PATH = path.join(envRoot, "bin");
|
|
809
|
+
process.env.CARGO_INSTALL_ROOT = envRoot;
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
afterEach(async () => {
|
|
813
|
+
process.env.PATH = savedPath ?? "";
|
|
814
|
+
if (savedCargoRoot === undefined) process.env.CARGO_INSTALL_ROOT = "";
|
|
815
|
+
else process.env.CARGO_INSTALL_ROOT = savedCargoRoot;
|
|
816
|
+
await rm(envRoot, { recursive: true, force: true });
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// The old code passed "ripgrep@14.1.0" to which(), never matched, and
|
|
820
|
+
// reinstalled on every single run.
|
|
821
|
+
it("skips a pinned npm package already at that version", async () => {
|
|
822
|
+
execResults.set("/usr/bin/ripgrep --version", {
|
|
823
|
+
stdout: "14.1.0",
|
|
824
|
+
stderr: "",
|
|
825
|
+
});
|
|
826
|
+
execResults.set("/usr/bin/bun pm bin -g", { stdout: "", stderr: "" });
|
|
827
|
+
|
|
828
|
+
const result = await installPluginDeps({ npm: ["ripgrep@14.1.0"] });
|
|
829
|
+
|
|
830
|
+
expect(result.skipped).toContain("npm:ripgrep@14.1.0");
|
|
831
|
+
expect(result.installed).toHaveLength(0);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("reinstalls a pinned npm package at the wrong version", async () => {
|
|
835
|
+
execResults.set("/usr/bin/ripgrep --version", {
|
|
836
|
+
stdout: "14.0.0",
|
|
837
|
+
stderr: "",
|
|
838
|
+
});
|
|
839
|
+
execResults.set("/usr/bin/bun install -g", { stdout: "", stderr: "" });
|
|
840
|
+
|
|
841
|
+
const result = await installPluginDeps({ npm: ["ripgrep@14.1.0"] });
|
|
842
|
+
|
|
843
|
+
expect(result.installed).toContain("bun:ripgrep@14.1.0");
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it("skips a pinned cargo crate already at that version", async () => {
|
|
847
|
+
execResults.set("/usr/bin/ripgrep --version", {
|
|
848
|
+
stdout: "ripgrep 14.1.0",
|
|
849
|
+
stderr: "",
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
const result = await installPluginDeps({ cargo: ["ripgrep@14.1.0"] });
|
|
853
|
+
|
|
854
|
+
expect(result.skipped).toContain("cargo:ripgrep@14.1.0");
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("installs a pinned cargo crate with --version, not crate@ver", async () => {
|
|
858
|
+
execResults.set("/usr/bin/ripgrep --version", {
|
|
859
|
+
stdout: "ripgrep 14.0.0",
|
|
860
|
+
stderr: "",
|
|
861
|
+
});
|
|
862
|
+
execResults.set("/usr/bin/cargo install ripgrep --version 14.1.0", {
|
|
863
|
+
stdout: "",
|
|
864
|
+
stderr: "",
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
const result = await installPluginDeps({ cargo: ["ripgrep@14.1.0"] });
|
|
868
|
+
|
|
869
|
+
// Only the exact `--version` invocation is stubbed as success, so this
|
|
870
|
+
// passing proves the argv shape.
|
|
871
|
+
expect(result.installed).toContain("cargo:ripgrep@14.1.0");
|
|
872
|
+
expect(result.failed).toHaveLength(0);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
380
876
|
// ---------------------------------------------------------------------------
|
|
381
877
|
// 5. checkMissingDeps tests — required binaries
|
|
382
878
|
// ---------------------------------------------------------------------------
|
|
@@ -38,6 +38,36 @@ describe("buildProfileSettings", () => {
|
|
|
38
38
|
});
|
|
39
39
|
expect(s.enableAllProjectMcpServers).toBe(true);
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
// Regression: `install` enables the manifest-wide union at USER scope, and
|
|
43
|
+
// Claude Code resolves enabledPlugins per plugin id — an id the project
|
|
44
|
+
// scope omits falls through to the user scope's `true`. A profile that
|
|
45
|
+
// merely omits another profile's plugins therefore never disables them, so
|
|
46
|
+
// `profile switch` silently becomes additive instead of exclusive.
|
|
47
|
+
test("explicitly disables union plugins the profile excludes", () => {
|
|
48
|
+
const s = buildProfileSettings(
|
|
49
|
+
closure({ plugins: { "designer@magus": "latest" } }),
|
|
50
|
+
["designer@magus", "terminal@magus", "gtd@magus"],
|
|
51
|
+
);
|
|
52
|
+
expect(s.enabledPlugins).toEqual({
|
|
53
|
+
"designer@magus": true,
|
|
54
|
+
"terminal@magus": false,
|
|
55
|
+
"gtd@magus": false,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a profile's own plugin wins over its union entry", () => {
|
|
60
|
+
const s = buildProfileSettings(
|
|
61
|
+
closure({ plugins: { "terminal@magus": "4.1.4" } }),
|
|
62
|
+
["terminal@magus"],
|
|
63
|
+
);
|
|
64
|
+
expect(s.enabledPlugins).toEqual({ "terminal@magus": true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("no union given -> no false entries (unchanged legacy shape)", () => {
|
|
68
|
+
const s = buildProfileSettings(closure({ plugins: { "dev@magus": "latest" } }));
|
|
69
|
+
expect(s.enabledPlugins).toEqual({ "dev@magus": true });
|
|
70
|
+
});
|
|
41
71
|
});
|
|
42
72
|
|
|
43
73
|
describe("buildProfileMcp", () => {
|
|
@@ -79,4 +109,61 @@ describe("materializeProfile", () => {
|
|
|
79
109
|
const settings = await fs.readJson(join(dir, "settings.json"));
|
|
80
110
|
expect(settings.enabledPlugins).toEqual({ "b@m": true });
|
|
81
111
|
});
|
|
112
|
+
|
|
113
|
+
// Regression: activating a profile replaces .claude/skills with a symlink,
|
|
114
|
+
// and replacing means fs.remove first. A repo that committed its own project
|
|
115
|
+
// skills before adopting profiles would lose them at that moment.
|
|
116
|
+
test("seeds skills/ from the project's pre-existing .claude/skills", async () => {
|
|
117
|
+
const src = join(project, ".claude", "skills", "systematic-debugging");
|
|
118
|
+
await fs.outputFile(join(src, "SKILL.md"), "# committed project skill\n");
|
|
119
|
+
|
|
120
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
121
|
+
|
|
122
|
+
expect(
|
|
123
|
+
await fs.readFile(join(dir, "skills", "systematic-debugging", "SKILL.md"), "utf8"),
|
|
124
|
+
).toContain("committed project skill");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("does not re-seed skills/ once the profile dir exists", async () => {
|
|
128
|
+
await fs.outputFile(
|
|
129
|
+
join(project, ".claude", "skills", "gone", "SKILL.md"),
|
|
130
|
+
"# removed later\n",
|
|
131
|
+
);
|
|
132
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
133
|
+
await fs.remove(join(dir, "skills", "gone"));
|
|
134
|
+
|
|
135
|
+
await materializeProfile("p", closure(), project);
|
|
136
|
+
|
|
137
|
+
expect(await fs.pathExists(join(dir, "skills", "gone"))).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("still creates an empty skills/ when the project has none", async () => {
|
|
141
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
142
|
+
expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
|
|
143
|
+
expect(await fs.readdir(join(dir, "skills"))).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("two profiles from one union each disable the other's plugins", async () => {
|
|
147
|
+
const union = ["designer@magus", "terminal@magus"];
|
|
148
|
+
const fe = await materializeProfile(
|
|
149
|
+
"frontend",
|
|
150
|
+
closure({ plugins: { "designer@magus": "latest" } }),
|
|
151
|
+
project,
|
|
152
|
+
union,
|
|
153
|
+
);
|
|
154
|
+
const be = await materializeProfile(
|
|
155
|
+
"backend",
|
|
156
|
+
closure({ plugins: { "terminal@magus": "latest" } }),
|
|
157
|
+
project,
|
|
158
|
+
union,
|
|
159
|
+
);
|
|
160
|
+
expect((await fs.readJson(join(fe, "settings.json"))).enabledPlugins).toEqual({
|
|
161
|
+
"designer@magus": true,
|
|
162
|
+
"terminal@magus": false,
|
|
163
|
+
});
|
|
164
|
+
expect((await fs.readJson(join(be, "settings.json"))).enabledPlugins).toEqual({
|
|
165
|
+
"designer@magus": false,
|
|
166
|
+
"terminal@magus": true,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
82
169
|
});
|