magus-cli 7.10.3 → 7.10.5

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 (44) 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__/profile-worktree-include.test.ts +212 -0
  18. package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
  19. package/src/__tests__/tool-probe.test.ts +231 -144
  20. package/src/__tests__/tool-rows-followups.test.ts +315 -0
  21. package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
  22. package/src/__tests__/tool-versions.test.ts +164 -0
  23. package/src/__tests__/toolchain.test.ts +0 -2
  24. package/src/__tests__/update-plan.test.ts +40 -9
  25. package/src/cli/update-view.ts +180 -76
  26. package/src/cli/update.ts +44 -37
  27. package/src/data/cli-tools.ts +5 -10
  28. package/src/data/tool-versions.ts +137 -0
  29. package/src/services/cli-tool-commands.ts +1 -2
  30. package/src/services/cli-tool-version.ts +10 -88
  31. package/src/services/profile-activate.ts +9 -0
  32. package/src/services/profile-live.ts +26 -0
  33. package/src/services/profile-skills.ts +14 -4
  34. package/src/services/profile-state.ts +18 -0
  35. package/src/services/resolver.ts +9 -15
  36. package/src/services/tool-probe.ts +537 -691
  37. package/src/services/toolchain.ts +10 -4
  38. package/src/services/update-engine.ts +9 -15
  39. package/src/services/update-plan.ts +49 -21
  40. package/src/types/index.ts +0 -2
  41. package/src/ui/adapters/cliToolsAdapter.ts +31 -22
  42. package/src/ui/renderers/cliToolRenderers.tsx +15 -21
  43. package/src/ui/state/types.ts +0 -1
  44. package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.10.3",
3
+ "version": "7.10.5",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -65,8 +65,8 @@
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "magus-cli-darwin-arm64": "7.10.3",
69
- "magus-cli-darwin-x64": "7.10.3",
70
- "magus-cli-linux-x64": "7.10.3"
68
+ "magus-cli-darwin-arm64": "7.10.5",
69
+ "magus-cli-darwin-x64": "7.10.5",
70
+ "magus-cli-linux-x64": "7.10.5"
71
71
  }
72
72
  }
@@ -59,7 +59,6 @@ const SPECS: Record<string, ResolvedBin> = {
59
59
  tmux: {
60
60
  name: "tmux",
61
61
  via: "brew",
62
- check: "tmux -V",
63
62
  sources: ["terminal@magus"],
64
63
  },
65
64
  gemini: {
@@ -670,8 +670,8 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
670
670
  }
671
671
 
672
672
  const crush = p.cliBins.get("crush");
673
- expect(crush?.reason).toBe("not-managed");
674
- expect(crush?.action).not.toBe("upgrade");
673
+ expect(crush?.action).toBe("external");
674
+ expect(crush?.reason).toBe("installed-elsewhere");
675
675
  expect(p.cliBins.get("gemini")?.note).toBeTruthy();
676
676
  });
677
677
 
@@ -250,7 +250,7 @@ describe("classifyTool — unpinned", () => {
250
250
  expect(got.state).toBe("unknown");
251
251
  });
252
252
 
253
- test("behind, but the upgrade would not replace the file PATH runs: not upgrade / not-managed", () => {
253
+ test("behind, but the upgrade would not replace the file PATH runs: not upgrade / installed elsewhere", () => {
254
254
  const got = classifyTool(
255
255
  { name: "crush", via: "npm" },
256
256
  facts("crush", {
@@ -261,7 +261,8 @@ describe("classifyTool — unpinned", () => {
261
261
  }),
262
262
  );
263
263
  expect(got.state).not.toBe("upgrade");
264
- expect(got.reason).toBe("not-managed");
264
+ expect(got.state).toBe("external");
265
+ expect(got.reason).toBe("installed-elsewhere");
265
266
  });
266
267
 
267
268
  test("behind, but held back by the installer: not upgrade / held-back", () => {
@@ -457,25 +458,16 @@ describe("planBinUpdates and the one work predicate", () => {
457
458
  // ── the probe, through injected IO ─────────────────────────────────────────
458
459
 
459
460
  function brewIO(opts: { installed: string; outdatedJson: string }) {
461
+ const keg = `/opt/homebrew/Cellar/tmux/${opts.installed}/bin/tmux`;
460
462
  return fakeIO({
461
463
  paths: { tmux: "/opt/homebrew/bin/tmux", brew: "/opt/homebrew/bin/brew" },
462
- realpaths: {
463
- "/opt/homebrew/bin/tmux": `/opt/homebrew/Cellar/tmux/${opts.installed}/bin/tmux`,
464
- },
465
- env: {
466
- HOMEBREW_PREFIX: "/opt/homebrew",
467
- HOMEBREW_CELLAR: "/opt/homebrew/Cellar",
468
- },
464
+ realpaths: { "/opt/homebrew/bin/tmux": keg },
469
465
  exec: ([cmd, ...args]) => {
470
466
  if (cmd === "brew") {
471
467
  if (args.includes("outdated")) return OK(opts.outdatedJson);
472
- if (args.includes("list")) return OK(`tmux ${opts.installed}\n`);
473
- if (args.includes("--cellar")) return OK("/opt/homebrew/Cellar\n");
474
- if (args.includes("--prefix"))
475
- return OK(
476
- args.length > 1 ? "/opt/homebrew/opt/tmux\n" : "/opt/homebrew\n",
477
- );
478
- if (args.includes("--repository")) return OK("/opt/homebrew\n");
468
+ if (args.includes("--versions")) return OK(`tmux ${opts.installed}\n`);
469
+ // brew's own file list for the formula names the keg file.
470
+ if (args.includes("list")) return OK(`${keg}\n`);
479
471
  }
480
472
  if (cmd === "tmux") return OK(`tmux ${opts.installed}\n`);
481
473
  return undefined;
@@ -492,7 +484,6 @@ describe("probeTool through injected IO (no real brew/uv, no network)", () => {
492
484
  const spec: BinRequirement = {
493
485
  name: "tmux",
494
486
  via: "brew",
495
- check: "tmux -V",
496
487
  };
497
488
 
498
489
  const f = await probeTool(spec, io, { timeoutMs: 500, deadlineMs: 2000 });
@@ -521,7 +512,6 @@ describe("probeTool through injected IO (no real brew/uv, no network)", () => {
521
512
  const spec: BinRequirement = {
522
513
  name: "tmux",
523
514
  via: "brew",
524
- check: "tmux -V",
525
515
  };
526
516
 
527
517
  const f = await probeTool(spec, io, { timeoutMs: 500, deadlineMs: 2000 });
@@ -113,7 +113,12 @@ const SPEC: BinRequirement = {
113
113
 
114
114
  type UnknownReason = Exclude<
115
115
  ToolStatus["reason"],
116
- "absent" | "up-to-date" | "behind" | "at-pin" | "behind-pin"
116
+ | "absent"
117
+ | "up-to-date"
118
+ | "behind"
119
+ | "at-pin"
120
+ | "behind-pin"
121
+ | "installed-elsewhere"
117
122
  >;
118
123
 
119
124
  /** Every reason that can put a row in `unknown`, with realistic versions. */
@@ -133,7 +138,7 @@ const UNKNOWN: Record<
133
138
  target: null,
134
139
  pin: "2.0.0",
135
140
  },
136
- "not-managed": { installed: "1.2.3", latest: "2.0.0", target: null },
141
+ "owner-unknown": { installed: "1.2.3", latest: "2.0.0", target: null },
137
142
  "held-back": {
138
143
  installed: "1.2.3",
139
144
  latest: "2.0.0",
@@ -184,8 +189,8 @@ describe("C14 — cliToolStatusText on an unknown row", () => {
184
189
  "ahead-of-pin",
185
190
  "held-back",
186
191
  "latest-unavailable",
187
- "not-managed",
188
192
  "not-probed",
193
+ "owner-unknown",
189
194
  "pin-not-installable",
190
195
  "unorderable",
191
196
  "version-unreadable",
@@ -277,11 +277,11 @@ describe("C15 (a) — npm package in a prefix other than `npm root -g`", () => {
277
277
  expect(prefixOf(args)).toBe(NVM);
278
278
  });
279
279
 
280
- test("R9.7 classifyTool says upgrade, never the not-managed veto", async () => {
280
+ test("R9.7 classifyTool says upgrade, never the installed-elsewhere veto", async () => {
281
281
  const { facts } = await probe(CRUSH, nvmCrush(OTHER_NPM_ROOT));
282
282
  const status = classifyTool(CRUSH, facts);
283
283
  expect(status.state).toBe("upgrade");
284
- expect(status.reason).not.toBe("not-managed");
284
+ expect(status.reason).not.toBe("installed-elsewhere");
285
285
  });
286
286
 
287
287
  test("R9.7 control: at the registry's latest, classifyTool says current (still managed)", async () => {
@@ -324,7 +324,7 @@ describe("C15 (b) — npm package in the prefix `npm root -g` reports", () => {
324
324
  });
325
325
  const status = classifyTool(CRUSH, facts);
326
326
  expect(status.state).toBe("upgrade");
327
- expect(status.reason).not.toBe("not-managed");
327
+ expect(status.reason).not.toBe("installed-elsewhere");
328
328
  });
329
329
  });
330
330
 
@@ -361,12 +361,12 @@ describe("C15 (c) — a crush binary that npm does not own", () => {
361
361
  expect(facts.managed).not.toBe(true);
362
362
  });
363
363
 
364
- test("R9.7 classifyTool keeps the not-managed veto for it (external / not-managed)", async () => {
364
+ test("R9.7 classifyTool keeps the veto for it: installed elsewhere, never an upgrade", async () => {
365
365
  const { facts } = await probe(CRUSH, elsewhere());
366
366
  const status = classifyTool(CRUSH, facts);
367
367
  expect({ state: status.state, reason: status.reason }).toEqual({
368
368
  state: "external",
369
- reason: "not-managed",
369
+ reason: "installed-elsewhere",
370
370
  });
371
371
  });
372
372
 
@@ -449,14 +449,14 @@ describe("C15 (d) — claude resolving into <home>/.local/share/claude/versions/
449
449
  expect(facts.upgradeCommand).toEqual({ cmd: "claude", args: ["update"] });
450
450
  });
451
451
 
452
- test("R9.8 classifyTool says upgrade, never the not-managed veto", async () => {
452
+ test("R9.8 classifyTool says upgrade, never the installed-elsewhere veto", async () => {
453
453
  const { facts } = await probe(CLAUDE, nativeClaude());
454
454
  const status = classifyTool(CLAUDE, facts);
455
455
  expect(status.state).toBe("upgrade");
456
- expect(status.reason).not.toBe("not-managed");
456
+ expect(status.reason).not.toBe("installed-elsewhere");
457
457
  });
458
458
 
459
- test("R9.8 an npm copy of claude-code at `npm root -g` does not trigger the second-copy veto", async () => {
459
+ test("R9.8 an npm copy of claude-code at `npm root -g` does not trigger the installed-elsewhere veto", async () => {
460
460
  const { facts } = await probe(CLAUDE, nativeClaude({ npmCopy: "2.0.1" }));
461
461
  expect({
462
462
  installed: facts.installed,
@@ -469,7 +469,7 @@ describe("C15 (d) — claude resolving into <home>/.local/share/claude/versions/
469
469
  });
470
470
  const status = classifyTool(CLAUDE, facts);
471
471
  expect(status.state).toBe("upgrade");
472
- expect(status.reason).not.toBe("not-managed");
472
+ expect(status.reason).not.toBe("installed-elsewhere");
473
473
  });
474
474
 
475
475
  test("R9.8 control: at the registry's latest, classifyTool says current", async () => {
@@ -521,13 +521,13 @@ describe("C15 (e) — a claude binary outside the native layout and outside node
521
521
  expect(isClaudeUpdate).toBe(false);
522
522
  });
523
523
 
524
- test("R9.8 it is not managed, and classifyTool keeps the not-managed veto", async () => {
524
+ test("R9.8 it is not managed, and classifyTool keeps the veto: installed elsewhere", async () => {
525
525
  const { facts } = await probe(CLAUDE, strayClaude());
526
526
  expect(facts.managed).not.toBe(true);
527
527
  const status = classifyTool(CLAUDE, facts);
528
528
  expect({ state: status.state, reason: status.reason }).toEqual({
529
529
  state: "external",
530
- reason: "not-managed",
530
+ reason: "installed-elsewhere",
531
531
  });
532
532
  });
533
533
  });
@@ -2,7 +2,7 @@
2
2
  * Blind tests, contract C16: a tool whose declared installer does not own the
3
3
  * file PATH runs is adopted by the node installer (bun or npm) when that file
4
4
  * lies in bun's global tree or an npm global prefix. Otherwise it stays
5
- * external / not-managed with a note that names no path.
5
+ * external / installed-elsewhere, with no note.
6
6
  *
7
7
  * Written from the spec only. Every fact reaches `probeTool` through a
8
8
  * scripted fake `ToolProbeIO`.
@@ -245,15 +245,17 @@ describe("C16 case 6: file in no node tree, brew spec", () => {
245
245
  ...CELLAR,
246
246
  },
247
247
  };
248
- test("external / not-managed, note names who did not install it", async () => {
248
+ // Merged design (docs/plans/2026-10-08-magus-update-tool-rows.md): one
249
+ // state, `external` / `installed-elsewhere`, which carries no note — the
250
+ // row reads "installed elsewhere" and is counted, not explained.
251
+ test("external / installed-elsewhere, with no note and no path", async () => {
249
252
  const f = await probeTool(spec, fakeIO(world));
250
253
  const s = classifyTool(spec, f);
251
254
  expect(s.state).toBe("external");
252
- expect(s.reason).toBe("not-managed");
253
- expect(s.note).toBe("installed by an installer magus does not update");
254
- expect(s.note?.includes("/")).toBe(false);
255
+ expect(s.reason).toBe("installed-elsewhere");
256
+ expect(s.note).toBeUndefined();
255
257
  });
256
- test("an npm spec gets the same note: it names who installed it, never the spec installer", async () => {
258
+ test("an npm spec is installed elsewhere too", async () => {
257
259
  const npmSpec: BinRequirement = { name: "uv", via: "npm", package: "uv" };
258
260
  const f = await probeTool(
259
261
  npmSpec,
@@ -264,17 +266,19 @@ describe("C16 case 6: file in no node tree, brew spec", () => {
264
266
  );
265
267
  const s = classifyTool(npmSpec, f);
266
268
  expect(s.state).toBe("external");
267
- expect(s.note).toBe("installed by an installer magus does not update");
269
+ expect(s.note).toBeUndefined();
268
270
  });
269
- test("a pinned spec in that situation stays unknown", async () => {
271
+ // Merged design: a pin changes nothing when no installer magus drives has
272
+ // the tool; it is still installed elsewhere (tool-probe.test.ts M3).
273
+ test("a pinned spec in that situation is installed elsewhere too", async () => {
270
274
  const pinned: BinRequirement = { ...spec, version: "1.0.0" };
271
275
  const f = await probeTool(pinned, fakeIO(world));
272
- expect(classifyTool(pinned, f).state).toBe("unknown");
276
+ expect(classifyTool(pinned, f).state).toBe("external");
273
277
  });
274
278
  });
275
279
 
276
280
  describe("C16 case 7: operating-system file", () => {
277
- test("zsh in /bin is external with the operating-system note", async () => {
281
+ test("zsh in /bin is external, with no note", async () => {
278
282
  const spec: BinRequirement = { name: "zsh", via: "brew", formula: "zsh" };
279
283
  const f = await probeTool(
280
284
  spec,
@@ -286,8 +290,8 @@ describe("C16 case 7: operating-system file", () => {
286
290
  );
287
291
  const s = classifyTool(spec, f);
288
292
  expect(s.state).toBe("external");
289
- expect(s.reason).toBe("not-managed");
290
- expect(s.note).toBe("comes with the operating system");
293
+ expect(s.reason).toBe("installed-elsewhere");
294
+ expect(s.note).toBeUndefined();
291
295
  });
292
296
  });
293
297
 
@@ -22,6 +22,11 @@
22
22
  * downgrade, and latest moves further past the pin. Every other `unknown`
23
23
  * reason keeps "update anyway".
24
24
  *
25
+ * AMENDED 2026-10-08: a `held-back` row (a brew pin) has NO Enter action
26
+ * either. Nothing magus runs can move it, so "update anyway" is a no-op; the
27
+ * user's fix is `brew unpin <formula>`, which the CLI shows as the row's
28
+ * command (docs/plans/2026-10-08-magus-update-tool-rows.md, decision 4).
29
+ *
25
30
  * Pure: rows are built by hand to the CliToolRow contract. No IO. Modules are
26
31
  * loaded dynamically so a missing export fails each test, not the whole file.
27
32
  */
@@ -245,14 +250,13 @@ const CASES: Case[] = [
245
250
  kind: "reinstall",
246
251
  label: "reinstall",
247
252
  },
248
- // Every `unknown` reason except `ahead-of-pin`, which has no Enter action
249
- // (R5.9 AMENDED) and is covered in its own block below.
253
+ // Every `unknown` reason except `ahead-of-pin` and `held-back`, which have
254
+ // no Enter action (R5.9 AMENDED) and are covered in their own blocks below.
250
255
  ...(
251
256
  [
252
257
  ["version-unreadable", null, "2.0.0", undefined],
253
258
  ["unorderable", "nightly-2026-09-01", "2.0.0", undefined],
254
- ["not-managed", "1.2.3", "2.0.0", undefined],
255
- ["held-back", "1.2.3", "2.0.0", "1.2.3"],
259
+ ["owner-unknown", "1.2.3", "2.0.0", undefined],
256
260
  ["pin-not-installable", "1.2.3", "2.0.0", "9.9.9"],
257
261
  ["latest-unavailable", "1.2.3", null, undefined],
258
262
  ["not-probed", null, null, undefined],
@@ -307,6 +311,23 @@ describe("C17 — cliToolEnterAction while the row is being checked", () => {
307
311
  });
308
312
  });
309
313
 
314
+ describe("C17 — cliToolEnterAction on a tool installed another way", () => {
315
+ // docs/plans/2026-10-08-magus-update-tool-rows.md, decision 6: an `external`
316
+ // row offers nothing. Without its guard it fell through to `reinstall`,
317
+ // which would install a second copy through an installer that does not own
318
+ // the file — an if-chain the compiler does not check.
319
+ test("external / installed-elsewhere → null (Enter does nothing)", async () => {
320
+ const st = status({
321
+ state: "external",
322
+ reason: "installed-elsewhere",
323
+ installed: "1.2.3",
324
+ latest: "2.0.0",
325
+ target: null,
326
+ });
327
+ expect(await enterAction(row(st))).toBeNull();
328
+ });
329
+ });
330
+
310
331
  describe("C17 — cliToolEnterAction per state", () => {
311
332
  for (const c of CASES) {
312
333
  test(`R5.9 ${c.name}: kind "${c.kind}", footer label "${c.label}"`, async () => {
@@ -426,14 +447,28 @@ describe("C17 — cliToolEnterAction for ahead-of-pin rows (R5.9 AMENDED)", () =
426
447
  expect(hints.filter((b) => b.id === ENTER_ID)).toEqual([]);
427
448
  });
428
449
 
429
- test("R5.9 control: the other unknown reasons still offer update anyway (ahead-of-pin is the only exception)", async () => {
450
+ test("R5.9 unknown / held-back (AMENDED 2026-10-08): no Enter action, whatever commands the row carries", async () => {
451
+ const held = row(
452
+ status({
453
+ state: "unknown",
454
+ reason: "held-back",
455
+ installed: "1.2.3",
456
+ latest: "2.0.0",
457
+ target: null,
458
+ }),
459
+ { withUpdate: true },
460
+ );
461
+ const a = await enterAction(held);
462
+ expect(a === null ? null : { kind: a.kind, command: a.command }).toBeNull();
463
+ });
464
+
465
+ test("R5.9 control: the other unknown reasons still offer update anyway (ahead-of-pin and held-back are the exceptions)", async () => {
430
466
  const others = CASES.filter((c) => c.fields.state === "unknown");
431
467
  expect(others.map((c) => c.fields.reason).sort()).toEqual(
432
468
  [
433
- "held-back",
434
469
  "latest-unavailable",
435
- "not-managed",
436
470
  "not-probed",
471
+ "owner-unknown",
437
472
  "pin-not-installable",
438
473
  "unorderable",
439
474
  "version-unreadable",
@@ -1,9 +1,15 @@
1
1
  /**
2
- * Blind tests, contract C18: Homebrew owns an installed tool whose file lies
3
- * in `<brew prefix>/Cellar/<short name>/<version>/...`, whatever installer the
4
- * spec declares. A pinned spec is not adopted (brew takes no version).
2
+ * Blind tests, contract C18: Homebrew owns an installed tool when Homebrew
3
+ * says it has it, whatever installer the spec declares. A pinned spec is not
4
+ * adopted (brew takes no version).
5
5
  *
6
6
  * Written from the spec only, through a scripted fake `ToolProbeIO`.
7
+ *
8
+ * Merged with the tool-rows design (docs/plans/2026-10-08-magus-update-tool-rows.md):
9
+ * magus never reads ownership or a version off a Cellar path. It asks
10
+ * `brew list --versions <formula>` and reads the version PATH runs with its
11
+ * own version command, so the fake world answers those two questions instead
12
+ * of `brew --prefix`. Every expectation below is unchanged.
7
13
  */
8
14
  import { describe, expect, test } from "bun:test";
9
15
  import {
@@ -74,8 +80,12 @@ function fakeIO(setup: FakeSetup): ToolProbeIO & { calls: string[] } {
74
80
 
75
81
  const PREFIX = "/opt/homebrew";
76
82
  const CELLAR = { "/opt/homebrew/Cellar": "/opt/homebrew/Cellar" };
77
- const BREW_PREFIX_OK: Record<string, Partial<ExecResult>> = {
78
- "brew --prefix": { stdout: `${PREFIX}\n` },
83
+
84
+ /** What each tool's own version command prints (`data/tool-versions.ts`). */
85
+ const VERSION_OUTPUT: Record<string, (v: string) => [string, string]> = {
86
+ claudish: (v) => ["claudish --version", `claudish version ${v}\n`],
87
+ mnemex: (v) => ["mnemex --version", `mnemex v${v}\n`],
88
+ tmux: (v) => ["tmux -V", `tmux ${v}\n`],
79
89
  };
80
90
 
81
91
  const OUTDATED_NONE = JSON.stringify({ formulae: [], casks: [] });
@@ -92,22 +102,34 @@ const outdatedBehind = (name: string, installed: string, current: string) =>
92
102
  casks: [],
93
103
  });
94
104
 
95
- /** A tool whose PATH entry resolves into a Homebrew keg. */
105
+ /**
106
+ * A tool whose PATH entry resolves into a Homebrew keg. `list` is brew's
107
+ * answer to `brew list --versions <formula>`: the default lists the keg,
108
+ * `"missing"` means no brew is installed.
109
+ */
96
110
  function kegWorld(
97
111
  bin: string,
98
112
  short: string,
99
113
  version: string,
100
114
  outdatedKey: string,
101
115
  outdatedStdout: string,
102
- extraExec: Record<string, Partial<ExecResult>> = BREW_PREFIX_OK,
116
+ list: Partial<ExecResult> | "missing" = { stdout: `${short} ${version}\n` },
103
117
  ): FakeSetup {
104
118
  const file = `${PREFIX}/Cellar/${short}/${version}/bin/${bin}`;
105
119
  const onPath = `${PREFIX}/bin/${bin}`;
120
+ const versionCmd = VERSION_OUTPUT[bin]?.(version);
106
121
  return {
107
122
  onPath: { [bin]: onPath },
108
123
  real: { [onPath]: file, [file]: file, ...CELLAR },
109
124
  exec: {
110
- ...extraExec,
125
+ ...(list === "missing"
126
+ ? {}
127
+ : {
128
+ [`brew list --versions ${outdatedKey}`]: list,
129
+ // brew's own file list for the formula names the keg file.
130
+ [`brew list ${outdatedKey}`]: { stdout: `${file}\n` },
131
+ }),
132
+ ...(versionCmd ? { [versionCmd[0]]: { stdout: versionCmd[1] } } : {}),
111
133
  [`brew outdated --json=v2 ${outdatedKey}`]: { stdout: outdatedStdout },
112
134
  },
113
135
  };
@@ -248,7 +270,6 @@ describe("C18 case 6: a directory merely named Cellar is not Homebrew's", () =>
248
270
  fakeIO({
249
271
  onPath: { claudish: file },
250
272
  real: { [file]: file, ...CELLAR },
251
- exec: BREW_PREFIX_OK,
252
273
  }),
253
274
  );
254
275
  expect(f.managed).not.toBe(true);
@@ -263,25 +284,22 @@ describe("C18 case 7: Homebrew unavailable", () => {
263
284
  via: "bun",
264
285
  package: "claudish",
265
286
  };
266
- const world = (exec: Record<string, Partial<ExecResult>>): FakeSetup =>
287
+ const world = (list: Partial<ExecResult> | "missing"): FakeSetup =>
267
288
  kegWorld(
268
289
  "claudish",
269
290
  "claudish",
270
291
  "10.5.0",
271
292
  "claudish",
272
293
  outdatedBehind("claudish", "10.5.0", "10.6.0"),
273
- exec,
294
+ list,
274
295
  );
275
- test("brew --prefix missing: not adopted", async () => {
276
- const f = await probeTool(spec, fakeIO(world({})));
296
+ test("brew missing: not adopted", async () => {
297
+ const f = await probeTool(spec, fakeIO(world("missing")));
277
298
  expect(f.upgradeCommand?.cmd).not.toBe("brew");
278
299
  expect(classifyTool(spec, f).state).not.toBe("upgrade");
279
300
  });
280
- test("brew --prefix exits non-zero: not adopted", async () => {
281
- const f = await probeTool(
282
- spec,
283
- fakeIO(world({ "brew --prefix": { code: 1, stderr: "boom" } })),
284
- );
301
+ test("brew list fails with an error: not adopted", async () => {
302
+ const f = await probeTool(spec, fakeIO(world({ code: 1, stderr: "boom" })));
285
303
  expect(f.upgradeCommand?.cmd).not.toBe("brew");
286
304
  expect(classifyTool(spec, f).state).not.toBe("upgrade");
287
305
  });
@@ -329,7 +347,6 @@ describe("C18 case 9: bun ownership still wins over a brew spec", () => {
329
347
  version: "0.36.2",
330
348
  }),
331
349
  },
332
- exec: BREW_PREFIX_OK,
333
350
  fetch: {
334
351
  "https://registry.npmjs.org/-/package/mnemex/dist-tags": {
335
352
  ok: true,