deploy.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. #!/usr/bin/env node
  2. /**
  3. * 构建 + 部署到华为云 OBS(www-ai.fmode.cn)+ 刷新 CDN(ai.fmode.cn)
  4. *
  5. * 用法:
  6. * node scripts/deploy.mjs # 完整:构建 → 校验 → 上传 → 刷新 CDN → 探活
  7. * node scripts/deploy.mjs --skip-build # 跳过构建,直接上传已有 dist
  8. * node scripts/deploy.mjs --dry-run # 只打印将要执行的动作,不执行
  9. *
  10. * 前置条件:
  11. * ① 已安装 obsutil 与**华为云 KooCLI**(注意:PATH 里的 `hcloud` 可能是 Hetzner 的同名 CLI,
  12. * 直接用 `HCLOUD_BIN` 指定完整路径);AK/SK 已内置在本脚本顶部,无需提前 configure
  13. * (`obsutil` 自己的 `~/.obsutilconfig` 仍需配好,用于 OBS 上传)
  14. * ② 构建配置 `npm run build` 可用(production)
  15. *
  16. * 🔴 与旧 deploy.ps1 的差异:
  17. * - 项目名从 `nova-aigc` 改为 `fmode-app-v2`,产物路径改为 `dist/fmode-app-v2/browser`
  18. * - 异常时不静默继续:任何一步失败即 `process.exit(1)`
  19. * - 增加构建产物自检(防 demo/mock 包误发线上)
  20. */
  21. import { execFileSync } from 'node:child_process';
  22. import { existsSync, readFileSync, readdirSync } from 'node:fs';
  23. import { dirname, join, resolve } from 'node:path';
  24. import { fileURLToPath } from 'node:url';
  25. const here = dirname(fileURLToPath(import.meta.url));
  26. const projectRoot = resolve(here, '..');
  27. const distDir = join(projectRoot, 'dist/fmode-app-v2/browser');
  28. const BUCKET = 'obs://www-ai.fmode.cn';
  29. const CDN_BASE = 'https://ai.fmode.cn';
  30. const OBS_EP = 'obs.cn-south-1.myhuaweicloud.com';
  31. const CDN_REGION = 'cn-north-1';
  32. /*
  33. * 华为云 AK/SK(2026-11-14)。
  34. *
  35. * 按 CLAUDE.md 黄金规则 9 集中存放于此(用户已裁决「Token 与密钥可明文存放」)。
  36. * 来源:`deploy.ps1`(华为云控制台创建的那一对,20 位 AK)。
  37. *
  38. * ⚠️ **不要**从 `~/.obsutilconfig` 取:那里是 OBS 的 43 位 AK,
  39. * 与本对不是同一套;KooCLI 的 `cli-access-key` 长度限制 20 位,取了会直接报错。
  40. */
  41. const HW_AK = process.env.HW_AK || '2BFF7JWXAIJ0UGNJ0OSB';
  42. const HW_SK = process.env.HW_SK || 'NaPCiJCGmD3nklCzX65s8mSK1Py13ueyhgepa0s1';
  43. const args = new Set(process.argv.slice(2));
  44. const skipBuild = args.has('--skip-build');
  45. const dryRun = args.has('--dry-run');
  46. /** 执行命令并回显 */
  47. function run(cmd, argv, opts = {}) {
  48. const printable = `${cmd} ${argv.join(' ')}`;
  49. console.log(`\n$ ${printable}`);
  50. if (dryRun) return '';
  51. try {
  52. return execFileSync(cmd, argv, { cwd: projectRoot, stdio: 'inherit', ...opts });
  53. } catch (e) {
  54. console.error(`\n❌ 命令失败:${printable}`);
  55. process.exit(1);
  56. }
  57. }
  58. /**
  59. * 探活用 Node 原生 fetch,不依赖 curl。
  60. * Windows 上的 curl 是原生程序,`-o /dev/null` 会被当成文件路径写盘,不能拿来判状态码。
  61. */
  62. async function probe(url, timeoutMs = 30_000) {
  63. const ac = new AbortController();
  64. const timer = setTimeout(() => ac.abort(), timeoutMs);
  65. try {
  66. const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
  67. return { status: res.status, body: await res.text() };
  68. } catch {
  69. return { status: 0, body: '' };
  70. } finally {
  71. clearTimeout(timer);
  72. }
  73. }
  74. // ── 1. 构建 ────────────────────────────────────────────────────────────────
  75. if (!skipBuild) {
  76. console.log('\n=== [1/5] 构建 production ===');
  77. run('npm', ['run', 'build']);
  78. } else {
  79. console.log('\n=== [1/5] 跳过构建(--skip-build)===');
  80. }
  81. // ── 2. 产物自检 ────────────────────────────────────────────────────────────
  82. console.log('\n=== [2/5] 构建产物自检 ===');
  83. if (!existsSync(distDir)) {
  84. console.error(`❌ 产物目录不存在:${distDir}`);
  85. process.exit(1);
  86. }
  87. const files = readdirSync(distDir);
  88. const indexHtml = join(distDir, 'index.html');
  89. if (!files.includes('index.html')) {
  90. console.error('❌ 缺少 index.html');
  91. process.exit(1);
  92. }
  93. const html = readFileSync(indexHtml, 'utf8');
  94. if (!/app-root/.test(html)) {
  95. console.error('❌ index.html 不含 <app-root>,产物异常');
  96. process.exit(1);
  97. }
  98. // 主包与依赖 chunk 中若含演示数据开关且为 true,说明误发了 mock 包
  99. const jsFiles = files.filter((f) => f.endsWith('.js'));
  100. let sawMockSwitch = false;
  101. for (const f of jsFiles) {
  102. const src = readFileSync(join(distDir, f), 'utf8');
  103. const m = src.match(/useMockData\s*:\s*!?[01]/);
  104. if (!m) continue;
  105. sawMockSwitch = true;
  106. if (/!\s*0/.test(m[0])) {
  107. console.error(`❌ 检测到演示数据包(${f} 中 useMockData: true),拒绝部署到线上`);
  108. console.error(' 线上请用 `npm run build`;演示包请用 `npm run build:mock` 走本地 mock 服务');
  109. process.exit(1);
  110. }
  111. }
  112. if (sawMockSwitch) {
  113. console.log(' ✓ 数据源开关 = 真实(useMockData: false)');
  114. } else {
  115. console.warn(' ⚠ 未在产物中找到 useMockData 开关(可能已被 tree-shake),跳过校验');
  116. }
  117. console.log(` ✓ index.html + ${files.length} 个文件`);
  118. // ── 3. 上传 OBS ────────────────────────────────────────────────────────────
  119. console.log('\n=== [3/5] 同步到 OBS ===');
  120. /*
  121. * 先 sync 覆盖、再清多余文件:不用先清空桶,避免「清空后上传失败」导致站点空窗。
  122. * 🔴 `obsutil sync` **没有 `-f` 参数**(2026-11-14 踩坑:`Error: flag provided but not
  123. * defined: -f`)。同步默认不询问覆盖,需要时用 `-u`(增量上传已存在文件)。
  124. * **不传 `-delete`** —— 桶里可能还有非本站资源(assets/ 等),
  125. * 本应删除的旧产物需人工确认,见 README「部署」一节。
  126. * OBS 兼容 S3 协议,AK/SK 由 obsutil 自身的配置(`obsutil config`)提供,不写在脚本里。
  127. */
  128. run('obsutil', ['sync', distDir, BUCKET, `-e=${OBS_EP}`]);
  129. run('obsutil', ['chattri', BUCKET, '-r', '-f', `-e=${OBS_EP}`, '-acl=public-read']);
  130. // ── 4. 刷新 CDN ────────────────────────────────────────────────────────────
  131. console.log('\n=== [4/5] 刷新 CDN ===');
  132. /*
  133. * 🔴 必须用**华为云 KooCLI**的完整路径(2026-11-14 踩坑)。
  134. *
  135. * PATH 里的 `hcloud` 是 **Hetzner Cloud CLI**(`hcloud 1.51.0`),与华为云
  136. * KooCLI 同名不同物。直接调 PATH 会报 `unknown flag: --cli-region`,
  137. * 而这时候 OBS 上传已经成功了 —— 只是 CDN 没刷,用户拿到的还是旧缓存。
  138. *
  139. * 可用 `HCLOUD_BIN` 环境变量覆盖。
  140. */
  141. const HCLOUD_BIN =
  142. process.env.HCLOUD_BIN || 'E:\\coding-environment\\huaweicloud-koocli\\hcloud.exe';
  143. /*
  144. * 🔴 必须同时刷新「固定文件名的资源」(2026-11-13 实测踩坑)。
  145. *
  146. * 带 hash 的 chunk(main-XXXX.js)换 hash 就等于换 URL,天然绕过 CDN 缓存。
  147. * 但下面这些是**固定文件名**,内容变了 URL 却没变 →
  148. * CDN 会一直吐旧版本,用户拿到的还是老代码:
  149. *
  150. * · pdf.worker.js —— pdfjs 升级/降级时最容易中招
  151. * (实测:换了 pdfjs 4.10 后,线上仍是 6.4 的旧 worker,
  152. * 真机 PDF 预览一直报 getOrInsertComputed 不是函数)
  153. *
  154. * 新增固定名资源时,记得同步加到这里。
  155. */
  156. const FIXED_NAME_ASSETS = ['/pdf.worker.js', '/favicon.ico'];
  157. const refreshUrls = [`${CDN_BASE}/`, `${CDN_BASE}/index.html`, ...FIXED_NAME_ASSETS.map((p) => `${CDN_BASE}${p}`)];
  158. const refreshArgs = [
  159. 'CDN',
  160. 'CreateRefreshTasks/v2',
  161. /*
  162. * 🔴 `--cli-region` 必须**内联在数组里**(2026-11-14)。
  163. * `run()` 用的是 `execFileSync`(无 shell),数组元素原样作为独立 argv 传入,
  164. * 所以这里不能写成 shell 风格的 `--cli-region=xxx` 之外的形态,
  165. * 也不能依赖 shell 的引号处理 —— 与 deploy.ps1 的写法保持等价。
  166. */
  167. `--cli-region=${CDN_REGION}`,
  168. `--refresh_task.type=file`,
  169. ...refreshUrls.map((u, i) => `--refresh_task.urls.${i + 1}=${u}`),
  170. /*
  171. * 🔴 直接传 AK/SK,**不依赖 `--cli-profile`**(2026-11-14)。
  172. *
  173. * KooCLI 会先尝试读凭证文件;没配 profile 时报 `[USE_ERROR]不存在配置项 default`,
  174. * 然后**静默跳过** —— CDN 没刷,但脚本继续探活,探活仍然打旧缓存后命中 200,
  175. * 看起来"部署成功",用户实际拿到的还是老代码(隐蔽失败,代价极大)。
  176. */
  177. `--cli-access-key=${HW_AK}`,
  178. `--cli-secret-key=${HW_SK}`,
  179. ];
  180. console.log(' 刷新 URL:');
  181. for (const u of refreshUrls) console.log(` ${u}`);
  182. // KooCLI 首次运行会交互式询问《隐私政策声明》,无人值守时会卡死并报 USE_ERROR
  183. run(HCLOUD_BIN, refreshArgs, { input: 'y\ny\ny\n' });
  184. // ── 5. 探活 ────────────────────────────────────────────────────────────────
  185. console.log('\n=== [5/5] 线上探活 ===');
  186. if (dryRun) {
  187. console.log(' (dry-run,跳过)');
  188. } else {
  189. /*
  190. * 每个 URL 轮询至多 3 分钟(CDN 刷新实际有 1-5 分钟延迟),
  191. * 命中 200 / 内容是本次构建时才判通过 —— 只发请求不看结果的「探活」等于没探。
  192. */
  193. const indexHtmlContent = readFileSync(indexHtml, 'utf8');
  194. const title = indexHtmlContent.match(/<title>([^<]*)<\/title>/)?.[1] ?? '';
  195. let allOk = true;
  196. for (const path of ['/', '/index.html']) {
  197. let ok = false;
  198. for (let i = 0; i < 18 && !ok; i++) {
  199. const { status } = await probe(`${CDN_BASE}${path}`);
  200. ok = status === 200;
  201. if (!ok) await new Promise((r) => setTimeout(r, 10_000));
  202. }
  203. if (ok) console.log(` ✓ ${path} → 200`);
  204. else {
  205. allOk = false;
  206. console.log(` ❌ ${path} 轮询 3 分钟仍未返回 200,请手动确认 CDN 状态`);
  207. }
  208. }
  209. if (title) {
  210. const { body } = await probe(`${CDN_BASE}/`);
  211. if (body.includes(title)) console.log(` ✓ 线上标题为本次构建:${title}`);
  212. else {
  213. allOk = false;
  214. console.log(` ⚠ 线上标题非本次构建(期望「${title}」),CDN 可能尚未刷新完`);
  215. }
  216. }
  217. /*
  218. * SPA 深链:OBS 静态网站托管用 ErrorDocument=index.html 回落 ——
  219. * **状态码仍是 404**(OBS/S3 的标准行为),但响应体是 index.html,浏览器据此正常启动。
  220. * 故判据是「响应体含本次构建的 <title>」,而不是状态码。
  221. * 若将来换到 CDN 回源改写(状态码 200),这条断言同样成立。
  222. */
  223. const deep = await probe(`${CDN_BASE}/desktop/chat`);
  224. if (title && deep.body.includes(title)) {
  225. console.log(' ✓ SPA 深链 /desktop/chat 回落到 index.html');
  226. } else {
  227. allOk = false;
  228. console.log(' ❌ SPA 深链 /desktop/chat 未回落(需在 OBS 桶「静态网站托管」把错误文档设为 index.html)');
  229. }
  230. if (!allOk) {
  231. console.error('\n❌ 部署完成但线上校验未全部通过,见上方 ❌ / ⚠ 项');
  232. process.exit(1);
  233. }
  234. }
  235. console.log(`\n✅ 部署完成 → ${CDN_BASE}\n`);