magus-cli 7.2.0 → 7.3.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/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/plugin-resolution.test.ts +431 -0
- package/src/__tests__/plugin-row-text.test.ts +66 -0
- package/src/__tests__/stale-load-repair.test.ts +1076 -0
- package/src/__tests__/update-apply.test.ts +146 -1
- package/src/__tests__/update-plan.test.ts +338 -0
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/doctor.ts +128 -2
- package/src/cli/update-view.ts +31 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +4 -1
- package/src/data/npm-package.ts +15 -0
- package/src/services/active-closure.ts +23 -0
- package/src/services/claude-settings.ts +645 -34
- package/src/services/plugin-manager.ts +61 -0
- package/src/services/plugin-resolution.ts +280 -0
- package/src/services/update-engine.ts +104 -2
- package/src/services/update-plan.ts +188 -1
- package/src/services/version-check.ts +20 -10
- package/src/ui/App.tsx +63 -0
- package/src/ui/hooks/useStaleLoadModal.ts +157 -0
- package/src/ui/renderers/pluginRenderers.tsx +29 -0
- package/src/ui/renderers/pluginStatusText.ts +14 -0
- package/src/ui/screens/PluginsScreen.tsx +46 -13
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildStaleLoadModal,
|
|
4
|
+
staleLoadRepairSummary,
|
|
5
|
+
} from "../ui/hooks/useStaleLoadModal";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The startup popup for a broken plugin.
|
|
9
|
+
*
|
|
10
|
+
* Same shape as buildGitignoreModal beside it, with one difference that is the
|
|
11
|
+
* whole point of the popup: FIXING is the default. A plugin the loader resolves
|
|
12
|
+
* at the wrong version means every session in this directory runs code nobody
|
|
13
|
+
* installed, so Enter must repair it rather than dismiss.
|
|
14
|
+
*/
|
|
15
|
+
describe("buildStaleLoadModal", () => {
|
|
16
|
+
const args = (
|
|
17
|
+
over: Partial<Parameters<typeof buildStaleLoadModal>[0]> = {},
|
|
18
|
+
) => ({
|
|
19
|
+
brokenCount: 2,
|
|
20
|
+
onFix: () => {},
|
|
21
|
+
onOpenTab: () => {},
|
|
22
|
+
onDismiss: () => {},
|
|
23
|
+
...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("offers fix, review and dismiss, with FIX first", () => {
|
|
27
|
+
const modal = buildStaleLoadModal(args());
|
|
28
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
29
|
+
|
|
30
|
+
expect(modal.options.map((o) => o.value)).toEqual([
|
|
31
|
+
"fix",
|
|
32
|
+
"open-tab",
|
|
33
|
+
"dismiss",
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("defaults to FIX explicitly, not by accident of ordering", () => {
|
|
38
|
+
// Stated in the data so a future reordering cannot quietly make "leave it
|
|
39
|
+
// broken" the thing Enter does.
|
|
40
|
+
const modal = buildStaleLoadModal(args());
|
|
41
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
42
|
+
|
|
43
|
+
expect(modal.defaultIndex).toBe(0);
|
|
44
|
+
expect(modal.options[modal.defaultIndex ?? -1]?.value).toBe("fix");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("blames the Claude Code bug, not magus's own state", () => {
|
|
48
|
+
// The title is the whole message for anyone who reads one line. It has to
|
|
49
|
+
// say what is wrong — a defect in Claude Code's loader — rather than
|
|
50
|
+
// report that two of magus's stores disagree, which is not actionable and
|
|
51
|
+
// reads as though the user misconfigured something.
|
|
52
|
+
const modal = buildStaleLoadModal(args({ brokenCount: 3 }));
|
|
53
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
54
|
+
|
|
55
|
+
expect(modal.title).toContain("Claude Code");
|
|
56
|
+
expect(modal.message).toContain("stale cached version");
|
|
57
|
+
expect(modal.options[0].label).toBe("Fix it (3 plugins)");
|
|
58
|
+
expect(modal.message).toContain("3 plugins are on an older version");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("says the reach is every worktree, not just this directory", () => {
|
|
62
|
+
// Relevance is decided by repository root, so every worktree and the main
|
|
63
|
+
// checkout resolve identically and break together. A reader told only
|
|
64
|
+
// about "this directory" would fix one worktree and expect the others to
|
|
65
|
+
// differ — they cannot.
|
|
66
|
+
const modal = buildStaleLoadModal(args({ brokenCount: 3 }));
|
|
67
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
68
|
+
|
|
69
|
+
expect(modal.message).toContain("every worktree");
|
|
70
|
+
expect(modal.message).toContain("main checkout");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("states the effect plainly, without overstating it", () => {
|
|
74
|
+
// A draft claimed "your sessions run code you did not install". Nothing
|
|
75
|
+
// happens to the user's code; an old cached plugin version is used. The
|
|
76
|
+
// overstatement is pinned here so it cannot come back on the next edit.
|
|
77
|
+
const modal = buildStaleLoadModal(args({ brokenCount: 3 }));
|
|
78
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
79
|
+
|
|
80
|
+
expect(modal.message).not.toContain("run code");
|
|
81
|
+
expect(modal.message).not.toContain("did not install");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("reads correctly for a single plugin", () => {
|
|
85
|
+
const modal = buildStaleLoadModal(args({ brokenCount: 1 }));
|
|
86
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
87
|
+
|
|
88
|
+
expect(modal.options[0].label).toBe("Fix it (1 plugin)");
|
|
89
|
+
expect(modal.message).toContain("1 plugin is on an older version");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("promises the backup and promises not to delete or downgrade", () => {
|
|
93
|
+
// The popup is asking permission to edit Claude Code-owned state. What it
|
|
94
|
+
// will and will not do has to be on the screen, not only in a code comment.
|
|
95
|
+
const modal = buildStaleLoadModal(args());
|
|
96
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
97
|
+
|
|
98
|
+
expect(modal.message).toContain(
|
|
99
|
+
"installed_plugins.json is backed up first",
|
|
100
|
+
);
|
|
101
|
+
// Case-insensitive: the promise has to be on screen, but whether it opens a
|
|
102
|
+
// sentence or trails one is a wording choice, and pinning the capital made
|
|
103
|
+
// an ordinary copy edit fail for nothing.
|
|
104
|
+
expect(modal.message.toLowerCase()).toContain(
|
|
105
|
+
"nothing is deleted or downgraded",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("routes each choice to its callback, and cancel to dismiss", () => {
|
|
110
|
+
let fixed = 0;
|
|
111
|
+
let opened = 0;
|
|
112
|
+
let dismissed = 0;
|
|
113
|
+
const modal = buildStaleLoadModal(
|
|
114
|
+
args({
|
|
115
|
+
onFix: () => {
|
|
116
|
+
fixed++;
|
|
117
|
+
},
|
|
118
|
+
onOpenTab: () => {
|
|
119
|
+
opened++;
|
|
120
|
+
},
|
|
121
|
+
onDismiss: () => {
|
|
122
|
+
dismissed++;
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
127
|
+
|
|
128
|
+
modal.onSelect("fix");
|
|
129
|
+
modal.onSelect("open-tab");
|
|
130
|
+
modal.onSelect("dismiss");
|
|
131
|
+
modal.onCancel();
|
|
132
|
+
|
|
133
|
+
expect({ fixed, opened, dismissed }).toEqual({
|
|
134
|
+
fixed: 1,
|
|
135
|
+
opened: 1,
|
|
136
|
+
dismissed: 2,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("staleLoadRepairSummary", () => {
|
|
142
|
+
it("says what loads NOW for each repaired plugin", () => {
|
|
143
|
+
expect(
|
|
144
|
+
staleLoadRepairSummary([
|
|
145
|
+
{ pluginId: "madbench@magus", target: "0.5.0", ok: true },
|
|
146
|
+
{ pluginId: "dev@magus", target: "7.4.0", ok: true },
|
|
147
|
+
]),
|
|
148
|
+
).toBe("✓ madbench@magus now loads v0.5.0\n✓ dev@magus now loads v7.4.0");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("reports a failed repair with its own reason, never as a success", () => {
|
|
152
|
+
expect(
|
|
153
|
+
staleLoadRepairSummary([
|
|
154
|
+
{
|
|
155
|
+
pluginId: "madbench@magus",
|
|
156
|
+
target: "0.5.0",
|
|
157
|
+
ok: false,
|
|
158
|
+
error: "the loader still resolves v0.3.0 here, not v0.5.0",
|
|
159
|
+
},
|
|
160
|
+
]),
|
|
161
|
+
).toBe(
|
|
162
|
+
"✗ madbench@magus — the loader still resolves v0.3.0 here, not v0.5.0",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("falls back to a plain reason rather than an empty line", () => {
|
|
167
|
+
expect(
|
|
168
|
+
staleLoadRepairSummary([
|
|
169
|
+
{ pluginId: "a@magus", target: "1.0.0", ok: false },
|
|
170
|
+
]),
|
|
171
|
+
).toBe("✗ a@magus — repair did not take effect");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("says so when there was nothing to repair", () => {
|
|
175
|
+
expect(staleLoadRepairSummary([])).toBe("Nothing to repair.");
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/cli/doctor.ts
CHANGED
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
* 2. Profile symlink integrity — the active profile's links resolve.
|
|
8
8
|
* 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
|
|
9
9
|
* 4. Model routing — config validity, hook registration, and drift.
|
|
10
|
+
* 5. Plugin loads — what Claude Code will load here vs what is installed.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import fs from "fs-extra";
|
|
15
|
+
import {
|
|
16
|
+
getProjectInstalledPluginVersions,
|
|
17
|
+
readInstalledPluginsRegistry,
|
|
18
|
+
readSettings,
|
|
19
|
+
} from "../services/claude-settings.js";
|
|
14
20
|
import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
|
|
15
21
|
import { checkProject, fixDoctorIssues } from "../services/doctor.js";
|
|
16
22
|
import {
|
|
@@ -22,6 +28,11 @@ import {
|
|
|
22
28
|
readModelsConfig,
|
|
23
29
|
readModelsStatus,
|
|
24
30
|
} from "../services/models-manager.js";
|
|
31
|
+
import { isBehind } from "../services/plugin-manager.js";
|
|
32
|
+
import {
|
|
33
|
+
type RepoRootCache,
|
|
34
|
+
pickLoadedEntry,
|
|
35
|
+
} from "../services/plugin-resolution.js";
|
|
25
36
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
26
37
|
import { activeProfile, profileDir } from "../services/symlink-manager.js";
|
|
27
38
|
import { resolveExecutable } from "../utils/command-utils.js";
|
|
@@ -111,8 +122,123 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
|
111
122
|
// 4. Model routing.
|
|
112
123
|
problems += await checkModels(projectPath, fix);
|
|
113
124
|
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
// 5. Stale plugin loads. Counted SEPARATELY, because `--fix` does not repair
|
|
126
|
+
// them — see doctorExitCode.
|
|
127
|
+
const staleLoads = await checkPluginLoads(projectPath);
|
|
128
|
+
|
|
129
|
+
if (problems === 0 && staleLoads === 0) console.log("\n✓ No problems found.");
|
|
130
|
+
return doctorExitCode({ fixable: problems, unfixable: staleLoads, fix });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Doctor's exit code.
|
|
135
|
+
*
|
|
136
|
+
* `--fix` suppresses the exit code for what it repaired — that is the point of
|
|
137
|
+
* the flag, and a caller that asked for repairs does not want a red exit over
|
|
138
|
+
* problems that are now gone. It must NOT suppress a class it never touches.
|
|
139
|
+
*
|
|
140
|
+
* Stale plugin loads are exactly that class: `checkPluginLoads` prints them and
|
|
141
|
+
* says so, and the repair lives in `repairStaleLoad`, reached by `magus update`
|
|
142
|
+
* and the Plugins screen. Folding them into the same counter meant `magus doctor
|
|
143
|
+
* --fix` printed red lines and exited 0 — so a CI or pre-flight step went green
|
|
144
|
+
* over a plugin whose sessions run a version nobody installed, which is the same
|
|
145
|
+
* deception the stale-load work exists to end.
|
|
146
|
+
*
|
|
147
|
+
* Pure and exported so the decision is pinned by a test rather than inferred from
|
|
148
|
+
* a command that needs a real `~/.claude` to run.
|
|
149
|
+
*/
|
|
150
|
+
export function doctorExitCode(args: {
|
|
151
|
+
/** Problems `--fix` can act on. */
|
|
152
|
+
fixable: number;
|
|
153
|
+
/** Problems it cannot — reported either way, and never silenced by `--fix`. */
|
|
154
|
+
unfixable: number;
|
|
155
|
+
fix: boolean;
|
|
156
|
+
}): number {
|
|
157
|
+
if (args.unfixable > 0) return 1;
|
|
158
|
+
return args.fixable > 0 && !args.fix ? 1 : 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Plugins whose installed version is NOT the version that will load here.
|
|
163
|
+
*
|
|
164
|
+
* Reported, never repaired — `--fix` deliberately does nothing about it, and
|
|
165
|
+
* that is a division of labour rather than a dead end. A reinstall does not
|
|
166
|
+
* clear this (measured): it writes another `installed_plugins.json` row for THIS
|
|
167
|
+
* project, while the row the loader picks belongs to a different one — usually a
|
|
168
|
+
* worktree that has since been deleted, whose entry nothing prunes. Moving those
|
|
169
|
+
* rows forward is what fixes it, and that lives in `repairStaleLoad`, reached by
|
|
170
|
+
* `magus update` and by the Plugins screen's `a` key. Doctor's job is to name
|
|
171
|
+
* the winner, which is the one thing neither of those prints.
|
|
172
|
+
*
|
|
173
|
+
* Both halves come from the registry rather than the catalog, so this section
|
|
174
|
+
* makes no network call and holds even when GitHub is unreachable:
|
|
175
|
+
* installed — magus's own resolution, `pickRegistryEntry` via settings overlay
|
|
176
|
+
* loads — the loader's, `pickLoadedEntry`
|
|
177
|
+
*/
|
|
178
|
+
async function checkPluginLoads(projectPath: string): Promise<number> {
|
|
179
|
+
const registry = await readInstalledPluginsRegistry();
|
|
180
|
+
const settings = await readSettings(projectPath);
|
|
181
|
+
const installed = await getProjectInstalledPluginVersions(
|
|
182
|
+
settings.installedPluginVersions || {},
|
|
183
|
+
projectPath,
|
|
184
|
+
);
|
|
185
|
+
const ids = Object.keys(installed);
|
|
186
|
+
if (ids.length === 0) return 0;
|
|
187
|
+
|
|
188
|
+
// One cache across every plugin: the same handful of project paths repeat in
|
|
189
|
+
// hundreds of rows, and each miss walks the tree to the filesystem root.
|
|
190
|
+
const cache: RepoRootCache = new Map();
|
|
191
|
+
const stale: Array<{
|
|
192
|
+
pluginId: string;
|
|
193
|
+
loaded: string;
|
|
194
|
+
recorded: string;
|
|
195
|
+
winner: string;
|
|
196
|
+
}> = [];
|
|
197
|
+
|
|
198
|
+
for (const pluginId of ids.sort()) {
|
|
199
|
+
const recorded = installed[pluginId];
|
|
200
|
+
if (!recorded) continue;
|
|
201
|
+
const pick = pickLoadedEntry(
|
|
202
|
+
registry.plugins?.[pluginId],
|
|
203
|
+
projectPath,
|
|
204
|
+
cache,
|
|
205
|
+
);
|
|
206
|
+
if (!pick?.version) continue;
|
|
207
|
+
// The same predicate the classifier uses, so doctor and the plugin screen
|
|
208
|
+
// cannot disagree about which pairs count. Unorderable pairs answer null
|
|
209
|
+
// and are left alone — "cannot tell" is not a finding.
|
|
210
|
+
if (isBehind(recorded, pick.version) !== true) continue;
|
|
211
|
+
stale.push({
|
|
212
|
+
pluginId,
|
|
213
|
+
loaded: pick.version,
|
|
214
|
+
recorded,
|
|
215
|
+
winner:
|
|
216
|
+
pick.projectPath ??
|
|
217
|
+
(pick.scope === "user" ? "user scope (no project)" : "unknown project"),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log("\nPlugin loads:");
|
|
222
|
+
if (stale.length === 0) {
|
|
223
|
+
console.log(
|
|
224
|
+
` ✓ All ${ids.length} installed plugins load at their installed version.`,
|
|
225
|
+
);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const row of stale) {
|
|
230
|
+
console.log(
|
|
231
|
+
` ✗ ${row.pluginId} — loads v${row.loaded}, installed v${row.recorded}`,
|
|
232
|
+
);
|
|
233
|
+
console.log(` the entry that wins belongs to ${row.winner}`);
|
|
234
|
+
}
|
|
235
|
+
console.log(
|
|
236
|
+
" Reinstalling does not fix this. Run `magus update` — it moves those",
|
|
237
|
+
);
|
|
238
|
+
console.log(
|
|
239
|
+
" install records forward, backing up installed_plugins.json first.",
|
|
240
|
+
);
|
|
241
|
+
return stale.length;
|
|
116
242
|
}
|
|
117
243
|
|
|
118
244
|
/**
|