diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 313f6911..e7f6decf 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -47,6 +47,68 @@ class RecoveryManager { return false; } + /** + * Remove a dangling symlink so recovery can recreate the directory. + * Mirrors scripts/postinstall.js behavior for skipped lifecycle installs. + */ + private removeIfBrokenSymlink(targetPath: string): boolean { + try { + const stats = fs.lstatSync(targetPath); + if (!stats.isSymbolicLink()) { + return false; + } + + try { + fs.statSync(targetPath); + return false; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + return false; + } + + fs.unlinkSync(targetPath); + this.recovered.push(`Removed broken symlink: ${targetPath}`); + return true; + } + } catch { + return false; + } + } + + private inspectDirectoryPath( + targetPath: string + ): { state: 'ready' } | { state: 'missing' } | { state: 'invalid'; reason: string } { + try { + const stats = fs.lstatSync(targetPath); + + if (stats.isSymbolicLink()) { + try { + return fs.statSync(targetPath).isDirectory() + ? { state: 'ready' } + : { state: 'invalid', reason: 'symlink target is not a directory' }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === 'ENOENT' || code === 'ENOTDIR' + ? { state: 'missing' } + : { + state: 'invalid', + reason: `symlink target is not accessible (${code || 'unknown'})`, + }; + } + } + + return stats.isDirectory() + ? { state: 'ready' } + : { state: 'invalid', reason: 'existing path is not a directory' }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === 'ENOENT' + ? { state: 'missing' } + : { state: 'invalid', reason: `could not inspect path (${code || 'unknown'})` }; + } + } + /** * Ensure ~/.ccs/config.yaml exists with defaults * This is the primary config format (YAML unified config) @@ -126,20 +188,34 @@ class RecoveryManager { ensureSharedDirectories(): boolean { let created = false; + this.removeIfBrokenSymlink(this.sharedDir); + const sharedState = this.inspectDirectoryPath(this.sharedDir); + // Create shared directory - if (!fs.existsSync(this.sharedDir)) { + if (sharedState.state === 'missing') { fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o755 }); this.recovered.push(`Created ${this.sharedDir}`); created = true; + } else if (sharedState.state === 'invalid') { + this.recovered.push(`Skipped ${this.sharedDir}: ${sharedState.reason}`); + return created; } // Create subdirectories const subdirs = ['commands', 'skills', 'agents', 'plugins']; for (const subdir of subdirs) { const subdirPath = path.join(this.sharedDir, subdir); - if (!fs.existsSync(subdirPath)) { + this.removeIfBrokenSymlink(subdirPath); + const subdirState = this.inspectDirectoryPath(subdirPath); + + if (subdirState.state === 'missing') { fs.mkdirSync(subdirPath, { recursive: true, mode: 0o755 }); created = true; + continue; + } + + if (subdirState.state === 'invalid') { + this.recovered.push(`Skipped ${subdirPath}: ${subdirState.reason}`); } } diff --git a/tests/unit/recovery-manager.test.ts b/tests/unit/recovery-manager.test.ts new file mode 100644 index 00000000..a395ace4 --- /dev/null +++ b/tests/unit/recovery-manager.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import RecoveryManager from '../../src/management/recovery-manager'; + +function createDirectorySymlink(targetDir: string, linkPath: string): void { + const symlinkType = process.platform === 'win32' ? 'junction' : 'dir'; + + try { + fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + throw new Error( + `Symlink creation is not permitted in this environment (${code}) for ${linkPath}` + ); + } + throw error; + } +} + +describe('RecoveryManager', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-recovery-manager-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + mock.restore(); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('recreates ~/.ccs/shared when recovery finds a dangling symlink', () => { + const ccsDir = path.join(tempHome, '.ccs'); + const sharedDir = path.join(ccsDir, 'shared'); + fs.mkdirSync(ccsDir, { recursive: true }); + createDirectorySymlink(path.join(tempHome, 'missing-shared'), sharedDir); + + const recovery = new RecoveryManager(); + + expect(() => recovery.ensureSharedDirectories()).not.toThrow(); + expect(fs.statSync(sharedDir).isDirectory()).toBe(true); + expect(fs.statSync(path.join(sharedDir, 'commands')).isDirectory()).toBe(true); + expect(recovery.getRecoverySummary()).toContain(`Removed broken symlink: ${sharedDir}`); + }); + + it('recreates ~/.ccs/shared/commands when recovery finds a dangling symlink', () => { + const sharedDir = path.join(tempHome, '.ccs', 'shared'); + const commandsDir = path.join(sharedDir, 'commands'); + fs.mkdirSync(sharedDir, { recursive: true }); + createDirectorySymlink(path.join(tempHome, 'missing-commands'), commandsDir); + + const recovery = new RecoveryManager(); + + expect(() => recovery.ensureSharedDirectories()).not.toThrow(); + expect(fs.statSync(commandsDir).isDirectory()).toBe(true); + expect(recovery.getRecoverySummary()).toContain(`Removed broken symlink: ${commandsDir}`); + }); + + it('preserves valid shared command symlinks', () => { + const sharedDir = path.join(tempHome, '.ccs', 'shared'); + const commandsDir = path.join(sharedDir, 'commands'); + const externalCommandsDir = path.join(tempHome, 'external-commands'); + fs.mkdirSync(sharedDir, { recursive: true }); + fs.mkdirSync(externalCommandsDir, { recursive: true }); + createDirectorySymlink(externalCommandsDir, commandsDir); + + const recovery = new RecoveryManager(); + + expect(() => recovery.ensureSharedDirectories()).not.toThrow(); + expect(fs.lstatSync(commandsDir).isSymbolicLink()).toBe(true); + expect(path.resolve(path.dirname(commandsDir), fs.readlinkSync(commandsDir))).toBe( + externalCommandsDir + ); + expect(recovery.getRecoverySummary()).not.toContain(`Removed broken symlink: ${commandsDir}`); + }); + + it('does not delete a valid shared command symlink when target access is denied', () => { + const sharedDir = path.join(tempHome, '.ccs', 'shared'); + const commandsDir = path.join(sharedDir, 'commands'); + const externalCommandsDir = path.join(tempHome, 'external-commands'); + fs.mkdirSync(sharedDir, { recursive: true }); + fs.mkdirSync(externalCommandsDir, { recursive: true }); + createDirectorySymlink(externalCommandsDir, commandsDir); + + const originalStatSync = fs.statSync.bind(fs); + spyOn(fs, 'statSync').mockImplementation((targetPath: fs.PathLike) => { + if (String(targetPath) === commandsDir) { + const error = new Error('simulated permission failure') as NodeJS.ErrnoException; + error.code = 'EACCES'; + throw error; + } + + return originalStatSync(targetPath); + }); + + const recovery = new RecoveryManager(); + + expect(() => recovery.ensureSharedDirectories()).not.toThrow(); + expect(fs.lstatSync(commandsDir).isSymbolicLink()).toBe(true); + expect(path.resolve(path.dirname(commandsDir), fs.readlinkSync(commandsDir))).toBe( + externalCommandsDir + ); + expect(recovery.getRecoverySummary()).not.toContain(`Removed broken symlink: ${commandsDir}`); + expect(recovery.getRecoverySummary()).toContain( + `Skipped ${commandsDir}: symlink target is not accessible (EACCES)` + ); + }); +}); diff --git a/tests/unit/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts index 8556e50e..defdd1d3 100644 --- a/tests/unit/web-server/shared-routes.test.ts +++ b/tests/unit/web-server/shared-routes.test.ts @@ -231,6 +231,14 @@ describe('web-server shared-routes', () => { ]); }); + it('returns an empty command list when shared commands directory is missing', async () => { + const payload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/commands'); + + expect(payload.items).toEqual([]); + }); + it('returns full content for a shared command markdown file', async () => { const commandsDir = path.join(ccsDir, 'shared', 'commands'); fs.mkdirSync(commandsDir, { recursive: true });