magus-cli 7.3.0 → 7.4.0
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/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +13 -2
- package/src/data/npm-package.ts +15 -0
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/services/version-check.ts +20 -10
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- package/src/ui/hooks/useKeyboardHandler.ts +0 -63
|
@@ -1,71 +1,83 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
TABS,
|
|
4
|
+
type TabLayout,
|
|
5
|
+
barWidth,
|
|
6
|
+
layoutTabs,
|
|
7
|
+
} from "../ui/components/TabBar.js";
|
|
3
8
|
|
|
4
9
|
const FULL = TABS.map((t) => `${t.key}:${t.label}`);
|
|
5
10
|
const FULL_WIDTH = barWidth(FULL);
|
|
6
11
|
|
|
7
|
-
/**
|
|
12
|
+
/** Columns a layout actually paints. */
|
|
13
|
+
const painted = (layout: TabLayout) => barWidth(layout.texts, layout.padded);
|
|
14
|
+
|
|
15
|
+
/** The widest the key-only bar ever gets — whichever active label is longest. */
|
|
8
16
|
const WIDEST_COMPACT = Math.max(
|
|
9
|
-
...TABS.map((tab) =>
|
|
17
|
+
...TABS.map((tab) => painted(layoutTabs(TABS, tab.screen, 0))),
|
|
10
18
|
);
|
|
11
19
|
|
|
12
20
|
describe("tab bar layout", () => {
|
|
13
|
-
test("shows every label when the bar fits", () => {
|
|
14
|
-
expect(layoutTabs(TABS, "plugins", FULL_WIDTH)).toEqual(
|
|
21
|
+
test("shows every label, padded, when the full bar fits", () => {
|
|
22
|
+
expect(layoutTabs(TABS, "plugins", FULL_WIDTH)).toEqual({
|
|
23
|
+
texts: FULL,
|
|
24
|
+
padded: true,
|
|
25
|
+
});
|
|
15
26
|
});
|
|
16
27
|
|
|
17
|
-
test("drops
|
|
18
|
-
// A 94-column pane leaves
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
expect(
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
test("drops the padding before it drops any label", () => {
|
|
29
|
+
// A 94-column pane leaves 90 usable. Ten padded tabs need 110, but the
|
|
30
|
+
// same ten names unpadded need exactly 90 — so every tab keeps its name.
|
|
31
|
+
// The old rule jumped straight to bare numbers here.
|
|
32
|
+
expect(layoutTabs(TABS, "styles", 90)).toEqual({
|
|
33
|
+
texts: FULL,
|
|
34
|
+
padded: false,
|
|
35
|
+
});
|
|
24
36
|
});
|
|
25
37
|
|
|
26
|
-
test("
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
expect(
|
|
38
|
+
test("shortens inactive labels to prefixes before dropping them", () => {
|
|
39
|
+
const layout = layoutTabs(TABS, "styles", 70);
|
|
40
|
+
expect(layout.texts).toContain("9:Styles");
|
|
41
|
+
expect(layout.texts).not.toContain("1");
|
|
42
|
+
expect(layout.texts.find((t) => t.startsWith("1:"))).toMatch(/^1:Plu/);
|
|
43
|
+
expect(painted(layout)).toBeLessThanOrEqual(70);
|
|
30
44
|
});
|
|
31
45
|
|
|
32
|
-
test("
|
|
33
|
-
// The tenth tab pushed the
|
|
34
|
-
//
|
|
35
|
-
// the test below measures it rather than assuming.
|
|
46
|
+
test("a 100-column window names every tab", () => {
|
|
47
|
+
// The tenth tab pushed the padded bar past 100 columns. That no longer
|
|
48
|
+
// costs the names: at 96 usable columns the unpadded bar fits whole.
|
|
36
49
|
expect(FULL_WIDTH).toBeGreaterThan(100);
|
|
37
|
-
expect(layoutTabs(TABS, "models",
|
|
50
|
+
expect(layoutTabs(TABS, "models", 96).texts).toEqual(FULL);
|
|
38
51
|
});
|
|
39
52
|
|
|
40
|
-
test("the
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// leaves 76 — and the widest compact bar is well under it.
|
|
53
|
+
test("the bar fits an 80-column terminal, on every tab", () => {
|
|
54
|
+
// ScreenLayout pads the bar by 1 each side inside a container padded by
|
|
55
|
+
// 1, so an 80-column terminal leaves 76.
|
|
44
56
|
expect(WIDEST_COMPACT).toBeLessThanOrEqual(76);
|
|
45
57
|
for (const tab of TABS) {
|
|
46
|
-
expect(
|
|
47
|
-
76,
|
|
48
|
-
);
|
|
58
|
+
expect(painted(layoutTabs(TABS, tab.screen, 76))).toBeLessThanOrEqual(76);
|
|
49
59
|
}
|
|
50
60
|
});
|
|
51
61
|
|
|
52
62
|
test("keeps the label of whichever tab is active", () => {
|
|
53
63
|
for (const tab of TABS) {
|
|
54
|
-
const texts = layoutTabs(TABS, tab.screen, 40);
|
|
64
|
+
const { texts } = layoutTabs(TABS, tab.screen, 40);
|
|
55
65
|
expect(texts).toContain(`${tab.key}:${tab.label}`);
|
|
56
|
-
//
|
|
66
|
+
// At 40 columns only the active tab keeps a label.
|
|
57
67
|
expect(texts.filter((t) => t.includes(":"))).toHaveLength(1);
|
|
58
68
|
}
|
|
59
69
|
});
|
|
60
70
|
|
|
61
71
|
test("compacting never changes how many tabs are drawn", () => {
|
|
62
|
-
expect(layoutTabs(TABS, "plugins", 20)).toHaveLength(TABS.length);
|
|
63
|
-
expect(layoutTabs(TABS, "plugins", 10_000)).toHaveLength(TABS.length);
|
|
72
|
+
expect(layoutTabs(TABS, "plugins", 20).texts).toHaveLength(TABS.length);
|
|
73
|
+
expect(layoutTabs(TABS, "plugins", 10_000).texts).toHaveLength(TABS.length);
|
|
64
74
|
});
|
|
65
75
|
|
|
66
76
|
test("barWidth counts padding and separators", () => {
|
|
67
77
|
// " a " + "│" + " bb " = 3 + 1 + 4
|
|
68
78
|
expect(barWidth(["a", "bb"])).toBe(8);
|
|
79
|
+
// "a" + "│" + "bb" = 1 + 1 + 2
|
|
80
|
+
expect(barWidth(["a", "bb"], false)).toBe(4);
|
|
69
81
|
expect(barWidth([])).toBe(0);
|
|
70
82
|
});
|
|
71
83
|
|
|
@@ -24,8 +24,10 @@ import { CONTRAST_REFERENCE, brand, theme } from "../ui/theme.js";
|
|
|
24
24
|
* can clear a text-grade ratio against that terminal's own background).
|
|
25
25
|
* 2. Every accent clears 3:1 against BOTH reference backgrounds.
|
|
26
26
|
* 3. Ink on our own badges clears 4.5:1 against that badge.
|
|
27
|
-
* 4.
|
|
28
|
-
*
|
|
27
|
+
* 4. No component invents a colour outside the palette.
|
|
28
|
+
*
|
|
29
|
+
* The scope squares no longer own a hue each (see theme.ts): position names the
|
|
30
|
+
* scope and shape says the state, so there is no scope palette to measure here.
|
|
29
31
|
*/
|
|
30
32
|
|
|
31
33
|
// ─── Contrast maths (WCAG 2.1 relative luminance) ─────────────────────────────
|
|
@@ -48,37 +50,6 @@ function contrastRatio(a: string, b: string): number {
|
|
|
48
50
|
return (hi + 0.05) / (lo + 0.05);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
/**
|
|
52
|
-
* Perceptual distance (CIE76 ΔE) in L*a*b*.
|
|
53
|
-
*
|
|
54
|
-
* Contrast ratio is the wrong tool for "are these two swatches telling apart":
|
|
55
|
-
* it only compares luminance, and the scope colours are deliberately close in
|
|
56
|
-
* luminance — that is precisely what lets each of them clear 3:1 against both a
|
|
57
|
-
* light and a dark background. Measured, they sit at ratios of 1.00-1.03 to each
|
|
58
|
-
* other while being obviously different colours. ΔE sees the hue difference that
|
|
59
|
-
* a luminance ratio is blind to.
|
|
60
|
-
*/
|
|
61
|
-
function deltaE(aHex: string, bHex: string): number {
|
|
62
|
-
const toLab = (hex: string): [number, number, number] => {
|
|
63
|
-
const n = hex.replace("#", "");
|
|
64
|
-
const [r, g, b] = [0, 2, 4].map((i) => {
|
|
65
|
-
const c = Number.parseInt(n.slice(i, i + 2), 16) / 255;
|
|
66
|
-
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
67
|
-
});
|
|
68
|
-
// sRGB -> XYZ (D65), then XYZ -> Lab
|
|
69
|
-
const x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
|
|
70
|
-
const y = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
|
71
|
-
const z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
|
|
72
|
-
const f = (t: number) =>
|
|
73
|
-
t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
74
|
-
const [fx, fy, fz] = [f(x), f(y), f(z)];
|
|
75
|
-
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
|
76
|
-
};
|
|
77
|
-
const [l1, a1, b1] = toLab(aHex);
|
|
78
|
-
const [l2, a2, b2] = toLab(bHex);
|
|
79
|
-
return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
53
|
/** WCAG threshold for UI components and large text. 4.5 is unreachable against
|
|
83
54
|
* both a light and a dark background at once — the luminance bands do not overlap. */
|
|
84
55
|
const UI_CONTRAST = 3;
|
|
@@ -129,9 +100,9 @@ describe("accents are legible on light AND dark terminals", () => {
|
|
|
129
100
|
|
|
130
101
|
test("badge ink clears 4.5:1 on every badge colour we paint", () => {
|
|
131
102
|
const failures: string[] = [];
|
|
132
|
-
for (const [tone, colors] of Object.entries(theme.
|
|
103
|
+
for (const [tone, colors] of Object.entries(theme.badge)) {
|
|
133
104
|
const r = contrastRatio(brand.ink, colors.bg as string);
|
|
134
|
-
if (r < INK_CONTRAST) failures.push(`
|
|
105
|
+
if (r < INK_CONTRAST) failures.push(`badge.${tone} ${r.toFixed(2)}`);
|
|
135
106
|
}
|
|
136
107
|
for (const key of ["defaultBg", "primaryBg", "dangerBg"] as const) {
|
|
137
108
|
const r = contrastRatio(brand.ink, theme.hints[key]);
|
|
@@ -177,30 +148,6 @@ describe("accents are legible on light AND dark terminals", () => {
|
|
|
177
148
|
Object.keys(theme.meta).sort(),
|
|
178
149
|
);
|
|
179
150
|
});
|
|
180
|
-
|
|
181
|
-
test("the three scope colours are perceptually far apart", () => {
|
|
182
|
-
// They render as single-character squares, where a subtle shift is not
|
|
183
|
-
// readable at all. Deferring to ANSI put project and local on slots 2 and 3,
|
|
184
|
-
// which one real theme drew as two near-identical olives — the reason this
|
|
185
|
-
// rule exists. ΔE > 25 is comfortably past "different colour" (~2.3 is the
|
|
186
|
-
// just-noticeable difference); the bar is set high because these are tiny.
|
|
187
|
-
const scopes = Object.entries(theme.scopes) as [string, string][];
|
|
188
|
-
const tooClose: string[] = [];
|
|
189
|
-
for (let i = 0; i < scopes.length; i++) {
|
|
190
|
-
for (let j = i + 1; j < scopes.length; j++) {
|
|
191
|
-
const d = deltaE(scopes[i][1], scopes[j][1]);
|
|
192
|
-
if (d < 25)
|
|
193
|
-
tooClose.push(`${scopes[i][0]}~${scopes[j][0]} ΔE=${d.toFixed(1)}`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
expect(tooClose).toEqual([]);
|
|
197
|
-
expect(new Set(scopes.map(([, hex]) => hex)).size).toBe(scopes.length);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("ΔE agrees that identical colours are identical (guards the metric)", () => {
|
|
201
|
-
expect(deltaE(brand.success, brand.success)).toBeCloseTo(0, 5);
|
|
202
|
-
expect(deltaE("#000000", "#FFFFFF")).toBeGreaterThan(90);
|
|
203
|
-
});
|
|
204
151
|
});
|
|
205
152
|
|
|
206
153
|
describe("the canvas stays the terminal's", () => {
|
|
@@ -605,7 +605,7 @@ describe("isInside — containment in the package's OWN directory", () => {
|
|
|
605
605
|
// ── ownership through the probe, with a fake IO ─────────────────────────────
|
|
606
606
|
|
|
607
607
|
describe("probeTool — ownership decides managed", () => {
|
|
608
|
-
test("native claude is
|
|
608
|
+
test("native claude is its own installer's: managed, `claude update`, never the npm veto", async () => {
|
|
609
609
|
const io = fakeIO({
|
|
610
610
|
onPath: { claude: "/Users/j/.local/bin/claude" },
|
|
611
611
|
real: {
|
|
@@ -616,6 +616,10 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
616
616
|
"npm root -g": { stdout: "/Users/j/.local/lib/node_modules\n" },
|
|
617
617
|
"claude --version": { stdout: "2.1.268 (Claude Code)\n" },
|
|
618
618
|
},
|
|
619
|
+
fetch: {
|
|
620
|
+
"https://registry.npmjs.org/-/package/@anthropic-ai%2Fclaude-code/dist-tags":
|
|
621
|
+
{ ok: true, body: { latest: "2.1.270" } },
|
|
622
|
+
},
|
|
619
623
|
});
|
|
620
624
|
const s = spec({
|
|
621
625
|
name: "claude",
|
|
@@ -623,16 +627,21 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
623
627
|
package: "@anthropic-ai/claude-code",
|
|
624
628
|
check: "claude --version",
|
|
625
629
|
});
|
|
626
|
-
// The read-back path's opt-in. Planning never runs a check command —
|
|
627
|
-
// see "a check command is never run while planning" below.
|
|
628
630
|
const f = await probeTool(s, io, { allowCheckCommand: true });
|
|
629
631
|
expect(f).toMatchObject({
|
|
630
632
|
present: true,
|
|
631
|
-
managed:
|
|
633
|
+
managed: true,
|
|
632
634
|
installed: "2.1.268",
|
|
635
|
+
latest: "2.1.270",
|
|
636
|
+
upgradeCommand: { cmd: "claude", args: ["update"] },
|
|
633
637
|
});
|
|
634
|
-
expect(classifyTool(s, f)
|
|
635
|
-
|
|
638
|
+
expect(classifyTool(s, f)).toMatchObject({
|
|
639
|
+
state: "upgrade",
|
|
640
|
+
reason: "behind",
|
|
641
|
+
target: "2.1.270",
|
|
642
|
+
});
|
|
643
|
+
// The version is the directory entry's name: nothing ran, npm was not asked.
|
|
644
|
+
expect(io.calls).toEqual([]);
|
|
636
645
|
});
|
|
637
646
|
|
|
638
647
|
test("bun-owned codex: version from its package.json, latest from dist-tags", async () => {
|
|
@@ -732,27 +741,41 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
732
741
|
expect(classifyTool(s, await probeTool(s, io)).reason).toBe("held-back");
|
|
733
742
|
});
|
|
734
743
|
|
|
735
|
-
test("crush under nvm while PATH's npm writes elsewhere:
|
|
744
|
+
test("crush under nvm while PATH's npm writes elsewhere: managed through --prefix", async () => {
|
|
745
|
+
const prefix = "/Users/j/.nvm/versions/node/v24.14.0";
|
|
746
|
+
const pkgDir = `${prefix}/lib/node_modules/@charmland/crush`;
|
|
736
747
|
const io = fakeIO({
|
|
737
|
-
onPath: { crush:
|
|
738
|
-
real: {
|
|
739
|
-
|
|
740
|
-
"/Users/j/.nvm/versions/node/v24.14.0/lib/node_modules/@charmland/crush/run-crush.js",
|
|
741
|
-
},
|
|
748
|
+
onPath: { crush: `${prefix}/bin/crush` },
|
|
749
|
+
real: { [`${prefix}/bin/crush`]: `${pkgDir}/run-crush.js` },
|
|
750
|
+
files: { [`${pkgDir}/package.json`]: '{"version":"0.7.1"}' },
|
|
742
751
|
exec: {
|
|
743
752
|
"npm root -g": { stdout: "/Users/j/.local/lib/node_modules\n" },
|
|
744
753
|
"crush --version": { stdout: "crush version v0.7.1\n" },
|
|
745
754
|
},
|
|
755
|
+
fetch: {
|
|
756
|
+
"https://registry.npmjs.org/-/package/@charmland%2Fcrush/dist-tags": {
|
|
757
|
+
ok: true,
|
|
758
|
+
body: { latest: "0.8.0" },
|
|
759
|
+
},
|
|
760
|
+
},
|
|
746
761
|
});
|
|
747
762
|
const s = spec({ name: "crush", via: "npm", package: "@charmland/crush" });
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
763
|
+
const f = await probeTool(s, io, { allowCheckCommand: true });
|
|
764
|
+
expect(f).toMatchObject({
|
|
765
|
+
managed: true,
|
|
766
|
+
installed: "0.7.1",
|
|
767
|
+
latest: "0.8.0",
|
|
768
|
+
upgradeCommand: {
|
|
769
|
+
cmd: "npm",
|
|
770
|
+
args: ["install", "-g", "--prefix", prefix, "@charmland/crush@latest"],
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
expect(classifyTool(s, f)).toMatchObject({
|
|
774
|
+
state: "upgrade",
|
|
775
|
+
target: "0.8.0",
|
|
776
|
+
});
|
|
777
|
+
// Version from the package.json, not by running the tool.
|
|
778
|
+
expect(io.calls).toEqual(["npm root -g"]);
|
|
756
779
|
});
|
|
757
780
|
|
|
758
781
|
test("go: tmux-mcp pinned v2.0.0 reads build info only — no network", async () => {
|
|
@@ -843,10 +866,12 @@ describe("probeTool — ownership decides managed", () => {
|
|
|
843
866
|
const [f] = await probeTools([hostile], io);
|
|
844
867
|
expect(io.calls).toEqual(["npm root -g"]);
|
|
845
868
|
expect(f?.installed).toBeNull();
|
|
846
|
-
// Cannot tell ⇒ unknown, so it is reported and never applied.
|
|
869
|
+
// Cannot tell ⇒ unknown, so it is reported and never applied. The file
|
|
870
|
+
// is outside npm's tree, so the reason is ownership (R9.9), not the
|
|
871
|
+
// unread version that follows from it.
|
|
847
872
|
expect(classifyTool(hostile, f!)).toMatchObject({
|
|
848
873
|
state: "unknown",
|
|
849
|
-
reason: "
|
|
874
|
+
reason: "not-managed",
|
|
850
875
|
});
|
|
851
876
|
|
|
852
877
|
// Even an honest check command stays unrun while planning.
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `magus upgrade`, end to end, with node:child_process replaced.
|
|
3
|
+
*
|
|
4
|
+
* The upgrade must install the npm PACKAGE (`package.json` `name`,
|
|
5
|
+
* `magus-cli`) at `latest`, with whichever package manager owns the active
|
|
6
|
+
* `magus` binary. The bare npm name is an unrelated package: installing it
|
|
7
|
+
* leaves magus where it was. The duplicate-install hint must name the real
|
|
8
|
+
* package too. The probes that look for the COMMAND (`which magus`,
|
|
9
|
+
* `bun pm -g bin`, `npm prefix -g`) stay as they are.
|
|
10
|
+
*
|
|
11
|
+
* SAFETY. This is the one test that could run a real global install, so three
|
|
12
|
+
* independent layers stand between it and one:
|
|
13
|
+
* 1. identity guard: before every call, the imported `spawn` and `execSync`
|
|
14
|
+
* must BE the fakes, or the test throws without calling anything;
|
|
15
|
+
* 2. PATH is an empty temp directory for the duration of the call, so even a
|
|
16
|
+
* real `spawn("bun", ...)` finds nothing (bun 1.4.0's child_process.spawn
|
|
17
|
+
* honours a runtime PATH change; `Bun.which` does not, so it is not used);
|
|
18
|
+
* 3. the module mock is restored in afterAll, the way cli-router.test.ts
|
|
19
|
+
* restores its own, so no later file in the same process inherits it.
|
|
20
|
+
* Every other process launcher on the module is replaced by a fake that
|
|
21
|
+
* records and throws.
|
|
22
|
+
*
|
|
23
|
+
* Written black-box from the API contract.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
afterAll,
|
|
28
|
+
afterEach,
|
|
29
|
+
beforeAll,
|
|
30
|
+
beforeEach,
|
|
31
|
+
describe,
|
|
32
|
+
expect,
|
|
33
|
+
mock,
|
|
34
|
+
test,
|
|
35
|
+
} from "bun:test";
|
|
36
|
+
import * as childProcessNamespace from "node:child_process";
|
|
37
|
+
import { EventEmitter } from "node:events";
|
|
38
|
+
import {
|
|
39
|
+
mkdirSync,
|
|
40
|
+
mkdtempSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
realpathSync,
|
|
43
|
+
rmSync,
|
|
44
|
+
writeFileSync,
|
|
45
|
+
} from "node:fs";
|
|
46
|
+
import { tmpdir } from "node:os";
|
|
47
|
+
import { join } from "node:path";
|
|
48
|
+
import { stripVTControlCharacters } from "node:util";
|
|
49
|
+
|
|
50
|
+
// Read at test time: the single source of truth the requirements name.
|
|
51
|
+
const PKG = JSON.parse(
|
|
52
|
+
readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
|
|
53
|
+
) as { name: string; version: string };
|
|
54
|
+
|
|
55
|
+
const COMMAND = "magus";
|
|
56
|
+
const PACKAGE = "magus-cli";
|
|
57
|
+
const EXPECTED_ARGS = ["install", "-g", `${PACKAGE}@latest`];
|
|
58
|
+
|
|
59
|
+
// Snapshot the real module BEFORE mocking. mock.module is process-global and
|
|
60
|
+
// updates live namespaces, so the snapshot is what gets put back.
|
|
61
|
+
const realChildProcess: Record<string, unknown> = { ...childProcessNamespace };
|
|
62
|
+
const realDefault = (realChildProcess.default ?? {}) as Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Fakes
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
interface SpawnCall {
|
|
69
|
+
cmd: string;
|
|
70
|
+
args: string[];
|
|
71
|
+
options: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Scenario {
|
|
75
|
+
/** What `which magus` answers: the active binary. */
|
|
76
|
+
which: string;
|
|
77
|
+
/** What `bun pm -g bin` answers. */
|
|
78
|
+
bunBin: string;
|
|
79
|
+
/** What `npm prefix -g` answers. */
|
|
80
|
+
npmPrefix: string;
|
|
81
|
+
/** The exit code the fake child reports. */
|
|
82
|
+
exitCode: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let scenario: Scenario = { which: "", bunBin: "", npmPrefix: "", exitCode: 0 };
|
|
86
|
+
let spawnCalls: SpawnCall[] = [];
|
|
87
|
+
let execCalls: string[] = [];
|
|
88
|
+
|
|
89
|
+
function fakeExecSync(command: string, options?: { encoding?: string }) {
|
|
90
|
+
const text = String(command);
|
|
91
|
+
execCalls.push(text);
|
|
92
|
+
let answer: string | undefined;
|
|
93
|
+
if (text.includes("which magus")) answer = scenario.which;
|
|
94
|
+
else if (text.includes("bun pm -g bin")) answer = scenario.bunBin;
|
|
95
|
+
else if (text.includes("npm prefix -g")) answer = scenario.npmPrefix;
|
|
96
|
+
if (!answer) {
|
|
97
|
+
throw new Error(`fake execSync has no answer for: ${text}`);
|
|
98
|
+
}
|
|
99
|
+
// Mirror Node: a string when an encoding is asked for, a Buffer otherwise.
|
|
100
|
+
const out = `${answer}\n`;
|
|
101
|
+
const encoding = options?.encoding;
|
|
102
|
+
return encoding && encoding !== "buffer" ? out : Buffer.from(out);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fakeSpawn(
|
|
106
|
+
cmd: string,
|
|
107
|
+
args?: readonly string[],
|
|
108
|
+
options?: Record<string, unknown>,
|
|
109
|
+
) {
|
|
110
|
+
spawnCalls.push({
|
|
111
|
+
cmd: String(cmd),
|
|
112
|
+
args: [...(args ?? [])],
|
|
113
|
+
options: options ?? {},
|
|
114
|
+
});
|
|
115
|
+
const child = Object.assign(new EventEmitter(), {
|
|
116
|
+
pid: 424242,
|
|
117
|
+
stdin: null,
|
|
118
|
+
stdout: null,
|
|
119
|
+
stderr: null,
|
|
120
|
+
kill: () => true,
|
|
121
|
+
});
|
|
122
|
+
const code = scenario.exitCode;
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
child.emit("exit", code, null);
|
|
125
|
+
child.emit("close", code, null);
|
|
126
|
+
}, 0);
|
|
127
|
+
return child;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function refuse(name: string) {
|
|
131
|
+
return (...a: unknown[]) => {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`upgrade-spawn test refused child_process.${name}(${String(a[0])}): no real process may run here`,
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const fakes = {
|
|
139
|
+
spawn: fakeSpawn,
|
|
140
|
+
execSync: fakeExecSync,
|
|
141
|
+
spawnSync: refuse("spawnSync"),
|
|
142
|
+
exec: refuse("exec"),
|
|
143
|
+
execFile: refuse("execFile"),
|
|
144
|
+
execFileSync: refuse("execFileSync"),
|
|
145
|
+
fork: refuse("fork"),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Lifecycle
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
let runUpgradeCommand: () => Promise<number>;
|
|
153
|
+
|
|
154
|
+
beforeAll(async () => {
|
|
155
|
+
mock.module("node:child_process", () => ({
|
|
156
|
+
...realChildProcess,
|
|
157
|
+
...fakes,
|
|
158
|
+
default: { ...realDefault, ...fakes },
|
|
159
|
+
}));
|
|
160
|
+
// Imported only AFTER the mock, so the module binds to the fakes.
|
|
161
|
+
({ runUpgradeCommand } = await import("../cli/upgrade.js"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterAll(() => {
|
|
165
|
+
mock.module("node:child_process", () => realChildProcess);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/** Layer 1: refuse to proceed unless the fakes are what callers will get. */
|
|
169
|
+
async function assertChildProcessIsFake(): Promise<void> {
|
|
170
|
+
const cp = (await import("node:child_process")) as unknown as Record<
|
|
171
|
+
string,
|
|
172
|
+
unknown
|
|
173
|
+
>;
|
|
174
|
+
if (cp.spawn !== fakeSpawn || cp.execSync !== fakeExecSync) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
"SAFETY: node:child_process is not mocked. Refusing to call runUpgradeCommand, which would run a real global install.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let root: string;
|
|
182
|
+
let logs: string[];
|
|
183
|
+
let otherOutput: string[];
|
|
184
|
+
const realLog = console.log;
|
|
185
|
+
const realError = console.error;
|
|
186
|
+
const realWarn = console.warn;
|
|
187
|
+
const realFetch = globalThis.fetch;
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
// realpath: on macOS the temp dir sits behind a symlink.
|
|
191
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "magus-upgrade-spawn-")));
|
|
192
|
+
const bunBin = join(root, ".bun", "bin");
|
|
193
|
+
const npmPrefix = join(root, "npm");
|
|
194
|
+
mkdirSync(bunBin, { recursive: true });
|
|
195
|
+
mkdirSync(join(npmPrefix, "bin"), { recursive: true });
|
|
196
|
+
mkdirSync(join(root, "empty-path"));
|
|
197
|
+
// Two real installs of the command, one per manager (amendment M3).
|
|
198
|
+
writeFileSync(join(bunBin, COMMAND), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
199
|
+
writeFileSync(join(npmPrefix, "bin", COMMAND), "#!/bin/sh\nexit 0\n", {
|
|
200
|
+
mode: 0o755,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
scenario = { which: "", bunBin, npmPrefix, exitCode: 0 };
|
|
204
|
+
spawnCalls = [];
|
|
205
|
+
execCalls = [];
|
|
206
|
+
logs = [];
|
|
207
|
+
otherOutput = [];
|
|
208
|
+
|
|
209
|
+
console.log = (...a: unknown[]) => {
|
|
210
|
+
logs.push(a.map(String).join(" "));
|
|
211
|
+
};
|
|
212
|
+
console.error = (...a: unknown[]) => {
|
|
213
|
+
otherOutput.push(a.map(String).join(" "));
|
|
214
|
+
};
|
|
215
|
+
console.warn = (...a: unknown[]) => {
|
|
216
|
+
otherOutput.push(a.map(String).join(" "));
|
|
217
|
+
};
|
|
218
|
+
// No network, in case the upgrade consults the registry first.
|
|
219
|
+
globalThis.fetch = (async () =>
|
|
220
|
+
new Response(JSON.stringify({ version: "999.0.0" }), {
|
|
221
|
+
status: 200,
|
|
222
|
+
headers: { "content-type": "application/json" },
|
|
223
|
+
})) as unknown as typeof fetch;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
afterEach(() => {
|
|
227
|
+
console.log = realLog;
|
|
228
|
+
console.error = realError;
|
|
229
|
+
console.warn = realWarn;
|
|
230
|
+
globalThis.fetch = realFetch;
|
|
231
|
+
rmSync(root, { recursive: true, force: true });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const bunWhich = () => join(scenario.bunBin, COMMAND);
|
|
235
|
+
const npmWhich = () => join(scenario.npmPrefix, "bin", COMMAND);
|
|
236
|
+
|
|
237
|
+
async function runUpgrade(which: string, exitCode = 0): Promise<number> {
|
|
238
|
+
scenario.which = which;
|
|
239
|
+
scenario.exitCode = exitCode;
|
|
240
|
+
await assertChildProcessIsFake();
|
|
241
|
+
// Layer 2: nothing is reachable by name while the upgrade runs (M2).
|
|
242
|
+
const savedPath = process.env.PATH;
|
|
243
|
+
process.env.PATH = join(root, "empty-path");
|
|
244
|
+
try {
|
|
245
|
+
return await runUpgradeCommand();
|
|
246
|
+
} finally {
|
|
247
|
+
if (savedPath === undefined) Reflect.deleteProperty(process.env, "PATH");
|
|
248
|
+
else process.env.PATH = savedPath;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Captured console.log output, one entry per physical line, ANSI removed. */
|
|
253
|
+
function logLines(): string[] {
|
|
254
|
+
return logs.flatMap((l) => stripVTControlCharacters(l).split("\n"));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Tests
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
describe("runUpgradeCommand installs the npm package with the owning manager", () => {
|
|
262
|
+
test("a bun-owned binary upgrades with `bun install -g <package>@latest`", async () => {
|
|
263
|
+
const code = await runUpgrade(bunWhich());
|
|
264
|
+
|
|
265
|
+
expect(code).toBe(0);
|
|
266
|
+
expect(spawnCalls).toHaveLength(1);
|
|
267
|
+
const call = spawnCalls[0]!;
|
|
268
|
+
expect(call.cmd).toBe("bun");
|
|
269
|
+
expect(call.args).toEqual(EXPECTED_ARGS);
|
|
270
|
+
expect(call.args).toEqual(["install", "-g", `${PKG.name}@latest`]);
|
|
271
|
+
expect(call.options).toMatchObject({ stdio: "inherit", shell: false });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("an npm-owned binary upgrades with `npm install -g <package>@latest`", async () => {
|
|
275
|
+
// Precondition: nothing in this path may look bun-owned.
|
|
276
|
+
expect(npmWhich()).not.toContain(".bun");
|
|
277
|
+
expect(npmWhich()).not.toContain("bun/bin");
|
|
278
|
+
|
|
279
|
+
const code = await runUpgrade(npmWhich());
|
|
280
|
+
|
|
281
|
+
expect(code).toBe(0);
|
|
282
|
+
expect(spawnCalls).toHaveLength(1);
|
|
283
|
+
const call = spawnCalls[0]!;
|
|
284
|
+
expect(call.cmd).toBe("npm");
|
|
285
|
+
expect(call.args).toEqual(EXPECTED_ARGS);
|
|
286
|
+
expect(call.args).toEqual(["install", "-g", `${PKG.name}@latest`]);
|
|
287
|
+
expect(call.options).toMatchObject({ stdio: "inherit", shell: false });
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("a `bun/bin` path without `.bun` also counts as bun-owned", async () => {
|
|
291
|
+
const dir = join(root, "opt", "bun", "bin");
|
|
292
|
+
mkdirSync(dir, { recursive: true });
|
|
293
|
+
const which = join(dir, COMMAND);
|
|
294
|
+
writeFileSync(which, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
295
|
+
expect(which).not.toContain(".bun");
|
|
296
|
+
|
|
297
|
+
await runUpgrade(which);
|
|
298
|
+
|
|
299
|
+
expect(spawnCalls).toHaveLength(1);
|
|
300
|
+
expect(spawnCalls[0]!.cmd).toBe("bun");
|
|
301
|
+
expect(spawnCalls[0]!.args).toEqual(EXPECTED_ARGS);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("resolves with the child's exit code", async () => {
|
|
305
|
+
const code = await runUpgrade(npmWhich(), 3);
|
|
306
|
+
|
|
307
|
+
expect(spawnCalls).toHaveLength(1);
|
|
308
|
+
expect(code).toBe(3);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("the duplicate-install hint names the npm package", () => {
|
|
313
|
+
function expectBothHints() {
|
|
314
|
+
const out = logLines().join("\n");
|
|
315
|
+
expect(out).toContain(`bun uninstall -g ${PACKAGE}`);
|
|
316
|
+
expect(out).toContain(`npm uninstall -g ${PACKAGE}`);
|
|
317
|
+
// Every uninstall hint that was printed targets the package.
|
|
318
|
+
const hints = logLines().filter((l) => /\buninstall\b/.test(l));
|
|
319
|
+
expect(hints.length).toBeGreaterThanOrEqual(2);
|
|
320
|
+
for (const line of hints) {
|
|
321
|
+
const tokens = line.trim().split(/\s+/);
|
|
322
|
+
const i = tokens.indexOf("-g");
|
|
323
|
+
expect(i).toBeGreaterThanOrEqual(0);
|
|
324
|
+
expect(tokens[i + 1]).toBe(PKG.name);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
test("both installs present, bun active: both hints name the package", async () => {
|
|
329
|
+
await runUpgrade(bunWhich());
|
|
330
|
+
|
|
331
|
+
expectBothHints();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("both installs present, npm active: both hints name the package", async () => {
|
|
335
|
+
await runUpgrade(npmWhich());
|
|
336
|
+
|
|
337
|
+
expectBothHints();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("the probes still look for the command (R6)", () => {
|
|
342
|
+
test("`which magus`, `bun pm -g bin` and `npm prefix -g` are what gets asked", async () => {
|
|
343
|
+
await runUpgrade(bunWhich());
|
|
344
|
+
|
|
345
|
+
const probes = execCalls.join("\n");
|
|
346
|
+
expect(probes).toContain("bun pm -g bin");
|
|
347
|
+
expect(probes).toContain("npm prefix -g");
|
|
348
|
+
// `which` is asked about the COMMAND, not the package.
|
|
349
|
+
const whichProbe = execCalls
|
|
350
|
+
.map((c) => c.trim().split(/\s+/))
|
|
351
|
+
.find((tokens) => tokens.includes("which"));
|
|
352
|
+
expect(whichProbe).toBeDefined();
|
|
353
|
+
expect(whichProbe![whichProbe!.indexOf("which") + 1]).toBe(COMMAND);
|
|
354
|
+
});
|
|
355
|
+
});
|