diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index b6559720..1fe60c94 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -60,6 +60,7 @@ class InstanceManager { // Sync MCP servers from global ~/.claude.json (unless bare) if (!options.bare) { + this.sharedManager.normalizeSharedPluginMetadataPaths(); this.syncMcpServers(instancePath); } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 9a010edd..af5e15ef 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -210,8 +210,7 @@ class SharedManager { } } - // Normalize plugin registry paths after linking - this.normalizePluginRegistryPaths(); + this.normalizeSharedPluginMetadataPaths(); } /** @@ -539,6 +538,14 @@ class SharedManager { } } + /** + * Normalize shared plugin metadata files to canonical ~/.claude/ paths. + */ + normalizeSharedPluginMetadataPaths(): void { + this.normalizePluginRegistryPaths(); + this.normalizeMarketplaceRegistryPaths(); + } + /** * Normalize plugin registry paths to use canonical ~/.claude/ paths * instead of instance-specific ~/.ccs/instances// paths. @@ -547,9 +554,33 @@ class SharedManager { * which CCS instance installed the plugin. */ normalizePluginRegistryPaths(): void { - const registryPath = path.join(this.claudeDir, 'plugins', 'installed_plugins.json'); + this.normalizePluginMetadataFile( + path.join(this.claudeDir, 'plugins', 'installed_plugins.json'), + 'Normalized plugin registry paths', + 'plugin registry' + ); + } - // Skip if registry doesn't exist + /** + * Normalize marketplace registry paths to use canonical ~/.claude/ paths + * instead of instance-specific ~/.ccs/instances// paths. + * + * This ensures known_marketplaces.json is consistent regardless of + * which CCS instance added the marketplace. + */ + normalizeMarketplaceRegistryPaths(): void { + this.normalizePluginMetadataFile( + path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), + 'Normalized marketplace registry paths', + 'marketplace registry' + ); + } + + private normalizePluginMetadataFile( + registryPath: string, + successMessage: string, + warningLabel: string + ): void { if (!fs.existsSync(registryPath)) { return; } @@ -557,20 +588,16 @@ class SharedManager { try { const original = fs.readFileSync(registryPath, 'utf8'); - // Replace instance paths with canonical claude path // Pattern: /.ccs/instances// -> /.claude/ const normalized = original.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/'); - // Only write if changes were made if (normalized !== original) { - // Validate JSON before writing JSON.parse(normalized); fs.writeFileSync(registryPath, normalized, 'utf8'); - console.log(ok('Normalized plugin registry paths')); + console.log(ok(successMessage)); } } catch (err) { - // Log warning but don't fail - registry may be malformed - console.log(warn(`Could not normalize plugin registry: ${(err as Error).message}`)); + console.log(warn(`Could not normalize ${warningLabel}: ${(err as Error).message}`)); } } diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 1297069d..29dd11af 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -101,6 +101,10 @@ describe('InstanceManager MCP sync', () => { ); spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const normalizeSharedPluginMetadataSpy = spyOn( + SharedManager.prototype, + 'normalizeSharedPluginMetadataPaths' + ).mockImplementation(() => {}); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( () => false ); @@ -109,6 +113,28 @@ describe('InstanceManager MCP sync', () => { await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true }); expect(linkSharedSpy).not.toHaveBeenCalled(); + expect(normalizeSharedPluginMetadataSpy).not.toHaveBeenCalled(); expect(syncMcpSpy).not.toHaveBeenCalled(); }); + + it('normalizes shared plugin metadata for existing non-bare instances', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const normalizeSharedPluginMetadataSpy = spyOn( + SharedManager.prototype, + 'normalizeSharedPluginMetadataPaths' + ).mockImplementation(() => {}); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => false + ); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('work'); + fs.mkdirSync(instancePath, { recursive: true }); + + await manager.ensureInstance('work', { mode: 'isolated' }); + + expect(normalizeSharedPluginMetadataSpy).toHaveBeenCalledTimes(1); + expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); + }); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index c06e2b48..02446cb8 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -1,10 +1,11 @@ /** * Unit tests for SharedManager - plugin registry path normalization */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import SharedManager from '../../src/management/shared-manager'; // Test the normalization regex pattern directly const normalizePluginPaths = (content: string): string => { @@ -12,6 +13,40 @@ const normalizePluginPaths = (content: string): string => { }; describe('SharedManager', () => { + let tempRoot = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + spyOn(os, 'homedir').mockReturnValue(tempRoot); + process.env.HOME = tempRoot; + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + mock.restore(); + + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + describe('normalizePluginRegistryPaths', () => { describe('regex pattern', () => { it('should replace instance paths with canonical claude path', () => { @@ -82,6 +117,24 @@ describe('SharedManager', () => { '/home/kai/.claude/plugins/cache/claude-hud/claude-hud/0.0.2' ); }); + + it('should normalize marketplace installLocation values', () => { + const original = { + 'claude-code-plugins': { + installLocation: + '/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins', + }, + }; + const input = JSON.stringify(original, null, 2); + const result = normalizePluginPaths(input); + + expect(() => JSON.parse(result)).not.toThrow(); + + const parsed = JSON.parse(result); + expect(parsed['claude-code-plugins'].installLocation).toBe( + '/home/kai/.claude/plugins/marketplaces/claude-code-plugins' + ); + }); }); describe('edge cases', () => { @@ -102,4 +155,35 @@ describe('SharedManager', () => { }); }); }); + + describe('normalizeMarketplaceRegistryPaths', () => { + it('rewrites known_marketplaces.json on disk', () => { + const pluginsDir = path.join(tempRoot, '.claude', 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true }); + + const registryPath = path.join(pluginsDir, 'known_marketplaces.json'); + fs.writeFileSync( + registryPath, + JSON.stringify( + { + 'claude-code-plugins': { + installLocation: + '/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins', + }, + }, + null, + 2 + ), + 'utf8' + ); + + const manager = new SharedManager(); + manager.normalizeMarketplaceRegistryPaths(); + + const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8')); + expect(normalized['claude-code-plugins'].installLocation).toBe( + '/home/kai/.claude/plugins/marketplaces/claude-code-plugins' + ); + }); + }); });