-
Notifications
You must be signed in to change notification settings - Fork 2
Add push and pull functions commands
#70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Parker Henderson (parkerhendo)
merged 25 commits into
main
from
functions-push-pull-command
Mar 20, 2026
Merged
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 0e3d5d5
Add functions push/pull command structure, API layer, and report types
parkerhendo 17bc639
Implement bt functions push
parkerhendo 0937129
Implement bt functions pull
parkerhendo 4f5c904
Add functions push/pull tests and CLI fixtures
parkerhendo f635890
feat(functions): add positional file arguments to push command
parkerhendo 4487907
refactor(push): improve push confirmation prompt with file and projec…
parkerhendo fd96ed5
refactor(functions): add multi-slug support to pull command
parkerhendo 6547198
feat(functions): add version filter and legacy compatibility flags
parkerhendo 8974925
feat(functions): add JS bundling support with esbuild
parkerhendo b751e13
fix(pull): use correct variable names for project resolution
parkerhendo c9622eb
refactor(functions): remove create-missing-projects flag and confirma…
parkerhendo a6f1062
feat(functions): add progress indicator for pull command and cleanup …
parkerhendo 4c55188
refactor(push): use file_type instead of path.is_file() for consistency
parkerhendo 615392f
refactor(functions): remove legacy compatibility code and aliases
parkerhendo b37e5e1
fix: add legacy prompt support with tool function resolution
parkerhendo 8e91881
fix(functions-runner): force re-evaluation of imported input files
parkerhendo 4c40773
fix(functions-push): restore runner, bundler, and project parity
parkerhendo 5f7d51f
fix(functions-pull): preserve prompt serialization and identity metadata
parkerhendo 0cd82ad
test(ci): add functions test to GitHub Actions workflow and fix more …
parkerhendo 493e71d
fix(functions-runner): add proper Zod v4 schema serialization support
parkerhendo 4ed9128
feat(functions): add id and version fields to prompt creation for python
parkerhendo 4e13d64
ci: add pnpm install step before running tests
parkerhendo 648da03
refactor: simplify runtime extension classification
parkerhendo 1acb3e3
fix(functions-runner): allow functions to have empty parameters
parkerhendo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.