Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion crates/vite_global_cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1538,7 +1538,7 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result<ExitStatus,

Commands::Fmt { args } => 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,

Expand Down
135 changes: 135 additions & 0 deletions crates/vite_global_cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, serde_json::Value>,
#[serde(default)]
dev_dependencies: HashMap<String, serde_json::Value>,
}

/// 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> {
Expand Down Expand Up @@ -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;
Expand All @@ -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));
}
}
23 changes: 23 additions & 0 deletions crates/vite_global_cli/src/commands/run_or_delegate.rs
Original file line number Diff line number Diff line change
@@ -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 <args>`.
///
/// If vite-plus is a dependency, delegate to the local CLI.
/// If not, fall back to `<pm> run <args>`.
pub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result<ExitStatus, Error> {
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?)
}
}
1 change: 1 addition & 0 deletions crates/vite_install/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
116 changes: 116 additions & 0 deletions crates/vite_install/src/commands/run.rs
Original file line number Diff line number Diff line change
@@ -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 `<pm> run <args>` to execute a package.json script.
pub async fn run_script_command(
&self,
args: &[String],
cwd: impl AsRef<AbsolutePath>,
) -> Result<ExitStatus, Error> {
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 `<pm> run <args>` 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<String> = 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"]);
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
18 changes: 18 additions & 0 deletions packages/global/snap-tests/command-run-without-vite-plus/snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
> vp run hello # should fall back to pnpm run when no vite-plus dependency

> command-run-without-vite-plus@<semver> hello <cwd>
> echo hello from script

hello from script

> vp run greet --arg1 value1 # should pass through args to pnpm run

> command-run-without-vite-plus@<semver> greet <cwd>
> 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.
Original file line number Diff line number Diff line change
@@ -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"
]
}
Loading
Loading