magus-cli 7.1.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.
Files changed (48) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
  3. package/src/__tests__/doctor.test.ts +31 -0
  4. package/src/__tests__/plugin-resolution.test.ts +431 -0
  5. package/src/__tests__/plugin-row-text.test.ts +66 -0
  6. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  7. package/src/__tests__/profile-migration.test.ts +242 -0
  8. package/src/__tests__/profile-startup.test.ts +458 -0
  9. package/src/__tests__/stale-load-repair.test.ts +1076 -0
  10. package/src/__tests__/update-apply.test.ts +146 -1
  11. package/src/__tests__/update-plan.test.ts +338 -0
  12. package/src/__tests__/useStaleLoadModal.test.ts +177 -0
  13. package/src/cli/ansi.ts +39 -0
  14. package/src/cli/bootstrap.ts +169 -3
  15. package/src/cli/doctor.ts +128 -2
  16. package/src/cli/install.ts +5 -1
  17. package/src/cli/profile.ts +5 -1
  18. package/src/cli/prompt.ts +44 -12
  19. package/src/cli/tui-prompt.tsx +143 -0
  20. package/src/cli/update-view.ts +32 -1
  21. package/src/cli/update.ts +50 -7
  22. package/src/main.tsx +10 -1
  23. package/src/services/active-closure.ts +23 -0
  24. package/src/services/claude-settings.ts +645 -34
  25. package/src/services/manifest.ts +21 -0
  26. package/src/services/plugin-manager.ts +61 -0
  27. package/src/services/plugin-resolution.ts +280 -0
  28. package/src/services/profile-activate.ts +147 -0
  29. package/src/services/profile-adopt.ts +50 -4
  30. package/src/services/profile-startup.ts +311 -0
  31. package/src/services/profiles.ts +49 -11
  32. package/src/services/symlink-manager.ts +184 -30
  33. package/src/services/update-engine.ts +104 -2
  34. package/src/services/update-plan.ts +188 -1
  35. package/src/types/index.ts +44 -23
  36. package/src/ui/App.tsx +123 -0
  37. package/src/ui/components/layout/FooterHints.tsx +21 -9
  38. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  39. package/src/ui/components/modals/SelectModal.tsx +142 -21
  40. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  41. package/src/ui/hooks/useStaleLoadModal.ts +157 -0
  42. package/src/ui/renderers/pluginRenderers.tsx +29 -0
  43. package/src/ui/renderers/pluginStatusText.ts +14 -0
  44. package/src/ui/renderers/profileRenderers.tsx +27 -4
  45. package/src/ui/screens/PluginsScreen.tsx +46 -13
  46. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  47. package/src/ui/state/types.ts +18 -0
  48. package/src/ui/theme-mode.ts +54 -0
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { beforeEach, describe, expect, mock, test } from "bun:test";
10
10
  import type { PluginScope } from "../services/claude-cli.js";
11
+ import type { StaleLoadRepairResult } from "../services/claude-settings.js";
11
12
  import type { ToolFacts } from "../services/tool-probe.js";
12
13
  import {
13
14
  type ApplyReporter,
@@ -16,6 +17,7 @@ import {
16
17
  applyPlan,
17
18
  applyPlugins,
18
19
  installPluginInScope,
20
+ pluginNeedsWork,
19
21
  updatePluginInScope,
20
22
  } from "../services/update-engine.js";
21
23
  import type {
@@ -31,13 +33,39 @@ const PROJECT = "/tmp/project";
31
33
  /** Records every call, and lets a test dictate what the registry reads back. */
32
34
  function stubDeps(
33
35
  installedAfter: string | null | ((scope: PluginScope) => string | null),
36
+ /**
37
+ * What the registry repair answers. Defaults to success at the target, which
38
+ * is what every case that is not ABOUT the repair wants.
39
+ */
40
+ staleLoadOutcome: (
41
+ pluginId: string,
42
+ target: string,
43
+ ) => StaleLoadRepairResult = (pluginId, target) => ({
44
+ pluginId,
45
+ target,
46
+ rewritten: 1,
47
+ backup: "/tmp/installed_plugins.json.backup.1",
48
+ loaded: target,
49
+ ok: true,
50
+ }),
34
51
  ) {
35
52
  const saved: Array<{ id: string; version: string; scope: PluginScope }> = [];
36
53
  const installed: Array<{ id: string; scope: PluginScope }> = [];
37
54
  const updated: Array<{ id: string; scope: PluginScope }> = [];
38
55
  const repaired: Array<{ id: string; scope: PluginScope }> = [];
56
+ const rowsRepaired: Array<{
57
+ id: string;
58
+ target: string;
59
+ projectPath: string;
60
+ }> = [];
39
61
 
40
62
  const deps: PluginApplyDeps = {
63
+ repairStaleLoad: mock(
64
+ async (pluginId: string, target: string, projectPath: string) => {
65
+ rowsRepaired.push({ id: pluginId, target, projectPath });
66
+ return staleLoadOutcome(pluginId, target);
67
+ },
68
+ ),
41
69
  install: mock(async (id: string, scope: PluginScope) => {
42
70
  installed.push({ id, scope });
43
71
  }),
@@ -58,7 +86,7 @@ function stubDeps(
58
86
  },
59
87
  ),
60
88
  };
61
- return { deps, saved, installed, updated, repaired };
89
+ return { deps, saved, installed, updated, repaired, rowsRepaired };
62
90
  }
63
91
 
64
92
  function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
@@ -274,6 +302,123 @@ describe("what is skipped", () => {
274
302
  });
275
303
  });
276
304
 
305
+ /**
306
+ * A broken plugin — the loader resolves an older version than the one installed.
307
+ *
308
+ * It used to be SKIPPED here, on the argument that no installer could clear it.
309
+ * That is still true of every installer; what clears it is rewriting the registry
310
+ * rows, which `repairStaleLoad` does. These cases lock in that the engine reaches
311
+ * that repair and nothing else — a reinstall is measured not to fix this, so an
312
+ * `install`/`update`/`repair` call for one of these rows is a regression.
313
+ */
314
+ describe("a stale load is APPLIED, by repairing the registry", () => {
315
+ const broken = (over: Partial<PluginUpdateItem> = {}) =>
316
+ item({
317
+ pluginId: "madbench@magus",
318
+ action: "stale-load",
319
+ reason: "older-version-loads",
320
+ installed: "0.5.0",
321
+ available: "0.5.0",
322
+ loaded: "0.3.0",
323
+ target: "0.5.0",
324
+ scopes: [],
325
+ beforeVersions: { project: "0.5.0" },
326
+ note: "loads v0.3.0, installed v0.5.0",
327
+ ...over,
328
+ });
329
+
330
+ test("it is work, so a progress meter counts it", () => {
331
+ // `pluginNeedsWork` is the apply loop's own predicate and the meter's
332
+ // denominator. It cannot answer false here while the loop acts, or the bar
333
+ // finishes short of full and reads as a hang.
334
+ expect(pluginNeedsWork(broken())).toBe(true);
335
+ });
336
+
337
+ test("the repair runs, targeting the INSTALLED version", async () => {
338
+ const { deps, rowsRepaired } = stubDeps("0.5.0");
339
+ const result = await applyPlugins([broken()], PROJECT, deps);
340
+
341
+ expect(rowsRepaired).toEqual([
342
+ { id: "madbench@magus", target: "0.5.0", projectPath: PROJECT },
343
+ ]);
344
+ expect(result.ok).toBe(1);
345
+ expect(result.failed).toEqual([]);
346
+ });
347
+
348
+ test("no installer is invoked for it", async () => {
349
+ const { deps, installed, updated, repaired, saved } = stubDeps("0.5.0");
350
+ await applyPlugins([broken()], PROJECT, deps);
351
+
352
+ expect({ installed, updated, repaired, saved }).toEqual({
353
+ installed: [],
354
+ updated: [],
355
+ repaired: [],
356
+ saved: [],
357
+ });
358
+ });
359
+
360
+ test("a repair that did not change the resolution is a FAILURE", async () => {
361
+ // The repair's own post-write assertion says the loader still resolves the
362
+ // old version. Counting that as `ok` would report a fix for a plugin that is
363
+ // still broken — the precise deception this state exists to end.
364
+ const { deps } = stubDeps("0.5.0", (pluginId, target) => ({
365
+ pluginId,
366
+ target,
367
+ rewritten: 1,
368
+ backup: "/tmp/installed_plugins.json.backup.1",
369
+ loaded: "0.3.0",
370
+ ok: false,
371
+ error: "the loader still resolves v0.3.0 here, not v0.5.0",
372
+ }));
373
+
374
+ const result = await applyPlugins([broken()], PROJECT, deps);
375
+
376
+ expect(result.ok).toBe(0);
377
+ expect(result.failed).toEqual(["madbench@magus"]);
378
+ // And the reason reaches the reporter verbatim, not a generic message.
379
+ const seen: string[] = [];
380
+ const reporter: ApplyReporter = {
381
+ begin: () => {},
382
+ finish: (_ok, _name, detail) => seen.push(detail),
383
+ detach: (work) => work(),
384
+ };
385
+ await applyPlugins([broken()], PROJECT, deps, reporter);
386
+ expect(seen).toEqual(["the loader still resolves v0.3.0 here, not v0.5.0"]);
387
+ });
388
+
389
+ test("a throwing repair fails that plugin without stopping the run", async () => {
390
+ const { deps, updated } = stubDeps("2.0.0");
391
+ deps.repairStaleLoad = async () => {
392
+ throw new Error("registry is read-only");
393
+ };
394
+
395
+ const result = await applyPlugins(
396
+ [broken(), item({ pluginId: "dev@magus" })],
397
+ PROJECT,
398
+ deps,
399
+ );
400
+
401
+ expect(result.failed).toEqual(["madbench@magus"]);
402
+ // The ordinary update after it still ran.
403
+ expect(updated).toEqual([{ id: "dev@magus", scope: "project" }]);
404
+ expect(result.ok).toBe(1);
405
+ });
406
+
407
+ test("no target means no repair to run, and it is not silently skipped", async () => {
408
+ // `target` is the installed version; null means nothing is on disk to point
409
+ // the rows at. Reporting that is different from doing nothing quietly.
410
+ const { deps, rowsRepaired } = stubDeps("0.5.0");
411
+ const result = await applyPlugins(
412
+ [broken({ target: null, installed: null })],
413
+ PROJECT,
414
+ deps,
415
+ );
416
+
417
+ expect(rowsRepaired).toEqual([]);
418
+ expect(result.failed).toEqual(["madbench@magus"]);
419
+ });
420
+ });
421
+
277
422
  describe("failure handling", () => {
278
423
  test("a failing plugin is reported, and the rest still run", async () => {
279
424
  const { deps, updated } = stubDeps("2.0.0");
@@ -1359,6 +1359,7 @@ describe("countPluginStates", () => {
1359
1359
  update: 2,
1360
1360
  repair: 1,
1361
1361
  current: 0,
1362
+ "stale-load": 0,
1362
1363
  unknown: 1,
1363
1364
  });
1364
1365
  });
@@ -1394,3 +1395,340 @@ describe("scopeIsBehind — the scope picker's one question", () => {
1394
1395
  expect(scopeIsBehind(undefined, "project")).toBe(false);
1395
1396
  });
1396
1397
  });
1398
+
1399
+ describe("classifyPlugin — an older version LOADS than the one installed", () => {
1400
+ /**
1401
+ * The measured madbench case. 0.5.0 is installed in this project and is what
1402
+ * the marketplace offers, so every comparison the classifier used to make
1403
+ * said `current`. Claude Code loaded 0.3.0 out of a registry row belonging to
1404
+ * a worktree that no longer exists — see services/plugin-resolution.ts.
1405
+ *
1406
+ * What makes it worth its own state: the installed version is RIGHT. Nothing
1407
+ * in the version columns is wrong, so every report was green about a session
1408
+ * running two-versions-old code.
1409
+ */
1410
+ const madbench = (over: Partial<PluginInfo> = {}) =>
1411
+ info({
1412
+ id: "madbench@magus",
1413
+ version: "0.5.0",
1414
+ installedVersion: "0.5.0",
1415
+ loadedVersion: "0.3.0",
1416
+ projectScope: { enabled: true, version: "0.5.0" },
1417
+ ...over,
1418
+ });
1419
+
1420
+ test("beats `current`, and names both versions", () => {
1421
+ const [item] = planPluginUpdates(
1422
+ { "madbench@magus": "latest" },
1423
+ catalog(madbench()),
1424
+ );
1425
+
1426
+ expect(item).toMatchObject({
1427
+ action: "stale-load",
1428
+ reason: "older-version-loads",
1429
+ installed: "0.5.0",
1430
+ loaded: "0.3.0",
1431
+ note: "loads v0.3.0, installed v0.5.0",
1432
+ });
1433
+ });
1434
+
1435
+ test("targets the INSTALLED version, with no scope, and IS planned as work", () => {
1436
+ // It is applied by rewriting registry rows (`repairStaleLoad`), not by any
1437
+ // installer — a reinstall is measured not to clear it. So there is no scope
1438
+ // to enumerate, and `planWorkItems` has to name it anyway or the apply loop
1439
+ // would skip the one action the row tells the user to press a key for.
1440
+ //
1441
+ // The target is what is INSTALLED here, never the catalog version: the
1442
+ // repair points rows at a cache directory, and only the installed version's
1443
+ // cache is on disk.
1444
+ const [item] = planPluginUpdates(
1445
+ { "madbench@magus": "latest" },
1446
+ catalog(madbench()),
1447
+ );
1448
+
1449
+ expect(item?.target).toBe("0.5.0");
1450
+ expect(item?.scopes).toEqual([]);
1451
+ expect(
1452
+ planWorkItems(plan({ plugins: [item as PluginUpdateItem] })),
1453
+ ).toEqual(["madbench@magus"]);
1454
+ });
1455
+
1456
+ test("the target is the installed version even when the catalog is ahead", () => {
1457
+ // 0.4.0 installed, 0.5.0 published, 0.3.0 loading. Aiming at 0.5.0 would
1458
+ // name a cache directory that does not exist; `repairStaleLoad` refuses
1459
+ // those outright. The ordinary `update` to 0.5.0 is planned on the next run,
1460
+ // once the load is no longer stale, so the two steps converge.
1461
+ const [item] = planPluginUpdates(
1462
+ { "madbench@magus": "latest" },
1463
+ catalog(
1464
+ madbench({
1465
+ installedVersion: "0.4.0",
1466
+ projectScope: { enabled: true, version: "0.4.0" },
1467
+ }),
1468
+ ),
1469
+ );
1470
+
1471
+ expect(item).toMatchObject({
1472
+ action: "stale-load",
1473
+ installed: "0.4.0",
1474
+ available: "0.5.0",
1475
+ target: "0.4.0",
1476
+ });
1477
+ });
1478
+
1479
+ test("it fails the --check gate now that applying it clears it", () => {
1480
+ // It was excluded from `planIsBehind` while it was report-only, on the
1481
+ // un-greenable-gate rule. `magus update` repairs it, so a gate that stays
1482
+ // silent here would print "nothing behind" over a session running a version
1483
+ // nobody installed.
1484
+ const [item] = planPluginUpdates(
1485
+ { "madbench@magus": "latest" },
1486
+ catalog(madbench()),
1487
+ );
1488
+
1489
+ expect(planIsBehind(plan({ plugins: [item as PluginUpdateItem] }))).toBe(
1490
+ true,
1491
+ );
1492
+ });
1493
+
1494
+ test("beats `update` too — and says the update is still waiting", () => {
1495
+ // Behind the catalog AND loading something older still. The update is the
1496
+ // smaller problem and the loaded version is the fact that decides what
1497
+ // this session actually runs — but the pending release is NOT swallowed:
1498
+ // the note names it, because a user told only "loads v0.3.0, installed
1499
+ // v0.4.0" has no way to know 0.5.0 is waiting behind the repair.
1500
+ const [item] = planPluginUpdates(
1501
+ { "madbench@magus": "latest" },
1502
+ catalog(
1503
+ madbench({
1504
+ installedVersion: "0.4.0",
1505
+ projectScope: { enabled: true, version: "0.4.0" },
1506
+ }),
1507
+ ),
1508
+ );
1509
+
1510
+ expect(item).toMatchObject({
1511
+ action: "stale-load",
1512
+ installed: "0.4.0",
1513
+ available: "0.5.0",
1514
+ target: "0.4.0",
1515
+ note: "loads v0.3.0, installed v0.4.0; v0.5.0 is available once the load is repaired",
1516
+ });
1517
+ });
1518
+
1519
+ test("an ordinary PENDING RELEASE is an update, not a broken load", () => {
1520
+ // THE defect this branch shipped with: it compared the CATALOG version
1521
+ // against the loaded one, so installed 0.5.0 / loaded 0.5.0 / catalog 0.6.0
1522
+ // — nothing stale whatsoever, just a release waiting to be installed —
1523
+ // classified `stale-load` with an empty `scopes`. No installer ran,
1524
+ // `repairStaleLoad` had no row behind 0.5.0 to move and reported ok, the
1525
+ // engine counted a success, and 0.6.0 was never installed on any machine
1526
+ // with a pending release.
1527
+ //
1528
+ // The note it emitted contradicted itself in the same breath: "loads
1529
+ // v0.5.0, installed v0.5.0".
1530
+ const [item] = planPluginUpdates(
1531
+ { "madbench@magus": "latest" },
1532
+ catalog(madbench({ version: "0.6.0", loadedVersion: "0.5.0" })),
1533
+ );
1534
+
1535
+ expect(item).toMatchObject({
1536
+ action: "update",
1537
+ reason: "behind",
1538
+ installed: "0.5.0",
1539
+ loaded: "0.5.0",
1540
+ available: "0.6.0",
1541
+ target: "0.6.0",
1542
+ scopes: ["project"],
1543
+ });
1544
+ });
1545
+
1546
+ test("the same pending release is planned as work, and clears the gate once applied", () => {
1547
+ // The consequence of the misclassification, pinned separately from the
1548
+ // label: `stale-load` carries no scopes, so the apply loop had nothing to
1549
+ // install, while `planIsBehind` kept the `--check` gate red forever.
1550
+ const [item] = planPluginUpdates(
1551
+ { "madbench@magus": "latest" },
1552
+ catalog(madbench({ version: "0.6.0", loadedVersion: "0.5.0" })),
1553
+ );
1554
+ const single = plan({ plugins: [item as PluginUpdateItem] });
1555
+
1556
+ expect(planWorkItems(single)).toEqual(["madbench@magus"]);
1557
+ expect(item?.scopes).toEqual(["project"]);
1558
+ expect(planIsBehind(single)).toBe(true);
1559
+ });
1560
+
1561
+ test("an UNVERSIONED catalog cannot hide a real stale load", () => {
1562
+ // The mirror failure of the same wrong operand: 278 of the 292 plugins in
1563
+ // `claude-plugins-official` publish no version, so `available` is null,
1564
+ // `isRepairablyBehind(null, loaded)` is false, and a genuine stale load
1565
+ // classified `current` with reason `no-published-version` — green about a
1566
+ // session running old code.
1567
+ const [item] = planPluginUpdates(
1568
+ { "madbench@magus": "latest" },
1569
+ catalog(madbench({ version: null })),
1570
+ );
1571
+
1572
+ expect(item).toMatchObject({
1573
+ action: "stale-load",
1574
+ reason: "older-version-loads",
1575
+ installed: "0.5.0",
1576
+ loaded: "0.3.0",
1577
+ available: null,
1578
+ target: "0.5.0",
1579
+ note: "loads v0.3.0, installed v0.5.0",
1580
+ });
1581
+ });
1582
+
1583
+ test("an exact pin does not hide it either", () => {
1584
+ const [item] = planPluginUpdates(
1585
+ { "madbench@magus": "0.5.0" },
1586
+ catalog(madbench()),
1587
+ );
1588
+ expect(item?.action).toBe("stale-load");
1589
+ });
1590
+
1591
+ test("loading the installed version is plain `current`", () => {
1592
+ const [item] = planPluginUpdates(
1593
+ { "madbench@magus": "latest" },
1594
+ catalog(madbench({ loadedVersion: "0.5.0" })),
1595
+ );
1596
+ expect(item).toMatchObject({ action: "current", loaded: "0.5.0" });
1597
+ });
1598
+
1599
+ test("a loaded version AHEAD of the catalog is not a stale load", () => {
1600
+ const [item] = planPluginUpdates(
1601
+ { "madbench@magus": "latest" },
1602
+ catalog(madbench({ loadedVersion: "0.6.0" })),
1603
+ );
1604
+ expect(item?.action).toBe("current");
1605
+ });
1606
+
1607
+ test("an unknown loaded version changes nothing — cannot tell is not a finding", () => {
1608
+ const [item] = planPluginUpdates(
1609
+ { "madbench@magus": "latest" },
1610
+ catalog(madbench({ loadedVersion: null })),
1611
+ );
1612
+ expect(item).toMatchObject({ action: "current", loaded: null });
1613
+ });
1614
+
1615
+ /**
1616
+ * THE PIN IS A CEILING on this branch.
1617
+ *
1618
+ * `installed` is the highest version in ANY scope and `scopesNeeding` never
1619
+ * downgrades, so a scope legitimately sits above an exact pin — permanently.
1620
+ * Comparing the loaded version against the uncapped `installed` therefore
1621
+ * called a plugin loading exactly its pinned version a broken load, and the
1622
+ * repair then moved the pinned rows off the pin and reported success.
1623
+ *
1624
+ * One registry shape, two pins, opposite answers — which is the whole point:
1625
+ * only the pin can say whether moving this repo up to the user-scope version is
1626
+ * wanted.
1627
+ */
1628
+ const pinned = (over: Partial<PluginInfo> = {}) =>
1629
+ info({
1630
+ id: "madbench@magus",
1631
+ version: "0.3.0",
1632
+ installedVersion: "0.3.0",
1633
+ loadedVersion: "0.3.0",
1634
+ projectScope: { enabled: true, version: "0.3.0" },
1635
+ // Higher, in a scope this project did not ask for and cannot downgrade.
1636
+ userScope: { enabled: true, version: "0.5.0" },
1637
+ ...over,
1638
+ });
1639
+
1640
+ test("a plugin loading exactly its exact pin is NOT a broken load", () => {
1641
+ const [item] = planPluginUpdates(
1642
+ { "madbench@magus": "0.3.0" },
1643
+ catalog(pinned()),
1644
+ );
1645
+
1646
+ expect(item).toMatchObject({
1647
+ action: "current",
1648
+ reason: "up-to-date",
1649
+ pinned: "0.3.0",
1650
+ // Still reported honestly: 0.5.0 IS installed, in user scope.
1651
+ installed: "0.5.0",
1652
+ loaded: "0.3.0",
1653
+ target: "0.3.0",
1654
+ });
1655
+ expect(item?.note).toBeUndefined();
1656
+ });
1657
+
1658
+ test("under the same rows pinned to `latest`, the promotion IS a broken load", () => {
1659
+ // The negative control for the cap: it must not silently disable the state.
1660
+ // Nothing pins this plugin down, so 0.5.0 on the machine is the version to
1661
+ // converge on and loading 0.3.0 here is the defect.
1662
+ const [item] = planPluginUpdates(
1663
+ { "madbench@magus": "latest" },
1664
+ catalog(pinned({ version: "0.5.0" })),
1665
+ );
1666
+
1667
+ expect(item).toMatchObject({
1668
+ action: "stale-load",
1669
+ reason: "older-version-loads",
1670
+ installed: "0.5.0",
1671
+ loaded: "0.3.0",
1672
+ target: "0.5.0",
1673
+ note: "loads v0.3.0, installed v0.5.0",
1674
+ });
1675
+ });
1676
+
1677
+ test("an exact pin still reports a load BELOW it, and targets the pin", () => {
1678
+ // The cap is a ceiling, not an off switch: 0.2.0 loads, the pin asked for
1679
+ // 0.3.0, and 0.3.0 is installed here — so there is a repair to make, and its
1680
+ // target is the pinned version rather than user scope's 0.5.0.
1681
+ const [item] = planPluginUpdates(
1682
+ { "madbench@magus": "0.3.0" },
1683
+ catalog(pinned({ loadedVersion: "0.2.0" })),
1684
+ );
1685
+
1686
+ expect(item).toMatchObject({
1687
+ action: "stale-load",
1688
+ loaded: "0.2.0",
1689
+ target: "0.3.0",
1690
+ note: "loads v0.2.0, installed v0.3.0",
1691
+ });
1692
+ });
1693
+
1694
+ test("a pin naming a version installed nowhere at or below it stays silent", () => {
1695
+ // Only 0.5.0 is on disk and the pin asked for 0.3.0. There is no repair this
1696
+ // branch may perform — moving to 0.5.0 exceeds the pin, and 0.3.0 has no
1697
+ // cache directory — so the pin branch below reports the pin state instead of
1698
+ // this one quietly promoting the plugin.
1699
+ const [item] = planPluginUpdates(
1700
+ { "madbench@magus": "0.3.0" },
1701
+ catalog(
1702
+ pinned({
1703
+ projectScope: undefined,
1704
+ loadedVersion: "0.2.0",
1705
+ }),
1706
+ ),
1707
+ );
1708
+
1709
+ expect(item?.action).not.toBe("stale-load");
1710
+ expect(item?.installed).toBe("0.5.0");
1711
+ });
1712
+
1713
+ test("it is counted on its own, never as current", () => {
1714
+ const counts = countPluginStates(
1715
+ planPluginUpdates(
1716
+ { "madbench@magus": "latest", "dev@magus": "latest" },
1717
+ catalog(
1718
+ madbench(),
1719
+ info({
1720
+ id: "dev@magus",
1721
+ version: "7.4.0",
1722
+ installedVersion: "7.4.0",
1723
+ loadedVersion: "7.4.0",
1724
+ projectScope: { enabled: true, version: "7.4.0" },
1725
+ }),
1726
+ ),
1727
+ ),
1728
+ );
1729
+
1730
+ expect(counts["stale-load"]).toBe(1);
1731
+ expect(counts.current).toBe(1);
1732
+ expect(counts.update).toBe(0);
1733
+ });
1734
+ });