diff --git a/crates/vite_global_cli/src/commands/env/config.rs b/crates/vite_global_cli/src/commands/env/config.rs index bc0f248f27..45e955a797 100644 --- a/crates/vite_global_cli/src/commands/env/config.rs +++ b/crates/vite_global_cli/src/commands/env/config.rs @@ -959,9 +959,10 @@ mod tests { async fn test_resolve_version_session_file_takes_priority_over_node_version() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vite_shared::EnvConfig::test_guard( - vite_shared::EnvConfig::for_test_with_home(temp_dir.path()), - ); + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + is_ci: cfg!(windows), + ..vite_shared::EnvConfig::for_test_with_home(temp_dir.path()) + }); // Create .node-version file tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); @@ -1029,9 +1030,10 @@ mod tests { async fn test_session_file_source_accepted_by_install_validation() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vite_shared::EnvConfig::test_guard( - vite_shared::EnvConfig::for_test_with_home(temp_dir.path()), - ); + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + is_ci: cfg!(windows), + ..vite_shared::EnvConfig::for_test_with_home(temp_dir.path()) + }); // Write session version file write_session_version("22.0.0").await.unwrap(); diff --git a/crates/vite_global_cli/src/commands/env/mod.rs b/crates/vite_global_cli/src/commands/env/mod.rs index 1f5f7eed6b..6202039cb9 100644 --- a/crates/vite_global_cli/src/commands/env/mod.rs +++ b/crates/vite_global_cli/src/commands/env/mod.rs @@ -136,7 +136,7 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result &'static str { + match self { + EnvShell::Posix => "env", + EnvShell::Fish => "env.fish", + EnvShell::Nu => "env.nu", + EnvShell::Powershell => "env.ps1", + } + } +} + /// Tools to create shims for (node, npm, npx, vpx, vpr) pub(crate) const SHIM_TOOLS: &[&str] = &["node", "npm", "npx", "vpx", "vpr"]; @@ -443,41 +464,12 @@ async fn cleanup_legacy_completion_dir(vite_plus_home: &vite_path::AbsolutePath) } } -/// Create env files with PATH guard (prevents duplicate PATH entries). -/// -/// Creates: -/// - `~/.vite-plus/env` (POSIX shell — bash/zsh) with `vp()` wrapper function -/// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function -/// - `~/.vite-plus/env.nu` (Nushell) with `vp env use` wrapper function -/// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function -/// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`) -async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> { - let bin_path = vite_plus_home.join("bin"); - - // Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env) - // This makes the env file portable across sessions where HOME may differ - let home_dir = vite_shared::EnvConfig::get().user_home; - let to_ref = |path: &vite_path::AbsolutePath| -> String { - home_dir - .as_ref() - .and_then(|h| path.as_path().strip_prefix(h).ok()) - .map(|s| { - // Normalize to forward slashes for $HOME/... paths (POSIX-style) - format!("$HOME/{}", s.display().to_string().replace('\\', "/")) - }) - .unwrap_or_else(|| path.as_path().display().to_string()) - }; - let bin_path_ref = to_ref(&bin_path); - // Nushell requires `~` instead of `$HOME` in string literals — `$HOME` is not expanded - // at parse time, so PATH entries would contain a literal "$HOME/..." segment. - let bin_path_ref_nu = bin_path_ref.replace("$HOME/", "~/"); - - // POSIX env file (bash/zsh) - // When sourced multiple times, removes existing entry and re-prepends to front - // Uses parameter expansion to split PATH around the bin entry in O(1) operations - // Includes vp() shell function wrapper for `vp env use` (evals stdout) - // Includes shell completion support - let env_content = r#"#!/bin/sh +// POSIX env file (bash/zsh) +// When sourced multiple times, removes existing entry and re-prepends to front +// Uses parameter expansion to split PATH around the bin entry in O(1) operations +// Includes vp() shell function wrapper for `vp env use` (evals stdout) +// Includes shell completion support +const ENV_TEMPLATE_POSIX: &str = r#"#!/bin/sh # Vite+ environment setup (https://viteplus.dev) __vp_bin="__VP_BIN__" case ":${PATH}:" in @@ -523,13 +515,9 @@ elif [ -n "$ZSH_VERSION" ] && type compdef >/dev/null 2>&1; then compdef _vpr_complete vpr ' fi -"# - .replace("__VP_BIN__", &bin_path_ref); - let env_file = vite_plus_home.join("env"); - tokio::fs::write(&env_file, env_content).await?; +"#; - // Fish env file with vp wrapper function - let env_fish_content = r#"# Vite+ environment setup (https://viteplus.dev) +const ENV_TEMPLATE_FISH: &str = r#"# Vite+ environment setup (https://viteplus.dev) set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) and set -e PATH[$__vp_idx] set -gx PATH __VP_BIN__ $PATH @@ -558,15 +546,12 @@ function __vpr_complete VP_COMPLETE=fish command vp -- vp run $tokens[2..] $current end complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" -"# - .replace("__VP_BIN__", &bin_path_ref); - let env_fish_file = vite_plus_home.join("env.fish"); - tokio::fs::write(&env_fish_file, env_fish_content).await?; - - // Nushell env file with vp wrapper function. - // 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. - let env_nu_content = r#"# Vite+ environment setup (https://viteplus.dev) +"#; + +// Nushell env file with vp wrapper function. +// 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.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") # Shell function wrapper: intercepts `vp env use` to parse its stdout, @@ -624,13 +609,9 @@ def "nu-complete vpr" [context: string] { } } export extern "vpr" [...args: string@"nu-complete vpr"] -"# - .replace("__VP_BIN__", &bin_path_ref_nu); - let env_nu_file = vite_plus_home.join("env.nu"); - tokio::fs::write(&env_nu_file, env_nu_content).await?; +"#; - // PowerShell env file - let env_ps1_content = r#"# Vite+ environment setup (https://viteplus.dev) +const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) $__vp_bin = "__VP_BIN_WIN__" if ($env:Path -split ';' -notcontains $__vp_bin) { $env:Path = "$__vp_bin;$env:Path" @@ -689,19 +670,61 @@ $__vpr_comp = { Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp "#; - // For PowerShell, use the actual absolute path (not $HOME-relative) - let bin_path_win = bin_path.as_path().display().to_string(); - let env_ps1_content = env_ps1_content.replace("__VP_BIN_WIN__", &bin_path_win); - let env_ps1_file = vite_plus_home.join("env.ps1"); - tokio::fs::write(&env_ps1_file, env_ps1_content).await?; +// cmd.exe wrapper for `vp env use` (cmd.exe cannot define shell functions). +// Users run `vp-use 24` in cmd.exe instead of `vp env use 24`. +const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; + +/// Render the env-file content for `shell` against `vite_plus_home`. +fn render_env_content(shell: EnvShell, vite_plus_home: &vite_path::AbsolutePath) -> String { + let bin_path = vite_plus_home.join("bin"); + + // Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env). + // This makes the env file portable across sessions where HOME may differ. + let home_dir = vite_shared::EnvConfig::get().user_home; + let bin_path_ref = home_dir + .as_ref() + .and_then(|h| bin_path.as_path().strip_prefix(h).ok()) + .map(|s| { + // Normalize to forward slashes for $HOME/... paths (POSIX-style) + format!("$HOME/{}", s.display().to_string().replace('\\', "/")) + }) + .unwrap_or_else(|| bin_path.as_path().display().to_string()); + + match shell { + EnvShell::Posix => ENV_TEMPLATE_POSIX.replace("__VP_BIN__", &bin_path_ref), + EnvShell::Fish => ENV_TEMPLATE_FISH.replace("__VP_BIN__", &bin_path_ref), + EnvShell::Nu => { + // Nushell requires `~` instead of `$HOME` in string literals — `$HOME` is not + // expanded at parse time, so PATH entries would contain a literal "$HOME/...". + let bin_path_ref_nu = bin_path_ref.replace("$HOME/", "~/"); + ENV_TEMPLATE_NU.replace("__VP_BIN__", &bin_path_ref_nu) + } + EnvShell::Powershell => { + // PowerShell uses the actual absolute path (not $HOME-relative) + let bin_path_win = bin_path.as_path().display().to_string(); + ENV_TEMPLATE_PS1.replace("__VP_BIN_WIN__", &bin_path_win) + } + } +} + +/// Create env files with PATH guard (prevents duplicate PATH entries). +/// +/// Creates: +/// - `~/.vite-plus/env` (POSIX shell — bash/zsh) with `vp()` wrapper function +/// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function +/// - `~/.vite-plus/env.nu` (Nushell) with `vp env use` wrapper function +/// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function +/// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`) +async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> { + for shell in [EnvShell::Posix, EnvShell::Fish, EnvShell::Nu, EnvShell::Powershell] { + let content = render_env_content(shell, vite_plus_home); + tokio::fs::write(vite_plus_home.join(shell.env_file_name()), content).await?; + } - // cmd.exe wrapper for `vp env use` (cmd.exe cannot define shell functions) - // Users run `vp-use 24` in cmd.exe instead of `vp env use 24` - let vp_use_cmd_content = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; - // Only write if bin directory exists (it may not during --env-only) + // Only write the cmd wrapper if bin directory exists (it may not during --env-only) + let bin_path = vite_plus_home.join("bin"); if tokio::fs::try_exists(&bin_path).await.unwrap_or(false) { - let vp_use_cmd_file = bin_path.join("vp-use.cmd"); - tokio::fs::write(&vp_use_cmd_file, vp_use_cmd_content).await?; + tokio::fs::write(bin_path.join("vp-use.cmd"), VP_USE_CMD_CONTENT).await?; } Ok(()) diff --git a/crates/vite_global_cli/src/commands/env/use.rs b/crates/vite_global_cli/src/commands/env/use.rs index 5076aa849c..ed1da3684f 100644 --- a/crates/vite_global_cli/src/commands/env/use.rs +++ b/crates/vite_global_cli/src/commands/env/use.rs @@ -12,7 +12,10 @@ use std::process::ExitStatus; use vite_path::AbsolutePathBuf; -use super::config::{self, VERSION_ENV_VAR}; +use super::{ + config::{self, VERSION_ENV_VAR}, + exit_status, +}; use crate::{ commands::shell::{Shell, detect_shell}, error::Error, @@ -49,6 +52,19 @@ fn has_eval_wrapper() -> bool { vite_shared::EnvConfig::get().env_use_eval_enable } +fn can_use_session_file() -> bool { + cfg!(not(windows)) || vite_shared::EnvConfig::get().is_ci +} + +fn print_windows_eval_wrapper_required() { + eprintln!( + "vp env use on Windows requires the Vite+ PowerShell wrapper to affect only the current shell session." + ); + eprintln!("Add this line to your PowerShell $PROFILE:"); + eprintln!(" . \"$env:USERPROFILE\\.vite-plus\\env.ps1\""); + eprintln!("Then dot-source it now (or open a new PowerShell session) to load the wrapper."); +} + /// Execute the `vp env use` command. pub async fn execute( cwd: AbsolutePathBuf, @@ -59,12 +75,15 @@ pub async fn execute( ) -> Result { let shell = detect_shell(); - // Handle --unset: remove session override + // Handle --unset: remove session override. + // Always delete the session file: on Windows it lives under VP_HOME and can + // leak across shell windows, so even eval mode must clean it up. if unset { + config::delete_session_version().await?; if has_eval_wrapper() { println!("{}", format_unset(&shell)); - } else { - config::delete_session_version().await?; + } else if !can_use_session_file() { + print_windows_eval_wrapper_required(); } eprintln!("Reverted to file-based Node.js version resolution"); return Ok(ExitStatus::default()); @@ -79,10 +98,13 @@ pub async fn execute( (resolved, format!("{ver}")) } else { // No version argument - unset session override first + config::delete_session_version().await?; if has_eval_wrapper() { println!("{}", format_unset(&shell)); - } else { - config::delete_session_version().await?; + } else if !can_use_session_file() { + eprintln!("Reverted to file-based Node.js version resolution"); + print_windows_eval_wrapper_required(); + return Ok(ExitStatus::default()); } // Now resolve from project files (not from session override) let resolution = config::resolve_version_from_files(&cwd).await?; @@ -101,7 +123,11 @@ pub async fn execute( if current.as_deref() == Some(&resolved_version) { // Already active — idempotent, skip stderr status message if has_eval_wrapper() { + config::delete_session_version().await?; println!("{}", format_export(&shell, &resolved_version)); + } else if !can_use_session_file() { + print_windows_eval_wrapper_required(); + return Ok(exit_status(1)); } else { config::write_session_version(&resolved_version).await?; } @@ -133,8 +159,12 @@ pub async fn execute( } if has_eval_wrapper() { + config::delete_session_version().await?; // Output the shell command to stdout (consumed by shell wrapper's eval) println!("{}", format_export(&shell, &resolved_version)); + } else if !can_use_session_file() { + print_windows_eval_wrapper_required(); + return Ok(exit_status(1)); } else { // No eval wrapper (CI or direct invocation) — write session file so shims can read it config::write_session_version(&resolved_version).await?; @@ -277,4 +307,54 @@ mod tests { let result = format_unset(&Shell::NuShell); assert_eq!(result, "hide-env VP_NODE_VERSION"); } + + #[cfg(windows)] + #[tokio::test] + async fn test_windows_direct_use_without_eval_wrapper_does_not_write_session_file() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _guard = vite_shared::EnvConfig::test_guard( + vite_shared::EnvConfig::for_test_with_home(temp_dir.path()), + ); + + let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + + assert_eq!(status.code(), Some(1)); + assert!(config::read_session_version().await.is_none()); + } + + #[cfg(windows)] + #[tokio::test] + async fn test_windows_ci_direct_use_writes_session_file() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + is_ci: true, + ..vite_shared::EnvConfig::for_test_with_home(temp_dir.path()) + }); + + let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + + assert!(status.success()); + assert_eq!(config::read_session_version().await.as_deref(), Some("20.18.0")); + } + + #[cfg(windows)] + #[tokio::test] + async fn test_windows_eval_wrapper_cleans_legacy_session_file() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + env_use_eval_enable: true, + vp_shell_pwsh: true, + ..vite_shared::EnvConfig::for_test_with_home(temp_dir.path()) + }); + + config::write_session_version("22.0.0").await.unwrap(); + + let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + + assert!(status.success()); + assert!(config::read_session_version().await.is_none()); + } } diff --git a/docs/guide/env.md b/docs/guide/env.md index 8b2ad88355..68b7f97dda 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -28,11 +28,33 @@ This switches to system-first mode, where the shims prefer your system Node.js a ### Setup -- `vp env setup` creates or updates shims in `VP_HOME/bin` +- `vp env setup` creates or updates shims in `VP_HOME/bin` (and writes the per-shell setup scripts under `~/.vite-plus/`) - `vp env on` enables managed mode so shims always use Vite+-managed Node.js - `vp env off` enables system-first mode so shims prefer system Node.js first - `vp env print` prints the shell snippet for the current session +PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: + +```powershell +. "$env:USERPROFILE\.vite-plus\env.ps1" +``` + +Add that line to the end of your PowerShell `$PROFILE` to apply it automatically in new shells. It does not require elevated privileges. + +Create the profile file if it does not already exist: + +```powershell +if (-not (Test-Path $PROFILE)) { New-Item $PROFILE -Force } +``` + +Open the profile file for editing: + +```powershell +Invoke-Item $PROFILE +``` + +In CI, `vp env use` can still run without shell initialization. It writes a temporary session file under `VP_HOME` so later shim calls in the same job can resolve the selected Node.js version. + ### Manage - `vp env default` sets or shows the global default Node.js version diff --git a/rfcs/env-command.md b/rfcs/env-command.md index af61b74f2c..ef0c584356 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -50,6 +50,9 @@ vp env default # Control shim mode vp env on # Enable managed mode (shims always use vite-plus Node.js) vp env off # Enable system-first mode (shims prefer system Node.js) + +# PowerShell session setup: dot-source the script generated by `vp env setup` +. "$env:USERPROFILE\.vite-plus\env.ps1" ``` ### Diagnostic Commands @@ -122,12 +125,25 @@ vp env use --silent-if-unchanged # Suppress output if version already active 1. `~/.vite-plus/env` includes a `vp()` shell function that intercepts `vp env use` calls 2. The wrapper sets `VITE_PLUS_ENV_USE_EVAL_ENABLE=1` before calling `command vp env use ...` 3. When the env var is present (wrapper active), `vp env use` outputs shell commands to stdout for eval -4. When the env var is absent (CI, direct invocation), `vp env use` writes a session file (`~/.vite-plus/.session-node-version`) instead +4. When the env var is absent in CI, `vp env use` writes a session file (`~/.vite-plus/.session-node-version`) instead 5. The shim dispatch checks `VITE_PLUS_NODE_VERSION` env var first, then the session file, in the resolution chain -**Automatic session file (for CI / wrapper-less environments):** +On Windows interactive shells, `vp env use` requires the PowerShell setup script (`~/.vite-plus/env.ps1`, written by `vp env setup`) to be dot-sourced in the current shell so the selected version stays session-scoped: + +```powershell +. "$env:USERPROFILE\.vite-plus\env.ps1" +``` + +Add that line to the end of the PowerShell `$PROFILE` to apply it automatically in new shells: + +```powershell +if (-not (Test-Path $PROFILE)) { New-Item $PROFILE -Force } +Invoke-Item $PROFILE +``` + +**Automatic session file (for CI):** -When `vp env use` detects that the shell eval wrapper is not active (i.e., `VITE_PLUS_ENV_USE_EVAL_ENABLE` is not set), it automatically writes the resolved version to `~/.vite-plus/.session-node-version`. Shims read this file directly from disk, so `vp env use` works without the shell wrapper — no extra flags needed. The env var still takes priority when set, so the shell wrapper experience is unchanged. +When `vp env use` detects a CI environment and the shell eval wrapper is not active (i.e., `VITE_PLUS_ENV_USE_EVAL_ENABLE` is not set), it automatically writes the resolved version to `~/.vite-plus/.session-node-version`. Shims read this file directly from disk, so CI jobs can keep using `vp env use` without shell setup. The env var still takes priority when set, so the shell wrapper experience is unchanged. ```bash # GitHub Actions example (no shell wrapper, session file written automatically) @@ -519,8 +535,8 @@ When resolving which Node.js version to use, vite-plus checks the following sour - Overrides all file-based resolution 1. **`.session-node-version`** file (session override) - - Written by `vp env use` to `~/.vite-plus/.session-node-version` - - Works without shell eval wrapper (CI environments) + - Written by `vp env use` to `~/.vite-plus/.session-node-version` in CI + - Preserves wrapper-less CI behavior without making Windows interactive shells global - Deleted by `vp env use --unset` 2. **`.node-version`** file