diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 2063a39c..9e177d2a 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -37,6 +37,9 @@ class InstanceManager { // Validate structure (auto-fix missing dirs) this.validateInstance(instancePath); + // Keep project memory shared across instances. + this.sharedManager.syncProjectMemories(instancePath); + return instancePath; } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 754e42e7..03d1e633 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -201,6 +201,99 @@ class SharedManager { this.normalizePluginRegistryPaths(); } + /** + * Ensure all project memory directories for an instance are shared. + * + * Source layout (isolated): + * ~/.ccs/instances//projects//memory/ + * + * Shared layout (canonical): + * ~/.ccs/shared/memory// + */ + syncProjectMemories(instancePath: string): void { + const projectsDir = path.join(instancePath, 'projects'); + if (!fs.existsSync(projectsDir)) { + return; + } + + if (!fs.existsSync(this.sharedDir)) { + fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); + } + + const sharedMemoryRoot = path.join(this.sharedDir, 'memory'); + if (!fs.existsSync(sharedMemoryRoot)) { + fs.mkdirSync(sharedMemoryRoot, { recursive: true, mode: 0o700 }); + } + + const projects = fs.readdirSync(projectsDir, { withFileTypes: true }).filter((entry) => { + return 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); + + if (!fs.existsSync(projectMemoryPath)) { + if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + linked++; + } + continue; + } + + const projectMemoryStats = fs.lstatSync(projectMemoryPath); + + if (projectMemoryStats.isSymbolicLink()) { + if (this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { + continue; + } + + fs.unlinkSync(projectMemoryPath); + if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + linked++; + } + continue; + } + + if (!projectMemoryStats.isDirectory()) { + continue; + } + + if (!fs.existsSync(sharedProjectMemoryPath)) { + this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath); + migrated++; + } else { + merged += this.mergeDirectoryWithConflictCopies( + projectMemoryPath, + sharedProjectMemoryPath, + instanceName + ); + fs.rmSync(projectMemoryPath, { recursive: true, force: true }); + } + + if (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 registry paths to use canonical ~/.claude/ paths * instead of instance-specific ~/.ccs/instances// paths. @@ -420,6 +513,162 @@ class SharedManager { 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 ensureProjectMemoryLink(linkPath: string, targetPath: string): boolean { + if (!fs.existsSync(targetPath)) { + fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 }); + } + + if (fs.existsSync(linkPath)) { + const stats = fs.lstatSync(linkPath); + if (stats.isSymbolicLink() && this.isSymlinkTarget(linkPath, targetPath)) { + return false; + } + + if (stats.isDirectory()) { + fs.rmSync(linkPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(linkPath); + } + } + + const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir'; + const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; + + try { + fs.symlinkSync(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 isSymlinkTarget(linkPath: string, expectedTarget: string): boolean { + try { + const stats = fs.lstatSync(linkPath); + if (!stats.isSymbolicLink()) { + return false; + } + + const currentTarget = fs.readlinkSync(linkPath); + const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); + const resolvedExpectedTarget = path.resolve(expectedTarget); + return resolvedCurrentTarget === resolvedExpectedTarget; + } catch (_err) { + return false; + } + } + + /** + * Move directory, with cross-device fallback. + */ + private moveDirectory(src: string, dest: string): void { + try { + fs.renameSync(src, dest); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code !== 'EXDEV') { + throw err; + } + + fs.cpSync(src, dest, { recursive: true }); + fs.rmSync(src, { recursive: true, force: true }); + } + } + + /** + * Merge source into target. On file conflicts, keep target and copy source + * as ".migrated-from-[-N]" to avoid data loss. + */ + private mergeDirectoryWithConflictCopies( + sourceDir: string, + targetDir: string, + instanceName: string + ): number { + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 }); + } + + let conflicts = 0; + const entries = fs.readdirSync(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 += this.mergeDirectoryWithConflictCopies(sourcePath, targetPath, instanceName); + continue; + } + + if (entry.isFile()) { + if (!fs.existsSync(targetPath)) { + fs.copyFileSync(sourcePath, targetPath); + continue; + } + + if (this.fileContentsEqual(sourcePath, targetPath)) { + continue; + } + + const conflictPath = this.getConflictCopyPath(targetPath, instanceName); + fs.copyFileSync(sourcePath, conflictPath); + conflicts++; + } + } + + return conflicts; + } + + /** + * Compare two files byte-for-byte. + */ + private fileContentsEqual(fileA: string, fileB: string): boolean { + try { + const statA = fs.statSync(fileA); + const statB = fs.statSync(fileB); + if (statA.size !== statB.size) { + return false; + } + + const contentA = fs.readFileSync(fileA); + const contentB = fs.readFileSync(fileB); + return contentA.equals(contentB); + } catch (_err) { + return false; + } + } + + /** + * Build a non-destructive conflict copy path. + */ + private getConflictCopyPath(existingTargetPath: string, instanceName: string): string { + const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + const baseSuffix = `.migrated-from-${safeInstanceName}`; + + let candidate = `${existingTargetPath}${baseSuffix}`; + let sequence = 1; + while (fs.existsSync(candidate)) { + candidate = `${existingTargetPath}${baseSuffix}-${sequence}`; + sequence++; + } + + return candidate; + } + /** * Copy directory as fallback (Windows without Developer Mode) */ diff --git a/tests/unit/shared-memory-sync.test.ts b/tests/unit/shared-memory-sync.test.ts new file mode 100644 index 00000000..b276d06c --- /dev/null +++ b/tests/unit/shared-memory-sync.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import SharedManager from '../../src/management/shared-manager'; + +function getTestCcsDir(): string { + if (!process.env.CCS_HOME) { + throw new Error('CCS_HOME must be set in tests'); + } + return path.join(path.resolve(process.env.CCS_HOME), '.ccs'); +} + +describe('SharedManager project memory sync', () => { + let tempRoot = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-memory-test-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + const isolatedHome = path.join(tempRoot, 'home'); + fs.mkdirSync(isolatedHome, { recursive: true }); + process.env.HOME = isolatedHome; + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('migrates existing project memory and replaces it with a shared symlink', () => { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + const projectName = '-tmp-my-project'; + const projectMemoryPath = path.join(instancePath, 'projects', projectName, 'memory'); + fs.mkdirSync(projectMemoryPath, { recursive: true }); + fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance knowledge', 'utf8'); + + const manager = new SharedManager(); + manager.syncProjectMemories(instancePath); + + const sharedMemoryFile = path.join(ccsDir, 'shared', 'memory', projectName, 'MEMORY.md'); + expect(fs.existsSync(sharedMemoryFile)).toBe(true); + expect(fs.readFileSync(sharedMemoryFile, 'utf8')).toBe('instance knowledge'); + + const linkStats = fs.lstatSync(projectMemoryPath); + expect(linkStats.isSymbolicLink()).toBe(true); + + const resolvedTarget = path.resolve(path.dirname(projectMemoryPath), fs.readlinkSync(projectMemoryPath)); + expect(resolvedTarget).toBe(path.join(ccsDir, 'shared', 'memory', projectName)); + }); + + it('preserves canonical memory and writes conflict copy when contents differ', () => { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + const projectName = '-tmp-shared-project'; + const projectMemoryPath = path.join(instancePath, 'projects', projectName, 'memory'); + const sharedProjectMemoryPath = path.join(ccsDir, 'shared', 'memory', projectName); + fs.mkdirSync(projectMemoryPath, { recursive: true }); + fs.mkdirSync(sharedProjectMemoryPath, { recursive: true }); + + fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance memory', 'utf8'); + fs.writeFileSync(path.join(sharedProjectMemoryPath, 'MEMORY.md'), 'shared memory', 'utf8'); + + const manager = new SharedManager(); + manager.syncProjectMemories(instancePath); + + const canonicalFile = path.join(sharedProjectMemoryPath, 'MEMORY.md'); + expect(fs.readFileSync(canonicalFile, 'utf8')).toBe('shared memory'); + + const conflictFile = path.join(sharedProjectMemoryPath, 'MEMORY.md.migrated-from-work'); + expect(fs.existsSync(conflictFile)).toBe(true); + expect(fs.readFileSync(conflictFile, 'utf8')).toBe('instance memory'); + + const linkStats = fs.lstatSync(projectMemoryPath); + expect(linkStats.isSymbolicLink()).toBe(true); + }); + + it('creates shared memory link for projects that do not have memory directory yet', () => { + const ccsDir = getTestCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'work'); + const projectName = '-tmp-new-project'; + const projectPath = path.join(instancePath, 'projects', projectName); + const projectMemoryPath = path.join(projectPath, 'memory'); + fs.mkdirSync(projectPath, { recursive: true }); + + const manager = new SharedManager(); + manager.syncProjectMemories(instancePath); + + const linkStats = fs.lstatSync(projectMemoryPath); + expect(linkStats.isSymbolicLink()).toBe(true); + + const sharedProjectMemoryPath = path.join(ccsDir, 'shared', 'memory', projectName); + expect(fs.existsSync(sharedProjectMemoryPath)).toBe(true); + }); +});