Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 28 additions & 23 deletions src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
static SUBCOMMAND_ARGV_INDEX: core::sync::atomic::AtomicUsize =
core::sync::atomic::AtomicUsize::new(1);

bun_core::declare_scope!(CLI, hidden);

pub(crate) type LoaderColonList =
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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;
};
Expand All @@ -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);
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
}

Expand Down
28 changes: 11 additions & 17 deletions src/runtime/cli/pm_trusted_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
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();
}

Expand All @@ -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.
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}

Expand Down
7 changes: 4 additions & 3 deletions src/runtime/cli/upgrade_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
"<r><red>error<r><d>:<r> This command updates Bun itself, and does not take package names.\n<blue>note<r><d>:<r> 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.");
Expand Down
26 changes: 26 additions & 0 deletions test/cli/init/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

test("bun init falls back to --yes when stdin is not a TTY", async () => {
await using temp = tempDir("bun-init-no-tty", {});

Expand Down
24 changes: 24 additions & 0 deletions test/cli/install/bun-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
Expand Down
53 changes: 53 additions & 0 deletions test/cli/install/bun-pm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -936,3 +936,56 @@ 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 });
Comment thread
robobun marked this conversation as resolved.
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 };
{
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, proc.stdout.text()]);
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, 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");
expect(exitCode).toBe(1);
}
});
35 changes: 35 additions & 0 deletions test/cli/install/bun-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment thread
claude[bot] marked this conversation as resolved.
});

it("completes against a locally-served release with the system temp dir held open without FILE_SHARE_DELETE", async () => {
Expand Down
Loading