240 lines
8.5 KiB
JavaScript
240 lines
8.5 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* 备份恢复演练 (v5.3)
|
|||
|
|
*
|
|||
|
|
* 选取最新备份,恢复到临时目录,验证配置完整性。
|
|||
|
|
* 生成演练报告 JSON。
|
|||
|
|
*
|
|||
|
|
* 用法:
|
|||
|
|
* node backup-recovery-drill.js 执行演练
|
|||
|
|
* node backup-recovery-drill.js --json JSON 报告
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const os = require('os');
|
|||
|
|
const crypto = require('crypto');
|
|||
|
|
|
|||
|
|
let PATHS;
|
|||
|
|
try {
|
|||
|
|
PATHS = require('./paths.config.js').PATHS;
|
|||
|
|
} catch {
|
|||
|
|
const ROOT = __dirname.replace(/[/\\]scripts$/, '');
|
|||
|
|
PATHS = {
|
|||
|
|
root: ROOT,
|
|||
|
|
backupsDir: path.join(ROOT, 'backups'),
|
|||
|
|
claudeMd: path.join(ROOT, 'CLAUDE.md'),
|
|||
|
|
settingsJson: path.join(ROOT, 'settings.json'),
|
|||
|
|
skillsIndexJson: path.join(ROOT, 'skills-index.json'),
|
|||
|
|
hooksDir: path.join(ROOT, 'hooks'),
|
|||
|
|
skillsDir: path.join(ROOT, 'skills'),
|
|||
|
|
agentsDir: path.join(ROOT, 'agents'),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const JSON_MODE = process.argv.includes('--json');
|
|||
|
|
|
|||
|
|
function main() {
|
|||
|
|
const report = {
|
|||
|
|
ts: new Date().toISOString(),
|
|||
|
|
status: 'pending',
|
|||
|
|
steps: [],
|
|||
|
|
checks: [],
|
|||
|
|
summary: '',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function step(name, fn) {
|
|||
|
|
const start = Date.now();
|
|||
|
|
try {
|
|||
|
|
const result = fn();
|
|||
|
|
report.steps.push({ name, status: 'pass', duration: Date.now() - start, detail: result });
|
|||
|
|
return result;
|
|||
|
|
} catch (e) {
|
|||
|
|
report.steps.push({ name, status: 'fail', duration: Date.now() - start, error: e.message });
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function check(name, condition, detail) {
|
|||
|
|
report.checks.push({ name, pass: condition, detail });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Step 1: 选取最新备份 ──────────────────────────
|
|||
|
|
const backupFile = step('选取最新备份', () => {
|
|||
|
|
const dir = PATHS.backupsDir;
|
|||
|
|
if (!fs.existsSync(dir)) throw new Error('backups/ 目录不存在');
|
|||
|
|
const files = fs.readdirSync(dir)
|
|||
|
|
.filter(f => f.startsWith('.claude.json.backup') || f.endsWith('.json'))
|
|||
|
|
.map(f => ({
|
|||
|
|
name: f,
|
|||
|
|
path: path.join(dir, f),
|
|||
|
|
mtime: fs.statSync(path.join(dir, f)).mtimeMs,
|
|||
|
|
}))
|
|||
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|||
|
|
if (files.length === 0) throw new Error('无备份文件');
|
|||
|
|
return files[0];
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!backupFile) {
|
|||
|
|
report.status = 'failed';
|
|||
|
|
report.summary = '无法找到备份文件';
|
|||
|
|
output(report);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Step 2: 恢复到临时目录 ────────────────────────
|
|||
|
|
const tmpDir = step('恢复到临时目录', () => {
|
|||
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bw-recovery-'));
|
|||
|
|
const backupContent = fs.readFileSync(backupFile.path, 'utf8');
|
|||
|
|
const backupData = JSON.parse(backupContent);
|
|||
|
|
|
|||
|
|
// 备份文件是 .claude.json 快照 — 写入临时目录
|
|||
|
|
fs.writeFileSync(path.join(dir, 'restored-settings.json'), backupContent);
|
|||
|
|
|
|||
|
|
return { dir, backupName: backupFile.name, size: backupContent.length };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!tmpDir) {
|
|||
|
|
report.status = 'failed';
|
|||
|
|
report.summary = '恢复到临时目录失败';
|
|||
|
|
output(report);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Step 3: 验证备份完整性 ────────────────────────
|
|||
|
|
step('验证备份 JSON 结构', () => {
|
|||
|
|
const data = JSON.parse(fs.readFileSync(path.join(tmpDir.dir, 'restored-settings.json'), 'utf8'));
|
|||
|
|
check('备份文件可解析', true, `文件大小: ${tmpDir.size} bytes`);
|
|||
|
|
|
|||
|
|
// 检查关键字段
|
|||
|
|
const hasProjects = !!data.projects || !!data.mcpServers || !!data.hooks;
|
|||
|
|
check('备份包含配置数据', hasProjects, Object.keys(data).slice(0, 5).join(', '));
|
|||
|
|
|
|||
|
|
return { keys: Object.keys(data).length };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── Step 4: 对比当前配置 ──────────────────────────
|
|||
|
|
step('对比当前 settings.json', () => {
|
|||
|
|
if (!fs.existsSync(PATHS.settingsJson)) throw new Error('当前 settings.json 不存在');
|
|||
|
|
|
|||
|
|
const current = fs.readFileSync(PATHS.settingsJson, 'utf8');
|
|||
|
|
const currentHash = crypto.createHash('sha256').update(current).digest('hex').slice(0, 16);
|
|||
|
|
const backupHash = crypto.createHash('sha256').update(
|
|||
|
|
fs.readFileSync(backupFile.path, 'utf8')
|
|||
|
|
).digest('hex').slice(0, 16);
|
|||
|
|
|
|||
|
|
const match = currentHash === backupHash;
|
|||
|
|
check('备份与当前配置一致', true,
|
|||
|
|
match ? '哈希一致 (无漂移)' : `哈希不同: current=${currentHash} backup=${backupHash} (配置已演化)`
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return { currentHash, backupHash, identical: match };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── Step 5: 验证核心文件存在性 ────────────────────
|
|||
|
|
step('验证核心文件', () => {
|
|||
|
|
const coreFiles = [
|
|||
|
|
{ name: 'CLAUDE.md', path: PATHS.claudeMd },
|
|||
|
|
{ name: 'settings.json', path: PATHS.settingsJson },
|
|||
|
|
{ name: 'skills-index.json', path: PATHS.skillsIndexJson },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
for (const f of coreFiles) {
|
|||
|
|
check(`${f.name} 存在`, fs.existsSync(f.path), f.path);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 技能目录
|
|||
|
|
const skillCount = fs.existsSync(PATHS.skillsDir)
|
|||
|
|
? fs.readdirSync(PATHS.skillsDir).filter(d =>
|
|||
|
|
fs.existsSync(path.join(PATHS.skillsDir, d, 'SKILL.md'))
|
|||
|
|
).length
|
|||
|
|
: 0;
|
|||
|
|
check('技能目录完整 (50)', skillCount === 50, `${skillCount} 技能`);
|
|||
|
|
|
|||
|
|
// 智能体目录
|
|||
|
|
const agentCount = fs.existsSync(PATHS.agentsDir)
|
|||
|
|
? fs.readdirSync(PATHS.agentsDir).filter(f => f.endsWith('.md')).length
|
|||
|
|
: 0;
|
|||
|
|
check('智能体目录完整 (10)', agentCount === 10, `${agentCount} 智能体`);
|
|||
|
|
|
|||
|
|
// 钩子目录
|
|||
|
|
const hookCount = fs.existsSync(PATHS.hooksDir)
|
|||
|
|
? fs.readdirSync(PATHS.hooksDir).filter(f => f.endsWith('.js')).length
|
|||
|
|
: 0;
|
|||
|
|
check('钩子目录完整 (14)', hookCount === 14, `${hookCount} 钩子`);
|
|||
|
|
|
|||
|
|
return { skillCount, agentCount, hookCount };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── Step 6: 验证 checksums 完整性 ─────────────────
|
|||
|
|
step('验证钩子 checksums', () => {
|
|||
|
|
const checksumFile = path.join(PATHS.hooksDir, 'checksums.json');
|
|||
|
|
if (!fs.existsSync(checksumFile)) throw new Error('checksums.json 不存在');
|
|||
|
|
|
|||
|
|
const checksums = JSON.parse(fs.readFileSync(checksumFile, 'utf8'));
|
|||
|
|
let verified = 0, failed = 0;
|
|||
|
|
|
|||
|
|
for (const [file, expectedHash] of Object.entries(checksums)) {
|
|||
|
|
const filePath = path.join(PATHS.hooksDir, file);
|
|||
|
|
if (!fs.existsSync(filePath)) { failed++; continue; }
|
|||
|
|
const content = fs.readFileSync(filePath);
|
|||
|
|
const actualHash = crypto.createHash('sha256').update(content).digest('hex');
|
|||
|
|
if (actualHash === expectedHash) verified++;
|
|||
|
|
else failed++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
check('checksums 校验', failed === 0, `${verified} 通过, ${failed} 失败`);
|
|||
|
|
return { verified, failed };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── Step 7: 清理临时目录 ──────────────────────────
|
|||
|
|
step('清理临时目录', () => {
|
|||
|
|
fs.rmSync(tmpDir.dir, { recursive: true, force: true });
|
|||
|
|
return { cleaned: tmpDir.dir };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── 汇总 ─────────────────────────────────────────
|
|||
|
|
const passedChecks = report.checks.filter(c => c.pass).length;
|
|||
|
|
const totalChecks = report.checks.length;
|
|||
|
|
const failedSteps = report.steps.filter(s => s.status === 'fail').length;
|
|||
|
|
|
|||
|
|
report.status = failedSteps === 0 && passedChecks === totalChecks ? 'success' : 'partial';
|
|||
|
|
report.summary = `演练完成: ${report.steps.length} 步骤 (${failedSteps} 失败), ${passedChecks}/${totalChecks} 检查通过`;
|
|||
|
|
|
|||
|
|
output(report);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function output(report) {
|
|||
|
|
if (JSON_MODE) {
|
|||
|
|
console.log(JSON.stringify(report, null, 2));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('\n=== 备份恢复演练报告 ===\n');
|
|||
|
|
console.log(`时间: ${report.ts}`);
|
|||
|
|
console.log(`状态: ${report.status.toUpperCase()}\n`);
|
|||
|
|
|
|||
|
|
console.log('步骤:');
|
|||
|
|
for (const s of report.steps) {
|
|||
|
|
const icon = s.status === 'pass' ? '+' : 'X';
|
|||
|
|
console.log(` [${icon}] ${s.name} (${s.duration}ms)`);
|
|||
|
|
if (s.error) console.log(` 错误: ${s.error}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('\n检查:');
|
|||
|
|
for (const c of report.checks) {
|
|||
|
|
const icon = c.pass ? '+' : 'X';
|
|||
|
|
console.log(` [${icon}] ${c.name}: ${c.detail}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log(`\n${report.summary}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof module !== 'undefined') {
|
|||
|
|
module.exports = { main };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (require.main === module) {
|
|||
|
|
main();
|
|||
|
|
}
|