Skip to content

Commit 90a23d5

Browse files
authored
fix(cli): avoid symlink EPERM on Windows by falling back to copy/junction (#931)
closes #883 On Windows, creating symlinks requires administrator privileges. This fixes all symlink operations across the codebase: - agent.ts: catch EPERM on file symlinks and fall back to copyFile - skills.ts: use junction type for dir symlinks on Windows, and fix readlink comparison to use absolute path (junctions store absolute) - install-global-cli.ts: use junction type for dir symlinks on Windows
1 parent d27d87d commit 90a23d5

4 files changed

Lines changed: 54 additions & 6 deletions

File tree

packages/cli/src/utils/__tests__/agent.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,36 @@ describe('writeAgentInstructions symlink behavior', () => {
349349
);
350350
});
351351

352+
it('falls back to copy when symlink throws EPERM (Windows without admin)', async () => {
353+
const dir = await createProjectDir();
354+
const symlinkSpy = vi.spyOn(fsPromises, 'symlink');
355+
const copyFileSpy = vi.spyOn(fsPromises, 'copyFile').mockResolvedValue(undefined);
356+
357+
// Make symlink throw EPERM (Windows behavior without admin privileges)
358+
symlinkSpy.mockRejectedValue(
359+
Object.assign(new Error('EPERM: operation not permitted, symlink'), { code: 'EPERM' }),
360+
);
361+
362+
await writeAgentInstructions({
363+
projectRoot: dir,
364+
targetPaths: ['AGENTS.md', 'CLAUDE.md', '.github/copilot-instructions.md'],
365+
interactive: false,
366+
});
367+
368+
// AGENTS.md should be written as a regular file (not symlinked)
369+
expect(mockFs.existsSync(path.join(dir, 'AGENTS.md'))).toBe(true);
370+
371+
// Non-standard paths should fall back to copyFile since symlink failed
372+
expect(copyFileSpy).toHaveBeenCalledWith(
373+
path.join(dir, 'AGENTS.md'),
374+
path.join(dir, 'CLAUDE.md'),
375+
);
376+
expect(copyFileSpy).toHaveBeenCalledWith(
377+
path.join(dir, 'AGENTS.md'),
378+
path.join(dir, '.github', 'copilot-instructions.md'),
379+
);
380+
});
381+
352382
it('does not replace existing non-symlink files with symlinks', async () => {
353383
const dir = await createProjectDir();
354384
const existingClaude = path.join(dir, 'CLAUDE.md');

packages/cli/src/utils/agent.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,20 @@ async function tryLinkTargetToAgents(projectRoot: string, targetPath: string, si
609609
await fsPromises.unlink(destinationPath);
610610
}
611611

612-
await fsPromises.symlink(symlinkTarget, destinationPath);
612+
try {
613+
await fsPromises.symlink(symlinkTarget, destinationPath);
614+
} catch (err: unknown) {
615+
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
616+
// On Windows, symlinks require admin privileges.
617+
// Fall back to copying the file instead.
618+
await fsPromises.copyFile(agentsPath, destinationPath);
619+
if (!silent) {
620+
prompts.log.success(`Copied ${AGENT_STANDARD_PATH} to ${targetPath}`);
621+
}
622+
return true;
623+
}
624+
throw err;
625+
}
613626
if (!silent) {
614627
prompts.log.success(`Linked ${targetPath} to ${AGENT_STANDARD_PATH}`);
615628
}

packages/cli/src/utils/skills.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,20 @@ function linkSkills(
7474
mkdirSync(targetDir, { recursive: true });
7575
}
7676

77+
const isWindows = process.platform === 'win32';
78+
const symlinkType = isWindows ? 'junction' : 'dir';
79+
7780
let linked = 0;
7881
for (const skill of skills) {
7982
const linkPath = join(targetDir, skill.dirName);
8083
const sourcePath = join(skillsDir, skill.dirName);
8184
const relativeTarget = relative(targetDir, sourcePath);
85+
const symlinkTarget = isWindows ? sourcePath : relativeTarget;
8286

8387
if (pathExists(linkPath)) {
8488
try {
8589
const existing = readlinkSync(linkPath);
86-
if (existing === relativeTarget) {
90+
if (existing === symlinkTarget) {
8791
prompts.log.info(` ${skill.name} — already linked`);
8892
continue;
8993
}
@@ -100,7 +104,7 @@ function linkSkills(
100104
}
101105

102106
try {
103-
symlinkSync(relativeTarget, linkPath, 'dir');
107+
symlinkSync(symlinkTarget, linkPath, symlinkType);
104108
} catch (err: unknown) {
105109
prompts.log.warn(` ${skill.name} — failed to create symlink: ${(err as Error).message}`);
106110
continue;

packages/tools/src/install-global-cli.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,8 @@ function setupLocalDevDeps(versionDir: string) {
248248

249249
// Symlink node_modules/vite-plus → packages/cli (source)
250250
const cliDir = path.join(repoRoot, 'packages', 'cli');
251-
symlinkSync(cliDir, path.join(nodeModulesDir, 'vite-plus'), 'dir');
251+
const symlinkType = isWindows ? 'junction' : 'dir';
252+
symlinkSync(cliDir, path.join(nodeModulesDir, 'vite-plus'), symlinkType);
252253

253254
// Symlink transitive deps from packages/cli/node_modules
254255
const cliNodeModules = path.join(cliDir, 'node_modules');
@@ -267,10 +268,10 @@ function setupLocalDevDeps(versionDir: string) {
267268
if (entry.startsWith('@')) {
268269
mkdirSync(dest, { recursive: true });
269270
for (const sub of readdirSync(src)) {
270-
symlinkSync(path.join(src, sub), path.join(dest, sub), 'dir');
271+
symlinkSync(path.join(src, sub), path.join(dest, sub), symlinkType);
271272
}
272273
} else {
273-
symlinkSync(src, dest, 'dir');
274+
symlinkSync(src, dest, symlinkType);
274275
}
275276
}
276277
}

0 commit comments

Comments
 (0)