magus-cli 7.2.0 → 7.3.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__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -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__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/doctor.ts +128 -2
- package/src/cli/update-view.ts +31 -1
- 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/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,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
|
+
});
|