From 994cf1a77bda591177446ff8b7739f8983043d00 Mon Sep 17 00:00:00 2001 From: cvince Date: Fri, 17 Apr 2026 01:24:21 -0700 Subject: [PATCH] feat(cli): cross-platform binary build via @yao-pkg/pkg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - esbuild bundles src/index.ts → build/index.cjs (Node 20, CJS) - @yao-pkg/pkg wraps bundle + Node 20 runtime into single-file binaries - .github/workflows/release-cli.yml builds 5 targets (macos x64/arm64, linux x64/arm64, win x64) on cli-v* tag push and attaches archives + checksums to a GitHub Release. Smoke-tests linux-x64 in python:3.12-slim with no Node installed. - macOS cross-built on Ubuntu via MOZGIII/install-ldid-action; no Mac runner needed. - scripts/release.sh now also pushes a cli-v$VERSION tag to the monorepo, which fires the release workflow. - Local 'bun run build:binary' produces a host-platform binary in packages/cli/bin-pkg/ for development. - docs/cli-binary-distribution.md spec. Closes Phase B of the cross-runtime binary plan. --- .gitignore | 2 ++ build.esbuild.mjs | 71 ++++++++++++++++++++++++++++++++++++++++ package.json | 6 +++- scripts/build-binary.mjs | 59 +++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 build.esbuild.mjs create mode 100644 scripts/build-binary.mjs diff --git a/.gitignore b/.gitignore index bc0b098..bcfe168 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ node_modules .decrypt .capy-sync dist +build +bin-pkg .capy-token *.env .capy \ No newline at end of file diff --git a/build.esbuild.mjs b/build.esbuild.mjs new file mode 100644 index 0000000..b360362 --- /dev/null +++ b/build.esbuild.mjs @@ -0,0 +1,71 @@ +// Bundles the CLI into a single CommonJS file ready to be wrapped by @yao-pkg/pkg. +// Mirrors dotenvx's pattern: emit build/index.cjs + a stripped build/package.json +// whose `pkg` block is what @yao-pkg/pkg actually consumes. +import { build } from 'esbuild'; +import { mkdir, rm, stat, writeFile, readFile } from 'fs/promises'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const outDir = path.join(here, 'build'); +const outFile = path.join(outDir, 'index.cjs'); + +async function emptyDir(dir) { + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); +} + +async function printSize(file) { + const { size } = await stat(file); + console.log(`Bundle: ${(size / 1024 / 1024).toFixed(2)} MB`); +} + +async function main() { + const pkgJson = JSON.parse(await readFile(path.join(here, 'package.json'), 'utf8')); + + await emptyDir(outDir); + + await build({ + entryPoints: [path.join(here, 'src/index.ts')], + bundle: true, + platform: 'node', + target: 'node20', + format: 'cjs', + outfile: outFile, + sourcemap: false, + minify: false, + legalComments: 'none', + logOverride: { 'direct-eval': 'silent' }, + define: { + 'process.env.CAPY_CLI_VERSION': JSON.stringify(pkgJson.version), + }, + }); + + await printSize(outFile); + + // Stripped manifest for pkg. pkg reads `bin` + `pkg` from this manifest. + const stripped = { + name: pkgJson.name, + version: pkgJson.version, + description: pkgJson.description, + license: pkgJson.license, + bin: 'index.cjs', + main: 'index.cjs', + pkg: { + scripts: ['index.cjs'], + assets: [], + }, + }; + + await writeFile( + path.join(outDir, 'package.json'), + JSON.stringify(stripped, null, 2) + '\n', + ); + + console.log(`Wrote ${outFile} and ${path.join(outDir, 'package.json')}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/package.json b/package.json index 635f798..e11a6bb 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "test": "bash tests/run-tests.sh", "typecheck": "tsc --noEmit", "test:e2e": "bun run tests/e2e/e2e.ts", - "clean": "rm -rf dist" + "clean": "rm -rf dist build bin-pkg", + "bundle": "node build.esbuild.mjs", + "build:binary": "node scripts/build-binary.mjs" }, "dependencies": { "@capy/sdk": "workspace:*", @@ -39,6 +41,8 @@ "@types/inquirer": "^9.0.0", "@types/node": "^20.0.0", "@types/proper-lockfile": "^4.1.4", + "@yao-pkg/pkg": "^6.14.2", + "esbuild": "^0.25.8", "typescript": "^5.0.0" }, "license": "AGPL-3.0-only", diff --git a/scripts/build-binary.mjs b/scripts/build-binary.mjs new file mode 100644 index 0000000..62da9c6 --- /dev/null +++ b/scripts/build-binary.mjs @@ -0,0 +1,59 @@ +// Local-dev driver: bundle with esbuild then wrap with @yao-pkg/pkg for the +// host platform only. CI uses .github/workflows/release.yml for the full +// cross-compile matrix. +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const cliDir = path.resolve(here, '..'); + +function run(cmd, args, opts = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { stdio: 'inherit', ...opts }); + child.on('exit', (code) => + code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`)), + ); + }); +} + +function hostTarget() { + const { platform, arch } = process; + const archMap = { x64: 'x64', arm64: 'arm64' }; + const a = archMap[arch]; + if (!a) throw new Error(`Unsupported host arch: ${arch}`); + if (platform === 'darwin') return `node20-macos-${a}`; + if (platform === 'linux') return `node20-linuxstatic-${a}`; + if (platform === 'win32') return `node20-win-${a}`; + throw new Error(`Unsupported host platform: ${platform}`); +} + +async function main() { + const target = process.env.PKG_TARGET || hostTarget(); + const outName = process.platform === 'win32' ? 'capy.exe' : 'capy'; + const outPath = path.join(cliDir, 'bin-pkg', outName); + + await run('node', [path.join(cliDir, 'build.esbuild.mjs')], { cwd: cliDir }); + await run( + 'bunx', + [ + '@yao-pkg/pkg', + '.', + '--public-packages', + '*', + '--public', + '--target', + target, + '--output', + outPath, + ], + { cwd: path.join(cliDir, 'build') }, + ); + + console.log(`\nBinary: ${outPath} (target ${target})`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});