Skip to content

Commit 2f3f3e2

Browse files
authored
fix: handle dangling symlinks in restorePkgSymlink (#57)
1 parent 3c242ee commit 2f3f3e2

File tree

2 files changed

+135
-2
lines changed

2 files changed

+135
-2
lines changed

src/core/prepare.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,23 @@ export function restorePkgSymlink(skillsDir: string, name: string, info: SkillIn
3434
if (!existsSync(join(skillsDir, name)))
3535
return
3636

37-
if (existsSync(pkgLink))
38-
return
37+
// Use lstatSync to detect dangling symlinks — existsSync follows symlinks
38+
// and returns false for dangling ones, causing symlinkSync to throw EEXIST
39+
try {
40+
const stat = lstatSync(pkgLink)
41+
if (stat.isSymbolicLink()) {
42+
if (existsSync(pkgLink))
43+
return // symlink exists and target is valid
44+
unlinkSync(pkgLink) // dangling symlink — remove before re-creating
45+
}
46+
else {
47+
return // real file/dir exists at this path
48+
}
49+
}
50+
catch (err) {
51+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
52+
return // permission/IO error — bail instead of masking
53+
}
3954

4055
const pkgName = info.packageName || name
4156
const pkgDir = resolvePkgDir(pkgName, cwd, info.version)

test/unit/prepare-restore.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
vi.mock('node:fs', async () => {
4+
const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
5+
return {
6+
...actual,
7+
existsSync: vi.fn(),
8+
lstatSync: vi.fn(),
9+
mkdirSync: vi.fn(),
10+
symlinkSync: vi.fn(),
11+
unlinkSync: vi.fn(),
12+
}
13+
})
14+
15+
vi.mock('../../src/cache/version', () => ({
16+
getCacheDir: (name: string, version: string) => `/home/.skilld/references/${name}@${version}`,
17+
}))
18+
19+
describe('restorePkgSymlink', () => {
20+
beforeEach(() => vi.resetAllMocks())
21+
afterEach(() => vi.restoreAllMocks())
22+
23+
it('skips when skill directory does not exist', async () => {
24+
const fs = await import('node:fs')
25+
const { restorePkgSymlink } = await import('../../src/core/prepare')
26+
27+
vi.mocked(fs.existsSync).mockImplementation((p) => {
28+
// skill dir doesn't exist — triggers early return
29+
if (String(p).endsWith('/vue'))
30+
return false
31+
return true
32+
})
33+
34+
restorePkgSymlink('/project/.skills', 'vue', { version: '3.4.0' }, '/project')
35+
36+
expect(fs.symlinkSync).not.toHaveBeenCalled()
37+
})
38+
39+
it('skips when pkg symlink target is valid', async () => {
40+
const fs = await import('node:fs')
41+
const { restorePkgSymlink } = await import('../../src/core/prepare')
42+
43+
vi.mocked(fs.existsSync).mockReturnValue(true)
44+
vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => true } as any)
45+
46+
restorePkgSymlink('/project/.skills', 'vue', { version: '3.4.0' }, '/project')
47+
48+
expect(fs.symlinkSync).not.toHaveBeenCalled()
49+
})
50+
51+
it('removes dangling symlink before re-creating', async () => {
52+
const fs = await import('node:fs')
53+
const { restorePkgSymlink } = await import('../../src/core/prepare')
54+
55+
vi.mocked(fs.existsSync).mockImplementation((p) => {
56+
const path = String(p)
57+
// skill dir exists
58+
if (path === '/project/.skills/vue')
59+
return true
60+
// dangling symlink: existsSync returns false (follows symlink, target gone)
61+
if (path.endsWith('/pkg'))
62+
return false
63+
// node_modules/vue exists (freshly installed)
64+
if (path.includes('node_modules/vue'))
65+
return true
66+
return false
67+
})
68+
// lstatSync succeeds — the symlink itself exists on disk
69+
vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => true } as any)
70+
71+
restorePkgSymlink('/project/.skills', 'vue', { version: '3.4.0' }, '/project')
72+
73+
// Should remove the dangling symlink first
74+
expect(fs.unlinkSync).toHaveBeenCalledWith(
75+
expect.stringContaining('pkg'),
76+
)
77+
// Then create a fresh symlink
78+
expect(fs.symlinkSync).toHaveBeenCalledOnce()
79+
})
80+
81+
it('creates symlink when no prior link exists', async () => {
82+
const fs = await import('node:fs')
83+
const { restorePkgSymlink } = await import('../../src/core/prepare')
84+
85+
vi.mocked(fs.existsSync).mockImplementation((p) => {
86+
const path = String(p)
87+
if (path === '/project/.skills/vue')
88+
return true
89+
if (path.includes('node_modules/vue'))
90+
return true
91+
return false
92+
})
93+
// lstatSync throws ENOENT — no file at all
94+
vi.mocked(fs.lstatSync).mockImplementation(() => {
95+
const err = new Error('ENOENT') as NodeJS.ErrnoException
96+
err.code = 'ENOENT'
97+
throw err
98+
})
99+
100+
restorePkgSymlink('/project/.skills', 'vue', { version: '3.4.0' }, '/project')
101+
102+
expect(fs.unlinkSync).not.toHaveBeenCalled()
103+
expect(fs.symlinkSync).toHaveBeenCalledOnce()
104+
})
105+
106+
it('skips when real file exists at pkg path', async () => {
107+
const fs = await import('node:fs')
108+
const { restorePkgSymlink } = await import('../../src/core/prepare')
109+
110+
vi.mocked(fs.existsSync).mockReturnValue(true)
111+
// lstatSync returns a regular file, not a symlink
112+
vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => false } as any)
113+
114+
restorePkgSymlink('/project/.skills', 'vue', { version: '3.4.0' }, '/project')
115+
116+
expect(fs.symlinkSync).not.toHaveBeenCalled()
117+
})
118+
})

0 commit comments

Comments
 (0)