build-dist.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env node
  2. /**
  3. * build-dist.mjs —— 从各技能源仓库拉取真实源文件,生成 dist/(上传 SkillHub 的产物)
  4. *
  5. * 设计原则:
  6. * 1. 本仓库(fmode-skills)只维护清单 + 映射 + 打包逻辑,不存技能正文
  7. * 2. dist/ 每次构建全量重建,内含每个技能的真实 SKILL.md + 脚本 + 参考文档
  8. * 3. 触发词(description 里的“触发词:”)随 SKILL.md 一起进入 dist,SkillHub 才能检索到
  9. *
  10. * 用法:
  11. * node scripts/build-dist.mjs 全量构建
  12. * node scripts/build-dist.mjs --dry-run 只检查映射和源可达性,不落盘
  13. * node scripts/build-dist.mjs --skill=xxx 只构建单个技能
  14. * node scripts/build-dist.mjs --offline 使用本地缓存,不访问远端
  15. */
  16. import fs from 'node:fs';
  17. import path from 'node:path';
  18. import { execFileSync } from 'node:child_process';
  19. import { fileURLToPath } from 'node:url';
  20. import { normalizeSkill } from './normalize-skill.mjs';
  21. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  22. const ROOT = path.resolve(__dirname, '..');
  23. const DIST = path.join(ROOT, 'dist');
  24. const CACHE = path.join(ROOT, '.cache', 'repos');
  25. const SOURCES = JSON.parse(fs.readFileSync(path.join(ROOT, 'skill-sources.json'), 'utf8'));
  26. const args = process.argv.slice(2);
  27. const DRY = args.includes('--dry-run');
  28. const OFFLINE = args.includes('--offline');
  29. const ONLY = (args.find(a => a.startsWith('--skill=')) || '').split('=')[1] || null;
  30. const C = { g: '\x1b[32m', y: '\x1b[33m', r: '\x1b[31m', d: '\x1b[2m', b: '\x1b[1m', x: '\x1b[0m' };
  31. const log = (m) => console.log(m);
  32. const ok = (m) => log(`${C.g}✓${C.x} ${m}`);
  33. const warn = (m) => log(`${C.y}!${C.x} ${m}`);
  34. const err = (m) => log(`${C.r}✗${C.x} ${m}`);
  35. /** 需要排除的文件(不进 dist) */
  36. const EXCLUDE = [
  37. /(^|\/)\.git(\/|$)/, /(^|\/)node_modules(\/|$)/, /(^|\/)\.cache(\/|$)/, /(^|\/)dist(\/|$)/,
  38. /(^|\/)\.github(\/|$)/,
  39. /^\.gitignore$/, /^package-lock\.json$/, /^\.DS_Store$/,
  40. /\.bak$/, /\.bak-/, /\.log$/, /\.old$/, /~$/,
  41. ];
  42. function shouldExclude(rel) {
  43. return EXCLUDE.some(re => re.test(rel));
  44. }
  45. /** 递归复制目录,过滤排除项 */
  46. function copyDir(src, dest, stats, rootSrc) {
  47. if (!fs.existsSync(src)) return;
  48. const root = rootSrc || src;
  49. fs.mkdirSync(dest, { recursive: true });
  50. for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
  51. const s = path.join(src, entry.name);
  52. const d = path.join(dest, entry.name);
  53. const rel = path.relative(root, s).split(path.sep).join('/');
  54. if (shouldExclude(rel) || shouldExclude(entry.name)) continue;
  55. if (entry.isDirectory()) {
  56. copyDir(s, d, stats, root);
  57. } else if (entry.isFile()) {
  58. fs.copyFileSync(s, d);
  59. stats.files++;
  60. stats.bytes += fs.statSync(s).size;
  61. }
  62. }
  63. }
  64. /** 从仓库拉取/更新源码到缓存目录 */
  65. function syncRepo(name, cfg) {
  66. const cacheDir = path.join(CACHE, name);
  67. if (cfg.status === 'local-source') {
  68. const localDir = path.join(ROOT, 'sources', name);
  69. if (!fs.existsSync(localDir)) {
  70. return { ok: false, reason: `本地源目录不存在: sources/${name}` };
  71. }
  72. return { ok: true, dir: localDir };
  73. }
  74. if (cfg.status === 'local-repo') {
  75. // 直接使用本机已 clone 的仓库(构建机本地验证用)
  76. if (!fs.existsSync(cfg.localPath)) {
  77. return { ok: false, reason: `本地仓库不存在: ${cfg.localPath}` };
  78. }
  79. return { ok: true, dir: cfg.localPath };
  80. }
  81. if (cfg.status === 'pending-source') {
  82. return { ok: false, reason: '尚未建立源仓库(pending-source)', skip: true };
  83. }
  84. if (OFFLINE) {
  85. if (fs.existsSync(cacheDir)) return { ok: true, dir: cacheDir, cached: true };
  86. return { ok: false, reason: '离线模式且无缓存' };
  87. }
  88. try {
  89. if (fs.existsSync(path.join(cacheDir, '.git'))) {
  90. execFileSync('git', ['-C', cacheDir, 'fetch', '--depth=1', 'origin'], {
  91. stdio: 'pipe', timeout: 60000,
  92. });
  93. execFileSync('git', ['-C', cacheDir, 'reset', '--hard', 'origin/HEAD'], {
  94. stdio: 'pipe', timeout: 60000,
  95. });
  96. } else {
  97. fs.mkdirSync(CACHE, { recursive: true });
  98. execFileSync('git', ['clone', '--depth=1', cfg.repo, cacheDir], {
  99. stdio: 'pipe', timeout: 120000,
  100. });
  101. }
  102. return { ok: true, dir: cacheDir };
  103. } catch (e) {
  104. if (fs.existsSync(cacheDir)) {
  105. return { ok: true, dir: cacheDir, stale: true, reason: '远端拉取失败,使用旧缓存' };
  106. }
  107. return { ok: false, reason: `clone 失败: ${String(e.message).slice(0, 120)}` };
  108. }
  109. }
  110. /** 品牌映射:对外呈现统一为 FmodeAgent / FmodeCode
  111. * 注意:只替换独立出现的品牌词,避免改动代码标识符(如 readClaudeSettingsEnv) */
  112. const BRAND_MAP = [
  113. [/Hermes[\s-]?Agent/gi, 'FmodeAgent'],
  114. [/(?<![A-Za-z])Hermes(?![A-Za-z])/g, 'FmodeAgent'],
  115. [/Claude[\s-]?Code/gi, 'FmodeCode'],
  116. [/(?<![A-Za-z])Claude(?![A-Za-z])/g, 'FmodeCode'],
  117. [/(?<![A-Za-z])DeepSeek(?:-[vV][\d.]+\w*)?(?![A-Za-z])/g, 'FmodeCode'],
  118. [/(?<![A-Za-z])GPT-?[45](?:\.\d+)?(?![A-Za-z])/g, 'FmodeCode'],
  119. [/(?<![A-Za-z])OpenAI(?![A-Za-z])/g, 'Fmode'],
  120. ];
  121. /** 对 dist 目录内文本文件做品牌替换(.md/.json/.mjs/.js/.txt) */
  122. function brandFixDir(dir) {
  123. const TEXT_EXT = /\.(md|json|mjs|js|cjs|ts|txt|yaml|yml|html|htm|css|svg|xml)$/i;
  124. const walk = (d) => {
  125. for (const e of fs.readdirSync(d, { withFileTypes: true })) {
  126. const p = path.join(d, e.name);
  127. if (e.isDirectory()) { walk(p); continue; }
  128. if (!TEXT_EXT.test(e.name)) continue;
  129. let s = fs.readFileSync(p, 'utf8');
  130. let changed = false;
  131. for (const [re, to] of BRAND_MAP) {
  132. const n = s.replace(re, to);
  133. if (n !== s) { s = n; changed = true; }
  134. }
  135. if (changed) fs.writeFileSync(p, s, 'utf8');
  136. }
  137. };
  138. walk(dir);
  139. }
  140. // ─────────────────────────── 主流程 ───────────────────────────
  141. const summary = { built: [], skipped: [], failed: [], files: 0, bytes: 0 };
  142. log(`\n${C.b}Fmode Skills 打包${C.x} ${DRY ? '(dry-run)' : ''}${OFFLINE ? ' (offline)' : ''}\n`);
  143. if (!DRY) {
  144. fs.rmSync(DIST, { recursive: true, force: true });
  145. fs.mkdirSync(DIST, { recursive: true });
  146. }
  147. const names = ONLY ? [ONLY] : Object.keys(SOURCES.sources);
  148. for (const name of names) {
  149. const cfg = SOURCES.sources[name];
  150. if (!cfg) {
  151. err(`${name} 不在映射表中`);
  152. summary.failed.push({ name, reason: '未在 skill-sources.json 中定义' });
  153. continue;
  154. }
  155. const synced = syncRepo(name, cfg);
  156. if (!synced.ok) {
  157. if (synced.skip) {
  158. warn(`${name} 跳过 — ${synced.reason}`);
  159. summary.skipped.push({ name, reason: synced.reason });
  160. } else {
  161. err(`${name} 失败 — ${synced.reason}`);
  162. summary.failed.push({ name, reason: synced.reason });
  163. }
  164. continue;
  165. }
  166. const skillDir = path.join(synced.dir, cfg.path === '.' ? '' : cfg.path);
  167. // 规范化:统一 frontmatter,自动补触发词(SkillHub 检索依赖)
  168. const norm = normalizeSkill(skillDir, name);
  169. if (!norm.ok) {
  170. err(`${name} 规范化失败: ${norm.issues.join('; ')}`);
  171. summary.failed.push({ name, reason: norm.issues.join('; ') });
  172. continue;
  173. }
  174. if (!DRY) {
  175. const targetDir = path.join(DIST, cfg.target);
  176. copyDir(skillDir, targetDir, summary);
  177. // 用规范化后的 SKILL.md 覆盖(正文保留原文,仅统一 frontmatter)
  178. fs.writeFileSync(path.join(targetDir, 'SKILL.md'), norm.output, 'utf8');
  179. // 品牌映射:对外呈现统一为 FmodeAgent / FmodeCode
  180. brandFixDir(targetDir);
  181. }
  182. const trig = norm.meta?.triggers?.slice(0, 4).join('、') || '';
  183. 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}` : ''}`);
  184. summary.built.push(name);
  185. }
  186. // 生成 dist 索引
  187. if (!DRY && summary.built.length) {
  188. const index = {
  189. name: 'fmode-skills',
  190. version: SOURCES.version,
  191. builtAt: new Date().toISOString(),
  192. skills: summary.built.map(name => ({
  193. name,
  194. path: SOURCES.sources[name].target,
  195. })),
  196. };
  197. fs.writeFileSync(path.join(DIST, 'skills-index.json'), JSON.stringify(index, null, 2));
  198. }
  199. // 汇总
  200. log(`\n${C.b}汇总${C.x}`);
  201. log(` 构建成功: ${summary.built.length}`);
  202. if (summary.skipped.length) log(` ${C.y}跳过: ${summary.skipped.length}${C.x} ${summary.skipped.map(s => s.name).join(', ')}`);
  203. if (summary.failed.length) log(` ${C.r}失败: ${summary.failed.length}${C.x} ${summary.failed.map(f => f.name).join(', ')}`);
  204. if (!DRY) log(` 产出: ${summary.files} 文件 / ${(summary.bytes / 1024).toFixed(1)} KB → dist/`);
  205. process.exit(summary.failed.length > 0 && summary.built.length === 0 ? 1 : 0);