Files
claude-agent-acp/scripts/build-binary.mjs
Ben Brandt 4a3ff6a1eb fix: stop the release executable from loading the project's .env and bunfig.toml (#1589)
Fixes #1579.

## Problem

The release executable is built with `bun build --compile`. By default a
compiled Bun executable loads `.env`, `.env.local` and `bunfig.toml`
from its working directory at startup. The editor starts the agent in
the user's project, so:

- The project's dotenv values land in the agent's `process.env` and are
passed on to Claude and every Bash command as real environment
variables. Most frameworks never override a variable that is already
set, so the project's own `.env.test` is silently ignored. In the
reported case this made a test suite drop the dev database.
- A `bunfig.toml` in the project that Bun cannot parse stops the agent
from starting (`SyntaxError: An error occurred while parsing code`, exit
1).

Only the executable is affected; running the npm package under Node does
not load these files.

## Fix

`scripts/build-binary.mjs` passes `--no-compile-autoload-dotenv` and
`--no-compile-autoload-bunfig`. Bun's other two autoloads
(`tsconfig.json`, `package.json`) are already off by default.

## Testing

`scripts/smoke-binary.mjs` gains a check that runs the executable in a
directory containing a `.env`, a `.env.local` and an unparseable
`bunfig.toml`, with Node standing in for `claude` to print the
environment the CLI is spawned with. It fails on an executable built
without the flags and passes with them.

Run locally on macOS arm64 only; the Linux, Alpine and Windows targets
are covered by the `binaries.yml` matrix.
2026-11-10 21:27:42 +00:00

108 lines
4.0 KiB
JavaScript

#!/usr/bin/env node
// Builds an executable of the agent with `bun build --compile` and copies the
// target platform's Claude CLI next to it. Both files go in the release
// archive: the executable runs the CLI from its own directory.
//
// node scripts/build-binary.mjs [--target bun-linux-x64-musl] [--outfile path]
//
// --target defaults to the host. Cross-compiling needs the target's SDK
// platform package installed; the script prints the install command when it
// is missing.
import { spawnSync } from "node:child_process";
import { chmodSync, copyFileSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function hostTarget() {
const os = process.platform === "win32" ? "windows" : process.platform;
// Node linked against glibc reports its version; musl builds don't.
const musl =
process.platform === "linux" && !process.report.getReport().header.glibcVersionRuntime;
return `bun-${os}-${process.arch}${musl ? "-musl" : ""}`;
}
const { values } = parseArgs({
options: {
target: { type: "string", default: hostTarget() },
outfile: { type: "string" },
},
});
const target = values.target;
// Bun targets look like `bun-<os>-<arch>` with optional `-musl`, `-baseline` or
// `-modern` suffixes; the SDK names its packages `<platform>-<arch>[-musl]`.
const match = /^bun-(linux|darwin|windows)-(x64|arm64|aarch64)(?:-.+)?$/.exec(target);
if (!match) {
console.error(
`Unsupported target "${target}". Expected e.g. bun-linux-x64, bun-darwin-arm64, bun-windows-x64.`,
);
process.exit(1);
}
const [, os, rawArch] = match;
const platform = os === "windows" ? "win32" : os;
const arch = rawArch === "aarch64" ? "arm64" : rawArch;
const musl = target.split("-").includes("musl");
const exe = os === "windows" ? ".exe" : "";
const cliPackage = `@anthropic-ai/claude-agent-sdk-${platform}-${arch}${musl ? "-musl" : ""}`;
const cliSpecifier = `${cliPackage}/claude${exe}`;
const outfile = resolve(values.outfile ?? join(repoRoot, "dist-bin", `claude-agent-acp${exe}`));
// Resolve from the SDK, the way claudeCliPath() does at runtime, so a nested
// install is checked rather than only a hoisted one.
const sdkRequire = createRequire(
createRequire(join(repoRoot, "package.json")).resolve("@anthropic-ai/claude-agent-sdk"),
);
let cliPath;
try {
cliPath = sdkRequire.resolve(cliSpecifier);
} catch {
const sdkVersion = JSON.parse(
readFileSync(
join(dirname(sdkRequire.resolve("@anthropic-ai/claude-agent-sdk")), "package.json"),
"utf8",
),
).version;
console.error(
`${cliSpecifier} is not installed. npm only installs the host's platform package; for ${target} run:\n\n` +
` npm install --no-save --force ${cliPackage}@${sdkVersion}\n`,
);
process.exit(1);
}
// By default a compiled executable loads `.env`, `.env.local` and `bunfig.toml`
// from its working directory at startup. That directory is the user's project:
// its dotenv values would reach Claude and every Bash command as real
// environment variables (overriding the project's own `.env.test`), and a
// `bunfig.toml` that fails to parse would stop the agent from starting.
const bun = spawnSync(
"bun",
[
"build",
"src/binary-entry.ts",
"--compile",
"--no-compile-autoload-dotenv",
"--no-compile-autoload-bunfig",
"--target",
target,
"--outfile",
outfile,
],
{ cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32" },
);
if (bun.error) {
console.error(`Could not run bun: ${bun.error.message}. Install it from https://bun.sh.`);
process.exit(1);
}
if (bun.status !== 0) process.exit(bun.status ?? 1);
// The executable runs the CLI from its own directory (src/binary-entry.ts).
const cliOutfile = join(dirname(outfile), `claude${exe}`);
copyFileSync(cliPath, cliOutfile);
chmodSync(cliOutfile, 0o755);
console.log(`Copied ${cliSpecifier} to ${cliOutfile}`);