magus-cli 7.10.2 → 7.10.4
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__/blackbox-convergence.test.ts +0 -1
- package/src/__tests__/blackbox-parity.test.ts +2 -2
- package/src/__tests__/blackbox-tool-state.test.ts +8 -18
- package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
- package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
- package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
- package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
- package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
- package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +392 -0
- package/src/__tests__/cli-tool-commands.test.ts +2 -2
- package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
- package/src/__tests__/cli-tools-adapter.test.ts +32 -20
- package/src/__tests__/cli-update-view.test.ts +124 -51
- package/src/__tests__/fixtures/bins.ts +9 -2
- package/src/__tests__/npm-package-name.test.ts +3 -2
- package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
- package/src/__tests__/tool-probe.test.ts +231 -144
- package/src/__tests__/tool-rows-followups.test.ts +315 -0
- package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
- package/src/__tests__/tool-versions.test.ts +164 -0
- package/src/__tests__/toolchain.test.ts +0 -2
- package/src/__tests__/update-plan.test.ts +40 -9
- package/src/cli/update-view.ts +182 -68
- package/src/cli/update.ts +44 -37
- package/src/data/cli-tools.ts +5 -10
- package/src/data/tool-versions.ts +137 -0
- package/src/services/cli-tool-commands.ts +1 -2
- package/src/services/cli-tool-version.ts +10 -88
- package/src/services/resolver.ts +9 -15
- package/src/services/tool-probe.ts +557 -424
- package/src/services/toolchain.ts +10 -4
- package/src/services/update-engine.ts +9 -15
- package/src/services/update-plan.ts +49 -18
- package/src/types/index.ts +0 -2
- package/src/ui/adapters/cliToolsAdapter.ts +31 -22
- package/src/ui/renderers/cliToolRenderers.tsx +15 -10
- package/src/ui/state/types.ts +0 -1
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests, contract C19: a KEPT row names who installed the file, shows its
|
|
3
|
+
* version whenever it can be read, drives a self-updating standalone installer
|
|
4
|
+
* (uv's receipt), and reports a newer copy hidden later on PATH.
|
|
5
|
+
*
|
|
6
|
+
* Written from the spec only, through a scripted fake `ToolProbeIO`.
|
|
7
|
+
*
|
|
8
|
+
* Merged with the tool-rows design (docs/plans/2026-10-08-magus-update-tool-rows.md,
|
|
9
|
+
* decision 7). Kept as written: the uv receipt cases (A1–A5) and the guards.
|
|
10
|
+
* Changed, because they contradict that design's rules:
|
|
11
|
+
* - An `external` row carries no note, so the owner sentences (A6, B7–B10,
|
|
12
|
+
* C11–C12) now expect none. They were read off Cellar, Caskroom and cargo
|
|
13
|
+
* paths, which the design forbids. A pinned tool no installer has (B10) is
|
|
14
|
+
* `external`, not `unknown`.
|
|
15
|
+
* - zsh's version comes from magus's own version command (`zsh --version`,
|
|
16
|
+
* bare name), not from running a file chosen by a path test, so C's fake
|
|
17
|
+
* world answers that command.
|
|
18
|
+
* - A copy later on PATH is counted (`otherCopies`), never described: its
|
|
19
|
+
* owner and version were read off its path, and the warning named a PATH
|
|
20
|
+
* folder with nothing for the user to run. So D expects a count, and an
|
|
21
|
+
* older copy (D14) counts too — telling older from newer would need that
|
|
22
|
+
* same guess.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, expect, test } from "bun:test";
|
|
25
|
+
import {
|
|
26
|
+
type ExecResult,
|
|
27
|
+
type FetchJsonResult,
|
|
28
|
+
type ToolProbeIO,
|
|
29
|
+
classifyTool,
|
|
30
|
+
probeTool,
|
|
31
|
+
} from "../../services/tool-probe.js";
|
|
32
|
+
import type { BinRequirement } from "../../types/index.js";
|
|
33
|
+
|
|
34
|
+
interface FakeSetup {
|
|
35
|
+
exec?: Record<string, Partial<ExecResult>>;
|
|
36
|
+
fetch?: Record<string, FetchJsonResult>;
|
|
37
|
+
files?: Record<string, string>;
|
|
38
|
+
real?: Record<string, string>;
|
|
39
|
+
onPath?: Record<string, string>;
|
|
40
|
+
allOnPath?: Record<string, string[]>;
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
noAll?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const HOME = "/home/u";
|
|
46
|
+
|
|
47
|
+
function fakeIO(
|
|
48
|
+
setup: FakeSetup,
|
|
49
|
+
): ToolProbeIO & { calls: string[]; reads: string[] } {
|
|
50
|
+
const calls: string[] = [];
|
|
51
|
+
const reads: string[] = [];
|
|
52
|
+
const io: ToolProbeIO & { calls: string[]; reads: string[] } = {
|
|
53
|
+
calls,
|
|
54
|
+
reads,
|
|
55
|
+
exec: async (command) => {
|
|
56
|
+
const key = [command.cmd, ...command.args].join(" ");
|
|
57
|
+
calls.push(key);
|
|
58
|
+
const h = setup.exec?.[key];
|
|
59
|
+
if (h === undefined) {
|
|
60
|
+
return {
|
|
61
|
+
code: null,
|
|
62
|
+
stdout: "",
|
|
63
|
+
stderr: "",
|
|
64
|
+
timedOut: false,
|
|
65
|
+
missing: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
code: 0,
|
|
70
|
+
stdout: "",
|
|
71
|
+
stderr: "",
|
|
72
|
+
timedOut: false,
|
|
73
|
+
missing: false,
|
|
74
|
+
...h,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
fetchJson: async (url) =>
|
|
78
|
+
setup.fetch?.[url] ?? { ok: false, reason: "returned 404" },
|
|
79
|
+
readJson: async (file) => {
|
|
80
|
+
reads.push(file);
|
|
81
|
+
const t = setup.files?.[file];
|
|
82
|
+
if (t === undefined) return null;
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(t);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
readText: async (file) => setup.files?.[file] ?? null,
|
|
90
|
+
readlink: async () => null,
|
|
91
|
+
realpath: async (file) => setup.real?.[file] ?? null,
|
|
92
|
+
resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
|
|
93
|
+
env: setup.env ?? {},
|
|
94
|
+
homedir: HOME,
|
|
95
|
+
};
|
|
96
|
+
if (!setup.noAll) {
|
|
97
|
+
io.resolveAllExecutables = async (name: string) =>
|
|
98
|
+
setup.allOnPath?.[name] ??
|
|
99
|
+
(setup.onPath?.[name] !== undefined
|
|
100
|
+
? [setup.onPath[name] as string]
|
|
101
|
+
: []);
|
|
102
|
+
}
|
|
103
|
+
return io;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const PREFIX = "/opt/homebrew";
|
|
107
|
+
const BREW_PREFIX_OK: Record<string, Partial<ExecResult>> = {
|
|
108
|
+
"brew --prefix": { stdout: `${PREFIX}\n` },
|
|
109
|
+
};
|
|
110
|
+
const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
|
|
111
|
+
|
|
112
|
+
const UV: BinRequirement = { name: "uv", via: "brew", formula: "uv" };
|
|
113
|
+
const UV_FILE = "/home/u/.local/bin/uv";
|
|
114
|
+
const UV_RECEIPT_PATH = "/home/u/.config/uv/uv-receipt.json";
|
|
115
|
+
const uvReceipt = (prefix = "/home/u/.local/bin", version = "0.11.15") =>
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
binaries: ["uv", "uvx"],
|
|
118
|
+
install_prefix: prefix,
|
|
119
|
+
source: { app_name: "uv", name: "uv", owner: "astral-sh" },
|
|
120
|
+
version,
|
|
121
|
+
});
|
|
122
|
+
const pypi = (v: string): Record<string, FetchJsonResult> => ({
|
|
123
|
+
"https://pypi.org/pypi/uv/json": { ok: true, body: { info: { version: v } } },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function uvWorld(opts: {
|
|
127
|
+
latest?: string;
|
|
128
|
+
receiptPrefix?: string;
|
|
129
|
+
receiptAt?: string;
|
|
130
|
+
env?: Record<string, string>;
|
|
131
|
+
extra?: Partial<FakeSetup>;
|
|
132
|
+
}): FakeSetup {
|
|
133
|
+
return {
|
|
134
|
+
onPath: { uv: UV_FILE },
|
|
135
|
+
real: { [UV_FILE]: UV_FILE },
|
|
136
|
+
files: {
|
|
137
|
+
[opts.receiptAt ?? UV_RECEIPT_PATH]: uvReceipt(opts.receiptPrefix),
|
|
138
|
+
},
|
|
139
|
+
fetch: pypi(opts.latest ?? "0.13.0"),
|
|
140
|
+
env: opts.env ?? {},
|
|
141
|
+
...opts.extra,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe("C19 A: uv's own installer is a driven owner", () => {
|
|
146
|
+
test("1. receipt, PyPI newer: managed, upgrade, uv self update", async () => {
|
|
147
|
+
const f = await probeTool(UV, fakeIO(uvWorld({})));
|
|
148
|
+
expect(f.installed).toBe("0.11.15");
|
|
149
|
+
expect(f.managed).toBe(true);
|
|
150
|
+
expect(f.latest).toBe("0.13.0");
|
|
151
|
+
expect(f.upgradeCommand).toEqual({ cmd: "uv", args: ["self", "update"] });
|
|
152
|
+
const s = classifyTool(UV, f);
|
|
153
|
+
expect(s.state).toBe("upgrade");
|
|
154
|
+
expect(s.target).toBe("0.13.0");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("2. PyPI equals installed: current", async () => {
|
|
158
|
+
const f = await probeTool(UV, fakeIO(uvWorld({ latest: "0.11.15" })));
|
|
159
|
+
expect(classifyTool(UV, f).state).toBe("current");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("3. pinned spec: upgrade to the pin with a versioned command", async () => {
|
|
163
|
+
const spec: BinRequirement = { ...UV, version: "0.12.0" };
|
|
164
|
+
const f = await probeTool(spec, fakeIO(uvWorld({})));
|
|
165
|
+
expect(f.upgradeCommand).toEqual({
|
|
166
|
+
cmd: "uv",
|
|
167
|
+
args: ["self", "update", "0.12.0"],
|
|
168
|
+
});
|
|
169
|
+
const s = classifyTool(spec, f);
|
|
170
|
+
expect(s.state).toBe("upgrade");
|
|
171
|
+
expect(s.target).toBe("0.12.0");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("4. receipt for a different install_prefix is ignored", async () => {
|
|
175
|
+
const f = await probeTool(
|
|
176
|
+
UV,
|
|
177
|
+
fakeIO(uvWorld({ receiptPrefix: "/somewhere/else" })),
|
|
178
|
+
);
|
|
179
|
+
expect(f.managed).not.toBe(true);
|
|
180
|
+
expect(f.upgradeCommand).toBeUndefined();
|
|
181
|
+
expect(classifyTool(UV, f).state).not.toBe("upgrade");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("5. XDG_CONFIG_HOME relocates the receipt", async () => {
|
|
185
|
+
const io = fakeIO(
|
|
186
|
+
uvWorld({
|
|
187
|
+
env: { XDG_CONFIG_HOME: "/xdg" },
|
|
188
|
+
extra: {
|
|
189
|
+
// a decoy at the default place must not be the one used
|
|
190
|
+
files: {
|
|
191
|
+
"/xdg/uv/uv-receipt.json": uvReceipt(),
|
|
192
|
+
[UV_RECEIPT_PATH]: uvReceipt("/home/u/.local/bin", "9.9.9"),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
const f = await probeTool(UV, io);
|
|
198
|
+
expect(f.installed).toBe("0.11.15");
|
|
199
|
+
expect(f.managed).toBe(true);
|
|
200
|
+
expect(io.reads).toContain("/xdg/uv/uv-receipt.json");
|
|
201
|
+
expect(io.reads).not.toContain(UV_RECEIPT_PATH);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("6. receipt of an installer magus cannot update: external, versioned, named", async () => {
|
|
205
|
+
const spec: BinRequirement = { name: "ruff", via: "brew", formula: "ruff" };
|
|
206
|
+
const file = "/home/u/.local/bin/ruff";
|
|
207
|
+
const f = await probeTool(
|
|
208
|
+
spec,
|
|
209
|
+
fakeIO({
|
|
210
|
+
onPath: { ruff: file },
|
|
211
|
+
real: { [file]: file },
|
|
212
|
+
files: {
|
|
213
|
+
"/home/u/.config/ruff/ruff-receipt.json": JSON.stringify({
|
|
214
|
+
binaries: ["ruff"],
|
|
215
|
+
install_prefix: "/home/u/.local/bin",
|
|
216
|
+
source: { app_name: "ruff", name: "ruff", owner: "astral-sh" },
|
|
217
|
+
version: "0.9.1",
|
|
218
|
+
}),
|
|
219
|
+
},
|
|
220
|
+
exec: BREW_PREFIX_OK,
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
expect(f.installed).toBe("0.9.1");
|
|
224
|
+
expect(f.upgradeCommand).toBeUndefined();
|
|
225
|
+
const s = classifyTool(spec, f);
|
|
226
|
+
expect(s.state).toBe("external");
|
|
227
|
+
expect(s.note).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("C19 B: a file no driven installer has is installed elsewhere, with no note", () => {
|
|
232
|
+
const spec = (
|
|
233
|
+
name: string,
|
|
234
|
+
extra: Partial<BinRequirement> = {},
|
|
235
|
+
): BinRequirement => ({
|
|
236
|
+
name,
|
|
237
|
+
via: "brew",
|
|
238
|
+
formula: name,
|
|
239
|
+
...extra,
|
|
240
|
+
});
|
|
241
|
+
const world = (
|
|
242
|
+
name: string,
|
|
243
|
+
file: string,
|
|
244
|
+
env: Record<string, string> = {},
|
|
245
|
+
): FakeSetup => ({
|
|
246
|
+
onPath: { [name]: file },
|
|
247
|
+
real: { [file]: file, ...CELLAR },
|
|
248
|
+
exec: BREW_PREFIX_OK,
|
|
249
|
+
env,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("7. Homebrew cask", async () => {
|
|
253
|
+
const sp = spec("madbench");
|
|
254
|
+
const file = "/opt/homebrew/Caskroom/madbench/0.43.2/madbench";
|
|
255
|
+
const f = await probeTool(sp, fakeIO(world("madbench", file)));
|
|
256
|
+
const s = classifyTool(sp, f);
|
|
257
|
+
expect(s.state).toBe("external");
|
|
258
|
+
expect(s.note).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("8a. cargo bin under the home directory", async () => {
|
|
262
|
+
const sp = spec("rg");
|
|
263
|
+
const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
|
|
264
|
+
const s = classifyTool(sp, f);
|
|
265
|
+
expect(s.state).toBe("external");
|
|
266
|
+
expect(s.note).toBeUndefined();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("8b. cargo bin under CARGO_HOME", async () => {
|
|
270
|
+
const sp = spec("rg");
|
|
271
|
+
const f = await probeTool(
|
|
272
|
+
sp,
|
|
273
|
+
fakeIO(world("rg", "/opt/cargo/bin/rg", { CARGO_HOME: "/opt/cargo" })),
|
|
274
|
+
);
|
|
275
|
+
const s = classifyTool(sp, f);
|
|
276
|
+
expect(s.state).toBe("external");
|
|
277
|
+
expect(s.note).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("9. unrecognised place, never says 'outside'", async () => {
|
|
281
|
+
const sp = spec("foo");
|
|
282
|
+
const f = await probeTool(
|
|
283
|
+
sp,
|
|
284
|
+
fakeIO(world("foo", "/home/u/tools/bin/foo")),
|
|
285
|
+
);
|
|
286
|
+
const s = classifyTool(sp, f);
|
|
287
|
+
expect(s.state).toBe("external");
|
|
288
|
+
expect(s.note).toBeUndefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("10. pinned spec with a cargo file", async () => {
|
|
292
|
+
const sp = spec("rg", { version: "1.0.0" });
|
|
293
|
+
const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
|
|
294
|
+
const s = classifyTool(sp, f);
|
|
295
|
+
expect(s.state).toBe("external");
|
|
296
|
+
expect(s.note).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("C19 C: operating-system programs show their version", () => {
|
|
301
|
+
const zsh: BinRequirement = { name: "zsh", via: "brew", formula: "zsh" };
|
|
302
|
+
const world = (zshExec?: Partial<ExecResult>): FakeSetup => ({
|
|
303
|
+
onPath: { zsh: "/bin/zsh" },
|
|
304
|
+
real: { "/bin/zsh": "/bin/zsh", ...CELLAR },
|
|
305
|
+
exec: {
|
|
306
|
+
...BREW_PREFIX_OK,
|
|
307
|
+
...(zshExec ? { "zsh --version": zshExec } : {}),
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("11. version read from --version", async () => {
|
|
312
|
+
const f = await probeTool(
|
|
313
|
+
zsh,
|
|
314
|
+
fakeIO(world({ stdout: "zsh 5.9 (arm64-apple-darwin25.0)\n" })),
|
|
315
|
+
);
|
|
316
|
+
expect(f.installed).toBe("5.9");
|
|
317
|
+
const s = classifyTool(zsh, f);
|
|
318
|
+
expect(s.state).toBe("external");
|
|
319
|
+
expect(s.note).toBeUndefined();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("12a. --version exits non-zero: still external, installed null", async () => {
|
|
323
|
+
const f = await probeTool(
|
|
324
|
+
zsh,
|
|
325
|
+
fakeIO(world({ code: 1, stdout: "", stderr: "" })),
|
|
326
|
+
);
|
|
327
|
+
expect(f.installed).toBeNull();
|
|
328
|
+
const s = classifyTool(zsh, f);
|
|
329
|
+
expect(s.state).toBe("external");
|
|
330
|
+
expect(s.note).toBeUndefined();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("12b. --version missing: still external, installed null", async () => {
|
|
334
|
+
const f = await probeTool(zsh, fakeIO(world()));
|
|
335
|
+
expect(f.installed).toBeNull();
|
|
336
|
+
const s = classifyTool(zsh, f);
|
|
337
|
+
expect(s.state).toBe("external");
|
|
338
|
+
expect(s.note).toBeUndefined();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("C19 D: another copy later on PATH is counted", () => {
|
|
343
|
+
const BREW_BIN = "/opt/homebrew/bin/uv";
|
|
344
|
+
const cellarFile = (v: string) => `/opt/homebrew/Cellar/uv/${v}/bin/uv`;
|
|
345
|
+
const world = (second: string, secondReal: string): FakeSetup =>
|
|
346
|
+
uvWorld({
|
|
347
|
+
extra: {
|
|
348
|
+
allOnPath: { uv: [UV_FILE, second] },
|
|
349
|
+
real: { [UV_FILE]: UV_FILE, [second]: secondReal, ...CELLAR },
|
|
350
|
+
exec: BREW_PREFIX_OK,
|
|
351
|
+
env: { HOMEBREW_PREFIX: PREFIX },
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("13. a Homebrew copy later on PATH: counted, no path, no warning", async () => {
|
|
356
|
+
const f = await probeTool(
|
|
357
|
+
UV,
|
|
358
|
+
fakeIO(world(BREW_BIN, cellarFile("0.13.0"))),
|
|
359
|
+
);
|
|
360
|
+
expect(f.otherCopies).toBe(1);
|
|
361
|
+
expect(JSON.stringify(classifyTool(UV, f))).not.toContain("/");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("14. an older later copy is counted the same: no version is guessed", async () => {
|
|
365
|
+
const f = await probeTool(
|
|
366
|
+
UV,
|
|
367
|
+
fakeIO(world(BREW_BIN, cellarFile("0.10.0"))),
|
|
368
|
+
);
|
|
369
|
+
expect(f.otherCopies).toBe(1);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("15. a symlink to the running file is not another copy", async () => {
|
|
373
|
+
const f = await probeTool(UV, fakeIO(world("/usr/local/bin/uv", UV_FILE)));
|
|
374
|
+
expect(f.otherCopies).toBeUndefined();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("16. IO without resolveAllExecutables: nothing reported, nothing throws", async () => {
|
|
378
|
+
const w = world(BREW_BIN, cellarFile("0.13.0"));
|
|
379
|
+
w.noAll = true;
|
|
380
|
+
const f = await probeTool(UV, fakeIO(w));
|
|
381
|
+
expect(f.otherCopies).toBeUndefined();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("17. a current row is counted too", async () => {
|
|
385
|
+
const w = world(BREW_BIN, cellarFile("0.13.0"));
|
|
386
|
+
w.fetch = pypi("0.11.15");
|
|
387
|
+
const f = await probeTool(UV, fakeIO(w));
|
|
388
|
+
const s = classifyTool(UV, f);
|
|
389
|
+
expect(s.state).toBe("current");
|
|
390
|
+
expect(f.otherCopies).toBe(1);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
@@ -93,11 +93,12 @@ describe("the catalogue is well-formed", () => {
|
|
|
93
93
|
const declared = declaredRequirements();
|
|
94
94
|
expect(declared.size).toBeGreaterThan(0);
|
|
95
95
|
for (const entry of catalogue) {
|
|
96
|
+
// No entry carries a version command: that is data/tool-versions.ts.
|
|
97
|
+
expect("checkCommand" in entry).toBe(false);
|
|
96
98
|
if (isOwnedEntry(entry)) {
|
|
97
99
|
const [plugin] = entry.ownedBy.split("@");
|
|
98
100
|
expect(declared.get(entry.name) ?? []).toContain(plugin);
|
|
99
101
|
expect("packageName" in entry).toBe(false);
|
|
100
|
-
expect("checkCommand" in entry).toBe(false);
|
|
101
102
|
} else {
|
|
102
103
|
expect(declared.has(entry.name)).toBe(false);
|
|
103
104
|
}
|
|
@@ -124,7 +125,6 @@ describe("the catalogue is well-formed", () => {
|
|
|
124
125
|
expect(
|
|
125
126
|
claudish && !isPluginManagedItem(claudish) && claudish.tool,
|
|
126
127
|
).toMatchObject({
|
|
127
|
-
checkCommand: "claudish --version",
|
|
128
128
|
packageManager: "bun",
|
|
129
129
|
packageName: "claudish",
|
|
130
130
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tool installed another way (`external`), and one held back, on the CLI
|
|
3
|
+
* Tools screen (docs/plans/2026-10-08-magus-update-tool-rows.md, decisions 5
|
|
4
|
+
* and 6).
|
|
5
|
+
*
|
|
6
|
+
* The screen is an inventory, so the row stays — but it is not a warning and
|
|
7
|
+
* not "Up to date": no `?`, a muted icon, "installed elsewhere", a detail
|
|
8
|
+
* status that says the same, and no Enter action. The list icon and the
|
|
9
|
+
* detail Status line are if-chains the compiler does not check, which is why
|
|
10
|
+
* each branch is pinned here. Rendered through OpenTUI's test renderer; no IO.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
15
|
+
import { act } from "react";
|
|
16
|
+
import { cliTools } from "../data/cli-tools.js";
|
|
17
|
+
import type { CliTool } from "../data/cli-tools.js";
|
|
18
|
+
import type { ToolStatus } from "../services/tool-probe.js";
|
|
19
|
+
import type { CliToolRow } from "../ui/adapters/cliToolsAdapter.js";
|
|
20
|
+
import {
|
|
21
|
+
renderCliToolDetail,
|
|
22
|
+
renderCliToolRow,
|
|
23
|
+
} from "../ui/renderers/cliToolRenderers.js";
|
|
24
|
+
|
|
25
|
+
const TOOL = cliTools.find((t) => t.name === "crush") as CliTool;
|
|
26
|
+
|
|
27
|
+
function row(status: Pick<ToolStatus, "state" | "reason">): CliToolRow {
|
|
28
|
+
return {
|
|
29
|
+
tool: TOOL,
|
|
30
|
+
spec: { name: TOOL.name, via: "npm", package: TOOL.packageName },
|
|
31
|
+
checking: false,
|
|
32
|
+
status: {
|
|
33
|
+
name: TOOL.name,
|
|
34
|
+
installed: "0.55.0",
|
|
35
|
+
latest: null,
|
|
36
|
+
target: null,
|
|
37
|
+
...status,
|
|
38
|
+
},
|
|
39
|
+
updateCommand: { cmd: "npm", args: ["install", "-g", "x@latest"] },
|
|
40
|
+
installCommand: { cmd: "npm", args: ["install", "-g", "x"] },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EXTERNAL = row({ state: "external", reason: "installed-elsewhere" });
|
|
45
|
+
const CURRENT = row({ state: "current", reason: "up-to-date" });
|
|
46
|
+
|
|
47
|
+
async function render(node: React.ReactNode, width = 80) {
|
|
48
|
+
const { captureCharFrame, captureSpans, renderOnce, renderer } =
|
|
49
|
+
await testRender(<box flexDirection="column">{node}</box>, {
|
|
50
|
+
width,
|
|
51
|
+
height: 24,
|
|
52
|
+
});
|
|
53
|
+
await act(async () => {
|
|
54
|
+
await renderOnce();
|
|
55
|
+
await renderOnce();
|
|
56
|
+
});
|
|
57
|
+
const out = { chars: captureCharFrame(), lines: captureSpans().lines };
|
|
58
|
+
await act(async () => renderer.destroy());
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The colour of the row's ● icon. */
|
|
63
|
+
async function iconColour(r: CliToolRow): Promise<string> {
|
|
64
|
+
const { lines } = await render(renderCliToolRow(r, 0, false));
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const span = line.spans.find((s) => s.text.includes("●"));
|
|
67
|
+
if (span) return JSON.stringify(span.fg);
|
|
68
|
+
}
|
|
69
|
+
throw new Error("no ● icon on the row");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("an external CLI tool row", () => {
|
|
73
|
+
test("the row says installed elsewhere, with no ?", async () => {
|
|
74
|
+
const { chars } = await render(renderCliToolRow(EXTERNAL, 0, false));
|
|
75
|
+
expect(chars).toContain("● Crush v0.55.0 installed elsewhere");
|
|
76
|
+
expect(chars).not.toContain("?");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("its icon is muted, never the success colour of a current tool", async () => {
|
|
80
|
+
expect(await iconColour(EXTERNAL)).not.toBe(await iconColour(CURRENT));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the detail Status says Installed elsewhere, never Up to date, and offers no Enter", async () => {
|
|
84
|
+
const { chars } = await render(renderCliToolDetail(EXTERNAL, 70), 70);
|
|
85
|
+
expect(chars).toContain("Status ● Installed elsewhere");
|
|
86
|
+
expect(chars).not.toContain("Up to date");
|
|
87
|
+
expect(chars).not.toContain("Enter");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("control: a current tool's detail still says Up to date", async () => {
|
|
91
|
+
const { chars } = await render(renderCliToolDetail(CURRENT, 70), 70);
|
|
92
|
+
expect(chars).toContain("Up to date");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// A held-back tool was checked — brew says it is behind and pinned — so it is
|
|
97
|
+
// "held back" here as in `magus update`: never `?`, never "not checked".
|
|
98
|
+
const HELD = row({ state: "unknown", reason: "held-back" });
|
|
99
|
+
|
|
100
|
+
describe("a held-back CLI tool row", () => {
|
|
101
|
+
test("the row says held back, with no ?", async () => {
|
|
102
|
+
const { chars } = await render(renderCliToolRow(HELD, 0, false));
|
|
103
|
+
expect(chars).toContain("‖ Crush v0.55.0 held back");
|
|
104
|
+
expect(chars).not.toContain("?");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("the detail Status says Held back, never not checked", async () => {
|
|
108
|
+
const { chars } = await render(renderCliToolDetail(HELD, 70), 70);
|
|
109
|
+
expect(chars).toContain("Status ‖ Held back");
|
|
110
|
+
expect(chars).not.toContain("not checked");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -19,6 +19,7 @@ import type { ResolvedBin } from "../types/index.js";
|
|
|
19
19
|
import {
|
|
20
20
|
type PluginManagedRow,
|
|
21
21
|
buildToolRow,
|
|
22
|
+
cliToolEnterAction,
|
|
22
23
|
cliToolStatusText,
|
|
23
24
|
countToolRows,
|
|
24
25
|
isPluginManagedRow,
|
|
@@ -85,22 +86,21 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
|
|
|
85
86
|
const spec = specForCatalogTool(codex, [declared], install);
|
|
86
87
|
expect(spec).toBe(declared);
|
|
87
88
|
|
|
88
|
-
// Through npm, the bun-installed file is not npm's:
|
|
89
|
+
// Through npm, the bun-installed file is not npm's: installed elsewhere.
|
|
89
90
|
const f = facts("codex", {
|
|
90
91
|
managed: false,
|
|
91
92
|
resolvedPath:
|
|
92
93
|
"/Users/j/.bun/install/global/node_modules/@openai/codex/bin/codex.js",
|
|
93
|
-
owner: "npm (root /Users/j/.local/lib/node_modules)",
|
|
94
94
|
});
|
|
95
95
|
const row = buildToolRow(codex, spec, f, install);
|
|
96
96
|
const [cli] = planBinUpdates([declared], [f]);
|
|
97
97
|
expect(row.status).toMatchObject({
|
|
98
98
|
state: cli!.action,
|
|
99
99
|
reason: cli!.reason,
|
|
100
|
-
note: cli!.note,
|
|
101
100
|
target: cli!.target,
|
|
102
101
|
});
|
|
103
|
-
expect(row.status?.
|
|
102
|
+
expect(row.status?.note).toBe(cli!.note);
|
|
103
|
+
expect(row.status?.reason).toBe("installed-elsewhere");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
test("pinned and behind: the row's key installs the PIN, exactly what the CLI runs", () => {
|
|
@@ -197,7 +197,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
|
|
|
197
197
|
expect(row.installCommand).toEqual(cliToolInstall(codex));
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
-
test("detected pnpm → the catalogue's via, and
|
|
200
|
+
test("detected pnpm → the catalogue's via, and an installed-elsewhere row with no path", () => {
|
|
201
201
|
const opencode = catalogue("opencode");
|
|
202
202
|
const install = { primary: "pnpm" as const, all: ["pnpm" as const] };
|
|
203
203
|
const spec = specForCatalogTool(opencode, null, install);
|
|
@@ -207,15 +207,17 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
|
|
|
207
207
|
managed: false,
|
|
208
208
|
resolvedPath:
|
|
209
209
|
"/Users/j/.local/share/pnpm/global/5/node_modules/opencode-ai/bin/opencode",
|
|
210
|
-
owner: "npm (root /Users/j/.local/lib/node_modules)",
|
|
211
210
|
});
|
|
212
211
|
const row = buildToolRow(opencode, spec, f, install);
|
|
213
|
-
expect(row.status
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
expect(row.status?.note).
|
|
218
|
-
// Enter
|
|
212
|
+
expect(row.status).toMatchObject({
|
|
213
|
+
state: "external",
|
|
214
|
+
reason: "installed-elsewhere",
|
|
215
|
+
});
|
|
216
|
+
expect(row.status?.note).toBeUndefined();
|
|
217
|
+
// Enter offers nothing for a tool installed another way.
|
|
218
|
+
expect(cliToolEnterAction(row)).toBeNull();
|
|
219
|
+
// The row still builds its update command for how it IS installed
|
|
220
|
+
// (pnpm), even though Enter offers nothing for it.
|
|
219
221
|
expect(row.updateCommand?.cmd).toBe("pnpm");
|
|
220
222
|
});
|
|
221
223
|
|
|
@@ -252,26 +254,30 @@ describe("cliToolStatusText — the row's words", () => {
|
|
|
252
254
|
primary: "npm",
|
|
253
255
|
all: ["npm"],
|
|
254
256
|
});
|
|
255
|
-
const f = facts("crush", {
|
|
257
|
+
const f = facts("crush", {
|
|
258
|
+
managed: null,
|
|
259
|
+
failure: "npm timed out after 8s",
|
|
260
|
+
});
|
|
256
261
|
const row = buildToolRow(tool, spec, f);
|
|
257
262
|
const text = cliToolStatusText(row);
|
|
258
263
|
expect(text.tone).toBe("warning");
|
|
259
|
-
expect(text.suffix).toBe(" ? owner
|
|
264
|
+
expect(text.suffix).toBe(" ? owner unknown");
|
|
260
265
|
// The note is for the detail pane's "Why", not the row.
|
|
261
266
|
expect(row.status?.note).toBe(classifyTool(spec, f).note);
|
|
262
267
|
expect(text.suffix).not.toContain("/");
|
|
263
268
|
});
|
|
264
269
|
|
|
265
|
-
test("external renders muted
|
|
270
|
+
test("external renders muted as `installed elsewhere`, with no ?", () => {
|
|
266
271
|
const tool = catalogue("crush");
|
|
267
272
|
const spec = specForCatalogTool(tool, null, {
|
|
268
273
|
primary: "npm",
|
|
269
274
|
all: ["npm"],
|
|
270
275
|
});
|
|
271
276
|
const row = buildToolRow(tool, spec, facts("crush", { managed: false }));
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
277
|
+
expect(cliToolStatusText(row)).toEqual({
|
|
278
|
+
suffix: " installed elsewhere",
|
|
279
|
+
tone: "muted",
|
|
280
|
+
});
|
|
275
281
|
});
|
|
276
282
|
|
|
277
283
|
test("upgrade names the target once, with one v", () => {
|
|
@@ -303,13 +309,19 @@ describe("countToolRows — header counts from states", () => {
|
|
|
303
309
|
buildToolRow(tool, spec, facts("mnemex", { behind: false })),
|
|
304
310
|
buildToolRow(tool, spec, facts("mnemex", { behind: true })),
|
|
305
311
|
buildToolRow(tool, spec, facts("mnemex", { managed: null })),
|
|
306
|
-
//
|
|
312
|
+
// Installed elsewhere: installed, but neither an update nor unverified.
|
|
307
313
|
buildToolRow(tool, spec, facts("mnemex", { managed: false })),
|
|
314
|
+
// Held back: checked (behind and pinned), so not unverified either.
|
|
315
|
+
buildToolRow(
|
|
316
|
+
tool,
|
|
317
|
+
spec,
|
|
318
|
+
facts("mnemex", { behind: true, heldBack: "held by brew pin" }),
|
|
319
|
+
),
|
|
308
320
|
buildToolRow(tool, spec, facts("mnemex", { present: false })),
|
|
309
321
|
buildToolRow(tool, spec, undefined),
|
|
310
322
|
];
|
|
311
323
|
expect(countToolRows(rows)).toEqual({
|
|
312
|
-
installed:
|
|
324
|
+
installed: 5,
|
|
313
325
|
updates: 1,
|
|
314
326
|
unverified: 1,
|
|
315
327
|
});
|