magus-cli 7.10.2 → 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 +3 -3
- package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +389 -0
- package/src/__tests__/cli-tools-adapter.test.ts +3 -1
- package/src/__tests__/tool-probe.test.ts +8 -8
- package/src/cli/update-view.ts +10 -0
- package/src/services/tool-probe.ts +301 -14
- package/src/services/update-plan.ts +3 -0
- package/src/ui/renderers/cliToolRenderers.tsx +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.10.
|
|
3
|
+
"version": "7.10.3",
|
|
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.3",
|
|
69
|
+
"magus-cli-darwin-x64": "7.10.3",
|
|
70
|
+
"magus-cli-linux-x64": "7.10.3"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -250,10 +250,10 @@ describe("C16 case 6: file in no node tree, brew spec", () => {
|
|
|
250
250
|
const s = classifyTool(spec, f);
|
|
251
251
|
expect(s.state).toBe("external");
|
|
252
252
|
expect(s.reason).toBe("not-managed");
|
|
253
|
-
expect(s.note).toBe("installed
|
|
253
|
+
expect(s.note).toBe("installed by an installer magus does not update");
|
|
254
254
|
expect(s.note?.includes("/")).toBe(false);
|
|
255
255
|
});
|
|
256
|
-
test("an npm spec
|
|
256
|
+
test("an npm spec gets the same note: it names who installed it, never the spec installer", async () => {
|
|
257
257
|
const npmSpec: BinRequirement = { name: "uv", via: "npm", package: "uv" };
|
|
258
258
|
const f = await probeTool(
|
|
259
259
|
npmSpec,
|
|
@@ -264,7 +264,7 @@ describe("C16 case 6: file in no node tree, brew spec", () => {
|
|
|
264
264
|
);
|
|
265
265
|
const s = classifyTool(npmSpec, f);
|
|
266
266
|
expect(s.state).toBe("external");
|
|
267
|
-
expect(s.note).toBe("installed
|
|
267
|
+
expect(s.note).toBe("installed by an installer magus does not update");
|
|
268
268
|
});
|
|
269
269
|
test("a pinned spec in that situation stays unknown", async () => {
|
|
270
270
|
const pinned: BinRequirement = { ...spec, version: "1.0.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
|
+
});
|
|
@@ -213,7 +213,9 @@ 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(
|
|
216
|
+
expect(row.status?.note).toBe(
|
|
217
|
+
"installed by an installer magus does not update",
|
|
218
|
+
);
|
|
217
219
|
expect(row.status?.note).not.toContain("/");
|
|
218
220
|
// Enter still follows how it IS installed (pnpm).
|
|
219
221
|
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
|
|
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.
|
|
@@ -106,7 +106,20 @@ export type ToolFactSource =
|
|
|
106
106
|
| "package-json"
|
|
107
107
|
/** the file name of a versioned install (`…/versions/2.1.276`). */
|
|
108
108
|
| "version-dir"
|
|
109
|
-
| "check-command"
|
|
109
|
+
| "check-command"
|
|
110
|
+
/** an installer's own receipt (`~/.config/uv/uv-receipt.json`). */
|
|
111
|
+
| "installer-receipt"
|
|
112
|
+
/** `--version` of a program the operating system ships. */
|
|
113
|
+
| "os-version";
|
|
114
|
+
|
|
115
|
+
/** Another copy of the tool on PATH, behind the one that runs. */
|
|
116
|
+
export interface OtherCopy {
|
|
117
|
+
/** The installer as the user knows it: "Homebrew", "uv's own installer". */
|
|
118
|
+
owner: string;
|
|
119
|
+
version: string | null;
|
|
120
|
+
/** The PATH directory it was found in, with the home folder as `~`. */
|
|
121
|
+
dir: string;
|
|
122
|
+
}
|
|
110
123
|
|
|
111
124
|
export interface ToolFacts {
|
|
112
125
|
name: string;
|
|
@@ -135,6 +148,16 @@ export interface ToolFacts {
|
|
|
135
148
|
* "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
|
|
136
149
|
*/
|
|
137
150
|
owner?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Who installed the file PATH runs, as the user knows it ("uv's own
|
|
153
|
+
* installer", "a Homebrew cask"), when that is not the spec's installer and
|
|
154
|
+
* magus could name it. Read by the not-managed note.
|
|
155
|
+
*/
|
|
156
|
+
ownerName?: string;
|
|
157
|
+
/** The PATH directory the running copy was found in, home as `~`. */
|
|
158
|
+
pathDir?: string;
|
|
159
|
+
/** Other copies of the tool later on PATH, which nothing runs. */
|
|
160
|
+
otherCopies?: OtherCopy[];
|
|
138
161
|
/** e.g. "held by brew pin". */
|
|
139
162
|
heldBack?: string;
|
|
140
163
|
/**
|
|
@@ -178,6 +201,12 @@ export interface ToolStatus {
|
|
|
178
201
|
note?: string;
|
|
179
202
|
/** {@link ToolFacts.adopted}, carried when this state plans work. */
|
|
180
203
|
adopted?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Set in every state when a NEWER copy sits later on PATH, unused: e.g.
|
|
206
|
+
* "Homebrew also has 0.13.0, unused: ~/.local/bin comes first on PATH".
|
|
207
|
+
* A warning, because only the user can decide which copy to keep.
|
|
208
|
+
*/
|
|
209
|
+
shadowed?: string;
|
|
181
210
|
installed: string | null;
|
|
182
211
|
latest: string | null;
|
|
183
212
|
target: string | null;
|
|
@@ -248,20 +277,40 @@ function isSystemPath(resolved: string | null): boolean {
|
|
|
248
277
|
* the user acts on neither.
|
|
249
278
|
*/
|
|
250
279
|
function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
280
|
+
if (facts.managed !== false) {
|
|
281
|
+
return withFailure(
|
|
282
|
+
`could not tell whether ${installerWord(spec.via)} installed it`,
|
|
283
|
+
facts.failure,
|
|
284
|
+
);
|
|
254
285
|
}
|
|
286
|
+
// Who DID install it. "Outside Homebrew" named the installer magus would
|
|
287
|
+
// have used, which says nothing about the copy the user has.
|
|
288
|
+
const who = isSystemPath(facts.resolvedPath)
|
|
289
|
+
? "comes with the operating system"
|
|
290
|
+
: facts.ownerName
|
|
291
|
+
? `installed by ${facts.ownerName}`
|
|
292
|
+
: "installed by an installer magus does not update";
|
|
293
|
+
const pin = pinOf(spec);
|
|
255
294
|
// The KEPT chip already says magus leaves it as is; the note says only who
|
|
256
|
-
// installed it.
|
|
257
|
-
return
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
295
|
+
// installed it, and what that means for a pin.
|
|
296
|
+
return pin ? `${who}, so magus cannot move it to the pin ${pin}` : who;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The warning for a newer copy later on PATH, or undefined. Only a NEWER one
|
|
301
|
+
* (or any, when the running version is unknown) is worth a line: an older
|
|
302
|
+
* copy changes nothing the user runs.
|
|
303
|
+
*/
|
|
304
|
+
function shadowNote(facts: ToolFacts): string | undefined {
|
|
305
|
+
const running = facts.installed;
|
|
306
|
+
const newer = (facts.otherCopies ?? []).find(
|
|
307
|
+
(copy) =>
|
|
308
|
+
copy.version !== null &&
|
|
309
|
+
(running === null || compareToolVersions(copy.version, running) === 1),
|
|
310
|
+
);
|
|
311
|
+
if (!newer?.version) return undefined;
|
|
312
|
+
const first = facts.pathDir ? `${facts.pathDir} comes first on PATH` : "";
|
|
313
|
+
return `${newer.owner} also has ${newer.version}, unused${first ? `: ${first}` : ""}`;
|
|
265
314
|
}
|
|
266
315
|
|
|
267
316
|
/**
|
|
@@ -292,6 +341,12 @@ function externalOr(pin: string | undefined): ToolState {
|
|
|
292
341
|
* none.
|
|
293
342
|
*/
|
|
294
343
|
export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
344
|
+
const status = classifyState(spec, facts);
|
|
345
|
+
const shadowed = facts.present ? shadowNote(facts) : undefined;
|
|
346
|
+
return shadowed ? { ...status, shadowed } : status;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
295
350
|
const pin = pinOf(spec);
|
|
296
351
|
const base = {
|
|
297
352
|
name: spec.name,
|
|
@@ -392,7 +447,10 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
392
447
|
// From here on a row can plan work, so an adoption must travel with it.
|
|
393
448
|
const adopted = facts.adopted ? { adopted: facts.adopted } : {};
|
|
394
449
|
if (pin) {
|
|
395
|
-
|
|
450
|
+
// Homebrew cannot install a chosen version, but the spec's installer is
|
|
451
|
+
// not always the owner: an owner's own command (`uv self update <pin>`)
|
|
452
|
+
// is already aimed at the pin.
|
|
453
|
+
if (spec.via === "brew" && !facts.upgradeCommand) {
|
|
396
454
|
return {
|
|
397
455
|
...base,
|
|
398
456
|
state: "unknown",
|
|
@@ -758,6 +816,11 @@ export interface ToolProbeIO {
|
|
|
758
816
|
* null. A dangling symlink is not executable and does not count.
|
|
759
817
|
*/
|
|
760
818
|
resolveExecutable(name: string): Promise<string | null>;
|
|
819
|
+
/**
|
|
820
|
+
* Every executable `name` on PATH, in PATH order, as found. Optional: an IO
|
|
821
|
+
* without it reports no other copies.
|
|
822
|
+
*/
|
|
823
|
+
resolveAllExecutables?(name: string): Promise<string[]>;
|
|
761
824
|
env: Readonly<Record<string, string | undefined>>;
|
|
762
825
|
homedir: string;
|
|
763
826
|
}
|
|
@@ -876,6 +939,16 @@ export const REAL_TOOL_PROBE_IO: ToolProbeIO = {
|
|
|
876
939
|
}
|
|
877
940
|
return null;
|
|
878
941
|
},
|
|
942
|
+
resolveAllExecutables: async (name) => {
|
|
943
|
+
const found: string[] = [];
|
|
944
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
945
|
+
if (!dir) continue;
|
|
946
|
+
const candidate = path.join(dir, name);
|
|
947
|
+
if (!found.includes(candidate) && (await isExecutable(candidate)))
|
|
948
|
+
found.push(candidate);
|
|
949
|
+
}
|
|
950
|
+
return found;
|
|
951
|
+
},
|
|
879
952
|
env: process.env,
|
|
880
953
|
homedir: os.homedir(),
|
|
881
954
|
};
|
|
@@ -1527,6 +1600,207 @@ async function adoptActualOwner(
|
|
|
1527
1600
|
: binUpgradeCommand(actual));
|
|
1528
1601
|
}
|
|
1529
1602
|
|
|
1603
|
+
/**
|
|
1604
|
+
* Standalone installers that update their own copy in place, keyed by the
|
|
1605
|
+
* app their receipt names: the arguments after the program, and the PyPI
|
|
1606
|
+
* project that publishes it. uv's own installer writes a receipt and ships
|
|
1607
|
+
* `uv self update`, so a uv from it is as upgradeable as one from Homebrew.
|
|
1608
|
+
*/
|
|
1609
|
+
const SELF_UPDATERS: Readonly<
|
|
1610
|
+
Record<string, { args: readonly string[]; pypi: string }>
|
|
1611
|
+
> = {
|
|
1612
|
+
uv: { args: ["self", "update"], pypi: "uv" },
|
|
1613
|
+
};
|
|
1614
|
+
|
|
1615
|
+
interface InstallerReceipt {
|
|
1616
|
+
app: string;
|
|
1617
|
+
version: string;
|
|
1618
|
+
file: string;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* The receipt a standalone installer (cargo-dist's format, which uv's uses)
|
|
1623
|
+
* left for `name`, when it covers `resolvedPath`:
|
|
1624
|
+
* `$XDG_CONFIG_HOME/<name>/<name>-receipt.json`, whose `install_prefix` holds
|
|
1625
|
+
* the file and whose `binaries` list it. A receipt for another folder is a
|
|
1626
|
+
* different copy and says nothing about this one.
|
|
1627
|
+
*/
|
|
1628
|
+
async function installerReceipt(
|
|
1629
|
+
s: ProbeSession,
|
|
1630
|
+
name: string,
|
|
1631
|
+
resolvedPath: string,
|
|
1632
|
+
): Promise<InstallerReceipt | null> {
|
|
1633
|
+
const config = s.io.env.XDG_CONFIG_HOME || path.join(s.io.homedir, ".config");
|
|
1634
|
+
const file = path.join(config, name, `${name}-receipt.json`);
|
|
1635
|
+
const body = await s.io.readJson(file);
|
|
1636
|
+
if (!body || typeof body !== "object") return null;
|
|
1637
|
+
const r = body as {
|
|
1638
|
+
version?: unknown;
|
|
1639
|
+
install_prefix?: unknown;
|
|
1640
|
+
binaries?: unknown;
|
|
1641
|
+
source?: { app_name?: unknown } | null;
|
|
1642
|
+
};
|
|
1643
|
+
if (typeof r.version !== "string" || typeof r.install_prefix !== "string")
|
|
1644
|
+
return null;
|
|
1645
|
+
if (
|
|
1646
|
+
!Array.isArray(r.binaries) ||
|
|
1647
|
+
!r.binaries.includes(path.basename(resolvedPath))
|
|
1648
|
+
)
|
|
1649
|
+
return null;
|
|
1650
|
+
const prefix = (await s.io.realpath(r.install_prefix)) ?? r.install_prefix;
|
|
1651
|
+
if (!isInside(resolvedPath, prefix)) return null;
|
|
1652
|
+
const app = typeof r.source?.app_name === "string" ? r.source.app_name : name;
|
|
1653
|
+
return { app, version: r.version, file };
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* The file is a standalone installer's: its receipt gives the version, and
|
|
1658
|
+
* when that installer updates itself ({@link SELF_UPDATERS}) it is the owner
|
|
1659
|
+
* `magus update` drives. Otherwise the tool stays KEPT, but its row can say
|
|
1660
|
+
* who installed it and which version runs.
|
|
1661
|
+
*/
|
|
1662
|
+
async function adoptOwnInstaller(
|
|
1663
|
+
s: ProbeSession,
|
|
1664
|
+
spec: ManagedTool,
|
|
1665
|
+
f: ToolFacts,
|
|
1666
|
+
resolvedPath: string,
|
|
1667
|
+
): Promise<void> {
|
|
1668
|
+
const receipt = await installerReceipt(s, spec.name, resolvedPath);
|
|
1669
|
+
if (!receipt) return;
|
|
1670
|
+
const ownerName = `${receipt.app}'s own installer`;
|
|
1671
|
+
f.installed = receipt.version;
|
|
1672
|
+
addSource(f, "installer-receipt");
|
|
1673
|
+
const updater = SELF_UPDATERS[receipt.app];
|
|
1674
|
+
if (!updater) {
|
|
1675
|
+
f.managed = false;
|
|
1676
|
+
f.ownerName = ownerName;
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
f.managed = true;
|
|
1680
|
+
f.owner = `${ownerName} (receipt ${receipt.file})`;
|
|
1681
|
+
f.latest = null;
|
|
1682
|
+
f.behind = null;
|
|
1683
|
+
// What the spec's installer could not answer is moot: the owner is known.
|
|
1684
|
+
f.failure = undefined;
|
|
1685
|
+
const pin = pinOf(spec);
|
|
1686
|
+
f.upgradeCommand = {
|
|
1687
|
+
cmd: spec.name,
|
|
1688
|
+
args: [...updater.args, ...(pin ? [pin] : [])],
|
|
1689
|
+
};
|
|
1690
|
+
if (!pin) await pypiLatest(s, updater.pypi, f);
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* Who installed a file no installer magus drives owns, as the user knows it,
|
|
1695
|
+
* or undefined when the path does not say.
|
|
1696
|
+
*/
|
|
1697
|
+
async function nameOwner(
|
|
1698
|
+
s: ProbeSession,
|
|
1699
|
+
resolvedPath: string,
|
|
1700
|
+
): Promise<string | undefined> {
|
|
1701
|
+
if (resolvedPath.split(path.sep).includes("Caskroom"))
|
|
1702
|
+
return "a Homebrew cask";
|
|
1703
|
+
if (kegFormulaOf(resolvedPath)) return "Homebrew";
|
|
1704
|
+
const node = await nodeGlobalOwner(s, resolvedPath);
|
|
1705
|
+
if (node) return node.via;
|
|
1706
|
+
const cargoHome = s.io.env.CARGO_HOME || path.join(s.io.homedir, ".cargo");
|
|
1707
|
+
if (isInside(resolvedPath, path.join(cargoHome, "bin"))) return "cargo";
|
|
1708
|
+
return undefined;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* Where an operating-system program is safe to run for `--version`. Not
|
|
1713
|
+
* macOS's /usr/bin: several programs there are Xcode stubs that open an
|
|
1714
|
+
* install dialog when run on a machine without the developer tools.
|
|
1715
|
+
*/
|
|
1716
|
+
function osVersionDirs(): string[] {
|
|
1717
|
+
return process.platform === "darwin" ? ["/bin", "/sbin"] : SYSTEM_DIRS;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* The version of a program the operating system ships, from its own
|
|
1722
|
+
* `--version`. No installer keeps a record of these, and the file is the OS
|
|
1723
|
+
* vendor's, not a string a catalogue wrote, so running it is safe.
|
|
1724
|
+
*/
|
|
1725
|
+
async function versionFromOs(
|
|
1726
|
+
s: ProbeSession,
|
|
1727
|
+
f: ToolFacts,
|
|
1728
|
+
resolvedPath: string,
|
|
1729
|
+
): Promise<void> {
|
|
1730
|
+
if (!osVersionDirs().some((dir) => isInside(resolvedPath, dir))) return;
|
|
1731
|
+
const r = await s.run(resolvedPath, ["--version"], PLAIN_ENV);
|
|
1732
|
+
if (r.missing || r.timedOut) return;
|
|
1733
|
+
const v = parseVersionToken(`${r.stdout}\n${r.stderr}`);
|
|
1734
|
+
if (v) {
|
|
1735
|
+
f.installed = v;
|
|
1736
|
+
addSource(f, "os-version");
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/** `dir` with the home folder written as `~`, for a line the user reads. */
|
|
1741
|
+
function homeShort(io: ToolProbeIO, dir: string): string {
|
|
1742
|
+
return isInside(dir, io.homedir)
|
|
1743
|
+
? path.join("~", path.relative(io.homedir, dir))
|
|
1744
|
+
: dir;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/** Owner and version of a copy PATH does not run, read from its path. */
|
|
1748
|
+
async function describeCopy(
|
|
1749
|
+
s: ProbeSession,
|
|
1750
|
+
name: string,
|
|
1751
|
+
real: string,
|
|
1752
|
+
): Promise<{ owner: string; version: string | null }> {
|
|
1753
|
+
const parts = real.split(path.sep);
|
|
1754
|
+
for (const [marker, owner] of [
|
|
1755
|
+
["Cellar", "Homebrew"],
|
|
1756
|
+
["Caskroom", "a Homebrew cask"],
|
|
1757
|
+
] as const) {
|
|
1758
|
+
const at = parts.lastIndexOf(marker);
|
|
1759
|
+
if (at >= 0 && parts.length > at + 2)
|
|
1760
|
+
return { owner, version: parts[at + 2] ?? null };
|
|
1761
|
+
}
|
|
1762
|
+
const receipt = await installerReceipt(s, name, real);
|
|
1763
|
+
if (receipt)
|
|
1764
|
+
return {
|
|
1765
|
+
owner: `${receipt.app}'s own installer`,
|
|
1766
|
+
version: receipt.version,
|
|
1767
|
+
};
|
|
1768
|
+
const node = await nodeGlobalOwner(s, real);
|
|
1769
|
+
if (node) return { owner: node.via, version: readPackageVersionForBin(real) };
|
|
1770
|
+
if (isSystemPath(real))
|
|
1771
|
+
return { owner: "the operating system", version: null };
|
|
1772
|
+
return { owner: "another installer", version: null };
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* The other copies of the tool on PATH. The first one wins, and whatever
|
|
1777
|
+
* comes later is never run: a newer copy there is a surprise only the user
|
|
1778
|
+
* can settle, by removing one or reordering PATH.
|
|
1779
|
+
*/
|
|
1780
|
+
async function findOtherCopies(
|
|
1781
|
+
s: ProbeSession,
|
|
1782
|
+
name: string,
|
|
1783
|
+
f: ToolFacts,
|
|
1784
|
+
onPath: string,
|
|
1785
|
+
resolvedPath: string,
|
|
1786
|
+
): Promise<void> {
|
|
1787
|
+
f.pathDir = homeShort(s.io, path.dirname(onPath));
|
|
1788
|
+
if (!s.io.resolveAllExecutables) return;
|
|
1789
|
+
const seen = new Set([resolvedPath]);
|
|
1790
|
+
const copies: OtherCopy[] = [];
|
|
1791
|
+
for (const candidate of await s.io.resolveAllExecutables(name)) {
|
|
1792
|
+
if (candidate === onPath) continue;
|
|
1793
|
+
const real = await s.io.realpath(candidate);
|
|
1794
|
+
if (!real || seen.has(real)) continue;
|
|
1795
|
+
seen.add(real);
|
|
1796
|
+
copies.push({
|
|
1797
|
+
...(await describeCopy(s, name, real)),
|
|
1798
|
+
dir: homeShort(s.io, path.dirname(candidate)),
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
if (copies.length > 0) f.otherCopies = copies;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1530
1804
|
async function probeNodeGlobal(
|
|
1531
1805
|
s: ProbeSession,
|
|
1532
1806
|
spec: ManagedTool,
|
|
@@ -1794,14 +2068,27 @@ async function probeInto(
|
|
|
1794
2068
|
// to ask), but the path names bun's, npm's or Homebrew's tree: then that is
|
|
1795
2069
|
// the owner, and it can upgrade the file PATH runs.
|
|
1796
2070
|
if (f.managed !== true) await adoptActualOwner(s, spec, f, resolvedPath);
|
|
2071
|
+
// Or a standalone installer's, which may update itself (`uv self update`).
|
|
2072
|
+
if (f.managed !== true) await adoptOwnInstaller(s, spec, f, resolvedPath);
|
|
1797
2073
|
|
|
1798
2074
|
// The method's own record was silent. Before anything runs, read what the
|
|
1799
2075
|
// installed files already say about themselves.
|
|
1800
2076
|
if (f.installed === null) versionFromFiles(f, resolvedPath);
|
|
1801
2077
|
|
|
2078
|
+
// A program the operating system ships keeps no record anywhere: ask it.
|
|
2079
|
+
if (f.installed === null) await versionFromOs(s, f, resolvedPath);
|
|
2080
|
+
|
|
1802
2081
|
// Still nothing: read the running file itself — but only where the caller
|
|
1803
2082
|
// opted into running a check command (see there).
|
|
1804
2083
|
if (f.installed === null) await versionFromCheck(s, spec, f, onPath);
|
|
2084
|
+
|
|
2085
|
+
// A KEPT row names who installed it, not the installer magus would use.
|
|
2086
|
+
if (f.managed === false && !f.ownerName) {
|
|
2087
|
+
const who = await nameOwner(s, resolvedPath);
|
|
2088
|
+
if (who) f.ownerName = who;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
await findOtherCopies(s, spec.name, f, onPath, resolvedPath);
|
|
1805
2092
|
}
|
|
1806
2093
|
|
|
1807
2094
|
/**
|
|
@@ -1034,6 +1034,8 @@ export interface BinUpdateItem {
|
|
|
1034
1034
|
* the same value the TUI's row runs on Enter.
|
|
1035
1035
|
*/
|
|
1036
1036
|
upgradeCommand?: Command;
|
|
1037
|
+
/** {@link ToolStatus.shadowed}: a newer copy later on PATH, unused. */
|
|
1038
|
+
shadowed?: string;
|
|
1037
1039
|
/** The exact pin, when the profile declares one. */
|
|
1038
1040
|
version?: string;
|
|
1039
1041
|
/** The version PATH runs, or null when absent / unreadable. */
|
|
@@ -1089,6 +1091,7 @@ export function planBinUpdates(
|
|
|
1089
1091
|
};
|
|
1090
1092
|
if (status.note) item.note = status.note;
|
|
1091
1093
|
if (status.adopted) item.adopted = status.adopted;
|
|
1094
|
+
if (status.shadowed) item.shadowed = status.shadowed;
|
|
1092
1095
|
if (f?.upgradeCommand) item.upgradeCommand = f.upgradeCommand;
|
|
1093
1096
|
if (bin.version) item.version = bin.version;
|
|
1094
1097
|
return item;
|
|
@@ -397,6 +397,17 @@ export function renderCliToolDetail(
|
|
|
397
397
|
</text>
|
|
398
398
|
</box>
|
|
399
399
|
) : null}
|
|
400
|
+
{/* A newer copy later on PATH, which nothing runs. */}
|
|
401
|
+
{status?.shadowed ? (
|
|
402
|
+
<box flexDirection="row">
|
|
403
|
+
<text fg={theme.colors.muted} flexShrink={0}>
|
|
404
|
+
{"Warning "}
|
|
405
|
+
</text>
|
|
406
|
+
<text fg={theme.colors.warning} minWidth={0}>
|
|
407
|
+
{wrapValue(`⚠ ${status.shadowed}`, width)}
|
|
408
|
+
</text>
|
|
409
|
+
</box>
|
|
410
|
+
) : null}
|
|
400
411
|
{status?.installed ? (
|
|
401
412
|
<box flexDirection="row">
|
|
402
413
|
<text fg={theme.colors.muted} flexShrink={0}>
|