mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-18 14:16:57 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user