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
@@ -4,6 +4,22 @@
4
4
  * (uv's receipt), and reports a newer copy hidden later on PATH.
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
+ * decision 7). Kept as written: the uv receipt cases (A1–A5) and the guards.
10
+ * Changed, because they contradict that design's rules:
11
+ * - An `external` row carries no note, so the owner sentences (A6, B7–B10,
12
+ * C11–C12) now expect none. They were read off Cellar, Caskroom and cargo
13
+ * paths, which the design forbids. A pinned tool no installer has (B10) is
14
+ * `external`, not `unknown`.
15
+ * - zsh's version comes from magus's own version command (`zsh --version`,
16
+ * bare name), not from running a file chosen by a path test, so C's fake
17
+ * world answers that command.
18
+ * - A copy later on PATH is counted (`otherCopies`), never described: its
19
+ * owner and version were read off its path, and the warning named a PATH
20
+ * folder with nothing for the user to run. So D expects a count, and an
21
+ * older copy (D14) counts too — telling older from newer would need that
22
+ * same guess.
7
23
  */
8
24
  import { describe, expect, test } from "bun:test";
9
25
  import {
@@ -208,11 +224,11 @@ describe("C19 A: uv's own installer is a driven owner", () => {
208
224
  expect(f.upgradeCommand).toBeUndefined();
209
225
  const s = classifyTool(spec, f);
210
226
  expect(s.state).toBe("external");
211
- expect(s.note).toBe("installed by ruff's own installer");
227
+ expect(s.note).toBeUndefined();
212
228
  });
213
229
  });
214
230
 
215
- describe("C19 B: KEPT notes name who installed it", () => {
231
+ describe("C19 B: a file no driven installer has is installed elsewhere, with no note", () => {
216
232
  const spec = (
217
233
  name: string,
218
234
  extra: Partial<BinRequirement> = {},
@@ -239,7 +255,7 @@ describe("C19 B: KEPT notes name who installed it", () => {
239
255
  const f = await probeTool(sp, fakeIO(world("madbench", file)));
240
256
  const s = classifyTool(sp, f);
241
257
  expect(s.state).toBe("external");
242
- expect(s.note).toBe("installed by a Homebrew cask");
258
+ expect(s.note).toBeUndefined();
243
259
  });
244
260
 
245
261
  test("8a. cargo bin under the home directory", async () => {
@@ -247,7 +263,7 @@ describe("C19 B: KEPT notes name who installed it", () => {
247
263
  const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
248
264
  const s = classifyTool(sp, f);
249
265
  expect(s.state).toBe("external");
250
- expect(s.note).toBe("installed by cargo");
266
+ expect(s.note).toBeUndefined();
251
267
  });
252
268
 
253
269
  test("8b. cargo bin under CARGO_HOME", async () => {
@@ -258,7 +274,7 @@ describe("C19 B: KEPT notes name who installed it", () => {
258
274
  );
259
275
  const s = classifyTool(sp, f);
260
276
  expect(s.state).toBe("external");
261
- expect(s.note).toBe("installed by cargo");
277
+ expect(s.note).toBeUndefined();
262
278
  });
263
279
 
264
280
  test("9. unrecognised place, never says 'outside'", async () => {
@@ -269,18 +285,15 @@ describe("C19 B: KEPT notes name who installed it", () => {
269
285
  );
270
286
  const s = classifyTool(sp, f);
271
287
  expect(s.state).toBe("external");
272
- expect(s.note).toBe("installed by an installer magus does not update");
273
- expect(s.note ?? "").not.toContain("outside");
288
+ expect(s.note).toBeUndefined();
274
289
  });
275
290
 
276
291
  test("10. pinned spec with a cargo file", async () => {
277
292
  const sp = spec("rg", { version: "1.0.0" });
278
293
  const f = await probeTool(sp, fakeIO(world("rg", "/home/u/.cargo/bin/rg")));
279
294
  const s = classifyTool(sp, f);
280
- expect(s.state).toBe("unknown");
281
- expect(s.note).toBe(
282
- "installed by cargo, so magus cannot move it to the pin 1.0.0",
283
- );
295
+ expect(s.state).toBe("external");
296
+ expect(s.note).toBeUndefined();
284
297
  });
285
298
  });
286
299
 
@@ -291,7 +304,7 @@ describe("C19 C: operating-system programs show their version", () => {
291
304
  real: { "/bin/zsh": "/bin/zsh", ...CELLAR },
292
305
  exec: {
293
306
  ...BREW_PREFIX_OK,
294
- ...(zshExec ? { "/bin/zsh --version": zshExec } : {}),
307
+ ...(zshExec ? { "zsh --version": zshExec } : {}),
295
308
  },
296
309
  });
297
310
 
@@ -303,7 +316,7 @@ describe("C19 C: operating-system programs show their version", () => {
303
316
  expect(f.installed).toBe("5.9");
304
317
  const s = classifyTool(zsh, f);
305
318
  expect(s.state).toBe("external");
306
- expect(s.note).toBe("comes with the operating system");
319
+ expect(s.note).toBeUndefined();
307
320
  });
308
321
 
309
322
  test("12a. --version exits non-zero: still external, installed null", async () => {
@@ -314,7 +327,7 @@ describe("C19 C: operating-system programs show their version", () => {
314
327
  expect(f.installed).toBeNull();
315
328
  const s = classifyTool(zsh, f);
316
329
  expect(s.state).toBe("external");
317
- expect(s.note).toBe("comes with the operating system");
330
+ expect(s.note).toBeUndefined();
318
331
  });
319
332
 
320
333
  test("12b. --version missing: still external, installed null", async () => {
@@ -322,11 +335,11 @@ describe("C19 C: operating-system programs show their version", () => {
322
335
  expect(f.installed).toBeNull();
323
336
  const s = classifyTool(zsh, f);
324
337
  expect(s.state).toBe("external");
325
- expect(s.note).toBe("comes with the operating system");
338
+ expect(s.note).toBeUndefined();
326
339
  });
327
340
  });
328
341
 
329
- describe("C19 D: a newer copy later on PATH is reported", () => {
342
+ describe("C19 D: another copy later on PATH is counted", () => {
330
343
  const BREW_BIN = "/opt/homebrew/bin/uv";
331
344
  const cellarFile = (v: string) => `/opt/homebrew/Cellar/uv/${v}/bin/uv`;
332
345
  const world = (second: string, secondReal: string): FakeSetup =>
@@ -338,52 +351,42 @@ describe("C19 D: a newer copy later on PATH is reported", () => {
338
351
  env: { HOMEBREW_PREFIX: PREFIX },
339
352
  },
340
353
  });
341
- const NOTE =
342
- "Homebrew also has 0.13.0, unused: ~/.local/bin comes first on PATH";
343
354
 
344
- test("13. newer Homebrew copy: pathDir, otherCopies, shadowed", async () => {
355
+ test("13. a Homebrew copy later on PATH: counted, no path, no warning", async () => {
345
356
  const f = await probeTool(
346
357
  UV,
347
358
  fakeIO(world(BREW_BIN, cellarFile("0.13.0"))),
348
359
  );
349
- expect(f.pathDir).toBe("~/.local/bin");
350
- expect(f.otherCopies).toEqual([
351
- { owner: "Homebrew", version: "0.13.0", dir: "/opt/homebrew/bin" },
352
- ]);
353
- expect(classifyTool(UV, f).shadowed).toBe(NOTE);
360
+ expect(f.otherCopies).toBe(1);
361
+ expect(JSON.stringify(classifyTool(UV, f))).not.toContain("/");
354
362
  });
355
363
 
356
- test("14. older later copy: listed, not shadowed", async () => {
364
+ test("14. an older later copy is counted the same: no version is guessed", async () => {
357
365
  const f = await probeTool(
358
366
  UV,
359
367
  fakeIO(world(BREW_BIN, cellarFile("0.10.0"))),
360
368
  );
361
- expect(f.otherCopies).toEqual([
362
- { owner: "Homebrew", version: "0.10.0", dir: "/opt/homebrew/bin" },
363
- ]);
364
- expect(classifyTool(UV, f).shadowed).toBeUndefined();
369
+ expect(f.otherCopies).toBe(1);
365
370
  });
366
371
 
367
372
  test("15. a symlink to the running file is not another copy", async () => {
368
373
  const f = await probeTool(UV, fakeIO(world("/usr/local/bin/uv", UV_FILE)));
369
- expect(f.otherCopies ?? []).toEqual([]);
370
- expect(classifyTool(UV, f).shadowed).toBeUndefined();
374
+ expect(f.otherCopies).toBeUndefined();
371
375
  });
372
376
 
373
377
  test("16. IO without resolveAllExecutables: nothing reported, nothing throws", async () => {
374
378
  const w = world(BREW_BIN, cellarFile("0.13.0"));
375
379
  w.noAll = true;
376
380
  const f = await probeTool(UV, fakeIO(w));
377
- expect(f.otherCopies ?? []).toEqual([]);
378
- expect(classifyTool(UV, f).shadowed).toBeUndefined();
381
+ expect(f.otherCopies).toBeUndefined();
379
382
  });
380
383
 
381
- test("17. shadowed is set on a current row too", async () => {
384
+ test("17. a current row is counted too", async () => {
382
385
  const w = world(BREW_BIN, cellarFile("0.13.0"));
383
386
  w.fetch = pypi("0.11.15");
384
387
  const f = await probeTool(UV, fakeIO(w));
385
388
  const s = classifyTool(UV, f);
386
389
  expect(s.state).toBe("current");
387
- expect(s.shadowed).toBe(NOTE);
390
+ expect(f.otherCopies).toBe(1);
388
391
  });
389
392
  });
@@ -93,11 +93,12 @@ describe("the catalogue is well-formed", () => {
93
93
  const declared = declaredRequirements();
94
94
  expect(declared.size).toBeGreaterThan(0);
95
95
  for (const entry of catalogue) {
96
+ // No entry carries a version command: that is data/tool-versions.ts.
97
+ expect("checkCommand" in entry).toBe(false);
96
98
  if (isOwnedEntry(entry)) {
97
99
  const [plugin] = entry.ownedBy.split("@");
98
100
  expect(declared.get(entry.name) ?? []).toContain(plugin);
99
101
  expect("packageName" in entry).toBe(false);
100
- expect("checkCommand" in entry).toBe(false);
101
102
  } else {
102
103
  expect(declared.has(entry.name)).toBe(false);
103
104
  }
@@ -124,7 +125,6 @@ describe("the catalogue is well-formed", () => {
124
125
  expect(
125
126
  claudish && !isPluginManagedItem(claudish) && claudish.tool,
126
127
  ).toMatchObject({
127
- checkCommand: "claudish --version",
128
128
  packageManager: "bun",
129
129
  packageName: "claudish",
130
130
  });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A tool installed another way (`external`), and one held back, on the CLI
3
+ * Tools screen (docs/plans/2026-10-08-magus-update-tool-rows.md, decisions 5
4
+ * and 6).
5
+ *
6
+ * The screen is an inventory, so the row stays — but it is not a warning and
7
+ * not "Up to date": no `?`, a muted icon, "installed elsewhere", a detail
8
+ * status that says the same, and no Enter action. The list icon and the
9
+ * detail Status line are if-chains the compiler does not check, which is why
10
+ * each branch is pinned here. Rendered through OpenTUI's test renderer; no IO.
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import { testRender } from "@opentui/react/test-utils";
15
+ import { act } from "react";
16
+ import { cliTools } from "../data/cli-tools.js";
17
+ import type { CliTool } from "../data/cli-tools.js";
18
+ import type { ToolStatus } from "../services/tool-probe.js";
19
+ import type { CliToolRow } from "../ui/adapters/cliToolsAdapter.js";
20
+ import {
21
+ renderCliToolDetail,
22
+ renderCliToolRow,
23
+ } from "../ui/renderers/cliToolRenderers.js";
24
+
25
+ const TOOL = cliTools.find((t) => t.name === "crush") as CliTool;
26
+
27
+ function row(status: Pick<ToolStatus, "state" | "reason">): CliToolRow {
28
+ return {
29
+ tool: TOOL,
30
+ spec: { name: TOOL.name, via: "npm", package: TOOL.packageName },
31
+ checking: false,
32
+ status: {
33
+ name: TOOL.name,
34
+ installed: "0.55.0",
35
+ latest: null,
36
+ target: null,
37
+ ...status,
38
+ },
39
+ updateCommand: { cmd: "npm", args: ["install", "-g", "x@latest"] },
40
+ installCommand: { cmd: "npm", args: ["install", "-g", "x"] },
41
+ };
42
+ }
43
+
44
+ const EXTERNAL = row({ state: "external", reason: "installed-elsewhere" });
45
+ const CURRENT = row({ state: "current", reason: "up-to-date" });
46
+
47
+ async function render(node: React.ReactNode, width = 80) {
48
+ const { captureCharFrame, captureSpans, renderOnce, renderer } =
49
+ await testRender(<box flexDirection="column">{node}</box>, {
50
+ width,
51
+ height: 24,
52
+ });
53
+ await act(async () => {
54
+ await renderOnce();
55
+ await renderOnce();
56
+ });
57
+ const out = { chars: captureCharFrame(), lines: captureSpans().lines };
58
+ await act(async () => renderer.destroy());
59
+ return out;
60
+ }
61
+
62
+ /** The colour of the row's ● icon. */
63
+ async function iconColour(r: CliToolRow): Promise<string> {
64
+ const { lines } = await render(renderCliToolRow(r, 0, false));
65
+ for (const line of lines) {
66
+ const span = line.spans.find((s) => s.text.includes("●"));
67
+ if (span) return JSON.stringify(span.fg);
68
+ }
69
+ throw new Error("no ● icon on the row");
70
+ }
71
+
72
+ describe("an external CLI tool row", () => {
73
+ test("the row says installed elsewhere, with no ?", async () => {
74
+ const { chars } = await render(renderCliToolRow(EXTERNAL, 0, false));
75
+ expect(chars).toContain("● Crush v0.55.0 installed elsewhere");
76
+ expect(chars).not.toContain("?");
77
+ });
78
+
79
+ test("its icon is muted, never the success colour of a current tool", async () => {
80
+ expect(await iconColour(EXTERNAL)).not.toBe(await iconColour(CURRENT));
81
+ });
82
+
83
+ test("the detail Status says Installed elsewhere, never Up to date, and offers no Enter", async () => {
84
+ const { chars } = await render(renderCliToolDetail(EXTERNAL, 70), 70);
85
+ expect(chars).toContain("Status ● Installed elsewhere");
86
+ expect(chars).not.toContain("Up to date");
87
+ expect(chars).not.toContain("Enter");
88
+ });
89
+
90
+ test("control: a current tool's detail still says Up to date", async () => {
91
+ const { chars } = await render(renderCliToolDetail(CURRENT, 70), 70);
92
+ expect(chars).toContain("Up to date");
93
+ });
94
+ });
95
+
96
+ // A held-back tool was checked — brew says it is behind and pinned — so it is
97
+ // "held back" here as in `magus update`: never `?`, never "not checked".
98
+ const HELD = row({ state: "unknown", reason: "held-back" });
99
+
100
+ describe("a held-back CLI tool row", () => {
101
+ test("the row says held back, with no ?", async () => {
102
+ const { chars } = await render(renderCliToolRow(HELD, 0, false));
103
+ expect(chars).toContain("‖ Crush v0.55.0 held back");
104
+ expect(chars).not.toContain("?");
105
+ });
106
+
107
+ test("the detail Status says Held back, never not checked", async () => {
108
+ const { chars } = await render(renderCliToolDetail(HELD, 70), 70);
109
+ expect(chars).toContain("Status ‖ Held back");
110
+ expect(chars).not.toContain("not checked");
111
+ });
112
+ });
@@ -19,6 +19,7 @@ import type { ResolvedBin } from "../types/index.js";
19
19
  import {
20
20
  type PluginManagedRow,
21
21
  buildToolRow,
22
+ cliToolEnterAction,
22
23
  cliToolStatusText,
23
24
  countToolRows,
24
25
  isPluginManagedRow,
@@ -85,22 +86,21 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
85
86
  const spec = specForCatalogTool(codex, [declared], install);
86
87
  expect(spec).toBe(declared);
87
88
 
88
- // Through npm, the bun-installed file is not npm's: not-managed.
89
+ // Through npm, the bun-installed file is not npm's: installed elsewhere.
89
90
  const f = facts("codex", {
90
91
  managed: false,
91
92
  resolvedPath:
92
93
  "/Users/j/.bun/install/global/node_modules/@openai/codex/bin/codex.js",
93
- owner: "npm (root /Users/j/.local/lib/node_modules)",
94
94
  });
95
95
  const row = buildToolRow(codex, spec, f, install);
96
96
  const [cli] = planBinUpdates([declared], [f]);
97
97
  expect(row.status).toMatchObject({
98
98
  state: cli!.action,
99
99
  reason: cli!.reason,
100
- note: cli!.note,
101
100
  target: cli!.target,
102
101
  });
103
- expect(row.status?.reason).toBe("not-managed");
102
+ expect(row.status?.note).toBe(cli!.note);
103
+ expect(row.status?.reason).toBe("installed-elsewhere");
104
104
  });
105
105
 
106
106
  test("pinned and behind: the row's key installs the PIN, exactly what the CLI runs", () => {
@@ -197,7 +197,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
197
197
  expect(row.installCommand).toEqual(cliToolInstall(codex));
198
198
  });
199
199
 
200
- test("detected pnpm → the catalogue's via, and a not-managed row in the user's terms", () => {
200
+ test("detected pnpm → the catalogue's via, and an installed-elsewhere row with no path", () => {
201
201
  const opencode = catalogue("opencode");
202
202
  const install = { primary: "pnpm" as const, all: ["pnpm" as const] };
203
203
  const spec = specForCatalogTool(opencode, null, install);
@@ -207,17 +207,17 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
207
207
  managed: false,
208
208
  resolvedPath:
209
209
  "/Users/j/.local/share/pnpm/global/5/node_modules/opencode-ai/bin/opencode",
210
- owner: "npm (root /Users/j/.local/lib/node_modules)",
211
210
  });
212
211
  const row = buildToolRow(opencode, spec, f, install);
213
- expect(row.status?.state).toBe("external");
214
- expect(row.status?.reason).toBe("not-managed");
215
- // No path and no installer directory: the user acts on neither.
216
- expect(row.status?.note).toBe(
217
- "installed by an installer magus does not update",
218
- );
219
- expect(row.status?.note).not.toContain("/");
220
- // Enter still follows how it IS installed (pnpm).
212
+ expect(row.status).toMatchObject({
213
+ state: "external",
214
+ reason: "installed-elsewhere",
215
+ });
216
+ expect(row.status?.note).toBeUndefined();
217
+ // Enter offers nothing for a tool installed another way.
218
+ expect(cliToolEnterAction(row)).toBeNull();
219
+ // The row still builds its update command for how it IS installed
220
+ // (pnpm), even though Enter offers nothing for it.
221
221
  expect(row.updateCommand?.cmd).toBe("pnpm");
222
222
  });
223
223
 
@@ -254,26 +254,30 @@ describe("cliToolStatusText — the row's words", () => {
254
254
  primary: "npm",
255
255
  all: ["npm"],
256
256
  });
257
- const f = facts("crush", { managed: null });
257
+ const f = facts("crush", {
258
+ managed: null,
259
+ failure: "npm timed out after 8s",
260
+ });
258
261
  const row = buildToolRow(tool, spec, f);
259
262
  const text = cliToolStatusText(row);
260
263
  expect(text.tone).toBe("warning");
261
- expect(text.suffix).toBe(" ? owner not known");
264
+ expect(text.suffix).toBe(" ? owner unknown");
262
265
  // The note is for the detail pane's "Why", not the row.
263
266
  expect(row.status?.note).toBe(classifyTool(spec, f).note);
264
267
  expect(text.suffix).not.toContain("/");
265
268
  });
266
269
 
267
- test("external renders muted, naming the installer it is outside of", () => {
270
+ test("external renders muted as `installed elsewhere`, with no ?", () => {
268
271
  const tool = catalogue("crush");
269
272
  const spec = specForCatalogTool(tool, null, {
270
273
  primary: "npm",
271
274
  all: ["npm"],
272
275
  });
273
276
  const row = buildToolRow(tool, spec, facts("crush", { managed: false }));
274
- const text = cliToolStatusText(row);
275
- expect(text.tone).toBe("muted");
276
- expect(text.suffix).toBe(" · outside npm");
277
+ expect(cliToolStatusText(row)).toEqual({
278
+ suffix: " installed elsewhere",
279
+ tone: "muted",
280
+ });
277
281
  });
278
282
 
279
283
  test("upgrade names the target once, with one v", () => {
@@ -305,13 +309,19 @@ describe("countToolRows — header counts from states", () => {
305
309
  buildToolRow(tool, spec, facts("mnemex", { behind: false })),
306
310
  buildToolRow(tool, spec, facts("mnemex", { behind: true })),
307
311
  buildToolRow(tool, spec, facts("mnemex", { managed: null })),
308
- // Another installer's: installed, and not "unverified".
312
+ // Installed elsewhere: installed, but neither an update nor unverified.
309
313
  buildToolRow(tool, spec, facts("mnemex", { managed: false })),
314
+ // Held back: checked (behind and pinned), so not unverified either.
315
+ buildToolRow(
316
+ tool,
317
+ spec,
318
+ facts("mnemex", { behind: true, heldBack: "held by brew pin" }),
319
+ ),
310
320
  buildToolRow(tool, spec, facts("mnemex", { present: false })),
311
321
  buildToolRow(tool, spec, undefined),
312
322
  ];
313
323
  expect(countToolRows(rows)).toEqual({
314
- installed: 4,
324
+ installed: 5,
315
325
  updates: 1,
316
326
  unverified: 1,
317
327
  });