mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 20:13:02 +00:00
fix(shared-manager): adopt diverged settings.json content before re-linking
Claude Code saves settings.json atomically (temp file + rename), which replaces the managed shared symlink with a regular file holding the user's latest changes (see #57). The launch-time relink then deleted that file without reading it, silently reverting plugin enables and any other in-session settings change on every profile relaunch. Adopt the diverged file's content into the canonical ~/.claude file (with a .bak-ccs-adopt backup) before restoring the symlink, at both the shared-level and instance-level reconciliation points. Fixes #1681
This commit is contained in:
1 parent
f532f46c9f
commit
481f013ec2
2 files changed
+101
No files matched your search
@@ -88,6 +88,40 @@ export function detectCircularSymlink(target: string, sharedDir: string): boolea
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code saves settings.json with an atomic write (temp file + rename),
|
||||
* which replaces a managed symlink with a regular file holding the user's
|
||||
* latest changes (e.g. enabledPlugins toggles from /plugins) — see #57.
|
||||
* When reconciliation finds such a diverged regular file, adopt its content
|
||||
* into the canonical ~/.claude file before re-creating the symlink, instead
|
||||
* of discarding the user's changes. The previous canonical content is kept
|
||||
* in a `.bak-ccs-adopt` backup alongside it.
|
||||
*/
|
||||
function adoptDivergedFileContent(divergedPath: string, canonicalPath: string): void {
|
||||
try {
|
||||
const stats = fs.lstatSync(divergedPath);
|
||||
if (!stats.isFile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const diverged = fs.readFileSync(divergedPath);
|
||||
const current = fs.existsSync(canonicalPath) ? fs.readFileSync(canonicalPath) : null;
|
||||
if (current && diverged.equals(current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
fs.copyFileSync(canonicalPath, `${canonicalPath}.bak-ccs-adopt`);
|
||||
}
|
||||
fs.writeFileSync(canonicalPath, diverged);
|
||||
console.log(
|
||||
info(`Adopted diverged ${path.basename(divergedPath)} content into ${canonicalPath}`)
|
||||
);
|
||||
} catch (_err) {
|
||||
// Best effort: fall through to standard re-link behavior.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure shared directories exist as symlinks to ~/.claude/ and that the
|
||||
* plugin layout default directories and registry files are present.
|
||||
@@ -138,6 +172,10 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
||||
// Continue to recreate
|
||||
}
|
||||
|
||||
if (item.type === 'file') {
|
||||
adoptDivergedFileContent(sharedPath, claudePath);
|
||||
}
|
||||
|
||||
if (item.type === 'directory') {
|
||||
fs.rmSync(sharedPath, { recursive: true, force: true });
|
||||
} else {
|
||||
@@ -182,6 +220,10 @@ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string):
|
||||
const linkPath = path.join(instancePath, item.name);
|
||||
const targetPath = path.join(sharedDir, item.name);
|
||||
|
||||
if (item.type === 'file') {
|
||||
adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name));
|
||||
}
|
||||
|
||||
removeExistingPath(linkPath, item.type);
|
||||
|
||||
try {
|
||||
|
||||
@@ -306,6 +306,65 @@ describe('SharedManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('diverged settings adoption', () => {
|
||||
it('adopts a diverged shared settings.json into ~/.claude before re-linking', () => {
|
||||
const manager = new SharedManager();
|
||||
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||
const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json');
|
||||
|
||||
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||
fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true });
|
||||
writeJson(claudeSettingsPath, { enabledPlugins: { 'demo@market': false } });
|
||||
// Simulate Claude Code's atomic save (temp file + rename) replacing the
|
||||
// managed symlink with a regular file carrying the user's latest change.
|
||||
writeJson(sharedSettingsPath, { enabledPlugins: { 'demo@market': true } });
|
||||
|
||||
manager.ensureSharedDirectories();
|
||||
|
||||
expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true);
|
||||
expect(readJson(claudeSettingsPath)).toEqual({
|
||||
enabledPlugins: { 'demo@market': true },
|
||||
});
|
||||
expect(readJson(`${claudeSettingsPath}.bak-ccs-adopt`)).toEqual({
|
||||
enabledPlugins: { 'demo@market': false },
|
||||
});
|
||||
});
|
||||
|
||||
it('adopts a diverged instance settings.json during instance linking', () => {
|
||||
const manager = new SharedManager();
|
||||
const instancePath = instanceDir('work');
|
||||
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||
const instanceSettingsPath = path.join(instancePath, 'settings.json');
|
||||
|
||||
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
writeJson(claudeSettingsPath, { theme: 'light' });
|
||||
writeJson(instanceSettingsPath, { theme: 'dark' });
|
||||
|
||||
manager.linkSharedDirectories(instancePath);
|
||||
|
||||
expect(fs.lstatSync(instanceSettingsPath).isSymbolicLink()).toBe(true);
|
||||
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('does not create a backup when the diverged copy matches the canonical file', () => {
|
||||
const manager = new SharedManager();
|
||||
const claudeSettingsPath = path.join(claudeDir(), 'settings.json');
|
||||
const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json');
|
||||
|
||||
fs.mkdirSync(claudeDir(), { recursive: true });
|
||||
fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true });
|
||||
writeJson(claudeSettingsPath, { theme: 'dark' });
|
||||
fs.copyFileSync(claudeSettingsPath, sharedSettingsPath);
|
||||
|
||||
manager.ensureSharedDirectories();
|
||||
|
||||
expect(fs.lstatSync(sharedSettingsPath).isSymbolicLink()).toBe(true);
|
||||
expect(readJson(claudeSettingsPath)).toEqual({ theme: 'dark' });
|
||||
expect(fs.existsSync(`${claudeSettingsPath}.bak-ccs-adopt`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('marketplace registry ownership', () => {
|
||||
it('skips unstatable shared plugin entries during instance linking', () => {
|
||||
const manager = new SharedManager();
|
||||
|
||||
Reference in new issue
Block a user