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
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
versionFromVersionedPath,
|
|
53
53
|
} from "./cli-tool-version.js";
|
|
54
54
|
import {
|
|
55
|
+
binPinCommand,
|
|
56
|
+
binUpgradeCommand,
|
|
55
57
|
claudeNativeUpgradeCommand,
|
|
56
58
|
npmPrefixInstallCommand,
|
|
57
59
|
} from "./toolchain.js";
|
|
@@ -104,7 +106,20 @@ export type ToolFactSource =
|
|
|
104
106
|
| "package-json"
|
|
105
107
|
/** the file name of a versioned install (`…/versions/2.1.276`). */
|
|
106
108
|
| "version-dir"
|
|
107
|
-
| "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
|
+
}
|
|
108
123
|
|
|
109
124
|
export interface ToolFacts {
|
|
110
125
|
name: string;
|
|
@@ -133,6 +148,16 @@ export interface ToolFacts {
|
|
|
133
148
|
* "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
|
|
134
149
|
*/
|
|
135
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[];
|
|
136
161
|
/** e.g. "held by brew pin". */
|
|
137
162
|
heldBack?: string;
|
|
138
163
|
/**
|
|
@@ -176,6 +201,12 @@ export interface ToolStatus {
|
|
|
176
201
|
note?: string;
|
|
177
202
|
/** {@link ToolFacts.adopted}, carried when this state plans work. */
|
|
178
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;
|
|
179
210
|
installed: string | null;
|
|
180
211
|
latest: string | null;
|
|
181
212
|
target: string | null;
|
|
@@ -231,22 +262,55 @@ export function installerWord(via: ManagedTool["via"]): string {
|
|
|
231
262
|
return via === "brew" ? "Homebrew" : via;
|
|
232
263
|
}
|
|
233
264
|
|
|
265
|
+
/** The directories the operating system itself installs programs into. */
|
|
266
|
+
const SYSTEM_DIRS = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/System"];
|
|
267
|
+
|
|
268
|
+
function isSystemPath(resolved: string | null): boolean {
|
|
269
|
+
return (
|
|
270
|
+
resolved !== null && SYSTEM_DIRS.some((dir) => isInside(resolved, dir))
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
234
274
|
/**
|
|
235
275
|
* The `not-managed` sentence, in the user's terms: who did not install it,
|
|
236
276
|
* and what magus does about it. Never the path or the installer's directory:
|
|
237
277
|
* the user acts on neither.
|
|
238
278
|
*/
|
|
239
279
|
function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
280
|
+
if (facts.managed !== false) {
|
|
281
|
+
return withFailure(
|
|
282
|
+
`could not tell whether ${installerWord(spec.via)} installed it`,
|
|
283
|
+
facts.failure,
|
|
284
|
+
);
|
|
243
285
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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);
|
|
294
|
+
// The KEPT chip already says magus leaves it as is; the note says only who
|
|
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}` : ""}`;
|
|
250
314
|
}
|
|
251
315
|
|
|
252
316
|
/**
|
|
@@ -277,6 +341,12 @@ function externalOr(pin: string | undefined): ToolState {
|
|
|
277
341
|
* none.
|
|
278
342
|
*/
|
|
279
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 {
|
|
280
350
|
const pin = pinOf(spec);
|
|
281
351
|
const base = {
|
|
282
352
|
name: spec.name,
|
|
@@ -377,7 +447,10 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
377
447
|
// From here on a row can plan work, so an adoption must travel with it.
|
|
378
448
|
const adopted = facts.adopted ? { adopted: facts.adopted } : {};
|
|
379
449
|
if (pin) {
|
|
380
|
-
|
|
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) {
|
|
381
454
|
return {
|
|
382
455
|
...base,
|
|
383
456
|
state: "unknown",
|
|
@@ -743,6 +816,11 @@ export interface ToolProbeIO {
|
|
|
743
816
|
* null. A dangling symlink is not executable and does not count.
|
|
744
817
|
*/
|
|
745
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[]>;
|
|
746
824
|
env: Readonly<Record<string, string | undefined>>;
|
|
747
825
|
homedir: string;
|
|
748
826
|
}
|
|
@@ -861,6 +939,16 @@ export const REAL_TOOL_PROBE_IO: ToolProbeIO = {
|
|
|
861
939
|
}
|
|
862
940
|
return null;
|
|
863
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
|
+
},
|
|
864
952
|
env: process.env,
|
|
865
953
|
homedir: os.homedir(),
|
|
866
954
|
};
|
|
@@ -1223,6 +1311,9 @@ async function probeBrew(
|
|
|
1223
1311
|
resolvedPath: string,
|
|
1224
1312
|
): Promise<void> {
|
|
1225
1313
|
const formula = spec.formula ?? spec.name;
|
|
1314
|
+
// A tap formula ("user/tap/f") keeps its keg under the short name, so the
|
|
1315
|
+
// full name never matched and a Homebrew mnemex read as installed outside it.
|
|
1316
|
+
const keg = formula.slice(formula.lastIndexOf("/") + 1);
|
|
1226
1317
|
const prefix = await s.brewPrefix();
|
|
1227
1318
|
if ("failure" in prefix) {
|
|
1228
1319
|
f.managed = null;
|
|
@@ -1232,11 +1323,11 @@ async function probeBrew(
|
|
|
1232
1323
|
const cellar =
|
|
1233
1324
|
(await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
|
|
1234
1325
|
path.join(prefix.dir, "Cellar");
|
|
1235
|
-
const ownerDir = path.join(cellar,
|
|
1326
|
+
const ownerDir = path.join(cellar, keg);
|
|
1236
1327
|
f.owner = `brew (Cellar ${ownerDir})`;
|
|
1237
1328
|
f.managed = isInside(resolvedPath, ownerDir);
|
|
1238
1329
|
if (!f.managed) return;
|
|
1239
|
-
f.installed = brewKegVersion(resolvedPath, cellar,
|
|
1330
|
+
f.installed = brewKegVersion(resolvedPath, cellar, keg);
|
|
1240
1331
|
if (f.installed) addSource(f, "brew");
|
|
1241
1332
|
|
|
1242
1333
|
const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
|
|
@@ -1386,6 +1477,330 @@ async function readNodePackage(
|
|
|
1386
1477
|
if (!pinned) await npmLatest(s, pkg, f);
|
|
1387
1478
|
}
|
|
1388
1479
|
|
|
1480
|
+
/** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
|
|
1481
|
+
function bunGlobalRoot(io: ToolProbeIO): string {
|
|
1482
|
+
return path.join(
|
|
1483
|
+
io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
|
|
1484
|
+
"install",
|
|
1485
|
+
"global",
|
|
1486
|
+
"node_modules",
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** The package a path names, from its segments under `node_modules`. */
|
|
1491
|
+
function packageFromSegments(rel: string): string | null {
|
|
1492
|
+
const [first, second] = rel.split(path.sep);
|
|
1493
|
+
if (!first || first.startsWith(".")) return null;
|
|
1494
|
+
if (!first.startsWith("@")) return first;
|
|
1495
|
+
return second ? `${first}/${second}` : null;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* The bun or npm global package that holds `resolvedPath`, read from the path
|
|
1500
|
+
* alone. Null when the file lies in neither tree (a Homebrew keg included:
|
|
1501
|
+
* `npmGlobalPrefixOf` refuses one).
|
|
1502
|
+
*/
|
|
1503
|
+
async function nodeGlobalOwner(
|
|
1504
|
+
s: ProbeSession,
|
|
1505
|
+
resolvedPath: string,
|
|
1506
|
+
): Promise<{ via: "bun" | "npm"; pkg: string } | null> {
|
|
1507
|
+
const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
|
|
1508
|
+
if (bunRoot && isInside(resolvedPath, bunRoot)) {
|
|
1509
|
+
const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
|
|
1510
|
+
return pkg ? { via: "bun", pkg } : null;
|
|
1511
|
+
}
|
|
1512
|
+
const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
|
|
1513
|
+
const at = resolvedPath.lastIndexOf(marker);
|
|
1514
|
+
if (at < 0) return null;
|
|
1515
|
+
const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
|
|
1516
|
+
return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
|
|
1517
|
+
? { via: "npm", pkg }
|
|
1518
|
+
: null;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* The formula whose keg holds `resolvedPath` (`…/Cellar/<formula>/<version>/…`),
|
|
1523
|
+
* read from the path alone. `probeBrew` then checks that this Cellar is brew's.
|
|
1524
|
+
*/
|
|
1525
|
+
function kegFormulaOf(resolvedPath: string): string | null {
|
|
1526
|
+
const parts = resolvedPath.split(path.sep);
|
|
1527
|
+
const at = parts.lastIndexOf("Cellar");
|
|
1528
|
+
return at >= 0 && parts.length > at + 2 ? (parts[at + 1] ?? null) : null;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* The spec as the installer that actually holds `resolvedPath` would state it,
|
|
1533
|
+
* or null when that is the spec's own installer or none magus drives.
|
|
1534
|
+
*/
|
|
1535
|
+
async function actualOwnerSpec(
|
|
1536
|
+
s: ProbeSession,
|
|
1537
|
+
spec: ManagedTool,
|
|
1538
|
+
resolvedPath: string,
|
|
1539
|
+
): Promise<ManagedTool | null> {
|
|
1540
|
+
const base = {
|
|
1541
|
+
name: spec.name,
|
|
1542
|
+
...(spec.check ? { check: spec.check } : {}),
|
|
1543
|
+
...(spec.version ? { version: spec.version } : {}),
|
|
1544
|
+
};
|
|
1545
|
+
const node = await nodeGlobalOwner(s, resolvedPath);
|
|
1546
|
+
if (node) {
|
|
1547
|
+
return node.via === spec.via
|
|
1548
|
+
? null
|
|
1549
|
+
: { ...base, via: node.via, package: node.pkg };
|
|
1550
|
+
}
|
|
1551
|
+
const formula = kegFormulaOf(resolvedPath);
|
|
1552
|
+
// Homebrew takes no version, so it cannot honour a pin: such a tool stays
|
|
1553
|
+
// "installed outside", which the classifier reports as unknown.
|
|
1554
|
+
if (!formula || spec.via === "brew" || spec.version) return null;
|
|
1555
|
+
return { ...base, via: "brew", formula };
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* The spec's installer does not own the file PATH runs, but bun's or npm's
|
|
1560
|
+
* global tree or a Homebrew keg does: re-read the tool through THAT installer.
|
|
1561
|
+
*
|
|
1562
|
+
* A plugin's `requires` lists its install options in preference order for a
|
|
1563
|
+
* FRESH install, and magus plans through the first. That order says how to
|
|
1564
|
+
* install a missing tool, not which owner an installed one must have. A
|
|
1565
|
+
* mnemex from `bun add -g` or from Homebrew has a version, a registry and an
|
|
1566
|
+
* upgrade command like any other. Reading it as "installed outside" the
|
|
1567
|
+
* first option's installer showed a settled, upgradeable tool as one magus
|
|
1568
|
+
* could not touch.
|
|
1569
|
+
*
|
|
1570
|
+
* The catalogue's tools already follow how a tool IS installed
|
|
1571
|
+
* (`specForCatalogTool`); this gives a plugin's requirements the same rule.
|
|
1572
|
+
* The upgrade command is carried on the facts, so `magus update` and the TUI
|
|
1573
|
+
* run the owner's command rather than the spec's.
|
|
1574
|
+
*/
|
|
1575
|
+
async function adoptActualOwner(
|
|
1576
|
+
s: ProbeSession,
|
|
1577
|
+
spec: ManagedTool,
|
|
1578
|
+
f: ToolFacts,
|
|
1579
|
+
resolvedPath: string,
|
|
1580
|
+
): Promise<void> {
|
|
1581
|
+
const actual = await actualOwnerSpec(s, spec, resolvedPath);
|
|
1582
|
+
if (!actual) return;
|
|
1583
|
+
const read = blankFacts(spec.name);
|
|
1584
|
+
if (actual.via === "brew") await probeBrew(s, actual, read, resolvedPath);
|
|
1585
|
+
else await probeNodeGlobal(s, actual, read, resolvedPath);
|
|
1586
|
+
if (read.managed !== true) return;
|
|
1587
|
+
f.managed = true;
|
|
1588
|
+
f.owner = read.owner;
|
|
1589
|
+
f.installed = read.installed;
|
|
1590
|
+
f.latest = read.latest;
|
|
1591
|
+
f.behind = read.behind;
|
|
1592
|
+
// What the spec's installer could not answer is moot once the owner is
|
|
1593
|
+
// known: only the owner's own failure describes this tool.
|
|
1594
|
+
f.failure = read.failure;
|
|
1595
|
+
for (const source of read.sources) addSource(f, source);
|
|
1596
|
+
f.upgradeCommand =
|
|
1597
|
+
read.upgradeCommand ??
|
|
1598
|
+
(spec.version
|
|
1599
|
+
? (binPinCommand(actual) ?? undefined)
|
|
1600
|
+
: binUpgradeCommand(actual));
|
|
1601
|
+
}
|
|
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
|
+
|
|
1389
1804
|
async function probeNodeGlobal(
|
|
1390
1805
|
s: ProbeSession,
|
|
1391
1806
|
spec: ManagedTool,
|
|
@@ -1396,12 +1811,7 @@ async function probeNodeGlobal(
|
|
|
1396
1811
|
const pin = pinOf(spec);
|
|
1397
1812
|
const pinned = pin !== undefined;
|
|
1398
1813
|
if (spec.via === "bun") {
|
|
1399
|
-
const root =
|
|
1400
|
-
s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
|
|
1401
|
-
"install",
|
|
1402
|
-
"global",
|
|
1403
|
-
"node_modules",
|
|
1404
|
-
);
|
|
1814
|
+
const root = bunGlobalRoot(s.io);
|
|
1405
1815
|
f.owner = `bun (global ${root})`;
|
|
1406
1816
|
const { inside, real } = await containedIn(
|
|
1407
1817
|
s,
|
|
@@ -1654,13 +2064,31 @@ async function probeInto(
|
|
|
1654
2064
|
break;
|
|
1655
2065
|
}
|
|
1656
2066
|
|
|
2067
|
+
// Not the spec's installer's file, or it could not tell (npm with no `npm`
|
|
2068
|
+
// to ask), but the path names bun's, npm's or Homebrew's tree: then that is
|
|
2069
|
+
// the owner, and it can upgrade the file PATH runs.
|
|
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);
|
|
2073
|
+
|
|
1657
2074
|
// The method's own record was silent. Before anything runs, read what the
|
|
1658
2075
|
// installed files already say about themselves.
|
|
1659
2076
|
if (f.installed === null) versionFromFiles(f, resolvedPath);
|
|
1660
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
|
+
|
|
1661
2081
|
// Still nothing: read the running file itself — but only where the caller
|
|
1662
2082
|
// opted into running a check command (see there).
|
|
1663
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);
|
|
1664
2092
|
}
|
|
1665
2093
|
|
|
1666
2094
|
/**
|
|
@@ -397,7 +397,7 @@ async function readRows(
|
|
|
397
397
|
* The row Claude Code moved is left as it is: it belongs to this repository,
|
|
398
398
|
* and bringing this repository's rows forward is what the repair does anyway.
|
|
399
399
|
* Null when there was nothing to follow — this project's row moved (2.1.280),
|
|
400
|
-
* or no row of this repository moved at all
|
|
400
|
+
* or no row of this repository moved at all and none already holds `target`.
|
|
401
401
|
*/
|
|
402
402
|
async function followMisdirectedUpdate(
|
|
403
403
|
deps: PluginApplyDeps,
|
|
@@ -422,14 +422,32 @@ async function followMisdirectedUpdate(
|
|
|
422
422
|
scope,
|
|
423
423
|
projectPath: scope === "user" ? undefined : projectPath,
|
|
424
424
|
});
|
|
425
|
-
if (moved.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
425
|
+
if (moved.some((row) => rowKey(row) === mine)) return null;
|
|
426
|
+
// The row Claude Code picked may ALREADY hold the target — an earlier run
|
|
427
|
+
// from another checkout put it there. Then the update writes nothing, so
|
|
428
|
+
// nothing moves, and this project's row stays behind on every run (seen on
|
|
429
|
+
// 2.1.296: "the installer exited 0 but mnemex@magus is still 1.1.0" while
|
|
430
|
+
// tools/claudeup held 1.1.1). A row of this repository at exactly the
|
|
431
|
+
// target is the same misdirection, already landed.
|
|
432
|
+
const landed =
|
|
433
|
+
moved.length > 0 || !target
|
|
434
|
+
? moved
|
|
435
|
+
: after.filter(
|
|
436
|
+
(row) =>
|
|
437
|
+
row.version === target &&
|
|
438
|
+
rowKey(row) !== mine &&
|
|
439
|
+
inThisRepository(row, projectPath),
|
|
440
|
+
);
|
|
441
|
+
if (landed.length === 0) return null;
|
|
442
|
+
const version = landed
|
|
429
443
|
.map((row) => row.version)
|
|
430
444
|
.reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
|
|
431
445
|
if (!isRepairablyBehind(version, now)) return null;
|
|
432
|
-
|
|
446
|
+
// Already landed, every checkout at the target matches, but Claude Code
|
|
447
|
+
// wrote to one of them: report one folder, not all of them.
|
|
448
|
+
const elsewhere = (moved.length > 0 ? landed : landed.slice(0, 1)).map(
|
|
449
|
+
(row) => row.projectPath ?? `${row.scope} scope`,
|
|
450
|
+
);
|
|
433
451
|
if (target && version !== target) {
|
|
434
452
|
return {
|
|
435
453
|
elsewhere,
|
|
@@ -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}>
|