diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 3dd55c84..03668b47 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -42,7 +42,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise try { // Create instance directory console.log(info(`Creating profile: ${profileName}`)); - const instancePath = ctx.instanceMgr.ensureInstance(profileName); + const instancePath = await ctx.instanceMgr.ensureInstance(profileName); // Create/update profile entry based on config mode if (isUnifiedMode()) { diff --git a/src/ccs.ts b/src/ccs.ts index 5676fd96..dc673635 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -884,7 +884,7 @@ async function main(): Promise { const instanceMgr = new InstanceManager(); // Ensure instance exists (lazy init if needed) - const instancePath = instanceMgr.ensureInstance(profileInfo.name); + const instancePath = await instanceMgr.ensureInstance(profileInfo.name); // Update last_used timestamp (check unified config first, fallback to legacy) if (registry.hasAccountUnified(profileInfo.name)) { diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 2063a39c..a9b273bd 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -26,7 +26,7 @@ class InstanceManager { /** * Ensure instance exists for profile (lazy init only) */ - ensureInstance(profileName: string): string { + async ensureInstance(profileName: string): Promise { const instancePath = this.getInstancePath(profileName); // Lazy initialization @@ -37,6 +37,9 @@ class InstanceManager { // Validate structure (auto-fix missing dirs) this.validateInstance(instancePath); + // Keep project memory shared across instances. + await this.sharedManager.syncProjectMemories(instancePath); + return instancePath; } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 754e42e7..7871e95d 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -201,6 +201,98 @@ 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// + */ + async syncProjectMemories(instancePath: string): Promise { + 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 registry paths to use canonical ~/.claude/ paths * instead of instance-specific ~/.ccs/instances// paths. @@ -420,6 +512,190 @@ 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 async ensureProjectMemoryLink(linkPath: string, targetPath: string): Promise { + 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 { + 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; + } + } + + /** + * Move directory, with cross-device fallback. + */ + private async moveDirectory(src: string, dest: string): Promise { + 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 ".migrated-from-[-N]" to avoid data loss. + */ + private async mergeDirectoryWithConflictCopies( + sourceDir: string, + targetDir: string, + instanceName: string + ): Promise { + 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 { + 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 { + 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 async pathExists(targetPath: string): Promise { + try { + await fs.promises.access(targetPath); + return true; + } catch (_err) { + return false; + } + } + + private async ensureDirectory(targetPath: string): Promise { + await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o700 }); + } + + private async getLstat(targetPath: string): Promise { + try { + return await fs.promises.lstat(targetPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw err; + } + } + /** * 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..a041209b --- /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', async () => { + 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(); + await 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', async () => { + 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(); + await 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', async () => { + 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(); + await 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); + }); +});