magus-cli 7.10.0 → 7.10.2
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/c15-probe-ownership.test.ts +3 -3
- 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__/cli-tools-adapter.test.ts +23 -6
- package/src/__tests__/tool-probe.test.ts +21 -13
- package/src/__tests__/update-plan.test.ts +19 -3
- package/src/cli/update-view.ts +17 -14
- package/src/services/tool-probe.ts +190 -15
- package/src/services/update-engine.ts +24 -6
- package/src/services/update-plan.ts +9 -4
- package/src/ui/adapters/cliToolsAdapter.ts +15 -6
- package/src/ui/renderers/cliToolRenderers.tsx +13 -2
|
@@ -52,13 +52,26 @@ 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";
|
|
58
60
|
|
|
59
61
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
/**
|
|
64
|
+
* `external` is a determined fact, not a failure to check: the tool is on
|
|
65
|
+
* PATH, and something other than the installer magus would use put it there
|
|
66
|
+
* (bun, an official installer, the OS). magus leaves it as is, so it is
|
|
67
|
+
* neither work nor "could not be checked".
|
|
68
|
+
*/
|
|
69
|
+
export type ToolState =
|
|
70
|
+
| "install"
|
|
71
|
+
| "current"
|
|
72
|
+
| "upgrade"
|
|
73
|
+
| "external"
|
|
74
|
+
| "unknown";
|
|
62
75
|
|
|
63
76
|
export type ToolReason =
|
|
64
77
|
| "absent"
|
|
@@ -215,16 +228,51 @@ function withFailure(text: string, failure: string | undefined): string {
|
|
|
215
228
|
return failure ? `${text} (${failure})` : text;
|
|
216
229
|
}
|
|
217
230
|
|
|
218
|
-
/** The
|
|
231
|
+
/** The installer as the user knows it. */
|
|
232
|
+
export function installerWord(via: ManagedTool["via"]): string {
|
|
233
|
+
return via === "brew" ? "Homebrew" : via;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The directories the operating system itself installs programs into. */
|
|
237
|
+
const SYSTEM_DIRS = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/System"];
|
|
238
|
+
|
|
239
|
+
function isSystemPath(resolved: string | null): boolean {
|
|
240
|
+
return (
|
|
241
|
+
resolved !== null && SYSTEM_DIRS.some((dir) => isInside(resolved, dir))
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The `not-managed` sentence, in the user's terms: who did not install it,
|
|
247
|
+
* and what magus does about it. Never the path or the installer's directory:
|
|
248
|
+
* the user acts on neither.
|
|
249
|
+
*/
|
|
219
250
|
function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
|
|
251
|
+
const pin = pinOf(spec);
|
|
252
|
+
if (facts.managed === false && pin) {
|
|
253
|
+
return `installed outside ${installerWord(spec.via)}, so magus cannot move it to the pin ${pin}`;
|
|
254
|
+
}
|
|
255
|
+
// The KEPT chip already says magus leaves it as is; the note says only who
|
|
256
|
+
// installed it.
|
|
220
257
|
return facts.managed === false
|
|
221
|
-
?
|
|
258
|
+
? isSystemPath(facts.resolvedPath)
|
|
259
|
+
? "comes with the operating system"
|
|
260
|
+
: `installed outside ${installerWord(spec.via)}`
|
|
222
261
|
: withFailure(
|
|
223
|
-
`could not tell whether ${spec.via}
|
|
262
|
+
`could not tell whether ${installerWord(spec.via)} installed it`,
|
|
224
263
|
facts.failure,
|
|
225
264
|
);
|
|
226
265
|
}
|
|
227
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Another installer's file is `external`, settled, only while nothing is
|
|
269
|
+
* asked of it. With a pin the profile declares a version magus cannot deliver
|
|
270
|
+
* without a second copy, so that is still a warning: `unknown`.
|
|
271
|
+
*/
|
|
272
|
+
function externalOr(pin: string | undefined): ToolState {
|
|
273
|
+
return pin ? "unknown" : "external";
|
|
274
|
+
}
|
|
275
|
+
|
|
228
276
|
/**
|
|
229
277
|
* THE tool classifier. Pure. The first matching rule wins, and each branch
|
|
230
278
|
* returns, so reading top to bottom is reading the precedence.
|
|
@@ -271,7 +319,7 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
271
319
|
if (installed === null && facts.managed === false) {
|
|
272
320
|
return {
|
|
273
321
|
...base,
|
|
274
|
-
state:
|
|
322
|
+
state: externalOr(pin),
|
|
275
323
|
reason: "not-managed",
|
|
276
324
|
target: null,
|
|
277
325
|
note: notManagedNote(spec, facts),
|
|
@@ -317,11 +365,12 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
317
365
|
};
|
|
318
366
|
}
|
|
319
367
|
|
|
320
|
-
// 7. The veto: the upgrade would not replace the file PATH runs.
|
|
368
|
+
// 7. The veto: the upgrade would not replace the file PATH runs. Known to be
|
|
369
|
+
// another installer's file: `external`. Not known either way: `unknown`.
|
|
321
370
|
if (facts.managed !== true) {
|
|
322
371
|
return {
|
|
323
372
|
...base,
|
|
324
|
-
state: "unknown",
|
|
373
|
+
state: facts.managed === false ? externalOr(pin) : "unknown",
|
|
325
374
|
reason: "not-managed",
|
|
326
375
|
target: null,
|
|
327
376
|
note: notManagedNote(spec, facts),
|
|
@@ -1189,6 +1238,9 @@ async function probeBrew(
|
|
|
1189
1238
|
resolvedPath: string,
|
|
1190
1239
|
): Promise<void> {
|
|
1191
1240
|
const formula = spec.formula ?? spec.name;
|
|
1241
|
+
// A tap formula ("user/tap/f") keeps its keg under the short name, so the
|
|
1242
|
+
// full name never matched and a Homebrew mnemex read as installed outside it.
|
|
1243
|
+
const keg = formula.slice(formula.lastIndexOf("/") + 1);
|
|
1192
1244
|
const prefix = await s.brewPrefix();
|
|
1193
1245
|
if ("failure" in prefix) {
|
|
1194
1246
|
f.managed = null;
|
|
@@ -1198,11 +1250,11 @@ async function probeBrew(
|
|
|
1198
1250
|
const cellar =
|
|
1199
1251
|
(await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
|
|
1200
1252
|
path.join(prefix.dir, "Cellar");
|
|
1201
|
-
const ownerDir = path.join(cellar,
|
|
1253
|
+
const ownerDir = path.join(cellar, keg);
|
|
1202
1254
|
f.owner = `brew (Cellar ${ownerDir})`;
|
|
1203
1255
|
f.managed = isInside(resolvedPath, ownerDir);
|
|
1204
1256
|
if (!f.managed) return;
|
|
1205
|
-
f.installed = brewKegVersion(resolvedPath, cellar,
|
|
1257
|
+
f.installed = brewKegVersion(resolvedPath, cellar, keg);
|
|
1206
1258
|
if (f.installed) addSource(f, "brew");
|
|
1207
1259
|
|
|
1208
1260
|
const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
|
|
@@ -1352,6 +1404,129 @@ async function readNodePackage(
|
|
|
1352
1404
|
if (!pinned) await npmLatest(s, pkg, f);
|
|
1353
1405
|
}
|
|
1354
1406
|
|
|
1407
|
+
/** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
|
|
1408
|
+
function bunGlobalRoot(io: ToolProbeIO): string {
|
|
1409
|
+
return path.join(
|
|
1410
|
+
io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
|
|
1411
|
+
"install",
|
|
1412
|
+
"global",
|
|
1413
|
+
"node_modules",
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** The package a path names, from its segments under `node_modules`. */
|
|
1418
|
+
function packageFromSegments(rel: string): string | null {
|
|
1419
|
+
const [first, second] = rel.split(path.sep);
|
|
1420
|
+
if (!first || first.startsWith(".")) return null;
|
|
1421
|
+
if (!first.startsWith("@")) return first;
|
|
1422
|
+
return second ? `${first}/${second}` : null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* The bun or npm global package that holds `resolvedPath`, read from the path
|
|
1427
|
+
* alone. Null when the file lies in neither tree (a Homebrew keg included:
|
|
1428
|
+
* `npmGlobalPrefixOf` refuses one).
|
|
1429
|
+
*/
|
|
1430
|
+
async function nodeGlobalOwner(
|
|
1431
|
+
s: ProbeSession,
|
|
1432
|
+
resolvedPath: string,
|
|
1433
|
+
): Promise<{ via: "bun" | "npm"; pkg: string } | null> {
|
|
1434
|
+
const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
|
|
1435
|
+
if (bunRoot && isInside(resolvedPath, bunRoot)) {
|
|
1436
|
+
const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
|
|
1437
|
+
return pkg ? { via: "bun", pkg } : null;
|
|
1438
|
+
}
|
|
1439
|
+
const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
|
|
1440
|
+
const at = resolvedPath.lastIndexOf(marker);
|
|
1441
|
+
if (at < 0) return null;
|
|
1442
|
+
const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
|
|
1443
|
+
return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
|
|
1444
|
+
? { via: "npm", pkg }
|
|
1445
|
+
: null;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* The formula whose keg holds `resolvedPath` (`…/Cellar/<formula>/<version>/…`),
|
|
1450
|
+
* read from the path alone. `probeBrew` then checks that this Cellar is brew's.
|
|
1451
|
+
*/
|
|
1452
|
+
function kegFormulaOf(resolvedPath: string): string | null {
|
|
1453
|
+
const parts = resolvedPath.split(path.sep);
|
|
1454
|
+
const at = parts.lastIndexOf("Cellar");
|
|
1455
|
+
return at >= 0 && parts.length > at + 2 ? (parts[at + 1] ?? null) : null;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* The spec as the installer that actually holds `resolvedPath` would state it,
|
|
1460
|
+
* or null when that is the spec's own installer or none magus drives.
|
|
1461
|
+
*/
|
|
1462
|
+
async function actualOwnerSpec(
|
|
1463
|
+
s: ProbeSession,
|
|
1464
|
+
spec: ManagedTool,
|
|
1465
|
+
resolvedPath: string,
|
|
1466
|
+
): Promise<ManagedTool | null> {
|
|
1467
|
+
const base = {
|
|
1468
|
+
name: spec.name,
|
|
1469
|
+
...(spec.check ? { check: spec.check } : {}),
|
|
1470
|
+
...(spec.version ? { version: spec.version } : {}),
|
|
1471
|
+
};
|
|
1472
|
+
const node = await nodeGlobalOwner(s, resolvedPath);
|
|
1473
|
+
if (node) {
|
|
1474
|
+
return node.via === spec.via
|
|
1475
|
+
? null
|
|
1476
|
+
: { ...base, via: node.via, package: node.pkg };
|
|
1477
|
+
}
|
|
1478
|
+
const formula = kegFormulaOf(resolvedPath);
|
|
1479
|
+
// Homebrew takes no version, so it cannot honour a pin: such a tool stays
|
|
1480
|
+
// "installed outside", which the classifier reports as unknown.
|
|
1481
|
+
if (!formula || spec.via === "brew" || spec.version) return null;
|
|
1482
|
+
return { ...base, via: "brew", formula };
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* The spec's installer does not own the file PATH runs, but bun's or npm's
|
|
1487
|
+
* global tree or a Homebrew keg does: re-read the tool through THAT installer.
|
|
1488
|
+
*
|
|
1489
|
+
* A plugin's `requires` lists its install options in preference order for a
|
|
1490
|
+
* FRESH install, and magus plans through the first. That order says how to
|
|
1491
|
+
* install a missing tool, not which owner an installed one must have. A
|
|
1492
|
+
* mnemex from `bun add -g` or from Homebrew has a version, a registry and an
|
|
1493
|
+
* upgrade command like any other. Reading it as "installed outside" the
|
|
1494
|
+
* first option's installer showed a settled, upgradeable tool as one magus
|
|
1495
|
+
* could not touch.
|
|
1496
|
+
*
|
|
1497
|
+
* The catalogue's tools already follow how a tool IS installed
|
|
1498
|
+
* (`specForCatalogTool`); this gives a plugin's requirements the same rule.
|
|
1499
|
+
* The upgrade command is carried on the facts, so `magus update` and the TUI
|
|
1500
|
+
* run the owner's command rather than the spec's.
|
|
1501
|
+
*/
|
|
1502
|
+
async function adoptActualOwner(
|
|
1503
|
+
s: ProbeSession,
|
|
1504
|
+
spec: ManagedTool,
|
|
1505
|
+
f: ToolFacts,
|
|
1506
|
+
resolvedPath: string,
|
|
1507
|
+
): Promise<void> {
|
|
1508
|
+
const actual = await actualOwnerSpec(s, spec, resolvedPath);
|
|
1509
|
+
if (!actual) return;
|
|
1510
|
+
const read = blankFacts(spec.name);
|
|
1511
|
+
if (actual.via === "brew") await probeBrew(s, actual, read, resolvedPath);
|
|
1512
|
+
else await probeNodeGlobal(s, actual, read, resolvedPath);
|
|
1513
|
+
if (read.managed !== true) return;
|
|
1514
|
+
f.managed = true;
|
|
1515
|
+
f.owner = read.owner;
|
|
1516
|
+
f.installed = read.installed;
|
|
1517
|
+
f.latest = read.latest;
|
|
1518
|
+
f.behind = read.behind;
|
|
1519
|
+
// What the spec's installer could not answer is moot once the owner is
|
|
1520
|
+
// known: only the owner's own failure describes this tool.
|
|
1521
|
+
f.failure = read.failure;
|
|
1522
|
+
for (const source of read.sources) addSource(f, source);
|
|
1523
|
+
f.upgradeCommand =
|
|
1524
|
+
read.upgradeCommand ??
|
|
1525
|
+
(spec.version
|
|
1526
|
+
? (binPinCommand(actual) ?? undefined)
|
|
1527
|
+
: binUpgradeCommand(actual));
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1355
1530
|
async function probeNodeGlobal(
|
|
1356
1531
|
s: ProbeSession,
|
|
1357
1532
|
spec: ManagedTool,
|
|
@@ -1362,12 +1537,7 @@ async function probeNodeGlobal(
|
|
|
1362
1537
|
const pin = pinOf(spec);
|
|
1363
1538
|
const pinned = pin !== undefined;
|
|
1364
1539
|
if (spec.via === "bun") {
|
|
1365
|
-
const root =
|
|
1366
|
-
s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
|
|
1367
|
-
"install",
|
|
1368
|
-
"global",
|
|
1369
|
-
"node_modules",
|
|
1370
|
-
);
|
|
1540
|
+
const root = bunGlobalRoot(s.io);
|
|
1371
1541
|
f.owner = `bun (global ${root})`;
|
|
1372
1542
|
const { inside, real } = await containedIn(
|
|
1373
1543
|
s,
|
|
@@ -1620,6 +1790,11 @@ async function probeInto(
|
|
|
1620
1790
|
break;
|
|
1621
1791
|
}
|
|
1622
1792
|
|
|
1793
|
+
// Not the spec's installer's file, or it could not tell (npm with no `npm`
|
|
1794
|
+
// to ask), but the path names bun's, npm's or Homebrew's tree: then that is
|
|
1795
|
+
// the owner, and it can upgrade the file PATH runs.
|
|
1796
|
+
if (f.managed !== true) await adoptActualOwner(s, spec, f, resolvedPath);
|
|
1797
|
+
|
|
1623
1798
|
// The method's own record was silent. Before anything runs, read what the
|
|
1624
1799
|
// installed files already say about themselves.
|
|
1625
1800
|
if (f.installed === null) versionFromFiles(f, resolvedPath);
|
|
@@ -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,
|
|
@@ -1235,8 +1235,10 @@ export function summarizePlan(plan: UpdatePlan): {
|
|
|
1235
1235
|
unknownPlugins: number;
|
|
1236
1236
|
unknownTools: number;
|
|
1237
1237
|
/**
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1238
|
+
* Left as they are, on purpose. Plugins no longer published by their
|
|
1239
|
+
* marketplace and kept (moved, or still required), and tools another
|
|
1240
|
+
* installer put on PATH (`external`). Determined facts, so not part of
|
|
1241
|
+
* `unknown`.
|
|
1240
1242
|
*/
|
|
1241
1243
|
kept: number;
|
|
1242
1244
|
} {
|
|
@@ -1244,8 +1246,11 @@ export function summarizePlan(plan: UpdatePlan): {
|
|
|
1244
1246
|
plan.plugins.filter((p) => p.action === a).length;
|
|
1245
1247
|
const bins = (a: BinUpdateAction) =>
|
|
1246
1248
|
plan.bins.filter((b) => b.action === a).length;
|
|
1247
|
-
const
|
|
1248
|
-
const unknownPlugins = count("unknown") -
|
|
1249
|
+
const keptPlugins = plan.plugins.filter(isKeptDeprecated).length;
|
|
1250
|
+
const unknownPlugins = count("unknown") - keptPlugins;
|
|
1251
|
+
// A tool another installer put on PATH is left as it is: settled, like a
|
|
1252
|
+
// kept plugin, and never "could not be checked".
|
|
1253
|
+
const kept = keptPlugins + bins("external");
|
|
1249
1254
|
const unknownTools = bins("unknown");
|
|
1250
1255
|
return {
|
|
1251
1256
|
installed: count("install") + bins("install"),
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type ToolReason,
|
|
30
30
|
type ToolStatus,
|
|
31
31
|
classifyTool,
|
|
32
|
+
installerWord,
|
|
32
33
|
} from "../../services/tool-probe.js";
|
|
33
34
|
import { binCommandForState } from "../../services/toolchain.js";
|
|
34
35
|
import type { Presence } from "../../shared/plugin/hooks/lib/requires-probe.js";
|
|
@@ -246,7 +247,9 @@ export function cliToolEnterAction(row: CliToolRow): CliToolEnterAction | null {
|
|
|
246
247
|
command,
|
|
247
248
|
};
|
|
248
249
|
}
|
|
249
|
-
|
|
250
|
+
// Another installer's file, or one magus could not place: still offered,
|
|
251
|
+
// as an explicit choice, with the note beside it (see the renderer).
|
|
252
|
+
if (status.state === "unknown" || status.state === "external") {
|
|
250
253
|
return {
|
|
251
254
|
kind: "update-anyway",
|
|
252
255
|
label: "update anyway",
|
|
@@ -280,13 +283,14 @@ export interface CliToolStatusText {
|
|
|
280
283
|
* line, the same string `magus update` prints.
|
|
281
284
|
*
|
|
282
285
|
* Every label is at most 19 cells, so the row suffix (" ? " + label) stays
|
|
283
|
-
* within 24. Keyed by reason alone
|
|
284
|
-
* word, never a path.
|
|
286
|
+
* within 24. Keyed by reason alone.
|
|
285
287
|
*/
|
|
286
|
-
function unknownLabel(reason: ToolReason
|
|
288
|
+
function unknownLabel(reason: ToolReason): string {
|
|
287
289
|
switch (reason) {
|
|
290
|
+
// Only the undetermined case: a file known to be another installer's
|
|
291
|
+
// is `external`, not `unknown`.
|
|
288
292
|
case "not-managed":
|
|
289
|
-
return
|
|
293
|
+
return "owner not known";
|
|
290
294
|
case "version-unreadable":
|
|
291
295
|
return "version unreadable";
|
|
292
296
|
case "latest-unavailable":
|
|
@@ -321,9 +325,14 @@ export function cliToolStatusText(row: CliToolRow): CliToolStatusText {
|
|
|
321
325
|
switch (status.state) {
|
|
322
326
|
case "unknown":
|
|
323
327
|
return {
|
|
324
|
-
suffix: ` ? ${unknownLabel(status.reason
|
|
328
|
+
suffix: ` ? ${unknownLabel(status.reason)}`,
|
|
325
329
|
tone: "warning",
|
|
326
330
|
};
|
|
331
|
+
case "external":
|
|
332
|
+
return {
|
|
333
|
+
suffix: ` · outside ${installerWord(row.spec.via)}`,
|
|
334
|
+
tone: "muted",
|
|
335
|
+
};
|
|
327
336
|
case "upgrade":
|
|
328
337
|
return {
|
|
329
338
|
suffix: status.target
|
|
@@ -250,6 +250,10 @@ export function renderCliToolRow(
|
|
|
250
250
|
} else if (status?.state === "unknown") {
|
|
251
251
|
icon = "?";
|
|
252
252
|
iconColor = theme.colors.warning;
|
|
253
|
+
} else if (status?.state === "external") {
|
|
254
|
+
// Installed, and left alone on purpose: present, not a finding.
|
|
255
|
+
icon = "●";
|
|
256
|
+
iconColor = theme.colors.muted;
|
|
253
257
|
} else {
|
|
254
258
|
icon = "●";
|
|
255
259
|
iconColor = theme.colors.success;
|
|
@@ -353,6 +357,8 @@ export function renderCliToolDetail(
|
|
|
353
357
|
</text>
|
|
354
358
|
) : state === "upgrade" ? (
|
|
355
359
|
<text fg={theme.colors.warning}>{"● Update available"}</text>
|
|
360
|
+
) : state === "external" ? (
|
|
361
|
+
<text fg={theme.colors.muted}>{"● Installed, kept as is"}</text>
|
|
356
362
|
) : state === "unknown" ? (
|
|
357
363
|
// A version that was read but cannot be checked for updates is
|
|
358
364
|
// still a version: say "installed", and let Why say the rest.
|
|
@@ -365,12 +371,17 @@ export function renderCliToolDetail(
|
|
|
365
371
|
<text fg={theme.colors.success}>{"● Up to date"}</text>
|
|
366
372
|
)}
|
|
367
373
|
</box>
|
|
368
|
-
{state === "unknown" && status?.note ? (
|
|
374
|
+
{(state === "unknown" || state === "external") && status?.note ? (
|
|
369
375
|
<box flexDirection="row">
|
|
370
376
|
<text fg={theme.colors.muted} flexShrink={0}>
|
|
371
377
|
{"Why "}
|
|
372
378
|
</text>
|
|
373
|
-
<text
|
|
379
|
+
<text
|
|
380
|
+
fg={
|
|
381
|
+
state === "external" ? theme.colors.muted : theme.colors.warning
|
|
382
|
+
}
|
|
383
|
+
minWidth={0}
|
|
384
|
+
>
|
|
374
385
|
{wrapValue(status.note, width)}
|
|
375
386
|
</text>
|
|
376
387
|
</box>
|