diff --git a/crates/vite_global_cli/src/commands/env/doctor.rs b/crates/vite_global_cli/src/commands/env/doctor.rs index f4fd4adb81..f54fe09ae9 100644 --- a/crates/vite_global_cli/src/commands/env/doctor.rs +++ b/crates/vite_global_cli/src/commands/env/doctor.rs @@ -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 @@ -114,7 +114,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { // 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))] @@ -128,7 +128,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { // 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(); @@ -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) { 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. @@ -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 { - 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::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) { @@ -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, +) { 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 @@ -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(); @@ -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] @@ -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"); } diff --git a/crates/vite_global_cli/src/commands/env/off.rs b/crates/vite_global_cli/src/commands/env/off.rs index a893488332..96aac0932f 100644 --- a/crates/vite_global_cli/src/commands/env/off.rs +++ b/crates/vite_global_cli/src/commands/env/off.rs @@ -23,9 +23,9 @@ pub async fn execute() -> Result { 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()); } @@ -33,13 +33,13 @@ pub async fn execute() -> Result { 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()) } diff --git a/crates/vite_global_cli/src/commands/env/on.rs b/crates/vite_global_cli/src/commands/env/on.rs index 799e33b3b7..f61d3bb660 100644 --- a/crates/vite_global_cli/src/commands/env/on.rs +++ b/crates/vite_global_cli/src/commands/env/on.rs @@ -22,17 +22,17 @@ pub async fn execute() -> Result { 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")); diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 9d88b45f4a..6e15c41cf9 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -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. /// @@ -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?; @@ -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 @@ -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 { + 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; diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 8dd5b2446c..394bbe9467 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -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 { +pub(crate) fn find_system_tool(tool: &str) -> Option { let bin_dir = config::get_bin_dir().ok(); let path_var = std::env::var_os("PATH")?; tracing::debug!("path_var: {:?}", path_var); diff --git a/crates/vite_global_cli/src/shim/mod.rs b/crates/vite_global_cli/src/shim/mod.rs index e93d730847..09a8e3c9b3 100644 --- a/crates/vite_global_cli/src/shim/mod.rs +++ b/crates/vite_global_cli/src/shim/mod.rs @@ -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) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index abc3d6e385..faa2a88e9a 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -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 @@ -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] diff --git a/packages/cli/snap-tests-global/command-env-off-on/package.json b/packages/cli/snap-tests-global/command-env-off-on/package.json new file mode 100644 index 0000000000..2889c1ec79 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-off-on/package.json @@ -0,0 +1,12 @@ +{ + "name": "command-env-off-on", + "version": "1.0.0", + "private": true, + "scripts": { + "assert-managed": "node src/assert-managed.mjs", + "assert-not-managed": "node src/assert-not-managed.mjs" + }, + "engines": { + "node": "20.18.0" + } +} diff --git a/packages/cli/snap-tests-global/command-env-off-on/snap.txt b/packages/cli/snap-tests-global/command-env-off-on/snap.txt new file mode 100644 index 0000000000..0f842f0996 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-off-on/snap.txt @@ -0,0 +1,38 @@ +> vp run assert-managed # Managed mode: should use project's engines.node 20.18.0 +VITE+ - The Unified Toolchain for the Web + +$ node src/assert-managed.mjs ⊘ cache disabled +OK: v + + +> vp env off # Switch to system-first mode +VITE+ - The Unified Toolchain for the Web + +✓ Node.js management set to system-first. + +All vp commands and shims will now prefer system Node.js, falling back to managed if not found. + +Run `vp env on` to always use Vite+ managed Node.js. + +> vp run assert-not-managed # System-first mode: must NOT use 20.18.0 +VITE+ - The Unified Toolchain for the Web + +$ node src/assert-not-managed.mjs ⊘ cache disabled +OK: v + + +> vp env on # Switch back to managed mode +VITE+ - The Unified Toolchain for the Web + +✓ Node.js management set to managed. + +All vp commands and shims will now always use Vite+ managed Node.js. + +Run `vp env off` to prefer system Node.js instead. + +> vp run assert-managed # Managed mode restored: should use 20.18.0 again +VITE+ - The Unified Toolchain for the Web + +$ node src/assert-managed.mjs ⊘ cache disabled +OK: v + diff --git a/packages/cli/snap-tests-global/command-env-off-on/src/assert-managed.mjs b/packages/cli/snap-tests-global/command-env-off-on/src/assert-managed.mjs new file mode 100644 index 0000000000..47fcfb0704 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-off-on/src/assert-managed.mjs @@ -0,0 +1,6 @@ +// Assert we ARE using the managed Node.js (v20.18.0 from engines.node) +if (process.version !== 'v20.18.0') { + console.error(`Expected managed Node.js v20.18.0, got ${process.version}`); + process.exit(1); +} +console.log(`OK: ${process.version}`); diff --git a/packages/cli/snap-tests-global/command-env-off-on/src/assert-not-managed.mjs b/packages/cli/snap-tests-global/command-env-off-on/src/assert-not-managed.mjs new file mode 100644 index 0000000000..addf76e39e --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-off-on/src/assert-not-managed.mjs @@ -0,0 +1,6 @@ +// Assert we are NOT using the managed Node.js (v20.18.0 from engines.node) +if (process.version === 'v20.18.0') { + console.error(`Expected system Node.js, got managed v20.18.0`); + process.exit(1); +} +console.log(`OK: ${process.version}`); diff --git a/packages/cli/snap-tests-global/command-env-off-on/steps.json b/packages/cli/snap-tests-global/command-env-off-on/steps.json new file mode 100644 index 0000000000..4f402e64c6 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-off-on/steps.json @@ -0,0 +1,12 @@ +{ + "serial": true, + "ignoredPlatforms": ["win32"], + "commands": [ + "vp run assert-managed # Managed mode: should use project's engines.node 20.18.0", + "vp env off # Switch to system-first mode", + "vp run assert-not-managed # System-first mode: must NOT use 20.18.0", + "vp env on # Switch back to managed mode", + "vp run assert-managed # Managed mode restored: should use 20.18.0 again" + ], + "after": ["vp env on"] +} diff --git a/rfcs/env-command.md b/rfcs/env-command.md index 961c4440df..af61b74f2c 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -412,10 +412,10 @@ VITE_PLUS_HOME/ # Default: ~/.vite-plus // "defaultNodeVersion": "lts" // Always use latest LTS // "defaultNodeVersion": "latest" // Always use latest (not recommended) - // Shim mode: controls how shims resolve tools + // Node.js mode: controls how all vp commands and shims resolve Node.js // Set via: vp env on (managed) or vp env off (system_first) - // - "managed" (default): Shims always use vite-plus managed Node.js - // - "system_first": Shims prefer system Node.js, fallback to managed if not found + // - "managed" (default): All vp commands and shims use vite-plus managed Node.js + // - "system_first": All vp commands and shims prefer system Node.js, fallback to managed if not found "shimMode": "managed" } ``` @@ -824,7 +824,7 @@ Installation ✓ Shims node, npm, npx Configuration - ✓ Shim mode managed + ✓ Node.js mode managed PATH ✗ vp not in PATH @@ -922,7 +922,7 @@ Installation ✓ Shims node, npm, npx Configuration - ✓ Shim mode managed + ✓ Node.js mode managed ✓ IDE integration env sourced in ~/.zshenv PATH @@ -947,7 +947,7 @@ $ vp env doctor ... Configuration - ✓ Shim mode managed + ✓ Node.js mode managed ✓ IDE integration env sourced in ~/.zshenv ⚠ Session override VITE_PLUS_NODE_VERSION=20.18.0 Overrides all file-based resolution. @@ -965,10 +965,18 @@ $ vp env doctor ... Configuration - ✓ Shim mode system-first + ✓ Node.js mode system-first System Node.js /usr/local/bin/node ✓ IDE integration env sourced in ~/.zshenv +... + +Version Resolution + Directory /Users/user/projects/my-app + Source system PATH + Version v22.22.0 + ✓ Node binary /usr/local/bin/node + ... ``` @@ -979,8 +987,8 @@ $ vp env doctor ... Configuration - ✓ Shim mode system-first - ⚠ System Node.js not found (will use managed) + ✓ Node.js mode system-first + ⚠ System Node.js not found (will fall back to managed) ... ``` @@ -996,7 +1004,7 @@ Installation Run 'vp env setup' to create bin directory and shims. Configuration - ✓ Shim mode managed + ✓ Node.js mode managed PATH ✗ vp not in PATH @@ -1256,40 +1264,42 @@ No default version configured. Using latest LTS (22.13.0). Run 'vp env default ' to set a default. ``` -### Shim Mode Commands +### Node.js Mode Commands -The shim mode controls how shims resolve tools: +The Node.js mode controls how all vp commands and shims resolve Node.js: -| Mode | Description | -| ------------------- | ------------------------------------------------------------- | -| `managed` (default) | Shims always use vite-plus managed Node.js | -| `system_first` | Shims prefer system Node.js, fallback to managed if not found | +| Mode | Description | +| ------------------- | --------------------------------------------------------------------------------- | +| `managed` (default) | All vp commands and shims use vite-plus managed Node.js | +| `system_first` | All vp commands and shims prefer system Node.js, fallback to managed if not found | ```bash # Enable managed mode (always use vite-plus Node.js) $ vp env on -✓ Shim mode set to managed. +✓ Node.js management set to managed. -Shims will now always use the Vite+ managed Node.js. +All vp commands and shims will now always use Vite+ managed Node.js. Run 'vp env off' to prefer system Node.js instead. # Enable system-first mode (prefer system Node.js) $ vp env off -✓ Shim mode set to system-first. +✓ Node.js management set to system-first. -Shims will now prefer system Node.js, falling back to managed if not found. -Run 'vp env on' to always use vite-plus managed Node.js. +All vp commands and shims will now prefer system Node.js, falling back to managed if not found. +Run 'vp env on' to always use Vite+ managed Node.js. # If already in the requested mode $ vp env on -Shim mode is already set to managed. -Shims will always use vite-plus managed Node.js. +Node.js management is already set to managed. +All vp commands and shims will always use Vite+ managed Node.js. ``` **Use cases for system-first mode (`vp env off`)**: -- When you have a system Node.js that you want to use by default -- When working on projects that don't need vite-plus version management +- NixOS / GNU Guix where downloaded binaries are dynamically linked and fail to run +- Air-gapped environments with no network access to download Node.js +- Container images where Node.js is already installed +- Users managing Node.js via other tools (mise, nvm, fnm, etc.) - When debugging version-related issues by comparing system vs managed Node.js ### Which Command