mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
fix(recovery): harden shared commands startup recovery
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user