prepare-primary-runtime.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. /** Prepare pinned, relocatable script interpreters without installing into the build host. */
  2. import { execFileSync } from 'node:child_process'
  3. import { createHash } from 'node:crypto'
  4. import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  5. import { cp } from 'node:fs/promises'
  6. import { createRequire } from 'node:module'
  7. import { tmpdir } from 'node:os'
  8. import { dirname, join } from 'node:path'
  9. import extractZip from 'extract-zip'
  10. import { x as extractTar } from 'tar'
  11. import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../desktop-host/src/primary-runtime.ts'
  12. import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
  13. import lock from './primary-runtime-lock.json' with { type: 'json' }
  14. /**
  15. * Download or reuse an archive only when its bytes match the release lock.
  16. * @param url - Locked archive URL.
  17. * @param sha256 - Expected SHA-256 digest.
  18. * @param cache - Download cache directory.
  19. * @returns Verified local archive path.
  20. */
  21. export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise<string> {
  22. const destination = join(cache, sha256)
  23. let bytes: Buffer
  24. try { bytes = readFileSync(destination) } catch (error) {
  25. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  26. const response = await fetch(url)
  27. if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`)
  28. bytes = Buffer.from(await response.arrayBuffer())
  29. }
  30. if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`)
  31. writeFileSync(destination, bytes)
  32. return destination
  33. }
  34. async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise<string> {
  35. const artifact = lock.targets[target]
  36. const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz`
  37. return downloadPrimaryRuntimeAsset(`https://github.com/astral-sh/python-build-standalone/releases/download/${lock.pythonRelease}/${encodeURIComponent(filename)}`, artifact.pythonSha256, cache)
  38. }
  39. /**
  40. * Identify the inputs that assemble one target's payload, excluding unrelated target locks.
  41. * @param target - Desktop target whose archives are installed.
  42. * @param runtimeLock - Locked interpreter and wheel inputs.
  43. * @param pnpmVersion - Package-manager version copied into the payload.
  44. * @returns SHA-256 payload identity for installation reuse.
  45. */
  46. export function primaryRuntimePayloadDigest(target: keyof typeof lock.targets, runtimeLock: typeof lock, pnpmVersion: string): string {
  47. const { pythonVersion, pythonRelease, nodeVersion, wheels, pythonPackages } = runtimeLock
  48. // Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order.
  49. // Bump format when extraction or assembly changes payload bytes without changing locked inputs.
  50. return createHash('sha256').update(JSON.stringify({
  51. format: 2, target, pythonVersion, pythonRelease, nodeVersion,
  52. artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion,
  53. })).digest('hex')
  54. }
  55. /**
  56. * Unpack a locked library wheel, retaining auxiliary scripts in its distribution data directory.
  57. * @param archive - Hash-verified wheel archive.
  58. * @param destination - Absolute site-packages directory.
  59. * @returns Resolves after extraction without command wrappers; rejects other wheel installation schemes.
  60. */
  61. export async function unpackPrimaryRuntimeWheel(archive: string, destination: string): Promise<void> {
  62. await extractZip(archive, {
  63. dir: destination,
  64. onEntry: (entry) => {
  65. const [directory, scheme] = entry.fileName.split('/')
  66. if (directory?.endsWith('.data') && scheme !== '' && scheme !== 'scripts') {
  67. throw new Error(`primary runtime: wheel requires unsupported installation paths: ${entry.fileName}`)
  68. }
  69. },
  70. })
  71. }
  72. /**
  73. * Materialize the selected Desktop target's primary runtime in its build resources.
  74. * @returns Resolves after dependency installation and native-target execution checks.
  75. */
  76. export async function preparePrimaryRuntime(): Promise<void> {
  77. const target = resolveDesktopBuildTarget()
  78. const paths = resolveDesktopTargetBuildPaths()
  79. const artifact = lock.targets[target]
  80. mkdirSync(paths.runtime, { recursive: true })
  81. mkdirSync(paths.downloads, { recursive: true })
  82. const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-'))
  83. try {
  84. const output = join(staging, 'payload')
  85. const dependencies = join(output, 'dependencies')
  86. mkdirSync(dependencies, { recursive: true })
  87. const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}`
  88. const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads)
  89. const unpackedNode = join(staging, 'node')
  90. mkdirSync(unpackedNode)
  91. if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode })
  92. else await extractTar({ file: nodeArchive, cwd: unpackedNode })
  93. const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, ''))
  94. mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true })
  95. mkdirSync(join(dependencies, 'node', 'node_modules'))
  96. writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n')
  97. cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])),
  98. join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node'))
  99. cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE'))
  100. await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies })
  101. const require = createRequire(import.meta.url)
  102. const pnpmManifest = require.resolve('pnpm')
  103. const pnpm = JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }
  104. await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true })
  105. const desktop = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')) as { version: string }
  106. const manifest: PrimaryRuntimeManifest = {
  107. desktopVersion: desktop.version,
  108. platform: target === 'win-x64' ? 'win32' : 'darwin',
  109. arch: target === 'mac-arm64' ? 'arm64' : 'x64',
  110. payloadDigest: primaryRuntimePayloadDigest(target, lock, pnpm.version),
  111. pythonPackages: lock.pythonPackages,
  112. components: {
  113. python: lock.pythonVersion, node: lock.nodeVersion, pnpm: pnpm.version,
  114. numpy: lock.pythonPackages.numpy, pandas: lock.pythonPackages.pandas,
  115. },
  116. }
  117. const entries = workspaceDependencyPaths(output, manifest)
  118. for (const wheel of [...artifact.wheels, ...lock.wheels]) {
  119. await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages)
  120. }
  121. writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
  122. const destination = join(paths.runtime, 'primary-runtime')
  123. rmSync(destination, { recursive: true, force: true })
  124. await cp(output, destination, { recursive: true, dereference: true })
  125. } finally {
  126. rmSync(staging, { recursive: true, force: true })
  127. }
  128. smokePrimaryRuntime(join(paths.runtime, 'primary-runtime'))
  129. }
  130. /**
  131. * Execute the native payload's interpreters, package manager and Python libraries.
  132. * @param root - Final payload directory, including any platform signatures.
  133. */
  134. export function smokePrimaryRuntime(root: string): void {
  135. const manifest = JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')) as PrimaryRuntimeManifest
  136. if (manifest.platform !== process.platform || manifest.arch !== process.arch) return
  137. if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.')
  138. const entries = workspaceDependencyPaths(root, manifest)
  139. const options = { stdio: 'inherit', timeout: 120_000 } as const
  140. execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), manifest.components.python], options)
  141. execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options)
  142. execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options)
  143. execFileSync(entries.node, [entries.pnpm, '--version'], options)
  144. }
  145. if (import.meta.main) await preparePrimaryRuntime()