prepare-package-set.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. /** Select and copy the local npm tarball closures that supply Desktop dsh and its private Host. */
  2. import { createHash } from 'node:crypto'
  3. import {
  4. constants,
  5. copyFileSync,
  6. globSync,
  7. mkdirSync,
  8. readFileSync,
  9. readdirSync,
  10. rmSync,
  11. statSync,
  12. writeFileSync,
  13. } from 'node:fs'
  14. import { basename, join, resolve } from 'node:path'
  15. import { parseArgs } from 'node:util'
  16. import * as yaml from 'js-yaml'
  17. import {
  18. DESKTOP_HOST_PACKAGE,
  19. DESKTOP_HOST_RUNTIME_FILES,
  20. DESKTOP_PACKAGES_DIR,
  21. DESKTOP_PACKAGE_SET_FILE,
  22. parseDesktopCorePackageSet,
  23. type DesktopCorePackageRecord,
  24. } from '../src/core-package-set.ts'
  25. import { capture } from '../../../scripts/release/process.ts'
  26. import { tarballFiles } from '../../../scripts/release/tarball.ts'
  27. import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
  28. const DSH_PACKAGE = '@deepseek-ai/dsh'
  29. const ROOT_PACKAGES = [DSH_PACKAGE, DESKTOP_HOST_PACKAGE] as const
  30. const APP_ROOT = resolve(import.meta.dirname, '..')
  31. const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
  32. const REQUIRED_DEPENDENCY_SECTIONS = ['dependencies', 'peerDependencies'] as const
  33. const OPTIONAL_DEPENDENCY_SECTION = 'optionalDependencies'
  34. /** Packed package information needed to form the local Desktop closure. */
  35. export interface PackedDesktopPackage {
  36. readonly tarball: string
  37. readonly manifest: Readonly<Record<string, unknown>>
  38. }
  39. function dependencyNames(manifest: Readonly<Record<string, unknown>>, section: string): string[] {
  40. const value = manifest[section]
  41. if (value === undefined) return []
  42. if (value === null || typeof value !== 'object' || Array.isArray(value)) {
  43. throw new Error(`desktop package set: ${String(manifest.name)} has invalid ${section}`)
  44. }
  45. return Object.keys(value).sort()
  46. }
  47. /**
  48. * Select workspace dependencies rooted at dsh and its private Host; npm resolves external packages.
  49. * Reads the repository workspace manifest and package manifests to distinguish required local packages from npm-resolved externals.
  50. * @param available - Packed packages indexed by package name.
  51. * @returns Selected packages sorted by name.
  52. */
  53. export function selectDesktopPackageClosure(
  54. available: ReadonlyMap<string, PackedDesktopPackage>,
  55. ): PackedDesktopPackage[] {
  56. const workspace = yaml.load(readFileSync(join(REPOSITORY_ROOT, 'pnpm-workspace.yaml'), 'utf8')) as { packages: string[] }
  57. const workspaceNames = new Set(globSync(workspace.packages.map(pattern => `${pattern}/package.json`), { cwd: REPOSITORY_ROOT })
  58. .map(path => (JSON.parse(readFileSync(join(REPOSITORY_ROOT, path), 'utf8')) as { name: string }).name))
  59. const selected = new Map<string, PackedDesktopPackage>()
  60. const visit = (name: string): void => {
  61. if (selected.has(name)) return
  62. const packed = available.get(name)
  63. if (packed === undefined) throw new Error(`desktop package set: packed inputs omit required package ${name}`)
  64. selected.set(name, packed)
  65. for (const section of REQUIRED_DEPENDENCY_SECTIONS) {
  66. for (const dependency of dependencyNames(packed.manifest, section)) {
  67. if (available.has(dependency)) visit(dependency)
  68. else if (workspaceNames.has(dependency)) {
  69. throw new Error(`desktop package set: ${name} requires unpacked package ${dependency}`)
  70. }
  71. }
  72. }
  73. for (const dependency of dependencyNames(packed.manifest, OPTIONAL_DEPENDENCY_SECTION)) {
  74. if (available.has(dependency)) visit(dependency)
  75. }
  76. }
  77. for (const name of ROOT_PACKAGES) {
  78. if (!available.has(name)) throw new Error(`desktop package set: packed inputs omit ${name}`)
  79. visit(name)
  80. }
  81. return [...selected.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, packed]) => packed)
  82. }
  83. function packedManifest(tarball: string): Record<string, unknown> {
  84. const value: unknown = JSON.parse(capture('tar', ['-xOzf', tarball, 'package/package.json']))
  85. if (value === null || typeof value !== 'object' || Array.isArray(value)) {
  86. throw new Error(`desktop package set: ${tarball} has no package manifest`)
  87. }
  88. return value as Record<string, unknown>
  89. }
  90. function packedPackages(inputs: readonly string[]): Map<string, PackedDesktopPackage> {
  91. const available = new Map<string, PackedDesktopPackage>()
  92. for (const input of inputs) {
  93. const tarballs = readdirSync(input).filter(file => file.endsWith('.tgz')).sort()
  94. if (tarballs.length === 0) throw new Error(`desktop package set: ${input} contains no tarballs`)
  95. for (const file of tarballs) {
  96. const tarball = join(input, file)
  97. const manifest = packedManifest(tarball)
  98. const name = manifest.name
  99. if (typeof name !== 'string' || name === '') throw new Error(`desktop package set: ${tarball} has no package name`)
  100. if (available.has(name)) throw new Error(`desktop package set: duplicate packed package ${name}`)
  101. available.set(name, { tarball, manifest })
  102. }
  103. }
  104. return available
  105. }
  106. /**
  107. * Require every private Host file used before the Desktop profile can pass its health check.
  108. * @param files - Tarball paths rooted at `package/`.
  109. * @returns Nothing.
  110. */
  111. export function assertDesktopHostPackageFiles(files: readonly string[]): void {
  112. const available = new Set(files)
  113. const missing = DESKTOP_HOST_RUNTIME_FILES
  114. .map(file => `package/${file}`)
  115. .filter(file => !available.has(file))
  116. if (missing.length > 0) {
  117. throw new Error(`desktop package set: ${DESKTOP_HOST_PACKAGE} tarball omits required file(s): ${missing.join(', ')}`)
  118. }
  119. }
  120. /** Prepare a package set from release tarball directories. */
  121. export function prepareDesktopPackageSet(inputs: readonly string[], output: string): void {
  122. const selected = selectDesktopPackageClosure(packedPackages(inputs))
  123. const host = selected.find(packed => packed.manifest.name === DESKTOP_HOST_PACKAGE)
  124. if (host === undefined) throw new Error(`desktop package set: selected closure omits ${DESKTOP_HOST_PACKAGE}`)
  125. assertDesktopHostPackageFiles(tarballFiles(host.tarball))
  126. rmSync(output, { recursive: true, force: true })
  127. const packageDir = join(output, DESKTOP_PACKAGES_DIR)
  128. mkdirSync(packageDir, { recursive: true })
  129. const records: DesktopCorePackageRecord[] = selected.map((packed) => {
  130. const name = packed.manifest.name
  131. const version = packed.manifest.version
  132. if (typeof name !== 'string' || typeof version !== 'string') {
  133. throw new Error(`desktop package set: ${packed.tarball} has no package identity`)
  134. }
  135. const file = basename(packed.tarball)
  136. const destination = join(packageDir, file)
  137. copyFileSync(packed.tarball, destination, constants.COPYFILE_EXCL)
  138. const body = readFileSync(destination)
  139. return {
  140. name,
  141. version,
  142. file,
  143. bytes: statSync(destination).size,
  144. integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`,
  145. }
  146. })
  147. const packageSet = parseDesktopCorePackageSet({ schemaVersion: 1, packages: records })
  148. writeFileSync(join(output, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify(packageSet, undefined, 2)}\n`, { mode: 0o600 })
  149. }
  150. function main(): void {
  151. const buildPaths = resolveDesktopTargetBuildPaths()
  152. const defaultInputs = [
  153. buildPaths.packedDsh,
  154. buildPaths.packedVendor,
  155. buildPaths.packedLandlock,
  156. ]
  157. const { values } = parseArgs({
  158. options: { from: { type: 'string', multiple: true }, out: { type: 'string' } },
  159. allowPositionals: false,
  160. })
  161. const inputs = (values.from ?? defaultInputs).map(path => resolve(REPOSITORY_ROOT, path))
  162. const output = values.out === undefined ? buildPaths.packageSet : resolve(REPOSITORY_ROOT, values.out)
  163. prepareDesktopPackageSet(inputs, output)
  164. console.log(`desktop package set: prepared ${output}`)
  165. }
  166. if (import.meta.main) main()