claudeup 4.29.0 → 4.30.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/package.json +4 -4
- package/src/__tests__/plugin-setup.test.ts +502 -6
- package/src/services/plugin-setup.ts +413 -59
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.30.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "4.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.30.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.30.0",
|
|
69
|
+
"claudeup-linux-x64": "4.30.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
import { execFile } from "node:child_process";
|
|
29
29
|
import { promisify } from "node:util";
|
|
30
30
|
import fs from "fs-extra";
|
|
31
|
+
import { access } from "node:fs/promises";
|
|
32
|
+
import { constants as fsConstants } from "node:fs";
|
|
31
33
|
import path from "node:path";
|
|
32
34
|
import os from "node:os";
|
|
33
35
|
import { which } from "../utils/command-utils.js";
|
|
@@ -105,28 +107,90 @@ async function detectPipCommand(): Promise<{
|
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
/**
|
|
108
|
-
* Check if a
|
|
110
|
+
* Check if a binary is available in PATH
|
|
109
111
|
*/
|
|
110
|
-
async function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
async function isBinaryAvailable(name: string): Promise<boolean> {
|
|
113
|
+
return (await which(name)) !== null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Last path segment, e.g. "memextech/tap/ht-mcp" → "ht-mcp". */
|
|
117
|
+
function lastSegment(s: string): string {
|
|
118
|
+
return s.split("/").pop() || s;
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/**
|
|
118
|
-
*
|
|
122
|
+
* Extract binary name from a brew formula.
|
|
123
|
+
*
|
|
124
|
+
* Brew's `@` is part of the FORMULA NAME, not a version pin — `node@18` is a
|
|
125
|
+
* distinct formula, not "node pinned to 18" — so the spec is installed verbatim
|
|
126
|
+
* while the binary it provides drops the suffix.
|
|
119
127
|
*/
|
|
120
|
-
|
|
121
|
-
return (
|
|
128
|
+
export function extractBinaryName(pkg: string): string {
|
|
129
|
+
return lastSegment(pkg).replace(/@[^@/]*$/, "") || lastSegment(pkg);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A dependency spec split into what to install and what to verify. */
|
|
133
|
+
export interface ParsedSpec {
|
|
134
|
+
/** The spec as declared, handed to the installer verbatim. */
|
|
135
|
+
spec: string;
|
|
136
|
+
/** Name used to locate the installed artifact. */
|
|
137
|
+
name: string;
|
|
138
|
+
/** Exact pinned version, or null when unpinned or a moving target. */
|
|
139
|
+
version: string | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse an `name@version` spec — the shared syntax of go, npm and cargo.
|
|
144
|
+
*
|
|
145
|
+
* A leading `@` is an npm SCOPE, not a version separator, so `@babel/cli` is
|
|
146
|
+
* unpinned while `@babel/cli@7.0.0` pins 7.0.0.
|
|
147
|
+
*/
|
|
148
|
+
export function parseAtSpec(spec: string): ParsedSpec {
|
|
149
|
+
const at = spec.lastIndexOf("@");
|
|
150
|
+
if (at <= 0) return { spec, name: lastSegment(spec), version: null };
|
|
151
|
+
|
|
152
|
+
const version = spec.slice(at + 1).trim();
|
|
153
|
+
const name = lastSegment(spec.slice(0, at));
|
|
154
|
+
if (!version || version === "latest") return { spec, name, version: null };
|
|
155
|
+
return { spec, name, version };
|
|
122
156
|
}
|
|
123
157
|
|
|
124
158
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
159
|
+
* Parse a pip requirement. Only `==` is an exact, verifiable pin; `>=`, `~=`
|
|
160
|
+
* and friends describe a range, so there is no single version to check against.
|
|
127
161
|
*/
|
|
128
|
-
function
|
|
129
|
-
|
|
162
|
+
export function parsePipSpec(spec: string): ParsedSpec {
|
|
163
|
+
const exact = spec.match(/^\s*([A-Za-z0-9._-]+)\s*==\s*([^\s;]+)/);
|
|
164
|
+
if (exact) return { spec, name: exact[1], version: exact[2] };
|
|
165
|
+
const name = spec.split(/[<>=!~[;\s]/)[0].trim();
|
|
166
|
+
return { spec, name: name || spec, version: null };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The installed version of a pip distribution, or null when absent/unreadable.
|
|
171
|
+
* Uses importlib.metadata rather than an import probe: it reports the VERSION,
|
|
172
|
+
* and it works for distributions whose import name differs from their package
|
|
173
|
+
* name (browser-use → browser_use).
|
|
174
|
+
*/
|
|
175
|
+
async function getPipInstalledVersion(name: string): Promise<string | null> {
|
|
176
|
+
const { ok, stdout } = await run("python3", [
|
|
177
|
+
"-c",
|
|
178
|
+
`import importlib.metadata as m; print(m.version(${JSON.stringify(name)}))`,
|
|
179
|
+
]);
|
|
180
|
+
if (!ok || !stdout) return null;
|
|
181
|
+
return normalizeVersion(stdout.split("\n")[0]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Does this pip requirement still need installing? Present-but-wrong-version
|
|
186
|
+
* counts: without that, bumping a pin in plugin.json is inert.
|
|
187
|
+
*/
|
|
188
|
+
async function pipDepNeedsInstall(pkg: string): Promise<boolean> {
|
|
189
|
+
const { name, version } = parsePipSpec(pkg);
|
|
190
|
+
const installed = await getPipInstalledVersion(name);
|
|
191
|
+
if (installed === null) return true;
|
|
192
|
+
if (!version) return false;
|
|
193
|
+
return normalizeVersion(version) !== installed;
|
|
130
194
|
}
|
|
131
195
|
|
|
132
196
|
/**
|
|
@@ -144,13 +208,13 @@ async function installPipPackages(
|
|
|
144
208
|
return;
|
|
145
209
|
}
|
|
146
210
|
|
|
147
|
-
// Filter out already
|
|
211
|
+
// Filter out those already at the pinned version
|
|
148
212
|
const toInstall: string[] = [];
|
|
149
213
|
for (const pkg of packages) {
|
|
150
|
-
if (await
|
|
151
|
-
result.skipped.push(`pip:${pkg}`);
|
|
152
|
-
} else {
|
|
214
|
+
if (await pipDepNeedsInstall(pkg)) {
|
|
153
215
|
toInstall.push(pkg);
|
|
216
|
+
} else {
|
|
217
|
+
result.skipped.push(`pip:${pkg}`);
|
|
154
218
|
}
|
|
155
219
|
}
|
|
156
220
|
|
|
@@ -188,11 +252,15 @@ async function installBrewPackages(
|
|
|
188
252
|
}
|
|
189
253
|
|
|
190
254
|
for (const pkg of packages) {
|
|
191
|
-
const
|
|
192
|
-
if (
|
|
255
|
+
const decision = await brewDepDecision(pkg);
|
|
256
|
+
if (decision.action === "skip") {
|
|
193
257
|
result.skipped.push(`brew:${pkg}`);
|
|
194
258
|
continue;
|
|
195
259
|
}
|
|
260
|
+
if (decision.action === "blocked") {
|
|
261
|
+
result.failed.push({ pkg: `brew:${pkg}`, error: decision.detail });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
196
264
|
|
|
197
265
|
const { ok, stderr } = await run(brewPath, ["install", pkg]);
|
|
198
266
|
if (ok) {
|
|
@@ -223,6 +291,24 @@ async function detectNpmCommand(): Promise<{
|
|
|
223
291
|
return null;
|
|
224
292
|
}
|
|
225
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Global bin dir for the detected Node installer. Best effort: bun and npm
|
|
296
|
+
* report it differently, and null simply disables the reach check rather than
|
|
297
|
+
* blocking an install we can't reason about.
|
|
298
|
+
*/
|
|
299
|
+
async function getNpmInstallDir(installer: {
|
|
300
|
+
cmd: string;
|
|
301
|
+
label: string;
|
|
302
|
+
}): Promise<string | null> {
|
|
303
|
+
if (installer.label === "bun") {
|
|
304
|
+
const { ok, stdout } = await run(installer.cmd, ["pm", "bin", "-g"], 10000);
|
|
305
|
+
return ok && stdout ? stdout.split("\n")[0].trim() : null;
|
|
306
|
+
}
|
|
307
|
+
const { ok, stdout } = await run(installer.cmd, ["prefix", "-g"], 10000);
|
|
308
|
+
if (!ok || !stdout) return null;
|
|
309
|
+
return path.join(stdout.split("\n")[0].trim(), "bin");
|
|
310
|
+
}
|
|
311
|
+
|
|
226
312
|
/**
|
|
227
313
|
* Install global Node.js packages (prefers bun > npm)
|
|
228
314
|
*/
|
|
@@ -238,11 +324,23 @@ async function installNpmPackages(
|
|
|
238
324
|
return;
|
|
239
325
|
}
|
|
240
326
|
|
|
327
|
+
const installDir = () => getNpmInstallDir(installer);
|
|
328
|
+
|
|
241
329
|
for (const pkg of packages) {
|
|
242
|
-
|
|
330
|
+
// Parsed, not raw: `isBinaryAvailable("foo@1.2.3")` looks for a binary
|
|
331
|
+
// literally named "foo@1.2.3", never finds one, and reinstalls forever.
|
|
332
|
+
const decision = await binaryDepDecision(parseAtSpec(pkg), installDir);
|
|
333
|
+
if (decision.action === "skip") {
|
|
243
334
|
result.skipped.push(`npm:${pkg}`);
|
|
244
335
|
continue;
|
|
245
336
|
}
|
|
337
|
+
if (decision.action === "blocked") {
|
|
338
|
+
result.failed.push({
|
|
339
|
+
pkg: `${installer.label}:${pkg}`,
|
|
340
|
+
error: decision.detail,
|
|
341
|
+
});
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
246
344
|
|
|
247
345
|
const { ok, stderr } = await run(installer.cmd, [...installer.args, pkg]);
|
|
248
346
|
if (ok) {
|
|
@@ -269,12 +367,23 @@ async function installCargoPackages(
|
|
|
269
367
|
}
|
|
270
368
|
|
|
271
369
|
for (const pkg of packages) {
|
|
272
|
-
|
|
370
|
+
const parsed = parseAtSpec(pkg);
|
|
371
|
+
const decision = await binaryDepDecision(parsed, getCargoInstallDir);
|
|
372
|
+
if (decision.action === "skip") {
|
|
273
373
|
result.skipped.push(`cargo:${pkg}`);
|
|
274
374
|
continue;
|
|
275
375
|
}
|
|
376
|
+
if (decision.action === "blocked") {
|
|
377
|
+
result.failed.push({ pkg: `cargo:${pkg}`, error: decision.detail });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
276
380
|
|
|
277
|
-
|
|
381
|
+
// `--version` rather than `crate@ver`: the explicit flag works on every
|
|
382
|
+
// cargo, the @ form only on recent ones.
|
|
383
|
+
const args = parsed.version
|
|
384
|
+
? ["install", parsed.name, "--version", parsed.version]
|
|
385
|
+
: ["install", parsed.spec];
|
|
386
|
+
const { ok, stderr } = await run(cargoPath, args, 300000);
|
|
278
387
|
if (ok) {
|
|
279
388
|
result.installed.push(`cargo:${pkg}`);
|
|
280
389
|
} else {
|
|
@@ -290,9 +399,7 @@ async function installCargoPackages(
|
|
|
290
399
|
* "github.com/user/tool" → "tool"
|
|
291
400
|
*/
|
|
292
401
|
export function extractGoBinaryName(pkg: string): string {
|
|
293
|
-
|
|
294
|
-
const withoutVersion = pkg.replace(/@[^/]*$/, "");
|
|
295
|
-
return withoutVersion.split("/").pop() || pkg;
|
|
402
|
+
return parseAtSpec(pkg).name;
|
|
296
403
|
}
|
|
297
404
|
|
|
298
405
|
/**
|
|
@@ -302,11 +409,7 @@ export function extractGoBinaryName(pkg: string): string {
|
|
|
302
409
|
* "github.com/user/tool" → null
|
|
303
410
|
*/
|
|
304
411
|
export function extractGoVersion(pkg: string): string | null {
|
|
305
|
-
|
|
306
|
-
if (at === -1) return null;
|
|
307
|
-
const version = pkg.slice(at + 1);
|
|
308
|
-
if (version === "" || version === "latest") return null;
|
|
309
|
-
return version;
|
|
412
|
+
return parseAtSpec(pkg).version;
|
|
310
413
|
}
|
|
311
414
|
|
|
312
415
|
/** Normalize a semver-ish string for comparison: trim and drop a leading "v". */
|
|
@@ -315,43 +418,278 @@ function normalizeVersion(v: string): string {
|
|
|
315
418
|
}
|
|
316
419
|
|
|
317
420
|
/**
|
|
318
|
-
* Read
|
|
319
|
-
*
|
|
320
|
-
*
|
|
421
|
+
* Read a binary's version via `<path> --version`, normalized, or null when it
|
|
422
|
+
* produced no usable version string.
|
|
423
|
+
*
|
|
424
|
+
* Takes a RESOLVED path, never a bare name. Probing by name would re-run PATH
|
|
425
|
+
* resolution and can read a different binary than the one whose presence was
|
|
426
|
+
* checked — which is how a version check ends up passing judgement on one file
|
|
427
|
+
* while `go install` fixes another.
|
|
321
428
|
*/
|
|
322
429
|
async function getInstalledBinaryVersion(
|
|
323
|
-
|
|
430
|
+
binaryPath: string,
|
|
324
431
|
): Promise<string | null> {
|
|
325
|
-
const { ok, stdout } = await run(
|
|
432
|
+
const { ok, stdout } = await run(binaryPath, ["--version"], 10000);
|
|
326
433
|
if (!ok || !stdout) return null;
|
|
327
434
|
const match = stdout.split("\n")[0].match(/v?\d+\.\d+\.\d+[^\s]*/);
|
|
328
435
|
return match ? normalizeVersion(match[0]) : null;
|
|
329
436
|
}
|
|
330
437
|
|
|
438
|
+
/** PATH split into ordered, non-empty entries. */
|
|
439
|
+
function pathEntries(): string[] {
|
|
440
|
+
return (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
|
441
|
+
}
|
|
442
|
+
|
|
331
443
|
/**
|
|
332
|
-
*
|
|
333
|
-
* plugin *update* actually update its binary: without a version check an
|
|
334
|
-
* already-present binary is skipped forever, so a bumped plugin never pulls the
|
|
335
|
-
* new binary.
|
|
444
|
+
* Parse `go env -json GOBIN GOPATH`.
|
|
336
445
|
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
|
|
342
|
-
|
|
446
|
+
* JSON rather than the line-oriented form deliberately: `go env GOBIN GOPATH`
|
|
447
|
+
* prints an empty FIRST line when GOBIN is unset, so any trimming of the output
|
|
448
|
+
* (which {@link run} does) silently promotes GOPATH into GOBIN's slot and points
|
|
449
|
+
* the install dir one level too high.
|
|
450
|
+
*/
|
|
451
|
+
export function parseGoEnv(stdout: string): { gobin: string; gopath: string } {
|
|
452
|
+
try {
|
|
453
|
+
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
|
454
|
+
const str = (v: unknown) => (typeof v === "string" ? v.trim() : "");
|
|
455
|
+
return { gobin: str(parsed.GOBIN), gopath: str(parsed.GOPATH) };
|
|
456
|
+
} catch {
|
|
457
|
+
return { gobin: "", gopath: "" };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The directory `go install` writes into: GOBIN when set, else GOPATH/bin.
|
|
463
|
+
* Null when go can't be asked — callers must then draw no conclusions about
|
|
464
|
+
* where an install would land.
|
|
465
|
+
*/
|
|
466
|
+
async function getGoInstallDir(): Promise<string | null> {
|
|
467
|
+
const goPath = await which("go");
|
|
468
|
+
if (!goPath) return null;
|
|
469
|
+
const { ok, stdout } = await run(
|
|
470
|
+
goPath,
|
|
471
|
+
["env", "-json", "GOBIN", "GOPATH"],
|
|
472
|
+
10000,
|
|
473
|
+
);
|
|
474
|
+
if (!ok) return null;
|
|
475
|
+
const { gobin, gopath } = parseGoEnv(stdout);
|
|
476
|
+
if (gobin) return gobin;
|
|
477
|
+
if (gopath) return path.join(gopath, "bin");
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Directories in `entries` (PATH order) holding an executable `binaryName`. */
|
|
482
|
+
async function findOccurrenceDirs(
|
|
483
|
+
binaryName: string,
|
|
484
|
+
entries: string[],
|
|
485
|
+
): Promise<string[]> {
|
|
486
|
+
const dirs: string[] = [];
|
|
487
|
+
for (const entry of entries) {
|
|
488
|
+
try {
|
|
489
|
+
await access(path.join(entry, binaryName), fsConstants.X_OK);
|
|
490
|
+
dirs.push(entry);
|
|
491
|
+
} catch {
|
|
492
|
+
// absent here, or not executable
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return dirs;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export type InstallReach =
|
|
499
|
+
| { reach: "wins" }
|
|
500
|
+
| { reach: "shadowed"; byDir: string }
|
|
501
|
+
| { reach: "off-path" };
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Whether writing a binary into `installDir` would change what PATH resolves —
|
|
505
|
+
* the question `go install` on its own cannot answer.
|
|
506
|
+
*
|
|
507
|
+
* Walks PATH in order; the first entry that either IS the install dir or
|
|
508
|
+
* already holds the binary decides. Running off the end without meeting the
|
|
509
|
+
* install dir means a binary placed there can never be executed.
|
|
510
|
+
*
|
|
511
|
+
* Pure by design: the caller supplies `occurrenceDirs` and `installDir`, so the
|
|
512
|
+
* ordering rules are testable without touching a filesystem or a PATH.
|
|
513
|
+
*/
|
|
514
|
+
export function classifyInstallReach(
|
|
515
|
+
entries: string[],
|
|
516
|
+
occurrenceDirs: string[],
|
|
517
|
+
installDir: string,
|
|
518
|
+
): InstallReach {
|
|
519
|
+
const target = path.resolve(installDir);
|
|
520
|
+
const occupied = new Set(occurrenceDirs.map((d) => path.resolve(d)));
|
|
521
|
+
|
|
522
|
+
for (const entry of entries) {
|
|
523
|
+
const dir = path.resolve(entry);
|
|
524
|
+
if (dir === target) return { reach: "wins" };
|
|
525
|
+
if (occupied.has(dir)) return { reach: "shadowed", byDir: entry };
|
|
526
|
+
}
|
|
527
|
+
return { reach: "off-path" };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export type GoDepDecision =
|
|
531
|
+
| {
|
|
532
|
+
action: "install";
|
|
533
|
+
reason: "absent" | "version-mismatch" | "version-unreadable";
|
|
534
|
+
}
|
|
535
|
+
| { action: "skip"; reason: "up-to-date" | "unpinned-present" }
|
|
536
|
+
| {
|
|
537
|
+
action: "blocked";
|
|
538
|
+
reason: "shadowed" | "install-dir-off-path";
|
|
539
|
+
detail: string;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Why an install into the go bin dir would not take effect, or null when it
|
|
544
|
+
* would — or when we cannot tell, in which case we don't stand in the way.
|
|
545
|
+
*/
|
|
546
|
+
async function findInstallBlocker(
|
|
547
|
+
binaryName: string,
|
|
548
|
+
installedVersion: string | null,
|
|
549
|
+
getInstallDir: () => Promise<string | null>,
|
|
550
|
+
): Promise<GoDepDecision | null> {
|
|
551
|
+
const installDir = await getInstallDir();
|
|
552
|
+
if (!installDir) return null;
|
|
553
|
+
|
|
554
|
+
const entries = pathEntries();
|
|
555
|
+
const reach = classifyInstallReach(
|
|
556
|
+
entries,
|
|
557
|
+
await findOccurrenceDirs(binaryName, entries),
|
|
558
|
+
installDir,
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
if (reach.reach === "wins") return null;
|
|
562
|
+
|
|
563
|
+
if (reach.reach === "off-path") {
|
|
564
|
+
return {
|
|
565
|
+
action: "blocked",
|
|
566
|
+
reason: "install-dir-off-path",
|
|
567
|
+
detail: [
|
|
568
|
+
`go install writes ${binaryName} to ${installDir}, which is not in`,
|
|
569
|
+
"PATH, so the installed binary would never run.",
|
|
570
|
+
`Add ${installDir} to PATH.`,
|
|
571
|
+
].join(" "),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const shadowPath = path.join(reach.byDir, binaryName);
|
|
576
|
+
const reports =
|
|
577
|
+
installedVersion === null
|
|
578
|
+
? "which cannot report a version"
|
|
579
|
+
: `which reports ${installedVersion}`;
|
|
580
|
+
return {
|
|
581
|
+
action: "blocked",
|
|
582
|
+
reason: "shadowed",
|
|
583
|
+
detail: [
|
|
584
|
+
`go install would write ${binaryName} to ${installDir}, but PATH resolves`,
|
|
585
|
+
`${binaryName} to ${shadowPath} first (${reports}), so installing cannot`,
|
|
586
|
+
`change what runs. Remove ${shadowPath}, or move ${installDir} ahead of`,
|
|
587
|
+
`${reach.byDir} in PATH.`,
|
|
588
|
+
].join(" "),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Decide what to do about a Go dependency. This is what makes a plugin *update*
|
|
594
|
+
* actually update its binary: without a version check an already-present binary
|
|
595
|
+
* is skipped forever, so a bumped plugin never pulls the new one.
|
|
596
|
+
*
|
|
597
|
+
* The binary is resolved ONCE and that exact path is probed. Resolving per step
|
|
598
|
+
* is how a version check judges one file while `go install` fixes another, then
|
|
599
|
+
* repeats forever — reporting success every time.
|
|
600
|
+
*
|
|
601
|
+
* - binary absent → install
|
|
602
|
+
* - pinned version, resolved binary reports the same → skip
|
|
603
|
+
* - pinned version, resolved binary reports another → install
|
|
604
|
+
* - pinned version, version can't be read → install (can't
|
|
605
|
+
* confirm the pin, so reinstall to be safe)
|
|
606
|
+
* - unpinned (@latest) and present → skip (a moving target
|
|
343
607
|
* can't be verified without a network round-trip)
|
|
608
|
+
* - an install PATH would ignore → blocked, naming the
|
|
609
|
+
* binary that shadows it
|
|
344
610
|
*/
|
|
345
|
-
export async function
|
|
346
|
-
|
|
347
|
-
|
|
611
|
+
export async function binaryDepDecision(
|
|
612
|
+
parsed: ParsedSpec,
|
|
613
|
+
getInstallDir: () => Promise<string | null>,
|
|
614
|
+
): Promise<GoDepDecision> {
|
|
615
|
+
const { name, version: pinned } = parsed;
|
|
616
|
+
const resolved = await which(name);
|
|
617
|
+
|
|
618
|
+
if (!resolved) {
|
|
619
|
+
return (
|
|
620
|
+
(await findInstallBlocker(name, null, getInstallDir)) ?? {
|
|
621
|
+
action: "install",
|
|
622
|
+
reason: "absent",
|
|
623
|
+
}
|
|
624
|
+
);
|
|
625
|
+
}
|
|
348
626
|
|
|
349
|
-
|
|
350
|
-
if (!pinned) return false;
|
|
627
|
+
if (!pinned) return { action: "skip", reason: "unpinned-present" };
|
|
351
628
|
|
|
352
|
-
const installed = await getInstalledBinaryVersion(
|
|
353
|
-
if (installed ===
|
|
354
|
-
|
|
629
|
+
const installed = await getInstalledBinaryVersion(resolved);
|
|
630
|
+
if (installed !== null && installed === normalizeVersion(pinned)) {
|
|
631
|
+
return { action: "skip", reason: "up-to-date" };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return (
|
|
635
|
+
(await findInstallBlocker(name, installed, getInstallDir)) ?? {
|
|
636
|
+
action: "install",
|
|
637
|
+
reason: installed === null ? "version-unreadable" : "version-mismatch",
|
|
638
|
+
}
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** {@link binaryDepDecision} for a Go module path. */
|
|
643
|
+
export async function goDepDecision(pkg: string): Promise<GoDepDecision> {
|
|
644
|
+
return binaryDepDecision(parseAtSpec(pkg), getGoInstallDir);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Where `cargo install` puts binaries: CARGO_INSTALL_ROOT, else CARGO_HOME/bin,
|
|
649
|
+
* else ~/.cargo/bin.
|
|
650
|
+
*/
|
|
651
|
+
async function getCargoInstallDir(): Promise<string | null> {
|
|
652
|
+
const root = process.env.CARGO_INSTALL_ROOT;
|
|
653
|
+
if (root) return path.join(root, "bin");
|
|
654
|
+
const home = process.env.CARGO_HOME;
|
|
655
|
+
if (home) return path.join(home, "bin");
|
|
656
|
+
return path.join(os.homedir(), ".cargo", "bin");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Where brew links binaries: `brew --prefix`/bin. */
|
|
660
|
+
async function getBrewInstallDir(): Promise<string | null> {
|
|
661
|
+
const brewPath = await which("brew");
|
|
662
|
+
if (!brewPath) return null;
|
|
663
|
+
const { ok, stdout } = await run(brewPath, ["--prefix"], 10000);
|
|
664
|
+
if (!ok || !stdout) return null;
|
|
665
|
+
return path.join(stdout.split("\n")[0].trim(), "bin");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** {@link binaryDepDecision} for a cargo crate. */
|
|
669
|
+
export async function cargoDepDecision(pkg: string): Promise<GoDepDecision> {
|
|
670
|
+
return binaryDepDecision(parseAtSpec(pkg), getCargoInstallDir);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Decision for a brew formula. Brew has no exact-pin syntax, so `version` is
|
|
675
|
+
* always null and this reduces to presence plus a reach check — which is the
|
|
676
|
+
* case that actually bit us: a Homebrew copy sitting behind another PATH entry.
|
|
677
|
+
*/
|
|
678
|
+
export async function brewDepDecision(pkg: string): Promise<GoDepDecision> {
|
|
679
|
+
const name = extractBinaryName(pkg);
|
|
680
|
+
return binaryDepDecision(
|
|
681
|
+
{ spec: pkg, name, version: null },
|
|
682
|
+
getBrewInstallDir,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Boolean view of {@link goDepDecision}: does this dependency still need
|
|
688
|
+
* attention? A blocked dep counts — it is not satisfied, and the install path
|
|
689
|
+
* reports why rather than silently succeeding.
|
|
690
|
+
*/
|
|
691
|
+
export async function goDepNeedsInstall(pkg: string): Promise<boolean> {
|
|
692
|
+
return (await goDepDecision(pkg)).action !== "skip";
|
|
355
693
|
}
|
|
356
694
|
|
|
357
695
|
/**
|
|
@@ -370,11 +708,21 @@ async function installGoPackages(
|
|
|
370
708
|
}
|
|
371
709
|
|
|
372
710
|
for (const pkg of packages) {
|
|
373
|
-
|
|
711
|
+
const decision = await goDepDecision(pkg);
|
|
712
|
+
|
|
713
|
+
if (decision.action === "skip") {
|
|
374
714
|
result.skipped.push(`go:${pkg}`);
|
|
375
715
|
continue;
|
|
376
716
|
}
|
|
377
717
|
|
|
718
|
+
// Installing would leave what actually runs untouched. Reporting success
|
|
719
|
+
// here is what turned a broken PATH into an endless reinstall loop that
|
|
720
|
+
// looked healthy every round, so say what's wrong instead.
|
|
721
|
+
if (decision.action === "blocked") {
|
|
722
|
+
result.failed.push({ pkg: `go:${pkg}`, error: decision.detail });
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
|
|
378
726
|
const { ok, stderr } = await run(goPath, ["install", pkg], 300000);
|
|
379
727
|
if (ok) {
|
|
380
728
|
result.installed.push(`go:${pkg}`);
|
|
@@ -529,10 +877,12 @@ export async function checkMissingDeps(
|
|
|
529
877
|
): Promise<SetupConfig> {
|
|
530
878
|
const missing: SetupConfig = {};
|
|
531
879
|
|
|
880
|
+
// Each of these mirrors its installer's decision function. When the two
|
|
881
|
+
// disagree the UI reports one thing and the install does another.
|
|
532
882
|
if (setup.pip?.length) {
|
|
533
883
|
const missingPip: string[] = [];
|
|
534
884
|
for (const pkg of setup.pip) {
|
|
535
|
-
if (
|
|
885
|
+
if (await pipDepNeedsInstall(pkg)) {
|
|
536
886
|
missingPip.push(pkg);
|
|
537
887
|
}
|
|
538
888
|
}
|
|
@@ -542,8 +892,7 @@ export async function checkMissingDeps(
|
|
|
542
892
|
if (setup.brew?.length) {
|
|
543
893
|
const missingBrew: string[] = [];
|
|
544
894
|
for (const pkg of setup.brew) {
|
|
545
|
-
|
|
546
|
-
if (!(await isBinaryAvailable(bin))) {
|
|
895
|
+
if ((await brewDepDecision(pkg)).action !== "skip") {
|
|
547
896
|
missingBrew.push(pkg);
|
|
548
897
|
}
|
|
549
898
|
}
|
|
@@ -551,9 +900,14 @@ export async function checkMissingDeps(
|
|
|
551
900
|
}
|
|
552
901
|
|
|
553
902
|
if (setup.npm?.length) {
|
|
903
|
+
const installer = await detectNpmCommand();
|
|
904
|
+
const installDir = installer
|
|
905
|
+
? () => getNpmInstallDir(installer)
|
|
906
|
+
: async () => null;
|
|
554
907
|
const missingNpm: string[] = [];
|
|
555
908
|
for (const pkg of setup.npm) {
|
|
556
|
-
|
|
909
|
+
const decision = await binaryDepDecision(parseAtSpec(pkg), installDir);
|
|
910
|
+
if (decision.action !== "skip") {
|
|
557
911
|
missingNpm.push(pkg);
|
|
558
912
|
}
|
|
559
913
|
}
|
|
@@ -563,7 +917,7 @@ export async function checkMissingDeps(
|
|
|
563
917
|
if (setup.cargo?.length) {
|
|
564
918
|
const missingCargo: string[] = [];
|
|
565
919
|
for (const pkg of setup.cargo) {
|
|
566
|
-
if (
|
|
920
|
+
if ((await cargoDepDecision(pkg)).action !== "skip") {
|
|
567
921
|
missingCargo.push(pkg);
|
|
568
922
|
}
|
|
569
923
|
}
|