Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d0a79ff
Add shared infrastructure for functions push/pull
parkerhendo Mar 6, 2026
0e3d5d5
Add functions push/pull command structure, API layer, and report types
parkerhendo Mar 6, 2026
17bc639
Implement bt functions push
parkerhendo Mar 6, 2026
0937129
Implement bt functions pull
parkerhendo Mar 6, 2026
4f5c904
Add functions push/pull tests and CLI fixtures
parkerhendo Mar 6, 2026
f635890
feat(functions): add positional file arguments to push command
parkerhendo Mar 6, 2026
4487907
refactor(push): improve push confirmation prompt with file and projec…
parkerhendo Mar 6, 2026
fd96ed5
refactor(functions): add multi-slug support to pull command
parkerhendo Mar 6, 2026
6547198
feat(functions): add version filter and legacy compatibility flags
parkerhendo Mar 6, 2026
8974925
feat(functions): add JS bundling support with esbuild
parkerhendo Mar 6, 2026
b751e13
fix(pull): use correct variable names for project resolution
parkerhendo Mar 7, 2026
c9622eb
refactor(functions): remove create-missing-projects flag and confirma…
parkerhendo Mar 9, 2026
a6f1062
feat(functions): add progress indicator for pull command and cleanup …
parkerhendo Mar 9, 2026
4c55188
refactor(push): use file_type instead of path.is_file() for consistency
parkerhendo Mar 9, 2026
615392f
refactor(functions): remove legacy compatibility code and aliases
parkerhendo Mar 10, 2026
b37e5e1
fix: add legacy prompt support with tool function resolution
parkerhendo Mar 10, 2026
8e91881
fix(functions-runner): force re-evaluation of imported input files
parkerhendo Mar 10, 2026
4c40773
fix(functions-push): restore runner, bundler, and project parity
parkerhendo Mar 17, 2026
5f7d51f
fix(functions-pull): preserve prompt serialization and identity metadata
parkerhendo Mar 17, 2026
0cd82ad
test(ci): add functions test to GitHub Actions workflow and fix more …
parkerhendo Mar 17, 2026
493e71d
fix(functions-runner): add proper Zod v4 schema serialization support
parkerhendo Mar 19, 2026
4ed9128
feat(functions): add id and version fields to prompt creation for python
parkerhendo Mar 19, 2026
4e13d64
ci: add pnpm install step before running tests
parkerhendo Mar 20, 2026
648da03
refactor: simplify runtime extension classification
parkerhendo Mar 20, 2026
1acb3e3
fix(functions-runner): allow functions to have empty parameters
parkerhendo Mar 20, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,12 @@ jobs:
run: |
corepack enable
corepack prepare pnpm@10.28.2 --activate
- name: Install JS toolchain dependencies
run: pnpm install --ignore-scripts
- name: Run eval fixtures
run: cargo test --test eval_fixtures
- name: Run functions fixtures
run: cargo test --test functions

eval-tests-python:
name: eval-tests-python (py ${{ matrix.python-version }})
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ chrono = { version = "0.4.40", features = ["clock"] }
dirs = "5"
pathdiff = "0.2.3"
glob = "0.3"
flate2 = "1.1.2"

[profile.dist]
inherits = "release"
Expand Down Expand Up @@ -73,3 +74,6 @@ install-success-msg = ""

[dev-dependencies]
tempfile = "3"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
320 changes: 320 additions & 0 deletions scripts/functions-bundler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";

type EsbuildBuild = (options: Record<string, unknown>) => Promise<unknown>;
type EsbuildModule = {
build: EsbuildBuild;
};

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function isEsbuildModule(value: unknown): value is EsbuildModule {
return isObject(value) && typeof value.build === "function";
}

function parseExternalPackages(raw: string | undefined): string[] {
if (!raw) {
return [];
}
return raw
.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0);
}

function loadTsconfigPath(): string | undefined {
const tsNode = process.env.TS_NODE_PROJECT?.trim();
if (tsNode) {
return tsNode;
}
const tsx = process.env.TSX_TSCONFIG_PATH?.trim();
if (tsx) {
return tsx;
}
return undefined;
}

function buildExternalPackagePatterns(additionalPackages: string[]): string[] {
const knownPackages = [
"braintrust",
"autoevals",
"@braintrust/",
"config",
"lightningcss",
"@mapbox/node-pre-gyp",
"fsevents",
"chokidar",
...additionalPackages,
];
const patterns = new Set<string>(["node_modules/*"]);
for (const pkg of knownPackages) {
const trimmed = pkg.trim();
if (!trimmed) {
continue;
}
if (trimmed.endsWith("/")) {
patterns.add(`${trimmed}*`);
continue;
}
patterns.add(trimmed);
patterns.add(`${trimmed}/*`);
}
return [...patterns];
}

function findNodeModulesBinary(
binary: string,
startPath: string,
): string | null {
let current = path.resolve(startPath);
if (!fs.existsSync(current)) {
current = path.dirname(current);
} else if (!fs.statSync(current).isDirectory()) {
current = path.dirname(current);
}

const binaryCandidates =
process.platform === "win32" ? [`${binary}.cmd`, binary] : [binary];

while (true) {
for (const candidateName of binaryCandidates) {
const candidate = path.join(
current,
"node_modules",
".bin",
candidateName,
);
if (fs.existsSync(candidate)) {
return candidate;
}
}

const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}

function resolveEsbuildBinary(sourceFile: string): string | null {
const searchRoots = [path.resolve(sourceFile), process.cwd()];
const seen = new Set<string>();
for (const root of searchRoots) {
const normalized = path.resolve(root);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
const candidate = findNodeModulesBinary("esbuild", normalized);
if (candidate) {
return candidate;
}
}
return null;
}

function resolveEsbuildModulePath(sourceFile: string): string | null {
const filePath = path.resolve(sourceFile);
try {
const requireFromFile = createRequire(pathToFileURL(filePath).href);
return requireFromFile.resolve("esbuild");
} catch {
// Fall through to process cwd.
}

try {
const requireFromCwd = createRequire(path.join(process.cwd(), "noop.js"));
return requireFromCwd.resolve("esbuild");
} catch {
return null;
}
}

function normalizeEsbuildModule(loaded: unknown): EsbuildModule | null {
if (isEsbuildModule(loaded)) {
return loaded;
}
if (isObject(loaded) && isEsbuildModule(loaded.default)) {
return loaded.default;
}
return null;
}

async function loadEsbuild(sourceFile: string): Promise<EsbuildModule | null> {
const resolvedPath = resolveEsbuildModulePath(sourceFile);
if (resolvedPath) {
if (typeof require === "function") {
try {
const loaded = require(resolvedPath) as unknown;
const normalized = normalizeEsbuildModule(loaded);
if (normalized) {
return normalized;
}
} catch {
// Fall through to dynamic import.
}
}

try {
const loaded = (await import(
pathToFileURL(resolvedPath).href
)) as unknown;
const normalized = normalizeEsbuildModule(loaded);
if (normalized) {
return normalized;
}
} catch {
// Fall through to direct require/import.
}
}

if (typeof require === "function") {
try {
const loaded = require("esbuild") as unknown;
const normalized = normalizeEsbuildModule(loaded);
if (normalized) {
return normalized;
}
} catch {
// Fall through to dynamic import.
}
}

try {
// Keep module name dynamic so TypeScript doesn't require local esbuild types at compile time.
const specifier = "esbuild";
const loaded = (await import(specifier)) as unknown;
const normalized = normalizeEsbuildModule(loaded);
if (normalized) {
return normalized;
}
} catch {
// handled below
}

return null;
}

function computeNodeTargetVersion(): string {
return typeof process.version === "string" && process.version.startsWith("v")
? process.version.slice(1)
: process.versions.node || "18";
}

async function bundleWithEsbuildModule(
esbuild: EsbuildModule,
sourceFile: string,
outputFile: string,
tsconfig: string | undefined,
external: string[],
): Promise<void> {
await esbuild.build({
entryPoints: [sourceFile],
bundle: true,
treeShaking: true,
platform: "node",
target: `node${computeNodeTargetVersion()}`,
write: true,
outfile: outputFile,
tsconfig,
external,
});
}

function bundleWithEsbuildBinary(
esbuildBinary: string,
sourceFile: string,
outputFile: string,
tsconfig: string | undefined,
external: string[],
): void {
const args: string[] = [
sourceFile,
"--bundle",
"--tree-shaking=true",
"--platform=node",
`--target=node${computeNodeTargetVersion()}`,
`--outfile=${outputFile}`,
];

if (tsconfig) {
args.push(`--tsconfig=${tsconfig}`);
}
for (const pattern of external) {
args.push(`--external:${pattern}`);
}

const result = spawnSync(esbuildBinary, args, { encoding: "utf8" });
if (result.error) {
throw new Error(
`failed to invoke esbuild CLI at ${esbuildBinary}: ${result.error.message}`,
);
}
if (result.status !== 0) {
const stderr = (result.stderr ?? "").trim();
const stdout = (result.stdout ?? "").trim();
const details = stderr || stdout || "unknown error";
throw new Error(
`esbuild CLI exited with status ${String(result.status)}: ${details}`,
);
}
}

async function main(): Promise<void> {
const [sourceFile, outputFile] = process.argv.slice(2);
if (!sourceFile || !outputFile) {
throw new Error("functions-bundler requires <SOURCE_FILE> <OUTPUT_FILE>");
}

const externalPackages = parseExternalPackages(
process.env.BT_FUNCTIONS_PUSH_EXTERNAL_PACKAGES,
);
const external = buildExternalPackagePatterns(externalPackages);
const tsconfig = loadTsconfigPath();

const outputDir = path.dirname(outputFile);
fs.mkdirSync(outputDir, { recursive: true });

const esbuild = await loadEsbuild(sourceFile);
if (esbuild) {
await bundleWithEsbuildModule(
esbuild,
sourceFile,
outputFile,
tsconfig,
external,
);
return;
}

const esbuildBinary = resolveEsbuildBinary(sourceFile);
if (esbuildBinary) {
bundleWithEsbuildBinary(
esbuildBinary,
sourceFile,
outputFile,
tsconfig,
external,
);
return;
}

throw new Error(
"failed to load esbuild for JS bundling; install esbuild in your project or use a runner that provides it",
);
}

main().catch((error: unknown) => {
const message =
error instanceof Error
? error.message
: `failed to bundle JS source: ${String(error)}`;
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
Loading
Loading