runtime-release.mjs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. import { createHash } from 'node:crypto'
  2. import { execFileSync } from 'node:child_process'
  3. import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'
  4. import { join, relative, resolve } from 'node:path'
  5. const digest = value => createHash('sha256').update(value).digest('hex')
  6. export const readJSON = path => JSON.parse(readFileSync(path, 'utf8'))
  7. export function sourceBuild(root) {
  8. try {
  9. const git = args => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
  10. const ancestors = git(['rev-list', 'HEAD']).split('\n')
  11. return { commit: ancestors[0], ancestors, buildNumber: ancestors.length, dirty: Boolean(git(['status', '--porcelain'])) }
  12. } catch { return { commit: 'source-archive', ancestors: [], buildNumber: 0, dirty: true } }
  13. }
  14. export function readRuntime(root) {
  15. const release = readJSON(join(root, 'release.json'))
  16. if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(release.webVersion) || release.webVersion !== release.releaseVersion || release.gitTag !== `v${release.webVersion}`)
  17. throw new Error('服务发布清单无效')
  18. const build = existsSync(join(root, 'build-info.json')) ? readJSON(join(root, 'build-info.json')) : sourceBuild(root)
  19. const manifest = existsSync(join(root, 'runtime-manifest.json')) ? readJSON(join(root, 'runtime-manifest.json')) : undefined
  20. return { ...build, version: release.webVersion, release, hasBuildIdentity: Number.isSafeInteger(build.buildNumber) && existsSync(join(root, 'build-info.json')), artifactDigest: manifest?.artifactDigest }
  21. }
  22. export function compareVersions(a, b) {
  23. const parts = value => { const split = value.indexOf('-'); return [value.slice(0, split < 0 ? undefined : split).split('.').map(Number), split < 0 ? undefined : value.slice(split + 1).split('.')] }
  24. const [ac, as] = parts(a), [bc, bs] = parts(b)
  25. for (let i = 0; i < 3; i++) if (ac[i] !== bc[i]) return ac[i] > bc[i] ? 1 : -1
  26. if (!as || !bs) return as ? -1 : bs ? 1 : 0
  27. for (let i = 0; i < Math.max(as.length, bs.length); i++) {
  28. if (as[i] === bs[i]) continue
  29. if (as[i] === undefined || bs[i] === undefined) return as[i] === undefined ? -1 : 1
  30. const an = /^\d+$/.test(as[i]), bn = /^\d+$/.test(bs[i])
  31. if (an && bn) return Number(as[i]) > Number(bs[i]) ? 1 : -1
  32. if (an !== bn) return an ? -1 : 1
  33. return as[i] > bs[i] ? 1 : -1
  34. }
  35. return 0
  36. }
  37. /** Commit hashes are identities, never sortable version numbers. Unknown branch
  38. * ancestry must not turn an App install into an accidental downgrade. */
  39. export function syncDecision(target, current) {
  40. if (!current) return 'install'
  41. const version = compareVersions(target.version, current.version)
  42. if (version) return version > 0 ? 'upgrade' : 'newer'
  43. if (target.artifactDigest && target.artifactDigest === current.artifactDigest) return 'same'
  44. if (target.commit === current.commit && !target.dirty && !current.dirty) return 'same'
  45. if (target.ancestors?.includes(current.commit) && target.commit !== current.commit) return 'upgrade'
  46. if (current.ancestors?.includes(target.commit) && target.commit !== current.commit) return 'newer'
  47. return 'unknown'
  48. }
  49. export function runtimeFiles(root, prefix = '') {
  50. const files = {}
  51. for (const name of readdirSync(join(root, prefix)).sort()) {
  52. const path = join(prefix, name), stat = lstatSync(join(root, path))
  53. if (path === 'runtime-manifest.json') continue
  54. if (stat.isSymbolicLink()) throw new Error('服务包不得包含符号链接')
  55. if (stat.isDirectory()) Object.assign(files, runtimeFiles(root, path))
  56. else if (stat.isFile()) files[path] = digest(readFileSync(join(root, path)))
  57. else throw new Error('服务包包含不支持的文件')
  58. }
  59. return files
  60. }
  61. export function sealRuntime(root) {
  62. const files = runtimeFiles(root), artifactDigest = digest(JSON.stringify(files))
  63. writeFileSync(join(root, 'runtime-manifest.json'), JSON.stringify({ schemaVersion: 1, artifactDigest, files }, null, 2) + '\n')
  64. return artifactDigest
  65. }
  66. export function verifyRuntimePackage(root) {
  67. const manifest = readJSON(join(root, 'runtime-manifest.json')), files = runtimeFiles(root)
  68. if (manifest.schemaVersion !== 1 || digest(JSON.stringify(files)) !== manifest.artifactDigest || JSON.stringify(files) !== JSON.stringify(manifest.files))
  69. throw new Error('App 配套服务包校验失败,请重新安装完整 App')
  70. for (const file of ['node', 'server.mjs', 'ui/index.html', 'bin/hermes-yaoyao.mjs', 'bin/hermes-yaoyao-updater.mjs', 'build-info.json'])
  71. if (!files[file]) throw new Error(`App 配套服务包缺少 ${file}`)
  72. return readRuntime(root)
  73. }
  74. export function inside(path, root) {
  75. const result = relative(realpathSync(root), resolve(path))
  76. return result !== '' && result !== '..' && !result.startsWith('../') && !result.startsWith('/')
  77. }