prepare-seed.ts 8.5 KB

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