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.
Files changed (39) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +0 -1
  3. package/src/__tests__/blackbox-parity.test.ts +2 -2
  4. package/src/__tests__/blackbox-tool-state.test.ts +8 -18
  5. package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
  6. package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
  7. package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
  8. package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
  9. package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
  10. package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +38 -35
  11. package/src/__tests__/cli-tool-commands.test.ts +2 -2
  12. package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
  13. package/src/__tests__/cli-tools-adapter.test.ts +32 -22
  14. package/src/__tests__/cli-update-view.test.ts +124 -51
  15. package/src/__tests__/fixtures/bins.ts +9 -2
  16. package/src/__tests__/npm-package-name.test.ts +3 -2
  17. package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
  18. package/src/__tests__/tool-probe.test.ts +231 -144
  19. package/src/__tests__/tool-rows-followups.test.ts +315 -0
  20. package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
  21. package/src/__tests__/tool-versions.test.ts +164 -0
  22. package/src/__tests__/toolchain.test.ts +0 -2
  23. package/src/__tests__/update-plan.test.ts +40 -9
  24. package/src/cli/update-view.ts +180 -76
  25. package/src/cli/update.ts +44 -37
  26. package/src/data/cli-tools.ts +5 -10
  27. package/src/data/tool-versions.ts +137 -0
  28. package/src/services/cli-tool-commands.ts +1 -2
  29. package/src/services/cli-tool-version.ts +10 -88
  30. package/src/services/resolver.ts +9 -15
  31. package/src/services/tool-probe.ts +537 -691
  32. package/src/services/toolchain.ts +10 -4
  33. package/src/services/update-engine.ts +9 -15
  34. package/src/services/update-plan.ts +49 -21
  35. package/src/types/index.ts +0 -2
  36. package/src/ui/adapters/cliToolsAdapter.ts +31 -22
  37. package/src/ui/renderers/cliToolRenderers.tsx +15 -21
  38. package/src/ui/state/types.ts +0 -1
  39. package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
@@ -12,20 +12,20 @@
12
12
  */
13
13
 
14
14
  import { afterEach, describe, expect, test } from "bun:test";
15
- import { setColorEnabled, stripAnsi, width } from "../cli/ansi.js";
15
+ import { dim, setColorEnabled, stripAnsi, width } from "../cli/ansi.js";
16
16
  import {
17
17
  type ApplyState,
18
18
  type Step,
19
19
  applyFrame,
20
20
  applyRow,
21
21
  header,
22
+ nothingToApplyLines,
22
23
  nothingToApplyNotice,
23
24
  planReport,
24
25
  stepFrame,
25
26
  stepRecord,
26
27
  summaryLine,
27
28
  uncheckedGateNotice,
28
- unknownToolsNotice,
29
29
  } from "../cli/update-view.js";
30
30
  import type { UpdatePlan } from "../services/update-plan.js";
31
31
 
@@ -200,11 +200,11 @@ describe("NO_COLOR", () => {
200
200
  // Colour never carries meaning alone: the report has to survive a pipe
201
201
  // and colour blindness. Plugins say their state as a GROUP HEADING and a
202
202
  // glyph; the tool and skill sections still badge each row with its own
203
- // action word.
203
+ // action word. A current tool gets no row; its header count says it.
204
204
  for (const heading of ["Update", "Fix required", "Current"])
205
205
  expect(out).toContain(heading);
206
- for (const label of ["CURRENT", "UPGRADE", "INSTALL"])
207
- expect(out).toContain(label);
206
+ for (const label of ["UPGRADE", "INSTALL"]) expect(out).toContain(label);
207
+ expect(out).toContain("1 current");
208
208
  for (const glyph of ["=", "↑", "+", "?"]) expect(out).toContain(glyph);
209
209
  });
210
210
 
@@ -414,58 +414,54 @@ describe("sections", () => {
414
414
  expect(row).toContain("3.7b → 3.7c");
415
415
  });
416
416
 
417
- test("an unpinned CURRENT tool shows its version, not 'pinned —' (F8)", () => {
417
+ test("a CURRENT tool gets no row, and the header counts it", () => {
418
418
  setColorEnabled(false);
419
- const p = plan();
420
- p.bins = [
421
- {
422
- name: "tmux",
423
- action: "current",
424
- reason: "up-to-date",
425
- installed: "3.7c",
426
- latest: null,
427
- target: null,
428
- sources: [],
429
- },
430
- ];
431
- const row = planReport(p).find(
432
- (l) => l.includes("tmux ") && l.includes("CURRENT"),
433
- )!;
434
- expect(row).toContain("3.7c");
435
- expect(row).not.toContain("pinned");
436
- });
437
-
438
- test("a pinned current tool still names its pin", () => {
439
- setColorEnabled(false);
440
- const row = planReport(plan()).find((l) => l.includes("tmux-mcp"))!;
441
- expect(row).toContain("pinned v1.7.1");
419
+ const out = planReport(plan());
420
+ expect(out.some((l) => l.includes("tmux-mcp"))).toBe(false);
421
+ const header = out.find((l) => /^\s*CLI\s/.test(l))!;
422
+ expect(header).toMatch(/CLI\s+2\s/);
423
+ expect(header).toContain("1 current");
424
+ expect(header).toContain("1 upgrade");
442
425
  });
443
426
 
444
- test("an UNKNOWN tool shows its reason and no command", () => {
427
+ test("an unknown tool with no fix gets no row, is counted 'not checked', and no path is printed", () => {
445
428
  setColorEnabled(false);
446
429
  const p = plan();
447
430
  p.bins = [
448
431
  {
449
432
  name: "crush",
450
433
  action: "unknown",
451
- reason: "not-managed",
452
- note: "on PATH at /n/crush.js, which npm (root /l) does not own",
434
+ reason: "owner-unknown",
435
+ note: "could not tell which installer owns it (npm timed out after 8s)",
453
436
  installed: "0.7.1",
454
437
  latest: null,
455
438
  target: null,
456
439
  sources: [],
457
440
  },
441
+ {
442
+ name: "zsh",
443
+ action: "external",
444
+ reason: "installed-elsewhere",
445
+ installed: "5.9",
446
+ latest: null,
447
+ target: null,
448
+ sources: [],
449
+ },
458
450
  ];
459
451
  p.binSpecs = new Map([
460
452
  [
461
453
  "crush",
462
454
  { name: "crush", via: "npm", package: "@charmland/crush", sources: [] },
463
455
  ],
456
+ ["zsh", { name: "zsh", via: "brew", sources: [] }],
464
457
  ]);
465
- const row = planReport(p).find((l) => l.includes("crush"))!;
466
- expect(row).toContain("0.7.1");
467
- expect(row).toContain("which npm (root /l) does not own");
468
- expect(row).not.toContain("npm install");
458
+ const out = planReport(p);
459
+ expect(out.some((l) => l.includes("crush") || l.includes("zsh"))).toBe(
460
+ false,
461
+ );
462
+ const header = out.find((l) => /^\s*CLI\s/.test(l))!;
463
+ expect(header).toMatch(/CLI\s+2\s+1 not checked · 1 installed elsewhere$/);
464
+ expect(out.join("\n")).not.toContain("npm install");
469
465
  });
470
466
  });
471
467
 
@@ -476,18 +472,14 @@ describe("unverified notices", () => {
476
472
  // closing notices must hold for every one of them. They used to say "re-run
477
473
  // once the catalog is reachable", which is false advice for a plugin whose
478
474
  // marketplace publishes no version — the case a real dry run showed five of.
479
- const notices = () => [
480
- nothingToApplyNotice(5),
481
- unknownToolsNotice(1, ["crush"]),
482
- uncheckedGateNotice(6),
483
- ];
475
+ const notices = () => [nothingToApplyNotice(5), uncheckedGateNotice(6, 0)];
484
476
 
485
477
  test("never advise a re-run, never blame the catalog", () => {
486
478
  setColorEnabled(false);
487
479
  for (const line of notices()) {
488
480
  expect(line).not.toMatch(/re-run/i);
489
481
  expect(line).not.toContain("catalog is reachable");
490
- expect(line).toContain("Reasons are shown above.");
482
+ expect(line).toMatch(/[Rr]eason(s are| is) shown above\./);
491
483
  }
492
484
  });
493
485
 
@@ -503,22 +495,103 @@ describe("unverified notices", () => {
503
495
  expect(nothing).toContain("They are NOT reported as up to date.");
504
496
  });
505
497
 
506
- test("the tool notice names the tools it could not check", () => {
507
- setColorEnabled(false);
508
- expect(unknownToolsNotice(2, ["crush", "tmux"])).toContain(
509
- "2 tool(s) could not be checked: crush, tmux — not applied.",
510
- );
511
- });
512
-
513
498
  test("the gate notice says the unknowns passed without being verified", () => {
514
499
  // `--check` exits 0 with unknowns present, so this line carries the whole
515
500
  // difference between "nothing is known to be behind" and "all verified".
516
501
  setColorEnabled(false);
517
- const line = uncheckedGateNotice(6);
518
- expect(line).toContain("6 item(s) could not be checked");
502
+ const line = uncheckedGateNotice(4, 2);
503
+ expect(line).toContain("4 plugin(s) and 2 tool(s) could not be checked");
519
504
  expect(line).toContain("they do not fail this gate");
520
505
  expect(line).toContain("They are NOT reported as up to date.");
521
506
  // A warning, not a failure: the gate is about to pass.
522
507
  expect(line.trimStart()).toStartWith("⚠");
523
508
  });
509
+
510
+ test("the gate notice points at reasons only where the report shows them", () => {
511
+ // A plugin's reason is on its row; an unchecked tool has no row, only a
512
+ // count in the CLI header — so a tools-only notice claims no reasons.
513
+ setColorEnabled(false);
514
+ expect(uncheckedGateNotice(1, 0)).toContain(
515
+ "Each plugin's reason is shown above.",
516
+ );
517
+ const tools = uncheckedGateNotice(0, 2);
518
+ expect(tools).toContain("2 tool(s) could not be checked");
519
+ expect(tools).not.toContain("shown above");
520
+ expect(tools).not.toContain("plugin");
521
+ });
522
+ });
523
+
524
+ describe("nothing to apply", () => {
525
+ const lines = (c: {
526
+ unknownPlugins: number;
527
+ unknownTools: number;
528
+ heldBack: number;
529
+ }) => {
530
+ setColorEnabled(false);
531
+ return nothingToApplyLines(c);
532
+ };
533
+
534
+ test("'Everything is up to date' only when nothing is unchecked or held back", () => {
535
+ expect(lines({ unknownPlugins: 0, unknownTools: 0, heldBack: 0 })).toEqual([
536
+ " ✓ Everything is up to date.",
537
+ ]);
538
+ });
539
+
540
+ test("unchecked tools alone: a dim note, never the success line, never a warning", () => {
541
+ const out = lines({ unknownPlugins: 0, unknownTools: 2, heldBack: 0 });
542
+ expect(out).toHaveLength(1);
543
+ expect(out[0]!.trim()).toBe("Nothing to apply. 2 tool(s) not checked.");
544
+ expect(out.join("\n")).not.toContain("up to date");
545
+ expect(out.join("\n")).not.toMatch(/[✓⚠]/);
546
+ });
547
+
548
+ test("a held-back tool alone is not 'up to date' either", () => {
549
+ const out = lines({ unknownPlugins: 0, unknownTools: 0, heldBack: 1 });
550
+ expect(out.map((l) => l.trim())).toEqual([
551
+ "Nothing to apply. 1 held back.",
552
+ ]);
553
+ });
554
+
555
+ test("unverified plugins keep their warning; tools add a dim count", () => {
556
+ const out = lines({ unknownPlugins: 3, unknownTools: 1, heldBack: 1 });
557
+ expect(out[0]).toBe(nothingToApplyNotice(3));
558
+ expect(out[1]!.trim()).toBe("1 tool(s) not checked · 1 held back.");
559
+ expect(out.join("\n")).not.toContain("Everything is up to date");
560
+ });
561
+
562
+ test("the dim note really is dim when colour is on", () => {
563
+ setColorEnabled(true);
564
+ const [line] = nothingToApplyLines({
565
+ unknownPlugins: 0,
566
+ unknownTools: 1,
567
+ heldBack: 0,
568
+ });
569
+ expect(line).toContain(dim("Nothing to apply. 1 tool(s) not checked."));
570
+ });
571
+ });
572
+
573
+ describe("a held-back tool", () => {
574
+ test("is its own kind: a HELD row with `brew unpin`, counted 'held back'", () => {
575
+ setColorEnabled(false);
576
+ const p = plan();
577
+ p.bins = [
578
+ {
579
+ name: "tmux",
580
+ action: "unknown",
581
+ reason: "held-back",
582
+ note: "held by brew pin",
583
+ installed: "3.7b",
584
+ latest: "3.7c",
585
+ target: null,
586
+ sources: [],
587
+ },
588
+ ];
589
+ const out = planReport(p);
590
+ const header = out.find((l) => /^\s*CLI\s/.test(l))!;
591
+ expect(header).toMatch(/CLI\s+1\s+1 held back$/);
592
+ const row = out.find((l) => l.includes("tmux "))!;
593
+ expect(row).toContain("HELD");
594
+ expect(row).toContain("brew unpin tmux");
595
+ expect(row).not.toContain("UNKNOWN");
596
+ });
524
597
  });
@@ -14,8 +14,15 @@ import type {
14
14
  SelectedBin,
15
15
  } from "../../types/index.js";
16
16
 
17
+ /**
18
+ * A version-managed tool, plus the REQUIREMENT's presence check as one line
19
+ * (`Requirement.check`, which doctor runs). ManagedTool itself carries no check:
20
+ * the update probe reads versions only through magus's own table.
21
+ */
22
+ export type TestTool = ManagedTool & { check?: string };
23
+
17
24
  /** A selected requirement whose one option (and choice) is `t`'s installer. */
18
- export function selected(t: ManagedTool, root?: string): SelectedBin {
25
+ export function selected(t: TestTool, root?: string): SelectedBin {
19
26
  const option = managedOption(t);
20
27
  return {
21
28
  name: t.name,
@@ -27,7 +34,7 @@ export function selected(t: ManagedTool, root?: string): SelectedBin {
27
34
  }
28
35
 
29
36
  /** {@link selected}, with provenance. */
30
- export function resolved(t: ManagedTool, sources: string[]): ResolvedBin {
37
+ export function resolved(t: TestTool, sources: string[]): ResolvedBin {
31
38
  return { ...selected(t), sources };
32
39
  }
33
40
 
@@ -19,6 +19,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
19
19
  import { readFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { cliTools } from "../data/cli-tools.js";
22
+ import { presenceArgv } from "../data/tool-versions.js";
22
23
  import {
23
24
  cliToolInstall,
24
25
  cliToolUninstall,
@@ -192,7 +193,8 @@ describe("the CLI Tools entry for magus", () => {
192
193
  // R6: everything that names the command is unchanged.
193
194
  expect(entry.name).toBe(COMMAND);
194
195
  expect(entry.displayName).toBe(COMMAND);
195
- expect(entry.checkCommand).toBe("magus --version");
196
+ // The version command is magus's one table, keyed by the command.
197
+ expect(presenceArgv(entry.name)).toEqual(["magus", "--version"]);
196
198
  expect(entry.packageManager).toBe("bun");
197
199
  });
198
200
 
@@ -271,7 +273,6 @@ describe("pins and probes resolve to the package", () => {
271
273
  name: COMMAND,
272
274
  via: "bun",
273
275
  package: PACKAGE,
274
- check: "magus --version",
275
276
  });
276
277
  });
277
278
  });
@@ -0,0 +1,167 @@
1
+ // REGRESSION: brew adopted as owner of a same-version copy PATH does not run — release review of magus 7.10.4
2
+ /**
3
+ * docs/plans/2026-10-08-magus-update-tool-rows.md, decision 7: when the
4
+ * declared installer does not have the file PATH runs, magus asks the other
5
+ * installers it drives. Ownership is the installer's OWN answer about the file
6
+ * PATH runs. A same-version string in `brew list --versions` is not that
7
+ * answer: brew's own file list for the formula (`brew list <formula>`, one
8
+ * absolute path per line) is.
9
+ *
10
+ * Blind tests: written from the spec and the exported ToolProbeIO seam only.
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import {
15
+ type ExecResult,
16
+ type ToolFacts,
17
+ type ToolProbeIO,
18
+ classifyTool,
19
+ probeTool,
20
+ } from "../services/tool-probe.js";
21
+ import type { ManagedTool } from "../types/index.js";
22
+
23
+ type Handler = Partial<ExecResult>;
24
+
25
+ interface Setup {
26
+ exec: Record<string, Handler>;
27
+ onPath: Record<string, string>;
28
+ real: Record<string, string>;
29
+ json?: Record<string, unknown>;
30
+ env: Record<string, string>;
31
+ }
32
+
33
+ const MISSING: ExecResult = {
34
+ code: null,
35
+ stdout: "",
36
+ stderr: "",
37
+ timedOut: false,
38
+ missing: true,
39
+ };
40
+
41
+ function fakeIO(setup: Setup): ToolProbeIO & { calls: string[] } {
42
+ const calls: string[] = [];
43
+ return {
44
+ calls,
45
+ exec: async (command) => {
46
+ const key = [command.cmd, ...command.args].join(" ");
47
+ calls.push(key);
48
+ const h = setup.exec[key];
49
+ if (h === undefined) return MISSING;
50
+ return {
51
+ code: 0,
52
+ stdout: "",
53
+ stderr: "",
54
+ timedOut: false,
55
+ missing: false,
56
+ ...h,
57
+ };
58
+ },
59
+ fetchJson: async () => ({ ok: false, reason: "returned 404" }),
60
+ readJson: async (file) => setup.json?.[file] ?? null,
61
+ readText: async () => null,
62
+ readlink: async () => null,
63
+ realpath: async (file) => setup.real[file] ?? file,
64
+ resolveExecutable: async (name) => setup.onPath[name] ?? null,
65
+ env: setup.env,
66
+ homedir: "/Users/j",
67
+ };
68
+ }
69
+
70
+ const OPTS = { timeoutMs: 500, deadlineMs: 3000 };
71
+ const BUN_HOME = "/Users/j/.bun";
72
+ const ENV = { HOMEBREW_PREFIX: "/opt/homebrew", BUN_INSTALL: BUN_HOME };
73
+ const ON_PATH = "/usr/local/bin/claudish";
74
+ const BREW_FILE = "/opt/homebrew/Cellar/claudish/10.4.1/bin/claudish";
75
+
76
+ const OUTDATED_BEHIND = JSON.stringify({
77
+ formulae: [
78
+ {
79
+ name: "claudish",
80
+ installed_versions: ["10.4.1"],
81
+ current_version: "10.4.2",
82
+ pinned: false,
83
+ pinned_version: null,
84
+ },
85
+ ],
86
+ casks: [],
87
+ });
88
+
89
+ /** brew's own file list for the formula: one absolute path per line. */
90
+ const brewFiles = (...extra: string[]): string =>
91
+ `${[
92
+ BREW_FILE,
93
+ "/opt/homebrew/Cellar/claudish/10.4.1/INSTALL_RECEIPT.json",
94
+ "/opt/homebrew/Cellar/claudish/10.4.1/README.md",
95
+ ...extra,
96
+ ].join("\n")}\n`;
97
+
98
+ /** A world where bun is declared, bun's tree has no `claudish`, brew has 10.4.1. */
99
+ function world(resolved: string, json?: Record<string, unknown>) {
100
+ return fakeIO({
101
+ env: ENV,
102
+ onPath: { claudish: ON_PATH },
103
+ real: { [ON_PATH]: resolved },
104
+ json,
105
+ exec: {
106
+ "brew list --versions claudish": { stdout: "claudish 10.4.1\n" },
107
+ "brew list claudish": { stdout: brewFiles() },
108
+ "brew outdated --json=v2 claudish": { stdout: OUTDATED_BEHIND },
109
+ "claudish --version": { stdout: "claudish version 10.4.1\n" },
110
+ },
111
+ });
112
+ }
113
+
114
+ const bunSpec: ManagedTool = { name: "claudish", via: "bun" };
115
+
116
+ function plansBrewUpgrade(io: { calls: string[] }, f: ToolFacts): boolean {
117
+ return (
118
+ f.upgradeCommand?.cmd === "brew" ||
119
+ (f.managed === true &&
120
+ (f.sources as string[]).includes("brew") &&
121
+ io.calls.includes("brew outdated --json=v2 claudish") &&
122
+ f.behind === true)
123
+ );
124
+ }
125
+
126
+ describe("brew is credited only for a file brew itself lists", () => {
127
+ test("1. same-version twin: PATH runs a copy brew does not list — installed elsewhere, never a brew upgrade", async () => {
128
+ // Not brew's, not bun's, not under any node_modules prefix.
129
+ const io = world("/Users/j/tools/claudish-dist/claudish");
130
+ const f = await probeTool(bunSpec, io, OPTS);
131
+ const st = classifyTool(bunSpec, f);
132
+
133
+ expect(st.state).not.toBe("upgrade");
134
+ expect(f.upgradeCommand?.cmd).not.toBe("brew");
135
+ expect(plansBrewUpgrade(io, f)).toBe(false);
136
+ expect(f.managed).toBe(false);
137
+ expect(st.state as string).toBe("external");
138
+ expect(st.reason as string).toBe("installed-elsewhere");
139
+ });
140
+
141
+ test("2. positive control: brew's file list includes the file PATH runs — brew owns it, upgrade through brew", async () => {
142
+ const io = world(BREW_FILE);
143
+ const f = await probeTool(bunSpec, io, OPTS);
144
+ const st = classifyTool(bunSpec, f);
145
+
146
+ expect(f.managed).toBe(true);
147
+ expect(f.installed).toBe("10.4.1");
148
+ expect(st.state).toBe("upgrade");
149
+ expect(st.latest).toBe("10.4.2");
150
+ expect(f.upgradeCommand?.cmd).toBe("brew");
151
+ expect(f.upgradeCommand?.args).toContain("upgrade");
152
+ });
153
+
154
+ test("3. a file in bun's global tree under another package never falls through to brew adoption", async () => {
155
+ const pkg = `${BUN_HOME}/install/global/node_modules/other-pkg`;
156
+ const io = world(`${pkg}/bin/claudish.cjs`, {
157
+ [`${pkg}/package.json`]: { name: "other-pkg", version: "10.4.1" },
158
+ });
159
+ const f = await probeTool(bunSpec, io, OPTS);
160
+ const st = classifyTool(bunSpec, f);
161
+
162
+ expect(f.upgradeCommand?.cmd).not.toBe("brew");
163
+ expect(plansBrewUpgrade(io, f)).toBe(false);
164
+ expect(st.state).not.toBe("upgrade");
165
+ expect(io.calls.filter((c) => c.startsWith("brew upgrade"))).toEqual([]);
166
+ });
167
+ });