magus-cli 7.10.5 → 7.11.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/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 +68 -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/cli/deps-view.ts +5 -3
- 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/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
|
@@ -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: [] } });
|
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) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one installer executor: plan what is missing, then install it.
|
|
3
3
|
*
|
|
4
|
-
* `planDeps` turns the requirements a probe found missing
|
|
4
|
+
* `planDeps` turns the requirements a probe found missing or outdated into
|
|
5
|
+
* ordered steps,
|
|
5
6
|
* each rendered by requires-probe's `renderInstall` (the only renderer).
|
|
6
7
|
* `applyDepsPlan` runs them through `runArgv` and returns one `ApplyOutcome`
|
|
7
8
|
* per step. Nothing here prints: `magus doctor` (text and `--json`),
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
BUN_PREFIX,
|
|
43
44
|
type Bootstrap,
|
|
44
45
|
type BootstrapTool,
|
|
46
|
+
type InstallReason,
|
|
45
47
|
type InstallStep,
|
|
46
48
|
LOCAL_PREFIX,
|
|
47
49
|
type Os,
|
|
@@ -240,10 +242,11 @@ function fromMagusMarketplace(bin: ResolvedBin): boolean {
|
|
|
240
242
|
}
|
|
241
243
|
|
|
242
244
|
/**
|
|
243
|
-
* The steps that install every `missing` requirement
|
|
244
|
-
* run. `presence[i]` is the probe's
|
|
245
|
-
* requirement is not reinstalled: it needs
|
|
246
|
-
* carries. An `unknown` one (a check too slow
|
|
245
|
+
* The steps that install every `missing` requirement and replace every
|
|
246
|
+
* `outdated` one, in the order they must run. `presence[i]` is the probe's
|
|
247
|
+
* answer for `bins[i]`. An `off-path` requirement is not reinstalled: it needs
|
|
248
|
+
* a PATH line, which `pathAdvice` carries. An `unknown` one (a check too slow
|
|
249
|
+
* to answer, or one that printed no version) is left alone.
|
|
247
250
|
*/
|
|
248
251
|
export function planDeps(
|
|
249
252
|
bins: ResolvedBin[],
|
|
@@ -253,10 +256,11 @@ export function planDeps(
|
|
|
253
256
|
const unfixable: DepsPlan["unfixable"] = [];
|
|
254
257
|
/** The prefixes off the PATH, in the order they came up. */
|
|
255
258
|
const offPathPrefixes = new Set<UserPrefix>();
|
|
256
|
-
const missing: ResolvedBin[] = [];
|
|
259
|
+
const missing: { bin: ResolvedBin; reason: InstallReason }[] = [];
|
|
257
260
|
bins.forEach((bin, i) => {
|
|
258
261
|
const p = presence[i];
|
|
259
|
-
if (p?.status === "missing") missing.push(bin);
|
|
262
|
+
if (p?.status === "missing") missing.push({ bin, reason: "missing" });
|
|
263
|
+
if (p?.status === "outdated") missing.push({ bin, reason: "outdated" });
|
|
260
264
|
if (p?.status === "off-path") offPathPrefixes.add(p.prefix);
|
|
261
265
|
});
|
|
262
266
|
|
|
@@ -285,7 +289,7 @@ export function planDeps(
|
|
|
285
289
|
pluginRoot: bin.root ?? "",
|
|
286
290
|
});
|
|
287
291
|
|
|
288
|
-
for (const bin of missing) {
|
|
292
|
+
for (const { bin, reason } of missing) {
|
|
289
293
|
const option = bin.chosen;
|
|
290
294
|
if (!option) {
|
|
291
295
|
unfixable.push({
|
|
@@ -294,11 +298,12 @@ export function planDeps(
|
|
|
294
298
|
});
|
|
295
299
|
continue;
|
|
296
300
|
}
|
|
297
|
-
const step = renderInstall(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
pluginRoot: bin.root ?? "",
|
|
301
|
-
|
|
301
|
+
const step = renderInstall(
|
|
302
|
+
bin,
|
|
303
|
+
option,
|
|
304
|
+
{ os: env.os, arch: env.arch, pluginRoot: bin.root ?? "" },
|
|
305
|
+
reason,
|
|
306
|
+
);
|
|
302
307
|
if (
|
|
303
308
|
step.kind === "uv-module" &&
|
|
304
309
|
step.escalation === "sudo" &&
|
|
@@ -895,7 +900,9 @@ async function landingDir(
|
|
|
895
900
|
* After a step's installer exited 0, re-probe everything it installs with the
|
|
896
901
|
* same probe doctor runs. Installed only when the probe now finds each one.
|
|
897
902
|
* Found in a directory off the PATH Claude Code starts with: off-path, naming
|
|
898
|
-
* the directory and the rc line. Not found at all: failed.
|
|
903
|
+
* the directory and the rc line. Not found at all: failed. Still below its
|
|
904
|
+
* `minVersion`: failed, naming the version found, the floor and the file the
|
|
905
|
+
* PATH resolves to — usually an older copy ahead of the one just installed.
|
|
899
906
|
*/
|
|
900
907
|
async function verifyInstalled(
|
|
901
908
|
p: PlannedStep,
|
|
@@ -904,6 +911,7 @@ async function verifyInstalled(
|
|
|
904
911
|
const { env } = ctx;
|
|
905
912
|
const offPath: Verified[] = [];
|
|
906
913
|
const missing: string[] = [];
|
|
914
|
+
const outdated: string[] = [];
|
|
907
915
|
for (const target of p.installs) {
|
|
908
916
|
const presence = await ctx.io.verify(target, env);
|
|
909
917
|
if (presence.status === "present") continue;
|
|
@@ -911,6 +919,18 @@ async function verifyInstalled(
|
|
|
911
919
|
offPath.push({ status: "off-path", dir: presence.prefix });
|
|
912
920
|
continue;
|
|
913
921
|
}
|
|
922
|
+
if (presence.status === "outdated") {
|
|
923
|
+
const tool = target.requirement.check[0] ?? target.requirement.name;
|
|
924
|
+
const resolved = onPath(tool, env.path);
|
|
925
|
+
outdated.push(
|
|
926
|
+
`${target.requirement.name} is still ${presence.found}, below the ${presence.minVersion} it needs: ${
|
|
927
|
+
resolved
|
|
928
|
+
? `the PATH Claude Code starts with runs ${resolved}`
|
|
929
|
+
: `${tool} is not on the PATH Claude Code starts with`
|
|
930
|
+
} — remove that older copy or put the new one ahead of it, then restart Claude Code from a new terminal`,
|
|
931
|
+
);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
914
934
|
const tool = target.requirement.check[0] ?? target.requirement.name;
|
|
915
935
|
const landed = await landingDir(p.step, ctx);
|
|
916
936
|
if (landed && onPath(tool, landed)) {
|
|
@@ -924,6 +944,10 @@ async function verifyInstalled(
|
|
|
924
944
|
if (missing.length > 0) {
|
|
925
945
|
return { status: "failed", why: missing.join("\n") };
|
|
926
946
|
}
|
|
947
|
+
if (outdated.length > 0) {
|
|
948
|
+
const why = outdated.join("\n");
|
|
949
|
+
return { status: "failed", why, remedy: why };
|
|
950
|
+
}
|
|
927
951
|
const first = offPath[0];
|
|
928
952
|
if (first?.dir) {
|
|
929
953
|
return {
|
|
@@ -33,13 +33,20 @@ import {
|
|
|
33
33
|
prefixOnPath,
|
|
34
34
|
requiredByOf,
|
|
35
35
|
} from "./deps-plan.js";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
DOCTOR_CHECK_CAP_MS,
|
|
38
|
+
installReason,
|
|
39
|
+
probeBins,
|
|
40
|
+
} from "./doctor-bins.js";
|
|
37
41
|
import { readManifest } from "./manifest.js";
|
|
38
42
|
import { resolvePluginRequires, selectBins } from "./plugin-requires.js";
|
|
39
43
|
import { binKey, cliToolBins, mergeBins } from "./resolver.js";
|
|
40
44
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
45
|
+
/**
|
|
46
|
+
* The `--json` document's shape version. 2 added the `outdated` status and a
|
|
47
|
+
* dep's `found` and `minVersion`.
|
|
48
|
+
*/
|
|
49
|
+
export const DEPS_REPORT_SCHEMA = 2;
|
|
43
50
|
|
|
44
51
|
export interface PluginParseProblem {
|
|
45
52
|
plugin: string;
|
|
@@ -187,6 +194,10 @@ export interface DepJson {
|
|
|
187
194
|
blockedBy?: string;
|
|
188
195
|
prefix?: string;
|
|
189
196
|
reason?: string;
|
|
197
|
+
/** Only when `status` is "outdated": the version the check reported. */
|
|
198
|
+
found?: string;
|
|
199
|
+
/** Only when `status` is "outdated": the lowest version that will do. */
|
|
200
|
+
minVersion?: string;
|
|
190
201
|
fix?: {
|
|
191
202
|
via: string;
|
|
192
203
|
escalation: "none" | "sudo";
|
|
@@ -227,11 +238,17 @@ export function depsReportJson(
|
|
|
227
238
|
if (p.status === "missing" && p.blockedBy) entry.blockedBy = p.blockedBy;
|
|
228
239
|
if (p.status === "off-path") entry.prefix = p.prefix;
|
|
229
240
|
if (p.status === "unknown") entry.reason = p.reason;
|
|
241
|
+
if (p.status === "outdated") {
|
|
242
|
+
entry.found = p.found;
|
|
243
|
+
entry.minVersion = p.minVersion;
|
|
244
|
+
}
|
|
230
245
|
if (p.status !== "present" && bin.chosen) {
|
|
231
|
-
const step = renderInstall(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
246
|
+
const step = renderInstall(
|
|
247
|
+
bin,
|
|
248
|
+
bin.chosen,
|
|
249
|
+
{ ...env, pluginRoot: bin.root ?? "" },
|
|
250
|
+
installReason(p),
|
|
251
|
+
);
|
|
235
252
|
const prefix = prefixOf(step);
|
|
236
253
|
entry.fix = {
|
|
237
254
|
via: bin.chosen.via,
|