Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-bt-cli-bin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": minor
---

feat: ship the `bt` CLI with the SDK. Installing `braintrust` now exposes a `bt` command in `node_modules/.bin` that runs the prebuilt native binary for your platform (delivered via `@braintrust/bt-*` optional dependencies). If optional dependencies are skipped (e.g. `--no-optional` / `--omit=optional`), a postinstall script downloads the matching binary from the npm registry as a fallback.
41 changes: 41 additions & 0 deletions js/bin/bt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env node
"use strict";

// Runtime launcher for the `bt` CLI. Resolves the platform-specific binary
// (installed via the matching `@braintrust/bt-*` optionalDependency, or
// downloaded by `scripts/install.js` as a fallback) and forwards argv,
// stdio, signals, and the exit code.

const childProcess = require("node:child_process");
const { getBinaryPath } = require("../scripts/bt-helper");

let binaryPath;
try {
binaryPath = getBinaryPath();
} catch (err) {
console.error(err.message);
process.exit(1);
}

const child = childProcess
.spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
windowsHide: true,
})
.on("error", (err) => {
console.error(err);
process.exit(1);
})
.on("exit", (code, signal) => {
if (signal) {
// Detach our forwarding listener so the re-raised signal hits Node's
// default handler; otherwise we'd intercept it again and exit 0.
process.removeAllListeners(signal);
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});

process.on("SIGTERM", () => child.kill("SIGTERM"));
process.on("SIGINT", () => child.kill("SIGINT"));
18 changes: 16 additions & 2 deletions js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"./dist/index.d.mts": "./dist/browser.d.mts"
},
"bin": {
"braintrust": "./dist/cli.js"
"braintrust": "./dist/cli.js",
"bt": "./bin/bt"
},
"exports": {
"./package.json": "./package.json",
Expand Down Expand Up @@ -127,9 +128,13 @@
"files": [
"dist/**/*",
"dev/dist/**/*",
"util/dist/**/*"
"util/dist/**/*",
"bin/bt",
"scripts/bt-helper.js",
"scripts/install.js"
],
"scripts": {
"postinstall": "node ./scripts/install.js",
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsup",
"check:typings": "tsc --noEmit",
"watch": "tsup --watch",
Expand Down Expand Up @@ -224,6 +229,15 @@
"peerDependencies": {
"zod": "^3.25.34 || ^4.0"
},
"optionalDependencies": {
"@braintrust/bt-darwin-arm64": "0.10.0",
"@braintrust/bt-darwin-x64": "0.10.0",
"@braintrust/bt-linux-arm64": "0.10.0",
"@braintrust/bt-linux-x64": "0.10.0",
"@braintrust/bt-linux-x64-musl": "0.10.0",
"@braintrust/bt-win32-arm64": "0.10.0",
"@braintrust/bt-win32-x64": "0.10.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/",
Expand Down
139 changes: 139 additions & 0 deletions js/scripts/bt-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"use strict";

// Shared helpers for locating the `bt` binary. Used by both `bin/bt`
// (the runtime launcher) and `scripts/install.js` (the postinstall
// fallback downloader).

const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");

const BINARY_DISTRIBUTIONS = [
{ packageName: "@braintrust/bt-darwin-arm64", subpath: "bin/bt" },
{ packageName: "@braintrust/bt-darwin-x64", subpath: "bin/bt" },
{ packageName: "@braintrust/bt-linux-arm64", subpath: "bin/bt" },
{ packageName: "@braintrust/bt-linux-x64", subpath: "bin/bt" },
{ packageName: "@braintrust/bt-linux-x64-musl", subpath: "bin/bt" },
{ packageName: "@braintrust/bt-win32-arm64", subpath: "bin/bt.exe" },
{ packageName: "@braintrust/bt-win32-x64", subpath: "bin/bt.exe" },
];

function detectLibc() {
if (process.platform !== "linux") return null;
try {
const report = process.report && process.report.getReport();
if (report && report.header && report.header.glibcVersionRuntime) {
return "glibc";
}
return "musl";
} catch {
return "glibc";
}
}

function binaryName() {
return process.platform === "win32" ? "bt.exe" : "bt";
}

function getDistributionForThisPlatform() {
const arch = os.arch();
const platform = os.platform();
const subpath = `bin/${binaryName()}`;

let packageName;
if (platform === "darwin") {
if (arch === "arm64") packageName = "@braintrust/bt-darwin-arm64";
else if (arch === "x64") packageName = "@braintrust/bt-darwin-x64";
} else if (platform === "linux") {
if (arch === "arm64") {
packageName = "@braintrust/bt-linux-arm64";
} else if (arch === "x64") {
packageName =
detectLibc() === "musl"
? "@braintrust/bt-linux-x64-musl"
: "@braintrust/bt-linux-x64";
}
} else if (platform === "win32") {
if (arch === "arm64") packageName = "@braintrust/bt-win32-arm64";
else if (arch === "x64") packageName = "@braintrust/bt-win32-x64";
}

return { packageName, subpath };
}

function throwUnsupportedPlatformError() {
throw new Error(
`Unsupported operating system or architecture! The bt CLI does not work on ${process.platform}-${process.arch}.

bt supports:
- macOS (darwin) on arm64 and x64
- Linux on arm64 and x64 (glibc and musl)
- Windows on arm64 and x64`,
);
}

// Constructed indirectly so bundlers (e.g. @vercel/nft) don't statically
// detect the fallback binary path as an asset to trace.
function getFallbackBinaryPath() {
const parts = [__dirname, binaryName()];
return path.resolve(...parts);
}

function getBinaryPath() {
if (process.env.BT_BINARY_PATH) {
return process.env.BT_BINARY_PATH;
}

const { packageName, subpath } = getDistributionForThisPlatform();

if (packageName === undefined) {
throwUnsupportedPlatformError();
}

// Prefer the optional dep so a stale fallback from a prior
// `--omit=optional` install can't shadow a newer optional dep on upgrade.
try {
return require.resolve(`${packageName}/${subpath}`);
} catch (e) {
const fallbackBinaryPath = getFallbackBinaryPath();
if (fs.existsSync(fallbackBinaryPath)) {
return fallbackBinaryPath;
}

const otherInstalled = BINARY_DISTRIBUTIONS.find((dist) => {
try {
require.resolve(`${dist.packageName}/${dist.subpath}`);
return true;
} catch {
return false;
}
});

// Error messages inspired by esbuild:
// https://github.com/evanw/esbuild/blob/f3d535262e3998d845d0f102b944ecd5a9efda57/lib/npm/node-platform.ts#L150
if (otherInstalled) {
throw new Error(
`bt binary for this platform/architecture not found!

The "${otherInstalled.packageName}" package is installed, but for the current platform you should have the "${packageName}" package installed instead. This usually happens if "braintrust" is installed on one platform (for example macOS or Windows) and the "node_modules" folder is then reused on another (for example Linux in Docker).

To fix this, avoid copying the "node_modules" folder, and instead freshly install your dependencies on the target system. You can also configure your package manager to install the right package. For example, yarn has the "supportedArchitectures" feature: https://yarnpkg.com/configuration/yarnrc/#supportedArchitecture.`,
);
}

throw new Error(
`bt binary for this platform/architecture not found!

It seems like none of the "braintrust" package's optional dependencies got installed. Please make sure your package manager is configured to install optional dependencies. If you are using npm, don't set the "--no-optional", "--ignore-optional", or "--omit=optional" flags. The "braintrust" package needs the "optionalDependencies" feature in order to install the bt binary.`,
);
}
}

module.exports = {
BINARY_DISTRIBUTIONS,
binaryName,
getBinaryPath,
getDistributionForThisPlatform,
getFallbackBinaryPath,
throwUnsupportedPlatformError,
};
153 changes: 153 additions & 0 deletions js/scripts/install.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"use strict";

// Postinstall script for the `bt` CLI binary.
//
// The native binary ships in a per-platform `@braintrust/bt-*` package listed
// in `optionalDependencies`; npm/pnpm install only the one matching the host.
// If a package manager is run with `--no-optional`, `--ignore-optional`, or
// `--omit=optional`, none of those packages get installed and `bt` would be
// unusable. As a workaround, we manually fetch the matching tarball from the
// npm registry and extract the binary so the launcher can find it.

const fs = require("node:fs");
const https = require("node:https");
const path = require("node:path");
const zlib = require("node:zlib");

const helper = require("./bt-helper");
const pkg = require("../package.json");

if (process.env.BT_SKIP_DOWNLOAD === "1") {
console.log(
"bt: skipping post-install binary download because BT_SKIP_DOWNLOAD=1 is set.",
);
process.exit(0);
}

const { packageName, subpath } = helper.getDistributionForThisPlatform();

if (packageName === undefined) {
// Don't fail the install; the launcher will surface the unsupported-platform
// error if/when the user actually tries to run `bt`.
console.error(
`bt: no prebuilt binary available for ${process.platform}-${process.arch}; the bt CLI will not be available.`,
);
process.exit(0);
}

try {
require.resolve(`${packageName}/${subpath}`);
// Optional dependency was installed successfully. Nothing to do.
process.exit(0);
} catch (e) {
// Fall through to the manual download path below.
console.log(
`bt: failed to locate the "${packageName}" package after installation.

This can happen if you use an option to disable optional dependencies during installation, like "--no-optional", "--ignore-optional", or "--omit=optional". The "braintrust" package uses the "optionalDependencies" package.json feature to install the correct bt binary for your platform and operating system. This post-install script will now try to work around that by manually downloading the bt binary from the npm registry. If this fails, you need to remove the "--no-optional", "--ignore-optional", and "--omit=optional" flags for bt to work.`,
);
}

const version = (pkg.optionalDependencies || {})[packageName];
if (!version) {
// Don't fail the parent install: the SDK works without `bt`, and the
// launcher errors clearly if it's actually invoked.
console.error(
`bt: cannot determine which version of "${packageName}" to download — it is not listed in the "braintrust" package's optionalDependencies. The bt CLI will not be available; the rest of the braintrust SDK is unaffected.`,
);
process.exit(0);
}

function fetchBuffer(url, redirectsRemaining = 5) {
return new Promise((resolve, reject) => {
https
.get(url, (response) => {
const { statusCode = 0, headers } = response;
if (statusCode >= 200 && statusCode < 300) {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => resolve(Buffer.concat(chunks)));
response.on("error", reject);
return;
}
if (
statusCode >= 300 &&
statusCode < 400 &&
headers.location &&
redirectsRemaining > 0
) {
response.resume();
fetchBuffer(headers.location, redirectsRemaining - 1).then(
resolve,
reject,
);
return;
}
response.resume();
reject(
new Error(
`npm registry responded with status code ${statusCode} when downloading ${url}`,
),
);
})
.on("error", reject);
});
}

// Extracts a single file from an uncompressed tar archive. Tar archives are
// organized in 512-byte blocks: a header block (file name in bytes 0-99,
// file size in bytes 124-135 as an octal string) followed by data blocks
// padded out to the next multiple of 512.
function extractFileFromTarball(tarball, target) {
let offset = 0;
while (offset + 512 <= tarball.length) {
const header = tarball.subarray(offset, offset + 512);
offset += 512;
const fileName = header.toString("utf-8", 0, 100).replace(/\0.*/g, "");
if (!fileName) break;
const fileSize = parseInt(
header.toString("utf-8", 124, 136).replace(/\0.*/g, ""),
8,
);
if (fileName === target) {
return tarball.subarray(offset, offset + fileSize);
}
offset = (offset + fileSize + 511) & ~511;
}
return null;
}

async function downloadFallback() {
// npm tarball URLs look like:
// https://registry.npmjs.org/<scope>/<name>/-/<name>-<version>.tgz
// where <name> is the unscoped package name.
const tarballName = packageName.split("/").pop();
const url = `https://registry.npmjs.org/${packageName}/-/${tarballName}-${version}.tgz`;
console.log(`bt: downloading ${packageName}@${version} from ${url}`);

const gzipped = await fetchBuffer(url);
const tarball = zlib.gunzipSync(gzipped);
const binary = extractFileFromTarball(tarball, `package/${subpath}`);

if (!binary) {
throw new Error(
`could not find "package/${subpath}" inside ${packageName}@${version} tarball`,
);
}

const fallbackBinaryPath = helper.getFallbackBinaryPath();
fs.mkdirSync(path.dirname(fallbackBinaryPath), { recursive: true });
fs.writeFileSync(fallbackBinaryPath, binary);
fs.chmodSync(fallbackBinaryPath, 0o755);
console.log(`bt: installed fallback binary at ${fallbackBinaryPath}`);
}

downloadFallback().catch((err) => {
// Don't fail the parent install: airgapped/proxied CI may not reach the
// npm registry, and the SDK works without `bt`.
console.error(
`bt: failed to download fallback binary for ${packageName}@${version}: ${err.message}
The bt CLI will not be available; the rest of the braintrust SDK is unaffected.`,
);
process.exit(0);
});
1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ blockExoticSubdeps: true
minimumReleaseAge: 4320 # 3 days (in minutes)
minimumReleaseAgeExclude:
- "@apm-js-collab/*" # instrumentation deps we need to pin closely
- "@braintrust/bt-*" # bt binary packages, published in lockstep with braintrust
trustPolicy: no-downgrade
# Ignore the check for packages published more than 30 days ago (pnpm 10.27+)
# Useful for older packages that pre-date provenance support
Expand Down
Loading