Skip to content
1 change: 1 addition & 0 deletions src/install/PackageManager/CommandLineArguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
),
clap::param!("-g, --global Install globally"),
clap::param!("--cwd <STR> Set a specific cwd"),
clap::param!("--env-file <STR>..."),

Check warning on line 96 in src/install/PackageManager/CommandLineArguments.rs

View check run for this annotation

Claude / Claude Code Review

--env-file is consumed but never loaded for install/add/pm-family commands

The hidden `--env-file <STR>...` entry consumes the flag so its value doesn't leak as a positional, but nothing in `CommandLineArguments::parse` reads it and `PackageManager::init`'s `env.load(entries, &[], …)` hardcodes an empty explicit-files list — so `bun --env-file my.env install` now runs but silently discards the env file (e.g. `NPM_CONFIG_TOKEN` / `BUN_CONFIG_REGISTRY` in `my.env` are ignored). Before this PR the command errored loudly; consider either threading `args.options(b"--env-fil
Comment thread
robobun marked this conversation as resolved.
BACKEND_PARAM,
clap::param!(
"--registry <STR> Use a specific registry by default, overriding .npmrc, bunfig.toml and environment variables"
Expand Down
12 changes: 9 additions & 3 deletions src/runtime/cli/create_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,12 +232,18 @@
..Default::default()
};

if opts.positionals.len() >= 1
&& (opts.positionals[0] == b"c" || opts.positionals[0] == b"create")
// Drop everything up to and including the `c`/`create` keyword so the
// template name is `positionals[0]`. A leading global flag's value
// (`bun --cwd dir create foo`) falls through as a positional because
// `params()` doesn't declare it; scanning by name keeps that harmless.
Comment thread
robobun marked this conversation as resolved.
Outdated
if let Some(i) = opts
.positionals
.iter()
.position(|&p| p == b"c" || p == b"create")
{
let mut v = core::mem::take(&mut opts.positionals).into_vec();
v.remove(0);
v.drain(..=i);
opts.positionals = v.into_boxed_slice();

Check warning on line 246 in src/runtime/cli/create_command.rs

View check run for this annotation

Claude / Claude Code Review

CreateOptions::parse drops wrong positionals when --cwd value is 'c' or 'create'

Edge case: since `CreateOptions::params()` doesn't declare `--cwd`/`--env-file`, their values leak as positionals — so `bun --cwd c create foo` yields positionals `['c','create','foo']`, `.position()` matches `'c'` at index 0, and `extract_info()` ends up with `positionals[0] = 'create'` as the template. The clean fix is to add hidden `--cwd <STR>` and `--env-file <STR>...` entries to `CreateOptions::params()` (as this PR already did for install's `SHARED_PARAMS`), so the values never leak and t
Comment thread
robobun marked this conversation as resolved.
Outdated
Comment thread
robobun marked this conversation as resolved.
Outdated
}

opts.skip_package_json = args.flag(b"--no-package-json");
Expand Down
74 changes: 49 additions & 25 deletions src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,17 @@
/// This is set `true` during `Command.which()` if argv0 is "bunx"
static IS_BUNX_EXE: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);

/// argv index of the subcommand keyword (`run`, `test`, `install`, …) as
/// located by `Command::which()`. Defaults to 1 (argv[1]). Larger when a
/// value-taking global flag precedes the subcommand, e.g.
/// `bun --cwd ./dir test …` → 3.
///
/// Handlers that re-scan raw `bun::argv()` for their own positionals read
/// this instead of hard-coding `2` / `argv[2..]`, so the same code path
/// handles both `bun test …` and `bun --cwd d test …`.
Comment thread
robobun marked this conversation as resolved.
Outdated
static SUBCOMMAND_ARGV_INDEX: core::sync::atomic::AtomicUsize =
core::sync::atomic::AtomicUsize::new(1);

bun_core::declare_scope!(CLI, hidden);

pub(crate) type LoaderColonList =
Expand Down Expand Up @@ -746,20 +757,8 @@

#[cold]
pub(crate) fn exec() -> crate::Result<()> {
let mut command_name: &[u8] = b"";
for (i, arg) in bun::argv().iter().enumerate() {
if i == 0 {
continue;
}
if arg.len() > 1 && arg[0] == b'-' {
continue;
}
command_name = arg;
break;
}
if command_name.is_empty() {
command_name = bun::argv().get(1).map(|z| z.as_bytes()).unwrap_or(b"");
}
let idx = super::command::subcommand_argv_index();
let command_name: &[u8] = bun::argv().get(idx).map(|z| z.as_bytes()).unwrap_or(b"");
pretty_error!(
"<r><red>Uh-oh<r>. <b><yellow>bun {0}<r> is a subcommand reserved for future use by Bun.\n\nIf you were trying to run a package.json script called {0}, use <b><magenta>bun run {0}<r>.\n",
bstr::BStr::new(command_name)
Expand All @@ -785,6 +784,13 @@
(0..a.len()).map(|i| a.get(i).unwrap()).collect()
}

/// argv index of the subcommand keyword as found by [`which()`].
/// `bun test …` → 1; `bun --cwd ./dir test …` → 3.
Comment thread
robobun marked this conversation as resolved.
Outdated
#[inline]
pub(crate) fn subcommand_argv_index() -> usize {
super::SUBCOMMAND_ARGV_INDEX.load(core::sync::atomic::Ordering::Relaxed)
}

pub use bun_options_types::command_tag::Tag;
pub use bun_options_types::command_tag::{LOADS_CONFIG, USES_GLOBAL_OPTIONS};
pub use bun_options_types::context::{Context, ContextData, HotReload, TestOptions};
Expand Down Expand Up @@ -930,18 +936,34 @@
return Tag::RunAsNodeCommand;
}

let mut idx: usize = 1;
let Some(mut first_arg_name) = iter.next() else {
return Tag::AutoCommand;
};
while !first_arg_name.is_empty()
&& first_arg_name[0] == b'-'
&& !(first_arg_name.len() > 1 && first_arg_name[1] == b'e')
{
// Step past the value of the required-value global flags in
// `BASE_PARAMS_` so it isn't misread as the subcommand keyword
// (`bun --cwd ./dir test …`, `bun --env-file .env run …`).
// `-c, --config` is `Values::OneOptional` and intentionally not
// listed: clap never consumes a separate token for it.
Comment thread
robobun marked this conversation as resolved.
Outdated
if matches!(first_arg_name, b"--cwd" | b"--env-file") {
if iter.next().is_none() {
return Tag::AutoCommand;
}
idx += 1;
}
match iter.next() {
Some(n) => first_arg_name = n,
Some(n) => {
idx += 1;
first_arg_name = n;
}
None => return Tag::AutoCommand,
}
}
SUBCOMMAND_ARGV_INDEX.store(idx, core::sync::atomic::Ordering::Relaxed);

type RootCommandMatcher = strings::ExactSizeMatcher<12>;
let x = RootCommandMatcher::r#match(first_arg_name);
Expand Down Expand Up @@ -1485,10 +1507,11 @@

#[cold]
#[inline(never)]
fn exec_init() -> CmdResult {
// InitCommand parses its own argv (no Context).
let argv = argv_zslice();
super::init_command::InitCommand::exec(&argv[2.min(argv.len())..])
let start = (subcommand_argv_index() + 1).min(argv.len());
super::init_command::InitCommand::exec(&argv[start..])

Check failure on line 1514 in src/runtime/cli/mod.rs

View check run for this annotation

Claude / Claude Code Review

bun --cwd <dir> init silently ignores --cwd (writes to wrong directory)

`bun --cwd sub init -y` (one of the PR's motivating examples) now dispatches `InitCommand` correctly but silently ignores `--cwd`: `exec_init()` never calls `init()`/`create_context_data()`, so `arguments::parse` (which does the `chdir` for `--cwd`) never runs, and `InitCommand::exec` receives only `['-y']` — it writes `package.json` to the *current* directory instead of `sub`. Before this PR the same invocation errored loudly with "Script not found"; now it succeeds in the wrong place. The new
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
}

#[cold]
Expand All @@ -1498,7 +1521,7 @@
// exec handles both the non-tty path (dump the embedded completion
// script to stdout) and the tty install path (bunx symlink, fpath/XDG
// dir search, profile patching).
for a in bun::argv().iter().skip(2) {
for a in bun::argv().iter().skip(subcommand_argv_index() + 1) {
if matches!(a, b"--help" | b"-h") {
tag_print_help(Tag::InstallCompletionsCommand, true);
Global::exit(0);
Expand All @@ -1522,10 +1545,10 @@
let start_idx = if IS_BUNX_EXE.load(core::sync::atomic::Ordering::Relaxed) {
0
} else {
1
subcommand_argv_index()
};
let argv = argv_zslice();
super::bunx_command::BunxCommand::exec(ctx, &argv[start_idx..])
super::bunx_command::BunxCommand::exec(ctx, &argv[start_idx.min(argv.len())..])
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
}

#[cold]
Expand Down Expand Up @@ -1770,8 +1793,9 @@
// Create command wraps bunx
let ctx = init(Tag::CreateCommand, log)?;
let args = argv_zslice();
let cmd_idx = subcommand_argv_index();

if args.len() <= 2 {
if args.len() <= cmd_idx + 1 {
tag_print_help(Tag::CreateCommand, false);
Global::exit(1);
}
Expand All @@ -1782,15 +1806,15 @@
let mut dash_dash_bun = false;
let mut print_help = false;

if args.len() > 2 {
let remainder = &args[1..];
{
let remainder = &args[cmd_idx..];
let mut remainder_i: usize = 0;
while remainder_i < remainder.len() && positional_i < positionals.len() {
let slice = strings::trim(remainder[remainder_i].as_bytes(), b" \t\n");
if !slice.is_empty() {
if !strings::has_prefix(slice, b"--") {
if positional_i == 1 {
template_name_start = remainder_i + 2;
template_name_start = cmd_idx + remainder_i + 1;
}
positionals[positional_i] = slice;
positional_i += 1;
Expand Down Expand Up @@ -1925,10 +1949,10 @@
let mut package_name: &[u8] = b"";
let mut property_path: Option<&[u8]> = None;

// Find non-flag arguments starting from argv[2] (after "bun info").
// Find non-flag arguments after the `info` keyword.
let mut found_package = false;
let argv = bun::argv();
for arg in argv.iter().skip(2) {
for arg in argv.iter().skip(subcommand_argv_index() + 1) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
// Skip flags
if !arg.is_empty() && arg[0] == b'-' {
continue;
Expand Down
11 changes: 6 additions & 5 deletions src/runtime/cli/package_manager_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,16 @@ Learn more about these at <magenta>https://bun.com/docs/cli/pm<r>.\n";
}

pub(crate) fn exec(ctx: Command::Context) -> crate::Result<()> {
// `bun_core::argv()` includes argv[0]; skip it and collect into a
// borrowed-slice Vec so `&[&[u8]]` callers (TrustCommand/UntrustedCommand,
// `left_has_any_in_right`) keep their shape.
let args_vec: Vec<&'static [u8]> = bun_core::argv().into_iter().skip(1).collect();
// `bun_core::argv()` includes argv[0]; skip to the subcommand keyword and
// collect into a borrowed-slice Vec so `&[&[u8]]` callers
// (TrustCommand/UntrustedCommand, `left_has_any_in_right`) keep their shape.
Comment thread
robobun marked this conversation as resolved.
Outdated
let cmd_idx = Command::subcommand_argv_index();
let args_vec: Vec<&'static [u8]> = bun_core::argv().into_iter().skip(cmd_idx).collect();
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
let args: &[&[u8]] = &args_vec;

// Check if we're being invoked directly as "bun whoami" instead of "bun pm whoami"
let is_direct_whoami = bun_core::argv()
.get(1)
.get(cmd_idx)
.is_some_and(|arg| strings::eql_comptime(arg.as_bytes(), b"whoami"));

let cli = CommandLineArguments::parse(Subcommand::Pm)?;
Expand Down
7 changes: 4 additions & 3 deletions src/runtime/cli/upgrade_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -509,13 +509,14 @@ impl UpgradeCommand {
#[cold]
pub(crate) fn exec(ctx: Command::Context) -> crate::Result<()> {
let args = bun_core::argv();
if args.len() > 2 {
for arg in args.iter().skip(2) {
let start = Command::subcommand_argv_index() + 1;
if args.len() > start {
for arg in args.iter().skip(start) {
if !strings::contains(arg, b"--") {
bun_core::pretty_error!(
"<r><red>error<r><d>:<r> This command updates Bun itself, and does not take package names.\n<blue>note<r><d>:<r> Use `bun update"
);
for arg_err in args.iter().skip(2) {
for arg_err in args.iter().skip(start) {
bun_core::pretty_error!(" {}", bstr::BStr::new(arg_err));
}
bun_core::pretty_errorln!("` instead.");
Expand Down
125 changes: 125 additions & 0 deletions test/cli/bun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,128 @@
});
});
});

// `Command::which()` scans argv for the first non-dash token to pick the
// subcommand. It must step past the value of `--cwd` / `--env-file` so that
// value isn't misread as the subcommand name.
describe.concurrent("global flag before subcommand", () => {
async function run(cwd: string, argv: string[]) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...argv],
env: bunEnv,
cwd,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}

const files = {
"package.json": JSON.stringify({ name: "p", scripts: { greet: "echo hello-from-script" } }),
"app.ts": `console.log("ran:" + (process.env.FROM_ENV_FILE ?? "unset"));`,
"pass.test.ts": `import {test,expect} from "bun:test"; test("t", () => expect(1).toBe(1));`,
"my.env": "FROM_ENV_FILE=loaded\n",
"sub/package.json": JSON.stringify({
name: "sub",
scripts: { greet: "echo hello-from-sub" },
dependencies: {},
}),
};

for (const pre of [["--cwd", "."], ["--env-file", "my.env"], ["--cwd", ".", "--env-file", "my.env"]] as const) {
test(`bun ${pre.join(" ")} run <script> dispatches RunCommand`, async () => {
using dir = tempDir("which-run", files);
const { stdout, stderr, exitCode } = await run(String(dir), [...pre, "run", "greet"]);
expect(stderr).not.toContain("Script not found");
// Misroute to AutoCommand prints the `bun run` help with exit 0.
expect(stdout).not.toContain("Usage:");
expect(stdout).toContain("hello-from-script");
expect(exitCode).toBe(0);
});

test(`bun ${pre.join(" ")} test <file> dispatches TestCommand`, async () => {
using dir = tempDir("which-test", files);
const { stderr, exitCode } = await run(String(dir), [...pre, "test", "pass.test.ts"]);
expect(stderr).not.toContain("Script not found");
expect(stderr).toContain("1 pass");
expect(exitCode).toBe(0);
});
}

test("bun --env-file my.env run app.ts loads the env file", async () => {
using dir = tempDir("which-env", files);
const { stdout, stderr, exitCode } = await run(String(dir), ["--env-file", "my.env", "run", "app.ts"]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "ran:loaded\n", stderr: "", exitCode: 0 });
});

test("bun --cwd sub run <script> resolves scripts from the --cwd dir", async () => {
using dir = tempDir("which-cwd", files);
const { stdout, stderr, exitCode } = await run(String(dir), ["--cwd", "sub", "run", "greet"]);
expect(stderr).not.toContain("Script not found");
expect(stdout).not.toContain("Usage:");
expect(stdout).toContain("hello-from-sub");
expect(exitCode).toBe(0);
});

test("bun --cwd sub install dispatches InstallCommand (not add 'sub')", async () => {
using dir = tempDir("which-install", files);
const { stdout, stderr, exitCode } = await run(String(dir), ["--cwd", "sub", "install", "--dry-run"]);
expect(stderr).not.toContain("Script not found");
// A misroute to `bun add` would print "installed <pkg>" / hit the registry;
// a misroute to AutoCommand would print "Script not found".
expect(stdout + stderr).not.toMatch(/\badd\b.*\binstall\b/);
expect(stdout + stderr).not.toContain('"sub"');
expect(exitCode).toBe(0);
});

test("bun --env-file my.env install does not treat the path as a package", async () => {
using dir = tempDir("which-install-env", files);
const { stdout, stderr, exitCode } = await run(String(dir), [
"--env-file",
"my.env",
"install",
"--dry-run",
"--cwd",
"sub",
]);
// Regression guard for the #34983 revert: `.env` must not leak as a
// positional and `install` must not be treated as a package name.
expect(stdout + stderr).not.toContain("my.env");
expect(stderr).not.toContain("Script not found");
expect(exitCode).toBe(0);
});

test("bun --cwd . init -y -m dispatches InitCommand", async () => {
using dir = tempDir("which-init", { "keep/.gitkeep": "" });
const { stderr, exitCode } = await run(String(dir), ["--cwd", ".", "init", "-y", "-m"]);
expect(stderr).not.toContain("Script not found");
expect(fs.existsSync(`${dir}/package.json`)).toBe(true);
expect(exitCode).toBe(0);
});

test("bun --cwd . exec <cmd> dispatches ExecCommand", async () => {
using dir = tempDir("which-exec", files);
const { stdout, stderr, exitCode } = await run(String(dir), ["--cwd", ".", "exec", "echo from-exec"]);
expect(stderr).not.toContain("Script not found");
expect(stdout).toContain("from-exec");
expect(exitCode).toBe(0);
});

test("bun --cwd . build app.ts dispatches BuildCommand", async () => {
using dir = tempDir("which-build", files);
const { stdout, stderr, exitCode } = await run(String(dir), ["--cwd", ".", "build", "./app.ts"]);
expect(stderr).not.toContain("Script not found");
expect(stdout).toContain("FROM_ENV_FILE");
expect(exitCode).toBe(0);
});

test("bun --cwd sub add --dry-run does not misread 'sub' as a package", async () => {
using dir = tempDir("which-add", files);
const { stdout, stderr } = await run(String(dir), ["--cwd", "sub", "add", "--dry-run"]);
// `bun add` with no package prints usage; the point is `sub` / `add` never
// reach the resolver.
expect(stdout + stderr).not.toMatch(/GET .*\/(sub|add)\b/);
expect(stderr).not.toContain("Script not found");
});

Check warning on line 270 in test/cli/bun.test.ts

View check run for this annotation

Claude / Claude Code Review

add --dry-run test has only negative assertions (vacuous on crash)

This test contains only negative assertions (`.not.toMatch`, `.not.toContain`) and — unlike every sibling test in this block — never checks `exitCode` or any positive output marker, so it passes vacuously if the subprocess crashes with an unrelated message or produces empty output. Since the comment already notes "`bun add` with no package prints usage", assert that positively (e.g. `expect(stdout + stderr).toMatch(/bun add/i)`) or at minimum destructure and check `exitCode` like the other tests
Comment thread
robobun marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
});
Loading