mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(management): localize marketplace registry per instance
This commit is contained in:
@@ -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}`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user