From 0eb1e15422112e003181195e4e65e7d754ff1644 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:57:31 +0000 Subject: [PATCH 1/7] cli: make the entry point of the node shim the main module When bun runs as `node`, `exec_as_if_node` booted the path as the user gave it. For a directory, a file without its extension, or a symlink, `vm.main()` was not the key of the entry module. `require.main`, `process.mainModule` and `import.meta.main` did not match, and `has_loaded` stayed false, so an import with an unknown extension was parsed as code. `boot` now resolves that path with the VM resolver, like `bun `. `process.argv[1]` and the Bun shell `$1` keep the given path, like Node. --- src/jsc/VirtualMachine.rs | 16 +++ src/runtime/cli/fuzzilli_command.rs | 9 +- src/runtime/cli/run_command.rs | 64 ++++++++-- src/runtime/node/node_process.rs | 2 +- src/runtime/shell/interpreter.rs | 2 +- test/cli/run/as-node.test.ts | 173 +++++++++++++++++++++++++++- 6 files changed, 253 insertions(+), 13 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 52461eaf0526..da1db0d0b39e 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -150,6 +150,9 @@ pub struct VirtualMachine { /// `RawSlice` carries the BACKREF outlives-holder invariant — read via /// `main()`. main: bun_ptr::RawSlice, + /// `process.argv[1]` when it is not `main`: the `node` shim keeps the path + /// the user gave, like Node. + main_for_argv: Option<&'static [u8]>, pub main_is_html_entrypoint: bool, pub main_resolved_path: bun_core::String, pub main_hash: u32, @@ -3120,6 +3123,7 @@ impl VirtualMachine { // `log` is a fresh leaked Box; outlives the VM. addr_of_mut!((*vm).log).write(NonNull::new(log)); addr_of_mut!((*vm).main).write(bun_ptr::RawSlice::EMPTY); + addr_of_mut!((*vm).main_for_argv).write(None); addr_of_mut!((*vm).main_hash).write(0); addr_of_mut!((*vm).main_resolved_path).write(bun_core::String::EMPTY); addr_of_mut!((*vm).hide_bun_stackframes).write(true); @@ -3308,6 +3312,18 @@ impl VirtualMachine { self.main = bun_ptr::RawSlice::new(path); } + /// The entry path for `process.argv[1]`: `main()`, unless + /// `set_main_for_argv` gave another path. + #[inline] + pub fn main_for_argv(&self) -> &[u8] { + self.main_for_argv.unwrap_or_else(|| self.main()) + } + + #[inline] + pub fn set_main_for_argv(&mut self, path: &'static [u8]) { + self.main_for_argv = Some(path); + } + /// `eventLoop().waitForPromise(promise)` — spin tick/auto_tick until /// `promise` settles. Thin forwarder; body lives in /// [`crate::event_loop::EventLoop::wait_for_promise`]. diff --git a/src/runtime/cli/fuzzilli_command.rs b/src/runtime/cli/fuzzilli_command.rs index 1558ef6eced9..e63f20db4261 100644 --- a/src/runtime/cli/fuzzilli_command.rs +++ b/src/runtime/cli/fuzzilli_command.rs @@ -8,7 +8,7 @@ use bun_core::{Environment, Global}; use bun_sys::{self as sys, Fd, FdExt, O}; #[cfg(unix)] -use super::run_command::RunCommand; +use super::run_command::{EntryPath, RunCommand}; use crate::Command; pub(crate) struct FuzzilliCommand; @@ -92,7 +92,12 @@ impl FuzzilliCommand { let temp_path: &[u8] = b"/tmp/bun-fuzzilli-reprl.js"; // The `Run.boot` entry point is hosted on `RunCommand` to avoid the // higher-tier crate cycle (see run_command.rs §`Run`). - let result = RunCommand::boot(_ctx, temp_path.to_vec().into_boxed_slice(), None); + let result = RunCommand::boot( + _ctx, + temp_path.to_vec().into_boxed_slice(), + EntryPath::Resolved, + None, + ); // `defer fd.close()` — Fd is Copy and has no Drop; close explicitly. temp_file_fd.close(); diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index aba13b40f541..c20b5e50bc2e 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -94,6 +94,17 @@ pub(crate) struct ConfigureEnvOptions { pub(crate) store_root_fd: bool, } +/// What the `entry_path` of [`RunCommand::boot`] names. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum EntryPath { + /// The file to run. + Resolved, + /// A path as `node` takes it: a directory, or a file without its + /// extension, also runs. `boot` resolves it to the file to run and keeps + /// the given path as `process.argv[1]`. + Unresolved, +} + pub(crate) struct RunCommand; impl RunCommand { @@ -934,6 +945,7 @@ Full documentation is available at https://bun.com/docs/cli/run pub(crate) fn boot( ctx: &mut ContextData, entry_path: Box<[u8]>, + entry_kind: EntryPath, loader: Option, ) -> crate::Result<()> { if !ctx.debug.loaded_bunfig { @@ -988,8 +1000,9 @@ Full documentation is available at https://bun.com/docs/cli/run // so the allocation is process-lifetime by construction. let entry: &'static [u8] = Box::leak(entry_path); // What `Run::start` passes to `vm.load_entry_point`; `mut` because the - // cron-execution branch below may swap in a synthetic `cwd/[eval]` path - // while `entry` stays the user's path for the loader check further down. + // cron-execution branch below may swap in a synthetic `cwd/[eval]` path, + // and `EntryPath::Unresolved` the resolved file, while `entry` stays the + // user's path for the loader check further down. let mut run_entry = entry; vm.set_main(entry); @@ -1067,6 +1080,17 @@ Full documentation is available at https://bun.com/docs/cli/run crate::run_main::fail_with_build_error(vm); } + // `vm.main()` must be the key the module loader gives the entry module. + if entry_kind == EntryPath::Unresolved + && vm.module_loader.eval_source.is_none() + && let Some(resolved) = Self::resolve_entry_path(vm, entry) + && resolved != entry + { + vm.set_main_for_argv(entry); + vm.set_main(resolved); + run_entry = resolved; + } + // Allow setting a custom timezone. Without `$TZ`, JSC/ICU lazily // auto-detects the host zone the first time a `Date` is constructed — // matching upstream Bun. `.env` files are loaded by @@ -1115,6 +1139,25 @@ Full documentation is available at https://bun.com/docs/cli/run .start() } + /// Resolve `entry` to the file to run, like `bun ` does. On `None` + /// the module loader reports the failure when it imports `entry`. + fn resolve_entry_path(vm: &mut VirtualMachine, entry: &'static [u8]) -> Option<&'static [u8]> { + let top_level_dir = vm.top_level_dir(); + // Like the module loader, keep the resolver's messages out of `vm.log`. + let mut log = bun_ast::Log::default(); + let resolver = &raw mut vm.transpiler.resolver; + // SAFETY: `resolver` is a field of the live VM. `log` is declared + // before the guard, so the guard restores the log before `log` drops. + let _restore_log = unsafe { + bun_resolver::Resolver::scoped_log(resolver, ::core::ptr::NonNull::from(&mut log)) + }; + // SAFETY: `vm` is borrowed for this call, so nothing else uses its resolver. + let resolved = unsafe { &mut *resolver } + .resolve(top_level_dir, entry, bun_ast::ImportKind::EntryPointRun) + .ok()?; + Some(resolved.path_const()?.text) + } + /// Entry point for /// `bun build --compile` executables. Mirrors [`boot`] but routes through /// `VirtualMachine::init_with_module_graph` and applies the standalone @@ -1710,7 +1753,7 @@ impl RunCommand { // owned copy by value. let owned: Box<[u8]> = path.to_vec().into_boxed_slice(); - if let Err(err) = Self::boot(ctx, owned, loader) { + if let Err(err) = Self::boot(ctx, owned, EntryPath::Resolved, loader) { Self::boot_failed_exit(ctx, paths::basename(path), &err); } true @@ -2869,7 +2912,7 @@ impl RunCommand { // `basename(target_name)` (= "-"), not `basename(entry_path)` // (= "[stdin]"), in the error message. let owned: Box<[u8]> = entry_path.to_vec().into_boxed_slice(); - if let Err(err) = Self::boot(ctx, owned, None) { + if let Err(err) = Self::boot(ctx, owned, EntryPath::Resolved, None) { Self::boot_failed_exit(ctx, b"-", &err); } Ok(true) @@ -2919,7 +2962,7 @@ impl RunCommand { let entry: Box<[u8]> = entry_point_buf[..cwd_len + EVAL_TRIGGER.len()] .to_vec() .into_boxed_slice(); - Self::boot(ctx, entry, None) + Self::boot(ctx, entry, EntryPath::Resolved, None) } /// `node` argv0 emulation. Port of `execAsIfNode`. @@ -2954,7 +2997,7 @@ impl RunCommand { let entry: Box<[u8]> = entry_point_buf[..cwd_len + EVAL_TRIGGER.len()] .to_vec() .into_boxed_slice(); - return Self::boot(ctx, entry, None); + return Self::boot(ctx, entry, EntryPath::Resolved, None); } if ctx.positionals.is_empty() { @@ -2997,7 +3040,7 @@ impl RunCommand { // `Global::configure_allocator` and (b) uses the // `Output.err(err, "Failed to run script \"...\"")` form. let basename: Box<[u8]> = paths::basename(&normalized).to_vec().into_boxed_slice(); - if let Err(err) = Self::boot(ctx, normalized, None) { + if let Err(err) = Self::boot(ctx, normalized, EntryPath::Unresolved, None) { Self::exec_as_if_node_boot_failed(ctx, &basename, err); } Ok(()) @@ -4036,7 +4079,12 @@ impl BunXFastPath { ::core::slice::from_raw_parts_mut(raw.cast::(), bun_paths::PATH_MAX_WIDE * 2) }; let utf8 = strings::convert_utf16_to_utf8_in_buffer(out_buf, wpath); - if let Err(err) = RunCommand::boot(ctx, utf8.to_vec().into_boxed_slice(), None) { + if let Err(err) = RunCommand::boot( + ctx, + utf8.to_vec().into_boxed_slice(), + EntryPath::Resolved, + None, + ) { // SAFETY: `ctx.log` was set in `create_context_data`. let _ = unsafe { &mut *ctx.log }.print(std::ptr::from_mut(Output::error_writer())); Output::err( diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 619f925b98d3..808d9988b2d1 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -385,7 +385,7 @@ mod _impl { if worker.is_some_and(|w| w.eval_mode()) { args_list.push(BunString::static_("[worker eval]")); } else { - args_list.push(BunString::borrow_utf8(vm.main())); + args_list.push(BunString::borrow_utf8(vm.main_for_argv())); } } diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 4408eceeed03..61e1a51e874e 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -1701,7 +1701,7 @@ impl Interpreter { // SAFETY: `bun_vm()` on a JS event loop returns the live // `*VirtualMachine` owning that loop. let vm = unsafe { &*vm_ptr }; - let main = vm.main(); + let main = vm.main_for_argv(); if !main.is_empty() { if int == 0 { out.extend_from_slice(main); diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index 34ee77efd693..339681b3a1ba 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdirSync, symlinkSync } from "fs"; import { join } from "path"; -import { bunEnv, bunExe, fakeNodeRun, tempDir } from "../../harness"; +import { bunEnv, bunExe, fakeNodeRun, isWindows, tempDir } from "../../harness"; describe("fake node cli", () => { test("the node cli actually works", () => { @@ -111,4 +112,174 @@ describe("fake node cli", () => { expect(result.stderr.toString()).toContain("Missing script"); expect(result.success).toBe(false); }); + + // `node ./pkg`, `node .`, `node ./entry` and `node ./bin/link` do not name the file that runs. + describe("an entry point that does not name the file that runs", () => { + const cjs = `console.log(JSON.stringify({ + argv1: process.argv[1], + filename: __filename, + requireMain: require.main === module, + mainModule: process.mainModule === module, + bunMain: Bun.main, + }));`; + const esm = `console.log(JSON.stringify({ + argv1: process.argv[1], + filename: import.meta.filename, + importMetaMain: import.meta.main, + bunMain: Bun.main, + }));`; + const entryPoints = [ + { name: "a directory", pkg: {}, file: "pkg/index.js", cwd: ".", arg: "./pkg", argv1: "pkg" }, + { + name: "`.` with a package.json main", + pkg: { main: "./lib/entry.js" }, + file: "pkg/lib/entry.js", + cwd: "pkg", + arg: ".", + argv1: "pkg", + }, + { + name: "a file without its extension", + pkg: {}, + file: "pkg/entry.js", + cwd: "pkg", + arg: "./entry", + argv1: "pkg/entry", + }, + ]; + + test.concurrent.each(entryPoints)("CommonJS, $name", async ({ pkg, file, cwd, arg, argv1 }) => { + using temp = tempDir("fake-node-main", { "pkg/package.json": JSON.stringify(pkg), [file]: cjs }); + const result = await runAsNode(join(temp, cwd), [arg]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual({ + stdout: { + argv1: join(temp, argv1), + filename: join(temp, file), + requireMain: true, + mainModule: true, + bunMain: join(temp, file), + }, + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent.each(entryPoints)("ESM, $name", async ({ pkg, file, cwd, arg, argv1 }) => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": JSON.stringify({ ...pkg, type: "module" }), + [file]: esm, + }); + const result = await runAsNode(join(temp, cwd), [arg]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual({ + stdout: { + argv1: join(temp, argv1), + filename: join(temp, file), + importMetaMain: true, + bunMain: join(temp, file), + }, + stderr: "", + exitCode: 0, + }); + }); + + // Windows needs administrator rights to create a symlink. + test.concurrent.skipIf(isWindows)("CommonJS, a symlink", async () => { + using temp = tempDir("fake-node-main", { "pkg/package.json": "{}", "pkg/cli.js": cjs }); + mkdirSync(join(temp, "bin")); + symlinkSync(join("..", "pkg", "cli.js"), join(temp, "bin", "link")); + const result = await runAsNode(String(temp), ["./bin/link"]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual({ + stdout: { + argv1: join(temp, "bin", "link"), + filename: join(temp, "pkg", "cli.js"), + requireMain: true, + mainModule: true, + bunMain: join(temp, "pkg", "cli.js"), + }, + stderr: "", + exitCode: 0, + }); + }); + + test.concurrent("a package.json that does not parse adds no output", async () => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": "{", + "pkg/index.js": "console.log(require.main === module);", + }); + expect(await runAsNode(String(temp), ["./pkg"])).toEqual({ stdout: "true\n", stderr: "", exitCode: 0 }); + }); + + test.concurrent("the Bun shell `$1` is process.argv[1]", async () => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": JSON.stringify({ type: "module" }), + "pkg/index.js": "console.log(JSON.stringify([process.argv[1], (await Bun.$`echo $1`.text()).trim()]));", + }); + const result = await runAsNode(String(temp), ["./pkg"]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual({ + stdout: [join(temp, "pkg"), join(temp, "pkg")], + stderr: "", + exitCode: 0, + }); + }); + }); + + // Until the main module loads, Bun parses an import with an unknown extension as code. + describe("an import with an unknown extension is an asset", () => { + const files = { + "pkg/package.json": JSON.stringify({ type: "module" }), + "pkg/index.js": ` + import asset from "./asset.bin"; + import { basename } from "node:path"; + console.log(JSON.stringify({ filename: import.meta.filename, asset: basename(asset) }));`, + "pkg/asset.bin": "this is not javascript {", + }; + const loaded = (temp: string) => ({ + stdout: { filename: join(temp, "pkg", "index.js"), asset: "asset.bin" }, + stderr: "", + exitCode: 0, + }); + + test.concurrent("when the entry point is a directory", async () => { + using temp = tempDir("fake-node-asset", files); + const result = await runAsNode(String(temp), ["./pkg"]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual(loaded(temp)); + }); + + // On Windows the module loader names the entry point with `\`. + test.concurrent("when the entry point is an absolute path with `/`", async () => { + using temp = tempDir("fake-node-asset", files); + const result = await runAsNode(String(temp), [join(temp, "pkg", "index.js").replaceAll("\\", "/")]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual(loaded(temp)); + }); + + // Windows needs administrator rights to create a symlink. + test.concurrent.skipIf(isWindows)("when the entry point is a symlink", async () => { + using temp = tempDir("fake-node-asset", files); + mkdirSync(join(temp, "bin")); + symlinkSync(join("..", "pkg", "index.js"), join(temp, "bin", "link")); + const result = await runAsNode(String(temp), ["./bin/link"]); + expect({ ...result, stdout: parse(result.stdout) }).toEqual(loaded(temp)); + }); + }); }); + +async function runAsNode(cwd: string, args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--bun", "node", ...args], + cwd, + env: { ...bunEnv, NODE_ENV: undefined }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +// A child that fails prints no JSON. Keep what it printed for the failure message. +function parse(stdout: string) { + try { + return JSON.parse(stdout); + } catch { + return stdout; + } +} From c5518fe2c520259ad14c776ccdf8305b8b54f728 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:27:37 +0000 Subject: [PATCH 2/7] test: run the symlink cases of the node shim on Windows too --- test/cli/run/as-node.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index 339681b3a1ba..f5e72ba1a6cd 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, symlinkSync } from "fs"; import { join } from "path"; -import { bunEnv, bunExe, fakeNodeRun, isWindows, tempDir } from "../../harness"; +import { bunEnv, bunExe, fakeNodeRun, tempDir } from "../../harness"; describe("fake node cli", () => { test("the node cli actually works", () => { @@ -182,8 +182,7 @@ describe("fake node cli", () => { }); }); - // Windows needs administrator rights to create a symlink. - test.concurrent.skipIf(isWindows)("CommonJS, a symlink", async () => { + test.concurrent("CommonJS, a symlink", async () => { using temp = tempDir("fake-node-main", { "pkg/package.json": "{}", "pkg/cli.js": cjs }); mkdirSync(join(temp, "bin")); symlinkSync(join("..", "pkg", "cli.js"), join(temp, "bin", "link")); @@ -252,8 +251,7 @@ describe("fake node cli", () => { expect({ ...result, stdout: parse(result.stdout) }).toEqual(loaded(temp)); }); - // Windows needs administrator rights to create a symlink. - test.concurrent.skipIf(isWindows)("when the entry point is a symlink", async () => { + test.concurrent("when the entry point is a symlink", async () => { using temp = tempDir("fake-node-asset", files); mkdirSync(join(temp, "bin")); symlinkSync(join("..", "pkg", "index.js"), join(temp, "bin", "link")); From 1c97f171524e193804c7cb0b2b6f7314eeb7b5bd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:32:19 +0000 Subject: [PATCH 3/7] cli: cite Node's resolveMainPath for the unresolved entry path --- src/runtime/cli/run_command.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index c20b5e50bc2e..4a812a2cd67a 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -101,7 +101,8 @@ pub(crate) enum EntryPath { Resolved, /// A path as `node` takes it: a directory, or a file without its /// extension, also runs. `boot` resolves it to the file to run and keeps - /// the given path as `process.argv[1]`. + /// the given path as `process.argv[1]`, like Node's `resolveMainPath`: + /// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/modules/run_main.js#L29-L45 Unresolved, } From bfeb333831be658d35b2b8e523b50c3a254bd14b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:35:19 +0000 Subject: [PATCH 4/7] test: cron execution mode under the node shim with a directory entry --- test/cli/run/as-node.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index f5e72ba1a6cd..e458484ed9dd 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -208,6 +208,15 @@ describe("fake node cli", () => { expect(await runAsNode(String(temp), ["./pkg"])).toEqual({ stdout: "true\n", stderr: "", exitCode: 0 }); }); + test.concurrent("cron execution mode still calls scheduled()", async () => { + using temp = tempDir("fake-node-main", { + "pkg/package.json": JSON.stringify({ type: "module" }), + "pkg/index.js": "export default { scheduled(controller) { console.log(controller.cron); } };", + }); + const result = await runAsNode(String(temp), ["--cron-title=job", "--cron-period=* * * * *", "./pkg"]); + expect(result).toEqual({ stdout: "* * * * *\n", stderr: "", exitCode: 0 }); + }); + test.concurrent("the Bun shell `$1` is process.argv[1]", async () => { using temp = tempDir("fake-node-main", { "pkg/package.json": JSON.stringify({ type: "module" }), From 81de69d06d9cfa3d11f34385acdf05803d9e1df3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:40:56 +0000 Subject: [PATCH 5/7] test: run the node shim tests one at a time --- test/cli/run/as-node.test.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index e458484ed9dd..c8ebf44e64ec 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -113,6 +113,9 @@ describe("fake node cli", () => { expect(result.success).toBe(false); }); + // These tests are not concurrent. In a debug build each `bun --bun` deletes and makes again the + // directory of the `node` shim, so a concurrent one can fail with `Script not found "node"`. + // `node ./pkg`, `node .`, `node ./entry` and `node ./bin/link` do not name the file that runs. describe("an entry point that does not name the file that runs", () => { const cjs = `console.log(JSON.stringify({ @@ -148,7 +151,7 @@ describe("fake node cli", () => { }, ]; - test.concurrent.each(entryPoints)("CommonJS, $name", async ({ pkg, file, cwd, arg, argv1 }) => { + test.each(entryPoints)("CommonJS, $name", async ({ pkg, file, cwd, arg, argv1 }) => { using temp = tempDir("fake-node-main", { "pkg/package.json": JSON.stringify(pkg), [file]: cjs }); const result = await runAsNode(join(temp, cwd), [arg]); expect({ ...result, stdout: parse(result.stdout) }).toEqual({ @@ -164,7 +167,7 @@ describe("fake node cli", () => { }); }); - test.concurrent.each(entryPoints)("ESM, $name", async ({ pkg, file, cwd, arg, argv1 }) => { + test.each(entryPoints)("ESM, $name", async ({ pkg, file, cwd, arg, argv1 }) => { using temp = tempDir("fake-node-main", { "pkg/package.json": JSON.stringify({ ...pkg, type: "module" }), [file]: esm, @@ -182,7 +185,7 @@ describe("fake node cli", () => { }); }); - test.concurrent("CommonJS, a symlink", async () => { + test("CommonJS, a symlink", async () => { using temp = tempDir("fake-node-main", { "pkg/package.json": "{}", "pkg/cli.js": cjs }); mkdirSync(join(temp, "bin")); symlinkSync(join("..", "pkg", "cli.js"), join(temp, "bin", "link")); @@ -200,7 +203,7 @@ describe("fake node cli", () => { }); }); - test.concurrent("a package.json that does not parse adds no output", async () => { + test("a package.json that does not parse adds no output", async () => { using temp = tempDir("fake-node-main", { "pkg/package.json": "{", "pkg/index.js": "console.log(require.main === module);", @@ -208,7 +211,7 @@ describe("fake node cli", () => { expect(await runAsNode(String(temp), ["./pkg"])).toEqual({ stdout: "true\n", stderr: "", exitCode: 0 }); }); - test.concurrent("cron execution mode still calls scheduled()", async () => { + test("cron execution mode still calls scheduled()", async () => { using temp = tempDir("fake-node-main", { "pkg/package.json": JSON.stringify({ type: "module" }), "pkg/index.js": "export default { scheduled(controller) { console.log(controller.cron); } };", @@ -217,7 +220,7 @@ describe("fake node cli", () => { expect(result).toEqual({ stdout: "* * * * *\n", stderr: "", exitCode: 0 }); }); - test.concurrent("the Bun shell `$1` is process.argv[1]", async () => { + test("the Bun shell `$1` is process.argv[1]", async () => { using temp = tempDir("fake-node-main", { "pkg/package.json": JSON.stringify({ type: "module" }), "pkg/index.js": "console.log(JSON.stringify([process.argv[1], (await Bun.$`echo $1`.text()).trim()]));", @@ -247,20 +250,20 @@ describe("fake node cli", () => { exitCode: 0, }); - test.concurrent("when the entry point is a directory", async () => { + test("when the entry point is a directory", async () => { using temp = tempDir("fake-node-asset", files); const result = await runAsNode(String(temp), ["./pkg"]); expect({ ...result, stdout: parse(result.stdout) }).toEqual(loaded(temp)); }); // On Windows the module loader names the entry point with `\`. - test.concurrent("when the entry point is an absolute path with `/`", async () => { + test("when the entry point is an absolute path with `/`", async () => { using temp = tempDir("fake-node-asset", files); const result = await runAsNode(String(temp), [join(temp, "pkg", "index.js").replaceAll("\\", "/")]); expect({ ...result, stdout: parse(result.stdout) }).toEqual(loaded(temp)); }); - test.concurrent("when the entry point is a symlink", async () => { + test("when the entry point is a symlink", async () => { using temp = tempDir("fake-node-asset", files); mkdirSync(join(temp, "bin")); symlinkSync(join("..", "pkg", "index.js"), join(temp, "bin", "link")); From 9165b16fb8a2e6291b6074bb807db0d0e08bd285 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 08:59:59 +0000 Subject: [PATCH 6/7] cli: resolve the node shim's entry right before it loads The resolve in `boot` ran before `NODE_PRESERVE_SYMLINKS` reached the resolver and before `--watch` and `--hot` installed the watcher. Move it to `Run::start`, next to `load_entry_point`. There the resolver has the same state as it has for the import of the entry point. --- src/runtime/cli/run_command.rs | 36 +++++++++++++++++++--------------- test/cli/run/as-node.test.ts | 14 +++++++++++-- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 4a812a2cd67a..4d4f3c2706d4 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -100,8 +100,8 @@ pub(crate) enum EntryPath { /// The file to run. Resolved, /// A path as `node` takes it: a directory, or a file without its - /// extension, also runs. `boot` resolves it to the file to run and keeps - /// the given path as `process.argv[1]`, like Node's `resolveMainPath`: + /// extension, also runs. `Run::start` resolves it to the file to run and + /// keeps the given path as `process.argv[1]`, like Node's `resolveMainPath`: /// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/modules/run_main.js#L29-L45 Unresolved, } @@ -1001,9 +1001,8 @@ Full documentation is available at https://bun.com/docs/cli/run // so the allocation is process-lifetime by construction. let entry: &'static [u8] = Box::leak(entry_path); // What `Run::start` passes to `vm.load_entry_point`; `mut` because the - // cron-execution branch below may swap in a synthetic `cwd/[eval]` path, - // and `EntryPath::Unresolved` the resolved file, while `entry` stays the - // user's path for the loader check further down. + // cron-execution branch below may swap in a synthetic `cwd/[eval]` path + // while `entry` stays the user's path for the loader check further down. let mut run_entry = entry; vm.set_main(entry); @@ -1081,17 +1080,6 @@ Full documentation is available at https://bun.com/docs/cli/run crate::run_main::fail_with_build_error(vm); } - // `vm.main()` must be the key the module loader gives the entry module. - if entry_kind == EntryPath::Unresolved - && vm.module_loader.eval_source.is_none() - && let Some(resolved) = Self::resolve_entry_path(vm, entry) - && resolved != entry - { - vm.set_main_for_argv(entry); - vm.set_main(resolved); - run_entry = resolved; - } - // Allow setting a custom timezone. Without `$TZ`, JSC/ICU lazily // auto-detects the host zone the first time a `Date` is constructed — // matching upstream Bun. `.env` files are loaded by @@ -1136,6 +1124,7 @@ Full documentation is available at https://bun.com/docs/cli/run ctx, vm, entry_path: run_entry, + entry_kind, } .start() } @@ -1274,6 +1263,7 @@ Full documentation is available at https://bun.com/docs/cli/run ctx, vm, entry_path: entry, + entry_kind: EntryPath::Resolved, } .start() } @@ -1294,6 +1284,7 @@ pub struct Run<'a> { /// reloader stores them too (`boot` leaks the `Box<[u8]>`, cron mode uses /// the runner arena). entry_path: &'static [u8], + entry_kind: EntryPath, } // `on_unhandled_rejection_before_close` is a plain fn pointer stored on the @@ -1343,6 +1334,7 @@ impl Run<'_> { ctx, vm, entry_path: mut entry, + entry_kind, } = self; let _api_lock = vm.global().vm().get_api_lock(); @@ -1475,6 +1467,18 @@ impl Run<'_> { } } + // `vm.main()` must be the key the module loader gives the entry module. + // Resolve only now: the resolver has its final options and, under + // `--watch` and `--hot`, its watcher, as it has for every import. + if entry_kind == EntryPath::Unresolved + && vm.module_loader.eval_source.is_none() + && let Some(resolved) = RunCommand::resolve_entry_path(vm, entry) + && resolved != entry + { + vm.set_main_for_argv(entry); + entry = resolved; + } + match vm.load_entry_point(entry) { Ok(promise) => { // SAFETY: `promise` is a live GC cell returned by the module loader. diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index c8ebf44e64ec..72ca8d3abacc 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -203,6 +203,16 @@ describe("fake node cli", () => { }); }); + test("NODE_PRESERVE_SYMLINKS=1 keeps the path through a symlinked directory", async () => { + using temp = tempDir("fake-node-main", { + "real/package.json": JSON.stringify({ type: "module" }), + "real/entry.js": "console.log(import.meta.filename);", + }); + symlinkSync(join(temp, "real"), join(temp, "link"), "dir"); + const result = await runAsNode(String(temp), ["./link/entry.js"], { NODE_PRESERVE_SYMLINKS: "1" }); + expect(result).toEqual({ stdout: join(temp, "link", "entry.js") + "\n", stderr: "", exitCode: 0 }); + }); + test("a package.json that does not parse adds no output", async () => { using temp = tempDir("fake-node-main", { "pkg/package.json": "{", @@ -273,11 +283,11 @@ describe("fake node cli", () => { }); }); -async function runAsNode(cwd: string, args: string[]) { +async function runAsNode(cwd: string, args: string[], env?: Record) { await using proc = Bun.spawn({ cmd: [bunExe(), "--bun", "node", ...args], cwd, - env: { ...bunEnv, NODE_ENV: undefined }, + env: { ...bunEnv, NODE_ENV: undefined, ...env }, stdout: "pipe", stderr: "pipe", }); From d62e10a84d06f33fa2c0ee2a4dd5e49e07a50251 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:18:43 +0000 Subject: [PATCH 7/7] cli: keep the new comments to one line --- src/jsc/VirtualMachine.rs | 6 ++---- src/runtime/cli/run_command.rs | 12 +++--------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index da1db0d0b39e..8957016a46fb 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -150,8 +150,7 @@ pub struct VirtualMachine { /// `RawSlice` carries the BACKREF outlives-holder invariant — read via /// `main()`. main: bun_ptr::RawSlice, - /// `process.argv[1]` when it is not `main`: the `node` shim keeps the path - /// the user gave, like Node. + /// `process.argv[1]` when the `node` shim resolved `main` from it. Node keeps the given path too. main_for_argv: Option<&'static [u8]>, pub main_is_html_entrypoint: bool, pub main_resolved_path: bun_core::String, @@ -3312,8 +3311,7 @@ impl VirtualMachine { self.main = bun_ptr::RawSlice::new(path); } - /// The entry path for `process.argv[1]`: `main()`, unless - /// `set_main_for_argv` gave another path. + /// `process.argv[1]`: the path from `set_main_for_argv`, else `main()`. #[inline] pub fn main_for_argv(&self) -> &[u8] { self.main_for_argv.unwrap_or_else(|| self.main()) diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 4d4f3c2706d4..3ec085880773 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -99,10 +99,7 @@ pub(crate) struct ConfigureEnvOptions { pub(crate) enum EntryPath { /// The file to run. Resolved, - /// A path as `node` takes it: a directory, or a file without its - /// extension, also runs. `Run::start` resolves it to the file to run and - /// keeps the given path as `process.argv[1]`, like Node's `resolveMainPath`: - /// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/modules/run_main.js#L29-L45 + /// A path as `node` takes it. `Run::start` resolves it and it stays `process.argv[1]` (Node: `resolveMainPath`). Unresolved, } @@ -1129,8 +1126,7 @@ Full documentation is available at https://bun.com/docs/cli/run .start() } - /// Resolve `entry` to the file to run, like `bun ` does. On `None` - /// the module loader reports the failure when it imports `entry`. + /// The module key of `entry`, for `vm.main()`. On `None` the module loader reports the failure. fn resolve_entry_path(vm: &mut VirtualMachine, entry: &'static [u8]) -> Option<&'static [u8]> { let top_level_dir = vm.top_level_dir(); // Like the module loader, keep the resolver's messages out of `vm.log`. @@ -1467,9 +1463,7 @@ impl Run<'_> { } } - // `vm.main()` must be the key the module loader gives the entry module. - // Resolve only now: the resolver has its final options and, under - // `--watch` and `--hot`, its watcher, as it has for every import. + // Resolve last: `NODE_PRESERVE_SYMLINKS` and the `--watch`/`--hot` watcher must be on the resolver. if entry_kind == EntryPath::Unresolved && vm.module_loader.eval_source.is_none() && let Some(resolved) = RunCommand::resolve_entry_path(vm, entry)