From 5b375ce551cee0b2379d6fef08482b1bbc8fda98 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 21:34:14 +0800 Subject: [PATCH 01/15] feat(env): add fallback shim directory and preserve trampoline roots --- crates/vp_shared/src/dirs.rs | 13 ++++++++++--- crates/vp_trampoline/src/cmdline.rs | 7 +++++-- crates/vp_trampoline/src/main.rs | 15 ++++++++++----- crates/vp_trampoline/src/win.rs | 6 +++++- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/crates/vp_shared/src/dirs.rs b/crates/vp_shared/src/dirs.rs index 1dcf588e8f..7d4460bbb8 100644 --- a/crates/vp_shared/src/dirs.rs +++ b/crates/vp_shared/src/dirs.rs @@ -29,7 +29,7 @@ pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; pub const SHIM_POINTER_HEADER: &str = "vite-plus-shim-v1"; /// Extension for a Windows trampoline sidecar. The sidecar records the layout, -/// data root, and cache root. It is next to its executable +/// data, bin, and cache roots. It is next to its executable /// (`/.shim`). /// /// The complete `VP_BIN_DIR`, `VP_DATA_DIR`, and `VP_CACHE_DIR` group can put @@ -118,6 +118,12 @@ impl VpDirs { }) } + /// Low-priority managed shims live under the owned data root, even when bin is shared. + #[must_use] + pub fn fallback_bin(&self) -> AbsolutePathBuf { + self.data.join("fallback-bin") + } + /// Return the resolution mode that selected these roots. #[must_use] pub const fn layout(&self) -> VpDirsLayout { @@ -147,10 +153,11 @@ impl VpDirs { std::fs::create_dir_all(parent)?; } let contents = format!( - "{SHIM_POINTER_HEADER}\nlayout={}\ndata={}\ncache={}\n", + "{SHIM_POINTER_HEADER}\nlayout={}\ndata={}\ncache={}\nbin={}\n", self.layout.as_str(), self.data.as_path().to_string_lossy(), - self.cache.as_path().to_string_lossy() + self.cache.as_path().to_string_lossy(), + self.bin.as_path().to_string_lossy() ); std::fs::write(exe_path.with_extension(SHIM_POINTER_EXTENSION), contents) } diff --git a/crates/vp_trampoline/src/cmdline.rs b/crates/vp_trampoline/src/cmdline.rs index 9941a590fa..9de5ae4998 100644 --- a/crates/vp_trampoline/src/cmdline.rs +++ b/crates/vp_trampoline/src/cmdline.rs @@ -23,7 +23,7 @@ pub const SHIM_POINTER_HEADER: &str = "vite-plus-shim-v1"; #[derive(Debug, PartialEq, Eq)] pub enum ShimLayout<'a> { SingleRoot, - Split { cache: &'a str }, + Split { cache: &'a str, bin: Option<&'a str> }, } #[derive(Debug, PartialEq, Eq)] @@ -48,11 +48,14 @@ pub fn parse_shim_pointer(bytes: &[u8]) -> Option> { let mut layout = None; let mut data = None; let mut cache = None; + let mut bin = None; for line in lines { if let Some(value) = line.strip_prefix("layout=") { layout = Some(value); } else if let Some(value) = line.strip_prefix("data=") { data = (!value.is_empty()).then_some(value); + } else if let Some(value) = line.strip_prefix("bin=") { + bin = (!value.is_empty()).then_some(value); } else if let Some(value) = line.strip_prefix("cache=") { cache = (!value.is_empty()).then_some(value); } @@ -61,7 +64,7 @@ pub fn parse_shim_pointer(bytes: &[u8]) -> Option> { let data = data?; let layout = match layout? { "single-root" => ShimLayout::SingleRoot, - "split" => ShimLayout::Split { cache: cache? }, + "split" => ShimLayout::Split { cache: cache?, bin }, _ => return None, }; Some(ShimPointer { data, layout }) diff --git a/crates/vp_trampoline/src/main.rs b/crates/vp_trampoline/src/main.rs index 8ae88462f1..7892f757f2 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -58,7 +58,7 @@ mod portable { enum ShimLayout { SingleRoot, - Split { cache: PathBuf }, + Split { cache: PathBuf, bin: PathBuf }, } struct ShimPointer { @@ -95,7 +95,13 @@ mod portable { let parsed = cmdline::parse_shim_pointer(&bytes)?; let layout = match parsed.layout { ParsedShimLayout::SingleRoot => ShimLayout::SingleRoot, - ParsedShimLayout::Split { cache } => ShimLayout::Split { cache: PathBuf::from(cache) }, + ParsedShimLayout::Split { cache, bin } => ShimLayout::Split { + cache: PathBuf::from(cache), + // Old sidecars only occur beside the main bin entrypoints. + bin: bin + .map(PathBuf::from) + .unwrap_or_else(|| exe_path.parent().unwrap().to_path_buf()), + }, }; Some(ShimPointer { data: PathBuf::from(parsed.data), layout }) } @@ -107,7 +113,6 @@ mod portable { exe_path.file_stem().and_then(|s| s.to_str()).unwrap_or_else(|| process::exit(1)); // 2. Locate vp.exe via `.shim` (written next to every trampoline). - let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1)); let Some(location) = resolve_vp_exe(&exe_path) else { use std::io::Write; let stderr = std::io::stderr(); @@ -123,10 +128,10 @@ mod portable { ShimLayout::SingleRoot => { cmd.env("VP_HOME", &location.pointer.data); } - ShimLayout::Split { cache } => { + ShimLayout::Split { cache, bin } => { cmd.env_remove("VP_HOME"); cmd.env("VP_DATA_DIR", &location.pointer.data); - cmd.env("VP_BIN_DIR", bin_dir); + cmd.env("VP_BIN_DIR", bin); cmd.env("VP_CACHE_DIR", cache); } } diff --git a/crates/vp_trampoline/src/win.rs b/crates/vp_trampoline/src/win.rs index 62259eff2c..c37d3841da 100644 --- a/crates/vp_trampoline/src/win.rs +++ b/crates/vp_trampoline/src/win.rs @@ -467,10 +467,14 @@ pub fn run() -> ! { ShimLayout::SingleRoot => { set_env(w!("VP_HOME"), b"VP_HOME", Some(&data)); } - ShimLayout::Split { cache } => { + ShimLayout::Split { cache, bin } => { let Some(cache) = utf8_path(cache) else { fail_invalid_pointer(&pointer_path); }; + // A fallback shim is outside the main bin directory. Older sidecars use the executable parent. + let bin = bin + .map(|bin| utf8_path(bin).unwrap_or_else(|| fail_invalid_pointer(&pointer_path))); + let bin_dir = bin.as_deref().unwrap_or(bin_dir); set_env(w!("VP_HOME"), b"VP_HOME", None); set_env(w!("VP_DATA_DIR"), b"VP_DATA_DIR", Some(&data)); set_env(w!("VP_BIN_DIR"), b"VP_BIN_DIR", Some(bin_dir)); From b60d8e4b0c6f53e84ead127aa25380fb8569be8b Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 21:34:23 +0800 Subject: [PATCH 02/15] feat(env): reconcile shim placement and shell paths from preferences --- crates/vp_global_cli/src/cli.rs | 2 +- .../vp_global_cli/src/commands/env/doctor.rs | 26 ++- crates/vp_global_cli/src/commands/env/off.rs | 3 +- crates/vp_global_cli/src/commands/env/on.rs | 1 + .../vp_global_cli/src/commands/env/setup.rs | 175 +++++++++++++----- .../src/commands/env/setup/unix.rs | 2 + crates/vp_global_cli/src/commands/implode.rs | 7 +- crates/vp_global_cli/src/self_setup.rs | 10 +- crates/vp_global_cli/src/self_setup/shell.rs | 4 +- crates/vp_global_cli/src/shim/mod.rs | 11 +- 10 files changed, 177 insertions(+), 64 deletions(-) diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 8d549844c8..306b981a0f 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -344,7 +344,7 @@ Examples: scope: Option, }, - /// Create or update shims in VP_HOME/bin + /// Create or update managed and system-first tool shims Setup { /// Force refresh shims even if they exist #[arg(long)] diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 5916f82acb..f315c5b59e 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -222,11 +222,15 @@ async fn check_shims(scope: EnvScope) -> bool { return false; } + let settings = match load_config().await { + Ok(settings) => settings, + Err(_) => return false, + }; let mut missing = Vec::new(); let tools = selected_shim_tools(scope); for tool in &tools { - let shim_path = bin_dir.join(shim_filename(tool)); + let shim_path = super::setup::shim_dir(&settings, tool).join(shim_filename(tool)); if !tokio::fs::try_exists(&shim_path).await.unwrap_or(false) { missing.push(*tool); } @@ -556,10 +560,22 @@ async fn check_path(scope: EnvScope) -> bool { return false; } + let fallback = vp_shared::EnvConfig::get().dirs.fallback_bin(); + if !paths.iter().any(|path| path == fallback.as_path()) { + print_check(&style(output::CROSS).red().to_string(), "Fallback dir", "not in PATH"); + print_path_fix(&vp_shared::EnvConfig::get().dirs.config); + return false; + } + let settings = match load_config().await { + Ok(settings) => settings, + Err(_) => return false, + }; + // Show which tool would be executed for each shim for tool in selected_shim_tools(scope) { if let Some(tool_path) = find_in_path(tool) { - let expected = bin_dir.join(shim_filename(tool)); + let expected_dir = super::setup::shim_dir(&settings, tool); + let expected = expected_dir.join(shim_filename(tool)); let display = abbreviate_home(&tool_path.display().to_string()); if tool_path == expected.as_path() { print_check( @@ -567,6 +583,12 @@ async fn check_path(scope: EnvScope) -> bool { tool, &format!("{display} {}", style("(vp shim)").dim()), ); + } else if expected_dir == fallback { + print_check( + &style(output::CHECK).green().to_string(), + tool, + &format!("{display} (system)"), + ); } else { print_check( &style(output::WARN_SIGN).yellow().to_string(), diff --git a/crates/vp_global_cli/src/commands/env/off.rs b/crates/vp_global_cli/src/commands/env/off.rs index c528ed6ca0..621a82fbef 100644 --- a/crates/vp_global_cli/src/commands/env/off.rs +++ b/crates/vp_global_cli/src/commands/env/off.rs @@ -1,7 +1,7 @@ //! Enable system-first mode command. //! //! Handles `vp env off` to set shim mode to "system_first" - -//! shims prefer system Node.js, fallback to managed if not found. +//! Tool shims move to the fallback directory so PATH prefers system tools. use std::process::ExitStatus; @@ -24,6 +24,7 @@ pub async fn execute(scope: Option) -> Result { ShimMode::SystemFirst, ); } + super::setup::refresh_shims(&std::env::current_exe()?, &config, false, false).await?; save_config(&config).await?; let component = match scope { diff --git a/crates/vp_global_cli/src/commands/env/on.rs b/crates/vp_global_cli/src/commands/env/on.rs index b414c57185..67a1205607 100644 --- a/crates/vp_global_cli/src/commands/env/on.rs +++ b/crates/vp_global_cli/src/commands/env/on.rs @@ -23,6 +23,7 @@ pub async fn execute(scope: Option) -> Result { ShimMode::Managed, ); } + super::setup::refresh_shims(&std::env::current_exe()?, &config, false, false).await?; save_config(&config).await?; let component = match scope { diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 621f83d685..87a664e00d 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -103,35 +103,9 @@ pub(crate) async fn execute_for_binary( // Create wrapper script in bin/ setup_vp_wrapper(current_exe, bin_dir, refresh_entrypoints).await?; - // Create default tool shims - let mut created = Vec::new(); - let mut skipped = Vec::new(); - - for tool in crate::shim::DEFAULT_SHIM_TOOLS { - let refresh_tool = - if matches!(*tool, "vpx" | "vpr") { refresh_entrypoints } else { refresh }; - let result = create_shim(current_exe, bin_dir, tool, refresh_tool).await?; - if result { - created.push(*tool); - } else { - skipped.push(*tool); - } - - // Remove legacy .cmd/.ps1/extensionless launchers that would shadow - // an existing trampoline .exe in PowerShell/Git Bash (create_shim - // skips existing shims without cleaning siblings). - #[cfg(windows)] - cleanup_legacy_windows_shim(bin_dir, tool).await; - - // Drop stale `npm install -g` link configs for default shim names. The - // link itself is replaced by the shim above, and a leftover Npm-sourced - // BinConfig would let a later `npm uninstall -g` delete the default shim. - if let Ok(Some(config)) = super::bin_config::BinConfig::load(tool).await - && config.source == super::bin_config::BinSource::Npm - { - let _ = super::bin_config::BinConfig::delete(tool).await; - } - } + let settings = super::config::load_config().await?; + let (created, skipped) = + refresh_shims(current_exe, &settings, refresh, refresh_entrypoints).await?; #[cfg(windows)] if refresh { @@ -149,8 +123,7 @@ pub(crate) async fn execute_for_binary( // Print results if !created.is_empty() { output::raw(&help::render_heading("Created Shims")); - for tool in &created { - let shim_path = bin_dir.join(shim_filename(tool)); + for shim_path in &created { output::raw(&format!(" {}", shim_path.as_path().display())); } } @@ -160,8 +133,7 @@ pub(crate) async fn execute_for_binary( output::raw(""); } output::raw(&help::render_heading("Skipped Shims")); - for tool in &skipped { - let shim_path = bin_dir.join(shim_filename(tool)); + for shim_path in &skipped { output::raw(&format!(" {}", shim_path.as_path().display())); } output::raw(""); @@ -174,6 +146,85 @@ pub(crate) async fn execute_for_binary( Ok(ExitStatus::default()) } +/// Resolve placement independently for Node.js and each package-manager family. +pub(super) fn shim_dir(settings: &super::config::Config, tool: &str) -> vt_path::AbsolutePathBuf { + let dirs = &vp_shared::EnvConfig::get().dirs; + let mode = if tool == "node" { + settings.node_shim_mode + } else if let Some(kind) = vp_pm_cli::PackageManagerType::from_tool(tool) { + settings.package_manager_shim_mode_for(kind) + } else { + super::config::ShimMode::Managed + }; + match mode { + super::config::ShimMode::Managed => dirs.bin.clone(), + super::config::ShimMode::SystemFirst => dirs.fallback_bin(), + } +} + +/// Reconcile both shim directories from effective preferences; setup and mode changes share this path. +pub(super) async fn refresh_shims( + current_exe: &std::path::Path, + settings: &super::config::Config, + refresh: bool, + refresh_entrypoints: bool, +) -> Result<(Vec, Vec), Error> { + let dirs = &vp_shared::EnvConfig::get().dirs; + let fallback_bin = dirs.fallback_bin(); + tokio::fs::create_dir_all(&dirs.bin).await?; + tokio::fs::create_dir_all(&fallback_bin).await?; + let owns_shim = |path: &vt_path::AbsolutePath| { + crate::commands::global::install::is_vp_shim_target(path) + || (std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) + && std::fs::canonicalize(path).is_ok_and(|target| { + std::fs::canonicalize(current_exe).is_ok_and(|source| source == target) + })) + }; + let mut created = Vec::new(); + let mut skipped = Vec::new(); + for tool in crate::shim::DEFAULT_SHIM_TOOLS { + let bin_dir = shim_dir(settings, tool); + let other_dir = if bin_dir == dirs.bin { &fallback_bin } else { &dirs.bin }; + let shim_path = bin_dir.join(shim_filename(tool)); + let refresh_tool = + if matches!(*tool, "vpx" | "vpr") { refresh_entrypoints } else { refresh }; + let exists = tokio::fs::symlink_metadata(&shim_path).await.is_ok(); + // A configured bin directory can contain foreign tools. Never replace them to change modes. + let foreign = exists && crate::shim::is_core_shim_tool(tool) && !owns_shim(&shim_path); + if !foreign && create_shim(current_exe, &bin_dir, tool, refresh_tool).await? { + created.push(shim_path); + } else { + skipped.push(shim_path); + } + + let stale = other_dir.join(shim_filename(tool)); + if owns_shim(&stale) { + #[cfg(unix)] + tokio::fs::remove_file(&stale).await?; + #[cfg(windows)] + { + remove_or_rename_to_old(&stale).await; + let pointer = stale.as_path().with_extension(vp_shared::SHIM_POINTER_EXTENSION); + tokio::fs::remove_file(pointer).await?; + cleanup_legacy_windows_shim(other_dir, tool).await; + } + } + #[cfg(windows)] + if !foreign { + cleanup_legacy_windows_shim(&bin_dir, tool).await; + } + // Old npm-global metadata must not let uninstall remove a default shim. + if let Ok(Some(config)) = BinConfig::load(tool).await + && config.source == super::bin_config::BinSource::Npm + { + BinConfig::delete(tool).await?; + } + } + #[cfg(windows)] + cleanup_old_files(&fallback_bin).await; + Ok((created, skipped)) +} + /// Remove legacy managed installs left by versions that did not expose package-manager shims. async fn cleanup_legacy_package_manager_installs(bin_dir: &vt_path::AbsolutePath) { for package_name in LEGACY_PACKAGE_MANAGER_PACKAGES { @@ -610,16 +661,20 @@ pub(crate) async fn cleanup_legacy_windows_shim(bin_dir: &vt_path::AbsolutePath, const ENV_TEMPLATE_POSIX: &str = r#"#!/bin/sh # Vite+ environment setup (https://viteplus.dev) __ENV_EXPORTS____vp_bin="__VP_BIN__" -while case ":${PATH}:" in *":${__vp_bin}:"*) true ;; *) false ;; esac; do - __vp_tmp=":${PATH}:" - __vp_before="${__vp_tmp%%":${__vp_bin}:"*}" - __vp_before="${__vp_before#:}" - __vp_after="${__vp_tmp#*":${__vp_bin}:"}" - __vp_after="${__vp_after%:}" - PATH="${__vp_before}${__vp_before:+${__vp_after:+:}}${__vp_after}" +__vp_fallback="__VP_FALLBACK_BIN__" +for __vp_dir in "$__vp_bin" "$__vp_fallback"; do + while case ":${PATH}:" in *":${__vp_dir}:"*) true ;; *) false ;; esac; do + __vp_tmp=":${PATH}:" + __vp_before="${__vp_tmp%%":${__vp_dir}:"*}" + __vp_before="${__vp_before#:}" + __vp_after="${__vp_tmp#*":${__vp_dir}:"}" + __vp_after="${__vp_after%:}" + PATH="${__vp_before}${__vp_before:+${__vp_after:+:}}${__vp_after}" + done done -export PATH="${__vp_bin}${PATH:+:${PATH}}" -unset __vp_bin __vp_tmp __vp_before __vp_after +export PATH="${__vp_bin}${PATH:+:${PATH}}:${__vp_fallback}" +unset __vp_bin __vp_fallback __vp_dir __vp_tmp __vp_before __vp_after +hash -r 2>/dev/null || true # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -646,7 +701,9 @@ vp() { eval "$__vp_out" else unset __vp_env_use - command vp "$@" + command vp "$@" || return $? + # Mode changes move executables between directories; discard cached command paths. + hash -r 2>/dev/null || true fi } @@ -693,7 +750,10 @@ const ENV_TEMPLATE_FISH: &str = r#"# Vite+ environment setup (https://viteplus.d __ENV_EXPORTS__while set -l __vp_idx (contains -i -- "__VP_BIN__" $PATH) set -e PATH[$__vp_idx] end -set -gx PATH "__VP_BIN__" $PATH +while set -l __vp_idx (contains -i -- "__VP_FALLBACK_BIN__" $PATH) + set -e PATH[$__vp_idx] +end +set -gx PATH "__VP_BIN__" $PATH "__VP_FALLBACK_BIN__" # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -753,7 +813,7 @@ complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" // Completions delegate to Fish dynamically (VP_COMPLETE=fish) because clap_complete_nushell // generates multiple rest params (e.g. for `vp install`), which Nushell does not support. const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev) -__ENV_EXPORTS__$env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") +__ENV_EXPORTS__$env.PATH = ($env.PATH | where { $in != "__VP_BIN__" and $in != "__VP_FALLBACK_BIN__" } | prepend "__VP_BIN__" | append "__VP_FALLBACK_BIN__") # Shell function wrapper: intercepts `vp env use` to parse its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -829,9 +889,9 @@ export extern "vpr" [...args: string@"nu-complete vpr"] const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) __ENV_EXPORTS__$__vp_bin = '__VP_BIN_WIN__' -if ($env:Path -split ';' -notcontains $__vp_bin) { - $env:Path = "$__vp_bin;$env:Path" -} +$__vp_fallback = '__VP_FALLBACK_BIN_WIN__' +$__vp_paths = @($env:Path -split ';' | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback }) +$env:Path = (@($__vp_bin) + $__vp_paths + @($__vp_fallback)) -join ';' # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -1048,6 +1108,7 @@ fn render_env_content(shell: EnvShell, config: &vp_shared::EnvConfig) -> String let dirs = &config.dirs; let home_dir = config.user_home.as_path(); let bin_path_ref = render_home_relative_path(dirs.bin.as_path(), home_dir); + let fallback_path_ref = render_home_relative_path(dirs.fallback_bin().as_path(), home_dir); let dir_envs = render_dir_envs(shell, config); match shell { @@ -1056,7 +1117,12 @@ fn render_env_content(shell: EnvShell, config: &vp_shared::EnvConfig) -> String &bin_path_ref, escape_posix_double_quoted_string, ); + let fallback_path_ref = escape_home_relative_double_quoted_path( + &fallback_path_ref, + escape_posix_double_quoted_string, + ); ENV_TEMPLATE_POSIX + .replace("__VP_FALLBACK_BIN__", &fallback_path_ref) .replace("__ENV_EXPORTS__", &dir_envs) .replace("__VP_BIN__", &bin_path_ref) } @@ -1065,7 +1131,12 @@ fn render_env_content(shell: EnvShell, config: &vp_shared::EnvConfig) -> String &bin_path_ref, escape_fish_double_quoted_string, ); + let fallback_path_ref = escape_home_relative_double_quoted_path( + &fallback_path_ref, + escape_fish_double_quoted_string, + ); ENV_TEMPLATE_FISH + .replace("__VP_FALLBACK_BIN__", &fallback_path_ref) .replace("__ENV_EXPORTS__", &dir_envs) .replace("__VP_BIN__", &bin_path_ref) } @@ -1075,6 +1146,10 @@ fn render_env_content(shell: EnvShell, config: &vp_shared::EnvConfig) -> String let bin_path_ref_nu = escape_nu_double_quoted_string(&render_nu_path_ref(&bin_path_ref)); ENV_TEMPLATE_NU + .replace( + "__VP_FALLBACK_BIN__", + &escape_nu_double_quoted_string(&render_nu_path_ref(&fallback_path_ref)), + ) .replace("__ENV_EXPORTS__", &dir_envs) .replace("__VP_BIN__", &bin_path_ref_nu) } @@ -1083,6 +1158,10 @@ fn render_env_content(shell: EnvShell, config: &vp_shared::EnvConfig) -> String let bin_path_win = escape_powershell_single_quoted_string(&dirs.bin.as_path().display().to_string()); ENV_TEMPLATE_PS1 + .replace( + "__VP_FALLBACK_BIN_WIN__", + &escape_powershell_single_quoted_string(&dirs.fallback_bin().to_string()), + ) .replace("__ENV_EXPORTS__", &dir_envs) .replace("__VP_BIN_WIN__", &bin_path_win) } diff --git a/crates/vp_global_cli/src/commands/env/setup/unix.rs b/crates/vp_global_cli/src/commands/env/setup/unix.rs index 097f7ae163..8314cbeaff 100644 --- a/crates/vp_global_cli/src/commands/env/setup/unix.rs +++ b/crates/vp_global_cli/src/commands/env/setup/unix.rs @@ -8,6 +8,7 @@ pub(super) fn external_shim_target(binary: &Path) -> Option { let canonical = std::fs::canonicalize(binary).ok()?; let env = EnvConfig::get(); let bin = env.dirs.bin.as_path(); + let fallback_bin = env.dirs.fallback_bin(); let cwd = vt_path::current_dir().ok()?; let path = std::env::var_os("PATH").unwrap_or_default(); let mut candidates: Vec<_> = std::env::split_paths(&path).map(|dir| dir.join("vp")).collect(); @@ -27,6 +28,7 @@ pub(super) fn external_shim_target(binary: &Path) -> Option { candidate != &canonical && std::fs::canonicalize(candidate).is_ok_and(|target| target == canonical) && !passes_through_shims(candidate, bin) + && !passes_through_shims(candidate, fallback_bin.as_path()) }) } diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 378cc0c582..d16c946b93 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -912,11 +912,14 @@ fn remove_vite_plus_lines( /// Remove the vp bin directory from the Windows User PATH via PowerShell. #[cfg(windows)] fn remove_windows_path_entry(bin_path: &vt_path::AbsolutePath) -> std::io::Result<()> { - let bin_str = bin_path.as_path().to_string_lossy(); + let bin_str = super::env::setup::escape_powershell_single_quoted_string(&bin_path.to_string()); + let fallback = super::env::setup::escape_powershell_single_quoted_string( + &vp_shared::EnvConfig::get().dirs.fallback_bin().to_string(), + ); let script = vt_str::format!( "[Environment]::SetEnvironmentVariable('Path', \ ([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | \ - Where-Object {{ $_ -ne '{bin_str}' }}) -join ';', 'User')" + Where-Object {{ $_ -ne '{bin_str}' -and $_ -ne '{fallback}' }}) -join ';', 'User')" ); let status = std::process::Command::new("powershell") .args(["-NoProfile", "-Command", &script]) diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index 48e45c215b..fe65130603 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -297,6 +297,12 @@ async fn run(source: &Path, bundled: bool) -> Result { config::save_config(&settings).await?; } + // Existing Windows installs also need the fallback directory in their persistent PATH. + #[cfg(windows)] + if in_place && std::env::var(env_vars::VP_SELF_SETUP_NO_MODIFY_PATH).as_deref() != Ok("1") { + shell::configure().await?; + } + // 2. Activate a standalone download; an upgrade hook must not overwrite rollback history. if deploy { install::save_previous_version(&dirs.data).await?; @@ -310,9 +316,7 @@ async fn run(source: &Path, bundled: bool) -> Result { // 3. Run setup in this process. Spawning the unmarked binary here would reenter self-setup. tokio::fs::create_dir_all(&dirs.bin).await?; - // Always create and refresh shims, even in system-first mode; `vp env off` and per-tool preferences control runtime dispatch. - // VpDirs::bin is private by default, so replacing its shims leaves system-first tools elsewhere on PATH intact. - // Users explicitly pointing VpDirs::bin at a shared directory accept replacement of conflicting entries there. + // Setup places each tool according to its effective management mode. setup::execute_for_binary(binary.as_path(), true, true, false).await?; if deploy { let name = version_dir diff --git a/crates/vp_global_cli/src/self_setup/shell.rs b/crates/vp_global_cli/src/self_setup/shell.rs index 9193ee921f..23db53d582 100644 --- a/crates/vp_global_cli/src/self_setup/shell.rs +++ b/crates/vp_global_cli/src/self_setup/shell.rs @@ -93,8 +93,10 @@ pub(super) async fn configure() -> Result<(), Error> { #[cfg(windows)] { let bin = setup::escape_powershell_single_quoted_string(&config.dirs.bin.to_string()); + let fallback = + setup::escape_powershell_single_quoted_string(&config.dirs.fallback_bin().to_string()); let script = format!( - "$bin = '{bin}'; $path = [Environment]::GetEnvironmentVariable('Path', 'User'); if (($path -split ';') -notcontains $bin) {{ [Environment]::SetEnvironmentVariable('Path', ($bin + ';' + $path), 'User') }}" + "$bin = '{bin}'; $fallback = '{fallback}'; $path = @([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object {{ $_ -and $_ -ne $bin -and $_ -ne $fallback }}); [Environment]::SetEnvironmentVariable('Path', ((@($bin) + $path + @($fallback)) -join ';'), 'User')" ); let result = tokio::process::Command::new("powershell") .args(["-NoProfile", "-NonInteractive", "-Command", &script]) diff --git a/crates/vp_global_cli/src/shim/mod.rs b/crates/vp_global_cli/src/shim/mod.rs index cb235dba3f..b94c9b8878 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -19,14 +19,12 @@ pub use dispatch::dispatch; pub(crate) use dispatch::find_system_tool; use vp_shared::env_vars; -use crate::commands::env::config::get_bin_dir; - /// Default shims created by `vp env setup`. pub const DEFAULT_SHIM_TOOLS: &[&str] = &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vpx", "vpr"]; /// Extract the tool name from argv[0]. -/// We hope all bins should be put under $VP_HOME/bin +/// Core tool shims can live in either the main or fallback bin directory. /// /// Handles various formats: /// - `node` (Unix) @@ -42,9 +40,10 @@ pub fn extract_tool_name(argv0: &str) -> String { if cfg!(target_os = "linux") { stem } else { - let bin_dir = get_bin_dir(); - if let Ok(bin_dir) = bin_dir { - if let Ok(read_dir) = fs::read_dir(&bin_dir) { + let dirs = &vp_shared::EnvConfig::get().dirs; + let fallback = dirs.fallback_bin(); + for bin_dir in [&dirs.bin, &fallback] { + if let Ok(read_dir) = fs::read_dir(bin_dir) { for bin in read_dir.flatten() { if bin.path().file_stem().unwrap_or_default().to_string_lossy().to_lowercase() == stem.to_lowercase() From 900f2dafda794c22893c9350f0d80eff5bd41ff2 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 21:34:33 +0800 Subject: [PATCH 03/15] fix(env): resolve system-first tools without shim recursion --- crates/vp_global_cli/src/js_executor.rs | 2 +- crates/vp_global_cli/src/shim/dispatch.rs | 217 +++++----------------- 2 files changed, 49 insertions(+), 170 deletions(-) diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 245dcbd900..5b4bfac8a4 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -501,7 +501,7 @@ async fn has_valid_version_source(project_path: &AbsolutePath) -> Result i32 { return crate::commands::vpr::execute_vpr(args, &cwd).await; } - // A child may replace PATH while retaining the injection marker. + // Preserve an already selected managed executable, including direct calls to a shim from its children. + // External manager shims must not be re-entered here: they may have fallen back to us. if env.contains(tool) - && let Some(system_path) = find_system_tool(tool) + && let Some(path) = find_system_tool(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)) + }) { - tracing::debug!("tool path already injected: {tool}"); - return exec::exec_tool(&system_path, args, env); + return exec::exec_tool(&path, args, env); } // Check bypass mode (explicit environment variable) @@ -746,46 +755,7 @@ pub async fn dispatch(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { return bypass_to_system(tool, args, env); } - // Check shim mode from config - let shim_mode = load_shim_mode(tool).await; - if shim_mode == ShimMode::SystemFirst { - tracing::debug!("system-first mode enabled"); - // In system-first mode, try to find system tool first - if let Some(system_path) = find_system_tool(tool) { - let child_env = if PackageManagerType::from_tool(tool).is_some() { - match prepare_node_path_for_system_package_manager(env).await { - Ok(env) => env, - Err(error) => { - eprintln!( - "vp: Failed to prepare Node.js for system package manager: {error}" - ); - return 1; - } - } - } else { - env - }; - // Append current bin_dir to VP_BYPASS to prevent infinite loops - // when multiple vite-plus installations exist in PATH. - // The next installation will filter all accumulated paths. - if let Ok(bin_dir) = config::get_bin_dir() { - let bypass_val = match std::env::var_os(env_vars::VP_BYPASS) { - Some(existing) => { - let mut paths: Vec<_> = std::env::split_paths(&existing).collect(); - paths.push(bin_dir.as_path().to_path_buf()); - std::env::join_paths(paths).unwrap_or(existing) - } - None => std::ffi::OsString::from(bin_dir.as_path()), - }; - // SAFETY: Setting env vars before exec (which replaces the process) is safe - unsafe { - std::env::set_var(env_vars::VP_BYPASS, bypass_val); - } - } - return exec::exec_tool(&system_path, args, child_env); - } - // Fall through to managed if system not found - } + // PATH placement selects system-first precedence. Reaching a shim always selects its managed tool. // Package binaries use their install-time Node.js version; core shims use // the project-resolved runtime below. @@ -805,7 +775,9 @@ pub async fn dispatch(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { // Ensure Node.js is installed and locate its binary for PATH preparation. // Package-manager shims can use their own declared version, but JS-based // package managers still need the Node.js runtime selected by its mode. - let inherited_node = env.contains("node").then(|| find_system_tool("node")).flatten(); + let inherited_node = (PackageManagerType::from_tool(tool).is_some() && env.contains("node")) + .then(|| find_system_tool("node")) + .flatten(); let node_is_inherited = inherited_node.is_some(); let system_node = if node_is_inherited { inherited_node @@ -968,31 +940,6 @@ fn read_node_version(node_path: &AbsolutePath) -> Option { .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) } -async fn prepare_node_path_for_system_package_manager( - mut env: ToolPathEnv, -) -> Result { - if env.contains("node") && find_system_tool("node").is_some() { - return Ok(env); - } - let config = config::load_config().await?; - if config.node_shim_mode == ShimMode::SystemFirst - && let Some(node) = find_system_tool("node") - && let Some(bin_dir) = node.parent() - { - env.prepend(bin_dir, &["node"], PrependOptions::default())?; - return Ok(env); - } - - let cwd = current_dir()?; - let resolution = resolve_with_cache(&cwd).await.map_err(|error| Error::Other(error.into()))?; - let node = - ensure_installed(&resolution.version).await.map_err(|error| Error::Other(error.into()))?; - let bin_dir = - node.parent().ok_or_else(|| Error::Other("Node.js has no bin directory".into()))?; - env.prepend(bin_dir, &["node"], PrependOptions::default())?; - Ok(env) -} - /// Dispatch a package binary shim. /// /// Finds the package that provides this binary and executes it with the @@ -1340,112 +1287,44 @@ pub(crate) fn resolve_external_node_executable( .ok_or_else(|| format!("Invalid Node executable path: {}", executable.trim())) } -/// Load shim mode from config. -/// -/// Returns the default (Managed) if config cannot be read. -async fn load_shim_mode(tool: &str) -> ShimMode { - let Some(package_manager) = PackageManagerType::from_tool(tool) else { - return config::load_config().await.map(|config| config.node_shim_mode).unwrap_or_default(); - }; - resolve_package_manager_shim_mode(tool, package_manager).await -} - -async fn resolve_package_manager_shim_mode( - tool: &str, - package_manager: PackageManagerType, -) -> ShimMode { - let mut config = match config::load_config().await { - Ok(config) => config, - Err(error) => { - output::warn(&format!("Could not read package-manager shim preferences: {error}")); - return ShimMode::Managed; - } - }; - if let Some(mode) = config.configured_package_manager_shim_mode_for(package_manager) { - return mode; - } - - let Some(system_path) = find_system_tool(tool) else { - return ShimMode::Managed; - }; - - if !vp_shared::is_interactive_terminal() { - return ShimMode::Managed; - } - - let Some((mode, apply_to_all)) = - prompt_package_manager_shim_mode(package_manager, &system_path) - else { - output::note("Package-manager preference was not saved; using the system tool this time."); - return ShimMode::SystemFirst; - }; - if apply_to_all { - config.set_all_package_manager_shim_modes(mode); - } else { - config.set_package_manager_shim_mode(package_manager, mode); - } - if let Err(error) = config::save_config(&config).await { - output::warn(&format!("Could not save package-manager shim preferences: {error}")); - } - mode -} - -fn prompt_package_manager_shim_mode( - package_manager: PackageManagerType, - system_path: &AbsolutePath, -) -> Option<(ShimMode, bool)> { - let options = [ - "Use Vite+ for all package managers".to_string(), - format!("Use Vite+ for {package_manager}"), - format!("Use system {package_manager}"), - "Use system package managers".to_string(), - ]; - - output::raw_stderr("vp: Vite+ now can manage package-manager versions for each project."); - output::raw_stderr(&format!("Existing {package_manager}: {}", system_path.as_path().display())); - output::raw_stderr(""); - emit_prompt_milestone(&format!("pm-shim-choice:{package_manager}")); - let choice = Select::with_theme(&ColorfulTheme::default()) - .with_prompt(format!("How should {package_manager} run?")) - .items(&options) - .default(1) - .interact() - .ok()?; - - Some(match choice { - 0 => (ShimMode::Managed, true), - 1 => (ShimMode::Managed, false), - 2 => (ShimMode::SystemFirst, false), - _ => (ShimMode::SystemFirst, true), - }) -} - -/// Emit an invisible synchronization point for the PTY snapshot suite. -#[expect(clippy::disallowed_macros)] -fn emit_prompt_milestone(name: &str) { - use std::io::Write as _; - - if std::env::var_os(env_vars::VP_EMIT_MILESTONES).is_none_or(|value| value != "1") { - return; - } - let id = uuid::Uuid::new_v4(); - let encoded_name = base64_simd::URL_SAFE_NO_PAD.encode_to_string(name.as_bytes()); - let mut stderr = std::io::stderr().lock(); - let _ = write!(stderr, "\x1b]2;pty-terminal-test:{}:{encoded_name}\x1b\\", id.simple()); - let _ = stderr.flush(); -} - -/// Find a system tool in PATH, skipping the vite-plus bin directory and any -/// directories listed in `VP_BYPASS`. +/// Return the first PATH match only if it is external; a Vite+ shim selects managed resolution. /// /// Returns the absolute path to the tool if found, None otherwise. pub(crate) fn find_system_tool(tool: &str) -> Option { - find_system_tool_in(tool, ¤t_dir().ok()?) + let cwd = current_dir().ok()?; + if std::env::var_os(env_vars::VP_BYPASS).is_some() { + return find_external_tool_in(tool, &cwd); + } + find_system_tool_in(tool, &cwd) } /// `cwd` only resolves relative PATH entries; it is a parameter so tests can /// exercise them without mutating the process-wide working directory. fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option { + let path = std::env::var_os("PATH")?; + let paths = std::env::split_paths(&path).map(|path| { + if path.is_absolute() || path.starts_with("~") { path } else { cwd.as_path().join(path) } + }); + let path = std::env::join_paths(paths).ok()?; + let resolved = vp_command::resolve_bin(tool, Some(&path), cwd).ok()?; + let canonical = resolved.as_path().canonicalize().ok(); + let self_real = std::env::current_exe().ok().and_then(|exe| exe.canonicalize().ok()); + 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_some() && canonical == self_real) + { + None + } else { + Some(resolved) + } +} + +/// Explicit bypass skips Vite+ installations and the directories listed by the caller. +fn find_external_tool_in(tool: &str, cwd: &AbsolutePath) -> Option { let bin_dir = config::get_bin_dir().ok(); let path_var = std::env::var_os("PATH")?; tracing::debug!("path_var: {:?}", path_var); From 08ccbab4c9bc620386d1a047bda7f12f97b0b6b0 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 21:34:39 +0800 Subject: [PATCH 04/15] test(env): verify shim roots and explicit bypass resolution --- .../vp_global_cli/src/commands/env/setup.rs | 5 +- .../src/commands/global/install.rs | 5 +- crates/vp_global_cli/src/shim/dispatch.rs | 61 ++++++++++++------- crates/vp_shared/src/dirs.rs | 5 +- crates/vp_trampoline/src/cmdline.rs | 2 +- crates/vp_trampoline/src/main.rs | 28 ++++++++- 6 files changed, 76 insertions(+), 30 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 87a664e00d..c85408968d 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1651,12 +1651,13 @@ mod tests { "env file should contain a PATH cleanup loop" ); assert!( - env_content.contains("*\":${__vp_bin}:\"*)"), + env_content.contains("*\":${__vp_dir}:\"*)"), "env file should check for existing bin in PATH" ); // Verify it re-prepends exactly once after cleanup. assert!( - env_content.contains("export PATH=\"${__vp_bin}${PATH:+:${PATH}}\""), + env_content + .contains("export PATH=\"${__vp_bin}${PATH:+:${PATH}}:${__vp_fallback}\""), "env file should prepend bin to PATH after removing duplicates" ); }, diff --git a/crates/vp_global_cli/src/commands/global/install.rs b/crates/vp_global_cli/src/commands/global/install.rs index d28651cbf2..5a70953c69 100644 --- a/crates/vp_global_cli/src/commands/global/install.rs +++ b/crates/vp_global_cli/src/commands/global/install.rs @@ -1295,11 +1295,12 @@ mod tests { assert_eq!( contents, format!( - "{}\nlayout={}\ndata={}\ncache={}\n", + "{}\nlayout={}\ndata={}\ncache={}\nbin={}\n", vp_shared::SHIM_POINTER_HEADER, dirs.layout().as_str(), dirs.data.as_path().display(), - dirs.cache.as_path().display() + dirs.cache.as_path().display(), + dirs.bin.as_path().display() ) ); } diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index e8bef468ab..7a82752c08 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -1511,20 +1511,20 @@ mod tests { } #[test] - fn test_find_system_tool_works_without_bypass() { + fn test_find_external_tool_works_without_bypass() { let temp = TempDir::new().unwrap(); let dir = temp.path().join("bin_a"); std::fs::create_dir_all(&dir).unwrap(); create_fake_executable(&dir, "mytesttool"); temp_env::with_vars([("PATH", Some(dir.as_os_str())), (env_vars::VP_BYPASS, None)], || { - let result = find_system_tool("mytesttool"); + let result = find_external_tool_in("mytesttool", ¤t_dir().unwrap()); assert!(result.is_some(), "Should find tool when no bypass is set"); assert!(result.unwrap().as_path().starts_with(&dir)); }); } #[test] - fn test_find_system_tool_skips_other_installation_trampolines() { + fn test_find_external_tool_skips_other_installation_trampolines() { let temp = TempDir::new().unwrap(); let dirs = ["install_a", "install_b", "real"].map(|name| temp.path().join(name)); for (index, dir) in dirs.iter().enumerate() { @@ -1546,19 +1546,24 @@ mod tests { temp_env::with_vars( [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, None)], || { - assert!(find_system_tool("node").unwrap().as_path().starts_with(&dirs[2])); + assert!( + find_external_tool_in("node", ¤t_dir().unwrap()) + .unwrap() + .as_path() + .starts_with(&dirs[2]) + ); }, ); let path = std::env::join_paths(&dirs[..2]).unwrap(); temp_env::with_vars( [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, None)], - || assert!(find_system_tool("node").is_none()), + || assert!(find_external_tool_in("node", ¤t_dir().unwrap()).is_none()), ); } #[test] #[cfg(unix)] - fn test_find_system_tool_distinguishes_vp_from_shared_manager_shims() { + fn test_find_external_tool_distinguishes_vp_from_shared_manager_shims() { let temp = TempDir::new().unwrap(); let dirs = ["install", "aliases", "real"].map(|name| temp.path().join(name)); for dir in &dirs { @@ -1570,7 +1575,14 @@ mod tests { let path = std::env::join_paths([&dirs[1], &dirs[2]]).unwrap(); temp_env::with_vars( [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, None)], - || assert!(find_system_tool("node").unwrap().as_path().starts_with(&dirs[2])), + || { + assert!( + find_external_tool_in("node", ¤t_dir().unwrap()) + .unwrap() + .as_path() + .starts_with(&dirs[2]) + ) + }, ); let manager = create_fake_executable(&dirs[0], "tool-manager"); @@ -1580,12 +1592,17 @@ mod tests { let path = std::env::join_paths([&dirs[0], &dirs[2]]).unwrap(); temp_env::with_vars( [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, None)], - || assert_eq!(find_system_tool("node").unwrap().as_path(), dirs[0].join("node")), + || { + assert_eq!( + find_external_tool_in("node", ¤t_dir().unwrap()).unwrap().as_path(), + dirs[0].join("node") + ) + }, ); } #[test] - fn test_find_system_tool_skips_single_bypass_path() { + fn test_find_external_tool_skips_single_bypass_path() { let temp = TempDir::new().unwrap(); let dir_a = temp.path().join("bin_a"); let dir_b = temp.path().join("bin_b"); @@ -1598,7 +1615,7 @@ mod tests { temp_env::with_vars( [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, Some(dir_a.as_os_str()))], || { - let result = find_system_tool("mytesttool"); + let result = find_external_tool_in("mytesttool", ¤t_dir().unwrap()); assert!(result.is_some(), "Should find tool in non-bypassed directory"); assert!( result.unwrap().as_path().starts_with(&dir_b), @@ -1629,7 +1646,7 @@ mod tests { /// search continues to the real tool later in PATH. #[cfg(unix)] #[test] - fn test_find_system_tool_skips_self_symlink_and_keeps_searching() { + fn test_find_external_tool_skips_self_symlink_and_keeps_searching() { let temp = TempDir::new().unwrap(); let (dir_a, dir_b) = setup_self_symlink_dirs(&temp); @@ -1637,7 +1654,7 @@ mod tests { temp_env::with_vars( [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, None)], || { - let result = find_system_tool("mytesttool"); + let result = find_external_tool_in("mytesttool", ¤t_dir().unwrap()); assert!(result.is_some(), "Should skip the self symlink and keep searching"); assert!( result.unwrap().as_path().starts_with(&dir_b), @@ -1653,7 +1670,7 @@ mod tests { /// instead of reaching dir_b. #[cfg(unix)] #[test] - fn test_find_system_tool_skips_self_symlink_in_relative_path_entry() { + fn test_find_external_tool_skips_self_symlink_in_relative_path_entry() { let temp = TempDir::new().unwrap(); let (_dir_a, dir_b) = setup_self_symlink_dirs(&temp); @@ -1662,7 +1679,7 @@ mod tests { [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, None)], || { let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - let result = find_system_tool_in("mytesttool", &cwd); + let result = find_external_tool_in("mytesttool", &cwd); assert!( result.is_some(), "Should skip the relative self-symlink entry and keep searching" @@ -1676,7 +1693,7 @@ mod tests { } #[test] - fn test_find_system_tool_filters_multiple_bypass_paths() { + fn test_find_external_tool_filters_multiple_bypass_paths() { let temp = TempDir::new().unwrap(); let dir_a = temp.path().join("bin_a"); let dir_b = temp.path().join("bin_b"); @@ -1694,7 +1711,7 @@ mod tests { temp_env::with_vars( [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, Some(bypass.as_os_str()))], || { - let result = find_system_tool("mytesttool"); + let result = find_external_tool_in("mytesttool", ¤t_dir().unwrap()); assert!(result.is_some(), "Should find tool in dir_c"); assert!( result.unwrap().as_path().starts_with(&dir_c), @@ -1705,7 +1722,7 @@ mod tests { } #[test] - fn test_find_system_tool_returns_none_when_all_paths_bypassed() { + fn test_find_external_tool_returns_none_when_all_paths_bypassed() { let temp = TempDir::new().unwrap(); let dir_a = temp.path().join("bin_a"); std::fs::create_dir_all(&dir_a).unwrap(); @@ -1713,7 +1730,7 @@ mod tests { temp_env::with_vars( [("PATH", Some(dir_a.as_os_str())), (env_vars::VP_BYPASS, Some(dir_a.as_os_str()))], || { - let result = find_system_tool("mytesttool"); + let result = find_external_tool_in("mytesttool", ¤t_dir().unwrap()); assert!(result.is_none(), "Should return None when all paths are bypassed"); }, ); @@ -1724,7 +1741,7 @@ mod tests { /// both A's dir (from bypass) and its own dir (from get_bin_dir), finding the real tool /// in a third directory or returning None. #[test] - fn test_find_system_tool_cumulative_bypass_prevents_loop() { + fn test_find_external_tool_cumulative_bypass_prevents_loop() { let temp = TempDir::new().unwrap(); let install_a_bin = temp.path().join("install_a_bin"); let install_b_bin = temp.path().join("install_b_bin"); @@ -1753,7 +1770,7 @@ mod tests { temp_env::with_vars( [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, Some(bypass.as_os_str()))], || { - let result = find_system_tool("mytesttool"); + let result = find_external_tool_in("mytesttool", ¤t_dir().unwrap()); assert!(result.is_some(), "Should find tool in real_system directory"); assert!( result.unwrap().as_path().starts_with(&real_system_bin), @@ -1765,7 +1782,7 @@ mod tests { /// When both installations are bypassed and no real system tool exists, should return None. #[test] - fn test_find_system_tool_returns_none_with_no_real_system_tool() { + fn test_find_external_tool_returns_none_with_no_real_system_tool() { let temp = TempDir::new().unwrap(); let install_a_bin = temp.path().join("install_a_bin"); let install_b_bin = temp.path().join("install_b_bin"); @@ -1781,7 +1798,7 @@ mod tests { temp_env::with_vars( [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, Some(bypass.as_os_str()))], || { - let result = find_system_tool("mytesttool"); + let result = find_external_tool_in("mytesttool", ¤t_dir().unwrap()); assert!( result.is_none(), "Should return None when all dirs are bypassed and no real system tool exists" diff --git a/crates/vp_shared/src/dirs.rs b/crates/vp_shared/src/dirs.rs index 7d4460bbb8..08c8acbb4a 100644 --- a/crates/vp_shared/src/dirs.rs +++ b/crates/vp_shared/src/dirs.rs @@ -220,8 +220,9 @@ mod tests { assert_eq!( contents, format!( - "{SHIM_POINTER_HEADER}\nlayout={}\ndata={data}\ncache={cache}\n", - config.dirs.layout().as_str() + "{SHIM_POINTER_HEADER}\nlayout={}\ndata={data}\ncache={cache}\nbin={}\n", + config.dirs.layout().as_str(), + config.dirs.bin.as_path().display() ) ); } diff --git a/crates/vp_trampoline/src/cmdline.rs b/crates/vp_trampoline/src/cmdline.rs index 9de5ae4998..8eb1bee123 100644 --- a/crates/vp_trampoline/src/cmdline.rs +++ b/crates/vp_trampoline/src/cmdline.rs @@ -294,7 +294,7 @@ mod tests { ), Some(ShimPointer { data: r"D:\data", - layout: ShimLayout::Split { cache: r"C:\cache" }, + layout: ShimLayout::Split { cache: r"C:\cache", bin: None }, }) ); } diff --git a/crates/vp_trampoline/src/main.rs b/crates/vp_trampoline/src/main.rs index 7892f757f2..36e95a79bd 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -177,6 +177,32 @@ mod portable { ) } + #[test] + fn fallback_pointer_preserves_main_bin_root() { + let root = env::temp_dir().join(format!("vp-trampoline-fallback-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let data = root.join("data"); + let bin = root.join("shared-bin"); + let fallback = data.join("fallback-bin"); + std::fs::create_dir_all(&fallback).unwrap(); + write_exe(&data.join("current/bin/vp.exe")); + std::fs::write( + fallback.join("node.shim"), + format!( + "{}bin={}\n", + versioned_pointer("split", &data, &root.join("cache")), + bin.display() + ), + ) + .unwrap(); + let location = resolve_vp_exe(&fallback.join("node.exe")).unwrap(); + assert_eq!(location.exe, data.join("current/bin/vp.exe")); + assert!( + matches!(location.pointer.layout, ShimLayout::Split { bin: actual, .. } if actual == bin) + ); + let _ = fs::remove_dir_all(root); + } + #[test] #[cfg(unix)] fn preserves_signal_exit_code() { @@ -307,7 +333,7 @@ mod portable { let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); assert!(matches!( location.pointer.layout, - ShimLayout::Split { cache: value } if value == cache + ShimLayout::Split { cache: value, .. } if value == cache )); let _ = fs::remove_dir_all(&root); } From 46101b19a9e739102ac2da20f4ea11d9f24d59c3 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 21:34:45 +0800 Subject: [PATCH 05/15] test(env): cover fallback placement and foreign manager recursion --- .../shim_system_first_fallback/.node-version | 1 + .../shim_system_first_fallback/placement.sh | 53 +++++++++++++++++++ .../shim_system_first_fallback/recursion.sh | 17 ++++++ .../shim_system_first_fallback/snapshots.toml | 26 +++++++++ ...gn_manager_falls_back_without_recursion.md | 9 ++++ .../placement_and_path_precedence.md | 9 ++++ .../split_layout_preserves_foreign_tools.md | 9 ++++ .../shim_system_first_fallback/split.sh | 25 +++++++++ .../tests/cli_snapshots/main.rs | 10 ++-- 9 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/.node-version create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/placement.sh create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/recursion.sh create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/placement_and_path_precedence.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/split.sh diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/.node-version new file mode 100644 index 0000000000..2a393af592 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/.node-version @@ -0,0 +1 @@ +20.18.0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/placement.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/placement.sh new file mode 100644 index 0000000000..7c74a46f3a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/placement.sh @@ -0,0 +1,53 @@ +set -eu +. "$VP_HOME/env" +original_path=$PATH +mkdir -p system-bin +printf '#!/bin/sh\necho v99.0.0\n' > system-bin/node +chmod +x system-bin/node +system_bin="$PWD/system-bin" + +vp env on >/dev/null +node --version >/dev/null # Populate Bash's command cache before moving the shim. +vp env off node >/dev/null +test ! -e "$VP_HOME/bin/node" +test -L "$VP_HOME/fallback-bin/node" +test -L "$VP_HOME/bin/pnpm" +PATH="$system_bin:$PATH" +. "$VP_HOME/env" +test "$(command -v node)" = "$system_bin/node" +test "$(node --version)" = v99.0.0 +test "$(vp env which node)" = "$system_bin/node" + +vp env off pnpm >/dev/null +for tool in pnpm pnpx; do + test ! -e "$VP_HOME/bin/$tool" + test -L "$VP_HOME/fallback-bin/$tool" +done +for tool in npm npx yarn yarnpkg bun bunx vpr vpx; do + test -L "$VP_HOME/bin/$tool" +done +vp env setup --refresh >/dev/null +test -L "$VP_HOME/fallback-bin/node" +test -L "$VP_HOME/fallback-bin/pnpm" + +# A Vite+ shim before another executable ends internal lookup at managed resolution. +PATH="$VP_HOME/fallback-bin:$system_bin:$original_path" +resolved=$(vp env which node) +test "${resolved%%$'\n'*}" = "$VP_HOME/js_runtime/node/20.18.0/bin/node" +test "$(node --version)" = v20.18.0 +PATH="$system_bin:$original_path" +. "$VP_HOME/env" +. "$VP_HOME/env" +test "${PATH%%:*}" = "$VP_HOME/bin" +test "${PATH##*:}" = "$VP_HOME/fallback-bin" +test "$(node --version)" = v99.0.0 + +vp env on node >/dev/null +test "$(command -v node)" = "$VP_HOME/bin/node" +test "$(node --version)" = v20.18.0 +test ! -e "$VP_HOME/fallback-bin/node" +test -L "$VP_HOME/fallback-bin/pnpm" +vp env on pnpm >/dev/null +test -L "$VP_HOME/bin/pnpm" +test ! -e "$VP_HOME/fallback-bin/pnpm" +echo 'Scoped placement, refresh, shell cache, and PATH precedence passed' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/recursion.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/recursion.sh new file mode 100644 index 0000000000..4776f3ed13 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/recursion.sh @@ -0,0 +1,17 @@ +set -eu +. "$VP_HOME/env" +vp env off node >/dev/null +mkdir -p foreign-bin +cat > foreign-bin/node <<'EOF' +#!/bin/sh +# Model a manager finding Vite+ as its fallback while retaining its original PATH. +exec "$VP_HOME/fallback-bin/node" "$@" +EOF +chmod +x foreign-bin/node +PATH="$PWD/foreign-bin:$VP_HOME/fallback-bin:/usr/bin:/bin" +export PATH +test "$(node --version)" = v20.18.0 +test "$(VP_PATH_INJECTED_TOOLS=node node --version)" = v20.18.0 +# Internal system-first lookup selects the foreign manager, which reaches the managed fallback. +test "$("$VP_HOME/bin/vp" env exec node --version)" = v20.18.0 +echo 'Foreign manager fallback terminates with managed Node' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml new file mode 100644 index 0000000000..6360ff1990 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml @@ -0,0 +1,26 @@ +[[case]] +name = "placement_and_path_precedence" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [ + { argv = ["bash", "placement.sh"], comment = "Scoped changes move tool families together; refreshing preserves choices and PATH selects the first matching tool." }, +] + +[[case]] +name = "foreign_manager_falls_back_without_recursion" +vp = "global" +skip-platforms = ["windows"] +requires = ["sh"] +steps = [ + { argv = ["sh", "recursion.sh"], comment = "A foreign manager falling back to Vite+ reaches managed Node even with an inherited tool marker." }, +] + +[[case]] +name = "split_layout_preserves_foreign_tools" +vp = "global" +skip-platforms = ["windows"] +requires = ["sh"] +steps = [ + { argv = ["sh", "split.sh"], comment = "Fallback shims stay in the data root when the main bin directory is shared, and refresh preserves foreign executables." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md new file mode 100644 index 0000000000..0e86912bfb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md @@ -0,0 +1,9 @@ +# foreign_manager_falls_back_without_recursion + +## `sh recursion.sh` + +A foreign manager falling back to Vite+ reaches managed Node even with an inherited tool marker. + +``` +Foreign manager fallback terminates with managed Node +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/placement_and_path_precedence.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/placement_and_path_precedence.md new file mode 100644 index 0000000000..3d742496a0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/placement_and_path_precedence.md @@ -0,0 +1,9 @@ +# placement_and_path_precedence + +## `bash placement.sh` + +Scoped changes move tool families together; refreshing preserves choices and PATH selects the first matching tool. + +``` +Scoped placement, refresh, shell cache, and PATH precedence passed +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md new file mode 100644 index 0000000000..7e3e328ac1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md @@ -0,0 +1,9 @@ +# split_layout_preserves_foreign_tools + +## `sh split.sh` + +Fallback shims stay in the data root when the main bin directory is shared, and refresh preserves foreign executables. + +``` +Split layout and foreign tool preservation passed +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/split.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/split.sh new file mode 100644 index 0000000000..598598d229 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/split.sh @@ -0,0 +1,25 @@ +set -eu +vp_binary="$VP_HOME/bin/vp" +unset VP_HOME +HOME="$PWD/user" +XDG_CONFIG_HOME="$HOME/config" +VP_BIN_DIR="$PWD/shared-bin" +VP_DATA_DIR="$PWD/data" +VP_CACHE_DIR="$PWD/cache" +export HOME XDG_CONFIG_HOME VP_BIN_DIR VP_DATA_DIR VP_CACHE_DIR +mkdir -p "$VP_BIN_DIR" +printf '#!/bin/sh\necho foreign-node\n' > "$VP_BIN_DIR/node" +chmod +x "$VP_BIN_DIR/node" +"$vp_binary" env setup --refresh >/dev/null +"$vp_binary" env off node >/dev/null +test -L "$VP_DATA_DIR/fallback-bin/node" +test "$("$VP_BIN_DIR/node")" = foreign-node +"$vp_binary" env setup --refresh >/dev/null +test -L "$VP_DATA_DIR/fallback-bin/node" +test "$("$VP_BIN_DIR/node")" = foreign-node +. "$XDG_CONFIG_HOME/vite-plus/env" +test "${PATH%%:*}" = "$VP_BIN_DIR" +test "${PATH##*:}" = "$VP_DATA_DIR/fallback-bin" +"$vp_binary" env on node >/dev/null +test "$("$VP_BIN_DIR/node")" = foreign-node +echo 'Split layout and foreign tool preservation passed' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index df49838295..be15518eca 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -626,8 +626,13 @@ impl CaseHome { tool_dirs.push(case_root.to_path_buf()); } + let path_env = compose_path_env(&path_dirs); + let mut entries: Vec<_> = std::env::split_paths(&path_env).collect(); + let fallback_bin = self.vp_home().join("fallback-bin"); + entries.push(fallback_bin.clone()); + tool_dirs.push(fallback_bin); Ok(CaseInstall { - path_env: compose_path_env(&path_dirs), + path_env: std::env::join_paths(entries).unwrap(), tool_dirs, vpt: runtime.vpt.clone(), sh: runtime.sh.clone(), @@ -705,8 +710,7 @@ impl CaseHome { )); } - // Cases start from fresh-install consent. A dedicated first-use fixture - // removes this config before exercising upgrade compatibility. + // Cases start with explicit package-manager preferences, as fresh installations do. let output = std::process::Command::new(vp) .args(["env", "on", "pm"]) .env_clear() From bf219c0ed2dd30a5b75bdbc645e367c100760d91 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 21:34:55 +0800 Subject: [PATCH 06/15] test(env): update setup and package manager mode snapshots --- ...mmand_env_doctor_system_package_manager.md | 4 ++-- .../command_self_setup/seed-owned-shims.cjs | 6 ++++++ .../command_self_setup/snapshots.toml | 11 ++++------ .../command_self_setup_mixed_shim_refresh.md | 21 ++++++------------- .../snapshots.toml | 2 +- .../system_npm_does_not_claim_missing_npx.md | 2 +- .../snapshots.toml | 14 +++---------- ...fers_existing_family_and_records_choice.md | 6 +++--- 8 files changed, 26 insertions(+), 40 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/seed-owned-shims.cjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md index 1fd1834b97..7587b51a1e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md @@ -23,8 +23,8 @@ Configuration PATH ✓ vp ~/.vite-plus/bin/vp ✓ Shim dir ~/.vite-plus/bin - ✓ pnpm ~/.vite-plus/bin/pnpm (vp shim) - ✓ pnpx ~/.vite-plus/bin/pnpx (vp shim) + ✓ pnpm /system-bin/pnpm (system) + ✓ pnpx ~/.vite-plus/fallback-bin/pnpx (vp shim) Package Manager Resolution Source system PATH diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/seed-owned-shims.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/seed-owned-shims.cjs new file mode 100644 index 0000000000..da2c3f7699 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/seed-owned-shims.cjs @@ -0,0 +1,6 @@ +const { symlinkSync } = require('node:fs'); + +// Model the old layout with owned links, rather than unrelated executable files. +for (const tool of ['node', 'npm', 'pnpm', 'pnpx']) { + symlinkSync('../current/bin/vp', `home/bin/${tool}`); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml index 6de6bfdd25..2b055aa706 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml @@ -107,15 +107,12 @@ steps = [ { argv = ["vpt", "mkdir", "-p", "external", "home/bin", "user-bin"], snapshot = false }, { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, - { argv = ["vpt", "write-file", "home/bin/node", "old-node-shim"], snapshot = false }, - { argv = ["vpt", "write-file", "home/bin/npm", "old-npm-shim"], snapshot = false }, - { argv = ["vpt", "write-file", "home/bin/pnpm", "old-pnpm-shim"], snapshot = false }, - { argv = ["vpt", "write-file", "home/bin/pnpx", "old-pnpx-shim"], snapshot = false }, + { argv = ["node", "seed-owned-shims.cjs"], snapshot = false }, { argv = ["vpt", "write-file", "user-bin/node", "user-node-shim"], snapshot = false }, { argv = ["vpt", "write-file", "user-bin/pnpm", "user-pnpm-shim"], snapshot = false }, - { argv = ["./external/vp"], tty = false, envs = [["VP_HOME", "${workspace}/home"], ["VP_VERSION", "mixed-shims"], ["VP_NODE_MANAGER", "no"], ["VP_PM_MANAGER", "no"], ["VP_PNPM_MANAGER", "yes"], ["PATH", "${workspace}/user-bin${PATH_SEPARATOR}${PATH}"]], comment = "Installation refreshes every Vite+ shim regardless of management preferences, leaving user tools elsewhere on PATH untouched", snapshot = false }, - ["vpt", "stat-file", "home/bin/node", "--assert", "symlink"], - ["vpt", "stat-file", "home/bin/npm", "--assert", "symlink"], + { argv = ["./external/vp"], tty = false, envs = [["VP_HOME", "${workspace}/home"], ["VP_VERSION", "mixed-shims"], ["VP_NODE_MANAGER", "no"], ["VP_PM_MANAGER", "no"], ["VP_PNPM_MANAGER", "yes"], ["PATH", "${workspace}/user-bin${PATH_SEPARATOR}${PATH}"]], comment = "Installation places owned shims according to each management preference, leaving user tools elsewhere on PATH untouched", snapshot = false }, + ["vpt", "stat-file", "home/fallback-bin/node", "--assert", "symlink"], + ["vpt", "stat-file", "home/fallback-bin/npm", "--assert", "symlink"], ["vpt", "stat-file", "home/bin/pnpm", "--assert", "symlink"], ["vpt", "stat-file", "home/bin/pnpx", "--assert", "symlink"], ["vpt", "print-file", "user-bin/node"], diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_mixed_shim_refresh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_mixed_shim_refresh.md index a906ca553f..fd2e910e05 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_mixed_shim_refresh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_mixed_shim_refresh.md @@ -9,16 +9,7 @@ ## `vpt chmod +x external/vp` -## `vpt write-file home/bin/node old-node-shim` - - -## `vpt write-file home/bin/npm old-npm-shim` - - -## `vpt write-file home/bin/pnpm old-pnpm-shim` - - -## `vpt write-file home/bin/pnpx old-pnpx-shim` +## `node seed-owned-shims.cjs` ## `vpt write-file user-bin/node user-node-shim` @@ -29,19 +20,19 @@ ## `VP_HOME=${workspace}/home VP_VERSION=mixed-shims VP_NODE_MANAGER=no VP_PM_MANAGER=no VP_PNPM_MANAGER=yes PATH=${workspace}/user-bin${PATH_SEPARATOR}${PATH} ./external/vp` -Installation refreshes every Vite+ shim regardless of management preferences, leaving user tools elsewhere on PATH untouched +Installation places owned shims according to each management preference, leaving user tools elsewhere on PATH untouched -## `vpt stat-file home/bin/node --assert symlink` +## `vpt stat-file home/fallback-bin/node --assert symlink` ``` -home/bin/node: symlink +home/fallback-bin/node: symlink ``` -## `vpt stat-file home/bin/npm --assert symlink` +## `vpt stat-file home/fallback-bin/npm --assert symlink` ``` -home/bin/npm: symlink +home/fallback-bin/npm: symlink ``` ## `vpt stat-file home/bin/pnpm --assert symlink` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/snapshots.toml index ecff410570..c97f25e188 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/snapshots.toml @@ -41,7 +41,7 @@ steps = [ { argv = ["node", "setup-system-npm.cjs"], snapshot = false }, { argv = ["vp", "env", "off", "npm"], snapshot = false }, { argv = ["vp", "env", "off", "node"], snapshot = false }, - { argv = ["vp", "install"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-npm${PATH_SEPARATOR}/usr/bin${PATH_SEPARATOR}/bin"]], comment = "An absent system alias resolves normally instead of entering injected-tool passthrough" }, + { argv = ["vp", "install"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-npm${PATH_SEPARATOR}/usr/bin${PATH_SEPARATOR}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin"]], comment = "An absent system alias resolves normally instead of entering injected-tool passthrough" }, ] [[case]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/snapshots/system_npm_does_not_claim_missing_npx.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/snapshots/system_npm_does_not_claim_missing_npx.md index 32e4c0f58a..7788fe9b98 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/snapshots/system_npm_does_not_claim_missing_npx.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_injected_tool_contracts/snapshots/system_npm_does_not_claim_missing_npx.md @@ -18,7 +18,7 @@ ## `vp env off node` -## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-npm${PATH_SEPARATOR}/usr/bin${PATH_SEPARATOR}/bin vp install` +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-npm${PATH_SEPARATOR}/usr/bin${PATH_SEPARATOR}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin vp install` An absent system alias resolves normally instead of entering injected-tool passthrough diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index 1f4bbee6f9..b93728a6fd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -6,18 +6,10 @@ steps = [ { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/yarn"], snapshot = false }, - { argv = ["pnpm", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ - { "expect-milestone" = "pm-shim-choice:pnpm" }, - { "write-key" = "down" }, - { "write-key" = "enter" }, - ] }, + { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "the explicit system choice records only pnpm" }, - { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "later pnpm invocations use the recorded choice without prompting" }, - { argv = ["yarn", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ - { "expect-milestone" = "pm-shim-choice:yarn" }, - { "write-key" = "down" }, - { "write-key" = "enter" }, - ] }, + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "PATH selects the system pnpm directly" }, + { argv = ["vp", "env", "off", "yarn"], snapshot = false }, { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "Yarn records its own decision without changing pnpm" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md index acdd1e77e2..7db5e0679a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md @@ -9,7 +9,7 @@ ## `vpt chmod +x system-bin/yarn` -## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` +## `vp env off pnpm` ## `vpt print-file $VP_HOME/config.json` @@ -26,13 +26,13 @@ the explicit system choice records only pnpm ## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` -later pnpm invocations use the recorded choice without prompting +PATH selects the system pnpm directly ``` system-pnpm ``` -## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} yarn --version` +## `vp env off yarn` ## `vpt print-file $VP_HOME/config.json` From eabfd5bbb307b7450fc054f4967af8f6a481dccc Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 21:35:00 +0800 Subject: [PATCH 07/15] docs(env): describe system-first fallback shims --- docs/guide/env.md | 9 +++++---- rfcs/directory-layout.md | 13 +++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/guide/env.md b/docs/guide/env.md index 81b060865d..35a7e1af37 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -120,16 +120,17 @@ If you do not want Vite+ to manage Node.js first, run: vp env off ``` -This switches both components to system-first mode. Vite+ prefers system tools and falls back to managed installations. Mixed configurations compose: a system package-manager launcher receives the Node.js selected by the Node mode. +This switches both components to system-first mode. Vite+ prefers system tools and falls back to managed installations. Mixed configurations compose: package-manager launchers that look up Node.js through PATH receive the runtime selected by the Node mode. -Using `pm` records the selected mode for all currently supported package managers and replaces their individual choices. An unscoped `on` or `off` does the same while also changing Node.js. A family without a recorded mode remains undecided until its shim is first used or an `on` / `off` command configures it. +Using `pm` records the selected mode for all currently supported package managers and replaces their individual choices. An unscoped `on` or `off` does the same while also changing Node.js. A family without a recorded mode defaults to managed mode until an `on` / `off` command configures it. ## Commands ### Setup -- `vp env setup` creates or updates the `node`, `npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`, `bun`, `bunx`, `vpx`, and `vpr` shims in the resolved bin directory. It writes shell setup scripts in the config directory. -- `vp env on` / `vp env off` changes both modes; append `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the change +- `vp env setup` creates or updates the `node`, `npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`, `bun`, `bunx`, `vpx`, and `vpr` shims. Managed tool shims live in the resolved bin directory; system-first tool shims live in `fallback-bin` under the data directory. `vp`, `vpx`, `vpr`, and global package commands remain in the main bin directory. Shell setup scripts prepend the main bin directory and append the fallback directory to PATH. +- `vp env setup --refresh` recreates owned shims according to the saved modes and regenerates shell setup scripts. Upgrades run this automatically. Reload the setup script or start a new terminal to activate the new PATH layout in an existing installation. +- `vp env on` / `vp env off` changes both modes and moves the affected shims; append `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the change - `vp env print` prints PATH setup for both components; append a selector to print one PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: diff --git a/rfcs/directory-layout.md b/rfcs/directory-layout.md index 2be8a3e213..24ec5083a5 100644 --- a/rfcs/directory-layout.md +++ b/rfcs/directory-layout.md @@ -320,8 +320,12 @@ Files in ``, including `config.json`, must contain portable user preferences. Store machine-specific paths, downloaded payloads, caches, and session state in the local categories. -The generated `/env*` files add the resolved `` to `PATH`. They do -not export the internal `VP_BIN_DIR`, `VP_DATA_DIR`, and `VP_CACHE_DIR` group. +The generated `/env*` files prepend the resolved `` and append +`/fallback-bin` to `PATH`. System-first Node.js and package-manager shims +live in the fallback directory; reaching any of these shims selects its managed +tool. `vp env setup --refresh` and mode changes reconcile placement from config. +The fallback directory stays under the owned data root when `` is shared. +The files do not export the internal `VP_BIN_DIR`, `VP_DATA_DIR`, and `VP_CACHE_DIR` group. Each process resolves the split layout from its current environment. The files keep an explicit `VP_HOME` only when they must preserve a custom monolithic root. Features must not store machine identity or durable state in these files. @@ -431,8 +435,9 @@ permission. Without permission, the installer keeps the foreign entry. shim only after the ownership check identifies it as a Vite+ shim. Windows sidecar files record ownership and tell the trampoline which layout to -preserve. The versioned sidecar records the layout mode, data root, and cache -root. A split trampoline sets `VP_DATA_DIR`, `VP_BIN_DIR`, and `VP_CACHE_DIR` +preserve. The versioned sidecar records the layout mode, data root, bin root, +and cache root. Older sidecars without a bin root use their executable parent; +fallback trampolines record the main bin root explicitly. A split trampoline sets `VP_DATA_DIR`, `VP_BIN_DIR`, and `VP_CACHE_DIR` for its child. A single-root trampoline sets `VP_HOME`. It does not infer the mode from path equality because an explicit split layout can also set `` to `/bin`. From bf9a1052fda8fdc8562c964fdafe761bc0baa01b Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 21:49:35 +0800 Subject: [PATCH 08/15] docs(env): simplify setup command descriptions --- docs/guide/env.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/guide/env.md b/docs/guide/env.md index 35a7e1af37..05e0b4f7e2 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -128,10 +128,12 @@ Using `pm` records the selected mode for all currently supported package manager ### Setup -- `vp env setup` creates or updates the `node`, `npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`, `bun`, `bunx`, `vpx`, and `vpr` shims. Managed tool shims live in the resolved bin directory; system-first tool shims live in `fallback-bin` under the data directory. `vp`, `vpx`, `vpr`, and global package commands remain in the main bin directory. Shell setup scripts prepend the main bin directory and append the fallback directory to PATH. -- `vp env setup --refresh` recreates owned shims according to the saved modes and regenerates shell setup scripts. Upgrades run this automatically. Reload the setup script or start a new terminal to activate the new PATH layout in an existing installation. -- `vp env on` / `vp env off` changes both modes and moves the affected shims; append `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the change -- `vp env print` prints PATH setup for both components; append a selector to print one +- `vp env setup` creates tool shims and writes shell setup scripts. +- `vp env setup --refresh` recreates Vite+ shims and regenerates shell setup scripts using your saved preferences. +- `vp env on` / `vp env off` switches between Vite+-managed and system-first modes; append `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the change. +- `vp env print` prints PATH setup for both components; append a selector to print one. + +Upgrades refresh the setup automatically. Open a new terminal or reload your shell setup script afterward. PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: From 1c6380381a21b67d5151b19f08a138721730453a Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 21:59:55 +0800 Subject: [PATCH 09/15] test(env): expose fallback scenarios and enable Windows coverage --- .../assert-node-resolution.cjs | 7 + .../shim_system_first_fallback/list-shims.cjs | 14 ++ .../shim_system_first_fallback/placement.sh | 32 +--- .../shim_system_first_fallback/recursion.sh | 17 -- .../setup-foreign-manager.cjs | 9 ++ .../setup-split-layout.cjs | 7 + .../setup-system-node.cjs | 5 + .../shim_system_first_fallback/snapshots.toml | 68 +++++++- .../bash_path_precedence_and_cache.md | 9 ++ ...gn_manager_falls_back_without_recursion.md | 37 ++++- .../placement_and_path_precedence.md | 149 +++++++++++++++++- .../split_layout_preserves_foreign_tools.md | 105 +++++++++++- .../shim_system_first_fallback/split.sh | 23 +-- 13 files changed, 401 insertions(+), 81 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/assert-node-resolution.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/list-shims.cjs delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/recursion.sh create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-foreign-manager.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-split-layout.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-system-node.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/bash_path_precedence_and_cache.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/assert-node-resolution.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/assert-node-resolution.cjs new file mode 100644 index 0000000000..e3bccdde6a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/assert-node-resolution.cjs @@ -0,0 +1,7 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const { realpathSync } = require('node:fs'); + +const output = execFileSync('vp', ['env', 'which', 'node'], { encoding: 'utf8', timeout: 10000 }); +assert.equal(realpathSync(output.split('\n')[0].trim()), realpathSync(process.execPath)); +console.log('env which selects the same Node as PATH'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/list-shims.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/list-shims.cjs new file mode 100644 index 0000000000..e51e81a7cd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/list-shims.cjs @@ -0,0 +1,14 @@ +const { existsSync, readdirSync } = require('node:fs'); +const path = require('node:path'); + +// Normalize executable suffixes and omit Windows sidecars, keeping placement visible in snapshots. +for (const directory of process.argv.slice(2)) { + const resolved = directory.replace('$VP_HOME', process.env.VP_HOME); + const names = readdirSync(resolved).filter((name) => + /^(node|npm|npx|pnpm|pnpx|yarn|yarnpkg|bun|bunx|vpx|vpr)(\.exe)?$/.test(name), + ); + for (const name of names.sort()) { + if (!existsSync(path.join(resolved, name))) throw new Error(`Broken shim: ${name}`); + } + console.log(`${directory}: ${names.map((name) => name.replace(/\.exe$/, '')).join(', ') || '(empty)'}`); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/placement.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/placement.sh index 7c74a46f3a..1ffa012967 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/placement.sh +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/placement.sh @@ -9,45 +9,23 @@ system_bin="$PWD/system-bin" vp env on >/dev/null node --version >/dev/null # Populate Bash's command cache before moving the shim. vp env off node >/dev/null -test ! -e "$VP_HOME/bin/node" -test -L "$VP_HOME/fallback-bin/node" -test -L "$VP_HOME/bin/pnpm" PATH="$system_bin:$PATH" . "$VP_HOME/env" +. "$VP_HOME/env" +test "${PATH%%:*}" = "$VP_HOME/bin" +test "${PATH##*:}" = "$VP_HOME/fallback-bin" test "$(command -v node)" = "$system_bin/node" test "$(node --version)" = v99.0.0 test "$(vp env which node)" = "$system_bin/node" -vp env off pnpm >/dev/null -for tool in pnpm pnpx; do - test ! -e "$VP_HOME/bin/$tool" - test -L "$VP_HOME/fallback-bin/$tool" -done -for tool in npm npx yarn yarnpkg bun bunx vpr vpx; do - test -L "$VP_HOME/bin/$tool" -done -vp env setup --refresh >/dev/null -test -L "$VP_HOME/fallback-bin/node" -test -L "$VP_HOME/fallback-bin/pnpm" - -# A Vite+ shim before another executable ends internal lookup at managed resolution. PATH="$VP_HOME/fallback-bin:$system_bin:$original_path" resolved=$(vp env which node) test "${resolved%%$'\n'*}" = "$VP_HOME/js_runtime/node/20.18.0/bin/node" test "$(node --version)" = v20.18.0 PATH="$system_bin:$original_path" . "$VP_HOME/env" -. "$VP_HOME/env" -test "${PATH%%:*}" = "$VP_HOME/bin" -test "${PATH##*:}" = "$VP_HOME/fallback-bin" -test "$(node --version)" = v99.0.0 - +node --version >/dev/null vp env on node >/dev/null test "$(command -v node)" = "$VP_HOME/bin/node" test "$(node --version)" = v20.18.0 -test ! -e "$VP_HOME/fallback-bin/node" -test -L "$VP_HOME/fallback-bin/pnpm" -vp env on pnpm >/dev/null -test -L "$VP_HOME/bin/pnpm" -test ! -e "$VP_HOME/fallback-bin/pnpm" -echo 'Scoped placement, refresh, shell cache, and PATH precedence passed' +echo 'Repeated shell setup and mode changes preserve PATH precedence and clear Bash cache' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/recursion.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/recursion.sh deleted file mode 100644 index 4776f3ed13..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/recursion.sh +++ /dev/null @@ -1,17 +0,0 @@ -set -eu -. "$VP_HOME/env" -vp env off node >/dev/null -mkdir -p foreign-bin -cat > foreign-bin/node <<'EOF' -#!/bin/sh -# Model a manager finding Vite+ as its fallback while retaining its original PATH. -exec "$VP_HOME/fallback-bin/node" "$@" -EOF -chmod +x foreign-bin/node -PATH="$PWD/foreign-bin:$VP_HOME/fallback-bin:/usr/bin:/bin" -export PATH -test "$(node --version)" = v20.18.0 -test "$(VP_PATH_INJECTED_TOOLS=node node --version)" = v20.18.0 -# Internal system-first lookup selects the foreign manager, which reaches the managed fallback. -test "$("$VP_HOME/bin/vp" env exec node --version)" = v20.18.0 -echo 'Foreign manager fallback terminates with managed Node' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-foreign-manager.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-foreign-manager.cjs new file mode 100644 index 0000000000..fc0432a528 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-foreign-manager.cjs @@ -0,0 +1,9 @@ +const { mkdirSync, writeFileSync } = require('node:fs'); + +mkdirSync('foreign-bin'); +// Retain PATH when forwarding to Vite+, as an external version manager would. +if (process.platform === 'win32') { + writeFileSync('foreign-bin/node.cmd', '@"%VP_HOME%\\fallback-bin\\node.exe" %*\r\n'); +} else { + writeFileSync('foreign-bin/node', '#!/bin/sh\nexec "$VP_HOME/fallback-bin/node" "$@"\n', { mode: 0o755 }); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-split-layout.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-split-layout.cjs new file mode 100644 index 0000000000..9741636b74 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-split-layout.cjs @@ -0,0 +1,7 @@ +const { copyFileSync, mkdirSync, symlinkSync } = require('node:fs'); +const path = require('node:path'); + +mkdirSync('shared-bin'); +copyFileSync(process.execPath, path.join('shared-bin', process.platform === 'win32' ? 'node.exe' : 'node')); +// Reuse the seeded runtime without downloading it again under the split data root. +symlinkSync(path.join(process.env.VP_HOME, 'js_runtime'), 'data/js_runtime', 'junction'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-system-node.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-system-node.cjs new file mode 100644 index 0000000000..0710f256f0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-system-node.cjs @@ -0,0 +1,5 @@ +const { copyFileSync, mkdirSync } = require('node:fs'); +const path = require('node:path'); + +mkdirSync('system-bin'); +copyFileSync(process.execPath, path.join('system-bin', process.platform === 'win32' ? 'node.exe' : 'node')); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml index 6360ff1990..ee1d3d3c4b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml @@ -1,26 +1,80 @@ [[case]] name = "placement_and_path_precedence" vp = "global" -skip-platforms = ["windows"] -requires = ["bash"] steps = [ - { argv = ["bash", "placement.sh"], comment = "Scoped changes move tool families together; refreshing preserves choices and PATH selects the first matching tool." }, + { argv = ["node", "setup-system-node.cjs"], snapshot = false }, + { argv = ["vp", "env", "on"], snapshot = false }, + # Keep inspection steps on case-owned Node even when Windows PATH includes a host installation. + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Managed mode keeps all tool families in the main bin; fallback can be empty." }, + ["vp", "env", "off", "node"], + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Only Node moves to fallback." }, + { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "The shell selects system Node before fallback." }, + { argv = ["node", "assert-node-resolution.cjs"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Internal Node resolution agrees with PATH." }, + ["vp", "env", "off", "pnpm"], + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "pnpm and pnpx move together; other families and vpx/vpr stay managed." }, + { argv = ["vp", "env", "setup", "--refresh"], snapshot = false }, + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Refresh preserves saved choices." }, + { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "A fallback shim before system Node selects managed Node." }, + { argv = ["node", "assert-node-resolution.cjs"], envs = [["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Internal resolution must also stop at the first Vite+ shim." }, + ["vp", "env", "on", "node"], + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Restoring Node leaves the pnpm preference intact and removes its old fallback entry." }, + { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Managed Node takes precedence again." }, + ["vp", "env", "on", "pnpm"], + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Restoring pnpm empties fallback again." }, ] [[case]] name = "foreign_manager_falls_back_without_recursion" vp = "global" -skip-platforms = ["windows"] -requires = ["sh"] steps = [ - { argv = ["sh", "recursion.sh"], comment = "A foreign manager falling back to Vite+ reaches managed Node even with an inherited tool marker." }, + { argv = ["node", "setup-foreign-manager.cjs"], snapshot = false }, + ["vp", "env", "off", "node"], + # Piped std::process execution handles the Windows .cmd manager; ConPTY cannot launch it directly. + { argv = ["node", "--version"], tty = false, envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "The external manager forwards to the managed fallback without re-entering itself." }, + { argv = ["node", "--version"], tty = false, envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], ["VP_PATH_INJECTED_TOOLS", "node"]], comment = "An inherited injection marker must not cause recursion either." }, + { argv = ["vp", "env", "exec", "node", "--version"], envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "env exec still runs managed Node when the inherited PATH contains the external manager." }, ] [[case]] name = "split_layout_preserves_foreign_tools" vp = "global" +# Windows resolves config through OS known folders, which HOME/APPDATA overrides do not isolate. skip-platforms = ["windows"] requires = ["sh"] steps = [ - { argv = ["sh", "split.sh"], comment = "Fallback shims stay in the data root when the main bin directory is shared, and refresh preserves foreign executables." }, + { argv = ["vpt", "mkdir", "-p", "data/current"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "$VP_HOME/current/bin", "data/current/bin"], comment = "Copy the installed CLI and its setup marker into the split data root.", snapshot = false }, + { argv = ["node", "setup-split-layout.cjs"], snapshot = false }, + { argv = ["vp", "env", "setup", "--refresh"], + envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], + snapshot = false }, + { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Setup preserves the existing executable in the shared bin." }, + { argv = ["sh", "split.sh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"]], comment = "The generated shell script puts shared bin first and data-root fallback last." }, + { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, + { argv = ["vp", "env", "off", "node"], + envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], + snapshot = false }, + { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "System-first Node lives under the data root." }, + { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, + { argv = ["node", "-p", "process.env.VP_BIN_DIR === require('node:path').resolve('shared-bin')"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["PATH", "${workspace}/data/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "The split fallback executes managed Node with the configured main bin root." }, + { argv = ["vp", "env", "setup", "--refresh"], + envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], + snapshot = false }, + { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Refresh preserves both placement and the foreign Node." }, + { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, + { argv = ["vp", "env", "on", "node"], + envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], + snapshot = false }, + { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Managed mode must not overwrite a foreign executable either." }, + { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, +] + +# Bash command-cache behavior needs a persistent Unix shell; placement and recursion run on all platforms. +[[case]] +name = "bash_path_precedence_and_cache" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [ + { argv = ["bash", "placement.sh"], comment = "Reloading env is idempotent; mode changes invalidate Bash command paths." }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/bash_path_precedence_and_cache.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/bash_path_precedence_and_cache.md new file mode 100644 index 0000000000..f1ddfbbb0e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/bash_path_precedence_and_cache.md @@ -0,0 +1,9 @@ +# bash_path_precedence_and_cache + +## `bash placement.sh` + +Reloading env is idempotent; mode changes invalidate Bash command paths. + +``` +Repeated shell setup and mode changes preserve PATH precedence and clear Bash cache +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md index 0e86912bfb..dc2cb316f3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md @@ -1,9 +1,40 @@ # foreign_manager_falls_back_without_recursion -## `sh recursion.sh` +## `node setup-foreign-manager.cjs` -A foreign manager falling back to Vite+ reaches managed Node even with an inherited tool marker. + +## `vp env off node` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +``` + +## `PATH=${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} node --version` + +The external manager forwards to the managed fallback without re-entering itself. + +``` + +``` + +## `PATH=${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} VP_PATH_INJECTED_TOOLS=node node --version` + +An inherited injection marker must not cause recursion either. + +``` + +``` + +## `PATH=${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} vp env exec node --version` + +env exec still runs managed Node when the inherited PATH contains the external manager. ``` -Foreign manager fallback terminates with managed Node + ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/placement_and_path_precedence.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/placement_and_path_precedence.md index 3d742496a0..307da2a1a2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/placement_and_path_precedence.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/placement_and_path_precedence.md @@ -1,9 +1,152 @@ # placement_and_path_precedence -## `bash placement.sh` +## `node setup-system-node.cjs` -Scoped changes move tool families together; refreshing preserves choices and PATH selects the first matching tool. + +## `vp env on` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} node list-shims.cjs $VP_HOME/bin $VP_HOME/fallback-bin` + +Managed mode keeps all tool families in the main bin; fallback can be empty. + +``` +$VP_HOME/bin: bun, bunx, node, npm, npx, pnpm, pnpx, vpr, vpx, yarn, yarnpkg +$VP_HOME/fallback-bin: (empty) +``` + +## `vp env off node` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} node list-shims.cjs $VP_HOME/bin $VP_HOME/fallback-bin` + +Only Node moves to fallback. + +``` +$VP_HOME/bin: bun, bunx, npm, npx, pnpm, pnpx, vpr, vpx, yarn, yarnpkg +$VP_HOME/fallback-bin: node +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} node -p process.execPath.includes('system-bin')` + +The shell selects system Node before fallback. + +``` +true +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} node assert-node-resolution.cjs` + +Internal Node resolution agrees with PATH. + +``` +env which selects the same Node as PATH +``` + +## `vp env off pnpm` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ pnpm management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} node list-shims.cjs $VP_HOME/bin $VP_HOME/fallback-bin` + +pnpm and pnpx move together; other families and vpx/vpr stay managed. + +``` +$VP_HOME/bin: bun, bunx, npm, npx, vpr, vpx, yarn, yarnpkg +$VP_HOME/fallback-bin: node, pnpm, pnpx +``` + +## `vp env setup --refresh` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} node list-shims.cjs $VP_HOME/bin $VP_HOME/fallback-bin` + +Refresh preserves saved choices. + +``` +$VP_HOME/bin: bun, bunx, npm, npx, vpr, vpx, yarn, yarnpkg +$VP_HOME/fallback-bin: node, pnpm, pnpx +``` + +## `PATH=${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} node -p process.execPath.includes('system-bin')` + +A fallback shim before system Node selects managed Node. + +``` +false +``` + +## `PATH=${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} node assert-node-resolution.cjs` + +Internal resolution must also stop at the first Vite+ shim. + +``` +env which selects the same Node as PATH +``` + +## `vp env on node` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js management set to managed. + +Selected commands and shims will now use Vite+ managed tools. + +Run `vp env off` to prefer system tools instead. +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} node list-shims.cjs $VP_HOME/bin $VP_HOME/fallback-bin` + +Restoring Node leaves the pnpm preference intact and removes its old fallback entry. + +``` +$VP_HOME/bin: bun, bunx, node, npm, npx, vpr, vpx, yarn, yarnpkg +$VP_HOME/fallback-bin: pnpm, pnpx +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} node -p process.execPath.includes('system-bin')` + +Managed Node takes precedence again. + +``` +false +``` + +## `vp env on pnpm` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ pnpm management set to managed. + +Selected commands and shims will now use Vite+ managed tools. + +Run `vp env off` to prefer system tools instead. +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} node list-shims.cjs $VP_HOME/bin $VP_HOME/fallback-bin` + +Restoring pnpm empties fallback again. ``` -Scoped placement, refresh, shell cache, and PATH precedence passed +$VP_HOME/bin: bun, bunx, node, npm, npx, pnpm, pnpx, vpr, vpx, yarn, yarnpkg +$VP_HOME/fallback-bin: (empty) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md index 7e3e328ac1..786de4b828 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md @@ -1,9 +1,108 @@ # split_layout_preserves_foreign_tools -## `sh split.sh` +## `vpt mkdir -p data/current` -Fallback shims stay in the data root when the main bin directory is shared, and refresh preserves foreign executables. + +## `vpt cp -r $VP_HOME/current/bin data/current/bin` + +Copy the installed CLI and its setup marker into the split data root. + + +## `node setup-split-layout.cjs` + + +## `HOME=${workspace}/user VP_HOME= VP_BIN_DIR=${workspace}/shared-bin VP_DATA_DIR=${workspace}/data VP_CACHE_DIR=${workspace}/cache XDG_CONFIG_HOME=${workspace}/config PATH=${workspace}/data/current/bin${PATH_SEPARATOR}${PATH} vp env setup --refresh` + + +## `node list-shims.cjs shared-bin data/fallback-bin` + +Setup preserves the existing executable in the shared bin. + +``` +shared-bin: bun, bunx, node, npm, npx, pnpm, pnpx, vpr, vpx, yarn, yarnpkg +data/fallback-bin: (empty) +``` + +## `HOME=${workspace}/user VP_HOME= VP_BIN_DIR=${workspace}/shared-bin VP_DATA_DIR=${workspace}/data VP_CACHE_DIR=${workspace}/cache XDG_CONFIG_HOME=${workspace}/config sh split.sh` + +The generated shell script puts shared bin first and data-root fallback last. + +``` +Split shell PATH starts with shared bin and ends with data-root fallback +``` + +## `PATH=${workspace}/shared-bin${PATH_SEPARATOR}${PATH} node -p process.execPath.includes('shared-bin')` + +The foreign Node remains executable. + +``` +true +``` + +## `HOME=${workspace}/user VP_HOME= VP_BIN_DIR=${workspace}/shared-bin VP_DATA_DIR=${workspace}/data VP_CACHE_DIR=${workspace}/cache XDG_CONFIG_HOME=${workspace}/config PATH=${workspace}/data/current/bin${PATH_SEPARATOR}${PATH} vp env off node` + + +## `node list-shims.cjs shared-bin data/fallback-bin` + +System-first Node lives under the data root. + +``` +shared-bin: bun, bunx, node, npm, npx, pnpm, pnpx, vpr, vpx, yarn, yarnpkg +data/fallback-bin: node +``` + +## `PATH=${workspace}/shared-bin${PATH_SEPARATOR}${PATH} node -p process.execPath.includes('shared-bin')` + +The foreign Node remains executable. + +``` +true +``` + +## `HOME=${workspace}/user VP_HOME= VP_BIN_DIR=${workspace}/shared-bin VP_DATA_DIR=${workspace}/data VP_CACHE_DIR=${workspace}/cache PATH=${workspace}/data/fallback-bin${PATH_SEPARATOR}${PATH} node -p 'process.env.VP_BIN_DIR === require('\''node:path'\'').resolve('\''shared-bin'\'')'` + +The split fallback executes managed Node with the configured main bin root. + +``` +true +``` + +## `HOME=${workspace}/user VP_HOME= VP_BIN_DIR=${workspace}/shared-bin VP_DATA_DIR=${workspace}/data VP_CACHE_DIR=${workspace}/cache XDG_CONFIG_HOME=${workspace}/config PATH=${workspace}/data/current/bin${PATH_SEPARATOR}${PATH} vp env setup --refresh` + + +## `node list-shims.cjs shared-bin data/fallback-bin` + +Refresh preserves both placement and the foreign Node. + +``` +shared-bin: bun, bunx, node, npm, npx, pnpm, pnpx, vpr, vpx, yarn, yarnpkg +data/fallback-bin: node +``` + +## `PATH=${workspace}/shared-bin${PATH_SEPARATOR}${PATH} node -p process.execPath.includes('shared-bin')` + +The foreign Node remains executable. + +``` +true +``` + +## `HOME=${workspace}/user VP_HOME= VP_BIN_DIR=${workspace}/shared-bin VP_DATA_DIR=${workspace}/data VP_CACHE_DIR=${workspace}/cache XDG_CONFIG_HOME=${workspace}/config PATH=${workspace}/data/current/bin${PATH_SEPARATOR}${PATH} vp env on node` + + +## `node list-shims.cjs shared-bin data/fallback-bin` + +Managed mode must not overwrite a foreign executable either. + +``` +shared-bin: bun, bunx, node, npm, npx, pnpm, pnpx, vpr, vpx, yarn, yarnpkg +data/fallback-bin: (empty) +``` + +## `PATH=${workspace}/shared-bin${PATH_SEPARATOR}${PATH} node -p process.execPath.includes('shared-bin')` + +The foreign Node remains executable. ``` -Split layout and foreign tool preservation passed +true ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/split.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/split.sh index 598598d229..74bd9fb26a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/split.sh +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/split.sh @@ -1,25 +1,6 @@ set -eu -vp_binary="$VP_HOME/bin/vp" -unset VP_HOME -HOME="$PWD/user" -XDG_CONFIG_HOME="$HOME/config" -VP_BIN_DIR="$PWD/shared-bin" -VP_DATA_DIR="$PWD/data" -VP_CACHE_DIR="$PWD/cache" -export HOME XDG_CONFIG_HOME VP_BIN_DIR VP_DATA_DIR VP_CACHE_DIR -mkdir -p "$VP_BIN_DIR" -printf '#!/bin/sh\necho foreign-node\n' > "$VP_BIN_DIR/node" -chmod +x "$VP_BIN_DIR/node" -"$vp_binary" env setup --refresh >/dev/null -"$vp_binary" env off node >/dev/null -test -L "$VP_DATA_DIR/fallback-bin/node" -test "$("$VP_BIN_DIR/node")" = foreign-node -"$vp_binary" env setup --refresh >/dev/null -test -L "$VP_DATA_DIR/fallback-bin/node" -test "$("$VP_BIN_DIR/node")" = foreign-node +. "$XDG_CONFIG_HOME/vite-plus/env" . "$XDG_CONFIG_HOME/vite-plus/env" test "${PATH%%:*}" = "$VP_BIN_DIR" test "${PATH##*:}" = "$VP_DATA_DIR/fallback-bin" -"$vp_binary" env on node >/dev/null -test "$("$VP_BIN_DIR/node")" = foreign-node -echo 'Split layout and foreign tool preservation passed' +echo 'Split shell PATH starts with shared bin and ends with data-root fallback' From c9188e4d3357ef33c18f82e9f9897dcbd8127291 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 22:01:22 +0800 Subject: [PATCH 10/15] test(env): keep snapshot inline tables on one line --- .../shim_system_first_fallback/snapshots.toml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml index ee1d3d3c4b..2b0a6624ef 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml @@ -45,26 +45,18 @@ steps = [ { argv = ["vpt", "mkdir", "-p", "data/current"], snapshot = false }, { argv = ["vpt", "cp", "-r", "$VP_HOME/current/bin", "data/current/bin"], comment = "Copy the installed CLI and its setup marker into the split data root.", snapshot = false }, { argv = ["node", "setup-split-layout.cjs"], snapshot = false }, - { argv = ["vp", "env", "setup", "--refresh"], - envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], - snapshot = false }, + { argv = ["vp", "env", "setup", "--refresh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Setup preserves the existing executable in the shared bin." }, { argv = ["sh", "split.sh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"]], comment = "The generated shell script puts shared bin first and data-root fallback last." }, { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, - { argv = ["vp", "env", "off", "node"], - envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], - snapshot = false }, + { argv = ["vp", "env", "off", "node"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "System-first Node lives under the data root." }, { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, { argv = ["node", "-p", "process.env.VP_BIN_DIR === require('node:path').resolve('shared-bin')"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["PATH", "${workspace}/data/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "The split fallback executes managed Node with the configured main bin root." }, - { argv = ["vp", "env", "setup", "--refresh"], - envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], - snapshot = false }, + { argv = ["vp", "env", "setup", "--refresh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Refresh preserves both placement and the foreign Node." }, { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, - { argv = ["vp", "env", "on", "node"], - envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], - snapshot = false }, + { argv = ["vp", "env", "on", "node"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Managed mode must not overwrite a foreign executable either." }, { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, ] From 92135a6294356b6548174c547b0ae134d90395d3 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 22:02:38 +0800 Subject: [PATCH 11/15] test(env): organize fallback scenarios into annotated steps --- .../shim_system_first_fallback/snapshots.toml | 336 +++++++++++++++--- 1 file changed, 284 insertions(+), 52 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml index 2b0a6624ef..6adbd02e83 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml @@ -1,72 +1,304 @@ [[case]] name = "placement_and_path_precedence" vp = "global" -steps = [ - { argv = ["node", "setup-system-node.cjs"], snapshot = false }, - { argv = ["vp", "env", "on"], snapshot = false }, - # Keep inspection steps on case-owned Node even when Windows PATH includes a host installation. - { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Managed mode keeps all tool families in the main bin; fallback can be empty." }, - ["vp", "env", "off", "node"], - { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Only Node moves to fallback." }, - { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "The shell selects system Node before fallback." }, - { argv = ["node", "assert-node-resolution.cjs"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Internal Node resolution agrees with PATH." }, - ["vp", "env", "off", "pnpm"], - { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "pnpm and pnpx move together; other families and vpx/vpr stay managed." }, - { argv = ["vp", "env", "setup", "--refresh"], snapshot = false }, - { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Refresh preserves saved choices." }, - { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "A fallback shim before system Node selects managed Node." }, - { argv = ["node", "assert-node-resolution.cjs"], envs = [["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Internal resolution must also stop at the first Vite+ shim." }, - ["vp", "env", "on", "node"], - { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Restoring Node leaves the pnpm preference intact and removes its old fallback entry." }, - { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Managed Node takes precedence again." }, - ["vp", "env", "on", "pnpm"], - { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Restoring pnpm empties fallback again." }, + +# Prepare a system Node and start with every tool managed by Vite+. +[[case.steps]] +argv = ["node", "setup-system-node.cjs"] +snapshot = false + +[[case.steps]] +argv = ["vp", "env", "on"] +snapshot = false + +# Inspection commands put both Vite+ directories first so Windows cannot pick a host Node. +[[case.steps]] +comment = "Managed mode keeps all tool families in the main bin; fallback can be empty." +argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] +envs = [ + ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], +] + +# Turn off only Node: system Node wins, and env which must agree with execution. +[[case.steps]] +argv = ["vp", "env", "off", "node"] + +[[case.steps]] +comment = "Only Node moves to fallback." +argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] +envs = [ + ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], +] + +[[case.steps]] +comment = "The shell selects system Node before fallback." +argv = ["node", "-p", "process.execPath.includes('system-bin')"] +envs = [ + ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], +] + +[[case.steps]] +comment = "Internal Node resolution agrees with PATH." +argv = ["node", "assert-node-resolution.cjs"] +envs = [ + ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], +] + +# Turn off pnpm as a family, then refresh without resetting either preference. +[[case.steps]] +argv = ["vp", "env", "off", "pnpm"] + +[[case.steps]] +comment = "pnpm and pnpx move together; other families and vpx/vpr stay managed." +argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] +envs = [ + ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], +] + +[[case.steps]] +argv = ["vp", "env", "setup", "--refresh"] +snapshot = false + +[[case.steps]] +comment = "Refresh preserves saved choices." +argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] +envs = [ + ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], +] + +# Reverse PATH deliberately: reaching the fallback shim must select managed Node. +[[case.steps]] +comment = "A fallback shim before system Node selects managed Node." +argv = ["node", "-p", "process.execPath.includes('system-bin')"] +envs = [ + ["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], +] + +[[case.steps]] +comment = "Internal resolution must also stop at the first Vite+ shim." +argv = ["node", "assert-node-resolution.cjs"] +envs = [ + ["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], +] + +# Restore Node first, then pnpm; each change must leave the other preference intact. +[[case.steps]] +argv = ["vp", "env", "on", "node"] + +[[case.steps]] +comment = "Restoring Node leaves the pnpm preference intact and removes its old fallback entry." +argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] +envs = [ + ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], +] + +[[case.steps]] +comment = "Managed Node takes precedence again." +argv = ["node", "-p", "process.execPath.includes('system-bin')"] +envs = [ + ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], +] + +[[case.steps]] +argv = ["vp", "env", "on", "pnpm"] + +[[case.steps]] +comment = "Restoring pnpm empties fallback again." +argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] +envs = [ + ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], ] [[case]] name = "foreign_manager_falls_back_without_recursion" vp = "global" -steps = [ - { argv = ["node", "setup-foreign-manager.cjs"], snapshot = false }, - ["vp", "env", "off", "node"], - # Piped std::process execution handles the Windows .cmd manager; ConPTY cannot launch it directly. - { argv = ["node", "--version"], tty = false, envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "The external manager forwards to the managed fallback without re-entering itself." }, - { argv = ["node", "--version"], tty = false, envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], ["VP_PATH_INJECTED_TOOLS", "node"]], comment = "An inherited injection marker must not cause recursion either." }, - { argv = ["vp", "env", "exec", "node", "--version"], envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "env exec still runs managed Node when the inherited PATH contains the external manager." }, + +# Create an external manager that forwards to the Vite+ fallback without changing PATH. +[[case.steps]] +argv = ["node", "setup-foreign-manager.cjs"] +snapshot = false + +[[case.steps]] +argv = ["vp", "env", "off", "node"] + +# Piped execution supports the Windows .cmd manager, which ConPTY cannot launch directly. +[[case.steps]] +comment = "The external manager forwards to the managed fallback without re-entering itself." +argv = ["node", "--version"] +envs = [ + ["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], +] +tty = false + +# Repeat with a retained injection marker, the condition that previously risked recursion. +[[case.steps]] +comment = "An inherited injection marker must not cause recursion either." +argv = ["node", "--version"] +envs = [ + ["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], + ["VP_PATH_INJECTED_TOOLS", "node"], +] +tty = false + +[[case.steps]] +comment = "env exec still runs managed Node when the inherited PATH contains the external manager." +argv = ["vp", "env", "exec", "node", "--version"] +envs = [ + ["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], ] [[case]] name = "split_layout_preserves_foreign_tools" vp = "global" -# Windows resolves config through OS known folders, which HOME/APPDATA overrides do not isolate. +# Windows known-folder config paths cannot be isolated by HOME/APPDATA overrides. skip-platforms = ["windows"] requires = ["sh"] -steps = [ - { argv = ["vpt", "mkdir", "-p", "data/current"], snapshot = false }, - { argv = ["vpt", "cp", "-r", "$VP_HOME/current/bin", "data/current/bin"], comment = "Copy the installed CLI and its setup marker into the split data root.", snapshot = false }, - { argv = ["node", "setup-split-layout.cjs"], snapshot = false }, - { argv = ["vp", "env", "setup", "--refresh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, - { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Setup preserves the existing executable in the shared bin." }, - { argv = ["sh", "split.sh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"]], comment = "The generated shell script puts shared bin first and data-root fallback last." }, - { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, - { argv = ["vp", "env", "off", "node"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, - { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "System-first Node lives under the data root." }, - { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, - { argv = ["node", "-p", "process.env.VP_BIN_DIR === require('node:path').resolve('shared-bin')"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["PATH", "${workspace}/data/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "The split fallback executes managed Node with the configured main bin root." }, - { argv = ["vp", "env", "setup", "--refresh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, - { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Refresh preserves both placement and the foreign Node." }, - { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, - { argv = ["vp", "env", "on", "node"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, - { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Managed mode must not overwrite a foreign executable either." }, - { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, -] - -# Bash command-cache behavior needs a persistent Unix shell; placement and recursion run on all platforms. + +# Prepare separate bin/data/cache roots and a real foreign Node in shared-bin. +[[case.steps]] +argv = ["vpt", "mkdir", "-p", "data/current"] +snapshot = false + +[[case.steps]] +comment = "Copy the installed CLI and its setup marker into the split data root." +argv = ["vpt", "cp", "-r", "$VP_HOME/current/bin", "data/current/bin"] +snapshot = false + +[[case.steps]] +argv = ["node", "setup-split-layout.cjs"] +snapshot = false + +# Install into the split layout. Isolate HOME/config and bypass the original bin wrapper. +[[case.steps]] +argv = ["vp", "env", "setup", "--refresh"] +envs = [ + ["HOME", "${workspace}/user"], + ["VP_HOME", ""], + ["VP_BIN_DIR", "${workspace}/shared-bin"], + ["VP_DATA_DIR", "${workspace}/data"], + ["VP_CACHE_DIR", "${workspace}/cache"], + ["XDG_CONFIG_HOME", "${workspace}/config"], + ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"], +] +snapshot = false + +[[case.steps]] +comment = "Setup preserves the existing executable in the shared bin." +argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"] + +[[case.steps]] +comment = "The generated shell script puts shared bin first and data-root fallback last." +argv = ["sh", "split.sh"] +envs = [ + ["HOME", "${workspace}/user"], + ["VP_HOME", ""], + ["VP_BIN_DIR", "${workspace}/shared-bin"], + ["VP_DATA_DIR", "${workspace}/data"], + ["VP_CACHE_DIR", "${workspace}/cache"], + ["XDG_CONFIG_HOME", "${workspace}/config"], +] + +[[case.steps]] +comment = "The foreign Node remains executable." +argv = ["node", "-p", "process.execPath.includes('shared-bin')"] +envs = [ + ["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"], +] + +# Switch Node to system-first: preserve the foreign executable and add a data-root fallback. +[[case.steps]] +argv = ["vp", "env", "off", "node"] +envs = [ + ["HOME", "${workspace}/user"], + ["VP_HOME", ""], + ["VP_BIN_DIR", "${workspace}/shared-bin"], + ["VP_DATA_DIR", "${workspace}/data"], + ["VP_CACHE_DIR", "${workspace}/cache"], + ["XDG_CONFIG_HOME", "${workspace}/config"], + ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"], +] +snapshot = false + +[[case.steps]] +comment = "System-first Node lives under the data root." +argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"] + +[[case.steps]] +comment = "The foreign Node remains executable." +argv = ["node", "-p", "process.execPath.includes('shared-bin')"] +envs = [ + ["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"], +] + +[[case.steps]] +comment = "The split fallback executes managed Node with the configured main bin root." +argv = ["node", "-p", "process.env.VP_BIN_DIR === require('node:path').resolve('shared-bin')"] +envs = [ + ["HOME", "${workspace}/user"], + ["VP_HOME", ""], + ["VP_BIN_DIR", "${workspace}/shared-bin"], + ["VP_DATA_DIR", "${workspace}/data"], + ["VP_CACHE_DIR", "${workspace}/cache"], + ["PATH", "${workspace}/data/fallback-bin${PATH_SEPARATOR}${PATH}"], +] + +# Refresh must retain the saved placement and the foreign executable. +[[case.steps]] +argv = ["vp", "env", "setup", "--refresh"] +envs = [ + ["HOME", "${workspace}/user"], + ["VP_HOME", ""], + ["VP_BIN_DIR", "${workspace}/shared-bin"], + ["VP_DATA_DIR", "${workspace}/data"], + ["VP_CACHE_DIR", "${workspace}/cache"], + ["XDG_CONFIG_HOME", "${workspace}/config"], + ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"], +] +snapshot = false + +[[case.steps]] +comment = "Refresh preserves both placement and the foreign Node." +argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"] + +[[case.steps]] +comment = "The foreign Node remains executable." +argv = ["node", "-p", "process.execPath.includes('shared-bin')"] +envs = [ + ["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"], +] + +# Switch back to managed mode: remove the fallback without replacing foreign Node. +[[case.steps]] +argv = ["vp", "env", "on", "node"] +envs = [ + ["HOME", "${workspace}/user"], + ["VP_HOME", ""], + ["VP_BIN_DIR", "${workspace}/shared-bin"], + ["VP_DATA_DIR", "${workspace}/data"], + ["VP_CACHE_DIR", "${workspace}/cache"], + ["XDG_CONFIG_HOME", "${workspace}/config"], + ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"], +] +snapshot = false + +[[case.steps]] +comment = "Managed mode must not overwrite a foreign executable either." +argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"] + +[[case.steps]] +comment = "The foreign Node remains executable." +argv = ["node", "-p", "process.execPath.includes('shared-bin')"] +envs = [ + ["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"], +] + [[case]] name = "bash_path_precedence_and_cache" vp = "global" skip-platforms = ["windows"] requires = ["bash"] -steps = [ - { argv = ["bash", "placement.sh"], comment = "Reloading env is idempotent; mode changes invalidate Bash command paths." }, -] + +# Keep same-shell checks here: separate process steps cannot exercise the Bash command cache. +[[case.steps]] +comment = "Reloading env is idempotent; mode changes invalidate Bash command paths." +argv = ["bash", "placement.sh"] From 7daf4169e3bf2c92b380e3d01517ee45f8663568 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 22:03:06 +0800 Subject: [PATCH 12/15] test(env): keep annotated snapshot steps compact --- .../shim_system_first_fallback/snapshots.toml | 355 ++++-------------- 1 file changed, 71 insertions(+), 284 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml index 6adbd02e83..557ff7c1b0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml @@ -1,304 +1,91 @@ [[case]] name = "placement_and_path_precedence" vp = "global" - -# Prepare a system Node and start with every tool managed by Vite+. -[[case.steps]] -argv = ["node", "setup-system-node.cjs"] -snapshot = false - -[[case.steps]] -argv = ["vp", "env", "on"] -snapshot = false - -# Inspection commands put both Vite+ directories first so Windows cannot pick a host Node. -[[case.steps]] -comment = "Managed mode keeps all tool families in the main bin; fallback can be empty." -argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] -envs = [ - ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], -] - -# Turn off only Node: system Node wins, and env which must agree with execution. -[[case.steps]] -argv = ["vp", "env", "off", "node"] - -[[case.steps]] -comment = "Only Node moves to fallback." -argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] -envs = [ - ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], -] - -[[case.steps]] -comment = "The shell selects system Node before fallback." -argv = ["node", "-p", "process.execPath.includes('system-bin')"] -envs = [ - ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], -] - -[[case.steps]] -comment = "Internal Node resolution agrees with PATH." -argv = ["node", "assert-node-resolution.cjs"] -envs = [ - ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], -] - -# Turn off pnpm as a family, then refresh without resetting either preference. -[[case.steps]] -argv = ["vp", "env", "off", "pnpm"] - -[[case.steps]] -comment = "pnpm and pnpx move together; other families and vpx/vpr stay managed." -argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] -envs = [ - ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], -] - -[[case.steps]] -argv = ["vp", "env", "setup", "--refresh"] -snapshot = false - -[[case.steps]] -comment = "Refresh preserves saved choices." -argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] -envs = [ - ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], -] - -# Reverse PATH deliberately: reaching the fallback shim must select managed Node. -[[case.steps]] -comment = "A fallback shim before system Node selects managed Node." -argv = ["node", "-p", "process.execPath.includes('system-bin')"] -envs = [ - ["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], -] - -[[case.steps]] -comment = "Internal resolution must also stop at the first Vite+ shim." -argv = ["node", "assert-node-resolution.cjs"] -envs = [ - ["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], -] - -# Restore Node first, then pnpm; each change must leave the other preference intact. -[[case.steps]] -argv = ["vp", "env", "on", "node"] - -[[case.steps]] -comment = "Restoring Node leaves the pnpm preference intact and removes its old fallback entry." -argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] -envs = [ - ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], -] - -[[case.steps]] -comment = "Managed Node takes precedence again." -argv = ["node", "-p", "process.execPath.includes('system-bin')"] -envs = [ - ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], -] - -[[case.steps]] -argv = ["vp", "env", "on", "pnpm"] - -[[case.steps]] -comment = "Restoring pnpm empties fallback again." -argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"] -envs = [ - ["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], +steps = [ + # Start with all tools managed; system-bin provides an alternative Node. + { argv = ["node", "setup-system-node.cjs"], snapshot = false }, + { argv = ["vp", "env", "on"], snapshot = false }, + # Keep inspection steps on case-owned Node even when Windows PATH includes a host installation. + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Managed mode keeps all tool families in the main bin; fallback can be empty." }, + + # Move Node to fallback and verify that the external tool takes precedence. + ["vp", "env", "off", "node"], + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Only Node moves to fallback." }, + { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "The shell selects system Node before fallback." }, + { argv = ["node", "assert-node-resolution.cjs"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Internal Node resolution agrees with PATH." }, + + # Move the pnpm family together; refresh must preserve both preferences. + ["vp", "env", "off", "pnpm"], + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "pnpm and pnpx move together; other families and vpx/vpr stay managed." }, + { argv = ["vp", "env", "setup", "--refresh"], snapshot = false }, + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Refresh preserves saved choices." }, + + # Reverse PATH: reaching the Vite+ shim first must select managed Node. + { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "A fallback shim before system Node selects managed Node." }, + { argv = ["node", "assert-node-resolution.cjs"], envs = [["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Internal resolution must also stop at the first Vite+ shim." }, + + # Restore each family independently; fallback should end up empty. + ["vp", "env", "on", "node"], + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Restoring Node leaves the pnpm preference intact and removes its old fallback entry." }, + { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Managed Node takes precedence again." }, + ["vp", "env", "on", "pnpm"], + { argv = ["node", "list-shims.cjs", "$VP_HOME/bin", "$VP_HOME/fallback-bin"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "Restoring pnpm empties fallback again." }, ] [[case]] name = "foreign_manager_falls_back_without_recursion" vp = "global" - -# Create an external manager that forwards to the Vite+ fallback without changing PATH. -[[case.steps]] -argv = ["node", "setup-foreign-manager.cjs"] -snapshot = false - -[[case.steps]] -argv = ["vp", "env", "off", "node"] - -# Piped execution supports the Windows .cmd manager, which ConPTY cannot launch directly. -[[case.steps]] -comment = "The external manager forwards to the managed fallback without re-entering itself." -argv = ["node", "--version"] -envs = [ - ["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], -] -tty = false - -# Repeat with a retained injection marker, the condition that previously risked recursion. -[[case.steps]] -comment = "An inherited injection marker must not cause recursion either." -argv = ["node", "--version"] -envs = [ - ["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], - ["VP_PATH_INJECTED_TOOLS", "node"], -] -tty = false - -[[case.steps]] -comment = "env exec still runs managed Node when the inherited PATH contains the external manager." -argv = ["vp", "env", "exec", "node", "--version"] -envs = [ - ["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], +steps = [ + # The external manager forwards to Vite+ without removing itself from PATH. + { argv = ["node", "setup-foreign-manager.cjs"], snapshot = false }, + ["vp", "env", "off", "node"], + # Piped std::process execution handles the Windows .cmd manager; ConPTY cannot launch it directly. + { argv = ["node", "--version"], tty = false, envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "The external manager forwards to the managed fallback without re-entering itself." }, + { argv = ["node", "--version"], tty = false, envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], ["VP_PATH_INJECTED_TOOLS", "node"]], comment = "An inherited injection marker must not cause recursion either." }, + { argv = ["vp", "env", "exec", "node", "--version"], envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "env exec still runs managed Node when the inherited PATH contains the external manager." }, ] [[case]] name = "split_layout_preserves_foreign_tools" vp = "global" -# Windows known-folder config paths cannot be isolated by HOME/APPDATA overrides. +# Windows resolves config through OS known folders, which HOME/APPDATA overrides do not isolate. skip-platforms = ["windows"] requires = ["sh"] - -# Prepare separate bin/data/cache roots and a real foreign Node in shared-bin. -[[case.steps]] -argv = ["vpt", "mkdir", "-p", "data/current"] -snapshot = false - -[[case.steps]] -comment = "Copy the installed CLI and its setup marker into the split data root." -argv = ["vpt", "cp", "-r", "$VP_HOME/current/bin", "data/current/bin"] -snapshot = false - -[[case.steps]] -argv = ["node", "setup-split-layout.cjs"] -snapshot = false - -# Install into the split layout. Isolate HOME/config and bypass the original bin wrapper. -[[case.steps]] -argv = ["vp", "env", "setup", "--refresh"] -envs = [ - ["HOME", "${workspace}/user"], - ["VP_HOME", ""], - ["VP_BIN_DIR", "${workspace}/shared-bin"], - ["VP_DATA_DIR", "${workspace}/data"], - ["VP_CACHE_DIR", "${workspace}/cache"], - ["XDG_CONFIG_HOME", "${workspace}/config"], - ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"], -] -snapshot = false - -[[case.steps]] -comment = "Setup preserves the existing executable in the shared bin." -argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"] - -[[case.steps]] -comment = "The generated shell script puts shared bin first and data-root fallback last." -argv = ["sh", "split.sh"] -envs = [ - ["HOME", "${workspace}/user"], - ["VP_HOME", ""], - ["VP_BIN_DIR", "${workspace}/shared-bin"], - ["VP_DATA_DIR", "${workspace}/data"], - ["VP_CACHE_DIR", "${workspace}/cache"], - ["XDG_CONFIG_HOME", "${workspace}/config"], -] - -[[case.steps]] -comment = "The foreign Node remains executable." -argv = ["node", "-p", "process.execPath.includes('shared-bin')"] -envs = [ - ["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"], -] - -# Switch Node to system-first: preserve the foreign executable and add a data-root fallback. -[[case.steps]] -argv = ["vp", "env", "off", "node"] -envs = [ - ["HOME", "${workspace}/user"], - ["VP_HOME", ""], - ["VP_BIN_DIR", "${workspace}/shared-bin"], - ["VP_DATA_DIR", "${workspace}/data"], - ["VP_CACHE_DIR", "${workspace}/cache"], - ["XDG_CONFIG_HOME", "${workspace}/config"], - ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"], -] -snapshot = false - -[[case.steps]] -comment = "System-first Node lives under the data root." -argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"] - -[[case.steps]] -comment = "The foreign Node remains executable." -argv = ["node", "-p", "process.execPath.includes('shared-bin')"] -envs = [ - ["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"], -] - -[[case.steps]] -comment = "The split fallback executes managed Node with the configured main bin root." -argv = ["node", "-p", "process.env.VP_BIN_DIR === require('node:path').resolve('shared-bin')"] -envs = [ - ["HOME", "${workspace}/user"], - ["VP_HOME", ""], - ["VP_BIN_DIR", "${workspace}/shared-bin"], - ["VP_DATA_DIR", "${workspace}/data"], - ["VP_CACHE_DIR", "${workspace}/cache"], - ["PATH", "${workspace}/data/fallback-bin${PATH_SEPARATOR}${PATH}"], -] - -# Refresh must retain the saved placement and the foreign executable. -[[case.steps]] -argv = ["vp", "env", "setup", "--refresh"] -envs = [ - ["HOME", "${workspace}/user"], - ["VP_HOME", ""], - ["VP_BIN_DIR", "${workspace}/shared-bin"], - ["VP_DATA_DIR", "${workspace}/data"], - ["VP_CACHE_DIR", "${workspace}/cache"], - ["XDG_CONFIG_HOME", "${workspace}/config"], - ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"], -] -snapshot = false - -[[case.steps]] -comment = "Refresh preserves both placement and the foreign Node." -argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"] - -[[case.steps]] -comment = "The foreign Node remains executable." -argv = ["node", "-p", "process.execPath.includes('shared-bin')"] -envs = [ - ["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"], -] - -# Switch back to managed mode: remove the fallback without replacing foreign Node. -[[case.steps]] -argv = ["vp", "env", "on", "node"] -envs = [ - ["HOME", "${workspace}/user"], - ["VP_HOME", ""], - ["VP_BIN_DIR", "${workspace}/shared-bin"], - ["VP_DATA_DIR", "${workspace}/data"], - ["VP_CACHE_DIR", "${workspace}/cache"], - ["XDG_CONFIG_HOME", "${workspace}/config"], - ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"], -] -snapshot = false - -[[case.steps]] -comment = "Managed mode must not overwrite a foreign executable either." -argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"] - -[[case.steps]] -comment = "The foreign Node remains executable." -argv = ["node", "-p", "process.execPath.includes('shared-bin')"] -envs = [ - ["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"], -] - +steps = [ + # Prepare isolated roots and an existing foreign Node in shared-bin. + { argv = ["vpt", "mkdir", "-p", "data/current"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "$VP_HOME/current/bin", "data/current/bin"], comment = "Copy the installed CLI and its setup marker into the split data root.", snapshot = false }, + { argv = ["node", "setup-split-layout.cjs"], snapshot = false }, + + # Setup must preserve the foreign Node and generate the split shell PATH. + { argv = ["vp", "env", "setup", "--refresh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, + { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Setup preserves the existing executable in the shared bin." }, + { argv = ["sh", "split.sh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"]], comment = "The generated shell script puts shared bin first and data-root fallback last." }, + { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, + + # System-first adds a fallback under data while keeping the foreign Node intact. + { argv = ["vp", "env", "off", "node"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, + { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "System-first Node lives under the data root." }, + { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, + { argv = ["node", "-p", "process.env.VP_BIN_DIR === require('node:path').resolve('shared-bin')"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["PATH", "${workspace}/data/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "The split fallback executes managed Node with the configured main bin root." }, + + # Refresh must retain the saved placement and the foreign executable. + { argv = ["vp", "env", "setup", "--refresh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, + { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Refresh preserves both placement and the foreign Node." }, + { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, + + # Switching back removes the fallback without overwriting the foreign Node. + { argv = ["vp", "env", "on", "node"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, + { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Managed mode must not overwrite a foreign executable either." }, + { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, +] + +# Bash command-cache behavior needs a persistent Unix shell; placement and recursion run on all platforms. [[case]] name = "bash_path_precedence_and_cache" vp = "global" skip-platforms = ["windows"] requires = ["bash"] - -# Keep same-shell checks here: separate process steps cannot exercise the Bash command cache. -[[case.steps]] -comment = "Reloading env is idempotent; mode changes invalidate Bash command paths." -argv = ["bash", "placement.sh"] +steps = [ + { argv = ["bash", "placement.sh"], comment = "Reloading env is idempotent; mode changes invalidate Bash command paths." }, +] From fc7673aeff6bd0b6623ee182c693e223c9fdb57f Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 22:16:55 +0800 Subject: [PATCH 13/15] fix(ci): align checks with system-first shim placement --- .github/workflows/ci.yml | 15 +++++-- .github/workflows/test-standalone-install.yml | 34 ++++++++++------ Cargo.lock | 1 - .../command_self_setup_external_homebrew.md | 2 +- .../verify-refresh.mjs | 13 +++++-- .../shell_integration_cwd_templates.md | 39 ++++++++++++------- .../tests/cli_snapshots/redact.rs | 7 ++++ crates/vp_cli_snapshots/tests/redact_unit.rs | 7 ++++ crates/vp_global_cli/Cargo.toml | 1 - 9 files changed, 82 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4dcaf4fba..8a9400d544 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -440,12 +440,19 @@ jobs: - name: Install Global CLI vp run: | pnpm bootstrap-cli:ci - if [[ "$RUNNER_OS" == "Windows" ]]; then - echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH - else - echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH + if [[ "$RUNNER_OS" != "Windows" ]]; then + . "$HOME/.vite-plus/env" + # GITHUB_PATH prepends entries, but fallback shims must remain last. + echo "PATH=$PATH" >> "$GITHUB_ENV" fi + - name: Load Windows CLI environment + if: runner.os == 'Windows' + shell: pwsh + run: | + . (Join-Path $HOME '.vite-plus/env.ps1') + "PATH=$env:Path" >> $env:GITHUB_ENV + - name: Verify vp installation run: | which vp diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 985c28d878..9baa446037 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -476,7 +476,7 @@ jobs: test "$CONFIG_DIR" = "$(dump_dir config)" test "$STATE_DIR" = "$(dump_dir state)" - - name: Custom shared bin refreshes Node even in system-first mode + - name: Custom shared bin preserves Node in system-first mode run: | set -euo pipefail FOREIGN=$(mktemp -d) @@ -492,7 +492,7 @@ jobs: export VP_BIN_DIR="$FOREIGN/.local/bin" export VP_DATA_DIR="$FOREIGN/.local/share/vite-plus" export VP_CACHE_DIR="$FOREIGN/.cache/vite-plus" - # Explicitly sharing the Vite+ bin directory accepts replacement there; system-first tools elsewhere stay intact. + # System-first shims belong under data, leaving shared-bin tools intact. export VP_NODE_MANAGER=no export PATH="$FOREIGN/user-bin:$PATH" unset VP_HOME @@ -500,8 +500,10 @@ jobs: VP_LOCAL_TGZ="$FAKE_TGZ" VP_VERSION=local-foreign-node bash packages/cli/install.sh test ! -d "$FOREIGN/.vite-plus" - test -f "$FOREIGN/.local/bin/node" - test -L "$FOREIGN/.local/bin/node" + test "$("$FOREIGN/.local/bin/node")" = "foreign-node" + # Probe the installed launcher without downloading a managed runtime. + FALLBACK_BIN=$(VP_DUMP_DIRS=1 "$VP_DATA_DIR/fallback-bin/node" | awk -F '\t' '$1 == "bin" { print $2 }') + test "$FALLBACK_BIN" = "$VP_BIN_DIR" test "$("$FOREIGN/user-bin/node")" = "foreign-node" - name: Existing ~/.vite-plus is reused @@ -1759,7 +1761,7 @@ jobs: if (Test-Path $legacyRoot) { throw "implode left the monolithic root" } if ($removingRoots.Count -ne 0) { throw "implode left a renamed monolithic root" } - - name: PowerShell installer refreshes shared-bin Node even in system-first mode + - name: PowerShell installer preserves shared-bin Node in system-first mode shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -1787,18 +1789,28 @@ jobs: $env:VP_LOCAL_TGZ = $fakeTgz $env:VP_SKIP_DEPS_INSTALL = "1" $env:VP_VERSION = "local-foreign-node" - # Explicitly sharing the Vite+ bin directory accepts replacement there; system-first tools elsewhere stay intact. + # System-first shims belong under data, leaving shared-bin tools intact. $env:VP_NODE_MANAGER = "no" $env:CI = "true" & ./packages/cli/install.ps1 - $trampoline = Join-Path $data "current/bin/vp-shim.exe" - if ((Get-FileHash $node).Hash -ne (Get-FileHash $trampoline).Hash) { - Write-Error "install.ps1 did not refresh node.exe in the configured bin directory" + if ([System.IO.File]::ReadAllText($node) -ne "foreign-node") { + Write-Error "install.ps1 replaced the foreign Node in the shared bin directory" } - if ([System.IO.File]::ReadAllText($pointer) -ne [System.IO.File]::ReadAllText((Join-Path $bin "vp.shim"))) { - Write-Error "install.ps1 did not refresh the Node shim pointer" + if ([System.IO.File]::ReadAllText($pointer) -ne "$data`n") { + Write-Error "install.ps1 replaced the foreign Node pointer" + } + $fallbackNode = Join-Path $data "fallback-bin/node.exe" + # Probe the installed launcher without downloading a managed runtime. + $env:VP_DUMP_DIRS = "1" + try { + $dirs = @(& $fallbackNode) + if ($LASTEXITCODE -ne 0 -or $dirs -notcontains "bin`t$bin") { + Write-Error "The fallback Node launcher did not preserve the main bin directory" + } + } finally { + Remove-Item Env:VP_DUMP_DIRS } if ([System.IO.File]::ReadAllText($externalNode) -ne "external-node") { Write-Error "install.ps1 replaced node.exe outside the configured bin directory" diff --git a/Cargo.lock b/Cargo.lock index 8606770648..4e9ebf3f48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8747,7 +8747,6 @@ dependencies = [ name = "vp_global_cli" version = "0.3.3" dependencies = [ - "base64-simd", "chrono", "clap", "clap_complete", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md index c2d0d77954..fc840de00b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md @@ -109,7 +109,7 @@ brew-prefix/bin/.vp-setup-complete: missing Cleanup removes user data and leaves the Homebrew package installed ``` -✓ Vite+ removed 12 shims from /home/bin +✓ Vite+ removed 3 shims from /home/bin ✓ Removed /home ✓ Vite+ removed its managed files and shell entries from your system. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs index 7d2d97c037..5389615478 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs @@ -44,9 +44,13 @@ function createEnvironment(directory) { VP_HOME: home, VP_SELF_SETUP_NO_MODIFY_PATH: '1', NPM_CONFIG_REGISTRY: 'http://127.0.0.1:9', - PATH: [path.join(home, 'bin'), path.join(directory, 'brew/bin'), system, env.PATH].join( - path.delimiter, - ), + PATH: [ + path.join(home, 'bin'), + path.join(directory, 'brew/bin'), + system, + env.PATH, + path.join(home, 'fallback-bin'), + ].join(path.delimiter), }; } @@ -69,6 +73,7 @@ function verifyDoctor(source) { const env = createEnvironment(directory); const publicBin = path.join(directory, 'brew/bin'); const shimBin = path.join(env.VP_HOME, 'bin'); + const fallbackBin = path.join(env.VP_HOME, 'fallback-bin'); const systemBin = path.join(directory, 'system/bin'); fs.mkdirSync(publicBin, { recursive: true }); fs.symlinkSync(binary, path.join(publicBin, 'vp')); @@ -104,7 +109,7 @@ function verifyDoctor(source) { binary, ['env', 'doctor', 'node'], directory, - { ...env, PATH: paths.join(path.delimiter) }, + { ...env, PATH: [...paths, fallbackBin].join(path.delimiter) }, status, ); } finally { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md index cdaaf2eba4..cd19e5f537 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md @@ -12,16 +12,20 @@ POSIX wrapper and zsh vpr completion keep global -C before env use/run # Vite+ environment setup (https://viteplus.dev) export VP_HOME="/home" __vp_bin="/home/bin" -while case ":${PATH}:" in *":${__vp_bin}:"*) true ;; *) false ;; esac; do - __vp_tmp=":${PATH}:" - __vp_before="${__vp_tmp%%":${__vp_bin}:"*}" - __vp_before="${__vp_before#:}" - __vp_after="${__vp_tmp#*":${__vp_bin}:"}" - __vp_after="${__vp_after%:}" - PATH="${__vp_before}${__vp_before:+${__vp_after:+:}}${__vp_after}" +__vp_fallback="/home/fallback-bin" +for __vp_dir in "$__vp_bin" "$__vp_fallback"; do + while case ":${PATH}:" in *":${__vp_dir}:"*) true ;; *) false ;; esac; do + __vp_tmp=":${PATH}:" + __vp_before="${__vp_tmp%%":${__vp_dir}:"*}" + __vp_before="${__vp_before#:}" + __vp_after="${__vp_tmp#*":${__vp_dir}:"}" + __vp_after="${__vp_after%:}" + PATH="${__vp_before}${__vp_before:+${__vp_after:+:}}${__vp_after}" + done done -export PATH="${__vp_bin}${PATH:+:${PATH}}" -unset __vp_bin __vp_tmp __vp_before __vp_after +export PATH="${__vp_bin}${PATH:+:${PATH}}:${__vp_fallback}" +unset __vp_bin __vp_fallback __vp_dir __vp_tmp __vp_before __vp_after +hash -r 2>/dev/null || true # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -48,7 +52,9 @@ vp() { eval "$__vp_out" else unset __vp_env_use - command vp "$@" + command vp "$@" || return $? + # Mode changes move executables between directories; discard cached command paths. + hash -r 2>/dev/null || true fi } @@ -101,7 +107,10 @@ set -gx VP_HOME "/home" while set -l __vp_idx (contains -i -- "/home/bin" $PATH) set -e PATH[$__vp_idx] end -set -gx PATH "/home/bin" $PATH +while set -l __vp_idx (contains -i -- "/home/fallback-bin" $PATH) + set -e PATH[$__vp_idx] +end +set -gx PATH "/home/bin" $PATH "/home/fallback-bin" # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -164,7 +173,7 @@ Nushell wrapper and vpr completion keep global -C before env use/run ``` # Vite+ environment setup (https://viteplus.dev) $env.VP_HOME = ("/home" | path expand --no-symlink) -$env.PATH = ($env.PATH | where { $in != "/home/bin" } | prepend "/home/bin") +$env.PATH = ($env.PATH | where { $in != "/home/bin" and $in != "/home/fallback-bin" } | prepend "/home/bin" | append "/home/fallback-bin") # Shell function wrapper: intercepts `vp env use` to parse its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -246,9 +255,9 @@ PowerShell wrapper and vpr completion keep global -C before env use/run # Vite+ environment setup (https://viteplus.dev) $env:VP_HOME = '/home' $__vp_bin = '/home/bin' -if ($env:Path -split ';' -notcontains $__vp_bin) { - $env:Path = "$__vp_bin;$env:Path" -} +$__vp_fallback = '/home/fallback-bin' +$__vp_paths = @($env:Path -split ';' | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback }) +$env:Path = (@($__vp_bin) + $__vp_paths + @($__vp_fallback)) -join ';' # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index 5cc9576271..3edbe77156 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -431,6 +431,13 @@ pub fn redact_output( paths: &[(&str, &'static str)], normalize_separators: bool, ) -> String { + // Piped Windows commands retain CRLF, unlike the rendered PTY output. + { + use cow_utils::CowUtils as _; + if let Cow::Owned(replaced) = output.as_str().cow_replace("\r\n", "\n") { + output = replaced; + } + } // ConPTY repaints rows padded to the full grid width with explicit // spaces when a second console client attaches to the terminal. Trailing // blanks are never meaningful in a rendered grid, so trim every row on diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index 71a80ff3ad..dc202173f0 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -23,6 +23,13 @@ fn masks_bare_version_block_only_for_version_probe_steps() { assert_eq!(redact_output(node_version_file.clone(), &[], true), node_version_file); } +#[test] +fn normalizes_piped_windows_line_endings() { + // Non-TTY Node version probes must share snapshots with Unix and PTY output. + let input = "```\nv22.18.0\r\n\n```\n".to_owned(); + assert_eq!(redact_output(input, &[], true), "```\n\n\n```\n"); +} + #[test] fn trims_trailing_row_padding_on_every_platform() { // ConPTY repaints rows padded to the grid width with explicit spaces. diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index 868785c426..c5c4fabf43 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -12,7 +12,6 @@ name = "vp" path = "src/main.rs" [dependencies] -base64-simd = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true, features = ["unstable-dynamic"] } From 156465186be4949691dd5bffca3c6355e2bb08eb Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 22:29:31 +0800 Subject: [PATCH 14/15] fix(env): restore legacy package manager consent prompts --- Cargo.lock | 1 + .../snapshots.toml | 16 +- ...fers_existing_family_and_records_choice.md | 12 +- crates/vp_global_cli/Cargo.toml | 1 + .../vp_global_cli/src/commands/env/setup.rs | 2 +- crates/vp_global_cli/src/shim/dispatch.rs | 182 +++++++++++++++++- 6 files changed, 206 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e9ebf3f48..8606770648 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8747,6 +8747,7 @@ dependencies = [ name = "vp_global_cli" version = "0.3.3" dependencies = [ + "base64-simd", "chrono", "clap", "clap_complete", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index b93728a6fd..9dca428582 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -6,10 +6,20 @@ steps = [ { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/yarn"], snapshot = false }, - { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, + { argv = ["pnpm", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ + { "expect-milestone" = "pm-shim-choice:pnpm" }, + { "write-key" = "down" }, + { "write-key" = "enter" }, + ] }, + { argv = ["vpt", "stat-file", "$VP_HOME/bin/pnpm", "--assert", "missing"], snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/fallback-bin/pnpm", "--assert", "symlink"], snapshot = false }, { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "the explicit system choice records only pnpm" }, - { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "PATH selects the system pnpm directly" }, - { argv = ["vp", "env", "off", "yarn"], snapshot = false }, + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "later pnpm invocations use the recorded choice without prompting" }, + { argv = ["yarn", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ + { "expect-milestone" = "pm-shim-choice:yarn" }, + { "write-key" = "down" }, + { "write-key" = "enter" }, + ] }, { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "Yarn records its own decision without changing pnpm" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md index 7db5e0679a..e4374ed299 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md @@ -9,7 +9,13 @@ ## `vpt chmod +x system-bin/yarn` -## `vp env off pnpm` +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` + + +## `vpt stat-file $VP_HOME/bin/pnpm --assert missing` + + +## `vpt stat-file $VP_HOME/fallback-bin/pnpm --assert symlink` ## `vpt print-file $VP_HOME/config.json` @@ -26,13 +32,13 @@ the explicit system choice records only pnpm ## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` -PATH selects the system pnpm directly +later pnpm invocations use the recorded choice without prompting ``` system-pnpm ``` -## `vp env off yarn` +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} yarn --version` ## `vpt print-file $VP_HOME/config.json` diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index c5c4fabf43..868785c426 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -12,6 +12,7 @@ name = "vp" path = "src/main.rs" [dependencies] +base64-simd = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true, features = ["unstable-dynamic"] } diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index c85408968d..afc83b1a71 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -163,7 +163,7 @@ pub(super) fn shim_dir(settings: &super::config::Config, tool: &str) -> vt_path: } /// Reconcile both shim directories from effective preferences; setup and mode changes share this path. -pub(super) async fn refresh_shims( +pub(crate) async fn refresh_shims( current_exe: &std::path::Path, settings: &super::config::Config, refresh: bool, diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 7a82752c08..116ab2d8b3 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -755,7 +755,11 @@ pub async fn dispatch(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { return bypass_to_system(tool, args, env); } - // PATH placement selects system-first precedence. Reaching a shim always selects its managed tool. + if let Some(exit_code) = legacy::dispatch_package_manager(tool, args, &env).await { + return exit_code; + } + + // After the first-use decision, PATH placement selects system-first precedence. // Package binaries use their install-time Node.js version; core shims use // the project-resolved runtime below. @@ -1387,6 +1391,182 @@ fn find_external_tool_in(tool: &str, cwd: &AbsolutePath) -> Option Option { + // Undecided upgrades still ask before taking over an existing package manager. + // Inherited selections skip this so an external manager falling back to us cannot prompt again. + if !env.contains(tool) + && let Some(package_manager) = PackageManagerType::from_tool(tool) + { + match resolve_package_manager_shim_choice(tool, package_manager).await { + Ok(Some(system_path)) => { + let mut child_env = env.clone(); + if let Some(bin_dir) = system_path.parent() + && let Err(error) = child_env.prepend( + bin_dir, + &[tool], + PrependOptions { dedupe_anywhere: true }, + ) + { + eprintln!("vp: Failed to prepare package manager PATH: {error}"); + return Some(1); + } + let child_env = match prepare_node_path_for_system_package_manager(child_env) + .await + { + Ok(env) => env, + Err(error) => { + eprintln!( + "vp: Failed to prepare Node.js for system package manager: {error}" + ); + return Some(1); + } + }; + return Some(exec::exec_tool(&system_path, args, child_env)); + } + Ok(None) => {} + Err(error) => { + eprintln!("vp: Failed to configure package-manager shims: {error}"); + return Some(1); + } + } + } + + None + } + + async fn prepare_node_path_for_system_package_manager( + mut env: ToolPathEnv, + ) -> Result { + if env.contains("node") && find_system_tool("node").is_some() { + return Ok(env); + } + let config = config::load_config().await?; + if config.node_shim_mode == ShimMode::SystemFirst + && let Some(node) = find_system_tool("node") + && let Some(bin_dir) = node.parent() + { + env.prepend(bin_dir, &["node"], PrependOptions::default())?; + return Ok(env); + } + + let cwd = current_dir()?; + let resolution = + resolve_with_cache(&cwd).await.map_err(|error| Error::Other(error.into()))?; + let node = ensure_installed(&resolution.version) + .await + .map_err(|error| Error::Other(error.into()))?; + let bin_dir = + node.parent().ok_or_else(|| Error::Other("Node.js has no bin directory".into()))?; + env.prepend(bin_dir, &["node"], PrependOptions::default())?; + Ok(env) + } + + // Return a system executable only for this first decision; saved modes use PATH placement. + async fn resolve_package_manager_shim_choice( + tool: &str, + package_manager: PackageManagerType, + ) -> Result, Error> { + let mut settings = config::load_config().await?; + if settings.configured_package_manager_shim_mode_for(package_manager).is_some() + || !vp_shared::is_interactive_terminal() + { + return Ok(None); + } + let Some(system_path) = find_external_tool_in(tool, ¤t_dir()?) else { + return Ok(None); + }; + let Some((mode, apply_to_all)) = + prompt_package_manager_shim_mode(package_manager, &system_path) + else { + output::note( + "Package-manager preference was not saved; using the system tool this time.", + ); + return Ok(Some(system_path)); + }; + if apply_to_all { + settings.set_all_package_manager_shim_modes(mode); + } else { + settings.set_package_manager_shim_mode(package_manager, mode); + } + // The running shim may be removed while relocating this family. Keep a stable source. + let current_exe = std::fs::canonicalize(std::env::current_exe()?)?; + crate::commands::env::setup::refresh_shims(¤t_exe, &settings, false, false).await?; + config::save_config(&settings).await?; + Ok((mode == ShimMode::SystemFirst).then_some(system_path)) + } + + fn prompt_package_manager_shim_mode( + package_manager: PackageManagerType, + system_path: &AbsolutePath, + ) -> Option<(ShimMode, bool)> { + let options = [ + "Use Vite+ for all package managers".to_string(), + format!("Use Vite+ for {package_manager}"), + format!("Use system {package_manager}"), + "Use system package managers".to_string(), + ]; + + output::raw_stderr("vp: Vite+ now can manage package-manager versions for each project."); + output::raw_stderr(&format!( + "Existing {package_manager}: {}", + system_path.as_path().display() + )); + output::raw_stderr(""); + emit_prompt_milestone(&format!("pm-shim-choice:{package_manager}")); + let choice = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("How should {package_manager} run?")) + .items(&options) + .default(1) + .interact() + .ok()?; + + Some(match choice { + 0 => (ShimMode::Managed, true), + 1 => (ShimMode::Managed, false), + 2 => (ShimMode::SystemFirst, false), + _ => (ShimMode::SystemFirst, true), + }) + } + + /// Emit an invisible synchronization point for the PTY snapshot suite. + #[expect(clippy::disallowed_macros)] + fn emit_prompt_milestone(name: &str) { + use std::io::Write as _; + + if std::env::var_os(env_vars::VP_EMIT_MILESTONES).is_none_or(|value| value != "1") { + return; + } + let id = uuid::Uuid::new_v4(); + let encoded_name = base64_simd::URL_SAFE_NO_PAD.encode_to_string(name.as_bytes()); + let mut stderr = std::io::stderr().lock(); + let _ = write!(stderr, "\x1b]2;pty-terminal-test:{}:{encoded_name}\x1b\\", id.simple()); + let _ = stderr.flush(); + } +} + #[cfg(test)] mod tests { use tempfile::TempDir; From 476943db76d2601af83fb907cb015347d039004e Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 20 Sep 2026 22:39:42 +0800 Subject: [PATCH 15/15] docs(shim): note follow-up for legacy consent lookup --- crates/vp_global_cli/src/shim/dispatch.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 116ab2d8b3..bff1c75a07 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -1397,6 +1397,8 @@ mod legacy { //! Fresh installations record these preferences during setup. Keep the prompt, //! shim relocation, and one-time system dispatch together for older installations. + // TODO: Consider moving first-use consent to `vp upgrade` so the legacy PATH lookup can be removed. + use dialoguer::{Select, theme::ColorfulTheme}; use vp_pm_cli::PackageManagerType; use vp_shared::{PrependOptions, ToolPathEnv, env_vars, output};