bookworm-smart-assistant/scripts/build-portable.js

589 lines
19 KiB
JavaScript
Raw Permalink Normal View History

#!/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 <dir>] [--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 注释)
/<!--[\s\S]*?-->/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(/<!--[\s\S]*?-->/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);
}