fix(memory): use async fs APIs to satisfy maintainability gate

This commit is contained in:
Tam Nhu Tran
2026-02-23 23:29:22 +07:00
parent e2623e632c
commit c6c44d0341
5 changed files with 97 additions and 70 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
try { try {
// Create instance directory // Create instance directory
console.log(info(`Creating profile: ${profileName}`)); 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 // Create/update profile entry based on config mode
if (isUnifiedMode()) { if (isUnifiedMode()) {
+1 -1
View File
@@ -884,7 +884,7 @@ async function main(): Promise<void> {
const instanceMgr = new InstanceManager(); const instanceMgr = new InstanceManager();
// Ensure instance exists (lazy init if needed) // 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) // Update last_used timestamp (check unified config first, fallback to legacy)
if (registry.hasAccountUnified(profileInfo.name)) { if (registry.hasAccountUnified(profileInfo.name)) {
+2 -2
View File
@@ -26,7 +26,7 @@ class InstanceManager {
/** /**
* Ensure instance exists for profile (lazy init only) * Ensure instance exists for profile (lazy init only)
*/ */
ensureInstance(profileName: string): string { async ensureInstance(profileName: string): Promise<string> {
const instancePath = this.getInstancePath(profileName); const instancePath = this.getInstancePath(profileName);
// Lazy initialization // Lazy initialization
@@ -38,7 +38,7 @@ class InstanceManager {
this.validateInstance(instancePath); this.validateInstance(instancePath);
// Keep project memory shared across instances. // Keep project memory shared across instances.
this.sharedManager.syncProjectMemories(instancePath); await this.sharedManager.syncProjectMemories(instancePath);
return instancePath; return instancePath;
} }
+87 -60
View File
@@ -210,25 +210,25 @@ class SharedManager {
* Shared layout (canonical): * Shared layout (canonical):
* ~/.ccs/shared/memory/<project>/ * ~/.ccs/shared/memory/<project>/
*/ */
syncProjectMemories(instancePath: string): void { async syncProjectMemories(instancePath: string): Promise<void> {
const projectsDir = path.join(instancePath, 'projects'); const projectsDir = path.join(instancePath, 'projects');
if (!fs.existsSync(projectsDir)) { if (!(await this.pathExists(projectsDir))) {
return; return;
} }
if (!fs.existsSync(this.sharedDir)) { await this.ensureDirectory(this.sharedDir);
fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 });
}
const sharedMemoryRoot = path.join(this.sharedDir, 'memory'); const sharedMemoryRoot = path.join(this.sharedDir, 'memory');
if (!fs.existsSync(sharedMemoryRoot)) { await this.ensureDirectory(sharedMemoryRoot);
fs.mkdirSync(sharedMemoryRoot, { recursive: true, mode: 0o700 });
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) => { const projects = projectEntries.filter((entry) => entry.isDirectory());
return entry.isDirectory();
});
if (projects.length === 0) { if (projects.length === 0) {
return; return;
} }
@@ -243,22 +243,21 @@ class SharedManager {
const projectMemoryPath = path.join(projectDir, 'memory'); const projectMemoryPath = path.join(projectDir, 'memory');
const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name); const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name);
if (!fs.existsSync(projectMemoryPath)) { const projectMemoryStats = await this.getLstat(projectMemoryPath);
if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { if (!projectMemoryStats) {
if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) {
linked++; linked++;
} }
continue; continue;
} }
const projectMemoryStats = fs.lstatSync(projectMemoryPath);
if (projectMemoryStats.isSymbolicLink()) { if (projectMemoryStats.isSymbolicLink()) {
if (this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { if (await this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) {
continue; continue;
} }
fs.unlinkSync(projectMemoryPath); await fs.promises.unlink(projectMemoryPath);
if (this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) {
linked++; linked++;
} }
continue; continue;
@@ -268,19 +267,19 @@ class SharedManager {
continue; continue;
} }
if (!fs.existsSync(sharedProjectMemoryPath)) { if (!(await this.pathExists(sharedProjectMemoryPath))) {
this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath); await this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath);
migrated++; migrated++;
} else { } else {
merged += this.mergeDirectoryWithConflictCopies( merged += await this.mergeDirectoryWithConflictCopies(
projectMemoryPath, projectMemoryPath,
sharedProjectMemoryPath, sharedProjectMemoryPath,
instanceName 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++; linked++;
} }
} }
@@ -517,21 +516,19 @@ class SharedManager {
* Ensure memory path is linked to shared memory root. * Ensure memory path is linked to shared memory root.
* Returns true when a link/copy was created or updated. * Returns true when a link/copy was created or updated.
*/ */
private ensureProjectMemoryLink(linkPath: string, targetPath: string): boolean { private async ensureProjectMemoryLink(linkPath: string, targetPath: string): Promise<boolean> {
if (!fs.existsSync(targetPath)) { await this.ensureDirectory(targetPath);
fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 });
}
if (fs.existsSync(linkPath)) { const linkStats = await this.getLstat(linkPath);
const stats = fs.lstatSync(linkPath); if (linkStats) {
if (stats.isSymbolicLink() && this.isSymlinkTarget(linkPath, targetPath)) { if (linkStats.isSymbolicLink() && (await this.isSymlinkTarget(linkPath, targetPath))) {
return false; return false;
} }
if (stats.isDirectory()) { if (linkStats.isDirectory()) {
fs.rmSync(linkPath, { recursive: true, force: true }); await fs.promises.rm(linkPath, { recursive: true, force: true });
} else { } else {
fs.unlinkSync(linkPath); await fs.promises.unlink(linkPath);
} }
} }
@@ -539,7 +536,7 @@ class SharedManager {
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
try { try {
fs.symlinkSync(linkTarget, linkPath, symlinkType); await fs.promises.symlink(linkTarget, linkPath, symlinkType);
return true; return true;
} catch (_err) { } catch (_err) {
if (process.platform === 'win32') { if (process.platform === 'win32') {
@@ -556,14 +553,14 @@ class SharedManager {
/** /**
* Check whether symlink points to expected target. * Check whether symlink points to expected target.
*/ */
private isSymlinkTarget(linkPath: string, expectedTarget: string): boolean { private async isSymlinkTarget(linkPath: string, expectedTarget: string): Promise<boolean> {
try { try {
const stats = fs.lstatSync(linkPath); const stats = await fs.promises.lstat(linkPath);
if (!stats.isSymbolicLink()) { if (!stats.isSymbolicLink()) {
return false; return false;
} }
const currentTarget = fs.readlinkSync(linkPath); const currentTarget = await fs.promises.readlink(linkPath);
const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget);
const resolvedExpectedTarget = path.resolve(expectedTarget); const resolvedExpectedTarget = path.resolve(expectedTarget);
return resolvedCurrentTarget === resolvedExpectedTarget; return resolvedCurrentTarget === resolvedExpectedTarget;
@@ -575,17 +572,17 @@ class SharedManager {
/** /**
* Move directory, with cross-device fallback. * Move directory, with cross-device fallback.
*/ */
private moveDirectory(src: string, dest: string): void { private async moveDirectory(src: string, dest: string): Promise<void> {
try { try {
fs.renameSync(src, dest); await fs.promises.rename(src, dest);
} catch (err) { } catch (err) {
const error = err as NodeJS.ErrnoException; const error = err as NodeJS.ErrnoException;
if (error.code !== 'EXDEV') { if (error.code !== 'EXDEV') {
throw err; throw err;
} }
fs.cpSync(src, dest, { recursive: true }); await fs.promises.cp(src, dest, { recursive: true });
fs.rmSync(src, { recursive: true, force: 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 * Merge source into target. On file conflicts, keep target and copy source
* as "<name>.migrated-from-<instance>[-N]" to avoid data loss. * as "<name>.migrated-from-<instance>[-N]" to avoid data loss.
*/ */
private mergeDirectoryWithConflictCopies( private async mergeDirectoryWithConflictCopies(
sourceDir: string, sourceDir: string,
targetDir: string, targetDir: string,
instanceName: string instanceName: string
): number { ): Promise<number> {
if (!fs.existsSync(targetDir)) { await this.ensureDirectory(targetDir);
fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
}
let conflicts = 0; let conflicts = 0;
const entries = fs.readdirSync(sourceDir, { withFileTypes: true }); const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name); const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name); const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
conflicts += this.mergeDirectoryWithConflictCopies(sourcePath, targetPath, instanceName); conflicts += await this.mergeDirectoryWithConflictCopies(
sourcePath,
targetPath,
instanceName
);
continue; continue;
} }
if (entry.isFile()) { if (entry.isFile()) {
if (!fs.existsSync(targetPath)) { if (!(await this.pathExists(targetPath))) {
fs.copyFileSync(sourcePath, targetPath); await fs.promises.copyFile(sourcePath, targetPath);
continue; continue;
} }
if (this.fileContentsEqual(sourcePath, targetPath)) { if (await this.fileContentsEqual(sourcePath, targetPath)) {
continue; continue;
} }
const conflictPath = this.getConflictCopyPath(targetPath, instanceName); const conflictPath = await this.getConflictCopyPath(targetPath, instanceName);
fs.copyFileSync(sourcePath, conflictPath); await fs.promises.copyFile(sourcePath, conflictPath);
conflicts++; conflicts++;
} }
} }
@@ -636,16 +635,17 @@ class SharedManager {
/** /**
* Compare two files byte-for-byte. * Compare two files byte-for-byte.
*/ */
private fileContentsEqual(fileA: string, fileB: string): boolean { private async fileContentsEqual(fileA: string, fileB: string): Promise<boolean> {
try { try {
const statA = fs.statSync(fileA); const [statA, statB] = await Promise.all([fs.promises.stat(fileA), fs.promises.stat(fileB)]);
const statB = fs.statSync(fileB);
if (statA.size !== statB.size) { if (statA.size !== statB.size) {
return false; return false;
} }
const contentA = fs.readFileSync(fileA); const [contentA, contentB] = await Promise.all([
const contentB = fs.readFileSync(fileB); fs.promises.readFile(fileA),
fs.promises.readFile(fileB),
]);
return contentA.equals(contentB); return contentA.equals(contentB);
} catch (_err) { } catch (_err) {
return false; return false;
@@ -655,13 +655,16 @@ class SharedManager {
/** /**
* Build a non-destructive conflict copy path. * Build a non-destructive conflict copy path.
*/ */
private getConflictCopyPath(existingTargetPath: string, instanceName: string): string { private async getConflictCopyPath(
existingTargetPath: string,
instanceName: string
): Promise<string> {
const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
const baseSuffix = `.migrated-from-${safeInstanceName}`; const baseSuffix = `.migrated-from-${safeInstanceName}`;
let candidate = `${existingTargetPath}${baseSuffix}`; let candidate = `${existingTargetPath}${baseSuffix}`;
let sequence = 1; let sequence = 1;
while (fs.existsSync(candidate)) { while (await this.pathExists(candidate)) {
candidate = `${existingTargetPath}${baseSuffix}-${sequence}`; candidate = `${existingTargetPath}${baseSuffix}-${sequence}`;
sequence++; sequence++;
} }
@@ -669,6 +672,30 @@ class SharedManager {
return candidate; return candidate;
} }
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;
}
}
/** /**
* Copy directory as fallback (Windows without Developer Mode) * Copy directory as fallback (Windows without Developer Mode)
*/ */
+6 -6
View File
@@ -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 ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work'); const instancePath = path.join(ccsDir, 'instances', 'work');
const projectName = '-tmp-my-project'; const projectName = '-tmp-my-project';
@@ -54,7 +54,7 @@ describe('SharedManager project memory sync', () => {
fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance knowledge', 'utf8'); fs.writeFileSync(path.join(projectMemoryPath, 'MEMORY.md'), 'instance knowledge', 'utf8');
const manager = new SharedManager(); const manager = new SharedManager();
manager.syncProjectMemories(instancePath); await manager.syncProjectMemories(instancePath);
const sharedMemoryFile = path.join(ccsDir, 'shared', 'memory', projectName, 'MEMORY.md'); const sharedMemoryFile = path.join(ccsDir, 'shared', 'memory', projectName, 'MEMORY.md');
expect(fs.existsSync(sharedMemoryFile)).toBe(true); expect(fs.existsSync(sharedMemoryFile)).toBe(true);
@@ -67,7 +67,7 @@ describe('SharedManager project memory sync', () => {
expect(resolvedTarget).toBe(path.join(ccsDir, 'shared', 'memory', projectName)); 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 ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work'); const instancePath = path.join(ccsDir, 'instances', 'work');
const projectName = '-tmp-shared-project'; const projectName = '-tmp-shared-project';
@@ -80,7 +80,7 @@ describe('SharedManager project memory sync', () => {
fs.writeFileSync(path.join(sharedProjectMemoryPath, 'MEMORY.md'), 'shared memory', 'utf8'); fs.writeFileSync(path.join(sharedProjectMemoryPath, 'MEMORY.md'), 'shared memory', 'utf8');
const manager = new SharedManager(); const manager = new SharedManager();
manager.syncProjectMemories(instancePath); await manager.syncProjectMemories(instancePath);
const canonicalFile = path.join(sharedProjectMemoryPath, 'MEMORY.md'); const canonicalFile = path.join(sharedProjectMemoryPath, 'MEMORY.md');
expect(fs.readFileSync(canonicalFile, 'utf8')).toBe('shared memory'); expect(fs.readFileSync(canonicalFile, 'utf8')).toBe('shared memory');
@@ -93,7 +93,7 @@ describe('SharedManager project memory sync', () => {
expect(linkStats.isSymbolicLink()).toBe(true); 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 ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work'); const instancePath = path.join(ccsDir, 'instances', 'work');
const projectName = '-tmp-new-project'; const projectName = '-tmp-new-project';
@@ -102,7 +102,7 @@ describe('SharedManager project memory sync', () => {
fs.mkdirSync(projectPath, { recursive: true }); fs.mkdirSync(projectPath, { recursive: true });
const manager = new SharedManager(); const manager = new SharedManager();
manager.syncProjectMemories(instancePath); await manager.syncProjectMemories(instancePath);
const linkStats = fs.lstatSync(projectMemoryPath); const linkStats = fs.lstatSync(projectMemoryPath);
expect(linkStats.isSymbolicLink()).toBe(true); expect(linkStats.isSymbolicLink()).toBe(true);