Merge pull request #756 from kaitranntt/kai/fix/755-marketplace-install-location

fix: localize marketplace registry ownership for isolated instances
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-18 11:26:06 -04:00
committed by GitHub
14 changed files with 1185 additions and 279 deletions
+8 -1
View File
@@ -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/<profile>/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.
+2 -1
View File
@@ -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.
+16 -1
View File
@@ -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/<profile>/
|
+---> 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
```
+1 -1
View File
@@ -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.
}
+1 -1
View File
@@ -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) {
+28 -10
View File
@@ -26,11 +26,13 @@ class InstanceManager {
private readonly instancesDir: string;
private readonly sharedManager: SharedManager;
private readonly contextSyncLock: ProfileContextSyncLock;
private readonly pluginLayoutLock: ProfileContextSyncLock;
constructor() {
this.instancesDir = path.join(getCcsDir(), 'instances');
this.sharedManager = new SharedManager();
this.contextSyncLock = new ProfileContextSyncLock(this.instancesDir);
this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir);
}
/**
@@ -56,9 +58,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<void> {
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();
});
+82 -3
View File
@@ -106,8 +106,8 @@ class ProfileContextSyncLock {
return this.tryRemoveLockIfUnchanged(lockPath, snapshot.raw);
}
async withLock<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
const lockPath = this.getLockPath(profileName);
async withNamedLock<T>(lockName: string, callback: () => Promise<T>): Promise<T> {
const lockPath = this.getLockPath(lockName);
const retryDelayMs = 50;
const staleLockMs = 30000;
const timeoutMs = staleLockMs + 5000;
@@ -159,7 +159,7 @@ class ProfileContextSyncLock {
}
if (Date.now() - start > timeoutMs) {
throw new Error(`Timed out waiting for profile context lock: ${profileName}`);
throw new Error(`Timed out waiting for profile context lock: ${lockName}`);
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
@@ -172,6 +172,85 @@ class ProfileContextSyncLock {
this.tryRemoveLockIfUnchanged(lockPath, ownerPayloadRaw);
}
}
async withLock<T>(profileName: string, callback: () => Promise<T>): Promise<T> {
return this.withNamedLock(profileName, callback);
}
withNamedLockSync<T>(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<T>(profileName: string, callback: () => T): T {
return this.withNamedLockSync(profileName, callback);
}
}
export default ProfileContextSyncLock;
+469 -30
View File
@@ -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<string, unknown>).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<string, SharedItem>(
this.sharedPluginEntries.map((entry) => [entry.name, { ...entry }])
);
for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) {
if (items.has(entry.name) || this.instanceLocalPluginMetadataFiles.has(entry.name)) {
continue;
}
const entryPath = path.join(sharedPluginsPath, entry.name);
const stats = fs.statSync(entryPath);
items.set(entry.name, {
name: entry.name,
type: stats.isDirectory() ? 'directory' : 'file',
});
}
return [...items.values()];
}
private removeExistingPath(targetPath: string, typeHint: SharedItem['type']): void {
try {
const stats = fs.lstatSync(targetPath);
if (stats.isDirectory() && !stats.isSymbolicLink()) {
fs.rmSync(targetPath, { recursive: true, force: true });
return;
}
if (stats.isSymbolicLink() || typeHint === 'file') {
fs.unlinkSync(targetPath);
return;
}
fs.rmSync(targetPath, { recursive: true, force: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return;
}
if (typeHint === 'directory') {
fs.rmSync(targetPath, { recursive: true, force: true });
} else {
fs.rmSync(targetPath, { force: true });
}
}
}
/**
* Sync project workspace context based on account policy.
*
@@ -583,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/<name>/ paths.
@@ -607,19 +811,31 @@ class SharedManager {
}
/**
* Normalize marketplace registry paths to use canonical ~/.claude/ paths
* instead of instance-specific ~/.ccs/instances/<name>/ paths.
*
* This ensures known_marketplaces.json is consistent regardless of
* which CCS instance added the marketplace.
* Reconcile marketplace registry content into the active config dir while
* keeping the global ~/.claude copy up to date for non-instance flows.
*/
normalizeMarketplaceRegistryPaths(configDir?: string): void {
this.normalizePluginMetadataFiles(
'known_marketplaces.json',
configDir,
'Normalized marketplace registry paths',
'marketplace registry'
);
const successMessage = 'Synchronized marketplace registry paths';
const warningLabel = 'marketplace registry';
try {
const sourcePaths = this.getMarketplaceRegistrySourcePaths(configDir);
this.writePluginMetadataFile(
path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'),
this.buildMarketplaceRegistryContent(sourcePaths, this.claudeDir),
successMessage
);
if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) {
this.writePluginMetadataFile(
path.join(configDir, 'plugins', 'known_marketplaces.json'),
this.buildMarketplaceRegistryContent(sourcePaths, configDir),
successMessage
);
}
} catch (err) {
console.log(warn(`Could not synchronize ${warningLabel}: ${(err as Error).message}`));
}
}
private normalizePluginMetadataFiles(
@@ -676,6 +892,117 @@ 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() || 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<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) {
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<string, unknown>),
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<string, { installLocation: string }> {
const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces');
if (!fs.existsSync(marketplacesDir)) {
return {};
}
const discovered: Record<string, { installLocation: string }> = {};
for (const entry of fs.readdirSync(marketplacesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
discovered[entry.name] = {
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
};
}
return discovered;
}
private writePluginMetadataFile(
registryPath: string,
content: string,
successMessage: string
): void {
fs.mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 });
const current = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null;
if (current === content) {
return;
}
fs.writeFileSync(registryPath, content, 'utf8');
console.log(ok(successMessage));
}
/**
* Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/)
* Runs once on upgrade
@@ -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<string, unknown> = {};
try {
const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
parsed = raw as Record<string, unknown>;
}
} 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);
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -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.
}
+2 -2
View File
@@ -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<void> => {
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);
+253 -41
View File
@@ -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<string, unknown>;
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<string, unknown>
): 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<string, { installLocation?: string }>;
expect(parsed['claude-code-plugins']?.installLocation).toBe(expectedLocation);
}
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
originalHome = process.env.HOME;
@@ -95,9 +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<string, { command: string }>;
};
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<string, unknown>;
}
>;
const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record<
string,
{
installLocation?: string;
label?: string;
refreshToken?: string;
metadata?: Record<string, unknown>;
}
>;
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',
});
});
});
@@ -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);
});
});
+226 -185
View File
@@ -1,19 +1,12 @@
/**
* Unit tests for SharedManager - plugin registry path normalization
*/
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as path from 'path';
import SharedManager, {
normalizePluginMetadataContent,
normalizePluginMetadataPathString,
} from '../../src/management/shared-manager';
// Test the normalization regex pattern directly
const normalizePluginPaths = (content: string): string => {
return normalizePluginMetadataPathString(content);
};
describe('SharedManager', () => {
let tempRoot = '';
let originalHome: string | undefined;
@@ -21,6 +14,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<string, unknown>;
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<string, { installLocation?: string }>;
return parsed[name]?.installLocation ?? '';
}
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
originalHome = process.env.HOME;
@@ -55,218 +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<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('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<string, { label?: string; installLocation?: string }>;
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);
});
});
});