magus-cli 7.10.1 → 7.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +323 -0
- package/src/__tests__/blind/c17-misdirected-update-already-landed.test.ts +271 -0
- package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +345 -0
- package/src/__tests__/cli-tools-adapter.test.ts +1 -3
- package/src/__tests__/tool-probe.test.ts +2 -2
- package/src/cli/update-view.ts +4 -0
- package/src/services/tool-probe.ts +150 -9
- package/src/services/update-engine.ts +24 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.10.
|
|
3
|
+
"version": "7.10.2",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"typescript": "^5.6.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"magus-cli-darwin-arm64": "7.10.
|
|
69
|
-
"magus-cli-darwin-x64": "7.10.
|
|
70
|
-
"magus-cli-linux-x64": "7.10.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.10.2",
|
|
69
|
+
"magus-cli-darwin-x64": "7.10.2",
|
|
70
|
+
"magus-cli-linux-x64": "7.10.2"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests, contract C16: a tool whose declared installer does not own the
|
|
3
|
+
* file PATH runs is adopted by the node installer (bun or npm) when that file
|
|
4
|
+
* lies in bun's global tree or an npm global prefix. Otherwise it stays
|
|
5
|
+
* external / not-managed with a note that names no path.
|
|
6
|
+
*
|
|
7
|
+
* Written from the spec only. Every fact reaches `probeTool` through a
|
|
8
|
+
* scripted fake `ToolProbeIO`.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import {
|
|
12
|
+
type ExecResult,
|
|
13
|
+
type FetchJsonResult,
|
|
14
|
+
type ToolProbeIO,
|
|
15
|
+
classifyTool,
|
|
16
|
+
probeTool,
|
|
17
|
+
} from "../../services/tool-probe.js";
|
|
18
|
+
import type { BinRequirement } from "../../types/index.js";
|
|
19
|
+
|
|
20
|
+
interface FakeSetup {
|
|
21
|
+
exec?: Record<string, Partial<ExecResult>>;
|
|
22
|
+
fetch?: Record<string, FetchJsonResult>;
|
|
23
|
+
files?: Record<string, string>;
|
|
24
|
+
real?: Record<string, string>;
|
|
25
|
+
onPath?: Record<string, string>;
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fakeIO(setup: FakeSetup): ToolProbeIO & { calls: string[] } {
|
|
30
|
+
const calls: string[] = [];
|
|
31
|
+
return {
|
|
32
|
+
calls,
|
|
33
|
+
exec: async (command) => {
|
|
34
|
+
const key = [command.cmd, ...command.args].join(" ");
|
|
35
|
+
calls.push(key);
|
|
36
|
+
const h = setup.exec?.[key];
|
|
37
|
+
if (h === undefined) {
|
|
38
|
+
return {
|
|
39
|
+
code: null,
|
|
40
|
+
stdout: "",
|
|
41
|
+
stderr: "",
|
|
42
|
+
timedOut: false,
|
|
43
|
+
missing: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
code: 0,
|
|
48
|
+
stdout: "",
|
|
49
|
+
stderr: "",
|
|
50
|
+
timedOut: false,
|
|
51
|
+
missing: false,
|
|
52
|
+
...h,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
fetchJson: async (url) => {
|
|
56
|
+
const r = setup.fetch?.[url];
|
|
57
|
+
return r ?? { ok: false, reason: "returned 404" };
|
|
58
|
+
},
|
|
59
|
+
readJson: async (file) => {
|
|
60
|
+
const t = setup.files?.[file];
|
|
61
|
+
if (t === undefined) return null;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(t);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
readText: async (file) => setup.files?.[file] ?? null,
|
|
69
|
+
readlink: async () => null,
|
|
70
|
+
realpath: async (file) => setup.real?.[file] ?? null,
|
|
71
|
+
resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
|
|
72
|
+
env: setup.env ?? {},
|
|
73
|
+
homedir: "/Users/j",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const BUN_ROOT = "/Users/j/.bun/install/global/node_modules";
|
|
78
|
+
const BREW_ENV = { HOMEBREW_PREFIX: "/opt/homebrew" };
|
|
79
|
+
const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
|
|
80
|
+
|
|
81
|
+
const distTags = (pkg: string, latest: string) => ({
|
|
82
|
+
[`https://registry.npmjs.org/-/package/${pkg.replace("/", "%2F")}/dist-tags`]:
|
|
83
|
+
{ ok: true, body: { latest } } as FetchJsonResult,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const pkgJson = (name: string, version: string) =>
|
|
87
|
+
JSON.stringify({ name, version });
|
|
88
|
+
|
|
89
|
+
/** A tool whose PATH entry resolves into bun's global tree. */
|
|
90
|
+
function bunWorld(
|
|
91
|
+
bin: string,
|
|
92
|
+
pkg: string,
|
|
93
|
+
installed: string,
|
|
94
|
+
latest: string,
|
|
95
|
+
): FakeSetup {
|
|
96
|
+
const pkgDir = `${BUN_ROOT}/${pkg}`;
|
|
97
|
+
const file = `${pkgDir}/dist/index.js`;
|
|
98
|
+
const onPath = `/Users/j/.bun/bin/${bin}`;
|
|
99
|
+
return {
|
|
100
|
+
env: BREW_ENV,
|
|
101
|
+
onPath: { [bin]: onPath },
|
|
102
|
+
real: {
|
|
103
|
+
[onPath]: file,
|
|
104
|
+
[file]: file,
|
|
105
|
+
[pkgDir]: pkgDir,
|
|
106
|
+
[BUN_ROOT]: BUN_ROOT,
|
|
107
|
+
...CELLAR,
|
|
108
|
+
},
|
|
109
|
+
files: { [`${pkgDir}/package.json`]: pkgJson(pkg, installed) },
|
|
110
|
+
fetch: distTags(pkg, latest),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const MNEMEX: BinRequirement = {
|
|
115
|
+
name: "mnemex",
|
|
116
|
+
via: "brew",
|
|
117
|
+
formula: "madappgang/tap/mnemex",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
describe("C16 case 1: brew spec, file in bun's global tree", () => {
|
|
121
|
+
test("managed, versions read, bun upgrade command, state upgrade", async () => {
|
|
122
|
+
const f = await probeTool(
|
|
123
|
+
MNEMEX,
|
|
124
|
+
fakeIO(bunWorld("mnemex", "mnemex", "0.36.1", "0.36.2")),
|
|
125
|
+
);
|
|
126
|
+
expect(f.managed).toBe(true);
|
|
127
|
+
expect(f.installed).toBe("0.36.1");
|
|
128
|
+
expect(f.latest).toBe("0.36.2");
|
|
129
|
+
expect(f.upgradeCommand).toEqual({
|
|
130
|
+
cmd: "bun",
|
|
131
|
+
args: ["install", "-g", "mnemex@latest"],
|
|
132
|
+
});
|
|
133
|
+
const s = classifyTool(MNEMEX, f);
|
|
134
|
+
expect(s.state).toBe("upgrade");
|
|
135
|
+
expect(s.target).toBe("0.36.2");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("C16 case 2: latest equals installed", () => {
|
|
140
|
+
test("state is current", async () => {
|
|
141
|
+
const f = await probeTool(
|
|
142
|
+
MNEMEX,
|
|
143
|
+
fakeIO(bunWorld("mnemex", "mnemex", "0.36.2", "0.36.2")),
|
|
144
|
+
);
|
|
145
|
+
expect(f.managed).toBe(true);
|
|
146
|
+
expect(classifyTool(MNEMEX, f).state).toBe("current");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("C16 case 3: scoped package under bun's tree", () => {
|
|
151
|
+
test("the package is @scope/tool in the upgrade command", async () => {
|
|
152
|
+
const spec: BinRequirement = {
|
|
153
|
+
name: "tool",
|
|
154
|
+
via: "brew",
|
|
155
|
+
formula: "madappgang/tap/tool",
|
|
156
|
+
};
|
|
157
|
+
const f = await probeTool(
|
|
158
|
+
spec,
|
|
159
|
+
fakeIO(bunWorld("tool", "@scope/tool", "1.0.0", "1.1.0")),
|
|
160
|
+
);
|
|
161
|
+
expect(f.managed).toBe(true);
|
|
162
|
+
expect(f.installed).toBe("1.0.0");
|
|
163
|
+
expect(f.latest).toBe("1.1.0");
|
|
164
|
+
expect(f.upgradeCommand).toEqual({
|
|
165
|
+
cmd: "bun",
|
|
166
|
+
args: ["install", "-g", "@scope/tool@latest"],
|
|
167
|
+
});
|
|
168
|
+
expect(classifyTool(spec, f).state).toBe("upgrade");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("C16 case 4: npm global prefix", () => {
|
|
173
|
+
test("brew spec adopted by npm under the root npm reports", async () => {
|
|
174
|
+
const root = "/usr/local/lib/node_modules";
|
|
175
|
+
const pkgDir = `${root}/claudish`;
|
|
176
|
+
const file = `${pkgDir}/dist/index.js`;
|
|
177
|
+
const spec: BinRequirement = {
|
|
178
|
+
name: "claudish",
|
|
179
|
+
via: "brew",
|
|
180
|
+
formula: "madappgang/tap/claudish",
|
|
181
|
+
};
|
|
182
|
+
const f = await probeTool(
|
|
183
|
+
spec,
|
|
184
|
+
fakeIO({
|
|
185
|
+
env: BREW_ENV,
|
|
186
|
+
onPath: { claudish: "/usr/local/bin/claudish" },
|
|
187
|
+
real: {
|
|
188
|
+
"/usr/local/bin/claudish": file,
|
|
189
|
+
[file]: file,
|
|
190
|
+
[pkgDir]: pkgDir,
|
|
191
|
+
[root]: root,
|
|
192
|
+
...CELLAR,
|
|
193
|
+
},
|
|
194
|
+
files: { [`${pkgDir}/package.json`]: pkgJson("claudish", "10.0.0") },
|
|
195
|
+
exec: { "npm root -g": { stdout: `${root}\n` } },
|
|
196
|
+
fetch: distTags("claudish", "10.1.0"),
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
expect(f.managed).toBe(true);
|
|
200
|
+
expect(f.installed).toBe("10.0.0");
|
|
201
|
+
expect(f.latest).toBe("10.1.0");
|
|
202
|
+
expect(f.upgradeCommand).toEqual({
|
|
203
|
+
cmd: "npm",
|
|
204
|
+
args: ["install", "-g", "claudish@latest"],
|
|
205
|
+
});
|
|
206
|
+
const s = classifyTool(spec, f);
|
|
207
|
+
expect(s.state).toBe("upgrade");
|
|
208
|
+
expect(s.target).toBe("10.1.0");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("C16 case 5: pinned npm spec, file in bun's tree", () => {
|
|
213
|
+
const spec: BinRequirement = {
|
|
214
|
+
name: "tool",
|
|
215
|
+
via: "npm",
|
|
216
|
+
package: "tool",
|
|
217
|
+
version: "1.0.0",
|
|
218
|
+
};
|
|
219
|
+
test("managed, target is the pin, command carries tool@1.0.0", async () => {
|
|
220
|
+
const f = await probeTool(
|
|
221
|
+
spec,
|
|
222
|
+
fakeIO({
|
|
223
|
+
...bunWorld("tool", "tool", "0.9.0", "2.0.0"),
|
|
224
|
+
exec: { "npm root -g": { stdout: "/usr/local/lib/node_modules\n" } },
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
expect(f.managed).toBe(true);
|
|
228
|
+
expect(f.installed).toBe("0.9.0");
|
|
229
|
+
const args = f.upgradeCommand?.args ?? [];
|
|
230
|
+
expect(args).toContain("tool@1.0.0");
|
|
231
|
+
expect(args.some((a) => a.includes("@latest"))).toBe(false);
|
|
232
|
+
const s = classifyTool(spec, f);
|
|
233
|
+
expect(s.state).toBe("upgrade");
|
|
234
|
+
expect(s.target).toBe("1.0.0");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("C16 case 6: file in no node tree, brew spec", () => {
|
|
239
|
+
const spec: BinRequirement = { name: "uv", via: "brew", formula: "uv" };
|
|
240
|
+
const world: FakeSetup = {
|
|
241
|
+
env: BREW_ENV,
|
|
242
|
+
onPath: { uv: "/Users/j/.local/bin/uv" },
|
|
243
|
+
real: {
|
|
244
|
+
"/Users/j/.local/bin/uv": "/Users/j/.local/bin/uv",
|
|
245
|
+
...CELLAR,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
test("external / not-managed, note names who did not install it", async () => {
|
|
249
|
+
const f = await probeTool(spec, fakeIO(world));
|
|
250
|
+
const s = classifyTool(spec, f);
|
|
251
|
+
expect(s.state).toBe("external");
|
|
252
|
+
expect(s.reason).toBe("not-managed");
|
|
253
|
+
expect(s.note).toBe("installed outside Homebrew");
|
|
254
|
+
expect(s.note?.includes("/")).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
test("an npm spec says installed outside npm", async () => {
|
|
257
|
+
const npmSpec: BinRequirement = { name: "uv", via: "npm", package: "uv" };
|
|
258
|
+
const f = await probeTool(
|
|
259
|
+
npmSpec,
|
|
260
|
+
fakeIO({
|
|
261
|
+
...world,
|
|
262
|
+
exec: { "npm root -g": { stdout: "/usr/local/lib/node_modules\n" } },
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
const s = classifyTool(npmSpec, f);
|
|
266
|
+
expect(s.state).toBe("external");
|
|
267
|
+
expect(s.note).toBe("installed outside npm");
|
|
268
|
+
});
|
|
269
|
+
test("a pinned spec in that situation stays unknown", async () => {
|
|
270
|
+
const pinned: BinRequirement = { ...spec, version: "1.0.0" };
|
|
271
|
+
const f = await probeTool(pinned, fakeIO(world));
|
|
272
|
+
expect(classifyTool(pinned, f).state).toBe("unknown");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("C16 case 7: operating-system file", () => {
|
|
277
|
+
test("zsh in /bin is external with the operating-system note", async () => {
|
|
278
|
+
const spec: BinRequirement = { name: "zsh", via: "brew", formula: "zsh" };
|
|
279
|
+
const f = await probeTool(
|
|
280
|
+
spec,
|
|
281
|
+
fakeIO({
|
|
282
|
+
env: BREW_ENV,
|
|
283
|
+
onPath: { zsh: "/bin/zsh" },
|
|
284
|
+
real: { "/bin/zsh": "/bin/zsh", ...CELLAR },
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
const s = classifyTool(spec, f);
|
|
288
|
+
expect(s.state).toBe("external");
|
|
289
|
+
expect(s.reason).toBe("not-managed");
|
|
290
|
+
expect(s.note).toBe("comes with the operating system");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("C16 case 8: a Homebrew keg is never adopted as an npm install", () => {
|
|
295
|
+
test("npm spec, file in a keg's libexec/lib/node_modules", async () => {
|
|
296
|
+
const keg = "/opt/homebrew/Cellar/gemini-cli/0.46.0/libexec";
|
|
297
|
+
const pkgDir = `${keg}/lib/node_modules/gemini-pkg`;
|
|
298
|
+
const file = `${pkgDir}/bundle/gemini.js`;
|
|
299
|
+
const spec: BinRequirement = {
|
|
300
|
+
name: "gemini",
|
|
301
|
+
via: "npm",
|
|
302
|
+
package: "gemini-pkg",
|
|
303
|
+
};
|
|
304
|
+
const f = await probeTool(
|
|
305
|
+
spec,
|
|
306
|
+
fakeIO({
|
|
307
|
+
env: BREW_ENV,
|
|
308
|
+
onPath: { gemini: "/opt/homebrew/bin/gemini" },
|
|
309
|
+
real: {
|
|
310
|
+
"/opt/homebrew/bin/gemini": file,
|
|
311
|
+
[file]: file,
|
|
312
|
+
[pkgDir]: pkgDir,
|
|
313
|
+
...CELLAR,
|
|
314
|
+
},
|
|
315
|
+
files: { [`${pkgDir}/package.json`]: pkgJson("gemini-pkg", "0.46.0") },
|
|
316
|
+
exec: { "npm root -g": { stdout: "/opt/homebrew/lib/node_modules\n" } },
|
|
317
|
+
fetch: distTags("gemini-pkg", "0.50.0"),
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
expect(f.upgradeCommand?.cmd).not.toBe("npm");
|
|
321
|
+
expect(classifyTool(spec, f).state).not.toBe("upgrade");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
test,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
import {
|
|
14
|
+
loadedVersionHere,
|
|
15
|
+
repairStaleLoad,
|
|
16
|
+
} from "../../services/claude-settings.js";
|
|
17
|
+
import {
|
|
18
|
+
type PluginApplyDeps,
|
|
19
|
+
applyPlugins,
|
|
20
|
+
} from "../../services/update-engine.js";
|
|
21
|
+
import type {
|
|
22
|
+
InstalledPluginEntry,
|
|
23
|
+
InstalledPluginsRegistry,
|
|
24
|
+
} from "../../types/index.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Written blind from a spec. The row Claude Code picks for `plugin update`
|
|
28
|
+
* already holds the target, so the update writes nothing and no row moves.
|
|
29
|
+
* magus must treat it as the same misdirection as when a row moves.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const GIT_CFG = [
|
|
33
|
+
"-c",
|
|
34
|
+
"user.name=test",
|
|
35
|
+
"-c",
|
|
36
|
+
"user.email=test@example.com",
|
|
37
|
+
"-c",
|
|
38
|
+
"commit.gpgsign=false",
|
|
39
|
+
"-c",
|
|
40
|
+
"init.defaultBranch=main",
|
|
41
|
+
];
|
|
42
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
43
|
+
execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" }).trim();
|
|
44
|
+
|
|
45
|
+
const PLUGIN = "madbench@magus";
|
|
46
|
+
const SAME_REPO_DETAIL =
|
|
47
|
+
"project → 0.5.0 · Claude Code updated another folder of this repository instead of this one — this folder now loads v0.5.0 too";
|
|
48
|
+
|
|
49
|
+
let base: string;
|
|
50
|
+
let mainTree: string;
|
|
51
|
+
let here: string;
|
|
52
|
+
let gone: string;
|
|
53
|
+
let otherRepo: string;
|
|
54
|
+
let root: string;
|
|
55
|
+
let registryFile: string;
|
|
56
|
+
|
|
57
|
+
const cacheFor = (version: string) =>
|
|
58
|
+
path.join(root, "cache", "magus", "madbench", version);
|
|
59
|
+
|
|
60
|
+
const row = (
|
|
61
|
+
over: Partial<InstalledPluginEntry> & { version: string },
|
|
62
|
+
): InstalledPluginEntry => ({
|
|
63
|
+
scope: "project",
|
|
64
|
+
installPath: cacheFor(over.version),
|
|
65
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
66
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
67
|
+
...over,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
async function writeRegistry(rows: InstalledPluginEntry[]): Promise<void> {
|
|
71
|
+
const registry: InstalledPluginsRegistry = {
|
|
72
|
+
version: 2,
|
|
73
|
+
plugins: { [PLUGIN]: rows },
|
|
74
|
+
};
|
|
75
|
+
await fs.outputJson(registryFile, registry, { spaces: 2 });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const readRegistry = (): Promise<InstalledPluginsRegistry> =>
|
|
79
|
+
fs.readJson(registryFile);
|
|
80
|
+
|
|
81
|
+
const versionsByPath = async () =>
|
|
82
|
+
(await readRegistry()).plugins[PLUGIN].map((e) => [e.projectPath, e.version]);
|
|
83
|
+
|
|
84
|
+
const hereVersion = async () =>
|
|
85
|
+
(await readRegistry()).plugins[PLUGIN].find((e) => e.projectPath === here)
|
|
86
|
+
?.version;
|
|
87
|
+
|
|
88
|
+
beforeAll(async () => {
|
|
89
|
+
base = await fs.realpath(
|
|
90
|
+
await fs.mkdtemp(path.join(os.tmpdir(), "magus-c17-")),
|
|
91
|
+
);
|
|
92
|
+
mainTree = path.join(base, "magus-src");
|
|
93
|
+
here = path.join(mainTree, ".claude", "worktrees", "madbench");
|
|
94
|
+
gone = path.join(mainTree, ".claude", "worktrees", "gone");
|
|
95
|
+
otherRepo = path.join(base, "unrelated");
|
|
96
|
+
|
|
97
|
+
await fs.ensureDir(mainTree);
|
|
98
|
+
git(mainTree, "init", "-q");
|
|
99
|
+
await fs.writeFile(path.join(mainTree, "README.md"), "hello\n");
|
|
100
|
+
git(mainTree, "add", "-A");
|
|
101
|
+
git(mainTree, "commit", "-qm", "initial");
|
|
102
|
+
git(mainTree, "worktree", "add", "-q", "-b", "madbench", here);
|
|
103
|
+
git(mainTree, "worktree", "add", "-q", "-b", "gone", gone);
|
|
104
|
+
await fs.remove(gone);
|
|
105
|
+
|
|
106
|
+
await fs.ensureDir(otherRepo);
|
|
107
|
+
git(otherRepo, "init", "-q");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(async () => {
|
|
111
|
+
if (base) await fs.remove(base);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
beforeEach(async () => {
|
|
115
|
+
root = await fs.mkdtemp(path.join(base, "plugins-"));
|
|
116
|
+
registryFile = path.join(root, "installed_plugins.json");
|
|
117
|
+
for (const v of ["0.3.0", "0.4.0", "0.5.0", "0.6.0"]) {
|
|
118
|
+
await fs.ensureDir(cacheFor(v));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** `claude plugin update` that finds its row already on the target: writes nothing. */
|
|
123
|
+
function deps(): PluginApplyDeps {
|
|
124
|
+
return {
|
|
125
|
+
install: async () => {},
|
|
126
|
+
update: async () => {},
|
|
127
|
+
repair: async () => {},
|
|
128
|
+
readInstalled: async () =>
|
|
129
|
+
(await readRegistry()).plugins[PLUGIN].find((e) => e.projectPath === here)
|
|
130
|
+
?.version ?? null,
|
|
131
|
+
saveInstalled: async () => {},
|
|
132
|
+
registryRows: async () => (await readRegistry()).plugins[PLUGIN],
|
|
133
|
+
repairStaleLoad: (id, target, projectPath) =>
|
|
134
|
+
repairStaleLoad(id, target, projectPath, root),
|
|
135
|
+
loadedVersion: (id, projectPath) =>
|
|
136
|
+
loadedVersionHere(id, projectPath, root),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const item = (target: string) => ({
|
|
141
|
+
pluginId: PLUGIN,
|
|
142
|
+
pinned: "latest",
|
|
143
|
+
installed: "0.4.0",
|
|
144
|
+
available: target,
|
|
145
|
+
target,
|
|
146
|
+
action: "update" as const,
|
|
147
|
+
reason: "behind" as const,
|
|
148
|
+
scopes: ["project" as const],
|
|
149
|
+
beforeVersions: { project: "0.4.0" },
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function collect() {
|
|
153
|
+
const details: string[] = [];
|
|
154
|
+
return {
|
|
155
|
+
details,
|
|
156
|
+
reporter: {
|
|
157
|
+
begin: () => {},
|
|
158
|
+
finish: (_ok: boolean, _name: string, detail: string) =>
|
|
159
|
+
details.push(detail),
|
|
160
|
+
detach: <T>(work: () => T) => work(),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const NO_CHANGE = `no change — the installer exited 0 but ${PLUGIN} is still 0.4.0`;
|
|
166
|
+
|
|
167
|
+
describe("a misdirected update whose row already held the target", () => {
|
|
168
|
+
test("1: another row of the repository is at the target — this row follows, counted as changed", async () => {
|
|
169
|
+
await writeRegistry([
|
|
170
|
+
row({ version: "0.5.0", projectPath: mainTree }),
|
|
171
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
172
|
+
]);
|
|
173
|
+
const { details, reporter } = collect();
|
|
174
|
+
|
|
175
|
+
const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
|
|
176
|
+
|
|
177
|
+
expect(result.ok).toBe(1);
|
|
178
|
+
expect(result.unchanged).toEqual([]);
|
|
179
|
+
expect(await versionsByPath()).toEqual([
|
|
180
|
+
[mainTree, "0.5.0"],
|
|
181
|
+
[here, "0.5.0"],
|
|
182
|
+
]);
|
|
183
|
+
expect(details).toEqual([SAME_REPO_DETAIL]);
|
|
184
|
+
expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("2: several other rows at the target — still one folder, 'another folder'", async () => {
|
|
188
|
+
await writeRegistry([
|
|
189
|
+
row({ version: "0.5.0", projectPath: gone }),
|
|
190
|
+
row({ version: "0.5.0", projectPath: mainTree }),
|
|
191
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
192
|
+
]);
|
|
193
|
+
const { details, reporter } = collect();
|
|
194
|
+
|
|
195
|
+
const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
|
|
196
|
+
|
|
197
|
+
expect(result.ok).toBe(1);
|
|
198
|
+
expect(result.unchanged).toEqual([]);
|
|
199
|
+
expect(await hereVersion()).toBe("0.5.0");
|
|
200
|
+
expect(details).toEqual([SAME_REPO_DETAIL]);
|
|
201
|
+
expect(details[0]).not.toMatch(/other folders/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("3 (negative a): the only row at the target is another repository's — no follow", async () => {
|
|
205
|
+
await writeRegistry([
|
|
206
|
+
row({ version: "0.5.0", projectPath: otherRepo }),
|
|
207
|
+
row({ version: "0.4.0", projectPath: mainTree }),
|
|
208
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
209
|
+
]);
|
|
210
|
+
const { details, reporter } = collect();
|
|
211
|
+
|
|
212
|
+
const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
|
|
213
|
+
|
|
214
|
+
expect(result.unchanged).toEqual([PLUGIN]);
|
|
215
|
+
expect(result.ok).toBe(0);
|
|
216
|
+
expect(await hereVersion()).toBe("0.4.0");
|
|
217
|
+
expect(details).toEqual([NO_CHANGE]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("4 (negative b): other repository rows are NEWER than the target — no follow", async () => {
|
|
221
|
+
await writeRegistry([
|
|
222
|
+
row({ version: "0.6.0", projectPath: mainTree }),
|
|
223
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
224
|
+
]);
|
|
225
|
+
const { details, reporter } = collect();
|
|
226
|
+
|
|
227
|
+
const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
|
|
228
|
+
|
|
229
|
+
expect(result.unchanged).toEqual([PLUGIN]);
|
|
230
|
+
expect(await hereVersion()).toBe("0.4.0");
|
|
231
|
+
expect(await versionsByPath()).toEqual([
|
|
232
|
+
[mainTree, "0.6.0"],
|
|
233
|
+
[here, "0.4.0"],
|
|
234
|
+
]);
|
|
235
|
+
expect(details).toEqual([NO_CHANGE]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("4b (negative b): other repository rows are OLDER than the target — no follow", async () => {
|
|
239
|
+
await writeRegistry([
|
|
240
|
+
row({ version: "0.3.0", projectPath: mainTree }),
|
|
241
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
242
|
+
]);
|
|
243
|
+
const { details, reporter } = collect();
|
|
244
|
+
|
|
245
|
+
await applyPlugins([item("0.5.0")], here, deps(), reporter);
|
|
246
|
+
|
|
247
|
+
// The older row is not the target, so this row does not follow it. (The
|
|
248
|
+
// stale-load repair that raises that older row to this one's version is
|
|
249
|
+
// older behaviour, tested on its own.)
|
|
250
|
+
expect(await hereVersion()).toBe("0.4.0");
|
|
251
|
+
expect(details.join("\n")).not.toContain("another folder");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("5: the target is not downloaded — could not follow, row stays", async () => {
|
|
255
|
+
await writeRegistry([
|
|
256
|
+
row({ version: "0.5.0", projectPath: mainTree }),
|
|
257
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
258
|
+
]);
|
|
259
|
+
await fs.remove(cacheFor("0.5.0"));
|
|
260
|
+
const { details, reporter } = collect();
|
|
261
|
+
|
|
262
|
+
const result = await applyPlugins([item("0.5.0")], here, deps(), reporter);
|
|
263
|
+
|
|
264
|
+
expect(result.unchanged).toEqual([PLUGIN]);
|
|
265
|
+
expect(await hereVersion()).toBe("0.4.0");
|
|
266
|
+
expect(details).toHaveLength(1);
|
|
267
|
+
expect(details[0]).toContain(
|
|
268
|
+
`no change — the installer exited 0 but ${PLUGIN} is still 0.4.0 (Claude Code updated another folder of this repository instead of this one, and this folder could not follow: v0.5.0 is not downloaded — run magus install)`,
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests, contract C18: Homebrew owns an installed tool whose file lies
|
|
3
|
+
* in `<brew prefix>/Cellar/<short name>/<version>/...`, whatever installer the
|
|
4
|
+
* spec declares. A pinned spec is not adopted (brew takes no version).
|
|
5
|
+
*
|
|
6
|
+
* Written from the spec only, through a scripted fake `ToolProbeIO`.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
type ExecResult,
|
|
11
|
+
type FetchJsonResult,
|
|
12
|
+
type ToolProbeIO,
|
|
13
|
+
classifyTool,
|
|
14
|
+
probeTool,
|
|
15
|
+
} from "../../services/tool-probe.js";
|
|
16
|
+
import type { BinRequirement } from "../../types/index.js";
|
|
17
|
+
|
|
18
|
+
interface FakeSetup {
|
|
19
|
+
exec?: Record<string, Partial<ExecResult>>;
|
|
20
|
+
fetch?: Record<string, FetchJsonResult>;
|
|
21
|
+
files?: Record<string, string>;
|
|
22
|
+
real?: Record<string, string>;
|
|
23
|
+
onPath?: Record<string, string>;
|
|
24
|
+
env?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fakeIO(setup: FakeSetup): ToolProbeIO & { calls: string[] } {
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
return {
|
|
30
|
+
calls,
|
|
31
|
+
exec: async (command) => {
|
|
32
|
+
const key = [command.cmd, ...command.args].join(" ");
|
|
33
|
+
calls.push(key);
|
|
34
|
+
const h = setup.exec?.[key];
|
|
35
|
+
if (h === undefined) {
|
|
36
|
+
return {
|
|
37
|
+
code: null,
|
|
38
|
+
stdout: "",
|
|
39
|
+
stderr: "",
|
|
40
|
+
timedOut: false,
|
|
41
|
+
missing: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
code: 0,
|
|
46
|
+
stdout: "",
|
|
47
|
+
stderr: "",
|
|
48
|
+
timedOut: false,
|
|
49
|
+
missing: false,
|
|
50
|
+
...h,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
fetchJson: async (url) => {
|
|
54
|
+
const r = setup.fetch?.[url];
|
|
55
|
+
return r ?? { ok: false, reason: "returned 404" };
|
|
56
|
+
},
|
|
57
|
+
readJson: async (file) => {
|
|
58
|
+
const t = setup.files?.[file];
|
|
59
|
+
if (t === undefined) return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(t);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
readText: async (file) => setup.files?.[file] ?? null,
|
|
67
|
+
readlink: async () => null,
|
|
68
|
+
realpath: async (file) => setup.real?.[file] ?? null,
|
|
69
|
+
resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
|
|
70
|
+
env: setup.env ?? {},
|
|
71
|
+
homedir: "/Users/j",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const PREFIX = "/opt/homebrew";
|
|
76
|
+
const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
|
|
77
|
+
const BREW_PREFIX_OK: Record<string, Partial<ExecResult>> = {
|
|
78
|
+
"brew --prefix": { stdout: `${PREFIX}\n` },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const OUTDATED_NONE = JSON.stringify({ formulae: [], casks: [] });
|
|
82
|
+
const outdatedBehind = (name: string, installed: string, current: string) =>
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
formulae: [
|
|
85
|
+
{
|
|
86
|
+
name,
|
|
87
|
+
installed_versions: [installed],
|
|
88
|
+
current_version: current,
|
|
89
|
+
pinned: false,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
casks: [],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** A tool whose PATH entry resolves into a Homebrew keg. */
|
|
96
|
+
function kegWorld(
|
|
97
|
+
bin: string,
|
|
98
|
+
short: string,
|
|
99
|
+
version: string,
|
|
100
|
+
outdatedKey: string,
|
|
101
|
+
outdatedStdout: string,
|
|
102
|
+
extraExec: Record<string, Partial<ExecResult>> = BREW_PREFIX_OK,
|
|
103
|
+
): FakeSetup {
|
|
104
|
+
const file = `${PREFIX}/Cellar/${short}/${version}/bin/${bin}`;
|
|
105
|
+
const onPath = `${PREFIX}/bin/${bin}`;
|
|
106
|
+
return {
|
|
107
|
+
onPath: { [bin]: onPath },
|
|
108
|
+
real: { [onPath]: file, [file]: file, ...CELLAR },
|
|
109
|
+
exec: {
|
|
110
|
+
...extraExec,
|
|
111
|
+
[`brew outdated --json=v2 ${outdatedKey}`]: { stdout: outdatedStdout },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const MNEMEX: BinRequirement = {
|
|
117
|
+
name: "mnemex",
|
|
118
|
+
via: "brew",
|
|
119
|
+
formula: "madappgang/tap/mnemex",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
describe("C18 case 1: tap formula, brew spec, current", () => {
|
|
123
|
+
test("managed, version from the keg, state current", async () => {
|
|
124
|
+
const f = await probeTool(
|
|
125
|
+
MNEMEX,
|
|
126
|
+
fakeIO(
|
|
127
|
+
kegWorld(
|
|
128
|
+
"mnemex",
|
|
129
|
+
"mnemex",
|
|
130
|
+
"0.36.2",
|
|
131
|
+
"madappgang/tap/mnemex",
|
|
132
|
+
OUTDATED_NONE,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
expect(f.managed).toBe(true);
|
|
137
|
+
expect(f.installed).toBe("0.36.2");
|
|
138
|
+
expect(classifyTool(MNEMEX, f).state).toBe("current");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("C18 case 2: tap formula behind", () => {
|
|
143
|
+
test("state upgrade, latest from brew", async () => {
|
|
144
|
+
const f = await probeTool(
|
|
145
|
+
MNEMEX,
|
|
146
|
+
fakeIO(
|
|
147
|
+
kegWorld(
|
|
148
|
+
"mnemex",
|
|
149
|
+
"mnemex",
|
|
150
|
+
"0.36.2",
|
|
151
|
+
"madappgang/tap/mnemex",
|
|
152
|
+
outdatedBehind("madappgang/tap/mnemex", "0.36.2", "0.37.0"),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
expect(f.managed).toBe(true);
|
|
157
|
+
expect(f.latest).toBe("0.37.0");
|
|
158
|
+
expect(classifyTool(MNEMEX, f).state).toBe("upgrade");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("C18 case 3: bun spec, file in a Homebrew keg", () => {
|
|
163
|
+
const spec: BinRequirement = {
|
|
164
|
+
name: "claudish",
|
|
165
|
+
via: "bun",
|
|
166
|
+
package: "claudish",
|
|
167
|
+
};
|
|
168
|
+
test("adopted by brew: brew upgrade command, never bun", async () => {
|
|
169
|
+
const f = await probeTool(
|
|
170
|
+
spec,
|
|
171
|
+
fakeIO(
|
|
172
|
+
kegWorld(
|
|
173
|
+
"claudish",
|
|
174
|
+
"claudish",
|
|
175
|
+
"10.5.0",
|
|
176
|
+
"claudish",
|
|
177
|
+
outdatedBehind("claudish", "10.5.0", "10.6.0"),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
expect(f.managed).toBe(true);
|
|
182
|
+
expect(f.installed).toBe("10.5.0");
|
|
183
|
+
expect(f.latest).toBe("10.6.0");
|
|
184
|
+
expect(f.upgradeCommand).toEqual({
|
|
185
|
+
cmd: "brew",
|
|
186
|
+
args: ["upgrade", "claudish"],
|
|
187
|
+
});
|
|
188
|
+
expect(f.upgradeCommand?.cmd).not.toBe("bun");
|
|
189
|
+
expect(classifyTool(spec, f).state).toBe("upgrade");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("C18 case 4: npm spec, file in a Homebrew keg", () => {
|
|
194
|
+
const spec: BinRequirement = {
|
|
195
|
+
name: "claudish",
|
|
196
|
+
via: "npm",
|
|
197
|
+
package: "claudish",
|
|
198
|
+
};
|
|
199
|
+
test("current, upgrade command is brew", async () => {
|
|
200
|
+
const f = await probeTool(
|
|
201
|
+
spec,
|
|
202
|
+
fakeIO(
|
|
203
|
+
kegWorld("claudish", "claudish", "10.5.0", "claudish", OUTDATED_NONE),
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
expect(f.upgradeCommand?.cmd).toBe("brew");
|
|
207
|
+
expect(classifyTool(spec, f).state).toBe("current");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("C18 case 5: a pinned spec is not adopted by Homebrew", () => {
|
|
212
|
+
const spec: BinRequirement = {
|
|
213
|
+
name: "claudish",
|
|
214
|
+
via: "bun",
|
|
215
|
+
package: "claudish",
|
|
216
|
+
version: "10.4.0",
|
|
217
|
+
};
|
|
218
|
+
test("unknown, no brew command, note mentions the pin", async () => {
|
|
219
|
+
const f = await probeTool(
|
|
220
|
+
spec,
|
|
221
|
+
fakeIO(
|
|
222
|
+
kegWorld(
|
|
223
|
+
"claudish",
|
|
224
|
+
"claudish",
|
|
225
|
+
"10.5.0",
|
|
226
|
+
"claudish",
|
|
227
|
+
outdatedBehind("claudish", "10.5.0", "10.6.0"),
|
|
228
|
+
),
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
expect(f.upgradeCommand?.cmd).not.toBe("brew");
|
|
232
|
+
const s = classifyTool(spec, f);
|
|
233
|
+
expect(s.state).toBe("unknown");
|
|
234
|
+
expect(s.note ?? "").toContain("10.4.0");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("C18 case 6: a directory merely named Cellar is not Homebrew's", () => {
|
|
239
|
+
const spec: BinRequirement = {
|
|
240
|
+
name: "claudish",
|
|
241
|
+
via: "bun",
|
|
242
|
+
package: "claudish",
|
|
243
|
+
};
|
|
244
|
+
test("not adopted: external, no upgrade command", async () => {
|
|
245
|
+
const file = "/Users/u/projects/Cellar/claudish/1.0.0/bin/claudish";
|
|
246
|
+
const f = await probeTool(
|
|
247
|
+
spec,
|
|
248
|
+
fakeIO({
|
|
249
|
+
onPath: { claudish: file },
|
|
250
|
+
real: { [file]: file, ...CELLAR },
|
|
251
|
+
exec: BREW_PREFIX_OK,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
expect(f.managed).not.toBe(true);
|
|
255
|
+
expect(f.upgradeCommand).toBeUndefined();
|
|
256
|
+
expect(classifyTool(spec, f).state).toBe("external");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("C18 case 7: Homebrew unavailable", () => {
|
|
261
|
+
const spec: BinRequirement = {
|
|
262
|
+
name: "claudish",
|
|
263
|
+
via: "bun",
|
|
264
|
+
package: "claudish",
|
|
265
|
+
};
|
|
266
|
+
const world = (exec: Record<string, Partial<ExecResult>>): FakeSetup =>
|
|
267
|
+
kegWorld(
|
|
268
|
+
"claudish",
|
|
269
|
+
"claudish",
|
|
270
|
+
"10.5.0",
|
|
271
|
+
"claudish",
|
|
272
|
+
outdatedBehind("claudish", "10.5.0", "10.6.0"),
|
|
273
|
+
exec,
|
|
274
|
+
);
|
|
275
|
+
test("brew --prefix missing: not adopted", async () => {
|
|
276
|
+
const f = await probeTool(spec, fakeIO(world({})));
|
|
277
|
+
expect(f.upgradeCommand?.cmd).not.toBe("brew");
|
|
278
|
+
expect(classifyTool(spec, f).state).not.toBe("upgrade");
|
|
279
|
+
});
|
|
280
|
+
test("brew --prefix exits non-zero: not adopted", async () => {
|
|
281
|
+
const f = await probeTool(
|
|
282
|
+
spec,
|
|
283
|
+
fakeIO(world({ "brew --prefix": { code: 1, stderr: "boom" } })),
|
|
284
|
+
);
|
|
285
|
+
expect(f.upgradeCommand?.cmd).not.toBe("brew");
|
|
286
|
+
expect(classifyTool(spec, f).state).not.toBe("upgrade");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("C18 case 8: a non-tap brew spec still works", () => {
|
|
291
|
+
test("tmux current, installed 3.8", async () => {
|
|
292
|
+
const spec: BinRequirement = {
|
|
293
|
+
name: "tmux",
|
|
294
|
+
via: "brew",
|
|
295
|
+
formula: "tmux",
|
|
296
|
+
check: "tmux -V",
|
|
297
|
+
};
|
|
298
|
+
const f = await probeTool(
|
|
299
|
+
spec,
|
|
300
|
+
fakeIO(kegWorld("tmux", "tmux", "3.8", "tmux", OUTDATED_NONE)),
|
|
301
|
+
);
|
|
302
|
+
expect(f.managed).toBe(true);
|
|
303
|
+
expect(f.installed).toBe("3.8");
|
|
304
|
+
expect(classifyTool(spec, f).state).toBe("current");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("C18 case 9: bun ownership still wins over a brew spec", () => {
|
|
309
|
+
test("file in bun's global tree: bun command, current", async () => {
|
|
310
|
+
const root = "/Users/j/.bun/install/global/node_modules";
|
|
311
|
+
const pkgDir = `${root}/mnemex`;
|
|
312
|
+
const file = `${pkgDir}/dist/index.js`;
|
|
313
|
+
const onPath = "/Users/j/.bun/bin/mnemex";
|
|
314
|
+
const f = await probeTool(
|
|
315
|
+
MNEMEX,
|
|
316
|
+
fakeIO({
|
|
317
|
+
env: { HOMEBREW_PREFIX: PREFIX },
|
|
318
|
+
onPath: { mnemex: onPath },
|
|
319
|
+
real: {
|
|
320
|
+
[onPath]: file,
|
|
321
|
+
[file]: file,
|
|
322
|
+
[pkgDir]: pkgDir,
|
|
323
|
+
[root]: root,
|
|
324
|
+
...CELLAR,
|
|
325
|
+
},
|
|
326
|
+
files: {
|
|
327
|
+
[`${pkgDir}/package.json`]: JSON.stringify({
|
|
328
|
+
name: "mnemex",
|
|
329
|
+
version: "0.36.2",
|
|
330
|
+
}),
|
|
331
|
+
},
|
|
332
|
+
exec: BREW_PREFIX_OK,
|
|
333
|
+
fetch: {
|
|
334
|
+
"https://registry.npmjs.org/-/package/mnemex/dist-tags": {
|
|
335
|
+
ok: true,
|
|
336
|
+
body: { latest: "0.36.2" },
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
expect(f.managed).toBe(true);
|
|
342
|
+
expect(f.upgradeCommand?.cmd).toBe("bun");
|
|
343
|
+
expect(classifyTool(MNEMEX, f).state).toBe("current");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -213,9 +213,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
|
|
|
213
213
|
expect(row.status?.state).toBe("external");
|
|
214
214
|
expect(row.status?.reason).toBe("not-managed");
|
|
215
215
|
// No path and no installer directory: the user acts on neither.
|
|
216
|
-
expect(row.status?.note).toBe(
|
|
217
|
-
"installed outside npm, so magus leaves it as is",
|
|
218
|
-
);
|
|
216
|
+
expect(row.status?.note).toBe("installed outside npm");
|
|
219
217
|
expect(row.status?.note).not.toContain("/");
|
|
220
218
|
// Enter still follows how it IS installed (pnpm).
|
|
221
219
|
expect(row.updateCommand?.cmd).toBe("pnpm");
|
|
@@ -238,7 +238,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
238
238
|
}),
|
|
239
239
|
);
|
|
240
240
|
expect(s).toMatchObject({ state: "external", reason: "not-managed" });
|
|
241
|
-
expect(s.note).toBe("installed outside npm
|
|
241
|
+
expect(s.note).toBe("installed outside npm");
|
|
242
242
|
expect(s.note).not.toContain("/");
|
|
243
243
|
});
|
|
244
244
|
|
|
@@ -254,7 +254,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
254
254
|
spec({ via: "brew" }),
|
|
255
255
|
facts({ managed: false, behind: true }),
|
|
256
256
|
).note,
|
|
257
|
-
).toBe("installed outside Homebrew
|
|
257
|
+
).toBe("installed outside Homebrew");
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
test("7. held back → unknown, with the hold as the note", () => {
|
package/src/cli/update-view.ts
CHANGED
|
@@ -458,6 +458,10 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
458
458
|
item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
|
|
459
459
|
}`;
|
|
460
460
|
case "external":
|
|
461
|
+
// Kept as is: a missing version is not a finding, so no "—" stands in.
|
|
462
|
+
return [item.installed, item.note ? dim(`(${item.note})`) : ""]
|
|
463
|
+
.filter(Boolean)
|
|
464
|
+
.join(" ");
|
|
461
465
|
case "unknown":
|
|
462
466
|
return `${item.installed ?? "—"}${
|
|
463
467
|
item.note ? ` ${dim(`(${item.note})`)}` : ""
|
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
versionFromVersionedPath,
|
|
53
53
|
} from "./cli-tool-version.js";
|
|
54
54
|
import {
|
|
55
|
+
binPinCommand,
|
|
56
|
+
binUpgradeCommand,
|
|
55
57
|
claudeNativeUpgradeCommand,
|
|
56
58
|
npmPrefixInstallCommand,
|
|
57
59
|
} from "./toolchain.js";
|
|
@@ -231,6 +233,15 @@ export function installerWord(via: ManagedTool["via"]): string {
|
|
|
231
233
|
return via === "brew" ? "Homebrew" : via;
|
|
232
234
|
}
|
|
233
235
|
|
|
236
|
+
/** The directories the operating system itself installs programs into. */
|
|
237
|
+
const SYSTEM_DIRS = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/System"];
|
|
238
|
+
|
|
239
|
+
function isSystemPath(resolved: string | null): boolean {
|
|
240
|
+
return (
|
|
241
|
+
resolved !== null && SYSTEM_DIRS.some((dir) => isInside(resolved, dir))
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
234
245
|
/**
|
|
235
246
|
* The `not-managed` sentence, in the user's terms: who did not install it,
|
|
236
247
|
* and what magus does about it. Never the path or the installer's directory:
|
|
@@ -241,8 +252,12 @@ function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
|
|
|
241
252
|
if (facts.managed === false && pin) {
|
|
242
253
|
return `installed outside ${installerWord(spec.via)}, so magus cannot move it to the pin ${pin}`;
|
|
243
254
|
}
|
|
255
|
+
// The KEPT chip already says magus leaves it as is; the note says only who
|
|
256
|
+
// installed it.
|
|
244
257
|
return facts.managed === false
|
|
245
|
-
?
|
|
258
|
+
? isSystemPath(facts.resolvedPath)
|
|
259
|
+
? "comes with the operating system"
|
|
260
|
+
: `installed outside ${installerWord(spec.via)}`
|
|
246
261
|
: withFailure(
|
|
247
262
|
`could not tell whether ${installerWord(spec.via)} installed it`,
|
|
248
263
|
facts.failure,
|
|
@@ -1223,6 +1238,9 @@ async function probeBrew(
|
|
|
1223
1238
|
resolvedPath: string,
|
|
1224
1239
|
): Promise<void> {
|
|
1225
1240
|
const formula = spec.formula ?? spec.name;
|
|
1241
|
+
// A tap formula ("user/tap/f") keeps its keg under the short name, so the
|
|
1242
|
+
// full name never matched and a Homebrew mnemex read as installed outside it.
|
|
1243
|
+
const keg = formula.slice(formula.lastIndexOf("/") + 1);
|
|
1226
1244
|
const prefix = await s.brewPrefix();
|
|
1227
1245
|
if ("failure" in prefix) {
|
|
1228
1246
|
f.managed = null;
|
|
@@ -1232,11 +1250,11 @@ async function probeBrew(
|
|
|
1232
1250
|
const cellar =
|
|
1233
1251
|
(await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
|
|
1234
1252
|
path.join(prefix.dir, "Cellar");
|
|
1235
|
-
const ownerDir = path.join(cellar,
|
|
1253
|
+
const ownerDir = path.join(cellar, keg);
|
|
1236
1254
|
f.owner = `brew (Cellar ${ownerDir})`;
|
|
1237
1255
|
f.managed = isInside(resolvedPath, ownerDir);
|
|
1238
1256
|
if (!f.managed) return;
|
|
1239
|
-
f.installed = brewKegVersion(resolvedPath, cellar,
|
|
1257
|
+
f.installed = brewKegVersion(resolvedPath, cellar, keg);
|
|
1240
1258
|
if (f.installed) addSource(f, "brew");
|
|
1241
1259
|
|
|
1242
1260
|
const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
|
|
@@ -1386,6 +1404,129 @@ async function readNodePackage(
|
|
|
1386
1404
|
if (!pinned) await npmLatest(s, pkg, f);
|
|
1387
1405
|
}
|
|
1388
1406
|
|
|
1407
|
+
/** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
|
|
1408
|
+
function bunGlobalRoot(io: ToolProbeIO): string {
|
|
1409
|
+
return path.join(
|
|
1410
|
+
io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
|
|
1411
|
+
"install",
|
|
1412
|
+
"global",
|
|
1413
|
+
"node_modules",
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** The package a path names, from its segments under `node_modules`. */
|
|
1418
|
+
function packageFromSegments(rel: string): string | null {
|
|
1419
|
+
const [first, second] = rel.split(path.sep);
|
|
1420
|
+
if (!first || first.startsWith(".")) return null;
|
|
1421
|
+
if (!first.startsWith("@")) return first;
|
|
1422
|
+
return second ? `${first}/${second}` : null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* The bun or npm global package that holds `resolvedPath`, read from the path
|
|
1427
|
+
* alone. Null when the file lies in neither tree (a Homebrew keg included:
|
|
1428
|
+
* `npmGlobalPrefixOf` refuses one).
|
|
1429
|
+
*/
|
|
1430
|
+
async function nodeGlobalOwner(
|
|
1431
|
+
s: ProbeSession,
|
|
1432
|
+
resolvedPath: string,
|
|
1433
|
+
): Promise<{ via: "bun" | "npm"; pkg: string } | null> {
|
|
1434
|
+
const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
|
|
1435
|
+
if (bunRoot && isInside(resolvedPath, bunRoot)) {
|
|
1436
|
+
const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
|
|
1437
|
+
return pkg ? { via: "bun", pkg } : null;
|
|
1438
|
+
}
|
|
1439
|
+
const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
|
|
1440
|
+
const at = resolvedPath.lastIndexOf(marker);
|
|
1441
|
+
if (at < 0) return null;
|
|
1442
|
+
const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
|
|
1443
|
+
return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
|
|
1444
|
+
? { via: "npm", pkg }
|
|
1445
|
+
: null;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* The formula whose keg holds `resolvedPath` (`…/Cellar/<formula>/<version>/…`),
|
|
1450
|
+
* read from the path alone. `probeBrew` then checks that this Cellar is brew's.
|
|
1451
|
+
*/
|
|
1452
|
+
function kegFormulaOf(resolvedPath: string): string | null {
|
|
1453
|
+
const parts = resolvedPath.split(path.sep);
|
|
1454
|
+
const at = parts.lastIndexOf("Cellar");
|
|
1455
|
+
return at >= 0 && parts.length > at + 2 ? (parts[at + 1] ?? null) : null;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* The spec as the installer that actually holds `resolvedPath` would state it,
|
|
1460
|
+
* or null when that is the spec's own installer or none magus drives.
|
|
1461
|
+
*/
|
|
1462
|
+
async function actualOwnerSpec(
|
|
1463
|
+
s: ProbeSession,
|
|
1464
|
+
spec: ManagedTool,
|
|
1465
|
+
resolvedPath: string,
|
|
1466
|
+
): Promise<ManagedTool | null> {
|
|
1467
|
+
const base = {
|
|
1468
|
+
name: spec.name,
|
|
1469
|
+
...(spec.check ? { check: spec.check } : {}),
|
|
1470
|
+
...(spec.version ? { version: spec.version } : {}),
|
|
1471
|
+
};
|
|
1472
|
+
const node = await nodeGlobalOwner(s, resolvedPath);
|
|
1473
|
+
if (node) {
|
|
1474
|
+
return node.via === spec.via
|
|
1475
|
+
? null
|
|
1476
|
+
: { ...base, via: node.via, package: node.pkg };
|
|
1477
|
+
}
|
|
1478
|
+
const formula = kegFormulaOf(resolvedPath);
|
|
1479
|
+
// Homebrew takes no version, so it cannot honour a pin: such a tool stays
|
|
1480
|
+
// "installed outside", which the classifier reports as unknown.
|
|
1481
|
+
if (!formula || spec.via === "brew" || spec.version) return null;
|
|
1482
|
+
return { ...base, via: "brew", formula };
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* The spec's installer does not own the file PATH runs, but bun's or npm's
|
|
1487
|
+
* global tree or a Homebrew keg does: re-read the tool through THAT installer.
|
|
1488
|
+
*
|
|
1489
|
+
* A plugin's `requires` lists its install options in preference order for a
|
|
1490
|
+
* FRESH install, and magus plans through the first. That order says how to
|
|
1491
|
+
* install a missing tool, not which owner an installed one must have. A
|
|
1492
|
+
* mnemex from `bun add -g` or from Homebrew has a version, a registry and an
|
|
1493
|
+
* upgrade command like any other. Reading it as "installed outside" the
|
|
1494
|
+
* first option's installer showed a settled, upgradeable tool as one magus
|
|
1495
|
+
* could not touch.
|
|
1496
|
+
*
|
|
1497
|
+
* The catalogue's tools already follow how a tool IS installed
|
|
1498
|
+
* (`specForCatalogTool`); this gives a plugin's requirements the same rule.
|
|
1499
|
+
* The upgrade command is carried on the facts, so `magus update` and the TUI
|
|
1500
|
+
* run the owner's command rather than the spec's.
|
|
1501
|
+
*/
|
|
1502
|
+
async function adoptActualOwner(
|
|
1503
|
+
s: ProbeSession,
|
|
1504
|
+
spec: ManagedTool,
|
|
1505
|
+
f: ToolFacts,
|
|
1506
|
+
resolvedPath: string,
|
|
1507
|
+
): Promise<void> {
|
|
1508
|
+
const actual = await actualOwnerSpec(s, spec, resolvedPath);
|
|
1509
|
+
if (!actual) return;
|
|
1510
|
+
const read = blankFacts(spec.name);
|
|
1511
|
+
if (actual.via === "brew") await probeBrew(s, actual, read, resolvedPath);
|
|
1512
|
+
else await probeNodeGlobal(s, actual, read, resolvedPath);
|
|
1513
|
+
if (read.managed !== true) return;
|
|
1514
|
+
f.managed = true;
|
|
1515
|
+
f.owner = read.owner;
|
|
1516
|
+
f.installed = read.installed;
|
|
1517
|
+
f.latest = read.latest;
|
|
1518
|
+
f.behind = read.behind;
|
|
1519
|
+
// What the spec's installer could not answer is moot once the owner is
|
|
1520
|
+
// known: only the owner's own failure describes this tool.
|
|
1521
|
+
f.failure = read.failure;
|
|
1522
|
+
for (const source of read.sources) addSource(f, source);
|
|
1523
|
+
f.upgradeCommand =
|
|
1524
|
+
read.upgradeCommand ??
|
|
1525
|
+
(spec.version
|
|
1526
|
+
? (binPinCommand(actual) ?? undefined)
|
|
1527
|
+
: binUpgradeCommand(actual));
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1389
1530
|
async function probeNodeGlobal(
|
|
1390
1531
|
s: ProbeSession,
|
|
1391
1532
|
spec: ManagedTool,
|
|
@@ -1396,12 +1537,7 @@ async function probeNodeGlobal(
|
|
|
1396
1537
|
const pin = pinOf(spec);
|
|
1397
1538
|
const pinned = pin !== undefined;
|
|
1398
1539
|
if (spec.via === "bun") {
|
|
1399
|
-
const root =
|
|
1400
|
-
s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
|
|
1401
|
-
"install",
|
|
1402
|
-
"global",
|
|
1403
|
-
"node_modules",
|
|
1404
|
-
);
|
|
1540
|
+
const root = bunGlobalRoot(s.io);
|
|
1405
1541
|
f.owner = `bun (global ${root})`;
|
|
1406
1542
|
const { inside, real } = await containedIn(
|
|
1407
1543
|
s,
|
|
@@ -1654,6 +1790,11 @@ async function probeInto(
|
|
|
1654
1790
|
break;
|
|
1655
1791
|
}
|
|
1656
1792
|
|
|
1793
|
+
// Not the spec's installer's file, or it could not tell (npm with no `npm`
|
|
1794
|
+
// to ask), but the path names bun's, npm's or Homebrew's tree: then that is
|
|
1795
|
+
// the owner, and it can upgrade the file PATH runs.
|
|
1796
|
+
if (f.managed !== true) await adoptActualOwner(s, spec, f, resolvedPath);
|
|
1797
|
+
|
|
1657
1798
|
// The method's own record was silent. Before anything runs, read what the
|
|
1658
1799
|
// installed files already say about themselves.
|
|
1659
1800
|
if (f.installed === null) versionFromFiles(f, resolvedPath);
|
|
@@ -397,7 +397,7 @@ async function readRows(
|
|
|
397
397
|
* The row Claude Code moved is left as it is: it belongs to this repository,
|
|
398
398
|
* and bringing this repository's rows forward is what the repair does anyway.
|
|
399
399
|
* Null when there was nothing to follow — this project's row moved (2.1.280),
|
|
400
|
-
* or no row of this repository moved at all
|
|
400
|
+
* or no row of this repository moved at all and none already holds `target`.
|
|
401
401
|
*/
|
|
402
402
|
async function followMisdirectedUpdate(
|
|
403
403
|
deps: PluginApplyDeps,
|
|
@@ -422,14 +422,32 @@ async function followMisdirectedUpdate(
|
|
|
422
422
|
scope,
|
|
423
423
|
projectPath: scope === "user" ? undefined : projectPath,
|
|
424
424
|
});
|
|
425
|
-
if (moved.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
425
|
+
if (moved.some((row) => rowKey(row) === mine)) return null;
|
|
426
|
+
// The row Claude Code picked may ALREADY hold the target — an earlier run
|
|
427
|
+
// from another checkout put it there. Then the update writes nothing, so
|
|
428
|
+
// nothing moves, and this project's row stays behind on every run (seen on
|
|
429
|
+
// 2.1.296: "the installer exited 0 but mnemex@magus is still 1.1.0" while
|
|
430
|
+
// tools/claudeup held 1.1.1). A row of this repository at exactly the
|
|
431
|
+
// target is the same misdirection, already landed.
|
|
432
|
+
const landed =
|
|
433
|
+
moved.length > 0 || !target
|
|
434
|
+
? moved
|
|
435
|
+
: after.filter(
|
|
436
|
+
(row) =>
|
|
437
|
+
row.version === target &&
|
|
438
|
+
rowKey(row) !== mine &&
|
|
439
|
+
inThisRepository(row, projectPath),
|
|
440
|
+
);
|
|
441
|
+
if (landed.length === 0) return null;
|
|
442
|
+
const version = landed
|
|
429
443
|
.map((row) => row.version)
|
|
430
444
|
.reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
|
|
431
445
|
if (!isRepairablyBehind(version, now)) return null;
|
|
432
|
-
|
|
446
|
+
// Already landed, every checkout at the target matches, but Claude Code
|
|
447
|
+
// wrote to one of them: report one folder, not all of them.
|
|
448
|
+
const elsewhere = (moved.length > 0 ? landed : landed.slice(0, 1)).map(
|
|
449
|
+
(row) => row.projectPath ?? `${row.scope} scope`,
|
|
450
|
+
);
|
|
433
451
|
if (target && version !== target) {
|
|
434
452
|
return {
|
|
435
453
|
elsewhere,
|