From e2623e632c057dc88f7ee1061864b44efbe2e03a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 23:18:34 +0700 Subject: [PATCH 1/2] feat(memory): share project memory across account instances --- src/management/instance-manager.ts | 3 + src/management/shared-manager.ts | 249 ++++++++++++++++++++++++++ tests/unit/shared-memory-sync.test.ts | 113 ++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 tests/unit/shared-memory-sync.test.ts 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); + }); +}); From c6c44d03411a85034ab452ac8fc82b35b50c3c27 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 23:29:22 +0700 Subject: [PATCH 2/2] fix(memory): use async fs APIs to satisfy maintainability gate --- src/auth/commands/create-command.ts | 2 +- src/ccs.ts | 2 +- src/management/instance-manager.ts | 4 +- src/management/shared-manager.ts | 147 +++++++++++++++----------- tests/unit/shared-memory-sync.test.ts | 12 +-- 5 files changed, 97 insertions(+), 70 deletions(-) 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 9e177d2a..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 @@ -38,7 +38,7 @@ class InstanceManager { this.validateInstance(instancePath); // Keep project memory shared across instances. - this.sharedManager.syncProjectMemories(instancePath); + await this.sharedManager.syncProjectMemories(instancePath); return instancePath; } diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 03d1e633..7871e95d 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -210,25 +210,25 @@ class SharedManager { * Shared layout (canonical): * ~/.ccs/shared/memory// */ - syncProjectMemories(instancePath: string): void { + async syncProjectMemories(instancePath: string): Promise { const projectsDir = path.join(instancePath, 'projects'); - if (!fs.existsSync(projectsDir)) { + if (!(await this.pathExists(projectsDir))) { return; } - if (!fs.existsSync(this.sharedDir)) { - fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); - } + await this.ensureDirectory(this.sharedDir); const sharedMemoryRoot = path.join(this.sharedDir, 'memory'); - if (!fs.existsSync(sharedMemoryRoot)) { - fs.mkdirSync(sharedMemoryRoot, { recursive: true, mode: 0o700 }); + await this.ensureDirectory(sharedMemoryRoot); + + let projectEntries: fs.Dirent[] = []; + try { + projectEntries = await fs.promises.readdir(projectsDir, { withFileTypes: true }); + } catch (_err) { + return; } - const projects = fs.readdirSync(projectsDir, { withFileTypes: true }).filter((entry) => { - return entry.isDirectory(); - }); - + const projects = projectEntries.filter((entry) => entry.isDirectory()); if (projects.length === 0) { return; } @@ -243,22 +243,21 @@ class SharedManager { const projectMemoryPath = path.join(projectDir, 'memory'); const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name); - if (!fs.existsSync(projectMemoryPath)) { - if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + const projectMemoryStats = await this.getLstat(projectMemoryPath); + if (!projectMemoryStats) { + if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { linked++; } continue; } - const projectMemoryStats = fs.lstatSync(projectMemoryPath); - if (projectMemoryStats.isSymbolicLink()) { - if (this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { + if (await this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { continue; } - fs.unlinkSync(projectMemoryPath); - if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + await fs.promises.unlink(projectMemoryPath); + if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { linked++; } continue; @@ -268,19 +267,19 @@ class SharedManager { continue; } - if (!fs.existsSync(sharedProjectMemoryPath)) { - this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath); + if (!(await this.pathExists(sharedProjectMemoryPath))) { + await this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath); migrated++; } else { - merged += this.mergeDirectoryWithConflictCopies( + merged += await this.mergeDirectoryWithConflictCopies( projectMemoryPath, sharedProjectMemoryPath, instanceName ); - fs.rmSync(projectMemoryPath, { recursive: true, force: true }); + await fs.promises.rm(projectMemoryPath, { recursive: true, force: true }); } - if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { + if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { linked++; } } @@ -517,21 +516,19 @@ class SharedManager { * 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 }); - } + private async ensureProjectMemoryLink(linkPath: string, targetPath: string): Promise { + await this.ensureDirectory(targetPath); - if (fs.existsSync(linkPath)) { - const stats = fs.lstatSync(linkPath); - if (stats.isSymbolicLink() && this.isSymlinkTarget(linkPath, targetPath)) { + const linkStats = await this.getLstat(linkPath); + if (linkStats) { + if (linkStats.isSymbolicLink() && (await this.isSymlinkTarget(linkPath, targetPath))) { return false; } - if (stats.isDirectory()) { - fs.rmSync(linkPath, { recursive: true, force: true }); + if (linkStats.isDirectory()) { + await fs.promises.rm(linkPath, { recursive: true, force: true }); } else { - fs.unlinkSync(linkPath); + await fs.promises.unlink(linkPath); } } @@ -539,7 +536,7 @@ class SharedManager { const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; try { - fs.symlinkSync(linkTarget, linkPath, symlinkType); + await fs.promises.symlink(linkTarget, linkPath, symlinkType); return true; } catch (_err) { if (process.platform === 'win32') { @@ -556,14 +553,14 @@ class SharedManager { /** * Check whether symlink points to expected target. */ - private isSymlinkTarget(linkPath: string, expectedTarget: string): boolean { + private async isSymlinkTarget(linkPath: string, expectedTarget: string): Promise { try { - const stats = fs.lstatSync(linkPath); + const stats = await fs.promises.lstat(linkPath); if (!stats.isSymbolicLink()) { return false; } - const currentTarget = fs.readlinkSync(linkPath); + const currentTarget = await fs.promises.readlink(linkPath); const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); const resolvedExpectedTarget = path.resolve(expectedTarget); return resolvedCurrentTarget === resolvedExpectedTarget; @@ -575,17 +572,17 @@ class SharedManager { /** * Move directory, with cross-device fallback. */ - private moveDirectory(src: string, dest: string): void { + private async moveDirectory(src: string, dest: string): Promise { try { - fs.renameSync(src, dest); + await fs.promises.rename(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 }); + await fs.promises.cp(src, dest, { recursive: true }); + await fs.promises.rm(src, { recursive: true, force: true }); } } @@ -593,39 +590,41 @@ class SharedManager { * Merge source into target. On file conflicts, keep target and copy source * as ".migrated-from-[-N]" to avoid data loss. */ - private mergeDirectoryWithConflictCopies( + private async mergeDirectoryWithConflictCopies( sourceDir: string, targetDir: string, instanceName: string - ): number { - if (!fs.existsSync(targetDir)) { - fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 }); - } + ): Promise { + await this.ensureDirectory(targetDir); let conflicts = 0; - const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); + 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 += this.mergeDirectoryWithConflictCopies(sourcePath, targetPath, instanceName); + conflicts += await this.mergeDirectoryWithConflictCopies( + sourcePath, + targetPath, + instanceName + ); continue; } if (entry.isFile()) { - if (!fs.existsSync(targetPath)) { - fs.copyFileSync(sourcePath, targetPath); + if (!(await this.pathExists(targetPath))) { + await fs.promises.copyFile(sourcePath, targetPath); continue; } - if (this.fileContentsEqual(sourcePath, targetPath)) { + if (await this.fileContentsEqual(sourcePath, targetPath)) { continue; } - const conflictPath = this.getConflictCopyPath(targetPath, instanceName); - fs.copyFileSync(sourcePath, conflictPath); + const conflictPath = await this.getConflictCopyPath(targetPath, instanceName); + await fs.promises.copyFile(sourcePath, conflictPath); conflicts++; } } @@ -636,16 +635,17 @@ class SharedManager { /** * Compare two files byte-for-byte. */ - private fileContentsEqual(fileA: string, fileB: string): boolean { + private async fileContentsEqual(fileA: string, fileB: string): Promise { try { - const statA = fs.statSync(fileA); - const statB = fs.statSync(fileB); + const [statA, statB] = await Promise.all([fs.promises.stat(fileA), fs.promises.stat(fileB)]); if (statA.size !== statB.size) { return false; } - const contentA = fs.readFileSync(fileA); - const contentB = fs.readFileSync(fileB); + const [contentA, contentB] = await Promise.all([ + fs.promises.readFile(fileA), + fs.promises.readFile(fileB), + ]); return contentA.equals(contentB); } catch (_err) { return false; @@ -655,13 +655,16 @@ class SharedManager { /** * Build a non-destructive conflict copy path. */ - private getConflictCopyPath(existingTargetPath: string, instanceName: string): string { + 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 (fs.existsSync(candidate)) { + while (await this.pathExists(candidate)) { candidate = `${existingTargetPath}${baseSuffix}-${sequence}`; sequence++; } @@ -669,6 +672,30 @@ class SharedManager { 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 index b276d06c..a041209b 100644 --- a/tests/unit/shared-memory-sync.test.ts +++ b/tests/unit/shared-memory-sync.test.ts @@ -45,7 +45,7 @@ describe('SharedManager project memory sync', () => { } }); - it('migrates existing project memory and replaces it with a shared symlink', () => { + 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'; @@ -54,7 +54,7 @@ describe('SharedManager project memory sync', () => { fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance knowledge', 'utf8'); const manager = new SharedManager(); - manager.syncProjectMemories(instancePath); + await manager.syncProjectMemories(instancePath); const sharedMemoryFile = path.join(ccsDir, 'shared', 'memory', projectName, 'MEMORY.md'); expect(fs.existsSync(sharedMemoryFile)).toBe(true); @@ -67,7 +67,7 @@ describe('SharedManager project memory sync', () => { expect(resolvedTarget).toBe(path.join(ccsDir, 'shared', 'memory', projectName)); }); - it('preserves canonical memory and writes conflict copy when contents differ', () => { + 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'; @@ -80,7 +80,7 @@ describe('SharedManager project memory sync', () => { fs.writeFileSync(path.join(sharedProjectMemoryPath, 'MEMORY.md'), 'shared memory', 'utf8'); const manager = new SharedManager(); - manager.syncProjectMemories(instancePath); + await manager.syncProjectMemories(instancePath); const canonicalFile = path.join(sharedProjectMemoryPath, 'MEMORY.md'); expect(fs.readFileSync(canonicalFile, 'utf8')).toBe('shared memory'); @@ -93,7 +93,7 @@ describe('SharedManager project memory sync', () => { expect(linkStats.isSymbolicLink()).toBe(true); }); - it('creates shared memory link for projects that do not have memory directory yet', () => { + 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'; @@ -102,7 +102,7 @@ describe('SharedManager project memory sync', () => { fs.mkdirSync(projectPath, { recursive: true }); const manager = new SharedManager(); - manager.syncProjectMemories(instancePath); + await manager.syncProjectMemories(instancePath); const linkStats = fs.lstatSync(projectMemoryPath); expect(linkStats.isSymbolicLink()).toBe(true);