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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.29.0",
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.29.0",
68
- "claudeup-darwin-x64": "4.29.0",
69
- "claudeup-linux-x64": "4.29.0"
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", { stdout: "v1.6.1", stderr: "" });
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", { stdout: "1.6.2", stderr: "" });
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", { stdout: "v1.6.1", stderr: "" });
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", { stdout: "v1.6.1", stderr: "" });
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 Python package is already installed
110
+ * Check if a binary is available in PATH
109
111
  */
110
- async function isPythonPackageInstalled(pkg: string): Promise<boolean> {
111
- // Convert package name to importable module name (e.g., browser-use → browser_use)
112
- const moduleName = pkg.replace(/-/g, "_");
113
- const result = await run("python3", ["-c", `import ${moduleName}`]);
114
- return result.ok;
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
- * Check if a binary is available in PATH
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
- async function isBinaryAvailable(name: string): Promise<boolean> {
121
- return (await which(name)) !== null;
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
- * Extract binary name from a brew formula or npm package
126
- * e.g., "memextech/tap/ht-mcp" "ht-mcp"
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 extractBinaryName(pkg: string): string {
129
- return pkg.split("/").pop() || pkg;
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 installed
211
+ // Filter out those already at the pinned version
148
212
  const toInstall: string[] = [];
149
213
  for (const pkg of packages) {
150
- if (await isPythonPackageInstalled(pkg)) {
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 binaryName = extractBinaryName(pkg);
192
- if (await isBinaryAvailable(binaryName)) {
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
- if (await isBinaryAvailable(pkg)) {
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
- if (await isBinaryAvailable(pkg)) {
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
- const { ok, stderr } = await run(cargoPath, ["install", pkg], 300000);
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
- // Strip @version suffix first
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
- const at = pkg.lastIndexOf("@");
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 an installed binary's version via `<binary> --version`, normalized, or
319
- * null if it produced no usable version string. Used to tell whether a pinned Go
320
- * dependency is already at the right version.
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
- binaryName: string,
430
+ binaryPath: string,
324
431
  ): Promise<string | null> {
325
- const { ok, stdout } = await run(binaryName, ["--version"], 10000);
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
- * Decide whether a Go dependency needs (re)installing. This is what makes a
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
- * - binary absent → yes
338
- * - pinned version, installed reports the same one → no
339
- * - pinned version, installed reports a different one yes
340
- * - pinned version, installed version can't be read → yes (can't confirm the
341
- * pin, so reinstall to be safe)
342
- * - unpinned (@latest) and present → no (a moving target
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 goDepNeedsInstall(pkg: string): Promise<boolean> {
346
- const binaryName = extractGoBinaryName(pkg);
347
- if (!(await isBinaryAvailable(binaryName))) return true;
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
- const pinned = extractGoVersion(pkg);
350
- if (!pinned) return false;
627
+ if (!pinned) return { action: "skip", reason: "unpinned-present" };
351
628
 
352
- const installed = await getInstalledBinaryVersion(binaryName);
353
- if (installed === null) return true;
354
- return normalizeVersion(pinned) !== installed;
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
- if (!(await goDepNeedsInstall(pkg))) {
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 (!(await isPythonPackageInstalled(pkg))) {
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
- const bin = extractBinaryName(pkg);
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
- if (!(await isBinaryAvailable(pkg))) {
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 (!(await isBinaryAvailable(pkg))) {
920
+ if ((await cargoDepDecision(pkg)).action !== "skip") {
567
921
  missingCargo.push(pkg);
568
922
  }
569
923
  }