precheck.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. /**
  2. * precheck.mjs — 安装前置自检(安装体检表)
  3. *
  4. * 按 07-git.md 已验证链路检查三环节,任一环节不通给出恢复指引,绝不伪造通过:
  5. * 1. 平台身份:~/.fmode/config/user.json 的 fmodeApiToken(真实字段名,非 sessionToken)
  6. * → POST server.fmode.cn/api/functions HEAD 探测平台可达
  7. * 2. Git 账户:POST /api/functions {token, path:"/gogs/admin/proxy",
  8. * params:{method:"POST", path:"/admin/users", body:{...}}}
  9. * → 201 新建 / 200 幂等返回新 token.sha1(忘密码不找回,重调即得)
  10. * → 401/209 → 提示走「对话式验证码登录」恢复(07-git.md 标准节)
  11. * 3. Storage 能力:探测 obsutil config / OBS_AK 环境变量;无 → 不阻塞(报告走 Gogs 降级)
  12. *
  13. * 纪律:凭据零暴露(输出绝不含 token/AK/SK 本体);结果只报状态不造假。
  14. *
  15. * 用法:node precheck.mjs [--json]
  16. * --json 供上游脚本消费(含 gogsToken,仅内存传递,不落盘)
  17. */
  18. import fs from 'node:fs';
  19. import path from 'node:path';
  20. import os from 'node:os';
  21. import { execFileSync } from 'node:child_process';
  22. const FMODE_API_BASE = (process.env.FMODE_FUNCTIONS_BASE_URL || 'https://server.fmode.cn').replace(/\/$/, '');
  23. const GOGS_BASE = 'https://git.fmode.cn';
  24. function readJsonMaybe(p) {
  25. try {
  26. if (!p || !fs.existsSync(p)) return null;
  27. return JSON.parse(fs.readFileSync(p, 'utf-8').replace(/^/, ''));
  28. } catch {
  29. return null;
  30. }
  31. }
  32. /* ── 环节 1:平台身份 ─────────────────────────────────────────── */
  33. function checkPlatformIdentity() {
  34. const row = { item: '平台身份', ok: false, detail: '', hint: '' };
  35. const candidates = [
  36. process.env.FMODE_USER_CONFIG,
  37. path.join(os.homedir(), '.fmode', 'config', 'user.json'),
  38. path.join('/opt/data/home', '.fmode', 'config', 'user.json'),
  39. ].filter(Boolean);
  40. let token = null;
  41. let cfgPath = null;
  42. let tokenKind = null;
  43. for (const p of candidates) {
  44. const cfg = readJsonMaybe(p);
  45. if (cfg && cfg.fmodeApiToken) { token = cfg.fmodeApiToken; cfgPath = p; tokenKind = 'fmodeApiToken'; break; }
  46. }
  47. // 云函数 /gogs/admin/proxy 的 token 口径是 Parse sessionToken(34 位),非 sk- API token。
  48. // 两者都在场时 sessionToken 优先供 Git 环节使用,fmodeApiToken 仅作平台身份在场证明。
  49. let sessionToken = process.env.FMODE_SESSION_TOKEN && !/^sk-/.test(process.env.FMODE_SESSION_TOKEN)
  50. ? process.env.FMODE_SESSION_TOKEN.trim() : null;
  51. for (const p of candidates) {
  52. const cfg = readJsonMaybe(p);
  53. if (!sessionToken && cfg && cfg.sessionToken) { sessionToken = cfg.sessionToken; cfgPath = p; tokenKind = 'sessionToken'; break; }
  54. }
  55. if (!token && !sessionToken) {
  56. row.detail = '未找到 fmodeApiToken(~/.fmode/config/user.json)';
  57. row.hint = '登录 FMODE Studio 保存一次配置,或对话式验证码登录';
  58. return { row, token: null };
  59. }
  60. row.detail = `已读取(${cfgPath},${tokenKind}=${tokenKind === 'fmodeApiToken' && token ? 'sk-***' + token.slice(-4) : '***' + String(sessionToken || '').slice(-4)})`;
  61. return { row, token: sessionToken || token, tokenKind };
  62. }
  63. async function probePlatformReachable() {
  64. // HEAD 探测平台可达(任意 4xx/5xx 响应头均证明链路通)
  65. try {
  66. const res = await fetch(`${FMODE_API_BASE}/api/functions`, { method: 'HEAD', signal: AbortSignal.timeout(8000) });
  67. return { reachable: true, status: res.status };
  68. } catch {
  69. try {
  70. const res = await fetch(`${FMODE_API_BASE}/parse/health`, { signal: AbortSignal.timeout(8000) });
  71. return { reachable: res.ok, status: res.status };
  72. } catch (e) {
  73. return { reachable: false, status: 0, error: String(e.message || e) };
  74. }
  75. }
  76. }
  77. /* ── 环节 2:Git 账户(07-git.md 已验证链路)──────────────────── */
  78. async function ensureGitAccount(platformToken) {
  79. const row = { item: 'Git 账户', ok: false, detail: '', hint: '' };
  80. if (!platformToken) {
  81. row.detail = '跳过(无平台 token)';
  82. row.hint = '先恢复平台身份';
  83. return { row, gogsToken: null, gogsUser: null };
  84. }
  85. const mobile = process.env.FMODE_MOBILE || 'user'; // 稳定用户标识,来自环境或默认
  86. const username = `fmode-${mobile}`;
  87. try {
  88. const res = await fetch(`${FMODE_API_BASE}/api/functions`, {
  89. method: 'POST',
  90. headers: { 'Content-Type': 'application/json' },
  91. signal: AbortSignal.timeout(20000),
  92. body: JSON.stringify({
  93. token: platformToken,
  94. path: '/gogs/admin/proxy',
  95. params: {
  96. method: 'POST',
  97. path: '/admin/users',
  98. body: {
  99. username,
  100. // email 用 username 派生:与既有账户 email 冲突会让云函数"重置内部密码失败"(502)。
  101. // 422 实测:admin/users 对 email 唯一性敏感;派生值保证幂等重调永远命中同一账户。
  102. email: process.env.FMODE_EMAIL || `${username}@fmode.agent`,
  103. send_notify: false,
  104. // 注:已存在账户 → 云函数幂等补发新 access token(200 + token.sha1),
  105. // 原密码不可找回也不需要;新账户(201)由云函数代管初始密码。
  106. },
  107. },
  108. }),
  109. });
  110. const body = await res.json().catch(() => ({}));
  111. if (res.status === 401 || res.status === 209) {
  112. row.detail = `平台返回 ${res.status}(token 失效/未登录)`;
  113. row.hint = '走「对话式验证码登录」恢复:手机号 → VerifyCode → 换 sessionToken(07-git.md 标准节)';
  114. return { row, gogsToken: null, gogsUser: null };
  115. }
  116. if (res.status === 201 && body.username) {
  117. // 新建账户:201 只返回用户对象,token 由云函数侧续发一次幂等调用取得
  118. const again = await fetch(`${FMODE_API_BASE}/api/functions`, {
  119. method: 'POST',
  120. headers: { 'Content-Type': 'application/json' },
  121. signal: AbortSignal.timeout(20000),
  122. body: JSON.stringify({
  123. token: platformToken,
  124. path: '/gogs/admin/proxy',
  125. params: { method: 'POST', path: '/admin/users', body: { username, email: process.env.FMODE_EMAIL || `${username}@fmode.agent`, send_notify: false } },
  126. }),
  127. });
  128. const againBody = await again.json().catch(() => ({}));
  129. if (again.status === 200 && againBody.token && againBody.token.sha1) {
  130. row.ok = true;
  131. row.detail = `新建 Gogs 账户 ${againBody.username},access token 已签发(sha1 ***${againBody.token.sha1.slice(-4)})`;
  132. return { row, gogsToken: againBody.token.sha1, gogsUser: againBody.username };
  133. }
  134. row.detail = `账户已创建(201)但 token 续发未返回(${again.status})`;
  135. row.hint = '重跑本自检(幂等)';
  136. return { row, gogsToken: null, gogsUser: body.username };
  137. }
  138. if ((res.status === 200 || res.status === 502) && body.token && body.token.sha1) {
  139. row.ok = true;
  140. row.detail = `Git 账户已存在(${body.username}),幂等补发新 access token(sha1 ***${body.token.sha1.slice(-4)})${res.status === 502 ? '(云函数 502 但返回体有效)' : ''}`;
  141. return { row, gogsToken: body.token.sha1, gogsUser: body.username };
  142. }
  143. /* email 冲突等 4xx → 换稳定派生 email 重试一次(幂等口径不变) */
  144. if (res.status >= 400 && res.status < 500 && /email/i.test(JSON.stringify(body))) {
  145. const altEmail = `${username}@fmode.agent`;
  146. const retry = await fetch(`${FMODE_API_BASE}/api/functions`, {
  147. method: 'POST',
  148. headers: { 'Content-Type': 'application/json' },
  149. signal: AbortSignal.timeout(20000),
  150. body: JSON.stringify({
  151. token: platformToken,
  152. path: '/gogs/admin/proxy',
  153. params: { method: 'POST', path: '/admin/users', body: { username, email: altEmail, send_notify: false } },
  154. }),
  155. });
  156. const retryBody = await retry.json().catch(() => ({}));
  157. if ((retry.status === 200 || retry.status === 201 || retry.status === 502) && retryBody.token && retryBody.token.sha1) {
  158. row.ok = true;
  159. row.detail = `Git 账户就绪(${retryBody.username},email 冲突已换 ${altEmail}),token 补发成功(sha1 ***${retryBody.token.sha1.slice(-4)})`;
  160. return { row, gogsToken: retryBody.token.sha1, gogsUser: retryBody.username };
  161. }
  162. }
  163. row.detail = `未预期的响应 ${res.status}:${JSON.stringify(body).slice(0, 120)}`;
  164. row.hint = '重跑自检;持续失败报告用户,不伪造通过';
  165. return { row, gogsToken: null, gogsUser: null };
  166. } catch (e) {
  167. row.detail = `网络/服务异常:${String(e.message || e).slice(0, 120)}`;
  168. row.hint = '检查 server.fmode.cn 可达性后重跑';
  169. return { row, gogsToken: null, gogsUser: null };
  170. }
  171. }
  172. /* ── 环节 3:Storage 能力(不阻塞)──────────────────────────── */
  173. function checkStorage() {
  174. const row = { item: 'Storage 能力', ok: false, degraded: true, detail: '', hint: '' };
  175. // 3a. 环境变量
  176. if (process.env.OBS_AK && process.env.OBS_SK) {
  177. row.ok = true; row.degraded = false;
  178. row.detail = 'OBS_AK/OBS_SK 环境变量已配置';
  179. return { row };
  180. }
  181. // 3b. obsutil config 文件(OBSUTIL_CONFIG / ~/.obsutilconfig / getpwuid home 变体)
  182. const cfgPaths = [];
  183. if (process.env.OBSUTIL_CONFIG) cfgPaths.push(process.env.OBSUTIL_CONFIG);
  184. cfgPaths.push(path.join(os.homedir(), '.obsutilconfig'), path.join('/opt/data', '.obsutilconfig'));
  185. try {
  186. const pwHome = os.userInfo().homedir;
  187. if (pwHome) cfgPaths.push(path.join(pwHome, '.obsutilconfig'));
  188. } catch { /* ignore */ }
  189. for (const p of [...new Set(cfgPaths)]) {
  190. try {
  191. const text = fs.readFileSync(p, 'utf-8');
  192. if (/^ak=/m.test(text) && /^sk=/m.test(text)) {
  193. row.ok = true; row.degraded = false;
  194. row.detail = `obsutil config 已配置(${p})`;
  195. return { row };
  196. }
  197. } catch { /* next */ }
  198. }
  199. // 3c. obsutil 可执行文件存在(有 config 但无二进制也算能力缺失)
  200. row.detail = '未探测到 OBS 凭据(OBS_AK/SK 环境变量 或 obsutil config)';
  201. row.hint = '不阻塞:报告走 Gogs 降级通道;如需 Storage 主通道,按 skill-storage init 向导一次性配置';
  202. return { row };
  203. }
  204. /* ── 主流程 ─────────────────────────────────────────────────── */
  205. export async function runPrecheck({ json = false } = {}) {
  206. const { row: idRow, token } = checkPlatformIdentity();
  207. const reach = token ? await probePlatformReachable() : { reachable: false, status: 0 };
  208. if (!reach.reachable && token) {
  209. idRow.ok = false;
  210. idRow.detail += ',但平台探测不可达';
  211. idRow.hint = '检查网络 / server.fmode.cn 状态';
  212. } else if (reach.reachable) {
  213. idRow.ok = true;
  214. idRow.detail += `;平台可达(探测 ${reach.status})`;
  215. }
  216. const git = await ensureGitAccount(reach.reachable ? token : null);
  217. const storage = checkStorage();
  218. const rows = [idRow, git.row, storage.row];
  219. const passAll = rows.every(r => r.ok);
  220. if (json) {
  221. // gogsToken 仅随进程内存/管道传递,禁止落盘
  222. process.stdout.write(JSON.stringify({
  223. passAll,
  224. rows,
  225. gogsToken: git.gogsToken,
  226. gogsUser: git.gogsUser,
  227. }) + '\n');
  228. return { passAll, rows, gogsToken: git.gogsToken, gogsUser: git.gogsUser };
  229. }
  230. /* 安装体检表(人类可读,凭据零暴露) */
  231. const line = '─'.repeat(66);
  232. console.log('┌' + line + '┐');
  233. console.log('│ study-report · 安装体检表' + ' '.repeat(40) + '│');
  234. console.log('├' + line + '┤');
  235. for (const r of rows) {
  236. const mark = r.ok ? '✅' : (r.degraded ? '🟡' : '❌');
  237. const name = (r.item + ' ').padEnd(10, '·');
  238. console.log(`│ ${mark} ${name} ${r.detail}`.slice(0, 66 + 2).padEnd(67) + '│');
  239. if (!r.ok && r.hint) {
  240. for (const ln of wrapHint(r.hint, 58)) {
  241. console.log(`│ ${ln}`.padEnd(67) + '│');
  242. }
  243. }
  244. }
  245. console.log('├' + line + '┤');
  246. console.log(`│ 结论:${passAll ? '全部就绪,可直接执行完整流程' : '存在未就绪环节——按上方指引恢复后重跑'}${' '.repeat(8)}│`);
  247. console.log('└' + line + '┘');
  248. return { passAll, rows, gogsToken: git.gogsToken, gogsUser: git.gogsUser };
  249. }
  250. function wrapHint(text, width) {
  251. const out = [];
  252. let cur = '';
  253. for (const ch of text) {
  254. cur += ch;
  255. if (cur.length >= width) { out.push(cur); cur = ''; }
  256. }
  257. if (cur) out.push(cur);
  258. return out;
  259. }
  260. /* ── CLI ────────────────────────────────────────────────────── */
  261. const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
  262. if (isMain) {
  263. runPrecheck({ json: process.argv.includes('--json') }).then(r => {
  264. process.exitCode = r.passAll ? 0 : 1;
  265. }).catch(e => {
  266. console.error('[precheck] 异常退出:', e.message);
  267. process.exit(2);
  268. });
  269. }