From c0d49ec645d74fd2f06d586a5a3696161ca2a2c6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:28:39 +0000 Subject: [PATCH 1/4] cli: stop the subcommand search at a lone "-" so `bun - ` reads stdin Command::which() steps over leading flags to find the subcommand name and treated "-" as one of them, so the argument after it was dispatched as the command: `bun - help` printed help, `bun - add x` ran `bun add x`, and `bun - run x` dropped "run" from process.argv. A lone "-" is the stdin entry point, so the search ends there and the invocation is AutoCommand, as it already is for `bun run - `. --- src/runtime/cli/mod.rs | 9 +++++---- test/cli/run/run-eval.test.ts | 38 ++++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 9bd01c88ea88..49478701fa10 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -937,10 +937,11 @@ 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') - { + // Step over leading flags to reach the subcommand name. A lone `-` is not a + // flag: it is the stdin entry point (`bun - `, as in `node -`), so it + // ends the search like `-e` does. Otherwise the argument after it would be + // taken as the subcommand and `bun - add x` would run `bun add x`. + while first_arg_name.len() > 1 && first_arg_name[0] == b'-' && first_arg_name[1] != b'e' { // `--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. diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 0f928a7ce6a7..0a177b55c9c1 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"; @@ -276,6 +276,42 @@ describe("echo | bun run -", () => { group(run); }); +// `-` is the stdin entry point, not a flag: whatever follows it belongs to the +// script (`node - help` runs stdin with argv ["-", "help"]). The subcommand +// lookup used to skip the `-` and dispatch on the next argument instead, so +// `bun - help` printed bun's help and `bun - add x` ran `bun add x`. +describe("bun - runs stdin even when an arg is a subcommand name", () => { + const cases: { flags: string[]; args: string[] }[] = [ + { flags: [], args: ["help"] }, + { flags: [], args: ["add", "--help"] }, + { flags: [], args: ["test"] }, + // Landed on `bun run`, which happened to read stdin but dropped "run" from argv. + { flags: [], args: ["run", "x"] }, + // The `-` is reached after stepping over a real flag. + { flags: ["--silent"], args: ["upgrade", "--help"] }, + ]; + + for (const { flags, args } of cases) { + test.concurrent(["bun", ...flags, "-", ...args].join(" "), async () => { + // An empty cwd: if the args are dispatched as a subcommand instead, it + // must not find a project (or test files) to act on. + using dir = tempDir("bun-dash-stdin", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...flags, "-", ...args], + cwd: String(dir), + env: bunEnv, + stdin: Buffer.from("console.log(JSON.stringify(process.argv.slice(1)))"), + 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(["-", ...args]) + "\n"); + expect(exitCode).toBe(0); + }); + } +}); + test("process._eval (undefined for normal run)", async () => { const cwd = tmpdirSync(); const file = join(cwd, "test.js"); From c9f2af47f551d505e12d0f7386b89d5bf27009ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:15:21 +0000 Subject: [PATCH 2/4] ci: retrigger From 530bb5007282c514a7a0b6d88529177e5e5c0deb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:11:02 +0000 Subject: [PATCH 3/4] cli: shorten the comment on the subcommand skip loop --- src/runtime/cli/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 49478701fa10..0faf17d131bc 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -937,10 +937,8 @@ pub mod command { let Some(mut first_arg_name) = iter.next() else { return Tag::AutoCommand; }; - // Step over leading flags to reach the subcommand name. A lone `-` is not a - // flag: it is the stdin entry point (`bun - `, as in `node -`), so it - // ends the search like `-e` does. Otherwise the argument after it would be - // taken as the subcommand and `bun - add x` would run `bun add x`. + // A lone `-` (the stdin script) ends the search like `-e` does: what follows + // it belongs to the script, not to the subcommand lookup. while first_arg_name.len() > 1 && first_arg_name[0] == b'-' && first_arg_name[1] != b'e' { // `--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 From a7f3b420dd36911988a59b4304d0a76363c037ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:13:55 +0000 Subject: [PATCH 4/4] cli: one-line comment on the subcommand skip loop --- src/runtime/cli/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 0faf17d131bc..d7f042f5db44 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -937,8 +937,7 @@ pub mod command { let Some(mut first_arg_name) = iter.next() else { return Tag::AutoCommand; }; - // A lone `-` (the stdin script) ends the search like `-e` does: what follows - // it belongs to the script, not to the subcommand lookup. + // A lone `-` (stdin script) ends the search like `-e`: the rest of argv is the script's. while first_arg_name.len() > 1 && first_arg_name[0] == b'-' && first_arg_name[1] != b'e' { // `--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