verify-cloud-fn.mjs 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. #!/usr/bin/env node
  2. /**
  3. * 云函数线上/本地一致性校验(只读,不写线上)
  4. *
  5. * 解决的问题:线上函数被热更过,工作区源码却停在旧版本。
  6. * 直接部署旧源码会把线上已有功能**回退**(2026-11-06 实际发生过:
  7. * 线上已是 v0.1.6,工作区还停在 v0.1.5,缺 kind/memberIds)。
  8. *
  9. * 做法:不比对源码(读源码需要 Master Key,前端项目不持有),
  10. * 而是**比对行为契约**——调用线上函数,看它返回的字段集是否覆盖本源码声明的字段。
  11. *
  12. * 用法:
  13. * node scripts/verify-cloud-fn.mjs
  14. * # 需要 ~/.fmode/config/user.json 的 sessionToken(与 bind-container 同源)
  15. *
  16. * 退出码:0 = 一致;1 = 线上缺少本地声明的字段(禁止部署);2 = 无法探测
  17. */
  18. import { readFileSync } from 'node:fs';
  19. import { homedir } from 'node:os';
  20. import { join } from 'node:path';
  21. const FN_ID = 'oR5O67i1Cm';
  22. const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions';
  23. /** 本地源码声明 opList 必须返回的字段(改动 opList 时同步这里) */
  24. const EXPECTED_THREAD_FIELDS = [
  25. 'objectId',
  26. 'agentId',
  27. 'roomId',
  28. 'threadRoot',
  29. 'title',
  30. 'titleLocked',
  31. 'messageCount',
  32. 'lastMessageAt',
  33. 'archived',
  34. 'lastPreview',
  35. 'workspaceId',
  36. // v0.1.6
  37. 'kind',
  38. 'memberIds',
  39. ];
  40. function loadToken() {
  41. const p = join(homedir(), '.fmode', 'config', 'user.json');
  42. try {
  43. const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
  44. if (typeof t === 'string' && t.startsWith('r:')) return t;
  45. } catch {
  46. /* fallthrough */
  47. }
  48. console.error(`❌ 读不到 sessionToken(期望 ${p})`);
  49. process.exit(2);
  50. }
  51. const token = loadToken();
  52. async function call(params) {
  53. const res = await fetch(FUNCTIONS_URL, {
  54. method: 'POST',
  55. headers: { 'Content-Type': 'application/json' },
  56. body: JSON.stringify({ id: FN_ID, token, params }),
  57. });
  58. return res.json();
  59. }
  60. const out = await call({ op: 'list' });
  61. const threads = Array.isArray(out) ? out : out?.threads;
  62. if (!Array.isArray(threads)) {
  63. console.error('❌ 线上 list 未返回 threads:', JSON.stringify(out).slice(0, 200));
  64. process.exit(2);
  65. }
  66. if (threads.length === 0) {
  67. console.error('⚠️ 该账号下没有会话记录,无法比对字段集。请用有数据的账号再跑一次。');
  68. process.exit(2);
  69. }
  70. // 取所有记录的字段并集(空值字段在某些实现里可能被省略)
  71. const actual = new Set();
  72. for (const t of threads) for (const k of Object.keys(t)) actual.add(k);
  73. const missing = EXPECTED_THREAD_FIELDS.filter((f) => !actual.has(f));
  74. const extra = [...actual].filter((f) => !EXPECTED_THREAD_FIELDS.includes(f));
  75. console.log(`线上返回字段(${threads.length} 条记录并集):`);
  76. console.log(' ' + [...actual].sort().join(', '));
  77. if (missing.length === 0) {
  78. console.log(`\n✅ 一致:本地声明的 ${EXPECTED_THREAD_FIELDS.length} 个字段线上都有。`);
  79. if (extra.length) console.log(`ℹ️ 线上多出:${extra.join(', ')}(本地源码未声明,建议补齐)`);
  80. process.exit(0);
  81. }
  82. console.log(`\n❌ 线上缺少本地声明的字段:${missing.join(', ')}`);
  83. console.log(' → 说明工作区源码比线上新,或线上被回退。部署前请确认。');
  84. process.exit(1);