From ca50ef9aedd579bf6fac64239bae97f9d531e3f5 Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Mon, 20 Jul 2026 05:28:04 +0700 Subject: [PATCH] feat(js): add Bun command support --- README.md | 2 + src/cmds/js/bun_cmd.rs | 126 +++++++++++++++++++++++++++++++++++++++ src/core/tracking.rs | 4 +- src/discover/registry.rs | 41 +++++++++++++ src/discover/rules.rs | 18 ++++++ src/main.rs | 62 ++++++++++++++++++- 6 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 src/cmds/js/bun_cmd.rs diff --git a/README.md b/README.md index 3789143b7f..e35888cd5d 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,8 @@ rtk rubocop # Ruby linting (JSON, -60%+) ### Package Managers ```bash rtk pnpm list # Compact dependency tree +rtk bun run build # Bun scripts (strip banner and command echo) +rtk bunx tsc --noEmit # bunx tools with compact output rtk uv run pytest # Preserve uv env, errors only rtk pip list # Python packages (auto-detect uv) rtk pip outdated # Outdated packages diff --git a/src/cmds/js/bun_cmd.rs b/src/cmds/js/bun_cmd.rs new file mode 100644 index 0000000000..b624b4b807 --- /dev/null +++ b/src/cmds/js/bun_cmd.rs @@ -0,0 +1,126 @@ +//! Filters Bun command output while preserving the Bun/Bunx executable. + +use crate::core::runner; +use crate::core::utils::{resolved_command, strip_ansi}; +use anyhow::Result; + +/// Run `bun ` through the Bun output filter. +pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result { + run_filtered("bun", args, verbose, skip_env) +} + +/// Run `bunx ` through the Bun output filter. +pub fn exec(args: &[String], verbose: u8, skip_env: bool) -> Result { + run_filtered("bunx", args, verbose, skip_env) +} + +fn run_filtered(name: &str, args: &[String], verbose: u8, skip_env: bool) -> Result { + let mut cmd = resolved_command(name); + for arg in args { + cmd.arg(arg); + } + + if skip_env { + cmd.env("SKIP_ENV_VALIDATION", "1"); + } + + let args_display = args.join(" "); + if verbose > 0 { + eprintln!("Running: {} {}", name, args_display); + } + + runner::run_filtered( + cmd, + name, + &args_display, + filter_bun_output, + runner::RunOptions::default(), + ) +} + +/// Remove Bun's version banner and the script command it immediately echoes. +fn filter_bun_output(output: &str) -> String { + let mut result = Vec::new(); + let mut skip_script_echo = false; + + for line in output.lines() { + let clean = strip_ansi(line); + let trimmed = clean.trim_start(); + + if is_bun_banner(trimmed) { + skip_script_echo = trimmed.starts_with("bun run v"); + continue; + } + + if skip_script_echo { + if trimmed.is_empty() { + continue; + } + skip_script_echo = false; + if trimmed.starts_with("$ ") { + continue; + } + } + + if trimmed.is_empty() { + continue; + } + + result.push(line.to_string()); + } + + if result.is_empty() { + "ok".to_string() + } else { + result.join("\n") + } +} + +fn is_bun_banner(line: &str) -> bool { + (line.starts_with("bun run v") || line.starts_with("bun test v")) + && line.contains(|c: char| c.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filters_run_banner_and_immediate_script_echo() { + let output = "bun run v1.2.3 (abc123)\n\n$ tsc --noEmit\nsrc/index.ts(1,1): error TS1234: broken\n"; + + assert_eq!( + filter_bun_output(output), + "src/index.ts(1,1): error TS1234: broken" + ); + } + + #[test] + fn filters_test_banner_but_keeps_test_output() { + let output = "bun test v1.2.3 (abc123)\n\npass tests/example.test.ts\n 1 pass\n"; + + assert_eq!( + filter_bun_output(output), + "pass tests/example.test.ts\n 1 pass" + ); + } + + #[test] + fn keeps_dollar_prefixed_program_output() { + let output = "bun run v1.2.3\n$ node script.js\nstarted\n$ 10.00 total\n"; + + assert_eq!(filter_bun_output(output), "started\n$ 10.00 total"); + } + + #[test] + fn recognizes_ansi_colored_banners() { + let output = "\u{1b}[1mbun run v1.2.3\u{1b}[0m\n\u{1b}[2m$ echo ready\u{1b}[0m\nready\n"; + + assert_eq!(filter_bun_output(output), "ready"); + } + + #[test] + fn returns_ok_for_boilerplate_only_output() { + assert_eq!(filter_bun_output("bun run v1.2.3\n$ true\n"), "ok"); + } +} diff --git a/src/core/tracking.rs b/src/core/tracking.rs index 359e4f6885..df2392f2aa 100644 --- a/src/core/tracking.rs +++ b/src/core/tracking.rs @@ -1203,8 +1203,8 @@ fn categorize_command(rtk_cmd: &str) -> String { match tool { "git" | "gh" | "gt" => "git", "cargo" => "cargo", - "npm" | "npx" | "pnpm" | "vitest" | "tsc" | "lint" | "prettier" | "next" | "playwright" - | "prisma" => "js", + "npm" | "npx" | "pnpm" | "bun" | "bunx" | "vitest" | "tsc" | "lint" | "prettier" + | "next" | "playwright" | "prisma" => "js", "pytest" | "ruff" | "mypy" | "pip" => "python", "go" | "golangci-lint" => "go", "docker" | "kubectl" => "cloud", diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 6a7e9e89ff..ab5724e46d 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -3308,6 +3308,47 @@ mod tests { ); } + #[test] + fn test_rewrite_bun_commands() { + let cases = [ + ( + "bun test src/example.test.ts", + "rtk bun test src/example.test.ts", + ), + ( + "bun --env-file=.env.test test tests/unit", + "rtk bun --env-file=.env.test test tests/unit", + ), + ("bun run tsc --noEmit", "rtk bun run tsc --noEmit"), + ("bun drizzle-kit generate", "rtk bun drizzle-kit generate"), + ("bun", "rtk bun"), + ]; + + for (command, expected) in cases { + assert_eq!( + rewrite_command_no_prefixes(command, &[]), + Some(expected.to_string()), + "Failed for command: {command}" + ); + } + } + + #[test] + fn test_rewrite_bun_preserves_environment_prefix() { + assert_eq!( + rewrite_command("SMS_PROVIDER=console bun test src/module.test.ts", &[], &[]), + Some("SMS_PROVIDER=console rtk bun test src/module.test.ts".to_string()), + ); + } + + #[test] + fn test_rewrite_bunx() { + assert_eq!( + rewrite_command_no_prefixes("bunx tsc --noEmit", &[]), + Some("rtk bunx tsc --noEmit".to_string()), + ); + } + // --- Gradle --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 9de6e45b01..efa26044a9 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -79,6 +79,24 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + pattern: r"^bun(?:\s|$)", + rtk_cmd: "rtk bun", + rewrite_prefixes: &["bun"], + category: "PackageManager", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^bunx\s+", + rtk_cmd: "rtk bunx", + rewrite_prefixes: &["bunx"], + category: "PackageManager", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^(cat|head|tail)\s+", rtk_cmd: "rtk read", diff --git a/src/main.rs b/src/main.rs index 445a599b1e..408e8ff2f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,8 +12,8 @@ use cmds::dotnet::{binlog, dotnet_cmd, dotnet_format_report, dotnet_trx}; use cmds::git::{diff_cmd, gh_cmd, git, glab_cmd, gt_cmd}; use cmds::go::{go_cmd, golangci_cmd}; use cmds::js::{ - lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd, tsc_cmd, - vitest_cmd, + bun_cmd, lint_cmd, next_cmd, npm_cmd, playwright_cmd, pnpm_cmd, prettier_cmd, prisma_cmd, + tsc_cmd, vitest_cmd, }; use cmds::jvm::{gradlew_cmd, mvn_cmd}; use cmds::php::{ecs_cmd, paratest_cmd, pest_cmd, php_cmd, phpstan_cmd, phpunit_cmd, pint_cmd}; @@ -566,6 +566,20 @@ enum Commands { args: Vec, }, + /// Bun commands with compact output + Bun { + /// Bun arguments (subcommand, script, and options) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// bunx commands with compact output + Bunx { + /// bunx arguments (command and options) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Curl with auto-JSON detection and schema output Curl { /// Curl arguments (URL + options) @@ -2320,6 +2334,15 @@ fn run_cli() -> Result { } } + Commands::Bun { args } => bun_cmd::run(&args, cli.verbose, cli.skip_env)?, + + Commands::Bunx { args } => { + if args.is_empty() { + anyhow::bail!("bunx requires a command argument"); + } + bun_cmd::exec(&args, cli.verbose, cli.skip_env)? + } + Commands::Ruff { args } => ruff_cmd::run(&args, cli.verbose)?, Commands::Pytest { args } => pytest_cmd::run(&args, cli.verbose)?, @@ -2709,6 +2732,8 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Cargo { .. } | Commands::Npm { .. } | Commands::Npx { .. } + | Commands::Bun { .. } + | Commands::Bunx { .. } | Commands::Curl { .. } | Commands::Ruff { .. } | Commands::Pytest { .. } @@ -3091,6 +3116,8 @@ mod tests { "cargo", "npm", "npx", + "bun", + "bunx", "curl", "ruff", "pytest", @@ -3537,6 +3564,37 @@ mod tests { } } + #[test] + fn test_bun_commands_preserve_arguments() { + let cases = [ + vec!["rtk", "bun", "test", "src/example.test.ts"], + vec!["rtk", "bun", "--env-file=.env.test", "test", "tests/unit"], + vec!["rtk", "bun", "run", "tsc", "--noEmit"], + vec!["rtk", "bun", "drizzle-kit", "generate"], + ]; + + for case in cases { + let expected = case[2..] + .iter() + .map(|arg| arg.to_string()) + .collect::>(); + let cli = Cli::try_parse_from(case).unwrap(); + match cli.command { + Commands::Bun { args } => assert_eq!(args, expected), + _ => panic!("Expected Commands::Bun"), + } + } + } + + #[test] + fn test_bunx_preserves_arguments() { + let cli = Cli::try_parse_from(["rtk", "bunx", "tsc", "--noEmit"]).unwrap(); + match cli.command { + Commands::Bunx { args } => assert_eq!(args, vec!["tsc", "--noEmit"]), + _ => panic!("Expected Commands::Bunx"), + } + } + #[test] fn test_init_pi_flag_rejected() { // --pi has been removed; --agent pi is the canonical form