fix(shared-manager): normalize marketplace registry paths

This commit is contained in:
Tam Nhu Tran
2026-03-17 09:56:31 -04:00
parent 7b1d7baefd
commit 2cb5b4ebec
4 changed files with 149 additions and 11 deletions
+1
View File
@@ -60,6 +60,7 @@ class InstanceManager {
// Sync MCP servers from global ~/.claude.json (unless bare)
if (!options.bare) {
this.sharedManager.normalizeSharedPluginMetadataPaths();
this.syncMcpServers(instancePath);
}
+37 -10
View File
@@ -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/<name>/ 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/<name>/ 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/<instance-name>/ -> /.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}`));
}
}
@@ -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);
});
});
+85 -1
View File
@@ -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'
);
});
});
});