Skip to content
Merged
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
30 changes: 30 additions & 0 deletions packages/cli/src/utils/__tests__/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,36 @@ describe('writeAgentInstructions symlink behavior', () => {
);
});

it('falls back to copy when symlink throws EPERM (Windows without admin)', async () => {
const dir = await createProjectDir();
const symlinkSpy = vi.spyOn(fsPromises, 'symlink');
const copyFileSpy = vi.spyOn(fsPromises, 'copyFile').mockResolvedValue(undefined);

// Make symlink throw EPERM (Windows behavior without admin privileges)
symlinkSpy.mockRejectedValue(
Object.assign(new Error('EPERM: operation not permitted, symlink'), { code: 'EPERM' }),
);

await writeAgentInstructions({
projectRoot: dir,
targetPaths: ['AGENTS.md', 'CLAUDE.md', '.github/copilot-instructions.md'],
interactive: false,
});

// AGENTS.md should be written as a regular file (not symlinked)
expect(mockFs.existsSync(path.join(dir, 'AGENTS.md'))).toBe(true);

// Non-standard paths should fall back to copyFile since symlink failed
expect(copyFileSpy).toHaveBeenCalledWith(
path.join(dir, 'AGENTS.md'),
path.join(dir, 'CLAUDE.md'),
);
expect(copyFileSpy).toHaveBeenCalledWith(
path.join(dir, 'AGENTS.md'),
path.join(dir, '.github', 'copilot-instructions.md'),
);
});

it('does not replace existing non-symlink files with symlinks', async () => {
const dir = await createProjectDir();
const existingClaude = path.join(dir, 'CLAUDE.md');
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/utils/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,20 @@ async function tryLinkTargetToAgents(projectRoot: string, targetPath: string, si
await fsPromises.unlink(destinationPath);
}

await fsPromises.symlink(symlinkTarget, destinationPath);
try {
await fsPromises.symlink(symlinkTarget, destinationPath);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EPERM') {
// On Windows, symlinks require admin privileges.
// Fall back to copying the file instead.
await fsPromises.copyFile(agentsPath, destinationPath);
if (!silent) {
prompts.log.success(`Copied ${AGENT_STANDARD_PATH} to ${targetPath}`);
}
return true;
}
throw err;
}
if (!silent) {
prompts.log.success(`Linked ${targetPath} to ${AGENT_STANDARD_PATH}`);
}
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/utils/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,20 @@ function linkSkills(
mkdirSync(targetDir, { recursive: true });
}

const isWindows = process.platform === 'win32';
const symlinkType = isWindows ? 'junction' : 'dir';

let linked = 0;
for (const skill of skills) {
const linkPath = join(targetDir, skill.dirName);
const sourcePath = join(skillsDir, skill.dirName);
const relativeTarget = relative(targetDir, sourcePath);
const symlinkTarget = isWindows ? sourcePath : relativeTarget;

if (pathExists(linkPath)) {
try {
const existing = readlinkSync(linkPath);
if (existing === relativeTarget) {
if (existing === symlinkTarget) {
prompts.log.info(` ${skill.name} — already linked`);
continue;
}
Expand All @@ -100,7 +104,7 @@ function linkSkills(
}

try {
symlinkSync(relativeTarget, linkPath, 'dir');
symlinkSync(symlinkTarget, linkPath, symlinkType);
} catch (err: unknown) {
prompts.log.warn(` ${skill.name} — failed to create symlink: ${(err as Error).message}`);
continue;
Expand Down
7 changes: 4 additions & 3 deletions packages/tools/src/install-global-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ function setupLocalDevDeps(versionDir: string) {

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

// Symlink transitive deps from packages/cli/node_modules
const cliNodeModules = path.join(cliDir, 'node_modules');
Expand All @@ -267,10 +268,10 @@ function setupLocalDevDeps(versionDir: string) {
if (entry.startsWith('@')) {
mkdirSync(dest, { recursive: true });
for (const sub of readdirSync(src)) {
symlinkSync(path.join(src, sub), path.join(dest, sub), 'dir');
symlinkSync(path.join(src, sub), path.join(dest, sub), symlinkType);
}
} else {
symlinkSync(src, dest, 'dir');
symlinkSync(src, dest, symlinkType);
}
}
}
Expand Down
Loading