prepare-package-set.ts 6.8 KB

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