diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index 9a3c68944704..9b9c5635f440 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -93,6 +93,7 @@ const SHARED_PARAMS: &[ParamType] = &[ ), clap::param!("-g, --global Install globally"), clap::param!("--cwd Set a specific cwd"), + clap::param!("--env-file ..."), BACKEND_PARAM, clap::param!( "--registry Use a specific registry by default, overriding .npmrc, bunfig.toml and environment variables" diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index f7dc22bfa707..c609d252d076 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -115,6 +115,11 @@ impl Options { ctx.debug.run_in_bun = true; } else if positional == b"--no-install" { opts.no_install = true; + } else if positional == b"--cwd" || positional == b"--env-file" { + // Step past the value so it isn't mistaken for the package + // name. `--cwd` before `x` was applied by the caller's + // `apply_leading_cwd()`; after `x` it is not honored yet. + i += 1; } else if positional == b"--package" || positional == b"-p" { // Next argument should be the package name i += 1; diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index 41d1f2c42dac..958e981086b4 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -232,11 +232,13 @@ impl CreateOptions { ..Default::default() }; - if opts.positionals.len() >= 1 - && (opts.positionals[0] == b"c" || opts.positionals[0] == b"create") + 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(); } diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index dff989aa43be..a92853e5c5c0 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -492,6 +492,11 @@ pub use bun_install::PRETEND_TO_BE_NODE; /// 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 as located by `Command::which()`. +/// `bun test …` → 1; `bun --cwd ./dir test …` → 3. +static SUBCOMMAND_ARGV_INDEX: core::sync::atomic::AtomicUsize = + core::sync::atomic::AtomicUsize::new(1); + bun_core::declare_scope!(CLI, hidden); pub(crate) type LoaderColonList = @@ -746,20 +751,8 @@ pub mod reserved_command { #[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!( "Uh-oh. bun {0} is a subcommand reserved for future use by Bun.\n\nIf you were trying to run a package.json script called {0}, use bun run {0}.\n", bstr::BStr::new(command_name) @@ -785,6 +778,46 @@ pub mod command { (0..a.len()).map(|i| a.get(i).unwrap()).collect() } + /// See [`SUBCOMMAND_ARGV_INDEX`](super::SUBCOMMAND_ARGV_INDEX). + #[inline] + pub(crate) fn subcommand_argv_index() -> usize { + super::SUBCOMMAND_ARGV_INDEX.load(core::sync::atomic::Ordering::Relaxed) + } + + /// Apply a `--cwd ` / `--cwd=` that preceded the subcommand + /// keyword, for handlers that don't route through `arguments::parse` / + /// `CommandLineArguments::parse`. Last occurrence wins (clap semantics). + #[cold] + pub(crate) fn apply_leading_cwd() { + let argv = bun::argv(); + let end = subcommand_argv_index().min(argv.len()); + let mut last: Option<&[u8]> = None; + let mut i = 1; + while i < end { + let a = argv.get(i).map(|z| z.as_bytes()).unwrap_or(b""); + if a == b"--cwd" { + if let Some(dir) = argv.get(i + 1).filter(|_| i + 1 < end) { + last = Some(dir.as_bytes()); + i += 1; + } + } else if let Some(dir) = a.strip_prefix(b"--cwd=") { + last = Some(dir); + } + i += 1; + } + if let Some(dir) = last { + let dir_z = bun_core::ZBox::from_bytes(dir); + if let bun_sys::Result::Err(err) = bun_sys::chdir(&dir_z) { + Output::err( + err, + "Could not change directory to \"{}\"\n", + format_args!("{}", bstr::BStr::new(dir)), + ); + Global::exit(1); + } + } + } + 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}; @@ -930,6 +963,7 @@ pub mod command { return Tag::RunAsNodeCommand; } + let mut idx: usize = 1; let Some(mut first_arg_name) = iter.next() else { return Tag::AutoCommand; }; @@ -937,11 +971,24 @@ pub mod command { && first_arg_name[0] == b'-' && !(first_arg_name.len() > 1 && first_arg_name[1] == b'e') { + // Step past the value of the required-value `BASE_PARAMS_` flags. + // `-c, --config` is `Values::OneOptional`: clap never consumes a + // separate token for it, so it's intentionally not listed. + 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); @@ -1487,8 +1534,10 @@ pub mod command { #[inline(never)] fn exec_init() -> CmdResult { // InitCommand parses its own argv (no Context). + apply_leading_cwd(); 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..]) } #[cold] @@ -1498,7 +1547,7 @@ pub mod command { // 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); @@ -1518,6 +1567,7 @@ pub mod command { #[cold] #[inline(never)] fn exec_bunx(log: &mut bun_ast::Log) -> CmdResult { + apply_leading_cwd(); let ctx = init(Tag::BunxCommand, log)?; let start_idx = if IS_BUNX_EXE.load(core::sync::atomic::Ordering::Relaxed) { 0 @@ -1768,10 +1818,12 @@ pub mod command { } // Create command wraps bunx + apply_leading_cwd(); 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); } @@ -1782,7 +1834,7 @@ pub mod command { let mut dash_dash_bun = false; let mut print_help = false; - if args.len() > 2 { + { let remainder = &args[1..]; let mut remainder_i: usize = 0; while remainder_i < remainder.len() && positional_i < positionals.len() { @@ -1800,6 +1852,8 @@ pub mod command { dash_dash_bun = true; } else if slice == b"--help" || slice == b"-h" { print_help = true; + } else if slice == b"--cwd" || slice == b"--env-file" { + remainder_i += 1; } } } @@ -1918,30 +1972,16 @@ To create a project with the official Next.js scaffolding tool, run\n\ // Parse arguments manually since the standard flow doesn't work for standalone commands let cli = CommandLineArguments::parse(PmSubcommand::Info)?; let json_output = cli.json_output; + // `positionals[0]` is the `info` keyword itself. + let positionals = match cli.positionals { + [b"info", rest @ ..] => rest, + rest => rest, + }; + let package_name: &[u8] = positionals.first().copied().unwrap_or(b""); + let property_path: Option<&[u8]> = positionals.get(1).copied(); let ctx = init(Tag::InfoCommand, log)?; let (pm, _) = PackageManager::init(ctx, cli, Subcommand::Info)?; - // Handle arguments correctly for standalone info command - let mut package_name: &[u8] = b""; - let mut property_path: Option<&[u8]> = None; - - // Find non-flag arguments starting from argv[2] (after "bun info"). - let mut found_package = false; - let argv = bun::argv(); - for arg in argv.iter().skip(2) { - // Skip flags - if !arg.is_empty() && arg[0] == b'-' { - continue; - } - if !found_package { - package_name = arg; - found_package = true; - } else { - property_path = Some(arg); - break; - } - } - super::pm_view_command::view(pm, package_name, property_path, json_output) } diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 5f31eb8f7ff2..58ab1333bfb6 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -190,15 +190,18 @@ Learn more about these at https://bun.com/docs/cli/pm.\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(); - let args: &[&[u8]] = &args_vec; + // `bun_core::argv()` includes argv[0]; skip to the subcommand keyword and + // collect into a borrowed-slice Vec so `&[&[u8]]` callers + // (TrustCommand/UntrustedCommand) index `args[2..]` relative to the + // keyword. Flag probes (`--all`, `--trusted`) scan `all_args` instead + // so a flag placed before the keyword is still seen. + let cmd_idx = Command::subcommand_argv_index(); + let all_args: Vec<&'static [u8]> = bun_core::argv().into_iter().skip(1).collect(); + let args: &[&[u8]] = &all_args[(cmd_idx - 1).min(all_args.len())..]; // 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)?; @@ -534,9 +537,9 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; more_packages[0] = true; } - let trusted_only = strings::left_has_any_in_right(args, &[b"--trusted"]); + let trusted_only = strings::left_has_any_in_right(&all_args, &[b"--trusted"]); - if strings::left_has_any_in_right(args, &[b"-A", b"-a", b"--all"]) { + if strings::left_has_any_in_right(&all_args, &[b"-A", b"-a", b"--all"]) { if trusted_only { // Trust is by package name, not tree position, so a trusted // package nested under an untrusted parent must still be diff --git a/src/runtime/cli/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index 3491daa2aaa5..e1de284fc8e4 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -249,10 +249,6 @@ impl TrustCommand { ); Output::flush(); - if args.len() == 2 { - Self::error_expected_args(); - } - // Reshaped for borrowck — see `UntrustedCommand::exec`. // `load_lockfile` lives until `save_to_disk` near the end, so every // `pm`/`pm.lockfile` access in between goes through `pm_raw`. @@ -279,8 +275,9 @@ impl TrustCommand { packages_to_trust.push(arg); } } - let trust_all = - strings::left_has_any_in_right(args, &[b"-a".as_slice(), b"--all".as_slice()]); + let trust_all = bun_core::argv() + .iter() + .any(|a| matches!(a, b"-a" | b"--all")); if !trust_all && packages_to_trust.is_empty() { Self::error_expected_args(); diff --git a/src/runtime/cli/upgrade_command.rs b/src/runtime/cli/upgrade_command.rs index fcd16bf1b0bf..108e8f40605c 100644 --- a/src/runtime/cli/upgrade_command.rs +++ b/src/runtime/cli/upgrade_command.rs @@ -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!( "error: This command updates Bun itself, and does not take package names.\nnote: 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."); diff --git a/test/cli/bun.test.ts b/test/cli/bun.test.ts index 7e974a6e692c..3092d24a8e58 100644 --- a/test/cli/bun.test.ts +++ b/test/cli/bun.test.ts @@ -1,6 +1,6 @@ import { spawnSync } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import fs from "node:fs"; import { tmpdir } from "node:os"; @@ -144,3 +144,154 @@ describe("bun", () => { }); }); }); + +// `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