#!/usr/bin/env node /** * Bookworm Portable 发行版构建器 * * 从管理员 .claude/ 源目录编译出脱敏、混淆的 Portable 分发版本。 * * 步骤: * 1. 复制源文件到 dist-portable/ * 2. 替换 CLAUDE.md → 脱敏模板 * 3. 替换 settings.json → Portable 模板 (含 NDA hooks) * 4. terser 混淆 JS hooks (剥离注释 + 压缩) * 5. SKILL.md 脱敏 (移除触发关键词、参考资源、内部注释) * 6. Agent .md 脱敏 (移除内部注释) * 7. 移除管理员专属文件 (memory, debug, __tests__, .disabled, _deprecated, templates) * 8. 生成 integrity.sha256 完整性哈希 * 9. 输出构建报告 * * 用法: * node scripts/build-portable.js [--out ] [--no-minify] [--dry-run] * * 输出: * 默认 ~/.claude/dist-portable/ */ 'use strict'; const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { execSync } = require('child_process'); // --- 参数解析 --- const args = process.argv.slice(2); const DRY_RUN = args.includes('--dry-run'); const NO_MINIFY = args.includes('--no-minify'); const outIdx = args.indexOf('--out'); const SRC = path.resolve(path.join(__dirname, '..')); const DIST = outIdx >= 0 && args[outIdx + 1] ? path.resolve(args[outIdx + 1]) : path.join(SRC, 'dist-portable'); // --- 构建统计 --- const stats = { copied: 0, skipped: 0, minified: 0, sanitized: 0, removed: 0, errors: [], }; // --- 工具函数 --- function log(msg) { console.log(` ${msg}`); } function logStep(msg) { console.log(`\n--- ${msg} ---`); } function sha256File(filePath) { return crypto.createHash('sha256') .update(fs.readFileSync(filePath)) .digest('hex'); } function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } /** * 递归复制目录,跳过排除项 */ function copyDirSync(src, dest, excludes = []) { ensureDir(dest); const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); const relPath = path.relative(SRC, srcPath).replace(/\\/g, '/'); if (excludes.some(ex => { if (typeof ex === 'string') return relPath === ex || relPath.startsWith(ex + '/'); return ex.test(relPath); })) { stats.skipped++; continue; } if (entry.isDirectory()) { copyDirSync(srcPath, destPath, excludes); } else { fs.copyFileSync(srcPath, destPath); stats.copied++; } } } // ======================================================================== // 步骤 1: 白名单复制 (只复制 Bookworm 配置,忽略 Claude Code 运行时数据) // ======================================================================== function step1_copy() { logStep('1. 白名单复制'); if (fs.existsSync(DIST)) { // 保留 .git 目录 (Gitea 推送仓库),只清除内容 const gitDir = path.join(DIST, '.git'); const hasGit = fs.existsSync(gitDir); if (hasGit) { // 删除 .git 以外的所有内容 for (const entry of fs.readdirSync(DIST)) { if (entry === '.git') continue; fs.rmSync(path.join(DIST, entry), { recursive: true, force: true }); } log('清除旧内容 (保留 .git)'); } else { fs.rmSync(DIST, { recursive: true, force: true }); log('清除旧 dist-portable/'); } } // --- 白名单: 只复制这些顶级目录/文件 --- const INCLUDE_DIRS = [ 'skills', 'agents', 'hooks', 'scripts', 'constitution', 'rules', 'docs', ]; const INCLUDE_FILES = [ 'CLAUDE.md', 'settings.json', 'settings.local.template.json', 'settings.template.json', 'SKILL-REGISTRY.md', 'feature-flags.json', 'integrity.sha256', 'package.json', 'skills-index.json', 'skills-index-lite.json', ]; // --- 排除: 白名单目录内部的不需要项 --- const EXCLUDES = [ '.git', 'hooks/__tests__', 'hooks/deprecated', 'hooks/tests', 'skills/_deprecated', /\.disabled$/, /\.self-hash$/, /checksums\.sig$/, /node_modules/, /package-lock\.json$/, /pnpm-lock\.yaml$/, /yarn\.lock$/, // Skill 重型子目录 (任意深度) /^skills\/.*\/(?:src|dist|build|test|__tests__|bin|\.next|\.turbo|coverage)(?:\/|$)/, ]; if (DRY_RUN) { log('[DRY RUN] 将复制 ' + SRC + ' → ' + DIST); return; } ensureDir(DIST); // 复制白名单文件 for (const file of INCLUDE_FILES) { const srcPath = path.join(SRC, file); if (fs.existsSync(srcPath)) { fs.copyFileSync(srcPath, path.join(DIST, file)); stats.copied++; } } // 复制白名单目录 for (const dir of INCLUDE_DIRS) { const srcPath = path.join(SRC, dir); if (fs.existsSync(srcPath)) { copyDirSync(srcPath, path.join(DIST, dir), EXCLUDES); } } log(`复制完成: ${stats.copied} 文件, 跳过 ${stats.skipped} 项`); } // ======================================================================== // 步骤 2: 替换 CLAUDE.md // ======================================================================== function step2_claudeMd() { logStep('2. 替换 CLAUDE.md → 脱敏模板'); const templatePath = path.join(SRC, 'templates', 'CLAUDE-portable.md'); const destPath = path.join(DIST, 'CLAUDE.md'); if (!fs.existsSync(templatePath)) { stats.errors.push('模板文件不存在: templates/CLAUDE-portable.md'); return; } // 从 stats-compiled.json 读取版本号 let version = 'v6.5.1'; try { const statsFile = path.join(SRC, 'stats-compiled.json'); const compiled = JSON.parse(fs.readFileSync(statsFile, 'utf8')); version = compiled.summary?.version || version; } catch {} let content = fs.readFileSync(templatePath, 'utf8'); content = content.replace(/\{version\}/g, version); if (!DRY_RUN) { fs.writeFileSync(destPath, content, 'utf8'); } log(`CLAUDE.md 替换完成 (版本: ${version})`); } // ======================================================================== // 步骤 3: 替换 settings.json // ======================================================================== function step3_settings() { logStep('3. 替换 settings.json → Portable 模板'); const templatePath = path.join(SRC, 'templates', 'settings.portable.json'); const destPath = path.join(DIST, 'settings.json'); if (!fs.existsSync(templatePath)) { stats.errors.push('模板文件不存在: templates/settings.portable.json'); return; } // settings.portable.json 中的 {{CLAUDE_DIR}} 和 {{HOME}} 保留原样 // install.ps1 在安装时会替换这些占位符 if (!DRY_RUN) { fs.copyFileSync(templatePath, destPath); // 同时替换 settings.template.json — install.ps1 用它渲染 settings.json // 不替换会导致 NDA hooks 被管理员版模板覆盖 (P0 bug fix) const templateDest = path.join(DIST, 'settings.template.json'); fs.copyFileSync(templatePath, templateDest); } log('settings.json + settings.template.json 替换完成 (含 NDA hooks 注册)'); // 3b: 用 standalone 版替换 nda-read-guard.js (内联依赖, 混淆后无 require 链开销) const standaloneSrc = path.join(SRC, 'hooks', 'nda-read-guard.standalone.js'); const guardDest = path.join(DIST, 'hooks', 'nda-read-guard.js'); if (fs.existsSync(standaloneSrc) && !DRY_RUN) { fs.copyFileSync(standaloneSrc, guardDest); log('nda-read-guard.js → standalone 版替换完成'); } } // ======================================================================== // 步骤 4: JS Hook 混淆 // ======================================================================== function step4_minifyHooks() { logStep('4. JS Hook 混淆'); if (NO_MINIFY) { log('[SKIP] --no-minify 标志已设置'); return; } const hooksDir = path.join(DIST, 'hooks'); if (!fs.existsSync(hooksDir)) return; const jsFiles = fs.readdirSync(hooksDir).filter(f => f.endsWith('.js')); let terserAvailable = false; // 检测 terser try { execSync('npx terser --version', { stdio: 'pipe', timeout: 10000 }); terserAvailable = true; } catch { log('terser 不可用, 退回注释剥离模式'); } for (const file of jsFiles) { const filePath = path.join(hooksDir, file); try { if (terserAvailable && !DRY_RUN) { // terser 混淆: 压缩 + 去注释 + 混淆变量名 execSync( `npx terser "${filePath}" --compress --mangle --output "${filePath}" --comments false`, { stdio: 'pipe', timeout: 60000 } ); } else if (!DRY_RUN) { // 退回: 仅剥离注释和空行 let code = fs.readFileSync(filePath, 'utf8'); code = code .replace(/\/\*[\s\S]*?\*\//g, '') // 多行注释 .replace(/\/\/.*$/gm, '') // 单行注释 .replace(/^\s*\n/gm, ''); // 空行 fs.writeFileSync(filePath, code, 'utf8'); } stats.minified++; } catch (e) { stats.errors.push(`混淆失败: hooks/${file} — ${e.message?.slice(0, 100)}`); } } // 同时混淆 hooks/lib/ 下的模块 const libDir = path.join(hooksDir, 'lib'); if (fs.existsSync(libDir)) { const libFiles = fs.readdirSync(libDir).filter(f => f.endsWith('.js')); for (const file of libFiles) { const filePath = path.join(libDir, file); try { if (terserAvailable && !DRY_RUN) { execSync( `npx terser "${filePath}" --compress --mangle --output "${filePath}" --comments false`, { stdio: 'pipe', timeout: 30000 } ); } stats.minified++; } catch (e) { stats.errors.push(`混淆失败: hooks/lib/${file} — ${e.message?.slice(0, 100)}`); } } } log(`混淆完成: ${stats.minified} 个 JS 文件`); } // ======================================================================== // 步骤 5: SKILL.md 脱敏 // ======================================================================== function step5_sanitizeSkills() { logStep('5. SKILL.md 脱敏'); const skillsDir = path.join(DIST, 'skills'); if (!fs.existsSync(skillsDir)) return; const skills = fs.readdirSync(skillsDir).filter(f => { const fp = path.join(skillsDir, f); return fs.statSync(fp).isDirectory() && f !== '_deprecated'; }); // 需要移除的段落模式 (匹配 ##/### 开头到下一个同级标题) const STRIP_SECTIONS = [ // 触发关键词表 — 暴露路由边界 /## 触发关键词[\s\S]*?(?=\n## |\n---\s*$|$)/g, // 参考资源/参考文档 — 暴露内部文件路径 /## 参考资源[\s\S]*?(?=\n## |\n---\s*$|$)/g, /## 参考文档[\s\S]*?(?=\n## |\n---\s*$|$)/g, // 内部注释 (HTML 注释) //g, // ARGUMENTS 行 (有时包含调试信息) /^ARGUMENTS:.*$/gm, ]; // 敏感关键词替换 (模糊化内部术语) const REDACT_PATTERNS = [ // BWR 路由引用 [/BWR[: ]/g, ''], // Hook 名称引用 [/(?:route-interceptor|prompt-dispatcher|bash-precheck)[a-z-]*/gi, ''], // 内部文件路径 [/scripts\/[a-z-]+\.js/gi, ''], [/hooks\/[a-z-]+\.js/gi, ''], // stats-compiled 引用 [/stats-compiled\.json/gi, ''], ]; let sanitizedCount = 0; for (const skill of skills) { const mdPath = path.join(skillsDir, skill, 'SKILL.md'); if (!fs.existsSync(mdPath)) continue; let content = fs.readFileSync(mdPath, 'utf8'); const original = content; // 移除敏感段落 for (const pattern of STRIP_SECTIONS) { content = content.replace(pattern, ''); } // 替换敏感关键词 for (const [pattern, replacement] of REDACT_PATTERNS) { content = content.replace(pattern, replacement); } // 清理多余空行 (脱敏后可能留下连续空行) content = content.replace(/\n{3,}/g, '\n\n'); if (content !== original) { if (!DRY_RUN) fs.writeFileSync(mdPath, content, 'utf8'); sanitizedCount++; } } stats.sanitized += sanitizedCount; log(`脱敏完成: ${sanitizedCount}/${skills.length} 个 SKILL.md 被修改`); } // ======================================================================== // 步骤 6: Agent .md 脱敏 // ======================================================================== function step6_sanitizeAgents() { logStep('6. Agent .md 脱敏'); const agentsDir = path.join(DIST, 'agents'); if (!fs.existsSync(agentsDir)) return; const agents = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')); let sanitizedCount = 0; for (const file of agents) { const filePath = path.join(agentsDir, file); let content = fs.readFileSync(filePath, 'utf8'); const original = content; // 移除 HTML 注释 content = content.replace(//g, ''); // 移除内部文件路径引用 content = content.replace(/scripts\/[a-z-]+\.(js|json)/gi, ''); content = content.replace(/hooks\/[a-z-]+\.js/gi, ''); // 清理多余空行 content = content.replace(/\n{3,}/g, '\n\n'); if (content !== original) { if (!DRY_RUN) fs.writeFileSync(filePath, content, 'utf8'); sanitizedCount++; } } stats.sanitized += sanitizedCount; log(`脱敏完成: ${sanitizedCount}/${agents.length} 个 Agent .md 被修改`); } // ======================================================================== // 步骤 7: 清理残留管理员文件 // ======================================================================== function step7_cleanup() { logStep('7. 清理残留文件'); const CLEANUP_PATTERNS = [ // MEMORY.md 索引 (指向管理员记忆) 'MEMORY.md', // 项目级记忆 (projects/*/memory/) — 保留 projects/*/CLAUDE.md // docs 中的内部文档 'docs/active-projects.md', 'docs/project-config.md', // stats-compiled.json (含详细内部统计) 'stats-compiled.json', // evolution-log (系统进化日志) 'evolution-log.jsonl', // checksums.json (需重新生成) 'hooks/checksums.json', 'hooks/checksums.sig', ]; let removed = 0; for (const rel of CLEANUP_PATTERNS) { const fp = path.join(DIST, rel); if (fs.existsSync(fp)) { if (!DRY_RUN) fs.rmSync(fp, { force: true }); removed++; } } stats.removed += removed; log(`清理完成: ${removed} 个残留文件移除`); } // ======================================================================== // 步骤 8: 生成 integrity.sha256 // ======================================================================== function step8_integrity() { logStep('8. 生成 integrity.sha256'); const checksums = {}; function walkDir(dir, prefix = '') { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { walkDir(path.join(dir, entry.name), prefix + entry.name + '/'); } else { const relPath = prefix + entry.name; const fullPath = path.join(dir, entry.name); checksums[relPath] = sha256File(fullPath); } } } if (!DRY_RUN) { // 只哈希关键目录 const KEY_DIRS = ['hooks', 'skills', 'agents', 'constitution', 'rules']; const KEY_FILES = ['CLAUDE.md', 'settings.json']; for (const dir of KEY_DIRS) { const dirPath = path.join(DIST, dir); if (fs.existsSync(dirPath)) walkDir(dirPath, dir + '/'); } for (const file of KEY_FILES) { const fp = path.join(DIST, file); if (fs.existsSync(fp)) checksums[file] = sha256File(fp); } fs.writeFileSync( path.join(DIST, 'hooks', 'checksums.json'), JSON.stringify(checksums, null, 2), 'utf8' ); log(`完整性哈希: ${Object.keys(checksums).length} 个文件`); } else { log('[DRY RUN] 将生成完整性哈希'); } } // ======================================================================== // 步骤 9: 构建报告 // ======================================================================== function step9_report() { logStep('9. 构建报告'); // 统计 dist 大小 let totalSize = 0; let fileCount = 0; function countDir(dir) { if (!fs.existsSync(dir)) return; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fp = path.join(dir, entry.name); if (entry.isDirectory()) { countDir(fp); } else { totalSize += fs.statSync(fp).size; fileCount++; } } } if (!DRY_RUN) countDir(DIST); console.log(` ╔════════════════════════════════════════════╗ ║ Bookworm Portable Build Report ║ ╠════════════════════════════════════════════╣ ║ 输出目录: ${DIST.slice(-35).padEnd(30)} ║ ║ 文件总数: ${String(fileCount).padEnd(30)} ║ ║ 总大小: ${((totalSize / 1024 / 1024).toFixed(1) + ' MB').padEnd(30)} ║ ║ ──────────────────────────────────────── ║ ║ 复制: ${String(stats.copied).padEnd(30)} ║ ║ 跳过: ${String(stats.skipped).padEnd(30)} ║ ║ 混淆: ${String(stats.minified).padEnd(30)} ║ ║ 脱敏: ${String(stats.sanitized).padEnd(30)} ║ ║ 移除: ${String(stats.removed).padEnd(30)} ║ ║ 错误: ${String(stats.errors.length).padEnd(30)} ║ ╚════════════════════════════════════════════╝`); if (stats.errors.length > 0) { console.log('\n⚠ 错误详情:'); for (const err of stats.errors) { console.log(` - ${err}`); } } if (DRY_RUN) { console.log('\n[DRY RUN] 未实际写入文件。去掉 --dry-run 执行实际构建。'); } } // ======================================================================== // 主流程 // ======================================================================== console.log(` ╔════════════════════════════════════════════╗ ║ Bookworm Portable Builder ║ ║ Source: ${SRC.slice(-35).padEnd(33)} ║ ║ Output: ${DIST.slice(-35).padEnd(33)} ║ ╚════════════════════════════════════════════╝`); try { step1_copy(); step2_claudeMd(); step3_settings(); step4_minifyHooks(); step5_sanitizeSkills(); step6_sanitizeAgents(); step7_cleanup(); step8_integrity(); step9_report(); process.exit(stats.errors.length > 0 ? 1 : 0); } catch (e) { console.error('\n❌ 构建失败:', e.message); process.exit(1); }