diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 59e869e21..9568536c7 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -22,6 +22,11 @@ import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targ import { uninstallTargets } from '../src/installer'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; import { cleanupLegacyHooks } from '../src/installer/targets/claude'; +import { + atomicWriteFileSync, + replaceOrAppendMarkedSection, + removeMarkedSection, +} from '../src/installer/targets/shared'; function mkTmpDir(label: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`)); @@ -878,6 +883,129 @@ describe('Installer — Cursor rules file cleanup on uninstall', () => { }); }); +describe('Installer — symlink preservation', () => { + const MARKER_START = ''; + const MARKER_END = ''; + + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkTmpDir('sym'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('atomicWriteFileSync follows a symlink and writes to the real file, preserving the link', () => { + const realFile = path.join(tmpDir, 'real.md'); + const linkFile = path.join(tmpDir, 'link.md'); + + fs.writeFileSync(realFile, 'original content'); + fs.symlinkSync(realFile, linkFile); + + atomicWriteFileSync(linkFile, 'updated via symlink'); + + // Symlink still exists and points to the real file + const lstat = fs.lstatSync(linkFile); + expect(lstat.isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(linkFile)).toBe(realFile); + + // Real file has the new content + expect(fs.readFileSync(realFile, 'utf-8')).toBe('updated via symlink'); + }); + + it('replaceOrAppendMarkedSection preserves a symlink when updating content', () => { + const realFile = path.join(tmpDir, 'real.md'); + const linkFile = path.join(tmpDir, 'link.md'); + + fs.writeFileSync(realFile, 'existing text'); + fs.symlinkSync(realFile, linkFile); + + replaceOrAppendMarkedSection(linkFile, 'CODE BLOCK', MARKER_START, MARKER_END); + + // Symlink preserved + expect(fs.lstatSync(linkFile).isSymbolicLink()).toBe(true); + + // Real file contains the code block + expect(fs.readFileSync(realFile, 'utf-8')).toContain('CODE BLOCK'); + }); + + it('replaceOrAppendMarkedSection updates an existing marker block while preserving the symlink', () => { + const realFile = path.join(tmpDir, 'real.md'); + const linkFile = path.join(tmpDir, 'link.md'); + + const existing = [ + 'prefix line', + MARKER_START, + 'old block', + MARKER_END, + 'suffix line', + ].join('\n'); + fs.writeFileSync(realFile, existing); + fs.symlinkSync(realFile, linkFile); + + replaceOrAppendMarkedSection(linkFile, 'NEW BLOCK', MARKER_START, MARKER_END); + + // Symlink preserved + expect(fs.lstatSync(linkFile).isSymbolicLink()).toBe(true); + + // Real file has updated block + const content = fs.readFileSync(realFile, 'utf-8'); + expect(content).toContain('prefix line'); + expect(content).toContain('NEW BLOCK'); + expect(content).not.toContain('old block'); + expect(content).toContain('suffix line'); + }); + + it('removeMarkedSection removes the target file content but keeps the symlink', () => { + const realFile = path.join(tmpDir, 'real.md'); + const linkFile = path.join(tmpDir, 'link.md'); + + // File contains only the codegraph block (will become empty) + const content = MARKER_START + '\nblock\n' + MARKER_END; + fs.writeFileSync(realFile, content); + fs.symlinkSync(realFile, linkFile); + + const result = removeMarkedSection(linkFile, MARKER_START, MARKER_END); + expect(result).toBe('removed'); + + // Symlink still exists (though now dangling) + expect(fs.lstatSync(linkFile).isSymbolicLink()).toBe(true); + + // Real file was deleted + expect(fs.existsSync(realFile)).toBe(false); + }); + + it('atomicWriteFileSync handles a dangling symlink by creating the target file', () => { + const realFile = path.join(tmpDir, 'real.md'); + const linkFile = path.join(tmpDir, 'link.md'); + + // Dangling symlink — target doesn't exist + fs.symlinkSync(realFile, linkFile); + expect(fs.existsSync(realFile)).toBe(false); + + atomicWriteFileSync(linkFile, 'created via dangling symlink'); + + // Symlink preserved + expect(fs.lstatSync(linkFile).isSymbolicLink()).toBe(true); + + // Target file was created + expect(fs.existsSync(realFile)).toBe(true); + expect(fs.readFileSync(realFile, 'utf-8')).toBe('created via dangling symlink'); + }); + + it('atomicWriteFileSync creates a regular file when the path is not a symlink', () => { + const normalFile = path.join(tmpDir, 'normal.md'); + expect(fs.existsSync(normalFile)).toBe(false); + + atomicWriteFileSync(normalFile, 'just a file'); + + expect(fs.lstatSync(normalFile).isSymbolicLink()).toBe(false); + expect(fs.readFileSync(normalFile, 'utf-8')).toBe('just a file'); + }); +}); + function listAllFiles(dir: string): string[] { if (!fs.existsSync(dir)) return []; const out: string[] = []; diff --git a/src/installer/targets/shared.ts b/src/installer/targets/shared.ts index 6d54ab570..8b633a90d 100644 --- a/src/installer/targets/shared.ts +++ b/src/installer/targets/shared.ts @@ -65,21 +65,51 @@ export function readJsonFile(filePath: string): Record { } } +/** + * If `filePath` is a symbolic link, resolve it to the real target path. + * Otherwise return `filePath` unchanged. Non-existent paths are returned + * as-is so the caller can create them normally. + * + * Handles dangling symlinks by manually resolving via readlink. + */ +function resolveSymlink(filePath: string): string { + let stat: fs.Stats; + try { + stat = fs.lstatSync(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return filePath; + throw err; + } + if (!stat.isSymbolicLink()) return filePath; + + try { + return fs.realpathSync(filePath); + } catch { + // Dangling symlink — resolve manually + const target = fs.readlinkSync(filePath); + return path.resolve(path.dirname(filePath), target); + } +} + /** * Write a file atomically: write to `.tmp.`, then rename. * * Prevents corruption if the process crashes mid-write. The temp * file is cleaned up on rename failure. + * + * If `filePath` is a symbolic link, the write follows the link and + * writes to the real target file, preserving the symlink. */ export function atomicWriteFileSync(filePath: string, content: string): void { - const dir = path.dirname(filePath); + const resolvedPath = resolveSymlink(filePath); + const dir = path.dirname(resolvedPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } - const tmpPath = filePath + '.tmp.' + process.pid; + const tmpPath = resolvedPath + '.tmp.' + process.pid; try { fs.writeFileSync(tmpPath, content); - fs.renameSync(tmpPath, filePath); + fs.renameSync(tmpPath, resolvedPath); } catch (err) { try { fs.unlinkSync(tmpPath); } catch { /* ignore */ } throw err; @@ -198,7 +228,7 @@ export function removeMarkedSection( const joined = before + (before && after ? '\n\n' : '') + after; if (joined.trim() === '') { - try { fs.unlinkSync(filePath); } catch { /* ignore */ } + try { fs.unlinkSync(resolveSymlink(filePath)); } catch { /* ignore */ } } else { atomicWriteFileSync(filePath, joined.trim() + '\n'); }