Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 4 additions & 12 deletions src/clap/comptime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -589,18 +589,10 @@ impl<Id> ComptimeClap<Id> {
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_ {
Comment on lines 589 to 598
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This change is at the shared clap layer, so it also changes bun run <package.json-script> -- args and bun run <bin> -- args, not just script files. After this PR, bun run dev -- --port 3000 (where dev is e.g. "vite") appends -- --port 3000 to the shell command instead of --port 3000, which diverges from npm and breaks the very common npm run script -- args habit. The leading -- should be stripped in the package-script / binary / filter / multi-run consumers (or the preservation moved to where vm.argv is populated) rather than removed wholesale from clap.

Extended reasoning...

What changed and why it leaks

The fix removes the leading--- strip from the stop_after_positional_at codepath in comptime.rs. That codepath produces passthrough_positionals, which is exposed via args.remaining() and assigned once to ctx.passthrough at src/runtime/cli/Arguments.rs:944. ctx.passthrough is then consumed by every bun run execution mode, not just script-file execution:

  • run_command.rs:963 / :1190vm.argv = take(ctx.passthrough)process.argv for script files (the intended fix target)
  • run_command.rs:2515run_package_script_foreground (loop at :273) — appends each passthrough item to the shell command for package.json scripts
  • run_command.rs:2141-2145run_binary_without_bunx_path builds argv = [executable, ...passthrough] for node_modules/.bin executables
  • filter_run.rs:820-829 — same shell-concat pattern for --filter
  • multi_run.rs:778-781 — pushes each passthrough item as a script name for --parallel/--sequential

None of these downstream consumers strip a leading -- (grep for b"--" in run_command.rs returns no relevant hits), so the new -- flows straight through.

Step-by-step proof: bun run dev -- --port 3000

Given a package.json with "scripts": { "dev": "vite" }:

  1. RunCommand parses with stop_after_positional_at = 2 (Arguments.rs:760). Positionals = ["run", "dev"], then parsing stops.
  2. stream.iter.remain() = ["--", "--port", "3000"].
    • Before this PR: leading "--" was dropped → passthrough = ["--port", "3000"].
    • After this PR: passthrough = ["--", "--port", "3000"].
  3. Arguments.rs:944: ctx.passthrough = ["--", "--port", "3000"].
  4. run_command.rs resolves dev as a package.json script (not a file), clones ctx.passthrough at :2515, and calls run_package_script_foreground.
  5. At :273, each part is appended space-separated to the script body: copy_script = "vite -- --port 3000" (was "vite --port 3000" before).
  6. The shell runs vite -- --port 3000. Vite (and most CLIs) treats -- as end-of-options, so --port and 3000 become positional file arguments instead of the port option — the user's dev server starts on the default port (or errors on the unknown entry).

The same trace applies to run_binary_without_bunx_path: bun run prettier -- file.js now execs ["prettier", "--", "file.js"] instead of ["prettier", "file.js"]. And in multi_run.rs:778, bun run --parallel a b -- c would now push "--" as a script name → Script not found: --.

Why nothing prevents it

The old behavior — stripping the first -- separator before forwarding to scripts/binaries — matched npm run <script> -- <args> semantics, where -- is a CLI-level separator that npm consumes. There is no compensating strip anywhere downstream of ctx.passthrough; the only place -- was ever removed is the line this PR deletes. The PR's new tests only exercise the script-file → process.argv path; there is no test covering bun run <pkg-script> -- args or bun run <bin> -- args, so CI won't catch this.

Impact

This is a user-visible regression in an extremely common workflow (bun run <script> -- <flags> is muscle memory for anyone coming from npm/yarn). It silently changes how flags are delivered to dev servers, formatters, test runners, etc., and diverges from npm. The PR title/description only mention script files, so this appears unintended.

Suggested fix

Keep the clap change (it's correct for process.argv), and strip a single leading "--" from passthrough at the four non-vm.argv consumption points (run_package_script_foreground call site at run_command.rs:2515, run_binary_without_bunx_path at :2141, filter_run.rs:820, multi_run.rs:778). Alternatively, leave clap as-is on main and instead preserve -- only where vm.argv is populated (run_command.rs:963/:1190). Either way, please add a test for bun run <pkg-script> -- arg to lock the behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in latest push. Now stripping -- at the downstream consumers instead of at the clap layer.

Expand Down
9 changes: 4 additions & 5 deletions src/clap/comptime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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_| {
Expand Down
8 changes: 6 additions & 2 deletions src/runtime/cli/filter_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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::<true>(part, &mut copy_script)?;
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/cli/multi_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,9 @@ pub fn run(ctx: &mut Command::ContextData) -> Result<core::convert::Infallible,
script_names.push(pos.clone());
}
}
for pt in &ctx.passthrough {
// Strip leading `--` (npm-style separator) before collecting
// script names. See: https://github.com/oven-sh/bun/issues/13984
for pt in super::run_command::strip_leading_double_dash(&ctx.passthrough) {
if !pt.is_empty() {
script_names.push(pt.clone());
}
Expand Down
45 changes: 42 additions & 3 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@
//! bun-shell / system shell. PATH stitching, `node_modules/.bin` lookup,
//! markdown rendering, and the Windows bunx fast-path are all handled here.

/// Strip a leading `"--"` from a passthrough slice.
///
/// `ctx.passthrough` now preserves `"--"` (see #13984) so that `process.argv`
/// matches Node.js for script files. However, when forwarding args to
/// package-scripts (`bun run dev -- --port 3000`) or node_modules/.bin
/// binaries, the leading `"--"` is an npm-style separator that should be
/// consumed, matching `npm run` / `yarn run` behavior.
pub(crate) fn strip_leading_double_dash(passthrough: &[Box<[u8]>]) -> &[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 _;
Expand Down Expand Up @@ -303,15 +318,27 @@ Full documentation is available at <magenta>https://bun.com/docs/cli/run<r>
// 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,
name,
&copy_script,
Some(cwd),
) {
Ok(c) => c,
Ok(c) => {
ctx.passthrough = saved_passthrough;
c
}
Err(err) => {
ctx.passthrough = saved_passthrough;
if !silent {
pretty_errorln!(
"<r><red>error<r>: Failed to run script <b>{}<r> due to error <b>{}<r>",
Expand Down Expand Up @@ -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<Box<[u8]>> = Vec::with_capacity(1 + passthrough.len());
argv.push(executable.to_vec().into_boxed_slice());
for p in passthrough {
Expand Down Expand Up @@ -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<Box<[u8]>> = ctx.passthrough.clone();
// Strip leading `--` (npm-style separator) before
// forwarding to the shell command.
let passthrough: Vec<Box<[u8]>> =
strip_leading_double_dash(&ctx.passthrough).to_vec();
let silent = ctx.debug.silent;
let use_system_shell = ctx.debug.use_system_shell;

Expand Down Expand Up @@ -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;
Expand Down
109 changes: 108 additions & 1 deletion test/cli/run/run_command.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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 <code> -- 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);
Comment on lines +39 to +49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add Bun’s subprocess failure-diagnostics guard before exit-code assertions.

Please add if (exitCode !== 0) { expect(stderr.toString()).toBe(""); } immediately before each expect(exitCode).toBe(0) so failures surface stderr clearly in CI diffs.

Based on learnings: when spawning subprocesses in Bun tests, place the stderr guard immediately before the final expect(exitCode).toBe(0) assertion for better failure output.

Also applies to: 57-69, 77-87

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli/run/run_command.test.ts` around lines 39 - 49, Add a stderr guard
before the exit-code assertion for each spawnSync case: when checking the child
exit you must insert if (exitCode !== 0) { expect(stderr.toString()).toBe(""); }
immediately before each expect(exitCode).toBe(0) so failures print stderr;
update the block that calls spawnSync (referencing spawnSync, exitCode, stderr,
stdout) and the other similar test blocks noted (the cases around the test
assertions) to include that guard right before the final exit-code assertion.

});

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 <pkg-script> -- 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 <pkg-script> -- 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);
});
});