precheck.mjs 15 KB

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