hermes-yaoyao-updater.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. #!/usr/bin/env node
  2. import { execFileSync } from 'node:child_process'
  3. import {
  4. copyFileSync,
  5. cpSync,
  6. existsSync,
  7. lstatSync,
  8. mkdirSync,
  9. readFileSync,
  10. readlinkSync,
  11. renameSync,
  12. rmSync,
  13. symlinkSync,
  14. writeFileSync,
  15. } from 'node:fs'
  16. import { dirname, join, relative, resolve, sep } from 'node:path'
  17. import { fileURLToPath } from 'node:url'
  18. const label = 'com.samien.hermes-yaoyao'
  19. const uid = process.getuid?.() ?? 501
  20. const domain = `gui/${uid}`
  21. function fail(message) { throw new Error(message) }
  22. function readJSON(path) { return JSON.parse(readFileSync(path, 'utf8')) }
  23. function writeJSON(path, value) {
  24. const temporary = `${path}.tmp-${process.pid}`
  25. writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
  26. renameSync(temporary, path)
  27. }
  28. export function validateManifest(value) {
  29. if (!value || typeof value !== 'object' || value.schemaVersion !== 1) fail('release.json 格式无效')
  30. for (const key of ['releaseVersion', 'webVersion', 'pluginVersion']) {
  31. if (typeof value[key] !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value[key])) {
  32. fail(`release.json ${key} 无效`)
  33. }
  34. }
  35. if (value.releaseVersion !== value.webVersion || value.gitTag !== `v${value.releaseVersion}`) {
  36. fail('release.json 版本组合不一致')
  37. }
  38. return value
  39. }
  40. function updateJob(jobPath, patch) {
  41. const current = readJSON(jobPath)
  42. writeJSON(jobPath, { ...current, ...patch, updatedAt: new Date().toISOString() })
  43. }
  44. const COMMAND_OUTPUT_LIMIT = 6_000
  45. function cleanCommandOutput(value) {
  46. if (value === undefined || value === null) return ''
  47. return String(value)
  48. .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '')
  49. .replace(/([a-z][a-z0-9+.-]*:\/\/)([^@\s/]+)@/gi, '$1***@')
  50. .replace(/([?&](?:access_token|token|password)=)[^&\s]+/gi, '$1***')
  51. .trim()
  52. }
  53. function outputTail(value) {
  54. if (value.length <= COMMAND_OUTPUT_LIMIT) return value
  55. return `…已省略前面的输出…\n${value.slice(-COMMAND_OUTPUT_LIMIT)}`
  56. }
  57. export function formatCommandFailure(command, args, error) {
  58. const commandLine = cleanCommandOutput([command, ...args].join(' '))
  59. const status = typeof error?.status === 'number'
  60. ? `,退出码 ${error.status}`
  61. : error?.signal ? `,信号 ${error.signal}` : ''
  62. const stdout = cleanCommandOutput(error?.stdout)
  63. const stderr = cleanCommandOutput(error?.stderr)
  64. let detail = [stdout, stderr].filter(Boolean).join('\n')
  65. if (!detail) {
  66. detail = cleanCommandOutput(error instanceof Error ? error.message : error)
  67. .replace(/^Command failed:[^\n]*(?:\n|$)/, '')
  68. .trim()
  69. }
  70. return `${commandLine} 失败${status}${detail ? `\n${outputTail(detail)}` : ''}`
  71. }
  72. function run(command, args, options = {}) {
  73. try {
  74. return execFileSync(command, args, {
  75. cwd: options.cwd,
  76. env: options.env ?? process.env,
  77. encoding: 'utf8',
  78. stdio: 'pipe',
  79. timeout: options.timeout ?? 120_000,
  80. maxBuffer: 8 * 1_024 * 1_024,
  81. })
  82. } catch (error) {
  83. throw new Error(formatCommandFailure(command, args, error))
  84. }
  85. }
  86. function pathInside(path, parent) {
  87. const target = resolve(path)
  88. const root = resolve(parent)
  89. return target.startsWith(`${root}${sep}`)
  90. }
  91. function removeInside(path, parent) {
  92. if (!pathInside(path, parent)) fail(`拒绝清理非预期路径:${path}`)
  93. rmSync(path, { recursive: true, force: true })
  94. }
  95. function loaded() {
  96. try {
  97. run('launchctl', ['print', `${domain}/${label}`])
  98. return true
  99. } catch {
  100. return false
  101. }
  102. }
  103. function stopService() {
  104. if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
  105. }
  106. function stopDashboard() {
  107. try { run('hermes', ['dashboard', '--stop'], { timeout: 30_000 }) } catch { /* already stopped */ }
  108. }
  109. function enabledPlugins() {
  110. const raw = run('hermes', ['config', 'get', 'plugins.enabled']).trim()
  111. if (!raw) return []
  112. const value = JSON.parse(raw)
  113. if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
  114. fail('Hermes plugins.enabled 不是字符串数组')
  115. }
  116. return value
  117. }
  118. function setEnabledPlugins(value) {
  119. run('hermes', ['config', 'set', 'plugins.enabled', JSON.stringify(value)])
  120. }
  121. function ensureYaoyaoEnabled(previous) {
  122. if (!previous.includes('yaoyao')) setEnabledPlugins([...previous, 'yaoyao'])
  123. }
  124. function atomicDirectory(source, target, scratchRoot, token) {
  125. const parent = dirname(target)
  126. mkdirSync(parent, { recursive: true })
  127. const incoming = join(parent, `.${token}-incoming`)
  128. const previous = join(parent, `.${token}-previous`)
  129. removeInside(incoming, scratchRoot)
  130. removeInside(previous, scratchRoot)
  131. cpSync(source, incoming, { recursive: true, preserveTimestamps: true })
  132. if (existsSync(target)) renameSync(target, previous)
  133. renameSync(incoming, target)
  134. return previous
  135. }
  136. function atomicFile(source, target, scratchRoot, token) {
  137. const parent = dirname(target)
  138. mkdirSync(parent, { recursive: true })
  139. const incoming = join(parent, `.${token}-incoming.yaml`)
  140. const previous = join(parent, `.${token}-previous.yaml`)
  141. removeInside(incoming, scratchRoot)
  142. removeInside(previous, scratchRoot)
  143. copyFileSync(source, incoming)
  144. if (existsSync(target)) renameSync(target, previous)
  145. renameSync(incoming, target)
  146. return previous
  147. }
  148. export function currentTarget(releaseRoot) {
  149. const current = join(releaseRoot, 'current')
  150. try {
  151. const stat = lstatSync(current)
  152. if (!stat.isSymbolicLink()) fail('发布目录 current 必须是符号链接')
  153. return resolve(releaseRoot, readlinkSync(current))
  154. } catch (error) {
  155. if (error?.code === 'ENOENT') return undefined
  156. throw error
  157. }
  158. }
  159. export function switchCurrent(releaseRoot, target, token) {
  160. mkdirSync(releaseRoot, { recursive: true, mode: 0o700 })
  161. const current = join(releaseRoot, 'current')
  162. const temporary = join(releaseRoot, `.current-${token}`)
  163. removeInside(temporary, releaseRoot)
  164. symlinkSync(relative(releaseRoot, target), temporary, 'dir')
  165. renameSync(temporary, current)
  166. }
  167. function removeCurrent(releaseRoot) {
  168. const current = join(releaseRoot, 'current')
  169. try {
  170. if (!lstatSync(current).isSymbolicLink()) fail('拒绝删除非符号链接 current')
  171. rmSync(current)
  172. } catch (error) {
  173. if (error?.code !== 'ENOENT') throw error
  174. }
  175. }
  176. function serviceEnvironment(serviceRoot, plan) {
  177. return {
  178. ...process.env,
  179. HERMES_YAOYAO_SERVICE_ROOT: serviceRoot,
  180. HERMES_YAOYAO_RELEASE_ROOT: plan.releaseRoot,
  181. HERMES_YAOYAO_RELEASE_SOURCE: plan.source,
  182. }
  183. }
  184. function startService(serviceRoot, plan, stableRoot = true) {
  185. const cli = join(serviceRoot, 'bin', 'hermes-yaoyao.mjs')
  186. if (!existsSync(cli)) fail(`服务入口不存在:${cli}`)
  187. const env = serviceEnvironment(stableRoot ? join(plan.releaseRoot, 'current') : serviceRoot, plan)
  188. if (!stableRoot) delete env.HERMES_YAOYAO_SERVICE_ROOT
  189. run(process.execPath, [cli, 'service', 'install'], { env, timeout: 30_000 })
  190. }
  191. async function responseJSON(url) {
  192. const response = await fetch(url, { signal: AbortSignal.timeout(3_000), cache: 'no-store' })
  193. if (!response.ok) fail(`${url} 返回 HTTP ${response.status}`)
  194. return response.json()
  195. }
  196. async function verifyRuntime(pluginVersion, timeoutMs = 45_000) {
  197. const deadline = Date.now() + timeoutMs
  198. let lastError = ''
  199. while (Date.now() < deadline) {
  200. try {
  201. const health = await responseJSON('http://127.0.0.1:8800/healthz')
  202. const ready = await responseJSON('http://127.0.0.1:8800/readyz')
  203. const plugins = await responseJSON('http://127.0.0.1:9119/api/dashboard/plugins')
  204. const plugin = Array.isArray(plugins) ? plugins.find(item => item?.name === 'yaoyao') : undefined
  205. const pluginMatches = pluginVersion ? plugin?.version === pluginVersion : plugin === undefined
  206. if (health.ok === true && ready.ok === true && pluginMatches) return
  207. lastError = pluginVersion
  208. ? `运行版本不匹配:期望插件 ${pluginVersion}`
  209. : '上一版本未安装夭夭插件,但回滚后仍检测到插件'
  210. } catch (error) {
  211. lastError = error instanceof Error ? error.message : String(error)
  212. }
  213. await new Promise(resolvePromise => setTimeout(resolvePromise, 500))
  214. }
  215. fail(`升级后健康检查失败:${lastError}`)
  216. }
  217. export function backupPlugin(plan, jobID) {
  218. const pluginRoot = join(plan.hermesHome, 'plugins', 'yaoyao')
  219. const backupRoot = join(plan.dataHome, 'backups', 'system-update', jobID)
  220. const pluginPreviouslyInstalled = existsSync(join(pluginRoot, 'dashboard', 'manifest.json'))
  221. mkdirSync(backupRoot, { recursive: true, mode: 0o700 })
  222. if (existsSync(join(pluginRoot, 'dashboard'))) {
  223. cpSync(join(pluginRoot, 'dashboard'), join(backupRoot, 'dashboard'), { recursive: true, preserveTimestamps: true })
  224. }
  225. if (existsSync(join(pluginRoot, 'plugin.yaml'))) copyFileSync(join(pluginRoot, 'plugin.yaml'), join(backupRoot, 'plugin.yaml'))
  226. return { pluginRoot, backupRoot, pluginPreviouslyInstalled }
  227. }
  228. export function restorePlugin(pluginRoot, backupRoot, token, pluginPreviouslyInstalled = true) {
  229. const dashboardBackup = join(backupRoot, 'dashboard')
  230. const manifestBackup = join(backupRoot, 'plugin.yaml')
  231. if (!pluginPreviouslyInstalled) {
  232. removeInside(join(pluginRoot, 'dashboard'), pluginRoot)
  233. removeInside(join(pluginRoot, 'plugin.yaml'), pluginRoot)
  234. return
  235. }
  236. if (!existsSync(dashboardBackup) || !existsSync(manifestBackup)) fail('插件回滚备份不完整')
  237. const oldDashboard = atomicDirectory(dashboardBackup, join(pluginRoot, 'dashboard'), pluginRoot, `${token}-restore-dashboard`)
  238. const oldManifest = atomicFile(manifestBackup, join(pluginRoot, 'plugin.yaml'), pluginRoot, `${token}-restore-manifest`)
  239. removeInside(oldDashboard, pluginRoot)
  240. removeInside(oldManifest, pluginRoot)
  241. }
  242. function backupPluginVersion(backupRoot) {
  243. return String(readJSON(join(backupRoot, 'dashboard', 'manifest.json')).version ?? '')
  244. }
  245. function stageRelease(job, jobPath) {
  246. const { plan, target, id } = job
  247. if (!target) fail('升级任务缺少目标版本')
  248. const releasesRoot = join(plan.releaseRoot, 'releases')
  249. mkdirSync(releasesRoot, { recursive: true, mode: 0o700 })
  250. const staging = join(releasesRoot, `.staging-${id}`)
  251. removeInside(staging, releasesRoot)
  252. updateJob(jobPath, { state: 'downloading', message: `正在下载 ${target.gitTag}` })
  253. run('git', ['clone', '--quiet', '--depth', '1', '--single-branch', '--branch', target.gitTag, plan.source, staging], { timeout: 600_000 })
  254. const manifest = validateManifest(readJSON(join(staging, 'release.json')))
  255. for (const field of ['schemaVersion', 'releaseVersion', 'webVersion', 'pluginVersion', 'gitTag']) {
  256. if (manifest[field] !== target[field]) fail('下载的发布清单与检查结果不一致')
  257. }
  258. const commit = run('git', ['rev-parse', 'HEAD'], { cwd: staging }).trim()
  259. if (!/^[0-9a-f]{40,64}$/.test(plan.targetCommit || '') || commit !== plan.targetCommit) {
  260. fail('下载提交与检查时锁定的发布提交不一致')
  261. }
  262. updateJob(jobPath, { state: 'building', message: `正在构建 Web ${target.webVersion}` })
  263. const buildEnvironment = { ...process.env, NODE_ENV: 'development' }
  264. run('npm', ['ci', '--include=dev'], { cwd: staging, env: buildEnvironment, timeout: 600_000 })
  265. run('npm', ['run', 'build'], { cwd: staging, env: buildEnvironment, timeout: 600_000 })
  266. const finalRoot = join(releasesRoot, `${target.releaseVersion}-${commit.slice(0, 12)}`)
  267. if (existsSync(finalRoot)) removeInside(staging, releasesRoot)
  268. else renameSync(staging, finalRoot)
  269. return { finalRoot, commit }
  270. }
  271. async function runUpdate(jobPath, job) {
  272. const { plan, target, id } = job
  273. if (!target) fail('升级任务缺少目标发布清单')
  274. const { finalRoot, commit } = stageRelease(job, jobPath)
  275. const pluginSource = join(finalRoot, 'hermes-plugins', 'yaoyao')
  276. const pluginManifest = readJSON(join(pluginSource, 'dashboard', 'manifest.json'))
  277. if (pluginManifest.version !== target.pluginVersion) fail('构建产物中的插件版本与发布清单不一致')
  278. const previousCurrentTarget = currentTarget(plan.releaseRoot)
  279. const { pluginRoot, backupRoot, pluginPreviouslyInstalled } = backupPlugin(plan, id)
  280. const previousPluginVersion = pluginPreviouslyInstalled ? backupPluginVersion(backupRoot) : ''
  281. const previousEnabledPlugins = enabledPlugins()
  282. let transitioned = false
  283. let previousDashboard = ''
  284. let previousPluginYaml = ''
  285. try {
  286. updateJob(jobPath, { state: 'installing', message: `正在安装插件 ${target.pluginVersion}` })
  287. stopService()
  288. stopDashboard()
  289. transitioned = true
  290. previousDashboard = atomicDirectory(
  291. join(pluginSource, 'dashboard'), join(pluginRoot, 'dashboard'), pluginRoot, `${id}-dashboard`,
  292. )
  293. previousPluginYaml = atomicFile(
  294. join(pluginSource, 'plugin.yaml'), join(pluginRoot, 'plugin.yaml'), pluginRoot, `${id}-manifest`,
  295. )
  296. ensureYaoyaoEnabled(previousEnabledPlugins)
  297. switchCurrent(plan.releaseRoot, finalRoot, id)
  298. updateJob(jobPath, { state: 'restarting', message: '正在启动新版本服务' })
  299. startService(join(plan.releaseRoot, 'current'), plan, true)
  300. updateJob(jobPath, { state: 'verifying', message: '正在验证 8800、9119 和插件版本' })
  301. await verifyRuntime(target.pluginVersion)
  302. removeInside(previousDashboard, pluginRoot)
  303. removeInside(previousPluginYaml, pluginRoot)
  304. writeJSON(join(dirname(jobPath), 'last-success.json'), {
  305. jobId: id,
  306. previousCurrentTarget,
  307. previousServiceRoot: plan.previousServiceRoot,
  308. previousPluginVersion,
  309. pluginPreviouslyInstalled,
  310. previousEnabledPlugins,
  311. backupRoot,
  312. target,
  313. finalRoot,
  314. commit,
  315. })
  316. updateJob(jobPath, { state: 'succeeded', message: `已升级到 Web ${target.webVersion} + 插件 ${target.pluginVersion}` })
  317. } catch (error) {
  318. const message = error instanceof Error ? error.message : String(error)
  319. if (transitioned) {
  320. updateJob(jobPath, { state: 'rolling_back', message: '升级失败,正在自动恢复上一版本', error: message })
  321. try {
  322. stopService()
  323. stopDashboard()
  324. restorePlugin(pluginRoot, backupRoot, id, pluginPreviouslyInstalled)
  325. setEnabledPlugins(previousEnabledPlugins)
  326. if (previousCurrentTarget) {
  327. switchCurrent(plan.releaseRoot, previousCurrentTarget, `${id}-rollback`)
  328. startService(join(plan.releaseRoot, 'current'), plan, true)
  329. } else {
  330. removeCurrent(plan.releaseRoot)
  331. startService(plan.previousServiceRoot, plan, false)
  332. }
  333. await verifyRuntime(previousPluginVersion)
  334. updateJob(jobPath, { state: 'failed', message: '升级失败,已自动恢复上一版本', error: message })
  335. } catch (rollbackError) {
  336. const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
  337. updateJob(jobPath, { state: 'failed', message: '升级和自动回滚均失败,需要人工处理', error: `${message}; 回滚:${rollbackMessage}` })
  338. }
  339. } else {
  340. updateJob(jobPath, { state: 'failed', message: '升级准备失败,现有服务未被修改', error: message })
  341. }
  342. }
  343. }
  344. async function runRollback(jobPath, job) {
  345. const { plan, id } = job
  346. const recordPath = join(dirname(jobPath), 'last-success.json')
  347. if (!existsSync(recordPath)) fail('没有可回滚的上一版本')
  348. const record = readJSON(recordPath)
  349. updateJob(jobPath, { state: 'rolling_back', message: '正在恢复上一版本' })
  350. stopService()
  351. stopDashboard()
  352. restorePlugin(
  353. join(plan.hermesHome, 'plugins', 'yaoyao'),
  354. record.backupRoot,
  355. id,
  356. record.pluginPreviouslyInstalled !== false,
  357. )
  358. if (Array.isArray(record.previousEnabledPlugins)) setEnabledPlugins(record.previousEnabledPlugins)
  359. if (record.previousCurrentTarget) {
  360. switchCurrent(plan.releaseRoot, record.previousCurrentTarget, id)
  361. startService(join(plan.releaseRoot, 'current'), plan, true)
  362. } else {
  363. removeCurrent(plan.releaseRoot)
  364. startService(record.previousServiceRoot, plan, false)
  365. }
  366. await verifyRuntime(record.previousPluginVersion)
  367. rmSync(recordPath, { force: true })
  368. updateJob(jobPath, { state: 'rolled_back', message: `已回滚到插件 ${record.previousPluginVersion}` })
  369. }
  370. async function main() {
  371. const [, , command, flag, jobPathValue] = process.argv
  372. if (command !== 'run' || flag !== '--job' || !jobPathValue) fail('用法:hermes-yaoyao-updater run --job <path>')
  373. const jobPath = resolve(jobPathValue)
  374. const job = readJSON(jobPath)
  375. try {
  376. if (job.operation === 'update') await runUpdate(jobPath, job)
  377. else if (job.operation === 'rollback') await runRollback(jobPath, job)
  378. else fail('未知升级任务类型')
  379. } catch (error) {
  380. updateJob(jobPath, {
  381. state: 'failed',
  382. message: job.operation === 'rollback' ? '回滚失败,需要人工处理' : '升级失败',
  383. error: error instanceof Error ? error.message : String(error),
  384. })
  385. } finally {
  386. const lockPath = join(dirname(jobPath), 'active.lock')
  387. try {
  388. const raw = readFileSync(lockPath, 'utf8').trim()
  389. let lockJobID = raw
  390. try { lockJobID = JSON.parse(raw).jobID } catch { /* legacy lock */ }
  391. if (lockJobID === job.id) rmSync(lockPath)
  392. } catch { /* already released */ }
  393. }
  394. }
  395. if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main()