desktop-upload-plan.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. /** Validate packaged Desktop update artifacts before any network upload begins. */
  2. import { createHash } from 'node:crypto'
  3. import { createReadStream } from 'node:fs'
  4. import { readFile, stat } from 'node:fs/promises'
  5. import { basename, join, resolve } from 'node:path'
  6. import { dump, load } from 'js-yaml'
  7. import { prerelease } from 'semver'
  8. import type { DesktopPackageTargetName } from './package-target.ts'
  9. import {
  10. desktopBuildRecordFilename,
  11. desktopUpdateMetadataFilename,
  12. resolveDesktopUploadConfig,
  13. } from './desktop-auto-update-environment.mjs'
  14. import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
  15. const APP_ROOT = resolve(import.meta.dirname, '..')
  16. const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
  17. const TARGETS = {
  18. 'mac-arm64': { platform: 'darwin', arch: 'arm64', os: 'mac' },
  19. 'mac-x64': { platform: 'darwin', arch: 'x64', os: 'mac' },
  20. 'win-x64': { platform: 'win32', arch: 'x64', os: 'win' },
  21. } as const satisfies Record<DesktopPackageTargetName, {
  22. readonly platform: NodeJS.Platform
  23. readonly arch: string
  24. readonly os: string
  25. }>
  26. /** One local file and its final object metadata. */
  27. export interface DesktopUploadArtifact {
  28. readonly path: string
  29. readonly filename: string
  30. readonly key: string
  31. readonly contentType: string
  32. readonly channelMetadata: boolean
  33. /** Published YAML with normalized artifact URLs; binary bytes remain file-backed. */
  34. readonly contents?: string
  35. }
  36. /** A fully validated upload operation with channel metadata ordered last. */
  37. export interface DesktopUploadPlan {
  38. readonly environment: 'test' | 'production'
  39. readonly target: DesktopPackageTargetName
  40. readonly version: string
  41. readonly publicUrl: string
  42. readonly bucket: string
  43. readonly secretIdEnvName: string
  44. readonly secretKeyEnvName: string
  45. readonly artifacts: readonly DesktopUploadArtifact[]
  46. }
  47. /** Filesystem and environment inputs used to validate one upload. */
  48. export interface DesktopUploadPlanOptions {
  49. readonly environment?: NodeJS.ProcessEnv
  50. readonly repositoryRoot?: string
  51. readonly appRoot?: string
  52. readonly artifactsRoot?: string
  53. }
  54. interface UpdateFileInfo {
  55. readonly filename: string
  56. readonly size: number
  57. readonly sha512: string
  58. }
  59. function object(value: unknown, label: string): Record<string, unknown> {
  60. if (typeof value !== 'object' || value === null || Array.isArray(value)) {
  61. throw new Error(`desktop upload: ${label} must be an object`)
  62. }
  63. return value as Record<string, unknown>
  64. }
  65. function stringField(value: unknown, label: string): string {
  66. if (typeof value !== 'string' || value === '') {
  67. throw new Error(`desktop upload: ${label} must be a non-empty string`)
  68. }
  69. return value
  70. }
  71. function numberField(value: unknown, label: string): number {
  72. if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
  73. throw new Error(`desktop upload: ${label} must be a positive integer`)
  74. }
  75. return value
  76. }
  77. async function jsonFile(path: string, label: string): Promise<Record<string, unknown>> {
  78. let parsed: unknown
  79. try {
  80. parsed = JSON.parse(await readFile(path, 'utf8'))
  81. }
  82. catch (error) {
  83. throw new Error(`desktop upload: cannot read ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}`)
  84. }
  85. return object(parsed, label)
  86. }
  87. async function manifestVersion(path: string, label: string): Promise<string> {
  88. return stringField((await jsonFile(path, label)).version, `${label}.version`)
  89. }
  90. function updateFileInfo(value: unknown, label: string, expectedFilename: string): UpdateFileInfo {
  91. const info = object(value, label)
  92. const filename = stringField(info.url ?? info.path, `${label}.url`)
  93. if (filename !== basename(filename) || filename !== expectedFilename) {
  94. throw new Error(`desktop upload: ${label} must reference ${expectedFilename}, received ${filename}`)
  95. }
  96. return {
  97. filename,
  98. size: numberField(info.size, `${label}.size`),
  99. sha512: stringField(info.sha512, `${label}.sha512`),
  100. }
  101. }
  102. async function sha512(path: string): Promise<string> {
  103. const hash = createHash('sha512')
  104. for await (const chunk of createReadStream(path)) hash.update(chunk)
  105. return hash.digest('base64')
  106. }
  107. async function verifyChecksummedArtifact(
  108. artifactsRoot: string,
  109. info: UpdateFileInfo,
  110. ): Promise<string> {
  111. const path = join(artifactsRoot, info.filename)
  112. const details = await stat(path).catch(() => undefined)
  113. if (details === undefined || !details.isFile()) {
  114. throw new Error(`desktop upload: missing artifact ${path}`)
  115. }
  116. if (details.size !== info.size) {
  117. throw new Error(`desktop upload: ${info.filename} size ${details.size} does not match update metadata ${info.size}`)
  118. }
  119. const actual = await sha512(path)
  120. if (actual !== info.sha512) {
  121. throw new Error(`desktop upload: ${info.filename} SHA-512 does not match update metadata`)
  122. }
  123. return path
  124. }
  125. async function requireArtifact(artifactsRoot: string, filename: string): Promise<string> {
  126. const path = join(artifactsRoot, filename)
  127. const details = await stat(path).catch(() => undefined)
  128. if (details === undefined || !details.isFile() || details.size === 0) {
  129. throw new Error(`desktop upload: missing or empty artifact ${path}`)
  130. }
  131. return path
  132. }
  133. function uploadArtifact(
  134. path: string,
  135. keyPrefix: string,
  136. contentType: string,
  137. channelMetadata = false,
  138. ): DesktopUploadArtifact {
  139. const filename = basename(path)
  140. return {
  141. path,
  142. filename,
  143. key: `${keyPrefix}/${filename}`,
  144. contentType,
  145. channelMetadata,
  146. }
  147. }
  148. /**
  149. * Validate the completed package record, dsh version, update metadata, hashes, and target files.
  150. * @param targetName - Fixed platform and architecture selected by the upload command.
  151. * @param options - Optional filesystem roots and environment for tests or release automation.
  152. * @returns An upload plan whose mutable channel metadata is the final entry.
  153. */
  154. export async function createDesktopUploadPlan(
  155. targetName: DesktopPackageTargetName,
  156. options: DesktopUploadPlanOptions = {},
  157. ): Promise<DesktopUploadPlan> {
  158. const target = TARGETS[targetName]
  159. if (target === undefined) {
  160. throw new Error(`desktop upload: unsupported target ${String(targetName)}`)
  161. }
  162. const environment = options.environment ?? process.env
  163. const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT
  164. const appRoot = options.appRoot ?? APP_ROOT
  165. const artifactsRoot = options.artifactsRoot ?? desktopTargetBuildPaths(targetName).artifacts
  166. const dshVersion = await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package')
  167. const desktopVersion = await manifestVersion(join(appRoot, 'package.json'), 'desktop package')
  168. if (dshVersion !== desktopVersion) {
  169. throw new Error(`desktop upload: desktop version ${desktopVersion} does not match current dsh version ${dshVersion}`)
  170. }
  171. const update = resolveDesktopUploadConfig(environment, target.platform, target.arch)
  172. const buildRecord = await jsonFile(
  173. join(artifactsRoot, desktopBuildRecordFilename(targetName)),
  174. `${targetName} package completion record`,
  175. )
  176. if (buildRecord.schemaVersion !== 1
  177. || buildRecord.target !== targetName
  178. || buildRecord.version !== dshVersion
  179. || buildRecord.environment !== update.environment
  180. || buildRecord.publicUrl !== update.publicUrl) {
  181. throw new Error(`desktop upload: ${targetName} package completion record does not match dsh ${dshVersion} and ${update.environment} update destination`)
  182. }
  183. const metadataFilename = desktopUpdateMetadataFilename(dshVersion, target.platform)
  184. const metadataPath = join(artifactsRoot, metadataFilename)
  185. let metadataValue: unknown
  186. try {
  187. metadataValue = load(await readFile(metadataPath, 'utf8'))
  188. }
  189. catch (error) {
  190. throw new Error(`desktop upload: cannot read update metadata at ${metadataPath}: ${error instanceof Error ? error.message : String(error)}`)
  191. }
  192. const metadata = object(metadataValue, metadataFilename)
  193. const metadataVersion = stringField(metadata.version, `${metadataFilename}.version`)
  194. if (metadataVersion !== dshVersion) {
  195. throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match current dsh version ${dshVersion}`)
  196. }
  197. if (!Array.isArray(metadata.files) || metadata.files.length !== 1) {
  198. throw new Error(`desktop upload: ${metadataFilename}.files must contain exactly one target update file`)
  199. }
  200. const base = `deepseek-harness-${dshVersion}-${target.os}-${target.arch}`
  201. const updaterExtension = target.platform === 'darwin' ? 'zip' : 'exe'
  202. const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`)
  203. const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo)
  204. const artifacts: DesktopUploadArtifact[] = []
  205. const binaryPrefix = `dsh-desk/bin/${targetName}`
  206. if (target.platform === 'darwin') {
  207. const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`)
  208. const blockmapPath = await requireArtifact(artifactsRoot, `${base}.zip.blockmap`)
  209. artifacts.push(
  210. uploadArtifact(dmgPath, binaryPrefix, 'application/x-apple-diskimage'),
  211. uploadArtifact(updaterPath, binaryPrefix, 'application/zip'),
  212. uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'),
  213. )
  214. }
  215. else {
  216. const blockmapPath = await requireArtifact(artifactsRoot, `${base}.exe.blockmap`)
  217. artifacts.push(uploadArtifact(
  218. updaterPath,
  219. binaryPrefix,
  220. 'application/vnd.microsoft.portable-executable',
  221. ))
  222. artifacts.push(uploadArtifact(blockmapPath, binaryPrefix, 'application/octet-stream'))
  223. }
  224. const payloadUrl = `${update.origin}/${binaryPrefix}/${updaterInfo.filename}`
  225. const published = {
  226. ...metadata,
  227. files: [{ ...object(metadata.files[0], `${metadataFilename}.files[0]`), url: payloadUrl }],
  228. ...(metadata.path === undefined ? {} : { path: payloadUrl }),
  229. }
  230. const channelArtifact = {
  231. ...uploadArtifact(metadataPath, update.keyPrefix, 'application/yaml', true),
  232. contents: dump(published),
  233. }
  234. artifacts.push(channelArtifact)
  235. if (prerelease(dshVersion) === null) {
  236. const stableFilename = metadataFilename.replace('nightly', 'latest')
  237. artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` })
  238. }
  239. return {
  240. environment: update.environment,
  241. target: targetName,
  242. version: dshVersion,
  243. publicUrl: update.publicUrl,
  244. bucket: update.bucket,
  245. secretIdEnvName: update.secretIdEnvName,
  246. secretKeyEnvName: update.secretKeyEnvName,
  247. artifacts,
  248. }
  249. }