import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { InstanceManager } from '../../src/management/instance-manager'; import SharedManager from '../../src/management/shared-manager'; describe('InstanceManager MCP sync', () => { let tempRoot = ''; let originalHome: string | undefined; 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 ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void { fs.mkdirSync(marketplacePath(configDir, name), { recursive: true }); } function writeMarketplaceRegistry(registryPath: string, installLocation: string): void { fs.mkdirSync(path.dirname(registryPath), { recursive: true }); fs.writeFileSync( registryPath, JSON.stringify( { 'claude-code-plugins': { installLocation, }, }, null, 2 ), 'utf8' ); } function writeMarketplaceRegistryWithMetadata( registryPath: string, installLocation: string, metadata: Record ): void { fs.mkdirSync(path.dirname(registryPath), { recursive: true }); fs.writeFileSync( registryPath, JSON.stringify( { 'claude-code-plugins': { installLocation, ...metadata, }, }, null, 2 ), 'utf8' ); } 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; 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 }); } }); it('merges global MCP servers and preserves instance-specific overrides', () => { fs.writeFileSync( path.join(tempRoot, '.claude.json'), JSON.stringify( { mcpServers: { globalOnly: { command: 'global-cmd' }, shared: { command: 'global-shared' }, }, }, null, 2 ), 'utf8' ); const manager = new InstanceManager(); const instancePath = manager.getInstancePath('work'); fs.mkdirSync(instancePath, { recursive: true }); fs.writeFileSync( path.join(instancePath, '.claude.json'), JSON.stringify( { mcpServers: { shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }, otherKey: 'keep-me', }, null, 2 ), 'utf8' ); const synced = manager.syncMcpServers(instancePath); expect(synced).toBe(true); 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' }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }); }); it('repairs managed ccs-websearch and ccs-browser entries from global config while preserving other instance MCP overrides', () => { fs.writeFileSync( path.join(tempRoot, '.claude.json'), JSON.stringify( { mcpServers: { 'ccs-websearch': { type: 'stdio', command: 'node', args: ['/global/server.cjs'], env: {}, }, 'ccs-browser': { type: 'stdio', command: 'node', args: ['/global/browser-server.cjs'], env: {}, }, shared: { command: 'global-shared' }, }, }, null, 2 ), 'utf8' ); const manager = new InstanceManager(); const instancePath = manager.getInstancePath('work'); fs.mkdirSync(instancePath, { recursive: true }); fs.writeFileSync( path.join(instancePath, '.claude.json'), JSON.stringify( { mcpServers: { 'ccs-websearch': { type: 'stdio', command: 'node', args: ['/old/server.cjs'], env: {}, }, 'ccs-browser': { type: 'stdio', command: 'node', args: ['/old/browser-server.cjs'], env: {}, }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }, }, null, 2 ), 'utf8' ); expect(manager.syncMcpServers(instancePath)).toBe(true); const instanceContent = readJson(path.join(instancePath, '.claude.json')) as { mcpServers: Record; }; expect(instanceContent.mcpServers).toEqual({ 'ccs-websearch': { type: 'stdio', command: 'node', args: ['/global/server.cjs'], env: {}, }, 'ccs-browser': { type: 'stdio', command: 'node', args: ['/global/browser-server.cjs'], env: {}, }, shared: { command: 'instance-shared' }, instanceOnly: { command: 'instance-only' }, }); }); it('preserves existing instance .claude.json permissions when syncing managed MCP servers', () => { fs.writeFileSync( path.join(tempRoot, '.claude.json'), JSON.stringify( { mcpServers: { 'ccs-browser': { type: 'stdio', command: 'node', args: ['/global/browser.cjs'], env: {}, }, }, }, null, 2 ), 'utf8' ); const manager = new InstanceManager(); const instancePath = manager.getInstancePath('work'); fs.mkdirSync(instancePath, { recursive: true }); const instanceClaudeJson = path.join(instancePath, '.claude.json'); fs.writeFileSync(instanceClaudeJson, JSON.stringify({ existing: true }, null, 2) + '\n', { encoding: 'utf8', mode: 0o640, }); fs.chmodSync(instanceClaudeJson, 0o640); expect(manager.syncMcpServers(instancePath)).toBe(true); expect(fs.statSync(instanceClaudeJson).mode & 0o777).toBe(0o640); }); it('logs warning when global MCP sync fails', () => { fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8'); const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); const manager = new InstanceManager(); const instancePath = manager.getInstancePath('work'); fs.mkdirSync(instancePath, { recursive: true }); const synced = manager.syncMcpServers(instancePath); expect(synced).toBe(false); expect(warnSpy).toHaveBeenCalledTimes(1); expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped'); }); it('does not list lock housekeeping as an instance', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false); const manager = new InstanceManager(); await manager.ensureInstance('work', { mode: 'isolated' }); expect(manager.listInstances()).toEqual(['work']); }); it('skips shared symlinks and MCP sync for bare instance creation', async () => { const linkSharedSpy = spyOn( SharedManager.prototype, 'linkSharedDirectories' ).mockImplementation(() => {}); spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( () => false ); const manager = new InstanceManager(); const instancePath = manager.getInstancePath('sandbox'); const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( globalRegistryPath, path.join( tempRoot, '.ccs', 'instances', 'work', 'plugins', 'marketplaces', 'claude-code-plugins' ) ); await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true }); expect(linkSharedSpy).not.toHaveBeenCalled(); expect(fs.existsSync(instancePath)).toBe(true); expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe( false ); expect(syncMcpSpy).not.toHaveBeenCalled(); }); it('detaches existing shared layout when an instance is reopened as bare', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( () => false ); const manager = new InstanceManager(); const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); syncMcpSpy.mockClear(); await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true }); expect(fs.existsSync(path.join(instancePath, 'settings.json'))).toBe(false); expect(fs.existsSync(path.join(instancePath, 'commands'))).toBe(false); expect(fs.existsSync(path.join(instancePath, 'skills'))).toBe(false); expect(fs.existsSync(path.join(instancePath, 'agents'))).toBe(false); expect(fs.existsSync(path.join(instancePath, 'plugins'))).toBe(false); expect(syncMcpSpy).not.toHaveBeenCalled(); }); it('restores the shared layout when a bare-reopened instance is switched back to non-bare', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( () => false ); const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry(globalRegistryPath, marketplacePath(claudeDir())); const manager = new InstanceManager(); const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true }); syncMcpSpy.mockClear(); await manager.ensureInstance('work', { mode: 'isolated' }); expect(fs.lstatSync(path.join(instancePath, 'settings.json')).isSymbolicLink()).toBe(true); expectMarketplaceLocation( path.join(instancePath, 'plugins', 'known_marketplaces.json'), marketplacePath(instancePath) ); expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); it('preserves genuine bare-local content when re-ensuring a bare instance', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( () => false ); const manager = new InstanceManager(); const instancePath = manager.getInstancePath('sandbox'); fs.mkdirSync(path.join(instancePath, 'plugins', 'marketplaces', 'custom-market'), { recursive: true, }); fs.mkdirSync(path.join(instancePath, 'commands'), { recursive: true }); fs.writeFileSync( path.join(instancePath, 'settings.json'), JSON.stringify({ local: true }, null, 2), 'utf8' ); fs.writeFileSync(path.join(instancePath, 'commands', 'local.md'), '# local', 'utf8'); writeMarketplaceRegistry( path.join(instancePath, 'plugins', 'known_marketplaces.json'), marketplacePath(instancePath, 'custom-market') ); await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true }); expect(readJson(path.join(instancePath, 'settings.json'))).toEqual({ local: true }); expect(fs.readFileSync(path.join(instancePath, 'commands', 'local.md'), 'utf8')).toBe( '# local' ); expectMarketplaceLocation( path.join(instancePath, 'plugins', 'known_marketplaces.json'), marketplacePath(instancePath, 'custom-market') ); expect(syncMcpSpy).not.toHaveBeenCalled(); }); 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( () => false ); const manager = new InstanceManager(); const instancePath = manager.getInstancePath('work'); ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( path.join(instancePath, 'plugins', 'known_marketplaces.json'), path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins') ); await manager.ensureInstance('work', { mode: 'isolated' }); expectMarketplaceLocation( path.join(instancePath, 'plugins', 'known_marketplaces.json'), marketplacePath(instancePath) ); expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); 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 globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( globalRegistryPath, path.join( tempRoot, '.ccs', 'instances', 'work', 'plugins', 'marketplaces', 'claude-code-plugins' ) ); const manager = new InstanceManager(); const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); expectMarketplaceLocation( path.join(instancePath, 'plugins', 'known_marketplaces.json'), marketplacePath(instancePath) ); expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); it('reconciles marketplace metadata across isolated instances without losing refresh fields', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const manager = new InstanceManager(); ensureMarketplacePayload(claudeDir()); const workPath = await manager.ensureInstance('work', { mode: 'isolated' }); const workRegistryPath = path.join(workPath, 'plugins', 'known_marketplaces.json'); writeMarketplaceRegistryWithMetadata(workRegistryPath, marketplacePath(workPath), { label: 'Official marketplace', refreshToken: 'refresh-token', metadata: { source: 'refresh-flow', lastSyncedAt: '2026-03-18T00:00:00Z', }, }); const personalPath = await manager.ensureInstance('personal', { mode: 'isolated' }); const workRegistry = readJson(workRegistryPath) as Record< string, { installLocation?: string; label?: string; refreshToken?: string; metadata?: Record; } >; const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record< string, { installLocation?: string; label?: string; refreshToken?: string; metadata?: Record; } >; expect(workRegistry['claude-code-plugins']).toMatchObject({ installLocation: marketplacePath(workPath), label: 'Official marketplace', refreshToken: 'refresh-token', metadata: { source: 'refresh-flow', lastSyncedAt: '2026-03-18T00:00:00Z', }, }); expect(personalRegistry['claude-code-plugins']).toMatchObject({ installLocation: marketplacePath(personalPath), label: 'Official marketplace', refreshToken: 'refresh-token', metadata: { source: 'refresh-flow', lastSyncedAt: '2026-03-18T00:00:00Z', }, }); }); it('upgrades a legacy shared plugins symlink to an instance-local layout', async () => { spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); const manager = new InstanceManager(); const legacyPath = manager.getInstancePath('legacy'); const sharedPluginsPath = path.join(tempRoot, '.ccs', 'shared', 'plugins'); fs.mkdirSync(sharedPluginsPath, { recursive: true }); fs.mkdirSync(legacyPath, { recursive: true }); fs.symlinkSync(sharedPluginsPath, path.join(legacyPath, 'plugins'), 'dir'); ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistryWithMetadata( path.join(claudeDir(), 'plugins', 'known_marketplaces.json'), path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'claude-code-plugins'), { label: 'Legacy marketplace', refreshToken: 'legacy-refresh-token', } ); await manager.ensureInstance('legacy', { mode: 'isolated' }); expect(fs.lstatSync(path.join(legacyPath, 'plugins')).isSymbolicLink()).toBe(false); expectMarketplaceLocation( path.join(legacyPath, 'plugins', 'known_marketplaces.json'), marketplacePath(legacyPath) ); const legacyRegistry = readJson( path.join(legacyPath, 'plugins', 'known_marketplaces.json') ) as Record< string, { installLocation?: string; label?: string; refreshToken?: string } >; expect(legacyRegistry['claude-code-plugins']).toMatchObject({ installLocation: marketplacePath(legacyPath), label: 'Legacy marketplace', refreshToken: 'legacy-refresh-token', }); }); });