mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +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 instancesDir: string;
|
||||||
private readonly sharedManager: SharedManager;
|
private readonly sharedManager: SharedManager;
|
||||||
private readonly contextSyncLock: ProfileContextSyncLock;
|
private readonly contextSyncLock: ProfileContextSyncLock;
|
||||||
|
private readonly pluginLayoutLock: ProfileContextSyncLock;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.instancesDir = path.join(getCcsDir(), 'instances');
|
this.instancesDir = path.join(getCcsDir(), 'instances');
|
||||||
this.sharedManager = new SharedManager();
|
this.sharedManager = new SharedManager();
|
||||||
this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir);
|
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).
|
// Apply context policy (isolated by default, optional shared group).
|
||||||
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
||||||
await this.sharedManager.syncAdvancedContinuityArtifacts(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)
|
// Sync MCP servers from global ~/.claude.json (unless bare)
|
||||||
if (!options.bare) {
|
if (!options.bare) {
|
||||||
@@ -82,7 +90,7 @@ class InstanceManager {
|
|||||||
private initializeInstance(
|
private initializeInstance(
|
||||||
profileName: string,
|
profileName: string,
|
||||||
instancePath: string,
|
instancePath: string,
|
||||||
options: InstanceOptions = {}
|
_options: InstanceOptions = {}
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
// Create base directory
|
// Create base directory
|
||||||
@@ -106,10 +114,7 @@ class InstanceManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bare profiles skip shared symlinks (commands, skills, agents, settings.json)
|
// Shared links are created during ensureInstance() under the plugin layout lock.
|
||||||
if (!options.bare) {
|
|
||||||
this.sharedManager.linkSharedDirectories(instancePath);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
|
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ class ProfileContextSyncLock {
|
|||||||
return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw);
|
return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
async withLock<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
|
async withNamedLock<T>(lockName: string, callback: () => Promise<T>): Promise<T> {
|
||||||
const lockPath = this.getLockPath(profileName);
|
const lockPath = this.getLockPath(lockName);
|
||||||
const retryDelayMs = 50;
|
const retryDelayMs = 50;
|
||||||
const staleLockMs = 30000;
|
const staleLockMs = 30000;
|
||||||
const timeoutMs = staleLockMs + 5000;
|
const timeoutMs = staleLockMs + 5000;
|
||||||
@@ -159,7 +159,7 @@ class ProfileContextSyncLock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Date.now() - start > timeoutMs) {
|
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));
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||||
@@ -172,6 +172,10 @@ class ProfileContextSyncLock {
|
|||||||
this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw);
|
this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async withLock<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
|
||||||
|
return this.withNamedLock(profileName, callback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProfileContextSyncLock;
|
export default ProfileContextSyncLock;
|
||||||
|
|||||||
@@ -18,23 +18,64 @@ interface SharedItem {
|
|||||||
type: 'directory' | 'file';
|
type: 'directory' | 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePluginMetadataPathString(input: string): string {
|
const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify(
|
||||||
return input.replace(
|
{
|
||||||
/([\\/])\.ccs\1instances\1[^\\/]+\1/g,
|
version: 2,
|
||||||
(_match, separator: string) => `${separator}.claude${separator}`
|
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') {
|
if (typeof value === 'string') {
|
||||||
const normalized = normalizePluginMetadataPathString(value);
|
const normalized = normalizePluginMetadataPathString(value, targetConfigDir);
|
||||||
return { normalized, changed: normalized !== value };
|
return { normalized, changed: normalized !== value };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const normalized = value.map((item) => {
|
const normalized = value.map((item) => {
|
||||||
const result = normalizePluginMetadataValue(item);
|
const result = normalizePluginMetadataValue(item, targetConfigDir);
|
||||||
changed = changed || result.changed;
|
changed = changed || result.changed;
|
||||||
return result.normalized;
|
return result.normalized;
|
||||||
});
|
});
|
||||||
@@ -45,7 +86,7 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
const normalized = Object.fromEntries(
|
const normalized = Object.fromEntries(
|
||||||
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
|
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
|
||||||
const result = normalizePluginMetadataValue(item);
|
const result = normalizePluginMetadataValue(item, targetConfigDir);
|
||||||
changed = changed || result.changed;
|
changed = changed || result.changed;
|
||||||
return [key, result.normalized];
|
return [key, result.normalized];
|
||||||
})
|
})
|
||||||
@@ -56,9 +97,12 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch
|
|||||||
return { normalized: value, changed: false };
|
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 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;
|
return result.changed ? JSON.stringify(result.normalized, null, 2) : original;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +115,12 @@ class SharedManager {
|
|||||||
private readonly claudeDir: string;
|
private readonly claudeDir: string;
|
||||||
private readonly instancesDir: string;
|
private readonly instancesDir: string;
|
||||||
private readonly sharedItems: SharedItem[];
|
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[] = [
|
private readonly advancedContinuityItems: readonly string[] = [
|
||||||
'session-env',
|
'session-env',
|
||||||
'file-history',
|
'file-history',
|
||||||
@@ -148,6 +198,8 @@ class SharedManager {
|
|||||||
fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.ensureSharedPluginLayoutDefaults();
|
||||||
|
|
||||||
// Create symlinks ~/.ccs/shared/* → ~/.claude/*
|
// Create symlinks ~/.ccs/shared/* → ~/.claude/*
|
||||||
for (const item of this.sharedItems) {
|
for (const item of this.sharedItems) {
|
||||||
const claudePath = path.join(this.claudeDir, item.name);
|
const claudePath = path.join(this.claudeDir, item.name);
|
||||||
@@ -221,17 +273,15 @@ class SharedManager {
|
|||||||
this.ensureSharedDirectories();
|
this.ensureSharedDirectories();
|
||||||
|
|
||||||
for (const item of this.sharedItems) {
|
for (const item of this.sharedItems) {
|
||||||
|
if (item.name === 'plugins') {
|
||||||
|
this.linkInstancePlugins(instancePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const linkPath = path.join(instancePath, item.name);
|
const linkPath = path.join(instancePath, item.name);
|
||||||
const targetPath = path.join(this.sharedDir, item.name);
|
const targetPath = path.join(this.sharedDir, item.name);
|
||||||
|
|
||||||
// Remove existing file/directory/link
|
this.removeExistingPath(linkPath, item.type);
|
||||||
if (fs.existsSync(linkPath)) {
|
|
||||||
if (item.type === 'directory') {
|
|
||||||
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
||||||
} else {
|
|
||||||
fs.unlinkSync(linkPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create symlink
|
// Create symlink
|
||||||
try {
|
try {
|
||||||
@@ -257,6 +307,126 @@ class SharedManager {
|
|||||||
this.normalizeSharedPluginMetadataPaths(instancePath);
|
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.
|
* 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 {
|
normalizeSharedPluginMetadataPaths(configDir?: string): void {
|
||||||
this.normalizePluginRegistryPaths(configDir);
|
this.normalizePluginRegistryPaths(configDir);
|
||||||
@@ -607,19 +777,31 @@ class SharedManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize marketplace registry paths to use canonical ~/.claude/ paths
|
* Reconcile marketplace registry content into the active config dir while
|
||||||
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
|
* keeping the global ~/.claude copy up to date for non-instance flows.
|
||||||
*
|
|
||||||
* This ensures known_marketplaces.json is consistent regardless of
|
|
||||||
* which CCS instance added the marketplace.
|
|
||||||
*/
|
*/
|
||||||
normalizeMarketplaceRegistryPaths(configDir?: string): void {
|
normalizeMarketplaceRegistryPaths(configDir?: string): void {
|
||||||
this.normalizePluginMetadataFiles(
|
const successMessage = 'Synchronized marketplace registry paths';
|
||||||
'known_marketplaces.json',
|
const warningLabel = 'marketplace registry';
|
||||||
configDir,
|
|
||||||
'Normalized marketplace registry paths',
|
try {
|
||||||
'marketplace registry'
|
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(
|
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/)
|
* Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/)
|
||||||
* Runs once on upgrade
|
* Runs once on upgrade
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
let originalCcsHome: string | undefined;
|
let originalCcsHome: string | undefined;
|
||||||
let originalCcsDir: 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 {
|
function writeMarketplaceRegistry(registryPath: string, installLocation: string): void {
|
||||||
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
||||||
fs.writeFileSync(
|
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(() => {
|
beforeEach(() => {
|
||||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
|
||||||
originalHome = process.env.HOME;
|
originalHome = process.env.HOME;
|
||||||
@@ -95,9 +106,10 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
const synced = manager.syncMcpServers(instancePath);
|
const synced = manager.syncMcpServers(instancePath);
|
||||||
expect(synced).toBe(true);
|
expect(synced).toBe(true);
|
||||||
|
|
||||||
const instanceContent = JSON.parse(
|
const instanceContent = readJson(path.join(instancePath, '.claude.json')) as {
|
||||||
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
|
otherKey: string;
|
||||||
);
|
mcpServers: Record<string, { command: string }>;
|
||||||
|
};
|
||||||
expect(instanceContent.otherKey).toBe('keep-me');
|
expect(instanceContent.otherKey).toBe('keep-me');
|
||||||
expect(instanceContent.mcpServers).toEqual({
|
expect(instanceContent.mcpServers).toEqual({
|
||||||
globalOnly: { command: 'global-cmd' },
|
globalOnly: { command: 'global-cmd' },
|
||||||
@@ -134,9 +146,9 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
|
|
||||||
const manager = new InstanceManager();
|
const manager = new InstanceManager();
|
||||||
const instancePath = manager.getInstancePath('sandbox');
|
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(
|
writeMarketplaceRegistry(
|
||||||
sharedRegistryPath,
|
globalRegistryPath,
|
||||||
path.join(
|
path.join(
|
||||||
tempRoot,
|
tempRoot,
|
||||||
'.ccs',
|
'.ccs',
|
||||||
@@ -150,20 +162,16 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
|
|
||||||
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
|
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
|
||||||
|
|
||||||
const normalized = JSON.parse(fs.readFileSync(sharedRegistryPath, 'utf8'));
|
|
||||||
|
|
||||||
expect(linkSharedSpy).not.toHaveBeenCalled();
|
expect(linkSharedSpy).not.toHaveBeenCalled();
|
||||||
expect(fs.existsSync(instancePath)).toBe(true);
|
expect(fs.existsSync(instancePath)).toBe(true);
|
||||||
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir()));
|
||||||
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
|
||||||
);
|
|
||||||
expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe(
|
expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(syncMcpSpy).not.toHaveBeenCalled();
|
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, 'syncProjectContext').mockResolvedValue(undefined);
|
||||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||||
@@ -174,38 +182,28 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
const instancePath = manager.getInstancePath('work');
|
const instancePath = manager.getInstancePath('work');
|
||||||
writeMarketplaceRegistry(
|
writeMarketplaceRegistry(
|
||||||
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||||
path.join(
|
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
||||||
tempRoot,
|
|
||||||
'.ccs',
|
|
||||||
'instances',
|
|
||||||
'work',
|
|
||||||
'plugins',
|
|
||||||
'marketplaces',
|
|
||||||
'claude-code-plugins'
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await manager.ensureInstance('work', { mode: 'isolated' });
|
await manager.ensureInstance('work', { mode: 'isolated' });
|
||||||
|
|
||||||
const normalized = JSON.parse(
|
expectMarketplaceLocation(
|
||||||
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
|
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||||
);
|
marketplacePath(instancePath)
|
||||||
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
|
||||||
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
|
|
||||||
);
|
);
|
||||||
expect(syncMcpSpy).toHaveBeenCalledWith(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, 'syncProjectContext').mockResolvedValue(undefined);
|
||||||
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
|
||||||
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
|
||||||
() => false
|
() => false
|
||||||
);
|
);
|
||||||
|
|
||||||
const registryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
|
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||||
writeMarketplaceRegistry(
|
writeMarketplaceRegistry(
|
||||||
registryPath,
|
globalRegistryPath,
|
||||||
path.join(
|
path.join(
|
||||||
tempRoot,
|
tempRoot,
|
||||||
'.ccs',
|
'.ccs',
|
||||||
@@ -220,20 +218,51 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
const manager = new InstanceManager();
|
const manager = new InstanceManager();
|
||||||
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
|
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
|
||||||
|
|
||||||
const expected = path.join(
|
expectMarketplaceLocation(globalRegistryPath, marketplacePath(claudeDir()));
|
||||||
tempRoot,
|
expectMarketplaceLocation(
|
||||||
'.claude',
|
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
|
||||||
'plugins',
|
marketplacePath(instancePath)
|
||||||
'marketplaces',
|
|
||||||
'claude-code-plugins'
|
|
||||||
);
|
);
|
||||||
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);
|
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 @@
|
|||||||
/**
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
* Unit tests for SharedManager - plugin registry path normalization
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
import SharedManager, {
|
import SharedManager, {
|
||||||
|
normalizePluginMetadataContent,
|
||||||
normalizePluginMetadataPathString,
|
normalizePluginMetadataPathString,
|
||||||
} from '../../src/management/shared-manager';
|
} from '../../src/management/shared-manager';
|
||||||
|
|
||||||
// Test the normalization regex pattern directly
|
|
||||||
const normalizePluginPaths = (content: string): string => {
|
|
||||||
return normalizePluginMetadataPathString(content);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('SharedManager', () => {
|
describe('SharedManager', () => {
|
||||||
let tempRoot = '';
|
let tempRoot = '';
|
||||||
let originalHome: string | undefined;
|
let originalHome: string | undefined;
|
||||||
@@ -21,6 +14,24 @@ describe('SharedManager', () => {
|
|||||||
let originalCcsDir: string | undefined;
|
let originalCcsDir: string | undefined;
|
||||||
let originalPlatform: PropertyDescriptor | 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(() => {
|
beforeEach(() => {
|
||||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
|
||||||
originalHome = process.env.HOME;
|
originalHome = process.env.HOME;
|
||||||
@@ -55,218 +66,173 @@ describe('SharedManager', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('normalizePluginRegistryPaths', () => {
|
describe('plugin metadata path normalization', () => {
|
||||||
describe('regex pattern', () => {
|
it('rewrites instance plugin paths to the requested target config dir', () => {
|
||||||
it('should replace instance paths with canonical claude path', () => {
|
const targetConfigDir = path.join('/home/user', '.claude');
|
||||||
const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2';
|
const input = '/home/user/.ccs/instances/work/plugins/cache/plugin/0.0.2';
|
||||||
const expected = '/home/user/.claude/plugins/cache/plugin/0.0.2';
|
|
||||||
expect(normalizePluginPaths(input)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle different instance names', () => {
|
expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(
|
||||||
const inputs = [
|
'/home/user/.claude/plugins/cache/plugin/0.0.2'
|
||||||
'/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/');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple occurrences', () => {
|
it('rewrites shared plugin paths to an instance-local target config dir', () => {
|
||||||
const input = JSON.stringify({
|
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: {
|
plugins: {
|
||||||
'plugin-a': [{ installPath: '/home/user/.ccs/instances/ck/plugins/a' }],
|
'plugin-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': [
|
|
||||||
{
|
{
|
||||||
scope: 'user',
|
installPath: path.join(
|
||||||
installPath:
|
tempRoot,
|
||||||
'/home/kai/.ccs/instances/ck/plugins/cache/claude-hud/claude-hud/0.0.2',
|
'.ccs',
|
||||||
version: '0.0.2',
|
'instances',
|
||||||
|
'old',
|
||||||
|
'plugins',
|
||||||
|
'cache',
|
||||||
|
'plugin-a'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
marketplaces: {
|
||||||
const input = JSON.stringify(original, null, 2);
|
official: {
|
||||||
const result = normalizePluginPaths(input);
|
installLocation: path.join(
|
||||||
|
tempRoot,
|
||||||
// Should be valid JSON
|
'.claude',
|
||||||
expect(() => JSON.parse(result)).not.toThrow();
|
'plugins',
|
||||||
|
'marketplaces',
|
||||||
// Should have normalized path
|
'official'
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
const input = JSON.stringify(original, null, 2);
|
null,
|
||||||
const result = normalizePluginPaths(input);
|
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(normalized.plugins['plugin-a'][0].installPath).toBe(
|
||||||
expect(parsed['claude-code-plugins'].installLocation).toBe(
|
path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a')
|
||||||
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
|
);
|
||||||
);
|
expect(normalized.marketplaces.official.installLocation).toBe(
|
||||||
});
|
marketplacePath(targetConfigDir, 'official')
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('edge cases', () => {
|
it('preserves paths already rooted at the target config dir', () => {
|
||||||
it('should handle empty object', () => {
|
const targetConfigDir = instanceDir('work');
|
||||||
const input = JSON.stringify({});
|
const input = path.join(targetConfigDir, 'plugins', 'cache', 'plugin-a');
|
||||||
expect(normalizePluginPaths(input)).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle plugins without installPath', () => {
|
expect(normalizePluginMetadataPathString(input, targetConfigDir)).toBe(input);
|
||||||
const input = JSON.stringify({ plugins: {} });
|
});
|
||||||
expect(normalizePluginPaths(input)).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle Windows-style paths (backslash)', () => {
|
it('handles Windows path separators', () => {
|
||||||
const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache';
|
const targetConfigDir = 'C:\\Users\\user\\.claude';
|
||||||
expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache');
|
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', () => {
|
describe('marketplace registry ownership', () => {
|
||||||
it('rewrites known_marketplaces.json on disk', () => {
|
it('writes global and instance registries with different authoritative install locations', () => {
|
||||||
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
|
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
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');
|
const instancePath = instanceDir('personal');
|
||||||
fs.writeFileSync(
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
registryPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
'claude-code-plugins': {
|
|
||||||
installLocation:
|
|
||||||
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const manager = new SharedManager();
|
const manager = new SharedManager();
|
||||||
manager.normalizeMarketplaceRegistryPaths();
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
|
||||||
const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
|
||||||
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir()));
|
||||||
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
|
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', () => {
|
it('self-heals missing installLocation from discovered marketplace payloads', () => {
|
||||||
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'
|
|
||||||
);
|
|
||||||
|
|
||||||
const manager = new SharedManager();
|
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'));
|
fs.mkdirSync(marketplacePath(claudeDir()), { recursive: true });
|
||||||
expect(normalized['claude-code-plugins'].installLocation).toBe(
|
writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), {
|
||||||
'C:\\Users\\kai\\.claude\\plugins\\marketplaces\\claude-code-plugins'
|
'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' });
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||||
spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
spyOn(fs, 'symlinkSync').mockImplementation(() => {
|
||||||
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
|
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
|
const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json');
|
||||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
writeJson(globalRegistryPath, {
|
||||||
|
'claude-code-plugins': {
|
||||||
|
installLocation: path.join(
|
||||||
|
tempRoot,
|
||||||
|
'.claude',
|
||||||
|
'plugins',
|
||||||
|
'marketplaces',
|
||||||
|
'claude-code-plugins'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
|
const instancePath = instanceDir('personal');
|
||||||
fs.writeFileSync(
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
registryPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
'claude-code-plugins': {
|
|
||||||
installLocation:
|
|
||||||
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
),
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const manager = new SharedManager();
|
const manager = new SharedManager();
|
||||||
const instancePath = path.join(tempRoot, '.ccs', 'instances', 'personal');
|
|
||||||
fs.mkdirSync(instancePath, { recursive: true });
|
|
||||||
manager.linkSharedDirectories(instancePath);
|
manager.linkSharedDirectories(instancePath);
|
||||||
|
|
||||||
const expected = '/home/kai/.claude/plugins/marketplaces/claude-code-plugins';
|
const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json');
|
||||||
const claudeRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
expect(readMarketplaceLocation(globalRegistryPath)).toBe(marketplacePath(claudeDir()));
|
||||||
const sharedRegistry = JSON.parse(
|
expect(readMarketplaceLocation(instanceRegistryPath)).toBe(marketplacePath(instancePath));
|
||||||
fs.readFileSync(
|
expect(fs.existsSync(path.join(instancePath, 'plugins', 'marketplaces'))).toBe(true);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user