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.
@@ -0,0 +1,1076 @@
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
10
+ import { execFileSync } from "node:child_process";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import fs from "fs-extra";
14
+ import {
15
+ findStaleLoads,
16
+ repairAllStaleLoads,
17
+ repairStaleLoad,
18
+ writeInstalledPluginsRegistry,
19
+ } from "../services/claude-settings.js";
20
+ import type { PluginInfo } from "../services/plugin-manager.js";
21
+ import {
22
+ isRelevantInstallation,
23
+ pickLoadedEntry,
24
+ repoRootForPath,
25
+ resolveLoadedVersions,
26
+ } from "../services/plugin-resolution.js";
27
+ import {
28
+ classifyPluginRows,
29
+ countPluginStates,
30
+ } from "../services/update-plan.js";
31
+ import type {
32
+ InstalledPluginEntry,
33
+ InstalledPluginsRegistry,
34
+ } from "../types/index.js";
35
+
36
+ /**
37
+ * `repairStaleLoad` — the one place magus writes `installed_plugins.json`
38
+ * deliberately.
39
+ *
40
+ * Exercised against REAL git repositories, for the same reason
41
+ * plugin-resolution.test.ts is: the whole difficulty is that rows belonging to
42
+ * other worktrees of the same repository — including deleted ones — take part in
43
+ * the loader's selection, and a hand-written `.git` file would only confirm
44
+ * whatever parse it was written from.
45
+ *
46
+ * Every case passes an explicit plugins ROOT under a temp directory. Nothing
47
+ * here may touch the operator's real `~/.claude` — this function rewrites the
48
+ * record of what every project on the machine has installed.
49
+ */
50
+
51
+ // Isolate from the operator's git config, exactly as plugin-resolution.test.ts does.
52
+ const GIT_CFG = [
53
+ "-c",
54
+ "user.name=test",
55
+ "-c",
56
+ "user.email=test@example.com",
57
+ "-c",
58
+ "commit.gpgsign=false",
59
+ "-c",
60
+ "init.defaultBranch=main",
61
+ ];
62
+ const git = (cwd: string, ...args: string[]) =>
63
+ execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" }).trim();
64
+
65
+ const PLUGIN = "madbench@magus";
66
+
67
+ let base: string;
68
+ /** The repository under test, standing in for magus-src. */
69
+ let mainTree: string;
70
+ /** A live linked worktree of it — the directory every case runs "in". */
71
+ let here: string;
72
+ /** A worktree that was created, registered, then REMOVED. */
73
+ let gone: string;
74
+ /** The plugins root: registry file + cache, as `~/.claude/plugins`. */
75
+ let root: string;
76
+ let registryFile: string;
77
+
78
+ const cacheFor = (version: string) =>
79
+ path.join(root, "cache", "magus", "madbench", version);
80
+
81
+ const row = (
82
+ over: Partial<InstalledPluginEntry> & { version: string },
83
+ ): InstalledPluginEntry => ({
84
+ scope: "project",
85
+ installPath: cacheFor(over.version),
86
+ installedAt: "2026-01-01T00:00:00.000Z",
87
+ lastUpdated: "2026-01-01T00:00:00.000Z",
88
+ ...over,
89
+ });
90
+
91
+ async function writeRegistry(rows: InstalledPluginEntry[]): Promise<void> {
92
+ const registry: InstalledPluginsRegistry = {
93
+ version: 2,
94
+ plugins: { [PLUGIN]: rows },
95
+ };
96
+ await fs.outputJson(registryFile, registry, { spaces: 2 });
97
+ }
98
+
99
+ const readRegistry = (): Promise<InstalledPluginsRegistry> =>
100
+ fs.readJson(registryFile);
101
+
102
+ /** Every backup this suite's writes produced, newest last. */
103
+ async function backups(): Promise<string[]> {
104
+ const names = await fs.readdir(path.dirname(registryFile));
105
+ return names
106
+ .filter((n) => n.startsWith("installed_plugins.json.backup."))
107
+ .sort();
108
+ }
109
+
110
+ beforeAll(async () => {
111
+ // realpath: on macOS os.tmpdir() is a symlink, and git reports real paths.
112
+ base = await fs.realpath(
113
+ await fs.mkdtemp(path.join(os.tmpdir(), "magus-repair-")),
114
+ );
115
+ mainTree = path.join(base, "magus-src");
116
+ here = path.join(mainTree, ".claude", "worktrees", "madbench");
117
+ gone = path.join(mainTree, ".claude", "worktrees", "gone");
118
+
119
+ await fs.ensureDir(mainTree);
120
+ git(mainTree, "init", "-q");
121
+ await fs.writeFile(path.join(mainTree, "README.md"), "hello\n");
122
+ git(mainTree, "add", "-A");
123
+ git(mainTree, "commit", "-qm", "initial");
124
+ git(mainTree, "worktree", "add", "-q", "-b", "madbench", here);
125
+
126
+ // Created, registered, then removed — its registry rows are what survive it,
127
+ // and on the reporting machine those were the rows that won.
128
+ git(mainTree, "worktree", "add", "-q", "-b", "gone", gone);
129
+ await fs.remove(gone);
130
+ });
131
+
132
+ afterAll(async () => {
133
+ if (base) await fs.remove(base);
134
+ });
135
+
136
+ beforeEach(async () => {
137
+ // A fresh plugins root per case, so a backup from one case cannot be counted
138
+ // by the next.
139
+ root = await fs.mkdtemp(path.join(base, "plugins-"));
140
+ registryFile = path.join(root, "installed_plugins.json");
141
+ for (const v of ["0.3.0", "0.4.0", "0.5.0", "0.9.0"]) {
142
+ await fs.ensureDir(cacheFor(v));
143
+ }
144
+ });
145
+
146
+ describe("repairStaleLoad — rows move FORWARD and none are removed", () => {
147
+ test("the deleted worktree's winning row is rewritten to the target", async () => {
148
+ // The measured defect: 0.3.0 registered against a worktree that no longer
149
+ // exists, written first, so it wins over this project's own 0.5.0.
150
+ await writeRegistry([
151
+ row({ version: "0.3.0", projectPath: gone }),
152
+ row({ version: "0.5.0", projectPath: here }),
153
+ ]);
154
+ expect(
155
+ pickLoadedEntry((await readRegistry()).plugins[PLUGIN], here)?.version,
156
+ ).toBe("0.3.0");
157
+
158
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
159
+
160
+ expect(result.ok).toBe(true);
161
+ expect(result.rewritten).toBe(1);
162
+ expect(result.loaded).toBe("0.5.0");
163
+
164
+ const after = (await readRegistry()).plugins[PLUGIN];
165
+ // NOTHING REMOVED — both rows survive, in their original order.
166
+ expect(after.length).toBe(2);
167
+ expect(after.map((e) => e.projectPath)).toEqual([gone, here]);
168
+ expect(after.map((e) => e.version)).toEqual(["0.5.0", "0.5.0"]);
169
+ expect(after[0].installPath).toBe(cacheFor("0.5.0"));
170
+ });
171
+
172
+ test("every relevant row behind the target moves, not just the winner", async () => {
173
+ await writeRegistry([
174
+ row({ version: "0.3.0", projectPath: gone }),
175
+ row({ version: "0.4.0", projectPath: mainTree }),
176
+ row({ version: "0.5.0", projectPath: here }),
177
+ ]);
178
+
179
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
180
+
181
+ expect(result.ok).toBe(true);
182
+ expect(result.rewritten).toBe(2);
183
+ expect(
184
+ (await readRegistry()).plugins[PLUGIN].map((e) => e.version),
185
+ ).toEqual(["0.5.0", "0.5.0", "0.5.0"]);
186
+ });
187
+
188
+ test("a row AHEAD of the target is left exactly as it was", async () => {
189
+ // Move forward only. A row at 0.9.0 is not something to "correct" down to
190
+ // the target, and rewriting it would be a downgrade this function promises
191
+ // never to perform.
192
+ await writeRegistry([
193
+ row({ version: "0.3.0", projectPath: gone }),
194
+ row({ version: "0.9.0", projectPath: mainTree }),
195
+ row({ version: "0.5.0", projectPath: here }),
196
+ ]);
197
+
198
+ await repairStaleLoad(PLUGIN, "0.5.0", here, root);
199
+
200
+ const after = (await readRegistry()).plugins[PLUGIN];
201
+ expect(after[1].version).toBe("0.9.0");
202
+ expect(after[1].installPath).toBe(cacheFor("0.9.0"));
203
+ });
204
+
205
+ test("a row from an UNRELATED repository is never touched", async () => {
206
+ const other = path.join(base, "unrelated");
207
+ await fs.ensureDir(other);
208
+ git(other, "init", "-q");
209
+
210
+ await writeRegistry([
211
+ row({ version: "0.3.0", projectPath: other }),
212
+ row({ version: "0.5.0", projectPath: here }),
213
+ ]);
214
+
215
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
216
+
217
+ expect(result.rewritten).toBe(0);
218
+ expect((await readRegistry()).plugins[PLUGIN][0].version).toBe("0.3.0");
219
+ });
220
+
221
+ test("the recorded commit is dropped on a rewritten row", async () => {
222
+ // It describes the version we just moved AWAY from; keeping it would have
223
+ // content-drift compare two different releases and report the repaired
224
+ // plugin as needing a reinstall.
225
+ await writeRegistry([
226
+ row({
227
+ version: "0.3.0",
228
+ projectPath: gone,
229
+ gitCommitSha: "a".repeat(40),
230
+ }),
231
+ row({ version: "0.5.0", projectPath: here }),
232
+ ]);
233
+
234
+ await repairStaleLoad(PLUGIN, "0.5.0", here, root);
235
+
236
+ expect(
237
+ (await readRegistry()).plugins[PLUGIN][0].gitCommitSha,
238
+ ).toBeUndefined();
239
+ });
240
+ });
241
+
242
+ describe("repairStaleLoad — the backup", () => {
243
+ test("a timestamped backup sits beside the registry after a write", async () => {
244
+ await writeRegistry([
245
+ row({ version: "0.3.0", projectPath: gone }),
246
+ row({ version: "0.5.0", projectPath: here }),
247
+ ]);
248
+
249
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
250
+
251
+ expect(result.backup).not.toBeNull();
252
+ expect(await backups()).toEqual([path.basename(result.backup as string)]);
253
+ expect(path.dirname(result.backup as string)).toBe(root);
254
+ });
255
+
256
+ test("the backup holds the PRE-repair rows, so the edit can be undone", async () => {
257
+ await writeRegistry([
258
+ row({ version: "0.3.0", projectPath: gone }),
259
+ row({ version: "0.5.0", projectPath: here }),
260
+ ]);
261
+
262
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
263
+ const saved: InstalledPluginsRegistry = await fs.readJson(
264
+ result.backup as string,
265
+ );
266
+
267
+ expect(saved.plugins[PLUGIN].map((e) => e.version)).toEqual([
268
+ "0.3.0",
269
+ "0.5.0",
270
+ ]);
271
+ });
272
+
273
+ test("no rows to move means no write and no backup", async () => {
274
+ await writeRegistry([row({ version: "0.5.0", projectPath: here })]);
275
+
276
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
277
+
278
+ expect(result.ok).toBe(true);
279
+ expect(result.rewritten).toBe(0);
280
+ expect(result.backup).toBeNull();
281
+ expect(await backups()).toEqual([]);
282
+ });
283
+
284
+ test("an unbackupable registry is NOT written", async () => {
285
+ // The backup is the only undo for an edit to the record of every project's
286
+ // installs, so a repair that cannot take one must not proceed. Simulated by
287
+ // making the directory read-only: `fs.copy` cannot create the backup file,
288
+ // while the registry itself is still perfectly readable.
289
+ await writeRegistry([
290
+ row({ version: "0.3.0", projectPath: gone }),
291
+ row({ version: "0.5.0", projectPath: here }),
292
+ ]);
293
+ const before = await fs.readFile(registryFile, "utf8");
294
+ await fs.chmod(root, 0o500);
295
+
296
+ try {
297
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
298
+
299
+ expect(result.ok).toBe(false);
300
+ expect(result.error).toContain("could not back up");
301
+ expect(result.rewritten).toBe(0);
302
+ expect(result.backup).toBeNull();
303
+ expect(await fs.readFile(registryFile, "utf8")).toBe(before);
304
+ } finally {
305
+ await fs.chmod(root, 0o700);
306
+ }
307
+ });
308
+ });
309
+
310
+ describe("repairStaleLoad — refusals", () => {
311
+ test("a target with no cache directory is refused before any write", async () => {
312
+ // Writing that row would leave the registry naming a directory that is not
313
+ // there: the loader would resolve it and load nothing at all, which is worse
314
+ // than the stale load it replaced.
315
+ await writeRegistry([
316
+ row({ version: "0.3.0", projectPath: gone }),
317
+ row({ version: "0.7.0", projectPath: here }),
318
+ ]);
319
+ expect(await fs.pathExists(cacheFor("0.7.0"))).toBe(false);
320
+ const before = await fs.readFile(registryFile, "utf8");
321
+
322
+ const result = await repairStaleLoad(PLUGIN, "0.7.0", here, root);
323
+
324
+ expect(result.ok).toBe(false);
325
+ expect(result.error).toContain("no cache directory for v0.7.0");
326
+ expect(result.backup).toBeNull();
327
+ expect(await fs.readFile(registryFile, "utf8")).toBe(before);
328
+ expect(await backups()).toEqual([]);
329
+ });
330
+
331
+ test("a plugin with no registry rows is refused", async () => {
332
+ await fs.outputJson(registryFile, { version: 2, plugins: {} });
333
+
334
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
335
+
336
+ expect(result.ok).toBe(false);
337
+ expect(result.error).toContain("has no rows");
338
+ });
339
+
340
+ test("a string that is not a plugin id is refused", async () => {
341
+ const result = await repairStaleLoad("madbench", "0.5.0", here, root);
342
+ expect(result.ok).toBe(false);
343
+ expect(result.error).toContain("not a plugin id");
344
+ });
345
+
346
+ test("an unreadable registry is refused rather than replaced", async () => {
347
+ // `readInstalledPluginsRegistry` answers an EMPTY registry for a corrupt
348
+ // file, and writing that back would erase every project's installs on the
349
+ // machine. The repair reads and parses the same bytes it will check before
350
+ // writing, so a document it cannot parse stops it here.
351
+ await fs.outputFile(registryFile, "{ not json");
352
+
353
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
354
+
355
+ expect(result.ok).toBe(false);
356
+ expect(result.error).toContain("not a readable registry");
357
+ expect(await fs.readFile(registryFile, "utf8")).toBe("{ not json");
358
+ });
359
+ });
360
+
361
+ /**
362
+ * HOW FAR A REPAIR MAY REACH: this repository's own records, and nothing else.
363
+ *
364
+ * The loader's relevance filter counts `user` and `managed` rows for every
365
+ * project on the machine, and the repair used to rewrite through that same
366
+ * filter — so a fix offered as "this repo's install records" moved a machine-wide
367
+ * record, installed from an unrelated directory, forward on its own. The dialog
368
+ * is what the user consents on, so the repair is what had to change.
369
+ */
370
+ describe("repairStaleLoad — scope reach", () => {
371
+ /** A machine-wide row: no projectPath, relevant to every directory. */
372
+ const machineWide = (scope: string, version: string): InstalledPluginEntry =>
373
+ ({
374
+ scope,
375
+ installPath: cacheFor(version),
376
+ version,
377
+ installedAt: "2026-01-01T00:00:00.000Z",
378
+ lastUpdated: "2026-01-01T00:00:00.000Z",
379
+ // `managed` is a scope Claude Code writes and types/index.ts does not
380
+ // name — see LoaderScope in plugin-resolution.ts.
381
+ }) as unknown as InstalledPluginEntry;
382
+
383
+ test("a user-scope row is NOT moved forward, and this repo's rows are left alone too", async () => {
384
+ // It is the winner here, so no rewrite this function is willing to make can
385
+ // change the resolution. Refusing and naming the scope is the answer; the
386
+ // old behaviour moved the machine-wide row and reported success.
387
+ await writeRegistry([
388
+ machineWide("user", "0.3.0"),
389
+ row({ version: "0.5.0", projectPath: here }),
390
+ ]);
391
+ const before = await fs.readFile(registryFile, "utf8");
392
+
393
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
394
+
395
+ expect(result.ok).toBe(false);
396
+ expect(result.error).toContain("machine-wide user-scope record at v0.3.0");
397
+ expect(result.error).toContain("--scope user");
398
+ expect(result.rewritten).toBe(0);
399
+ expect(await fs.readFile(registryFile, "utf8")).toBe(before);
400
+ expect(await backups()).toEqual([]);
401
+ });
402
+
403
+ test("an exact pin caps the survey, so a pinned plugin is never offered or moved", async () => {
404
+ // The measured shape: pin 0.3.0, this repository installed and loading 0.3.0,
405
+ // and a user-scope install at 0.5.0. The machine-wide row is relevant to every
406
+ // directory, so an uncapped survey took 0.5.0 as the target, offered the
407
+ // plugin in the startup popup, and the repair succeeded — moving a pinned
408
+ // plugin off its pin. Only the pin can say that move is unwanted.
409
+ //
410
+ // Array order puts this repository's row first so it is the loader's winner;
411
+ // otherwise the user row loads and there is no mismatch to survey at all.
412
+ await writeRegistry([
413
+ row({ version: "0.3.0", projectPath: here }),
414
+ machineWide("user", "0.5.0"),
415
+ ]);
416
+ expect(
417
+ pickLoadedEntry((await readRegistry()).plugins[PLUGIN], here)?.version,
418
+ ).toBe("0.3.0");
419
+
420
+ const pins = { [PLUGIN]: "0.3.0" };
421
+ expect(await findStaleLoads(here, root, pins)).toEqual([]);
422
+ expect(await repairAllStaleLoads(here, root, pins)).toEqual([]);
423
+ expect((await readRegistry()).plugins[PLUGIN][0].version).toBe("0.3.0");
424
+ expect(await backups()).toEqual([]);
425
+ });
426
+
427
+ test("the same rows on `latest` are surveyed and repaired to the user-scope version", async () => {
428
+ // The negative control for the cap: with nothing pinning the plugin down,
429
+ // 0.5.0 is the version this machine holds and loading 0.3.0 here is the
430
+ // defect the popup exists to report. The cap must not disable that.
431
+ await writeRegistry([
432
+ row({ version: "0.3.0", projectPath: here }),
433
+ machineWide("user", "0.5.0"),
434
+ ]);
435
+
436
+ expect(await findStaleLoads(here, root)).toEqual([
437
+ { pluginId: PLUGIN, loaded: "0.3.0", target: "0.5.0" },
438
+ ]);
439
+ const [result] = await repairAllStaleLoads(here, root);
440
+ expect(result?.ok).toBe(true);
441
+ expect((await readRegistry()).plugins[PLUGIN][0].version).toBe("0.5.0");
442
+ });
443
+
444
+ test("a managed row is named as policy, not as something to update", async () => {
445
+ await writeRegistry([
446
+ machineWide("managed", "0.3.0"),
447
+ row({ version: "0.5.0", projectPath: here }),
448
+ ]);
449
+
450
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
451
+
452
+ expect(result.ok).toBe(false);
453
+ expect(result.error).toContain("machine-wide managed-scope record");
454
+ expect(result.error).toContain("administrator policy");
455
+ });
456
+
457
+ test("a user-scope row that is not the winner stays where it is", async () => {
458
+ // The rows that decide the load here are this repository's, so the repair
459
+ // runs — and the machine-wide row at 0.4.0 must come out untouched. It was
460
+ // rewritten to 0.5.0 before this filter existed, changing what every other
461
+ // project on the machine resolves.
462
+ await writeRegistry([
463
+ row({ version: "0.3.0", projectPath: gone }),
464
+ machineWide("user", "0.4.0"),
465
+ row({ version: "0.5.0", projectPath: here }),
466
+ ]);
467
+
468
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
469
+
470
+ expect(result.ok).toBe(true);
471
+ expect(result.rewritten).toBe(1);
472
+ const after = (await readRegistry()).plugins[PLUGIN];
473
+ expect(after.map((e) => [e.scope, e.version])).toEqual([
474
+ ["project", "0.5.0"],
475
+ ["user", "0.4.0"],
476
+ ["project", "0.5.0"],
477
+ ]);
478
+ expect(after[1].installPath).toBe(cacheFor("0.4.0"));
479
+ });
480
+
481
+ test("a rootless row from another directory is relevant but never rewritten", async () => {
482
+ // The loader's filter compares repository roots and two nulls are equal
483
+ // there, so this row genuinely competes — `isRelevantInstallation` says so
484
+ // and plugin-resolution.test.ts pins it. Writing it is a different question:
485
+ // "the same repository" cannot be true of two directories that are in no
486
+ // repository, so the repair reports that it could not fix this rather than
487
+ // editing a stranger's row.
488
+ const looseHere = path.join(base, "loose-here");
489
+ const looseOther = path.join(base, "loose-other");
490
+ await fs.ensureDir(looseHere);
491
+ await fs.ensureDir(looseOther);
492
+ expect(repoRootForPath(looseHere)).toBeNull();
493
+ await writeRegistry([
494
+ row({ version: "0.3.0", projectPath: looseOther }),
495
+ row({ version: "0.5.0", projectPath: looseHere }),
496
+ ]);
497
+ expect(
498
+ isRelevantInstallation(
499
+ (await readRegistry()).plugins[PLUGIN][0],
500
+ looseHere,
501
+ repoRootForPath(looseHere),
502
+ ),
503
+ ).toBe(true);
504
+
505
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", looseHere, root);
506
+
507
+ expect(result.rewritten).toBe(0);
508
+ expect(result.ok).toBe(false);
509
+ expect(result.error).toContain("still resolves v0.3.0");
510
+ expect((await readRegistry()).plugins[PLUGIN][0].version).toBe("0.3.0");
511
+ });
512
+ });
513
+
514
+ /**
515
+ * A LOST UPDATE is not prevented by an atomic write.
516
+ *
517
+ * The rename cannot be seen half-done, and that was the whole guarantee: the
518
+ * repair still rewrote the WHOLE file from a snapshot, so any other session's
519
+ * `claude plugin install` landing in between was discarded by a write that was
520
+ * perfectly atomic. This file is written constantly — dozens of live sessions on
521
+ * a working machine — and this repo's own resolution note measured it drifting
522
+ * inside minutes.
523
+ */
524
+ describe("repairStaleLoad — a concurrent write is refused, not clobbered", () => {
525
+ test("another session's install survives, and the repair says why it stopped", async () => {
526
+ await writeRegistry([
527
+ row({ version: "0.3.0", projectPath: gone }),
528
+ row({ version: "0.5.0", projectPath: here }),
529
+ ]);
530
+
531
+ // The interleave, forced at the one point the repair is guaranteed to await
532
+ // between reading the file and renaming over it: the backup copy. The
533
+ // concurrent writer adds a row for a DIFFERENT plugin, which is what a
534
+ // `claude plugin install` in another project looks like.
535
+ const realCopy: (src: string, dest: string) => Promise<void> =
536
+ fs.copy.bind(fs);
537
+ const spy = spyOn(fs, "copy").mockImplementation(
538
+ async (src: string, dest: string) => {
539
+ const current: InstalledPluginsRegistry =
540
+ await fs.readJson(registryFile);
541
+ current.plugins["other@magus"] = [
542
+ row({ version: "1.0.0", projectPath: here }),
543
+ ];
544
+ await fs.outputJson(registryFile, current, { spaces: 2 });
545
+ return realCopy(src, dest);
546
+ },
547
+ );
548
+
549
+ try {
550
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
551
+
552
+ expect(result.ok).toBe(false);
553
+ expect(result.error).toContain("changed on disk after it was read");
554
+ expect(result.rewritten).toBe(0);
555
+ } finally {
556
+ spy.mockRestore();
557
+ }
558
+
559
+ const after = await readRegistry();
560
+ // The other session's row is still there — the whole point.
561
+ expect(after.plugins["other@magus"]?.[0]?.version).toBe("1.0.0");
562
+ // And nothing of ours was written from the stale snapshot.
563
+ expect(after.plugins[PLUGIN].map((e) => e.version)).toEqual([
564
+ "0.3.0",
565
+ "0.5.0",
566
+ ]);
567
+ // A backup is an undo for a file that changed; nothing changed, so it goes.
568
+ expect(await backups()).toEqual([]);
569
+ });
570
+
571
+ test("the guarded write refuses directly when the bytes it was given are stale", async () => {
572
+ // The mechanism on its own, without the interleave: `expectBytes` is the
573
+ // precondition, and a write carrying yesterday's bytes must not land.
574
+ await writeRegistry([row({ version: "0.5.0", projectPath: here })]);
575
+ const stale = await fs.readFile(registryFile, "utf8");
576
+ await writeRegistry([row({ version: "0.9.0", projectPath: here })]);
577
+ const current = await fs.readFile(registryFile, "utf8");
578
+
579
+ await expect(
580
+ writeInstalledPluginsRegistry({ version: 2, plugins: {} }, root, stale),
581
+ ).rejects.toThrow("changed on disk after it was read");
582
+ expect(await fs.readFile(registryFile, "utf8")).toBe(current);
583
+
584
+ // With the CURRENT bytes it lands, so the guard is not simply refusing
585
+ // everything.
586
+ await writeInstalledPluginsRegistry(
587
+ { version: 2, plugins: {} },
588
+ root,
589
+ current,
590
+ );
591
+ expect((await readRegistry()).plugins).toEqual({});
592
+ });
593
+ });
594
+
595
+ describe("registry backups — one per repair, pruned", () => {
596
+ test("the filename names the plugin, so two repairs in one run are two files", async () => {
597
+ // Keyed on `Date.now()` alone, two repairs inside the same millisecond
598
+ // produced the SAME path and `fs.copy` overwrote — so in a multi-plugin
599
+ // repair the surviving file no longer held the first plugin's pre-repair
600
+ // state, and the only undo for it was gone.
601
+ await fs.ensureDir(path.join(root, "cache", "magus", "dev", "7.0.0"));
602
+ await fs.ensureDir(path.join(root, "cache", "magus", "dev", "7.4.0"));
603
+ const devRow = (version: string, projectPath: string) => ({
604
+ scope: "project" as const,
605
+ projectPath,
606
+ installPath: path.join(root, "cache", "magus", "dev", version),
607
+ version,
608
+ installedAt: "2026-01-01T00:00:00.000Z",
609
+ lastUpdated: "2026-01-01T00:00:00.000Z",
610
+ });
611
+ await fs.outputJson(
612
+ registryFile,
613
+ {
614
+ version: 2,
615
+ plugins: {
616
+ [PLUGIN]: [
617
+ row({ version: "0.3.0", projectPath: gone }),
618
+ row({ version: "0.5.0", projectPath: here }),
619
+ ],
620
+ "dev@magus": [devRow("7.0.0", gone), devRow("7.4.0", here)],
621
+ },
622
+ },
623
+ { spaces: 2 },
624
+ );
625
+
626
+ const results = await repairAllStaleLoads(here, root);
627
+ expect(results.every((r) => r.ok)).toBe(true);
628
+
629
+ const names = await backups();
630
+ expect(names.length).toBe(2);
631
+ expect(names.some((n) => n.endsWith("dev-magus"))).toBe(true);
632
+ expect(names.some((n) => n.endsWith("madbench-magus"))).toBe(true);
633
+ // Each holds the state before ITS OWN repair: dev's backup predates both.
634
+ const devBackup: InstalledPluginsRegistry = await fs.readJson(
635
+ path.join(root, names.find((n) => n.endsWith("dev-magus")) as string),
636
+ );
637
+ expect(devBackup.plugins["dev@magus"].map((e) => e.version)).toEqual([
638
+ "7.0.0",
639
+ "7.4.0",
640
+ ]);
641
+ });
642
+
643
+ test("old backups are pruned to the newest ten", async () => {
644
+ // The backup is the only undo for a write to Claude Code-owned state, so
645
+ // they are kept — but nothing pruned them, and every repair left another
646
+ // copy of a multi-hundred-KB file in a directory magus does not own.
647
+ await writeRegistry([
648
+ row({ version: "0.3.0", projectPath: gone }),
649
+ row({ version: "0.5.0", projectPath: here }),
650
+ ]);
651
+ // Timestamps well below `Date.now()`, same width so a name sort orders them,
652
+ // so the real backup sorts newest and the dummies sort oldest-first.
653
+ for (let i = 0; i < 12; i++) {
654
+ const stamp = `1000000000${String(i).padStart(3, "0")}`;
655
+ await fs.outputFile(`${registryFile}.backup.${stamp}.0.older`, "{}");
656
+ }
657
+
658
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
659
+ expect(result.ok).toBe(true);
660
+
661
+ const names = await backups();
662
+ expect(names.length).toBe(10);
663
+ expect(names.at(-1)).toBe(path.basename(result.backup as string));
664
+ // The oldest three went; the newest of the dummies stayed.
665
+ expect(names.some((n) => n.includes("1000000000000.0.older"))).toBe(false);
666
+ expect(names.some((n) => n.includes("1000000000002.0.older"))).toBe(false);
667
+ expect(names.some((n) => n.includes("1000000000003.0.older"))).toBe(true);
668
+ expect(names.some((n) => n.includes("1000000000011.0.older"))).toBe(true);
669
+ });
670
+ });
671
+
672
+ describe("repairStaleLoad — the post-write assertion", () => {
673
+ test("a write that does NOT change the resolution reports FAILURE", async () => {
674
+ // Every relevant row behind 0.5.0 is moved forward, and the loader still
675
+ // resolves something else: mainTree's row is ahead of the target, exists on
676
+ // disk, and comes first, so it keeps winning. The repair genuinely did not
677
+ // achieve what it was asked for, and saying "ok" here would be exactly the
678
+ // deception this whole state exists to end.
679
+ await writeRegistry([
680
+ row({ version: "0.9.0", projectPath: mainTree }),
681
+ row({ version: "0.3.0", projectPath: gone }),
682
+ row({ version: "0.5.0", projectPath: here }),
683
+ ]);
684
+
685
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
686
+
687
+ expect(result.rewritten).toBe(1);
688
+ expect(result.loaded).toBe("0.9.0");
689
+ expect(result.ok).toBe(false);
690
+ expect(result.error).toContain("still resolves v0.9.0");
691
+ });
692
+
693
+ test("the assertion reads the file from DISK, not the object it mutated", async () => {
694
+ await writeRegistry([
695
+ row({ version: "0.3.0", projectPath: gone }),
696
+ row({ version: "0.5.0", projectPath: here }),
697
+ ]);
698
+
699
+ const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
700
+
701
+ // Proving the claim means re-resolving the persisted file independently and
702
+ // getting the same answer the repair reported.
703
+ expect(
704
+ pickLoadedEntry((await readRegistry()).plugins[PLUGIN], here)?.version,
705
+ ).toBe(result.loaded);
706
+ expect(result.loaded).toBe("0.5.0");
707
+ });
708
+
709
+ test("a repair reporting ok means the loader really resolves the target", async () => {
710
+ await writeRegistry([
711
+ row({ version: "0.3.0", projectPath: gone }),
712
+ row({ version: "0.4.0", projectPath: here }),
713
+ ]);
714
+
715
+ const result = await repairStaleLoad(PLUGIN, "0.4.0", here, root);
716
+
717
+ expect(result.ok).toBe(true);
718
+ expect(
719
+ pickLoadedEntry((await readRegistry()).plugins[PLUGIN], here)?.version,
720
+ ).toBe("0.4.0");
721
+ });
722
+ });
723
+
724
+ describe("findStaleLoads", () => {
725
+ test("names the broken plugin and the version to repair it to", async () => {
726
+ await writeRegistry([
727
+ row({ version: "0.3.0", projectPath: gone }),
728
+ row({ version: "0.5.0", projectPath: here }),
729
+ ]);
730
+
731
+ expect(await findStaleLoads(here, root)).toEqual([
732
+ { pluginId: PLUGIN, loaded: "0.3.0", target: "0.5.0" },
733
+ ]);
734
+ });
735
+
736
+ test("a plugin loading its newest relevant row is not reported", async () => {
737
+ await writeRegistry([
738
+ row({ version: "0.5.0", projectPath: here }),
739
+ row({ version: "0.3.0", projectPath: gone }),
740
+ ]);
741
+
742
+ expect(await findStaleLoads(here, root)).toEqual([]);
743
+ });
744
+
745
+ test("rows from other repositories cannot make a plugin look broken", async () => {
746
+ const other = path.join(base, "unrelated-find");
747
+ await fs.ensureDir(other);
748
+ git(other, "init", "-q");
749
+
750
+ await writeRegistry([
751
+ row({ version: "0.3.0", projectPath: here }),
752
+ row({ version: "0.9.0", projectPath: other }),
753
+ ]);
754
+
755
+ expect(await findStaleLoads(here, root)).toEqual([]);
756
+ });
757
+
758
+ test("an empty registry reports nothing", async () => {
759
+ await fs.outputJson(registryFile, { version: 2, plugins: {} });
760
+ expect(await findStaleLoads(here, root)).toEqual([]);
761
+ });
762
+
763
+ test("a target with no cache directory is not offered as a finding", async () => {
764
+ // `repairStaleLoad` refuses this target outright, so reporting it would put
765
+ // a plugin in the popup's count that pressing FIX could not repair. Asking
766
+ // the refusal in advance is the fix.
767
+ await writeRegistry([
768
+ row({ version: "0.3.0", projectPath: gone }),
769
+ row({ version: "0.7.0", projectPath: here }),
770
+ ]);
771
+ expect(await fs.pathExists(cacheFor("0.7.0"))).toBe(false);
772
+
773
+ expect(await findStaleLoads(here, root)).toEqual([]);
774
+ });
775
+
776
+ test("an 'unknown' version is never a target", async () => {
777
+ // MEASURED: plugin-dev@claude-plugins-official recorded the literal string
778
+ // "unknown". There is no version to move rows to and no cache directory
779
+ // named `unknown`, so the popup was offering a fix that could not run.
780
+ await writeRegistry([
781
+ row({ version: "76c85b7366c8", projectPath: gone }),
782
+ row({ version: "unknown", projectPath: here }),
783
+ ]);
784
+
785
+ expect(await findStaleLoads(here, root)).toEqual([]);
786
+ });
787
+
788
+ test("two differing commit shas are not a repair", async () => {
789
+ // MEASURED: skill-creator@claude-plugins-official. `isBehind` answers true
790
+ // for two differing opaque ids — correct for "is this what is published
791
+ // now", wrong here. Nothing orders two shas, so "move forward" would pick
792
+ // one at random and call it a repair.
793
+ const shaHere = "517b2fcd1b60";
794
+ const shaGone = "76c85b7366c8";
795
+ await fs.ensureDir(path.join(root, "cache", "magus", "madbench", shaHere));
796
+ await writeRegistry([
797
+ row({
798
+ version: shaGone,
799
+ projectPath: gone,
800
+ installPath: path.join(root, "cache", "magus", "madbench", shaGone),
801
+ }),
802
+ row({
803
+ version: shaHere,
804
+ projectPath: here,
805
+ installPath: path.join(root, "cache", "magus", "madbench", shaHere),
806
+ }),
807
+ ]);
808
+
809
+ expect(await findStaleLoads(here, root)).toEqual([]);
810
+ });
811
+ });
812
+
813
+ /**
814
+ * THE invariant: the startup popup and the Plugins header count the same plugins.
815
+ *
816
+ * They did not. Measured in this worktree on the reporting machine, the header
817
+ * said `6 broken` and the popup said `8 broken plugins` — the popup was asking
818
+ * `isBehind` where the classifier asks the narrower "is this an ordered move
819
+ * forward", so it counted two plugins nothing could repair and offered to fix
820
+ * them. This repo's own note in PluginsScreen.tsx says a header must never count
821
+ * a row the list beside it does not show; the same rule across two surfaces is
822
+ * what this describes.
823
+ *
824
+ * Both sides are derived from ONE registry fixture here, the panel's through
825
+ * `resolveLoadedVersions` exactly as plugin-manager does it, so the test cannot
826
+ * pass by two hand-written numbers agreeing.
827
+ */
828
+ describe("the popup count equals the Plugins header count", () => {
829
+ /**
830
+ * The panel's side: classify rows whose loadedVersion comes off the registry.
831
+ *
832
+ * @param published what the MARKETPLACE offers for every plugin, relative to
833
+ * what this project holds. The fixture used to hardcode "the same version",
834
+ * and that is the one configuration where the classifier's two candidate
835
+ * formulas — catalog-versus-loaded and installed-versus-loaded — cannot
836
+ * disagree. 220 tests passed over a `stale-load` branch that asked the
837
+ * catalog question, which turned every pending release into a BROKEN row
838
+ * that no installer ever fixed. So the pending case is a parameter now, and
839
+ * `"ahead"` is the input that separates the two. `"none"` is the mirror
840
+ * failure: a marketplace that publishes no version at all (278 of the 292
841
+ * official plugins), where the catalog formula answered false and a real
842
+ * stale load read as `current`.
843
+ */
844
+ async function headerBrokenCount(
845
+ published: "same" | "ahead" | "none" = "same",
846
+ ): Promise<number> {
847
+ const registry: InstalledPluginsRegistry = await readRegistry();
848
+ const loaded = resolveLoadedVersions(registry, here);
849
+ const plugins: PluginInfo[] = Object.entries(registry.plugins).map(
850
+ ([id, rows]) => {
851
+ // The version this project holds, as the Plugins screen sees it: the
852
+ // newest row relevant to this directory.
853
+ const mine =
854
+ rows.find((r) => r.projectPath === here)?.version ??
855
+ (rows[0]?.version as string);
856
+ return {
857
+ id,
858
+ name: id.split("@")[0] as string,
859
+ // What the catalog offers. `ahead` stands for the ordinary state of
860
+ // this marketplace: a release is published and not installed yet.
861
+ version:
862
+ published === "ahead"
863
+ ? bumped(mine)
864
+ : published === "none"
865
+ ? null
866
+ : mine,
867
+ description: "",
868
+ marketplace: id.split("@")[1] ?? "magus",
869
+ marketplaceDisplay: "",
870
+ enabled: true,
871
+ installedVersion: mine,
872
+ loadedVersion: loaded[id] ?? null,
873
+ projectScope: { enabled: true, version: mine },
874
+ } as PluginInfo;
875
+ },
876
+ );
877
+ return countPluginStates(classifyPluginRows(plugins, {}).values())[
878
+ "stale-load"
879
+ ];
880
+ }
881
+
882
+ /**
883
+ * One minor version up, or the string unchanged when it carries no ordering
884
+ * (a commit sha, the literal "unknown"). Those plugins are excluded by both
885
+ * surfaces for other reasons and must stay excluded, so leaving them alone
886
+ * keeps this helper from being the thing that changes their answer.
887
+ */
888
+ function bumped(version: string): string {
889
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
890
+ if (!m) return version;
891
+ return `${m[1]}.${Number(m[2]) + 1}.0`;
892
+ }
893
+
894
+ /**
895
+ * Two genuinely broken plugins, plus the two shapes that used to inflate the
896
+ * popup, plus one healthy plugin.
897
+ */
898
+ beforeEach(async () => {
899
+ const at = (name: string, version: string) =>
900
+ path.join(root, "cache", "magus", name, version);
901
+ for (const [name, version] of [
902
+ ["madbench", "0.3.0"],
903
+ ["madbench", "0.5.0"],
904
+ ["dev", "7.0.0"],
905
+ ["dev", "8.0.0"],
906
+ ["healthy", "1.0.0"],
907
+ ["shaish", "517b2fcd1b60"],
908
+ ["shaish", "76c85b7366c8"],
909
+ ["unversioned", "76c85b7366c8"],
910
+ ] as const) {
911
+ await fs.ensureDir(at(name, version));
912
+ }
913
+
914
+ const pair = (
915
+ name: string,
916
+ oldVersion: string,
917
+ newVersion: string,
918
+ ): InstalledPluginEntry[] => [
919
+ {
920
+ scope: "project",
921
+ projectPath: gone,
922
+ installPath: at(name, oldVersion),
923
+ version: oldVersion,
924
+ installedAt: "2026-01-01T00:00:00.000Z",
925
+ lastUpdated: "2026-01-01T00:00:00.000Z",
926
+ },
927
+ {
928
+ scope: "project",
929
+ projectPath: here,
930
+ installPath: at(name, newVersion),
931
+ version: newVersion,
932
+ installedAt: "2026-01-01T00:00:00.000Z",
933
+ lastUpdated: "2026-01-01T00:00:00.000Z",
934
+ },
935
+ ];
936
+
937
+ await fs.outputJson(
938
+ registryFile,
939
+ {
940
+ version: 2,
941
+ plugins: {
942
+ // Broken and repairable.
943
+ "madbench@magus": pair("madbench", "0.3.0", "0.5.0"),
944
+ "dev@magus": pair("dev", "7.0.0", "8.0.0"),
945
+ // Sha-versioned: unorderable, so not a repair either way.
946
+ "shaish@magus": pair("shaish", "76c85b7366c8", "517b2fcd1b60"),
947
+ // Target is the literal string "unknown".
948
+ "unversioned@magus": pair("unversioned", "76c85b7366c8", "unknown"),
949
+ // Healthy: one row, loading what it holds.
950
+ "healthy@magus": [
951
+ {
952
+ scope: "project",
953
+ projectPath: here,
954
+ installPath: at("healthy", "1.0.0"),
955
+ version: "1.0.0",
956
+ installedAt: "2026-01-01T00:00:00.000Z",
957
+ lastUpdated: "2026-01-01T00:00:00.000Z",
958
+ },
959
+ ],
960
+ },
961
+ },
962
+ { spaces: 2 },
963
+ );
964
+ });
965
+
966
+ test("both surfaces count exactly the two repairable plugins", async () => {
967
+ const popup = await findStaleLoads(here, root);
968
+
969
+ expect(popup.map((p) => p.pluginId)).toEqual([
970
+ "dev@magus",
971
+ "madbench@magus",
972
+ ]);
973
+ expect(popup.length).toBe(await headerBrokenCount());
974
+ expect(popup.length).toBe(2);
975
+ });
976
+
977
+ test("the sha-versioned and 'unknown' plugins are excluded by BOTH", async () => {
978
+ // Named individually so a regression says which shape came back rather than
979
+ // only that a count moved.
980
+ const reported = (await findStaleLoads(here, root)).map((p) => p.pluginId);
981
+
982
+ expect(reported).not.toContain("shaish@magus");
983
+ expect(reported).not.toContain("unversioned@magus");
984
+ expect(reported).not.toContain("healthy@magus");
985
+ expect(await headerBrokenCount()).toBe(2);
986
+ });
987
+
988
+ test("a PENDING RELEASE on every plugin changes neither count", async () => {
989
+ // The configuration the fixture could not previously express, and the one
990
+ // that matters most: the marketplace is one minor version ahead of what is
991
+ // installed, which is the normal state of this repo's own marketplace.
992
+ //
993
+ // `healthy@magus` loads exactly what it holds and is merely behind the
994
+ // catalog — an `update`, not a broken load. Before the classifier compared
995
+ // the INSTALLED version against the loaded one, it counted here as
996
+ // `stale-load` with no scopes: red in the header, offered as "press a to
997
+ // fix", and fixed by nothing, because no installer runs for that state.
998
+ expect(await headerBrokenCount("ahead")).toBe(2);
999
+ expect((await findStaleLoads(here, root)).length).toBe(2);
1000
+ });
1001
+
1002
+ test("an UNVERSIONED catalog hides neither of them", async () => {
1003
+ // The mirror: with no published version to compare, the catalog formula
1004
+ // answered false for every plugin and both genuinely broken ones read as
1005
+ // `current` — a clean bill of health over two sessions running old code.
1006
+ expect(await headerBrokenCount("none")).toBe(2);
1007
+ expect((await findStaleLoads(here, root)).length).toBe(2);
1008
+ });
1009
+
1010
+ test("every plugin the popup names is one repairAllStaleLoads actually fixes", async () => {
1011
+ // The count agreeing is not enough: the popup's promise is that pressing FIX
1012
+ // repairs what it counted. Each result must be `ok`, which is the repair's
1013
+ // own post-write assertion that the loader now resolves the target.
1014
+ const results = await repairAllStaleLoads(here, root);
1015
+
1016
+ expect(results.map((r) => [r.pluginId, r.ok])).toEqual([
1017
+ ["dev@magus", true],
1018
+ ["madbench@magus", true],
1019
+ ]);
1020
+ expect(await findStaleLoads(here, root)).toEqual([]);
1021
+ expect(await headerBrokenCount()).toBe(0);
1022
+ });
1023
+ });
1024
+
1025
+ describe("repairAllStaleLoads", () => {
1026
+ test("repairs every plugin findStaleLoads names, and clears the survey", async () => {
1027
+ await fs.ensureDir(path.join(root, "cache", "magus", "dev", "7.4.0"));
1028
+ await fs.ensureDir(path.join(root, "cache", "magus", "dev", "7.0.0"));
1029
+ await fs.outputJson(
1030
+ registryFile,
1031
+ {
1032
+ version: 2,
1033
+ plugins: {
1034
+ [PLUGIN]: [
1035
+ row({ version: "0.3.0", projectPath: gone }),
1036
+ row({ version: "0.5.0", projectPath: here }),
1037
+ ],
1038
+ "dev@magus": [
1039
+ {
1040
+ scope: "project",
1041
+ projectPath: gone,
1042
+ installPath: path.join(root, "cache", "magus", "dev", "7.0.0"),
1043
+ version: "7.0.0",
1044
+ installedAt: "2026-01-01T00:00:00.000Z",
1045
+ lastUpdated: "2026-01-01T00:00:00.000Z",
1046
+ },
1047
+ {
1048
+ scope: "project",
1049
+ projectPath: here,
1050
+ installPath: path.join(root, "cache", "magus", "dev", "7.4.0"),
1051
+ version: "7.4.0",
1052
+ installedAt: "2026-01-01T00:00:00.000Z",
1053
+ lastUpdated: "2026-01-01T00:00:00.000Z",
1054
+ },
1055
+ ],
1056
+ },
1057
+ },
1058
+ { spaces: 2 },
1059
+ );
1060
+
1061
+ const results = await repairAllStaleLoads(here, root);
1062
+
1063
+ expect(results.map((r) => [r.pluginId, r.ok, r.loaded])).toEqual([
1064
+ ["dev@magus", true, "7.4.0"],
1065
+ [PLUGIN, true, "0.5.0"],
1066
+ ]);
1067
+ expect(await findStaleLoads(here, root)).toEqual([]);
1068
+ });
1069
+
1070
+ test("a healthy machine repairs nothing and writes no backup", async () => {
1071
+ await writeRegistry([row({ version: "0.5.0", projectPath: here })]);
1072
+
1073
+ expect(await repairAllStaleLoads(here, root)).toEqual([]);
1074
+ expect(await backups()).toEqual([]);
1075
+ });
1076
+ });