Files
deepseek-harness/apps/desktop/scripts/package-target.ts
T

286 lines
10 KiB
TypeScript

/** Build one release target with matching Electron, Node.js, and seed architecture. */
import { spawn } from 'node:child_process'
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { join, resolve } from 'node:path'
import {
desktopBuildRecordFilename,
resolveDesktopAutoUpdateConfig,
} from './desktop-auto-update-environment.mjs'
import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
const APP_ROOT = resolve(import.meta.dirname, '..')
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
const WINDOWS_SIGNING_ENV_PREFIX = 'DSH_DESKTOP_WINDOWS_'
const WINDOWS_SIGNING_ENV_NAMES = [
'DSH_DESKTOP_WINDOWS_CER_FILE',
'DSH_DESKTOP_WINDOWS_KEY_CONTAINER',
'DSH_DESKTOP_WINDOWS_SIGNTOOL',
'DSH_DESKTOP_WINDOWS_TOKEN_PIN',
] as const
const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
'DOWNLOAD_TEST_COS_SECRET_ID',
'DOWNLOAD_TEST_COS_SECRET_KEY',
'DOWNLOAD_PROD_COS_SECRET_ID',
'DOWNLOAD_PROD_COS_SECRET_KEY',
])
/** Fixed platform and architecture identifiers exposed by package scripts. */
export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64'
/** One supported release target and its electron-builder selectors. */
export interface DesktopPackageTarget {
readonly name: DesktopPackageTargetName
readonly platform: 'darwin' | 'win32'
readonly arch: 'arm64' | 'x64'
readonly builderPlatform: '--mac' | '--win'
readonly builderArch: '--arm64' | '--x64'
}
const TARGETS: Record<DesktopPackageTargetName, DesktopPackageTarget> = {
'mac-arm64': {
name: 'mac-arm64',
platform: 'darwin',
arch: 'arm64',
builderPlatform: '--mac',
builderArch: '--arm64',
},
'mac-x64': {
name: 'mac-x64',
platform: 'darwin',
arch: 'x64',
builderPlatform: '--mac',
builderArch: '--x64',
},
'win-x64': {
name: 'win-x64',
platform: 'win32',
arch: 'x64',
builderPlatform: '--win',
builderArch: '--x64',
},
}
/**
* Remove Windows signing configuration from package preparation subprocesses.
* @param environment - Packaging command environment.
* @returns A copy without Windows signing fields.
*/
export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return Object.fromEntries(Object.entries(environment)
.filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX)))
}
/**
* Remove upload-only COS credentials from every packaging subprocess.
* @param environment - Packaging command environment.
* @returns A copy without Desktop upload credentials.
*/
export function withoutDesktopUploadCredentials(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return Object.fromEntries(Object.entries(environment)
.filter(([name]) => !DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES.has(name)))
}
function isTargetName(value: string): value is DesktopPackageTargetName {
return Object.hasOwn(TARGETS, value)
}
function packageVersion(path: string, label: string): string {
const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }
if (typeof manifest.version !== 'string' || manifest.version === '') {
throw new Error(`desktop package: ${label} has no version`)
}
return manifest.version
}
function writeReleaseRecord(
target: DesktopPackageTarget,
environment: NodeJS.ProcessEnv,
artifactsRoot: string,
): void {
const desktopVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')
const dshVersion = packageVersion(join(REPOSITORY_ROOT, 'package.json'), 'dsh package')
if (desktopVersion !== dshVersion) {
throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`)
}
const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name))
const temporaryPath = `${recordPath}.tmp`
writeFileSync(temporaryPath, `${JSON.stringify({
schemaVersion: 1,
target: target.name,
version: dshVersion,
environment: update.environment,
publicUrl: update.publicUrl,
}, null, 2)}\n`)
renameSync(temporaryPath, recordPath)
}
/**
* Resolve a named release target and reject hosts that cannot execute its packaged runtime.
* @param name - One of the fixed Desktop release target names.
* @param hostPlatform - Build-host Node.js platform.
* @param hostArch - Build-host Node.js architecture.
* @returns The target selectors shared by runtime preparation and electron-builder.
*/
export function resolveDesktopPackageTarget(
name: string,
hostPlatform: NodeJS.Platform = process.platform,
hostArch: string = process.arch,
): DesktopPackageTarget {
if (!isTargetName(name)) {
throw new Error(`desktop package: unsupported target ${JSON.stringify(name)}; expected ${Object.keys(TARGETS).join(', ')}`)
}
const target = TARGETS[name]
if (target.platform === 'win32' && (hostPlatform !== 'win32' || hostArch !== 'x64')) {
throw new Error('desktop package: win-x64 requires a Windows x64 build host')
}
if (target.platform === 'darwin' && hostPlatform !== 'darwin') {
throw new Error(`desktop package: ${name} requires a macOS build host`)
}
if (name === 'mac-arm64' && hostArch !== 'arm64') {
throw new Error('desktop package: mac-arm64 requires an Apple Silicon build host')
}
if (name === 'mac-x64' && hostArch !== 'arm64' && hostArch !== 'x64') {
throw new Error('desktop package: mac-x64 requires an Intel Mac or Apple Silicon with Rosetta')
}
return target
}
interface DesktopPackageInvocation {
readonly target: DesktopPackageTarget
readonly directory: boolean
readonly prepareOnly: boolean
}
function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName {
const name = `${platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform}-${arch}`
if (!isTargetName(name)) throw new Error(`desktop package: unsupported build host ${platform}-${arch}`)
return name
}
/**
* Parse the fixed-target packaging command line.
* @param argv - Arguments after the script entry point.
* @param hostPlatform - Build-host Node.js platform.
* @param hostArch - Build-host Node.js architecture.
* @returns The validated target and whether to emit an unpacked directory.
*/
export function parseDesktopPackageInvocation(
argv: readonly string[],
hostPlatform: NodeJS.Platform = process.platform,
hostArch: string = process.arch,
): DesktopPackageInvocation {
const { values, positionals } = parseArgs({
args: [...argv],
allowPositionals: true,
options: {
dir: { type: 'boolean', default: false },
'prepare-only': { type: 'boolean', default: false },
},
})
if (positionals.length > 1) throw new Error('desktop package: expected at most one target')
const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch)
return {
target: resolveDesktopPackageTarget(name, hostPlatform, hostArch),
directory: values.dir,
prepareOnly: values['prepare-only'],
}
}
/**
* Build the electron-builder command arguments for one validated target.
* @param target - Supported release target.
* @param directory - Whether to stop at an unpacked application directory.
* @returns Arguments that keep publishing under the separate validated upload command.
*/
export function desktopElectronBuilderArguments(
target: DesktopPackageTarget,
directory: boolean,
): readonly string[] {
return [
'exec',
'electron-builder',
'--config',
'electron-builder.config.mjs',
target.builderPlatform,
target.builderArch,
'--publish',
'never',
...(directory ? ['--dir'] : []),
]
}
function runPnpm(
args: readonly string[],
env: NodeJS.ProcessEnv = process.env,
cwd: string = APP_ROOT,
): Promise<void> {
const pnpmEntry = process.env.npm_execpath
if (pnpmEntry === undefined || pnpmEntry === '') {
throw new Error('desktop package: invoke this script through a pnpm package command')
}
return new Promise((resolvePromise, reject) => {
const child = spawn(process.execPath, [pnpmEntry, ...args], {
cwd,
env,
stdio: 'inherit',
})
child.once('error', reject)
child.once('close', (code, signal) => {
if (code === 0) resolvePromise()
else reject(new Error(`desktop package: pnpm ${args.join(' ')} exited with ${String(code ?? signal)}`))
})
})
}
async function main(): Promise<void> {
const invocation = parseDesktopPackageInvocation(process.argv.slice(2))
const { target } = invocation
const buildPaths = desktopTargetBuildPaths(target.name)
const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
if (!invocation.prepareOnly) {
rmSync(releaseRecordPath, { force: true })
rmSync(`${releaseRecordPath}.tmp`, { force: true })
}
const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials(process.env))
const targetEnv: NodeJS.ProcessEnv = {
...buildEnv,
DSH_DESKTOP_TARGET_PLATFORM: target.platform,
DSH_DESKTOP_TARGET_ARCH: target.arch,
}
const electronBuilderEnv = { ...targetEnv }
for (const name of WINDOWS_SIGNING_ENV_NAMES) {
if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name]
}
await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT)
await runPnpm([
'--dir',
'apps/desktop-host',
'pack',
'--pack-destination',
buildPaths.packedDsh,
], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT)
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
mkdirSync(buildPaths.packedLandlock, { recursive: true })
await runPnpm(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
await runPnpm([
'--dir',
'native/system/packages/entry',
'pack',
'--pack-destination',
buildPaths.packedLandlock,
], buildEnv, REPOSITORY_ROOT)
await runPnpm(['run', 'prepare:runtime'], targetEnv)
await runPnpm(['run', 'prepare:packages'], targetEnv)
await runPnpm(['run', 'prepare:seed'], targetEnv)
if (invocation.prepareOnly) return
await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)
if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()