diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 9bd01c88ea88..d7f042f5db44 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; }; - while !first_arg_name.is_empty() - && first_arg_name[0] == b'-' - && !(first_arg_name.len() > 1 && first_arg_name[1] == b'e') - { + // 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 // 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");