diff --git a/docs/runtime/index.mdx b/docs/runtime/index.mdx index fbf58dd8c34b..dc662cb247e9 100644 --- a/docs/runtime/index.mdx +++ b/docs/runtime/index.mdx @@ -147,6 +147,8 @@ By default, Bun respects this shebang and executes the script with `node`. The ` bun run --bun vite ``` +When `bun run` uses its Node shim, `NODE` and `npm_node_execpath` point to the shim executable. Scripts can invoke either path directly. Bun uses this shim when `--bun` is set or when no Node executable is available. + ### Filtering In a monorepo, the `--filter` argument runs a script in many packages at once. diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 8f82021427a8..1095f1706f49 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1886,7 +1886,12 @@ impl RunCommand { pub(crate) fn bun_node_file_utf8() -> crate::Result<&'static ZStr> { #[cfg(not(windows))] { - Ok(bun_install::RunCommand::bun_node_dir()) + static FILE: std::sync::OnceLock = std::sync::OnceLock::new(); + Ok(FILE.get_or_init(|| { + let mut path = bun_install::RunCommand::bun_node_dir().as_bytes().to_vec(); + path.extend_from_slice(b"/node"); + bun_core::ZBox::from_bytes(path) + })) } #[cfg(windows)] { @@ -1997,18 +2002,18 @@ impl RunCommand { let bun_node_exe = Self::bun_node_file_utf8()?; let bun_node_dir_win = bun_paths::dirname(bun_node_exe.as_bytes()).ok_or(crate::Error::FailedToGetTempPath)?; - let found_node = env_loader - .load_node_js_config( - bun_paths::fs::FileSystem::instance(), - if force_using_bun { - bun_node_exe.as_bytes() - } else { - b"" - }, - ) - .unwrap_or(false); - - let mut needs_to_force_bun = force_using_bun || !found_node; + let found_node = !force_using_bun + && env_loader + .load_node_js_config(bun_paths::fs::FileSystem::instance(), b"") + .unwrap_or(false); + + // A prior --filter package may have cached our shim as NODE. + let found_real_node = found_node + && env_loader + .get(b"NODE") + .is_some_and(|node| node != bun_node_exe.as_bytes()); + + let mut needs_to_force_bun = force_using_bun || !found_real_node; let mut optional_bun_self_path: &[u8] = b""; let mut new_path_len: usize = path.len() + 2; @@ -2050,7 +2055,8 @@ impl RunCommand { ), } - if !force_using_bun { + // Only publish the shim after creation has appended its directory. + if !new_path.is_empty() { let env_mut = &mut *env_loader; env_mut .map @@ -2060,10 +2066,12 @@ impl RunCommand { .map .put(b"npm_node_execpath", bun_node_exe.as_bytes()) .unwrap_or_oom(); - env_mut - .map - .put(b"npm_execpath", optional_bun_self_path) - .unwrap_or_oom(); + if !force_using_bun && !optional_bun_self_path.is_empty() { + env_mut + .map + .put(b"npm_execpath", optional_bun_self_path) + .unwrap_or_oom(); + } } needs_to_force_bun = false; diff --git a/test/cli/install/bun-run.test.ts b/test/cli/install/bun-run.test.ts index 1c8918227511..69f43d2b06fc 100644 --- a/test/cli/install/bun-run.test.ts +++ b/test/cli/install/bun-run.test.ts @@ -9,6 +9,33 @@ const bunEnv: typeof bunEnv_ = { BUN_INTERNAL_SUPPRESS_CRASH_IN_BUN_RUN: "1", }; +it.concurrent.each([false, true])("node shim environment uses the executable path (force Bun: %s)", async forceBun => { + using dir = tempDir("bun-run-node-env", { + "package.json": JSON.stringify({ + name: "node-env", + scripts: { probe: 'echo "$NODE"; echo "$npm_node_execpath"; echo "$npm_execpath"' }, + }), + }); + // The builtin echo avoids racing other debug processes that recreate the shared shim. + await using proc = Bun.spawn({ + cmd: [bunExe(), "--silent", ...(forceBun ? ["--bun"] : []), "--shell=bun", "run", "probe"], + cwd: String(dir), + env: { ...bunEnv, PATH: String(dir), NODE: undefined, npm_node_execpath: undefined, npm_execpath: undefined }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const lines = stdout + .trim() + .split(/\r?\n/) + .map(line => (isWindows ? line.replaceAll("\\", "/") : line)); + expect({ lines, stderr, exitCode }).toEqual({ + lines: [expect.stringMatching(/[/\\]bun-node[^/\\]*[/\\]node(?:\.exe)?$/), lines[0], bunExe()], + stderr: "", + exitCode: 0, + }); +}); + describe.concurrent("bun run", () => { for (let withRun of [false, true]) { describe(withRun ? "bun run" : "bun", () => { diff --git a/test/cli/run/filter-workspace.test.ts b/test/cli/run/filter-workspace.test.ts index 031bd4ee5686..db683868feb5 100644 --- a/test/cli/run/filter-workspace.test.ts +++ b/test/cli/run/filter-workspace.test.ts @@ -19,6 +19,48 @@ import { join } from "path"; const { parseLockfile } = install_test_helpers; +test("every filtered package keeps the node shim executable on PATH", async () => { + const pkg = (name: string) => ({ + "package.json": JSON.stringify({ name, scripts: { probe: "node probe.js" } }), + "probe.js": `console.log(JSON.stringify({ + pkg: ${JSON.stringify(name)}, + node: process.env.NODE, + npmNodeExecpath: process.env.npm_node_execpath, + bun: typeof Bun !== "undefined", + }));`, + }); + using dir = tempDir("filter-node-env", { + "package.json": JSON.stringify({ name: "workspace", workspaces: ["packages/*"] }), + packages: { a: pkg("a"), b: pkg("b"), c: pkg("c") }, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--shell=bun", "--filter", "*", "--elide-lines=0", "probe"], + cwd: String(dir), + env: { + ...bunEnv, + NO_COLOR: "1", + FORCE_COLOR: undefined, + PATH: String(dir), + NODE: undefined, + npm_node_execpath: undefined, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const results = stdout + .split(/\r?\n/) + .filter(line => line.includes('{"pkg"')) + .map(line => JSON.parse(line.slice(line.indexOf("{")))) + .sort((a, b) => a.pkg.localeCompare(b.pkg)); + const shimExe = expect.stringMatching(/[/\\]bun-node[^/\\]*[/\\]node(?:\.exe)?$/); + expect({ results, stderr, exitCode }).toEqual({ + results: ["a", "b", "c"].map(pkg => ({ pkg, node: shimExe, npmNodeExecpath: shimExe, bun: true })), + stderr: "", + exitCode: 0, + }); +}); + const cwd_root = tempDirWithFiles("testworkspace", { packages: { pkga: {