From 481f013ec2581b943131fde1f11e34a098013b38 Mon Sep 17 00:00:00 2001 From: Nelson Melo Date: Fri, 31 Jul 2026 12:14:29 -0400 Subject: [PATCH] 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 --- .../shared-manager/shared-dir-linker.ts | 42 +++++++++++++ tests/unit/shared-manager.test.ts | 59 +++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/management/shared-manager/shared-dir-linker.ts b/src/management/shared-manager/shared-dir-linker.ts index 00acef38..c74fc964 100644 --- a/src/management/shared-manager/shared-dir-linker.ts +++ b/src/management/shared-manager/shared-dir-linker.ts @@ -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 { diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 555953eb..e4c98dee 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -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();