2026-04-21 17:57:05 +08:00
|
|
|
#!/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',
|
2026-04-27 17:59:44 +08:00
|
|
|
'VERSION',
|
2026-04-21 17:57:05 +08:00
|
|
|
];
|
|
|
|
|
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 仍含硬编码路径');
|
|
|
|
|
}
|
2026-04-27 22:15:39 +08:00
|
|
|
// 剥离仅主机安装的 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 {}
|
2026-04-21 17:57:05 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-04-27 17:59:44 +08:00
|
|
|
// 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
|
2026-04-21 17:57:05 +08:00
|
|
|
const manifest = {
|
2026-04-27 17:59:44 +08:00
|
|
|
version,
|
2026-04-21 17:57:05 +08:00
|
|
|
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 已写入');
|
|
|
|
|
|
2026-04-27 17:59:44 +08:00
|
|
|
// 10. VERSION 文件 (OTA 版本比对用, 纯文本一行)
|
|
|
|
|
fs.writeFileSync(path.join(exportDir, 'VERSION'), version + '\n');
|
|
|
|
|
console.log(`[export] VERSION 已写入: ${version}`);
|
|
|
|
|
|
2026-04-21 17:57:05 +08:00
|
|
|
console.log('\n✓ 导出完成:', exportDir);
|
|
|
|
|
console.log(' 文件数:', targets.length);
|
|
|
|
|
console.log(' 签名指纹:', manifest.pubKeyFingerprint);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main();
|