Skip to content
Open
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
106 changes: 60 additions & 46 deletions crates/vite_global_cli/src/commands/env/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use vite_path::{AbsolutePathBuf, current_dir};
use vite_shared::{env_vars, output};

use super::config::{self, ShimMode, get_bin_dir, get_vp_home, load_config, resolve_version};
use crate::error::Error;
use crate::{error::Error, shim};

/// IDE-relevant profile files that GUI-launched applications can see.
/// GUI apps don't run through an interactive shell, so only login/environment
Expand Down Expand Up @@ -114,7 +114,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result<ExitStatus, Error> {

// Section: Configuration
print_section("Configuration");
check_shim_mode().await;
let (shim_mode, system_node_path) = check_shim_mode().await;

// Check env sourcing: IDE-relevant profiles first, then all shell profiles
#[cfg(not(windows))]
Expand All @@ -128,7 +128,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result<ExitStatus, Error> {

// Section: Version Resolution
print_section("Version Resolution");
check_current_resolution(&cwd).await;
check_current_resolution(&cwd, shim_mode, system_node_path).await;

// Section: Conflicts (conditional)
check_conflicts();
Expand Down Expand Up @@ -247,43 +247,48 @@ fn shim_filename(tool: &str) -> String {
}
}

/// Check and display shim mode.
async fn check_shim_mode() {
/// Check and display shim mode. Returns the mode and any found system node path.
async fn check_shim_mode() -> (ShimMode, Option<AbsolutePathBuf>) {
let config = match load_config().await {
Ok(c) => c,
Err(e) => {
print_check(
&output::WARN_SIGN.yellow().to_string(),
"Shim mode",
"Node.js mode",
&format!("config error: {e}").yellow().to_string(),
);
return;
return (ShimMode::default(), None);
}
};

let mut system_node_path = None;

match config.shim_mode {
ShimMode::Managed => {
print_check(&output::CHECK.green().to_string(), "Shim mode", "managed");
print_check(&output::CHECK.green().to_string(), "Node.js mode", "managed");
}
ShimMode::SystemFirst => {
print_check(
&output::CHECK.green().to_string(),
"Shim mode",
"Node.js mode",
&"system-first".bright_blue().to_string(),
);

// Check if system Node.js is available
if let Some(system_node) = find_system_node() {
print_check(" ", "System Node.js", &system_node.display().to_string());
if let Some(system_node) = shim::find_system_tool("node") {
print_check(" ", "System Node.js", &system_node.as_path().display().to_string());
system_node_path = Some(system_node);
} else {
print_check(
&output::WARN_SIGN.yellow().to_string(),
"System Node.js",
&"not found (will use managed)".yellow().to_string(),
&"not found (will fall back to managed)".yellow().to_string(),
);
}
}
}

(config.shim_mode, system_node_path)
}

/// Check profile files for env sourcing and classify where it was found.
Expand Down Expand Up @@ -338,36 +343,6 @@ fn check_env_sourcing() -> EnvSourcingStatus {
EnvSourcingStatus::NotFound
}

/// Find system Node.js, skipping vite-plus bin directory and any
/// directories listed in `VP_BYPASS`.
fn find_system_node() -> Option<std::path::PathBuf> {
let bin_dir = get_bin_dir().ok();
let path_var = std::env::var_os("PATH")?;

// Parse VP_BYPASS as a PATH-style list of additional directories to skip
let bypass_paths: Vec<std::path::PathBuf> = std::env::var_os(env_vars::VP_BYPASS)
.map(|v| std::env::split_paths(&v).collect())
.unwrap_or_default();

// Filter PATH to exclude our bin directory and any bypass directories
let filtered_paths: Vec<_> = std::env::split_paths(&path_var)
.filter(|p| {
if let Some(ref bin) = bin_dir {
if p == bin.as_path() {
return false;
}
}
!bypass_paths.iter().any(|bp| p == bp)
})
.collect();

let filtered_path = std::env::join_paths(filtered_paths).ok()?;

// Use vite_command::resolve_bin with filtered PATH - stops at first match
let cwd = current_dir().ok()?;
vite_command::resolve_bin("node", Some(&filtered_path), &cwd).ok().map(|p| p.into_path_buf())
}

/// Check for active session override via VP_NODE_VERSION or session file.
fn check_session_override() {
if let Ok(version) = std::env::var(config::VERSION_ENV_VAR) {
Expand Down Expand Up @@ -613,9 +588,35 @@ fn print_ide_setup_guidance(bin_dir: &vite_path::AbsolutePath) {
}

/// Check current directory version resolution.
async fn check_current_resolution(cwd: &AbsolutePathBuf) {
async fn check_current_resolution(
cwd: &AbsolutePathBuf,
shim_mode: ShimMode,
system_node_path: Option<AbsolutePathBuf>,
) {
print_check(" ", "Directory", &cwd.as_path().display().to_string());

// In system-first mode, show system Node.js info instead of managed resolution
if shim_mode == ShimMode::SystemFirst {
if let Some(system_node) = system_node_path {
let version = get_node_version(&system_node).await;
print_check(" ", "Source", "system PATH");
print_check(" ", "Version", &version.bright_green().to_string());
print_check(
&output::CHECK.green().to_string(),
"Node binary",
&system_node.as_path().display().to_string(),
);
} else {
print_check(
&output::WARN_SIGN.yellow().to_string(),
"System Node.js",
&"not found in PATH".yellow().to_string(),
);
print_hint("Install Node.js or run 'vp env on' to use managed Node.js.");
}
return;
}

match resolve_version(cwd).await {
Ok(resolution) => {
let source_display = resolution
Expand Down Expand Up @@ -658,6 +659,16 @@ async fn check_current_resolution(cwd: &AbsolutePathBuf) {
}
}

/// Get the version string from a Node.js binary.
async fn get_node_version(node_path: &vite_path::AbsolutePath) -> String {
match tokio::process::Command::new(node_path.as_path()).arg("--version").output().await {
Ok(output) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
_ => "unknown".to_string(),
}
}

/// Check for conflicts with other version managers.
fn check_conflicts() {
let mut conflicts = Vec::new();
Expand Down Expand Up @@ -806,9 +817,12 @@ mod tests {
std::env::set_var(env_vars::VP_BYPASS, dir_a.as_os_str());
}

let result = find_system_node();
let result = shim::find_system_tool("node");
assert!(result.is_some(), "Should find node in non-bypassed directory");
assert!(result.unwrap().starts_with(&dir_b), "Should find node in dir_b, not dir_a");
assert!(
result.unwrap().as_path().starts_with(&dir_b),
"Should find node in dir_b, not dir_a"
);
}

#[test]
Expand All @@ -826,7 +840,7 @@ mod tests {
std::env::set_var(env_vars::VP_BYPASS, dir_a.as_os_str());
}

let result = find_system_node();
let result = shim::find_system_tool("node");
assert!(result.is_none(), "Should return None when all paths are bypassed");
}

Expand Down
10 changes: 5 additions & 5 deletions crates/vite_global_cli/src/commands/env/off.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,23 @@ pub async fn execute() -> Result<ExitStatus, Error> {
let mut config = load_config().await?;

if config.shim_mode == ShimMode::SystemFirst {
println!("Shim mode is already set to system-first.");
println!("Node.js management is already set to system-first.");
println!(
"Shims will prefer system Node.js, falling back to Vite+ managed Node.js if not found."
"All vp commands and shims will prefer system Node.js, falling back to managed if not found."
);
return Ok(ExitStatus::default());
}

config.shim_mode = ShimMode::SystemFirst;
save_config(&config).await?;

println!("\u{2713} Shim mode set to system-first.");
println!("\u{2713} Node.js management set to system-first.");
println!();
println!(
"Shims will now prefer system Node.js, falling back to Vite+ managed Node.js if not found."
"All vp commands and shims will now prefer system Node.js, falling back to managed if not found."
);
println!();
println!("Run {} to always use the Vite+ managed Node.js.", accent_command("vp env on"));
println!("Run {} to always use Vite+ managed Node.js.", accent_command("vp env on"));

Ok(ExitStatus::default())
}
8 changes: 4 additions & 4 deletions crates/vite_global_cli/src/commands/env/on.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,17 @@ pub async fn execute() -> Result<ExitStatus, Error> {
let mut config = load_config().await?;

if config.shim_mode == ShimMode::Managed {
println!("Shim mode is already set to managed.");
println!("Shims will always use the Vite+ managed Node.js.");
println!("Node.js management is already set to managed.");
println!("All vp commands and shims will always use Vite+ managed Node.js.");
return Ok(ExitStatus::default());
}

config.shim_mode = ShimMode::Managed;
save_config(&config).await?;

println!("\u{2713} Shim mode set to managed.");
println!("\u{2713} Node.js management set to managed.");
println!();
println!("Shims will now always use the Vite+ managed Node.js.");
println!("All vp commands and shims will now always use Vite+ managed Node.js.");
println!();
println!("Run {} to prefer system Node.js instead.", accent_command("vp env off"));

Expand Down
35 changes: 34 additions & 1 deletion crates/vite_global_cli/src/js_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ use vite_js_runtime::{
use vite_path::{AbsolutePath, AbsolutePathBuf};
use vite_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend};

use crate::{commands::env::config, error::Error};
use crate::{
commands::env::config::{self, ShimMode},
error::Error,
shim,
};

/// JavaScript executor using managed Node.js runtime.
///
Expand Down Expand Up @@ -125,8 +129,15 @@ impl JsExecutor {
///
/// Uses the CLI's package.json `devEngines.runtime` configuration
/// to determine which Node.js version to use.
///
/// When system-first mode is active (`vp env off`), prefers the
/// system-installed Node.js found in PATH.
pub async fn ensure_cli_runtime(&mut self) -> Result<&JsRuntime, Error> {
if self.cli_runtime.is_none() {
if let Some(system_runtime) = find_system_node_runtime().await {
return Ok(self.cli_runtime.insert(system_runtime));
}

let cli_dir = self.get_cli_package_dir()?;
tracing::debug!("Resolving CLI runtime from {:?}", cli_dir);
let runtime = download_runtime_for_project(&cli_dir).await?;
Expand All @@ -151,6 +162,10 @@ impl JsExecutor {
if self.project_runtime.is_none() {
tracing::debug!("Resolving project runtime from {:?}", project_path);

if let Some(system_runtime) = find_system_node_runtime().await {
return Ok(self.project_runtime.insert(system_runtime));
}

// 1–2. Session overrides: env var (from `vp env use`), then file
let session_version = vite_shared::EnvConfig::get()
.node_version
Expand Down Expand Up @@ -391,6 +406,24 @@ async fn has_valid_version_source(
Ok(engines_valid || dev_engines_valid)
}

/// Try to find system Node.js when in system-first mode (`vp env off`).
///
/// Returns `Some(JsRuntime)` when both conditions are met:
/// 1. Config has `shim_mode == SystemFirst`
/// 2. A system `node` binary is found in PATH (excluding the vite-plus bin directory)
///
/// Returns `None` if mode is `Managed` or no system Node.js is found,
/// allowing the caller to fall through to managed runtime resolution.
async fn find_system_node_runtime() -> Option<JsRuntime> {
let config = config::load_config().await.ok()?;
if config.shim_mode != ShimMode::SystemFirst {
return None;
}
let system_node = shim::find_system_tool("node")?;
tracing::info!("System-first mode: using system Node.js at {:?}", system_node);
Some(JsRuntime::from_system(JsRuntimeType::Node, system_node))
}

#[cfg(test)]
mod tests {
use serial_test::serial;
Expand Down
2 changes: 1 addition & 1 deletion crates/vite_global_cli/src/shim/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1159,7 +1159,7 @@ async fn load_shim_mode() -> ShimMode {
/// directories listed in `VP_BYPASS`.
///
/// Returns the absolute path to the tool if found, None otherwise.
fn find_system_tool(tool: &str) -> Option<AbsolutePathBuf> {
pub(crate) fn find_system_tool(tool: &str) -> Option<AbsolutePathBuf> {
let bin_dir = config::get_bin_dir().ok();
let path_var = std::env::var_os("PATH")?;
tracing::debug!("path_var: {:?}", path_var);
Expand Down
1 change: 1 addition & 0 deletions crates/vite_global_cli/src/shim/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub(crate) mod exec;

pub(crate) use cache::invalidate_cache;
pub use dispatch::dispatch;
pub(crate) use dispatch::find_system_tool;
use vite_shared::env_vars;

/// Core shim tools (node, npm, npx)
Expand Down
42 changes: 42 additions & 0 deletions crates/vite_js_runtime/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ impl JsRuntime {
pub fn version(&self) -> &str {
&self.version
}

/// Create a `JsRuntime` from a system-installed binary path.
///
/// `get_bin_prefix()` returns the parent directory of `binary_path`.
#[must_use]
pub fn from_system(runtime_type: JsRuntimeType, binary_path: AbsolutePathBuf) -> Self {
let install_dir = binary_path
.parent()
.map(vite_path::AbsolutePath::to_absolute_path_buf)
.unwrap_or_else(|| binary_path.clone());
let binary_filename: Str = Str::from(
binary_path.as_path().file_name().unwrap_or_default().to_string_lossy().as_ref(),
);
debug_assert!(!binary_filename.is_empty(), "binary_path has no filename: {binary_path:?}");
Self {
runtime_type,
version: "system".into(),
install_dir,
binary_relative_path: binary_filename,
bin_dir_relative_path: Str::default(),
}
}
}

/// Download and cache a JavaScript runtime
Expand Down Expand Up @@ -557,6 +579,26 @@ mod tests {
assert_eq!(JsRuntimeType::Node.to_string(), "node");
}

#[test]
fn test_js_runtime_from_system() {
let binary_path = AbsolutePathBuf::new(std::path::PathBuf::from(if cfg!(windows) {
"C:\\Program Files\\nodejs\\node.exe"
} else {
"/usr/local/bin/node"
}))
.unwrap();

let runtime = JsRuntime::from_system(JsRuntimeType::Node, binary_path.clone());

assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);
assert_eq!(runtime.version(), "system");
assert_eq!(runtime.get_binary_path(), binary_path);

// bin prefix should be the directory containing the binary
let expected_bin_prefix = binary_path.parent().unwrap().to_absolute_path_buf();
assert_eq!(runtime.get_bin_prefix(), expected_bin_prefix);
}

/// Test that install_dir path is constructed correctly without embedded forward slashes.
/// This ensures Windows compatibility by using separate join() calls.
#[test]
Expand Down
Loading
Loading