#!/usr/bin/env node // Bookworm Smart Assistant - 同步导出器 (Step 3) // 功能: // 1. 复制白名单文件到 %TEMP%\bw-sa-export- // 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 仍含硬编码路径'); } // 剥离仅主机安装的 MCP hook (Portable 用户没有这些 npm 包) const STRIP_HOOK_PATTERNS = [/session-continuity-mcp/]; try { const obj = JSON.parse(raw); if (obj.hooks) { let stripped = 0; for (const [event, groups] of Object.entries(obj.hooks)) { for (const group of groups) { if (group.hooks) { const before = group.hooks.length; group.hooks = group.hooks.filter(h => !STRIP_HOOK_PATTERNS.some(p => p.test(h.command || '')) ); stripped += before - group.hooks.length; } } obj.hooks[event] = groups.filter(g => !g.hooks || g.hooks.length > 0); } if (stripped > 0) { raw = JSON.stringify(obj, null, 2); console.log(`[export] settings.template 剥离 ${stripped} 个主机专属 hook`); } } } catch {} 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();