bookworm-smart-assistant/tools/export.mjs

276 lines
10 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
// Bookworm Smart Assistant - 同步导出器 (Step 3)
// 功能:
// 1. 复制白名单文件到 %TEMP%\bw-sa-export-<ts>
// 2. 基于当前 settings.json 重新生成 settings.template.json (占位 {{CLAUDE_ROOT}} / {{HOME}})
// 3. 生成 INTEGRITY.sha256 (sha256 over every shipped file, 稳定排序)
// 4. Ed25519 签名 INTEGRITY.sha256 → INTEGRITY.sha256.sig
// 5. 输出 bw-signing-pubkey.pem 到导出包 (从机可验签)
//
// 使用: node tools/export.mjs
//
// 先决条件: 先跑 tools/scrubber.mjs 必须 0 命中
//
// 密钥位置:
// 私钥: C:\Users\leesu\bookworm-admin-private\ed25519-sync.priv.pem (仅主机)
// 公钥: C:\Users\leesu\bookworm-admin-private\ed25519-sync.pub.pem + 导出包副本
// 首次运行自动生成密钥对。
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CLAUDE_ROOT = path.resolve(__dirname, '..');
const ADMIN_PRIVATE = path.join(os.homedir(), 'bookworm-admin-private');
const PRIV_KEY = path.join(ADMIN_PRIVATE, 'ed25519-sync.priv.pem');
const PUB_KEY = path.join(ADMIN_PRIVATE, 'ed25519-sync.pub.pem');
// ── 同步白名单 (与 scrubber.mjs 同步, 含单测已剔除) ──
const INCLUDE_DIRS = [
'agents', 'hooks', 'skills', 'lib', 'scripts',
'constitution', 'docs', 'templates', 'config', 'tests', 'tools',
];
const INCLUDE_FILES = [
'CLAUDE.md', 'package.json',
'feature-flags.json', 'feature-flags.json.sig',
// 注: 不包含 'integrity.sha256' (legacy hook basline)
// 原因: Windows NTFS case-insensitive, 会与我们生成的 INTEGRITY.sha256 碰撞
'settings.template.json', 'settings.local.template.json',
'SKILL-REGISTRY.md',
'skills-index.json', 'skills-index-lite.json',
'stats-compiled.json',
'VERSION',
];
const EXCLUDE_SUBPATHS = [
/[\\/]_archived([\\/]|$)/i,
/[\\/]_deprecated([\\/]|$)/i,
/[\\/]node_modules([\\/]|$)/,
/[\\/]\.git([\\/]|$)/,
/[\\/]__pycache__([\\/]|$)/,
/docs[\\/]active-projects\.md$/i,
/hooks[\\/]tests([\\/]|$)/i,
/hooks[\\/].*__tests__([\\/]|$)/i,
/\.bak(\..+)?$/i,
/\.tmp(\..+)?$/i,
/config[\\/]auto-sync-repos\.json$/i,
/scripts[\\/]apply-settings-patch\.py$/i,
// 导出器自己产生的报告不推
/tools[\\/]scrubber-report\.json$/i,
];
const BINARY_EXTS = new Set([
'.png','.jpg','.jpeg','.gif','.webp','.ico','.bmp',
'.mp3','.mp4','.wav','.ogg','.webm',
'.pdf','.zip','.tar','.gz','.7z','.rar',
'.exe','.dll','.so','.dylib','.bin',
'.woff','.woff2','.ttf','.otf','.eot',
]);
// ── 密钥管理 ──
function ensureSigningKey() {
if (!fs.existsSync(ADMIN_PRIVATE)) {
fs.mkdirSync(ADMIN_PRIVATE, { recursive: true });
}
if (fs.existsSync(PRIV_KEY) && fs.existsSync(PUB_KEY)) {
return { privPem: fs.readFileSync(PRIV_KEY, 'utf8'), pubPem: fs.readFileSync(PUB_KEY, 'utf8') };
}
console.log('[export] 首次运行, 生成 Ed25519 签名密钥对...');
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const pubPem = publicKey.export({ type: 'spki', format: 'pem' });
fs.writeFileSync(PRIV_KEY, privPem, { mode: 0o600 });
fs.writeFileSync(PUB_KEY, pubPem);
console.log('[export] 私钥:', PRIV_KEY);
console.log('[export] 公钥:', PUB_KEY);
return { privPem, pubPem };
}
// ── 导出目录 ──
function makeExportDir() {
const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T','-').replace('Z','');
const dir = path.join(os.tmpdir(), `bw-sa-export-${ts}`);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
// ── 文件遍历 ──
function isExcluded(relPath) {
return EXCLUDE_SUBPATHS.some(r => r.test(relPath));
}
function* walk(absDir, relRoot) {
let ents;
try { ents = fs.readdirSync(absDir, { withFileTypes: true }); }
catch { return; }
for (const e of ents) {
const abs = path.join(absDir, e.name);
const rel = relRoot ? path.join(relRoot, e.name) : e.name;
if (isExcluded(rel)) continue;
if (e.isDirectory()) yield* walk(abs, rel);
else if (e.isFile()) yield { abs, rel };
}
}
function collectTargets() {
const out = [];
for (const d of INCLUDE_DIRS) {
const abs = path.join(CLAUDE_ROOT, d);
if (fs.existsSync(abs)) for (const f of walk(abs, d)) out.push(f);
}
for (const f of INCLUDE_FILES) {
const abs = path.join(CLAUDE_ROOT, f);
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push({ abs, rel: f });
}
// 稳定排序 (POSIX 风格 forward slash)
out.forEach(o => { o.relPosix = o.rel.replace(/\\/g, '/'); });
out.sort((a, b) => a.relPosix.localeCompare(b.relPosix));
return out;
}
// ── 生成 settings.template.json (从当前 settings.json 反推) ──
function generateSettingsTemplate(exportDir) {
const settingsJson = path.join(CLAUDE_ROOT, 'settings.json');
if (!fs.existsSync(settingsJson)) {
console.warn('[export] settings.json 不存在, 跳过模板生成');
return;
}
let raw = fs.readFileSync(settingsJson, 'utf8');
// 去 BOM
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
// 替换绝对路径 (顺序: 长的先, 避免 C:/Users/leesu 误替换 C:/Users/leesu/.claude)
raw = raw.replace(/C:\/Users\/leesu\/\.claude/g, '{{CLAUDE_ROOT}}');
raw = raw.replace(/C:\\\\Users\\\\leesu\\\\\.claude/g, '{{CLAUDE_ROOT}}');
raw = raw.replace(/C:\/Users\/leesu/g, '{{HOME}}');
raw = raw.replace(/C:\\\\Users\\\\leesu/g, '{{HOME}}');
// 校验没有遗漏用户路径
if (/C:\/Users\/leesu|C:\\+Users\\+leesu/i.test(raw)) {
throw new Error('[export] settings.template 仍含硬编码路径');
}
const target = path.join(exportDir, 'settings.template.json');
fs.writeFileSync(target, raw);
console.log('[export] settings.template.json 已重新生成 →', path.relative(exportDir, target));
}
// ── 复制文件 ──
function copyFiles(targets, exportDir) {
let count = 0, skipBinary = 0;
for (const t of targets) {
const dest = path.join(exportDir, t.rel);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(t.abs, dest);
const ext = path.extname(t.abs).toLowerCase();
if (BINARY_EXTS.has(ext)) skipBinary++;
count++;
}
console.log(`[export] 已复制 ${count} 个文件 (含 ${skipBinary} 个二进制)`);
}
// ── 写 .gitattributes (完全禁用 git 对行尾的转换, 保 sha256/Ed25519 字节级一致) ──
function writeGitAttributes(exportDir) {
// 全量 -text: git 把所有文件按二进制存, 既不 CRLF→LF 也不 LF→CRLF
// 代价: 人类看到的行尾跟主机导出时一致 (不做平台适配)
// 收益: INTEGRITY.sha256 的哈希在 push/clone 后保持字节级稳定
const body = '* -text\n';
fs.writeFileSync(path.join(exportDir, '.gitattributes'), body);
console.log('[export] .gitattributes 已写入 (全量 -text, 保签名字节级一致)');
}
// ── 生成 INTEGRITY.sha256 ──
function buildIntegrity(targets, exportDir) {
const lines = [];
for (const t of targets) {
const destAbs = path.join(exportDir, t.rel);
const buf = fs.readFileSync(destAbs);
const hash = crypto.createHash('sha256').update(buf).digest('hex');
lines.push(`${hash} ${t.relPosix}`);
}
const body = lines.join('\n') + '\n';
const integrityPath = path.join(exportDir, 'INTEGRITY.sha256');
fs.writeFileSync(integrityPath, body);
console.log(`[export] INTEGRITY.sha256 已写入 (${lines.length} 行)`);
return integrityPath;
}
// ── Ed25519 签名 ──
function signIntegrity(integrityPath, privPem, pubPem, exportDir) {
const body = fs.readFileSync(integrityPath);
const privKey = crypto.createPrivateKey(privPem);
// Ed25519: sign API 使用 crypto.sign(null, data, key)
const sig = crypto.sign(null, body, privKey);
const sigHex = sig.toString('hex');
fs.writeFileSync(integrityPath + '.sig', sigHex + '\n');
// 公钥副本入包, 从机可验签
fs.writeFileSync(path.join(exportDir, 'bw-signing-pubkey.pem'), pubPem);
console.log('[export] INTEGRITY.sha256.sig 已写入');
console.log('[export] bw-signing-pubkey.pem 已随包发布');
// 自测: 立即验签一次防链路损坏
const pubKey = crypto.createPublicKey(pubPem);
const ok = crypto.verify(null, body, pubKey, sig);
if (!ok) throw new Error('[export] 验签自测失败, 链路损坏');
console.log('[export] 验签自测 PASS');
}
// ── 主流程 ──
function main() {
console.log('[export] CLAUDE_ROOT =', CLAUDE_ROOT);
// 1. 密钥
const { privPem, pubPem } = ensureSigningKey();
// 2. 导出目录
const exportDir = makeExportDir();
console.log('[export] 导出目录:', exportDir);
// 3. 收集文件
const targets = collectTargets();
console.log(`[export] 同步白名单命中 ${targets.length} 个文件`);
// 4. 复制
copyFiles(targets, exportDir);
// 4.5 写 .gitattributes (防 git autocrlf 破坏 pem/sig/integrity 哈希)
writeGitAttributes(exportDir);
// 5. 重新生成 settings.template.json (覆盖从源码库拷来的旧版)
generateSettingsTemplate(exportDir);
// 6. 重新收集 INTEGRITY 的 targets (settings.template 已改)
// 但要用 exportDir 下的文件来 hash (确保 hash 与实际发布一致)
const integrityPath = buildIntegrity(targets, exportDir);
// 7. 签名
signIntegrity(integrityPath, privPem, pubPem, exportDir);
// 8. 版本号: VERSION 文件为权威源, package.json 为 fallback
const versionFile = path.join(CLAUDE_ROOT, 'VERSION');
let version = 'unknown';
if (fs.existsSync(versionFile)) {
version = fs.readFileSync(versionFile, 'utf8').trim();
} else {
version = JSON.parse(fs.readFileSync(path.join(CLAUDE_ROOT, 'package.json'), 'utf8')).version || 'unknown';
}
// 9. 输出 manifest
const manifest = {
version,
exportedAt: new Date().toISOString(),
fileCount: targets.length,
pubKeyFingerprint: crypto.createHash('sha256').update(pubPem).digest('hex').slice(0, 16),
};
fs.writeFileSync(path.join(exportDir, 'MANIFEST.json'), JSON.stringify(manifest, null, 2));
console.log('[export] MANIFEST.json 已写入');
// 10. VERSION 文件 (OTA 版本比对用, 纯文本一行)
fs.writeFileSync(path.join(exportDir, 'VERSION'), version + '\n');
console.log(`[export] VERSION 已写入: ${version}`);
console.log('\n✓ 导出完成:', exportDir);
console.log(' 文件数:', targets.length);
console.log(' 签名指纹:', manifest.pubKeyFingerprint);
}
main();