diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index e423d77d..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) { @@ -947,22 +951,20 @@ class SharedManager { const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir); - for (const [name, value] of Object.entries(discoveredEntries)) { - const existing = merged[name]; - if (existing && typeof existing === 'object' && !Array.isArray(existing)) { - merged[name] = { - ...(existing as Record), - installLocation: value.installLocation, - }; - continue; - } - - merged[name] = value; - } - + // Keep only registry entries that have a physical directory, and update their + // installLocation. Entries only on disk (no registry record) are excluded — + // they lack required schema fields that Claude Code enforces. for (const name of Object.keys(merged)) { + const entry = merged[name]; if (!(name in discoveredEntries)) { delete merged[name]; + } else if (this.isMarketplaceRegistryEntry(entry)) { + merged[name] = { + ...entry, + installLocation: discoveredEntries[name].installLocation, + }; + } else { + delete merged[name]; } } @@ -984,6 +986,11 @@ class SharedManager { continue; } + // Skip hidden dirs and Claude Code rename-dance leftovers (.staging/.bak). + if (this.isTransientMarketplaceDirectory(entry.name)) { + continue; + } + discovered[entry.name] = { installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name), }; @@ -992,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 240769c6..730d2f57 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -360,6 +360,95 @@ describe('SharedManager', () => { expect(reconciled.stale).toBeUndefined(); }); + 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. + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + // 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', () => { + // Regression guard: buildMarketplaceRegistryContent merges JSON sources then + // cross-checks against discoveredEntries. Any name in the merged registry that + // has no matching directory on disk must be pruned so stale entries don't + // accumulate across marketplace uninstalls or renames. + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + // Write a registry entry for a marketplace that has no physical directory. + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'vanished-marketplace': { + source: { type: 'github', repo: 'example/vanished' }, + lastUpdated: '2024-01-01T00:00:00.000Z', + installLocation: marketplacePath(claudeDir(), 'vanished-marketplace'), + }, + }); + // Intentionally do NOT create the physical directory — simulate an uninstalled + // marketplace whose registry entry was not cleaned up. + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const global = readJson(globalRegistryPath) as Record; + expect(global['vanished-marketplace']).toBeUndefined(); + + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + const instance = readJson(instanceRegistryPath) as Record; + 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');