From 54ea36fd18955778a8c15bd825df22618593d9ed Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 07:08:09 -0400 Subject: [PATCH] fix(management): localize marketplace registry per instance --- src/management/instance-manager.ts | 17 +- src/management/profile-context-sync-lock.ts | 10 +- src/management/shared-manager.ts | 343 +++++++++++++++++-- tests/unit/instance-manager-mcp-sync.test.ts | 111 +++--- tests/unit/shared-manager.test.ts | 336 ++++++++---------- 5 files changed, 552 insertions(+), 265 deletions(-) diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 7cdbdc97..7f3af249 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -26,11 +26,13 @@ class InstanceManager { private readonly instancesDir: string; private readonly sharedManager: SharedManager; private readonly contextSyncLock: ProfileContextSyncLock; + private readonly pluginLayoutLock: ProfileContextSyncLock; constructor() { this.instancesDir = path.join(getCcsDir(), 'instances'); this.sharedManager = new SharedManager(); this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir); + this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir); } /** @@ -56,9 +58,15 @@ class InstanceManager { // Apply context policy (isolated by default, optional shared group). await this.sharedManager.syncProjectContext(instancePath, contextPolicy); await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy); + + if (!options.bare) { + await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + this.sharedManager.linkSharedDirectories(instancePath); + }); + } }); - this.sharedManager.normalizeSharedPluginMetadataPaths(instancePath); + this.sharedManager.normalizeSharedPluginMetadataPaths(options.bare ? undefined : instancePath); // Sync MCP servers from global ~/.claude.json (unless bare) if (!options.bare) { @@ -82,7 +90,7 @@ class InstanceManager { private initializeInstance( profileName: string, instancePath: string, - options: InstanceOptions = {} + _options: InstanceOptions = {} ): void { try { // Create base directory @@ -106,10 +114,7 @@ class InstanceManager { } }); - // Bare profiles skip shared symlinks (commands, skills, agents, settings.json) - if (!options.bare) { - this.sharedManager.linkSharedDirectories(instancePath); - } + // Shared links are created during ensureInstance() under the plugin layout lock. } catch (error) { throw new Error( `Failed to initialize instance for ${profileName}: ${(error as Error).message}` diff --git a/src/management/profile-context-sync-lock.ts b/src/management/profile-context-sync-lock.ts index c8158bc3..01072c6e 100644 --- a/src/management/profile-context-sync-lock.ts +++ b/src/management/profile-context-sync-lock.ts @@ -106,8 +106,8 @@ class ProfileContextSyncLock { return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw); } - async withLock(profileName: string, callback: () => Promise): Promise { - const lockPath = this.getLockPath(profileName); + async withNamedLock(lockName: string, callback: () => Promise): Promise { + const lockPath = this.getLockPath(lockName); const retryDelayMs = 50; const staleLockMs = 30000; const timeoutMs = staleLockMs + 5000; @@ -159,7 +159,7 @@ class ProfileContextSyncLock { } if (Date.now() - start > timeoutMs) { - throw new Error(`Timed out waiting for profile context lock: ${profileName}`); + throw new Error(`Timed out waiting for profile context lock: ${lockName}`); } await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); @@ -172,6 +172,10 @@ class ProfileContextSyncLock { this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw); } } + + async withLock(profileName: string, callback: () => Promise): Promise { + return this.withNamedLock(profileName, callback); + } } export default ProfileContextSyncLock; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index cc617d3b..8df6e3e9 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -18,23 +18,64 @@ interface SharedItem { type: 'directory' | 'file'; } -export function normalizePluginMetadataPathString(input: string): string { - return input.replace( - /([\\/])\.ccs\1instances\1[^\\/]+\1/g, - (_match, separator: string) => `${separator}.claude${separator}` +const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify( + { + version: 2, + plugins: {}, + }, + null, + 2 +); + +function getPluginPathModule( + targetConfigDir: string, + input: string +): typeof path.posix | typeof path.win32 { + return targetConfigDir.includes('\\') || input.includes('\\') ? path.win32 : path.posix; +} + +function normalizeTargetConfigDir(targetConfigDir: string, input: string): string { + const pathModule = getPluginPathModule(targetConfigDir, input); + return pathModule.normalize( + pathModule === path.win32 + ? targetConfigDir.replace(/\//g, '\\') + : targetConfigDir.replace(/\\/g, '/') ); } -function normalizePluginMetadataValue(value: unknown): { normalized: unknown; changed: boolean } { +export function normalizePluginMetadataPathString( + input: string, + targetConfigDir = path.join(os.homedir(), '.claude') +): string { + const match = input.match( + /^(.*?)([\\/])(?:\.claude|\.ccs\2shared|\.ccs\2instances\2[^\\/]+)\2plugins(?:(\2.*))?$/ + ); + + if (!match) { + return input; + } + + const pathModule = getPluginPathModule(targetConfigDir, input); + const normalizedTargetConfigDir = normalizeTargetConfigDir(targetConfigDir, input); + const suffix = match[3] ?? ''; + const suffixSegments = suffix.split(/[\\/]+/).filter(Boolean); + + return pathModule.join(normalizedTargetConfigDir, 'plugins', ...suffixSegments); +} + +function normalizePluginMetadataValue( + value: unknown, + targetConfigDir: string +): { normalized: unknown; changed: boolean } { if (typeof value === 'string') { - const normalized = normalizePluginMetadataPathString(value); + const normalized = normalizePluginMetadataPathString(value, targetConfigDir); return { normalized, changed: normalized !== value }; } if (Array.isArray(value)) { let changed = false; const normalized = value.map((item) => { - const result = normalizePluginMetadataValue(item); + const result = normalizePluginMetadataValue(item, targetConfigDir); changed = changed || result.changed; return result.normalized; }); @@ -45,7 +86,7 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch let changed = false; const normalized = Object.fromEntries( Object.entries(value as Record).map(([key, item]) => { - const result = normalizePluginMetadataValue(item); + const result = normalizePluginMetadataValue(item, targetConfigDir); changed = changed || result.changed; return [key, result.normalized]; }) @@ -56,9 +97,12 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch return { normalized: value, changed: false }; } -export function normalizePluginMetadataContent(original: string): string { +export function normalizePluginMetadataContent( + original: string, + targetConfigDir = path.join(os.homedir(), '.claude') +): string { const parsed = JSON.parse(original) as unknown; - const result = normalizePluginMetadataValue(parsed); + const result = normalizePluginMetadataValue(parsed, targetConfigDir); return result.changed ? JSON.stringify(result.normalized, null, 2) : original; } @@ -71,6 +115,12 @@ class SharedManager { private readonly claudeDir: string; private readonly instancesDir: string; private readonly sharedItems: SharedItem[]; + private readonly sharedPluginEntries: readonly SharedItem[] = [ + { name: 'cache', type: 'directory' }, + { name: 'marketplaces', type: 'directory' }, + { name: 'installed_plugins.json', type: 'file' }, + ]; + private readonly instanceLocalPluginMetadataFiles = new Set(['known_marketplaces.json']); private readonly advancedContinuityItems: readonly string[] = [ 'session-env', 'file-history', @@ -148,6 +198,8 @@ class SharedManager { fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); } + this.ensureSharedPluginLayoutDefaults(); + // Create symlinks ~/.ccs/shared/* → ~/.claude/* for (const item of this.sharedItems) { const claudePath = path.join(this.claudeDir, item.name); @@ -221,17 +273,15 @@ class SharedManager { this.ensureSharedDirectories(); for (const item of this.sharedItems) { + if (item.name === 'plugins') { + this.linkInstancePlugins(instancePath); + continue; + } + const linkPath = path.join(instancePath, item.name); const targetPath = path.join(this.sharedDir, item.name); - // Remove existing file/directory/link - if (fs.existsSync(linkPath)) { - if (item.type === 'directory') { - fs.rmSync(linkPath, { recursive: true, force: true }); - } else { - fs.unlinkSync(linkPath); - } - } + this.removeExistingPath(linkPath, item.type); // Create symlink try { @@ -257,6 +307,126 @@ class SharedManager { this.normalizeSharedPluginMetadataPaths(instancePath); } + private ensureSharedPluginLayoutDefaults(): void { + const pluginsDir = path.join(this.claudeDir, 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true, mode: 0o700 }); + + for (const entry of this.sharedPluginEntries) { + const entryPath = path.join(pluginsDir, entry.name); + if (fs.existsSync(entryPath)) { + continue; + } + + if (entry.type === 'directory') { + fs.mkdirSync(entryPath, { recursive: true, mode: 0o700 }); + continue; + } + + fs.writeFileSync(entryPath, DEFAULT_INSTALLED_PLUGIN_REGISTRY, 'utf8'); + } + + const marketplaceRegistryPath = path.join(pluginsDir, 'known_marketplaces.json'); + if (!fs.existsSync(marketplaceRegistryPath)) { + fs.writeFileSync(marketplaceRegistryPath, JSON.stringify({}, null, 2), 'utf8'); + } + } + + private linkInstancePlugins(instancePath: string): void { + const linkPath = path.join(instancePath, 'plugins'); + const targetPath = path.join(this.sharedDir, 'plugins'); + let linkStats: fs.Stats | null = null; + + try { + linkStats = fs.lstatSync(linkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) { + this.removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file'); + } + + if (!linkStats || !linkStats.isDirectory()) { + fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }); + } + + for (const item of this.getSharedPluginLinkItems()) { + const targetEntryPath = path.join(targetPath, item.name); + const linkEntryPath = path.join(linkPath, item.name); + + this.removeExistingPath(linkEntryPath, item.type); + + try { + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType); + } catch (_err) { + if (process.platform === 'win32') { + if (item.type === 'directory') { + this.copyDirectoryFallback(targetEntryPath, linkEntryPath); + } else { + fs.copyFileSync(targetEntryPath, linkEntryPath); + } + console.log( + warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; + } + } + } + } + + private getSharedPluginLinkItems(): SharedItem[] { + const sharedPluginsPath = path.join(this.sharedDir, 'plugins'); + const items = new Map( + this.sharedPluginEntries.map((entry) => [entry.name, { ...entry }]) + ); + + for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) { + if (items.has(entry.name) || this.instanceLocalPluginMetadataFiles.has(entry.name)) { + continue; + } + + const entryPath = path.join(sharedPluginsPath, entry.name); + const stats = fs.statSync(entryPath); + items.set(entry.name, { + name: entry.name, + type: stats.isDirectory() ? 'directory' : 'file', + }); + } + + return [...items.values()]; + } + + private removeExistingPath(targetPath: string, typeHint: SharedItem['type']): void { + try { + const stats = fs.lstatSync(targetPath); + if (stats.isDirectory() && !stats.isSymbolicLink()) { + fs.rmSync(targetPath, { recursive: true, force: true }); + return; + } + + if (stats.isSymbolicLink() || typeHint === 'file') { + fs.unlinkSync(targetPath); + return; + } + + fs.rmSync(targetPath, { recursive: true, force: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + + if (typeHint === 'directory') { + fs.rmSync(targetPath, { recursive: true, force: true }); + } else { + fs.rmSync(targetPath, { force: true }); + } + } + } + /** * Sync project workspace context based on account policy. * @@ -583,7 +753,7 @@ class SharedManager { } /** - * Normalize shared plugin metadata files to canonical ~/.claude/ paths. + * Normalize plugin metadata and reconcile marketplace metadata for the active config dir. */ normalizeSharedPluginMetadataPaths(configDir?: string): void { this.normalizePluginRegistryPaths(configDir); @@ -607,19 +777,31 @@ class SharedManager { } /** - * 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. + * Reconcile marketplace registry content into the active config dir while + * keeping the global ~/.claude copy up to date for non-instance flows. */ normalizeMarketplaceRegistryPaths(configDir?: string): void { - this.normalizePluginMetadataFiles( - 'known_marketplaces.json', - configDir, - 'Normalized marketplace registry paths', - 'marketplace registry' - ); + const successMessage = 'Synchronized marketplace registry paths'; + const warningLabel = 'marketplace registry'; + + try { + const sourcePaths = this.getMarketplaceRegistrySourcePaths(configDir); + this.writePluginMetadataFile( + path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), + this.buildMarketplaceRegistryContent(sourcePaths, this.claudeDir), + successMessage + ); + + if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { + this.writePluginMetadataFile( + path.join(configDir, 'plugins', 'known_marketplaces.json'), + this.buildMarketplaceRegistryContent(sourcePaths, configDir), + successMessage + ); + } + } catch (err) { + console.log(warn(`Could not synchronize ${warningLabel}: ${(err as Error).message}`)); + } } private normalizePluginMetadataFiles( @@ -676,6 +858,107 @@ class SharedManager { } } + private getMarketplaceRegistrySourcePaths(configDir?: string): string[] { + const sourcePaths = new Set([ + path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), + ]); + + if (fs.existsSync(this.instancesDir)) { + for (const entry of fs.readdirSync(this.instancesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + sourcePaths.add( + path.join(this.instancesDir, entry.name, 'plugins', 'known_marketplaces.json') + ); + } + } + + if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { + sourcePaths.add(path.join(configDir, 'plugins', 'known_marketplaces.json')); + } + + return [...sourcePaths]; + } + + private buildMarketplaceRegistryContent(sourcePaths: string[], targetConfigDir: string): string { + const merged: Record = {}; + + for (const registryPath of sourcePaths) { + if (!fs.existsSync(registryPath)) { + continue; + } + + try { + const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue; + } + + for (const [name, value] of Object.entries(parsed as Record)) { + merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; + } + } catch (_err) { + // Best-effort merge: malformed sources should not block self-heal. + } + } + + for (const [name, value] of Object.entries(this.discoverMarketplaceEntries(targetConfigDir))) { + const existing = merged[name]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + merged[name] = { + ...(existing as Record), + installLocation: value.installLocation, + }; + continue; + } + + merged[name] = value; + } + + return JSON.stringify(merged, null, 2); + } + + private discoverMarketplaceEntries( + targetConfigDir: string + ): Record { + const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces'); + if (!fs.existsSync(marketplacesDir)) { + return {}; + } + + const discovered: Record = {}; + + for (const entry of fs.readdirSync(marketplacesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + discovered[entry.name] = { + installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name), + }; + } + + return discovered; + } + + private writePluginMetadataFile( + registryPath: string, + content: string, + successMessage: string + ): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 }); + const current = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null; + + if (current === content) { + return; + } + + fs.writeFileSync(registryPath, content, 'utf8'); + console.log(ok(successMessage)); + } + /** * Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/) * Runs once on upgrade diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 1a39e803..847b37b3 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -11,6 +11,12 @@ describe('InstanceManager MCP sync', () => { let originalCcsHome: string | undefined; let originalCcsDir: string | undefined; + const claudeDir = () => path.join(tempRoot, '.claude'); + const marketplacePath = (configDir: string, name = 'claude-code-plugins') => + path.join(configDir, 'plugins', 'marketplaces', name); + const readJson = (filePath: string) => + JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + function writeMarketplaceRegistry(registryPath: string, installLocation: string): void { fs.mkdirSync(path.dirname(registryPath), { recursive: true }); fs.writeFileSync( @@ -28,6 +34,11 @@ describe('InstanceManager MCP sync', () => { ); } + function expectMarketplaceLocation(registryPath: string, expectedLocation: string): void { + const parsed = readJson(registryPath) as Record; + expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation); + } + beforeEach(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-')); originalHome = process.env.HOME; @@ -95,9 +106,10 @@ describe('InstanceManager MCP sync', () => { const synced = manager.syncMcpServers(instancePath); expect(synced).toBe(true); - const instanceContent = JSON.parse( - fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') - ); + const instanceContent = readJson(path.join(instancePath, '.claude.json')) as { + otherKey: string; + mcpServers: Record; + }; expect(instanceContent.otherKey).toBe('keep-me'); expect(instanceContent.mcpServers).toEqual({ globalOnly: { command: 'global-cmd' }, @@ -134,9 +146,9 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = manager.getInstancePath('sandbox'); - const sharedRegistryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json'); + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); writeMarketplaceRegistry( - sharedRegistryPath, + globalRegistryPath, path.join( tempRoot, '.ccs', @@ -150,20 +162,16 @@ describe('InstanceManager MCP sync', () => { await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true }); - const normalized = JSON.parse(fs.readFileSync(sharedRegistryPath, 'utf8')); - expect(linkSharedSpy).not.toHaveBeenCalled(); expect(fs.existsSync(instancePath)).toBe(true); - expect(normalized['claude-code-plugins'].installLocation).toBe( - path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') - ); + expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe( false ); expect(syncMcpSpy).not.toHaveBeenCalled(); }); - it('normalizes shared plugin metadata for existing non-bare instances', async () => { + it('rewrites existing non-bare instance marketplace metadata to the instance-local plugin dir', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( @@ -174,38 +182,28 @@ describe('InstanceManager MCP sync', () => { const instancePath = manager.getInstancePath('work'); writeMarketplaceRegistry( path.join(instancePath, 'plugins', 'known_marketplaces.json'), - path.join( - tempRoot, - '.ccs', - 'instances', - 'work', - 'plugins', - 'marketplaces', - 'claude-code-plugins' - ) + path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') ); await manager.ensureInstance('work', { mode: 'isolated' }); - const normalized = JSON.parse( - fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8') - ); - expect(normalized['claude-code-plugins'].installLocation).toBe( - path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath) ); expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); - it('normalizes shared plugin metadata during new non-bare instance creation', async () => { + it('writes new non-bare instance marketplace metadata without clobbering the global copy', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( () => false ); - const registryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json'); + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); writeMarketplaceRegistry( - registryPath, + globalRegistryPath, path.join( tempRoot, '.ccs', @@ -220,20 +218,51 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); - const expected = path.join( - tempRoot, - '.claude', - 'plugins', - 'marketplaces', - 'claude-code-plugins' + expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath) ); - const normalizedShared = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - const normalizedInstance = JSON.parse( - fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8') - ); - - expect(normalizedShared['claude-code-plugins'].installLocation).toBe(expected); - expect(normalizedInstance['claude-code-plugins'].installLocation).toBe(expected); expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); + + it('keeps alternating instances independently valid for Claude Code validation', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeMarketplaceRegistry( + globalRegistryPath, + path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') + ); + + const manager = new InstanceManager(); + const workPath = await manager.ensureInstance('work', { mode: 'isolated' }); + const personalPath = await manager.ensureInstance('personal', { mode: 'isolated' }); + await manager.ensureInstance('work', { mode: 'isolated' }); + + const workInstallLocation = ( + readJson(path.join(workPath, 'plugins', 'known_marketplaces.json')) as Record< + string, + { installLocation?: string } + > + )['claude-code-plugins']?.installLocation; + const personalInstallLocation = ( + readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record< + string, + { installLocation?: string } + > + )['claude-code-plugins']?.installLocation; + + expect(workInstallLocation).toBe(marketplacePath(workPath)); + expect(personalInstallLocation).toBe(marketplacePath(personalPath)); + expect(path.resolve(workInstallLocation ?? '')).toStartWith( + path.resolve(path.join(workPath, 'plugins', 'marketplaces')) + ); + expect(path.resolve(personalInstallLocation ?? '')).toStartWith( + path.resolve(path.join(personalPath, 'plugins', 'marketplaces')) + ); + expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); + }); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 96ee5eee..9bffc9b4 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -1,19 +1,12 @@ -/** - * Unit tests for SharedManager - plugin registry path normalization - */ -import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; -import * as path from 'path'; import * as os from 'os'; +import * as path from 'path'; import SharedManager, { + normalizePluginMetadataContent, normalizePluginMetadataPathString, } from '../../src/management/shared-manager'; -// Test the normalization regex pattern directly -const normalizePluginPaths = (content: string): string => { - return normalizePluginMetadataPathString(content); -}; - describe('SharedManager', () => { let tempRoot = ''; let originalHome: string | undefined; @@ -21,6 +14,24 @@ describe('SharedManager', () => { let originalCcsDir: string | undefined; let originalPlatform: PropertyDescriptor | undefined; + const claudeDir = () => path.join(tempRoot, '.claude'); + const ccsDir = () => path.join(tempRoot, '.ccs'); + const instanceDir = (name: string) => path.join(ccsDir(), 'instances', name); + const marketplacePath = (configDir: string, name = 'claude-code-plugins') => + path.join(configDir, 'plugins', 'marketplaces', name); + const readJson = (filePath: string) => + JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record; + + function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); + } + + function readMarketplaceLocation(filePath: string, name = 'claude-code-plugins'): string { + const parsed = readJson(filePath) as Record; + return parsed[name]?.installLocation ?? ''; + } + beforeEach(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-')); originalHome = process.env.HOME; @@ -55,218 +66,173 @@ describe('SharedManager', () => { } }); - describe('normalizePluginRegistryPaths', () => { - describe('regex pattern', () => { - it('should replace instance paths with canonical claude path', () => { - const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; - const expected = '/home/user/.claude/plugins/cache/plugin/0.0.2'; - expect(normalizePluginPaths(input)).toBe(expected); - }); + describe('plugin metadata path normalization', () => { + it('rewrites instance plugin paths to the requested target config dir', () => { + const targetConfigDir = path.join('/home/user', '.claude'); + const input = '/home/user/.ccs/instances/work/plugins/cache/plugin/0.0.2'; - it('should handle different instance names', () => { - const inputs = [ - '/home/user/.ccs/instances/work/plugins/cache/plugin/1.0.0', - '/home/user/.ccs/instances/personal/plugins/cache/plugin/1.0.0', - '/home/user/.ccs/instances/test-account/plugins/cache/plugin/1.0.0', - ]; - for (const input of inputs) { - expect(normalizePluginPaths(input)).toContain('/.claude/'); - expect(normalizePluginPaths(input)).not.toContain('/.ccs/instances/'); - } - }); + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe( + '/home/user/.claude/plugins/cache/plugin/0.0.2' + ); + }); - it('should handle multiple occurrences', () => { - const input = JSON.stringify({ + it('rewrites shared plugin paths to an instance-local target config dir', () => { + const targetConfigDir = instanceDir('personal'); + const input = path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'official'); + + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe( + marketplacePath(targetConfigDir, 'official') + ); + }); + + it('normalizes all matching JSON string values without changing the structure', () => { + const targetConfigDir = instanceDir('work'); + const input = JSON.stringify( + { plugins: { - 'plugin-a': [{ installPath: '/home/user/.ccs/instances/ck/plugins/a' }], - 'plugin-b': [{ installPath: '/home/user/.ccs/instances/work/plugins/b' }], - }, - }); - const result = normalizePluginPaths(input); - expect(result).not.toContain('/.ccs/instances/'); - expect(result.match(/\.claude/g)?.length).toBe(2); - }); - - it('should not modify already-canonical paths', () => { - const input = '/home/user/.claude/plugins/cache/plugin/0.0.2'; - expect(normalizePluginPaths(input)).toBe(input); - }); - - it('should be idempotent', () => { - const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; - const first = normalizePluginPaths(input); - const second = normalizePluginPaths(first); - expect(first).toBe(second); - }); - - it('should preserve JSON structure', () => { - const original = { - version: 2, - plugins: { - 'claude-hud@claude-hud': [ + 'plugin-a': [ { - scope: 'user', - installPath: - '/home/kai/.ccs/instances/ck/plugins/cache/claude-hud/claude-hud/0.0.2', - version: '0.0.2', + installPath: path.join( + tempRoot, + '.ccs', + 'instances', + 'old', + 'plugins', + 'cache', + 'plugin-a' + ), }, ], }, - }; - const input = JSON.stringify(original, null, 2); - const result = normalizePluginPaths(input); - - // Should be valid JSON - expect(() => JSON.parse(result)).not.toThrow(); - - // Should have normalized path - const parsed = JSON.parse(result); - expect(parsed.plugins['claude-hud@claude-hud'][0].installPath).toBe( - '/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', + marketplaces: { + official: { + installLocation: path.join( + tempRoot, + '.claude', + 'plugins', + 'marketplaces', + 'official' + ), + }, }, - }; - const input = JSON.stringify(original, null, 2); - const result = normalizePluginPaths(input); + }, + null, + 2 + ); - expect(() => JSON.parse(result)).not.toThrow(); + const normalized = JSON.parse(normalizePluginMetadataContent(input, targetConfigDir)) as { + plugins: { 'plugin-a': [{ installPath: string }] }; + marketplaces: { official: { installLocation: string } }; + }; - const parsed = JSON.parse(result); - expect(parsed['claude-code-plugins'].installLocation).toBe( - '/home/kai/.claude/plugins/marketplaces/claude-code-plugins' - ); - }); + expect(normalized.plugins['plugin-a'][0].installPath).toBe( + path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a') + ); + expect(normalized.marketplaces.official.installLocation).toBe( + marketplacePath(targetConfigDir, 'official') + ); }); - describe('edge cases', () => { - it('should handle empty object', () => { - const input = JSON.stringify({}); - expect(normalizePluginPaths(input)).toBe(input); - }); + it('preserves paths already rooted at the target config dir', () => { + const targetConfigDir = instanceDir('work'); + const input = path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a'); - it('should handle plugins without installPath', () => { - const input = JSON.stringify({ plugins: {} }); - expect(normalizePluginPaths(input)).toBe(input); - }); + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(input); + }); - it('should handle Windows-style paths (backslash)', () => { - const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache'; - expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache'); - }); + it('handles Windows path separators', () => { + const targetConfigDir = 'C:\\Users\\user\\.claude'; + const input = 'C:\\Users\\user\\.ccs\\instances\\work\\plugins\\marketplaces\\official'; + + expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe( + 'C:\\Users\\user\\.claude\\plugins\\marketplaces\\official' + ); }); }); - describe('normalizeMarketplaceRegistryPaths', () => { - it('rewrites known_marketplaces.json on disk', () => { - const pluginsDir = path.join(tempRoot, '.claude', 'plugins'); - fs.mkdirSync(pluginsDir, { recursive: true }); + describe('marketplace registry ownership', () => { + it('writes global and instance registries with different authoritative install locations', () => { + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'claude-code-plugins': { + installLocation: path.join( + tempRoot, + '.ccs', + 'instances', + 'work', + 'plugins', + 'marketplaces', + 'claude-code-plugins' + ), + }, + }); - 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 instancePath = instanceDir('personal'); + fs.mkdirSync(instancePath, { recursive: true }); const manager = new SharedManager(); - manager.normalizeMarketplaceRegistryPaths(); + manager.linkSharedDirectories(instancePath); - const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - expect(normalized['claude-code-plugins'].installLocation).toBe( - '/home/kai/.claude/plugins/marketplaces/claude-code-plugins' - ); + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir())); + expect(readMarketplaceLocation(instanceRegistryPath)).toBe(marketplacePath(instancePath)); + expect(fs.lstatSync(path.join(instancePath, 'plugins')).isSymbolicLink()).toBe(false); + expect(fs.lstatSync(instanceRegistryPath).isSymbolicLink()).toBe(false); }); - it('rewrites Windows-style known_marketplaces.json paths 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: - 'C:\\Users\\kai\\.ccs\\instances\\work\\plugins\\marketplaces\\claude-code-plugins', - }, - }, - null, - 2 - ), - 'utf8' - ); - + it('self-heals missing installLocation from discovered marketplace payloads', () => { const manager = new SharedManager(); - manager.normalizeMarketplaceRegistryPaths(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); - const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - expect(normalized['claude-code-plugins'].installLocation).toBe( - 'C:\\Users\\kai\\.claude\\plugins\\marketplaces\\claude-code-plugins' - ); + fs.mkdirSync(marketplacePath(claudeDir()), { recursive: true }); + writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), { + 'claude-code-plugins': { + label: 'Official marketplace', + }, + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const repaired = readJson( + path.join(instancePath, 'plugins', 'known_marketplaces.json') + ) as Record; + expect(repaired['claude-code-plugins']).toEqual({ + label: 'Official marketplace', + installLocation: marketplacePath(instancePath), + }); }); - it('normalizes copied shared and instance metadata under Windows fallback', () => { + it('keeps the instance-local registry valid under Windows copy fallback', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); spyOn(fs, 'symlinkSync').mockImplementation(() => { throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' }); }); - const pluginsDir = path.join(tempRoot, '.claude', 'plugins'); - fs.mkdirSync(pluginsDir, { recursive: true }); + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'claude-code-plugins': { + installLocation: path.join( + tempRoot, + '.claude', + 'plugins', + 'marketplaces', + 'claude-code-plugins' + ), + }, + }); - 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 instancePath = instanceDir('personal'); + fs.mkdirSync(instancePath, { recursive: true }); const manager = new SharedManager(); - const instancePath = path.join(tempRoot, '.ccs', 'instances', 'personal'); - fs.mkdirSync(instancePath, { recursive: true }); manager.linkSharedDirectories(instancePath); - const expected = '/home/kai/.claude/plugins/marketplaces/claude-code-plugins'; - const claudeRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); - const sharedRegistry = JSON.parse( - fs.readFileSync( - path.join(tempRoot, '.ccs', 'shared', 'plugins', 'known_marketplaces.json'), - 'utf8' - ) - ); - const instanceRegistry = JSON.parse( - fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8') - ); - - expect(claudeRegistry['claude-code-plugins'].installLocation).toBe(expected); - expect(sharedRegistry['claude-code-plugins'].installLocation).toBe(expected); - expect(instanceRegistry['claude-code-plugins'].installLocation).toBe(expected); + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir())); + expect(readMarketplaceLocation(instanceRegistryPath)).toBe(marketplacePath(instancePath)); + expect(fs.existsSync(path.join(instancePath, 'plugins', 'marketplaces'))).toBe(true); }); }); });