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,389 @@
|
|
|
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
|
+
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
|
+
allOnPath?: Record<string, string[]>;
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
noAll?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HOME = "/home/u";
|
|
30
|
+
|
|
31
|
+
function fakeIO(
|
|
32
|
+
setup: FakeSetup,
|
|
33
|
+
): ToolProbeIO & { calls: string[]; reads: string[] } {
|
|
34
|
+
const calls: string[] = [];
|
|
35
|
+
const reads: string[] = [];
|
|
36
|
+
const io: ToolProbeIO & { calls: string[]; reads: string[] } = {
|
|
37
|
+
calls,
|
|
38
|
+
reads,
|
|
39
|
+
exec: async (command) => {
|
|
40
|
+
const key = [command.cmd, ...command.args].join(" ");
|
|
41
|
+
calls.push(key);
|
|
42
|
+
const h = setup.exec?.[key];
|
|
43
|
+
if (h === undefined) {
|
|
44
|
+
return {
|
|
45
|
+
code: null,
|
|
46
|
+
stdout: "",
|
|
47
|
+
stderr: "",
|
|
48
|
+
timedOut: false,
|
|
49
|
+
missing: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
code: 0,
|
|
54
|
+
stdout: "",
|
|
55
|
+
stderr: "",
|
|
56
|
+
timedOut: false,
|
|
57
|
+
missing: false,
|
|
58
|
+
...h,
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
fetchJson: async (url) =>
|
|
62
|
+
setup.fetch?.[url] ?? { ok: false, reason: "returned 404" },
|
|
63
|
+
readJson: async (file) => {
|
|
64
|
+
reads.push(file);
|
|
65
|
+
const t = setup.files?.[file];
|
|
66
|
+
if (t === undefined) return null;
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(t);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
readText: async (file) => setup.files?.[file] ?? null,
|
|
74
|
+
readlink: async () => null,
|
|
75
|
+
realpath: async (file) => setup.real?.[file] ?? null,
|
|
76
|
+
resolveExecutable: async (name) => setup.onPath?.[name] ?? null,
|
|
77
|
+
env: setup.env ?? {},
|
|
78
|
+
homedir: HOME,
|
|
79
|
+
};
|
|
80
|
+
if (!setup.noAll) {
|
|
81
|
+
io.resolveAllExecutables = async (name: string) =>
|
|
82
|
+
setup.allOnPath?.[name] ??
|
|
83
|
+
(setup.onPath?.[name] !== undefined
|
|
84
|
+
? [setup.onPath[name] as string]
|
|
85
|
+
: []);
|
|
86
|
+
}
|
|
87
|
+
return io;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const PREFIX = "/opt/homebrew";
|
|
91
|
+
const BREW_PREFIX_OK: Record<string, Partial<ExecResult>> = {
|
|
92
|
+
"brew --prefix": { stdout: `${PREFIX}\n` },
|
|
93
|
+
};
|
|
94
|
+
const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
|
|
95
|
+
|
|
96
|
+
const UV: BinRequirement = { name: "uv", via: "brew", formula: "uv" };
|
|
97
|
+
const UV_FILE = "/home/u/.local/bin/uv";
|
|
98
|
+
const UV_RECEIPT_PATH = "/home/u/.config/uv/uv-receipt.json";
|
|
99
|
+
const uvReceipt = (prefix = "/home/u/.local/bin", version = "0.11.15") =>
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
binaries: ["uv", "uvx"],
|
|
102
|
+
install_prefix: prefix,
|
|
103
|
+
source: { app_name: "uv", name: "uv", owner: "astral-sh" },
|
|
104
|
+
version,
|
|
105
|
+
});
|
|
106
|
+
const pypi = (v: string): Record<string, FetchJsonResult> => ({
|
|
107
|
+
"https://pypi.org/pypi/uv/json": { ok: true, body: { info: { version: v } } },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function uvWorld(opts: {
|
|
111
|
+
latest?: string;
|
|
112
|
+
receiptPrefix?: string;
|
|
113
|
+
receiptAt?: string;
|
|
114
|
+
env?: Record<string, string>;
|
|
115
|
+
extra?: Partial<FakeSetup>;
|
|
116
|
+
}): FakeSetup {
|
|
117
|
+
return {
|
|
118
|
+
onPath: { uv: UV_FILE },
|
|
119
|
+
real: { [UV_FILE]: UV_FILE },
|
|
120
|
+
files: {
|
|
121
|
+
[opts.receiptAt ?? UV_RECEIPT_PATH]: uvReceipt(opts.receiptPrefix),
|
|
122
|
+
},
|
|
123
|
+
fetch: pypi(opts.latest ?? "0.13.0"),
|
|
124
|
+
env: opts.env ?? {},
|
|
125
|
+
...opts.extra,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe("C19 A: uv's own installer is a driven owner", () => {
|
|
130
|
+
test("1. receipt, PyPI newer: managed, upgrade, uv self update", async () => {
|
|
131
|
+
const f = await probeTool(UV, fakeIO(uvWorld({})));
|
|
132
|
+
expect(f.installed).toBe("0.11.15");
|
|
133
|
+
expect(f.managed).toBe(true);
|
|
134
|
+
expect(f.latest).toBe("0.13.0");
|
|
135
|
+
expect(f.upgradeCommand).toEqual({ cmd: "uv", args: ["self", "update"] });
|
|
136
|
+
const s = classifyTool(UV, f);
|
|
137
|
+
expect(s.state).toBe("upgrade");
|
|
138
|
+
expect(s.target).toBe("0.13.0");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("2. PyPI equals installed: current", async () => {
|
|
142
|
+
const f = await probeTool(UV, fakeIO(uvWorld({ latest: "0.11.15" })));
|
|
143
|
+
expect(classifyTool(UV, f).state).toBe("current");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("3. pinned spec: upgrade to the pin with a versioned command", async () => {
|
|
147
|
+
const spec: BinRequirement = { ...UV, version: "0.12.0" };
|
|
148
|
+
const f = await probeTool(spec, fakeIO(uvWorld({})));
|
|
149
|
+
expect(f.upgradeCommand).toEqual({
|
|
150
|
+
cmd: "uv",
|
|
151
|
+
args: ["self", "update", "0.12.0"],
|
|
152
|
+
});
|
|
153
|
+
const s = classifyTool(spec, f);
|
|
154
|
+
expect(s.state).toBe("upgrade");
|
|
155
|
+
expect(s.target).toBe("0.12.0");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("4. receipt for a different install_prefix is ignored", async () => {
|
|
159
|
+
const f = await probeTool(
|
|
160
|
+
UV,
|
|
161
|
+
fakeIO(uvWorld({ receiptPrefix: "/somewhere/else" })),
|
|
162
|
+
);
|
|
163
|
+
expect(f.managed).not.toBe(true);
|
|
164
|
+
expect(f.upgradeCommand).toBeUndefined();
|
|
165
|
+
expect(classifyTool(UV, f).state).not.toBe("upgrade");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("5. XDG_CONFIG_HOME relocates the receipt", async () => {
|
|
169
|
+
const io = fakeIO(
|
|
170
|
+
uvWorld({
|
|
171
|
+
env: { XDG_CONFIG_HOME: "/xdg" },
|
|
172
|
+
extra: {
|
|
173
|
+
// a decoy at the default place must not be the one used
|
|
174
|
+
files: {
|
|
175
|
+
"/xdg/uv/uv-receipt.json": uvReceipt(),
|
|
176
|
+
[UV_RECEIPT_PATH]: uvReceipt("/home/u/.local/bin", "9.9.9"),
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
const f = await probeTool(UV, io);
|
|
182
|
+
expect(f.installed).toBe("0.11.15");
|
|
183
|
+
expect(f.managed).toBe(true);
|
|
184
|
+
expect(io.reads).toContain("/xdg/uv/uv-receipt.json");
|
|
185
|
+
expect(io.reads).not.toContain(UV_RECEIPT_PATH);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("6. receipt of an installer magus cannot update: external, versioned, named", async () => {
|
|
189
|
+
const spec: BinRequirement = { name: "ruff", via: "brew", formula: "ruff" };
|
|
190
|
+
const file = "/home/u/.local/bin/ruff";
|
|
191
|
+
const f = await probeTool(
|
|
192
|
+
spec,
|
|
193
|
+
fakeIO({
|
|
194
|
+
onPath: { ruff: file },
|
|
195
|
+
real: { [file]: file },
|
|
196
|
+
files: {
|
|
197
|
+
"/home/u/.config/ruff/ruff-receipt.json": JSON.stringify({
|
|
198
|
+
binaries: ["ruff"],
|
|
199
|
+
install_prefix: "/home/u/.local/bin",
|
|
200
|
+
source: { app_name: "ruff", name: "ruff", owner: "astral-sh" },
|
|
201
|
+
version: "0.9.1",
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
exec: BREW_PREFIX_OK,
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
expect(f.installed).toBe("0.9.1");
|
|
208
|
+
expect(f.upgradeCommand).toBeUndefined();
|
|
209
|
+
const s = classifyTool(spec, f);
|
|
210
|
+
expect(s.state).toBe("external");
|
|
211
|
+
expect(s.note).toBe("installed by ruff's own installer");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("C19 B: KEPT notes name who installed it", () => {
|
|
216
|
+
const spec = (
|
|
217
|
+
name: string,
|
|
218
|
+
extra: Partial<BinRequirement> = {},
|
|
219
|
+
): BinRequirement => ({
|
|
220
|
+
name,
|
|
221
|
+
via: "brew",
|
|
222
|
+
formula: name,
|
|
223
|
+
...extra,
|
|
224
|
+
});
|
|
225
|
+
const world = (
|
|
226
|
+
name: string,
|
|
227
|
+
file: string,
|
|
228
|
+
env: Record<string, string> = {},
|
|
229
|
+
): FakeSetup => ({
|
|
230
|
+
onPath: { [name]: file },
|
|
231
|
+
real: { [file]: file, ...CELLAR },
|
|
232
|
+
exec: BREW_PREFIX_OK,
|
|
233
|
+
env,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("7. Homebrew cask", async () => {
|
|
237
|
+
const sp = spec("madbench");
|
|
238
|
+
const file = "/opt/homebrew/Caskroom/madbench/0.43.2/madbench";
|
|
239
|
+
const f = await probeTool(sp, fakeIO(world("madbench", file)));
|
|
240
|
+
const s = classifyTool(sp, f);
|
|
241
|
+
expect(s.state).toBe("external");
|
|
242
|
+
expect(s.note).toBe("installed by a Homebrew cask");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("8a. cargo bin under the home directory", async () => {
|
|
246
|
+
const sp = spec("rg");
|
|
247
|
+
const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
|
|
248
|
+
const s = classifyTool(sp, f);
|
|
249
|
+
expect(s.state).toBe("external");
|
|
250
|
+
expect(s.note).toBe("installed by cargo");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("8b. cargo bin under CARGO_HOME", async () => {
|
|
254
|
+
const sp = spec("rg");
|
|
255
|
+
const f = await probeTool(
|
|
256
|
+
sp,
|
|
257
|
+
fakeIO(world("rg", "/opt/cargo/bin/rg", { CARGO_HOME: "/opt/cargo" })),
|
|
258
|
+
);
|
|
259
|
+
const s = classifyTool(sp, f);
|
|
260
|
+
expect(s.state).toBe("external");
|
|
261
|
+
expect(s.note).toBe("installed by cargo");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("9. unrecognised place, never says 'outside'", async () => {
|
|
265
|
+
const sp = spec("foo");
|
|
266
|
+
const f = await probeTool(
|
|
267
|
+
sp,
|
|
268
|
+
fakeIO(world("foo", "/home/u/tools/bin/foo")),
|
|
269
|
+
);
|
|
270
|
+
const s = classifyTool(sp, f);
|
|
271
|
+
expect(s.state).toBe("external");
|
|
272
|
+
expect(s.note).toBe("installed by an installer magus does not update");
|
|
273
|
+
expect(s.note ?? "").not.toContain("outside");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("10. pinned spec with a cargo file", async () => {
|
|
277
|
+
const sp = spec("rg", { version: "1.0.0" });
|
|
278
|
+
const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
|
|
279
|
+
const s = classifyTool(sp, f);
|
|
280
|
+
expect(s.state).toBe("unknown");
|
|
281
|
+
expect(s.note).toBe(
|
|
282
|
+
"installed by cargo, so magus cannot move it to the pin 1.0.0",
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("C19 C: operating-system programs show their version", () => {
|
|
288
|
+
const zsh: BinRequirement = { name: "zsh", via: "brew", formula: "zsh" };
|
|
289
|
+
const world = (zshExec?: Partial<ExecResult>): FakeSetup => ({
|
|
290
|
+
onPath: { zsh: "/bin/zsh" },
|
|
291
|
+
real: { "/bin/zsh": "/bin/zsh", ...CELLAR },
|
|
292
|
+
exec: {
|
|
293
|
+
...BREW_PREFIX_OK,
|
|
294
|
+
...(zshExec ? { "/bin/zsh --version": zshExec } : {}),
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("11. version read from --version", async () => {
|
|
299
|
+
const f = await probeTool(
|
|
300
|
+
zsh,
|
|
301
|
+
fakeIO(world({ stdout: "zsh 5.9 (arm64-apple-darwin25.0)\n" })),
|
|
302
|
+
);
|
|
303
|
+
expect(f.installed).toBe("5.9");
|
|
304
|
+
const s = classifyTool(zsh, f);
|
|
305
|
+
expect(s.state).toBe("external");
|
|
306
|
+
expect(s.note).toBe("comes with the operating system");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("12a. --version exits non-zero: still external, installed null", async () => {
|
|
310
|
+
const f = await probeTool(
|
|
311
|
+
zsh,
|
|
312
|
+
fakeIO(world({ code: 1, stdout: "", stderr: "" })),
|
|
313
|
+
);
|
|
314
|
+
expect(f.installed).toBeNull();
|
|
315
|
+
const s = classifyTool(zsh, f);
|
|
316
|
+
expect(s.state).toBe("external");
|
|
317
|
+
expect(s.note).toBe("comes with the operating system");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("12b. --version missing: still external, installed null", async () => {
|
|
321
|
+
const f = await probeTool(zsh, fakeIO(world()));
|
|
322
|
+
expect(f.installed).toBeNull();
|
|
323
|
+
const s = classifyTool(zsh, f);
|
|
324
|
+
expect(s.state).toBe("external");
|
|
325
|
+
expect(s.note).toBe("comes with the operating system");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("C19 D: a newer copy later on PATH is reported", () => {
|
|
330
|
+
const BREW_BIN = "/opt/homebrew/bin/uv";
|
|
331
|
+
const cellarFile = (v: string) => `/opt/homebrew/Cellar/uv/${v}/bin/uv`;
|
|
332
|
+
const world = (second: string, secondReal: string): FakeSetup =>
|
|
333
|
+
uvWorld({
|
|
334
|
+
extra: {
|
|
335
|
+
allOnPath: { uv: [UV_FILE, second] },
|
|
336
|
+
real: { [UV_FILE]: UV_FILE, [second]: secondReal, ...CELLAR },
|
|
337
|
+
exec: BREW_PREFIX_OK,
|
|
338
|
+
env: { HOMEBREW_PREFIX: PREFIX },
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
const NOTE =
|
|
342
|
+
"Homebrew also has 0.13.0, unused: ~/.local/bin comes first on PATH";
|
|
343
|
+
|
|
344
|
+
test("13. newer Homebrew copy: pathDir, otherCopies, shadowed", async () => {
|
|
345
|
+
const f = await probeTool(
|
|
346
|
+
UV,
|
|
347
|
+
fakeIO(world(BREW_BIN, cellarFile("0.13.0"))),
|
|
348
|
+
);
|
|
349
|
+
expect(f.pathDir).toBe("~/.local/bin");
|
|
350
|
+
expect(f.otherCopies).toEqual([
|
|
351
|
+
{ owner: "Homebrew", version: "0.13.0", dir: "/opt/homebrew/bin" },
|
|
352
|
+
]);
|
|
353
|
+
expect(classifyTool(UV, f).shadowed).toBe(NOTE);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("14. older later copy: listed, not shadowed", async () => {
|
|
357
|
+
const f = await probeTool(
|
|
358
|
+
UV,
|
|
359
|
+
fakeIO(world(BREW_BIN, cellarFile("0.10.0"))),
|
|
360
|
+
);
|
|
361
|
+
expect(f.otherCopies).toEqual([
|
|
362
|
+
{ owner: "Homebrew", version: "0.10.0", dir: "/opt/homebrew/bin" },
|
|
363
|
+
]);
|
|
364
|
+
expect(classifyTool(UV, f).shadowed).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("15. a symlink to the running file is not another copy", async () => {
|
|
368
|
+
const f = await probeTool(UV, fakeIO(world("/usr/local/bin/uv", UV_FILE)));
|
|
369
|
+
expect(f.otherCopies ?? []).toEqual([]);
|
|
370
|
+
expect(classifyTool(UV, f).shadowed).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("16. IO without resolveAllExecutables: nothing reported, nothing throws", async () => {
|
|
374
|
+
const w = world(BREW_BIN, cellarFile("0.13.0"));
|
|
375
|
+
w.noAll = true;
|
|
376
|
+
const f = await probeTool(UV, fakeIO(w));
|
|
377
|
+
expect(f.otherCopies ?? []).toEqual([]);
|
|
378
|
+
expect(classifyTool(UV, f).shadowed).toBeUndefined();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("17. shadowed is set on a current row too", async () => {
|
|
382
|
+
const w = world(BREW_BIN, cellarFile("0.13.0"));
|
|
383
|
+
w.fetch = pypi("0.11.15");
|
|
384
|
+
const f = await probeTool(UV, fakeIO(w));
|
|
385
|
+
const s = classifyTool(UV, f);
|
|
386
|
+
expect(s.state).toBe("current");
|
|
387
|
+
expect(s.shadowed).toBe(NOTE);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
@@ -214,7 +214,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
|
|
|
214
214
|
expect(row.status?.reason).toBe("not-managed");
|
|
215
215
|
// No path and no installer directory: the user acts on neither.
|
|
216
216
|
expect(row.status?.note).toBe(
|
|
217
|
-
"installed
|
|
217
|
+
"installed by an installer magus does not update",
|
|
218
218
|
);
|
|
219
219
|
expect(row.status?.note).not.toContain("/");
|
|
220
220
|
// Enter still follows how it IS installed (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
|
|
241
|
+
expect(s.note).toBe("installed by an installer magus does not update");
|
|
242
242
|
expect(s.note).not.toContain("/");
|
|
243
243
|
});
|
|
244
244
|
|
|
@@ -248,13 +248,13 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
248
248
|
).toMatchObject({ state: "unknown", reason: "not-managed" });
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
test("6.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
).
|
|
251
|
+
test("6. the note names who DID install it, never the spec's installer", () => {
|
|
252
|
+
const note = classifyTool(
|
|
253
|
+
spec({ via: "brew" }),
|
|
254
|
+
facts({ managed: false, behind: true, ownerName: "cargo" }),
|
|
255
|
+
).note;
|
|
256
|
+
expect(note).toBe("installed by cargo");
|
|
257
|
+
expect(note).not.toContain("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
|
@@ -435,6 +435,16 @@ function pluginDetail(item: PluginUpdateItem): string {
|
|
|
435
435
|
* pin is shown only when there is one.
|
|
436
436
|
*/
|
|
437
437
|
function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
438
|
+
const detail = binStateDetail(item, plan);
|
|
439
|
+
// A newer copy later on PATH is the one thing here only the user can settle.
|
|
440
|
+
return item.shadowed
|
|
441
|
+
? [detail, fg(brand.warning, `⚠ ${item.shadowed}`)]
|
|
442
|
+
.filter(Boolean)
|
|
443
|
+
.join(" ")
|
|
444
|
+
: detail;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function binStateDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
438
448
|
const spec = plan.binSpecs.get(item.name);
|
|
439
449
|
// Rendered for the reader's eyes only — this row says what WILL run, while
|
|
440
450
|
// applyBins runs the argv itself. Same builder, so they cannot differ.
|
|
@@ -458,6 +468,10 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
458
468
|
item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
|
|
459
469
|
}`;
|
|
460
470
|
case "external":
|
|
471
|
+
// Kept as is: a missing version is not a finding, so no "—" stands in.
|
|
472
|
+
return [item.installed, item.note ? dim(`(${item.note})`) : ""]
|
|
473
|
+
.filter(Boolean)
|
|
474
|
+
.join(" ");
|
|
461
475
|
case "unknown":
|
|
462
476
|
return `${item.installed ?? "—"}${
|
|
463
477
|
item.note ? ` ${dim(`(${item.note})`)}` : ""
|