From e087d07e03be7fb6d44bfff42a8e6377493c6d2c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:59:09 +0000 Subject: [PATCH 1/3] cli: stop the subcommand lookup at every eval flag, not only -e Command::which() steps over the flags in front of the subcommand name. It only stopped at tokens starting with -e, so for -p, -pe, --print and --eval the next argv token was matched against the subcommand names: `bun -p test` ran the test runner, `bun -pe help` printed the help, and with an attached value (`bun -p=1 test`, `bun --print=1 test`) the script's first argument was dispatched the same way. Any eval flag spelling now classifies the invocation as AutoCommand, where arguments::parse already handles these flags; the -e entry in the keyword matcher was only reachable through the old carve-out. --- src/runtime/cli/mod.rs | 25 +++++++++---- test/cli/run/run-eval.test.ts | 70 ++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 9bd01c88ea88..0a04dbadc666 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -890,6 +890,18 @@ pub mod command { strings::contains_char(basename, b'.') } + /// `-e` / `--eval` / `-p` / `--print` in any spelling `arguments::parse` + /// accepts for [`Tag::AutoCommand`]: the value as the next token, attached + /// (`-p1+1`, `-p=1+1`, `--print=1+1`), or node's `-pe` (a + /// `NODE_SHORT_ALIASES` entry). `-e` and `-p` take a value, so anything + /// trailing them in the same token is that value, never another flag. + #[inline] + fn is_eval_flag(arg: &[u8]) -> bool { + matches!(arg, [b'-', b'e' | b'p', ..] | b"--eval" | b"--print") + || arg.starts_with(b"--eval=") + || arg.starts_with(b"--print=") + } + /// `#[inline(never)]`: argv→`Tag` classification, called once from /// `Cli::start` on every `bun` invocation. Kept a concrete symbol so /// `src/startup.order` can place it next to `Cli::start` / @@ -937,10 +949,12 @@ pub mod command { 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') - { + while !first_arg_name.is_empty() && first_arg_name[0] == b'-' { + // What follows an eval flag is the script and its argv, so `bun -p test` + // evaluates `test` rather than running `bun test`. + if is_eval_flag(first_arg_name) { + return Tag::AutoCommand; + } // `--interactive` stays on AutoCommand: Arguments.rs parses it and the no-target check // routes to RunCommand::exec_node_repl. An early ReplCommand return here would bypass // that and boot the legacy `bun repl` implementation instead. @@ -1068,9 +1082,6 @@ pub mod command { } return Tag::AutoCommand; } - if x == RootCommandMatcher::case(b"-e") { - return Tag::AutoCommand; - } Tag::AutoCommand } diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 0f928a7ce6a7..565cb97a4d26 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -1,7 +1,7 @@ import { SyncSubprocess } from "bun"; import { describe, expect, test } from "bun:test"; import { rmSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness"; import { tmpdir } from "os"; import { join, sep } from "path"; @@ -108,6 +108,74 @@ for (const flag of ["-e", "--print"]) { }); } +// The subcommand lookup skips the leading flags and matches the next argument +// against the subcommand names. It used to stop only at `-e`; after any other +// eval flag the code (or, with an attached value, the script's first argument) +// was matched instead, so `bun -p test` ran the test runner and `bun -pe help` +// printed bun's help. Every child runs in an empty directory so a misdispatched +// subcommand has nothing to act on. +describe("eval flags take precedence over subcommand names", () => { + describe("the code is a subcommand name", () => { + const cases: string[][] = [ + ["-p", "test"], + ["--print", "test"], + ["--eval", "test"], + ["-pe", "help"], + ["-e", "test"], + // The eval flag comes after another flag. + ["--smol", "-p", "test"], + ]; + + for (const args of cases) { + const name = args[args.length - 1]; + test.concurrent(`bun ${args.join(" ")} evaluates \`${name}\``, async () => { + using dir = tempDir("eval-subcommand-name", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(`ReferenceError: ${name} is not defined`); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }); + } + }); + + describe("a script argument is a subcommand name", () => { + const argv = "JSON.stringify(process.argv.slice(1))"; + const cases: string[][] = [ + [`-p${argv}`, "test"], + [`-p=${argv}`, "test"], + [`--print=${argv}`, "test"], + [`--eval=console.log(${argv})`, "help"], + [`-e=console.log(${argv})`, "test"], + ["-p", argv, "test"], + ]; + + for (const args of cases) { + const name = args[args.length - 1]; + test.concurrent(`bun ${args.join(" ")} passes \`${name}\` to the script`, async () => { + using dir = tempDir("eval-subcommand-arg", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe(JSON.stringify([name]) + "\n"); + expect(exitCode).toBe(0); + }); + } + }); +}); + describe("--print for cjs/esm", () => { test("eval result between esm imports", async () => { let cwd = tmpdirSync(); From d8a9fc3335ac1be565acb1907a319e9159e4155f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:29:22 +0000 Subject: [PATCH 2/3] cli: state the cluster limit of is_eval_flag; test the #23631 repro The doc comment claimed every spelling clap accepts is recognized, but short clusters such as `-bp ` are not. Link the issue from the dispatch check and the tests, and use its literal `bun --print x` as one of the cases. --- src/runtime/cli/mod.rs | 11 ++++------- test/cli/run/run-eval.test.ts | 13 +++++-------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 0a04dbadc666..73025cf4cb47 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -890,11 +890,8 @@ pub mod command { strings::contains_char(basename, b'.') } - /// `-e` / `--eval` / `-p` / `--print` in any spelling `arguments::parse` - /// accepts for [`Tag::AutoCommand`]: the value as the next token, attached - /// (`-p1+1`, `-p=1+1`, `--print=1+1`), or node's `-pe` (a - /// `NODE_SHORT_ALIASES` entry). `-e` and `-p` take a value, so anything - /// trailing them in the same token is that value, never another flag. + /// `-e` and `-p` take a value, so every `-e…` / `-p…` token (node's `-pe` + /// included) is an eval flag; clusters such as `-bp` are not recognized. #[inline] fn is_eval_flag(arg: &[u8]) -> bool { matches!(arg, [b'-', b'e' | b'p', ..] | b"--eval" | b"--print") @@ -950,8 +947,8 @@ pub mod command { return Tag::AutoCommand; }; while !first_arg_name.is_empty() && first_arg_name[0] == b'-' { - // What follows an eval flag is the script and its argv, so `bun -p test` - // evaluates `test` rather than running `bun test`. + // The rest of argv is the script and its arguments: `bun -p test` evaluates + // `test` (https://github.com/oven-sh/bun/issues/23631). if is_eval_flag(first_arg_name) { return Tag::AutoCommand; } diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 565cb97a4d26..86f90d1b484f 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -108,21 +108,18 @@ for (const flag of ["-e", "--print"]) { }); } -// The subcommand lookup skips the leading flags and matches the next argument -// against the subcommand names. It used to stop only at `-e`; after any other -// eval flag the code (or, with an attached value, the script's first argument) -// was matched instead, so `bun -p test` ran the test runner and `bun -pe help` -// printed bun's help. Every child runs in an empty directory so a misdispatched -// subcommand has nothing to act on. +// https://github.com/oven-sh/bun/issues/23631 +// Each child runs in an empty directory so that a token dispatched as a +// subcommand (`bun test`, `bun x`, ...) has nothing to act on. describe("eval flags take precedence over subcommand names", () => { describe("the code is a subcommand name", () => { const cases: string[][] = [ ["-p", "test"], - ["--print", "test"], + ["--print", "x"], ["--eval", "test"], ["-pe", "help"], ["-e", "test"], - // The eval flag comes after another flag. + // The eval flag is reached after skipping an unrelated flag. ["--smol", "-p", "test"], ]; From eecba162524e69551ad81f2f9fe33cebe8991953 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:06:06 +0000 Subject: [PATCH 3/3] cli: keep the is_eval_flag comments to one line --- src/runtime/cli/mod.rs | 6 ++---- test/cli/run/run-eval.test.ts | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 73025cf4cb47..47db997bf0b7 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -890,8 +890,7 @@ pub mod command { strings::contains_char(basename, b'.') } - /// `-e` and `-p` take a value, so every `-e…` / `-p…` token (node's `-pe` - /// included) is an eval flag; clusters such as `-bp` are not recognized. + /// `-e`/`-p` take a value, so any `-e…`/`-p…` token is one; clusters like `-bp` are not. #[inline] fn is_eval_flag(arg: &[u8]) -> bool { matches!(arg, [b'-', b'e' | b'p', ..] | b"--eval" | b"--print") @@ -947,8 +946,7 @@ pub mod command { return Tag::AutoCommand; }; while !first_arg_name.is_empty() && first_arg_name[0] == b'-' { - // The rest of argv is the script and its arguments: `bun -p test` evaluates - // `test` (https://github.com/oven-sh/bun/issues/23631). + // The rest of argv belongs to the script: https://github.com/oven-sh/bun/issues/23631 if is_eval_flag(first_arg_name) { return Tag::AutoCommand; } diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 86f90d1b484f..b9fca9710a34 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -109,8 +109,7 @@ for (const flag of ["-e", "--print"]) { } // https://github.com/oven-sh/bun/issues/23631 -// Each child runs in an empty directory so that a token dispatched as a -// subcommand (`bun test`, `bun x`, ...) has nothing to act on. +// Empty cwd: a token dispatched as a subcommand (`bun test`, `bun x`, ...) must find nothing to act on. describe("eval flags take precedence over subcommand names", () => { describe("the code is a subcommand name", () => { const cases: string[][] = [