diff --git a/crates/vp_command/src/external_tool.rs b/crates/vp_command/src/external_tool.rs new file mode 100644 index 0000000000..5b7bdc9017 --- /dev/null +++ b/crates/vp_command/src/external_tool.rs @@ -0,0 +1,91 @@ +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use crate::resolve_bin; + +/// Inputs for [`find_external_tool`]. +/// +/// The caller supplies every process-dependent value, so the search does not +/// assume that `current_exe()` is a Vite+ binary. In a NAPI module, it is the +/// Node.js executable. +#[derive(Debug, Clone, Copy)] +pub struct ExternalToolQuery<'a> { + /// PATH-style list of directories to search. + pub path: &'a OsStr, + /// Additional directories to skip, such as the `VP_BYPASS` entries. + pub bypass: &'a [PathBuf], + /// Canonicalized executable to exclude, or `None` to exclude no executable. + pub self_exe: Option<&'a Path>, +} + +/// Find `tool` on PATH outside Vite+ installations. +/// +/// Skips the Vite+ bin directory, the bypass directories, shims from other +/// installations, and `self_exe`. Those would otherwise dispatch back into vp. +/// `cwd` only resolves relative PATH entries. +pub fn find_external_tool( + tool: &str, + query: &ExternalToolQuery<'_>, + cwd: &AbsolutePath, +) -> Option { + let env = vp_shared::EnvConfig::get(); + let bin_dir = env.dirs.bin.as_path(); + tracing::debug!("path_var: {:?}", query.path); + tracing::debug!("bypass_paths: {:?}", query.bypass); + + // Filter PATH to exclude our bin directory and any bypass directories. + // Relative entries are resolved against cwd because `which` reports + // matches from them as relative paths, which `AbsolutePathBuf` rejects; + // `~`-prefixed entries are left to `which`'s own tilde expansion. + let mut filtered_paths: Vec<_> = std::env::split_paths(query.path) + .filter(|p| p != bin_dir && !query.bypass.iter().any(|bp| p == bp)) + .map(|p| if p.is_absolute() || p.starts_with("~") { p } else { cwd.as_path().join(p) }) + .collect(); + + loop { + // Use resolve_bin with filtered PATH - stops at first match + let search_path = std::env::join_paths(&filtered_paths).ok()?; + let resolved = resolve_bin(tool, Some(&search_path), cwd).ok()?; + let canonical = resolved.as_path().canonicalize().ok(); + // A sibling `vp` may itself be a shim sharing an external manager's executable. + let is_unix_shim = cfg!(unix) + && canonical + .as_ref() + .is_some_and(|target| target.file_name().is_some_and(|name| name == "vp")); + if !vp_shared::is_windows_trampoline(resolved.as_path()) + && !is_unix_shim + && (query.self_exe.is_none() || canonical.as_deref() != query.self_exe) + { + return Some(resolved); + } + // Canonicalize both sides of the comparison so symlink-aliased PATH + // entries still match the resolved parent. + let self_dir = resolved.as_path().parent()?.to_path_buf(); + let canonical_self_dir = self_dir.canonicalize().ok(); + let len_before = filtered_paths.len(); + filtered_paths.retain(|p| { + *p != self_dir + && (canonical_self_dir.is_none() || p.canonicalize().ok() != canonical_self_dir) + }); + if filtered_paths.len() == len_before { + // Nothing left to drop; give up rather than loop forever. + return None; + } + } +} + +/// Whether `path` resolves into a Vite+-managed Node.js runtime or package +/// manager, that is, under `/js_runtime` or `/package_manager`. +pub fn is_managed_install(path: &Path) -> bool { + let Ok(target) = path.canonicalize() else { + return false; + }; + let data = &vp_shared::EnvConfig::get().dirs.data; + ["js_runtime", "package_manager"].iter().any(|directory| { + data.join(directory).as_path().canonicalize().is_ok_and(|root| target.starts_with(root)) + }) +} diff --git a/crates/vp_command/src/lib.rs b/crates/vp_command/src/lib.rs index 7f6ebd6099..2e3844436b 100644 --- a/crates/vp_command/src/lib.rs +++ b/crates/vp_command/src/lib.rs @@ -19,8 +19,10 @@ use tokio_util::sync::CancellationToken; use vp_error::Error; use vt_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; +mod external_tool; mod ps1_shim; +pub use external_tool::{ExternalToolQuery, find_external_tool, is_managed_install}; pub use ps1_shim::rewrite_cmd_to_powershell; /// Result of running a command with fspy tracking. diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index c782b35d54..6443ab8e0a 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -9,7 +9,7 @@ use std::{path::Path, process::ExitCode}; use dialoguer::{Confirm, theme::ColorfulTheme}; use vp_pm_cli::PackageManagerType; use vp_setup::{SELF_SETUP_MARKER, VP_BINARY_NAME, install, is_commit_preview_version}; -use vp_shared::{EnvConfig, env_vars, output}; +use vp_shared::{EnvConfig, ManagerPreference, env_vars, output}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::{ @@ -175,7 +175,7 @@ async fn run(source: &Path, bundled: bool) -> Result { )); } let previous_install = if deploy { previous_install()? } else { None }; - let node_override = manager_mode("VP_NODE_MANAGER"); + let node_override = shim_mode(env.node_manager); // A package upgrade can change the executable path or expire its receipt. // Preferences belong to the user, not to that particular binary. let configured = bundled && config::get_config_path()?.as_path().is_file(); @@ -302,14 +302,14 @@ async fn run(source: &Path, bundled: bool) -> Result { if let Some(mode) = node_override.or(default_mode) { settings.node_shim_mode = mode; } - let pm_mode = manager_mode("VP_PM_MANAGER").or(default_mode); - for (family, variable) in [ - (PackageManagerType::Npm, "VP_NPM_MANAGER"), - (PackageManagerType::Pnpm, "VP_PNPM_MANAGER"), - (PackageManagerType::Yarn, "VP_YARN_MANAGER"), - (PackageManagerType::Bun, "VP_BUN_MANAGER"), + let pm_mode = shim_mode(env.pm_manager).or(default_mode); + for (family, preference) in [ + (PackageManagerType::Npm, env.npm_manager), + (PackageManagerType::Pnpm, env.pnpm_manager), + (PackageManagerType::Yarn, env.yarn_manager), + (PackageManagerType::Bun, env.bun_manager), ] { - if let Some(mode) = manager_mode(variable).or(pm_mode) { + if let Some(mode) = shim_mode(preference).or(pm_mode) { settings.set_package_manager_shim_mode(family, mode); } } @@ -381,11 +381,10 @@ fn confirm(prompt: &str, default: bool) -> Result { .map_err(|error| Error::Other(error.to_string().into())) } -fn manager_mode(variable: &str) -> Option { - match std::env::var(variable).as_deref() { - Ok("yes") => Some(config::ShimMode::Managed), - Ok("no") => Some(config::ShimMode::SystemFirst), - _ => None, +fn shim_mode(preference: Option) -> Option { + match preference? { + ManagerPreference::Yes => Some(config::ShimMode::Managed), + ManagerPreference::No => Some(config::ShimMode::SystemFirst), } } diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 90a9429928..a1a320bdf8 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -745,16 +745,7 @@ pub async fn dispatch(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { PackageManagerType::from_tool(tool).map_or(tool, |kind| kind.bin_name_for_tool(tool)); if env.contains(inherited_tool) && let Some(path) = find_system_tool(inherited_tool) - && let Ok(target) = path.as_path().canonicalize() - && ["js_runtime", "package_manager"].iter().any(|directory| { - vp_shared::EnvConfig::get() - .dirs - .data - .join(directory) - .as_path() - .canonicalize() - .is_ok_and(|root| target.starts_with(root)) - }) + && vp_command::is_managed_install(path.as_path()) { return exec::exec_tool(&path, args, env); } @@ -1331,70 +1322,42 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option Option { - let bin_dir = config::get_bin_dir().ok(); - let path_var = std::env::var_os("PATH")?; - tracing::debug!("path_var: {:?}", path_var); - - // Parse VP_BYPASS as a PATH-style list of additional directories to skip. - // This prevents infinite loops when multiple vite-plus installations exist in PATH. - let bypass_paths: Vec = std::env::var_os(env_vars::VP_BYPASS) - .map(|v| std::env::split_paths(&v).collect()) - .unwrap_or_default(); - tracing::debug!("bypass_paths: {:?}", bypass_paths); - - // Filter PATH to exclude our bin directory and any bypass directories. - // Relative entries are resolved against cwd because `which` reports - // matches from them as relative paths, which `AbsolutePathBuf` rejects; - // `~`-prefixed entries are left to `which`'s own tilde expansion. - let mut 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) - }) - .map(|p| if p.is_absolute() || p.starts_with("~") { p } else { cwd.as_path().join(p) }) - .collect(); +/// Process-dependent inputs for [`vp_command::find_external_tool`]. +struct ExternalToolInputs { + path: std::ffi::OsString, + bypass: Vec, + self_exe: Option, +} - // Exclude our executable and shims from other installations, which would - // otherwise dispatch back into vp. - let self_real = std::env::current_exe().ok().and_then(|exe| exe.canonicalize().ok()); - loop { - // Use vp_command::resolve_bin with filtered PATH - stops at first match - let search_path = std::env::join_paths(&filtered_paths).ok()?; - let resolved = vp_command::resolve_bin(tool, Some(&search_path), cwd).ok()?; - let canonical = resolved.as_path().canonicalize().ok(); - // A sibling `vp` may itself be a shim sharing an external manager's executable. - let is_unix_shim = cfg!(unix) - && canonical - .as_ref() - .is_some_and(|target| target.file_name().is_some_and(|name| name == "vp")); - if !vp_shared::is_windows_trampoline(resolved.as_path()) - && !is_unix_shim - && (self_real.is_none() || canonical != self_real) - { - return Some(resolved); - } - // Canonicalize both sides of the comparison so symlink-aliased PATH - // entries still match the resolved parent. - let self_dir = resolved.as_path().parent()?.to_path_buf(); - let canonical_self_dir = self_dir.canonicalize().ok(); - let len_before = filtered_paths.len(); - filtered_paths.retain(|p| { - *p != self_dir - && (canonical_self_dir.is_none() || p.canonicalize().ok() != canonical_self_dir) - }); - if filtered_paths.len() == len_before { - // Nothing left to drop; give up rather than loop forever. - return None; +impl ExternalToolInputs { + /// Read PATH, `VP_BYPASS`, and the canonical `current_exe()`. + /// Returns `None` when PATH is unset. + fn from_process_env() -> Option { + let path = std::env::var_os("PATH")?; + // Parse VP_BYPASS as a PATH-style list of additional directories to skip. + // This prevents infinite loops when multiple vite-plus installations exist in PATH. + let bypass = std::env::var_os(env_vars::VP_BYPASS) + .map(|v| std::env::split_paths(&v).collect()) + .unwrap_or_default(); + let self_exe = std::env::current_exe().ok().and_then(|exe| exe.canonicalize().ok()); + Some(Self { path, bypass, self_exe }) + } + + fn query(&self) -> vp_command::ExternalToolQuery<'_> { + vp_command::ExternalToolQuery { + path: &self.path, + bypass: &self.bypass, + self_exe: self.self_exe.as_deref(), } } } +/// Explicit bypass skips Vite+ installations and the directories listed by the caller. +fn find_external_tool_in(tool: &str, cwd: &AbsolutePath) -> Option { + let inputs = ExternalToolInputs::from_process_env()?; + vp_command::find_external_tool(tool, &inputs.query(), cwd) +} + #[cfg(test)] mod tests { use tempfile::TempDir; diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index fe80624943..5dd276138a 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -166,10 +166,10 @@ async fn do_install(opts: &cli::Options, dirs: &VpDirs) -> Result<(), Box HashMap<&'static str, String> { HashMap::default() } +/// Management preference from a `VP_*_MANAGER` variable. +/// +/// Only the exact values `yes` and `no` are recognized. Any other value, +/// including a non-UTF-8 one, counts as unset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManagerPreference { + /// `yes`: Vite+ manages the tool. + Yes, + /// `no`: prefer the system tool. + No, +} + +impl ManagerPreference { + fn from_var(name: &str) -> Option { + match std::env::var(name).as_deref() { + Ok("yes") => Some(Self::Yes), + Ok("no") => Some(Self::No), + _ => None, + } + } +} + /// Centralized configuration read from environment variables. /// /// Construction reads all known Vite+ environment variables, including the @@ -173,6 +195,25 @@ pub struct EnvConfig { pub yarn_version: Option, pub bun_version: Option, + /// Node.js management preference. + /// + /// Env: `VP_NODE_MANAGER` + pub node_manager: Option, + + /// Package-manager management preference shared by every family. + /// + /// Env: `VP_PM_MANAGER` + pub pm_manager: Option, + + /// Family-specific package-manager management preferences. Each one takes + /// precedence over [`Self::pm_manager`]. + /// + /// Env: `VP_NPM_MANAGER`, `VP_PNPM_MANAGER`, `VP_YARN_MANAGER`, `VP_BUN_MANAGER` + pub npm_manager: Option, + pub pnpm_manager: Option, + pub yarn_manager: Option, + pub bun_manager: Option, + /// User home directory. /// /// Resolved once from `HOME` or `USERPROFILE` in platform order. See @@ -230,6 +271,12 @@ impl EnvConfig { pnpm_version: std::env::var(env_vars::VP_PNPM_VERSION).ok(), yarn_version: std::env::var(env_vars::VP_YARN_VERSION).ok(), bun_version: std::env::var(env_vars::VP_BUN_VERSION).ok(), + node_manager: ManagerPreference::from_var(env_vars::VP_NODE_MANAGER), + pm_manager: ManagerPreference::from_var(env_vars::VP_PM_MANAGER), + npm_manager: ManagerPreference::from_var(env_vars::VP_NPM_MANAGER), + pnpm_manager: ManagerPreference::from_var(env_vars::VP_PNPM_MANAGER), + yarn_manager: ManagerPreference::from_var(env_vars::VP_YARN_MANAGER), + bun_manager: ManagerPreference::from_var(env_vars::VP_BUN_MANAGER), user_home, vp_shell: std::env::var(env_vars::VP_SHELL).ok(), nu_version: std::env::var(env_vars::VP_NU_VERSION).ok(), diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 7b9a5bfe6d..75fe5337f3 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -106,6 +106,25 @@ pub const VP_YARN_VERSION: &str = "VP_YARN_VERSION"; /// Override the bun and bunx shim version. pub const VP_BUN_VERSION: &str = "VP_BUN_VERSION"; +/// Node.js management preference (`yes` or `no`). +pub const VP_NODE_MANAGER: &str = "VP_NODE_MANAGER"; + +/// Package-manager management preference shared by every family. +/// Family-specific variables take precedence over it. +pub const VP_PM_MANAGER: &str = "VP_PM_MANAGER"; + +/// npm management preference; takes precedence over `VP_PM_MANAGER`. +pub const VP_NPM_MANAGER: &str = "VP_NPM_MANAGER"; + +/// pnpm management preference; takes precedence over `VP_PM_MANAGER`. +pub const VP_PNPM_MANAGER: &str = "VP_PNPM_MANAGER"; + +/// Yarn management preference; takes precedence over `VP_PM_MANAGER`. +pub const VP_YARN_MANAGER: &str = "VP_YARN_MANAGER"; + +/// Bun management preference; takes precedence over `VP_PM_MANAGER`. +pub const VP_BUN_MANAGER: &str = "VP_BUN_MANAGER"; + /// Enable debug output for shim dispatch. pub const VP_DEBUG_SHIM: &str = "VP_DEBUG_SHIM"; diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index 2e77b14aa5..ee1072df04 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -28,7 +28,7 @@ pub use dirs::{ VpDirsLayout, is_windows_trampoline, shim_pointer_file_name, validate_vp_dir_env, }; pub use documentation::documentation_url; -pub use env_config::EnvConfig; +pub use env_config::{EnvConfig, ManagerPreference}; pub use error::format_error_chain; pub use http::{ HttpClientError, download_timeout, request_timeout, shared_http_client,