Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0213e0b
run: shim npm/npx alongside node in the --bun PATH dir
robobun Jul 24, 2026
0ea0f32
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 24, 2026
478b40e
address review: fallback-only npm shim, map npm exec/init, translate …
robobun Jul 25, 2026
9b0a189
npm shim: pass publish/version flags through, map npm version
robobun Jul 25, 2026
84fe62f
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 25, 2026
cef8bf7
npm shim: filter npx config flags, map npm pack, fix hoisted flag pla…
robobun Jul 25, 2026
3d0099e
npm shim: map upgrade/cache/c, apply keep list before the subcommand,…
robobun Jul 25, 2026
f525298
npm shim: delete stale Windows npm/npx hardlinks, decide init vs crea…
robobun Jul 25, 2026
de1eaca
npm shim: rename stale Windows shim links when deletion is denied
robobun Jul 25, 2026
f72a0ea
npm shim: ignore own shim in the PATH probe, gate --workspace rename …
robobun Jul 25, 2026
e7dc0f9
npm shim: map the subcommand after a leading --, gate the stale-link …
robobun Jul 25, 2026
24b83df
npm shim: handle short save aliases, missing config flags, and create…
robobun Jul 25, 2026
f5164f9
npm shim: remove comment blocks flagged by review
robobun Jul 25, 2026
16e9d8b
npm shim: drop remaining flagged doc comments
robobun Jul 25, 2026
2105f8b
npm shim: pass npx --package and its value through the flag filter
robobun Jul 25, 2026
a2666db
npm shim: hoist npm config flags appearing after the script name
robobun Jul 25, 2026
ec433e1
npm shim: consume unknown config flag values in the run mapping
robobun Jul 25, 2026
ed8da95
npm shim: do not pair boolean npm flags with the following token
robobun Jul 25, 2026
466c7fa
npm shim: drop flagged comment
robobun Jul 25, 2026
fe7751a
npm shim: cover remaining npm booleans, pre-subcommand flags, and ven…
robobun Jul 25, 2026
4de95c8
npm shim: drain pre-subcommand flags for bunx/pm mappings, drop colli…
robobun Jul 25, 2026
ada11e3
npm shim: filter the bunx-mapped tail too
robobun Jul 25, 2026
564ecc1
npm shim: close the -dd/-l/-m shorthand gaps
robobun Jul 25, 2026
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
19 changes: 17 additions & 2 deletions src/install/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,16 @@ impl RunCommand {
// SAFETY: literal ends in NUL; len excludes it.
ZStr::from_static(B)
};
const NPM_LINK: &ZStr = {
const B: &[u8] = concatcp!(RunCommand::BUN_NODE_DIR, "/npm\0").as_bytes();
// SAFETY: literal ends in NUL; len excludes it.
ZStr::from_static(B)
};
const NPX_LINK: &ZStr = {
const B: &[u8] = concatcp!(RunCommand::BUN_NODE_DIR, "/npx\0").as_bytes();
// SAFETY: literal ends in NUL; len excludes it.
ZStr::from_static(B)
};
const DIR_Z: &ZStr = {
const B: &[u8] = concatcp!(RunCommand::BUN_NODE_DIR, "\0").as_bytes();
// SAFETY: literal ends in NUL; len excludes it.
Expand All @@ -616,7 +626,7 @@ impl RunCommand {
Err(_) => return Ok(()),
}

for dest in [NODE_LINK, BUN_LINK] {
for dest in [NODE_LINK, BUN_LINK, NPM_LINK, NPX_LINK] {
let mut replaced = false;
loop {
match bun_sys::symlink(argv0_z, dest) {
Expand Down Expand Up @@ -721,7 +731,12 @@ impl RunCommand {
}

let image_path = win::exe_path_w();
for name in [strings::w!("\\node.exe\0"), strings::w!("\\bun.exe\0")] {
for name in [
strings::w!("\\node.exe\0"),
strings::w!("\\bun.exe\0"),
strings::w!("\\npm.exe\0"),
strings::w!("\\npx.exe\0"),
] {
target_path_buffer[dir_slice_len..][..name.len()].copy_from_slice(name);
// `target_path_buffer` is mutated in place between FFI calls
// (the dir-NUL/backslash toggle below).
Expand Down
269 changes: 235 additions & 34 deletions src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,178 @@
}
}

// `is_npm`/`is_npx` compare the basename exactly so `pnpm`/`pnpx` (and
// anything else ending in "npm"/"npx") are not caught.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) fn is_npm(argv0: &[u8]) -> bool {
let base = bun_paths::basename(argv0);
base == b"npm" || (cfg!(windows) && base == b"npm.exe")
}

pub(crate) fn is_npx(argv0: &[u8]) -> bool {
let base = bun_paths::basename(argv0);
base == b"npx" || (cfg!(windows) && base == b"npx.exe")
}

/// npm long options that take a separate value argument and have no bun
/// equivalent. When bun is invoked as `npm` via the `--bun` shim these are
/// dropped together with their value so the value is not mistaken for a
/// positional (e.g. `npm install --loglevel error react` must not try to
/// install a package called `error`). Kept sorted for `binary_search`.
const NPM_VALUE_FLAGS_IGNORED: &[&[u8]] = &[
b"access",
Comment thread
robobun marked this conversation as resolved.
Resolved
b"before",
b"cache",
b"depth",
b"editor",
b"fetch-retries",
b"fetch-retry-factor",
b"fetch-retry-maxtimeout",
b"fetch-retry-mintimeout",
b"fetch-timeout",
b"globalconfig",
b"init-author-email",
b"init-author-name",
b"init-author-url",
b"init-license",
b"init-module",
b"init-version",
b"location",
b"loglevel",
b"logs-dir",
b"logs-max",
b"maxsockets",
b"message",
b"node-options",
b"otp",
b"pack-destination",
b"prefix",

Check warning on line 894 in src/runtime/cli/mod.rs

View check run for this annotation

Claude / Claude Code Review

npm --prefix / --pack-destination / --workspace need translation, not just drop or pass-through

Related to the `tag/access/otp/message` comment above but with a different fix shape: `prefix`, `pack-destination`, and `workspace` also have bun equivalents, but bun spells them differently (`--cwd`, `--destination`, `--filter`), so simply removing them from the drop list does *not* work — `npm --prefix ./client install` would then pass `./client` as a positional and try to `bun add ./client` into the current project. As-is, dropping them means `npm --prefix ./client install` silently runs `bun
Comment thread
robobun marked this conversation as resolved.
Resolved
b"script-shell",
b"searchexclude",
b"searchlimit",
b"searchopts",
b"searchstaleness",
b"tag",
b"userconfig",
b"viewer",
b"workspace",
];

#[cold]
fn is_npm_ignored_value_flag(arg: &[u8]) -> bool {
if let Some(name) = arg.strip_prefix(b"--") {
let name = match strings::index_of_char(name, b'=') {
Some(eq) => &name[..eq as usize],
None => name,
};
return NPM_VALUE_FLAGS_IGNORED.binary_search(&name).is_ok();
}
arg == b"-w" || arg.starts_with(b"-w=")
}
Comment thread
robobun marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved

/// Rewrite argv when bun is invoked as `npm` (via the `--bun` shim dir) so
/// the rest of the CLI can dispatch it as a normal bun invocation.
/// Best-effort: subcommands bun has no equivalent for fall through
/// unchanged.
///
/// # Safety
/// Single-threaded CLI startup; stores into the process-global argv slot.
Comment thread
robobun marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved
#[cold]
#[inline(never)]
unsafe fn translate_npm_argv() {
use bun_core::{ZStr, zstr};
debug_assert!(
NPM_VALUE_FLAGS_IGNORED.is_sorted(),
"NPM_VALUE_FLAGS_IGNORED must be sorted for binary_search"
);

let argv = bun::argv().as_slice();
let mut out: Vec<&'static ZStr> = Vec::with_capacity(argv.len() + 1);
let Some(&argv0) = argv.first() else { return };
out.push(argv0);

// Copy argv[from..] into `out`, dropping npm-only value-taking flags
// (and their separate value). Stops at the first positional when
// `stop_at_positional` is set and returns its index; otherwise copies
// through to the end, returning `argv.len()`. A bare `--` ends flag
// processing either way.
let copy_dropping_npm_flags =
|out: &mut Vec<&'static ZStr>, from: usize, stop_at_positional: bool| -> usize {
let mut i = from;
while i < argv.len() {
let a = argv[i];
let ab = a.as_bytes();
if ab == b"--" {
if !stop_at_positional {
out.extend_from_slice(&argv[i..]);
}
return i;
}
if ab.first() != Some(&b'-') {
if stop_at_positional {
return i;
}
out.push(a);
i += 1;
continue;
}
if is_npm_ignored_value_flag(ab) {
let consumes_next = !strings::contains_char(ab, b'=')
&& argv
.get(i + 1)
.is_some_and(|n| n.as_bytes().first() != Some(&b'-'));
i += if consumes_next { 2 } else { 1 };
continue;
}
out.push(a);
i += 1;
}
argv.len()
};

let sub_idx = copy_dropping_npm_flags(&mut out, 1, true);
let subcommand = argv.get(sub_idx).filter(|a| a.as_bytes() != b"--");
let rest_start = sub_idx + usize::from(sub_idx < argv.len());
Comment thread
robobun marked this conversation as resolved.
Resolved

match subcommand.map(|z| z.as_bytes()) {
// npm's lifecycle shortcuts run the package.json script; map to
// `bun run <name>` so `npm test` does not invoke bun's test runner.
Comment thread
robobun marked this conversation as resolved.
Resolved
Some(b"test" | b"t" | b"tst") => {
out.push(zstr!("run"));
out.push(zstr!("test"));
}
Some(b"start") => {
out.push(zstr!("run"));
out.push(zstr!("start"));
}
Some(b"stop") => {
out.push(zstr!("run"));
out.push(zstr!("stop"));
}
Some(b"restart") => {
out.push(zstr!("run"));
out.push(zstr!("restart"));
}
Some(b"run-script" | b"rum" | b"urn") => out.push(zstr!("run")),
Some(b"clean-install" | b"ic" | b"install-clean" | b"isntall-clean") => {
out.push(zstr!("ci"));
}
Some(b"i" | b"isntall" | b"in" | b"ins" | b"inst" | b"insta" | b"instal") => {
out.push(zstr!("install"));
}
Some(b"ls" | b"la" | b"ll") => out.push(zstr!("list")),
Some(b"view" | b"show") => out.push(zstr!("info")),
Some(_) => out.push(*subcommand.unwrap()),
Comment thread
robobun marked this conversation as resolved.
Resolved
Comment thread
robobun marked this conversation as resolved.
Resolved
None => {}
}

copy_dropping_npm_flags(&mut out, rest_start, false);

static SLOT: std::sync::OnceLock<Box<[&'static ZStr]>> = std::sync::OnceLock::new();
let stored = SLOT.get_or_init(move || out.into_boxed_slice());
// SAFETY: per fn contract — single-threaded startup.
unsafe { bun::set_argv(stored) };
}

/// Cheap argv prescan for the dominant `bun <path>` / `bun .` shape.
///
/// `which()` classifies any first positional that isn't one of the ~40
Expand Down Expand Up @@ -943,8 +1115,35 @@
return Tag::RunAsNodeCommand;
}

if is_npx(argv0) {
// SAFETY: single-threaded startup
IS_BUNX_EXE.store(true, core::sync::atomic::Ordering::Relaxed);
return Tag::BunxCommand;
}

let as_npm = is_npm(argv0);
let (argv, mut iter) = if as_npm {
bun_clap::streaming::WARN_ON_UNRECOGNIZED_FLAG
.store(false, core::sync::atomic::Ordering::Relaxed);
// SAFETY: single-threaded startup; rewrites the process-global argv
// view. Fall through to the normal subcommand matcher on the
// rewritten argv so `npm install` / `npm run` / etc. dispatch to
// the same Tag a direct `bun` invocation would.
unsafe { translate_npm_argv() };
let argv = bun::argv();
let mut iter = argv.iter();
let _ = iter.next();
(argv, iter)
} else {
(argv, iter)
};

let Some(mut first_arg_name) = iter.next() else {
return Tag::AutoCommand;
return if as_npm {
Tag::HelpCommand
} else {
Tag::AutoCommand
};
};
while !first_arg_name.is_empty()
&& first_arg_name[0] == b'-'
Expand Down Expand Up @@ -1232,7 +1431,7 @@
{
let argv = bun::argv();
let argv0 = argv.get(0).map(bun_core::ZStr::as_bytes).unwrap_or(b"");
if !is_node(argv0) && !is_bun_x(argv0) {
if !is_node(argv0) && !is_bun_x(argv0) && !is_npx(argv0) {
if argv.len() == 2 {
match argv.get(1).map(bun_core::ZStr::as_bytes) {
Some(b"-v" | b"--version") => print_version_and_exit(),
Expand All @@ -1241,40 +1440,42 @@
}
}

let empty_eval = match argv.len() {
2 => matches!(
argv.get(1).map(bun_core::ZStr::as_bytes),
Some(b"-e=" | b"-p=" | b"--eval=" | b"--print=")
),
3 => {
argv.get(2).is_some_and(|a| a.as_bytes().is_empty())
&& matches!(
argv.get(1).map(bun_core::ZStr::as_bytes),
Some(b"-e" | b"-p" | b"--eval" | b"--print")
)
if !is_npm(argv0) {
let empty_eval = match argv.len() {
2 => matches!(
argv.get(1).map(bun_core::ZStr::as_bytes),
Some(b"-e=" | b"-p=" | b"--eval=" | b"--print=")
),
3 => {
argv.get(2).is_some_and(|a| a.as_bytes().is_empty())
&& matches!(
argv.get(1).map(bun_core::ZStr::as_bytes),
Some(b"-e" | b"-p" | b"--eval" | b"--print")
)
}
_ => false,
};
if empty_eval {
Output::flush();
return HelpCommand::exec();
}
_ => false,
};
if empty_eval {
Output::flush();
return HelpCommand::exec();
}

// `bun <path>` / `bun .` — the dominant run shape. argv[1] is
// path-shaped (`looks_like_run_entrypoint`), which no
// subcommand keyword can be, so `which()` would unambiguously
// return `Tag::AutoCommand`; short-circuit straight to that
// arm so a plain `bun <file>` never decodes the subcommand
// classifier (`which()` + its `RootCommandMatcher` keyword
// table / rodata) or walks the per-tag dispatch `match` below.
// Dispatches to exactly the arm `which()` would have selected,
// so config loading / arg parsing / passthrough are unchanged.
if argv
.get(1)
.map(bun_core::ZStr::as_bytes)
.is_some_and(looks_like_run_entrypoint)
{
return exec_auto_or_run(Tag::AutoCommand, log);
// `bun <path>` / `bun .` — the dominant run shape. argv[1] is
// path-shaped (`looks_like_run_entrypoint`), which no
// subcommand keyword can be, so `which()` would unambiguously
// return `Tag::AutoCommand`; short-circuit straight to that
// arm so a plain `bun <file>` never decodes the subcommand
// classifier (`which()` + its `RootCommandMatcher` keyword
// table / rodata) or walks the per-tag dispatch `match` below.
// Dispatches to exactly the arm `which()` would have selected,
// so config loading / arg parsing / passthrough are unchanged.
Comment thread
robobun marked this conversation as resolved.
Resolved
if argv
.get(1)
.map(bun_core::ZStr::as_bytes)
.is_some_and(looks_like_run_entrypoint)
{
return exec_auto_or_run(Tag::AutoCommand, log);
}
}
}
}
Expand Down
Loading
Loading