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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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, readFileSync, statSync } from 'node:fs'
  10. import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
  11. import { basename, dirname, 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. const SPAWN_HELPER_SUFFIX = '-spawn-helper'
  20. /** Default Node major; SEA mode requires at least Node 22. */
  21. const DEFAULT_NODE_RANGE = 'node24'
  22. /** Pinned for reproducible builds. */
  23. const PKG_SPEC = '@yao-pkg/pkg@6.21.0'
  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. /** Documentation excluded from the generated runtime directory. */
  30. const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
  31. /**
  32. * Whole-tree assets cover Cordis's runtime bare-package imports, which pkg's
  33. * static analysis cannot see. Package manifests are explicit because bare-name
  34. * resolution depends on them.
  35. */
  36. const ASSET_GLOBS = [
  37. 'package.json',
  38. 'node_modules/**/*.js',
  39. 'node_modules/**/*.cjs',
  40. 'node_modules/**/*.mjs',
  41. 'node_modules/**/package.json',
  42. 'node_modules/**/*.json',
  43. 'node_modules/**/*.node',
  44. 'node_modules/**/*.wasm',
  45. ]
  46. const PLATFORMS = ['linux', 'macos'] as const
  47. const ARCHES = ['x64', 'arm64'] as const
  48. type Platform = (typeof PLATFORMS)[number]
  49. type Arch = (typeof ARCHES)[number]
  50. interface RuntimeProduct {
  51. executable: string
  52. spawnHelper?: string
  53. }
  54. function spawnHelperBinaryTarget(path: string): string | undefined {
  55. const header = readFileSync(path).subarray(0, 8)
  56. if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) {
  57. const cpuType = header.readUInt32LE(4)
  58. if (cpuType === 0x01000007) return 'macos-x64'
  59. if (cpuType === 0x0100000c) return 'macos-arm64'
  60. }
  61. return undefined
  62. }
  63. function runtimeProductFiles(product: RuntimeProduct): string[] {
  64. return [product.executable, ...(product.spawnHelper === undefined ? [] : [product.spawnHelper])]
  65. }
  66. function isPlatform(value: string): value is Platform {
  67. return (PLATFORMS as readonly string[]).includes(value)
  68. }
  69. function isArch(value: string): value is Arch {
  70. return (ARCHES as readonly string[]).includes(value)
  71. }
  72. /**
  73. * A parsed pkg target triple, constructed from `--targets` or the host.
  74. */
  75. class Target {
  76. private constructor(
  77. /** pkg Node range (`node<major>`). */
  78. readonly nodeRange: string,
  79. /**
  80. * pkg platform tag. Windows is a documented non-goal
  81. * (.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
  82. */
  83. readonly platform: Platform,
  84. /** pkg CPU tag. */
  85. readonly arch: Arch,
  86. ) {}
  87. /** The pkg `--targets` spec string `<nodeRange>-<platform>-<arch>`. */
  88. get spec(): string {
  89. return `${this.nodeRange}-${this.platform}-${this.arch}`
  90. }
  91. /**
  92. * Parse one target spec, rejecting malformed triples and unsupported platform or architecture.
  93. * @param spec - the raw triple, e.g. `node24-linux-x64`.
  94. * @returns the parsed target.
  95. */
  96. static parse(spec: string): Target {
  97. const parts = spec.split('-')
  98. const [nodeRange, platform, arch] = parts
  99. if (parts.length !== 3 || nodeRange === undefined || platform === undefined || arch === undefined) {
  100. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)} must be <nodeRange>-<platform>-<arch>, e.g. node24-linux-x64.`)
  101. }
  102. if (!/^node\d+$/.test(nodeRange)) {
  103. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`)
  104. }
  105. if (!isPlatform(platform)) {
  106. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')}, got ${JSON.stringify(platform)}.`)
  107. }
  108. if (!isArch(arch)) {
  109. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`)
  110. }
  111. return new Target(nodeRange, platform, arch)
  112. }
  113. /**
  114. * Resolve the host-platform default on Node 24.
  115. * @returns the host target; throws on an unsupported host platform or arch.
  116. */
  117. static host(): Target {
  118. const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined
  119. if (platform === undefined) {
  120. throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`)
  121. }
  122. const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined
  123. if (arch === undefined) {
  124. throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`)
  125. }
  126. return new Target(DEFAULT_NODE_RANGE, platform, arch)
  127. }
  128. }
  129. /**
  130. * Validated CLI configuration; construction owns help and parse-error exits.
  131. */
  132. class BuildCli {
  133. private constructor(
  134. /** Build targets; defaults to the host platform only. */
  135. readonly targets: readonly Target[],
  136. /** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */
  137. readonly skipBuild: boolean,
  138. /** Print every command and config patch instead of executing. */
  139. readonly dryRun: boolean,
  140. ) {}
  141. /**
  142. * Parse argv. Help exits 0; malformed flags exit 1; invalid or colliding
  143. * targets throw.
  144. * @param argv - the raw arguments (`process.argv.slice(2)`).
  145. * @returns the parsed, validated configuration.
  146. */
  147. static parse(argv: string[]): BuildCli {
  148. let values: ReturnType<typeof BuildCli.parseRaw>
  149. try {
  150. values = BuildCli.parseRaw(argv)
  151. } catch (error) {
  152. console.error(`build-exe-for-python-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
  153. console.error(BuildCli.usage())
  154. process.exit(1)
  155. }
  156. if (values.help) {
  157. console.log(BuildCli.usage())
  158. process.exit(0)
  159. }
  160. const targets = values.targets === undefined
  161. ? [Target.host()]
  162. : values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec))
  163. if (targets.length === 0) throw new Error('build-exe-for-python-sdk: --targets is empty.')
  164. const seen = new Set<string>()
  165. for (const target of targets) {
  166. const key = `${target.platform}-${target.arch}`
  167. if (seen.has(key)) {
  168. throw new Error(`build-exe-for-python-sdk: duplicate platform-arch ${key} in --targets; canonical product names would collide.`)
  169. }
  170. seen.add(key)
  171. }
  172. return new BuildCli(targets, values['skip-build'], values['dry-run'])
  173. }
  174. private static parseRaw(argv: string[]) {
  175. return parseArgs({
  176. args: argv,
  177. options: {
  178. 'targets': { type: 'string' },
  179. 'skip-build': { type: 'boolean', default: false },
  180. 'dry-run': { type: 'boolean', default: false },
  181. 'help': { type: 'boolean', default: false },
  182. },
  183. }).values
  184. }
  185. private static usage(): string {
  186. return [
  187. 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]',
  188. '',
  189. ' --targets=<t1,t2,...> pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.',
  190. ' Default: the host platform only (on node24).',
  191. ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
  192. ' --dry-run print every command and config patch without executing.',
  193. ' --help print this help.',
  194. '',
  195. `Build route: ${PKG_SPEC} --sea; see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.`,
  196. `Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
  197. ].join('\n')
  198. }
  199. }
  200. function pnpmBin(): string {
  201. return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
  202. }
  203. /**
  204. * Render a command for logs and errors, quoting arguments with spaces.
  205. * @param command - the executable.
  206. * @param args - its arguments.
  207. * @returns the printable command line.
  208. */
  209. function formatCommand(command: string, args: string[]): string {
  210. return [command, ...args].map(part => (part.includes(' ') ? JSON.stringify(part) : part)).join(' ')
  211. }
  212. /**
  213. * Sequential build pipeline. Subprocesses inherit stdio and errors include
  214. * the command; dry runs print commands and filesystem changes.
  215. */
  216. class SingleExeBuild {
  217. /**
  218. * The cleared deploy target, pkg input, and Python node-mode carrier. The
  219. * checked-in default `cordis.yml` remains in its parent directory.
  220. */
  221. readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR)
  222. private readonly outDir = resolve(root, OUT_DIR)
  223. constructor(private readonly cli: BuildCli) {}
  224. /** Verify the closure before compiling or packaging. */
  225. async verifyClosure(): Promise<void> {
  226. await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure'])
  227. }
  228. /** Build all package artifacts unless `--skip-build` was passed. */
  229. async build(): Promise<void> {
  230. if (this.cli.skipBuild) {
  231. console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)')
  232. return
  233. }
  234. await this.run('build', pnpmBin(), ['run', 'build'])
  235. }
  236. /** Clear and deploy the runtime closure into the node carrier. */
  237. async deployStaging(): Promise<void> {
  238. if (this.staging === root || root.startsWith(this.staging + sep)) {
  239. throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`)
  240. }
  241. if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`)
  242. else await rm(this.staging, { recursive: true, force: true })
  243. await this.run('deploy', pnpmBin(), [
  244. '--filter',
  245. DEPLOY_ROOT_PACKAGE,
  246. 'deploy',
  247. '--legacy',
  248. '--prod',
  249. '--config.node-linker=hoisted',
  250. '--config.auto-install-peers=false',
  251. '--config.link-workspace-packages=true',
  252. // The production closure intentionally omits the patched dev-only
  253. // @earendil-works/pi-tui package. The root frozen install still validates
  254. // every patch; this exception is scoped only to the production deploy.
  255. '--config.allow-unused-patches=true',
  256. this.staging,
  257. ])
  258. if (this.cli.dryRun) {
  259. for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`)
  260. } else {
  261. await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true })))
  262. }
  263. }
  264. /** Add the executable entry and pkg assets to the staged manifest. */
  265. async injectPkgConfig(): Promise<void> {
  266. const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } }
  267. const manifestPath = join(this.staging, 'package.json')
  268. if (this.cli.dryRun) {
  269. console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`)
  270. return
  271. }
  272. if (!existsSync(manifestPath)) {
  273. throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`)
  274. }
  275. if (!existsSync(join(this.staging, ENTRY_BIN))) {
  276. throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`)
  277. }
  278. const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
  279. await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`)
  280. console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`)
  281. }
  282. /**
  283. * Package one target; SEA mode accepts one target per invocation.
  284. * @param target - the pkg target triple to build.
  285. * @returns the canonical product path `<out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>`.
  286. */
  287. async pack(target: Target): Promise<RuntimeProduct> {
  288. const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
  289. await this.prepareNativePty(target)
  290. if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true })
  291. await this.run(`pkg ${target.spec}`, pnpmBin(), [
  292. 'dlx',
  293. PKG_SPEC,
  294. this.staging,
  295. '--sea',
  296. '--targets',
  297. target.spec,
  298. '--output',
  299. product,
  300. ])
  301. if (!this.cli.dryRun && !existsSync(product)) {
  302. throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
  303. }
  304. if (target.platform !== 'macos') return { executable: product }
  305. const spawnHelper = `${product}${SPAWN_HELPER_SUFFIX}`
  306. if (this.cli.dryRun) {
  307. console.log(`build-exe-for-python-sdk: [dry-run] copy target node-pty spawn-helper to ${spawnHelper}`)
  308. } else {
  309. const source = this.resolveSpawnHelper(target)
  310. await copyFile(source, spawnHelper)
  311. await chmod(spawnHelper, statSync(source).mode & 0o777)
  312. }
  313. return { executable: product, spawnHelper }
  314. }
  315. /**
  316. * Put the target node-pty addon in the staged closure. Linux npm installs
  317. * build it from source, but legacy deploy omits that side-effect directory.
  318. * @param target - the pkg target whose native addon is being staged.
  319. */
  320. private async prepareNativePty(target: Target): Promise<void> {
  321. const stagedRoot = join(this.staging, 'node_modules', 'node-pty')
  322. const stagedBuild = join(stagedRoot, 'build')
  323. if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`)
  324. else await rm(stagedBuild, { recursive: true, force: true })
  325. const nativePlatform = target.platform === 'macos' ? 'darwin' : 'linux'
  326. const prebuilt = join(stagedRoot, 'prebuilds', `${nativePlatform}-${target.arch}`, 'pty.node')
  327. const source = join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node')
  328. const destination = join(stagedBuild, 'Release', 'pty.node')
  329. if (this.cli.dryRun) {
  330. if (target.platform === 'linux') console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`)
  331. return
  332. }
  333. if (existsSync(prebuilt)) return
  334. const host = Target.host()
  335. if (target.platform !== host.platform || target.arch !== host.arch || !existsSync(source)) {
  336. throw new Error(
  337. `build-exe-for-python-sdk: node-pty native addon for ${target.platform}-${target.arch} is missing; `
  338. + `checked ${prebuilt}, ${source}. Build the Linux runtime on its target architecture.`,
  339. )
  340. }
  341. await mkdir(dirname(destination), { recursive: true })
  342. await copyFile(source, destination)
  343. }
  344. /**
  345. * Resolve the node-pty helper that matches a pkg target.
  346. * @param target - the pkg target whose helper must be shipped.
  347. * @returns a physical executable outside pkg's virtual snapshot.
  348. */
  349. private resolveSpawnHelper(target: Target): string {
  350. const nodePtyRoot = join(this.staging, 'node_modules', 'node-pty')
  351. const candidates = [
  352. join(nodePtyRoot, 'prebuilds', `darwin-${target.arch}`, 'spawn-helper'),
  353. ]
  354. const host = Target.host()
  355. if (target.platform === host.platform && target.arch === host.arch) {
  356. candidates.push(join(root, 'packages', 'pty', 'pty-local', 'node_modules', 'node-pty', 'build', 'Release', 'spawn-helper'))
  357. }
  358. const helper = candidates.find(candidate => existsSync(candidate))
  359. if (helper === undefined) {
  360. throw new Error(
  361. `build-exe-for-python-sdk: node-pty spawn-helper for ${target.platform}-${target.arch} is missing; `
  362. + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`,
  363. )
  364. }
  365. if (statSync(helper).mode & 0o111) {
  366. const expected = `${target.platform}-${target.arch}`
  367. const actual = spawnHelperBinaryTarget(helper)
  368. if (actual !== expected) {
  369. throw new Error(
  370. `build-exe-for-python-sdk: node-pty spawn-helper binary mismatch: expected ${expected}, `
  371. + `found ${actual ?? 'unsupported format or architecture'} at ${helper}`,
  372. )
  373. }
  374. return helper
  375. }
  376. throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`)
  377. }
  378. /**
  379. * Print each product path and, outside dry-run mode, its size.
  380. * @param products - the product paths returned by {@link pack}.
  381. */
  382. printProducts(products: RuntimeProduct[]): void {
  383. console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:')
  384. for (const product of products) {
  385. if (this.cli.dryRun) {
  386. for (const path of runtimeProductFiles(product)) console.log(` ${path}`)
  387. continue
  388. }
  389. for (const path of runtimeProductFiles(product)) {
  390. const megabytes = statSync(path).size / (1024 * 1024)
  391. console.log(` ${path} (${megabytes.toFixed(1)} MB)`)
  392. }
  393. }
  394. }
  395. /**
  396. * Copy each executable into the Python runtime package. The deployed node
  397. * carrier is already in place, and `dist-exe/` retains upload copies.
  398. * @param products - the product paths returned by {@link pack}.
  399. */
  400. async syncToPythonRuntime(products: RuntimeProduct[]): Promise<void> {
  401. const destDir = resolve(root, PYTHON_RUNTIME_DIR)
  402. if (this.cli.dryRun) {
  403. for (const product of products) {
  404. for (const path of runtimeProductFiles(product)) {
  405. console.log(`build-exe-for-python-sdk: [dry-run] cp ${path} ${join(destDir, basename(path))}`)
  406. }
  407. }
  408. return
  409. }
  410. mkdirSync(destDir, { recursive: true })
  411. for (const product of products) {
  412. for (const path of runtimeProductFiles(product)) {
  413. const destination = join(destDir, basename(path))
  414. await copyFile(path, destination)
  415. await chmod(destination, statSync(path).mode & 0o777)
  416. console.log(`build-exe-for-python-sdk: synced ${destination}`)
  417. }
  418. }
  419. }
  420. /**
  421. * Run one subprocess with inherited stdio. Spawn and non-zero-exit errors
  422. * include the command; dry runs only print it.
  423. * @param label - the step name used in logs and error messages.
  424. * @param command - the executable.
  425. * @param args - its arguments.
  426. */
  427. private async run(label: string, command: string, args: string[]): Promise<void> {
  428. const printable = formatCommand(command, args)
  429. if (this.cli.dryRun) {
  430. console.log(`build-exe-for-python-sdk: [dry-run] ${printable}`)
  431. return
  432. }
  433. console.log(`build-exe-for-python-sdk: ${label}: ${printable}`)
  434. await new Promise<void>((resolvePromise, reject) => {
  435. const child = spawn(command, args, {
  436. cwd: root,
  437. stdio: 'inherit',
  438. // Artifact builds must not mutate or validate a developer's Git hooks.
  439. env: { ...process.env, CI: 'true' },
  440. })
  441. child.once('error', (error) => {
  442. reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`))
  443. })
  444. child.once('exit', (code, signal) => {
  445. if (code === 0) {
  446. resolvePromise()
  447. return
  448. }
  449. const cause = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}`
  450. reject(new Error(`build-exe-for-python-sdk: ${label} failed (${cause}): ${printable}`))
  451. })
  452. })
  453. }
  454. }
  455. async function main(): Promise<void> {
  456. const cli = BuildCli.parse(process.argv.slice(2))
  457. const pipeline = new SingleExeBuild(cli)
  458. console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`)
  459. console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`)
  460. await pipeline.verifyClosure()
  461. await pipeline.build()
  462. await pipeline.deployStaging()
  463. await pipeline.injectPkgConfig()
  464. const products: RuntimeProduct[] = []
  465. for (const target of cli.targets) products.push(await pipeline.pack(target))
  466. pipeline.printProducts(products)
  467. await pipeline.syncToPythonRuntime(products)
  468. }
  469. await main()