#!/usr/bin/env node /** * build-dist.mjs —— 从各技能源仓库拉取真实源文件,生成 dist/(上传 SkillHub 的产物) * * 设计原则: * 1. 本仓库(fmode-skills)只维护清单 + 映射 + 打包逻辑,不存技能正文 * 2. dist/ 每次构建全量重建,内含每个技能的真实 SKILL.md + 脚本 + 参考文档 * 3. 触发词(description 里的“触发词:”)随 SKILL.md 一起进入 dist,SkillHub 才能检索到 * * 用法: * node scripts/build-dist.mjs 全量构建 * node scripts/build-dist.mjs --dry-run 只检查映射和源可达性,不落盘 * node scripts/build-dist.mjs --skill=xxx 只构建单个技能 * node scripts/build-dist.mjs --offline 使用本地缓存,不访问远端 */ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { normalizeSkill } from './normalize-skill.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const DIST = path.join(ROOT, 'dist'); const CACHE = path.join(ROOT, '.cache', 'repos'); const SOURCES = JSON.parse(fs.readFileSync(path.join(ROOT, 'skill-sources.json'), 'utf8')); const args = process.argv.slice(2); const DRY = args.includes('--dry-run'); const OFFLINE = args.includes('--offline'); const ONLY = (args.find(a => a.startsWith('--skill=')) || '').split('=')[1] || null; const C = { g: '\x1b[32m', y: '\x1b[33m', r: '\x1b[31m', d: '\x1b[2m', b: '\x1b[1m', x: '\x1b[0m' }; const log = (m) => console.log(m); const ok = (m) => log(`${C.g}✓${C.x} ${m}`); const warn = (m) => log(`${C.y}!${C.x} ${m}`); const err = (m) => log(`${C.r}✗${C.x} ${m}`); /** 需要排除的文件(不进 dist) */ const EXCLUDE = [ /(^|\/)\.git(\/|$)/, /(^|\/)node_modules(\/|$)/, /(^|\/)\.cache(\/|$)/, /(^|\/)dist(\/|$)/, /(^|\/)\.github(\/|$)/, /^\.gitignore$/, /^package-lock\.json$/, /^\.DS_Store$/, /\.bak$/, /\.bak-/, /\.log$/, /\.old$/, /~$/, ]; function shouldExclude(rel) { return EXCLUDE.some(re => re.test(rel)); } /** 递归复制目录,过滤排除项 */ function copyDir(src, dest, stats, rootSrc) { if (!fs.existsSync(src)) return; const root = rootSrc || src; fs.mkdirSync(dest, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const s = path.join(src, entry.name); const d = path.join(dest, entry.name); const rel = path.relative(root, s).split(path.sep).join('/'); if (shouldExclude(rel) || shouldExclude(entry.name)) continue; if (entry.isDirectory()) { copyDir(s, d, stats, root); } else if (entry.isFile()) { fs.copyFileSync(s, d); stats.files++; stats.bytes += fs.statSync(s).size; } } } /** 从仓库拉取/更新源码到缓存目录 */ function syncRepo(name, cfg) { const cacheDir = path.join(CACHE, name); if (cfg.status === 'local-source') { const localDir = path.join(ROOT, 'sources', name); if (!fs.existsSync(localDir)) { return { ok: false, reason: `本地源目录不存在: sources/${name}` }; } return { ok: true, dir: localDir }; } if (cfg.status === 'local-repo') { // 直接使用本机已 clone 的仓库(构建机本地验证用) if (!fs.existsSync(cfg.localPath)) { return { ok: false, reason: `本地仓库不存在: ${cfg.localPath}` }; } return { ok: true, dir: cfg.localPath }; } if (cfg.status === 'pending-source') { return { ok: false, reason: '尚未建立源仓库(pending-source)', skip: true }; } if (OFFLINE) { if (fs.existsSync(cacheDir)) return { ok: true, dir: cacheDir, cached: true }; return { ok: false, reason: '离线模式且无缓存' }; } try { if (fs.existsSync(path.join(cacheDir, '.git'))) { execFileSync('git', ['-C', cacheDir, 'fetch', '--depth=1', 'origin'], { stdio: 'pipe', timeout: 60000, }); execFileSync('git', ['-C', cacheDir, 'reset', '--hard', 'origin/HEAD'], { stdio: 'pipe', timeout: 60000, }); } else { fs.mkdirSync(CACHE, { recursive: true }); execFileSync('git', ['clone', '--depth=1', cfg.repo, cacheDir], { stdio: 'pipe', timeout: 120000, }); } return { ok: true, dir: cacheDir }; } catch (e) { if (fs.existsSync(cacheDir)) { return { ok: true, dir: cacheDir, stale: true, reason: '远端拉取失败,使用旧缓存' }; } return { ok: false, reason: `clone 失败: ${String(e.message).slice(0, 120)}` }; } } /** 品牌映射:对外呈现统一为 FmodeAgent / FmodeCode * 注意:只替换独立出现的品牌词,避免改动代码标识符(如 readClaudeSettingsEnv) */ const BRAND_MAP = [ [/Hermes[\s-]?Agent/gi, 'FmodeAgent'], [/(? { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.isDirectory()) { walk(p); continue; } if (!TEXT_EXT.test(e.name)) continue; let s = fs.readFileSync(p, 'utf8'); let changed = false; for (const [re, to] of BRAND_MAP) { const n = s.replace(re, to); if (n !== s) { s = n; changed = true; } } if (changed) fs.writeFileSync(p, s, 'utf8'); } }; walk(dir); } // ─────────────────────────── 主流程 ─────────────────────────── const summary = { built: [], skipped: [], failed: [], files: 0, bytes: 0 }; log(`\n${C.b}Fmode Skills 打包${C.x} ${DRY ? '(dry-run)' : ''}${OFFLINE ? ' (offline)' : ''}\n`); if (!DRY) { fs.rmSync(DIST, { recursive: true, force: true }); fs.mkdirSync(DIST, { recursive: true }); } const names = ONLY ? [ONLY] : Object.keys(SOURCES.sources); for (const name of names) { const cfg = SOURCES.sources[name]; if (!cfg) { err(`${name} 不在映射表中`); summary.failed.push({ name, reason: '未在 skill-sources.json 中定义' }); continue; } const synced = syncRepo(name, cfg); if (!synced.ok) { if (synced.skip) { warn(`${name} 跳过 — ${synced.reason}`); summary.skipped.push({ name, reason: synced.reason }); } else { err(`${name} 失败 — ${synced.reason}`); summary.failed.push({ name, reason: synced.reason }); } continue; } const skillDir = path.join(synced.dir, cfg.path === '.' ? '' : cfg.path); // 规范化:统一 frontmatter,自动补触发词(SkillHub 检索依赖) const norm = normalizeSkill(skillDir, name); if (!norm.ok) { err(`${name} 规范化失败: ${norm.issues.join('; ')}`); summary.failed.push({ name, reason: norm.issues.join('; ') }); continue; } if (!DRY) { const targetDir = path.join(DIST, cfg.target); copyDir(skillDir, targetDir, summary); // 用规范化后的 SKILL.md 覆盖(正文保留原文,仅统一 frontmatter) fs.writeFileSync(path.join(targetDir, 'SKILL.md'), norm.output, 'utf8'); // 品牌映射:对外呈现统一为 FmodeAgent / FmodeCode brandFixDir(targetDir); } const trig = norm.meta?.triggers?.slice(0, 4).join('、') || ''; ok(`${name}${trig ? ` ${C.d}触发词: ${trig}${norm.meta.triggers.length > 4 ? '…' : ''}${C.x}` : ''}${synced.cached ? ` ${C.d}(缓存)${C.x}` : ''}${synced.stale ? ` ${C.y}(旧缓存)${C.x}` : ''}`); summary.built.push(name); } // 生成 dist 索引 if (!DRY && summary.built.length) { const index = { name: 'fmode-skills', version: SOURCES.version, builtAt: new Date().toISOString(), skills: summary.built.map(name => ({ name, path: SOURCES.sources[name].target, })), }; fs.writeFileSync(path.join(DIST, 'skills-index.json'), JSON.stringify(index, null, 2)); } // 汇总 log(`\n${C.b}汇总${C.x}`); log(` 构建成功: ${summary.built.length}`); if (summary.skipped.length) log(` ${C.y}跳过: ${summary.skipped.length}${C.x} ${summary.skipped.map(s => s.name).join(', ')}`); if (summary.failed.length) log(` ${C.r}失败: ${summary.failed.length}${C.x} ${summary.failed.map(f => f.name).join(', ')}`); if (!DRY) log(` 产出: ${summary.files} 文件 / ${(summary.bytes / 1024).toFixed(1)} KB → dist/`); process.exit(summary.failed.length > 0 && summary.built.length === 0 ? 1 : 0);