diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml new file mode 100644 index 0000000000..46eab512bd --- /dev/null +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -0,0 +1,200 @@ +name: Build single-exe + +# Single-file executable (single-exe) builds of the DeepSeek Harness SDK +# runtime. The build pipeline and target platforms are specified in +# docs/rfc/implemented/architecture/2026-07-10-single-exe-sdk-runtime.md: each target is +# built natively on a runner of its own platform (no cross-compilation) by +# scripts/build-exe-for-python-sdk.ts, which deploys the dsh-jsonrpc-agent-pkg closure manifest +# closure with @yao-pkg/pkg into dist-exe/. +# +# Each build leg uploads two artifacts: +# - dsh-jsonrpc-agent-pkg- — the bare single-file exe, for +# consumers that want just the binary. +# - deepseek-harness-python- — the whole python/ directory as a +# tar.gz with that exe already embedded (the build script syncs it into +# the Python runtime package): unpack and both packages pip install +# as-is, the checked-in default runtime/cordis.yml is editable in +# place, and the embedded exe also runs directly. +# +# workflow_dispatch ONLY — deliberately not triggered by push/pull_request: +# the exe is a release-style deliverable, and the build (full pnpm build + +# pnpm deploy + pkg across a 3-platform matrix, ~100MB per artifact) is far +# too expensive to run as a per-commit CI signal. Dispatch it from the +# Actions tab when artifacts are needed. There is no `ref` input on purpose: +# actions/checkout already checks out the branch/tag the run was dispatched +# on. +on: + workflow_dispatch: + inputs: + targets: + description: >- + Comma-separated pkg targets to build. Any subset of: + node24-linux-x64, node24-linux-arm64, node24-macos-arm64. + type: string + required: false + default: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 + +# Manual runs on the same ref supersede each other. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: the jobs only read the repo; artifact upload needs no +# extra scope. +permissions: + contents: read + +jobs: + # Turn the `targets` input into the build matrix. The `matrix` context is + # not available in a job-level `if:` (jobs..if only sees + # github/needs/vars/inputs), so target selection happens here instead of + # skipping matrix legs; an unknown target name fails the whole run loudly + # instead of being silently ignored. + plan: + name: plan targets + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Compute matrix from targets input + id: plan + env: + TARGETS: ${{ inputs.targets }} + run: | + set -euo pipefail + matrix='[]' + IFS=',' read -r -a targets <<< "$TARGETS" + for raw in "${targets[@]}"; do + t="$(echo "$raw" | xargs)" # trim surrounding whitespace + [ -z "$t" ] && continue + # Native builds only — each target maps to a runner of its own + # platform: linux-arm64 uses GitHub's hosted arm64 label + # ubuntu-24.04-arm (there is no ubuntu-latest-arm), macos-arm64 + # uses macos-latest (Apple Silicon since macos-14). + case "$t" in + node24-linux-x64) runner=ubuntu-latest ;; + node24-linux-arm64) runner=ubuntu-24.04-arm ;; + node24-macos-arm64) runner=macos-latest ;; + *) + echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64." + exit 1 + ;; + esac + matrix="$(jq -c --arg target "$t" --arg runner "$runner" '. + [{target: $target, runner: $runner}]' <<< "$matrix")" + done + if [ "$matrix" = '[]' ]; then + echo "::error::The targets input selected nothing to build." + exit 1 + fi + echo "Matrix: $matrix" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + build: + needs: plan + name: ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Enable corepack (pnpm) + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + # Unlike ci.yml (x64-only), this matrix spans two Linux architectures + # that share runner.os, so runner.arch is part of the key. + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm- + + # The first run per target has pkg-fetch download yao-pkg's patched + # Node binary into ~/.pkg-cache; cache it so later runs skip the + # download. The target string pins Node major + platform + arch; + # pnpm-lock.yaml rolls the key when @yao-pkg/pkg (and with it the + # pinned patched-binary version) is bumped, with restore-keys still + # seeding from the previous cache. + - uses: actions/cache@v4 + with: + path: ~/.pkg-cache + key: pkg-fetch-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pkg-fetch-${{ matrix.target }}- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + # The script runs the whole pipeline itself (pnpm run build → pnpm + # deploy --prod → pkg) and writes its output to dist-exe/ by default. + - name: Build single-exe + run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} + + - uses: actions/upload-artifact@v6 + with: + name: dsh-jsonrpc-agent-pkg-${{ matrix.target }} + path: dist-exe/ + if-no-files-found: error + + # After the build step, python/ is already in its complete + # distributable shape — the script synced this leg's exe into + # python/sdk-runtime/src/deepseek_harness_runtime/runtime/ next to the + # checked-in default cordis.yml — so packing is all that is left. + # Everything goes under one top-level deepseek-harness-python/ + # directory so unpacking never scatters files. Shipping a tar (rather + # than uploading the tree bare) preserves the exe's executable bit — + # tar keeps file modes; upload-artifact's zip does not. + # + # Excluded: runtime/node/ (dev-only node-mode carrier, ~140MB) plus + # __pycache__ / .pytest_cache / .venv / node_modules anywhere (install + # or test leftovers); uv.lock stays in. + # + # Portability: GNU tar (ubuntu) and bsdtar (macos) both accept + # `tar -czf out.tar.gz --exclude=… -C ` and both treat + # an excluded directory as pruned (no descent). The top-level rename + # is done by copying into a temp dir first — GNU --transform / BSD -s + # are single-implementation flags. + - name: Pack Python SDK bundle + id: pack + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + platform_arch="${TARGET#*-}" # node24-macos-arm64 -> macos-arm64 + exe="python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg-${platform_arch}" + if [ ! -x "$exe" ]; then + echo "::error::$exe missing or not executable — the build step did not sync this leg's exe into the Python runtime package; refusing to pack a half-empty bundle." + exit 1 + fi + staging="$(mktemp -d)" + cp -R python "$staging/deepseek-harness-python" + bundle="deepseek-harness-python-${platform_arch}.tar.gz" + tar -czf "$bundle" \ + --exclude='deepseek-harness-python/sdk-runtime/src/deepseek_harness_runtime/runtime/node' \ + --exclude='__pycache__' \ + --exclude='.pytest_cache' \ + --exclude='.venv' \ + --exclude='node_modules' \ + -C "$staging" deepseek-harness-python + rm -rf "$staging" + ls -lh "$bundle" + echo "bundle=$bundle" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v6 + with: + name: deepseek-harness-python-${{ matrix.target }} + path: ${{ steps.pack.outputs.bundle }} + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 6faccc8b6d..90709e81c0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,5 +19,8 @@ tmp/ .DS_Store .idea mise.toml +dist-exe/ +python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-* +python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/ python/**/__pycache__/ python/**/.pytest_cache/ diff --git a/knip.json b/knip.json index f37f1d7636..825980f205 100644 --- a/knip.json +++ b/knip.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], "ignoreBinaries": ["bwrap", "sandbox-exec"], - "ignoreWorkspaces": ["vendor/*"], + "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"], "workspaces": { ".": { "entry": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d868ae049..164bcbb17f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1620,6 +1620,186 @@ importers: specifier: ^4.19.2 version: 4.22.4 + python/sdk-runtime: + dependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../vendor/loader + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../vendor/timer + '@deepseek-ai/dsh-acp': + specifier: workspace:^ + version: link:../../packages/ui/acp + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../packages/core/agent + '@deepseek-ai/dsh-agent-core': + specifier: workspace:^ + version: link:../../packages/core/agent-core + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../packages/core/agent-loop + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../packages/ui/app-boot + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../packages/bash/bash + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../packages/bash/bash-local + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../packages/util/brand + '@deepseek-ai/dsh-code-runtime': + specifier: workspace:^ + version: link:../../packages/code-runtime/code-runtime + '@deepseek-ai/dsh-code-runtime-worker': + specifier: workspace:^ + version: link:../../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../packages/compact/compact + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../packages/fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../packages/fs/fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-hook-protocol': + specifier: workspace:^ + version: link:../../packages/hooks/hook-protocol + '@deepseek-ai/dsh-hooks-claude': + specifier: workspace:^ + version: link:../../packages/hooks/hooks-claude + '@deepseek-ai/dsh-hooks-codex': + specifier: workspace:^ + version: link:../../packages/hooks/hooks-codex + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../packages/support/invariants + '@deepseek-ai/dsh-jsonrpc': + specifier: workspace:^ + version: link:../../packages/ui/jsonrpc + '@deepseek-ai/dsh-jsonrpc-agent': + specifier: workspace:^ + version: link:../../packages/ui/jsonrpc-agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../packages/llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:^ + version: link:../../packages/llm/llm-pi-ai + '@deepseek-ai/dsh-repeat-tool-guard': + specifier: workspace:^ + version: link:../../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../packages/core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-persistence-sqlite': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-acp': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-fork + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-inprocess + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subagent-subprocess': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-subprocess + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../packages/core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../packages/util/timeout + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../packages/bash/tool-bash + '@deepseek-ai/dsh-tool-cordis': + specifier: workspace:^ + version: link:../../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../packages/web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/tool-workflow + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../packages/core/tools + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../../packages/ui/user-interaction + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../../packages/web/web + '@deepseek-ai/dsh-web-fetch-local': + specifier: workspace:^ + version: link:../../packages/web/web-fetch-local + '@deepseek-ai/dsh-web-search-deepseek': + specifier: workspace:^ + version: link:../../packages/web/web-search-deepseek + '@deepseek-ai/dsh-web-search-exa': + specifier: workspace:^ + version: link:../../packages/web/web-search-exa + '@deepseek-ai/dsh-web-search-perplexity': + specifier: workspace:^ + version: link:../../packages/web/web-search-perplexity + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/workflow + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../packages/workflow/workflow-workerthread + cordis: + specifier: workspace:^ + version: link:../../vendor/cordis + vendor/cordis: dependencies: '@cordisjs/plugin-include': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6407a99f52..6dc85a6079 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,9 @@ packages: - vendor/* - packages/*/* + # Deploy root of the single-exe build: a pure dependency manifest whose + # closure is what the exe bundles and what the Python runtime distributes. + - python/sdk-runtime peerDependencyRules: allowedVersions: diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json new file mode 100644 index 0000000000..7f179f3941 --- /dev/null +++ b/python/sdk-runtime/package.json @@ -0,0 +1,68 @@ +{ + "name": "dsh-jsonrpc-agent-pkg", + "description": "Deploy root of the single-exe pipeline and the single source of truth unifying 'which plugins the exe bundles' and 'what the Python runtime distributes': the dependency list below IS the exe closure. Pure manifest — no code; a deploy materializes only this package.json plus node_modules.", + "version": "0.0.1", + "private": true, + "type": "module", + "dependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-hooks-claude": "workspace:^", + "@deepseek-ai/dsh-hooks-codex": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jsonrpc": "workspace:^", + "@deepseek-ai/dsh-jsonrpc-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-acp": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-web-fetch-local": "workspace:^", + "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", + "@deepseek-ai/dsh-web-search-exa": "workspace:^", + "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "cordis": "workspace:^" + } +} diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts new file mode 100644 index 0000000000..0cc7b0c731 --- /dev/null +++ b/scripts/build-exe-for-python-sdk.ts @@ -0,0 +1,457 @@ +/** + * Build the single-file SDK runtime executables + * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * + * Every settled decision is hardcoded — the PoC judged @yao-pkg/pkg's + * standard mode unusable for this architecture (its ESM→CJS transform breaks + * every runtime `import()`), so the pipeline is fixed on `--sea` mode, plain + * ESM entry, plain-source assets, and a hoisted (symlink-free) staged tree. + * + * Pipeline — every step fails loud with the command it ran: + * + * 1. `pnpm run build` — all packages emit `lib/` (skippable via --skip-build). + * 2. `pnpm --filter dsh-jsonrpc-agent-pkg deploy` — materialize the + * closure-manifest package (python/sdk-runtime/package.json — the single + * source of truth for the exe's plugin set) into the staging dir + * (cleared first; pnpm refuses a non-empty deploy target). Flags, all + * verified against pnpm 11.7: `--legacy` because the workspace does not + * set `inject-workspace-packages=true`; `node-linker=hoisted` for a plain + * file tree with zero symlinks (the safe shape for pkg's VFS, and it + * physically guarantees a single cordis copy); `auto-install-peers=false` + * so transitive `^0.0.x` peers on unpublished packages never hit the + * registry; `link-workspace-packages=true` so the closure resolves to + * workspace/vendor sources. + * 3. Inject the pkg config into the staged package.json: `bin` = the ESM + * `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` (SEA mode + * hands it to Node's default ESM loader — no CJS shim), plus whole-tree + * asset globs. The cordis Loader resolves plugins + * through runtime dynamic `import()` of bare package names, so pkg's + * static analysis discovers none of them — the entire staged tree must be + * globbed in explicitly. + * 4. `pnpm dlx @yao-pkg/pkg@ --sea --targets --output + * /dsh-jsonrpc-agent-pkg--` — once per target (SEA mode + * packs a single target per invocation), so each product gets its + * canonical name directly. + * 5. Sync into the Python runtime package + * (python/sdk-runtime/src/deepseek_harness_runtime/runtime/, + * created if missing): each product under its canonical filename (exe + * mode), plus the whole staged closure into runtime/node/ (node mode — + * `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` + * runs it directly; the injected pkg + * fields are harmless to node). dist-exe/ keeps the originals for CI + * artifact upload. + * + * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` → host-platform exe into dist-exe/ + * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64` + * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --dry-run` → print the plan without executing + */ + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, statSync } from 'node:fs' +import { copyFile, readFile, rm, writeFile } from 'node:fs/promises' +import { basename, join, resolve, sep } from 'node:path' +import { parseArgs } from 'node:util' + +const root = resolve(import.meta.dirname, '..') + +/** + * The deploy root: the closure-manifest package (python/sdk-runtime) whose + * dependencies define the exe's contents; the runnable entry inside the + * closure is {@link ENTRY_BIN}. + */ +const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' +/** The bin entry inside the deployed closure (the dsh-jsonrpc-agent app bin). */ +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js' +/** Basename of every product; the canonical name appends `--`. */ +const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' +/** Default exe Node major; SEA mode requires >= node22, the repo tracks node24. */ +const DEFAULT_NODE_RANGE = 'node24' +/** Pinned pkg version (the one the PoC and acceptance ran on) for reproducible builds. */ +const PKG_SPEC = '@yao-pkg/pkg@6.21.0' +/** Staging dir for the deployed closure — cleared on every run (gitignored). */ +// (No external staging dir: the deploy target IS the Python runtime's +// node-mode carrier — see PYTHON_RUNTIME_DIR/PYTHON_NODE_SUBDIR.) +/** Product output dir (gitignored). */ +const OUT_DIR = 'dist-exe' +/** + * Python runtime package dir the products are synced into. A parallel change + * owns the directory and its .gitignore; this script's only contract is the + * destination path, so a missing dir is created, never an error. + */ +const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' +/** Subdir of {@link PYTHON_RUNTIME_DIR} carrying the staged closure for node-mode execution. */ +const PYTHON_NODE_SUBDIR = 'node' + +/** + * Whole-tree asset globs. The cordis Loader dynamic-imports bare package names + * at runtime, invisible to pkg's static analysis, so every runtime file in the + * closure is listed; SEA mode ships them as plain source in the VFS. Every + * package.json must ride along — bare-name resolution dies without them (the + * json glob would already match, but the manifests are resolution-critical, so + * they get their own explicit entry). + */ +const ASSET_GLOBS = [ + 'package.json', + 'node_modules/**/*.js', + 'node_modules/**/*.cjs', + 'node_modules/**/*.mjs', + 'node_modules/**/package.json', + 'node_modules/**/*.json', + 'node_modules/**/*.node', + 'node_modules/**/*.wasm', +] + +const PLATFORMS = ['linux', 'macos'] as const +const ARCHES = ['x64', 'arm64'] as const +type Platform = (typeof PLATFORMS)[number] +type Arch = (typeof ARCHES)[number] + +/** True when `value` is a supported pkg platform tag. */ +function isPlatform(value: string): value is Platform { + return (PLATFORMS as readonly string[]).includes(value) +} + +/** True when `value` is a supported pkg CPU tag. */ +function isArch(value: string): value is Arch { + return (ARCHES as readonly string[]).includes(value) +} + +/** + * One pkg target triple, e.g. `node24-linux-x64`, as an immutable value. + * Construction goes through {@link Target.parse} (a `--targets` entry) or + * {@link Target.host} (the default), which own all validation. + */ +class Target { + private constructor( + /** pkg Node range (`node`); pins the official base binary pkg pulls. */ + readonly nodeRange: string, + /** + * pkg platform tag. Windows is a documented non-goal + * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + */ + readonly platform: Platform, + /** pkg CPU tag. */ + readonly arch: Arch, + ) {} + + /** The pkg `--targets` spec string `--`. */ + get spec(): string { + return `${this.nodeRange}-${this.platform}-${this.arch}` + } + + /** + * Parse and validate one target spec; throws on any malformed component. + * @param spec - the raw triple, e.g. `node24-linux-x64`. + * @returns the parsed target. + */ + static parse(spec: string): Target { + const parts = spec.split('-') + const [nodeRange, platform, arch] = parts + if (parts.length !== 3 || nodeRange === undefined || platform === undefined || arch === undefined) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)} must be --, e.g. node24-linux-x64.`) + } + if (!/^node\d+$/.test(nodeRange)) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`) + } + if (!isPlatform(platform)) { + 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)}.`) + } + if (!isArch(arch)) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`) + } + return new Target(nodeRange, platform, arch) + } + + /** + * The default target when --targets is omitted: the host platform on node24. + * @returns the host target; throws on an unsupported host platform or arch. + */ + static host(): Target { + const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined + if (platform === undefined) { + throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`) + } + const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined + if (arch === undefined) { + throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`) + } + return new Target(DEFAULT_NODE_RANGE, platform, arch) + } +} + +/** + * Parsed CLI configuration. {@link BuildCli.parse} is the only constructor + * path — it owns flag parsing, target validation, and the --help / bad-flag + * process exits, so an instance always holds a valid plan. + */ +class BuildCli { + private constructor( + /** Build targets; defaults to the host platform only. */ + readonly targets: readonly Target[], + /** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */ + readonly skipBuild: boolean, + /** Print every command and config patch instead of executing. */ + readonly dryRun: boolean, + ) {} + + /** + * Parse argv into a validated configuration. Exits the process for --help + * (code 0, usage) and for unknown/malformed flags (code 1, usage on + * stderr); throws on invalid or colliding targets. + * @param argv - the raw arguments (`process.argv.slice(2)`). + * @returns the parsed, validated configuration. + */ + static parse(argv: string[]): BuildCli { + let values: ReturnType + try { + values = BuildCli.parseRaw(argv) + } catch (error) { + console.error(`build-exe-for-python-sdk: ${error instanceof Error ? error.message : String(error)}\n`) + console.error(BuildCli.usage()) + process.exit(1) + } + if (values.help) { + console.log(BuildCli.usage()) + process.exit(0) + } + const targets = values.targets === undefined + ? [Target.host()] + : values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec)) + if (targets.length === 0) throw new Error('build-exe-for-python-sdk: --targets is empty.') + const seen = new Set() + for (const target of targets) { + const key = `${target.platform}-${target.arch}` + if (seen.has(key)) { + throw new Error(`build-exe-for-python-sdk: duplicate platform-arch ${key} in --targets; canonical product names would collide.`) + } + seen.add(key) + } + return new BuildCli(targets, values['skip-build'], values['dry-run']) + } + + /** The flag grammar in one place; parseArgs throws on any unknown flag. */ + private static parseRaw(argv: string[]) { + return parseArgs({ + args: argv, + options: { + 'targets': { type: 'string' }, + 'skip-build': { type: 'boolean', default: false }, + 'dry-run': { type: 'boolean', default: false }, + 'help': { type: 'boolean', default: false }, + }, + }).values + } + + /** The --help text; also printed under flag-parse errors. */ + private static usage(): string { + return [ + 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]', + '', + ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.', + ' Default: the host platform only (on node24).', + ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).', + ' --dry-run print every command and config patch without executing.', + ' --help print this help.', + '', + 'Settled decisions are hardcoded (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md): pkg runs in --sea mode', + `(standard mode breaks runtime import()), pinned to ${PKG_SPEC}; the deploy tree is`, + `hoisted/symlink-free; the closure deploys straight into ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and products land in ${OUT_DIR}/.`, + ].join('\n') + } +} + +/** The pnpm executable name for the host OS. */ +function pnpmBin(): string { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +} + +/** + * Render a command line for logs and error messages, quoting arguments that + * contain spaces. + * @param command - the executable. + * @param args - its arguments. + * @returns the printable command line. + */ +function formatCommand(command: string, args: string[]): string { + return [command, ...args].map(part => (part.includes(' ') ? JSON.stringify(part) : part)).join(' ') +} + +/** + * The four-step build pipeline over one parsed CLI. Steps are sequential + * async methods; every subprocess inherits stdio and fails loud with the + * exact command it ran. In --dry-run the command/filesystem layer prints + * what it would do instead of executing. + */ +class SingleExeBuild { + /** + * Absolute staging dir — the Python runtime's node-mode carrier: step 2 + * deploys the closure DIRECTLY here (cleared first; it is a pure build + * product, the checked-in default `cordis.yml` lives one level up), step 4 + * reads it as the pkg input, and node mode runs it in place. + */ + readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR) + /** Absolute product output dir. */ + private readonly outDir = resolve(root, OUT_DIR) + + constructor(private readonly cli: BuildCli) {} + + /** Step 1: `pnpm run build` — all packages emit `lib/` (skipped via --skip-build). */ + async build(): Promise { + if (this.cli.skipBuild) { + console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)') + return + } + await this.run('build', pnpmBin(), ['run', 'build']) + } + + /** Step 2: clear the staging dir and deploy the bridge closure into it. */ + async deployStaging(): Promise { + if (this.staging === root || root.startsWith(this.staging + sep)) { + throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`) + } + if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`) + else await rm(this.staging, { recursive: true, force: true }) + await this.run('deploy', pnpmBin(), [ + '--filter', + DEPLOY_ROOT_PACKAGE, + 'deploy', + '--legacy', + '--prod', + '--config.node-linker=hoisted', + '--config.auto-install-peers=false', + '--config.link-workspace-packages=true', + this.staging, + ]) + } + + /** Step 3: patch the staged package.json with the bin entry + pkg asset globs. */ + async injectPkgConfig(): Promise { + const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } + const manifestPath = join(this.staging, 'package.json') + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`) + return + } + if (!existsSync(manifestPath)) { + throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`) + } + if (!existsSync(join(this.staging, ENTRY_BIN))) { + throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`) + } + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record + await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`) + console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`) + } + + /** + * Step 4: run @yao-pkg/pkg over the staged tree for ONE target (SEA mode + * packs a single target per invocation) and return the product path. + * @param target - the pkg target triple to build. + * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. + */ + async pack(target: Target): Promise { + const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) + await this.run(`pkg ${target.spec}`, pnpmBin(), [ + 'dlx', + PKG_SPEC, + this.staging, + '--sea', + '--targets', + target.spec, + '--output', + product, + ]) + if (!this.cli.dryRun && !existsSync(product)) { + throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) + } + return product + } + + /** + * Print each product path (and size, when it exists on disk). + * @param products - the product paths returned by {@link pack}. + */ + printProducts(products: string[]): void { + console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:') + for (const product of products) { + if (this.cli.dryRun) { + console.log(` ${product}`) + continue + } + const megabytes = statSync(product).size / (1024 * 1024) + console.log(` ${product} (${megabytes.toFixed(1)} MB)`) + } + } + + /** + * Step 5: copy every product into the Python runtime package under its + * canonical filename (exe mode). The node-mode carrier needs no sync — step + * 2 deployed the closure into it directly. dist-exe/ keeps the originals + * for CI artifact upload; the destination dir is created if missing. + * @param products - the product paths returned by {@link pack}. + */ + async syncToPythonRuntime(products: string[]): Promise { + const destDir = resolve(root, PYTHON_RUNTIME_DIR) + if (this.cli.dryRun) { + for (const product of products) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`) + } + return + } + mkdirSync(destDir, { recursive: true }) + for (const product of products) { + const destination = join(destDir, basename(product)) + await copyFile(product, destination) + console.log(`build-exe-for-python-sdk: synced ${destination}`) + } + } + + /** + * Run one pipeline step as a subprocess with inherited stdio; reject — + * carrying the printable command — on spawn failure and non-zero exit + * alike. In --dry-run, print the command instead of executing. + * @param label - the step name used in logs and error messages. + * @param command - the executable. + * @param args - its arguments. + */ + private async run(label: string, command: string, args: string[]): Promise { + const printable = formatCommand(command, args) + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] ${printable}`) + return + } + console.log(`build-exe-for-python-sdk: ${label}: ${printable}`) + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit' }) + child.once('error', (error) => { + reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`)) + }) + child.once('exit', (code, signal) => { + if (code === 0) { + resolvePromise() + return + } + const cause = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}` + reject(new Error(`build-exe-for-python-sdk: ${label} failed (${cause}): ${printable}`)) + }) + }) + } +} + +/** Entry point: parse the CLI, then await each pipeline step in order. */ +async function main(): Promise { + const cli = BuildCli.parse(process.argv.slice(2)) + const pipeline = new SingleExeBuild(cli) + console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`) + console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`) + await pipeline.build() + await pipeline.deployStaging() + await pipeline.injectPkgConfig() + const products: string[] = [] + for (const target of cli.targets) products.push(await pipeline.pack(target)) + pipeline.printProducts(products) + await pipeline.syncToPythonRuntime(products) +} + +await main()