From 68a5d17327e4fc5e3bd5c9fcb48e1bcd96dd92c4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 07:34:05 -0400 Subject: [PATCH] fix(management): serialize marketplace registry reconciliation - keep bare and non-bare marketplace normalization inside the plugin-layout lock - warn when malformed registry sources are skipped during reconciliation - add regression coverage for cross-instance refresh metadata and legacy layout upgrade --- src/management/instance-manager.ts | 13 +- src/management/shared-manager.ts | 6 +- tests/unit/instance-manager-mcp-sync.test.ts | 135 +++++++++++++++---- tests/unit/shared-manager.test.ts | 38 ++++++ 4 files changed, 156 insertions(+), 36 deletions(-) diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 7f3af249..d6e2f141 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -59,14 +59,15 @@ class InstanceManager { await this.sharedManager.syncProjectContext(instancePath, contextPolicy); await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy); - if (!options.bare) { - await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + if (!options.bare) { this.sharedManager.linkSharedDirectories(instancePath); - }); - } - }); + return; + } - this.sharedManager.normalizeSharedPluginMetadataPaths(options.bare ? undefined : instancePath); + this.sharedManager.normalizeSharedPluginMetadataPaths(); + }); + }); // Sync MCP servers from global ~/.claude.json (unless bare) if (!options.bare) { diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 8df6e3e9..7e87c198 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -899,8 +899,10 @@ class SharedManager { 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. + } catch (err) { + console.log( + warn(`Skipping malformed marketplace registry ${registryPath}: ${(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 847b37b3..fea00791 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -34,6 +34,28 @@ describe('InstanceManager MCP sync', () => { ); } + 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); @@ -226,43 +248,100 @@ describe('InstanceManager MCP sync', () => { expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); }); - it('keeps alternating instances independently valid for Claude Code validation', async () => { + it('reconciles marketplace metadata across isolated instances without losing refresh fields', 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 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' }); - await manager.ensureInstance('work', { 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; + } + >; - 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(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', + }, + }); + }); - expect(workInstallLocation).toBe(marketplacePath(workPath)); - expect(personalInstallLocation).toBe(marketplacePath(personalPath)); - expect(path.resolve(workInstallLocation ?? '')).toStartWith( - path.resolve(path.join(workPath, 'plugins', 'marketplaces')) + 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'); + + 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', + } ); - expect(path.resolve(personalInstallLocation ?? '')).toStartWith( - path.resolve(path.join(personalPath, 'plugins', 'marketplaces')) + + 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) ); - expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir())); + + 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', + }); }); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 9bffc9b4..88caf1f2 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -204,6 +204,44 @@ describe('SharedManager', () => { }); }); + it('warns and skips malformed marketplace registries while keeping valid sources', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + 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' + ), + label: 'Official marketplace', + }, + }); + + const malformedRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + fs.writeFileSync(malformedRegistryPath, '{invalid-json', 'utf8'); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + expect(readMarketplaceLocation(malformedRegistryPath)).toBe(marketplacePath(instancePath)); + expect( + logSpy.mock.calls.some( + ([message]) => + String(message).includes('Skipping malformed marketplace registry') && + String(message).includes(malformedRegistryPath) + ) + ).toBe(true); + }); + it('keeps the instance-local registry valid under Windows copy fallback', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); spyOn(fs, 'symlinkSync').mockImplementation(() => {