mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
feat(memory): share project memory across account instances
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,99 @@ class SharedManager {
|
||||
this.normalizePluginRegistryPaths();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>/
|
||||
*/
|
||||
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/<name>/ 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 "<name>.migrated-from-<instance>[-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)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user