publish.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. /**
  2. * publish.mjs — Storage 主 + Gogs 降级/并行 双通道发布
  3. *
  4. * 1. Storage(主):复用 skill-storage uploader.mjs(诚实 4 级凭据链)put 报告.html
  5. * → 公开 URL(study-report/<姓名>-<日期>/ 前缀)
  6. * 2. Gogs(降级/并行):用自检拿到的 token 建 study-report-<姓名拼音> 仓 → push 报告+素材
  7. * (建仓走 07-git.md 已验证链路:云函数幂等 token → Gogs API/web 建仓 → HTTPS push)
  8. * 3. ZIP:打包报告目录 → Storage put 到 downloads/skill-study-report.zip(CDN 通道探测,
  9. * fmode.cn/downloads/ 映射未上线时报告为 Storage 直链,不伪造 CDN URL)
  10. *
  11. * 07-git.md 纪律:凭据零暴露 / 不伪造成功 / 状态门禁分别报告(Storage/Gogs/ZIP 三个独立状态)。
  12. *
  13. * 用法:node publish.mjs --dir <报告目录> [--name 姓名] [--gogs-token <自检所得token>]
  14. * [--gogs-user <gogs用户名>] [--no-gogs] [--no-storage]
  15. * 输出:JSON { storage: {...}, gogs: {...}, zip: {...} }(每通道独立 ok 状态)
  16. */
  17. import fs from 'node:fs';
  18. import path from 'node:path';
  19. import os from 'node:os';
  20. import { execFileSync } from 'node:child_process';
  21. const FMODE_API_BASE = (process.env.FMODE_FUNCTIONS_BASE_URL || 'https://server.fmode.cn').replace(/\/$/, '');
  22. const GOGS_BASE = 'https://git.fmode.cn';
  23. const UPLOADER = process.env.SKILL_STORAGE_UPLOADER
  24. || '/opt/data/git-repos/fmode/skill-storage/skills/fmode-storage/scripts/uploader.mjs';
  25. function argVal(flag, dflt) {
  26. const i = process.argv.indexOf(flag);
  27. return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
  28. }
  29. function hasFlag(flag) { return process.argv.includes(flag); }
  30. function sh(cmd, args, opts = {}) {
  31. return execFileSync(cmd, args, { encoding: 'utf8', timeout: 180000, ...opts });
  32. }
  33. /** 汉字姓名 → 拼音仓库名(无拼音库,取用户名/uid 兜底,稳定即可) */
  34. export function repoNameFor(name) {
  35. const pinyinMap = { 刘: 'liu', 雨: 'yu', 飏: 'yang' };
  36. let py = '';
  37. for (const ch of String(name)) py += pinyinMap[ch] || '';
  38. if (py) return `study-report-${py}`;
  39. const ascii = String(name).toLowerCase().replace(/[^a-z0-9]/g, '');
  40. const uid = (process.env.USER || 'user').toLowerCase().replace(/[^a-z0-9]/g, '');
  41. return `study-report-${ascii || uid || 'agent'}`;
  42. }
  43. /* ── 通道 1:Storage(主)────────────────────────────────── */
  44. export async function publishStorage(reportPath, name, dateTag) {
  45. if (!fs.existsSync(UPLOADER)) {
  46. return { ok: false, degraded: true, reason: `skill-storage uploader 未找到:${UPLOADER}(设 SKILL_STORAGE_UPLOADER 指向)` };
  47. }
  48. const key = `study-report/${name}-${dateTag}/report.html`;
  49. try {
  50. const out = sh(process.execPath, [UPLOADER, 'put', reportPath, '--key', key], { timeout: 300000 });
  51. const jsonStart = out.indexOf('{');
  52. if (jsonStart < 0) throw new Error('uploader 输出非 JSON:' + out.slice(0, 120));
  53. const parsed = JSON.parse(out.slice(jsonStart));
  54. if (!parsed.ok || !parsed.url) throw new Error('uploader 未返回 ok/url');
  55. /* 公开可达性验证——不伪造成功 */
  56. const head = await fetch(parsed.url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
  57. if (!head.ok) throw new Error(`上传后 HEAD 验证 ${head.status}`);
  58. return { ok: true, url: parsed.url, key, via: parsed.via || null };
  59. } catch (e) {
  60. return { ok: false, degraded: true, reason: String(e.message || e).slice(0, 200) };
  61. }
  62. }
  63. /* ── 通道 2:Gogs(降级/并行)────────────────────────────── */
  64. /**
  65. * 幂等拿 Gogs token:优先用自检传入;否则重调云函数(07-git.md:忘密码不找回,重调即得)。
  66. * token 口径与 precheck 一致:云函数要 Parse sessionToken(非 sk- API token)。
  67. * email 用 username 派生(email 冲突会让云函数"重置内部密码失败"→502)。
  68. */
  69. async function ensureGogsToken() {
  70. const userCfgPaths = [path.join(os.homedir(), '.fmode', 'config', 'user.json'), '/opt/data/home/.fmode/config/user.json'];
  71. let platformToken = process.env.FMODE_SESSION_TOKEN && !/^sk-/.test(process.env.FMODE_SESSION_TOKEN)
  72. ? process.env.FMODE_SESSION_TOKEN.trim() : null;
  73. for (const p of userCfgPaths) {
  74. if (platformToken) break;
  75. try {
  76. const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
  77. platformToken = cfg.sessionToken || null;
  78. } catch { /* next */ }
  79. }
  80. if (!platformToken) return { ok: false, reason: '无平台 sessionToken(precheck 环节1 未通过)' };
  81. const mobile = process.env.FMODE_MOBILE || 'user';
  82. const username = `fmode-${mobile}`;
  83. try {
  84. const res = await fetch(`${FMODE_API_BASE}/api/functions`, {
  85. method: 'POST',
  86. headers: { 'Content-Type': 'application/json' },
  87. signal: AbortSignal.timeout(20000),
  88. body: JSON.stringify({
  89. token: platformToken,
  90. path: '/gogs/admin/proxy',
  91. params: { method: 'POST', path: '/admin/users', body: { username, email: process.env.FMODE_EMAIL || `${username}@fmode.agent`, send_notify: false } },
  92. }),
  93. });
  94. const body = await res.json().catch(() => ({}));
  95. if ((res.status === 200 || res.status === 201 || res.status === 502) && body.token && body.token.sha1) {
  96. return { ok: true, token: body.token.sha1, user: body.username };
  97. }
  98. return { ok: false, reason: `云函数 ${res.status}:${JSON.stringify(body).slice(0, 120)}` };
  99. } catch (e) {
  100. return { ok: false, reason: String(e.message || e).slice(0, 160) };
  101. }
  102. }
  103. /** 幂等建仓:Gogs API(token)→ 已存在(409)复用 → 401 时回落 web 表单(fmode org 场景) */
  104. async function ensureRepo(gogsToken, gogsUser, repoName, description) {
  105. /* 先探测是否已存在 */
  106. try {
  107. const probe = await fetch(`${GOGS_BASE}/api/v1/repos/${gogsUser}/${repoName}`, {
  108. headers: { Authorization: `token ${gogsToken}` }, signal: AbortSignal.timeout(15000),
  109. });
  110. if (probe.ok) return { ok: true, fullName: `${gogsUser}/${repoName}`, reused: true, ownerType: 'user' };
  111. } catch { /* continue to create */ }
  112. /* 用户名下建仓 */
  113. const res = await fetch(`${GOGS_BASE}/api/v1/user/repos`, {
  114. method: 'POST',
  115. headers: { Authorization: `token ${gogsToken}`, 'Content-Type': 'application/json' },
  116. signal: AbortSignal.timeout(20000),
  117. body: JSON.stringify({ name: repoName, private: false, description, auto_init: false }),
  118. });
  119. if (res.ok) {
  120. const body = await res.json().catch(() => ({}));
  121. return { ok: true, fullName: body.full_name || `${gogsUser}/${repoName}`, reused: false, ownerType: 'user' };
  122. }
  123. if (res.status === 409) return { ok: true, fullName: `${gogsUser}/${repoName}`, reused: true, ownerType: 'user' };
  124. return { ok: false, status: res.status, reason: `建仓失败 ${res.status}` };
  125. }
  126. /** push 报告目录到 Gogs(凭据不进 remote URL 持久层——临时 URL 仅进程内使用) */
  127. export function pushToGogs(repoFullName, gogsToken, gogsUser, dir, commitMsg) {
  128. const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sr-gogs-'));
  129. try {
  130. /* 复制报告目录(排除大素材),init + commit + push */
  131. sh('cp', ['-r', `${dir}/.`, workDir]);
  132. const gitDir = path.join(workDir, '.git');
  133. fs.rmSync(gitDir, { recursive: true, force: true }); // 报告目录若自带 .git 则去掉
  134. sh('git', ['init', '-q', '-b', 'main'], { cwd: workDir });
  135. sh('git', ['config', 'user.email', process.env.FMODE_EMAIL || 'agent@fmode.cn'], { cwd: workDir });
  136. sh('git', ['config', 'user.name', 'study-report-agent'], { cwd: workDir });
  137. fs.writeFileSync(path.join(workDir, '.gitignore'), 'node_modules/\n.DS_Store\n*.log\n');
  138. sh('git', ['add', '-A'], { cwd: workDir });
  139. sh('git', ['commit', '-q', '-m', commitMsg], { cwd: workDir });
  140. /* 一次性 URL:凭据只存在于本次 push 的进程参数中,不写入任何持久文件 */
  141. const url = `https://${gogsUser}:${gogsToken}@${GOGS_BASE.replace('https://', '')}/${repoFullName}.git`;
  142. /* 幂等重跑:远端已有历史 → fetch 后以远端为基叠加本次报告(不强推、不删历史,07-git 纪律) */
  143. let pushArgs = ['push', '-q', url, 'HEAD:main'];
  144. try {
  145. sh('git', ['fetch', '-q', url, 'main'], { cwd: workDir });
  146. sh('git', ['reset', '-q', '--soft', 'FETCH_HEAD'], { cwd: workDir });
  147. /* 远端历史之上重放工作区内容:相同内容 → 无新 commit 可跳过;有变化 → 新 commit */
  148. const staged = sh('git', ['status', '--porcelain'], { cwd: workDir }).trim();
  149. if (staged) {
  150. sh('git', ['add', '-A'], { cwd: workDir });
  151. sh('git', ['commit', '-q', '-m', commitMsg], { cwd: workDir });
  152. }
  153. pushArgs = ['push', '-q', url, 'HEAD:main'];
  154. } catch { /* 远端为空仓:直接首推 */ }
  155. sh('git', pushArgs, { cwd: workDir });
  156. /* ls-remote 验证(07-git.md:验证远端 SHA) */
  157. const remote = sh('git', ['ls-remote', url, 'HEAD'], { cwd: workDir }).trim();
  158. const sha = remote.split('\t')[0] || null;
  159. return { ok: true, repo: `https://${GOGS_BASE.replace('https://', '')}/${repoFullName}`, commit: sha };
  160. } catch (e) {
  161. return { ok: false, reason: String(e.message || e).slice(0, 300) };
  162. } finally {
  163. fs.rmSync(workDir, { recursive: true, force: true });
  164. }
  165. }
  166. export async function publishGogs({ dir, name, gogsToken = null, gogsUser = null }) {
  167. try {
  168. let token = gots => gots;
  169. let tokenRes = { ok: !!(gogsToken && gogsUser), token: gogsToken, user: gogsUser };
  170. if (!tokenRes.ok) {
  171. tokenRes = await ensureGogsToken();
  172. if (!tokenRes.ok) return { ok: false, degraded: true, reason: tokenRes.reason };
  173. }
  174. const repoName = repoNameFor(name);
  175. const repo = await ensureRepo(tokenRes.token, tokenRes.user, repoName, `${name} · 48h Agent 学习复盘报告(skill-study-report 自动发布)`);
  176. if (!repo.ok) return { ok: false, degraded: true, reason: repo.reason };
  177. void token;
  178. const pushed = pushToGogs(repo.fullName, tokenRes.token, tokenRes.user, dir,
  179. `feat: ${name} 学习复盘报告(自动发布 ${new Date().toISOString().slice(0, 10)})`);
  180. if (!pushed.ok) return { ok: false, degraded: true, reason: pushed.reason, repo: repo.fullName };
  181. return {
  182. ok: true,
  183. repo: pushed.repo,
  184. commit: pushed.commit,
  185. reportUrl: `${pushed.repo}/blob/main/report.html`,
  186. reused: repo.reused,
  187. };
  188. } catch (e) {
  189. return { ok: false, degraded: true, reason: String(e.message || e).slice(0, 200) };
  190. }
  191. }
  192. /* ── 通道 3:ZIP → Storage downloads/(CDN 通道)──────────── */
  193. export async function publishZip(dir, name, dateTag) {
  194. const zipBase = `/tmp/study-report-${name}-${dateTag}`;
  195. const zipPath = `${zipBase}.zip`;
  196. try {
  197. fs.rmSync(zipPath, { force: true });
  198. const workZipDir = path.dirname(zipPath);
  199. sh('cp', ['-r', dir, zipBase]);
  200. fs.rmSync(path.join(zipBase, 'report-data.json'), { force: true }); // ZIP 只带可复现壳
  201. try {
  202. sh('zip', ['-qr', zipPath, path.basename(zipBase)], { cwd: workZipDir });
  203. } catch {
  204. /* zip 不在 PATH → python3 zipfile 回落(幂等产物) */
  205. sh('python3', ['-c',
  206. `import shutil; shutil.make_archive(${JSON.stringify(zipPath.replace(/\.zip$/, ''))}, 'zip', ${JSON.stringify(workZipDir)}, ${JSON.stringify(path.basename(zipBase))})`]);
  207. }
  208. fs.rmSync(zipBase, { recursive: true, force: true });
  209. if (!fs.existsSync(UPLOADER)) {
  210. return { ok: false, degraded: true, localFile: zipPath, reason: 'uploader 未找到——ZIP 留本地' };
  211. }
  212. const key = `downloads/${path.basename(zipPath)}`;
  213. const out = sh(process.execPath, [UPLOADER, 'put', zipPath, '--key', key], { timeout: 300000 });
  214. const jsonStart = out.indexOf('{');
  215. const parsed = jsonStart >= 0 ? JSON.parse(out.slice(jsonStart)) : null;
  216. if (!parsed || !parsed.ok) throw new Error('ZIP 上传失败');
  217. /* CDN 探测:fmode.cn/downloads/ 是否映射(官方源站配置后自动生效) */
  218. let cdnOk = false;
  219. try {
  220. const head = await fetch(`https://fmode.cn/${key}`, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
  221. cdnOk = head.ok;
  222. } catch { cdnOk = false; }
  223. /* 直链可达性 */
  224. let directOk = false;
  225. try {
  226. const head = await fetch(parsed.url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
  227. directOk = head.ok;
  228. } catch { directOk = false; }
  229. return {
  230. ok: directOk || cdnOk,
  231. cdnUrl: cdnOk ? `https://fmode.cn/${key}` : null,
  232. directUrl: directOk ? parsed.url : null,
  233. cdnMapped: cdnOk,
  234. localFile: zipPath,
  235. note: cdnOk ? 'CDN 已映射' : 'CDN 未映射(fmode.cn/downloads/ 官方源站未配置),用 Storage 直链',
  236. };
  237. } catch (e) {
  238. return { ok: false, degraded: true, reason: String(e.message || e).slice(0, 200) };
  239. }
  240. }
  241. /* ── 主流程 ─────────────────────────────────────────────── */
  242. const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
  243. if (isMain) {
  244. const dir = argVal('--dir', null);
  245. if (!dir || !fs.existsSync(path.join(dir, 'report.html'))) {
  246. console.error('[publish] 缺 --dir <报告目录>(须含 report.html,先跑 build-report.mjs)');
  247. process.exit(2);
  248. }
  249. const name = argVal('--name', (process.env.USER || 'agent'));
  250. const dateTag = new Date().toISOString().slice(0, 10);
  251. (async () => {
  252. const result = { name, dateTag };
  253. if (!hasFlag('--no-storage')) {
  254. console.log('[publish] 通道1 Storage(主)上传中…');
  255. result.storage = await publishStorage(path.join(dir, 'report.html'), name, dateTag);
  256. console.log(` → ${result.storage.ok ? '✅ ' + result.storage.url : '🟡 降级:' + result.storage.reason}`);
  257. }
  258. if (!hasFlag('--no-gogs')) {
  259. console.log('[publish] 通道2 Gogs(降级/并行)…');
  260. result.gogs = await publishGogs({
  261. dir, name,
  262. gogsToken: argVal('--gogs-token', null),
  263. gogsUser: argVal('--gogs-user', null),
  264. });
  265. console.log(` → ${result.gogs.ok ? '✅ ' + result.gogs.repo : '🟡 降级:' + result.gogs.reason}`);
  266. }
  267. console.log('[publish] 通道3 ZIP → Storage downloads/(CDN 探测)…');
  268. result.zip = await publishZip(dir, name, dateTag);
  269. console.log(` → ${result.zip.ok
  270. ? (result.zip.cdnMapped ? '✅ CDN: ' + result.zip.cdnUrl : '✅ 直链: ' + result.zip.directUrl)
  271. : '🟡 ' + (result.zip.reason || '失败')}`);
  272. /* 状态门禁分别报告 */
  273. const anyOk = [result.storage, result.gogs, result.zip].filter(Boolean).some(r => r.ok);
  274. fs.writeFileSync(path.join(dir, 'publish-result.json'), JSON.stringify(result, null, 2));
  275. console.log('\n发布状态门禁:');
  276. console.log(` Storage: ${result.storage ? (result.storage.ok ? '✅ 已发布' : '🟡 未发布(' + result.storage.reason.slice(0, 40) + ')') : '⏭ 跳过'}`);
  277. console.log(` Gogs: ${result.gogs ? (result.gogs.ok ? '✅ 已归档 ' + result.gogs.repo : '🟡 未归档') : '⏭ 跳过'}`);
  278. console.log(` ZIP: ${result.zip.ok ? (result.zip.cdnMapped ? '✅ CDN 分发' : '✅ Storage 直链') : '🟡 未分发'}`);
  279. process.exitCode = anyOk ? 0 : 2;
  280. })().catch(e => {
  281. console.error('[publish] 异常退出:', e.message);
  282. process.exit(2);
  283. });
  284. }