build-exe-for-python-sdk.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. /**
  2. * Build the dsh executables and development Node carrier for the Python runtime wheel. The fixed
  3. * `@yao-pkg/pkg --sea` route, deploy flags, and artifact layout are owned by
  4. * .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
  5. * The staged closure is symlink-free, and whole-tree assets cover Cordis's
  6. * runtime imports that pkg cannot discover statically.
  7. */
  8. import { spawn } from 'node:child_process'
  9. import { existsSync, statSync } from 'node:fs'
  10. import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
  11. import { basename, dirname, extname, join, resolve, sep } from 'node:path'
  12. import { parseArgs } from 'node:util'
  13. import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
  14. import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts'
  15. const root = resolve(import.meta.dirname, '..')
  16. /** The closure manifest whose dependencies define the executable. */
  17. const DEPLOY_ROOT_PACKAGE = 'dsh-python-runtime-closure'
  18. /** The sole executable entry inside the deployed closure. */
  19. const ENTRY_BIN = 'runtime-bootstrap.mjs'
  20. /** Python-visible executable basename. */
  21. const OUTPUT_BASENAME = 'deepseek-harness-sdk-runtime'
  22. /** Default Node major; SEA mode requires at least Node 22. */
  23. const DEFAULT_NODE_RANGE = 'node24'
  24. const OUT_DIR = 'dist-exe'
  25. /** Python package destination; created when absent. */
  26. const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime'
  27. /** The deployed closure doubles as the node-mode carrier. */
  28. const PYTHON_NODE_SUBDIR = 'node'
  29. /** Legacy deploy may hoist peer-specialized workspace packages back here. */
  30. const DEPLOY_SOURCE_NODE_MODULES = 'python/sdk-runtime/node_modules'
  31. /** Documentation excluded from the generated runtime directory. */
  32. const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
  33. /**
  34. * Whole-tree assets cover Cordis's runtime bare-package imports, which pkg's
  35. * static analysis cannot see. Package manifests are explicit because bare-name
  36. * resolution depends on them.
  37. */
  38. const ASSET_GLOBS = [
  39. 'package.json',
  40. 'node_modules/**/*.js',
  41. 'node_modules/**/*.cjs',
  42. 'node_modules/**/*.mjs',
  43. 'node_modules/**/package.json',
  44. 'node_modules/**/*.json',
  45. // Package-owned Markdown includes runtime skill instructions and badge content.
  46. 'node_modules/**/*.md',
  47. 'node_modules/**/*.dylib',
  48. 'node_modules/**/*.dll',
  49. 'node_modules/**/*.node',
  50. 'node_modules/**/*.so',
  51. 'node_modules/**/*.so.*',
  52. 'node_modules/**/*.wasm',
  53. 'node_modules/**/*.yaml',
  54. 'node_modules/**/*.yml',
  55. // web-app builds this path dynamically, so pkg cannot discover the static frontend.
  56. 'node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*',
  57. // skill-badge resolves both Markdown and image resources through import.meta.url.
  58. 'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*',
  59. ]
  60. const PLATFORMS = ['linux', 'macos', 'win'] as const
  61. const ARCHES = ['x64', 'arm64'] as const
  62. type Platform = (typeof PLATFORMS)[number]
  63. type Arch = (typeof ARCHES)[number]
  64. function isPlatform(value: string): value is Platform {
  65. return (PLATFORMS as readonly string[]).includes(value)
  66. }
  67. function isArch(value: string): value is Arch {
  68. return (ARCHES as readonly string[]).includes(value)
  69. }
  70. /**
  71. * A parsed pkg target triple, constructed from `--targets` or the host.
  72. */
  73. class Target {
  74. private constructor(
  75. /** pkg Node range (`node<major>`). */
  76. readonly nodeRange: string,
  77. /** pkg platform tag. */
  78. readonly platform: Platform,
  79. /** pkg CPU tag. */
  80. readonly arch: Arch,
  81. ) {}
  82. /** The pkg `--targets` spec string `<nodeRange>-<platform>-<arch>`. */
  83. get spec(): string {
  84. return `${this.nodeRange}-${this.platform}-${this.arch}`
  85. }
  86. /**
  87. * Parse one target spec, rejecting malformed triples and unsupported platform or architecture.
  88. * @param spec - the raw triple, e.g. `node24-linux-x64`.
  89. * @returns the parsed target.
  90. */
  91. static parse(spec: string): Target {
  92. const parts = spec.split('-')
  93. const [nodeRange, platform, arch] = parts
  94. if (parts.length !== 3 || nodeRange === undefined || platform === undefined || arch === undefined) {
  95. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)} must be <nodeRange>-<platform>-<arch>, e.g. node24-linux-x64.`)
  96. }
  97. if (!/^node\d+$/.test(nodeRange)) {
  98. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`)
  99. }
  100. if (!isPlatform(platform)) {
  101. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')}, got ${JSON.stringify(platform)}.`)
  102. }
  103. if (!isArch(arch)) {
  104. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`)
  105. }
  106. if (platform === 'win' && arch !== 'x64') {
  107. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: Windows supports x64 only.`)
  108. }
  109. return new Target(nodeRange, platform, arch)
  110. }
  111. /**
  112. * Resolve the host-platform default on Node 24.
  113. * @returns the host target; throws on an unsupported host platform or arch.
  114. */
  115. static host(): Target {
  116. const platform = process.platform === 'darwin'
  117. ? 'macos'
  118. : process.platform === 'linux'
  119. ? 'linux'
  120. : process.platform === 'win32'
  121. ? 'win'
  122. : undefined
  123. if (platform === undefined) {
  124. throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`)
  125. }
  126. const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined
  127. if (arch === undefined) {
  128. throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`)
  129. }
  130. if (platform === 'win' && arch !== 'x64') {
  131. throw new Error('build-exe-for-python-sdk: Windows supports x64 only; use an x64 Node process.')
  132. }
  133. return new Target(DEFAULT_NODE_RANGE, platform, arch)
  134. }
  135. }
  136. /**
  137. * Validated CLI configuration; construction owns help and parse-error exits.
  138. */
  139. class BuildCli {
  140. private constructor(
  141. /** Build targets; defaults to the host platform only. */
  142. readonly targets: readonly Target[],
  143. /** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */
  144. readonly skipBuild: boolean,
  145. /** Print every command and config patch instead of executing. */
  146. readonly dryRun: boolean,
  147. ) {}
  148. /**
  149. * Parse argv. Help exits 0; malformed flags exit 1; invalid or colliding
  150. * targets throw.
  151. * @param argv - the raw arguments (`process.argv.slice(2)`).
  152. * @returns the parsed, validated configuration.
  153. */
  154. static parse(argv: string[]): BuildCli {
  155. let values: ReturnType<typeof BuildCli.parseRaw>
  156. try {
  157. values = BuildCli.parseRaw(argv)
  158. } catch (error) {
  159. console.error(`build-exe-for-python-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
  160. console.error(BuildCli.usage())
  161. process.exit(1)
  162. }
  163. if (values.help) {
  164. console.log(BuildCli.usage())
  165. process.exit(0)
  166. }
  167. const targets = values.targets === undefined
  168. ? [Target.host()]
  169. : values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec))
  170. if (targets.length === 0) throw new Error('build-exe-for-python-sdk: --targets is empty.')
  171. const seen = new Set<string>()
  172. for (const target of targets) {
  173. const key = `${target.platform}-${target.arch}`
  174. if (seen.has(key)) {
  175. throw new Error(`build-exe-for-python-sdk: duplicate platform-arch ${key} in --targets; canonical product names would collide.`)
  176. }
  177. seen.add(key)
  178. }
  179. return new BuildCli(targets, values['skip-build'], values['dry-run'])
  180. }
  181. private static parseRaw(argv: string[]) {
  182. return parseArgs({
  183. args: argv,
  184. options: {
  185. 'targets': { type: 'string' },
  186. 'skip-build': { type: 'boolean', default: false },
  187. 'dry-run': { type: 'boolean', default: false },
  188. 'help': { type: 'boolean', default: false },
  189. },
  190. }).values
  191. }
  192. private static usage(): string {
  193. return [
  194. 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]',
  195. '',
  196. ' --targets=<t1,t2,...> pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64.',
  197. ' Default: the host platform only (on node24).',
  198. ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
  199. ' --dry-run print every command and config patch without executing.',
  200. ' --help print this help.',
  201. '',
  202. 'Build route: @yao-pkg/pkg --sea (root devDependency, pnpm-patched); see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.',
  203. `Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
  204. ].join('\n')
  205. }
  206. }
  207. function pnpmInvocation(args: string[]): [command: string, args: string[]] {
  208. const entrypoint = process.env.npm_execpath?.trim()
  209. if (entrypoint !== undefined && entrypoint !== '') {
  210. const extension = extname(entrypoint).toLowerCase()
  211. if (extension === '.js' || extension === '.cjs' || extension === '.mjs') {
  212. return [process.execPath, [entrypoint, ...args]]
  213. }
  214. if (extension !== '.cmd') return [entrypoint, args]
  215. }
  216. const home = process.env.PNPM_HOME?.trim()
  217. if (home !== undefined && home !== '') {
  218. const packageBin = resolve(home, '..', 'pnpm', 'bin')
  219. for (const filename of ['pnpm.mjs', 'pnpm.cjs']) {
  220. const candidate = resolve(packageBin, filename)
  221. if (existsSync(candidate)) return [process.execPath, [candidate, ...args]]
  222. }
  223. }
  224. if (process.platform === 'win32') {
  225. throw new Error('build-exe-for-python-sdk: pnpm must expose a JavaScript entrypoint through npm_execpath or PNPM_HOME on Windows.')
  226. }
  227. return ['pnpm', args]
  228. }
  229. /**
  230. * Render a command for logs and errors, quoting arguments with spaces.
  231. * @param command - the executable.
  232. * @param args - its arguments.
  233. * @returns the printable command line.
  234. */
  235. function formatCommand(command: string, args: string[]): string {
  236. return [command, ...args].map(part => (part.includes(' ') ? JSON.stringify(part) : part)).join(' ')
  237. }
  238. /**
  239. * Sequential build pipeline. Subprocesses inherit stdio and errors include
  240. * the command; dry runs print commands and filesystem changes.
  241. */
  242. class SingleExeBuild {
  243. /**
  244. * The cleared deploy target, pkg input, and Python node-mode carrier.
  245. */
  246. readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR)
  247. private readonly outDir = resolve(root, OUT_DIR)
  248. constructor(private readonly cli: BuildCli) {}
  249. /** Verify the closure before compiling or packaging. */
  250. async verifyClosure(): Promise<void> {
  251. await this.runPnpm('runtime dependency closure', ['run', 'verify-runtime-closure'])
  252. }
  253. /** Build all package artifacts unless `--skip-build` was passed. */
  254. async build(): Promise<void> {
  255. if (this.cli.skipBuild) {
  256. console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)')
  257. return
  258. }
  259. await this.runPnpm('build', ['run', 'build'])
  260. }
  261. /** Clear and deploy the runtime closure into the node carrier. */
  262. async deployStaging(): Promise<void> {
  263. if (this.staging === root || root.startsWith(this.staging + sep)) {
  264. throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`)
  265. }
  266. if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`)
  267. else await rm(this.staging, { recursive: true, force: true })
  268. await this.runPnpm('deploy', [
  269. '--filter',
  270. DEPLOY_ROOT_PACKAGE,
  271. 'deploy',
  272. '--legacy',
  273. '--prod',
  274. // Production deployment omits workspace tooling such as Electron's patched signer.
  275. '--config.allow-unused-patches=true',
  276. '--config.node-linker=hoisted',
  277. '--config.auto-install-peers=false',
  278. '--config.link-workspace-packages=true',
  279. this.staging,
  280. ])
  281. await this.restoreLegacyHoists()
  282. await this.materializeStagedLinks()
  283. if (this.cli.dryRun) {
  284. for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`)
  285. } else {
  286. await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true })))
  287. }
  288. }
  289. /**
  290. * Restore direct packages that pnpm's legacy hoister places beside the deploy
  291. * source instead of in the target. The runtime manifest supplies every peer,
  292. * so package-local node_modules trees are omitted to preserve one flat Cordis
  293. * instance and a symlink-free packaged payload.
  294. */
  295. private async restoreLegacyHoists(): Promise<void> {
  296. if (this.cli.dryRun) {
  297. console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy')
  298. return
  299. }
  300. const manifestPath = join(this.staging, 'package.json')
  301. const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
  302. dependencies?: Record<string, string>
  303. }
  304. const sourceNodeModules = resolve(root, DEPLOY_SOURCE_NODE_MODULES)
  305. const restored: string[] = []
  306. for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) {
  307. const destination = join(this.staging, 'node_modules', dependency)
  308. if (existsSync(destination)) continue
  309. const source = join(sourceNodeModules, dependency)
  310. if (!existsSync(source)) {
  311. throw new Error(
  312. `build-exe-for-python-sdk: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`,
  313. )
  314. }
  315. await mkdir(dirname(destination), { recursive: true })
  316. const nestedNodeModules = join(source, 'node_modules')
  317. await cp(source, destination, {
  318. recursive: true,
  319. dereference: true,
  320. filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
  321. })
  322. restored.push(dependency)
  323. }
  324. const stillMissing = Object.keys(manifest.dependencies ?? {})
  325. .filter(dependency => !existsSync(join(this.staging, 'node_modules', dependency)))
  326. if (stillMissing.length > 0) {
  327. throw new Error(`build-exe-for-python-sdk: staged dependencies remain missing: ${stillMissing.join(', ')}.`)
  328. }
  329. if (restored.length > 0) {
  330. console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`)
  331. }
  332. }
  333. /** Replace deploy-time package links with files and reject any remaining link. */
  334. private async materializeStagedLinks(): Promise<void> {
  335. if (this.cli.dryRun) {
  336. console.log('build-exe-for-python-sdk: [dry-run] materialize staged package links')
  337. return
  338. }
  339. const nodeModules = join(this.staging, 'node_modules')
  340. let remaining = await this.findSymlink(nodeModules)
  341. while (remaining !== undefined) {
  342. const segments = remaining.slice(nodeModules.length + 1).split(sep)
  343. const binIndex = segments.lastIndexOf('.bin')
  344. if (binIndex >= 0) {
  345. await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true })
  346. remaining = await this.findSymlink(nodeModules)
  347. continue
  348. }
  349. const destination = remaining
  350. const source = await realpath(destination)
  351. const nestedNodeModules = join(source, 'node_modules')
  352. await rm(destination, { recursive: true, force: true })
  353. await cp(source, destination, {
  354. recursive: true,
  355. dereference: true,
  356. filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
  357. })
  358. remaining = await this.findSymlink(nodeModules)
  359. }
  360. }
  361. /** Return the first symbolic link below a directory, if one exists. */
  362. private async findSymlink(directory: string): Promise<string | undefined> {
  363. for (const entry of await readdir(directory, { withFileTypes: true })) {
  364. const path = join(directory, entry.name)
  365. const metadata = await lstat(path)
  366. if (metadata.isSymbolicLink()) return path
  367. if (metadata.isDirectory()) {
  368. const nested = await this.findSymlink(path)
  369. if (nested !== undefined) return nested
  370. }
  371. }
  372. return undefined
  373. }
  374. /** Add the executable entry and pkg assets to the staged manifest. */
  375. async injectPkgConfig(): Promise<void> {
  376. const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS, ignore: OFFICE_ASSET_IGNORES } }
  377. const manifestPath = join(this.staging, 'package.json')
  378. if (this.cli.dryRun) {
  379. console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`)
  380. return
  381. }
  382. if (!existsSync(manifestPath)) {
  383. throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`)
  384. }
  385. if (!existsSync(join(this.staging, ENTRY_BIN))) {
  386. throw new Error(`build-exe-for-python-sdk: staged bootstrap ${join(this.staging, ENTRY_BIN)} is missing.`)
  387. }
  388. const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
  389. await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`)
  390. console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`)
  391. }
  392. /**
  393. * Package one target; SEA mode accepts one target per invocation.
  394. * @param target - the pkg target triple to build.
  395. * @returns the executable, Office directory, ripgrep, and required macOS spawn helper paths.
  396. */
  397. async pack(target: Target): Promise<string[]> {
  398. const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
  399. const product = target.platform === 'win' ? `${productBase}.exe` : productBase
  400. await this.prepareNativePty(target)
  401. if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true })
  402. await this.runPnpm(`pkg ${target.spec}`, [
  403. 'exec',
  404. 'pkg',
  405. this.staging,
  406. '--sea',
  407. '--targets',
  408. target.spec,
  409. '--output',
  410. product,
  411. ])
  412. if (!this.cli.dryRun && !existsSync(product)) {
  413. throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
  414. }
  415. const office = `${productBase}-office`
  416. if (this.cli.dryRun) {
  417. console.log(`build-exe-for-python-sdk: [dry-run] copy Office dependency closure from ${this.staging} to ${office}`)
  418. } else {
  419. const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
  420. const packages = await copyOfficeSidecar(this.staging, office, { platform, arch: target.arch })
  421. console.log(`build-exe-for-python-sdk: copied ${packages.length} Office packages to ${office}`)
  422. }
  423. const ripgrep = await this.copyRipgrepSidecar(target, product)
  424. if (target.platform !== 'macos') return [product, ripgrep, office]
  425. const spawnHelper = `${product}-spawn-helper`
  426. const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper')
  427. if (this.cli.dryRun) {
  428. console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${spawnHelper}`)
  429. } else {
  430. await copyFile(source, spawnHelper)
  431. await chmod(spawnHelper, 0o755)
  432. }
  433. return [product, ripgrep, spawnHelper, office]
  434. }
  435. /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */
  436. private async copyRipgrepSidecar(target: Target, product: string): Promise<string> {
  437. const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
  438. const executable = target.platform === 'win' ? 'rg.exe' : 'rg'
  439. const source = join(
  440. this.staging,
  441. 'node_modules',
  442. '@vscode',
  443. `ripgrep-${platform}-${target.arch}`,
  444. 'bin',
  445. executable,
  446. )
  447. const destination = target.platform === 'win'
  448. ? `${product.slice(0, -'.exe'.length)}-rg.exe`
  449. : `${product}-rg`
  450. if (this.cli.dryRun) {
  451. console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`)
  452. return destination
  453. }
  454. if (!existsSync(source)) {
  455. throw new Error(`build-exe-for-python-sdk: target ripgrep binary is missing at ${source}.`)
  456. }
  457. await copyFile(source, destination)
  458. await chmod(destination, 0o755)
  459. return destination
  460. }
  461. /**
  462. * Put the target node-pty addon in the staged closure. The release workflow
  463. * provides a manylinux build; ordinary installs use node-pty's target prebuild.
  464. * @param target - the pkg target whose native addon is being staged.
  465. */
  466. private async prepareNativePty(target: Target): Promise<void> {
  467. const stagedBuild = join(this.staging, 'node_modules', 'node-pty', 'build')
  468. if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`)
  469. else await rm(stagedBuild, { recursive: true, force: true })
  470. const packageDirectory = join(
  471. root,
  472. 'packages',
  473. 'subprocess',
  474. 'subprocess-local',
  475. 'node_modules',
  476. 'node-pty',
  477. )
  478. if (target.platform === 'win') {
  479. if (target.arch !== 'x64') {
  480. throw new Error('build-exe-for-python-sdk: Windows supports x64 only.')
  481. }
  482. const host = Target.host()
  483. if (target.platform !== host.platform || target.arch !== host.arch) {
  484. throw new Error(
  485. 'build-exe-for-python-sdk: build the Windows runtime under x64 Node on its target host; '
  486. + `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`,
  487. )
  488. }
  489. resolveWindowsNodePtyAddons(join(this.staging, 'node_modules', 'node-pty'), target.arch)
  490. return
  491. }
  492. if (target.platform !== 'linux') return
  493. const destination = join(stagedBuild, 'Release', 'pty.node')
  494. const source = resolveLinuxNodePtyAddon(packageDirectory, target.arch)
  495. if (this.cli.dryRun) {
  496. console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`)
  497. return
  498. }
  499. const host = Target.host()
  500. if (target.platform !== host.platform || target.arch !== host.arch) {
  501. throw new Error(
  502. 'build-exe-for-python-sdk: build the Linux runtime on its target architecture; '
  503. + `target ${target.platform}-${target.arch} does not match host ${host.platform}-${host.arch}.`,
  504. )
  505. }
  506. await mkdir(dirname(destination), { recursive: true })
  507. await copyFile(source, destination)
  508. }
  509. /**
  510. * Print each product path and, outside dry-run mode, its size.
  511. * @param products - the product paths returned by {@link pack}.
  512. */
  513. printProducts(products: string[]): void {
  514. console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:')
  515. for (const path of products) {
  516. if (this.cli.dryRun) {
  517. console.log(` ${path}`)
  518. continue
  519. }
  520. if (statSync(path).isDirectory()) {
  521. console.log(` ${path} (Office dependency directory)`)
  522. continue
  523. }
  524. const megabytes = statSync(path).size / (1024 * 1024)
  525. console.log(` ${path} (${megabytes.toFixed(1)} MB)`)
  526. }
  527. }
  528. /**
  529. * Copy each product into the Python runtime package. The deployed node
  530. * carrier is already in place, and `dist-exe/` retains upload copies.
  531. * @param products - the product paths returned by {@link pack}.
  532. */
  533. async syncToPythonRuntime(products: string[]): Promise<void> {
  534. const destDir = resolve(root, PYTHON_RUNTIME_DIR)
  535. if (this.cli.dryRun) {
  536. for (const path of products) {
  537. console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`)
  538. }
  539. return
  540. }
  541. await mkdir(destDir, { recursive: true })
  542. for (const path of products) {
  543. const destination = join(destDir, basename(path))
  544. if (statSync(path).isDirectory()) {
  545. await rm(destination, { recursive: true, force: true })
  546. await cp(path, destination, { recursive: true })
  547. } else await copyFile(path, destination)
  548. await chmod(destination, statSync(path).mode & 0o777)
  549. console.log(`build-exe-for-python-sdk: synced ${destination}`)
  550. }
  551. }
  552. /**
  553. * Run one subprocess with inherited stdio. Spawn and non-zero-exit errors
  554. * include the command; dry runs only print it.
  555. * @param label - the step name used in logs and error messages.
  556. * @param command - the executable.
  557. * @param args - its arguments.
  558. */
  559. private async run(label: string, command: string, args: string[]): Promise<void> {
  560. const printable = formatCommand(command, args)
  561. if (this.cli.dryRun) {
  562. console.log(`build-exe-for-python-sdk: [dry-run] ${printable}`)
  563. return
  564. }
  565. console.log(`build-exe-for-python-sdk: ${label}: ${printable}`)
  566. await new Promise<void>((resolvePromise, reject) => {
  567. const child = spawn(command, args, {
  568. cwd: root,
  569. stdio: 'inherit',
  570. // Artifact builds must not mutate or validate a developer's Git hooks.
  571. env: { ...process.env, CI: 'true' },
  572. })
  573. child.once('error', (error) => {
  574. reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`))
  575. })
  576. child.once('exit', (code, signal) => {
  577. if (code === 0) {
  578. resolvePromise()
  579. return
  580. }
  581. const cause = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}`
  582. reject(new Error(`build-exe-for-python-sdk: ${label} failed (${cause}): ${printable}`))
  583. })
  584. })
  585. }
  586. /** Run pnpm through its JavaScript entrypoint when the caller supplies one. */
  587. private async runPnpm(label: string, args: string[]): Promise<void> {
  588. const [command, invocationArgs] = pnpmInvocation(args)
  589. await this.run(label, command, invocationArgs)
  590. }
  591. }
  592. async function main(): Promise<void> {
  593. const cli = BuildCli.parse(process.argv.slice(2))
  594. const pipeline = new SingleExeBuild(cli)
  595. console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`)
  596. console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`)
  597. await pipeline.verifyClosure()
  598. await pipeline.build()
  599. await pipeline.deployStaging()
  600. await pipeline.injectPkgConfig()
  601. const products: string[] = []
  602. for (const target of cli.targets) products.push(...await pipeline.pack(target))
  603. pipeline.printProducts(products)
  604. await pipeline.syncToPythonRuntime(products)
  605. }
  606. await main()