magus-cli 7.8.0 → 7.9.1

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.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +16 -11
  4. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  5. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  6. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  7. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  8. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  9. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  10. package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
  11. package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
  12. package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
  13. package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
  14. package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
  15. package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
  16. package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
  17. package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
  18. package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
  19. package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
  20. package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
  21. package/src/__tests__/blind-780-review2.test.ts +0 -25
  22. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  23. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  24. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  25. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  26. package/src/__tests__/deps-adapter.test.ts +191 -0
  27. package/src/__tests__/deps-check-hook.test.ts +220 -0
  28. package/src/__tests__/deps-plan.test.ts +897 -0
  29. package/src/__tests__/deps-report.test.ts +197 -0
  30. package/src/__tests__/doctor-bins.test.ts +134 -36
  31. package/src/__tests__/doctor-json.test.ts +261 -0
  32. package/src/__tests__/failure-popup-review2.test.tsx +1 -25
  33. package/src/__tests__/final-fixes-780.test.tsx +25 -9
  34. package/src/__tests__/fixtures/bins.ts +55 -0
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  38. package/src/__tests__/npm-package-name.test.ts +6 -9
  39. package/src/__tests__/plugin-requires.test.ts +138 -153
  40. package/src/__tests__/requires-probe.test.ts +858 -0
  41. package/src/__tests__/resolver.test.ts +129 -52
  42. package/src/__tests__/run.test.ts +108 -0
  43. package/src/__tests__/tool-probe.test.ts +1 -1
  44. package/src/__tests__/toolchain.test.ts +123 -52
  45. package/src/__tests__/update-plan.test.ts +1 -1
  46. package/src/cli/ansi.ts +2 -7
  47. package/src/cli/deps-view.ts +213 -0
  48. package/src/cli/doctor.ts +169 -24
  49. package/src/cli/install.ts +51 -66
  50. package/src/cli/profile.ts +20 -2
  51. package/src/cli/router.ts +6 -2
  52. package/src/cli/update.ts +28 -7
  53. package/src/data/cli-tools.ts +24 -9
  54. package/src/services/active-closure.ts +3 -3
  55. package/src/services/claude-settings.ts +4 -348
  56. package/src/services/cli-tool-commands.ts +104 -3
  57. package/src/services/deps-plan.ts +1154 -0
  58. package/src/services/deps-report.ts +265 -0
  59. package/src/services/doctor-bins.ts +115 -43
  60. package/src/services/install-plan.ts +11 -1
  61. package/src/services/local-marketplace.ts +12 -206
  62. package/src/services/marketplace-refresh.ts +4 -4
  63. package/src/services/plugin-manager.ts +4 -18
  64. package/src/services/plugin-requires.ts +93 -162
  65. package/src/services/resolver.ts +168 -58
  66. package/src/services/tool-probe.ts +18 -25
  67. package/src/services/toolchain.ts +117 -111
  68. package/src/services/update-engine.ts +19 -14
  69. package/src/services/update-plan.ts +3 -3
  70. package/src/shared/plugin/hooks/bun-missing.sh +64 -0
  71. package/src/shared/plugin/hooks/deps-check.ts +240 -0
  72. package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
  73. package/src/types/index.ts +53 -16
  74. package/src/ui/App.tsx +6 -40
  75. package/src/ui/adapters/cliToolsAdapter.ts +75 -37
  76. package/src/ui/adapters/depsAdapter.ts +129 -0
  77. package/src/ui/adapters/startupReports.ts +0 -22
  78. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  79. package/src/ui/screens/CliToolsScreen.tsx +141 -51
  80. package/src/ui/screens/PluginsScreen.tsx +12 -48
  81. package/src/utils/command-utils.ts +4 -5
  82. package/src/utils/run.ts +143 -52
  83. package/src/utils/string-utils.ts +9 -0
  84. package/src/__tests__/go-module.test.ts +0 -43
  85. package/src/__tests__/plugin-setup.test.ts +0 -1016
  86. package/src/__tests__/shell-script-callers.test.ts +0 -92
  87. package/src/services/plugin-setup.ts +0 -965
  88. package/src/utils/go-module.ts +0 -36
@@ -1,1016 +0,0 @@
1
- /**
2
- * Tests for plugin-setup.ts: go package manager and required binary validator.
3
- *
4
- * Tests extractGoBinaryName (pure function), and uses module mocking to
5
- * verify installPluginDeps() and checkMissingDeps() behavior for `go`
6
- * and `required` setup keys.
7
- */
8
-
9
- import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
10
- import * as actualChildProcess from "node:child_process";
11
- import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
12
- import { tmpdir } from "node:os";
13
- import path from "node:path";
14
- // Real modules captured before the mock.module() overrides, so the mocks spread
15
- // the full surface instead of stripping exports (e.g. resolveExecutable) that
16
- // bun's global, non-restored mock.module would otherwise remove for later files.
17
- import * as actualCommandUtils from "../utils/command-utils.js";
18
-
19
- // ---------------------------------------------------------------------------
20
- // Mocks — must be set up BEFORE any import from plugin-setup
21
- // ---------------------------------------------------------------------------
22
-
23
- // Track which commands are "available" and what execFile calls return
24
- let availableBinaries: Set<string>;
25
- let execResults: Map<
26
- string,
27
- { stdout: string; stderr: string; error?: boolean }
28
- >;
29
-
30
- // Mock command-utils BEFORE importing plugin-setup
31
- mock.module("../utils/command-utils.js", () => ({
32
- ...actualCommandUtils,
33
- which: async (cmd: string) => {
34
- return availableBinaries.has(cmd) ? `/usr/bin/${cmd}` : null;
35
- },
36
- }));
37
-
38
- // Mock child_process execFile — promisify wraps this callback-based API
39
- mock.module("node:child_process", () => ({
40
- ...actualChildProcess,
41
- execFile: (
42
- cmd: string,
43
- args: string[],
44
- opts: unknown,
45
- cb?: (
46
- err: Error | null,
47
- result: { stdout: string; stderr: string },
48
- ) => void,
49
- ) => {
50
- const key = `${cmd} ${args.join(" ")}`;
51
-
52
- // Find matching result by prefix
53
- let result = execResults.get(key);
54
- if (!result) {
55
- for (const [k, v] of execResults.entries()) {
56
- if (key.startsWith(k)) {
57
- result = v;
58
- break;
59
- }
60
- }
61
- }
62
-
63
- if (!result) {
64
- result = { stdout: "", stderr: "", error: false };
65
- }
66
-
67
- if (typeof cb === "function") {
68
- if (result.error) {
69
- const err = new Error(result.stderr) as Error & {
70
- stdout: string;
71
- stderr: string;
72
- };
73
- err.stdout = result.stdout;
74
- err.stderr = result.stderr;
75
- cb(err, { stdout: result.stdout, stderr: result.stderr });
76
- } else {
77
- cb(null, { stdout: result.stdout, stderr: result.stderr });
78
- }
79
- }
80
- },
81
- }));
82
-
83
- // Import AFTER mocks are set up
84
- const {
85
- extractGoBinaryName,
86
- extractGoVersion,
87
- extractBinaryName,
88
- parseAtSpec,
89
- parseGoSpec,
90
- parsePipSpec,
91
- goDepNeedsInstall,
92
- goDepDecision,
93
- parseGoEnv,
94
- classifyInstallReach,
95
- installPluginDeps,
96
- checkMissingDeps,
97
- } = await import("../services/plugin-setup.js");
98
-
99
- // ---------------------------------------------------------------------------
100
- // 1. Pure function tests — extractGoBinaryName
101
- // ---------------------------------------------------------------------------
102
-
103
- describe("extractGoBinaryName", () => {
104
- it("extracts binary name from module@version", () => {
105
- expect(extractGoBinaryName("github.com/MadAppGang/tmux-mcp@latest")).toBe(
106
- "tmux-mcp",
107
- );
108
- });
109
-
110
- it("extracts binary name from module@semver", () => {
111
- expect(extractGoBinaryName("github.com/user/tool@v1.2.3")).toBe("tool");
112
- });
113
-
114
- it("extracts binary name from module without version", () => {
115
- expect(extractGoBinaryName("github.com/user/tool")).toBe("tool");
116
- });
117
-
118
- it("handles single-segment package name", () => {
119
- expect(extractGoBinaryName("mytool")).toBe("mytool");
120
- });
121
-
122
- it("handles module with nested path and version", () => {
123
- expect(extractGoBinaryName("github.com/org/repo/cmd/mycli@v2.0.0")).toBe(
124
- "mycli",
125
- );
126
- });
127
-
128
- it("handles empty string gracefully", () => {
129
- expect(extractGoBinaryName("")).toBe("");
130
- });
131
-
132
- // Negative control for the /vN fix: a plain basename returns "v2" here.
133
- // Without the go-module helper wired in, this test FAILS.
134
- it("names the binary after the module, not its /v2 major-version suffix", () => {
135
- expect(
136
- extractGoBinaryName("github.com/MadAppGang/tmux-mcp/v2@v2.0.0"),
137
- ).toBe("tmux-mcp");
138
- });
139
-
140
- it("strips the /v2 suffix from an unpinned module path too", () => {
141
- expect(extractGoBinaryName("github.com/MadAppGang/tmux-mcp/v2")).toBe(
142
- "tmux-mcp",
143
- );
144
- });
145
-
146
- it("keeps a name that merely starts with v2", () => {
147
- expect(extractGoBinaryName("github.com/x/v2tool@v1.0.0")).toBe("v2tool");
148
- });
149
- });
150
-
151
- // ---------------------------------------------------------------------------
152
- // 1b. parseGoSpec — the Go-specific view over parseAtSpec
153
- // ---------------------------------------------------------------------------
154
-
155
- describe("parseGoSpec", () => {
156
- it("v1 module: name and pinned version", () => {
157
- expect(parseGoSpec("github.com/MadAppGang/tmux-mcp@v1.7.1")).toEqual({
158
- spec: "github.com/MadAppGang/tmux-mcp@v1.7.1",
159
- name: "tmux-mcp",
160
- version: "v1.7.1",
161
- });
162
- });
163
-
164
- it("v2 module: the /v2 suffix is not the name, the version survives", () => {
165
- expect(parseGoSpec("github.com/MadAppGang/tmux-mcp/v2@v2.0.0")).toEqual({
166
- spec: "github.com/MadAppGang/tmux-mcp/v2@v2.0.0",
167
- name: "tmux-mcp",
168
- version: "v2.0.0",
169
- });
170
- });
171
-
172
- it("v2 module without a pin: name resolved, version null", () => {
173
- expect(parseGoSpec("github.com/MadAppGang/tmux-mcp/v2")).toEqual({
174
- spec: "github.com/MadAppGang/tmux-mcp/v2",
175
- name: "tmux-mcp",
176
- version: null,
177
- });
178
- });
179
-
180
- it("a segment that starts with v2 is a real name", () => {
181
- expect(parseGoSpec("github.com/x/v2tool@v1.0.0")).toEqual({
182
- spec: "github.com/x/v2tool@v1.0.0",
183
- name: "v2tool",
184
- version: "v1.0.0",
185
- });
186
- });
187
-
188
- it("@latest is unpinned, same as parseAtSpec", () => {
189
- expect(parseGoSpec("github.com/MadAppGang/tmux-mcp/v2@latest")).toEqual({
190
- spec: "github.com/MadAppGang/tmux-mcp/v2@latest",
191
- name: "tmux-mcp",
192
- version: null,
193
- });
194
- });
195
-
196
- // Terminal 5.0.0 contract cases (test plan Area 3, C5 and C6). They live
197
- // here rather than in their own file because any test file that imports
198
- // plugin-setup.js ahead of the mock.module seam above bakes the real
199
- // execFile into promisify() and fails the installPluginDeps suites.
200
- it("C5: a /v10 suffix is stripped in full, not only its first digit", () => {
201
- const { name, version } = parseGoSpec("github.com/x/tool/v10@v10.1.0");
202
- expect({ name, version }).toEqual({ name: "tool", version: "v10.1.0" });
203
- });
204
-
205
- it("C6: /v1 is never a Go major-version suffix, so it stays the name", () => {
206
- const { name, version } = parseGoSpec("github.com/x/tool/v1@v1.0.0");
207
- expect({ name, version }).toEqual({ name: "v1", version: "v1.0.0" });
208
- });
209
-
210
- it("C6 control: the same shape at /v2 is a suffix and is stripped", () => {
211
- const { name, version } = parseGoSpec("github.com/x/tool/v2@v2.0.0");
212
- expect({ name, version }).toEqual({ name: "tool", version: "v2.0.0" });
213
- });
214
- });
215
-
216
- // ---------------------------------------------------------------------------
217
- // 2. Integration tests — installPluginDeps with go packages
218
- // ---------------------------------------------------------------------------
219
-
220
- describe("installPluginDeps — go packages", () => {
221
- beforeEach(() => {
222
- availableBinaries = new Set(["go"]); // go is available by default
223
- execResults = new Map();
224
- });
225
-
226
- it("skips go packages when binary already exists", async () => {
227
- availableBinaries.add("tmux-mcp");
228
-
229
- const result = await installPluginDeps({
230
- go: ["github.com/MadAppGang/tmux-mcp@latest"],
231
- });
232
-
233
- expect(result.skipped).toContain(
234
- "go:github.com/MadAppGang/tmux-mcp@latest",
235
- );
236
- expect(result.installed).toHaveLength(0);
237
- expect(result.failed).toHaveLength(0);
238
- });
239
-
240
- it("installs go packages when binary not found", async () => {
241
- // go is available but tmux-mcp is not
242
- execResults.set("/usr/bin/go install", {
243
- stdout: "",
244
- stderr: "",
245
- });
246
-
247
- const result = await installPluginDeps({
248
- go: ["github.com/MadAppGang/tmux-mcp@latest"],
249
- });
250
-
251
- expect(result.installed).toContain(
252
- "go:github.com/MadAppGang/tmux-mcp@latest",
253
- );
254
- expect(result.failed).toHaveLength(0);
255
- });
256
-
257
- it("reports failure when go is not available", async () => {
258
- availableBinaries.delete("go");
259
-
260
- const result = await installPluginDeps({
261
- go: ["github.com/MadAppGang/tmux-mcp@latest"],
262
- });
263
-
264
- expect(result.failed).toHaveLength(1);
265
- expect(result.failed[0].pkg).toBe(
266
- "go:github.com/MadAppGang/tmux-mcp@latest",
267
- );
268
- expect(result.failed[0].error).toBe("go not found");
269
- });
270
-
271
- it("reports failure when go install fails", async () => {
272
- execResults.set("/usr/bin/go install", {
273
- stdout: "",
274
- stderr: "build error: something went wrong",
275
- error: true,
276
- });
277
-
278
- const result = await installPluginDeps({
279
- go: ["github.com/MadAppGang/tmux-mcp@latest"],
280
- });
281
-
282
- expect(result.failed).toHaveLength(1);
283
- expect(result.failed[0].error).toContain("something went wrong");
284
- });
285
- });
286
-
287
- // ---------------------------------------------------------------------------
288
- // 3. Integration tests — installPluginDeps with required binaries
289
- // ---------------------------------------------------------------------------
290
-
291
- describe("installPluginDeps — required binaries", () => {
292
- beforeEach(() => {
293
- availableBinaries = new Set();
294
- execResults = new Map();
295
- });
296
-
297
- it("skips required binaries that are present", async () => {
298
- availableBinaries.add("tmux");
299
-
300
- const result = await installPluginDeps({
301
- required: ["tmux"],
302
- });
303
-
304
- expect(result.skipped).toContain("required:tmux");
305
- expect(result.failed).toHaveLength(0);
306
- });
307
-
308
- it("reports failure for missing required binaries", async () => {
309
- const result = await installPluginDeps({
310
- required: ["tmux"],
311
- });
312
-
313
- expect(result.failed).toHaveLength(1);
314
- expect(result.failed[0].pkg).toBe("required:tmux");
315
- expect(result.failed[0].error).toContain(
316
- "Required binary 'tmux' not found in PATH",
317
- );
318
- expect(result.failed[0].error).toContain("install it manually");
319
- });
320
-
321
- it("handles multiple required binaries with mixed availability", async () => {
322
- availableBinaries.add("tmux");
323
- // ffmpeg is not available
324
-
325
- const result = await installPluginDeps({
326
- required: ["tmux", "ffmpeg"],
327
- });
328
-
329
- expect(result.skipped).toContain("required:tmux");
330
- expect(result.failed).toHaveLength(1);
331
- expect(result.failed[0].pkg).toBe("required:ffmpeg");
332
- });
333
- });
334
-
335
- // ---------------------------------------------------------------------------
336
- // 4. checkMissingDeps tests — go packages
337
- // ---------------------------------------------------------------------------
338
-
339
- describe("checkMissingDeps — go packages", () => {
340
- beforeEach(() => {
341
- availableBinaries = new Set();
342
- execResults = new Map();
343
- });
344
-
345
- it("detects missing go packages when binary not in PATH", async () => {
346
- const missing = await checkMissingDeps({
347
- go: ["github.com/MadAppGang/tmux-mcp@latest"],
348
- });
349
-
350
- expect(missing.go).toEqual(["github.com/MadAppGang/tmux-mcp@latest"]);
351
- });
352
-
353
- it("does not report go packages when binary exists", async () => {
354
- availableBinaries.add("tmux-mcp");
355
-
356
- const missing = await checkMissingDeps({
357
- go: ["github.com/MadAppGang/tmux-mcp@latest"],
358
- });
359
-
360
- expect(missing.go).toBeUndefined();
361
- });
362
- });
363
-
364
- // ---------------------------------------------------------------------------
365
- // 4b. Version-aware go dependencies — a bumped plugin must pull the new binary
366
- // ---------------------------------------------------------------------------
367
-
368
- describe("extractGoVersion", () => {
369
- it("returns the pinned version", () => {
370
- expect(extractGoVersion("github.com/MadAppGang/tmux-mcp@v1.6.2")).toBe(
371
- "v1.6.2",
372
- );
373
- });
374
- it("returns null for @latest (a moving target)", () => {
375
- expect(
376
- extractGoVersion("github.com/MadAppGang/tmux-mcp@latest"),
377
- ).toBeNull();
378
- });
379
- it("returns null when unpinned", () => {
380
- expect(extractGoVersion("github.com/user/tool")).toBeNull();
381
- });
382
- it("reads the pin behind a /v2 module path", () => {
383
- expect(extractGoVersion("github.com/MadAppGang/tmux-mcp/v2@v2.0.0")).toBe(
384
- "v2.0.0",
385
- );
386
- });
387
- it("a /v2 module path with no pin is unpinned, not pinned to v2", () => {
388
- expect(extractGoVersion("github.com/MadAppGang/tmux-mcp/v2")).toBeNull();
389
- });
390
- });
391
-
392
- describe("goDepNeedsInstall — version awareness", () => {
393
- beforeEach(() => {
394
- availableBinaries = new Set(["go"]);
395
- execResults = new Map();
396
- });
397
-
398
- it("reinstalls when the pinned version differs from the installed one", async () => {
399
- availableBinaries.add("tmux-mcp");
400
- execResults.set("/usr/bin/tmux-mcp --version", {
401
- stdout: "v1.6.1",
402
- stderr: "",
403
- });
404
-
405
- expect(
406
- await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
407
- ).toBe(true);
408
- });
409
-
410
- it("skips when the installed version matches the pin (v-prefix insensitive)", async () => {
411
- availableBinaries.add("tmux-mcp");
412
- execResults.set("/usr/bin/tmux-mcp --version", {
413
- stdout: "1.6.2",
414
- stderr: "",
415
- });
416
-
417
- expect(
418
- await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
419
- ).toBe(false);
420
- });
421
-
422
- it("reinstalls when the binary can't report its version", async () => {
423
- availableBinaries.add("tmux-mcp");
424
- execResults.set("/usr/bin/tmux-mcp --version", {
425
- stdout: "",
426
- stderr: "flag provided but not defined: -version",
427
- error: true,
428
- });
429
-
430
- expect(
431
- await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
432
- ).toBe(true);
433
- });
434
-
435
- it("installs when the binary is absent", async () => {
436
- expect(
437
- await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
438
- ).toBe(true);
439
- });
440
-
441
- it("leaves an unpinned (@latest) present binary alone", async () => {
442
- availableBinaries.add("tmux-mcp");
443
- expect(
444
- await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@latest"),
445
- ).toBe(false);
446
- });
447
- });
448
-
449
- describe("checkMissingDeps — pinned go version mismatch", () => {
450
- beforeEach(() => {
451
- availableBinaries = new Set(["go", "tmux-mcp"]);
452
- execResults = new Map();
453
- });
454
-
455
- it("reports a present-but-outdated pinned binary as missing", async () => {
456
- execResults.set("/usr/bin/tmux-mcp --version", {
457
- stdout: "v1.6.1",
458
- stderr: "",
459
- });
460
-
461
- const missing = await checkMissingDeps({
462
- go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
463
- });
464
-
465
- expect(missing.go).toEqual(["github.com/MadAppGang/tmux-mcp@v1.6.2"]);
466
- });
467
- });
468
-
469
- describe("installPluginDeps — reinstalls an outdated pinned go binary", () => {
470
- beforeEach(() => {
471
- availableBinaries = new Set(["go", "tmux-mcp"]);
472
- execResults = new Map();
473
- });
474
-
475
- it("runs go install when the pinned version is newer than installed", async () => {
476
- execResults.set("/usr/bin/tmux-mcp --version", {
477
- stdout: "v1.6.1",
478
- stderr: "",
479
- });
480
- execResults.set("/usr/bin/go install", { stdout: "", stderr: "" });
481
-
482
- const result = await installPluginDeps({
483
- go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
484
- });
485
-
486
- expect(result.installed).toContain(
487
- "go:github.com/MadAppGang/tmux-mcp@v1.6.2",
488
- );
489
- expect(result.skipped).toHaveLength(0);
490
- });
491
- });
492
-
493
- // ---------------------------------------------------------------------------
494
- // 4c. Install reach — a version check is worthless if the install lands
495
- // somewhere PATH never looks. Pure ordering rules, no mocking needed.
496
- // ---------------------------------------------------------------------------
497
-
498
- describe("parseGoEnv", () => {
499
- it("reads GOBIN and GOPATH", () => {
500
- expect(
501
- parseGoEnv('{"GOBIN": "/custom/gobin", "GOPATH": "/home/u/go"}'),
502
- ).toEqual({ gobin: "/custom/gobin", gopath: "/home/u/go" });
503
- });
504
-
505
- // The line-oriented `go env GOBIN GOPATH` puts an EMPTY line first when GOBIN
506
- // is unset. run() trims stdout, which used to shift GOPATH into GOBIN's slot
507
- // and aim the install dir at ~/go instead of ~/go/bin. Keyed JSON can't shift.
508
- it("keeps an unset GOBIN empty even though output gets trimmed", () => {
509
- expect(parseGoEnv('{"GOBIN": "", "GOPATH": "/Users/jack/go"}')).toEqual({
510
- gobin: "",
511
- gopath: "/Users/jack/go",
512
- });
513
- });
514
-
515
- it("survives empty output", () => {
516
- expect(parseGoEnv("")).toEqual({ gobin: "", gopath: "" });
517
- });
518
-
519
- it("survives non-JSON output", () => {
520
- expect(parseGoEnv("go: unknown flag -json")).toEqual({
521
- gobin: "",
522
- gopath: "",
523
- });
524
- });
525
-
526
- it("ignores non-string values", () => {
527
- expect(parseGoEnv('{"GOBIN": null, "GOPATH": 7}')).toEqual({
528
- gobin: "",
529
- gopath: "",
530
- });
531
- });
532
- });
533
-
534
- describe("classifyInstallReach", () => {
535
- it("wins when the install dir is the first PATH entry holding it", () => {
536
- expect(
537
- classifyInstallReach(
538
- ["/home/u/go/bin", "/usr/bin"],
539
- [],
540
- "/home/u/go/bin",
541
- ),
542
- ).toEqual({ reach: "wins" });
543
- });
544
-
545
- it("wins when the install dir precedes another copy", () => {
546
- expect(
547
- classifyInstallReach(
548
- ["/home/u/go/bin", "/opt/homebrew/bin"],
549
- ["/opt/homebrew/bin"],
550
- "/home/u/go/bin",
551
- ),
552
- ).toEqual({ reach: "wins" });
553
- });
554
-
555
- it("is shadowed when another copy precedes the install dir", () => {
556
- expect(
557
- classifyInstallReach(
558
- ["/home/u/.local/bin", "/home/u/go/bin"],
559
- ["/home/u/.local/bin"],
560
- "/home/u/go/bin",
561
- ),
562
- ).toEqual({ reach: "shadowed", byDir: "/home/u/.local/bin" });
563
- });
564
-
565
- // The generalisation of the tmux-mcp incident: no foreign package needed,
566
- // just Homebrew ahead of GOPATH/bin. go install writes one file, the version
567
- // probe reads another, and the mismatch never resolves.
568
- it("is shadowed by a homebrew copy ordered ahead of the go bin dir", () => {
569
- expect(
570
- classifyInstallReach(
571
- ["/opt/homebrew/bin", "/home/u/go/bin"],
572
- ["/opt/homebrew/bin"],
573
- "/home/u/go/bin",
574
- ),
575
- ).toEqual({ reach: "shadowed", byDir: "/opt/homebrew/bin" });
576
- });
577
-
578
- it("reports off-path when the install dir is absent from PATH", () => {
579
- expect(
580
- classifyInstallReach(["/usr/bin", "/bin"], [], "/home/u/go/bin"),
581
- ).toEqual({ reach: "off-path" });
582
- });
583
-
584
- it("normalises trailing slashes and redundant segments", () => {
585
- expect(
586
- classifyInstallReach(["/home/u/go/bin/"], [], "/home/u/go/./bin"),
587
- ).toEqual({ reach: "wins" });
588
- });
589
-
590
- it("lets the first of several duplicate PATH entries decide", () => {
591
- expect(
592
- classifyInstallReach(
593
- ["/home/u/.local/bin", "/home/u/go/bin", "/home/u/.local/bin"],
594
- ["/home/u/.local/bin"],
595
- "/home/u/go/bin",
596
- ),
597
- ).toEqual({ reach: "shadowed", byDir: "/home/u/.local/bin" });
598
- });
599
- });
600
-
601
- // ---------------------------------------------------------------------------
602
- // 4d. End-to-end shadowing — real directories on a real PATH, so the fs scan
603
- // is exercised rather than stubbed. Mirrors the tmux-mcp incident: a
604
- // foreign binary earlier in PATH that cannot report a version.
605
- // ---------------------------------------------------------------------------
606
-
607
- describe("go deps shadowed by an earlier PATH entry", () => {
608
- let tmpRoot: string;
609
- let shadowDir: string;
610
- let goBinDir: string;
611
- let originalPath: string | undefined;
612
-
613
- beforeEach(async () => {
614
- availableBinaries = new Set(["go", "tmux-mcp"]);
615
- execResults = new Map();
616
-
617
- tmpRoot = await mkdtemp(path.join(tmpdir(), "magus-shadow-"));
618
- shadowDir = path.join(tmpRoot, "shadow");
619
- goBinDir = path.join(tmpRoot, "gobin");
620
- await mkdir(shadowDir);
621
- await mkdir(goBinDir);
622
-
623
- // A foreign `tmux-mcp` earlier in PATH than the go install target.
624
- const shim = path.join(shadowDir, "tmux-mcp");
625
- await writeFile(shim, "#!/bin/sh\nexit 1\n");
626
- await chmod(shim, 0o755);
627
-
628
- originalPath = process.env.PATH;
629
- process.env.PATH = [shadowDir, goBinDir].join(path.delimiter);
630
-
631
- // GOBIN points at the (later) go bin dir.
632
- execResults.set("/usr/bin/go env -json GOBIN GOPATH", {
633
- stdout: JSON.stringify({ GOBIN: goBinDir, GOPATH: tmpRoot }),
634
- stderr: "",
635
- });
636
- // The shim chokes on --version, exactly like the npm package did.
637
- execResults.set("/usr/bin/tmux-mcp --version", {
638
- stdout: "",
639
- stderr: "ERR_PARSE_ARGS_UNKNOWN_OPTION",
640
- error: true,
641
- });
642
- });
643
-
644
- afterEach(async () => {
645
- process.env.PATH = originalPath ?? "";
646
- await rm(tmpRoot, { recursive: true, force: true });
647
- });
648
-
649
- it("reports blocked instead of asking for a futile install", async () => {
650
- const decision = await goDepDecision(
651
- "github.com/MadAppGang/tmux-mcp@v1.6.2",
652
- );
653
-
654
- expect(decision.action).toBe("blocked");
655
- if (decision.action !== "blocked") return;
656
- expect(decision.reason).toBe("shadowed");
657
- expect(decision.detail).toContain(path.join(shadowDir, "tmux-mcp"));
658
- expect(decision.detail).toContain(goBinDir);
659
- expect(decision.detail).toContain("cannot change what runs");
660
- });
661
-
662
- it("fails the install rather than reporting a success that changes nothing", async () => {
663
- execResults.set("/usr/bin/go install", { stdout: "", stderr: "" });
664
-
665
- const result = await installPluginDeps({
666
- go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
667
- });
668
-
669
- expect(result.installed).toHaveLength(0);
670
- expect(result.skipped).toHaveLength(0);
671
- expect(result.failed).toHaveLength(1);
672
- expect(result.failed[0].pkg).toBe(
673
- "go:github.com/MadAppGang/tmux-mcp@v1.6.2",
674
- );
675
- expect(result.failed[0].error).toContain("cannot change what runs");
676
- });
677
-
678
- it("still counts as needing attention, so the UI surfaces it", async () => {
679
- expect(
680
- await goDepNeedsInstall("github.com/MadAppGang/tmux-mcp@v1.6.2"),
681
- ).toBe(true);
682
-
683
- const missing = await checkMissingDeps({
684
- go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
685
- });
686
- expect(missing.go).toEqual(["github.com/MadAppGang/tmux-mcp@v1.6.2"]);
687
- });
688
-
689
- it("installs normally once the shadow is out of the way", async () => {
690
- process.env.PATH = [goBinDir, shadowDir].join(path.delimiter);
691
- execResults.set("/usr/bin/go install", { stdout: "", stderr: "" });
692
-
693
- const result = await installPluginDeps({
694
- go: ["github.com/MadAppGang/tmux-mcp@v1.6.2"],
695
- });
696
-
697
- expect(result.installed).toContain(
698
- "go:github.com/MadAppGang/tmux-mcp@v1.6.2",
699
- );
700
- expect(result.failed).toHaveLength(0);
701
- });
702
-
703
- it("reports off-path when the go bin dir is not in PATH at all", async () => {
704
- process.env.PATH = path.join(tmpRoot, "elsewhere");
705
- availableBinaries.delete("tmux-mcp");
706
-
707
- const decision = await goDepDecision(
708
- "github.com/MadAppGang/tmux-mcp@v1.6.2",
709
- );
710
-
711
- expect(decision.action).toBe("blocked");
712
- if (decision.action !== "blocked") return;
713
- expect(decision.reason).toBe("install-dir-off-path");
714
- expect(decision.detail).toContain("not in PATH");
715
- });
716
- });
717
-
718
- // ---------------------------------------------------------------------------
719
- // 4e. Spec parsing — the root of both bug directions. Go never reinstalled
720
- // because the pin was never compared; npm/cargo/pip always reinstalled
721
- // because the raw spec was used as a lookup key.
722
- // ---------------------------------------------------------------------------
723
-
724
- describe("parseAtSpec", () => {
725
- it("splits name and pinned version", () => {
726
- expect(parseAtSpec("ripgrep@14.1.0")).toEqual({
727
- spec: "ripgrep@14.1.0",
728
- name: "ripgrep",
729
- version: "14.1.0",
730
- });
731
- });
732
-
733
- it("treats a leading @ as an npm scope, not a version", () => {
734
- expect(parseAtSpec("@babel/cli")).toEqual({
735
- spec: "@babel/cli",
736
- name: "cli",
737
- version: null,
738
- });
739
- });
740
-
741
- it("pins a scoped npm package", () => {
742
- expect(parseAtSpec("@babel/cli@7.0.0")).toEqual({
743
- spec: "@babel/cli@7.0.0",
744
- name: "cli",
745
- version: "7.0.0",
746
- });
747
- });
748
-
749
- it("keeps go module paths working", () => {
750
- expect(parseAtSpec("github.com/MadAppGang/tmux-mcp@v1.6.2")).toEqual({
751
- spec: "github.com/MadAppGang/tmux-mcp@v1.6.2",
752
- name: "tmux-mcp",
753
- version: "v1.6.2",
754
- });
755
- });
756
-
757
- it("treats @latest as unpinned", () => {
758
- expect(parseAtSpec("tool@latest").version).toBeNull();
759
- });
760
-
761
- it("does NOT apply the Go /vN rule — an npm scope may be named v2", () => {
762
- // The Go major-version strip lives in parseGoSpec only; folding it in
763
- // here would turn this scoped npm package into "@scope".
764
- expect(parseAtSpec("@scope/v2@1.0.0")).toEqual({
765
- spec: "@scope/v2@1.0.0",
766
- name: "v2",
767
- version: "1.0.0",
768
- });
769
- });
770
-
771
- it("treats a bare name as unpinned", () => {
772
- expect(parseAtSpec("tool")).toEqual({
773
- spec: "tool",
774
- name: "tool",
775
- version: null,
776
- });
777
- });
778
- });
779
-
780
- describe("parsePipSpec", () => {
781
- it("reads an == pin", () => {
782
- expect(parsePipSpec("mcp==1.2.3")).toEqual({
783
- spec: "mcp==1.2.3",
784
- name: "mcp",
785
- version: "1.2.3",
786
- });
787
- });
788
-
789
- it("tolerates whitespace around ==", () => {
790
- expect(parsePipSpec("mcp == 1.2.3").version).toBe("1.2.3");
791
- });
792
-
793
- // A range is not a single version, so there is nothing to verify against.
794
- it("treats >= as unpinned but still reads the name", () => {
795
- expect(parsePipSpec("browser-use>=0.1.0")).toEqual({
796
- spec: "browser-use>=0.1.0",
797
- name: "browser-use",
798
- version: null,
799
- });
800
- });
801
-
802
- it("strips extras from the name", () => {
803
- expect(parsePipSpec("uvicorn[standard]").name).toBe("uvicorn");
804
- });
805
-
806
- it("handles a bare distribution name", () => {
807
- expect(parsePipSpec("browser-use")).toEqual({
808
- spec: "browser-use",
809
- name: "browser-use",
810
- version: null,
811
- });
812
- });
813
- });
814
-
815
- describe("extractBinaryName — brew", () => {
816
- it("drops the tap prefix", () => {
817
- expect(extractBinaryName("memextech/tap/ht-mcp")).toBe("ht-mcp");
818
- });
819
-
820
- // brew's @ is part of the formula name, but the binary it provides is not
821
- // called "node@18" — the old code looked for exactly that and never found it.
822
- it("drops a versioned-formula suffix from the binary name", () => {
823
- expect(extractBinaryName("node@18")).toBe("node");
824
- });
825
-
826
- it("leaves a plain formula alone", () => {
827
- expect(extractBinaryName("ripgrep")).toBe("ripgrep");
828
- });
829
- });
830
-
831
- // ---------------------------------------------------------------------------
832
- // 4f. Version awareness across the other managers
833
- // ---------------------------------------------------------------------------
834
-
835
- describe("pipDepNeedsInstall — via installPluginDeps", () => {
836
- const PIP_VERSION = /^python3 -c import importlib\.metadata/;
837
-
838
- beforeEach(() => {
839
- availableBinaries = new Set(["uv"]);
840
- execResults = new Map();
841
- });
842
-
843
- function pipVersion(stdout: string, error = false) {
844
- // The probe is `python3 -c "...m.version('mcp')"`, matched by prefix.
845
- execResults.set("python3 -c import importlib.metadata as m", {
846
- stdout,
847
- stderr: error ? "no such distribution" : "",
848
- error,
849
- });
850
- }
851
-
852
- it("skips a pinned package already at that version", async () => {
853
- pipVersion("1.2.3");
854
- const result = await installPluginDeps({ pip: ["mcp==1.2.3"] });
855
-
856
- expect(result.skipped).toContain("pip:mcp==1.2.3");
857
- expect(result.installed).toHaveLength(0);
858
- });
859
-
860
- it("reinstalls a pinned package sitting at another version", async () => {
861
- pipVersion("1.2.2");
862
- execResults.set("/usr/bin/uv pip install", { stdout: "", stderr: "" });
863
-
864
- const result = await installPluginDeps({ pip: ["mcp==1.2.3"] });
865
-
866
- expect(result.installed).toContain("pip:mcp==1.2.3");
867
- expect(result.skipped).toHaveLength(0);
868
- });
869
-
870
- it("skips an unpinned package that is present", async () => {
871
- pipVersion("0.9.0");
872
- const result = await installPluginDeps({ pip: ["browser-use"] });
873
-
874
- expect(result.skipped).toContain("pip:browser-use");
875
- });
876
-
877
- it("installs when the distribution is absent", async () => {
878
- pipVersion("", true);
879
- execResults.set("/usr/bin/uv pip install", { stdout: "", stderr: "" });
880
-
881
- const result = await installPluginDeps({ pip: ["browser-use"] });
882
-
883
- expect(result.installed).toContain("pip:browser-use");
884
- });
885
-
886
- it("reports a pinned mismatch through checkMissingDeps too", async () => {
887
- pipVersion("1.2.2");
888
- const missing = await checkMissingDeps({ pip: ["mcp==1.2.3"] });
889
- expect(missing.pip).toEqual(["mcp==1.2.3"]);
890
- expect(PIP_VERSION.test("python3 -c import importlib.metadata")).toBe(true);
891
- });
892
- });
893
-
894
- describe("npm and cargo version awareness", () => {
895
- // The reach check consults the real PATH and filesystem, so both are pinned
896
- // to a temp dir here. Without that these tests would pass or fail depending
897
- // on whether the machine running them happens to have ~/.cargo/bin on PATH.
898
- let envRoot: string;
899
- let savedPath: string | undefined;
900
- let savedCargoRoot: string | undefined;
901
-
902
- beforeEach(async () => {
903
- availableBinaries = new Set(["bun", "cargo", "ripgrep"]);
904
- execResults = new Map();
905
-
906
- envRoot = await mkdtemp(path.join(tmpdir(), "magus-env-"));
907
- await mkdir(path.join(envRoot, "bin"));
908
- savedPath = process.env.PATH;
909
- savedCargoRoot = process.env.CARGO_INSTALL_ROOT;
910
- process.env.PATH = path.join(envRoot, "bin");
911
- process.env.CARGO_INSTALL_ROOT = envRoot;
912
- });
913
-
914
- afterEach(async () => {
915
- process.env.PATH = savedPath ?? "";
916
- if (savedCargoRoot === undefined) process.env.CARGO_INSTALL_ROOT = "";
917
- else process.env.CARGO_INSTALL_ROOT = savedCargoRoot;
918
- await rm(envRoot, { recursive: true, force: true });
919
- });
920
-
921
- // The old code passed "ripgrep@14.1.0" to which(), never matched, and
922
- // reinstalled on every single run.
923
- it("skips a pinned npm package already at that version", async () => {
924
- execResults.set("/usr/bin/ripgrep --version", {
925
- stdout: "14.1.0",
926
- stderr: "",
927
- });
928
- execResults.set("/usr/bin/bun pm bin -g", { stdout: "", stderr: "" });
929
-
930
- const result = await installPluginDeps({ npm: ["ripgrep@14.1.0"] });
931
-
932
- expect(result.skipped).toContain("npm:ripgrep@14.1.0");
933
- expect(result.installed).toHaveLength(0);
934
- });
935
-
936
- it("reinstalls a pinned npm package at the wrong version", async () => {
937
- execResults.set("/usr/bin/ripgrep --version", {
938
- stdout: "14.0.0",
939
- stderr: "",
940
- });
941
- execResults.set("/usr/bin/bun install -g", { stdout: "", stderr: "" });
942
-
943
- const result = await installPluginDeps({ npm: ["ripgrep@14.1.0"] });
944
-
945
- expect(result.installed).toContain("bun:ripgrep@14.1.0");
946
- });
947
-
948
- it("skips a pinned cargo crate already at that version", async () => {
949
- execResults.set("/usr/bin/ripgrep --version", {
950
- stdout: "ripgrep 14.1.0",
951
- stderr: "",
952
- });
953
-
954
- const result = await installPluginDeps({ cargo: ["ripgrep@14.1.0"] });
955
-
956
- expect(result.skipped).toContain("cargo:ripgrep@14.1.0");
957
- });
958
-
959
- it("installs a pinned cargo crate with --version, not crate@ver", async () => {
960
- execResults.set("/usr/bin/ripgrep --version", {
961
- stdout: "ripgrep 14.0.0",
962
- stderr: "",
963
- });
964
- execResults.set("/usr/bin/cargo install ripgrep --version 14.1.0", {
965
- stdout: "",
966
- stderr: "",
967
- });
968
-
969
- const result = await installPluginDeps({ cargo: ["ripgrep@14.1.0"] });
970
-
971
- // Only the exact `--version` invocation is stubbed as success, so this
972
- // passing proves the argv shape.
973
- expect(result.installed).toContain("cargo:ripgrep@14.1.0");
974
- expect(result.failed).toHaveLength(0);
975
- });
976
- });
977
-
978
- // ---------------------------------------------------------------------------
979
- // 5. checkMissingDeps tests — required binaries
980
- // ---------------------------------------------------------------------------
981
-
982
- describe("checkMissingDeps — required binaries", () => {
983
- beforeEach(() => {
984
- availableBinaries = new Set();
985
- execResults = new Map();
986
- });
987
-
988
- it("detects missing required binaries", async () => {
989
- const missing = await checkMissingDeps({
990
- required: ["tmux", "ffmpeg"],
991
- });
992
-
993
- expect(missing.required).toEqual(["tmux", "ffmpeg"]);
994
- });
995
-
996
- it("does not report present required binaries", async () => {
997
- availableBinaries.add("tmux");
998
- availableBinaries.add("ffmpeg");
999
-
1000
- const missing = await checkMissingDeps({
1001
- required: ["tmux", "ffmpeg"],
1002
- });
1003
-
1004
- expect(missing.required).toBeUndefined();
1005
- });
1006
-
1007
- it("reports only missing required binaries", async () => {
1008
- availableBinaries.add("tmux");
1009
-
1010
- const missing = await checkMissingDeps({
1011
- required: ["tmux", "ffmpeg"],
1012
- });
1013
-
1014
- expect(missing.required).toEqual(["ffmpeg"]);
1015
- });
1016
- });