magus-cli 7.2.0 → 7.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -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__/upgrade-spawn.test.ts +355 -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/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +4 -1
- package/src/data/npm-package.ts +15 -0
- 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/services/version-check.ts +20 -10
- 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
|
@@ -14,6 +14,20 @@ import type {
|
|
|
14
14
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
15
15
|
import { repoHeadSha } from "./content-drift.js";
|
|
16
16
|
import { inheritablePaths } from "./git-worktree.js";
|
|
17
|
+
// `isRepairablyBehind` is the ONE predicate behind the `stale-load` state, shared
|
|
18
|
+
// with the classifier so the popup and the Plugins header cannot count different
|
|
19
|
+
// plugins — see the notes on it and on `findStaleLoads`. plugin-manager imports
|
|
20
|
+
// this module in turn; the cycle is binding-only (neither module calls the other
|
|
21
|
+
// at evaluation time), so both resolve whichever is loaded second against a live,
|
|
22
|
+
// hoisted function.
|
|
23
|
+
import { compareVersions, isRepairablyBehind } from "./plugin-manager.js";
|
|
24
|
+
import {
|
|
25
|
+
type RepoRootCache,
|
|
26
|
+
isMachineWideScope,
|
|
27
|
+
isRelevantInstallation,
|
|
28
|
+
pickLoadedEntry,
|
|
29
|
+
repoRootForPath,
|
|
30
|
+
} from "./plugin-resolution.js";
|
|
17
31
|
|
|
18
32
|
const CLAUDE_DIR = ".claude";
|
|
19
33
|
const SETTINGS_FILE = "settings.json";
|
|
@@ -790,8 +804,18 @@ export async function discoverAllGlobalMarketplaces(): Promise<
|
|
|
790
804
|
// os.homedir() in a module-level constant makes the path capture whatever
|
|
791
805
|
// HOME was set to when this module was first imported, which is fragile
|
|
792
806
|
// (import-order-dependent) and breaks any caller that changes HOME afterward.
|
|
793
|
-
|
|
794
|
-
|
|
807
|
+
/**
|
|
808
|
+
* `~/.claude/plugins` — the directory holding BOTH the registry and the plugin
|
|
809
|
+
* cache. The two are derived from one root rather than spelled out twice, so a
|
|
810
|
+
* caller that needs to point the pair somewhere else (a test, above all) moves
|
|
811
|
+
* one argument and cannot land the registry in a temp directory while the cache
|
|
812
|
+
* path it writes still names the operator's real home.
|
|
813
|
+
*/
|
|
814
|
+
export const pluginsDir = (): string =>
|
|
815
|
+
path.join(os.homedir(), ".claude", "plugins");
|
|
816
|
+
|
|
817
|
+
const installedPluginsFile = (root: string = pluginsDir()): string =>
|
|
818
|
+
path.join(root, "installed_plugins.json");
|
|
795
819
|
|
|
796
820
|
const knownMarketplacesFile = (): string =>
|
|
797
821
|
path.join(os.homedir(), ".claude", "plugins", "known_marketplaces.json");
|
|
@@ -1393,62 +1417,201 @@ export async function resolveCacheProvenance(
|
|
|
1393
1417
|
|
|
1394
1418
|
/**
|
|
1395
1419
|
* Read installed_plugins.json registry
|
|
1420
|
+
*
|
|
1421
|
+
* @param root the plugins directory to read from — see {@link pluginsDir}
|
|
1396
1422
|
*/
|
|
1397
|
-
export async function readInstalledPluginsRegistry(
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
await fs.copy(installedPluginsFile(), backup);
|
|
1413
|
-
console.warn(`Corrupted registry backed up to: ${backup}`);
|
|
1414
|
-
} catch {
|
|
1415
|
-
// Ignore backup errors
|
|
1416
|
-
}
|
|
1423
|
+
export async function readInstalledPluginsRegistry(
|
|
1424
|
+
root: string = pluginsDir(),
|
|
1425
|
+
): Promise<InstalledPluginsRegistry> {
|
|
1426
|
+
const file = installedPluginsFile(root);
|
|
1427
|
+
const raw = await readRegistryBytes(file);
|
|
1428
|
+
const parsed = parseRegistryBytes(raw);
|
|
1429
|
+
if (parsed) return parsed;
|
|
1430
|
+
if (raw !== null) {
|
|
1431
|
+
// Corrupt rather than absent — keep a copy before any caller writes over it.
|
|
1432
|
+
try {
|
|
1433
|
+
const backup = await registryBackupPath(file, "corrupt");
|
|
1434
|
+
await fs.copy(file, backup);
|
|
1435
|
+
console.warn(`Corrupted registry backed up to: ${backup}`);
|
|
1436
|
+
} catch {
|
|
1437
|
+
// Ignore backup errors
|
|
1417
1438
|
}
|
|
1418
1439
|
}
|
|
1419
1440
|
return { version: 2, plugins: {} };
|
|
1420
1441
|
}
|
|
1421
1442
|
|
|
1422
1443
|
/**
|
|
1423
|
-
*
|
|
1444
|
+
* The registry file's exact bytes, or null when it is not there.
|
|
1445
|
+
*
|
|
1446
|
+
* Separate from the parse so a caller can hold what it read and hand it back as
|
|
1447
|
+
* a write PRECONDITION — see {@link writeInstalledPluginsRegistry}. Parsing from
|
|
1448
|
+
* those same bytes, rather than re-reading the file, is what makes the
|
|
1449
|
+
* precondition mean anything: a snapshot parsed from a second read would already
|
|
1450
|
+
* be a different document than the one the precondition names.
|
|
1451
|
+
*/
|
|
1452
|
+
async function readRegistryBytes(file: string): Promise<string | null> {
|
|
1453
|
+
try {
|
|
1454
|
+
return await fs.readFile(file, "utf8");
|
|
1455
|
+
} catch {
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** Parse + structure check. Null means "not a registry", including absent. */
|
|
1461
|
+
function parseRegistryBytes(
|
|
1462
|
+
raw: string | null,
|
|
1463
|
+
): InstalledPluginsRegistry | null {
|
|
1464
|
+
if (raw === null) return null;
|
|
1465
|
+
try {
|
|
1466
|
+
const content = JSON.parse(raw);
|
|
1467
|
+
if (!content?.version || !content?.plugins) return null;
|
|
1468
|
+
return content as InstalledPluginsRegistry;
|
|
1469
|
+
} catch {
|
|
1470
|
+
return null;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** How many `installed_plugins.json.backup.*` files are kept. */
|
|
1475
|
+
const MAX_REGISTRY_BACKUPS = 10;
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* The one backup naming rule, so a reader can find every backup with one glob —
|
|
1479
|
+
* and so that two backups taken in the same millisecond are two files.
|
|
1480
|
+
*
|
|
1481
|
+
* `Date.now()` alone was the name. Two repairs inside one millisecond produced
|
|
1482
|
+
* the same path and `fs.copy` overwrote, which matters most in
|
|
1483
|
+
* `repairAllStaleLoads`: the second backup is a copy of the file AFTER the first
|
|
1484
|
+
* repair, so the surviving file no longer holds the pre-repair state of the
|
|
1485
|
+
* first plugin — the undo for it was gone. A process-lifetime sequence number
|
|
1486
|
+
* plus a label fixes the within-process case, and the existence loop covers two
|
|
1487
|
+
* magus processes landing on the same millisecond.
|
|
1488
|
+
*
|
|
1489
|
+
* The timestamp stays FIRST so a plain name sort is chronological, which is what
|
|
1490
|
+
* {@link pruneRegistryBackups} ranks by.
|
|
1491
|
+
*/
|
|
1492
|
+
let registryBackupSeq = 0;
|
|
1493
|
+
async function registryBackupPath(
|
|
1494
|
+
file: string,
|
|
1495
|
+
label: string,
|
|
1496
|
+
): Promise<string> {
|
|
1497
|
+
const safe = label.replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
1498
|
+
const stamp = Date.now();
|
|
1499
|
+
for (;;) {
|
|
1500
|
+
// Fixed-width sequence: two backups in the same millisecond must still sort
|
|
1501
|
+
// in the order they were taken, and "10" sorts before "2" otherwise.
|
|
1502
|
+
const seq = String(registryBackupSeq++).padStart(3, "0");
|
|
1503
|
+
const candidate = `${file}.backup.${stamp}.${seq}.${safe}`;
|
|
1504
|
+
if (!(await fs.pathExists(candidate))) return candidate;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Keep the newest {@link MAX_REGISTRY_BACKUPS} backups and delete the rest.
|
|
1510
|
+
*
|
|
1511
|
+
* Nothing pruned these, so every repair left another copy of a multi-hundred-KB
|
|
1512
|
+
* file in a directory magus does not own. Best-effort by design: a failure to
|
|
1513
|
+
* delete an old copy must never turn into a failed repair.
|
|
1514
|
+
*/
|
|
1515
|
+
async function pruneRegistryBackups(file: string): Promise<void> {
|
|
1516
|
+
try {
|
|
1517
|
+
const dir = path.dirname(file);
|
|
1518
|
+
const prefix = `${path.basename(file)}.backup.`;
|
|
1519
|
+
const names = (await fs.readdir(dir))
|
|
1520
|
+
.filter((n) => n.startsWith(prefix))
|
|
1521
|
+
.sort();
|
|
1522
|
+
for (const name of names.slice(0, -MAX_REGISTRY_BACKUPS)) {
|
|
1523
|
+
await fs.remove(path.join(dir, name)).catch(() => {});
|
|
1524
|
+
}
|
|
1525
|
+
} catch {
|
|
1526
|
+
// Best-effort.
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* A registry write was refused because the file changed after the caller read it.
|
|
1532
|
+
*
|
|
1533
|
+
* Its own type so a caller can tell a lost-update refusal from an IO failure and
|
|
1534
|
+
* say which happened, rather than reporting "could not write" for both.
|
|
1535
|
+
*/
|
|
1536
|
+
export class RegistryChangedError extends Error {
|
|
1537
|
+
constructor(readonly file: string) {
|
|
1538
|
+
super(
|
|
1539
|
+
`${file} changed on disk after it was read — another Claude Code session wrote it. Nothing was written; re-run to work from the current file.`,
|
|
1540
|
+
);
|
|
1541
|
+
this.name = "RegistryChangedError";
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Write installed_plugins.json registry.
|
|
1547
|
+
*
|
|
1548
|
+
* ATOMIC — temp file in the same directory, then rename. This file is read by
|
|
1549
|
+
* every Claude Code session on the machine, so a write interrupted partway
|
|
1550
|
+
* leaves a truncated JSON document that `readInstalledPluginsRegistry` treats as
|
|
1551
|
+
* corrupt and replaces with an EMPTY registry: every plugin on the machine
|
|
1552
|
+
* reads as not installed. `rename(2)` within one directory cannot be observed
|
|
1553
|
+
* half-done, so a reader sees either the old file or the new one.
|
|
1554
|
+
*
|
|
1555
|
+
* ATOMICITY IS NOT ISOLATION, and conflating the two is how this silently lost
|
|
1556
|
+
* other sessions' work. A read-modify-write rewrites the WHOLE file from the
|
|
1557
|
+
* snapshot the caller parsed, so a `claude plugin install` landing in between —
|
|
1558
|
+
* for any plugin, in any project — is discarded by a write that is perfectly
|
|
1559
|
+
* atomic. This repo's own note (`ai-docs/claude-code-plugin-resolution.md`)
|
|
1560
|
+
* measured that file drifting across sessions inside minutes and losing 12 of
|
|
1561
|
+
* 123 resolutions to it; dozens of sessions on one machine is the normal case.
|
|
1562
|
+
*
|
|
1563
|
+
* @param root the plugins directory to write to — see {@link pluginsDir}
|
|
1564
|
+
* @param expectBytes the exact bytes the caller read, from
|
|
1565
|
+
* {@link readRegistryBytes} — `null` means "the file did not exist". Given
|
|
1566
|
+
* one, this compares it against the file as the LAST act before the rename and
|
|
1567
|
+
* throws {@link RegistryChangedError} rather than renaming over a newer
|
|
1568
|
+
* document. Omitted (`undefined`), there is no precondition and the write
|
|
1569
|
+
* clobbers, which is the old behaviour of every caller that has no snapshot to
|
|
1570
|
+
* offer.
|
|
1571
|
+
*
|
|
1572
|
+
* It is a precondition, not a lock: the compare and the rename are two
|
|
1573
|
+
* syscalls, so a write landing between them is still lost. That window is
|
|
1574
|
+
* microseconds instead of the whole read-modify-write, and a loop that retried
|
|
1575
|
+
* until it won would be the other failure mode — an unbounded retry against a
|
|
1576
|
+
* file other processes write constantly. Refusing hands the decision back to
|
|
1577
|
+
* the caller, which is why the repair reports it instead of spinning.
|
|
1424
1578
|
*/
|
|
1425
1579
|
export async function writeInstalledPluginsRegistry(
|
|
1426
1580
|
registry: InstalledPluginsRegistry,
|
|
1581
|
+
root: string = pluginsDir(),
|
|
1582
|
+
expectBytes?: string | null,
|
|
1427
1583
|
): Promise<void> {
|
|
1428
|
-
|
|
1429
|
-
await fs.
|
|
1584
|
+
const file = installedPluginsFile(root);
|
|
1585
|
+
await fs.ensureDir(path.dirname(file));
|
|
1586
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1587
|
+
try {
|
|
1588
|
+
await fs.writeJson(tmp, registry, { spaces: 2 });
|
|
1589
|
+
if (expectBytes !== undefined) {
|
|
1590
|
+
const current = await readRegistryBytes(file);
|
|
1591
|
+
if (current !== expectBytes) throw new RegistryChangedError(file);
|
|
1592
|
+
}
|
|
1593
|
+
await fs.rename(tmp, file);
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
await fs.remove(tmp).catch(() => {});
|
|
1596
|
+
throw error;
|
|
1597
|
+
}
|
|
1430
1598
|
}
|
|
1431
1599
|
|
|
1432
1600
|
/**
|
|
1433
1601
|
* Get install path for a plugin version in cache
|
|
1602
|
+
*
|
|
1603
|
+
* @param root the plugins directory the cache sits under — see {@link pluginsDir}
|
|
1434
1604
|
*/
|
|
1435
1605
|
function getPluginCachePath(
|
|
1436
1606
|
pluginId: string,
|
|
1437
1607
|
version: string,
|
|
1438
1608
|
marketplace: string,
|
|
1609
|
+
root: string = pluginsDir(),
|
|
1439
1610
|
): string {
|
|
1440
1611
|
const { pluginName } = parsePluginId(pluginId) || {
|
|
1441
1612
|
pluginName: pluginId.split("@")[0],
|
|
1442
1613
|
};
|
|
1443
|
-
return path.join(
|
|
1444
|
-
os.homedir(),
|
|
1445
|
-
".claude",
|
|
1446
|
-
"plugins",
|
|
1447
|
-
"cache",
|
|
1448
|
-
marketplace,
|
|
1449
|
-
pluginName,
|
|
1450
|
-
version,
|
|
1451
|
-
);
|
|
1614
|
+
return path.join(root, "cache", marketplace, pluginName, version);
|
|
1452
1615
|
}
|
|
1453
1616
|
|
|
1454
1617
|
/**
|
|
@@ -1562,3 +1725,451 @@ export async function removeFromInstalledPluginsRegistry(
|
|
|
1562
1725
|
);
|
|
1563
1726
|
}
|
|
1564
1727
|
}
|
|
1728
|
+
|
|
1729
|
+
// ── Stale-load repair ────────────────────────────────────────────────────────
|
|
1730
|
+
|
|
1731
|
+
/**
|
|
1732
|
+
* Rows {@link repairStaleLoad} is allowed to rewrite: THIS repository's own
|
|
1733
|
+
* install records, and nothing else.
|
|
1734
|
+
*
|
|
1735
|
+
* Narrower than the loader's `isRelevantInstallation` in two deliberate ways, and
|
|
1736
|
+
* the difference is the point — relevance is the LOADER's question, this is the
|
|
1737
|
+
* WRITER's:
|
|
1738
|
+
*
|
|
1739
|
+
* - A `user` or `managed` row is relevant to every project on the machine, so
|
|
1740
|
+
* rewriting one moves every project at once. Measured before this filter
|
|
1741
|
+
* existed: repairing one project moved a user-scope row installed from an
|
|
1742
|
+
* unrelated directory forward on its own, while the dialog the user consented
|
|
1743
|
+
* on said "Fix updates this repo's install records". A repair must not exceed
|
|
1744
|
+
* the reach it states, so those rows are never written here. When such a row is
|
|
1745
|
+
* the one that WINS, the repair refuses outright and names the scope — silently
|
|
1746
|
+
* rewriting nothing would be the worse answer of the two.
|
|
1747
|
+
* - A null repository root matches null in the loader (two rootless directories
|
|
1748
|
+
* are relevant to each other there, and this port now says so). It must not
|
|
1749
|
+
* match here: "the same repository" cannot be true of two directories that are
|
|
1750
|
+
* in no repository at all, and treating it as true would have a repair run
|
|
1751
|
+
* from a scratch directory rewrite another scratch directory's rows. Only the
|
|
1752
|
+
* exact same directory qualifies without a root.
|
|
1753
|
+
*/
|
|
1754
|
+
function belongsToThisRepository(
|
|
1755
|
+
entry: InstalledPluginEntry,
|
|
1756
|
+
cwd: string,
|
|
1757
|
+
cwdRoot: string | null,
|
|
1758
|
+
cache?: RepoRootCache,
|
|
1759
|
+
): boolean {
|
|
1760
|
+
if (isMachineWideScope(entry.scope)) return false;
|
|
1761
|
+
if (!entry.projectPath) return false;
|
|
1762
|
+
if (path.resolve(entry.projectPath) === path.resolve(cwd)) return true;
|
|
1763
|
+
if (!cwdRoot) return false;
|
|
1764
|
+
return repoRootForPath(entry.projectPath, cache) === cwdRoot;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/** A plugin whose loader-resolved version is behind a row this repair can rewrite. */
|
|
1768
|
+
export interface StaleLoad {
|
|
1769
|
+
pluginId: string;
|
|
1770
|
+
/** What Claude Code resolves in this directory. */
|
|
1771
|
+
loaded: string;
|
|
1772
|
+
/** The newest version a row relevant to this directory holds — the target. */
|
|
1773
|
+
target: string;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* Which plugins {@link repairStaleLoad} can fix here, and to what version.
|
|
1778
|
+
*
|
|
1779
|
+
* REGISTRY ONLY — no catalog, no settings, no `git` subprocess. That is not a
|
|
1780
|
+
* shortcut, it is the question: this function's answer is the repair's own
|
|
1781
|
+
* PRECONDITION, and the repair rewrites registry rows, so the registry is the
|
|
1782
|
+
* only authority that can answer it. It therefore works offline and costs
|
|
1783
|
+
* nothing, which is what lets the TUI ask it during startup before any plugin
|
|
1784
|
+
* data or marketplace catalog has loaded.
|
|
1785
|
+
*
|
|
1786
|
+
* REPORTS ONLY WHAT IS ACTUALLY REPAIRABLE — with the one stated exception at the
|
|
1787
|
+
* end of this comment — and every exclusion below is a measured one. The first version of this function counted 8 broken plugins on
|
|
1788
|
+
* the reporting machine where the Plugins header counted 6, and offered to fix
|
|
1789
|
+
* all 8. The two extras could not be fixed by anything:
|
|
1790
|
+
*
|
|
1791
|
+
* - `plugin-dev@claude-plugins-official` recorded the version string
|
|
1792
|
+
* `"unknown"`. There is no cache directory named `unknown`, so
|
|
1793
|
+
* `repairStaleLoad` would reject it — the popup was offering a fix that
|
|
1794
|
+
* cannot run.
|
|
1795
|
+
* - `skill-creator@claude-plugins-official` held two differing commit shas.
|
|
1796
|
+
* `isBehind` calls those "behind" by design, but moving rows forward between
|
|
1797
|
+
* two unordered shas is a guess rather than a repair.
|
|
1798
|
+
*
|
|
1799
|
+
* So the version test is {@link isRepairablyBehind} — the SAME predicate
|
|
1800
|
+
* `classifyPlugin` uses for its `stale-load` state, which is what makes the
|
|
1801
|
+
* popup's count and the Plugins header's count the same question rather than two
|
|
1802
|
+
* that happen to agree. And the target's cache directory must exist, which is
|
|
1803
|
+
* `repairStaleLoad`'s own refusal asked in advance: a finding nothing can act on
|
|
1804
|
+
* does not belong in a popup that offers to act.
|
|
1805
|
+
*
|
|
1806
|
+
* ONE refusal is deliberately NOT asked in advance: a stale load whose winning
|
|
1807
|
+
* row is machine-wide (`user` or `managed`) is still reported here. It is a real
|
|
1808
|
+
* stale load — this directory runs old code — and the Plugins header counts it,
|
|
1809
|
+
* since the classifier sees versions and not scopes. Dropping it from the popup
|
|
1810
|
+
* to keep "everything I name is fixable" would mean the startup surface staying
|
|
1811
|
+
* silent about a session running a version nobody installed, which is the
|
|
1812
|
+
* deception this whole state exists to end. `repairStaleLoad` refuses it by name
|
|
1813
|
+
* and the fix summary prints that reason per plugin, so pressing Fix reports what
|
|
1814
|
+
* to do instead rather than a bare failure.
|
|
1815
|
+
*
|
|
1816
|
+
* Deliberately NOT the same question as `magus doctor`'s plugin-loads section,
|
|
1817
|
+
* which reports what MAGUS RECORDS as installed against what loads. That one is
|
|
1818
|
+
* a report and may legitimately name a mismatch nothing can act on.
|
|
1819
|
+
*
|
|
1820
|
+
* @param pins the active profile's pins — `pluginId -> exact version | "latest"`,
|
|
1821
|
+
* the SAME map `classifyPlugin` is given, so the popup and the Plugins header
|
|
1822
|
+
* cap their targets identically. An id the map does not mention is "latest",
|
|
1823
|
+
* which is also what `pinForRow` assumes for an installed-but-undeclared plugin.
|
|
1824
|
+
* The default `{}` therefore means "latest for everything".
|
|
1825
|
+
*
|
|
1826
|
+
* It is the one non-registry input, and it earns its place: without it a
|
|
1827
|
+
* user-scope row at 0.5.0 made this offer to move a plugin pinned to 0.3.0 —
|
|
1828
|
+
* installed at 0.3.0 here and loading it — up to 0.5.0, and the repair
|
|
1829
|
+
* succeeded. A machine-wide row is relevant to every directory, so the pin is
|
|
1830
|
+
* the only thing that can say the move is unwanted. Reading it costs one local
|
|
1831
|
+
* manifest read (`loadActiveClosure`), no network, so the offline promise holds.
|
|
1832
|
+
*/
|
|
1833
|
+
export async function findStaleLoads(
|
|
1834
|
+
projectPath: string,
|
|
1835
|
+
root: string = pluginsDir(),
|
|
1836
|
+
pins: Readonly<Record<string, string>> = {},
|
|
1837
|
+
): Promise<StaleLoad[]> {
|
|
1838
|
+
const registry = await readInstalledPluginsRegistry(root);
|
|
1839
|
+
// One cache for the whole pass — the same project paths repeat across every
|
|
1840
|
+
// plugin's rows, and each miss walks the tree to the filesystem root.
|
|
1841
|
+
const cache: RepoRootCache = new Map();
|
|
1842
|
+
const cwdRoot = repoRootForPath(projectPath, cache);
|
|
1843
|
+
const out: StaleLoad[] = [];
|
|
1844
|
+
|
|
1845
|
+
for (const [pluginId, rows] of Object.entries(registry.plugins ?? {})) {
|
|
1846
|
+
if (!Array.isArray(rows) || rows.length === 0) continue;
|
|
1847
|
+
const relevant = rows.filter((row) =>
|
|
1848
|
+
isRelevantInstallation(row, projectPath, cwdRoot, cache),
|
|
1849
|
+
);
|
|
1850
|
+
if (relevant.length === 0) continue;
|
|
1851
|
+
|
|
1852
|
+
// The newest version any relevant row holds THAT THE PIN PERMITS — by the
|
|
1853
|
+
// same ordered comparison, so a set of unrankable ids keeps the incumbent
|
|
1854
|
+
// instead of crowning one arbitrarily. The plugin is dropped below in that
|
|
1855
|
+
// case anyway.
|
|
1856
|
+
//
|
|
1857
|
+
// An exact pin is a ceiling, not a starting point: a row above it (usually
|
|
1858
|
+
// the machine-wide one) must not become the version this repository is moved
|
|
1859
|
+
// to. A version that cannot be ORDERED against the pin is excluded for the
|
|
1860
|
+
// same reason `isRepairablyBehind` excludes it — "cannot tell" never
|
|
1861
|
+
// licenses a rewrite.
|
|
1862
|
+
const pin = pins[pluginId] ?? "latest";
|
|
1863
|
+
const permitted = (version: string): boolean =>
|
|
1864
|
+
pin === "latest" || (compareVersions(pin, version) ?? -1) >= 0;
|
|
1865
|
+
let target: string | null = null;
|
|
1866
|
+
for (const row of relevant) {
|
|
1867
|
+
if (!row.version) continue;
|
|
1868
|
+
if (!permitted(row.version)) continue;
|
|
1869
|
+
if (target === null || isRepairablyBehind(row.version, target)) {
|
|
1870
|
+
target = row.version;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
if (!target) continue;
|
|
1874
|
+
|
|
1875
|
+
const loaded = pickLoadedEntry(rows, projectPath, cache)?.version ?? null;
|
|
1876
|
+
if (!isRepairablyBehind(target, loaded)) continue;
|
|
1877
|
+
|
|
1878
|
+
// `repairStaleLoad`'s cache-directory refusal, asked before offering the
|
|
1879
|
+
// fix instead of after accepting it.
|
|
1880
|
+
const parsed = parsePluginId(pluginId);
|
|
1881
|
+
if (!parsed) continue;
|
|
1882
|
+
if (
|
|
1883
|
+
!(await fs.pathExists(
|
|
1884
|
+
getPluginCachePath(pluginId, target, parsed.marketplace, root),
|
|
1885
|
+
))
|
|
1886
|
+
) {
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
out.push({ pluginId, loaded: loaded as string, target });
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
out.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
1894
|
+
return out;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Repair every plugin {@link findStaleLoads} reports, one at a time.
|
|
1899
|
+
*
|
|
1900
|
+
* The same `repairStaleLoad` the apply engine calls per plan item, so the
|
|
1901
|
+
* startup popup and the Plugins screen's `a` key run identical work — there is
|
|
1902
|
+
* one repair, reached two ways, not two repairs.
|
|
1903
|
+
*
|
|
1904
|
+
* @param pins passed straight through to {@link findStaleLoads}, so the fix moves
|
|
1905
|
+
* rows to exactly the versions the survey named. Handing the survey a pin map
|
|
1906
|
+
* and the repair a different one is how a dialog would offer one thing and do
|
|
1907
|
+
* another.
|
|
1908
|
+
*/
|
|
1909
|
+
export async function repairAllStaleLoads(
|
|
1910
|
+
projectPath: string,
|
|
1911
|
+
root: string = pluginsDir(),
|
|
1912
|
+
pins: Readonly<Record<string, string>> = {},
|
|
1913
|
+
): Promise<StaleLoadRepairResult[]> {
|
|
1914
|
+
const out: StaleLoadRepairResult[] = [];
|
|
1915
|
+
// Sequential, not Promise.all: every repair reads, mutates and rewrites the
|
|
1916
|
+
// SAME file, so running two concurrently means the second overwrites the
|
|
1917
|
+
// first's rows from a snapshot taken before them.
|
|
1918
|
+
for (const broken of await findStaleLoads(projectPath, root, pins)) {
|
|
1919
|
+
out.push(
|
|
1920
|
+
await repairStaleLoad(broken.pluginId, broken.target, projectPath, root),
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
return out;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* What one {@link repairStaleLoad} attempt did, and whether it worked.
|
|
1928
|
+
*
|
|
1929
|
+
* `ok` is the POST-WRITE assertion, not "the write succeeded". Rewriting rows
|
|
1930
|
+
* and reporting success without re-asking the loader is how a repair that
|
|
1931
|
+
* changed nothing would read as a fix — the exact failure mode that made the
|
|
1932
|
+
* `stale-load` finding necessary in the first place.
|
|
1933
|
+
*/
|
|
1934
|
+
export interface StaleLoadRepairResult {
|
|
1935
|
+
pluginId: string;
|
|
1936
|
+
/** The version every stale row was moved forward to. */
|
|
1937
|
+
target: string;
|
|
1938
|
+
/** Rows rewritten. Zero when no relevant row was behind the target. */
|
|
1939
|
+
rewritten: number;
|
|
1940
|
+
/** The backup taken before the first write; null when nothing was written. */
|
|
1941
|
+
backup: string | null;
|
|
1942
|
+
/** What the loader resolves here AFTER the write. */
|
|
1943
|
+
loaded: string | null;
|
|
1944
|
+
/** True only when `loaded` is now `target`. */
|
|
1945
|
+
ok: boolean;
|
|
1946
|
+
/** Why it failed. Absent when `ok`. */
|
|
1947
|
+
error?: string;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Make Claude Code's loader resolve `target` for `pluginId` in `projectPath`, by
|
|
1952
|
+
* moving this repository's stale registry rows FORWARD onto it.
|
|
1953
|
+
*
|
|
1954
|
+
* ── Why this edits installed_plugins.json directly ──
|
|
1955
|
+
*
|
|
1956
|
+
* The project's CLAUDE.md forbids writing `installed_plugins.json`: it is
|
|
1957
|
+
* Claude Code-owned state, and everything else goes through a `claude plugin`
|
|
1958
|
+
* command. This function is a DELIBERATE exception, and the user approved it,
|
|
1959
|
+
* because no `claude plugin` command can reach the rows that cause this bug.
|
|
1960
|
+
*
|
|
1961
|
+
* The loader takes the first row relevant to this repository whose `installPath`
|
|
1962
|
+
* exists (`pickLoadedEntry`, services/plugin-resolution.ts). "Relevant" includes
|
|
1963
|
+
* any row installed ANYWHERE in the same repository — including worktrees that
|
|
1964
|
+
* have since been deleted, whose rows nothing prunes. Measured on the reporting
|
|
1965
|
+
* machine, the winning rows belonged to other worktrees, several of them gone.
|
|
1966
|
+
* `claude plugin install` keys on the working directory, so it writes ANOTHER
|
|
1967
|
+
* row for this project and leaves the winner untouched; `claude plugin uninstall
|
|
1968
|
+
* --scope project` resolves the project from the working directory too, so it
|
|
1969
|
+
* cannot name a directory that no longer exists. A reinstall is measured not to
|
|
1970
|
+
* clear this. There is no CLI path to those rows at all.
|
|
1971
|
+
*
|
|
1972
|
+
* ── The rules this obeys ──
|
|
1973
|
+
*
|
|
1974
|
+
* MOVE FORWARD ONLY, DELETE NOTHING — the same promise `magus update` makes.
|
|
1975
|
+
* Rows are rewritten in place, so the array order the loader's tie-break depends
|
|
1976
|
+
* on is preserved, and a row belonging to a deleted worktree is moved forward
|
|
1977
|
+
* like any other. That is not collateral damage: it is the point, since that row
|
|
1978
|
+
* is usually the winner.
|
|
1979
|
+
*
|
|
1980
|
+
* A row is rewritten only when {@link belongsToThisRepository} accepts it AND
|
|
1981
|
+
* `isRepairablyBehind` says its version is ORDERED behind `target`. A row ahead
|
|
1982
|
+
* of the target, and one that cannot be ranked against it at all — two commit
|
|
1983
|
+
* shas, a `"unknown"` version — is left exactly as it is. "Cannot compare" is
|
|
1984
|
+
* never a licence to overwrite; the same predicate is what {@link findStaleLoads}
|
|
1985
|
+
* and the Plugins header ask, so nothing offers a repair this would then decline
|
|
1986
|
+
* to make.
|
|
1987
|
+
*
|
|
1988
|
+
* THIS REPOSITORY ONLY, which is what the dialog offering the fix promises. The
|
|
1989
|
+
* relevance filter the LOADER uses counts `user` and `managed` rows for every
|
|
1990
|
+
* project on the machine, and rewriting through it moved a machine-wide record
|
|
1991
|
+
* from an unrelated directory forward — measured. `belongsToThisRepository` is
|
|
1992
|
+
* the writer's narrower question; `pickLoadedEntry` keeps modelling the loader
|
|
1993
|
+
* faithfully, machine-wide relevance included, because a repair that misjudges
|
|
1994
|
+
* what loads is worse than one that declines to write.
|
|
1995
|
+
*
|
|
1996
|
+
* WHEN THE MACHINE-WIDE ROW IS THE WINNER, this refuses and says so, naming the
|
|
1997
|
+
* scope and the action that would fix it. The alternatives were both worse:
|
|
1998
|
+
* rewriting it exceeds the stated reach, and writing this repository's rows
|
|
1999
|
+
* anyway would change nothing the loader reads and then report `ok: false` from
|
|
2000
|
+
* the post-write assertion with no explanation of why. `magus update --check`
|
|
2001
|
+
* stays red for such a plugin until the user acts on that scope — the state is
|
|
2002
|
+
* real, and the one thing that must not happen is reporting it green.
|
|
2003
|
+
*
|
|
2004
|
+
* NOTHING IS WRITTEN OVER A FILE THAT MOVED. The snapshot is taken as bytes and
|
|
2005
|
+
* handed back to `writeInstalledPluginsRegistry` as a precondition, so a
|
|
2006
|
+
* concurrent `claude plugin install` makes this refuse rather than discard that
|
|
2007
|
+
* session's row. Dozens of sessions write this file on a working machine.
|
|
2008
|
+
*
|
|
2009
|
+
* @param root the plugins directory holding the registry and cache — see
|
|
2010
|
+
* {@link pluginsDir}. Tests pass a temp directory; nothing else should.
|
|
2011
|
+
*/
|
|
2012
|
+
export async function repairStaleLoad(
|
|
2013
|
+
pluginId: string,
|
|
2014
|
+
target: string,
|
|
2015
|
+
projectPath: string,
|
|
2016
|
+
root: string = pluginsDir(),
|
|
2017
|
+
): Promise<StaleLoadRepairResult> {
|
|
2018
|
+
const fail = (error: string): StaleLoadRepairResult => ({
|
|
2019
|
+
pluginId,
|
|
2020
|
+
target,
|
|
2021
|
+
rewritten: 0,
|
|
2022
|
+
backup: null,
|
|
2023
|
+
loaded: null,
|
|
2024
|
+
ok: false,
|
|
2025
|
+
error,
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
const parsed = parsePluginId(pluginId);
|
|
2029
|
+
if (!parsed) return fail(`not a plugin id: ${pluginId}`);
|
|
2030
|
+
|
|
2031
|
+
// Refuse a target with no cache directory. Writing that row would leave the
|
|
2032
|
+
// registry naming a directory that is not there — the loader would resolve
|
|
2033
|
+
// it, fail to load anything, and the state would be worse than the stale load
|
|
2034
|
+
// it replaced. The installed version's cache always exists, which is why the
|
|
2035
|
+
// target is what is installed here rather than what the catalog offers.
|
|
2036
|
+
const cacheDir = getPluginCachePath(
|
|
2037
|
+
pluginId,
|
|
2038
|
+
target,
|
|
2039
|
+
parsed.marketplace,
|
|
2040
|
+
root,
|
|
2041
|
+
);
|
|
2042
|
+
if (!(await fs.pathExists(cacheDir))) {
|
|
2043
|
+
return fail(
|
|
2044
|
+
`no cache directory for v${target} — ${cacheDir} does not exist`,
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const file = installedPluginsFile(root);
|
|
2049
|
+
// The BYTES first, and the snapshot parsed from those same bytes. They are the
|
|
2050
|
+
// precondition the write is checked against, so they have to describe one
|
|
2051
|
+
// moment — see `writeInstalledPluginsRegistry`.
|
|
2052
|
+
const before = await readRegistryBytes(file);
|
|
2053
|
+
const registry = parseRegistryBytes(before);
|
|
2054
|
+
if (!registry) {
|
|
2055
|
+
return fail(
|
|
2056
|
+
before === null
|
|
2057
|
+
? `${file} does not exist`
|
|
2058
|
+
: `${file} is not a readable registry — refusing to write over it`,
|
|
2059
|
+
);
|
|
2060
|
+
}
|
|
2061
|
+
const rows = registry.plugins[pluginId];
|
|
2062
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
2063
|
+
return fail(`${pluginId} has no rows in ${file}`);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// One cache for the whole pass: the same project paths repeat across rows,
|
|
2067
|
+
// and each miss walks the tree to the filesystem root.
|
|
2068
|
+
const cache: RepoRootCache = new Map();
|
|
2069
|
+
const cwdRoot = repoRootForPath(projectPath, cache);
|
|
2070
|
+
|
|
2071
|
+
// The row the loader picks here, asked BEFORE anything is written. When that
|
|
2072
|
+
// row is machine-wide, no rewrite this function is willing to make can change
|
|
2073
|
+
// the resolution, so it says so instead of writing rows that will not help —
|
|
2074
|
+
// and instead of reaching machine-wide, which is what it used to do.
|
|
2075
|
+
const winner = pickLoadedEntry(rows, projectPath, cache);
|
|
2076
|
+
if (
|
|
2077
|
+
winner &&
|
|
2078
|
+
isMachineWideScope(winner.scope) &&
|
|
2079
|
+
isRepairablyBehind(target, winner.version)
|
|
2080
|
+
) {
|
|
2081
|
+
// Widened: Claude Code writes a `managed` scope for policy-installed
|
|
2082
|
+
// plugins, which the registry type in types/index.ts does not name — see
|
|
2083
|
+
// `LoaderScope` in plugin-resolution.ts.
|
|
2084
|
+
const scope: string = winner.scope;
|
|
2085
|
+
return fail(
|
|
2086
|
+
`the row Claude Code loads here is the machine-wide ${scope}-scope record at v${
|
|
2087
|
+
winner.version
|
|
2088
|
+
}, which every project on this machine resolves through. This repair only rewrites THIS repository's records, so it will not move it. ${
|
|
2089
|
+
scope === "managed"
|
|
2090
|
+
? "A managed record is administrator policy — raise it with whoever set that policy."
|
|
2091
|
+
: `Update that scope directly instead: claude plugin install ${pluginId} --scope user`
|
|
2092
|
+
}`,
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
const stale = rows.filter(
|
|
2097
|
+
(row) =>
|
|
2098
|
+
belongsToThisRepository(row, projectPath, cwdRoot, cache) &&
|
|
2099
|
+
isRepairablyBehind(target, row.version),
|
|
2100
|
+
);
|
|
2101
|
+
|
|
2102
|
+
let backup: string | null = null;
|
|
2103
|
+
if (stale.length > 0) {
|
|
2104
|
+
// BEFORE the first mutation, and a failure here aborts the repair. This
|
|
2105
|
+
// file is the only record of what every project on the machine has
|
|
2106
|
+
// installed; an edit with no undo is not one to offer.
|
|
2107
|
+
try {
|
|
2108
|
+
backup = await registryBackupPath(file, pluginId);
|
|
2109
|
+
await fs.copy(file, backup);
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
return fail(
|
|
2112
|
+
`could not back up ${file}: ${
|
|
2113
|
+
error instanceof Error ? error.message : String(error)
|
|
2114
|
+
}`,
|
|
2115
|
+
);
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
const now = new Date().toISOString();
|
|
2119
|
+
for (const row of stale) {
|
|
2120
|
+
row.version = target;
|
|
2121
|
+
row.installPath = cacheDir;
|
|
2122
|
+
row.lastUpdated = now;
|
|
2123
|
+
// The recorded commit describes the version we just moved AWAY from, so
|
|
2124
|
+
// keeping it would have content-drift diff two different releases and
|
|
2125
|
+
// report every repaired plugin as "stale — reinstall". Unknown
|
|
2126
|
+
// provenance is `undefined`, which `hasContentDrift` answers false for.
|
|
2127
|
+
row.gitCommitSha = undefined;
|
|
2128
|
+
}
|
|
2129
|
+
try {
|
|
2130
|
+
// `before` makes this a compare-and-set: another session's write landing
|
|
2131
|
+
// since the read means this snapshot would silently drop it, so the write
|
|
2132
|
+
// is refused instead. Nothing was changed at that point — the rows were
|
|
2133
|
+
// mutated in memory only — so the backup just taken is an undo for a file
|
|
2134
|
+
// nobody touched, and it goes rather than accumulating.
|
|
2135
|
+
await writeInstalledPluginsRegistry(registry, root, before);
|
|
2136
|
+
} catch (error) {
|
|
2137
|
+
if (backup) await fs.remove(backup).catch(() => {});
|
|
2138
|
+
return fail(
|
|
2139
|
+
error instanceof RegistryChangedError
|
|
2140
|
+
? error.message
|
|
2141
|
+
: `could not write ${file}: ${
|
|
2142
|
+
error instanceof Error ? error.message : String(error)
|
|
2143
|
+
}`,
|
|
2144
|
+
);
|
|
2145
|
+
}
|
|
2146
|
+
await pruneRegistryBackups(file);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// Re-read from DISK, not from the object just mutated: the assertion has to
|
|
2150
|
+
// judge the file the loader will read, which also proves the write landed.
|
|
2151
|
+
const after = await readInstalledPluginsRegistry(root);
|
|
2152
|
+
const loaded =
|
|
2153
|
+
pickLoadedEntry(after.plugins[pluginId], projectPath)?.version ?? null;
|
|
2154
|
+
if (loaded === target) {
|
|
2155
|
+
return {
|
|
2156
|
+
pluginId,
|
|
2157
|
+
target,
|
|
2158
|
+
rewritten: stale.length,
|
|
2159
|
+
backup,
|
|
2160
|
+
loaded,
|
|
2161
|
+
ok: true,
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
return {
|
|
2165
|
+
pluginId,
|
|
2166
|
+
target,
|
|
2167
|
+
rewritten: stale.length,
|
|
2168
|
+
backup,
|
|
2169
|
+
loaded,
|
|
2170
|
+
ok: false,
|
|
2171
|
+
error: `the loader still resolves ${
|
|
2172
|
+
loaded ? `v${loaded}` : "nothing"
|
|
2173
|
+
} here, not v${target}`,
|
|
2174
|
+
};
|
|
2175
|
+
}
|