hermes-yaoyao-updater.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. #!/usr/bin/env node
  2. import { execFileSync } from 'node:child_process'
  3. import { createHash } from 'node:crypto'
  4. import {
  5. closeSync,
  6. existsSync,
  7. lstatSync,
  8. mkdirSync,
  9. openSync,
  10. readFileSync,
  11. readlinkSync,
  12. readdirSync,
  13. renameSync,
  14. rmSync,
  15. symlinkSync,
  16. writeFileSync,
  17. writeSync,
  18. realpathSync,
  19. } from 'node:fs'
  20. import { dirname, join, relative, resolve, sep } from 'node:path'
  21. import { fileURLToPath } from 'node:url'
  22. const label = 'com.samien.hermes-yaoyao'
  23. import { LaunchAgentService, updateMutex, recoverTransition, switchRelease, transitionService } from './lib/service-update.mjs'
  24. import { readRuntime } from './lib/runtime-release.mjs'
  25. import { assertRollbackCompatible } from './lib/service-data.mjs'
  26. import { NPM_PACKAGE, npmInstallationLocation, npmRuntimeDigest, parseNpmRelease, verifyPreparedNpmArchive } from './lib/npm-release.mjs'
  27. const uid = process.getuid?.() ?? 501
  28. const domain = `gui/${uid}`
  29. function fail(message) { throw new Error(message) }
  30. function readJSON(path) { return JSON.parse(readFileSync(path, 'utf8')) }
  31. function writeJSON(path, value) {
  32. const temporary = `${path}.tmp-${process.pid}`
  33. writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
  34. renameSync(temporary, path)
  35. }
  36. export function validateManifest(value) {
  37. if (!value || typeof value !== 'object' || value.schemaVersion !== 1) fail('release.json 格式无效')
  38. for (const key of ['releaseVersion', 'webVersion']) {
  39. if (typeof value[key] !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value[key])) {
  40. fail(`release.json ${key} 无效`)
  41. }
  42. }
  43. if (value.releaseVersion !== value.webVersion || value.gitTag !== `v${value.releaseVersion}`) {
  44. fail('release.json 版本组合不一致')
  45. }
  46. return value
  47. }
  48. function updateJob(jobPath, patch) {
  49. const current = readJSON(jobPath)
  50. writeJSON(jobPath, { ...current, ...patch, updatedAt: new Date().toISOString() })
  51. }
  52. const COMMAND_OUTPUT_LIMIT = 6_000
  53. function cleanCommandOutput(value) {
  54. if (value === undefined || value === null) return ''
  55. return String(value)
  56. .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '')
  57. .replace(/([a-z][a-z0-9+.-]*:\/\/)([^@\s/]+)@/gi, '$1***@')
  58. .replace(/([?&](?:access_token|token|password)=)[^&\s]+/gi, '$1***')
  59. .trim()
  60. }
  61. function outputTail(value) {
  62. if (value.length <= COMMAND_OUTPUT_LIMIT) return value
  63. return `…已省略前面的输出…\n${value.slice(-COMMAND_OUTPUT_LIMIT)}`
  64. }
  65. export function formatCommandFailure(command, args, error) {
  66. const commandLine = cleanCommandOutput([command, ...args].join(' '))
  67. const status = typeof error?.status === 'number'
  68. ? `,退出码 ${error.status}`
  69. : error?.signal ? `,信号 ${error.signal}` : ''
  70. const stdout = cleanCommandOutput(error?.stdout)
  71. const stderr = cleanCommandOutput(error?.stderr)
  72. let detail = [stdout, stderr].filter(Boolean).join('\n')
  73. if (!detail) {
  74. detail = cleanCommandOutput(error instanceof Error ? error.message : error)
  75. .replace(/^Command failed:[^\n]*(?:\n|$)/, '')
  76. .trim()
  77. }
  78. return `${commandLine} 失败${status}${detail ? `\n${outputTail(detail)}` : ''}`
  79. }
  80. function run(command, args, options = {}) {
  81. try {
  82. return execFileSync(command, args, {
  83. cwd: options.cwd,
  84. env: options.env ?? process.env,
  85. encoding: 'utf8',
  86. stdio: 'pipe',
  87. timeout: options.timeout ?? 120_000,
  88. maxBuffer: 8 * 1_024 * 1_024,
  89. })
  90. } catch (error) {
  91. throw new Error(formatCommandFailure(command, args, error))
  92. }
  93. }
  94. function pathInside(path, parent) {
  95. const target = resolve(path)
  96. const root = resolve(parent)
  97. return target.startsWith(`${root}${sep}`)
  98. }
  99. function removeInside(path, parent) {
  100. if (!pathInside(path, parent)) fail(`拒绝清理非预期路径:${path}`)
  101. rmSync(path, { recursive: true, force: true })
  102. }
  103. function loaded() {
  104. try {
  105. run('launchctl', ['print', `${domain}/${label}`])
  106. return true
  107. } catch {
  108. return false
  109. }
  110. }
  111. function stopService() {
  112. if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
  113. }
  114. export function currentTarget(releaseRoot) {
  115. const current = join(releaseRoot, 'current')
  116. try {
  117. const stat = lstatSync(current)
  118. if (!stat.isSymbolicLink()) fail('发布目录 current 必须是符号链接')
  119. return resolve(releaseRoot, readlinkSync(current))
  120. } catch (error) {
  121. if (error?.code === 'ENOENT') return undefined
  122. throw error
  123. }
  124. }
  125. export function switchCurrent(releaseRoot, target, token) {
  126. mkdirSync(releaseRoot, { recursive: true, mode: 0o700 })
  127. const current = join(releaseRoot, 'current')
  128. const temporary = join(releaseRoot, `.current-${token}`)
  129. removeInside(temporary, releaseRoot)
  130. symlinkSync(relative(releaseRoot, target), temporary, 'dir')
  131. renameSync(temporary, current)
  132. }
  133. function removeCurrent(releaseRoot) {
  134. const current = join(releaseRoot, 'current')
  135. try {
  136. if (!lstatSync(current).isSymbolicLink()) fail('拒绝删除非符号链接 current')
  137. rmSync(current)
  138. } catch (error) {
  139. if (error?.code !== 'ENOENT') throw error
  140. }
  141. }
  142. function serviceEnvironment(serviceRoot, plan) {
  143. return {
  144. ...process.env,
  145. HERMES_YAOYAO_SERVICE_ROOT: serviceRoot,
  146. HERMES_YAOYAO_RELEASE_ROOT: plan.releaseRoot,
  147. HERMES_YAOYAO_RELEASE_SOURCE: plan.source,
  148. HERMES_YAOYAO_HOST: '0.0.0.0',
  149. HERMES_YAOYAO_ALLOW_INSECURE_LAN: '1',
  150. }
  151. }
  152. export function serviceInstallInvocation(
  153. serviceRoot,
  154. plan,
  155. stableRoot = true,
  156. lifecycleRoot = serviceRoot,
  157. ) {
  158. const cli = join(lifecycleRoot, 'bin', 'hermes-yaoyao.mjs')
  159. const env = serviceEnvironment(stableRoot ? join(plan.releaseRoot, 'current') : serviceRoot, plan)
  160. return { cli, env }
  161. }
  162. function startService(serviceRoot, plan, stableRoot = true, lifecycleRoot = serviceRoot) {
  163. const { cli, env } = serviceInstallInvocation(serviceRoot, plan, stableRoot, lifecycleRoot)
  164. if (!existsSync(cli)) fail(`服务入口不存在:${cli}`)
  165. run(process.execPath, [cli, 'service', 'install'], { env, timeout: 30_000 })
  166. }
  167. export function restorePreviousService({
  168. plan,
  169. previousCurrentTarget,
  170. previousServiceRoot,
  171. lifecycleRoot,
  172. token,
  173. switchCurrentCommand = switchCurrent,
  174. removeCurrentCommand = removeCurrent,
  175. startServiceCommand = startService,
  176. }) {
  177. if (!lifecycleRoot) fail('当前发布版本不可用,无法安全执行回滚')
  178. if (previousCurrentTarget) {
  179. switchCurrentCommand(plan.releaseRoot, previousCurrentTarget, token)
  180. startServiceCommand(join(plan.releaseRoot, 'current'), plan, true, lifecycleRoot)
  181. } else {
  182. removeCurrentCommand(plan.releaseRoot)
  183. startServiceCommand(previousServiceRoot, plan, false, lifecycleRoot)
  184. }
  185. }
  186. async function responseJSON(url, fetchImpl) {
  187. const response = await fetchImpl(url, { signal: AbortSignal.timeout(3_000), cache: 'no-store' })
  188. if (!response.ok) fail(`${url} 返回 HTTP ${response.status}`)
  189. return response.json()
  190. }
  191. export async function verifyRuntime(timeoutMs = 45_000, fetchImpl = fetch) {
  192. const deadline = Date.now() + timeoutMs
  193. let lastError = ''
  194. while (Date.now() < deadline) {
  195. try {
  196. // /readyz deliberately includes 9119 reachability. Only the Web's own
  197. // health decides update/rollback success, including older releases.
  198. const health = await responseJSON('http://127.0.0.1:15300/healthz', fetchImpl)
  199. if (health.ok === true) return
  200. lastError = 'Web 服务尚未就绪'
  201. } catch (error) {
  202. lastError = error instanceof Error ? error.message : String(error)
  203. }
  204. await new Promise(resolvePromise => setTimeout(resolvePromise, 500))
  205. }
  206. fail(`升级后健康检查失败:${lastError}`)
  207. }
  208. function stageRelease(job, jobPath) {
  209. const { plan, target, id } = job
  210. if (!target) fail('升级任务缺少目标版本')
  211. const releasesRoot = join(plan.releaseRoot, 'releases')
  212. mkdirSync(releasesRoot, { recursive: true, mode: 0o700 })
  213. const staging = join(releasesRoot, `.staging-${id}`)
  214. removeInside(staging, releasesRoot)
  215. updateJob(jobPath, { state: 'downloading', message: `正在下载 ${target.gitTag}` })
  216. run('git', ['clone', '--quiet', '--depth', '1', '--single-branch', '--branch', target.gitTag, plan.source, staging], { timeout: 600_000 })
  217. const manifest = validateManifest(readJSON(join(staging, 'release.json')))
  218. for (const field of ['schemaVersion', 'releaseVersion', 'webVersion', 'gitTag']) {
  219. if (manifest[field] !== target[field]) fail('下载的发布清单与检查结果不一致')
  220. }
  221. const commit = run('git', ['rev-parse', 'HEAD'], { cwd: staging }).trim()
  222. if (!/^[0-9a-f]{40,64}$/.test(plan.targetCommit || '') || commit !== plan.targetCommit) {
  223. fail('下载提交与检查时锁定的发布提交不一致')
  224. }
  225. updateJob(jobPath, { state: 'building', message: `正在构建 Web ${target.webVersion}` })
  226. const buildEnvironment = { ...process.env, NODE_ENV: 'development' }
  227. run('npm', ['ci', '--include=dev'], { cwd: staging, env: buildEnvironment, timeout: 600_000 })
  228. run('npm', ['run', 'build'], { cwd: staging, env: buildEnvironment, timeout: 600_000 })
  229. const finalRoot = join(releasesRoot, `${target.releaseVersion}-${commit.slice(0, 12)}`)
  230. if (existsSync(finalRoot)) removeInside(staging, releasesRoot)
  231. else renameSync(staging, finalRoot)
  232. return { finalRoot, commit }
  233. }
  234. export async function stageNpmRelease(job, jobPath, { fetchImpl = fetch } = {}) {
  235. const { plan, target, id } = job
  236. validateManifest(target)
  237. const npm = parseNpmRelease({ name: NPM_PACKAGE, version: plan.npm?.version, dist: plan.npm })
  238. if (npm.version !== target.releaseVersion) fail('npm 发布信息与目标版本不一致')
  239. const downloads = join(plan.home, 'updates', 'npm'), staging = join(downloads, id)
  240. mkdirSync(downloads, { recursive: true, mode: 0o700 })
  241. // Only one update owns the mutex. A replacement download supersedes any
  242. // previous prepared archive, so temporary packages cannot accumulate.
  243. for (const name of readdirSync(downloads)) {
  244. if (/^[0-9a-f-]{36}$/.test(name)) removeInside(join(downloads, name), downloads)
  245. }
  246. mkdirSync(staging, { mode: 0o700 })
  247. const archive = join(staging, 'release.tgz')
  248. try {
  249. updateJob(jobPath, { state: 'downloading', message: `正在下载服务器 ${npm.version}…`, received: 0, total: 0 })
  250. const response = await fetchImpl(npm.tarball, { signal: AbortSignal.timeout(600000) })
  251. if (!response.ok || !response.body) fail(`npm 安装包下载失败:HTTP ${response.status}`)
  252. const total = Number(response.headers.get('content-length')) || 0, limit = 512 * 1024 * 1024
  253. if (total > limit) fail('npm 安装包超过下载大小限制')
  254. const hash = createHash('sha512'), descriptor = openSync(archive, 'wx', 0o600)
  255. let received = 0, lastProgress = 0
  256. try {
  257. for await (const chunk of response.body) {
  258. received += chunk.length
  259. if (received > limit) fail('npm 安装包超过下载大小限制')
  260. for (let offset = 0; offset < chunk.length;) offset += writeSync(descriptor, chunk, offset, chunk.length - offset)
  261. hash.update(chunk)
  262. if (Date.now() - lastProgress > 250) {
  263. updateJob(jobPath, { received, total, message: total > 0 ? `正在下载服务器更新 · ${Math.min(100, Math.floor(received / total * 100))}%` : '正在下载服务器更新…' })
  264. lastProgress = Date.now()
  265. }
  266. }
  267. } finally { closeSync(descriptor) }
  268. if (`sha512-${hash.digest('base64')}` !== npm.integrity) fail('npm 安装包完整性校验失败,原服务保持运行')
  269. const prepared = { archive }
  270. updateJob(jobPath, { state: 'prepared', message: `服务器 ${npm.version} 已下载完成,点击“重启服务器”覆盖更新`, received, total, plan: { ...plan, prepared } })
  271. return prepared
  272. } catch (error) {
  273. removeInside(staging, downloads)
  274. throw error
  275. }
  276. }
  277. export async function overwriteNpmRelease(job, jobPath, { driver = serviceOptions(jobPath, job).driver, install = (archive, location) => run('npm', [
  278. 'install', ...(location.global ? ['--global'] : []), '--prefix', location.prefix, '--no-save', '--package-lock=false', '--omit=dev', '--no-audit', '--no-fund', '--engine-strict', '--allow-file=all', archive,
  279. ], { cwd: location.prefix, timeout: 600000 }) } = {}) {
  280. const { plan, target } = job
  281. validateManifest(target)
  282. const npm = parseNpmRelease({ name: NPM_PACKAGE, version: plan.npm?.version, dist: plan.npm })
  283. if (npm.version !== target.releaseVersion) fail('npm 发布信息与目标版本不一致')
  284. const archive = verifyPreparedNpmArchive(plan.home, plan.prepared, npm)
  285. let stopped = false, quiescing = false
  286. try {
  287. const previous = driver.snapshot(), root = realpathSync(plan.previousServiceRoot)
  288. if (!previous || realpathSync(previous.root) !== root) fail('当前服务与 npm 安装目录不一致,已取消覆盖更新')
  289. const location = npmInstallationLocation(root), home = realpathSync(plan.home), modules = dirname(dirname(root))
  290. if (home === modules || pathInside(home, modules)) fail('用户数据目录位于 npm 程序目录内,已取消覆盖更新')
  291. if (existsSync(join(plan.home, 'updates', 'transition.json'))) fail('存在未完成的服务切换,请先处理后再覆盖更新')
  292. if (previous.wasRunning) await driver.verify(undefined, 5000)
  293. quiescing = true
  294. await driver.quiesce(message => updateJob(jobPath, { message }))
  295. updateJob(jobPath, { state: 'installing', message: '正在停止服务器并覆盖当前 npm 安装…' })
  296. await driver.stop(); stopped = true
  297. await install(archive, location)
  298. npmRuntimeDigest(root, npm.version)
  299. // Keep the same program path. No version copy, data snapshot or rollback
  300. // record is created for an npm overwrite.
  301. rmSync(join(plan.home, 'updates', 'last-success.json'), { force: true })
  302. switchRelease(plan.releaseRoot, root)
  303. updateJob(jobPath, { state: 'restarting', message: '正在启动更新后的服务器…' })
  304. await driver.start(root, previous)
  305. updateJob(jobPath, { state: 'verifying', message: '正在核对新版本和服务状态…' })
  306. await driver.verify(readRuntime(root))
  307. updateJob(jobPath, { state: 'succeeded', message: `服务器已覆盖更新到 ${target.webVersion}` })
  308. } catch (error) {
  309. if (quiescing && !stopped) await driver.resume()
  310. throw new Error(`${error.message}${stopped ? ';未保留旧版本,请修复当前安装后重启服务器' : ''}`)
  311. } finally {
  312. removeInside(dirname(archive), join(plan.home, 'updates', 'npm'))
  313. }
  314. }
  315. function serviceOptions(jobPath, job) {
  316. const home = job.plan.home || resolve(dirname(jobPath), '..')
  317. const options = { home, releaseRoot: job.plan.releaseRoot, port: job.plan.port || Number(process.env.HERMES_YAOYAO_PORT || 15300) }
  318. return { ...options, driver: new LaunchAgentService(options), onProgress: message => updateJob(jobPath, { message }) }
  319. }
  320. async function runUpdate(jobPath, job) {
  321. if (job.plan.npm) {
  322. if (job.plan.prepared) await overwriteNpmRelease(job, jobPath)
  323. else await stageNpmRelease(job, jobPath)
  324. return
  325. }
  326. const options = serviceOptions(jobPath, job)
  327. await recoverTransition(options)
  328. const { finalRoot } = stageRelease(job, jobPath)
  329. updateJob(jobPath, { state: 'installing', message: '正在准备切换 Web 服务' })
  330. await transitionService({ ...options, finalRoot })
  331. updateJob(jobPath, { state: 'succeeded', message: `已升级 Web ${job.target.webVersion}` })
  332. }
  333. async function runRollback(jobPath, job) {
  334. const options = serviceOptions(jobPath, job)
  335. await recoverTransition(options)
  336. const recordPath = join(dirname(jobPath), 'last-success.json')
  337. if (!existsSync(recordPath)) fail('没有可回滚的上一版本')
  338. const record = readJSON(recordPath)
  339. // Manual rollback never restores an older snapshot over data written since
  340. // the successful upgrade. Schema-changing releases need a separate recovery.
  341. assertRollbackCompatible(options.home, record)
  342. const finalRoot = record.previousCurrentTarget || record.previousServiceRoot
  343. if (!finalRoot || !existsSync(finalRoot)) fail('上一版本的程序目录已不可用')
  344. updateJob(jobPath, { state: 'rolling_back', message: '正在恢复上一版本' })
  345. await transitionService({ ...options, finalRoot, rollback: true })
  346. rmSync(recordPath)
  347. updateJob(jobPath, { state: 'rolled_back', message: '已回滚 Web 服务,保留现有数据' })
  348. }
  349. async function main() {
  350. const [, , command, flag, jobPathValue] = process.argv
  351. if (command !== 'run' || flag !== '--job' || !jobPathValue) fail('用法:hermes-yaoyao-updater run --job <path>')
  352. const jobPath = resolve(jobPathValue)
  353. const job = readJSON(jobPath)
  354. const unlock = updateMutex(job.plan.home || resolve(dirname(jobPath), '..'))
  355. try {
  356. if (job.operation === 'update') await runUpdate(jobPath, job)
  357. else if (job.operation === 'rollback') await runRollback(jobPath, job)
  358. else fail('未知升级任务类型')
  359. } catch (error) {
  360. updateJob(jobPath, {
  361. state: 'failed',
  362. message: job.operation === 'rollback' ? '回滚失败,需要人工处理' : '升级失败',
  363. error: error instanceof Error ? error.message : String(error),
  364. })
  365. } finally {
  366. const lockPath = join(dirname(jobPath), 'active.lock')
  367. try {
  368. const raw = readFileSync(lockPath, 'utf8').trim()
  369. let lockJobID = raw
  370. try { lockJobID = JSON.parse(raw).jobID } catch { /* legacy lock */ }
  371. if (lockJobID === job.id) rmSync(lockPath)
  372. } catch { /* already released */ }
  373. unlock()
  374. }
  375. }
  376. if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main()