magus-cli 7.5.1 → 7.5.2
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.5.
|
|
3
|
+
"version": "7.5.2",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"typescript": "^5.6.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"magus-cli-darwin-arm64": "7.5.
|
|
69
|
-
"magus-cli-darwin-x64": "7.5.
|
|
70
|
-
"magus-cli-linux-x64": "7.5.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.5.2",
|
|
69
|
+
"magus-cli-darwin-x64": "7.5.2",
|
|
70
|
+
"magus-cli-linux-x64": "7.5.2"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -1180,3 +1180,163 @@ describe("B4: an update brings the loader forward in the same run", () => {
|
|
|
1180
1180
|
expect(await backups()).toEqual([]);
|
|
1181
1181
|
});
|
|
1182
1182
|
});
|
|
1183
|
+
|
|
1184
|
+
describe("7.5.2: Claude Code 2.1.281 updates another project's row of the repository", () => {
|
|
1185
|
+
/**
|
|
1186
|
+
* `claude plugin update --scope project` as 2.1.281 does it: the FIRST row
|
|
1187
|
+
* relevant to the repository moves to `version` — here, the main tree's —
|
|
1188
|
+
* and this worktree's own row stays where it was.
|
|
1189
|
+
*/
|
|
1190
|
+
async function cli281(version: string): Promise<void> {
|
|
1191
|
+
const registry = await readRegistry();
|
|
1192
|
+
const rows = registry.plugins[PLUGIN];
|
|
1193
|
+
const first = pickLoadedEntry(rows, here) as InstalledPluginEntry;
|
|
1194
|
+
first.version = version;
|
|
1195
|
+
first.installPath = cacheFor(version);
|
|
1196
|
+
await fs.outputJson(registryFile, registry, { spaces: 2 });
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** The real registry, rewrite and loader question, against this root. */
|
|
1200
|
+
function deps(version: string): PluginApplyDeps {
|
|
1201
|
+
return {
|
|
1202
|
+
install: async () => {},
|
|
1203
|
+
update: async () => cli281(version),
|
|
1204
|
+
repair: async () => {},
|
|
1205
|
+
readInstalled: async () =>
|
|
1206
|
+
(await readRegistry()).plugins[PLUGIN].find(
|
|
1207
|
+
(e) => e.projectPath === here,
|
|
1208
|
+
)?.version ?? null,
|
|
1209
|
+
saveInstalled: async () => {},
|
|
1210
|
+
registryRows: async () => (await readRegistry()).plugins[PLUGIN],
|
|
1211
|
+
repairStaleLoad: (id, target, projectPath) =>
|
|
1212
|
+
repairStaleLoad(id, target, projectPath, root),
|
|
1213
|
+
loadedVersion: (id, projectPath) =>
|
|
1214
|
+
loadedVersionHere(id, projectPath, root),
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const item = (target: string) => ({
|
|
1219
|
+
pluginId: PLUGIN,
|
|
1220
|
+
pinned: "latest",
|
|
1221
|
+
installed: "0.4.0",
|
|
1222
|
+
available: target,
|
|
1223
|
+
target,
|
|
1224
|
+
action: "update" as const,
|
|
1225
|
+
reason: "behind" as const,
|
|
1226
|
+
scopes: ["project" as const],
|
|
1227
|
+
beforeVersions: { project: "0.4.0" },
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
function collect() {
|
|
1231
|
+
const details: string[] = [];
|
|
1232
|
+
return {
|
|
1233
|
+
details,
|
|
1234
|
+
reporter: {
|
|
1235
|
+
begin: () => {},
|
|
1236
|
+
finish: (_ok: boolean, _name: string, detail: string) =>
|
|
1237
|
+
details.push(detail),
|
|
1238
|
+
detach: <T>(work: () => T) => work(),
|
|
1239
|
+
},
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
test("7.5.2: this worktree's row follows to the version the CLI fetched, and the update says where it wrote", async () => {
|
|
1244
|
+
await writeRegistry([
|
|
1245
|
+
row({ version: "0.4.0", projectPath: mainTree }),
|
|
1246
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
1247
|
+
]);
|
|
1248
|
+
const { details, reporter } = collect();
|
|
1249
|
+
|
|
1250
|
+
const result = await applyPlugins(
|
|
1251
|
+
[item("0.5.0")],
|
|
1252
|
+
here,
|
|
1253
|
+
deps("0.5.0"),
|
|
1254
|
+
reporter,
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
expect(result.ok).toBe(1);
|
|
1258
|
+
expect(result.unchanged).toEqual([]);
|
|
1259
|
+
expect(
|
|
1260
|
+
(await readRegistry()).plugins[PLUGIN].map((e) => [
|
|
1261
|
+
e.projectPath,
|
|
1262
|
+
e.version,
|
|
1263
|
+
]),
|
|
1264
|
+
).toEqual([
|
|
1265
|
+
[mainTree, "0.5.0"],
|
|
1266
|
+
[here, "0.5.0"],
|
|
1267
|
+
]);
|
|
1268
|
+
expect(details).toEqual([
|
|
1269
|
+
`project → 0.5.0 · Claude Code updated ${mainTree} instead of this project — moved this project's record to v0.5.0 too`,
|
|
1270
|
+
]);
|
|
1271
|
+
expect(await loadedVersionHere(PLUGIN, here, root)).toBe("0.5.0");
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
test("7.5.2 review: a row of an UNRELATED repository moving during the update is not followed", async () => {
|
|
1275
|
+
const other = path.join(base, "unrelated-752");
|
|
1276
|
+
await fs.ensureDir(other);
|
|
1277
|
+
git(other, "init", "-q");
|
|
1278
|
+
await writeRegistry([
|
|
1279
|
+
row({ version: "0.4.0", projectPath: other }),
|
|
1280
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
1281
|
+
]);
|
|
1282
|
+
const { details, reporter } = collect();
|
|
1283
|
+
const unrelated: PluginApplyDeps = {
|
|
1284
|
+
...deps("0.5.0"),
|
|
1285
|
+
// Another repository's session updates its own row meanwhile.
|
|
1286
|
+
update: async () => {
|
|
1287
|
+
const registry = await readRegistry();
|
|
1288
|
+
const moved = registry.plugins[PLUGIN].find(
|
|
1289
|
+
(e) => e.projectPath === other,
|
|
1290
|
+
) as InstalledPluginEntry;
|
|
1291
|
+
moved.version = "0.5.0";
|
|
1292
|
+
moved.installPath = cacheFor("0.5.0");
|
|
1293
|
+
await fs.outputJson(registryFile, registry, { spaces: 2 });
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
const result = await applyPlugins(
|
|
1298
|
+
[item("0.5.0")],
|
|
1299
|
+
here,
|
|
1300
|
+
unrelated,
|
|
1301
|
+
reporter,
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
expect(result.unchanged).toEqual([PLUGIN]);
|
|
1305
|
+
expect(
|
|
1306
|
+
(await readRegistry()).plugins[PLUGIN].map((e) => [
|
|
1307
|
+
e.projectPath,
|
|
1308
|
+
e.version,
|
|
1309
|
+
]),
|
|
1310
|
+
).toEqual([
|
|
1311
|
+
[other, "0.5.0"],
|
|
1312
|
+
[here, "0.4.0"],
|
|
1313
|
+
]);
|
|
1314
|
+
expect(details).toEqual([
|
|
1315
|
+
`no change — the installer exited 0 but ${PLUGIN} is still 0.4.0`,
|
|
1316
|
+
]);
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
test("7.5.2: a version with no cache directory is not followed — no change, and why", async () => {
|
|
1320
|
+
await writeRegistry([
|
|
1321
|
+
row({ version: "0.4.0", projectPath: mainTree }),
|
|
1322
|
+
row({ version: "0.4.0", projectPath: here }),
|
|
1323
|
+
]);
|
|
1324
|
+
const { details, reporter } = collect();
|
|
1325
|
+
|
|
1326
|
+
const result = await applyPlugins(
|
|
1327
|
+
[item("0.6.0")],
|
|
1328
|
+
here,
|
|
1329
|
+
deps("0.6.0"),
|
|
1330
|
+
reporter,
|
|
1331
|
+
);
|
|
1332
|
+
|
|
1333
|
+
expect(result.unchanged).toEqual([PLUGIN]);
|
|
1334
|
+
expect(
|
|
1335
|
+
(await readRegistry()).plugins[PLUGIN].find((e) => e.projectPath === here)
|
|
1336
|
+
?.version,
|
|
1337
|
+
).toBe("0.4.0");
|
|
1338
|
+
expect(details[0]).toContain(
|
|
1339
|
+
`no change — the installer exited 0 but ${PLUGIN} is still 0.4.0 (Claude Code updated ${mainTree} instead of this project, and this project's record could not follow: no cache directory for v0.6.0`,
|
|
1340
|
+
);
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
PluginUpdateItem,
|
|
26
26
|
UpdatePlan,
|
|
27
27
|
} from "../services/update-plan.js";
|
|
28
|
-
import type { ResolvedBin } from "../types/index.js";
|
|
28
|
+
import type { InstalledPluginEntry, ResolvedBin } from "../types/index.js";
|
|
29
29
|
import { noMoveReasons } from "../ui/adapters/pluginsAdapter.js";
|
|
30
30
|
|
|
31
31
|
const PROJECT = "/tmp/project";
|
|
@@ -925,3 +925,199 @@ describe("B4: an install or update ends with the loader on the new version", ()
|
|
|
925
925
|
).rejects.toThrow("the loader still resolves v1.0.0 here, not v2.0.0");
|
|
926
926
|
});
|
|
927
927
|
});
|
|
928
|
+
|
|
929
|
+
// ── 7.5.2: Claude Code 2.1.281 updates ANOTHER project's row ─────────────────
|
|
930
|
+
//
|
|
931
|
+
// `claude plugin update P --scope project` under 2.1.281 moves the repository's
|
|
932
|
+
// FIRST relevant registry row — the one its loader would pick — not this
|
|
933
|
+
// project's. Measured 2026-09-24, same registry, cwd = the project:
|
|
934
|
+
// 2.1.280 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src)
|
|
935
|
+
// 2.1.281 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src/tools/claudeup)
|
|
936
|
+
// This project's row stayed at 8.0.0, and `magus update` said "no change".
|
|
937
|
+
|
|
938
|
+
describe("7.5.2: an update Claude Code applied to another project's row", () => {
|
|
939
|
+
const HERE = "/tmp/project";
|
|
940
|
+
const OTHER = "/tmp/project/tools/claudeup";
|
|
941
|
+
const UNRELATED = "/tmp/another-repository";
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* A registry for dev@magus — another project's row of the same repository
|
|
945
|
+
* first, this project's second — and a fake Claude CLI that updates either
|
|
946
|
+
* the first relevant row (`"2.1.281"`) or this project's (`"2.1.280"`).
|
|
947
|
+
*/
|
|
948
|
+
function registry(
|
|
949
|
+
cli: "2.1.280" | "2.1.281" | "unrelated" | "other-version",
|
|
950
|
+
cached: string[] = ["8.1.3", "8.5.0"],
|
|
951
|
+
) {
|
|
952
|
+
const rows: InstalledPluginEntry[] = [
|
|
953
|
+
{
|
|
954
|
+
scope: "project",
|
|
955
|
+
projectPath: OTHER,
|
|
956
|
+
version: "8.0.0",
|
|
957
|
+
} as InstalledPluginEntry,
|
|
958
|
+
{
|
|
959
|
+
scope: "project",
|
|
960
|
+
projectPath: HERE,
|
|
961
|
+
version: "8.0.0",
|
|
962
|
+
} as InstalledPluginEntry,
|
|
963
|
+
{
|
|
964
|
+
scope: "project",
|
|
965
|
+
projectPath: UNRELATED,
|
|
966
|
+
version: "8.0.0",
|
|
967
|
+
} as InstalledPluginEntry,
|
|
968
|
+
];
|
|
969
|
+
const repaired: string[] = [];
|
|
970
|
+
const saved: string[] = [];
|
|
971
|
+
const row = (projectPath: string) =>
|
|
972
|
+
rows.find((r) => r.projectPath === projectPath) as InstalledPluginEntry;
|
|
973
|
+
const deps: PluginApplyDeps = {
|
|
974
|
+
install: async () => {},
|
|
975
|
+
update: async () => {
|
|
976
|
+
if (cli === "2.1.280") row(HERE).version = "8.1.3";
|
|
977
|
+
if (cli === "2.1.281") row(OTHER).version = "8.1.3";
|
|
978
|
+
// Another repository's row moving while the CLI runs.
|
|
979
|
+
if (cli === "unrelated") row(UNRELATED).version = "8.1.3";
|
|
980
|
+
// This repository's row, but not to the version this update targets.
|
|
981
|
+
if (cli === "other-version") row(OTHER).version = "8.5.0";
|
|
982
|
+
},
|
|
983
|
+
repair: async () => {},
|
|
984
|
+
readInstalled: async (_id, scope, projectPath) =>
|
|
985
|
+
scope === "project" ? (row(projectPath)?.version ?? null) : null,
|
|
986
|
+
saveInstalled: async (_id, version) => {
|
|
987
|
+
saved.push(version);
|
|
988
|
+
},
|
|
989
|
+
registryRows: async () => rows.map((r) => ({ ...r })),
|
|
990
|
+
// Same repository = this project's tree, as git would answer.
|
|
991
|
+
inThisRepository: (r, projectPath) =>
|
|
992
|
+
(r.projectPath ?? "").startsWith(projectPath),
|
|
993
|
+
// What repairStaleLoad does: refuse an uncached target, else move every
|
|
994
|
+
// row of this repository that is behind it.
|
|
995
|
+
repairStaleLoad: async (pluginId, target) => {
|
|
996
|
+
repaired.push(target);
|
|
997
|
+
if (!cached.includes(target)) {
|
|
998
|
+
return {
|
|
999
|
+
pluginId,
|
|
1000
|
+
target,
|
|
1001
|
+
rewritten: 0,
|
|
1002
|
+
backup: null,
|
|
1003
|
+
loaded: null,
|
|
1004
|
+
ok: false,
|
|
1005
|
+
error: `no cache directory for v${target}`,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
let rewritten = 0;
|
|
1009
|
+
for (const r of rows) {
|
|
1010
|
+
if (r.projectPath?.startsWith(HERE) && r.version !== target) {
|
|
1011
|
+
r.version = target;
|
|
1012
|
+
rewritten++;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
pluginId,
|
|
1017
|
+
target,
|
|
1018
|
+
rewritten,
|
|
1019
|
+
backup: null,
|
|
1020
|
+
loaded: target,
|
|
1021
|
+
ok: true,
|
|
1022
|
+
};
|
|
1023
|
+
},
|
|
1024
|
+
// The loader: the first relevant row.
|
|
1025
|
+
loadedVersion: async () => rows[0]?.version ?? null,
|
|
1026
|
+
};
|
|
1027
|
+
return { rows, deps, repaired, saved, row };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const update = () =>
|
|
1031
|
+
item({
|
|
1032
|
+
installed: "8.0.0",
|
|
1033
|
+
available: "8.1.3",
|
|
1034
|
+
target: "8.1.3",
|
|
1035
|
+
beforeVersions: { project: "8.0.0" },
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("7.5.2: under 2.1.281 this project's row follows to the fetched version, reported as updated", async () => {
|
|
1039
|
+
const { deps, row, repaired, saved } = registry("2.1.281");
|
|
1040
|
+
const { reporter, rows } = recordingReporter();
|
|
1041
|
+
|
|
1042
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1043
|
+
|
|
1044
|
+
expect(row(HERE).version).toBe("8.1.3");
|
|
1045
|
+
expect(row(OTHER).version).toBe("8.1.3"); // the CLI's change stands
|
|
1046
|
+
expect(repaired).toEqual(["8.1.3"]);
|
|
1047
|
+
expect(saved).toEqual(["8.1.3"]);
|
|
1048
|
+
expect(result.ok).toBe(1);
|
|
1049
|
+
expect(result.unchanged).toEqual([]);
|
|
1050
|
+
expect(rows).toEqual([
|
|
1051
|
+
{
|
|
1052
|
+
ok: true,
|
|
1053
|
+
name: "dev@magus",
|
|
1054
|
+
detail: `project → 8.1.3 · Claude Code updated ${OTHER} instead of this project — moved this project's record to v8.1.3 too`,
|
|
1055
|
+
},
|
|
1056
|
+
]);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test("7.5.2: under 2.1.280 the CLI updates this project's row and nothing extra happens", async () => {
|
|
1060
|
+
const { deps, row, repaired } = registry("2.1.280");
|
|
1061
|
+
const { reporter, rows } = recordingReporter();
|
|
1062
|
+
|
|
1063
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1064
|
+
|
|
1065
|
+
expect(row(HERE).version).toBe("8.1.3");
|
|
1066
|
+
expect(result.ok).toBe(1);
|
|
1067
|
+
// Exactly 7.5.1's outcome: the older row ahead of this one is brought
|
|
1068
|
+
// forward by the loader check (B4), and no misdirection is reported.
|
|
1069
|
+
expect(repaired).toEqual(["8.1.3"]);
|
|
1070
|
+
expect(rows[0]?.detail).toBe(
|
|
1071
|
+
"project → 8.1.3 · Claude Code now loads v8.1.3 here (1 older record moved forward)",
|
|
1072
|
+
);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
test("7.5.2: a version the CLI did not cache is not followed — still an honest no change", async () => {
|
|
1076
|
+
const { deps, row } = registry("2.1.281", []);
|
|
1077
|
+
const { reporter, rows } = recordingReporter();
|
|
1078
|
+
|
|
1079
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1080
|
+
|
|
1081
|
+
expect(row(HERE).version).toBe("8.0.0");
|
|
1082
|
+
expect(result.unchanged).toEqual(["dev@magus"]);
|
|
1083
|
+
expect(rows[0]?.detail).toBe(
|
|
1084
|
+
`no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated ${OTHER} instead of this project, and this project's record could not follow: no cache directory for v8.1.3)`,
|
|
1085
|
+
);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test("7.5.2 review: a row of ANOTHER repository moving to the target is not followed", async () => {
|
|
1089
|
+
const { deps, row, repaired } = registry("unrelated");
|
|
1090
|
+
const { reporter, rows } = recordingReporter();
|
|
1091
|
+
|
|
1092
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1093
|
+
|
|
1094
|
+
expect(row(HERE).version).toBe("8.0.0");
|
|
1095
|
+
expect(repaired).toEqual([]);
|
|
1096
|
+
expect(result.unchanged).toEqual(["dev@magus"]);
|
|
1097
|
+
expect(rows[0]?.detail).toBe(
|
|
1098
|
+
"no change — the installer exited 0 but dev@magus is still 8.0.0",
|
|
1099
|
+
);
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
test("7.5.2 review: this repository's row moving to a version other than the target is not followed", async () => {
|
|
1103
|
+
const { deps, row, repaired } = registry("other-version");
|
|
1104
|
+
const { reporter, rows } = recordingReporter();
|
|
1105
|
+
|
|
1106
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1107
|
+
|
|
1108
|
+
expect(row(HERE).version).toBe("8.0.0");
|
|
1109
|
+
expect(repaired).toEqual([]);
|
|
1110
|
+
expect(result.unchanged).toEqual(["dev@magus"]);
|
|
1111
|
+
expect(rows[0]?.detail).toBe(
|
|
1112
|
+
`no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated ${OTHER} instead of this project, and this project's record could not follow: it went to v8.5.0, not the v8.1.3 this update asked for)`,
|
|
1113
|
+
);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test("7.5.2: the TUI's per-scope update key follows it too", async () => {
|
|
1117
|
+
const { deps, row } = registry("2.1.281");
|
|
1118
|
+
expect(await updatePluginInScope("dev@magus", "project", HERE, deps)).toBe(
|
|
1119
|
+
"8.1.3",
|
|
1120
|
+
);
|
|
1121
|
+
expect(row(HERE).version).toBe("8.1.3");
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
@@ -1991,6 +1991,24 @@ function belongsToThisRepository(
|
|
|
1991
1991
|
return repoRootForPath(entry.projectPath, cache) === cwdRoot;
|
|
1992
1992
|
}
|
|
1993
1993
|
|
|
1994
|
+
/**
|
|
1995
|
+
* Is `entry` a row of the repository `projectPath` is in — one
|
|
1996
|
+
* {@link repairStaleLoad} may rewrite? The same predicate, for callers that
|
|
1997
|
+
* judge one row at a time.
|
|
1998
|
+
*/
|
|
1999
|
+
export function rowBelongsToRepository(
|
|
2000
|
+
entry: InstalledPluginEntry,
|
|
2001
|
+
projectPath: string,
|
|
2002
|
+
): boolean {
|
|
2003
|
+
const cache: RepoRootCache = new Map();
|
|
2004
|
+
return belongsToThisRepository(
|
|
2005
|
+
entry,
|
|
2006
|
+
projectPath,
|
|
2007
|
+
repoRootForPath(projectPath, cache),
|
|
2008
|
+
cache,
|
|
2009
|
+
);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
1994
2012
|
/** A plugin whose loader-resolved version is behind a row this repair can rewrite. */
|
|
1995
2013
|
export interface StaleLoad {
|
|
1996
2014
|
pluginId: string;
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import type {
|
|
34
|
+
InstalledPluginEntry,
|
|
34
35
|
ProfileSkillRef,
|
|
35
36
|
ResolvedBin,
|
|
36
37
|
SkillInfo,
|
|
@@ -43,9 +44,12 @@ import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
|
|
|
43
44
|
import {
|
|
44
45
|
type StaleLoadRepairResult,
|
|
45
46
|
loadedVersionHere,
|
|
47
|
+
readInstalledPluginsRegistry,
|
|
46
48
|
repairStaleLoad,
|
|
49
|
+
rowBelongsToRepository,
|
|
47
50
|
} from "./claude-settings.js";
|
|
48
51
|
import {
|
|
52
|
+
compareVersions,
|
|
49
53
|
isRepairablyBehind,
|
|
50
54
|
readInstalledVersionForScope,
|
|
51
55
|
saveInstalledPluginVersionForScope,
|
|
@@ -192,6 +196,21 @@ export interface PluginApplyDeps {
|
|
|
192
196
|
pluginId: string,
|
|
193
197
|
projectPath: string,
|
|
194
198
|
) => Promise<string | null>;
|
|
199
|
+
/**
|
|
200
|
+
* Every registry row for a plugin, read fresh — compared before and after
|
|
201
|
+
* `update` to see WHICH row Claude Code moved (see
|
|
202
|
+
* {@link followMisdirectedUpdate}). Optional so a test double that does not
|
|
203
|
+
* model the registry skips the comparison.
|
|
204
|
+
*/
|
|
205
|
+
registryRows?: (pluginId: string) => Promise<readonly InstalledPluginEntry[]>;
|
|
206
|
+
/**
|
|
207
|
+
* Is a row this project's repository's — the rows the stale-load repair may
|
|
208
|
+
* rewrite. Defaults to that repair's own predicate (`rowBelongsToRepository`).
|
|
209
|
+
*/
|
|
210
|
+
inThisRepository?: (
|
|
211
|
+
row: InstalledPluginEntry,
|
|
212
|
+
projectPath: string,
|
|
213
|
+
) => boolean;
|
|
195
214
|
}
|
|
196
215
|
|
|
197
216
|
export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
@@ -202,8 +221,129 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
|
202
221
|
saveInstalled: saveInstalledPluginVersionForScope,
|
|
203
222
|
repairStaleLoad,
|
|
204
223
|
loadedVersion: loadedVersionHere,
|
|
224
|
+
registryRows: async (pluginId) => {
|
|
225
|
+
const rows = (await readInstalledPluginsRegistry()).plugins?.[pluginId];
|
|
226
|
+
return Array.isArray(rows) ? rows : [];
|
|
227
|
+
},
|
|
205
228
|
};
|
|
206
229
|
|
|
230
|
+
/**
|
|
231
|
+
* An update Claude Code applied to another project's row instead of this one.
|
|
232
|
+
*
|
|
233
|
+
* Claude Code 2.1.281 changed `claude plugin update P --scope project`: it
|
|
234
|
+
* updates the FIRST registry row relevant to the repository — the row its
|
|
235
|
+
* loader would pick — not the row of the project it runs in. Measured
|
|
236
|
+
* 2026-09-24 on two APFS clones of one sandbox, same registry, cwd = the
|
|
237
|
+
* project:
|
|
238
|
+
*
|
|
239
|
+
* 2.1.280 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src)
|
|
240
|
+
* 2.1.281 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src/tools/claudeup)
|
|
241
|
+
*
|
|
242
|
+
* Both exit 0; under 2.1.281 this project's row stays at 8.0.0.
|
|
243
|
+
*/
|
|
244
|
+
export interface MisdirectedUpdate {
|
|
245
|
+
/** Where Claude Code wrote instead: each moved row's project path, or scope. */
|
|
246
|
+
elsewhere: string[];
|
|
247
|
+
/** The version it moved them to. */
|
|
248
|
+
version: string;
|
|
249
|
+
/** Why this project's row did not follow to `version`; null when it did. */
|
|
250
|
+
notFollowed: string | null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One registry row's identity: scope, and the project it belongs to. */
|
|
254
|
+
function rowKey(
|
|
255
|
+
row: Pick<InstalledPluginEntry, "scope" | "projectPath">,
|
|
256
|
+
): string {
|
|
257
|
+
const where = row.projectPath ? path.resolve(row.projectPath) : "";
|
|
258
|
+
return `${row.scope}\u0000${where}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function readRows(
|
|
262
|
+
deps: PluginApplyDeps,
|
|
263
|
+
pluginId: string,
|
|
264
|
+
): Promise<readonly InstalledPluginEntry[] | null> {
|
|
265
|
+
if (!deps.registryRows) return null;
|
|
266
|
+
try {
|
|
267
|
+
return await deps.registryRows(pluginId);
|
|
268
|
+
} catch {
|
|
269
|
+
return null; // No readable registry: nothing to compare.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* After `claude plugin update P --scope <scope>`: if this project's row did not
|
|
275
|
+
* move but another row OF THIS REPOSITORY did — Claude Code 2.1.281 updating
|
|
276
|
+
* the repository's first relevant row instead (see {@link MisdirectedUpdate})
|
|
277
|
+
* — move this project's row forward to that version, with the registry
|
|
278
|
+
* rewrite the stale-load repair performs. That repair refuses a version whose
|
|
279
|
+
* cache directory does not exist.
|
|
280
|
+
*
|
|
281
|
+
* Only rows the repair itself would rewrite count (`inThisRepository`): a row
|
|
282
|
+
* of another repository that happens to move while the CLI runs says nothing
|
|
283
|
+
* about this update. And when the update's `target` is known, the row must
|
|
284
|
+
* have reached exactly that version, or it is not followed.
|
|
285
|
+
*
|
|
286
|
+
* The row Claude Code moved is left as it is: it belongs to this repository,
|
|
287
|
+
* and bringing this repository's rows forward is what the repair does anyway.
|
|
288
|
+
* Null when there was nothing to follow — this project's row moved (2.1.280),
|
|
289
|
+
* or no row of this repository moved at all.
|
|
290
|
+
*/
|
|
291
|
+
async function followMisdirectedUpdate(
|
|
292
|
+
deps: PluginApplyDeps,
|
|
293
|
+
pluginId: string,
|
|
294
|
+
scope: PluginScope,
|
|
295
|
+
projectPath: string,
|
|
296
|
+
before: readonly InstalledPluginEntry[],
|
|
297
|
+
target: string | null,
|
|
298
|
+
): Promise<MisdirectedUpdate | null> {
|
|
299
|
+
const now = await deps.readInstalled(pluginId, scope, projectPath);
|
|
300
|
+
const after = await readRows(deps, pluginId);
|
|
301
|
+
if (!now || !after) return null;
|
|
302
|
+
const inThisRepository = deps.inThisRepository ?? rowBelongsToRepository;
|
|
303
|
+
const was = new Map(before.map((row) => [rowKey(row), row.version]));
|
|
304
|
+
const moved = after.filter(
|
|
305
|
+
(row) =>
|
|
306
|
+
row.version &&
|
|
307
|
+
was.get(rowKey(row)) !== row.version &&
|
|
308
|
+
inThisRepository(row, projectPath),
|
|
309
|
+
);
|
|
310
|
+
const mine = rowKey({
|
|
311
|
+
scope,
|
|
312
|
+
projectPath: scope === "user" ? undefined : projectPath,
|
|
313
|
+
});
|
|
314
|
+
if (moved.length === 0 || moved.some((row) => rowKey(row) === mine)) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const version = moved
|
|
318
|
+
.map((row) => row.version)
|
|
319
|
+
.reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
|
|
320
|
+
if (!isRepairablyBehind(version, now)) return null;
|
|
321
|
+
const elsewhere = moved.map((row) => row.projectPath ?? `${row.scope} scope`);
|
|
322
|
+
if (target && version !== target) {
|
|
323
|
+
return {
|
|
324
|
+
elsewhere,
|
|
325
|
+
version,
|
|
326
|
+
notFollowed: `it went to v${version}, not the v${target} this update asked for`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const repair = await deps.repairStaleLoad(pluginId, version, projectPath);
|
|
330
|
+
return {
|
|
331
|
+
elsewhere,
|
|
332
|
+
version,
|
|
333
|
+
notFollowed: repair.ok
|
|
334
|
+
? null
|
|
335
|
+
: (repair.error ?? "the registry was not rewritten"),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The words for a misdirected update, for an item's detail. */
|
|
340
|
+
function describeMisdirected(m: MisdirectedUpdate): string {
|
|
341
|
+
const where = m.elsewhere.join(", ");
|
|
342
|
+
return m.notFollowed === null
|
|
343
|
+
? `Claude Code updated ${where} instead of this project — moved this project's record to v${m.version} too`
|
|
344
|
+
: `Claude Code updated ${where} instead of this project, and this project's record could not follow: ${m.notFollowed}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
207
347
|
/**
|
|
208
348
|
* After a plugin landed at `target` for `projectPath`: make sure that is the
|
|
209
349
|
* version Claude Code actually loads there, and bring the loader forward when
|
|
@@ -319,10 +459,26 @@ async function advanceInScope(
|
|
|
319
459
|
pluginId: string,
|
|
320
460
|
scope: PluginScope,
|
|
321
461
|
projectPath: string,
|
|
322
|
-
|
|
462
|
+
/** The version this update targets, when known — see followMisdirectedUpdate. */
|
|
463
|
+
target: string | null = null,
|
|
464
|
+
): Promise<MisdirectedUpdate | null> {
|
|
323
465
|
const here = await deps.readInstalled(pluginId, scope, projectPath);
|
|
324
|
-
if (here)
|
|
325
|
-
|
|
466
|
+
if (!here) {
|
|
467
|
+
await deps.install(pluginId, scope);
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
const before = await readRows(deps, pluginId);
|
|
471
|
+
await deps.update(pluginId, scope);
|
|
472
|
+
return before
|
|
473
|
+
? followMisdirectedUpdate(
|
|
474
|
+
deps,
|
|
475
|
+
pluginId,
|
|
476
|
+
scope,
|
|
477
|
+
projectPath,
|
|
478
|
+
before,
|
|
479
|
+
target,
|
|
480
|
+
)
|
|
481
|
+
: null;
|
|
326
482
|
}
|
|
327
483
|
|
|
328
484
|
/**
|
|
@@ -449,6 +605,7 @@ export async function applyPlugins(
|
|
|
449
605
|
report.begin(item.pluginId);
|
|
450
606
|
const startedAt = Date.now();
|
|
451
607
|
const landed: Array<[PluginScope, string | null]> = [];
|
|
608
|
+
const misdirected: MisdirectedUpdate[] = [];
|
|
452
609
|
|
|
453
610
|
try {
|
|
454
611
|
for (const scope of scopes) {
|
|
@@ -467,7 +624,14 @@ export async function applyPlugins(
|
|
|
467
624
|
} else if (item.action === "install") {
|
|
468
625
|
await deps.install(item.pluginId, scope);
|
|
469
626
|
} else {
|
|
470
|
-
await advanceInScope(
|
|
627
|
+
const m = await advanceInScope(
|
|
628
|
+
deps,
|
|
629
|
+
item.pluginId,
|
|
630
|
+
scope,
|
|
631
|
+
projectPath,
|
|
632
|
+
item.target,
|
|
633
|
+
);
|
|
634
|
+
if (m) misdirected.push(m);
|
|
471
635
|
}
|
|
472
636
|
|
|
473
637
|
// Record what LANDED, not what we asked for.
|
|
@@ -520,19 +684,24 @@ export async function applyPlugins(
|
|
|
520
684
|
);
|
|
521
685
|
result.failed.push(item.pluginId);
|
|
522
686
|
} else if (noMove && !forward) {
|
|
687
|
+
const why = misdirected.map(describeMisdirected);
|
|
523
688
|
report.finish(
|
|
524
689
|
true,
|
|
525
690
|
item.pluginId,
|
|
526
|
-
`no change — ${noMove}`,
|
|
691
|
+
`no change — ${noMove}${why.length > 0 ? ` (${why.join("; ")})` : ""}`,
|
|
527
692
|
Date.now() - startedAt,
|
|
528
693
|
);
|
|
529
694
|
result.unchanged.push(item.pluginId);
|
|
530
695
|
} else {
|
|
531
696
|
const moved = `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`;
|
|
697
|
+
const notes = [
|
|
698
|
+
...misdirected.map(describeMisdirected),
|
|
699
|
+
...(forward ? [describeLoadForward(forward)] : []),
|
|
700
|
+
];
|
|
532
701
|
report.finish(
|
|
533
702
|
true,
|
|
534
703
|
item.pluginId,
|
|
535
|
-
|
|
704
|
+
[moved, ...notes].join(" · "),
|
|
536
705
|
Date.now() - startedAt,
|
|
537
706
|
);
|
|
538
707
|
result.ok++;
|
|
@@ -621,8 +790,12 @@ export async function updatePluginInScope(
|
|
|
621
790
|
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
622
791
|
): Promise<string | null> {
|
|
623
792
|
// Same guard as the apply loop, for the same reason — see advanceInScope.
|
|
793
|
+
// A misdirected update is followed there; the version read back afterwards
|
|
794
|
+
// is what this returns.
|
|
624
795
|
return applyOneInScope(
|
|
625
|
-
(id, sc) =>
|
|
796
|
+
async (id, sc) => {
|
|
797
|
+
await advanceInScope(deps, id, sc, projectPath);
|
|
798
|
+
},
|
|
626
799
|
pluginId,
|
|
627
800
|
scope,
|
|
628
801
|
projectPath,
|