diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index df29f733..17ae9fca 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,6 +1,6 @@ # CCS Codebase Summary -Last Updated: 2026-03-17 +Last Updated: 2026-03-18 Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening. @@ -222,6 +222,13 @@ src/ - API route rejects `context_group`/`continuity_mode` when mode is not `shared` - registry normalization drops malformed persisted `context_group` values +### Shared Plugin Layout + +- Shared payload owner: `src/management/shared-manager.ts`. +- Profile entry point: `src/management/instance-manager.ts`. +- `plugins/marketplaces/`, `plugins/cache/`, and `installed_plugins.json` stay shared through the `~/.ccs/shared/` topology. +- `known_marketplaces.json` is now instance-local under `~/.ccs/instances//plugins/` so Claude Code validates `installLocation` against the active `CLAUDE_CONFIG_DIR` instead of a last-writer-wins shared file. + ### Target Adapter Module The targets module provides an extensible interface for dispatching profiles to different CLI implementations. diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 347851b2..ebaef666 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-17 +Last Updated: 2026-03-18 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback. - **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint. - **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo. - **#744**: API profile creation now keeps featured providers in a horizontal rail with scroll fallback, moves Anthropic Direct API to the end, reuses the shared Claude logo, and separates the custom-endpoint entry point from advanced template discovery. diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index a13cd68e..7de060eb 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -1,6 +1,6 @@ # CCS System Architecture -Last Updated: 2026-03-02 +Last Updated: 2026-03-18 High-level architecture overview for the CCS (Claude Code Switch) system. @@ -233,12 +233,27 @@ For detailed provider flows (CLIProxyAPI, legacy GLMT compatibility, quota manag +---> commands/ # Claude Code commands +---> skills/ # Custom skills +---> agents/ # Agent configurations + +---> plugins/ + | + +---> cache/ # Shared plugin payload/cache data + +---> marketplaces/ # Shared marketplace payload directories + +---> installed_plugins.json + + ~/.ccs/instances// + | + +---> plugins/ + | + +---> known_marketplaces.json # Instance-local registry for active CLAUDE_CONFIG_DIR validation ~/.factory/ (Droid CLI) | +---> settings.json # Droid config (custom models) ``` +Plugin ownership note: +- `commands/`, `skills/`, `agents/`, and `settings.json` remain shared through the existing symlink/copy flow. +- Marketplace payload directories stay shared, but `known_marketplaces.json` is reconciled per instance so Claude Code can validate `installLocation` against that instance's `CLAUDE_CONFIG_DIR/plugins/marketplaces`. + ### Config Loading Order ``` diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index f9edd2dc..ef59fb60 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -163,7 +163,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise if (!profileExistedBeforeCreate) { try { - ctx.instanceMgr.deleteInstance(profileName); + await ctx.instanceMgr.deleteInstance(profileName); } catch { // Best-effort cleanup. } diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts index cfef25cd..b7df32fa 100644 --- a/src/auth/commands/remove-command.ts +++ b/src/auth/commands/remove-command.ts @@ -68,7 +68,7 @@ export async function handleRemove(ctx: CommandContext, args: string[]): Promise } // Delete instance - ctx.instanceMgr.deleteInstance(profileName); + await ctx.instanceMgr.deleteInstance(profileName); // Delete profile from appropriate config if (isUnifiedMode() && existsUnified) { diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 7cdbdc97..576bc828 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -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,17 @@ class InstanceManager { // Apply context policy (isolated by default, optional shared group). await this.sharedManager.syncProjectContext(instancePath, contextPolicy); await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy); - }); - this.sharedManager.normalizeSharedPluginMetadataPaths(instancePath); + await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + if (!options.bare) { + this.sharedManager.linkSharedDirectories(instancePath); + return; + } + + this.sharedManager.detachSharedDirectories(instancePath); + this.sharedManager.normalizeSharedPluginMetadataPaths(); + }); + }); // Sync MCP servers from global ~/.claude.json (unless bare) if (!options.bare) { @@ -82,7 +92,7 @@ class InstanceManager { private initializeInstance( profileName: string, instancePath: string, - options: InstanceOptions = {} + _options: InstanceOptions = {} ): void { try { // Create base directory @@ -106,10 +116,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}` @@ -146,15 +153,22 @@ class InstanceManager { /** * Delete instance for profile */ - deleteInstance(profileName: string): void { + async deleteInstance(profileName: string): Promise { const instancePath = this.getInstancePath(profileName); if (!fs.existsSync(instancePath)) { return; } - // Recursive delete - fs.rmSync(instancePath, { recursive: true, force: true }); + await this.contextSyncLock.withLock(profileName, async () => { + await this.pluginLayoutLock.withNamedLock('__plugin-layout__', async () => { + if (!fs.existsSync(instancePath)) { + return; + } + + fs.rmSync(instancePath, { recursive: true, force: true }); + }); + }); } /** @@ -166,6 +180,10 @@ class InstanceManager { } return fs.readdirSync(this.instancesDir).filter((name) => { + if (name.startsWith('.')) { + return false; + } + const instancePath = path.join(this.instancesDir, name); return fs.statSync(instancePath).isDirectory(); }); diff --git a/src/management/profile-context-sync-lock.ts b/src/management/profile-context-sync-lock.ts index c8158bc3..01727405 100644 --- a/src/management/profile-context-sync-lock.ts +++ b/src/management/profile-context-sync-lock.ts @@ -106,8 +106,8 @@ class ProfileContextSyncLock { return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw); } - async withLock(profileName: string, callback: () => Promise): Promise { - const lockPath = this.getLockPath(profileName); + async withNamedLock(lockName: string, callback: () => Promise): Promise { + 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,85 @@ class ProfileContextSyncLock { this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw); } } + + async withLock(profileName: string, callback: () => Promise): Promise { + return this.withNamedLock(profileName, callback); + } + + withNamedLockSync(lockName: string, callback: () => T): T { + const lockPath = this.getLockPath(lockName); + const retryDelayMs = 50; + const staleLockMs = 30000; + const timeoutMs = staleLockMs + 5000; + const start = Date.now(); + const ownerPayload: ContextSyncLockPayload = { + version: 1, + pid: process.pid, + nonce: createHash('sha1') + .update(`${process.pid}:${Date.now()}:${Math.random()}`) + .digest('hex') + .slice(0, 16), + acquiredAtMs: Date.now(), + }; + const ownerPayloadRaw = JSON.stringify(ownerPayload); + + fs.mkdirSync(this.locksDir, { recursive: true, mode: 0o700 }); + + while (true) { + try { + const fd = fs.openSync(lockPath, 'wx', 0o600); + fs.writeFileSync(fd, ownerPayloadRaw, 'utf8'); + fs.closeSync(fd); + break; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'EEXIST') { + throw error; + } + + const lockSnapshot = this.readContextSyncLockSnapshot(lockPath); + if (lockSnapshot) { + if (this.tryRemoveDeadOwnerLock(lockPath, lockSnapshot)) { + continue; + } + + if (!lockSnapshot.owner) { + try { + const lockStats = fs.statSync(lockPath); + if (Date.now() - lockStats.mtimeMs > staleLockMs) { + if (this.tryRemoveLockIfUnchanged(lockPath, lockSnapshot.raw)) { + continue; + } + } + } catch { + // Best-effort stale lock cleanup. + } + } + } + + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for profile context lock: ${lockName}`); + } + + const until = Date.now() + retryDelayMs; + while (Date.now() < until) { + // Sync callers need a synchronous retry path here. + // This lock only guards short local filesystem normalization work, so + // contention should be brief and limited to profile/bootstrap edges. + } + } + } + + try { + return callback(); + } finally { + this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw); + } + } + + withLockSync(profileName: string, callback: () => T): T { + return this.withNamedLockSync(profileName, callback); + } } export default ProfileContextSyncLock; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index cc617d3b..b4054c22 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import ProfileContextSyncLock from './profile-context-sync-lock'; import { ok, info, warn } from '../utils/ui'; import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; @@ -18,23 +19,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 +87,7 @@ function normalizePluginMetadataValue(value: unknown): { normalized: unknown; ch let changed = false; const normalized = Object.fromEntries( Object.entries(value as Record).map(([key, item]) => { - const result = normalizePluginMetadataValue(item); + const result = normalizePluginMetadataValue(item, targetConfigDir); changed = changed || result.changed; return [key, result.normalized]; }) @@ -56,9 +98,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; } @@ -70,7 +115,14 @@ class SharedManager { private readonly sharedDir: string; private readonly claudeDir: string; private readonly instancesDir: string; + private readonly pluginLayoutLock: ProfileContextSyncLock; 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', @@ -84,6 +136,7 @@ class SharedManager { this.sharedDir = path.join(ccsDir, 'shared'); this.claudeDir = path.join(this.homeDir, '.claude'); this.instancesDir = path.join(ccsDir, 'instances'); + this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir); this.sharedItems = [ { name: 'commands', type: 'directory' }, { name: 'skills', type: 'directory' }, @@ -148,6 +201,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 +276,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 +310,151 @@ class SharedManager { this.normalizeSharedPluginMetadataPaths(instancePath); } + detachSharedDirectories(instancePath: string): void { + this.ensureSharedDirectories(); + + for (const item of this.sharedItems) { + const managedPath = path.join(instancePath, item.name); + if (!fs.existsSync(managedPath)) { + continue; + } + + if (item.name === 'plugins') { + this.detachManagedPluginLayout(instancePath); + continue; + } + + const stats = fs.lstatSync(managedPath); + if (!stats.isSymbolicLink()) { + continue; + } + + if (this.symlinkPointsTo(managedPath, path.join(this.sharedDir, item.name))) { + this.removeExistingPath(managedPath, item.type); + } + } + } + + 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( + 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,13 +781,19 @@ 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); this.normalizeMarketplaceRegistryPaths(configDir); } + normalizeSharedPluginMetadataPathsLocked(configDir?: string): void { + this.pluginLayoutLock.withNamedLockSync('__plugin-layout__', () => { + this.normalizeSharedPluginMetadataPaths(configDir); + }); + } + /** * Normalize plugin registry paths to use canonical ~/.claude/ paths * instead of instance-specific ~/.ccs/instances// paths. @@ -607,19 +811,31 @@ class SharedManager { } /** - * Normalize marketplace registry paths to use canonical ~/.claude/ paths - * instead of instance-specific ~/.ccs/instances// 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 +892,117 @@ class SharedManager { } } + private getMarketplaceRegistrySourcePaths(configDir?: string): string[] { + const sourcePaths = new Set([ + 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() || entry.name.startsWith('.')) { + 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 = {}; + + 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)) { + merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; + } + } catch (err) { + console.log( + warn(`Skipping malformed marketplace registry ${registryPath}: ${(err as Error).message}`) + ); + } + } + + const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir); + + for (const [name, value] of Object.entries(discoveredEntries)) { + const existing = merged[name]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + merged[name] = { + ...(existing as Record), + installLocation: value.installLocation, + }; + continue; + } + + merged[name] = value; + } + + for (const name of Object.keys(merged)) { + if (!(name in discoveredEntries)) { + delete merged[name]; + } + } + + return JSON.stringify(merged, null, 2); + } + + private discoverMarketplaceEntries( + targetConfigDir: string + ): Record { + const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces'); + if (!fs.existsSync(marketplacesDir)) { + return {}; + } + + const discovered: Record = {}; + + 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 @@ -1157,6 +1484,118 @@ class SharedManager { return candidate; } + private symlinkPointsTo(linkPath: string, expectedTarget: string): boolean { + try { + const currentTarget = fs.readlinkSync(linkPath); + const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); + return ( + this.resolveCanonicalPath(resolvedCurrentTarget) === + this.resolveCanonicalPath(expectedTarget) + ); + } catch { + return false; + } + } + + private detachManagedPluginLayout(instancePath: string): void { + const pluginsPath = path.join(instancePath, 'plugins'); + if (!fs.existsSync(pluginsPath)) { + return; + } + + const stats = fs.lstatSync(pluginsPath); + const sharedPluginsPath = path.join(this.sharedDir, 'plugins'); + + if (stats.isSymbolicLink()) { + if (this.symlinkPointsTo(pluginsPath, sharedPluginsPath)) { + this.removeExistingPath(pluginsPath, 'directory'); + } + return; + } + + if (!stats.isDirectory()) { + return; + } + + let removedManagedEntries = false; + + for (const item of this.getSharedPluginLinkItems()) { + const pluginEntryPath = path.join(pluginsPath, item.name); + if (!fs.existsSync(pluginEntryPath)) { + continue; + } + + const entryStats = fs.lstatSync(pluginEntryPath); + if (!entryStats.isSymbolicLink()) { + continue; + } + + if (this.symlinkPointsTo(pluginEntryPath, path.join(sharedPluginsPath, item.name))) { + this.removeExistingPath(pluginEntryPath, item.type); + removedManagedEntries = true; + } + } + + if (!removedManagedEntries) { + return; + } + + this.reconcileLocalMarketplaceRegistry(instancePath); + + if (fs.readdirSync(pluginsPath).length === 0) { + fs.rmSync(pluginsPath, { recursive: true, force: true }); + } + } + + private reconcileLocalMarketplaceRegistry(configDir: string): void { + const registryPath = path.join(configDir, 'plugins', 'known_marketplaces.json'); + if (!fs.existsSync(registryPath)) { + return; + } + + const discoveredEntries = this.discoverMarketplaceEntries(configDir); + if (Object.keys(discoveredEntries).length === 0) { + this.removeExistingPath(registryPath, 'file'); + return; + } + + let parsed: Record = {}; + try { + const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + parsed = raw as Record; + } + } catch { + parsed = {}; + } + + const reconciled = Object.fromEntries( + Object.entries(discoveredEntries).map(([name, value]) => { + const existing = parsed[name]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + return [ + name, + { + ...(normalizePluginMetadataValue(existing, configDir).normalized as Record< + string, + unknown + >), + installLocation: value.installLocation, + }, + ]; + } + + return [name, value]; + }) + ); + + this.writePluginMetadataFile( + registryPath, + JSON.stringify(reconciled, null, 2), + 'Synchronized marketplace registry paths' + ); + } + private resolveCanonicalPath(targetPath: string): string { try { return fs.realpathSync.native(targetPath); diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index 784c8560..9f6a9385 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -168,7 +168,7 @@ async function resolveExtensionEnv( profileType: result.type, target: 'claude', }); - new SharedManager().normalizeSharedPluginMetadataPaths(continuity.claudeConfigDir); + new SharedManager().normalizeSharedPluginMetadataPathsLocked(continuity.claudeConfigDir); if (continuity.claudeConfigDir) { notes.push(`Default profile inherits continuity from account "${continuity.sourceAccount}".`); return { @@ -250,7 +250,7 @@ async function resolveExtensionEnv( ); } - new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR); + new SharedManager().normalizeSharedPluginMetadataPathsLocked(env.CLAUDE_CONFIG_DIR); if (result.type === 'copilot') { warnings.push( diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 16861f00..ff96458e 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -125,7 +125,7 @@ export function execClaude( if (profileType !== 'account') { try { - new SharedManager().normalizeSharedPluginMetadataPaths(env.CLAUDE_CONFIG_DIR); + new SharedManager().normalizeSharedPluginMetadataPathsLocked(env.CLAUDE_CONFIG_DIR); } catch { // Best-effort normalization should never block Claude launch. } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index daa9c71a..147de4c8 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -324,7 +324,7 @@ router.delete('/reset-default', (_req: Request, res: Response): void => { /** * DELETE /api/accounts/:name - Delete an account */ -router.delete('/:name', (req: Request, res: Response): void => { +router.delete('/:name', async (req: Request, res: Response): Promise => { try { const { name } = req.params; @@ -371,7 +371,7 @@ router.delete('/:name', (req: Request, res: Response): void => { } // Match CLI remove ordering: delete instance first, metadata second. - instanceMgr.deleteInstance(name); + await instanceMgr.deleteInstance(name); if (existsUnified) { registry.removeAccountUnified(name); diff --git a/tests/unit/instance-manager-mcp-sync.test.ts b/tests/unit/instance-manager-mcp-sync.test.ts index 1a39e803..8eec45a7 100644 --- a/tests/unit/instance-manager-mcp-sync.test.ts +++ b/tests/unit/instance-manager-mcp-sync.test.ts @@ -11,6 +11,16 @@ 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; + + function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void { + fs.mkdirSync(marketplacePath(configDir, name), { recursive: true }); + } + function writeMarketplaceRegistry(registryPath: string, installLocation: string): void { fs.mkdirSync(path.dirname(registryPath), { recursive: true }); fs.writeFileSync( @@ -28,6 +38,33 @@ describe('InstanceManager MCP sync', () => { ); } + function writeMarketplaceRegistryWithMetadata( + registryPath: string, + installLocation: string, + metadata: Record + ): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync( + registryPath, + JSON.stringify( + { + 'claude-code-plugins': { + installLocation, + ...metadata, + }, + }, + null, + 2 + ), + 'utf8' + ); + } + + function expectMarketplaceLocation(registryPath: string, expectedLocation: string): void { + const parsed = readJson(registryPath) as Record; + expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation); + } + beforeEach(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-')); originalHome = process.env.HOME; @@ -95,9 +132,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; + }; expect(instanceContent.otherKey).toBe('keep-me'); expect(instanceContent.mcpServers).toEqual({ globalOnly: { command: 'global-cmd' }, @@ -121,6 +159,17 @@ describe('InstanceManager MCP sync', () => { expect(String(warnSpy.mock.calls[0]?.[0] || '')).toContain('MCP sync skipped'); }); + it('does not list lock housekeeping as an instance', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(() => false); + + const manager = new InstanceManager(); + await manager.ensureInstance('work', { mode: 'isolated' }); + + expect(manager.listInstances()).toEqual(['work']); + }); + it('skips shared symlinks and MCP sync for bare instance creation', async () => { const linkSharedSpy = spyOn( SharedManager.prototype, @@ -134,9 +183,10 @@ 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'); + ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( - sharedRegistryPath, + globalRegistryPath, path.join( tempRoot, '.ccs', @@ -150,20 +200,100 @@ 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('detaches existing shared layout when an instance is reopened as bare', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => false + ); + + const manager = new InstanceManager(); + const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); + syncMcpSpy.mockClear(); + + await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true }); + + expect(fs.existsSync(path.join(instancePath, 'settings.json'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'commands'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'skills'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'agents'))).toBe(false); + expect(fs.existsSync(path.join(instancePath, 'plugins'))).toBe(false); + expect(syncMcpSpy).not.toHaveBeenCalled(); + }); + + it('restores the shared layout when a bare-reopened instance is switched back to non-bare', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => false + ); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); + writeMarketplaceRegistry(globalRegistryPath, marketplacePath(claudeDir())); + + const manager = new InstanceManager(); + const instancePath = await manager.ensureInstance('work', { mode: 'isolated' }); + await manager.ensureInstance('work', { mode: 'isolated' }, { bare: true }); + syncMcpSpy.mockClear(); + + await manager.ensureInstance('work', { mode: 'isolated' }); + + expect(fs.lstatSync(path.join(instancePath, 'settings.json')).isSymbolicLink()).toBe(true); + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath) + ); + expect(syncMcpSpy).toHaveBeenCalledWith(instancePath); + }); + + it('preserves genuine bare-local content when re-ensuring a bare instance', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation( + () => false + ); + + const manager = new InstanceManager(); + const instancePath = manager.getInstancePath('sandbox'); + fs.mkdirSync(path.join(instancePath, 'plugins', 'marketplaces', 'custom-market'), { + recursive: true, + }); + fs.mkdirSync(path.join(instancePath, 'commands'), { recursive: true }); + fs.writeFileSync( + path.join(instancePath, 'settings.json'), + JSON.stringify({ local: true }, null, 2), + 'utf8' + ); + fs.writeFileSync(path.join(instancePath, 'commands', 'local.md'), '# local', 'utf8'); + writeMarketplaceRegistry( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath, 'custom-market') + ); + + await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true }); + + expect(readJson(path.join(instancePath, 'settings.json'))).toEqual({ local: true }); + expect(fs.readFileSync(path.join(instancePath, 'commands', 'local.md'), 'utf8')).toBe( + '# local' + ); + expectMarketplaceLocation( + path.join(instancePath, 'plugins', 'known_marketplaces.json'), + marketplacePath(instancePath, 'custom-market') + ); + expect(syncMcpSpy).not.toHaveBeenCalled(); + }); + + 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( @@ -172,40 +302,32 @@ describe('InstanceManager MCP sync', () => { const manager = new InstanceManager(); const instancePath = manager.getInstancePath('work'); + ensureMarketplacePayload(claudeDir()); 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'); + ensureMarketplacePayload(claudeDir()); writeMarketplaceRegistry( - registryPath, + globalRegistryPath, path.join( tempRoot, '.ccs', @@ -220,20 +342,110 @@ 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('reconciles marketplace metadata across isolated instances without losing refresh fields', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + + const manager = new InstanceManager(); + ensureMarketplacePayload(claudeDir()); + const workPath = await manager.ensureInstance('work', { mode: 'isolated' }); + const workRegistryPath = path.join(workPath, 'plugins', 'known_marketplaces.json'); + writeMarketplaceRegistryWithMetadata(workRegistryPath, marketplacePath(workPath), { + label: 'Official marketplace', + refreshToken: 'refresh-token', + metadata: { + source: 'refresh-flow', + lastSyncedAt: '2026-03-18T00:00:00Z', + }, + }); + + const personalPath = await manager.ensureInstance('personal', { mode: 'isolated' }); + const workRegistry = readJson(workRegistryPath) as Record< + string, + { + installLocation?: string; + label?: string; + refreshToken?: string; + metadata?: Record; + } + >; + const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record< + string, + { + installLocation?: string; + label?: string; + refreshToken?: string; + metadata?: Record; + } + >; + + expect(workRegistry['claude-code-plugins']).toMatchObject({ + installLocation: marketplacePath(workPath), + label: 'Official marketplace', + refreshToken: 'refresh-token', + metadata: { + source: 'refresh-flow', + lastSyncedAt: '2026-03-18T00:00:00Z', + }, + }); + expect(personalRegistry['claude-code-plugins']).toMatchObject({ + installLocation: marketplacePath(personalPath), + label: 'Official marketplace', + refreshToken: 'refresh-token', + metadata: { + source: 'refresh-flow', + lastSyncedAt: '2026-03-18T00:00:00Z', + }, + }); + }); + + it('upgrades a legacy shared plugins symlink to an instance-local layout', async () => { + spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined); + spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined); + + const manager = new InstanceManager(); + const legacyPath = manager.getInstancePath('legacy'); + const sharedPluginsPath = path.join(tempRoot, '.ccs', 'shared', 'plugins'); + fs.mkdirSync(sharedPluginsPath, { recursive: true }); + fs.mkdirSync(legacyPath, { recursive: true }); + fs.symlinkSync(sharedPluginsPath, path.join(legacyPath, 'plugins'), 'dir'); + + ensureMarketplacePayload(claudeDir()); + writeMarketplaceRegistryWithMetadata( + path.join(claudeDir(), 'plugins', 'known_marketplaces.json'), + path.join(tempRoot, '.ccs', 'shared', 'plugins', 'marketplaces', 'claude-code-plugins'), + { + label: 'Legacy marketplace', + refreshToken: 'legacy-refresh-token', + } + ); + + await manager.ensureInstance('legacy', { mode: 'isolated' }); + + expect(fs.lstatSync(path.join(legacyPath, 'plugins')).isSymbolicLink()).toBe(false); + expectMarketplaceLocation( + path.join(legacyPath, 'plugins', 'known_marketplaces.json'), + marketplacePath(legacyPath) + ); + + const legacyRegistry = readJson( + path.join(legacyPath, 'plugins', 'known_marketplaces.json') + ) as Record< + string, + { installLocation?: string; label?: string; refreshToken?: string } + >; + expect(legacyRegistry['claude-code-plugins']).toMatchObject({ + installLocation: marketplacePath(legacyPath), + label: 'Legacy marketplace', + refreshToken: 'legacy-refresh-token', + }); + }); }); diff --git a/tests/unit/profile-context-sync-lock.test.ts b/tests/unit/profile-context-sync-lock.test.ts new file mode 100644 index 00000000..323810e7 --- /dev/null +++ b/tests/unit/profile-context-sync-lock.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import ProfileContextSyncLock from '../../src/management/profile-context-sync-lock'; + +describe('ProfileContextSyncLock', () => { + let tempRoot = ''; + let instancesDir = ''; + + const getLockPath = (lockName: string): string => { + const safeName = lockName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + const profileHash = createHash('sha1').update(lockName).digest('hex').slice(0, 8); + return path.join(instancesDir, '.locks', `${safeName}-${profileHash}.lock`); + }; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-context-lock-test-')); + instancesDir = path.join(tempRoot, 'instances'); + fs.mkdirSync(instancesDir, { recursive: true }); + }); + + afterEach(() => { + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('acquires and releases synchronous named locks', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + + let sawLockInsideCallback = false; + const result = lock.withNamedLockSync('__plugin-layout__', () => { + sawLockInsideCallback = fs.existsSync(lockPath); + expect(fs.readFileSync(lockPath, 'utf8')).toContain(`"pid":${process.pid}`); + return 'ok'; + }); + + expect(result).toBe('ok'); + expect(sawLockInsideCallback).toBe(true); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('releases synchronous named locks when the callback throws', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + + expect(() => + lock.withNamedLockSync('__plugin-layout__', () => { + throw new Error('boom'); + }) + ).toThrow('boom'); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('reclaims dead-owner locks before entering the callback', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync( + lockPath, + JSON.stringify({ + version: 1, + pid: 999999, + nonce: 'dead-owner', + acquiredAtMs: Date.now() - 1000, + }), + 'utf8' + ); + + const result = lock.withNamedLockSync('__plugin-layout__', () => 'reclaimed'); + + expect(result).toBe('reclaimed'); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('reclaims malformed stale locks before entering the callback', () => { + const lock = new ProfileContextSyncLock(instancesDir); + const lockPath = getLockPath('__plugin-layout__'); + const staleDate = new Date(Date.now() - 60_000); + + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + fs.writeFileSync(lockPath, 'not-json', 'utf8'); + fs.utimesSync(lockPath, staleDate, staleDate); + + const result = lock.withNamedLockSync('__plugin-layout__', () => 'stale-reclaimed'); + + expect(result).toBe('stale-reclaimed'); + expect(fs.existsSync(lockPath)).toBe(false); + }); +}); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 96ee5eee..3cd142f8 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -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,28 @@ 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; + + function ensureMarketplacePayload(configDir: string, name = 'claude-code-plugins'): void { + fs.mkdirSync(marketplacePath(configDir, name), { recursive: true }); + } + + 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; + return parsed[name]?.installLocation ?? ''; + } + beforeEach(() => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-')); originalHome = process.env.HOME; @@ -55,218 +70,244 @@ 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'); + ensureMarketplacePayload(claudeDir()); + 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; + expect(repaired['claude-code-plugins']).toEqual({ + label: 'Official marketplace', + installLocation: marketplacePath(instancePath), + }); }); - it('normalizes copied shared and instance metadata under Windows fallback', () => { + it('prunes stale marketplace entries whose payload directories no longer exist', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + fs.mkdirSync(marketplacePath(claudeDir(), 'claude-code-plugins'), { recursive: true }); + writeJson(path.join(instancePath, 'plugins', 'known_marketplaces.json'), { + 'claude-code-plugins': { + installLocation: marketplacePath(instancePath, 'claude-code-plugins'), + label: 'Official marketplace', + }, + stale: { + installLocation: marketplacePath(instancePath, 'stale'), + label: 'Stale marketplace', + }, + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const reconciled = readJson( + path.join(instancePath, 'plugins', 'known_marketplaces.json') + ) as Record; + expect(reconciled['claude-code-plugins']).toEqual({ + installLocation: marketplacePath(instancePath, 'claude-code-plugins'), + label: 'Official marketplace', + }); + expect(reconciled.stale).toBeUndefined(); + }); + + it('warns and skips malformed marketplace registries while keeping valid sources', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + ensureMarketplacePayload(claudeDir()); + writeJson(globalRegistryPath, { + 'claude-code-plugins': { + installLocation: path.join( + tempRoot, + '.ccs', + 'instances', + 'work', + 'plugins', + 'marketplaces', + 'claude-code-plugins' + ), + label: 'Official marketplace', + }, + }); + + const malformedRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + fs.writeFileSync(malformedRegistryPath, '{invalid-json', 'utf8'); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + expect(readMarketplaceLocation(malformedRegistryPath)).toBe(marketplacePath(instancePath)); + expect( + logSpy.mock.calls.some( + ([message]) => + String(message).includes('Skipping malformed marketplace registry') && + String(message).includes(malformedRegistryPath) + ) + ).toBe(true); + }); + + it('keeps the instance-local registry valid under Windows copy fallback', () => { Object.defineProperty(process, 'platform', { value: 'win32' }); spyOn(fs, 'symlinkSync').mockImplementation(() => { 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'); + ensureMarketplacePayload(claudeDir()); + 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); }); }); });