diff --git a/src/clap/comptime.rs b/src/clap/comptime.rs index 74d111daa91..374f1fa6364 100644 --- a/src/clap/comptime.rs +++ b/src/clap/comptime.rs @@ -589,18 +589,10 @@ impl ComptimeClap { if param.names.long.is_none() && param.names.short.is_none() { pos.push(arg.value.unwrap()); if opt.stop_after_positional_at > 0 && pos.len() >= opt.stop_after_positional_at { - let mut remaining_ = stream.iter.remain(); - // PORT NOTE: Zig called `bun.span` (NUL-scan) on `[:0]const u8` argv - // entries. Our `ArgIter` already yields sized `&[u8]`, so `span` is a - // no-op and is dropped. - let first: &[u8] = if !remaining_.is_empty() { - remaining_[0] - } else { - b"" - }; - if !first.is_empty() && first == b"--" { - remaining_ = &remaining_[1..]; - } + // Preserve all remaining args including '--' so it + // appears in process.argv, matching Node.js behavior. + // See: https://github.com/oven-sh/bun/issues/13984 + let remaining_ = stream.iter.remain(); passthrough_positionals.reserve_exact(remaining_.len()); for arg_ in remaining_ { diff --git a/src/clap/comptime.zig b/src/clap/comptime.zig index a2a35c8d016..e59accb9b91 100644 --- a/src/clap/comptime.zig +++ b/src/clap/comptime.zig @@ -69,11 +69,10 @@ pub fn ComptimeClap( if (param.names.long == null and param.names.short == null) { try pos.append(arg.value.?); if (opt.stop_after_positional_at > 0 and pos.items.len >= opt.stop_after_positional_at) { - var remaining_ = stream.iter.remain; - const first: []const u8 = if (remaining_.len > 0) bun.span(remaining_[0]) else ""; - if (first.len > 0 and std.mem.eql(u8, first, "--")) { - remaining_ = remaining_[1..]; - } + // Preserve all remaining args including '--' so it + // appears in process.argv, matching Node.js behavior. + // See: https://github.com/oven-sh/bun/issues/13984 + const remaining_ = stream.iter.remain; try passthrough_positionals.ensureTotalCapacityPrecise(remaining_.len); for (remaining_) |arg_| { diff --git a/src/runtime/cli/filter_run.rs b/src/runtime/cli/filter_run.rs index c1e8d856d0d..cd868102146 100644 --- a/src/runtime/cli/filter_run.rs +++ b/src/runtime/cli/filter_run.rs @@ -816,8 +816,12 @@ pub fn run_scripts_with_filter( continue; }; + // Strip leading `--` (npm-style separator) before forwarding + // to the shell command. See: https://github.com/oven-sh/bun/issues/13984 + let passthrough = super::run_command::strip_leading_double_dash(&ctx.passthrough); + let mut copy_script_capacity: usize = original_content.len(); - for part in &ctx.passthrough { + for part in passthrough { copy_script_capacity += 1 + part.len(); } // we leak this @@ -826,7 +830,7 @@ pub fn run_scripts_with_filter( RunCommand::replace_package_manager_run(&mut copy_script, original_content)?; let len_command_only = copy_script.len(); - for part in &ctx.passthrough { + for part in passthrough { copy_script.push(b' '); if crate::shell::needs_escape_utf8_ascii_latin1(part) { crate::shell::escape_8bit::(part, &mut copy_script)?; diff --git a/src/runtime/cli/multi_run.rs b/src/runtime/cli/multi_run.rs index 84aaed834a2..47e4613d12e 100644 --- a/src/runtime/cli/multi_run.rs +++ b/src/runtime/cli/multi_run.rs @@ -775,7 +775,9 @@ pub fn run(ctx: &mut Command::ContextData) -> Result]) -> &[Box<[u8]>] { + if passthrough.first().is_some_and(|first| &**first == b"--") { + &passthrough[1..] + } else { + passthrough + } +} + use ::core::ffi::{c_char, c_void}; use ::core::sync::atomic::{AtomicBool, Ordering}; use std::io::Write as _; @@ -303,6 +318,14 @@ Full documentation is available at https://bun.com/docs/cli/run // duration of `init_and_run_from_source` — single-threaded mini loop, // no aliasing `&mut` exists across this call. let mini = unsafe { &mut *mini }; + + // The Bun shell interpreter reads `$1`, `$2`, … from + // `ctx.passthrough` directly. Swap in the stripped version + // (without leading `--`) so positional expansion matches + // npm semantics. See: https://github.com/oven-sh/bun/issues/13984 + let saved_passthrough = + std::mem::replace(&mut ctx.passthrough, passthrough.to_vec()); + let code = match crate::shell::Interpreter::init_and_run_from_source( ctx, mini, @@ -310,8 +333,12 @@ Full documentation is available at https://bun.com/docs/cli/run ©_script, Some(cwd), ) { - Ok(c) => c, + Ok(c) => { + ctx.passthrough = saved_passthrough; + c + } Err(err) => { + ctx.passthrough = saved_passthrough; if !silent { pretty_errorln!( "error: Failed to run script {} due to error {}", @@ -2138,6 +2165,10 @@ impl RunCommand { ) -> Result<::core::convert::Infallible, bun_core::Error> { use crate::api::bun_process::{Status as SpawnStatus, sync}; + // Strip leading `--` (npm-style separator) before forwarding to + // the binary. See: https://github.com/oven-sh/bun/issues/13984 + let passthrough = strip_leading_double_dash(passthrough); + let mut argv: Vec> = Vec::with_capacity(1 + passthrough.len()); argv.push(executable.to_vec().into_boxed_slice()); for p in passthrough { @@ -2512,7 +2543,10 @@ impl RunCommand { // PORT NOTE: borrowck reshape — `ctx.passthrough` is a // field of `ctx` but `run_package_script_foreground` // takes `&mut ContextData`; clone the slice up-front. - let passthrough: Vec> = ctx.passthrough.clone(); + // Strip leading `--` (npm-style separator) before + // forwarding to the shell command. + let passthrough: Vec> = + strip_leading_double_dash(&ctx.passthrough).to_vec(); let silent = ctx.debug.silent; let use_system_shell = ctx.debug.use_system_shell; @@ -4054,8 +4088,13 @@ impl BunXFastPath { } }; + // Strip leading `--` (npm-style separator) for the command line + // but preserve the full passthrough for `ctx.passthrough` → `vm.argv`. + // See: https://github.com/oven-sh/bun/issues/13984 + let cmd_passthrough = strip_leading_double_dash(passthrough); + let mut i: usize = 0; - for arg in passthrough { + for arg in cmd_passthrough { // Add space separator before each argument command_line[i] = b' ' as u16; i += 1; diff --git a/test/cli/run/run_command.test.ts b/test/cli/run/run_command.test.ts index 2c036b55bb6..da29477aaf9 100644 --- a/test/cli/run/run_command.test.ts +++ b/test/cli/run/run_command.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { describe, expect, test } from "bun:test"; import { rmSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, bunRun, isWindows } from "harness"; +import { bunEnv, bunExe, bunRun, isWindows, tempDir } from "harness"; let cwd: string; @@ -30,3 +30,110 @@ test.if(isWindows)("[windows] A file in drive root runs", () => { rmSync(path); } }); + +// https://github.com/oven-sh/bun/issues/13984 +// Node.js preserves '--' in process.argv when running a script file. +// Bun was stripping it in the CLI arg parser's stop_after_positional_at codepath. +describe("should preserve '--' in process.argv", () => { + test("bun -e -- rest (separator consumed)", () => { + const { exitCode, stdout, stderr } = spawnSync({ + cmd: [bunExe(), "-e", "console.log(JSON.stringify(process.argv))", "--", "rest", "--foo=bar"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const argv = JSON.parse(stdout.toString()); + // With -e, '--' is consumed as a separator (same as Node.js) + expect(argv.slice(1)).toEqual(["rest", "--foo=bar"]); + expect(exitCode).toBe(0); + }); + + test("bun run script.js -- rest (preserved in argv)", () => { + using dir = tempDir("test-double-dash", { + "test.js": "console.log(JSON.stringify(process.argv))", + }); + + const { exitCode, stdout, stderr } = spawnSync({ + cmd: [bunExe(), "run", "test.js", "--", "rest", "--foo=bar"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const argv = JSON.parse(stdout.toString()); + // argv[0] is the bun executable, argv[1] is the script path + // '--' must be preserved in argv[2], matching Node.js behavior + expect(argv.slice(2)).toEqual(["--", "rest", "--foo=bar"]); + expect(exitCode).toBe(0); + }); + + test("bun run script.js -- with multiple double dashes", () => { + using dir = tempDir("test-double-dash-multi", { + "test.js": "console.log(JSON.stringify(process.argv))", + }); + + const { exitCode, stdout, stderr } = spawnSync({ + cmd: [bunExe(), "run", "test.js", "--", "abc", "--", "def"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const argv = JSON.parse(stdout.toString()); + expect(argv.slice(2)).toEqual(["--", "abc", "--", "def"]); + expect(exitCode).toBe(0); + }); + + test("bun run -- args (separator consumed, npm compat)", () => { + using dir = tempDir("test-double-dash-pkg", { + "echo.js": "console.log(JSON.stringify(process.argv))", + "package.json": JSON.stringify({ + scripts: { + echo: `${bunExe()} echo.js`, + }, + }), + }); + + const { exitCode, stdout, stderr } = spawnSync({ + cmd: [bunExe(), "run", "echo", "--", "rest", "value"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const lines = stdout.toString().trim().split("\n"); + const argv = JSON.parse(lines[lines.length - 1]); + // For package scripts, '--' is consumed as an npm-style separator + // (matching npm/yarn), so only the args after it are forwarded. + // The inner script sees: [bun, echo.js, rest, value] + expect(argv.slice(2)).toEqual(["rest", "value"]); + expect(exitCode).toBe(0); + }); + + test("bun run -- args strips separator for Bun shell $1", () => { + using dir = tempDir("test-double-dash-pkg-shell", { + "package.json": JSON.stringify({ + scripts: { + show: 'echo "$1|$2" #', + }, + }), + }); + + const { exitCode, stdout, stderr } = spawnSync({ + cmd: [bunExe(), "run", "show", "--", "rest", "value"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + // Bun shell reads $1/$2 from ctx.passthrough; the leading '--' + // must be stripped so $1 = "rest", $2 = "value" (npm compat). + expect(stdout.toString().trim().split("\n").at(-1)).toBe("rest|value"); + expect(exitCode).toBe(0); + }); +});