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

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