claudeup 4.10.1 → 4.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,760 @@
1
+ /**
2
+ * Tests for plugin version mismatch detection and fix.
3
+ *
4
+ * Claude Code's plugin loader (pluginLoader.ts:2051) takes entries[0]
5
+ * from installed_plugins.json regardless of project scope. When
6
+ * different projects install different versions, only the one at
7
+ * index [0] loads for ALL projects.
8
+ *
9
+ * Each test uses a real temp filesystem with mocked os.homedir().
10
+ */
11
+
12
+ import {
13
+ afterAll,
14
+ afterEach,
15
+ beforeEach,
16
+ describe,
17
+ expect,
18
+ it,
19
+ mock,
20
+ } from "bun:test";
21
+ import path from "node:path";
22
+ import fs from "fs-extra";
23
+
24
+ // Grab real os before mocking
25
+ const realOs = await import("node:os");
26
+ const realTmpdir = realOs.tmpdir();
27
+
28
+ // Initialize tmpHome eagerly so module-level os.homedir() calls in
29
+ // claude-settings.ts (for INSTALLED_PLUGINS_FILE, KNOWN_MARKETPLACES_FILE)
30
+ // get a valid path during import. The mock closure reads this variable.
31
+ //
32
+ // IMPORTANT: INSTALLED_PLUGINS_FILE is a constant set once at module load
33
+ // using os.homedir(). It will always point to initTmpHome. So we must
34
+ // write installed_plugins.json to initTmpHome and use the same tmpHome
35
+ // for ALL tests (cleaning up between tests).
36
+ const initTmpHome = fs.mkdtempSync(path.join(realTmpdir, "vmcheck-init-"));
37
+ let tmpHome: string = initTmpHome;
38
+ let tmpProjectA: string;
39
+ let tmpProjectB: string;
40
+
41
+ /** Write global settings at <tmpHome>/.claude/settings.json */
42
+ async function writeGlobalSettings(settings: Record<string, unknown>) {
43
+ const dir = path.join(tmpHome, ".claude");
44
+ await fs.ensureDir(dir);
45
+ await fs.writeJson(path.join(dir, "settings.json"), settings, { spaces: 2 });
46
+ }
47
+
48
+ /** Write project settings at <projectPath>/.claude/settings.json */
49
+ async function writeProjectSettings(
50
+ projectPath: string,
51
+ settings: Record<string, unknown>,
52
+ ) {
53
+ const dir = path.join(projectPath, ".claude");
54
+ await fs.ensureDir(dir);
55
+ await fs.writeJson(path.join(dir, "settings.json"), settings, { spaces: 2 });
56
+ }
57
+
58
+ /**
59
+ * Write installed_plugins.json.
60
+ * INSTALLED_PLUGINS_FILE constant was set at module load using os.homedir()
61
+ * which returned initTmpHome. We must always write there.
62
+ */
63
+ async function writeInstalledPlugins(registry: Record<string, unknown>) {
64
+ const dir = path.join(initTmpHome, ".claude", "plugins");
65
+ await fs.ensureDir(dir);
66
+ await fs.writeJson(path.join(dir, "installed_plugins.json"), registry, {
67
+ spaces: 2,
68
+ });
69
+ }
70
+
71
+ /** Read installed_plugins.json back from disk (from initTmpHome) */
72
+ async function readInstalledPluginsFromDisk(): Promise<
73
+ Record<string, unknown>
74
+ > {
75
+ const p = path.join(
76
+ initTmpHome,
77
+ ".claude",
78
+ "plugins",
79
+ "installed_plugins.json",
80
+ );
81
+ if (await fs.pathExists(p)) {
82
+ return fs.readJson(p);
83
+ }
84
+ return {};
85
+ }
86
+
87
+ /** Clean installed_plugins.json between tests */
88
+ async function cleanInstalledPlugins() {
89
+ const p = path.join(
90
+ initTmpHome,
91
+ ".claude",
92
+ "plugins",
93
+ "installed_plugins.json",
94
+ );
95
+ await fs.remove(p);
96
+ }
97
+
98
+ // Mock os.homedir() — hoisted before import.
99
+ // tmpHome is used for global settings (read at runtime via os.homedir()),
100
+ // initTmpHome is used for INSTALLED_PLUGINS_FILE (set once at module load).
101
+ // We keep tmpHome = initTmpHome so both global settings and installed_plugins
102
+ // share the same .claude directory.
103
+ mock.module("node:os", () => {
104
+ const mocked = { ...realOs, homedir: () => tmpHome };
105
+ return {
106
+ ...mocked,
107
+ default: mocked,
108
+ };
109
+ });
110
+
111
+ // Import functions under test AFTER mock registration
112
+ const {
113
+ checkPluginVersionMismatches,
114
+ checkSinglePluginMismatch,
115
+ fixPluginVersionMismatch,
116
+ fixAllPluginVersionMismatches,
117
+ formatMismatchWarning,
118
+ formatMismatchBanner,
119
+ } = await import("../services/plugin-version-check.js");
120
+
121
+ // ── Test suite ───────────────────────────────────────────────────────────────
122
+
123
+ describe("checkPluginVersionMismatches", () => {
124
+ beforeEach(async () => {
125
+ // Keep tmpHome = initTmpHome so INSTALLED_PLUGINS_FILE and global
126
+ // settings share the same .claude directory
127
+ tmpHome = initTmpHome;
128
+ tmpProjectA = await fs.mkdtemp(path.join(realTmpdir, "vmcheck-projA-"));
129
+ tmpProjectB = await fs.mkdtemp(path.join(realTmpdir, "vmcheck-projB-"));
130
+ await fs.ensureDir(path.join(tmpHome, ".claude"));
131
+ await fs.ensureDir(path.join(tmpHome, ".claude", "plugins"));
132
+ await cleanInstalledPlugins();
133
+ });
134
+
135
+ afterEach(async () => {
136
+ await cleanInstalledPlugins();
137
+ // Clean global settings between tests
138
+ const globalSettingsPath = path.join(tmpHome, ".claude", "settings.json");
139
+ await fs.remove(globalSettingsPath);
140
+ await fs.remove(tmpProjectA);
141
+ await fs.remove(tmpProjectB);
142
+ });
143
+
144
+ afterAll(async () => {
145
+ await fs.remove(initTmpHome).catch(() => {});
146
+ });
147
+
148
+ it("detects mismatch when entries[0] has a different version than current project", async () => {
149
+ // Project A was installed first (at index 0) with v2.0.0
150
+ // Project B (current) has v4.0.2
151
+ await writeGlobalSettings({
152
+ enabledPlugins: { "terminal@magus": true },
153
+ });
154
+ await writeProjectSettings(tmpProjectB, {
155
+ enabledPlugins: { "terminal@magus": true },
156
+ });
157
+ await writeInstalledPlugins({
158
+ version: 2,
159
+ plugins: {
160
+ "terminal@magus": [
161
+ {
162
+ scope: "project",
163
+ projectPath: tmpProjectA,
164
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/2.0.0`,
165
+ version: "2.0.0",
166
+ installedAt: "2025-01-01T00:00:00.000Z",
167
+ lastUpdated: "2025-01-01T00:00:00.000Z",
168
+ },
169
+ {
170
+ scope: "project",
171
+ projectPath: tmpProjectB,
172
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
173
+ version: "4.0.2",
174
+ installedAt: "2025-03-01T00:00:00.000Z",
175
+ lastUpdated: "2025-03-01T00:00:00.000Z",
176
+ },
177
+ ],
178
+ },
179
+ });
180
+
181
+ const mismatches = await checkPluginVersionMismatches(tmpProjectB);
182
+
183
+ expect(mismatches).toHaveLength(1);
184
+ expect(mismatches[0].pluginId).toBe("terminal@magus");
185
+ expect(mismatches[0].firstEntryVersion).toBe("2.0.0");
186
+ expect(mismatches[0].currentProjectVersion).toBe("4.0.2");
187
+ expect(mismatches[0].firstEntryProject).toBe(tmpProjectA);
188
+ });
189
+
190
+ it("returns empty when entries[0] matches current project version", async () => {
191
+ await writeGlobalSettings({
192
+ enabledPlugins: { "terminal@magus": true },
193
+ });
194
+ await writeProjectSettings(tmpProjectA, {
195
+ enabledPlugins: { "terminal@magus": true },
196
+ });
197
+ await writeInstalledPlugins({
198
+ version: 2,
199
+ plugins: {
200
+ "terminal@magus": [
201
+ {
202
+ scope: "project",
203
+ projectPath: tmpProjectA,
204
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
205
+ version: "4.0.2",
206
+ installedAt: "2025-01-01T00:00:00.000Z",
207
+ lastUpdated: "2025-01-01T00:00:00.000Z",
208
+ },
209
+ {
210
+ scope: "project",
211
+ projectPath: tmpProjectB,
212
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
213
+ version: "4.0.2",
214
+ installedAt: "2025-03-01T00:00:00.000Z",
215
+ lastUpdated: "2025-03-01T00:00:00.000Z",
216
+ },
217
+ ],
218
+ },
219
+ });
220
+
221
+ const mismatches = await checkPluginVersionMismatches(tmpProjectA);
222
+
223
+ expect(mismatches).toEqual([]);
224
+ });
225
+
226
+ it("returns empty when plugin has only one entry", async () => {
227
+ await writeGlobalSettings({
228
+ enabledPlugins: { "terminal@magus": true },
229
+ });
230
+ await writeProjectSettings(tmpProjectA, {
231
+ enabledPlugins: { "terminal@magus": true },
232
+ });
233
+ await writeInstalledPlugins({
234
+ version: 2,
235
+ plugins: {
236
+ "terminal@magus": [
237
+ {
238
+ scope: "project",
239
+ projectPath: tmpProjectA,
240
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
241
+ version: "4.0.2",
242
+ installedAt: "2025-01-01T00:00:00.000Z",
243
+ lastUpdated: "2025-01-01T00:00:00.000Z",
244
+ },
245
+ ],
246
+ },
247
+ });
248
+
249
+ const mismatches = await checkPluginVersionMismatches(tmpProjectA);
250
+
251
+ expect(mismatches).toEqual([]);
252
+ });
253
+
254
+ it("returns empty when current project has no entry in registry", async () => {
255
+ await writeGlobalSettings({
256
+ enabledPlugins: { "terminal@magus": true },
257
+ });
258
+ await writeProjectSettings(tmpProjectB, {
259
+ enabledPlugins: { "terminal@magus": true },
260
+ });
261
+ await writeInstalledPlugins({
262
+ version: 2,
263
+ plugins: {
264
+ "terminal@magus": [
265
+ {
266
+ scope: "project",
267
+ projectPath: tmpProjectA,
268
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/2.0.0`,
269
+ version: "2.0.0",
270
+ installedAt: "2025-01-01T00:00:00.000Z",
271
+ lastUpdated: "2025-01-01T00:00:00.000Z",
272
+ },
273
+ ],
274
+ },
275
+ });
276
+
277
+ // tmpProjectB is enabled but has no registry entry — no mismatch detectable
278
+ const mismatches = await checkPluginVersionMismatches(tmpProjectB);
279
+
280
+ expect(mismatches).toEqual([]);
281
+ });
282
+
283
+ it("detects multiple mismatched plugins", async () => {
284
+ await writeGlobalSettings({
285
+ enabledPlugins: {
286
+ "terminal@magus": true,
287
+ "dev@magus": true,
288
+ },
289
+ });
290
+ await writeProjectSettings(tmpProjectB, {
291
+ enabledPlugins: {
292
+ "terminal@magus": true,
293
+ "dev@magus": true,
294
+ },
295
+ });
296
+ await writeInstalledPlugins({
297
+ version: 2,
298
+ plugins: {
299
+ "terminal@magus": [
300
+ {
301
+ scope: "project",
302
+ projectPath: tmpProjectA,
303
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/2.0.0`,
304
+ version: "2.0.0",
305
+ installedAt: "2025-01-01T00:00:00.000Z",
306
+ lastUpdated: "2025-01-01T00:00:00.000Z",
307
+ },
308
+ {
309
+ scope: "project",
310
+ projectPath: tmpProjectB,
311
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
312
+ version: "4.0.2",
313
+ installedAt: "2025-03-01T00:00:00.000Z",
314
+ lastUpdated: "2025-03-01T00:00:00.000Z",
315
+ },
316
+ ],
317
+ "dev@magus": [
318
+ {
319
+ scope: "project",
320
+ projectPath: tmpProjectA,
321
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/dev/1.0.0`,
322
+ version: "1.0.0",
323
+ installedAt: "2025-01-01T00:00:00.000Z",
324
+ lastUpdated: "2025-01-01T00:00:00.000Z",
325
+ },
326
+ {
327
+ scope: "project",
328
+ projectPath: tmpProjectB,
329
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/dev/2.7.0`,
330
+ version: "2.7.0",
331
+ installedAt: "2025-03-01T00:00:00.000Z",
332
+ lastUpdated: "2025-03-01T00:00:00.000Z",
333
+ },
334
+ ],
335
+ },
336
+ });
337
+
338
+ const mismatches = await checkPluginVersionMismatches(tmpProjectB);
339
+
340
+ expect(mismatches).toHaveLength(2);
341
+ const terminalMismatch = mismatches.find(
342
+ (m) => m.pluginId === "terminal@magus",
343
+ );
344
+ const devMismatch = mismatches.find((m) => m.pluginId === "dev@magus");
345
+
346
+ expect(terminalMismatch).toBeDefined();
347
+ expect(terminalMismatch?.firstEntryVersion).toBe("2.0.0");
348
+ expect(terminalMismatch?.currentProjectVersion).toBe("4.0.2");
349
+
350
+ expect(devMismatch).toBeDefined();
351
+ expect(devMismatch?.firstEntryVersion).toBe("1.0.0");
352
+ expect(devMismatch?.currentProjectVersion).toBe("2.7.0");
353
+ });
354
+
355
+ it("skips plugins not enabled for the project", async () => {
356
+ // Plugin is in registry but not enabled
357
+ await writeGlobalSettings({
358
+ enabledPlugins: {},
359
+ });
360
+ await writeProjectSettings(tmpProjectB, {
361
+ enabledPlugins: {},
362
+ });
363
+ await writeInstalledPlugins({
364
+ version: 2,
365
+ plugins: {
366
+ "terminal@magus": [
367
+ {
368
+ scope: "project",
369
+ projectPath: tmpProjectA,
370
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/2.0.0`,
371
+ version: "2.0.0",
372
+ installedAt: "2025-01-01T00:00:00.000Z",
373
+ lastUpdated: "2025-01-01T00:00:00.000Z",
374
+ },
375
+ {
376
+ scope: "project",
377
+ projectPath: tmpProjectB,
378
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
379
+ version: "4.0.2",
380
+ installedAt: "2025-03-01T00:00:00.000Z",
381
+ lastUpdated: "2025-03-01T00:00:00.000Z",
382
+ },
383
+ ],
384
+ },
385
+ });
386
+
387
+ const mismatches = await checkPluginVersionMismatches(tmpProjectB);
388
+
389
+ expect(mismatches).toEqual([]);
390
+ });
391
+
392
+ it("handles empty installed_plugins.json gracefully", async () => {
393
+ await writeGlobalSettings({
394
+ enabledPlugins: { "terminal@magus": true },
395
+ });
396
+ await writeProjectSettings(tmpProjectA, {
397
+ enabledPlugins: { "terminal@magus": true },
398
+ });
399
+ // No installed_plugins.json written
400
+
401
+ const mismatches = await checkPluginVersionMismatches(tmpProjectA);
402
+
403
+ expect(mismatches).toEqual([]);
404
+ });
405
+ });
406
+
407
+ describe("checkSinglePluginMismatch", () => {
408
+ beforeEach(async () => {
409
+ tmpHome = initTmpHome;
410
+ tmpProjectA = await fs.mkdtemp(path.join(realTmpdir, "vmcheck-projA-"));
411
+ tmpProjectB = await fs.mkdtemp(path.join(realTmpdir, "vmcheck-projB-"));
412
+ await fs.ensureDir(path.join(tmpHome, ".claude"));
413
+ await fs.ensureDir(path.join(tmpHome, ".claude", "plugins"));
414
+ await cleanInstalledPlugins();
415
+ });
416
+
417
+ afterEach(async () => {
418
+ await cleanInstalledPlugins();
419
+ await fs.remove(tmpProjectA);
420
+ await fs.remove(tmpProjectB);
421
+ });
422
+
423
+ it("returns mismatch info for a single plugin", async () => {
424
+ await writeInstalledPlugins({
425
+ version: 2,
426
+ plugins: {
427
+ "terminal@magus": [
428
+ {
429
+ scope: "project",
430
+ projectPath: tmpProjectA,
431
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/2.0.0`,
432
+ version: "2.0.0",
433
+ installedAt: "2025-01-01T00:00:00.000Z",
434
+ lastUpdated: "2025-01-01T00:00:00.000Z",
435
+ },
436
+ {
437
+ scope: "project",
438
+ projectPath: tmpProjectB,
439
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
440
+ version: "4.0.2",
441
+ installedAt: "2025-03-01T00:00:00.000Z",
442
+ lastUpdated: "2025-03-01T00:00:00.000Z",
443
+ },
444
+ ],
445
+ },
446
+ });
447
+
448
+ const mismatch = await checkSinglePluginMismatch(
449
+ "terminal@magus",
450
+ tmpProjectB,
451
+ );
452
+
453
+ expect(mismatch).not.toBeNull();
454
+ expect(mismatch?.firstEntryVersion).toBe("2.0.0");
455
+ expect(mismatch?.currentProjectVersion).toBe("4.0.2");
456
+ });
457
+
458
+ it("returns null when no mismatch", async () => {
459
+ await writeInstalledPlugins({
460
+ version: 2,
461
+ plugins: {
462
+ "terminal@magus": [
463
+ {
464
+ scope: "project",
465
+ projectPath: tmpProjectA,
466
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
467
+ version: "4.0.2",
468
+ installedAt: "2025-01-01T00:00:00.000Z",
469
+ lastUpdated: "2025-01-01T00:00:00.000Z",
470
+ },
471
+ {
472
+ scope: "project",
473
+ projectPath: tmpProjectB,
474
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
475
+ version: "4.0.2",
476
+ installedAt: "2025-03-01T00:00:00.000Z",
477
+ lastUpdated: "2025-03-01T00:00:00.000Z",
478
+ },
479
+ ],
480
+ },
481
+ });
482
+
483
+ const mismatch = await checkSinglePluginMismatch(
484
+ "terminal@magus",
485
+ tmpProjectB,
486
+ );
487
+
488
+ expect(mismatch).toBeNull();
489
+ });
490
+ });
491
+
492
+ describe("fixPluginVersionMismatch", () => {
493
+ beforeEach(async () => {
494
+ tmpHome = initTmpHome;
495
+ tmpProjectA = await fs.mkdtemp(path.join(realTmpdir, "vmcheck-projA-"));
496
+ tmpProjectB = await fs.mkdtemp(path.join(realTmpdir, "vmcheck-projB-"));
497
+ await fs.ensureDir(path.join(tmpHome, ".claude"));
498
+ await fs.ensureDir(path.join(tmpHome, ".claude", "plugins"));
499
+ await cleanInstalledPlugins();
500
+ });
501
+
502
+ afterEach(async () => {
503
+ await cleanInstalledPlugins();
504
+ await fs.remove(tmpProjectA);
505
+ await fs.remove(tmpProjectB);
506
+ });
507
+
508
+ it("updates all entries to the target version", async () => {
509
+ const cachePath402 = `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`;
510
+
511
+ await writeInstalledPlugins({
512
+ version: 2,
513
+ plugins: {
514
+ "terminal@magus": [
515
+ {
516
+ scope: "project",
517
+ projectPath: tmpProjectA,
518
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/2.0.0`,
519
+ version: "2.0.0",
520
+ installedAt: "2025-01-01T00:00:00.000Z",
521
+ lastUpdated: "2025-01-01T00:00:00.000Z",
522
+ },
523
+ {
524
+ scope: "project",
525
+ projectPath: tmpProjectB,
526
+ installPath: cachePath402,
527
+ version: "4.0.2",
528
+ installedAt: "2025-03-01T00:00:00.000Z",
529
+ lastUpdated: "2025-03-01T00:00:00.000Z",
530
+ },
531
+ ],
532
+ },
533
+ });
534
+
535
+ const result = await fixPluginVersionMismatch("terminal@magus", "4.0.2");
536
+
537
+ expect(result.updated).toBe(1);
538
+ expect(result.projects).toHaveLength(1);
539
+
540
+ // Verify the registry was updated on disk
541
+ const registry = await readInstalledPluginsFromDisk();
542
+ const plugins = registry.plugins as Record<
543
+ string,
544
+ Array<Record<string, string>>
545
+ >;
546
+ const entries = plugins["terminal@magus"];
547
+
548
+ expect(entries).toHaveLength(2);
549
+ expect(entries[0].version).toBe("4.0.2");
550
+ expect(entries[0].installPath).toBe(cachePath402);
551
+ expect(entries[1].version).toBe("4.0.2");
552
+ expect(entries[1].installPath).toBe(cachePath402);
553
+ });
554
+
555
+ it("returns zero updates when all entries already match", async () => {
556
+ await writeInstalledPlugins({
557
+ version: 2,
558
+ plugins: {
559
+ "terminal@magus": [
560
+ {
561
+ scope: "project",
562
+ projectPath: tmpProjectA,
563
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
564
+ version: "4.0.2",
565
+ installedAt: "2025-01-01T00:00:00.000Z",
566
+ lastUpdated: "2025-01-01T00:00:00.000Z",
567
+ },
568
+ {
569
+ scope: "project",
570
+ projectPath: tmpProjectB,
571
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`,
572
+ version: "4.0.2",
573
+ installedAt: "2025-03-01T00:00:00.000Z",
574
+ lastUpdated: "2025-03-01T00:00:00.000Z",
575
+ },
576
+ ],
577
+ },
578
+ });
579
+
580
+ const result = await fixPluginVersionMismatch("terminal@magus", "4.0.2");
581
+
582
+ expect(result.updated).toBe(0);
583
+ expect(result.projects).toEqual([]);
584
+ });
585
+
586
+ it("returns zero updates when target version not found in entries", async () => {
587
+ await writeInstalledPlugins({
588
+ version: 2,
589
+ plugins: {
590
+ "terminal@magus": [
591
+ {
592
+ scope: "project",
593
+ projectPath: tmpProjectA,
594
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/2.0.0`,
595
+ version: "2.0.0",
596
+ installedAt: "2025-01-01T00:00:00.000Z",
597
+ lastUpdated: "2025-01-01T00:00:00.000Z",
598
+ },
599
+ ],
600
+ },
601
+ });
602
+
603
+ const result = await fixPluginVersionMismatch("terminal@magus", "9.9.9");
604
+
605
+ expect(result.updated).toBe(0);
606
+ expect(result.projects).toEqual([]);
607
+ });
608
+
609
+ it("returns zero updates for unknown plugin", async () => {
610
+ await writeInstalledPlugins({
611
+ version: 2,
612
+ plugins: {},
613
+ });
614
+
615
+ const result = await fixPluginVersionMismatch("unknown@mp", "1.0.0");
616
+
617
+ expect(result.updated).toBe(0);
618
+ expect(result.projects).toEqual([]);
619
+ });
620
+ });
621
+
622
+ describe("fixAllPluginVersionMismatches", () => {
623
+ beforeEach(async () => {
624
+ tmpHome = initTmpHome;
625
+ tmpProjectA = await fs.mkdtemp(path.join(realTmpdir, "vmcheck-projA-"));
626
+ tmpProjectB = await fs.mkdtemp(path.join(realTmpdir, "vmcheck-projB-"));
627
+ await fs.ensureDir(path.join(tmpHome, ".claude"));
628
+ await fs.ensureDir(path.join(tmpHome, ".claude", "plugins"));
629
+ await cleanInstalledPlugins();
630
+ });
631
+
632
+ afterEach(async () => {
633
+ await cleanInstalledPlugins();
634
+ await fs.remove(tmpProjectA);
635
+ await fs.remove(tmpProjectB);
636
+ });
637
+
638
+ it("fixes multiple mismatches in a single write", async () => {
639
+ const termCachePath = `${tmpHome}/.claude/plugins/cache/magus/terminal/4.0.2`;
640
+ const devCachePath = `${tmpHome}/.claude/plugins/cache/magus/dev/2.7.0`;
641
+
642
+ await writeInstalledPlugins({
643
+ version: 2,
644
+ plugins: {
645
+ "terminal@magus": [
646
+ {
647
+ scope: "project",
648
+ projectPath: tmpProjectA,
649
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/terminal/2.0.0`,
650
+ version: "2.0.0",
651
+ installedAt: "2025-01-01T00:00:00.000Z",
652
+ lastUpdated: "2025-01-01T00:00:00.000Z",
653
+ },
654
+ {
655
+ scope: "project",
656
+ projectPath: tmpProjectB,
657
+ installPath: termCachePath,
658
+ version: "4.0.2",
659
+ installedAt: "2025-03-01T00:00:00.000Z",
660
+ lastUpdated: "2025-03-01T00:00:00.000Z",
661
+ },
662
+ ],
663
+ "dev@magus": [
664
+ {
665
+ scope: "project",
666
+ projectPath: tmpProjectA,
667
+ installPath: `${tmpHome}/.claude/plugins/cache/magus/dev/1.0.0`,
668
+ version: "1.0.0",
669
+ installedAt: "2025-01-01T00:00:00.000Z",
670
+ lastUpdated: "2025-01-01T00:00:00.000Z",
671
+ },
672
+ {
673
+ scope: "project",
674
+ projectPath: tmpProjectB,
675
+ installPath: devCachePath,
676
+ version: "2.7.0",
677
+ installedAt: "2025-03-01T00:00:00.000Z",
678
+ lastUpdated: "2025-03-01T00:00:00.000Z",
679
+ },
680
+ ],
681
+ },
682
+ });
683
+
684
+ const mismatches = [
685
+ {
686
+ pluginId: "terminal@magus",
687
+ firstEntryVersion: "2.0.0",
688
+ firstEntryProject: tmpProjectA,
689
+ currentProjectVersion: "4.0.2",
690
+ allEntries: [],
691
+ },
692
+ {
693
+ pluginId: "dev@magus",
694
+ firstEntryVersion: "1.0.0",
695
+ firstEntryProject: tmpProjectA,
696
+ currentProjectVersion: "2.7.0",
697
+ allEntries: [],
698
+ },
699
+ ];
700
+
701
+ const results = await fixAllPluginVersionMismatches(mismatches);
702
+
703
+ expect(results.size).toBe(2);
704
+ expect(results.get("terminal@magus")?.updated).toBe(1);
705
+ expect(results.get("dev@magus")?.updated).toBe(1);
706
+
707
+ // Verify on disk
708
+ const registry = await readInstalledPluginsFromDisk();
709
+ const plugins = registry.plugins as Record<
710
+ string,
711
+ Array<Record<string, string>>
712
+ >;
713
+ expect(plugins["terminal@magus"][0].version).toBe("4.0.2");
714
+ expect(plugins["terminal@magus"][0].installPath).toBe(termCachePath);
715
+ expect(plugins["dev@magus"][0].version).toBe("2.7.0");
716
+ expect(plugins["dev@magus"][0].installPath).toBe(devCachePath);
717
+ });
718
+ });
719
+
720
+ describe("formatMismatchWarning", () => {
721
+ it("formats warning text with plugin details and bug link", () => {
722
+ const mismatches = [
723
+ {
724
+ pluginId: "terminal@magus",
725
+ firstEntryVersion: "2.0.0",
726
+ firstEntryProject: "/Users/test/project-a",
727
+ currentProjectVersion: "4.0.2",
728
+ allEntries: [],
729
+ },
730
+ ];
731
+
732
+ const warning = formatMismatchWarning(mismatches);
733
+
734
+ expect(warning).toContain("terminal@magus");
735
+ expect(warning).toContain("v2.0.0");
736
+ expect(warning).toContain("v4.0.2");
737
+ expect(warning).toContain("#45997");
738
+ expect(warning).toContain("claudeup");
739
+ });
740
+ });
741
+
742
+ describe("formatMismatchBanner", () => {
743
+ it("formats concise banner text", () => {
744
+ const mismatches = [
745
+ {
746
+ pluginId: "terminal@magus",
747
+ firstEntryVersion: "2.0.0",
748
+ firstEntryProject: "/Users/test/project-a",
749
+ currentProjectVersion: "4.0.2",
750
+ allEntries: [],
751
+ },
752
+ ];
753
+
754
+ const banner = formatMismatchBanner(mismatches);
755
+
756
+ expect(banner).toContain("terminal@magus");
757
+ expect(banner).toContain("v2.0.0");
758
+ expect(banner).toContain("v4.0.2");
759
+ });
760
+ });