claudeup 4.42.1 → 5.0.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,631 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BinCheckResult } from "../services/doctor-bins.js";
3
+ import type { PluginInfo } from "../services/plugin-manager.js";
4
+ import {
5
+ type UpdatePlan,
6
+ planBinUpdates,
7
+ planHasWork,
8
+ planIsBehind,
9
+ planPluginUpdates,
10
+ planSkillUpdates,
11
+ scopesNeeding,
12
+ summarizePlan,
13
+ } from "../services/update-plan.js";
14
+ import type { ResolvedBin } from "../types/index.js";
15
+
16
+ /** A PluginInfo with only the fields the planner reads. */
17
+ function info(overrides: Partial<PluginInfo> & { id: string }): PluginInfo {
18
+ return {
19
+ name: overrides.id.split("@")[0]!,
20
+ version: null,
21
+ description: "",
22
+ marketplace: overrides.id.split("@")[1] ?? "magus",
23
+ marketplaceDisplay: "",
24
+ enabled: true,
25
+ ...overrides,
26
+ } as PluginInfo;
27
+ }
28
+
29
+ function catalog(...infos: PluginInfo[]): Map<string, PluginInfo> {
30
+ return new Map(infos.map((i) => [i.id, i]));
31
+ }
32
+
33
+ function plan(overrides: Partial<UpdatePlan> = {}): UpdatePlan {
34
+ return {
35
+ profileId: "test",
36
+ plugins: [],
37
+ bins: [],
38
+ skills: [],
39
+ binSpecs: new Map(),
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ describe("planPluginUpdates — the profile declares it, the machine lacks it", () => {
45
+ test("a plugin absent from every scope is an install", () => {
46
+ const [item] = planPluginUpdates(
47
+ { "dev@magus": "latest" },
48
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
49
+ );
50
+ expect(item).toMatchObject({
51
+ action: "install",
52
+ installed: null,
53
+ target: "4.7.0",
54
+ scopes: [],
55
+ });
56
+ });
57
+
58
+ test("a plugin missing from the catalog entirely is still an install", () => {
59
+ // The marketplace may not be registered yet — install registers it.
60
+ const [item] = planPluginUpdates({ "new@magus": "latest" }, catalog());
61
+ expect(item).toMatchObject({ action: "install", available: null });
62
+ });
63
+
64
+ test("an exact pin that is not installed installs that exact version", () => {
65
+ const [item] = planPluginUpdates(
66
+ { "dev@magus": "4.6.1" },
67
+ catalog(info({ id: "dev@magus", version: "4.7.0" })),
68
+ );
69
+ expect(item.target).toBe("4.6.1");
70
+ });
71
+ });
72
+
73
+ describe("planPluginUpdates — advancing a 'latest' pin", () => {
74
+ test("a newer catalog version is an update, targeting it", () => {
75
+ const [item] = planPluginUpdates(
76
+ { "dev@magus": "latest" },
77
+ catalog(
78
+ info({
79
+ id: "dev@magus",
80
+ version: "4.7.0",
81
+ projectScope: { enabled: true, version: "4.6.1" },
82
+ }),
83
+ ),
84
+ );
85
+ expect(item).toMatchObject({
86
+ action: "update",
87
+ installed: "4.6.1",
88
+ target: "4.7.0",
89
+ scopes: ["project"],
90
+ });
91
+ });
92
+
93
+ test("the same version is current, with no scopes to touch", () => {
94
+ const [item] = planPluginUpdates(
95
+ { "dev@magus": "latest" },
96
+ catalog(
97
+ info({
98
+ id: "dev@magus",
99
+ version: "4.7.0",
100
+ projectScope: { enabled: true, version: "4.7.0" },
101
+ }),
102
+ ),
103
+ );
104
+ expect(item).toMatchObject({ action: "current", scopes: [] });
105
+ });
106
+
107
+ test("an older catalog version does not downgrade a 'latest' pin", () => {
108
+ const [item] = planPluginUpdates(
109
+ { "dev@magus": "latest" },
110
+ catalog(
111
+ info({
112
+ id: "dev@magus",
113
+ version: "4.5.0",
114
+ projectScope: { enabled: true, version: "4.7.0" },
115
+ }),
116
+ ),
117
+ );
118
+ expect(item.action).toBe("current");
119
+ });
120
+ });
121
+
122
+ describe("planPluginUpdates — a failed check is never 'up to date'", () => {
123
+ test("updateCheckFailed yields 'unknown', not 'current'", () => {
124
+ const [item] = planPluginUpdates(
125
+ { "dev@magus": "latest" },
126
+ catalog(
127
+ info({
128
+ id: "dev@magus",
129
+ // The stale local clone reports the same version we have installed:
130
+ // without the flag this is indistinguishable from a clean check.
131
+ version: "4.6.1",
132
+ projectScope: { enabled: true, version: "4.6.1" },
133
+ updateCheckFailed: true,
134
+ updateCheckFailure: {
135
+ marketplace: "magus",
136
+ kind: "rate-limit",
137
+ detail: "GitHub rate limit",
138
+ },
139
+ }),
140
+ ),
141
+ );
142
+ expect(item.action).toBe("unknown");
143
+ expect(item.note).toBe("GitHub rate limit");
144
+ expect(item.target).toBeNull();
145
+ });
146
+
147
+ test("an unknown plugin keeps --check failing", () => {
148
+ const p = plan({
149
+ plugins: planPluginUpdates(
150
+ { "dev@magus": "latest" },
151
+ catalog(
152
+ info({
153
+ id: "dev@magus",
154
+ version: "4.6.1",
155
+ projectScope: { enabled: true, version: "4.6.1" },
156
+ updateCheckFailed: true,
157
+ }),
158
+ ),
159
+ ),
160
+ });
161
+ expect(planHasWork(p)).toBe(true);
162
+ });
163
+ });
164
+
165
+ describe("planPluginUpdates — content drift", () => {
166
+ test("same version, changed files is a repair across every installed scope", () => {
167
+ const [item] = planPluginUpdates(
168
+ { "dev@magus": "latest" },
169
+ catalog(
170
+ info({
171
+ id: "dev@magus",
172
+ version: "4.6.1",
173
+ contentStale: true,
174
+ userScope: { enabled: true, version: "4.6.1" },
175
+ projectScope: { enabled: true, version: "4.6.1" },
176
+ }),
177
+ ),
178
+ );
179
+ expect(item.action).toBe("repair");
180
+ expect(item.scopes).toEqual(["user", "project"]);
181
+ });
182
+ });
183
+
184
+ describe("planPluginUpdates — an exact pin plans against what is deliverable", () => {
185
+ // This is dependency management, not a package manager: `claude plugin
186
+ // install` takes no version, so the only installable version is whatever the
187
+ // marketplace publishes now. A pin naming anything else cannot be satisfied,
188
+ // and planning toward it anyway never converges.
189
+
190
+ test("targets the marketplace version, not the unreachable pin", () => {
191
+ const [item] = planPluginUpdates(
192
+ { "dev@magus": "4.6.1" },
193
+ catalog(
194
+ info({
195
+ id: "dev@magus",
196
+ version: "9.9.9",
197
+ projectScope: { enabled: true, version: "4.0.0" },
198
+ }),
199
+ ),
200
+ );
201
+ expect(item).toMatchObject({ action: "update", target: "9.9.9" });
202
+ });
203
+
204
+ test("an unreachable pin is NOTED, never silently abandoned", () => {
205
+ const [item] = planPluginUpdates(
206
+ { "dev@magus": "4.6.1" },
207
+ catalog(
208
+ info({
209
+ id: "dev@magus",
210
+ version: "9.9.9",
211
+ projectScope: { enabled: true, version: "9.9.9" },
212
+ }),
213
+ ),
214
+ );
215
+ expect(item.note).toBe(
216
+ "manifest pins 4.6.1; the marketplace offers only 9.9.9",
217
+ );
218
+ });
219
+
220
+ test("already on the only installable version is CURRENT — no churn", () => {
221
+ // The bug this guards: planning 9.9.9 → 4.6.1 forever. Install returns
222
+ // 9.9.9, the next run replans the identical move, and --check fails in
223
+ // between every time.
224
+ const [item] = planPluginUpdates(
225
+ { "dev@magus": "4.6.1" },
226
+ catalog(
227
+ info({
228
+ id: "dev@magus",
229
+ version: "9.9.9",
230
+ projectScope: { enabled: true, version: "9.9.9" },
231
+ }),
232
+ ),
233
+ );
234
+ expect(item.action).toBe("current");
235
+ expect(item.scopes).toEqual([]);
236
+ });
237
+
238
+ test("an unsatisfiable pin does not fail --check forever", () => {
239
+ const p = plan({
240
+ plugins: planPluginUpdates(
241
+ { "dev@magus": "4.6.1" },
242
+ catalog(
243
+ info({
244
+ id: "dev@magus",
245
+ version: "9.9.9",
246
+ projectScope: { enabled: true, version: "9.9.9" },
247
+ }),
248
+ ),
249
+ ),
250
+ });
251
+ expect(planIsBehind(p)).toBe(false);
252
+ });
253
+
254
+ test("a pin the marketplace DOES offer is honoured exactly", () => {
255
+ const [item] = planPluginUpdates(
256
+ { "dev@magus": "4.6.1" },
257
+ catalog(
258
+ info({
259
+ id: "dev@magus",
260
+ version: "4.6.1",
261
+ projectScope: { enabled: true, version: "4.0.0" },
262
+ }),
263
+ ),
264
+ );
265
+ expect(item).toMatchObject({ action: "update", target: "4.6.1" });
266
+ expect(item.note).toBeUndefined();
267
+ });
268
+
269
+ test("falls back to the pin when the catalog offers nothing", () => {
270
+ const [item] = planPluginUpdates(
271
+ { "dev@magus": "4.6.1" },
272
+ catalog(
273
+ info({
274
+ id: "dev@magus",
275
+ version: null,
276
+ projectScope: { enabled: true, version: "4.0.0" },
277
+ }),
278
+ ),
279
+ );
280
+ expect(item.target).toBe("4.6.1");
281
+ });
282
+ });
283
+
284
+ describe("per-scope decisions — a stale scope is never masked", () => {
285
+ test("project behind while user is current is an UPDATE, not 'current'", async () => {
286
+ // `installed` is the highest version across scopes. Deciding from it hid
287
+ // exactly this: project scope — the one that actually loads for the repo —
288
+ // sits behind while user scope drags the comparison up to "current".
289
+ const [item] = planPluginUpdates(
290
+ { "dev@magus": "latest" },
291
+ catalog(
292
+ info({
293
+ id: "dev@magus",
294
+ version: "4.7.0",
295
+ userScope: { enabled: true, version: "4.7.0" },
296
+ projectScope: { enabled: true, version: "4.6.1" },
297
+ }),
298
+ ),
299
+ );
300
+ expect(item.action).toBe("update");
301
+ expect(item.scopes).toEqual(["project"]);
302
+ });
303
+
304
+ test("the same masking under an EXACT pin is also caught", () => {
305
+ // The marketplace offers the pinned version here, so the target IS the pin
306
+ // and only the behind scope should move. (When the marketplace offers
307
+ // something else, every scope moves to that instead — see the
308
+ // "deliverable" suite.)
309
+ const [item] = planPluginUpdates(
310
+ { "dev@magus": "4.6.1" },
311
+ catalog(
312
+ info({
313
+ id: "dev@magus",
314
+ version: "4.6.1",
315
+ userScope: { enabled: true, version: "4.6.1" },
316
+ projectScope: { enabled: true, version: "4.0.0" },
317
+ }),
318
+ ),
319
+ );
320
+ expect(item.action).toBe("update");
321
+ expect(item.scopes).toEqual(["project"]);
322
+ });
323
+
324
+ test("an unreachable pin moves EVERY scope to the installable version", () => {
325
+ const [item] = planPluginUpdates(
326
+ { "dev@magus": "4.6.1" },
327
+ catalog(
328
+ info({
329
+ id: "dev@magus",
330
+ version: "9.9.9",
331
+ userScope: { enabled: true, version: "4.6.1" },
332
+ projectScope: { enabled: true, version: "4.0.0" },
333
+ }),
334
+ ),
335
+ );
336
+ expect(item.target).toBe("9.9.9");
337
+ expect(item.scopes).toEqual(["user", "project"]);
338
+ });
339
+
340
+ test("all scopes at the target is genuinely current", () => {
341
+ const [item] = planPluginUpdates(
342
+ { "dev@magus": "latest" },
343
+ catalog(
344
+ info({
345
+ id: "dev@magus",
346
+ version: "4.7.0",
347
+ userScope: { enabled: true, version: "4.7.0" },
348
+ projectScope: { enabled: true, version: "4.7.0" },
349
+ }),
350
+ ),
351
+ );
352
+ expect(item.action).toBe("current");
353
+ });
354
+
355
+ test("an installed plugin no marketplace offers is 'unknown', not 'current'", () => {
356
+ const [item] = planPluginUpdates(
357
+ { "dev@magus": "latest" },
358
+ catalog(
359
+ info({
360
+ id: "dev@magus",
361
+ version: null,
362
+ projectScope: { enabled: true, version: "4.6.1" },
363
+ }),
364
+ ),
365
+ );
366
+ expect(item.action).toBe("unknown");
367
+ expect(item.note).toContain("not offered");
368
+ });
369
+ });
370
+
371
+ describe("planIsBehind — the --check gate", () => {
372
+ const skillRef = {
373
+ name: "release",
374
+ repo: "MadAppGang/magus",
375
+ path: "skills/release",
376
+ };
377
+
378
+ test("an already-installed skill does NOT fail the gate", () => {
379
+ // A skill ref carries no content hash, so it is re-fetched every run.
380
+ // Counting that as drift made --check exit 1 forever for any profile
381
+ // declaring a single skill — a gate that can never pass is no gate.
382
+ const p = plan({
383
+ skills: planSkillUpdates([skillRef], new Set(["release"])),
384
+ });
385
+ expect(planHasWork(p)).toBe(true);
386
+ expect(planIsBehind(p)).toBe(false);
387
+ });
388
+
389
+ test("a MISSING skill does fail the gate", () => {
390
+ const p = plan({ skills: planSkillUpdates([skillRef], new Set()) });
391
+ expect(planIsBehind(p)).toBe(true);
392
+ });
393
+
394
+ test("a present unpinned binary does NOT fail the gate", () => {
395
+ // There is no version probe for a binary, only present/absent. "Upgrade"
396
+ // is best-effort advancement, not evidence anything is behind.
397
+ const p = plan({
398
+ bins: planBinUpdates(
399
+ [{ name: "tmux-mcp", via: "bun", sources: [] }],
400
+ [{ name: "tmux-mcp", present: true, installCommand: "", sources: [] }],
401
+ ),
402
+ });
403
+ expect(planHasWork(p)).toBe(true);
404
+ expect(planIsBehind(p)).toBe(false);
405
+ });
406
+
407
+ test("a MISSING binary does fail the gate", () => {
408
+ const p = plan({
409
+ bins: planBinUpdates(
410
+ [{ name: "tmux-mcp", via: "bun", sources: [] }],
411
+ [{ name: "tmux-mcp", present: false, installCommand: "", sources: [] }],
412
+ ),
413
+ });
414
+ expect(planIsBehind(p)).toBe(true);
415
+ });
416
+
417
+ test("an unverifiable plugin still fails the gate", () => {
418
+ const p = plan({
419
+ plugins: planPluginUpdates(
420
+ { "dev@magus": "latest" },
421
+ catalog(
422
+ info({
423
+ id: "dev@magus",
424
+ version: "4.6.1",
425
+ projectScope: { enabled: true, version: "4.6.1" },
426
+ updateCheckFailed: true,
427
+ }),
428
+ ),
429
+ ),
430
+ });
431
+ expect(planIsBehind(p)).toBe(true);
432
+ });
433
+
434
+ test("a fully current profile with skills and bins passes the gate", () => {
435
+ const p = plan({
436
+ plugins: planPluginUpdates(
437
+ { "dev@magus": "latest" },
438
+ catalog(
439
+ info({
440
+ id: "dev@magus",
441
+ version: "4.7.0",
442
+ projectScope: { enabled: true, version: "4.7.0" },
443
+ }),
444
+ ),
445
+ ),
446
+ bins: planBinUpdates(
447
+ [{ name: "tmux-mcp", via: "bun", version: "1.0.0", sources: [] }],
448
+ [{ name: "tmux-mcp", present: true, installCommand: "", sources: [] }],
449
+ ),
450
+ skills: planSkillUpdates([skillRef], new Set(["release"])),
451
+ });
452
+ expect(planIsBehind(p)).toBe(false);
453
+ });
454
+ });
455
+
456
+ describe("nothing ever plans a downgrade", () => {
457
+ // `claude plugin install` installs the newest published version and takes no
458
+ // version argument, so a downgrade is not a move this tool can perform.
459
+ // Planning one produces a step that runs, reports success, lands on the same
460
+ // version as before, and is replanned identically on the next run.
461
+
462
+ test("a scope AHEAD of the marketplace is left alone under an exact pin", () => {
463
+ const [item] = planPluginUpdates(
464
+ { "dev@magus": "4.6.1" },
465
+ catalog(
466
+ info({
467
+ id: "dev@magus",
468
+ version: "9.9.9",
469
+ projectScope: { enabled: true, version: "10.0.0" },
470
+ }),
471
+ ),
472
+ );
473
+ expect(item.action).toBe("current");
474
+ expect(item.scopes).toEqual([]);
475
+ });
476
+
477
+ test("an older pin with no catalog entry does not drag an install backwards", () => {
478
+ // available === null, so the pin is the only target on offer — and it is
479
+ // BELOW what is installed. Moving to it is neither possible nor wanted.
480
+ const [item] = planPluginUpdates(
481
+ { "dev@magus": "4.6.1" },
482
+ catalog(
483
+ info({
484
+ id: "dev@magus",
485
+ version: null,
486
+ projectScope: { enabled: true, version: "5.0.0" },
487
+ }),
488
+ ),
489
+ );
490
+ expect(item.action).toBe("current");
491
+ expect(item.scopes).toEqual([]);
492
+ });
493
+
494
+ test("only the behind scope moves when one is ahead and one behind", () => {
495
+ const [item] = planPluginUpdates(
496
+ { "dev@magus": "latest" },
497
+ catalog(
498
+ info({
499
+ id: "dev@magus",
500
+ version: "9.9.9",
501
+ userScope: { enabled: true, version: "10.0.0" },
502
+ projectScope: { enabled: true, version: "4.0.0" },
503
+ }),
504
+ ),
505
+ );
506
+ expect(item.scopes).toEqual(["project"]);
507
+ });
508
+
509
+ test("scopesNeeding never returns a scope ahead of the target", () => {
510
+ const i = info({
511
+ id: "dev@magus",
512
+ userScope: { enabled: true, version: "10.0.0" },
513
+ projectScope: { enabled: true, version: "9.9.9" },
514
+ localScope: { enabled: true, version: "1.0.0" },
515
+ });
516
+ expect(scopesNeeding(i, "9.9.9")).toEqual(["local"]);
517
+ });
518
+ });
519
+
520
+ describe("scopesNeeding", () => {
521
+ test("returns every outdated scope, not just user", () => {
522
+ // The prerunner's original bug: it fixed 'user' and left the project row
523
+ // behind, so the same drift reappeared on every run.
524
+ const i = info({
525
+ id: "dev@magus",
526
+ userScope: { enabled: true, version: "4.6.1" },
527
+ projectScope: { enabled: true, version: "4.6.1" },
528
+ localScope: { enabled: true, version: "4.7.0" },
529
+ });
530
+ expect(scopesNeeding(i, "4.7.0")).toEqual(["user", "project"]);
531
+ });
532
+
533
+ test("a scope already on the target is left alone", () => {
534
+ const i = info({
535
+ id: "dev@magus",
536
+ projectScope: { enabled: true, version: "4.7.0" },
537
+ });
538
+ expect(scopesNeeding(i, "4.7.0")).toEqual([]);
539
+ });
540
+ });
541
+
542
+ describe("planBinUpdates", () => {
543
+ const bin = (over: Partial<ResolvedBin>): ResolvedBin => ({
544
+ name: "tmux-mcp",
545
+ via: "bun",
546
+ sources: ["profile:test.cliTools"],
547
+ ...over,
548
+ });
549
+ const check = (name: string, present: boolean): BinCheckResult => ({
550
+ name,
551
+ present,
552
+ installCommand: "",
553
+ sources: [],
554
+ });
555
+
556
+ test("a missing binary is an install", () => {
557
+ const [item] = planBinUpdates([bin({})], [check("tmux-mcp", false)]);
558
+ expect(item.action).toBe("install");
559
+ });
560
+
561
+ test("present and unpinned is an upgrade — that is what 'update' means", () => {
562
+ const [item] = planBinUpdates([bin({})], [check("tmux-mcp", true)]);
563
+ expect(item.action).toBe("upgrade");
564
+ });
565
+
566
+ test("present and pinned is left alone — there is nothing to advance to", () => {
567
+ const [item] = planBinUpdates(
568
+ [bin({ version: "1.2.3" })],
569
+ [check("tmux-mcp", true)],
570
+ );
571
+ expect(item).toMatchObject({ action: "current", version: "1.2.3" });
572
+ });
573
+ });
574
+
575
+ describe("planSkillUpdates", () => {
576
+ const ref = {
577
+ name: "release",
578
+ repo: "MadAppGang/magus",
579
+ path: "skills/release",
580
+ };
581
+
582
+ test("an uninstalled skill is an install", () => {
583
+ expect(planSkillUpdates([ref], new Set())[0]!.action).toBe("install");
584
+ });
585
+
586
+ test("an installed skill is refreshed, since refs carry no content hash", () => {
587
+ expect(planSkillUpdates([ref], new Set(["release"]))[0]!.action).toBe(
588
+ "refresh",
589
+ );
590
+ });
591
+ });
592
+
593
+ describe("planHasWork / summarizePlan", () => {
594
+ test("an all-current plan has no work", () => {
595
+ const p = plan({
596
+ plugins: planPluginUpdates(
597
+ { "dev@magus": "latest" },
598
+ catalog(
599
+ info({
600
+ id: "dev@magus",
601
+ version: "4.7.0",
602
+ projectScope: { enabled: true, version: "4.7.0" },
603
+ }),
604
+ ),
605
+ ),
606
+ });
607
+ expect(planHasWork(p)).toBe(false);
608
+ expect(summarizePlan(p).current).toBe(1);
609
+ });
610
+
611
+ test("counts split installs from updates across plugins and bins", () => {
612
+ const p = plan({
613
+ plugins: planPluginUpdates(
614
+ { "a@magus": "latest", "b@magus": "latest" },
615
+ catalog(
616
+ info({ id: "a@magus", version: "1.0.0" }),
617
+ info({
618
+ id: "b@magus",
619
+ version: "2.0.0",
620
+ projectScope: { enabled: true, version: "1.0.0" },
621
+ }),
622
+ ),
623
+ ),
624
+ bins: planBinUpdates(
625
+ [{ name: "tmux-mcp", via: "bun", sources: [] }],
626
+ [{ name: "tmux-mcp", present: true, installCommand: "", sources: [] }],
627
+ ),
628
+ });
629
+ expect(summarizePlan(p)).toMatchObject({ installed: 1, updated: 2 });
630
+ });
631
+ });