diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 9e1eea0d22..a365261e32 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -1538,7 +1538,7 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result commands::delegate::execute(cwd, "fmt", &args).await, - Commands::Run { args } => commands::delegate::execute(cwd, "run", &args).await, + Commands::Run { args } => commands::run_or_delegate::execute(cwd, &args).await, Commands::Preview { args } => commands::delegate::execute(cwd, "preview", &args).await, diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 6d6bf0ad46..66755176c7 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -23,12 +23,50 @@ //! Category C - Local CLI Delegation: //! - `delegate`: Local CLI delegation +use std::{collections::HashMap, io::BufReader}; + use vite_install::package_manager::PackageManager; use vite_path::AbsolutePath; use vite_shared::{PrependOptions, prepend_to_path_env}; use crate::{error::Error, js_executor::JsExecutor}; +#[derive(serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct DepCheckPackageJson { + #[serde(default)] + dependencies: HashMap, + #[serde(default)] + dev_dependencies: HashMap, +} + +/// Check if vite-plus is listed in the nearest package.json's +/// dependencies or devDependencies. +/// +/// Returns `true` if vite-plus is found, `false` if not found +/// or if no package.json exists. +pub fn has_vite_plus_dependency(cwd: &AbsolutePath) -> bool { + let mut current = cwd; + loop { + let package_json_path = current.join("package.json"); + if package_json_path.as_path().exists() { + if let Ok(file) = std::fs::File::open(&package_json_path) { + if let Ok(pkg) = + serde_json::from_reader::<_, DepCheckPackageJson>(BufReader::new(file)) + { + return pkg.dependencies.contains_key("vite-plus") + || pkg.dev_dependencies.contains_key("vite-plus"); + } + } + return false; // Found package.json but couldn't parse deps → treat as no dependency + } + match current.parent() { + Some(parent) if parent != current => current = parent, + _ => return false, // Reached filesystem root + } + } +} + /// Ensure a package.json exists in the given directory. /// If it doesn't exist, create a minimal one with `{ "type": "module" }`. pub async fn ensure_package_json(project_path: &AbsolutePath) -> Result<(), Error> { @@ -106,6 +144,7 @@ pub mod self_update; // Category C: Local CLI Delegation pub mod delegate; +pub mod run_or_delegate; // Re-export command structs for convenient access pub use add::AddCommand; @@ -118,3 +157,99 @@ pub use remove::RemoveCommand; pub use unlink::UnlinkCommand; pub use update::UpdateCommand; pub use why::WhyCommand; + +#[cfg(test)] +mod tests { + use vite_path::AbsolutePathBuf; + + use super::*; + + #[test] + fn test_has_vite_plus_in_dev_dependencies() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + std::fs::write( + temp_path.join("package.json"), + r#"{ "devDependencies": { "vite-plus": "^1.0.0" } }"#, + ) + .unwrap(); + assert!(has_vite_plus_dependency(&temp_path)); + } + + #[test] + fn test_has_vite_plus_in_dependencies() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + std::fs::write( + temp_path.join("package.json"), + r#"{ "dependencies": { "vite-plus": "^1.0.0" } }"#, + ) + .unwrap(); + assert!(has_vite_plus_dependency(&temp_path)); + } + + #[test] + fn test_no_vite_plus_dependency() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + std::fs::write( + temp_path.join("package.json"), + r#"{ "devDependencies": { "vite": "^6.0.0" } }"#, + ) + .unwrap(); + assert!(!has_vite_plus_dependency(&temp_path)); + } + + #[test] + fn test_no_package_json() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + assert!(!has_vite_plus_dependency(&temp_path)); + } + + #[test] + fn test_nested_directory_walks_up() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + std::fs::write( + temp_path.join("package.json"), + r#"{ "devDependencies": { "vite-plus": "^1.0.0" } }"#, + ) + .unwrap(); + let child_dir = temp_path.join("child"); + std::fs::create_dir(&child_dir).unwrap(); + let child_path = AbsolutePathBuf::new(child_dir.as_path().to_path_buf()).unwrap(); + assert!(has_vite_plus_dependency(&child_path)); + } + + #[test] + fn test_empty_package_json() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + std::fs::write(temp_path.join("package.json"), r#"{}"#).unwrap(); + assert!(!has_vite_plus_dependency(&temp_path)); + } + + #[test] + fn test_nested_dir_stops_at_nearest_package_json() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + // Parent has vite-plus + std::fs::write( + temp_path.join("package.json"), + r#"{ "devDependencies": { "vite-plus": "^1.0.0" } }"#, + ) + .unwrap(); + // Child has its own package.json without vite-plus + let child_dir = temp_path.join("child"); + std::fs::create_dir(&child_dir).unwrap(); + std::fs::write( + child_dir.join("package.json"), + r#"{ "devDependencies": { "vite": "^6.0.0" } }"#, + ) + .unwrap(); + let child_path = AbsolutePathBuf::new(child_dir.as_path().to_path_buf()).unwrap(); + // Should find the child's package.json first and return false + assert!(!has_vite_plus_dependency(&child_path)); + } +} diff --git a/crates/vite_global_cli/src/commands/run_or_delegate.rs b/crates/vite_global_cli/src/commands/run_or_delegate.rs new file mode 100644 index 0000000000..334dd9e497 --- /dev/null +++ b/crates/vite_global_cli/src/commands/run_or_delegate.rs @@ -0,0 +1,23 @@ +//! Run command with fallback to package manager when vite-plus is not a dependency. + +use std::process::ExitStatus; + +use vite_path::AbsolutePathBuf; + +use crate::error::Error; + +/// Execute `vp run `. +/// +/// If vite-plus is a dependency, delegate to the local CLI. +/// If not, fall back to ` run `. +pub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result { + if super::has_vite_plus_dependency(&cwd) { + tracing::debug!("vite-plus is a dependency, delegating to local CLI"); + super::delegate::execute(cwd, "run", args).await + } else { + tracing::debug!("vite-plus is not a dependency, falling back to package manager run"); + super::prepend_js_runtime_to_path_env(&cwd).await?; + let package_manager = super::build_package_manager(&cwd).await?; + Ok(package_manager.run_script_command(args, &cwd).await?) + } +} diff --git a/crates/vite_install/src/commands/mod.rs b/crates/vite_install/src/commands/mod.rs index a144870cb5..7b16139d87 100644 --- a/crates/vite_install/src/commands/mod.rs +++ b/crates/vite_install/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod pack; pub mod prune; pub mod publish; pub mod remove; +pub mod run; pub mod unlink; pub mod update; pub mod view; diff --git a/crates/vite_install/src/commands/run.rs b/crates/vite_install/src/commands/run.rs new file mode 100644 index 0000000000..6ebfdd430c --- /dev/null +++ b/crates/vite_install/src/commands/run.rs @@ -0,0 +1,116 @@ +use std::{collections::HashMap, process::ExitStatus}; + +use vite_command::run_command; +use vite_error::Error; +use vite_path::AbsolutePath; + +use crate::package_manager::{ + PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, +}; + +impl PackageManager { + /// Run ` run ` to execute a package.json script. + pub async fn run_script_command( + &self, + args: &[String], + cwd: impl AsRef, + ) -> Result { + let resolve_command = self.resolve_run_script_command(args); + run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd) + .await + } + + /// Resolve the ` run ` command. + #[must_use] + pub fn resolve_run_script_command(&self, args: &[String]) -> ResolveCommandResult { + let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]); + let mut cmd_args: Vec = vec!["run".to_string()]; + cmd_args.extend(args.iter().cloned()); + + let bin_path = match self.client { + PackageManagerType::Pnpm => "pnpm", + PackageManagerType::Npm => "npm", + PackageManagerType::Yarn => "yarn", + }; + + ResolveCommandResult { bin_path: bin_path.to_string(), args: cmd_args, envs } + } +} + +#[cfg(test)] +mod tests { + use tempfile::{TempDir, tempdir}; + use vite_path::AbsolutePathBuf; + use vite_str::Str; + + use super::*; + + fn create_temp_dir() -> TempDir { + tempdir().expect("Failed to create temp directory") + } + + fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let install_dir = temp_dir_path.join("install"); + + PackageManager { + client: pm_type, + package_name: pm_type.to_string().into(), + version: Str::from(version), + hash: None, + bin_name: pm_type.to_string().into(), + workspace_root: temp_dir_path.clone(), + is_monorepo: false, + install_dir, + } + } + + #[test] + fn test_pnpm_run_script() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_run_script_command(&["dev".into()]); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["run", "dev"]); + } + + #[test] + fn test_pnpm_run_script_with_args() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_run_script_command(&["dev".into(), "--port".into(), "3000".into()]); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["run", "dev", "--port", "3000"]); + } + + #[test] + fn test_npm_run_script() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let result = pm.resolve_run_script_command(&["dev".into()]); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["run", "dev"]); + } + + #[test] + fn test_npm_run_script_with_args() { + let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0"); + let result = pm.resolve_run_script_command(&["dev".into(), "--port".into(), "3000".into()]); + assert_eq!(result.bin_path, "npm"); + assert_eq!(result.args, vec!["run", "dev", "--port", "3000"]); + } + + #[test] + fn test_yarn_run_script() { + let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0"); + let result = pm.resolve_run_script_command(&["build".into()]); + assert_eq!(result.bin_path, "yarn"); + assert_eq!(result.args, vec!["run", "build"]); + } + + #[test] + fn test_run_script_no_args() { + let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0"); + let result = pm.resolve_run_script_command(&[]); + assert_eq!(result.bin_path, "pnpm"); + assert_eq!(result.args, vec!["run"]); + } +} diff --git a/packages/global/snap-tests/command-run-without-vite-plus/package.json b/packages/global/snap-tests/command-run-without-vite-plus/package.json new file mode 100644 index 0000000000..164242e8db --- /dev/null +++ b/packages/global/snap-tests/command-run-without-vite-plus/package.json @@ -0,0 +1,9 @@ +{ + "name": "command-run-without-vite-plus", + "version": "1.0.0", + "scripts": { + "hello": "echo hello from script", + "greet": "echo greet" + }, + "packageManager": "pnpm@10.19.0" +} diff --git a/packages/global/snap-tests/command-run-without-vite-plus/snap.txt b/packages/global/snap-tests/command-run-without-vite-plus/snap.txt new file mode 100644 index 0000000000..8fdfed245a --- /dev/null +++ b/packages/global/snap-tests/command-run-without-vite-plus/snap.txt @@ -0,0 +1,18 @@ +> vp run hello # should fall back to pnpm run when no vite-plus dependency + +> command-run-without-vite-plus@ hello +> echo hello from script + +hello from script + +> vp run greet --arg1 value1 # should pass through args to pnpm run + +> command-run-without-vite-plus@ greet +> echo greet --arg1 value1 + +greet --arg1 value1 + +[1]> vp run nonexistent # should show pnpm missing script error + ERR_PNPM_NO_SCRIPT  Missing script: nonexistent + +Command "nonexistent" not found. diff --git a/packages/global/snap-tests/command-run-without-vite-plus/steps.json b/packages/global/snap-tests/command-run-without-vite-plus/steps.json new file mode 100644 index 0000000000..577c449d9c --- /dev/null +++ b/packages/global/snap-tests/command-run-without-vite-plus/steps.json @@ -0,0 +1,10 @@ +{ + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": [ + "vp run hello # should fall back to pnpm run when no vite-plus dependency", + "vp run greet --arg1 value1 # should pass through args to pnpm run", + "vp run nonexistent # should show pnpm missing script error" + ] +} diff --git a/rfcs/run-without-vite-plus-dependency.md b/rfcs/run-without-vite-plus-dependency.md new file mode 100644 index 0000000000..d7157fdb57 --- /dev/null +++ b/rfcs/run-without-vite-plus-dependency.md @@ -0,0 +1,469 @@ +# RFC: `vp run` Without vite-plus Dependency + +## Summary + +Allow `vp run