Skip to content

Commit 8a22e14

Browse files
authored
feat(cli): fall back to package manager for vp run without vite-plus dependency (#549)
When `vp run <script>` is executed in a project without `vite-plus` in dependencies or devDependencies, fall back to `<pm> run <script>` directly from the Rust layer instead of entering the JS delegation flow. This avoids unnecessary overhead of downloading Node.js runtime and prompting users to install vite-plus. - Add `has_vite_plus_dependency()` utility that walks up from cwd to find the nearest package.json and checks for vite-plus - Add `run_script_command()` / `resolve_run_script_command()` on PackageManager for executing `<pm> run <args>` - Add `run_or_delegate` module that checks dependency before routing - Add unit tests for dependency detection and command resolution - Add snap test verifying fallback behavior with pnpm
1 parent d44848f commit 8a22e14

9 files changed

Lines changed: 782 additions & 1 deletion

File tree

crates/vite_global_cli/src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1538,7 +1538,7 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result<ExitStatus,
15381538

15391539
Commands::Fmt { args } => commands::delegate::execute(cwd, "fmt", &args).await,
15401540

1541-
Commands::Run { args } => commands::delegate::execute(cwd, "run", &args).await,
1541+
Commands::Run { args } => commands::run_or_delegate::execute(cwd, &args).await,
15421542

15431543
Commands::Preview { args } => commands::delegate::execute(cwd, "preview", &args).await,
15441544

crates/vite_global_cli/src/commands/mod.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,50 @@
2323
//! Category C - Local CLI Delegation:
2424
//! - `delegate`: Local CLI delegation
2525
26+
use std::{collections::HashMap, io::BufReader};
27+
2628
use vite_install::package_manager::PackageManager;
2729
use vite_path::AbsolutePath;
2830
use vite_shared::{PrependOptions, prepend_to_path_env};
2931

3032
use crate::{error::Error, js_executor::JsExecutor};
3133

34+
#[derive(serde::Deserialize, Default)]
35+
#[serde(rename_all = "camelCase")]
36+
struct DepCheckPackageJson {
37+
#[serde(default)]
38+
dependencies: HashMap<String, serde_json::Value>,
39+
#[serde(default)]
40+
dev_dependencies: HashMap<String, serde_json::Value>,
41+
}
42+
43+
/// Check if vite-plus is listed in the nearest package.json's
44+
/// dependencies or devDependencies.
45+
///
46+
/// Returns `true` if vite-plus is found, `false` if not found
47+
/// or if no package.json exists.
48+
pub fn has_vite_plus_dependency(cwd: &AbsolutePath) -> bool {
49+
let mut current = cwd;
50+
loop {
51+
let package_json_path = current.join("package.json");
52+
if package_json_path.as_path().exists() {
53+
if let Ok(file) = std::fs::File::open(&package_json_path) {
54+
if let Ok(pkg) =
55+
serde_json::from_reader::<_, DepCheckPackageJson>(BufReader::new(file))
56+
{
57+
return pkg.dependencies.contains_key("vite-plus")
58+
|| pkg.dev_dependencies.contains_key("vite-plus");
59+
}
60+
}
61+
return false; // Found package.json but couldn't parse deps → treat as no dependency
62+
}
63+
match current.parent() {
64+
Some(parent) if parent != current => current = parent,
65+
_ => return false, // Reached filesystem root
66+
}
67+
}
68+
}
69+
3270
/// Ensure a package.json exists in the given directory.
3371
/// If it doesn't exist, create a minimal one with `{ "type": "module" }`.
3472
pub async fn ensure_package_json(project_path: &AbsolutePath) -> Result<(), Error> {
@@ -106,6 +144,7 @@ pub mod self_update;
106144

107145
// Category C: Local CLI Delegation
108146
pub mod delegate;
147+
pub mod run_or_delegate;
109148

110149
// Re-export command structs for convenient access
111150
pub use add::AddCommand;
@@ -118,3 +157,99 @@ pub use remove::RemoveCommand;
118157
pub use unlink::UnlinkCommand;
119158
pub use update::UpdateCommand;
120159
pub use why::WhyCommand;
160+
161+
#[cfg(test)]
162+
mod tests {
163+
use vite_path::AbsolutePathBuf;
164+
165+
use super::*;
166+
167+
#[test]
168+
fn test_has_vite_plus_in_dev_dependencies() {
169+
let temp_dir = tempfile::tempdir().unwrap();
170+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
171+
std::fs::write(
172+
temp_path.join("package.json"),
173+
r#"{ "devDependencies": { "vite-plus": "^1.0.0" } }"#,
174+
)
175+
.unwrap();
176+
assert!(has_vite_plus_dependency(&temp_path));
177+
}
178+
179+
#[test]
180+
fn test_has_vite_plus_in_dependencies() {
181+
let temp_dir = tempfile::tempdir().unwrap();
182+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
183+
std::fs::write(
184+
temp_path.join("package.json"),
185+
r#"{ "dependencies": { "vite-plus": "^1.0.0" } }"#,
186+
)
187+
.unwrap();
188+
assert!(has_vite_plus_dependency(&temp_path));
189+
}
190+
191+
#[test]
192+
fn test_no_vite_plus_dependency() {
193+
let temp_dir = tempfile::tempdir().unwrap();
194+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
195+
std::fs::write(
196+
temp_path.join("package.json"),
197+
r#"{ "devDependencies": { "vite": "^6.0.0" } }"#,
198+
)
199+
.unwrap();
200+
assert!(!has_vite_plus_dependency(&temp_path));
201+
}
202+
203+
#[test]
204+
fn test_no_package_json() {
205+
let temp_dir = tempfile::tempdir().unwrap();
206+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
207+
assert!(!has_vite_plus_dependency(&temp_path));
208+
}
209+
210+
#[test]
211+
fn test_nested_directory_walks_up() {
212+
let temp_dir = tempfile::tempdir().unwrap();
213+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
214+
std::fs::write(
215+
temp_path.join("package.json"),
216+
r#"{ "devDependencies": { "vite-plus": "^1.0.0" } }"#,
217+
)
218+
.unwrap();
219+
let child_dir = temp_path.join("child");
220+
std::fs::create_dir(&child_dir).unwrap();
221+
let child_path = AbsolutePathBuf::new(child_dir.as_path().to_path_buf()).unwrap();
222+
assert!(has_vite_plus_dependency(&child_path));
223+
}
224+
225+
#[test]
226+
fn test_empty_package_json() {
227+
let temp_dir = tempfile::tempdir().unwrap();
228+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
229+
std::fs::write(temp_path.join("package.json"), r#"{}"#).unwrap();
230+
assert!(!has_vite_plus_dependency(&temp_path));
231+
}
232+
233+
#[test]
234+
fn test_nested_dir_stops_at_nearest_package_json() {
235+
let temp_dir = tempfile::tempdir().unwrap();
236+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
237+
// Parent has vite-plus
238+
std::fs::write(
239+
temp_path.join("package.json"),
240+
r#"{ "devDependencies": { "vite-plus": "^1.0.0" } }"#,
241+
)
242+
.unwrap();
243+
// Child has its own package.json without vite-plus
244+
let child_dir = temp_path.join("child");
245+
std::fs::create_dir(&child_dir).unwrap();
246+
std::fs::write(
247+
child_dir.join("package.json"),
248+
r#"{ "devDependencies": { "vite": "^6.0.0" } }"#,
249+
)
250+
.unwrap();
251+
let child_path = AbsolutePathBuf::new(child_dir.as_path().to_path_buf()).unwrap();
252+
// Should find the child's package.json first and return false
253+
assert!(!has_vite_plus_dependency(&child_path));
254+
}
255+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//! Run command with fallback to package manager when vite-plus is not a dependency.
2+
3+
use std::process::ExitStatus;
4+
5+
use vite_path::AbsolutePathBuf;
6+
7+
use crate::error::Error;
8+
9+
/// Execute `vp run <args>`.
10+
///
11+
/// If vite-plus is a dependency, delegate to the local CLI.
12+
/// If not, fall back to `<pm> run <args>`.
13+
pub async fn execute(cwd: AbsolutePathBuf, args: &[String]) -> Result<ExitStatus, Error> {
14+
if super::has_vite_plus_dependency(&cwd) {
15+
tracing::debug!("vite-plus is a dependency, delegating to local CLI");
16+
super::delegate::execute(cwd, "run", args).await
17+
} else {
18+
tracing::debug!("vite-plus is not a dependency, falling back to package manager run");
19+
super::prepend_js_runtime_to_path_env(&cwd).await?;
20+
let package_manager = super::build_package_manager(&cwd).await?;
21+
Ok(package_manager.run_script_command(args, &cwd).await?)
22+
}
23+
}

crates/vite_install/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod pack;
1212
pub mod prune;
1313
pub mod publish;
1414
pub mod remove;
15+
pub mod run;
1516
pub mod unlink;
1617
pub mod update;
1718
pub mod view;
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use std::{collections::HashMap, process::ExitStatus};
2+
3+
use vite_command::run_command;
4+
use vite_error::Error;
5+
use vite_path::AbsolutePath;
6+
7+
use crate::package_manager::{
8+
PackageManager, PackageManagerType, ResolveCommandResult, format_path_env,
9+
};
10+
11+
impl PackageManager {
12+
/// Run `<pm> run <args>` to execute a package.json script.
13+
pub async fn run_script_command(
14+
&self,
15+
args: &[String],
16+
cwd: impl AsRef<AbsolutePath>,
17+
) -> Result<ExitStatus, Error> {
18+
let resolve_command = self.resolve_run_script_command(args);
19+
run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)
20+
.await
21+
}
22+
23+
/// Resolve the `<pm> run <args>` command.
24+
#[must_use]
25+
pub fn resolve_run_script_command(&self, args: &[String]) -> ResolveCommandResult {
26+
let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]);
27+
let mut cmd_args: Vec<String> = vec!["run".to_string()];
28+
cmd_args.extend(args.iter().cloned());
29+
30+
let bin_path = match self.client {
31+
PackageManagerType::Pnpm => "pnpm",
32+
PackageManagerType::Npm => "npm",
33+
PackageManagerType::Yarn => "yarn",
34+
};
35+
36+
ResolveCommandResult { bin_path: bin_path.to_string(), args: cmd_args, envs }
37+
}
38+
}
39+
40+
#[cfg(test)]
41+
mod tests {
42+
use tempfile::{TempDir, tempdir};
43+
use vite_path::AbsolutePathBuf;
44+
use vite_str::Str;
45+
46+
use super::*;
47+
48+
fn create_temp_dir() -> TempDir {
49+
tempdir().expect("Failed to create temp directory")
50+
}
51+
52+
fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {
53+
let temp_dir = create_temp_dir();
54+
let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
55+
let install_dir = temp_dir_path.join("install");
56+
57+
PackageManager {
58+
client: pm_type,
59+
package_name: pm_type.to_string().into(),
60+
version: Str::from(version),
61+
hash: None,
62+
bin_name: pm_type.to_string().into(),
63+
workspace_root: temp_dir_path.clone(),
64+
is_monorepo: false,
65+
install_dir,
66+
}
67+
}
68+
69+
#[test]
70+
fn test_pnpm_run_script() {
71+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
72+
let result = pm.resolve_run_script_command(&["dev".into()]);
73+
assert_eq!(result.bin_path, "pnpm");
74+
assert_eq!(result.args, vec!["run", "dev"]);
75+
}
76+
77+
#[test]
78+
fn test_pnpm_run_script_with_args() {
79+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
80+
let result = pm.resolve_run_script_command(&["dev".into(), "--port".into(), "3000".into()]);
81+
assert_eq!(result.bin_path, "pnpm");
82+
assert_eq!(result.args, vec!["run", "dev", "--port", "3000"]);
83+
}
84+
85+
#[test]
86+
fn test_npm_run_script() {
87+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
88+
let result = pm.resolve_run_script_command(&["dev".into()]);
89+
assert_eq!(result.bin_path, "npm");
90+
assert_eq!(result.args, vec!["run", "dev"]);
91+
}
92+
93+
#[test]
94+
fn test_npm_run_script_with_args() {
95+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
96+
let result = pm.resolve_run_script_command(&["dev".into(), "--port".into(), "3000".into()]);
97+
assert_eq!(result.bin_path, "npm");
98+
assert_eq!(result.args, vec!["run", "dev", "--port", "3000"]);
99+
}
100+
101+
#[test]
102+
fn test_yarn_run_script() {
103+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
104+
let result = pm.resolve_run_script_command(&["build".into()]);
105+
assert_eq!(result.bin_path, "yarn");
106+
assert_eq!(result.args, vec!["run", "build"]);
107+
}
108+
109+
#[test]
110+
fn test_run_script_no_args() {
111+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
112+
let result = pm.resolve_run_script_command(&[]);
113+
assert_eq!(result.bin_path, "pnpm");
114+
assert_eq!(result.args, vec!["run"]);
115+
}
116+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "command-run-without-vite-plus",
3+
"version": "1.0.0",
4+
"scripts": {
5+
"hello": "echo hello from script",
6+
"greet": "echo greet"
7+
},
8+
"packageManager": "pnpm@10.19.0"
9+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
> vp run hello # should fall back to pnpm run when no vite-plus dependency
2+
3+
> command-run-without-vite-plus@<semver> hello <cwd>
4+
> echo hello from script
5+
6+
hello from script
7+
8+
> vp run greet --arg1 value1 # should pass through args to pnpm run
9+
10+
> command-run-without-vite-plus@<semver> greet <cwd>
11+
> echo greet --arg1 value1
12+
13+
greet --arg1 value1
14+
15+
[1]> vp run nonexistent # should show pnpm missing script error
16+
 ERR_PNPM_NO_SCRIPT  Missing script: nonexistent
17+
18+
Command "nonexistent" not found.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"env": {
3+
"VITE_DISABLE_AUTO_INSTALL": "1"
4+
},
5+
"commands": [
6+
"vp run hello # should fall back to pnpm run when no vite-plus dependency",
7+
"vp run greet --arg1 value1 # should pass through args to pnpm run",
8+
"vp run nonexistent # should show pnpm missing script error"
9+
]
10+
}

0 commit comments

Comments
 (0)