hermes-yaoyao-updater.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. #!/usr/bin/env node
  2. import { execFileSync } from 'node:child_process'
  3. import {
  4. existsSync,
  5. lstatSync,
  6. mkdirSync,
  7. readFileSync,
  8. readlinkSync,
  9. renameSync,
  10. rmSync,
  11. symlinkSync,
  12. writeFileSync,
  13. } from 'node:fs'
  14. import { dirname, join, relative, resolve, sep } from 'node:path'
  15. import { fileURLToPath } from 'node:url'
  16. const label = 'com.samien.hermes-yaoyao'
  17. const uid = process.getuid?.() ?? 501
  18. const domain = `gui/${uid}`
  19. function fail(message) { throw new Error(message) }
  20. function readJSON(path) { return JSON.parse(readFileSync(path, 'utf8')) }
  21. function writeJSON(path, value) {
  22. const temporary = `${path}.tmp-${process.pid}`
  23. writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
  24. renameSync(temporary, path)
  25. }
  26. export function validateManifest(value) {
  27. if (!value || typeof value !== 'object' || value.schemaVersion !== 1) fail('release.json 格式无效')
  28. for (const key of ['releaseVersion', 'webVersion', 'pluginVersion']) {
  29. if (typeof value[key] !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value[key])) {
  30. fail(`release.json ${key} 无效`)
  31. }
  32. }
  33. if (value.releaseVersion !== value.webVersion || value.gitTag !== `v${value.releaseVersion}`) {
  34. fail('release.json 版本组合不一致')
  35. }
  36. return value
  37. }
  38. function updateJob(jobPath, patch) {
  39. const current = readJSON(jobPath)
  40. writeJSON(jobPath, { ...current, ...patch, updatedAt: new Date().toISOString() })
  41. }
  42. const COMMAND_OUTPUT_LIMIT = 6_000
  43. function cleanCommandOutput(value) {
  44. if (value === undefined || value === null) return ''
  45. return String(value)
  46. .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '')
  47. .replace(/([a-z][a-z0-9+.-]*:\/\/)([^@\s/]+)@/gi, '$1***@')
  48. .replace(/([?&](?:access_token|token|password)=)[^&\s]+/gi, '$1***')
  49. .trim()
  50. }
  51. function outputTail(value) {
  52. if (value.length <= COMMAND_OUTPUT_LIMIT) return value
  53. return `…已省略前面的输出…\n${value.slice(-COMMAND_OUTPUT_LIMIT)}`
  54. }
  55. export function formatCommandFailure(command, args, error) {
  56. const commandLine = cleanCommandOutput([command, ...args].join(' '))
  57. const status = typeof error?.status === 'number'
  58. ? `,退出码 ${error.status}`
  59. : error?.signal ? `,信号 ${error.signal}` : ''
  60. const stdout = cleanCommandOutput(error?.stdout)
  61. const stderr = cleanCommandOutput(error?.stderr)
  62. let detail = [stdout, stderr].filter(Boolean).join('\n')
  63. if (!detail) {
  64. detail = cleanCommandOutput(error instanceof Error ? error.message : error)
  65. .replace(/^Command failed:[^\n]*(?:\n|$)/, '')
  66. .trim()
  67. }
  68. return `${commandLine} 失败${status}${detail ? `\n${outputTail(detail)}` : ''}`
  69. }
  70. function run(command, args, options = {}) {
  71. try {
  72. return execFileSync(command, args, {
  73. cwd: options.cwd,
  74. env: options.env ?? process.env,
  75. encoding: 'utf8',
  76. stdio: 'pipe',
  77. timeout: options.timeout ?? 120_000,
  78. maxBuffer: 8 * 1_024 * 1_024,
  79. })
  80. } catch (error) {
  81. throw new Error(formatCommandFailure(command, args, error))
  82. }
  83. }
  84. function pathInside(path, parent) {
  85. const target = resolve(path)
  86. const root = resolve(parent)
  87. return target.startsWith(`${root}${sep}`)
  88. }
  89. function removeInside(path, parent) {
  90. if (!pathInside(path, parent)) fail(`拒绝清理非预期路径:${path}`)
  91. rmSync(path, { recursive: true, force: true })
  92. }
  93. function loaded() {
  94. try {
  95. run('launchctl', ['print', `${domain}/${label}`])
  96. return true
  97. } catch {
  98. return false
  99. }
  100. }
  101. function stopService() {
  102. if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
  103. }
  104. export function currentTarget(releaseRoot) {
  105. const current = join(releaseRoot, 'current')
  106. try {
  107. const stat = lstatSync(current)
  108. if (!stat.isSymbolicLink()) fail('发布目录 current 必须是符号链接')
  109. return resolve(releaseRoot, readlinkSync(current))
  110. } catch (error) {
  111. if (error?.code === 'ENOENT') return undefined
  112. throw error
  113. }
  114. }
  115. export function switchCurrent(releaseRoot, target, token) {
  116. mkdirSync(releaseRoot, { recursive: true, mode: 0o700 })
  117. const current = join(releaseRoot, 'current')
  118. const temporary = join(releaseRoot, `.current-${token}`)
  119. removeInside(temporary, releaseRoot)
  120. symlinkSync(relative(releaseRoot, target), temporary, 'dir')
  121. renameSync(temporary, current)
  122. }
  123. function removeCurrent(releaseRoot) {
  124. const current = join(releaseRoot, 'current')
  125. try {
  126. if (!lstatSync(current).isSymbolicLink()) fail('拒绝删除非符号链接 current')
  127. rmSync(current)
  128. } catch (error) {
  129. if (error?.code !== 'ENOENT') throw error
  130. }
  131. }
  132. function serviceEnvironment(serviceRoot, plan) {
  133. return {
  134. ...process.env,
  135. HERMES_YAOYAO_SERVICE_ROOT: serviceRoot,
  136. HERMES_YAOYAO_RELEASE_ROOT: plan.releaseRoot,
  137. HERMES_YAOYAO_RELEASE_SOURCE: plan.source,
  138. }
  139. }
  140. function startService(serviceRoot, plan, stableRoot = true) {
  141. const cli = join(serviceRoot, 'bin', 'hermes-yaoyao.mjs')
  142. if (!existsSync(cli)) fail(`服务入口不存在:${cli}`)
  143. const env = serviceEnvironment(stableRoot ? join(plan.releaseRoot, 'current') : serviceRoot, plan)
  144. if (!stableRoot) delete env.HERMES_YAOYAO_SERVICE_ROOT
  145. run(process.execPath, [cli, 'service', 'install'], { env, timeout: 30_000 })
  146. }
  147. async function responseJSON(url) {
  148. const response = await fetch(url, { signal: AbortSignal.timeout(3_000), cache: 'no-store' })
  149. if (!response.ok) fail(`${url} 返回 HTTP ${response.status}`)
  150. return response.json()
  151. }
  152. async function verifyRuntime(timeoutMs = 45_000) {
  153. const deadline = Date.now() + timeoutMs
  154. let lastError = ''
  155. while (Date.now() < deadline) {
  156. try {
  157. const health = await responseJSON('http://127.0.0.1:8800/healthz')
  158. const ready = await responseJSON('http://127.0.0.1:8800/readyz')
  159. if (health.ok === true && ready.ok === true) return
  160. lastError = 'Web 服务尚未就绪'
  161. } catch (error) {
  162. lastError = error instanceof Error ? error.message : String(error)
  163. }
  164. await new Promise(resolvePromise => setTimeout(resolvePromise, 500))
  165. }
  166. fail(`升级后健康检查失败:${lastError}`)
  167. }
  168. function stageRelease(job, jobPath) {
  169. const { plan, target, id } = job
  170. if (!target) fail('升级任务缺少目标版本')
  171. const releasesRoot = join(plan.releaseRoot, 'releases')
  172. mkdirSync(releasesRoot, { recursive: true, mode: 0o700 })
  173. const staging = join(releasesRoot, `.staging-${id}`)
  174. removeInside(staging, releasesRoot)
  175. updateJob(jobPath, { state: 'downloading', message: `正在下载 ${target.gitTag}` })
  176. run('git', ['clone', '--quiet', '--depth', '1', '--single-branch', '--branch', target.gitTag, plan.source, staging], { timeout: 600_000 })
  177. const manifest = validateManifest(readJSON(join(staging, 'release.json')))
  178. for (const field of ['schemaVersion', 'releaseVersion', 'webVersion', 'pluginVersion', 'gitTag']) {
  179. if (manifest[field] !== target[field]) fail('下载的发布清单与检查结果不一致')
  180. }
  181. const commit = run('git', ['rev-parse', 'HEAD'], { cwd: staging }).trim()
  182. if (!/^[0-9a-f]{40,64}$/.test(plan.targetCommit || '') || commit !== plan.targetCommit) {
  183. fail('下载提交与检查时锁定的发布提交不一致')
  184. }
  185. updateJob(jobPath, { state: 'building', message: `正在构建 Web ${target.webVersion}` })
  186. const buildEnvironment = { ...process.env, NODE_ENV: 'development' }
  187. run('npm', ['ci', '--include=dev'], { cwd: staging, env: buildEnvironment, timeout: 600_000 })
  188. run('npm', ['run', 'build'], { cwd: staging, env: buildEnvironment, timeout: 600_000 })
  189. const finalRoot = join(releasesRoot, `${target.releaseVersion}-${commit.slice(0, 12)}`)
  190. if (existsSync(finalRoot)) removeInside(staging, releasesRoot)
  191. else renameSync(staging, finalRoot)
  192. return { finalRoot, commit }
  193. }
  194. async function runUpdate(jobPath, job) {
  195. const { plan, target, id } = job
  196. if (!target) fail('升级任务缺少目标发布清单')
  197. const { finalRoot, commit } = stageRelease(job, jobPath)
  198. const pluginSource = join(finalRoot, 'hermes-plugins', 'yaoyao')
  199. const pluginManifest = readJSON(join(pluginSource, 'dashboard', 'manifest.json'))
  200. if (pluginManifest.version !== target.pluginVersion) fail('构建产物中的插件版本与发布清单不一致')
  201. const previousCurrentTarget = currentTarget(plan.releaseRoot)
  202. let transitioned = false
  203. try {
  204. updateJob(jobPath, { state: 'installing', message: `正在切换 Web ${target.webVersion}` })
  205. stopService()
  206. transitioned = true
  207. switchCurrent(plan.releaseRoot, finalRoot, id)
  208. updateJob(jobPath, { state: 'restarting', message: '正在启动新版本服务' })
  209. startService(join(plan.releaseRoot, 'current'), plan, true)
  210. updateJob(jobPath, { state: 'verifying', message: '正在验证 Web 服务' })
  211. await verifyRuntime()
  212. writeJSON(join(dirname(jobPath), 'last-success.json'), {
  213. jobId: id,
  214. previousCurrentTarget,
  215. previousServiceRoot: plan.previousServiceRoot,
  216. target,
  217. finalRoot,
  218. commit,
  219. })
  220. updateJob(jobPath, { state: 'succeeded', message: `已升级 Web ${target.webVersion};插件由 9119 管理` })
  221. } catch (error) {
  222. const message = error instanceof Error ? error.message : String(error)
  223. if (transitioned) {
  224. updateJob(jobPath, { state: 'rolling_back', message: '升级失败,正在自动恢复上一版本', error: message })
  225. try {
  226. stopService()
  227. if (previousCurrentTarget) {
  228. switchCurrent(plan.releaseRoot, previousCurrentTarget, `${id}-rollback`)
  229. startService(join(plan.releaseRoot, 'current'), plan, true)
  230. } else {
  231. removeCurrent(plan.releaseRoot)
  232. startService(plan.previousServiceRoot, plan, false)
  233. }
  234. await verifyRuntime()
  235. updateJob(jobPath, { state: 'failed', message: '升级失败,已自动恢复上一版本', error: message })
  236. } catch (rollbackError) {
  237. const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
  238. updateJob(jobPath, { state: 'failed', message: '升级和自动回滚均失败,需要人工处理', error: `${message}; 回滚:${rollbackMessage}` })
  239. }
  240. } else {
  241. updateJob(jobPath, { state: 'failed', message: '升级准备失败,现有服务未被修改', error: message })
  242. }
  243. }
  244. }
  245. async function runRollback(jobPath, job) {
  246. const { plan, id } = job
  247. const recordPath = join(dirname(jobPath), 'last-success.json')
  248. if (!existsSync(recordPath)) fail('没有可回滚的上一版本')
  249. const record = readJSON(recordPath)
  250. updateJob(jobPath, { state: 'rolling_back', message: '正在恢复上一版本' })
  251. stopService()
  252. if (record.previousCurrentTarget) {
  253. switchCurrent(plan.releaseRoot, record.previousCurrentTarget, id)
  254. startService(join(plan.releaseRoot, 'current'), plan, true)
  255. } else {
  256. removeCurrent(plan.releaseRoot)
  257. startService(record.previousServiceRoot, plan, false)
  258. }
  259. await verifyRuntime()
  260. rmSync(recordPath, { force: true })
  261. updateJob(jobPath, { state: 'rolled_back', message: '已回滚 Web 服务;插件继续由 9119 管理' })
  262. }
  263. async function main() {
  264. const [, , command, flag, jobPathValue] = process.argv
  265. if (command !== 'run' || flag !== '--job' || !jobPathValue) fail('用法:hermes-yaoyao-updater run --job <path>')
  266. const jobPath = resolve(jobPathValue)
  267. const job = readJSON(jobPath)
  268. try {
  269. if (job.operation === 'update') await runUpdate(jobPath, job)
  270. else if (job.operation === 'rollback') await runRollback(jobPath, job)
  271. else fail('未知升级任务类型')
  272. } catch (error) {
  273. updateJob(jobPath, {
  274. state: 'failed',
  275. message: job.operation === 'rollback' ? '回滚失败,需要人工处理' : '升级失败',
  276. error: error instanceof Error ? error.message : String(error),
  277. })
  278. } finally {
  279. const lockPath = join(dirname(jobPath), 'active.lock')
  280. try {
  281. const raw = readFileSync(lockPath, 'utf8').trim()
  282. let lockJobID = raw
  283. try { lockJobID = JSON.parse(raw).jobID } catch { /* legacy lock */ }
  284. if (lockJobID === job.id) rmSync(lockPath)
  285. } catch { /* already released */ }
  286. }
  287. }
  288. if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main()