npm-release.mjs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. import { execFile } from 'node:child_process'
  2. import { createHash } from 'node:crypto'
  3. import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync } from 'node:fs'
  4. import { basename, dirname, join, sep } from 'node:path'
  5. export const NPM_PACKAGE = '@lsamien/yaoyao'
  6. export const NPM_RELEASE_SOURCE = `npm:${NPM_PACKAGE}`
  7. const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
  8. export function isNpmInstallation(root) {
  9. if (!root.includes(`${sep}node_modules${sep}`) || existsSync(join(root, '.git'))) return false
  10. try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name === NPM_PACKAGE }
  11. catch { return false }
  12. }
  13. export function parseNpmRelease(value) {
  14. if (!value || value.name !== NPM_PACKAGE || typeof value.version !== 'string' || !versionPattern.test(value.version))
  15. throw new Error('npm 发布包名称或版本无效')
  16. const { integrity, tarball } = value.dist ?? {}
  17. if (typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity))
  18. throw new Error('npm 发布包缺少 SHA-512 完整性信息')
  19. let url
  20. try { url = new URL(tarball) } catch { throw new Error('npm 下载地址无效') }
  21. if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
  22. throw new Error('npm 下载地址无效')
  23. return { version: value.version, integrity, tarball: url.href }
  24. }
  25. export function inspectNpmRelease(cwd) {
  26. return new Promise((resolve, reject) => {
  27. execFile('npm', ['view', `${NPM_PACKAGE}@latest`, '--json'], {
  28. cwd, encoding: 'utf8', timeout: 60000, maxBuffer: 4 * 1024 * 1024,
  29. }, (error, stdout) => {
  30. if (error) { reject(new Error('无法读取 npm 最新版本,请检查仓库连接和 npm 配置')); return }
  31. try { resolve(parseNpmRelease(JSON.parse(stdout))) } catch (cause) { reject(cause) }
  32. })
  33. })
  34. }
  35. export function npmInstallationLocation(root) {
  36. root = realpathSync(root)
  37. const modules = dirname(dirname(root)), prefix = dirname(modules)
  38. if (basename(modules) !== 'node_modules' || root !== join(modules, NPM_PACKAGE) || !isNpmInstallation(root))
  39. throw new Error('直接覆盖更新仅支持已核对的 npm 安装目录')
  40. // Unix global installs use <prefix>/lib/node_modules; managed local installs
  41. // use <prefix>/node_modules. Preserve the layout and unrelated global tools.
  42. return basename(prefix) === 'lib' ? { prefix: dirname(prefix), global: true } : { prefix, global: false }
  43. }
  44. export function verifyPreparedNpmArchive(home, prepared, npm) {
  45. const downloads = realpathSync(join(home, 'updates', 'npm'))
  46. const archive = realpathSync(prepared.archive), directory = basename(dirname(archive))
  47. if (!/^[0-9a-f-]{36}$/.test(directory) || archive !== join(downloads, directory, 'release.tgz') || !lstatSync(archive).isFile())
  48. throw new Error('已下载更新的文件位置无效')
  49. const hash = createHash('sha512'), descriptor = openSync(archive, 'r'), buffer = Buffer.alloc(1024 * 1024)
  50. try {
  51. let bytes, received = 0
  52. while ((bytes = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) {
  53. received += bytes
  54. if (received > 512 * 1024 * 1024) throw new Error('npm 安装包超过下载大小限制')
  55. hash.update(buffer.subarray(0, bytes))
  56. }
  57. } finally { closeSync(descriptor) }
  58. if (`sha512-${hash.digest('base64')}` !== npm.integrity) throw new Error('已下载更新的安装包完整性校验失败')
  59. return archive
  60. }
  61. /** Validate the installed application's version, build and published files. */
  62. export function npmRuntimeDigest(root, version) {
  63. const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
  64. const release = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8'))
  65. const build = JSON.parse(readFileSync(join(root, 'build-info.json'), 'utf8'))
  66. if (packageJson.name !== NPM_PACKAGE || packageJson.version !== version
  67. || release.schemaVersion !== 1 || release.releaseVersion !== version || release.webVersion !== version || release.gitTag !== `v${version}`)
  68. throw new Error('npm 包与目标发布版本不一致')
  69. if (!/^[a-f0-9]{40,64}$/.test(build.commit) || !Number.isSafeInteger(build.buildNumber))
  70. throw new Error('npm 包缺少有效的构建信息')
  71. for (const file of ['dist/index.html', 'dist-server/server/index.js', 'bin/hermes-yaoyao.mjs', 'bin/hermes-yaoyao-updater.mjs']) {
  72. if (!lstatSync(join(root, file)).isFile()) throw new Error(`npm 包缺少服务文件:${file}`)
  73. }
  74. const hash = createHash('sha256')
  75. function visit(directory, prefix = '') {
  76. for (const name of readdirSync(directory).sort()) {
  77. if (name === 'node_modules') continue
  78. const path = join(directory, name), relative = `${prefix}${name}`, stat = lstatSync(path)
  79. if (stat.isDirectory()) visit(path, `${relative}/`)
  80. else if (stat.isFile()) hash.update(relative).update('\0').update(createHash('sha256').update(readFileSync(path)).digest())
  81. else throw new Error('npm 服务文件包含不支持的链接或文件')
  82. }
  83. }
  84. visit(root)
  85. return hash.digest('hex')
  86. }