| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- /**
- * publish.mjs — Storage 主 + Gogs 降级/并行 双通道发布
- *
- * 1. Storage(主):复用 skill-storage uploader.mjs(诚实 4 级凭据链)put 报告.html
- * → 公开 URL(study-report/<姓名>-<日期>/ 前缀)
- * 2. Gogs(降级/并行):用自检拿到的 token 建 study-report-<姓名拼音> 仓 → push 报告+素材
- * (建仓走 07-git.md 已验证链路:云函数幂等 token → Gogs API/web 建仓 → HTTPS push)
- * 3. ZIP:打包报告目录 → Storage put 到 downloads/skill-study-report.zip(CDN 通道探测,
- * fmode.cn/downloads/ 映射未上线时报告为 Storage 直链,不伪造 CDN URL)
- *
- * 07-git.md 纪律:凭据零暴露 / 不伪造成功 / 状态门禁分别报告(Storage/Gogs/ZIP 三个独立状态)。
- *
- * 用法:node publish.mjs --dir <报告目录> [--name 姓名] [--gogs-token <自检所得token>]
- * [--gogs-user <gogs用户名>] [--no-gogs] [--no-storage]
- * 输出:JSON { storage: {...}, gogs: {...}, zip: {...} }(每通道独立 ok 状态)
- */
- import fs from 'node:fs';
- import path from 'node:path';
- import os from 'node:os';
- import { execFileSync } from 'node:child_process';
- const FMODE_API_BASE = (process.env.FMODE_FUNCTIONS_BASE_URL || 'https://server.fmode.cn').replace(/\/$/, '');
- const GOGS_BASE = 'https://git.fmode.cn';
- const UPLOADER = process.env.SKILL_STORAGE_UPLOADER
- || '/opt/data/git-repos/fmode/skill-storage/skills/fmode-storage/scripts/uploader.mjs';
- function argVal(flag, dflt) {
- const i = process.argv.indexOf(flag);
- return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
- }
- function hasFlag(flag) { return process.argv.includes(flag); }
- function sh(cmd, args, opts = {}) {
- return execFileSync(cmd, args, { encoding: 'utf8', timeout: 180000, ...opts });
- }
- /** 汉字姓名 → 拼音仓库名(无拼音库,取用户名/uid 兜底,稳定即可) */
- export function repoNameFor(name) {
- const pinyinMap = { 刘: 'liu', 雨: 'yu', 飏: 'yang' };
- let py = '';
- for (const ch of String(name)) py += pinyinMap[ch] || '';
- if (py) return `study-report-${py}`;
- const ascii = String(name).toLowerCase().replace(/[^a-z0-9]/g, '');
- const uid = (process.env.USER || 'user').toLowerCase().replace(/[^a-z0-9]/g, '');
- return `study-report-${ascii || uid || 'agent'}`;
- }
- /* ── 通道 1:Storage(主)────────────────────────────────── */
- export async function publishStorage(reportPath, name, dateTag) {
- if (!fs.existsSync(UPLOADER)) {
- return { ok: false, degraded: true, reason: `skill-storage uploader 未找到:${UPLOADER}(设 SKILL_STORAGE_UPLOADER 指向)` };
- }
- const key = `study-report/${name}-${dateTag}/report.html`;
- try {
- const out = sh(process.execPath, [UPLOADER, 'put', reportPath, '--key', key], { timeout: 300000 });
- const jsonStart = out.indexOf('{');
- if (jsonStart < 0) throw new Error('uploader 输出非 JSON:' + out.slice(0, 120));
- const parsed = JSON.parse(out.slice(jsonStart));
- if (!parsed.ok || !parsed.url) throw new Error('uploader 未返回 ok/url');
- /* 公开可达性验证——不伪造成功 */
- const head = await fetch(parsed.url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
- if (!head.ok) throw new Error(`上传后 HEAD 验证 ${head.status}`);
- return { ok: true, url: parsed.url, key, via: parsed.via || null };
- } catch (e) {
- return { ok: false, degraded: true, reason: String(e.message || e).slice(0, 200) };
- }
- }
- /* ── 通道 2:Gogs(降级/并行)────────────────────────────── */
- /**
- * 幂等拿 Gogs token:优先用自检传入;否则重调云函数(07-git.md:忘密码不找回,重调即得)。
- * token 口径与 precheck 一致:云函数要 Parse sessionToken(非 sk- API token)。
- * email 用 username 派生(email 冲突会让云函数"重置内部密码失败"→502)。
- */
- async function ensureGogsToken() {
- const userCfgPaths = [path.join(os.homedir(), '.fmode', 'config', 'user.json'), '/opt/data/home/.fmode/config/user.json'];
- let platformToken = process.env.FMODE_SESSION_TOKEN && !/^sk-/.test(process.env.FMODE_SESSION_TOKEN)
- ? process.env.FMODE_SESSION_TOKEN.trim() : null;
- for (const p of userCfgPaths) {
- if (platformToken) break;
- try {
- const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
- platformToken = cfg.sessionToken || null;
- } catch { /* next */ }
- }
- if (!platformToken) return { ok: false, reason: '无平台 sessionToken(precheck 环节1 未通过)' };
- const mobile = process.env.FMODE_MOBILE || 'user';
- const username = `fmode-${mobile}`;
- try {
- const res = await fetch(`${FMODE_API_BASE}/api/functions`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- signal: AbortSignal.timeout(20000),
- body: JSON.stringify({
- token: platformToken,
- path: '/gogs/admin/proxy',
- params: { method: 'POST', path: '/admin/users', body: { username, email: process.env.FMODE_EMAIL || `${username}@fmode.agent`, send_notify: false } },
- }),
- });
- const body = await res.json().catch(() => ({}));
- if ((res.status === 200 || res.status === 201 || res.status === 502) && body.token && body.token.sha1) {
- return { ok: true, token: body.token.sha1, user: body.username };
- }
- return { ok: false, reason: `云函数 ${res.status}:${JSON.stringify(body).slice(0, 120)}` };
- } catch (e) {
- return { ok: false, reason: String(e.message || e).slice(0, 160) };
- }
- }
- /** 幂等建仓:Gogs API(token)→ 已存在(409)复用 → 401 时回落 web 表单(fmode org 场景) */
- async function ensureRepo(gogsToken, gogsUser, repoName, description) {
- /* 先探测是否已存在 */
- try {
- const probe = await fetch(`${GOGS_BASE}/api/v1/repos/${gogsUser}/${repoName}`, {
- headers: { Authorization: `token ${gogsToken}` }, signal: AbortSignal.timeout(15000),
- });
- if (probe.ok) return { ok: true, fullName: `${gogsUser}/${repoName}`, reused: true, ownerType: 'user' };
- } catch { /* continue to create */ }
- /* 用户名下建仓 */
- const res = await fetch(`${GOGS_BASE}/api/v1/user/repos`, {
- method: 'POST',
- headers: { Authorization: `token ${gogsToken}`, 'Content-Type': 'application/json' },
- signal: AbortSignal.timeout(20000),
- body: JSON.stringify({ name: repoName, private: false, description, auto_init: false }),
- });
- if (res.ok) {
- const body = await res.json().catch(() => ({}));
- return { ok: true, fullName: body.full_name || `${gogsUser}/${repoName}`, reused: false, ownerType: 'user' };
- }
- if (res.status === 409) return { ok: true, fullName: `${gogsUser}/${repoName}`, reused: true, ownerType: 'user' };
- return { ok: false, status: res.status, reason: `建仓失败 ${res.status}` };
- }
- /** push 报告目录到 Gogs(凭据不进 remote URL 持久层——临时 URL 仅进程内使用) */
- export function pushToGogs(repoFullName, gogsToken, gogsUser, dir, commitMsg) {
- const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sr-gogs-'));
- try {
- /* 复制报告目录(排除大素材),init + commit + push */
- sh('cp', ['-r', `${dir}/.`, workDir]);
- const gitDir = path.join(workDir, '.git');
- fs.rmSync(gitDir, { recursive: true, force: true }); // 报告目录若自带 .git 则去掉
- sh('git', ['init', '-q', '-b', 'main'], { cwd: workDir });
- sh('git', ['config', 'user.email', process.env.FMODE_EMAIL || 'agent@fmode.cn'], { cwd: workDir });
- sh('git', ['config', 'user.name', 'study-report-agent'], { cwd: workDir });
- fs.writeFileSync(path.join(workDir, '.gitignore'), 'node_modules/\n.DS_Store\n*.log\n');
- sh('git', ['add', '-A'], { cwd: workDir });
- sh('git', ['commit', '-q', '-m', commitMsg], { cwd: workDir });
- /* 一次性 URL:凭据只存在于本次 push 的进程参数中,不写入任何持久文件 */
- const url = `https://${gogsUser}:${gogsToken}@${GOGS_BASE.replace('https://', '')}/${repoFullName}.git`;
- /* 幂等重跑:远端已有历史 → fetch 后以远端为基叠加本次报告(不强推、不删历史,07-git 纪律) */
- let pushArgs = ['push', '-q', url, 'HEAD:main'];
- try {
- sh('git', ['fetch', '-q', url, 'main'], { cwd: workDir });
- sh('git', ['reset', '-q', '--soft', 'FETCH_HEAD'], { cwd: workDir });
- /* 远端历史之上重放工作区内容:相同内容 → 无新 commit 可跳过;有变化 → 新 commit */
- const staged = sh('git', ['status', '--porcelain'], { cwd: workDir }).trim();
- if (staged) {
- sh('git', ['add', '-A'], { cwd: workDir });
- sh('git', ['commit', '-q', '-m', commitMsg], { cwd: workDir });
- }
- pushArgs = ['push', '-q', url, 'HEAD:main'];
- } catch { /* 远端为空仓:直接首推 */ }
- sh('git', pushArgs, { cwd: workDir });
- /* ls-remote 验证(07-git.md:验证远端 SHA) */
- const remote = sh('git', ['ls-remote', url, 'HEAD'], { cwd: workDir }).trim();
- const sha = remote.split('\t')[0] || null;
- return { ok: true, repo: `https://${GOGS_BASE.replace('https://', '')}/${repoFullName}`, commit: sha };
- } catch (e) {
- return { ok: false, reason: String(e.message || e).slice(0, 300) };
- } finally {
- fs.rmSync(workDir, { recursive: true, force: true });
- }
- }
- export async function publishGogs({ dir, name, gogsToken = null, gogsUser = null }) {
- try {
- let token = gots => gots;
- let tokenRes = { ok: !!(gogsToken && gogsUser), token: gogsToken, user: gogsUser };
- if (!tokenRes.ok) {
- tokenRes = await ensureGogsToken();
- if (!tokenRes.ok) return { ok: false, degraded: true, reason: tokenRes.reason };
- }
- const repoName = repoNameFor(name);
- const repo = await ensureRepo(tokenRes.token, tokenRes.user, repoName, `${name} · 48h Agent 学习复盘报告(skill-study-report 自动发布)`);
- if (!repo.ok) return { ok: false, degraded: true, reason: repo.reason };
- void token;
- const pushed = pushToGogs(repo.fullName, tokenRes.token, tokenRes.user, dir,
- `feat: ${name} 学习复盘报告(自动发布 ${new Date().toISOString().slice(0, 10)})`);
- if (!pushed.ok) return { ok: false, degraded: true, reason: pushed.reason, repo: repo.fullName };
- return {
- ok: true,
- repo: pushed.repo,
- commit: pushed.commit,
- reportUrl: `${pushed.repo}/blob/main/report.html`,
- reused: repo.reused,
- };
- } catch (e) {
- return { ok: false, degraded: true, reason: String(e.message || e).slice(0, 200) };
- }
- }
- /* ── 通道 3:ZIP → Storage downloads/(CDN 通道)──────────── */
- export async function publishZip(dir, name, dateTag) {
- const zipBase = `/tmp/study-report-${name}-${dateTag}`;
- const zipPath = `${zipBase}.zip`;
- try {
- fs.rmSync(zipPath, { force: true });
- const workZipDir = path.dirname(zipPath);
- sh('cp', ['-r', dir, zipBase]);
- fs.rmSync(path.join(zipBase, 'report-data.json'), { force: true }); // ZIP 只带可复现壳
- try {
- sh('zip', ['-qr', zipPath, path.basename(zipBase)], { cwd: workZipDir });
- } catch {
- /* zip 不在 PATH → python3 zipfile 回落(幂等产物) */
- sh('python3', ['-c',
- `import shutil; shutil.make_archive(${JSON.stringify(zipPath.replace(/\.zip$/, ''))}, 'zip', ${JSON.stringify(workZipDir)}, ${JSON.stringify(path.basename(zipBase))})`]);
- }
- fs.rmSync(zipBase, { recursive: true, force: true });
- if (!fs.existsSync(UPLOADER)) {
- return { ok: false, degraded: true, localFile: zipPath, reason: 'uploader 未找到——ZIP 留本地' };
- }
- const key = `downloads/${path.basename(zipPath)}`;
- const out = sh(process.execPath, [UPLOADER, 'put', zipPath, '--key', key], { timeout: 300000 });
- const jsonStart = out.indexOf('{');
- const parsed = jsonStart >= 0 ? JSON.parse(out.slice(jsonStart)) : null;
- if (!parsed || !parsed.ok) throw new Error('ZIP 上传失败');
- /* CDN 探测:fmode.cn/downloads/ 是否映射(官方源站配置后自动生效) */
- let cdnOk = false;
- try {
- const head = await fetch(`https://fmode.cn/${key}`, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
- cdnOk = head.ok;
- } catch { cdnOk = false; }
- /* 直链可达性 */
- let directOk = false;
- try {
- const head = await fetch(parsed.url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
- directOk = head.ok;
- } catch { directOk = false; }
- return {
- ok: directOk || cdnOk,
- cdnUrl: cdnOk ? `https://fmode.cn/${key}` : null,
- directUrl: directOk ? parsed.url : null,
- cdnMapped: cdnOk,
- localFile: zipPath,
- note: cdnOk ? 'CDN 已映射' : 'CDN 未映射(fmode.cn/downloads/ 官方源站未配置),用 Storage 直链',
- };
- } catch (e) {
- return { ok: false, degraded: true, reason: String(e.message || e).slice(0, 200) };
- }
- }
- /* ── 主流程 ─────────────────────────────────────────────── */
- const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
- if (isMain) {
- const dir = argVal('--dir', null);
- if (!dir || !fs.existsSync(path.join(dir, 'report.html'))) {
- console.error('[publish] 缺 --dir <报告目录>(须含 report.html,先跑 build-report.mjs)');
- process.exit(2);
- }
- const name = argVal('--name', (process.env.USER || 'agent'));
- const dateTag = new Date().toISOString().slice(0, 10);
- (async () => {
- const result = { name, dateTag };
- if (!hasFlag('--no-storage')) {
- console.log('[publish] 通道1 Storage(主)上传中…');
- result.storage = await publishStorage(path.join(dir, 'report.html'), name, dateTag);
- console.log(` → ${result.storage.ok ? '✅ ' + result.storage.url : '🟡 降级:' + result.storage.reason}`);
- }
- if (!hasFlag('--no-gogs')) {
- console.log('[publish] 通道2 Gogs(降级/并行)…');
- result.gogs = await publishGogs({
- dir, name,
- gogsToken: argVal('--gogs-token', null),
- gogsUser: argVal('--gogs-user', null),
- });
- console.log(` → ${result.gogs.ok ? '✅ ' + result.gogs.repo : '🟡 降级:' + result.gogs.reason}`);
- }
- console.log('[publish] 通道3 ZIP → Storage downloads/(CDN 探测)…');
- result.zip = await publishZip(dir, name, dateTag);
- console.log(` → ${result.zip.ok
- ? (result.zip.cdnMapped ? '✅ CDN: ' + result.zip.cdnUrl : '✅ 直链: ' + result.zip.directUrl)
- : '🟡 ' + (result.zip.reason || '失败')}`);
- /* 状态门禁分别报告 */
- const anyOk = [result.storage, result.gogs, result.zip].filter(Boolean).some(r => r.ok);
- fs.writeFileSync(path.join(dir, 'publish-result.json'), JSON.stringify(result, null, 2));
- console.log('\n发布状态门禁:');
- console.log(` Storage: ${result.storage ? (result.storage.ok ? '✅ 已发布' : '🟡 未发布(' + result.storage.reason.slice(0, 40) + ')') : '⏭ 跳过'}`);
- console.log(` Gogs: ${result.gogs ? (result.gogs.ok ? '✅ 已归档 ' + result.gogs.repo : '🟡 未归档') : '⏭ 跳过'}`);
- console.log(` ZIP: ${result.zip.ok ? (result.zip.cdnMapped ? '✅ CDN 分发' : '✅ Storage 直链') : '🟡 未分发'}`);
- process.exitCode = anyOk ? 0 : 2;
- })().catch(e => {
- console.error('[publish] 异常退出:', e.message);
- process.exit(2);
- });
- }
|