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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. /**
  2. * Build the single-file SDK runtime executables
  3. * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
  4. *
  5. * Every settled decision is hardcoded — the PoC judged @yao-pkg/pkg's
  6. * standard mode unusable for this architecture (its ESM→CJS transform breaks
  7. * every runtime `import()`), so the pipeline is fixed on `--sea` mode, plain
  8. * ESM entry, plain-source assets, and a hoisted (symlink-free) staged tree.
  9. *
  10. * Pipeline — every step fails loud with the command it ran:
  11. *
  12. * 1. `pnpm run build` — all packages emit `lib/` (skippable via --skip-build).
  13. * 2. `pnpm --filter dsh-jsonrpc-agent-pkg deploy` — materialize the
  14. * closure-manifest package (python/sdk-runtime/package.json — the single
  15. * source of truth for the exe's plugin set) into the staging dir
  16. * (cleared first; pnpm refuses a non-empty deploy target). Flags, all
  17. * verified against pnpm 11.7: `--legacy` because the workspace does not
  18. * set `inject-workspace-packages=true`; `node-linker=hoisted` for a plain
  19. * file tree with zero symlinks (the safe shape for pkg's VFS, and it
  20. * physically guarantees a single cordis copy); `auto-install-peers=false`
  21. * so transitive `^0.0.x` peers on unpublished packages never hit the
  22. * registry; `link-workspace-packages=true` so the closure resolves to
  23. * workspace/vendor sources.
  24. * 3. Inject the pkg config into the staged package.json: `bin` = the ESM
  25. * `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` (SEA mode
  26. * hands it to Node's default ESM loader — no CJS shim), plus whole-tree
  27. * asset globs. The cordis Loader resolves plugins
  28. * through runtime dynamic `import()` of bare package names, so pkg's
  29. * static analysis discovers none of them — the entire staged tree must be
  30. * globbed in explicitly.
  31. * 4. `pnpm dlx @yao-pkg/pkg@<pinned> <staging> --sea --targets <t> --output
  32. * <out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>` — once per target (SEA mode
  33. * packs a single target per invocation), so each product gets its
  34. * canonical name directly.
  35. * 5. Sync into the Python runtime package
  36. * (python/sdk-runtime/src/deepseek_harness_runtime/runtime/,
  37. * created if missing): each product under its canonical filename (exe
  38. * mode), plus the whole staged closure into runtime/node/ (node mode —
  39. * `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`
  40. * runs it directly; the injected pkg
  41. * fields are harmless to node). dist-exe/ keeps the originals for CI
  42. * artifact upload.
  43. *
  44. * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` → host-platform exe into dist-exe/
  45. * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64`
  46. * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --dry-run` → print the plan without executing
  47. */
  48. import { spawn } from 'node:child_process'
  49. import { existsSync, mkdirSync, statSync } from 'node:fs'
  50. import { copyFile, readFile, rm, writeFile } from 'node:fs/promises'
  51. import { basename, join, resolve, sep } from 'node:path'
  52. import { parseArgs } from 'node:util'
  53. const root = resolve(import.meta.dirname, '..')
  54. /**
  55. * The deploy root: the closure-manifest package (python/sdk-runtime) whose
  56. * dependencies define the exe's contents; the runnable entry inside the
  57. * closure is {@link ENTRY_BIN}.
  58. */
  59. const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg'
  60. /** The bin entry inside the deployed closure (the dsh-jsonrpc-agent app bin). */
  61. const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js'
  62. /** Basename of every product; the canonical name appends `-<platform>-<arch>`. */
  63. const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg'
  64. /** Default exe Node major; SEA mode requires >= node22, the repo tracks node24. */
  65. const DEFAULT_NODE_RANGE = 'node24'
  66. /** Pinned pkg version (the one the PoC and acceptance ran on) for reproducible builds. */
  67. const PKG_SPEC = '@yao-pkg/pkg@6.21.0'
  68. /** Staging dir for the deployed closure — cleared on every run (gitignored). */
  69. // (No external staging dir: the deploy target IS the Python runtime's
  70. // node-mode carrier — see PYTHON_RUNTIME_DIR/PYTHON_NODE_SUBDIR.)
  71. /** Product output dir (gitignored). */
  72. const OUT_DIR = 'dist-exe'
  73. /**
  74. * Python runtime package dir the products are synced into. A parallel change
  75. * owns the directory and its .gitignore; this script's only contract is the
  76. * destination path, so a missing dir is created, never an error.
  77. */
  78. const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime'
  79. /** Subdir of {@link PYTHON_RUNTIME_DIR} carrying the staged closure for node-mode execution. */
  80. const PYTHON_NODE_SUBDIR = 'node'
  81. /** Deploy-root documentation is not runtime input and violates the generated-directory i18n exclusion if retained. */
  82. const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
  83. /**
  84. * Whole-tree asset globs. The cordis Loader dynamic-imports bare package names
  85. * at runtime, invisible to pkg's static analysis, so every runtime file in the
  86. * closure is listed; SEA mode ships them as plain source in the VFS. Every
  87. * package.json must ride along — bare-name resolution dies without them (the
  88. * json glob would already match, but the manifests are resolution-critical, so
  89. * they get their own explicit entry).
  90. */
  91. const ASSET_GLOBS = [
  92. 'package.json',
  93. 'node_modules/**/*.js',
  94. 'node_modules/**/*.cjs',
  95. 'node_modules/**/*.mjs',
  96. 'node_modules/**/package.json',
  97. 'node_modules/**/*.json',
  98. 'node_modules/**/*.node',
  99. 'node_modules/**/*.wasm',
  100. ]
  101. const PLATFORMS = ['linux', 'macos'] as const
  102. const ARCHES = ['x64', 'arm64'] as const
  103. type Platform = (typeof PLATFORMS)[number]
  104. type Arch = (typeof ARCHES)[number]
  105. /** True when `value` is a supported pkg platform tag. */
  106. function isPlatform(value: string): value is Platform {
  107. return (PLATFORMS as readonly string[]).includes(value)
  108. }
  109. /** True when `value` is a supported pkg CPU tag. */
  110. function isArch(value: string): value is Arch {
  111. return (ARCHES as readonly string[]).includes(value)
  112. }
  113. /**
  114. * One pkg target triple, e.g. `node24-linux-x64`, as an immutable value.
  115. * Construction goes through {@link Target.parse} (a `--targets` entry) or
  116. * {@link Target.host} (the default), which own all validation.
  117. */
  118. class Target {
  119. private constructor(
  120. /** pkg Node range (`node<major>`); pins the official base binary pkg pulls. */
  121. readonly nodeRange: string,
  122. /**
  123. * pkg platform tag. Windows is a documented non-goal
  124. * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
  125. */
  126. readonly platform: Platform,
  127. /** pkg CPU tag. */
  128. readonly arch: Arch,
  129. ) {}
  130. /** The pkg `--targets` spec string `<nodeRange>-<platform>-<arch>`. */
  131. get spec(): string {
  132. return `${this.nodeRange}-${this.platform}-${this.arch}`
  133. }
  134. /**
  135. * Parse and validate one target spec; throws on any malformed component.
  136. * @param spec - the raw triple, e.g. `node24-linux-x64`.
  137. * @returns the parsed target.
  138. */
  139. static parse(spec: string): Target {
  140. const parts = spec.split('-')
  141. const [nodeRange, platform, arch] = parts
  142. if (parts.length !== 3 || nodeRange === undefined || platform === undefined || arch === undefined) {
  143. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)} must be <nodeRange>-<platform>-<arch>, e.g. node24-linux-x64.`)
  144. }
  145. if (!/^node\d+$/.test(nodeRange)) {
  146. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`)
  147. }
  148. if (!isPlatform(platform)) {
  149. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')} (Windows is a docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md non-goal), got ${JSON.stringify(platform)}.`)
  150. }
  151. if (!isArch(arch)) {
  152. throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`)
  153. }
  154. return new Target(nodeRange, platform, arch)
  155. }
  156. /**
  157. * The default target when --targets is omitted: the host platform on node24.
  158. * @returns the host target; throws on an unsupported host platform or arch.
  159. */
  160. static host(): Target {
  161. const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined
  162. if (platform === undefined) {
  163. throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`)
  164. }
  165. const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined
  166. if (arch === undefined) {
  167. throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`)
  168. }
  169. return new Target(DEFAULT_NODE_RANGE, platform, arch)
  170. }
  171. }
  172. /**
  173. * Parsed CLI configuration. {@link BuildCli.parse} is the only constructor
  174. * path — it owns flag parsing, target validation, and the --help / bad-flag
  175. * process exits, so an instance always holds a valid plan.
  176. */
  177. class BuildCli {
  178. private constructor(
  179. /** Build targets; defaults to the host platform only. */
  180. readonly targets: readonly Target[],
  181. /** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */
  182. readonly skipBuild: boolean,
  183. /** Print every command and config patch instead of executing. */
  184. readonly dryRun: boolean,
  185. ) {}
  186. /**
  187. * Parse argv into a validated configuration. Exits the process for --help
  188. * (code 0, usage) and for unknown/malformed flags (code 1, usage on
  189. * stderr); throws on invalid or colliding targets.
  190. * @param argv - the raw arguments (`process.argv.slice(2)`).
  191. * @returns the parsed, validated configuration.
  192. */
  193. static parse(argv: string[]): BuildCli {
  194. let values: ReturnType<typeof BuildCli.parseRaw>
  195. try {
  196. values = BuildCli.parseRaw(argv)
  197. } catch (error) {
  198. console.error(`build-exe-for-python-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
  199. console.error(BuildCli.usage())
  200. process.exit(1)
  201. }
  202. if (values.help) {
  203. console.log(BuildCli.usage())
  204. process.exit(0)
  205. }
  206. const targets = values.targets === undefined
  207. ? [Target.host()]
  208. : values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec))
  209. if (targets.length === 0) throw new Error('build-exe-for-python-sdk: --targets is empty.')
  210. const seen = new Set<string>()
  211. for (const target of targets) {
  212. const key = `${target.platform}-${target.arch}`
  213. if (seen.has(key)) {
  214. throw new Error(`build-exe-for-python-sdk: duplicate platform-arch ${key} in --targets; canonical product names would collide.`)
  215. }
  216. seen.add(key)
  217. }
  218. return new BuildCli(targets, values['skip-build'], values['dry-run'])
  219. }
  220. /** The flag grammar in one place; parseArgs throws on any unknown flag. */
  221. private static parseRaw(argv: string[]) {
  222. return parseArgs({
  223. args: argv,
  224. options: {
  225. 'targets': { type: 'string' },
  226. 'skip-build': { type: 'boolean', default: false },
  227. 'dry-run': { type: 'boolean', default: false },
  228. 'help': { type: 'boolean', default: false },
  229. },
  230. }).values
  231. }
  232. /** The --help text; also printed under flag-parse errors. */
  233. private static usage(): string {
  234. return [
  235. 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]',
  236. '',
  237. ' --targets=<t1,t2,...> pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.',
  238. ' Default: the host platform only (on node24).',
  239. ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
  240. ' --dry-run print every command and config patch without executing.',
  241. ' --help print this help.',
  242. '',
  243. 'Settled decisions are hardcoded (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md): pkg runs in --sea mode',
  244. `(standard mode breaks runtime import()), pinned to ${PKG_SPEC}; the deploy tree is`,
  245. `hoisted/symlink-free; the closure deploys straight into ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and products land in ${OUT_DIR}/.`,
  246. ].join('\n')
  247. }
  248. }
  249. /** The pnpm executable name for the host OS. */
  250. function pnpmBin(): string {
  251. return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
  252. }
  253. /**
  254. * Render a command line for logs and error messages, quoting arguments that
  255. * contain spaces.
  256. * @param command - the executable.
  257. * @param args - its arguments.
  258. * @returns the printable command line.
  259. */
  260. function formatCommand(command: string, args: string[]): string {
  261. return [command, ...args].map(part => (part.includes(' ') ? JSON.stringify(part) : part)).join(' ')
  262. }
  263. /**
  264. * The four-step build pipeline over one parsed CLI. Steps are sequential
  265. * async methods; every subprocess inherits stdio and fails loud with the
  266. * exact command it ran. In --dry-run the command/filesystem layer prints
  267. * what it would do instead of executing.
  268. */
  269. class SingleExeBuild {
  270. /**
  271. * Absolute staging dir — the Python runtime's node-mode carrier: step 2
  272. * deploys the closure DIRECTLY here (cleared first; it is a pure build
  273. * product, the checked-in default `cordis.yml` lives one level up), step 4
  274. * reads it as the pkg input, and node mode runs it in place.
  275. */
  276. readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR)
  277. /** Absolute product output dir. */
  278. private readonly outDir = resolve(root, OUT_DIR)
  279. constructor(private readonly cli: BuildCli) {}
  280. /** Gate the manifest before spending time compiling or packaging it. */
  281. async verifyClosure(): Promise<void> {
  282. await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure'])
  283. }
  284. /** Step 1: `pnpm run build` — all packages emit `lib/` (skipped via --skip-build). */
  285. async build(): Promise<void> {
  286. if (this.cli.skipBuild) {
  287. console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)')
  288. return
  289. }
  290. await this.run('build', pnpmBin(), ['run', 'build'])
  291. }
  292. /** Step 2: clear the staging dir and deploy the bridge closure into it. */
  293. async deployStaging(): Promise<void> {
  294. if (this.staging === root || root.startsWith(this.staging + sep)) {
  295. throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`)
  296. }
  297. if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`)
  298. else await rm(this.staging, { recursive: true, force: true })
  299. await this.run('deploy', pnpmBin(), [
  300. '--filter',
  301. DEPLOY_ROOT_PACKAGE,
  302. 'deploy',
  303. '--legacy',
  304. '--prod',
  305. '--config.node-linker=hoisted',
  306. '--config.auto-install-peers=false',
  307. '--config.link-workspace-packages=true',
  308. this.staging,
  309. ])
  310. if (this.cli.dryRun) {
  311. for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`)
  312. } else {
  313. await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true })))
  314. }
  315. }
  316. /** Step 3: patch the staged package.json with the bin entry + pkg asset globs. */
  317. async injectPkgConfig(): Promise<void> {
  318. const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } }
  319. const manifestPath = join(this.staging, 'package.json')
  320. if (this.cli.dryRun) {
  321. console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`)
  322. return
  323. }
  324. if (!existsSync(manifestPath)) {
  325. throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`)
  326. }
  327. if (!existsSync(join(this.staging, ENTRY_BIN))) {
  328. throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`)
  329. }
  330. const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
  331. await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`)
  332. console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`)
  333. }
  334. /**
  335. * Step 4: run @yao-pkg/pkg over the staged tree for ONE target (SEA mode
  336. * packs a single target per invocation) and return the product path.
  337. * @param target - the pkg target triple to build.
  338. * @returns the canonical product path `<out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>`.
  339. */
  340. async pack(target: Target): Promise<string> {
  341. const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
  342. if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true })
  343. await this.run(`pkg ${target.spec}`, pnpmBin(), [
  344. 'dlx',
  345. PKG_SPEC,
  346. this.staging,
  347. '--sea',
  348. '--targets',
  349. target.spec,
  350. '--output',
  351. product,
  352. ])
  353. if (!this.cli.dryRun && !existsSync(product)) {
  354. throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
  355. }
  356. return product
  357. }
  358. /**
  359. * Print each product path (and size, when it exists on disk).
  360. * @param products - the product paths returned by {@link pack}.
  361. */
  362. printProducts(products: string[]): void {
  363. console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:')
  364. for (const product of products) {
  365. if (this.cli.dryRun) {
  366. console.log(` ${product}`)
  367. continue
  368. }
  369. const megabytes = statSync(product).size / (1024 * 1024)
  370. console.log(` ${product} (${megabytes.toFixed(1)} MB)`)
  371. }
  372. }
  373. /**
  374. * Step 5: copy every product into the Python runtime package under its
  375. * canonical filename (exe mode). The node-mode carrier needs no sync — step
  376. * 2 deployed the closure into it directly. dist-exe/ keeps the originals
  377. * for CI artifact upload; the destination dir is created if missing.
  378. * @param products - the product paths returned by {@link pack}.
  379. */
  380. async syncToPythonRuntime(products: string[]): Promise<void> {
  381. const destDir = resolve(root, PYTHON_RUNTIME_DIR)
  382. if (this.cli.dryRun) {
  383. for (const product of products) {
  384. console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`)
  385. }
  386. return
  387. }
  388. mkdirSync(destDir, { recursive: true })
  389. for (const product of products) {
  390. const destination = join(destDir, basename(product))
  391. await copyFile(product, destination)
  392. console.log(`build-exe-for-python-sdk: synced ${destination}`)
  393. }
  394. }
  395. /**
  396. * Run one pipeline step as a subprocess with inherited stdio; reject —
  397. * carrying the printable command — on spawn failure and non-zero exit
  398. * alike. In --dry-run, print the command instead of executing.
  399. * @param label - the step name used in logs and error messages.
  400. * @param command - the executable.
  401. * @param args - its arguments.
  402. */
  403. private async run(label: string, command: string, args: string[]): Promise<void> {
  404. const printable = formatCommand(command, args)
  405. if (this.cli.dryRun) {
  406. console.log(`build-exe-for-python-sdk: [dry-run] ${printable}`)
  407. return
  408. }
  409. console.log(`build-exe-for-python-sdk: ${label}: ${printable}`)
  410. await new Promise<void>((resolvePromise, reject) => {
  411. const child = spawn(command, args, { cwd: root, stdio: 'inherit' })
  412. child.once('error', (error) => {
  413. reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`))
  414. })
  415. child.once('exit', (code, signal) => {
  416. if (code === 0) {
  417. resolvePromise()
  418. return
  419. }
  420. const cause = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}`
  421. reject(new Error(`build-exe-for-python-sdk: ${label} failed (${cause}): ${printable}`))
  422. })
  423. })
  424. }
  425. }
  426. /** Entry point: parse the CLI, then await each pipeline step in order. */
  427. async function main(): Promise<void> {
  428. const cli = BuildCli.parse(process.argv.slice(2))
  429. const pipeline = new SingleExeBuild(cli)
  430. console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`)
  431. console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`)
  432. await pipeline.verifyClosure()
  433. await pipeline.build()
  434. await pipeline.deployStaging()
  435. await pipeline.injectPkgConfig()
  436. const products: string[] = []
  437. for (const target of cli.targets) products.push(await pipeline.pack(target))
  438. pipeline.printProducts(products)
  439. await pipeline.syncToPythonRuntime(products)
  440. }
  441. await main()