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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. /**
  2. * Build the SDK runtime executables and Python node carrier. 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, mkdirSync, statSync } from 'node:fs'
  10. import { copyFile, readFile, rm, writeFile } from 'node:fs/promises'
  11. import { basename, join, resolve, sep } from 'node:path'
  12. import { parseArgs } from 'node:util'
  13. const root = resolve(import.meta.dirname, '..')
  14. /** The closure manifest whose dependencies define the executable. */
  15. const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg'
  16. /** The app entry inside the deployed closure. */
  17. const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js'
  18. const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg'
  19. /** Default Node major; SEA mode requires at least Node 22. */
  20. const DEFAULT_NODE_RANGE = 'node24'
  21. /** Pinned for reproducible builds. */
  22. const PKG_SPEC = '@yao-pkg/pkg@6.21.0'
  23. const OUT_DIR = 'dist-exe'
  24. /** Python package destination; created when absent. */
  25. const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime'
  26. /** The deployed closure doubles as the node-mode carrier. */
  27. const PYTHON_NODE_SUBDIR = 'node'
  28. /** Documentation excluded from the generated runtime directory. */
  29. const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
  30. /**
  31. * Whole-tree assets cover Cordis's runtime bare-package imports, which pkg's
  32. * static analysis cannot see. Package manifests are explicit because bare-name
  33. * resolution depends on them.
  34. */
  35. const ASSET_GLOBS = [
  36. 'package.json',
  37. 'node_modules/**/*.js',
  38. 'node_modules/**/*.cjs',
  39. 'node_modules/**/*.mjs',
  40. 'node_modules/**/package.json',
  41. 'node_modules/**/*.json',
  42. 'node_modules/**/*.node',
  43. 'node_modules/**/*.wasm',
  44. ]
  45. const PLATFORMS = ['linux', 'macos'] as const
  46. const ARCHES = ['x64', 'arm64'] as const
  47. type Platform = (typeof PLATFORMS)[number]
  48. type Arch = (typeof ARCHES)[number]
  49. function isPlatform(value: string): value is Platform {
  50. return (PLATFORMS as readonly string[]).includes(value)
  51. }
  52. function isArch(value: string): value is Arch {
  53. return (ARCHES as readonly string[]).includes(value)
  54. }
  55. /**
  56. * A parsed pkg target triple, constructed from `--targets` or the host.
  57. */
  58. class Target {
  59. private constructor(
  60. /** pkg Node range (`node<major>`). */
  61. readonly nodeRange: string,
  62. /**
  63. * pkg platform tag. Windows is a documented non-goal
  64. * (.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
  65. */
  66. readonly platform: Platform,
  67. /** pkg CPU tag. */
  68. readonly arch: Arch,
  69. ) {}
  70. /** The pkg `--targets` spec string `<nodeRange>-<platform>-<arch>`. */
  71. get spec(): string {
  72. return `${this.nodeRange}-${this.platform}-${this.arch}`
  73. }
  74. /**
  75. * Parse one target spec, rejecting malformed triples and unsupported platform or architecture.
  76. * @param spec - the raw triple, e.g. `node24-linux-x64`.
  77. * @returns the parsed target.
  78. */
  79. static parse(spec: string): Target {
  80. const parts = spec.split('-')
  81. const [nodeRange, platform, arch] = parts
  82. if (parts.length !== 3 || nodeRange === undefined || platform === undefined || arch === undefined) {
  83. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)} must be <nodeRange>-<platform>-<arch>, e.g. node24-linux-x64.`)
  84. }
  85. if (!/^node\d+$/.test(nodeRange)) {
  86. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`)
  87. }
  88. if (!isPlatform(platform)) {
  89. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')}, got ${JSON.stringify(platform)}.`)
  90. }
  91. if (!isArch(arch)) {
  92. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`)
  93. }
  94. return new Target(nodeRange, platform, arch)
  95. }
  96. /**
  97. * Resolve the host-platform default on Node 24.
  98. * @returns the host target; throws on an unsupported host platform or arch.
  99. */
  100. static host(): Target {
  101. const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined
  102. if (platform === undefined) {
  103. throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`)
  104. }
  105. const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined
  106. if (arch === undefined) {
  107. throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`)
  108. }
  109. return new Target(DEFAULT_NODE_RANGE, platform, arch)
  110. }
  111. }
  112. /**
  113. * Validated CLI configuration; construction owns help and parse-error exits.
  114. */
  115. class BuildCli {
  116. private constructor(
  117. /** Build targets; defaults to the host platform only. */
  118. readonly targets: readonly Target[],
  119. /** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */
  120. readonly skipBuild: boolean,
  121. /** Print every command and config patch instead of executing. */
  122. readonly dryRun: boolean,
  123. ) {}
  124. /**
  125. * Parse argv. Help exits 0; malformed flags exit 1; invalid or colliding
  126. * targets throw.
  127. * @param argv - the raw arguments (`process.argv.slice(2)`).
  128. * @returns the parsed, validated configuration.
  129. */
  130. static parse(argv: string[]): BuildCli {
  131. let values: ReturnType<typeof BuildCli.parseRaw>
  132. try {
  133. values = BuildCli.parseRaw(argv)
  134. } catch (error) {
  135. console.error(`build-exe-for-python-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
  136. console.error(BuildCli.usage())
  137. process.exit(1)
  138. }
  139. if (values.help) {
  140. console.log(BuildCli.usage())
  141. process.exit(0)
  142. }
  143. const targets = values.targets === undefined
  144. ? [Target.host()]
  145. : values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec))
  146. if (targets.length === 0) throw new Error('build-exe-for-python-sdk: --targets is empty.')
  147. const seen = new Set<string>()
  148. for (const target of targets) {
  149. const key = `${target.platform}-${target.arch}`
  150. if (seen.has(key)) {
  151. throw new Error(`build-exe-for-python-sdk: duplicate platform-arch ${key} in --targets; canonical product names would collide.`)
  152. }
  153. seen.add(key)
  154. }
  155. return new BuildCli(targets, values['skip-build'], values['dry-run'])
  156. }
  157. private static parseRaw(argv: string[]) {
  158. return parseArgs({
  159. args: argv,
  160. options: {
  161. 'targets': { type: 'string' },
  162. 'skip-build': { type: 'boolean', default: false },
  163. 'dry-run': { type: 'boolean', default: false },
  164. 'help': { type: 'boolean', default: false },
  165. },
  166. }).values
  167. }
  168. private static usage(): string {
  169. return [
  170. 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]',
  171. '',
  172. ' --targets=<t1,t2,...> pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.',
  173. ' Default: the host platform only (on node24).',
  174. ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
  175. ' --dry-run print every command and config patch without executing.',
  176. ' --help print this help.',
  177. '',
  178. `Build route: ${PKG_SPEC} --sea; see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`,
  179. `Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
  180. ].join('\n')
  181. }
  182. }
  183. function pnpmBin(): string {
  184. return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
  185. }
  186. /**
  187. * Render a command for logs and errors, quoting arguments with spaces.
  188. * @param command - the executable.
  189. * @param args - its arguments.
  190. * @returns the printable command line.
  191. */
  192. function formatCommand(command: string, args: string[]): string {
  193. return [command, ...args].map(part => (part.includes(' ') ? JSON.stringify(part) : part)).join(' ')
  194. }
  195. /**
  196. * Sequential build pipeline. Subprocesses inherit stdio and errors include
  197. * the command; dry runs print commands and filesystem changes.
  198. */
  199. class SingleExeBuild {
  200. /**
  201. * The cleared deploy target, pkg input, and Python node-mode carrier. The
  202. * checked-in default `cordis.yml` remains in its parent directory.
  203. */
  204. readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR)
  205. private readonly outDir = resolve(root, OUT_DIR)
  206. constructor(private readonly cli: BuildCli) {}
  207. /** Verify the closure before compiling or packaging. */
  208. async verifyClosure(): Promise<void> {
  209. await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure'])
  210. }
  211. /** Build all package artifacts unless `--skip-build` was passed. */
  212. async build(): Promise<void> {
  213. if (this.cli.skipBuild) {
  214. console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)')
  215. return
  216. }
  217. await this.run('build', pnpmBin(), ['run', 'build'])
  218. }
  219. /** Clear and deploy the runtime closure into the node carrier. */
  220. async deployStaging(): Promise<void> {
  221. if (this.staging === root || root.startsWith(this.staging + sep)) {
  222. throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`)
  223. }
  224. if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`)
  225. else await rm(this.staging, { recursive: true, force: true })
  226. await this.run('deploy', pnpmBin(), [
  227. '--filter',
  228. DEPLOY_ROOT_PACKAGE,
  229. 'deploy',
  230. '--legacy',
  231. '--prod',
  232. '--config.node-linker=hoisted',
  233. '--config.auto-install-peers=false',
  234. '--config.link-workspace-packages=true',
  235. this.staging,
  236. ])
  237. if (this.cli.dryRun) {
  238. for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`)
  239. } else {
  240. await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true })))
  241. }
  242. }
  243. /** Add the executable entry and pkg assets to the staged manifest. */
  244. async injectPkgConfig(): Promise<void> {
  245. const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } }
  246. const manifestPath = join(this.staging, 'package.json')
  247. if (this.cli.dryRun) {
  248. console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`)
  249. return
  250. }
  251. if (!existsSync(manifestPath)) {
  252. throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`)
  253. }
  254. if (!existsSync(join(this.staging, ENTRY_BIN))) {
  255. throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`)
  256. }
  257. const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
  258. await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`)
  259. console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`)
  260. }
  261. /**
  262. * Package one target; SEA mode accepts one target per invocation.
  263. * @param target - the pkg target triple to build.
  264. * @returns the canonical product path `<out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>`.
  265. */
  266. async pack(target: Target): Promise<string> {
  267. const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
  268. if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true })
  269. await this.run(`pkg ${target.spec}`, pnpmBin(), [
  270. 'dlx',
  271. PKG_SPEC,
  272. this.staging,
  273. '--sea',
  274. '--targets',
  275. target.spec,
  276. '--output',
  277. product,
  278. ])
  279. if (!this.cli.dryRun && !existsSync(product)) {
  280. throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
  281. }
  282. return product
  283. }
  284. /**
  285. * Print each product path and, outside dry-run mode, its size.
  286. * @param products - the product paths returned by {@link pack}.
  287. */
  288. printProducts(products: string[]): void {
  289. console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:')
  290. for (const product of products) {
  291. if (this.cli.dryRun) {
  292. console.log(` ${product}`)
  293. continue
  294. }
  295. const megabytes = statSync(product).size / (1024 * 1024)
  296. console.log(` ${product} (${megabytes.toFixed(1)} MB)`)
  297. }
  298. }
  299. /**
  300. * Copy each executable into the Python runtime package. The deployed node
  301. * carrier is already in place, and `dist-exe/` retains upload copies.
  302. * @param products - the product paths returned by {@link pack}.
  303. */
  304. async syncToPythonRuntime(products: string[]): Promise<void> {
  305. const destDir = resolve(root, PYTHON_RUNTIME_DIR)
  306. if (this.cli.dryRun) {
  307. for (const product of products) {
  308. console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`)
  309. }
  310. return
  311. }
  312. mkdirSync(destDir, { recursive: true })
  313. for (const product of products) {
  314. const destination = join(destDir, basename(product))
  315. await copyFile(product, destination)
  316. console.log(`build-exe-for-python-sdk: synced ${destination}`)
  317. }
  318. }
  319. /**
  320. * Run one subprocess with inherited stdio. Spawn and non-zero-exit errors
  321. * include the command; dry runs only print it.
  322. * @param label - the step name used in logs and error messages.
  323. * @param command - the executable.
  324. * @param args - its arguments.
  325. */
  326. private async run(label: string, command: string, args: string[]): Promise<void> {
  327. const printable = formatCommand(command, args)
  328. if (this.cli.dryRun) {
  329. console.log(`build-exe-for-python-sdk: [dry-run] ${printable}`)
  330. return
  331. }
  332. console.log(`build-exe-for-python-sdk: ${label}: ${printable}`)
  333. await new Promise<void>((resolvePromise, reject) => {
  334. const child = spawn(command, args, { cwd: root, stdio: 'inherit' })
  335. child.once('error', (error) => {
  336. reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`))
  337. })
  338. child.once('exit', (code, signal) => {
  339. if (code === 0) {
  340. resolvePromise()
  341. return
  342. }
  343. const cause = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}`
  344. reject(new Error(`build-exe-for-python-sdk: ${label} failed (${cause}): ${printable}`))
  345. })
  346. })
  347. }
  348. }
  349. async function main(): Promise<void> {
  350. const cli = BuildCli.parse(process.argv.slice(2))
  351. const pipeline = new SingleExeBuild(cli)
  352. console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`)
  353. console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`)
  354. await pipeline.verifyClosure()
  355. await pipeline.build()
  356. await pipeline.deployStaging()
  357. await pipeline.injectPkgConfig()
  358. const products: string[] = []
  359. for (const target of cli.targets) products.push(await pipeline.pack(target))
  360. pipeline.printProducts(products)
  361. await pipeline.syncToPythonRuntime(products)
  362. }
  363. await main()