prepare-seed.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /** Build the release seed through the same embedded pnpm used on first launch. */
  2. import { spawn } from 'node:child_process'
  3. import { createHash } from 'node:crypto'
  4. import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { delimiter, dirname, join, relative, resolve, sep } from 'node:path'
  7. import { createSeedMetadata } from '../src/project-manager.ts'
  8. import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
  9. import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts'
  10. import {
  11. DESKTOP_HOST_PACKAGE,
  12. DESKTOP_HOST_RUNTIME_FILES,
  13. DESKTOP_PACKAGES_DIR,
  14. DESKTOP_PACKAGE_SET_FILE,
  15. readDesktopCorePackageSet,
  16. verifyDesktopCoreLockfile,
  17. } from '../src/core-package-set.ts'
  18. import {
  19. archivePnpmStore,
  20. extractPnpmStoreArchives,
  21. removePnpmProjectRegistrations,
  22. } from '../src/seed-store.ts'
  23. import {
  24. resolveDesktopAppId,
  25. resolveMacOSSigningEnvironment,
  26. } from './desktop-release-environment.mjs'
  27. import {
  28. signMacOSSeedStore,
  29. verifyMacOSSeedStore,
  30. } from './macos-seed-store.ts'
  31. import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
  32. const APP_ROOT = resolve(import.meta.dirname, '..')
  33. const BUILD_PATHS = resolveDesktopTargetBuildPaths()
  34. const SEED_OUTPUT_ROOT = BUILD_PATHS.seed
  35. const SEED_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-'))
  36. const STORE_ROOT = join(SEED_ROOT, 'store')
  37. const RUNTIME_ROOT = BUILD_PATHS.runtime
  38. const PNPM_BUILD_STATE = BUILD_PATHS.seedPnpm
  39. const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
  40. const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node')
  41. const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs')
  42. function manifestVersion(path: string, subject: string): string {
  43. const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
  44. if (typeof manifest.version !== 'string') throw new Error(`desktop seed: ${subject} has no version`)
  45. return manifest.version
  46. }
  47. function desktopRelease(): DesktopRelease {
  48. const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package')
  49. const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package')
  50. if (version !== dshVersion) {
  51. throw new Error(`desktop seed: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`)
  52. }
  53. const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record<string, unknown>
  54. return parseDesktopRelease({
  55. schemaVersion: 1,
  56. version,
  57. hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
  58. nodeVersion: runtime.node,
  59. pnpmVersion: runtime.pnpm,
  60. })
  61. }
  62. function runPnpm(args: readonly string[]): Promise<void> {
  63. return new Promise((resolvePromise, reject) => {
  64. const [command, ...commandArgs] = args
  65. if (command === undefined) throw new Error('desktop seed: pnpm command is required')
  66. const config = join(PNPM_BUILD_STATE, 'config')
  67. const userConfig = join(config, 'npmrc')
  68. mkdirSync(config, { recursive: true })
  69. writeFileSync(userConfig, '')
  70. const child = spawn(NODE, [
  71. PNPM,
  72. '--config.registry=https://registry.npmjs.org/',
  73. `--config.store-dir=${STORE_ROOT}`,
  74. '--config.enable-global-virtual-store=false',
  75. `--config.userconfig=${userConfig}`,
  76. command,
  77. ...commandArgs,
  78. ], {
  79. cwd: SEED_ROOT,
  80. env: {
  81. ...Object.fromEntries(Object.entries(process.env).filter(([name]) => (
  82. !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name)
  83. ))),
  84. NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/',
  85. NPM_CONFIG_STORE_DIR: STORE_ROOT,
  86. NPM_CONFIG_USERCONFIG: userConfig,
  87. PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`,
  88. XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'),
  89. XDG_CONFIG_HOME: config,
  90. XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'),
  91. },
  92. stdio: 'inherit',
  93. })
  94. child.once('error', reject)
  95. child.once('close', (code, signal) => {
  96. if (code === 0) resolvePromise()
  97. else reject(new Error(`desktop seed: pnpm exited with ${String(code ?? signal)}`))
  98. })
  99. })
  100. }
  101. function inventory(root: string): readonly { path: string; bytes: number; sha256: string }[] {
  102. const files: string[] = []
  103. const visit = (dir: string): void => {
  104. for (const entry of readdirSync(dir, { withFileTypes: true })) {
  105. const path = join(dir, entry.name)
  106. if (entry.isDirectory()) visit(path)
  107. else if (entry.isFile()) files.push(path)
  108. else throw new Error(`desktop seed: unsupported filesystem entry ${relative(root, path)}`)
  109. }
  110. }
  111. visit(root)
  112. return files.sort().map((path) => {
  113. const body = readFileSync(path)
  114. return {
  115. path: relative(root, path).split(sep).join('/'),
  116. bytes: statSync(path).size,
  117. sha256: createHash('sha256').update(body).digest('hex'),
  118. }
  119. })
  120. }
  121. async function verifyOfflineInstallation(release: DesktopRelease): Promise<void> {
  122. const installedModules = join(SEED_ROOT, 'node_modules')
  123. try {
  124. await runPnpm(['install', '--offline', '--frozen-lockfile', '--trust-lockfile'])
  125. const hostRoot = join(installedModules, ...DESKTOP_HOST_PACKAGE.split('/'))
  126. for (const file of DESKTOP_HOST_RUNTIME_FILES) {
  127. if (!existsSync(join(hostRoot, file))) {
  128. throw new Error(`desktop seed: local ${DESKTOP_HOST_PACKAGE}@${release.version} does not contain ${file}`)
  129. }
  130. }
  131. } finally {
  132. rmSync(installedModules, { recursive: true, force: true })
  133. }
  134. }
  135. async function main(): Promise<void> {
  136. rmSync(SEED_OUTPUT_ROOT, { recursive: true, force: true })
  137. rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
  138. mkdirSync(STORE_ROOT, { recursive: true })
  139. try {
  140. const release = desktopRelease()
  141. copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(SEED_ROOT, DESKTOP_PACKAGE_SET_FILE))
  142. cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(SEED_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
  143. createSeedMetadata(SEED_ROOT, release)
  144. await runPnpm(['install', '--lockfile-only'])
  145. verifyDesktopCoreLockfile(
  146. readFileSync(join(SEED_ROOT, 'pnpm-lock.yaml'), 'utf8'),
  147. readDesktopCorePackageSet(SEED_ROOT, release.version),
  148. )
  149. const installedModules = join(SEED_ROOT, 'node_modules')
  150. await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile', '--ignore-scripts'])
  151. rmSync(installedModules, { recursive: true, force: true })
  152. rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
  153. await verifyOfflineInstallation(release)
  154. const targetPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.platform
  155. let signedMachOFiles: number | undefined
  156. let macOSSigning: ReturnType<typeof resolveMacOSSigningEnvironment> | undefined
  157. if (targetPlatform === 'darwin') {
  158. macOSSigning = resolveMacOSSigningEnvironment(process.env)
  159. const signing = await signMacOSSeedStore(
  160. STORE_ROOT,
  161. resolveDesktopAppId(process.env),
  162. macOSSigning,
  163. )
  164. signedMachOFiles = signing.signedFiles
  165. process.stdout.write(
  166. `desktop seed: signed ${signing.signedFiles} Mach-O files, updated ${signing.updatedIndexRows} pnpm index records, and pruned ${signing.prunedOrphans} native orphans\n`,
  167. )
  168. await verifyOfflineInstallation(release)
  169. }
  170. removePnpmProjectRegistrations(STORE_ROOT)
  171. archivePnpmStore(SEED_ROOT, STORE_ROOT)
  172. if (macOSSigning !== undefined && signedMachOFiles !== undefined) {
  173. const extractedStore = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-verification-'))
  174. try {
  175. extractPnpmStoreArchives(SEED_ROOT, extractedStore)
  176. const verified = verifyMacOSSeedStore(extractedStore, macOSSigning)
  177. if (verified !== signedMachOFiles) {
  178. throw new Error(`desktop seed: archived store contains ${verified} signed Mach-O files; expected ${signedMachOFiles}`)
  179. }
  180. } finally {
  181. rmSync(extractedStore, { recursive: true, force: true })
  182. }
  183. }
  184. const records = inventory(SEED_ROOT).filter(entry => entry.path !== 'integrity.json')
  185. writeFileSync(join(SEED_ROOT, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files: records }, undefined, 2)}\n`)
  186. cpSync(SEED_ROOT, SEED_OUTPUT_ROOT, { recursive: true })
  187. } finally {
  188. rmSync(SEED_ROOT, { recursive: true, force: true })
  189. }
  190. }
  191. await main()