fix(management): localize marketplace registry per instance

This commit is contained in:
Tam Nhu Tran
2026-03-18 07:08:09 -04:00
parent 88f9532eb1
commit 54ea36fd18
5 changed files with 552 additions and 265 deletions
+11 -6
View File
@@ -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}`
+7 -3
View File
@@ -106,8 +106,8 @@ class ProfileContextSyncLock {
return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw);
}
async withLock<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
const lockPath = this.getLockPath(profileName);
async withNamedLock<T>(lockName: string, callback: () => Promise<T>): Promise<T> {
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<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
return this.withNamedLock(profileName, callback);
}
}
export default ProfileContextSyncLock;
+313 -30
View File
@@ -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<string, unknown>).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<string, SharedItem>(
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/<name>/ 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<string>([
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<string, unknown> = {};
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<string, unknown>)) {
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<string, unknown>),
installLocation: value.installLocation,
};
continue;
}
merged[name] = value;
}
return JSON.stringify(merged, null, 2);
}
private discoverMarketplaceEntries(
targetConfigDir: string
): Record<string, { installLocation: string }> {
const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces');
if (!fs.existsSync(marketplacesDir)) {
return {};
}
const discovered: Record<string, { installLocation: string }> = {};
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
+70 -41
View File
@@ -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<string, unknown>;
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<string, { installLocation?: string }>;
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<string, { command: string }>;
};
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()));
});
});
+151 -185
View File
@@ -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<string, unknown>;
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<string, { installLocation?: string }>;
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<string, { label?: string; installLocation?: string }>;
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);
});
});
});