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
91 changes: 91 additions & 0 deletions crates/vp_command/src/external_tool.rs
Original file line number Diff line number Diff line change
@@ -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<AbsolutePathBuf> {
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 `<DATA>/js_runtime` or `<DATA>/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))
})
}
2 changes: 2 additions & 0 deletions crates/vp_command/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 13 additions & 14 deletions crates/vp_global_cli/src/self_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -175,7 +175,7 @@ async fn run(source: &Path, bundled: bool) -> Result<AbsolutePathBuf, Error> {
));
}
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();
Expand Down Expand Up @@ -302,14 +302,14 @@ async fn run(source: &Path, bundled: bool) -> Result<AbsolutePathBuf, Error> {
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);
}
}
Expand Down Expand Up @@ -381,11 +381,10 @@ fn confirm(prompt: &str, default: bool) -> Result<bool, Error> {
.map_err(|error| Error::Other(error.to_string().into()))
}

fn manager_mode(variable: &str) -> Option<config::ShimMode> {
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<ManagerPreference>) -> Option<config::ShimMode> {
match preference? {
ManagerPreference::Yes => Some(config::ShimMode::Managed),
ManagerPreference::No => Some(config::ShimMode::SystemFirst),
}
}

Expand Down
101 changes: 32 additions & 69 deletions crates/vp_global_cli/src/shim/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -1331,70 +1322,42 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option<AbsolutePathBuf
}
}

/// Explicit bypass skips Vite+ installations and the directories listed by the caller.
fn find_external_tool_in(tool: &str, cwd: &AbsolutePath) -> Option<AbsolutePathBuf> {
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::path::PathBuf> = 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<std::path::PathBuf>,
self_exe: Option<std::path::PathBuf>,
}

// 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<Self> {
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<AbsolutePathBuf> {
let inputs = ExternalToolInputs::from_process_env()?;
vp_command::find_external_tool(tool, &inputs.query(), cwd)
}

#[cfg(test)]
mod tests {
use tempfile::TempDir;
Expand Down
14 changes: 7 additions & 7 deletions crates/vp_installer/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,10 @@ async fn do_install(opts: &cli::Options, dirs: &VpDirs) -> Result<(), Box<dyn st
command
.env_remove(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK)
.env(vp_shared::env_vars::VP_SELF_SETUP_REPLACE_EXISTING, if opts.yes { "1" } else { "0" })
.env("VP_NODE_MANAGER", manager)
.env(vp_shared::env_vars::VP_NODE_MANAGER, manager)
.env(
"VP_PM_MANAGER",
std::env::var("VP_PM_MANAGER")
vp_shared::env_vars::VP_PM_MANAGER,
std::env::var(vp_shared::env_vars::VP_PM_MANAGER)
.ok()
.filter(|value| matches!(value.as_str(), "yes" | "no"))
.unwrap_or_else(|| manager.to_string()),
Expand Down Expand Up @@ -279,7 +279,7 @@ fn auto_detect_node_manager_for_state(state: NodeShimState, interactive: bool) -
// VP_NODE_MANAGER env var: only "yes" and "no" are recognized;
// unrecognized values fall through to normal auto-detection
// (matching install.ps1/install.sh behavior).
if let Ok(val) = std::env::var("VP_NODE_MANAGER") {
if let Ok(val) = std::env::var(vp_shared::env_vars::VP_NODE_MANAGER) {
if val.eq_ignore_ascii_case("yes") {
return true;
}
Expand Down Expand Up @@ -492,7 +492,7 @@ mod tests {

#[test]
fn foreign_node_requires_interactive_or_explicit_consent() {
EnvConfig::with_vars([("VP_NODE_MANAGER", None), ("CI", Some("true"))], |_| {
EnvConfig::with_vars([(env_vars::VP_NODE_MANAGER, None), ("CI", Some("true"))], |_| {
assert!(
!auto_detect_node_manager_for_state(NodeShimState::Foreign, false),
"silent CI setup must preserve a foreign Node executable"
Expand All @@ -503,10 +503,10 @@ mod tests {
);
});

EnvConfig::with_vars([("VP_NODE_MANAGER", Some("yes")), ("CI", None)], |_| {
EnvConfig::with_vars([(env_vars::VP_NODE_MANAGER, Some("yes")), ("CI", None)], |_| {
assert!(auto_detect_node_manager_for_state(NodeShimState::Foreign, false));
});
EnvConfig::with_vars([("VP_NODE_MANAGER", Some("no")), ("CI", None)], |_| {
EnvConfig::with_vars([(env_vars::VP_NODE_MANAGER, Some("no")), ("CI", None)], |_| {
assert!(!auto_detect_node_manager_for_state(NodeShimState::Owned, true));
});
}
Expand Down
47 changes: 47 additions & 0 deletions crates/vp_shared/src/env_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,28 @@ fn persisted_dir_envs() -> 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<Self> {
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
Expand Down Expand Up @@ -173,6 +195,25 @@ pub struct EnvConfig {
pub yarn_version: Option<String>,
pub bun_version: Option<String>,

/// Node.js management preference.
///
/// Env: `VP_NODE_MANAGER`
pub node_manager: Option<ManagerPreference>,

/// Package-manager management preference shared by every family.
///
/// Env: `VP_PM_MANAGER`
pub pm_manager: Option<ManagerPreference>,

/// 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<ManagerPreference>,
pub pnpm_manager: Option<ManagerPreference>,
pub yarn_manager: Option<ManagerPreference>,
pub bun_manager: Option<ManagerPreference>,

/// User home directory.
///
/// Resolved once from `HOME` or `USERPROFILE` in platform order. See
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading