diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index a0b5ec77..730f1ded 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -936,6 +936,10 @@ class SharedManager { } for (const [name, value] of Object.entries(parsed as Record)) { + if (!this.isMarketplaceRegistryEntry(value)) { + continue; + } + merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; } } catch (err) { @@ -954,13 +958,14 @@ class SharedManager { const entry = merged[name]; if (!(name in discoveredEntries)) { delete merged[name]; - } else if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + } else if (this.isMarketplaceRegistryEntry(entry)) { merged[name] = { - ...(entry as Record), + ...entry, installLocation: discoveredEntries[name].installLocation, }; + } else { + delete merged[name]; } - // else: entry is in discoveredEntries but has a malformed value — preserve as-is } return JSON.stringify(merged, null, 2); @@ -981,8 +986,8 @@ class SharedManager { continue; } - // Skip hidden dirs and Claude Code's .staging rename-dance work trees. - if (entry.name.startsWith('.') || entry.name.endsWith('.staging')) { + // Skip hidden dirs and Claude Code rename-dance leftovers (.staging/.bak). + if (this.isTransientMarketplaceDirectory(entry.name)) { continue; } @@ -994,6 +999,14 @@ class SharedManager { return discovered; } + private isTransientMarketplaceDirectory(name: string): boolean { + return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak'); + } + + private isMarketplaceRegistryEntry(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + private writePluginMetadataFile( registryPath: string, content: string, diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 46feb170..730d2f57 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -360,7 +360,7 @@ describe('SharedManager', () => { expect(reconciled.stale).toBeUndefined(); }); - it('does not register .staging directories left behind by interrupted marketplace auto-updates', () => { + it('does not register transient marketplace directories left behind by interrupted auto-updates', () => { // Regression: CCS used to write bare { installLocation } entries for marketplace // directories with no registry record. Claude Code requires source + lastUpdated, // so those entries corrupted known_marketplaces.json and broke /plugin. @@ -369,24 +369,29 @@ describe('SharedManager', () => { fs.mkdirSync(instancePath, { recursive: true }); manager.linkSharedDirectories(instancePath); - // Simulate Claude Code leaving a .staging dir behind in both the global claude dir - // and the instance dir (discoverMarketplaceEntries scans each independently). - fs.mkdirSync(marketplacePath(claudeDir(), 'claude-plugins-official.staging'), { - recursive: true, - }); - fs.mkdirSync(marketplacePath(instancePath, 'claude-plugins-official.staging'), { - recursive: true, - }); + // Simulate Claude Code leaving rename-dance temp dirs behind in both the + // global claude dir and the instance dir (discoverMarketplaceEntries scans + // each independently). + for (const suffix of ['.staging', '.bak']) { + fs.mkdirSync(marketplacePath(claudeDir(), `claude-plugins-official${suffix}`), { + recursive: true, + }); + fs.mkdirSync(marketplacePath(instancePath, `claude-plugins-official${suffix}`), { + recursive: true, + }); + } manager.normalizeMarketplaceRegistryPaths(instancePath); const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); const global = readJson(globalRegistryPath) as Record; expect(global['claude-plugins-official.staging']).toBeUndefined(); + expect(global['claude-plugins-official.bak']).toBeUndefined(); const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); const instance = readJson(instanceRegistryPath) as Record; expect(instance['claude-plugins-official.staging']).toBeUndefined(); + expect(instance['claude-plugins-official.bak']).toBeUndefined(); }); it('removes registry entries whose physical marketplace directory no longer exists', () => { @@ -421,6 +426,29 @@ describe('SharedManager', () => { expect(instance['vanished-marketplace']).toBeUndefined(); }); + it('drops malformed marketplace entries even when the payload directory still exists', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + fs.mkdirSync(marketplacePath(claudeDir(), 'claude-code-plugins'), { recursive: true }); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'claude-code-plugins': 'bad-entry', + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const global = readJson(globalRegistryPath) as Record; + expect(global['claude-code-plugins']).toBeUndefined(); + + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + const instance = readJson(instanceRegistryPath) as Record; + expect(instance['claude-code-plugins']).toBeUndefined(); + }); + it('warns and skips malformed marketplace registries while keeping valid sources', () => { const manager = new SharedManager(); const instancePath = instanceDir('work');