@@ -163,15 +163,18 @@ describe("mock filesystem operations", () => {
163163 it ( "should detect symlink with lstat" , async ( ) => {
164164 mockFs . mockSymlinks . set ( "/link" , "/source" )
165165
166- const stats = await mockFs . mocks . lstat ( "/link" )
166+ const stats = ( await mockFs . mocks . lstat ( "/link" ) ) as { isSymbolicLink : ( ) => boolean }
167167
168168 expect ( stats . isSymbolicLink ( ) ) . toBe ( true )
169169 } )
170170
171171 it ( "should detect directory with lstat" , async ( ) => {
172172 addDirStructure ( mockFs , "/dir" , [ ] )
173173
174- const stats = await mockFs . mocks . lstat ( "/dir" )
174+ const stats = ( await mockFs . mocks . lstat ( "/dir" ) ) as {
175+ isDirectory : ( ) => boolean
176+ isSymbolicLink : ( ) => boolean
177+ }
175178
176179 expect ( stats . isDirectory ( ) ) . toBe ( true )
177180 expect ( stats . isSymbolicLink ( ) ) . toBe ( false )
@@ -250,7 +253,7 @@ describe("skill sync core logic", () => {
250253
251254 const versions = [ "1.0.0" , "2.1.3" , "1.5.0" , "0.9.1" ]
252255 const sorted = versions . slice ( ) . sort ( compareVersions )
253- const latest = sorted [ sorted . length - 1 ]
256+ const latest = sorted [ sorted . length - 1 ] as string
254257
255258 expect ( latest ) . toBe ( "2.1.3" )
256259 } )
@@ -456,3 +459,186 @@ describe("edge cases", () => {
456459 expect ( mockFs . mockSymlinks . get ( "/final" ) ) . toBe ( "/source" )
457460 } )
458461} )
462+
463+ /**
464+ * Clean slate symlink management tests
465+ */
466+ describe ( "symlink cleanup (clean slate)" , ( ) => {
467+ let mockFs : ReturnType < typeof createMockFilesystem >
468+
469+ beforeEach ( ( ) => {
470+ mockFs = createMockFilesystem ( )
471+ } )
472+
473+ it ( "should remove all symlinks from target directory" , async ( ) => {
474+ const targetDir = "/skills"
475+ addDirStructure ( mockFs , targetDir , [ "skill1" , "skill2" , "skill3" ] )
476+
477+ // Add symlinks
478+ mockFs . mockSymlinks . set ( "/skills/skill1" , "/cache/skill1" )
479+ mockFs . mockSymlinks . set ( "/skills/skill2" , "/cache/skill2" )
480+ mockFs . mockSymlinks . set ( "/skills/skill3" , "/cache/skill3" )
481+
482+ // Simulate cleanup: remove all symlinks
483+ const entries = ( await mockFs . mocks . readdir ( targetDir ) ) as string [ ]
484+ let cleaned = 0
485+
486+ for ( const entry of entries ) {
487+ const entryPath = join ( targetDir , entry )
488+ const lstats = ( await mockFs . mocks . lstat ( entryPath ) ) as { isSymbolicLink : ( ) => boolean }
489+
490+ if ( lstats . isSymbolicLink ( ) ) {
491+ await mockFs . mocks . unlink ( entryPath )
492+ cleaned ++
493+ }
494+ }
495+
496+ expect ( cleaned ) . toBe ( 3 )
497+ expect ( mockFs . mockSymlinks . has ( "/skills/skill1" ) ) . toBe ( false )
498+ expect ( mockFs . mockSymlinks . has ( "/skills/skill2" ) ) . toBe ( false )
499+ expect ( mockFs . mockSymlinks . has ( "/skills/skill3" ) ) . toBe ( false )
500+ } )
501+
502+ it ( "should NOT remove regular files in target directory" , async ( ) => {
503+ const targetDir = "/skills"
504+ addDirStructure ( mockFs , targetDir , [ "regular-file.txt" ] )
505+
506+ // Add a regular file (not a symlink)
507+ mockFs . mockFiles . add ( "/skills/regular-file.txt" )
508+
509+ // Simulate cleanup: only remove symlinks
510+ const entries = ( await mockFs . mocks . readdir ( targetDir ) ) as string [ ]
511+ let cleaned = 0
512+
513+ for ( const entry of entries ) {
514+ const entryPath = join ( targetDir , entry )
515+ const lstats = ( await mockFs . mocks . lstat ( entryPath ) ) as { isSymbolicLink : ( ) => boolean }
516+
517+ if ( lstats . isSymbolicLink ( ) ) {
518+ await mockFs . mocks . unlink ( entryPath )
519+ cleaned ++
520+ }
521+ }
522+
523+ expect ( cleaned ) . toBe ( 0 )
524+ expect ( mockFs . mockFiles . has ( "/skills/regular-file.txt" ) ) . toBe ( true )
525+ } )
526+
527+ it ( "should NOT remove directories in target directory" , async ( ) => {
528+ const targetDir = "/skills"
529+ addDirStructure ( mockFs , targetDir , [ "subdir" ] )
530+ addDirStructure ( mockFs , "/skills/subdir" , [ ] )
531+
532+ // Simulate cleanup: only remove symlinks
533+ const entries = ( await mockFs . mocks . readdir ( targetDir ) ) as string [ ]
534+ let cleaned = 0
535+
536+ for ( const entry of entries ) {
537+ const entryPath = join ( targetDir , entry )
538+ const lstats = ( await mockFs . mocks . lstat ( entryPath ) ) as { isSymbolicLink : ( ) => boolean }
539+
540+ if ( lstats . isSymbolicLink ( ) ) {
541+ await mockFs . mocks . unlink ( entryPath )
542+ cleaned ++
543+ }
544+ }
545+
546+ expect ( cleaned ) . toBe ( 0 )
547+ expect ( mockFs . mockDirs . has ( "/skills/subdir" ) ) . toBe ( true )
548+ } )
549+
550+ it ( "should handle mixed symlinks and files" , async ( ) => {
551+ const targetDir = "/skills"
552+ addDirStructure ( mockFs , targetDir , [ "skill1" , "file.txt" , "skill2" ] )
553+
554+ // Add mixed content
555+ mockFs . mockSymlinks . set ( "/skills/skill1" , "/cache/skill1" )
556+ mockFs . mockFiles . add ( "/skills/file.txt" )
557+ mockFs . mockSymlinks . set ( "/skills/skill2" , "/cache/skill2" )
558+
559+ // Simulate cleanup: only remove symlinks
560+ const entries = ( await mockFs . mocks . readdir ( targetDir ) ) as string [ ]
561+ let cleaned = 0
562+
563+ for ( const entry of entries ) {
564+ const entryPath = join ( targetDir , entry )
565+ const lstats = ( await mockFs . mocks . lstat ( entryPath ) ) as { isSymbolicLink : ( ) => boolean }
566+
567+ if ( lstats . isSymbolicLink ( ) ) {
568+ await mockFs . mocks . unlink ( entryPath )
569+ cleaned ++
570+ }
571+ }
572+
573+ expect ( cleaned ) . toBe ( 2 )
574+ expect ( mockFs . mockSymlinks . has ( "/skills/skill1" ) ) . toBe ( false )
575+ expect ( mockFs . mockSymlinks . has ( "/skills/skill2" ) ) . toBe ( false )
576+ expect ( mockFs . mockFiles . has ( "/skills/file.txt" ) ) . toBe ( true )
577+ } )
578+
579+ it ( "should create fresh symlinks for all skills after cleanup" , async ( ) => {
580+ const targetDir = "/skills"
581+ addDirStructure ( mockFs , targetDir , [ "old-skill" ] )
582+
583+ // Old symlink
584+ mockFs . mockSymlinks . set ( "/skills/old-skill" , "/old/cache/skill" )
585+
586+ // Simulate cleanup
587+ const entries = ( await mockFs . mocks . readdir ( targetDir ) ) as string [ ]
588+ for ( const entry of entries ) {
589+ const entryPath = join ( targetDir , entry )
590+ const lstats = ( await mockFs . mocks . lstat ( entryPath ) ) as { isSymbolicLink : ( ) => boolean }
591+
592+ if ( lstats . isSymbolicLink ( ) ) {
593+ await mockFs . mocks . unlink ( entryPath )
594+ }
595+ }
596+
597+ // Create new symlinks
598+ const skillMap = new Map < string , { path : string } > ( )
599+ skillMap . set ( "python-tdd" , { path : "/cache/python-tdd" } )
600+ skillMap . set ( "react-web" , { path : "/cache/react-web" } )
601+
602+ let created = 0
603+ for ( const [ name , skill ] of skillMap ) {
604+ const linkPath = join ( targetDir , name )
605+ await mockFs . mocks . symlink ( skill . path , linkPath )
606+ created ++
607+ }
608+
609+ expect ( created ) . toBe ( 2 )
610+ expect ( mockFs . mockSymlinks . get ( "/skills/python-tdd" ) ) . toBe ( "/cache/python-tdd" )
611+ expect ( mockFs . mockSymlinks . get ( "/skills/react-web" ) ) . toBe ( "/cache/react-web" )
612+ expect ( mockFs . mockSymlinks . has ( "/skills/old-skill" ) ) . toBe ( false )
613+ } )
614+
615+ it ( "should handle lstat errors gracefully during cleanup" , async ( ) => {
616+ const targetDir = "/skills"
617+ addDirStructure ( mockFs , targetDir , [ "skill1" ] )
618+ mockFs . mockSymlinks . set ( "/skills/skill1" , "/cache/skill1" )
619+
620+ // Spy on lstat to verify error handling
621+ const lstatSpy = vi . spyOn ( mockFs . mocks , "lstat" )
622+
623+ // Simulate cleanup with error handling
624+ const entries = ( await mockFs . mocks . readdir ( targetDir ) ) as string [ ]
625+ let cleaned = 0
626+
627+ for ( const entry of entries ) {
628+ try {
629+ const entryPath = join ( targetDir , entry )
630+ const lstats = ( await mockFs . mocks . lstat ( entryPath ) ) as { isSymbolicLink : ( ) => boolean }
631+
632+ if ( lstats . isSymbolicLink ( ) ) {
633+ await mockFs . mocks . unlink ( entryPath )
634+ cleaned ++
635+ }
636+ } catch {
637+ // Error handling during cleanup
638+ }
639+ }
640+
641+ expect ( cleaned ) . toBe ( 1 )
642+ expect ( lstatSpy ) . toHaveBeenCalled ( )
643+ } )
644+ } )
0 commit comments