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.
- 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__/profile-worktree-include.test.ts +212 -0
- 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/profile-activate.ts +9 -0
- package/src/services/profile-live.ts +26 -0
- package/src/services/profile-skills.ts +14 -4
- package/src/services/profile-state.ts +18 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.10.
|
|
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.
|
|
69
|
-
"magus-cli-darwin-x64": "7.10.
|
|
70
|
-
"magus-cli-linux-x64": "7.10.
|
|
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
|
}
|
|
@@ -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?.
|
|
674
|
-
expect(crush?.
|
|
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 /
|
|
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.
|
|
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("
|
|
473
|
-
|
|
474
|
-
if (args.includes("
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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("
|
|
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("
|
|
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
|
|
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: "
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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
|
|
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: "
|
|
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 /
|
|
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
|
-
|
|
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("
|
|
253
|
-
expect(s.note).
|
|
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
|
|
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).
|
|
269
|
+
expect(s.note).toBeUndefined();
|
|
268
270
|
});
|
|
269
|
-
|
|
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("
|
|
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
|
|
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("
|
|
290
|
-
expect(s.note).
|
|
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
|
|
249
|
-
// (R5.9 AMENDED) and
|
|
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
|
-
["
|
|
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
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
...
|
|
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 = (
|
|
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
|
-
|
|
294
|
+
list,
|
|
274
295
|
);
|
|
275
|
-
test("brew
|
|
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
|
|
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,
|