bookworm-smart-assistant/scripts/backup-recovery-drill.js

240 lines
8.5 KiB
JavaScript
Raw Normal View History

#!/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();
}