mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-29 14:20:50 +00:00
1689 lines
52 KiB
TypeScript
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;
|