magus-cli 7.10.1 → 7.10.3
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__/blind/c19-probe-owner-version-shadow.test.ts +389 -0
- package/src/__tests__/cli-tools-adapter.test.ts +1 -1
- package/src/__tests__/tool-probe.test.ts +8 -8
- package/src/cli/update-view.ts +14 -0
- package/src/services/tool-probe.ts +447 -19
- package/src/services/update-engine.ts +24 -6
- package/src/services/update-plan.ts +3 -0
- package/src/ui/renderers/cliToolRenderers.tsx +11 -0
|
@@ -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
|
+
});
|