diff --git a/.changeset/cli-tsdown-sea.md b/.changeset/cli-tsdown-sea.md new file mode 100644 index 00000000..c7daaa1f --- /dev/null +++ b/.changeset/cli-tsdown-sea.md @@ -0,0 +1,5 @@ +--- +"@neaps/cli": patch +--- + +Migrate SEA (Single Executable Application) build from a custom esbuild script to tsdown's built-in `exe` option. Removes the `esbuild` dev dependency. diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 906a2fa6..25df2eec 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -71,6 +71,14 @@ jobs: - name: Build SEA binary run: npm run build:sea --workspace=packages/cli + - name: Smoke test binary + shell: bash + run: | + EXT=${{ matrix.target == 'win-x64' && '".exe"' || '""' }} + BIN="packages/cli/dist/neaps${EXT}" + "$BIN" --version + "$BIN" --help + - name: Verify reproducible build shell: bash run: | diff --git a/package.json b/package.json index 90b7ef6c..48ac1597 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "make-fetch-happen": "^15.0.3", "npm-run-all": "^4.1.5", "prettier": "^3.7.4", - "tsdown": "^0.20.1", + "tsdown": "^0.21.0", "typescript": "^5.3.3", "typescript-eslint": "^8.56.0", "vitest": "^4.0.15" diff --git a/packages/cli/package.json b/packages/cli/package.json index 23bfb6b7..bb9a9079 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,7 +34,7 @@ ], "scripts": { "build": "tsdown", - "build:sea": "node scripts/build-sea.ts", + "build:sea": "tsdown --config tsdown.sea.config.ts", "prepack": "npm run build", "test": "vitest" }, @@ -48,7 +48,6 @@ }, "devDependencies": { "@types/node": "^25.0.2", - "esbuild": "^0.27.3", "nock": "^14.0.11" } } diff --git a/packages/cli/scripts/build-sea.ts b/packages/cli/scripts/build-sea.ts deleted file mode 100644 index 3ffe5844..00000000 --- a/packages/cli/scripts/build-sea.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Build a Node.js Single Executable Application (SEA). - * - * 1. Bundle ESM sources into a single CJS file with esbuild - * 2. Build SEA binary with `node --build-sea` (Node 25.5+) - */ -import { build } from "esbuild"; -import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; -import { execFileSync } from "node:child_process"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const root = resolve(__dirname, ".."); -const distDir = resolve(root, "dist"); -const bundlePath = resolve(distDir, "sea-bundle.cjs"); - -const isWindows = process.platform === "win32"; -const ext = isWindows ? ".exe" : ""; -const outputPath = resolve(distDir, `neaps${ext}`); - -if (!existsSync(distDir)) { - mkdirSync(distDir, { recursive: true }); -} - -// Step 1: Bundle ESM → single CJS file -console.log("Bundling with esbuild..."); -await build({ - entryPoints: [resolve(root, "src/index.ts")], - bundle: true, - platform: "node", - format: "cjs", - outfile: bundlePath, - minify: true, - sourcemap: false, - external: [], - target: "node25", - banner: { - js: "// Single executable application bundle", - }, -}); -console.log(`Bundled to ${bundlePath}`); - -// Step 2: Build SEA binary -const configPath = resolve(distDir, "sea-config.json"); -writeFileSync( - configPath, - JSON.stringify({ - main: "./dist/sea-bundle.cjs", - output: `./dist/neaps${ext}`, - disableExperimentalSEAWarning: true, - useCodeCache: false, - executable: process.execPath, - }), -); - -console.log("Building SEA binary..."); -execFileSync("node", ["--build-sea", configPath], { stdio: "inherit", cwd: root }); - -// macOS requires ad-hoc signing after injection -if (process.platform === "darwin") { - console.log("Signing binary (macOS)..."); - execFileSync("codesign", ["--sign", "-", "--identifier", "io.openwaters.neaps", outputPath], { - stdio: "inherit", - }); -} - -console.log(`\nSingle executable built: ${outputPath}`); - -const stats = statSync(outputPath); -const sizeMB = (stats.size / (1024 * 1024)).toFixed(1); -console.log(`Size: ${sizeMB} MB`); diff --git a/packages/cli/tsdown.sea.config.ts b/packages/cli/tsdown.sea.config.ts new file mode 100644 index 00000000..43a50c5b --- /dev/null +++ b/packages/cli/tsdown.sea.config.ts @@ -0,0 +1,39 @@ +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + platform: "node", + // CJS format has significantly faster startup than ESM in SEA binaries + // (synchronous require vs async module graph loading). + format: "cjs", + minify: true, + deps: { + // SEA needs everything bundled — override the default behavior that + // externalizes packages listed in package.json dependencies. + alwaysBundle: () => true, + }, + exe: { + fileName: "neaps", + seaConfig: { + disableExperimentalSEAWarning: true, + }, + }, + onSuccess: (config) => { + // Re-sign with the stable identifier for reproducible builds. + // tsdown performs an initial ad-hoc sign (defaulting to the binary + // name as the identifier); this overwrites it with the canonical one. + if (process.platform === "darwin") { + const outputPath = resolve(config.outDir, "neaps"); + execFileSync("codesign", [ + "--sign", + "-", + "--identifier", + "io.openwaters.neaps", + "--force", + outputPath, + ]); + } + }, +});