From 1024c80ab92fc2e2472b47a3ddcc0c3325b2082a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:10:32 +0000 Subject: [PATCH 1/2] cli: read init, info, upgrade and pm trust arguments relative to the dispatched subcommand which() skips the runtime flags that precede the subcommand keyword, both flags typed on the command line and the tokens BUN_OPTIONS splices in after argv[0], but init, upgrade and pm trust then read their arguments from a fixed argv offset that assumes the keyword is argv[1], and info scanned raw argv from the same offset. With anything in front of the keyword, the shifted keyword itself was taken as an argument: bun init created ./init/, bun info looked up a package named "info", bun upgrade rejected "upgrade" as a package name, and bun pm trust tried to trust a package named "trust". Record the keyword's index in which() (SUBCOMMAND_ARGV_INDEX) and have init and upgrade start after it. info and pm trust already run a clap parse that collects the positionals after the keyword, so read those instead of argv. --- src/runtime/cli/mod.rs | 51 +++++++++++++++------------ src/runtime/cli/pm_trusted_command.rs | 28 ++++++--------- src/runtime/cli/upgrade_command.rs | 7 ++-- test/cli/init/init.test.ts | 26 ++++++++++++++ test/cli/install/bun-info.test.ts | 24 +++++++++++++ test/cli/install/bun-pm.test.ts | 51 +++++++++++++++++++++++++++ test/cli/install/bun-upgrade.test.ts | 35 ++++++++++++++++++ 7 files changed, 179 insertions(+), 43 deletions(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index f609dfdd4a0b..9a8366bdb3f0 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -503,6 +503,13 @@ 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 init …` → 1; `bun --smol init …` → 2, as does `bun init …` with +/// `BUN_OPTIONS=--smol`, whose tokens are spliced in after argv[0]. Commands +/// that read their arguments straight out of argv start after this index. +static SUBCOMMAND_ARGV_INDEX: core::sync::atomic::AtomicUsize = + core::sync::atomic::AtomicUsize::new(1); + bun_core::declare_scope!(CLI, hidden); pub(crate) type LoaderColonList = @@ -797,6 +804,12 @@ 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) + } + 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}; @@ -942,6 +955,7 @@ pub mod command { return Tag::RunAsNodeCommand; } + let mut idx: usize = 1; let Some(mut first_arg_name) = iter.next() else { return Tag::AutoCommand; }; @@ -953,10 +967,14 @@ pub mod command { // routes to RunCommand::exec_node_repl. An early ReplCommand return here would bypass // that and boot the legacy `bun repl` implementation instead. 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); @@ -1504,7 +1522,8 @@ pub mod command { 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..]) } #[cold] @@ -1936,30 +1955,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/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index d03bd50c0d34..7518c6cf757b 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -249,7 +249,14 @@ impl TrustCommand { ); Output::flush(); - if args.len() == 2 { + // `get_subcommand` left `positionals[0]` as the `trust` keyword; flags + // never reach the positionals, so the rest are the package names. + let positionals: &[&[u8]] = pm.options.positionals; + let packages_to_trust: &[&[u8]] = &positionals[1.min(positionals.len())..]; + let trust_all = + strings::left_has_any_in_right(args, &[b"-a".as_slice(), b"--all".as_slice()]); + + if !trust_all && packages_to_trust.is_empty() { Self::error_expected_args(); } @@ -273,19 +280,6 @@ impl TrustCommand { } } - let mut packages_to_trust: Vec<&[u8]> = Vec::with_capacity(args[2..].len()); - for arg in &args[2..] { - if !arg.is_empty() && arg[0] != b'-' { - packages_to_trust.push(arg); - } - } - let trust_all = - strings::left_has_any_in_right(args, &[b"-a".as_slice(), b"--all".as_slice()]); - - if !trust_all && packages_to_trust.is_empty() { - Self::error_expected_args(); - } - // SAFETY: `pm_raw` is the singleton; `pm.log` set at init, non-null. let log: *mut bun_ast::Log = unsafe { (*pm_raw).log }; // SAFETY: `pm_raw` singleton; read-only `lockfile` borrow for the discovery phase. @@ -324,7 +318,7 @@ impl TrustCommand { } if untrusted_dep_ids.count() == 0 { - Self::print_error_zero_untrusted_dependencies_found(trust_all, &packages_to_trust); + Self::print_error_zero_untrusted_dependencies_found(trust_all, packages_to_trust); Global::crash(); } @@ -397,7 +391,7 @@ impl TrustCommand { break 'brk false; } - for package_name_from_cli in &packages_to_trust { + for package_name_from_cli in packages_to_trust { if strings::eql_long(package_name_from_cli, alias, true) && !lockfile.has_trusted_dependency( alias, @@ -435,7 +429,7 @@ impl TrustCommand { } if scripts_at_depth.count() == 0 || package_names_to_add.count() == 0 { - Self::print_error_zero_untrusted_dependencies_found(trust_all, &packages_to_trust); + Self::print_error_zero_untrusted_dependencies_found(trust_all, packages_to_trust); Global::crash(); } 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/init/init.test.ts b/test/cli/init/init.test.ts index c3d8bb58f880..0bd2fd3a762f 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -63,6 +63,32 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; expect(fs.existsSync(path.join(temp, "tsconfig.json"))).toBe(true); }, 30_000); + // A runtime flag ahead of the subcommand, typed (`bun --smol init`) or + // spliced into argv by BUN_OPTIONS, moves "init" out of argv[1]. init used to + // take its arguments from a fixed offset, so the shifted "init" keyword became + // the target folder and the project landed in ./init/ (#20347, #39377). + test.each([ + ["BUN_OPTIONS", [], { BUN_OPTIONS: "--smol" }], + ["a flag typed before the subcommand", ["--smol"], {}], + ])( + "bun init initializes the cwd with %s", + async (_, flagsBeforeSubcommand, extraEnv) => { + await using temp = tempDir("bun-init-shifted-argv", {}); + + await using proc = Bun.spawn({ + cmd: [bunExe(), ...flagsBeforeSubcommand, "init", "-y"], + cwd: temp, + stdio: ["ignore", "ignore", "ignore"], + env: { ...initEnv, ...extraEnv }, + }); + + expect(await proc.exited).toBe(0); + expect(fs.existsSync(path.join(temp, "init"))).toBe(false); + expect(fs.existsSync(path.join(temp, "package.json"))).toBe(true); + }, + 30_000, + ); + test("bun init falls back to --yes when stdin is not a TTY", async () => { await using temp = tempDir("bun-init-no-tty", {}); diff --git a/test/cli/install/bun-info.test.ts b/test/cli/install/bun-info.test.ts index 04b1d9fe80b7..1388bb9e1651 100644 --- a/test/cli/install/bun-info.test.ts +++ b/test/cli/install/bun-info.test.ts @@ -44,6 +44,30 @@ describe.concurrent("bun info", () => { expect(output).toContain("maintainers:"); }); + // A runtime flag ahead of the subcommand, typed or spliced into argv by + // BUN_OPTIONS, moves "info" out of argv[1]. The package-name scan used to + // start at a fixed offset and picked up the shifted "info" keyword as the + // package (#20347, #39377). + it.each([ + ["BUN_OPTIONS", [], { BUN_OPTIONS: "--smol" }], + ["a flag typed before the subcommand", ["--smol"], {}], + ])("should read the package name with %s", async (_, flagsBeforeSubcommand, extraEnv) => { + const testDir = await setupTest(); + await using proc = spawn({ + cmd: [bunExe(), ...flagsBeforeSubcommand, "info", "is-number"], + cwd: testDir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env: { ...bunEnv, ...extraEnv }, + }); + const [output, error, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(error).toBe(""); + expect(output).toContain("is-number@"); + expect(code).toBe(0); + }); + it("should display package info for specific version", async () => { const testDir = await setupTest(); const { output, error, code } = await runCommand([bunExe(), "info", "is-number@7.0.0"], testDir); diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index d0c54586c249..a84f5e6c1d10 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -936,3 +936,54 @@ test("bun pm cache rm does not create the directory named by a project-local .en expect(stderr).not.toContain("error"); expect(exitCode).toBe(0); }); + +// A runtime flag ahead of the subcommand, typed or spliced into argv by +// BUN_OPTIONS, moves "pm" out of argv[1]. trust used to take its package names +// from a fixed argv offset, so the shifted "trust" keyword was itself treated as +// a package name and `bun pm trust` without packages no longer errored (#20347). +test.each([ + ["BUN_OPTIONS", [], { BUN_OPTIONS: "--smol" }], + ["a flag typed before the subcommand", ["--smol"], {}], +])("bun pm trust reads its package names with %s", async (_, flagsBeforeSubcommand, extraEnv) => { + using dir = tempDir("pm-trust-shifted-argv", { + "package.json": JSON.stringify({ + name: "trust-shifted-argv", + version: "1.0.0", + dependencies: { dep: "file:./dep" }, + }), + "dep/package.json": JSON.stringify({ name: "dep", version: "1.0.0" }), + }); + const dirStr = String(dir); + + // trust needs a lockfile before it looks at the package names; a file: dependency produces one offline. + await using install = Bun.spawn({ cmd: [bunExe(), "install"], cwd: dirStr, stdout: "pipe", stderr: "pipe", env }); + expect(await install.exited).toBe(0); + + const spawnEnv = { ...env, ...extraEnv }; + { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...flagsBeforeSubcommand, "pm", "trust"], + cwd: dirStr, + stdout: "pipe", + stderr: "pipe", + env: spawnEnv, + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("expected package names(s) or --all"); + expect(exitCode).toBe(1); + } + { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...flagsBeforeSubcommand, "pm", "trust", "not-a-dependency"], + cwd: dirStr, + stdout: "pipe", + stderr: "pipe", + env: spawnEnv, + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + // The "don't exist" listing names exactly the packages given on the command line. + expect(stderr).toContain("- not-a-dependency"); + expect(stderr).not.toContain("- trust"); + expect(exitCode).toBe(1); + } +}); diff --git a/test/cli/install/bun-upgrade.test.ts b/test/cli/install/bun-upgrade.test.ts index 6857b47e84b5..d5cc456de56e 100644 --- a/test/cli/install/bun-upgrade.test.ts +++ b/test/cli/install/bun-upgrade.test.ts @@ -256,6 +256,41 @@ describe.concurrent(() => { expect(err.split(/\r?\n/)).not.toContain("note: Use `bun update --stable --profile` instead."); await proc.exited; }); + + // A runtime flag ahead of the subcommand, typed (`bun --bun upgrade`) or + // spliced into argv by BUN_OPTIONS, moves "upgrade" out of argv[1]. The + // package-name check used to scan from a fixed offset and rejected the + // shifted "upgrade" keyword itself as a package name (#20347, #39377). + it.each([ + ["BUN_OPTIONS", [], { BUN_OPTIONS: "--smol" }], + ["a flag typed before the subcommand", ["--smol"], {}], + ])("%s, should not display the package-names error", async (_, flagsBeforeSubcommand, extraEnv) => { + // `--stable` keeps canary builds on the GITHUB_API_DOMAIN release-server + // path, whose garbage archive makes the upgrade fail after validation so + // the binary is never actually replaced. + using server = startReleaseServer({ tagName: "bun-v9.9.9" }); + const cwd = tmpdirSync(); + const execPath = join(cwd, basename(bunExe())); + await copyFile(bunExe(), execPath); + await using proc = spawn({ + cmd: [execPath, ...flagsBeforeSubcommand, "upgrade", "--stable"], + cwd, + stdout: null, + stdin: "pipe", + stderr: "pipe", + env: { ...server.env, ...extraEnv }, + }); + + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err.split(/\r?\n/)).not.toContain( + "error: This command updates Bun itself, and does not take package names.", + ); + // Proves validation was passed: the run reached the release flow (it + // reports the mock server's v9.9.9 tag) and failed there on the garbage + // archive, not in argument parsing. + expect(err).toContain("v9.9.9"); + expect(exitCode).not.toBe(0); + }); }); it("completes against a locally-served release with the system temp dir held open without FILE_SHARE_DELETE", async () => { From 90a97e863e46ba06fa904ed7f104793b51a8018e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:06:35 +0000 Subject: [PATCH 2/2] test: drain subprocess pipes in the pm trust test --- test/cli/install/bun-pm.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index a84f5e6c1d10..f567d6159486 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -957,7 +957,9 @@ test.each([ // trust needs a lockfile before it looks at the package names; a file: dependency produces one offline. await using install = Bun.spawn({ cmd: [bunExe(), "install"], cwd: dirStr, stdout: "pipe", stderr: "pipe", env }); - expect(await install.exited).toBe(0); + const [installErr, installExit] = await Promise.all([install.stderr.text(), install.exited, install.stdout.text()]); + expect(installErr).not.toContain("error:"); + expect(installExit).toBe(0); const spawnEnv = { ...env, ...extraEnv }; { @@ -968,7 +970,7 @@ test.each([ stderr: "pipe", env: spawnEnv, }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); expect(stderr).toContain("expected package names(s) or --all"); expect(exitCode).toBe(1); } @@ -980,7 +982,7 @@ test.each([ stderr: "pipe", env: spawnEnv, }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); // The "don't exist" listing names exactly the packages given on the command line. expect(stderr).toContain("- not-a-dependency"); expect(stderr).not.toContain("- trust");