fix: use directory junctions on Windows for skill linking (#24823)

This commit is contained in:
Enjoy Kumawat
2026-04-08 00:58:43 +05:30
committed by GitHub
parent 5588000e93
commit ab3075feb9
2 changed files with 72 additions and 82 deletions
+65 -81
View File
@@ -26,66 +26,49 @@ describe('skillUtils', () => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
const itif = (condition: boolean) => (condition ? it : it.skip);
describe('linkSkill', () => { describe('linkSkill', () => {
// TODO: issue 19388 - Enable linkSkill tests on Windows it('should successfully link from a local directory', async () => {
itif(process.platform !== 'win32')( // Create a mock skill directory
'should successfully link from a local directory', const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
async () => { const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
// Create a mock skill directory await fs.mkdir(skillSubDir, { recursive: true });
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); await fs.writeFile(
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); path.join(skillSubDir, 'SKILL.md'),
await fs.mkdir(skillSubDir, { recursive: true }); '---\nname: test-skill\ndescription: test\n---\nbody',
await fs.writeFile( );
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
const skills = await linkSkill( const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
mockSkillSourceDir, expect(skills.length).toBe(1);
'workspace', expect(skills[0].name).toBe('test-skill');
() => {},
);
expect(skills.length).toBe(1);
expect(skills[0].name).toBe('test-skill');
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill'); const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
const stats = await fs.lstat(linkedPath); const stats = await fs.lstat(linkedPath);
expect(stats.isSymbolicLink()).toBe(true); expect(stats.isSymbolicLink()).toBe(true);
const linkTarget = await fs.readlink(linkedPath); const linkTarget = await fs.readlink(linkedPath);
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir)); expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
}, });
);
itif(process.platform !== 'win32')( it('should overwrite existing skill at destination', async () => {
'should overwrite existing skill at destination', const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
async () => { const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); await fs.mkdir(skillSubDir, { recursive: true });
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); await fs.writeFile(
await fs.mkdir(skillSubDir, { recursive: true }); path.join(skillSubDir, 'SKILL.md'),
await fs.writeFile( '---\nname: test-skill\ndescription: test\n---\nbody',
path.join(skillSubDir, 'SKILL.md'), );
'---\nname: test-skill\ndescription: test\n---\nbody',
);
const targetDir = path.join(tempDir, '.gemini/skills'); const targetDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(targetDir, { recursive: true }); await fs.mkdir(targetDir, { recursive: true });
const existingPath = path.join(targetDir, 'test-skill'); const existingPath = path.join(targetDir, 'test-skill');
await fs.mkdir(existingPath); await fs.mkdir(existingPath);
const skills = await linkSkill( const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
mockSkillSourceDir, expect(skills.length).toBe(1);
'workspace',
() => {},
);
expect(skills.length).toBe(1);
const stats = await fs.lstat(existingPath); const stats = await fs.lstat(existingPath);
expect(stats.isSymbolicLink()).toBe(true); expect(stats.isSymbolicLink()).toBe(true);
}, });
);
it('should abort linking if consent is rejected', async () => { it('should abort linking if consent is rejected', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
@@ -237,39 +220,40 @@ describe('skillUtils', () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
itif(process.platform !== 'win32')( it('should successfully uninstall a skill even if its name was updated after linking', async () => {
'should successfully uninstall a skill even if its name was updated after linking', // 1. Create source skill
async () => { const sourceDir = path.join(tempDir, 'source-skill');
// 1. Create source skill await fs.mkdir(sourceDir, { recursive: true });
const sourceDir = path.join(tempDir, 'source-skill'); const skillMdPath = path.join(sourceDir, 'SKILL.md');
await fs.mkdir(sourceDir, { recursive: true }); await fs.writeFile(
const skillMdPath = path.join(sourceDir, 'SKILL.md'); skillMdPath,
await fs.writeFile( '---\nname: original-name\ndescription: test\n---\nbody',
skillMdPath, );
'---\nname: original-name\ndescription: test\n---\nbody',
);
// 2. Link it // 2. Link it
const skillsDir = path.join(tempDir, '.gemini/skills'); const skillsDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(skillsDir, { recursive: true }); await fs.mkdir(skillsDir, { recursive: true });
const destPath = path.join(skillsDir, 'original-name'); const destPath = path.join(skillsDir, 'original-name');
await fs.symlink(sourceDir, destPath, 'dir'); await fs.symlink(
sourceDir,
destPath,
process.platform === 'win32' ? 'junction' : 'dir',
);
// 3. Update name in source // 3. Update name in source
await fs.writeFile( await fs.writeFile(
skillMdPath, skillMdPath,
'---\nname: updated-name\ndescription: test\n---\nbody', '---\nname: updated-name\ndescription: test\n---\nbody',
); );
// 4. Uninstall by NEW name (this is the bug fix) // 4. Uninstall by NEW name (this is the bug fix)
const result = await uninstallSkill('updated-name', 'user'); const result = await uninstallSkill('updated-name', 'user');
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result?.location).toBe(destPath); expect(result?.location).toBe(destPath);
const exists = await fs.lstat(destPath).catch(() => null); const exists = await fs.lstat(destPath).catch(() => null);
expect(exists).toBeNull(); expect(exists).toBeNull();
}, });
);
it('should successfully uninstall a skill by directory name if metadata is missing (fallback)', async () => { it('should successfully uninstall a skill by directory name if metadata is missing (fallback)', async () => {
const skillsDir = path.join(tempDir, '.gemini/skills'); const skillsDir = path.join(tempDir, '.gemini/skills');
+7 -1
View File
@@ -248,7 +248,13 @@ export async function linkSkill(
await fs.rm(destPath, { recursive: true, force: true }); await fs.rm(destPath, { recursive: true, force: true });
} }
await fs.symlink(skillSourceDir, destPath, 'dir'); // Use 'junction' on Windows to avoid EPERM errors — junctions don't
// require elevated privileges or Developer Mode (fixes #24816)
await fs.symlink(
skillSourceDir,
destPath,
process.platform === 'win32' ? 'junction' : 'dir',
);
linkedSkills.push({ name: skillName, location: destPath }); linkedSkills.push({ name: skillName, location: destPath });
} }