mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 16:15:13 +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;
|
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
|
* Ensure shared directories exist as symlinks to ~/.claude/ and that the
|
||||||
* plugin layout default directories and registry files are present.
|
* plugin layout default directories and registry files are present.
|
||||||
@@ -138,6 +172,10 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
|||||||
// Continue to recreate
|
// Continue to recreate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.type === 'file') {
|
||||||
|
adoptDivergedFileContent(sharedPath, claudePath);
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === 'directory') {
|
if (item.type === 'directory') {
|
||||||
fs.rmSync(sharedPath, { recursive: true, force: true });
|
fs.rmSync(sharedPath, { recursive: true, force: true });
|
||||||
} else {
|
} else {
|
||||||
@@ -182,6 +220,10 @@ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string):
|
|||||||
const linkPath = path.join(instancePath, item.name);
|
const linkPath = path.join(instancePath, item.name);
|
||||||
const targetPath = path.join(sharedDir, 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);
|
removeExistingPath(linkPath, item.type);
|
||||||
|
|
||||||
try {
|
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', () => {
|
describe('marketplace registry ownership', () => {
|
||||||
it('skips unstatable shared plugin entries during instance linking', () => {
|
it('skips unstatable shared plugin entries during instance linking', () => {
|
||||||
const manager = new SharedManager();
|
const manager = new SharedManager();
|
||||||
|
|||||||
Reference in new issue
Block a user