pack-release.mjs 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. #!/usr/bin/env node
  2. /**
  3. * pack-release.mjs —— 生成可直接上传 SkillHub 的发布 zip
  4. *
  5. * 产物:release/fmode-skills-<version>-<date>.zip
  6. * 内容:dist/ 全部技能 + 根 SKILL.md + README + manifest(不含内部脚本、不含缓存)
  7. *
  8. * 上传方式:SkillHub 后台「上传技能包」→ 选这个 zip
  9. */
  10. import fs from 'node:fs';
  11. import path from 'node:path';
  12. import { execFileSync } from 'node:child_process';
  13. import { fileURLToPath } from 'node:url';
  14. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  15. const ROOT = path.resolve(__dirname, '..');
  16. const DIST = path.join(ROOT, 'dist');
  17. const RELEASE = path.join(ROOT, 'release');
  18. const C = { g: '\x1b[32m', y: '\x1b[33m', r: '\x1b[31m', d: '\x1b[2m', b: '\x1b[1m', x: '\x1b[0m' };
  19. if (!fs.existsSync(DIST)) {
  20. console.error('dist 不存在,先跑 npm run build');
  21. process.exit(1);
  22. }
  23. // 版本
  24. const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
  25. const version = pkg.version || '1.0.0';
  26. const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
  27. const stamp = `${version}-${date}`;
  28. fs.mkdirSync(RELEASE, { recursive: true });
  29. const stageDir = path.join(RELEASE, `.stage-${stamp}`);
  30. fs.rmSync(stageDir, { recursive: true, force: true });
  31. fs.mkdirSync(stageDir, { recursive: true });
  32. /** 递归复制,跳过 .git / 缓存 / 内部脚本 */
  33. const SKIP = [
  34. /(^|\/)\.git(\/|$)/, /(^|\/)__pycache__(\/|$)/, /(^|\/)node_modules(\/|$)/,
  35. /\.pyc$/, /\.log$/, /\.bak/, /\.DS_Store$/,
  36. /(^|\/)gen-.*\.(py|sh)$/, // 内部生成脚本
  37. /(^|\/)\.legacy(\/|$)/,
  38. /(^|\/)\.cache(\/|$)/,
  39. ];
  40. function copyTree(src, dest, rel = '') {
  41. fs.mkdirSync(dest, { recursive: true });
  42. for (const e of fs.readdirSync(src, { withFileTypes: true })) {
  43. const relPath = rel ? `${rel}/${e.name}` : e.name;
  44. if (SKIP.some(re => re.test(relPath))) continue;
  45. const s = path.join(src, e.name);
  46. // SkillHub 拒绝无扩展名文件:LICENSE → LICENSE.md
  47. const outName = e.name === 'LICENSE' ? 'LICENSE.md' : e.name;
  48. const d = path.join(dest, outName);
  49. if (e.isDirectory()) copyTree(s, d, relPath);
  50. else if (e.isFile()) fs.copyFileSync(s, d);
  51. }
  52. }
  53. // 1. 复制 dist 里的技能
  54. copyTree(DIST, stageDir);
  55. // 2. 复制根级 meta(SKILL.md / README / manifest)
  56. for (const f of ['SKILL.md', 'README.md', 'skill-package-manifest.json']) {
  57. const src = path.join(ROOT, f);
  58. if (fs.existsSync(src)) fs.copyFileSync(src, path.join(stageDir, f));
  59. }
  60. // 3. 生成/更新 package.json(SkillHub 读这个)
  61. const outPkg = {
  62. name: pkg.name || 'fmode-harness',
  63. version,
  64. description: pkg.description,
  65. type: 'module',
  66. license: pkg.license || 'MPL-2.0',
  67. keywords: pkg.keywords,
  68. engines: pkg.engines,
  69. scripts: {
  70. install: 'node scripts/installer.mjs --all',
  71. check: 'node scripts/installer.mjs --check',
  72. },
  73. };
  74. fs.writeFileSync(path.join(stageDir, 'package.json'), JSON.stringify(outPkg, null, 2) + '\n');
  75. // 3.5 生成 _meta.json(SkillHub 包元数据)
  76. const meta = {
  77. slug: pkg.name || 'fmode-harness',
  78. name: pkg.name || 'fmode-harness',
  79. version,
  80. displayName: 'Fmode Harness 技能库',
  81. publishedAt: Date.now(),
  82. };
  83. fs.writeFileSync(path.join(stageDir, '_meta.json'), JSON.stringify(meta, null, 2) + '\n');
  84. // 4. 检查 installer 是否存在(技能安装入口)
  85. const installerSrc = path.join(ROOT, 'scripts', 'installer.mjs');
  86. if (fs.existsSync(installerSrc)) {
  87. fs.mkdirSync(path.join(stageDir, 'scripts'), { recursive: true });
  88. fs.copyFileSync(installerSrc, path.join(stageDir, 'scripts', 'installer.mjs'));
  89. }
  90. // 5. 统计 & 自检
  91. function walk(d, out = []) {
  92. for (const e of fs.readdirSync(d, { withFileTypes: true })) {
  93. const p = path.join(d, e.name);
  94. if (e.isDirectory()) walk(p, out);
  95. else out.push(p);
  96. }
  97. return out;
  98. }
  99. const files = walk(stageDir);
  100. const skills = fs.existsSync(path.join(stageDir, 'skills'))
  101. ? fs.readdirSync(path.join(stageDir, 'skills')).filter(n =>
  102. fs.statSync(path.join(stageDir, 'skills', n)).isDirectory())
  103. : [];
  104. // 自检:密钥模式
  105. const SECRET_RE = [
  106. /\bsk-[a-zA-Z0-9]{20,}/,
  107. /\br:[a-z0-9]{20,}/,
  108. /AKIA[0-9A-Z]{16}/,
  109. /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
  110. /(192\.168\.[0-9]{1,3}\.[0-9]{1,3})/,
  111. /(139\.159\.253\.131|172\.0\.0\.100)/,
  112. /(db\.fmode\.cn|userland\.fmode\.cn)/,
  113. ];
  114. let leaks = 0;
  115. for (const f of files) {
  116. if (!/\.(md|json|mjs|js|sh|py|txt|html|css)$/i.test(f)) continue;
  117. const c = fs.readFileSync(f, 'utf8');
  118. for (const re of SECRET_RE) {
  119. if (re.test(c)) {
  120. console.log(`${C.r}✗ 疑似敏感${C.x} ${path.relative(stageDir, f)} → ${re}`);
  121. leaks++;
  122. }
  123. }
  124. }
  125. // 5.5 发布前内容体检:内部地址 / 外部依赖 / 内部 IP
  126. const CONTENT_CHECKS = [
  127. { name: '内部 Git 地址', re: /git\.fmode\.cn/, hint: '改为"已随包分发,无需安装"' },
  128. { name: 'npx 外部包依赖', re: /npx (--yes )?(@fmode\/|skill-|fmode-)/, hint: '改用包内脚本 <技能目录>/bin|scripts/' },
  129. { name: '内网 IP', re: /(?<![0-9.])(139\.159\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2[0-9]|3[01])\.\d+\.\d+|10\.\d+\.\d+\.\d+)(?![0-9])/, hint: '删除内网地址' },
  130. { name: '明文凭据', re: /(password\s*[:=]\s*["'][^"'{}$]{3,}["']|ak\s*=\s*["'][A-Za-z0-9]{16,})/i, hint: '移除明文凭据' },
  131. ];
  132. const contentIssues = [];
  133. (function scanContent(d, rel = '') {
  134. for (const e of fs.readdirSync(d, { withFileTypes: true })) {
  135. const p = path.join(d, e.name);
  136. if (e.isDirectory()) { scanContent(p, `${rel}${e.name}/`); continue; }
  137. if (!/\.(md|mjs|js|json|html|py|sh)$/i.test(e.name)) continue;
  138. const txt = fs.readFileSync(p, 'utf8');
  139. for (const c of CONTENT_CHECKS) {
  140. const m = txt.match(c.re);
  141. if (m) contentIssues.push({ file: `${rel}${e.name}`, check: c.name, hit: m[0].slice(0, 60), hint: c.hint });
  142. }
  143. }
  144. })(stageDir);
  145. if (contentIssues.length) {
  146. console.log('\n✗ 发布包内容体检不通过,已中止:');
  147. for (const i of contentIssues) {
  148. console.log(` [${i.check}] ${i.file}`);
  149. console.log(` 命中: ${i.hit}`);
  150. console.log(` 处理: ${i.hint}`);
  151. }
  152. process.exit(1);
  153. }
  154. // 6. 文件类型白名单校验(SkillHub 有严格限制,提前拦住)
  155. const ALLOWED_EXT = new Set([
  156. '.md', '.json', '.js', '.mjs', '.cjs', '.ts', '.py', '.sh',
  157. '.html', '.htm', '.css', '.txt', '.yaml', '.yml',
  158. '.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif',
  159. ]);
  160. const badExt = [];
  161. (function scan(d, rel = '') {
  162. for (const e of fs.readdirSync(d, { withFileTypes: true })) {
  163. const p = path.join(d, e.name);
  164. if (e.isDirectory()) { scan(p, `${rel}${e.name}/`); continue; }
  165. const ext = path.extname(e.name).toLowerCase();
  166. if (!ext || !ALLOWED_EXT.has(ext)) badExt.push(`${rel}${e.name}`);
  167. }
  168. })(stageDir);
  169. if (badExt.length) {
  170. console.log('\n✗ 发现 SkillHub 不支持的文件类型,已中止打包:');
  171. for (const f of badExt) console.log(` ${f}`);
  172. console.log('\n处理建议:');
  173. console.log(' .j2 → .template.html (Jinja2 模板改扩展名,同步改引用)');
  174. console.log(' LICENSE(无扩展名)→ LICENSE.md');
  175. process.exit(1);
  176. }
  177. // 7. 打 zip
  178. const zipName = `${pkg.name || 'fmode-harness'}-${stamp}.zip`;
  179. const zipPath = path.join(RELEASE, zipName);
  180. fs.rmSync(zipPath, { force: true });
  181. try {
  182. execFileSync('zip', ['-rq', zipName, '.'], { cwd: stageDir, stdio: 'pipe' });
  183. } catch (e) {
  184. // 无 zip 命令,用 Node 的 python 兜底
  185. try {
  186. execFileSync('python3', ['-c', `
  187. import zipfile, os
  188. z = zipfile.ZipFile(${JSON.stringify(zipPath)}, 'w', zipfile.ZIP_DEFLATED)
  189. for root, dirs, fs in os.walk(${JSON.stringify(stageDir)}):
  190. for f in fs:
  191. p = os.path.join(root, f)
  192. z.write(p, os.path.relpath(p, ${JSON.stringify(stageDir)}))
  193. z.close()
  194. `], { stdio: 'pipe' });
  195. } catch (e2) {
  196. console.error('打包失败:无 zip 命令且 python 兜底也失败');
  197. process.exit(1);
  198. }
  199. }
  200. const size = fs.statSync(zipPath).size;
  201. fs.rmSync(stageDir, { recursive: true, force: true });
  202. console.log(`\n${C.b}发布包已生成${C.x}\n`);
  203. console.log(` 文件: ${zipPath}`);
  204. console.log(` 大小: ${(size / 1024).toFixed(1)} KB`);
  205. console.log(` 技能: ${skills.length} 个 — ${skills.join(', ')}`);
  206. console.log(` 文件数: ${files.length}`);
  207. console.log(` 敏感自检: ${leaks === 0 ? `${C.g}通过(0 处)${C.x}` : `${C.r}${leaks} 处需处理${C.x}`}`);
  208. console.log(`\n${C.d}上传:SkillHub 后台 → 上传技能包 → 选此 zip${C.x}\n`);
  209. process.exit(leaks > 0 ? 1 : 0);