From e8cabef09099afdc0d9621ee806bcff1163cbdb3 Mon Sep 17 00:00:00 2001 From: insign <1113045+insign@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:53:14 +0000 Subject: [PATCH] feat: add support for Node.js built-in commands - Added `CommandSupport::BuiltIn` to distinguish between scripts and built-in commands. - Updated `NodeValidator` to recognize common npm/yarn/pnpm/bun built-in commands (install, test, start, etc.) even if not present in `scripts`. - Updated `select_runner` to accept runners with built-in command support. - Updated `build_command` to execute built-in commands directly (e.g., `npm install`) instead of via `run` (e.g., `npm run install`), while preserving `run` behavior for scripts and unknown commands. - Added tests to verify built-in command execution and script priority. --- src/detectors/mod.rs | 28 ++++++++------ src/detectors/node.rs | 16 ++++++++ src/runner.rs | 11 +++++- tests/node_builtin_test.rs | 77 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 tests/node_builtin_test.rs diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index fcac6e1..35a7757 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -35,6 +35,8 @@ use std::sync::Arc; pub enum CommandSupport { /// The command is explicitly supported (e.g., found in package.json scripts) Supported, + /// The command is a built-in command of the tool (e.g., "install", "test" when not in scripts) + BuiltIn, /// The command is definitely not supported (e.g., not found in package.json scripts) NotSupported, /// It's unknown if the command is supported (e.g., no manifest parsing implemented) @@ -166,7 +168,7 @@ impl DetectedRunner { } /// Build the command to execute - pub fn build_command(&self, task: &str, extra_args: &[String]) -> Vec { + pub fn build_command(&self, task: &str, extra_args: &[String], working_dir: &Path) -> Vec { // First check if this is a custom command if let Some(commands) = &self.custom_commands { if let Some(cmd_str) = commands.get(task) { @@ -185,10 +187,14 @@ impl DetectedRunner { let mut cmd = match self.name.as_str() { // Node.js ecosystem - "bun" => vec!["bun".to_string(), "run".to_string(), task.to_string()], - "pnpm" => vec!["pnpm".to_string(), "run".to_string(), task.to_string()], - "yarn" => vec!["yarn".to_string(), "run".to_string(), task.to_string()], - "npm" => vec!["npm".to_string(), "run".to_string(), task.to_string()], + "bun" | "pnpm" | "yarn" | "npm" => { + let support = self.validator.supports_command(working_dir, task); + if support == CommandSupport::BuiltIn { + vec![self.name.clone(), task.to_string()] + } else { + vec![self.name.clone(), "run".to_string(), task.to_string()] + } + } // Python ecosystem "uv" => vec!["uv".to_string(), "run".to_string(), task.to_string()], @@ -358,35 +364,35 @@ mod tests { #[test] fn test_build_command_npm() { let runner = DetectedRunner::new("npm", "package.json", Ecosystem::NodeJs, 4); - let cmd = runner.build_command("test", &[]); + let cmd = runner.build_command("test", &[], Path::new(".")); assert_eq!(cmd, vec!["npm", "run", "test"]); } #[test] fn test_build_command_with_args() { let runner = DetectedRunner::new("npm", "package.json", Ecosystem::NodeJs, 4); - let cmd = runner.build_command("test", &["--coverage".to_string()]); + let cmd = runner.build_command("test", &["--coverage".to_string()], Path::new(".")); assert_eq!(cmd, vec!["npm", "run", "test", "--coverage"]); } #[test] fn test_build_command_cargo() { let runner = DetectedRunner::new("cargo", "Cargo.toml", Ecosystem::Rust, 9); - let cmd = runner.build_command("build", &["--release".to_string()]); + let cmd = runner.build_command("build", &["--release".to_string()], Path::new(".")); assert_eq!(cmd, vec!["cargo", "build", "--release"]); } #[test] fn test_build_command_go_path() { let runner = DetectedRunner::new("go", "go.mod", Ecosystem::Go, 12); - let cmd = runner.build_command("./cmd/main.go", &[]); + let cmd = runner.build_command("./cmd/main.go", &[], Path::new(".")); assert_eq!(cmd, vec!["go", "run", "./cmd/main.go"]); } #[test] fn test_build_command_go_task() { let runner = DetectedRunner::new("go", "go.mod", Ecosystem::Go, 12); - let cmd = runner.build_command("build", &[]); + let cmd = runner.build_command("build", &[], Path::new(".")); assert_eq!(cmd, vec!["go", "build"]); } @@ -585,7 +591,7 @@ precommit: build test commands, ); - let cmd = runner.build_command("hello", &[]); + let cmd = runner.build_command("hello", &[], Path::new(".")); assert_eq!(cmd, vec!["echo", "hello world"]); } } diff --git a/src/detectors/node.rs b/src/detectors/node.rs index 1a30fbc..72ca4ab 100644 --- a/src/detectors/node.rs +++ b/src/detectors/node.rs @@ -16,6 +16,14 @@ use std::sync::Arc; pub struct NodeValidator; +// List of built-in commands common to npm, yarn, pnpm, bun +// These should be executed directly, not via "run", unless a script with the same name exists +const BUILTIN_COMMANDS: &[&str] = &[ + "install", "test", "start", "restart", "stop", "publish", "version", "audit", "outdated", + "ci", "clean-install", "init", "link", "list", "login", "logout", "pack", "prune", "search", + "uninstall", "update", "view", "whoami", "add", "remove", "why", "dlx", "create", "exec", +]; + impl CommandValidator for NodeValidator { fn supports_command(&self, working_dir: &Path, command: &str) -> CommandSupport { let package_json = working_dir.join("package.json"); @@ -37,6 +45,14 @@ impl CommandValidator for NodeValidator { if scripts.contains_key(command) { return CommandSupport::Supported; } + } + + if BUILTIN_COMMANDS.contains(&command) { + return CommandSupport::BuiltIn; + } + + // If we have a scripts object but the command is not in it and not a builtin + if json.get("scripts").is_some() { return CommandSupport::NotSupported; } diff --git a/src/runner.rs b/src/runner.rs index 9166862..b72f105 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -195,6 +195,15 @@ pub fn select_runner( } supported_runners.push(runner); } + CommandSupport::BuiltIn => { + if verbose { + output::info(&format!( + "{} has built-in command '{}'", + runner.name, command + )); + } + supported_runners.push(runner); + } CommandSupport::NotSupported => { if verbose { output::info(&format!( @@ -243,7 +252,7 @@ pub fn execute( } // Build the command - let cmd_parts = runner.build_command(task, extra_args); + let cmd_parts = runner.build_command(task, extra_args, working_dir); let cmd_string = cmd_parts.join(" "); if verbose { diff --git a/tests/node_builtin_test.rs b/tests/node_builtin_test.rs new file mode 100644 index 0000000..df3fe56 --- /dev/null +++ b/tests/node_builtin_test.rs @@ -0,0 +1,77 @@ +use std::fs::File; +use std::io::Write; +use tempfile::tempdir; +use run_cli::detectors::{detect_all, CommandSupport}; +use run_cli::runner::{select_runner}; + +#[test] +fn test_npm_install_builtin_works() { + let dir = tempdir().unwrap(); + let mut file = File::create(dir.path().join("package.json")).unwrap(); + // Add a script so supports_command would normally return NotSupported for "install" + writeln!(file, r#"{{"scripts": {{"test": "echo test"}}}}"#).unwrap(); + File::create(dir.path().join("package-lock.json")).unwrap(); + + let runners = detect_all(dir.path(), &[]); + assert_eq!(runners.len(), 1); + let runner = &runners[0]; + assert_eq!(runner.name, "npm"); + + // "install" is a built-in command, so supports_command should return BuiltIn + assert_eq!(runner.supports_command("install", dir.path()), CommandSupport::BuiltIn); + + // select_runner should succeed + let result = select_runner(&runners, "install", dir.path(), false); + assert!(result.is_ok()); + let selected = result.unwrap(); + + // Verify command construction + let cmd = selected.build_command("install", &[], dir.path()); + assert_eq!(cmd, vec!["npm", "install"]); +} + +#[test] +fn test_npm_test_script_priority() { + let dir = tempdir().unwrap(); + let mut file = File::create(dir.path().join("package.json")).unwrap(); + // Add a script named "test" - this should take priority over built-in "test" + writeln!(file, r#"{{"scripts": {{"test": "echo custom test"}}}}"#).unwrap(); + File::create(dir.path().join("package-lock.json")).unwrap(); + + let runners = detect_all(dir.path(), &[]); + let runner = &runners[0]; + + // "test" is in scripts, so supports_command should return Supported + assert_eq!(runner.supports_command("test", dir.path()), CommandSupport::Supported); + + let result = select_runner(&runners, "test", dir.path(), false); + assert!(result.is_ok()); + let selected = result.unwrap(); + + // Verify command construction uses "run" + let cmd = selected.build_command("test", &[], dir.path()); + assert_eq!(cmd, vec!["npm", "run", "test"]); +} + +#[test] +fn test_npm_test_builtin_fallback() { + let dir = tempdir().unwrap(); + let mut file = File::create(dir.path().join("package.json")).unwrap(); + // No scripts + writeln!(file, r#"{{"scripts": {{}}}}"#).unwrap(); + File::create(dir.path().join("package-lock.json")).unwrap(); + + let runners = detect_all(dir.path(), &[]); + let runner = &runners[0]; + + // "test" is not in scripts but is built-in -> BuiltIn + assert_eq!(runner.supports_command("test", dir.path()), CommandSupport::BuiltIn); + + let result = select_runner(&runners, "test", dir.path(), false); + assert!(result.is_ok()); + let selected = result.unwrap(); + + // Verify command construction uses direct execution (npm test) + let cmd = selected.build_command("test", &[], dir.path()); + assert_eq!(cmd, vec!["npm", "test"]); +}