| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- #!/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'],
- [/(?<![A-Za-z])Hermes(?![A-Za-z])/g, 'FmodeAgent'],
- [/Claude[\s-]?Code/gi, 'FmodeCode'],
- [/(?<![A-Za-z])Claude(?![A-Za-z])/g, 'FmodeCode'],
- [/(?<![A-Za-z])DeepSeek(?:-[vV][\d.]+\w*)?(?![A-Za-z])/g, 'FmodeCode'],
- [/(?<![A-Za-z])GPT-?[45](?:\.\d+)?(?![A-Za-z])/g, 'FmodeCode'],
- [/(?<![A-Za-z])OpenAI(?![A-Za-z])/g, 'Fmode'],
- ];
- /** 对 dist 目录内文本文件做品牌替换(.md/.json/.mjs/.js/.txt) */
- function brandFixDir(dir) {
- const TEXT_EXT = /\.(md|json|mjs|js|cjs|ts|txt|yaml|yml|html|htm|css|svg|xml)$/i;
- const walk = (d) => {
- 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);
|