Files
ccs/src/management/shared-manager.ts
T

1689 lines
52 KiB
TypeScript

/**
* SharedManager - Manages symlinked shared directories for CCS
* v3.2.0: Symlink-based architecture
*
* Purpose: Eliminates duplication by symlinking:
* ~/.claude/ ← ~/.ccs/shared/ ← instance/
*/
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 { DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context';
import type { AccountContextPolicy } from '../auth/account-context';
import { getCcsDir } from '../utils/config-manager';
interface SharedItem {
name: string;
type: 'directory' | 'file';
}
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, '/')
);
}
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, targetConfigDir);
return { normalized, changed: normalized !== value };
}
if (Array.isArray(value)) {
let changed = false;
const normalized = value.map((item) => {
const result = normalizePluginMetadataValue(item, targetConfigDir);
changed = changed || result.changed;
return result.normalized;
});
return { normalized, changed };
}
if (value && typeof value === 'object') {
let changed = false;
const normalized = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
const result = normalizePluginMetadataValue(item, targetConfigDir);
changed = changed || result.changed;
return [key, result.normalized];
})
);
return { normalized, changed };
}
return { normalized: value, changed: false };
}
export function normalizePluginMetadataContent(
original: string,
targetConfigDir = path.join(os.homedir(), '.claude')
): string {
const parsed = JSON.parse(original) as unknown;
const result = normalizePluginMetadataValue(parsed, targetConfigDir);
return result.changed ? JSON.stringify(result.normalized, null, 2) : original;
}
/**
* SharedManager Class
*/
class SharedManager {
private readonly homeDir: string;
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',
'shell-snapshots',
'todos',
];
constructor() {
this.homeDir = os.homedir();
const ccsDir = getCcsDir();
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' },
{ name: 'agents', type: 'directory' },
{ name: 'plugins', type: 'directory' },
{ name: 'settings.json', type: 'file' },
];
}
/**
* Detect circular symlink before creation
*/
private detectCircularSymlink(target: string): boolean {
try {
const stats = fs.lstatSync(target);
if (!stats.isSymbolicLink()) {
return false;
}
// Resolve target's link
const targetLink = fs.readlinkSync(target);
const resolvedTarget = path.resolve(path.dirname(target), targetLink);
const sharedDirPath = path.resolve(this.sharedDir);
// A raw target path pointing back into ~/.ccs/shared is already unsafe.
// Re-pointing ~/.ccs/shared/* to ~/.claude/* would turn it into a real loop,
// even if the current ~/.ccs/shared entry ultimately resolves to an external path.
if (this.isPathWithinDirectory(resolvedTarget, sharedDirPath)) {
console.log(warn(`Circular symlink detected: ${target}${resolvedTarget}`));
return true;
}
// Only treat targets inside the managed shared root as circular.
// Existing shared symlinks may already resolve through ~/.claude/ to an
// external repo, which is a supported upgrade path rather than a loop.
const sharedDir = this.resolveCanonicalPath(sharedDirPath);
const canonicalResolvedTarget = this.resolveCanonicalPath(resolvedTarget);
if (this.isPathWithinDirectory(canonicalResolvedTarget, sharedDir)) {
console.log(warn(`Circular symlink detected: ${target}${resolvedTarget}`));
return true;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw err;
}
return false;
}
/**
* Ensure shared directories exist as symlinks to ~/.claude/
* Creates ~/.claude/ structure if missing
*/
ensureSharedDirectories(): void {
// Create ~/.claude/ if missing
if (!this.getLstatSync(this.claudeDir)) {
console.log(info('Creating ~/.claude/ directory structure'));
fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 });
}
// Create shared directory
if (!this.getLstatSync(this.sharedDir)) {
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);
const sharedPath = path.join(this.sharedDir, item.name);
// Create in ~/.claude/ if missing
if (!this.getLstatSync(claudePath)) {
if (item.type === 'directory') {
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
} else if (item.type === 'file') {
// Create empty settings.json if missing
fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8');
}
}
// Check for circular symlink
if (this.detectCircularSymlink(claudePath)) {
console.log(warn(`Skipping ${item.name}: circular symlink detected`));
continue;
}
// If already a symlink pointing to correct target, skip
if (this.getLstatSync(sharedPath)) {
try {
const stats = fs.lstatSync(sharedPath);
if (stats.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(sharedPath);
const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget);
if (resolvedTarget === claudePath) {
continue; // Already correct
}
}
} catch (_err) {
// Continue to recreate
}
// Remove existing file/directory/link
if (item.type === 'directory') {
fs.rmSync(sharedPath, { recursive: true, force: true });
} else {
fs.unlinkSync(sharedPath);
}
}
// Create symlink
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(claudePath, sharedPath, symlinkType);
} catch (_err) {
// Windows fallback: copy
if (process.platform === 'win32') {
if (item.type === 'directory') {
this.copyDirectoryFallback(claudePath, sharedPath);
} else if (item.type === 'file') {
fs.copyFileSync(claudePath, sharedPath);
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
}
}
}
/**
* Link shared directories to instance
*/
linkSharedDirectories(instancePath: string): void {
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);
this.removeExistingPath(linkPath, item.type);
// Create symlink
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(targetPath, linkPath, symlinkType);
} catch (_err) {
// Windows fallback
if (process.platform === 'win32') {
if (item.type === 'directory') {
this.copyDirectoryFallback(targetPath, linkPath);
} else if (item.type === 'file') {
fs.copyFileSync(targetPath, linkPath);
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
}
}
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.
*
* - isolated (default): each profile keeps its own ./projects directory.
* - shared: profile ./projects becomes symlink to shared context group root.
*/
async syncProjectContext(instancePath: string, policy: AccountContextPolicy): Promise<void> {
const projectsPath = path.join(instancePath, 'projects');
const instanceName = path.basename(instancePath);
const mode = policy.mode === 'shared' ? 'shared' : 'isolated';
if (mode === 'shared') {
const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
const sharedProjectsPath = path.join(
this.sharedDir,
'context-groups',
contextGroup,
'projects'
);
await this.ensureDirectory(sharedProjectsPath);
await this.ensureDirectory(path.dirname(projectsPath));
const currentStats = await this.getLstat(projectsPath);
if (!currentStats) {
await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath);
return;
}
if (currentStats.isSymbolicLink()) {
if (await this.isSymlinkTarget(projectsPath, sharedProjectsPath)) {
return;
}
const currentTarget = await this.resolveSymlinkTargetPath(projectsPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) &&
this.isSafeProjectsMergeSource(currentTarget, instanceName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
sharedProjectsPath,
instanceName
);
} else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) {
console.log(
warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`)
);
}
await fs.promises.unlink(projectsPath);
await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath);
return;
}
if (currentStats.isDirectory()) {
await this.detachLegacySharedMemoryLinks(projectsPath, instanceName);
await this.mergeDirectoryWithConflictCopies(projectsPath, sharedProjectsPath, instanceName);
await fs.promises.rm(projectsPath, { recursive: true, force: true });
await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath);
return;
}
await fs.promises.rm(projectsPath, { force: true });
await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath);
return;
}
const currentStats = await this.getLstat(projectsPath);
if (!currentStats) {
await this.ensureDirectory(projectsPath);
return;
}
if (currentStats.isDirectory()) {
await this.detachLegacySharedMemoryLinks(projectsPath, instanceName);
return;
}
if (currentStats.isSymbolicLink()) {
const currentTarget = await this.resolveSymlinkTargetPath(projectsPath);
await fs.promises.unlink(projectsPath);
await this.ensureDirectory(projectsPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(projectsPath) &&
this.isSafeProjectsMergeSource(currentTarget, instanceName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName);
} else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) {
console.log(
warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`)
);
}
return;
}
await fs.promises.rm(projectsPath, { force: true });
await this.ensureDirectory(projectsPath);
}
/**
* Sync advanced continuity artifacts for shared deeper mode.
*
* - shared + deeper: artifacts are linked per context group.
* - shared + standard / isolated: artifacts stay local to instance.
*/
async syncAdvancedContinuityArtifacts(
instancePath: string,
policy: AccountContextPolicy
): Promise<void> {
const instanceName = path.basename(instancePath);
const useSharedContinuity = policy.mode === 'shared' && policy.continuityMode === 'deeper';
const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
for (const artifactName of this.advancedContinuityItems) {
const instanceArtifactPath = path.join(instancePath, artifactName);
if (useSharedContinuity) {
const sharedArtifactPath = path.join(
this.sharedDir,
'context-groups',
contextGroup,
'continuity',
artifactName
);
await this.ensureDirectory(sharedArtifactPath);
await this.ensureDirectory(path.dirname(instanceArtifactPath));
const currentStats = await this.getLstat(instanceArtifactPath);
if (!currentStats) {
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
if (currentStats.isSymbolicLink()) {
if (await this.isSymlinkTarget(instanceArtifactPath, sharedArtifactPath)) {
continue;
}
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedArtifactPath) &&
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
sharedArtifactPath,
instanceName
);
} else if (
currentTarget &&
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
) {
console.log(
warn(
`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`
)
);
}
await fs.promises.unlink(instanceArtifactPath);
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
if (currentStats.isDirectory()) {
await this.mergeDirectoryWithConflictCopies(
instanceArtifactPath,
sharedArtifactPath,
instanceName
);
await fs.promises.rm(instanceArtifactPath, { recursive: true, force: true });
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
const currentStats = await this.getLstat(instanceArtifactPath);
if (!currentStats) {
await this.ensureDirectory(instanceArtifactPath);
continue;
}
if (currentStats.isDirectory()) {
continue;
}
if (currentStats.isSymbolicLink()) {
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
await fs.promises.unlink(instanceArtifactPath);
await this.ensureDirectory(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(instanceArtifactPath) &&
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
instanceArtifactPath,
instanceName
);
} else if (
currentTarget &&
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
) {
console.log(
warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`)
);
}
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await this.ensureDirectory(instanceArtifactPath);
}
}
/**
* Ensure all project memory directories for an instance are shared.
*
* Source layout (isolated):
* ~/.ccs/instances/<profile>/projects/<project>/memory/
*
* Shared layout (canonical):
* ~/.ccs/shared/memory/<project>/
*/
async syncProjectMemories(instancePath: string): Promise<void> {
const projectsDir = path.join(instancePath, 'projects');
if (!(await this.pathExists(projectsDir))) {
return;
}
await this.ensureDirectory(this.sharedDir);
const sharedMemoryRoot = path.join(this.sharedDir, 'memory');
await this.ensureDirectory(sharedMemoryRoot);
let projectEntries: fs.Dirent[] = [];
try {
projectEntries = await fs.promises.readdir(projectsDir, { withFileTypes: true });
} catch (_err) {
return;
}
const projects = projectEntries.filter((entry) => entry.isDirectory());
if (projects.length === 0) {
return;
}
let migrated = 0;
let merged = 0;
let linked = 0;
const instanceName = path.basename(instancePath);
for (const project of projects) {
const projectDir = path.join(projectsDir, project.name);
const projectMemoryPath = path.join(projectDir, 'memory');
const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name);
const projectMemoryStats = await this.getLstat(projectMemoryPath);
if (!projectMemoryStats) {
if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) {
linked++;
}
continue;
}
if (projectMemoryStats.isSymbolicLink()) {
if (await this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) {
continue;
}
await fs.promises.unlink(projectMemoryPath);
if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) {
linked++;
}
continue;
}
if (!projectMemoryStats.isDirectory()) {
continue;
}
if (!(await this.pathExists(sharedProjectMemoryPath))) {
await this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath);
migrated++;
} else {
merged += await this.mergeDirectoryWithConflictCopies(
projectMemoryPath,
sharedProjectMemoryPath,
instanceName
);
await fs.promises.rm(projectMemoryPath, { recursive: true, force: true });
}
if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) {
linked++;
}
}
if (migrated > 0 || merged > 0 || linked > 0) {
console.log(
ok(
`Synced shared project memory: ${migrated} migrated, ${merged} merged conflict(s), ${linked} linked`
)
);
}
}
/**
* 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.
*
* This ensures installed_plugins.json is consistent regardless of
* which CCS instance installed the plugin.
*/
normalizePluginRegistryPaths(configDir?: string): void {
this.normalizePluginMetadataFiles(
'installed_plugins.json',
configDir,
'Normalized plugin registry paths',
'plugin registry'
);
}
/**
* 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 {
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(
fileName: string,
configDir: string | undefined,
successMessage: string,
warningLabel: string
): void {
const seen = new Set<string>();
for (const registryPath of this.getPluginMetadataFilePaths(fileName, configDir)) {
const dedupeKey = this.resolveCanonicalPath(registryPath);
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
this.normalizePluginMetadataFile(registryPath, successMessage, warningLabel);
}
}
private getPluginMetadataFilePaths(fileName: string, configDir?: string): string[] {
const pluginDirs = new Set<string>([
path.join(this.claudeDir, 'plugins'),
path.join(this.sharedDir, 'plugins'),
]);
if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) {
pluginDirs.add(path.join(configDir, 'plugins'));
}
return [...pluginDirs].map((pluginDir) => path.join(pluginDir, fileName));
}
private normalizePluginMetadataFile(
registryPath: string,
successMessage: string,
warningLabel: string
): void {
if (!fs.existsSync(registryPath)) {
return;
}
try {
const original = fs.readFileSync(registryPath, 'utf8');
const normalized = normalizePluginMetadataContent(original);
if (normalized !== original) {
fs.writeFileSync(registryPath, normalized, 'utf8');
console.log(ok(successMessage));
}
} catch (err) {
console.log(warn(`Could not normalize ${warningLabel}: ${(err as Error).message}`));
}
}
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
*/
migrateFromV311(): void {
// Check if migration already done (shared dirs are symlinks)
const commandsPath = path.join(this.sharedDir, 'commands');
if (fs.existsSync(commandsPath)) {
try {
if (fs.lstatSync(commandsPath).isSymbolicLink()) {
return; // Already migrated
}
} catch (_err) {
// Continue with migration
}
}
console.log(info('Migrating from v3.1.1 to v3.2.0...'));
// Ensure ~/.claude/ exists
if (!fs.existsSync(this.claudeDir)) {
fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 });
}
// Copy user modifications from ~/.ccs/shared/ to ~/.claude/
for (const item of this.sharedItems) {
const sharedPath = path.join(this.sharedDir, item.name);
const claudePath = path.join(this.claudeDir, item.name);
if (!fs.existsSync(sharedPath)) continue;
try {
const stats = fs.lstatSync(sharedPath);
// Handle directories
if (item.type === 'directory' && stats.isDirectory()) {
// Create claude dir if missing
if (!fs.existsSync(claudePath)) {
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
}
// Copy files from shared to claude (preserve user modifications)
const entries = fs.readdirSync(sharedPath, { withFileTypes: true });
let copied = 0;
for (const entry of entries) {
const src = path.join(sharedPath, entry.name);
const dest = path.join(claudePath, entry.name);
// Skip if already exists in claude
if (fs.existsSync(dest)) continue;
if (entry.isDirectory()) {
fs.cpSync(src, dest, { recursive: true });
} else {
fs.copyFileSync(src, dest);
}
copied++;
}
if (copied > 0) {
console.log(ok(`Migrated ${copied} ${item.name} to ~/.claude/${item.name}`));
}
}
// Handle files (settings.json)
else if (item.type === 'file' && stats.isFile()) {
// Only copy if ~/.claude/ version doesn't exist
if (!fs.existsSync(claudePath)) {
fs.copyFileSync(sharedPath, claudePath);
console.log(ok(`Migrated ${item.name} to ~/.claude/${item.name}`));
}
}
} catch (_err) {
console.log(warn(`Failed to migrate ${item.name}: ${(_err as Error).message}`));
}
}
// Now run ensureSharedDirectories to create symlinks
this.ensureSharedDirectories();
// Update all instances to use new symlinks
if (fs.existsSync(this.instancesDir)) {
try {
const instances = fs.readdirSync(this.instancesDir);
for (const instance of instances) {
const instancePath = path.join(this.instancesDir, instance);
try {
if (fs.statSync(instancePath).isDirectory()) {
this.linkSharedDirectories(instancePath);
}
} catch (_err) {
console.log(warn(`Failed to update instance ${instance}: ${(_err as Error).message}`));
}
}
} catch (_err) {
// No instances to update
}
}
console.log(ok('Migration to v3.2.0 complete'));
}
/**
* Migrate existing instances from isolated to shared settings.json (v4.4+)
* Runs once on upgrade
*/
migrateToSharedSettings(): void {
console.log(info('Migrating instances to shared settings.json...'));
// Ensure ~/.claude/settings.json exists (authoritative source)
const claudeSettings = path.join(this.claudeDir, 'settings.json');
if (!fs.existsSync(claudeSettings)) {
// Create empty settings if missing
fs.writeFileSync(claudeSettings, JSON.stringify({}, null, 2), 'utf8');
console.log(info('Created ~/.claude/settings.json'));
}
// Ensure shared settings.json symlink exists
this.ensureSharedDirectories();
// Migrate each instance
if (!fs.existsSync(this.instancesDir)) {
console.log(info('No instances to migrate'));
return;
}
const instances = fs.readdirSync(this.instancesDir).filter((name) => {
const instancePath = path.join(this.instancesDir, name);
return fs.statSync(instancePath).isDirectory();
});
let migrated = 0;
let skipped = 0;
for (const instance of instances) {
const instancePath = path.join(this.instancesDir, instance);
const instanceSettings = path.join(instancePath, 'settings.json');
try {
// Check if already symlink
if (fs.existsSync(instanceSettings)) {
const stats = fs.lstatSync(instanceSettings);
if (stats.isSymbolicLink()) {
skipped++;
continue; // Already migrated
}
// Backup existing settings
const backup = instanceSettings + '.pre-shared-migration';
if (!fs.existsSync(backup)) {
fs.copyFileSync(instanceSettings, backup);
console.log(info(`Backed up ${instance}/settings.json`));
}
// Remove old settings.json
fs.unlinkSync(instanceSettings);
}
// Create symlink via SharedManager
const sharedSettings = path.join(this.sharedDir, 'settings.json');
try {
fs.symlinkSync(sharedSettings, instanceSettings, 'file');
migrated++;
} catch (_err) {
// Windows fallback
if (process.platform === 'win32') {
fs.copyFileSync(sharedSettings, instanceSettings);
console.log(warn(`Symlink failed for ${instance}, copied instead`));
migrated++;
} else {
throw _err;
}
}
} catch (_err) {
console.log(warn(`Failed to migrate ${instance}: ${(_err as Error).message}`));
}
}
console.log(ok(`Migrated ${migrated} instance(s), skipped ${skipped}`));
}
/**
* Ensure memory path is linked to shared memory root.
* Returns true when a link/copy was created or updated.
*/
private async ensureProjectMemoryLink(linkPath: string, targetPath: string): Promise<boolean> {
await this.ensureDirectory(targetPath);
const linkStats = await this.getLstat(linkPath);
if (linkStats) {
if (linkStats.isSymbolicLink() && (await this.isSymlinkTarget(linkPath, targetPath))) {
return false;
}
if (linkStats.isDirectory()) {
await fs.promises.rm(linkPath, { recursive: true, force: true });
} else {
await fs.promises.unlink(linkPath);
}
}
const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir';
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
try {
await fs.promises.symlink(linkTarget, linkPath, symlinkType);
return true;
} catch (_err) {
if (process.platform === 'win32') {
this.copyDirectoryFallback(targetPath, linkPath);
console.log(
warn(`Symlink failed for project memory, copied instead (enable Developer Mode)`)
);
return true;
}
throw _err;
}
}
/**
* Check whether symlink points to expected target.
*/
private async isSymlinkTarget(linkPath: string, expectedTarget: string): Promise<boolean> {
try {
const stats = await fs.promises.lstat(linkPath);
if (!stats.isSymbolicLink()) {
return false;
}
const currentTarget = await fs.promises.readlink(linkPath);
const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget);
const resolvedExpectedTarget = path.resolve(expectedTarget);
return resolvedCurrentTarget === resolvedExpectedTarget;
} catch (_err) {
return false;
}
}
/**
* Resolve symlink target to absolute path.
*/
private async resolveSymlinkTargetPath(linkPath: string): Promise<string | null> {
try {
const currentTarget = await fs.promises.readlink(linkPath);
return path.resolve(path.dirname(linkPath), currentTarget);
} catch (_err) {
return null;
}
}
/**
* Guard project merge operations to known CCS-managed roots only.
*/
private isSafeProjectsMergeSource(sourcePath: string, instanceName: string): boolean {
const resolvedSource = this.resolveCanonicalPath(sourcePath);
const sharedContextRoot = this.resolveCanonicalPath(
path.join(this.sharedDir, 'context-groups')
);
const instanceProjectsRoot = this.resolveCanonicalPath(
path.join(this.instancesDir, instanceName, 'projects')
);
return (
this.isPathWithinDirectory(resolvedSource, sharedContextRoot) ||
this.isPathWithinDirectory(resolvedSource, instanceProjectsRoot)
);
}
/**
* Guard advanced continuity merge operations to known CCS-managed roots only.
*/
private isSafeContinuityMergeSource(
sourcePath: string,
instanceName: string,
artifactName: string
): boolean {
const resolvedSource = this.resolveCanonicalPath(sourcePath);
const sharedContextRoot = this.resolveCanonicalPath(
path.join(this.sharedDir, 'context-groups')
);
const instanceArtifactRoot = this.resolveCanonicalPath(
path.join(this.instancesDir, instanceName, artifactName)
);
const normalizedSource =
process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource;
const continuitySegment =
process.platform === 'win32'
? `${path.sep}continuity${path.sep}`.toLowerCase()
: `${path.sep}continuity${path.sep}`;
const withinSharedContinuity =
this.isPathWithinDirectory(resolvedSource, sharedContextRoot) &&
normalizedSource.includes(continuitySegment);
return (
withinSharedContinuity || this.isPathWithinDirectory(resolvedSource, instanceArtifactRoot)
);
}
/**
* Link directory with Windows fallback to recursive copy.
*/
private async linkDirectoryWithFallback(targetPath: string, linkPath: string): Promise<void> {
const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir';
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
try {
await fs.promises.symlink(linkTarget, linkPath, symlinkType);
} catch (_err) {
if (process.platform === 'win32') {
this.copyDirectoryFallback(targetPath, linkPath);
console.log(
warn(`Symlink failed for context projects, copied instead (enable Developer Mode)`)
);
return;
}
throw _err;
}
}
/**
* Migrate legacy per-project memory symlinks that point to ~/.ccs/shared/memory.
* This preserves data while restoring true profile isolation.
*/
private async detachLegacySharedMemoryLinks(
projectsPath: string,
instanceName: string
): Promise<void> {
const sharedMemoryRoot = this.resolveCanonicalPath(path.join(this.sharedDir, 'memory'));
let projectEntries: fs.Dirent[] = [];
try {
projectEntries = await fs.promises.readdir(projectsPath, { withFileTypes: true });
} catch (_err) {
return;
}
for (const entry of projectEntries) {
if (!entry.isDirectory()) {
continue;
}
const projectPath = path.join(projectsPath, entry.name);
const memoryPath = path.join(projectPath, 'memory');
const memoryStats = await this.getLstat(memoryPath);
if (!memoryStats?.isSymbolicLink()) {
continue;
}
const memoryTarget = await this.resolveSymlinkTargetPath(memoryPath);
if (!memoryTarget) {
continue;
}
const canonicalMemoryTarget = this.resolveCanonicalPath(memoryTarget);
if (!this.isPathWithinDirectory(canonicalMemoryTarget, sharedMemoryRoot)) {
continue;
}
await fs.promises.unlink(memoryPath);
await this.ensureDirectory(memoryPath);
if (await this.pathExists(canonicalMemoryTarget)) {
await this.mergeDirectoryWithConflictCopies(
canonicalMemoryTarget,
memoryPath,
instanceName
);
}
}
}
/**
* Move directory, with cross-device fallback.
*/
private async moveDirectory(src: string, dest: string): Promise<void> {
try {
await fs.promises.rename(src, dest);
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code !== 'EXDEV') {
throw err;
}
await fs.promises.cp(src, dest, { recursive: true });
await fs.promises.rm(src, { recursive: true, force: true });
}
}
/**
* Merge source into target. On file conflicts, keep target and copy source
* as "<name>.migrated-from-<instance>[-N]" to avoid data loss.
*/
private async mergeDirectoryWithConflictCopies(
sourceDir: string,
targetDir: string,
instanceName: string
): Promise<number> {
await this.ensureDirectory(targetDir);
let conflicts = 0;
const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
conflicts += await this.mergeDirectoryWithConflictCopies(
sourcePath,
targetPath,
instanceName
);
continue;
}
if (entry.isFile()) {
if (!(await this.pathExists(targetPath))) {
await fs.promises.copyFile(sourcePath, targetPath);
continue;
}
if (await this.fileContentsEqual(sourcePath, targetPath)) {
continue;
}
const conflictPath = await this.getConflictCopyPath(targetPath, instanceName);
await fs.promises.copyFile(sourcePath, conflictPath);
conflicts++;
}
}
return conflicts;
}
/**
* Compare two files byte-for-byte.
*/
private async fileContentsEqual(fileA: string, fileB: string): Promise<boolean> {
try {
const [statA, statB] = await Promise.all([fs.promises.stat(fileA), fs.promises.stat(fileB)]);
if (statA.size !== statB.size) {
return false;
}
const [contentA, contentB] = await Promise.all([
fs.promises.readFile(fileA),
fs.promises.readFile(fileB),
]);
return contentA.equals(contentB);
} catch (_err) {
return false;
}
}
/**
* Build a non-destructive conflict copy path.
*/
private async getConflictCopyPath(
existingTargetPath: string,
instanceName: string
): Promise<string> {
const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
const baseSuffix = `.migrated-from-${safeInstanceName}`;
let candidate = `${existingTargetPath}${baseSuffix}`;
let sequence = 1;
while (await this.pathExists(candidate)) {
candidate = `${existingTargetPath}${baseSuffix}-${sequence}`;
sequence++;
}
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);
} catch {
return path.resolve(targetPath);
}
}
private isPathWithinDirectory(candidatePath: string, rootPath: string): boolean {
const normalizeForCompare = (inputPath: string): string => {
const resolved = path.resolve(inputPath);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
};
const normalizedCandidate = normalizeForCompare(candidatePath);
const normalizedRoot = normalizeForCompare(rootPath);
const relative = path.relative(normalizedRoot, normalizedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
private async pathExists(targetPath: string): Promise<boolean> {
try {
await fs.promises.access(targetPath);
return true;
} catch (_err) {
return false;
}
}
private async ensureDirectory(targetPath: string): Promise<void> {
await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o700 });
}
private async getLstat(targetPath: string): Promise<fs.Stats | null> {
try {
return await fs.promises.lstat(targetPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw err;
}
}
private getLstatSync(targetPath: string): fs.Stats | null {
try {
return fs.lstatSync(targetPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw err;
}
}
/**
* Copy directory as fallback (Windows without Developer Mode)
*/
private copyDirectoryFallback(src: string, dest: string): void {
if (!fs.existsSync(src)) {
fs.mkdirSync(src, { recursive: true, mode: 0o700 });
return;
}
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
this.copyDirectoryFallback(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
}
export default SharedManager;