magus-cli 7.10.5 → 7.11.1
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__/blind/plugin-deps/doctor.test.ts +2 -2
- package/src/__tests__/blind/plugin-deps/hook.test.ts +2 -2
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +1 -1
- package/src/__tests__/blind/plugin-deps/schema.test.ts +6 -6
- package/src/__tests__/blind/requires-min-version/TEST-PLAN.md +54 -0
- package/src/__tests__/blind/requires-min-version/manifests.test.ts +69 -0
- package/src/__tests__/blind/requires-min-version/parse.test.ts +266 -0
- package/src/__tests__/blind/requires-min-version/probe.test.ts +359 -0
- package/src/__tests__/blind/requires-min-version/render.test.ts +252 -0
- package/src/__tests__/blind/requires-min-version/support.ts +83 -0
- package/src/__tests__/blind/requires-min-version/surfaces.test.ts +231 -0
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +1 -1
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +1 -1
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +1 -1
- package/src/__tests__/blind/review-followups/path-advice.test.ts +1 -1
- package/src/__tests__/deps-check-hook.test.ts +9 -9
- package/src/__tests__/deps-plan.test.ts +1 -1
- package/src/__tests__/deps-report.test.ts +5 -5
- package/src/__tests__/doctor-json.test.ts +2 -2
- package/src/__tests__/marketplace-refresh.test.ts +1 -1
- package/src/__tests__/plugin-requires.test.ts +2 -2
- package/src/__tests__/requires-probe.test.ts +2 -2
- package/src/__tests__/scope-action-deprecated.test.ts +105 -0
- package/src/cli/deps-view.ts +5 -3
- package/src/cli/router.ts +6 -1
- package/src/services/deps-plan.ts +38 -14
- package/src/services/deps-report.ts +24 -7
- package/src/services/doctor-bins.ts +40 -12
- package/src/services/plugin-manager.ts +5 -1
- package/src/services/resolver.ts +1 -0
- package/src/shared/plugin/hooks/deps-check.ts +7 -3
- package/src/shared/plugin/hooks/lib/requires-probe.ts +257 -33
- package/src/ui/adapters/cliToolsAdapter.ts +6 -1
- package/src/ui/renderers/cliToolRenderers.tsx +7 -1
- package/src/ui/screens/CliToolsScreen.tsx +8 -1
- package/src/ui/screens/PluginsScreen.tsx +4 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Raw,
|
|
7
|
+
type World,
|
|
8
|
+
configureClaude,
|
|
9
|
+
linkExecutable,
|
|
10
|
+
makeHookPlugin,
|
|
11
|
+
makePlugin,
|
|
12
|
+
makeWorld,
|
|
13
|
+
removeWorld,
|
|
14
|
+
requirement,
|
|
15
|
+
requires,
|
|
16
|
+
runDoctor,
|
|
17
|
+
runHook,
|
|
18
|
+
sessionInput,
|
|
19
|
+
writeExecutable,
|
|
20
|
+
} from "./support";
|
|
21
|
+
|
|
22
|
+
// Fake bun: answers --version, does nothing for `add` (so an "upgrade" leaves
|
|
23
|
+
// the old binary in place). Never the real bun.
|
|
24
|
+
async function fakeBun(world: World): Promise<void> {
|
|
25
|
+
await writeExecutable(
|
|
26
|
+
join(world.bin, "bun"),
|
|
27
|
+
'if [ "$1" = "--version" ]; then echo 1.4.0; exit 0; fi\nexit 0',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bunAndWidget(minVersion: string): Raw {
|
|
32
|
+
return requires([
|
|
33
|
+
requirement("bun", [{ tool: "bun", via: "bootstrap" }]),
|
|
34
|
+
requirement("widget", [{ package: "widget", via: "bun" }], { minVersion }),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function installPlugin(
|
|
39
|
+
world: World,
|
|
40
|
+
name: string,
|
|
41
|
+
value: Raw,
|
|
42
|
+
): Promise<string> {
|
|
43
|
+
const root = await makePlugin(world, name, value);
|
|
44
|
+
await configureClaude(world, [{ key: `${name}@test`, root, scope: "user" }]);
|
|
45
|
+
return root;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseReport<T>(result: {
|
|
49
|
+
stdout: string;
|
|
50
|
+
stderr: string;
|
|
51
|
+
exitCode: number | null;
|
|
52
|
+
}): T {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(result.stdout) as T;
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`doctor printed no JSON (exit ${result.exitCode}); stderr: ${result.stderr.slice(0, 400)}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface Dep {
|
|
63
|
+
name: string;
|
|
64
|
+
status: string;
|
|
65
|
+
found?: string;
|
|
66
|
+
minVersion?: string;
|
|
67
|
+
fix?: { text: string };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("magus doctor with an outdated dependency", () => {
|
|
71
|
+
// REQ-27: DEPS_REPORT_SCHEMA 2; status "outdated"; found/minVersion only then.
|
|
72
|
+
test("REQ-27 --json reports schema 2 and an outdated dep with found and floor", async () => {
|
|
73
|
+
const world = await makeWorld();
|
|
74
|
+
try {
|
|
75
|
+
await fakeBun(world);
|
|
76
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
|
|
77
|
+
await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
|
|
78
|
+
const result = await runDoctor(world, ["--json"]);
|
|
79
|
+
expect(result.timedOut).toBe(false);
|
|
80
|
+
const report = parseReport<{ schema: number; deps: Dep[] }>(result);
|
|
81
|
+
expect(report.schema).toBe(2);
|
|
82
|
+
const widget = report.deps.find((d) => d.name === "widget");
|
|
83
|
+
expect(widget).toMatchObject({
|
|
84
|
+
found: "1.2.0",
|
|
85
|
+
minVersion: "2.0.0",
|
|
86
|
+
status: "outdated",
|
|
87
|
+
});
|
|
88
|
+
expect(widget?.fix?.text).toBe("bun add -g widget@latest");
|
|
89
|
+
const bun = report.deps.find((d) => d.name === "bun");
|
|
90
|
+
expect(bun?.status).toBe("present");
|
|
91
|
+
expect(bun).not.toHaveProperty("found");
|
|
92
|
+
expect(bun).not.toHaveProperty("minVersion");
|
|
93
|
+
} finally {
|
|
94
|
+
await removeWorld(world);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// REQ-28: "magus doctor text: Outdated rows are failures and count toward the exit code."
|
|
99
|
+
test("REQ-28 text doctor exits non-zero for an outdated dep, zero once satisfied", async () => {
|
|
100
|
+
const world = await makeWorld();
|
|
101
|
+
try {
|
|
102
|
+
await fakeBun(world);
|
|
103
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
|
|
104
|
+
await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
|
|
105
|
+
const outdated = await runDoctor(world, []);
|
|
106
|
+
expect(outdated.timedOut).toBe(false);
|
|
107
|
+
expect(outdated.exitCode).not.toBe(0);
|
|
108
|
+
expect(outdated.stdout + outdated.stderr).toContain("1.2.0");
|
|
109
|
+
|
|
110
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 2.0.0");
|
|
111
|
+
const healthy = await runDoctor(world, []);
|
|
112
|
+
expect(healthy.exitCode).toBe(0);
|
|
113
|
+
} finally {
|
|
114
|
+
await removeWorld(world);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// REQ-29: after install, still outdated → failed; remedy names found, floor, onPath.
|
|
119
|
+
test("REQ-29 --fix that leaves the old binary first on PATH reports failed with a remedy", async () => {
|
|
120
|
+
const world = await makeWorld();
|
|
121
|
+
try {
|
|
122
|
+
await fakeBun(world);
|
|
123
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
|
|
124
|
+
await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
|
|
125
|
+
const result = await runDoctor(world, ["--fix", "--yes", "--json"]);
|
|
126
|
+
expect(result.timedOut).toBe(false);
|
|
127
|
+
expect(result.exitCode).not.toBe(0);
|
|
128
|
+
const report = parseReport<{
|
|
129
|
+
applied?: Array<{
|
|
130
|
+
name: string;
|
|
131
|
+
status: string;
|
|
132
|
+
remedy?: string;
|
|
133
|
+
argv?: string[];
|
|
134
|
+
}>;
|
|
135
|
+
}>(result);
|
|
136
|
+
expect(report.applied).toBeDefined();
|
|
137
|
+
const applied = (report.applied ?? []).find((a) => a.name === "widget");
|
|
138
|
+
expect(applied?.status).toBe("failed");
|
|
139
|
+
// The executor may resolve argv[0] to an absolute path ("exactly what
|
|
140
|
+
// ran"); the spec fixes the rendered step, not how bun is located.
|
|
141
|
+
expect(applied?.argv?.[0]?.split("/").pop()).toBe("bun");
|
|
142
|
+
expect(applied?.argv?.slice(1)).toEqual(["add", "-g", "widget@latest"]);
|
|
143
|
+
expect(applied?.remedy).toContain("1.2.0");
|
|
144
|
+
expect(applied?.remedy).toContain("2.0.0");
|
|
145
|
+
expect(applied?.remedy).toContain(join(world.bin, "widget"));
|
|
146
|
+
} finally {
|
|
147
|
+
await removeWorld(world);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("SessionStart hook with an outdated dependency", () => {
|
|
153
|
+
// REQ-30: "An outdated dep is claimed and reported once per session, like a missing one."
|
|
154
|
+
test("REQ-30 reports an outdated dep once per session", async () => {
|
|
155
|
+
const world = await makeWorld();
|
|
156
|
+
try {
|
|
157
|
+
const plugin = await makeHookPlugin(
|
|
158
|
+
world,
|
|
159
|
+
"outdated",
|
|
160
|
+
requires([
|
|
161
|
+
requirement("widget", [{ formula: "widget", via: "brew" }], {
|
|
162
|
+
minVersion: "2.0.0",
|
|
163
|
+
}),
|
|
164
|
+
]),
|
|
165
|
+
);
|
|
166
|
+
// The hook itself needs a bun to execute TypeScript, as in the schema-1 suite.
|
|
167
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
168
|
+
await writeExecutable(join(world.bin, "brew"), "exit 0");
|
|
169
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 1.5.0");
|
|
170
|
+
const input = sessionInput("outdated-session");
|
|
171
|
+
const first = await runHook(
|
|
172
|
+
world,
|
|
173
|
+
plugin,
|
|
174
|
+
"outdated",
|
|
175
|
+
[world.bin],
|
|
176
|
+
input,
|
|
177
|
+
);
|
|
178
|
+
expect(first.timedOut).toBe(false);
|
|
179
|
+
expect(first.exitCode).toBe(0);
|
|
180
|
+
const banner = JSON.parse(first.stdout) as { systemMessage: string };
|
|
181
|
+
expect(banner.systemMessage).toContain("magus-deps");
|
|
182
|
+
expect(banner.systemMessage).toContain(
|
|
183
|
+
"outdated widget 1.5.0 (needs 2.0.0)",
|
|
184
|
+
);
|
|
185
|
+
const second = await runHook(
|
|
186
|
+
world,
|
|
187
|
+
plugin,
|
|
188
|
+
"outdated",
|
|
189
|
+
[world.bin],
|
|
190
|
+
input,
|
|
191
|
+
);
|
|
192
|
+
expect(second.exitCode).toBe(0);
|
|
193
|
+
expect(second.stdout).toBe("");
|
|
194
|
+
expect(
|
|
195
|
+
await readdir(
|
|
196
|
+
join(world.tmp, "magus-deps-outdated-session", "claimed"),
|
|
197
|
+
),
|
|
198
|
+
).toHaveLength(1);
|
|
199
|
+
} finally {
|
|
200
|
+
await removeWorld(world);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("REQ-30 a satisfied floor keeps the hook silent", async () => {
|
|
205
|
+
const world = await makeWorld();
|
|
206
|
+
try {
|
|
207
|
+
const plugin = await makeHookPlugin(
|
|
208
|
+
world,
|
|
209
|
+
"fresh",
|
|
210
|
+
requires([
|
|
211
|
+
requirement("widget", [{ formula: "widget", via: "brew" }], {
|
|
212
|
+
minVersion: "2.0.0",
|
|
213
|
+
}),
|
|
214
|
+
]),
|
|
215
|
+
);
|
|
216
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
217
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 2.3.0");
|
|
218
|
+
const result = await runHook(
|
|
219
|
+
world,
|
|
220
|
+
plugin,
|
|
221
|
+
"fresh",
|
|
222
|
+
[world.bin],
|
|
223
|
+
sessionInput("fresh-session"),
|
|
224
|
+
);
|
|
225
|
+
expect(result.exitCode).toBe(0);
|
|
226
|
+
expect(result.stdout).toBe("");
|
|
227
|
+
} finally {
|
|
228
|
+
await removeWorld(world);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -18,7 +18,7 @@ test("a Bun under BUN_INSTALL is reported as installed but absent from Claude Co
|
|
|
18
18
|
try {
|
|
19
19
|
const plugin = await makeHookPlugin(world, "bun-install", {
|
|
20
20
|
bin: [],
|
|
21
|
-
schema:
|
|
21
|
+
schema: 2,
|
|
22
22
|
});
|
|
23
23
|
const shellPath = await makeShellPath(world);
|
|
24
24
|
const bunInstall = join(world.root, "bun-install");
|
|
@@ -43,7 +43,7 @@ test("the TypeScript hook and shell fallback create ledger directories private t
|
|
|
43
43
|
const world = await makeWorld();
|
|
44
44
|
try {
|
|
45
45
|
const plugin = await makeHookPlugin(world, "private-ledger", {
|
|
46
|
-
schema:
|
|
46
|
+
schema: 2,
|
|
47
47
|
bin: [],
|
|
48
48
|
});
|
|
49
49
|
const shellPath = await makeShellPath(world);
|
|
@@ -26,7 +26,7 @@ test("each stale ledger is pruned even when another stale ledger cannot be remov
|
|
|
26
26
|
let blocked = "";
|
|
27
27
|
try {
|
|
28
28
|
const plugin = await makeHookPlugin(world, "ledger-pruning", {
|
|
29
|
-
schema:
|
|
29
|
+
schema: 2,
|
|
30
30
|
bin: [],
|
|
31
31
|
});
|
|
32
32
|
const shellPath = await makeShellPath(world);
|
|
@@ -17,7 +17,7 @@ test("doctor does not advise adding a PATH line already present in the shell rc
|
|
|
17
17
|
const rcLine = 'export PATH="$HOME/.bun/bin:$PATH"';
|
|
18
18
|
await writeFile(join(world.home, ".zshrc"), `${rcLine}\n`);
|
|
19
19
|
const plugin = await makePlugin(world, "path-advice", {
|
|
20
|
-
schema:
|
|
20
|
+
schema: 2,
|
|
21
21
|
bin: [
|
|
22
22
|
{
|
|
23
23
|
name: "bun",
|
|
@@ -105,14 +105,14 @@ const ledger = (sessionId = "s-1") => join(tmp, `magus-deps-${sessionId}`);
|
|
|
105
105
|
describe("deps-check hook", () => {
|
|
106
106
|
test("healthy: prints nothing, and the ledger proves it ran", () => {
|
|
107
107
|
tool("jq");
|
|
108
|
-
const dir = plugin("dev", { schema:
|
|
108
|
+
const dir = plugin("dev", { schema: 2, bin: [JQ] });
|
|
109
109
|
const r = runHook("dev", dir);
|
|
110
110
|
expect(r).toEqual({ stdout: "", exitCode: 0 });
|
|
111
111
|
expect(readFileSync(join(ledger(), "seen", "dev"), "utf8")).toBe("ts");
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
test("missing: one systemMessage with the marker, the dep and its fix", () => {
|
|
115
|
-
const dir = plugin("dev", { schema:
|
|
115
|
+
const dir = plugin("dev", { schema: 2, bin: [JQ] });
|
|
116
116
|
const r = runHook("dev", dir);
|
|
117
117
|
expect(r.exitCode).toBe(0);
|
|
118
118
|
const out = JSON.parse(r.stdout) as {
|
|
@@ -134,7 +134,7 @@ describe("deps-check hook", () => {
|
|
|
134
134
|
|
|
135
135
|
test("magus on PATH: the fix names magus alone", () => {
|
|
136
136
|
tool("magus");
|
|
137
|
-
const dir = plugin("dev", { schema:
|
|
137
|
+
const dir = plugin("dev", { schema: 2, bin: [JQ] });
|
|
138
138
|
const out = JSON.parse(runHook("dev", dir).stdout) as {
|
|
139
139
|
systemMessage: string;
|
|
140
140
|
};
|
|
@@ -149,7 +149,7 @@ describe("deps-check hook", () => {
|
|
|
149
149
|
JSON.parse(
|
|
150
150
|
runHook(
|
|
151
151
|
"dev",
|
|
152
|
-
plugin("dev", { schema:
|
|
152
|
+
plugin("dev", { schema: 2, bin: [JQ] }),
|
|
153
153
|
session,
|
|
154
154
|
input,
|
|
155
155
|
).stdout,
|
|
@@ -174,10 +174,10 @@ describe("deps-check hook", () => {
|
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
test("one identical declaration in two plugins is reported once per session", () => {
|
|
177
|
-
const first = runHook("dev", plugin("dev", { schema:
|
|
177
|
+
const first = runHook("dev", plugin("dev", { schema: 2, bin: [JQ] }));
|
|
178
178
|
const second = runHook(
|
|
179
179
|
"code-search",
|
|
180
|
-
plugin("code-search", { schema:
|
|
180
|
+
plugin("code-search", { schema: 2, bin: [JQ] }),
|
|
181
181
|
);
|
|
182
182
|
expect(first.stdout).toContain("magus-deps · dev: missing jq");
|
|
183
183
|
expect(second.stdout).toBe("");
|
|
@@ -188,14 +188,14 @@ describe("deps-check hook", () => {
|
|
|
188
188
|
});
|
|
189
189
|
|
|
190
190
|
test("no readable session id: no ledger, and the banner still prints", () => {
|
|
191
|
-
const dir = plugin("dev", { schema:
|
|
191
|
+
const dir = plugin("dev", { schema: 2, bin: [JQ] });
|
|
192
192
|
const r = runHook("dev", dir, null);
|
|
193
193
|
expect(r.stdout).toContain("magus-deps · dev: missing jq");
|
|
194
194
|
expect(existsSync(tmp) && readdirSync(tmp)).toEqual([]);
|
|
195
195
|
});
|
|
196
196
|
|
|
197
197
|
test("an invalid declaration is reported, not swallowed", () => {
|
|
198
|
-
const dir = plugin("dev", { schema:
|
|
198
|
+
const dir = plugin("dev", { schema: 2, bin: [{ name: "jq" }] });
|
|
199
199
|
const out = JSON.parse(runHook("dev", dir).stdout) as {
|
|
200
200
|
systemMessage: string;
|
|
201
201
|
};
|
|
@@ -213,7 +213,7 @@ describe("deps-check hook", () => {
|
|
|
213
213
|
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
|
214
214
|
require("node:fs").utimesSync(old, twoDaysAgo, twoDaysAgo);
|
|
215
215
|
tool("jq");
|
|
216
|
-
runHook("dev", plugin("dev", { schema:
|
|
216
|
+
runHook("dev", plugin("dev", { schema: 2, bin: [JQ] }));
|
|
217
217
|
expect(existsSync(old)).toBe(false);
|
|
218
218
|
expect(existsSync(fresh)).toBe(true);
|
|
219
219
|
});
|
|
@@ -66,7 +66,7 @@ const TMUX_MCP = {
|
|
|
66
66
|
],
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
const manifest = (bin: unknown[], schema =
|
|
69
|
+
const manifest = (bin: unknown[], schema = 2) => ({
|
|
70
70
|
requires: { schema, bin },
|
|
71
71
|
});
|
|
72
72
|
|
|
@@ -117,9 +117,9 @@ describe("collectDeps", () => {
|
|
|
117
117
|
const collected = await collectDeps(
|
|
118
118
|
root,
|
|
119
119
|
sources({
|
|
120
|
-
"new@x": manifest([BUN],
|
|
121
|
-
"old@x": manifest([BUN],
|
|
122
|
-
"bad@x": { requires: { schema:
|
|
120
|
+
"new@x": manifest([BUN], 3),
|
|
121
|
+
"old@x": manifest([BUN], 1),
|
|
122
|
+
"bad@x": { requires: { schema: 2, bin: [{ name: "x" }] } },
|
|
123
123
|
}),
|
|
124
124
|
);
|
|
125
125
|
expect(collected.problems.map((p) => [p.plugin, p.kind])).toEqual([
|
|
@@ -132,7 +132,7 @@ describe("collectDeps", () => {
|
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
describe("depsReportJson", () => {
|
|
135
|
-
test("names tmux-mcp missing with its release install, in the schema-
|
|
135
|
+
test("names tmux-mcp missing with its release install, in the schema-2 shape", async () => {
|
|
136
136
|
const state = await assessDeps(
|
|
137
137
|
root,
|
|
138
138
|
env,
|
|
@@ -36,7 +36,7 @@ beforeEach(() => {
|
|
|
36
36
|
JSON.stringify({
|
|
37
37
|
name: "fake",
|
|
38
38
|
requires: {
|
|
39
|
-
schema:
|
|
39
|
+
schema: 2,
|
|
40
40
|
bin: [
|
|
41
41
|
{
|
|
42
42
|
name: "faketool",
|
|
@@ -117,7 +117,7 @@ describe("magus doctor --json", () => {
|
|
|
117
117
|
const r = doctor("--json");
|
|
118
118
|
expect(r.code).toBe(1);
|
|
119
119
|
const doc = JSON.parse(r.out);
|
|
120
|
-
expect(doc.schema).toBe(
|
|
120
|
+
expect(doc.schema).toBe(2);
|
|
121
121
|
expect(doc.plugins).toEqual(["fake@magus"]);
|
|
122
122
|
expect(doc.deps).toEqual([
|
|
123
123
|
{
|
|
@@ -23,7 +23,7 @@ const BUN: Requirement = {
|
|
|
23
23
|
};
|
|
24
24
|
const declares = (bin: Requirement[]) => ({
|
|
25
25
|
name: "p",
|
|
26
|
-
requires: { schema:
|
|
26
|
+
requires: { schema: 2, bin },
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
let dir: string;
|
|
@@ -60,7 +60,7 @@ describe("readPluginRequires", () => {
|
|
|
60
60
|
test("an invalid declaration is reported, never dropped to empty", async () => {
|
|
61
61
|
await fs.outputJson(join(dir, ".claude-plugin", "plugin.json"), {
|
|
62
62
|
name: "p",
|
|
63
|
-
requires: { schema:
|
|
63
|
+
requires: { schema: 2, bin: [{ name: "x", via: "brew" }] },
|
|
64
64
|
});
|
|
65
65
|
const r = await readPluginRequires(dir);
|
|
66
66
|
expect(r?.ok).toBe(false);
|
|
@@ -156,10 +156,10 @@ describe("parseRequires", () => {
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
test("schema: newer asks for a newer magus-cli, older for a newer plugin", () => {
|
|
159
|
-
const newer = parseRequires({ requires: { schema:
|
|
159
|
+
const newer = parseRequires({ requires: { schema: 3, bin: [] } });
|
|
160
160
|
expect(newer.ok ? null : newer.kind).toBe("newer-schema");
|
|
161
161
|
expect(newer.ok ? "" : newer.errors[0]).toContain("update magus-cli");
|
|
162
|
-
const older = parseRequires({ requires: { schema:
|
|
162
|
+
const older = parseRequires({ requires: { schema: 1, bin: [] } });
|
|
163
163
|
expect(older.ok ? null : older.kind).toBe("older-schema");
|
|
164
164
|
expect(older.ok ? "" : older.errors[0]).toContain("update the plugin");
|
|
165
165
|
const none = parseRequires({ requires: { bin: [] } });
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type ScopeStatus,
|
|
4
|
+
isInstalledInScope,
|
|
5
|
+
resolveScopeAction,
|
|
6
|
+
} from "../services/plugin-manager.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* MTL-70: a deprecated plugin (no longer in any marketplace,
|
|
10
|
+
* `PluginInfo.isOrphaned === true`) can only be removed from a scope key.
|
|
11
|
+
* Installed in the scope → "uninstall"; not installed → "none". `behind` never
|
|
12
|
+
* matters for a deprecated plugin. Written blind from the spec.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const installedScopes: Array<[string, ScopeStatus]> = [
|
|
16
|
+
["installed with a registry version", { installed: true, version: "3.0.2" }],
|
|
17
|
+
["installed with no version (files missing)", { installed: true }],
|
|
18
|
+
['installed at "0.0.0"', { installed: true, version: "0.0.0" }],
|
|
19
|
+
[
|
|
20
|
+
"installed with an unorderable sha",
|
|
21
|
+
{ installed: true, version: "3ea32df27be7" },
|
|
22
|
+
],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const notInstalledScopes: Array<[string, ScopeStatus | undefined]> = [
|
|
26
|
+
["an absent scope", undefined],
|
|
27
|
+
["installed: false", { installed: false }],
|
|
28
|
+
[
|
|
29
|
+
"installed: false with a registry version",
|
|
30
|
+
{ installed: false, version: "3.0.2" },
|
|
31
|
+
],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
describe("resolveScopeAction — deprecated plugin installed in the scope (REQ-1)", () => {
|
|
35
|
+
for (const [label, scope] of installedScopes) {
|
|
36
|
+
test(`${label} is installed per isInstalledInScope (precondition)`, () => {
|
|
37
|
+
expect(isInstalledInScope(scope)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const behind of [false, true]) {
|
|
41
|
+
test(`REQ-1: ${label}, behind=${behind} resolves to uninstall`, () => {
|
|
42
|
+
expect(resolveScopeAction(scope, behind, true)).toBe("uninstall");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("resolveScopeAction — deprecated plugin NOT installed in the scope (REQ-2)", () => {
|
|
49
|
+
for (const [label, scope] of notInstalledScopes) {
|
|
50
|
+
test(`${label} is not installed per isInstalledInScope (precondition)`, () => {
|
|
51
|
+
expect(isInstalledInScope(scope)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
for (const behind of [false, true]) {
|
|
55
|
+
test(`REQ-2: ${label}, behind=${behind} resolves to none — no install, no update`, () => {
|
|
56
|
+
const action = resolveScopeAction(scope, behind, true);
|
|
57
|
+
expect(action).toBe("none");
|
|
58
|
+
expect(action).not.toBe("install");
|
|
59
|
+
expect(action).not.toBe("update");
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("resolveScopeAction — orphaned false or omitted is unchanged (REQ-3)", () => {
|
|
66
|
+
const cases: Array<[string, ScopeStatus | undefined, boolean, string]> = [
|
|
67
|
+
["files missing", { installed: true }, false, "install"],
|
|
68
|
+
["files missing, behind", { installed: true }, true, "install"],
|
|
69
|
+
["never installed (absent)", undefined, false, "install"],
|
|
70
|
+
["never installed (flag off)", { installed: false }, false, "install"],
|
|
71
|
+
[
|
|
72
|
+
"healthy at latest",
|
|
73
|
+
{ installed: true, version: "3.0.2" },
|
|
74
|
+
false,
|
|
75
|
+
"uninstall",
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
"healthy and behind",
|
|
79
|
+
{ installed: true, version: "2.9.0" },
|
|
80
|
+
true,
|
|
81
|
+
"update",
|
|
82
|
+
],
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
for (const [label, scope, behind, expected] of cases) {
|
|
86
|
+
test(`REQ-3: ${label} with orphaned omitted resolves to ${expected}`, () => {
|
|
87
|
+
expect(resolveScopeAction(scope, behind)).toBe(expected as never);
|
|
88
|
+
});
|
|
89
|
+
test(`REQ-3: ${label} with orphaned=false resolves to ${expected}`, () => {
|
|
90
|
+
expect(resolveScopeAction(scope, behind, false)).toBe(expected as never);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("REQ-3: a non-deprecated plugin never resolves to none", () => {
|
|
95
|
+
const all: Array<ScopeStatus | undefined> = [
|
|
96
|
+
...installedScopes.map(([, s]) => s),
|
|
97
|
+
...notInstalledScopes.map(([, s]) => s),
|
|
98
|
+
];
|
|
99
|
+
for (const scope of all) {
|
|
100
|
+
for (const behind of [false, true]) {
|
|
101
|
+
expect(resolveScopeAction(scope, behind, false)).not.toBe("none");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/cli/deps-view.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
requiredByOf,
|
|
19
19
|
} from "../services/deps-plan.js";
|
|
20
20
|
import type { DepsState } from "../services/deps-report.js";
|
|
21
|
-
import { installText } from "../services/doctor-bins.js";
|
|
21
|
+
import { installReason, installText } from "../services/doctor-bins.js";
|
|
22
22
|
import {
|
|
23
23
|
type Os,
|
|
24
24
|
type Presence,
|
|
@@ -33,6 +33,8 @@ function presenceLine(name: string, p: Presence, by: string): string | null {
|
|
|
33
33
|
return null;
|
|
34
34
|
case "missing":
|
|
35
35
|
return ` ✗ ${name} — missing${p.blockedBy ? ` (needs ${p.blockedBy} first)` : ""} (${by})`;
|
|
36
|
+
case "outdated":
|
|
37
|
+
return ` ✗ ${name} — outdated: ${p.found}, needs ${p.minVersion} or later (${by})`;
|
|
36
38
|
case "off-path":
|
|
37
39
|
return ` ⚠ ${name} — installed in ${p.prefix}, which is not on the PATH Claude Code starts with (${by})`;
|
|
38
40
|
case "unknown":
|
|
@@ -57,8 +59,8 @@ export function depsSectionLines(
|
|
|
57
59
|
const line = presenceLine(bin.name, p, requiredByOf(bin).join(", "));
|
|
58
60
|
if (!line) return;
|
|
59
61
|
lines.push(line);
|
|
60
|
-
if (p.status === "missing")
|
|
61
|
-
lines.push(` fix: ${installText(bin, env)}`);
|
|
62
|
+
if (p.status === "missing" || p.status === "outdated")
|
|
63
|
+
lines.push(` fix: ${installText(bin, env, installReason(p))}`);
|
|
62
64
|
if (p.status === "off-path") unfixable++;
|
|
63
65
|
});
|
|
64
66
|
for (const problem of collected.problems) {
|
package/src/cli/router.ts
CHANGED
|
@@ -103,7 +103,12 @@ const SUBCOMMANDS = new Set([
|
|
|
103
103
|
"doctor",
|
|
104
104
|
]);
|
|
105
105
|
|
|
106
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Subcommands that read or write profile state, and so pass the gate first.
|
|
108
|
+
* code-search setup runs `magus profile list` to regenerate settings.json after it
|
|
109
|
+
* edits profiles.json, so `profile` must keep running the gate
|
|
110
|
+
* (ai-docs/code-search-setup-profiles.md).
|
|
111
|
+
*/
|
|
107
112
|
const PROFILE_GATED = new Set(["install", "update", "profile", "models"]);
|
|
108
113
|
|
|
109
114
|
export async function route(
|