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.
- package/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +16 -11
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +57 -0
- package/src/__tests__/blind/review-followups/d1-empty-lock.test.ts +94 -0
- package/src/__tests__/blind/review-followups/d2-disappearing-lock.test.ts +90 -0
- package/src/__tests__/blind/review-followups/d3-release-temp-cleanup.test.ts +83 -0
- package/src/__tests__/blind/review-followups/d4-tool-stdout.test.ts +152 -0
- package/src/__tests__/blind/review-followups/installer-redirect.test.ts +74 -0
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +78 -0
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +79 -0
- package/src/__tests__/blind/review-followups/path-advice.test.ts +46 -0
- package/src/__tests__/blind/review-followups/stats-requires.test.ts +16 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +897 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +1154 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- package/src/shared/plugin/hooks/bun-missing.sh +64 -0
- package/src/shared/plugin/hooks/deps-check.ts +240 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1279 -0
- package/src/types/index.ts +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +143 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- 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
|
-
});
|