From 208f5cd2a2be0dcc239fb1f440aee1b184ed574a Mon Sep 17 00:00:00 2001 From: Tommy Gilligan Date: Thu, 15 Oct 2026 16:50:05 +1100 Subject: [PATCH] fix(bun): Run real bun for `bun run ` `bun run scripts/export` (a directory with an index.ts, or an extensionless file) has no `./` prefix or JS/TS extension, so it was routed to `aube run`, which treats it as a missing script name. Bun resolves package.json scripts first, then files and directories, so route to real bun when the target exists on disk and is not a script in the nearest package.json. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V3iJyiXGsPx2vmoAat5eKX --- src/lib.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/planner.rs | 6 ++++++ src/planner/bun.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 33bdaa0..4ce864b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -292,6 +292,50 @@ mod tests { } } + #[test] + fn bun_run_existing_relative_path_uses_real_bun() { + let repo = repo_fixture(); + fs::create_dir_all(repo.cwd.join("scripts/release")).unwrap(); + fs::write(repo.cwd.join("scripts/release/index.ts"), "").unwrap(); + fs::write(repo.cwd.join("tool"), "").unwrap(); + fs::write( + repo.cwd.join("package.json"), + r#"{"scripts":{"dev":"next dev"}}"#, + ) + .unwrap(); + + for args in [ + vec!["run", "scripts/release", "1.2.0", "--dry-run"], + vec!["run", "scripts/release"], + vec!["run", "tool"], + ] { + let plan = + plan_for_config(ShimTool::Bun, &os(&args), &Config::default(), &repo.cwd).unwrap(); + + assert_eq!(plan.target, Target::RealBun, "args={args:?}"); + assert_eq!(strings(&plan.args), args); + } + } + + #[test] + fn bun_run_script_or_missing_path_stays_on_aube() { + let repo = repo_fixture(); + fs::create_dir_all(repo.cwd.join("dev")).unwrap(); + fs::write( + repo.cwd.join("package.json"), + r#"{"scripts":{"dev":"next dev"}}"#, + ) + .unwrap(); + + for args in [vec!["run", "dev"], vec!["run", "scripts/missing"]] { + let plan = + plan_for_config(ShimTool::Bun, &os(&args), &Config::default(), &repo.cwd).unwrap(); + + assert_eq!(plan.target, Target::Aube, "args={args:?}"); + assert_eq!(strings(&plan.args), args); + } + } + #[test] fn npm_prefix_with_package_json_stays_on_aube() { let repo = repo_fixture(); diff --git a/src/planner.rs b/src/planner.rs index 8ca2a4d..8fe90d9 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -78,6 +78,12 @@ pub fn plan_for_config( args: args.to_vec(), }); } + if tool == ShimTool::Bun && bun::run_target_is_local_path(args, cwd) { + return Ok(Plan { + target: Target::RealBun, + args: args.to_vec(), + }); + } let packages = global_package_names_for_resolution(tool, args); let global_backend = resolve_backend(config.global_packages, &packages)?; return plan_for_global_packages(tool, args, global_backend); diff --git a/src/planner/bun.rs b/src/planner/bun.rs index 1cc0b99..f52a7cc 100644 --- a/src/planner/bun.rs +++ b/src/planner/bun.rs @@ -380,6 +380,50 @@ fn is_bun_runtime_flag(arg: &str) -> bool { ) || matches!(arg, "-b" | "-e" | "-i" | "-p" | "-r") } +// `bun run scripts/export` (a dir with index.ts) has no `./` or extension, so +// only the filesystem can tell it from a script name; package.json scripts win. +pub(super) fn run_target_is_local_path(args: &[OsString], cwd: &Path) -> bool { + let Some(command_idx) = command_index(args) else { + return false; + }; + let command = args[command_idx].to_string_lossy().to_ascii_lowercase(); + if normalize_bun_command(&command) != Some("run") { + return false; + } + let Some(target) = run_target(&args[command_idx + 1..]) else { + return false; + }; + cwd.join(&target).exists() && !is_package_script(&target, cwd) +} + +fn run_target(rest: &[OsString]) -> Option { + for arg in rest { + let arg = arg.to_string_lossy(); + if arg == "--" { + return None; + } + if !arg.starts_with('-') { + return Some(arg.into_owned()); + } + } + None +} + +fn is_package_script(name: &str, cwd: &Path) -> bool { + let Some(manifest) = cwd + .ancestors() + .map(|dir| dir.join("package.json")) + .find(|path| path.is_file()) + else { + return false; + }; + let Ok(bytes) = std::fs::read(manifest) else { + return false; + }; + serde_json::from_slice::(&bytes) + .is_ok_and(|value| value.get("scripts").and_then(|s| s.get(name)).is_some()) +} + fn looks_like_file_entrypoint(arg: &str) -> bool { arg == "-" || arg.starts_with("./")