claudeup 4.10.1 → 4.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/plugin-setup.test.ts +300 -0
- package/src/__tests__/plugin-version-check.test.ts +760 -0
- package/src/prerunner/index.js +17 -0
- package/src/prerunner/index.ts +20 -0
- package/src/services/plugin-setup.js +88 -1
- package/src/services/plugin-setup.ts +99 -1
- package/src/services/plugin-version-check.js +248 -0
- package/src/services/plugin-version-check.ts +340 -0
- package/src/ui/App.js +52 -27
- package/src/ui/App.tsx +102 -68
- package/src/ui/screens/PluginsScreen.js +86 -13
- package/src/ui/screens/PluginsScreen.tsx +135 -24
- package/src/ui/state/DimensionsContext.js +8 -6
- package/src/ui/state/DimensionsContext.tsx +10 -1
package/src/prerunner/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { UpdateCache } from "../services/update-cache.js";
|
|
|
5
5
|
import { getAvailablePlugins, clearMarketplaceCache, } from "../services/plugin-manager.js";
|
|
6
6
|
import { runClaude } from "../services/claude-runner.js";
|
|
7
7
|
import { recoverMarketplaceSettings, migrateMarketplaceRename, cleanupExtraKnownMarketplaces, getGlobalEnabledPlugins, getEnabledPlugins, getLocalEnabledPlugins, readGlobalSettings, writeGlobalSettings, saveGlobalInstalledPluginVersion, gapFillInstalledPluginVersions, } from "../services/claude-settings.js";
|
|
8
|
+
import { checkPluginVersionMismatches, formatMismatchWarning, } from "../services/plugin-version-check.js";
|
|
8
9
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
9
10
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
10
11
|
import { updatePlugin, addMarketplace, isClaudeAvailable, } from "../services/claude-cli.js";
|
|
@@ -200,6 +201,22 @@ export async function prerunClaude(claudeArgs, options = {}) {
|
|
|
200
201
|
catch {
|
|
201
202
|
// Non-fatal: gap-fill is best-effort
|
|
202
203
|
}
|
|
204
|
+
// STEP 0.8: Detect plugin version mismatches caused by the [0] index bug
|
|
205
|
+
// Claude Code's pluginLoader.ts takes entries[0] regardless of project,
|
|
206
|
+
// so if another project installed a different version first, this project
|
|
207
|
+
// will silently load the wrong version.
|
|
208
|
+
try {
|
|
209
|
+
const cwd = process.cwd();
|
|
210
|
+
const mismatches = await checkPluginVersionMismatches(cwd);
|
|
211
|
+
if (mismatches.length > 0) {
|
|
212
|
+
console.warn("");
|
|
213
|
+
console.warn(formatMismatchWarning(mismatches));
|
|
214
|
+
console.warn("");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Non-fatal: mismatch detection is best-effort
|
|
219
|
+
}
|
|
203
220
|
// STEP 1: Check if we should update (time-based cache, or forced)
|
|
204
221
|
const shouldUpdate = options.force || (await cache.shouldCheckForUpdates());
|
|
205
222
|
if (options.force) {
|
package/src/prerunner/index.ts
CHANGED
|
@@ -19,6 +19,10 @@ import {
|
|
|
19
19
|
saveGlobalInstalledPluginVersion,
|
|
20
20
|
gapFillInstalledPluginVersions,
|
|
21
21
|
} from "../services/claude-settings.js";
|
|
22
|
+
import {
|
|
23
|
+
checkPluginVersionMismatches,
|
|
24
|
+
formatMismatchWarning,
|
|
25
|
+
} from "../services/plugin-version-check.js";
|
|
22
26
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
23
27
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
24
28
|
import {
|
|
@@ -273,6 +277,22 @@ export async function prerunClaude(
|
|
|
273
277
|
// Non-fatal: gap-fill is best-effort
|
|
274
278
|
}
|
|
275
279
|
|
|
280
|
+
// STEP 0.8: Detect plugin version mismatches caused by the [0] index bug
|
|
281
|
+
// Claude Code's pluginLoader.ts takes entries[0] regardless of project,
|
|
282
|
+
// so if another project installed a different version first, this project
|
|
283
|
+
// will silently load the wrong version.
|
|
284
|
+
try {
|
|
285
|
+
const cwd = process.cwd();
|
|
286
|
+
const mismatches = await checkPluginVersionMismatches(cwd);
|
|
287
|
+
if (mismatches.length > 0) {
|
|
288
|
+
console.warn("");
|
|
289
|
+
console.warn(formatMismatchWarning(mismatches));
|
|
290
|
+
console.warn("");
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
// Non-fatal: mismatch detection is best-effort
|
|
294
|
+
}
|
|
295
|
+
|
|
276
296
|
// STEP 1: Check if we should update (time-based cache, or forced)
|
|
277
297
|
const shouldUpdate = options.force || (await cache.shouldCheckForUpdates());
|
|
278
298
|
|
|
@@ -9,12 +9,18 @@
|
|
|
9
9
|
* - brew: Homebrew formulae (macOS/Linux)
|
|
10
10
|
* - npm: Global npm packages
|
|
11
11
|
* - cargo: Rust crates
|
|
12
|
+
* - go: Go modules (via go install)
|
|
13
|
+
*
|
|
14
|
+
* Special keys:
|
|
15
|
+
* - required: System binaries that must exist in PATH (not auto-installed)
|
|
12
16
|
*
|
|
13
17
|
* Example plugin.json:
|
|
14
18
|
* {
|
|
15
19
|
* "setup": {
|
|
16
20
|
* "pip": ["browser-use", "mcp"],
|
|
17
|
-
* "brew": ["memextech/tap/ht-mcp"]
|
|
21
|
+
* "brew": ["memextech/tap/ht-mcp"],
|
|
22
|
+
* "go": ["github.com/MadAppGang/tmux-mcp@latest"],
|
|
23
|
+
* "required": ["tmux"]
|
|
18
24
|
* }
|
|
19
25
|
* }
|
|
20
26
|
*/
|
|
@@ -224,6 +230,60 @@ async function installCargoPackages(packages, result) {
|
|
|
224
230
|
}
|
|
225
231
|
}
|
|
226
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Extract binary name from a Go module path.
|
|
235
|
+
* The binary name is the last path segment before @version.
|
|
236
|
+
* e.g., "github.com/MadAppGang/tmux-mcp@latest" → "tmux-mcp"
|
|
237
|
+
* "github.com/user/tool" → "tool"
|
|
238
|
+
*/
|
|
239
|
+
export function extractGoBinaryName(pkg) {
|
|
240
|
+
// Strip @version suffix first
|
|
241
|
+
const withoutVersion = pkg.replace(/@[^/]*$/, "");
|
|
242
|
+
return withoutVersion.split("/").pop() || pkg;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Install Go packages via `go install`
|
|
246
|
+
*/
|
|
247
|
+
async function installGoPackages(packages, result) {
|
|
248
|
+
const goPath = await which("go");
|
|
249
|
+
if (!goPath) {
|
|
250
|
+
for (const pkg of packages) {
|
|
251
|
+
result.failed.push({ pkg: `go:${pkg}`, error: "go not found" });
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
for (const pkg of packages) {
|
|
256
|
+
const binaryName = extractGoBinaryName(pkg);
|
|
257
|
+
if (await isBinaryAvailable(binaryName)) {
|
|
258
|
+
result.skipped.push(`go:${pkg}`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const { ok, stderr } = await run(goPath, ["install", pkg], 300000);
|
|
262
|
+
if (ok) {
|
|
263
|
+
result.installed.push(`go:${pkg}`);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
result.failed.push({ pkg: `go:${pkg}`, error: stderr });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Check required system binaries and report missing ones.
|
|
272
|
+
* These are NOT installed by claudeup — the user must install them manually.
|
|
273
|
+
*/
|
|
274
|
+
async function checkRequiredBinaries(binaries, result) {
|
|
275
|
+
for (const name of binaries) {
|
|
276
|
+
if (await isBinaryAvailable(name)) {
|
|
277
|
+
result.skipped.push(`required:${name}`);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
result.failed.push({
|
|
281
|
+
pkg: `required:${name}`,
|
|
282
|
+
error: `Required binary '${name}' not found in PATH. Please install it manually.`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
227
287
|
/**
|
|
228
288
|
* Read the setup config from a plugin's cached manifest
|
|
229
289
|
*/
|
|
@@ -304,6 +364,12 @@ export async function installPluginDeps(setup) {
|
|
|
304
364
|
if (setup.cargo?.length) {
|
|
305
365
|
await installCargoPackages(setup.cargo, result);
|
|
306
366
|
}
|
|
367
|
+
if (setup.go?.length) {
|
|
368
|
+
await installGoPackages(setup.go, result);
|
|
369
|
+
}
|
|
370
|
+
if (setup.required?.length) {
|
|
371
|
+
await checkRequiredBinaries(setup.required, result);
|
|
372
|
+
}
|
|
307
373
|
return result;
|
|
308
374
|
}
|
|
309
375
|
/**
|
|
@@ -353,5 +419,26 @@ export async function checkMissingDeps(setup) {
|
|
|
353
419
|
if (missingCargo.length > 0)
|
|
354
420
|
missing.cargo = missingCargo;
|
|
355
421
|
}
|
|
422
|
+
if (setup.go?.length) {
|
|
423
|
+
const missingGo = [];
|
|
424
|
+
for (const pkg of setup.go) {
|
|
425
|
+
const bin = extractGoBinaryName(pkg);
|
|
426
|
+
if (!(await isBinaryAvailable(bin))) {
|
|
427
|
+
missingGo.push(pkg);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (missingGo.length > 0)
|
|
431
|
+
missing.go = missingGo;
|
|
432
|
+
}
|
|
433
|
+
if (setup.required?.length) {
|
|
434
|
+
const missingRequired = [];
|
|
435
|
+
for (const name of setup.required) {
|
|
436
|
+
if (!(await isBinaryAvailable(name))) {
|
|
437
|
+
missingRequired.push(name);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (missingRequired.length > 0)
|
|
441
|
+
missing.required = missingRequired;
|
|
442
|
+
}
|
|
356
443
|
return missing;
|
|
357
444
|
}
|
|
@@ -9,12 +9,18 @@
|
|
|
9
9
|
* - brew: Homebrew formulae (macOS/Linux)
|
|
10
10
|
* - npm: Global npm packages
|
|
11
11
|
* - cargo: Rust crates
|
|
12
|
+
* - go: Go modules (via go install)
|
|
13
|
+
*
|
|
14
|
+
* Special keys:
|
|
15
|
+
* - required: System binaries that must exist in PATH (not auto-installed)
|
|
12
16
|
*
|
|
13
17
|
* Example plugin.json:
|
|
14
18
|
* {
|
|
15
19
|
* "setup": {
|
|
16
20
|
* "pip": ["browser-use", "mcp"],
|
|
17
|
-
* "brew": ["memextech/tap/ht-mcp"]
|
|
21
|
+
* "brew": ["memextech/tap/ht-mcp"],
|
|
22
|
+
* "go": ["github.com/MadAppGang/tmux-mcp@latest"],
|
|
23
|
+
* "required": ["tmux"]
|
|
18
24
|
* }
|
|
19
25
|
* }
|
|
20
26
|
*/
|
|
@@ -33,6 +39,8 @@ export interface SetupConfig {
|
|
|
33
39
|
brew?: string[];
|
|
34
40
|
npm?: string[];
|
|
35
41
|
cargo?: string[];
|
|
42
|
+
go?: string[];
|
|
43
|
+
required?: string[];
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
export interface SetupResult {
|
|
@@ -275,6 +283,69 @@ async function installCargoPackages(
|
|
|
275
283
|
}
|
|
276
284
|
}
|
|
277
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Extract binary name from a Go module path.
|
|
288
|
+
* The binary name is the last path segment before @version.
|
|
289
|
+
* e.g., "github.com/MadAppGang/tmux-mcp@latest" → "tmux-mcp"
|
|
290
|
+
* "github.com/user/tool" → "tool"
|
|
291
|
+
*/
|
|
292
|
+
export function extractGoBinaryName(pkg: string): string {
|
|
293
|
+
// Strip @version suffix first
|
|
294
|
+
const withoutVersion = pkg.replace(/@[^/]*$/, "");
|
|
295
|
+
return withoutVersion.split("/").pop() || pkg;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Install Go packages via `go install`
|
|
300
|
+
*/
|
|
301
|
+
async function installGoPackages(
|
|
302
|
+
packages: string[],
|
|
303
|
+
result: SetupResult,
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
const goPath = await which("go");
|
|
306
|
+
if (!goPath) {
|
|
307
|
+
for (const pkg of packages) {
|
|
308
|
+
result.failed.push({ pkg: `go:${pkg}`, error: "go not found" });
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const pkg of packages) {
|
|
314
|
+
const binaryName = extractGoBinaryName(pkg);
|
|
315
|
+
if (await isBinaryAvailable(binaryName)) {
|
|
316
|
+
result.skipped.push(`go:${pkg}`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const { ok, stderr } = await run(goPath, ["install", pkg], 300000);
|
|
321
|
+
if (ok) {
|
|
322
|
+
result.installed.push(`go:${pkg}`);
|
|
323
|
+
} else {
|
|
324
|
+
result.failed.push({ pkg: `go:${pkg}`, error: stderr });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Check required system binaries and report missing ones.
|
|
331
|
+
* These are NOT installed by claudeup — the user must install them manually.
|
|
332
|
+
*/
|
|
333
|
+
async function checkRequiredBinaries(
|
|
334
|
+
binaries: string[],
|
|
335
|
+
result: SetupResult,
|
|
336
|
+
): Promise<void> {
|
|
337
|
+
for (const name of binaries) {
|
|
338
|
+
if (await isBinaryAvailable(name)) {
|
|
339
|
+
result.skipped.push(`required:${name}`);
|
|
340
|
+
} else {
|
|
341
|
+
result.failed.push({
|
|
342
|
+
pkg: `required:${name}`,
|
|
343
|
+
error: `Required binary '${name}' not found in PATH. Please install it manually.`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
278
349
|
/**
|
|
279
350
|
* Read the setup config from a plugin's cached manifest
|
|
280
351
|
*/
|
|
@@ -381,6 +452,12 @@ export async function installPluginDeps(
|
|
|
381
452
|
if (setup.cargo?.length) {
|
|
382
453
|
await installCargoPackages(setup.cargo, result);
|
|
383
454
|
}
|
|
455
|
+
if (setup.go?.length) {
|
|
456
|
+
await installGoPackages(setup.go, result);
|
|
457
|
+
}
|
|
458
|
+
if (setup.required?.length) {
|
|
459
|
+
await checkRequiredBinaries(setup.required, result);
|
|
460
|
+
}
|
|
384
461
|
|
|
385
462
|
return result;
|
|
386
463
|
}
|
|
@@ -435,5 +512,26 @@ export async function checkMissingDeps(
|
|
|
435
512
|
if (missingCargo.length > 0) missing.cargo = missingCargo;
|
|
436
513
|
}
|
|
437
514
|
|
|
515
|
+
if (setup.go?.length) {
|
|
516
|
+
const missingGo: string[] = [];
|
|
517
|
+
for (const pkg of setup.go) {
|
|
518
|
+
const bin = extractGoBinaryName(pkg);
|
|
519
|
+
if (!(await isBinaryAvailable(bin))) {
|
|
520
|
+
missingGo.push(pkg);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (missingGo.length > 0) missing.go = missingGo;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (setup.required?.length) {
|
|
527
|
+
const missingRequired: string[] = [];
|
|
528
|
+
for (const name of setup.required) {
|
|
529
|
+
if (!(await isBinaryAvailable(name))) {
|
|
530
|
+
missingRequired.push(name);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (missingRequired.length > 0) missing.required = missingRequired;
|
|
534
|
+
}
|
|
535
|
+
|
|
438
536
|
return missing;
|
|
439
537
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin version mismatch detection and fix.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's plugin loader (pluginLoader.ts:2051) takes the FIRST entry
|
|
5
|
+
* from installed_plugins.json for each plugin ID:
|
|
6
|
+
*
|
|
7
|
+
* const installEntry = installedPluginsData.plugins[pluginId]?.[0]
|
|
8
|
+
*
|
|
9
|
+
* This means the first project to install a plugin determines which version
|
|
10
|
+
* loads for ALL projects. When different projects install different versions,
|
|
11
|
+
* only the one at index [0] takes effect.
|
|
12
|
+
*
|
|
13
|
+
* Bug report: https://github.com/anthropics/claude-code/issues/45997
|
|
14
|
+
*/
|
|
15
|
+
import { getEnabledPlugins, getGlobalEnabledPlugins, getLocalEnabledPlugins, readInstalledPluginsRegistry, writeInstalledPluginsRegistry, } from "./claude-settings.js";
|
|
16
|
+
const BUG_REPORT_URL = "https://github.com/anthropics/claude-code/issues/45997";
|
|
17
|
+
// ── Detection ────────────────────────────────────────────────────────────────
|
|
18
|
+
/**
|
|
19
|
+
* Check for plugin version mismatches caused by the [0] index bug.
|
|
20
|
+
*
|
|
21
|
+
* For each enabled plugin in the current project:
|
|
22
|
+
* 1. Reads the plugin's entry array from installed_plugins.json
|
|
23
|
+
* 2. Finds the entry matching the current project
|
|
24
|
+
* 3. Checks if entries[0] has a DIFFERENT version than the current project's entry
|
|
25
|
+
* 4. If yes: this project will load the wrong version
|
|
26
|
+
*
|
|
27
|
+
* @param projectPath - The current project path to check
|
|
28
|
+
* @returns Array of mismatch info for each affected plugin
|
|
29
|
+
*/
|
|
30
|
+
export async function checkPluginVersionMismatches(projectPath) {
|
|
31
|
+
const registry = await readInstalledPluginsRegistry();
|
|
32
|
+
const enabledPluginIds = await collectEnabledPluginIds(projectPath);
|
|
33
|
+
const mismatches = [];
|
|
34
|
+
for (const pluginId of enabledPluginIds) {
|
|
35
|
+
const entries = registry.plugins[pluginId];
|
|
36
|
+
if (!entries || entries.length < 2)
|
|
37
|
+
continue;
|
|
38
|
+
// Find the entry for the current project
|
|
39
|
+
const currentEntry = entries.find((e) => e.scope === "project" &&
|
|
40
|
+
normalizePath(e.projectPath) === normalizePath(projectPath));
|
|
41
|
+
if (!currentEntry)
|
|
42
|
+
continue;
|
|
43
|
+
const firstEntry = entries[0];
|
|
44
|
+
// Compare the version at [0] with the current project's version
|
|
45
|
+
if (firstEntry.version !== currentEntry.version) {
|
|
46
|
+
mismatches.push({
|
|
47
|
+
pluginId,
|
|
48
|
+
firstEntryVersion: firstEntry.version,
|
|
49
|
+
firstEntryProject: firstEntry.projectPath,
|
|
50
|
+
currentProjectVersion: currentEntry.version,
|
|
51
|
+
allEntries: entries,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return mismatches;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Check a single plugin for version mismatch (used after plugin update).
|
|
59
|
+
*
|
|
60
|
+
* @param pluginId - The plugin ID to check
|
|
61
|
+
* @param projectPath - The current project path
|
|
62
|
+
* @returns Mismatch info or null if no mismatch
|
|
63
|
+
*/
|
|
64
|
+
export async function checkSinglePluginMismatch(pluginId, projectPath) {
|
|
65
|
+
const registry = await readInstalledPluginsRegistry();
|
|
66
|
+
const entries = registry.plugins[pluginId];
|
|
67
|
+
if (!entries || entries.length < 2)
|
|
68
|
+
return null;
|
|
69
|
+
const currentEntry = entries.find((e) => e.scope === "project" &&
|
|
70
|
+
normalizePath(e.projectPath) === normalizePath(projectPath));
|
|
71
|
+
if (!currentEntry)
|
|
72
|
+
return null;
|
|
73
|
+
const firstEntry = entries[0];
|
|
74
|
+
if (firstEntry.version === currentEntry.version)
|
|
75
|
+
return null;
|
|
76
|
+
return {
|
|
77
|
+
pluginId,
|
|
78
|
+
firstEntryVersion: firstEntry.version,
|
|
79
|
+
firstEntryProject: firstEntry.projectPath,
|
|
80
|
+
currentProjectVersion: currentEntry.version,
|
|
81
|
+
allEntries: entries,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// ── Fix ──────────────────────────────────────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* Fix a version mismatch by updating ALL entries for a plugin to use
|
|
87
|
+
* the target version. This is a JSON-only operation that rewrites
|
|
88
|
+
* installPath and version in each entry.
|
|
89
|
+
*
|
|
90
|
+
* IMPORTANT: This modifies installed_plugins.json which is Claude Code-owned.
|
|
91
|
+
* Only call after explicit user consent.
|
|
92
|
+
*
|
|
93
|
+
* @param pluginId - The plugin ID to fix
|
|
94
|
+
* @param targetVersion - The version to normalize all entries to
|
|
95
|
+
* @returns Number of updated entries and which projects were affected
|
|
96
|
+
*/
|
|
97
|
+
export async function fixPluginVersionMismatch(pluginId, targetVersion) {
|
|
98
|
+
const registry = await readInstalledPluginsRegistry();
|
|
99
|
+
const entries = registry.plugins[pluginId];
|
|
100
|
+
if (!entries || entries.length === 0) {
|
|
101
|
+
return { updated: 0, projects: [] };
|
|
102
|
+
}
|
|
103
|
+
// Find an existing entry with the target version to get the correct installPath
|
|
104
|
+
const templateEntry = entries.find((e) => e.version === targetVersion);
|
|
105
|
+
if (!templateEntry) {
|
|
106
|
+
return { updated: 0, projects: [] };
|
|
107
|
+
}
|
|
108
|
+
const targetInstallPath = templateEntry.installPath;
|
|
109
|
+
const updated = [];
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (entry.version !== targetVersion) {
|
|
112
|
+
entry.version = targetVersion;
|
|
113
|
+
entry.installPath = targetInstallPath;
|
|
114
|
+
entry.lastUpdated = new Date().toISOString();
|
|
115
|
+
updated.push(entry.projectPath || "(global)");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (updated.length > 0) {
|
|
119
|
+
await writeInstalledPluginsRegistry(registry);
|
|
120
|
+
}
|
|
121
|
+
return { updated: updated.length, projects: updated };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Fix all version mismatches by normalizing each plugin to its latest version.
|
|
125
|
+
*
|
|
126
|
+
* @param mismatches - Array of mismatches from checkPluginVersionMismatches
|
|
127
|
+
* @returns Map of pluginId to FixResult
|
|
128
|
+
*/
|
|
129
|
+
export async function fixAllPluginVersionMismatches(mismatches) {
|
|
130
|
+
const results = new Map();
|
|
131
|
+
// Read registry once, apply all fixes, write once
|
|
132
|
+
const registry = await readInstalledPluginsRegistry();
|
|
133
|
+
for (const mismatch of mismatches) {
|
|
134
|
+
const entries = registry.plugins[mismatch.pluginId];
|
|
135
|
+
if (!entries || entries.length === 0) {
|
|
136
|
+
results.set(mismatch.pluginId, { updated: 0, projects: [] });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// Use the current project's version as the target (the version the user expects)
|
|
140
|
+
const targetVersion = mismatch.currentProjectVersion;
|
|
141
|
+
const templateEntry = entries.find((e) => e.version === targetVersion);
|
|
142
|
+
if (!templateEntry) {
|
|
143
|
+
results.set(mismatch.pluginId, { updated: 0, projects: [] });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const targetInstallPath = templateEntry.installPath;
|
|
147
|
+
const updated = [];
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (entry.version !== targetVersion) {
|
|
150
|
+
entry.version = targetVersion;
|
|
151
|
+
entry.installPath = targetInstallPath;
|
|
152
|
+
entry.lastUpdated = new Date().toISOString();
|
|
153
|
+
updated.push(entry.projectPath || "(global)");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
results.set(mismatch.pluginId, {
|
|
157
|
+
updated: updated.length,
|
|
158
|
+
projects: updated,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
await writeInstalledPluginsRegistry(registry);
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
// ── Formatting ───────────────────────────────────────────────────────────────
|
|
165
|
+
/**
|
|
166
|
+
* Format mismatch info for console output (prerunner).
|
|
167
|
+
*/
|
|
168
|
+
export function formatMismatchWarning(mismatches) {
|
|
169
|
+
const lines = [];
|
|
170
|
+
lines.push("WARNING: Plugin version mismatch detected (Claude Code bug #45997)");
|
|
171
|
+
lines.push("Claude Code loads the first entry from installed_plugins.json for ALL projects.");
|
|
172
|
+
lines.push("The following plugins will load a different version than what this project has installed:\n");
|
|
173
|
+
for (const m of mismatches) {
|
|
174
|
+
const firstProject = m.firstEntryProject
|
|
175
|
+
? shortenPath(m.firstEntryProject)
|
|
176
|
+
: "(global)";
|
|
177
|
+
lines.push(` ${m.pluginId}:`);
|
|
178
|
+
lines.push(` Loading v${m.firstEntryVersion} (from ${firstProject})`);
|
|
179
|
+
lines.push(` Expected v${m.currentProjectVersion} (this project)`);
|
|
180
|
+
}
|
|
181
|
+
lines.push("");
|
|
182
|
+
lines.push(`Bug report: ${BUG_REPORT_URL}`);
|
|
183
|
+
lines.push("Run claudeup to fix — it can align all projects to the same version.");
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Format mismatch info for TUI display.
|
|
188
|
+
*/
|
|
189
|
+
export function formatMismatchBanner(mismatches) {
|
|
190
|
+
const parts = mismatches.map((m) => `${m.pluginId}: loading v${m.firstEntryVersion} instead of v${m.currentProjectVersion}`);
|
|
191
|
+
return `Version mismatch: ${parts.join(", ")}`;
|
|
192
|
+
}
|
|
193
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
194
|
+
/**
|
|
195
|
+
* Collect all enabled plugin IDs across all scopes for a project.
|
|
196
|
+
*/
|
|
197
|
+
async function collectEnabledPluginIds(projectPath) {
|
|
198
|
+
const pluginIds = new Set();
|
|
199
|
+
try {
|
|
200
|
+
const global = await getGlobalEnabledPlugins();
|
|
201
|
+
for (const [id, enabled] of Object.entries(global)) {
|
|
202
|
+
if (enabled)
|
|
203
|
+
pluginIds.add(id);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* skip unreadable */
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const project = await getEnabledPlugins(projectPath);
|
|
211
|
+
for (const [id, enabled] of Object.entries(project)) {
|
|
212
|
+
if (enabled)
|
|
213
|
+
pluginIds.add(id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* skip unreadable */
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const local = await getLocalEnabledPlugins(projectPath);
|
|
221
|
+
for (const [id, enabled] of Object.entries(local)) {
|
|
222
|
+
if (enabled)
|
|
223
|
+
pluginIds.add(id);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* skip unreadable */
|
|
228
|
+
}
|
|
229
|
+
return pluginIds;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Normalize a path for comparison (resolve and trailing-slash normalize).
|
|
233
|
+
*/
|
|
234
|
+
function normalizePath(p) {
|
|
235
|
+
if (!p)
|
|
236
|
+
return "";
|
|
237
|
+
return p.replace(/\/+$/, "");
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Shorten a path for display by replacing homedir with ~.
|
|
241
|
+
*/
|
|
242
|
+
function shortenPath(p) {
|
|
243
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
244
|
+
if (home && p.startsWith(home)) {
|
|
245
|
+
return `~${p.slice(home.length)}`;
|
|
246
|
+
}
|
|
247
|
+
return p;
|
|
248
|
+
}
|