magus-cli 7.10.3 → 7.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +0 -1
- package/src/__tests__/blackbox-parity.test.ts +2 -2
- package/src/__tests__/blackbox-tool-state.test.ts +8 -18
- package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
- package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
- package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
- package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
- package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
- package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +38 -35
- package/src/__tests__/cli-tool-commands.test.ts +2 -2
- package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
- package/src/__tests__/cli-tools-adapter.test.ts +32 -22
- package/src/__tests__/cli-update-view.test.ts +124 -51
- package/src/__tests__/fixtures/bins.ts +9 -2
- package/src/__tests__/npm-package-name.test.ts +3 -2
- package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
- package/src/__tests__/tool-probe.test.ts +231 -144
- package/src/__tests__/tool-rows-followups.test.ts +315 -0
- package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
- package/src/__tests__/tool-versions.test.ts +164 -0
- package/src/__tests__/toolchain.test.ts +0 -2
- package/src/__tests__/update-plan.test.ts +40 -9
- package/src/cli/update-view.ts +180 -76
- package/src/cli/update.ts +44 -37
- package/src/data/cli-tools.ts +5 -10
- package/src/data/tool-versions.ts +137 -0
- package/src/services/cli-tool-commands.ts +1 -2
- package/src/services/cli-tool-version.ts +10 -88
- package/src/services/resolver.ts +9 -15
- package/src/services/tool-probe.ts +537 -691
- package/src/services/toolchain.ts +10 -4
- package/src/services/update-engine.ts +9 -15
- package/src/services/update-plan.ts +49 -21
- package/src/types/index.ts +0 -2
- package/src/ui/adapters/cliToolsAdapter.ts +31 -22
- package/src/ui/renderers/cliToolRenderers.tsx +15 -21
- package/src/ui/state/types.ts +0 -1
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// REGRESSION: magus update showed UNKNOWN rows and a "could not be checked" warning for tools installed another way — Fixed in /dev:fix session dev-fix-20261008-133732-7ddf079e
|
|
2
|
+
/**
|
|
3
|
+
* The magus-owned version table (docs/plans/2026-10-08-magus-update-tool-rows.md,
|
|
4
|
+
* decision 2): tool name -> argv and an anchored parse. A version is read only
|
|
5
|
+
* from the installer's own record or from this table — never guessed.
|
|
6
|
+
*
|
|
7
|
+
* Every line below is a first line of real `--version` output captured on the
|
|
8
|
+
* reporting machine (session root-cause.md, localization.md). A tool whose
|
|
9
|
+
* output was never captured (qwen, cline, aider) must have NO entry.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
parseToolVersion,
|
|
17
|
+
presenceArgv,
|
|
18
|
+
toolVersionCommand,
|
|
19
|
+
} from "../data/tool-versions.js";
|
|
20
|
+
|
|
21
|
+
function parse(tool: string, stdout: string, stderr = ""): string | null {
|
|
22
|
+
const cmd = toolVersionCommand(tool);
|
|
23
|
+
if (!cmd) throw new Error(`no version command for ${tool}`);
|
|
24
|
+
return parseToolVersion(cmd, stdout, stderr);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("parseToolVersion against captured output", () => {
|
|
28
|
+
const cases: Array<[tool: string, output: string, version: string]> = [
|
|
29
|
+
["claudish", "claudish version 10.4.1\n", "10.4.1"],
|
|
30
|
+
["jq", "jq-1.8.2\n", "1.8.2"],
|
|
31
|
+
["mnemex", "mnemex v0.36.1\nUpdate available: v0.36.2\n", "0.36.1"],
|
|
32
|
+
["tmux", "tmux 3.7c\n", "3.7c"],
|
|
33
|
+
[
|
|
34
|
+
"uv",
|
|
35
|
+
"uv 0.11.15 (3cffe97c2 2026-05-18 aarch64-apple-darwin)\n",
|
|
36
|
+
"0.11.15",
|
|
37
|
+
],
|
|
38
|
+
// /opt/homebrew/bin/uv --version, captured 2026-10-08.
|
|
39
|
+
[
|
|
40
|
+
"uv",
|
|
41
|
+
"uv 0.12.23 (Homebrew 2026-10-03 aarch64-apple-darwin)\n",
|
|
42
|
+
"0.12.23",
|
|
43
|
+
],
|
|
44
|
+
["zsh", "zsh 5.9 (arm64-apple-darwin26.0)\n", "5.9"],
|
|
45
|
+
["magus", "magus v7.9.0\n", "7.9.0"],
|
|
46
|
+
["claude", "2.1.293 (Claude Code)\n", "2.1.293"],
|
|
47
|
+
["codex", "codex-cli 0.153.2\n", "0.153.2"],
|
|
48
|
+
["gemini", "0.46.0\n", "0.46.0"],
|
|
49
|
+
["opencode", "1.17.11\n", "1.17.11"],
|
|
50
|
+
["crush", "crush version v0.55.0\n", "0.55.0"],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
for (const [tool, output, version] of cases) {
|
|
54
|
+
test(`${tool}: ${JSON.stringify(output.split("\n")[0])} -> ${version}`, () => {
|
|
55
|
+
expect(parse(tool, output)).toBe(version);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("only the first non-empty line is read (mnemex's update notice is ignored)", () => {
|
|
60
|
+
expect(
|
|
61
|
+
parse("mnemex", "\n\nmnemex v0.36.1\nUpdate available: v0.36.2\n"),
|
|
62
|
+
).toBe("0.36.1");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("stderr is read when stdout is empty", () => {
|
|
66
|
+
expect(parse("tmux", "", "tmux 3.7c\n")).toBe("3.7c");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a line the pattern does not anchor gives null, never a guess", () => {
|
|
70
|
+
expect(parse("tmux", "tmux master\n")).toBeNull();
|
|
71
|
+
expect(parse("tmux", "tmux next-3.7\n")).toBeNull();
|
|
72
|
+
expect(parse("jq", "jq version 1.8.2 is great\n")).toBeNull();
|
|
73
|
+
expect(parse("claudish", "")).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("toolVersionCommand", () => {
|
|
78
|
+
test("tmux uses -V", () => {
|
|
79
|
+
expect([...(toolVersionCommand("tmux")?.args ?? [])]).toEqual(["-V"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("an Object.prototype key is not a tool", () => {
|
|
83
|
+
expect(toolVersionCommand("constructor")).toBeUndefined();
|
|
84
|
+
expect(toolVersionCommand("toString")).toBeUndefined();
|
|
85
|
+
expect(toolVersionCommand("__proto__")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("tools whose output was never captured have no entry", () => {
|
|
89
|
+
for (const tool of ["qwen", "cline", "aider"]) {
|
|
90
|
+
expect(toolVersionCommand(tool)).toBeUndefined();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("presenceArgv", () => {
|
|
96
|
+
test("a known tool uses its own args", () => {
|
|
97
|
+
expect(presenceArgv("tmux")).toEqual(["tmux", "-V"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("an unknown tool falls back to --version", () => {
|
|
101
|
+
expect(presenceArgv("sometool")).toEqual(["sometool", "--version"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── coverage: every plugin bin a version-managed installer owns ─────────────
|
|
106
|
+
|
|
107
|
+
interface InstallOption {
|
|
108
|
+
via?: string;
|
|
109
|
+
os?: string[];
|
|
110
|
+
formula?: string;
|
|
111
|
+
cask?: string;
|
|
112
|
+
}
|
|
113
|
+
interface BinDecl {
|
|
114
|
+
name: string;
|
|
115
|
+
install?: InstallOption[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const PLUGINS = path.resolve(
|
|
119
|
+
import.meta.dir,
|
|
120
|
+
"..",
|
|
121
|
+
"..",
|
|
122
|
+
"..",
|
|
123
|
+
"..",
|
|
124
|
+
"plugins",
|
|
125
|
+
);
|
|
126
|
+
const VERSION_MANAGED = new Set(["npm", "bun", "pip", "go"]);
|
|
127
|
+
|
|
128
|
+
/** Bins whose darwin install option is brew (formula, not cask), npm, bun, pip or go. */
|
|
129
|
+
function versionManagedBins(): Map<string, string> {
|
|
130
|
+
const found = new Map<string, string>();
|
|
131
|
+
for (const dir of readdirSync(PLUGINS)) {
|
|
132
|
+
const manifest = path.join(PLUGINS, dir, ".claude-plugin", "plugin.json");
|
|
133
|
+
if (!existsSync(manifest)) continue;
|
|
134
|
+
const json = JSON.parse(readFileSync(manifest, "utf8")) as {
|
|
135
|
+
requires?: { bin?: BinDecl[] };
|
|
136
|
+
};
|
|
137
|
+
for (const b of json.requires?.bin ?? []) {
|
|
138
|
+
const darwin = (b.install ?? []).find(
|
|
139
|
+
(o) => !o.os || o.os.includes("darwin"),
|
|
140
|
+
);
|
|
141
|
+
if (!darwin?.via) continue;
|
|
142
|
+
const brewFormula = darwin.via === "brew" && !darwin.cask;
|
|
143
|
+
if (brewFormula || VERSION_MANAGED.has(darwin.via))
|
|
144
|
+
found.set(b.name, dir);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe("the table covers every plugin-declared, version-managed bin", () => {
|
|
151
|
+
test("the scan finds the known set (not vacuous)", () => {
|
|
152
|
+
const names = [...versionManagedBins().keys()];
|
|
153
|
+
for (const known of ["claudish", "jq", "mnemex", "tmux", "uv", "zsh"]) {
|
|
154
|
+
expect(names).toContain(known);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("each has a table entry", () => {
|
|
159
|
+
const missing = [...versionManagedBins()]
|
|
160
|
+
.filter(([name]) => toolVersionCommand(name) === undefined)
|
|
161
|
+
.map(([name, plugin]) => `${name} (${plugin})`);
|
|
162
|
+
expect(missing).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -220,7 +220,6 @@ describe("managedTool — the version-managed view of a requirement", () => {
|
|
|
220
220
|
name: "claudish",
|
|
221
221
|
via: "bun",
|
|
222
222
|
package: "claudish",
|
|
223
|
-
check: "claudish --version",
|
|
224
223
|
version: "1.2.0",
|
|
225
224
|
});
|
|
226
225
|
expect(
|
|
@@ -276,7 +275,6 @@ describe("managedTool — the version-managed view of a requirement", () => {
|
|
|
276
275
|
name: "a",
|
|
277
276
|
via: "npm",
|
|
278
277
|
package: "a",
|
|
279
|
-
check: "a",
|
|
280
278
|
sources: ["plugin:p@m"],
|
|
281
279
|
},
|
|
282
280
|
]);
|
|
@@ -497,8 +497,9 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
497
497
|
});
|
|
498
498
|
|
|
499
499
|
test("an UNKNOWN tool does not fail the gate, and is no work", () => {
|
|
500
|
-
// Tool unknowns are often structural and permanent (
|
|
501
|
-
// file, go is absent) — counting them makes a gate that
|
|
500
|
+
// Tool unknowns are often structural and permanent (the installer cannot
|
|
501
|
+
// say who owns the file, go is absent) — counting them makes a gate that
|
|
502
|
+
// never passes.
|
|
502
503
|
const p = plan({
|
|
503
504
|
bins: planBinUpdates(
|
|
504
505
|
[{ name: "crush", via: "npm", sources: [] }],
|
|
@@ -510,17 +511,22 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
510
511
|
expect(planIsBehind(p)).toBe(false);
|
|
511
512
|
});
|
|
512
513
|
|
|
513
|
-
test("
|
|
514
|
+
test("a tool installed ELSEWHERE does not fail the gate, and is no work", () => {
|
|
515
|
+
// Another npm owns the file: found and correctly left alone.
|
|
514
516
|
const p = plan({
|
|
515
517
|
bins: planBinUpdates(
|
|
516
518
|
[{ name: "crush", via: "npm", sources: [] }],
|
|
517
|
-
[toolFacts("crush", { managed: false, behind: true })],
|
|
519
|
+
[toolFacts("crush", { managed: false, behind: true, latest: "9.9.9" })],
|
|
518
520
|
),
|
|
519
521
|
});
|
|
520
522
|
expect(p.bins[0]!.action).toBe("external");
|
|
521
523
|
expect(planHasWork(p)).toBe(false);
|
|
522
524
|
expect(planIsBehind(p)).toBe(false);
|
|
523
|
-
expect(summarizePlan(p)).toMatchObject({
|
|
525
|
+
expect(summarizePlan(p)).toMatchObject({
|
|
526
|
+
unknownTools: 0,
|
|
527
|
+
kept: 0,
|
|
528
|
+
external: 1,
|
|
529
|
+
});
|
|
524
530
|
});
|
|
525
531
|
|
|
526
532
|
test("a MISSING binary does fail the gate", () => {
|
|
@@ -796,11 +802,12 @@ describe("planBinUpdates", () => {
|
|
|
796
802
|
|
|
797
803
|
test("the note is classifyTool's note, verbatim (the TUI shows the same string)", () => {
|
|
798
804
|
const f = toolFacts("crush", {
|
|
799
|
-
managed:
|
|
805
|
+
managed: null,
|
|
800
806
|
resolvedPath: "/n/crush.js",
|
|
801
|
-
|
|
807
|
+
failure: "npm timed out after 8s",
|
|
802
808
|
});
|
|
803
809
|
const [item] = planBinUpdates([bin({ name: "crush", via: "npm" })], [f]);
|
|
810
|
+
expect(item.note).toBeTruthy();
|
|
804
811
|
expect(item.note).toBe(
|
|
805
812
|
classifyTool(bin({ name: "crush", via: "npm" }), f).note,
|
|
806
813
|
);
|
|
@@ -813,6 +820,7 @@ describe("binNeedsWork — one predicate for apply, the meter and 'has work'", (
|
|
|
813
820
|
"upgrade",
|
|
814
821
|
"current",
|
|
815
822
|
"unknown",
|
|
823
|
+
"external",
|
|
816
824
|
];
|
|
817
825
|
const itemFor = (action: BinUpdateAction): BinUpdateItem => ({
|
|
818
826
|
name: `t-${action}`,
|
|
@@ -824,12 +832,13 @@ describe("binNeedsWork — one predicate for apply, the meter and 'has work'", (
|
|
|
824
832
|
sources: [],
|
|
825
833
|
});
|
|
826
834
|
|
|
827
|
-
test("install and upgrade are work; current and
|
|
835
|
+
test("install and upgrade are work; current, unknown and external are not", () => {
|
|
828
836
|
expect(actions.map((a) => binNeedsWork(itemFor(a)))).toEqual([
|
|
829
837
|
true,
|
|
830
838
|
true,
|
|
831
839
|
false,
|
|
832
840
|
false,
|
|
841
|
+
false,
|
|
833
842
|
]);
|
|
834
843
|
});
|
|
835
844
|
|
|
@@ -974,7 +983,29 @@ describe("planHasWork / summarizePlan", () => {
|
|
|
974
983
|
unknownTools: 2,
|
|
975
984
|
unknown: 3,
|
|
976
985
|
current: 1,
|
|
977
|
-
kept:
|
|
986
|
+
kept: 0,
|
|
987
|
+
external: 1,
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test("a tool installed elsewhere is counted apart, never as unknown", () => {
|
|
992
|
+
const p = plan({
|
|
993
|
+
bins: planBinUpdates(
|
|
994
|
+
[
|
|
995
|
+
{ name: "crush", via: "npm", sources: [] },
|
|
996
|
+
{ name: "tmux", via: "brew", sources: [] },
|
|
997
|
+
],
|
|
998
|
+
[
|
|
999
|
+
toolFacts("crush", { managed: false }),
|
|
1000
|
+
toolFacts("tmux", { behind: false }),
|
|
1001
|
+
],
|
|
1002
|
+
),
|
|
1003
|
+
});
|
|
1004
|
+
expect(summarizePlan(p)).toMatchObject({
|
|
1005
|
+
unknownTools: 0,
|
|
1006
|
+
unknown: 0,
|
|
1007
|
+
current: 1,
|
|
1008
|
+
external: 1,
|
|
978
1009
|
});
|
|
979
1010
|
});
|
|
980
1011
|
});
|
package/src/cli/update-view.ts
CHANGED
|
@@ -26,12 +26,14 @@
|
|
|
26
26
|
* own word, so the report survives NO_COLOR, a pipe, and colour blindness.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { brewUnpinCommand } from "../services/toolchain.js";
|
|
29
30
|
import { binCommandFor } from "../services/update-engine.js";
|
|
30
31
|
import {
|
|
31
32
|
type BinUpdateItem,
|
|
32
33
|
type PluginUpdateItem,
|
|
33
34
|
type SkillUpdateItem,
|
|
34
35
|
type UpdatePlan,
|
|
36
|
+
binHeldBack,
|
|
35
37
|
isKeptDeprecated,
|
|
36
38
|
} from "../services/update-plan.js";
|
|
37
39
|
import type {
|
|
@@ -90,7 +92,7 @@ type ActionKey =
|
|
|
90
92
|
| "refresh"
|
|
91
93
|
| "stale-load"
|
|
92
94
|
| "remove"
|
|
93
|
-
| "
|
|
95
|
+
| "held"
|
|
94
96
|
| "unknown";
|
|
95
97
|
|
|
96
98
|
const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
@@ -112,9 +114,10 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
|
112
114
|
// dead weight Claude Code refuses to load, and the row is work, not a shrug.
|
|
113
115
|
// The plugin table shows the group's DEPRECATED chip, not this label.
|
|
114
116
|
remove: { glyph: "✗", label: "REMOVE", color: brand.danger },
|
|
115
|
-
// A tool
|
|
116
|
-
//
|
|
117
|
-
|
|
117
|
+
// A tool brew reports behind but holds with a pin: checked, behind, and the
|
|
118
|
+
// user's to release (`brew unpin`). Warning, like a decision waiting. Four
|
|
119
|
+
// characters, so CHIP_WIDTH is unchanged.
|
|
120
|
+
held: { glyph: "‖", label: "HELD", color: brand.warning },
|
|
118
121
|
// Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
|
|
119
122
|
// as "broken". The summary line and the TUI already use warning for it.
|
|
120
123
|
unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
|
|
@@ -428,57 +431,125 @@ function pluginDetail(item: PluginUpdateItem): string {
|
|
|
428
431
|
}
|
|
429
432
|
|
|
430
433
|
/**
|
|
431
|
-
*
|
|
434
|
+
* How the CLI section treats one tool: a ROW kind, which the user can act on,
|
|
435
|
+
* or a COUNTED kind, which only the header tallies. One word per kind, shared
|
|
436
|
+
* by the header and the row.
|
|
432
437
|
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
438
|
+
* The counted kinds have no {@link ACTIONS} entry on purpose: they are never a
|
|
439
|
+
* row, and a long label there ("INSTALLED ELSEWHERE") would widen CHIP_WIDTH —
|
|
440
|
+
* and with it every chip in every section.
|
|
436
441
|
*/
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
442
|
+
type BinRowKind = "install" | "upgrade" | "held";
|
|
443
|
+
type BinCountedKind = "current" | "elsewhere" | "unchecked";
|
|
444
|
+
type BinKind = BinRowKind | BinCountedKind;
|
|
445
|
+
|
|
446
|
+
function binKind(item: BinUpdateItem): BinKind {
|
|
447
|
+
switch (item.action) {
|
|
448
|
+
case "install":
|
|
449
|
+
return "install";
|
|
450
|
+
case "upgrade":
|
|
451
|
+
return "upgrade";
|
|
452
|
+
case "current":
|
|
453
|
+
return "current";
|
|
454
|
+
case "external":
|
|
455
|
+
return "elsewhere";
|
|
456
|
+
case "unknown":
|
|
457
|
+
return binHeldBack(item) ? "held" : "unchecked";
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const isRowKind = (kind: BinKind): kind is BinRowKind =>
|
|
462
|
+
kind === "install" || kind === "upgrade" || kind === "held";
|
|
463
|
+
|
|
464
|
+
/** The header's words for `n` tools of one kind. */
|
|
465
|
+
function binLegend(kind: BinKind, n: number): string {
|
|
466
|
+
switch (kind) {
|
|
467
|
+
case "install":
|
|
468
|
+
case "upgrade":
|
|
469
|
+
case "current":
|
|
470
|
+
return fg(
|
|
471
|
+
ACTIONS[kind].color,
|
|
472
|
+
`${n} ${ACTIONS[kind].label.toLowerCase()}`,
|
|
473
|
+
);
|
|
474
|
+
case "held":
|
|
475
|
+
return fg(ACTIONS.held.color, `${n} held back`);
|
|
476
|
+
case "elsewhere":
|
|
477
|
+
return dim(`${n} installed elsewhere`);
|
|
478
|
+
case "unchecked":
|
|
479
|
+
return dim(`${n} not checked`);
|
|
480
|
+
}
|
|
445
481
|
}
|
|
446
482
|
|
|
447
|
-
|
|
483
|
+
/** What a tool row says on its right: the command the user would run. */
|
|
484
|
+
function binDetail(
|
|
485
|
+
kind: BinRowKind,
|
|
486
|
+
item: BinUpdateItem,
|
|
487
|
+
plan: UpdatePlan,
|
|
488
|
+
): string {
|
|
448
489
|
const spec = plan.binSpecs.get(item.name);
|
|
490
|
+
if (kind === "held") {
|
|
491
|
+
// magus never runs it: releasing a pin is the user's decision.
|
|
492
|
+
return `${item.installed ?? "—"} ${dim(
|
|
493
|
+
formatCommand(brewUnpinCommand(spec ?? { name: item.name, via: "brew" })),
|
|
494
|
+
)}`;
|
|
495
|
+
}
|
|
449
496
|
// Rendered for the reader's eyes only — this row says what WILL run, while
|
|
450
497
|
// applyBins runs the argv itself. Same builder, so they cannot differ.
|
|
451
498
|
const cmd = spec ? binCommandFor(item, spec) : null;
|
|
452
499
|
const command = cmd ? dim(formatCommand(cmd)) : "";
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
500
|
+
if (kind === "install") return command;
|
|
501
|
+
return `${dim(item.installed ?? "—")} ${fg(
|
|
502
|
+
ACTIONS.upgrade.color,
|
|
503
|
+
"→",
|
|
504
|
+
)} ${fg(ACTIONS.upgrade.color, item.target ?? "?")}${
|
|
505
|
+
command ? ` ${command}` : ""
|
|
506
|
+
}${
|
|
507
|
+
// An upgrade that also changes the tool's owner says so here; a plain
|
|
508
|
+
// one reads identically to before.
|
|
509
|
+
item.adopted ? ` ${dim(`(${item.adopted})`)}` : ""
|
|
510
|
+
}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The CLI section: a header that counts every tool by kind, then a row for
|
|
515
|
+
* each tool the user can act on — an install, an upgrade, or a held-back tool
|
|
516
|
+
* whose `brew unpin` is theirs to run.
|
|
517
|
+
*
|
|
518
|
+
* A tool the user cannot act on — current, installed elsewhere, not checked —
|
|
519
|
+
* gets no row, and is never dropped: the header counts it, so the total
|
|
520
|
+
* matches the tools the profile declares. A pinned current tool's pin is not
|
|
521
|
+
* shown either; the profile states it.
|
|
522
|
+
*/
|
|
523
|
+
function binSection(plan: UpdatePlan, nameWidth: number): string[] {
|
|
524
|
+
const items = plan.bins;
|
|
525
|
+
if (items.length === 0) return [];
|
|
526
|
+
const counts = new Map<BinKind, number>();
|
|
527
|
+
const rows: string[] = [];
|
|
528
|
+
for (const item of items) {
|
|
529
|
+
const kind = binKind(item);
|
|
530
|
+
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
531
|
+
if (!isRowKind(kind)) continue;
|
|
532
|
+
rows.push(
|
|
533
|
+
`${PAD}${glyph(kind)} ${rowText(
|
|
534
|
+
kind,
|
|
535
|
+
padEnd(item.name, nameWidth),
|
|
536
|
+
)} ${chip(kind)} ${binDetail(kind, item, plan)}`,
|
|
537
|
+
);
|
|
481
538
|
}
|
|
539
|
+
// Tools with another copy later on PATH: counted, never described. Which
|
|
540
|
+
// copy to keep is the user's call, and nothing magus could run settles it.
|
|
541
|
+
const shadowed = items.filter((item) => (item.otherCopies ?? 0) > 0).length;
|
|
542
|
+
const legend = [
|
|
543
|
+
...[...counts].map(([kind, n]) => binLegend(kind, n)),
|
|
544
|
+
...(shadowed > 0 ? [dim(`${shadowed} with another copy on PATH`)] : []),
|
|
545
|
+
].join(dim(" · "));
|
|
546
|
+
return [
|
|
547
|
+
"",
|
|
548
|
+
`${PAD}${bold(padEnd("CLI", 9))} ${dim(
|
|
549
|
+
padStart(String(items.length), 3),
|
|
550
|
+
)} ${legend}`,
|
|
551
|
+
...(rows.length > 0 ? ["", ...rows] : []),
|
|
552
|
+
];
|
|
482
553
|
}
|
|
483
554
|
|
|
484
555
|
/**
|
|
@@ -718,8 +789,8 @@ export function planReport(plan: UpdatePlan): string[] {
|
|
|
718
789
|
...plan.skills.map((s) => width(s.name)),
|
|
719
790
|
);
|
|
720
791
|
|
|
721
|
-
// EVERY planned plugin gets a row. There is no filter here and
|
|
722
|
-
// not be one.
|
|
792
|
+
// EVERY planned plugin gets a row. There is no filter on plugins here and
|
|
793
|
+
// there must not be one.
|
|
723
794
|
//
|
|
724
795
|
// A previous revision dropped unlisted, not-installed rows
|
|
725
796
|
// — a pin naming a plugin no marketplace offers — on the grounds that a
|
|
@@ -728,21 +799,14 @@ export function planReport(plan: UpdatePlan): string[] {
|
|
|
728
799
|
// notice, while the closing lines still counted it and said "Reasons are
|
|
729
800
|
// shown above", pointing at nothing.
|
|
730
801
|
//
|
|
731
|
-
// A report may never silently drop
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
//
|
|
802
|
+
// A report may never silently drop an item. Tools are the one section that
|
|
803
|
+
// hides rows — a tool the user cannot act on (current, installed elsewhere,
|
|
804
|
+
// unknown with no fix) gets none — and it is not silent: `binSection`'s
|
|
805
|
+
// header counts EVERY tool by state, so the total always matches the plan
|
|
806
|
+
// and every hidden tool is accounted for by its state.
|
|
735
807
|
return [
|
|
736
808
|
...pluginSection(plan.plugins, nameWidth),
|
|
737
|
-
...
|
|
738
|
-
"CLI",
|
|
739
|
-
plan.bins.map((item: BinUpdateItem) => ({
|
|
740
|
-
action: item.action as ActionKey,
|
|
741
|
-
name: item.name,
|
|
742
|
-
detail: binDetail(item, plan),
|
|
743
|
-
})),
|
|
744
|
-
nameWidth,
|
|
745
|
-
),
|
|
809
|
+
...binSection(plan, nameWidth),
|
|
746
810
|
...section(
|
|
747
811
|
"Skills",
|
|
748
812
|
plan.skills.map((item: SkillUpdateItem) => ({
|
|
@@ -819,11 +883,13 @@ export function summaryLine(
|
|
|
819
883
|
ms: number,
|
|
820
884
|
unchanged = 0,
|
|
821
885
|
kept = 0,
|
|
886
|
+
elsewhere = 0,
|
|
887
|
+
held = 0,
|
|
822
888
|
): string[] {
|
|
823
889
|
const bar = stackedBar(BAR * 2, [
|
|
824
890
|
{ value: changed, color: brand.success },
|
|
825
|
-
{ value: current + kept, color: brand.muted },
|
|
826
|
-
{ value: unchanged + unknown, color: brand.warning },
|
|
891
|
+
{ value: current + kept + elsewhere, color: brand.muted },
|
|
892
|
+
{ value: unchanged + held + unknown, color: brand.warning },
|
|
827
893
|
{ value: failed, color: brand.danger },
|
|
828
894
|
]);
|
|
829
895
|
const parts = [
|
|
@@ -833,6 +899,10 @@ export function summaryLine(
|
|
|
833
899
|
// Deprecated but kept on purpose (moved, still required): settled, so
|
|
834
900
|
// grey beside `current`, and never folded into "unverified".
|
|
835
901
|
kept > 0 ? dim(`${kept} kept`) : "",
|
|
902
|
+
// Tools installed another way: found and left alone, so settled too.
|
|
903
|
+
elsewhere > 0 ? dim(`${elsewhere} installed elsewhere`) : "",
|
|
904
|
+
// Behind but pinned by the user: checked, so never "unverified".
|
|
905
|
+
held > 0 ? fg(brand.warning, `${held} held back`) : "",
|
|
836
906
|
unknown > 0 ? fg(brand.warning, `${unknown} unverified`) : "",
|
|
837
907
|
failed > 0 ? fg(brand.danger, `${failed} failed`) : "",
|
|
838
908
|
].filter(Boolean);
|
|
@@ -875,29 +945,63 @@ export function note(text: string): string {
|
|
|
875
945
|
// false for a plugin whose marketplace publishes no version: re-running changes
|
|
876
946
|
// nothing, and the reader went looking for a network fault that did not exist.
|
|
877
947
|
|
|
878
|
-
/** Unverified tools, under the plan. */
|
|
879
|
-
export function unknownToolsNotice(count: number, names: string[]): string {
|
|
880
|
-
return warn(
|
|
881
|
-
`${bold(String(count))} tool(s) could not be checked: ${names.join(", ")} — not applied. Reasons are shown above.`,
|
|
882
|
-
);
|
|
883
|
-
}
|
|
884
|
-
|
|
885
948
|
/**
|
|
886
949
|
* `--check` is about to exit 0 while N items went unchecked.
|
|
887
950
|
*
|
|
888
951
|
* The gate does not fail on an unknown — plugin or tool — because most unknowns
|
|
889
|
-
* are permanent (a marketplace that publishes no version, a
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
952
|
+
* are permanent (a marketplace that publishes no version, a private registry),
|
|
953
|
+
* and a gate that can never pass protects nothing. This line is what keeps the
|
|
954
|
+
* exit 0 honest: it is "nothing is KNOWN to be behind", not "everything was
|
|
955
|
+
* verified".
|
|
956
|
+
*
|
|
957
|
+
* Each kind is named with what the report above holds for it, and no more: a
|
|
958
|
+
* plugin's reason is on its row, while a tool that could not be checked has
|
|
959
|
+
* no row, only a count in the CLI header. A tool installed another way or held
|
|
960
|
+
* back was checked, and is never counted here.
|
|
894
961
|
*/
|
|
895
|
-
export function uncheckedGateNotice(
|
|
962
|
+
export function uncheckedGateNotice(plugins: number, tools: number): string {
|
|
963
|
+
const what = [
|
|
964
|
+
plugins > 0 ? `${bold(String(plugins))} plugin(s)` : "",
|
|
965
|
+
tools > 0 ? `${bold(String(tools))} tool(s)` : "",
|
|
966
|
+
]
|
|
967
|
+
.filter(Boolean)
|
|
968
|
+
.join(" and ");
|
|
896
969
|
return warn(
|
|
897
|
-
`${
|
|
970
|
+
`${what} could not be checked — they do not fail this gate. They are NOT reported as up to date.${
|
|
971
|
+
plugins > 0 ? " Each plugin's reason is shown above." : ""
|
|
972
|
+
}`,
|
|
898
973
|
);
|
|
899
974
|
}
|
|
900
975
|
|
|
976
|
+
/**
|
|
977
|
+
* The run's closing line when there is nothing to apply.
|
|
978
|
+
*
|
|
979
|
+
* "Everything is up to date." only when it is TRUE: nothing unchecked and
|
|
980
|
+
* nothing held back. A plugin that could not be checked keeps its warning; a
|
|
981
|
+
* tool that could not be checked, or one held back, gets a dim note — it is
|
|
982
|
+
* not a failure, and it is not "up to date" either.
|
|
983
|
+
*/
|
|
984
|
+
export function nothingToApplyLines(counts: {
|
|
985
|
+
unknownPlugins: number;
|
|
986
|
+
unknownTools: number;
|
|
987
|
+
heldBack: number;
|
|
988
|
+
}): string[] {
|
|
989
|
+
const tools = [
|
|
990
|
+
counts.unknownTools > 0 ? `${counts.unknownTools} tool(s) not checked` : "",
|
|
991
|
+
counts.heldBack > 0 ? `${counts.heldBack} held back` : "",
|
|
992
|
+
]
|
|
993
|
+
.filter(Boolean)
|
|
994
|
+
.join(" · ");
|
|
995
|
+
if (counts.unknownPlugins > 0) {
|
|
996
|
+
return [
|
|
997
|
+
nothingToApplyNotice(counts.unknownPlugins),
|
|
998
|
+
...(tools ? [note(`${tools}.`)] : []),
|
|
999
|
+
];
|
|
1000
|
+
}
|
|
1001
|
+
if (tools) return [note(`Nothing to apply. ${tools}.`)];
|
|
1002
|
+
return [ok("Everything is up to date.")];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
901
1005
|
/** The run ends with nothing to apply because every remaining plugin is unverified. */
|
|
902
1006
|
export function nothingToApplyNotice(unknownPlugins: number): string {
|
|
903
1007
|
return warn(
|