Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}-`));
Expand Down Expand Up @@ -878,6 +883,129 @@ describe('Installer — Cursor rules file cleanup on uninstall', () => {
});
});

describe('Installer — symlink preservation', () => {
const MARKER_START = '<!-- CODEGRAPH_START -->';
const MARKER_END = '<!-- CODEGRAPH_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[] = [];
Expand Down
38 changes: 34 additions & 4 deletions src/installer/targets/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,51 @@ export function readJsonFile(filePath: string): Record<string, any> {
}
}

/**
* 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 `<path>.tmp.<pid>`, 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;
Expand Down Expand Up @@ -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');
}
Expand Down