Skip to content

Commit e561d78

Browse files
authored
fix(cli): keep env use session-scoped on Windows (#1577)
## Summary - prevent `vp env use` on Windows from writing the shared `.session-node-version` fallback - require the PowerShell eval wrapper for Windows session-scoped env switching - ignore legacy session files from Windows runtime resolution and surface them in `vp env doctor` ## Verification - installed local dev CLI and verified direct Windows invocation no longer creates `.session-node-version` - verified separate wrapped PowerShell sessions can select Node 20.18.0 and 22.18.0 independently <img width="1958" height="430" alt="image" src="https://github.com/user-attachments/assets/ca2216d4-3b0e-4d1c-8559-f7c058ff5386" /> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes Windows behavior for `vp env use` by preventing session-file fallback outside CI and returning non-zero when the PowerShell eval wrapper isn’t loaded, which could break existing Windows workflows/scripts. > > **Overview** > Ensures `vp env use` stays *session-scoped on Windows* by disallowing `.session-node-version` writes in interactive Windows shells unless the PowerShell wrapper is active (CI is still allowed to use the session file fallback). `vp env use --unset` now always deletes any existing session file, and new Windows-only tests cover direct invocation, CI fallback, and wrapper cleanup behavior. > > Refactors env setup script generation in `env setup` into per-shell templates with a shared renderer, and updates PowerShell setup guidance/output (including docs and RFC) to explicitly dot-source `"$env:USERPROFILE\.vite-plus\env.ps1"`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6e38069. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 23d7e68 commit e561d78

6 files changed

Lines changed: 229 additions & 86 deletions

File tree

crates/vite_global_cli/src/commands/env/config.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -959,9 +959,10 @@ mod tests {
959959
async fn test_resolve_version_session_file_takes_priority_over_node_version() {
960960
let temp_dir = TempDir::new().unwrap();
961961
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
962-
let _guard = vite_shared::EnvConfig::test_guard(
963-
vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),
964-
);
962+
let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {
963+
is_ci: cfg!(windows),
964+
..vite_shared::EnvConfig::for_test_with_home(temp_dir.path())
965+
});
965966

966967
// Create .node-version file
967968
tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap();
@@ -1029,9 +1030,10 @@ mod tests {
10291030
async fn test_session_file_source_accepted_by_install_validation() {
10301031
let temp_dir = TempDir::new().unwrap();
10311032
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
1032-
let _guard = vite_shared::EnvConfig::test_guard(
1033-
vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),
1034-
);
1033+
let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {
1034+
is_ci: cfg!(windows),
1035+
..vite_shared::EnvConfig::for_test_with_home(temp_dir.path())
1036+
});
10351037

10361038
// Write session version file
10371039
write_session_version("22.0.0").await.unwrap();

crates/vite_global_cli/src/commands/env/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result<ExitStatus,
136136
};
137137
}
138138

139-
// No flags provided - show unified help to match `vp env --help`.
139+
// No subcommand provided - show unified help to match `vp env --help`.
140140
if !crate::help::print_unified_clap_help_for_path(&["env"]) {
141141
// Fallback to clap's built-in help printer if unified rendering fails.
142142
use clap::CommandFactory;

crates/vite_global_cli/src/commands/env/setup.rs

Lines changed: 90 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ use owo_colors::OwoColorize;
2222
use super::config::{get_bin_dir, get_vp_home};
2323
use crate::{error::Error, help};
2424

25+
/// Shells that get a generated `~/.vite-plus/env.*` setup script.
26+
#[derive(Clone, Copy, Debug)]
27+
enum EnvShell {
28+
Posix,
29+
Fish,
30+
Nu,
31+
Powershell,
32+
}
33+
34+
impl EnvShell {
35+
/// File name written under `~/.vite-plus/` for this shell's setup script.
36+
const fn env_file_name(self) -> &'static str {
37+
match self {
38+
EnvShell::Posix => "env",
39+
EnvShell::Fish => "env.fish",
40+
EnvShell::Nu => "env.nu",
41+
EnvShell::Powershell => "env.ps1",
42+
}
43+
}
44+
}
45+
2546
/// Tools to create shims for (node, npm, npx, vpx, vpr)
2647
pub(crate) const SHIM_TOOLS: &[&str] = &["node", "npm", "npx", "vpx", "vpr"];
2748

@@ -443,41 +464,12 @@ async fn cleanup_legacy_completion_dir(vite_plus_home: &vite_path::AbsolutePath)
443464
}
444465
}
445466

446-
/// Create env files with PATH guard (prevents duplicate PATH entries).
447-
///
448-
/// Creates:
449-
/// - `~/.vite-plus/env` (POSIX shell — bash/zsh) with `vp()` wrapper function
450-
/// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function
451-
/// - `~/.vite-plus/env.nu` (Nushell) with `vp env use` wrapper function
452-
/// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function
453-
/// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`)
454-
async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> {
455-
let bin_path = vite_plus_home.join("bin");
456-
457-
// Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env)
458-
// This makes the env file portable across sessions where HOME may differ
459-
let home_dir = vite_shared::EnvConfig::get().user_home;
460-
let to_ref = |path: &vite_path::AbsolutePath| -> String {
461-
home_dir
462-
.as_ref()
463-
.and_then(|h| path.as_path().strip_prefix(h).ok())
464-
.map(|s| {
465-
// Normalize to forward slashes for $HOME/... paths (POSIX-style)
466-
format!("$HOME/{}", s.display().to_string().replace('\\', "/"))
467-
})
468-
.unwrap_or_else(|| path.as_path().display().to_string())
469-
};
470-
let bin_path_ref = to_ref(&bin_path);
471-
// Nushell requires `~` instead of `$HOME` in string literals — `$HOME` is not expanded
472-
// at parse time, so PATH entries would contain a literal "$HOME/..." segment.
473-
let bin_path_ref_nu = bin_path_ref.replace("$HOME/", "~/");
474-
475-
// POSIX env file (bash/zsh)
476-
// When sourced multiple times, removes existing entry and re-prepends to front
477-
// Uses parameter expansion to split PATH around the bin entry in O(1) operations
478-
// Includes vp() shell function wrapper for `vp env use` (evals stdout)
479-
// Includes shell completion support
480-
let env_content = r#"#!/bin/sh
467+
// POSIX env file (bash/zsh)
468+
// When sourced multiple times, removes existing entry and re-prepends to front
469+
// Uses parameter expansion to split PATH around the bin entry in O(1) operations
470+
// Includes vp() shell function wrapper for `vp env use` (evals stdout)
471+
// Includes shell completion support
472+
const ENV_TEMPLATE_POSIX: &str = r#"#!/bin/sh
481473
# Vite+ environment setup (https://viteplus.dev)
482474
__vp_bin="__VP_BIN__"
483475
case ":${PATH}:" in
@@ -523,13 +515,9 @@ elif [ -n "$ZSH_VERSION" ] && type compdef >/dev/null 2>&1; then
523515
compdef _vpr_complete vpr
524516
'
525517
fi
526-
"#
527-
.replace("__VP_BIN__", &bin_path_ref);
528-
let env_file = vite_plus_home.join("env");
529-
tokio::fs::write(&env_file, env_content).await?;
518+
"#;
530519

531-
// Fish env file with vp wrapper function
532-
let env_fish_content = r#"# Vite+ environment setup (https://viteplus.dev)
520+
const ENV_TEMPLATE_FISH: &str = r#"# Vite+ environment setup (https://viteplus.dev)
533521
set -l __vp_idx (contains -i -- __VP_BIN__ $PATH)
534522
and set -e PATH[$__vp_idx]
535523
set -gx PATH __VP_BIN__ $PATH
@@ -558,15 +546,12 @@ function __vpr_complete
558546
VP_COMPLETE=fish command vp -- vp run $tokens[2..] $current
559547
end
560548
complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)"
561-
"#
562-
.replace("__VP_BIN__", &bin_path_ref);
563-
let env_fish_file = vite_plus_home.join("env.fish");
564-
tokio::fs::write(&env_fish_file, env_fish_content).await?;
565-
566-
// Nushell env file with vp wrapper function.
567-
// Completions delegate to Fish dynamically (VP_COMPLETE=fish) because clap_complete_nushell
568-
// generates multiple rest params (e.g. for `vp install`), which Nushell does not support.
569-
let env_nu_content = r#"# Vite+ environment setup (https://viteplus.dev)
549+
"#;
550+
551+
// Nushell env file with vp wrapper function.
552+
// Completions delegate to Fish dynamically (VP_COMPLETE=fish) because clap_complete_nushell
553+
// generates multiple rest params (e.g. for `vp install`), which Nushell does not support.
554+
const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev)
570555
$env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__")
571556
572557
# Shell function wrapper: intercepts `vp env use` to parse its stdout,
@@ -624,13 +609,9 @@ def "nu-complete vpr" [context: string] {
624609
}
625610
}
626611
export extern "vpr" [...args: string@"nu-complete vpr"]
627-
"#
628-
.replace("__VP_BIN__", &bin_path_ref_nu);
629-
let env_nu_file = vite_plus_home.join("env.nu");
630-
tokio::fs::write(&env_nu_file, env_nu_content).await?;
612+
"#;
631613

632-
// PowerShell env file
633-
let env_ps1_content = r#"# Vite+ environment setup (https://viteplus.dev)
614+
const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev)
634615
$__vp_bin = "__VP_BIN_WIN__"
635616
if ($env:Path -split ';' -notcontains $__vp_bin) {
636617
$env:Path = "$__vp_bin;$env:Path"
@@ -689,19 +670,61 @@ $__vpr_comp = {
689670
Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp
690671
"#;
691672

692-
// For PowerShell, use the actual absolute path (not $HOME-relative)
693-
let bin_path_win = bin_path.as_path().display().to_string();
694-
let env_ps1_content = env_ps1_content.replace("__VP_BIN_WIN__", &bin_path_win);
695-
let env_ps1_file = vite_plus_home.join("env.ps1");
696-
tokio::fs::write(&env_ps1_file, env_ps1_content).await?;
673+
// cmd.exe wrapper for `vp env use` (cmd.exe cannot define shell functions).
674+
// Users run `vp-use 24` in cmd.exe instead of `vp env use 24`.
675+
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";
676+
677+
/// Render the env-file content for `shell` against `vite_plus_home`.
678+
fn render_env_content(shell: EnvShell, vite_plus_home: &vite_path::AbsolutePath) -> String {
679+
let bin_path = vite_plus_home.join("bin");
680+
681+
// Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env).
682+
// This makes the env file portable across sessions where HOME may differ.
683+
let home_dir = vite_shared::EnvConfig::get().user_home;
684+
let bin_path_ref = home_dir
685+
.as_ref()
686+
.and_then(|h| bin_path.as_path().strip_prefix(h).ok())
687+
.map(|s| {
688+
// Normalize to forward slashes for $HOME/... paths (POSIX-style)
689+
format!("$HOME/{}", s.display().to_string().replace('\\', "/"))
690+
})
691+
.unwrap_or_else(|| bin_path.as_path().display().to_string());
692+
693+
match shell {
694+
EnvShell::Posix => ENV_TEMPLATE_POSIX.replace("__VP_BIN__", &bin_path_ref),
695+
EnvShell::Fish => ENV_TEMPLATE_FISH.replace("__VP_BIN__", &bin_path_ref),
696+
EnvShell::Nu => {
697+
// Nushell requires `~` instead of `$HOME` in string literals — `$HOME` is not
698+
// expanded at parse time, so PATH entries would contain a literal "$HOME/...".
699+
let bin_path_ref_nu = bin_path_ref.replace("$HOME/", "~/");
700+
ENV_TEMPLATE_NU.replace("__VP_BIN__", &bin_path_ref_nu)
701+
}
702+
EnvShell::Powershell => {
703+
// PowerShell uses the actual absolute path (not $HOME-relative)
704+
let bin_path_win = bin_path.as_path().display().to_string();
705+
ENV_TEMPLATE_PS1.replace("__VP_BIN_WIN__", &bin_path_win)
706+
}
707+
}
708+
}
709+
710+
/// Create env files with PATH guard (prevents duplicate PATH entries).
711+
///
712+
/// Creates:
713+
/// - `~/.vite-plus/env` (POSIX shell — bash/zsh) with `vp()` wrapper function
714+
/// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function
715+
/// - `~/.vite-plus/env.nu` (Nushell) with `vp env use` wrapper function
716+
/// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function
717+
/// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`)
718+
async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> {
719+
for shell in [EnvShell::Posix, EnvShell::Fish, EnvShell::Nu, EnvShell::Powershell] {
720+
let content = render_env_content(shell, vite_plus_home);
721+
tokio::fs::write(vite_plus_home.join(shell.env_file_name()), content).await?;
722+
}
697723

698-
// cmd.exe wrapper for `vp env use` (cmd.exe cannot define shell functions)
699-
// Users run `vp-use 24` in cmd.exe instead of `vp env use 24`
700-
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";
701-
// Only write if bin directory exists (it may not during --env-only)
724+
// Only write the cmd wrapper if bin directory exists (it may not during --env-only)
725+
let bin_path = vite_plus_home.join("bin");
702726
if tokio::fs::try_exists(&bin_path).await.unwrap_or(false) {
703-
let vp_use_cmd_file = bin_path.join("vp-use.cmd");
704-
tokio::fs::write(&vp_use_cmd_file, vp_use_cmd_content).await?;
727+
tokio::fs::write(bin_path.join("vp-use.cmd"), VP_USE_CMD_CONTENT).await?;
705728
}
706729

707730
Ok(())

crates/vite_global_cli/src/commands/env/use.rs

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ use std::process::ExitStatus;
1212

1313
use vite_path::AbsolutePathBuf;
1414

15-
use super::config::{self, VERSION_ENV_VAR};
15+
use super::{
16+
config::{self, VERSION_ENV_VAR},
17+
exit_status,
18+
};
1619
use crate::{
1720
commands::shell::{Shell, detect_shell},
1821
error::Error,
@@ -49,6 +52,19 @@ fn has_eval_wrapper() -> bool {
4952
vite_shared::EnvConfig::get().env_use_eval_enable
5053
}
5154

55+
fn can_use_session_file() -> bool {
56+
cfg!(not(windows)) || vite_shared::EnvConfig::get().is_ci
57+
}
58+
59+
fn print_windows_eval_wrapper_required() {
60+
eprintln!(
61+
"vp env use on Windows requires the Vite+ PowerShell wrapper to affect only the current shell session."
62+
);
63+
eprintln!("Add this line to your PowerShell $PROFILE:");
64+
eprintln!(" . \"$env:USERPROFILE\\.vite-plus\\env.ps1\"");
65+
eprintln!("Then dot-source it now (or open a new PowerShell session) to load the wrapper.");
66+
}
67+
5268
/// Execute the `vp env use` command.
5369
pub async fn execute(
5470
cwd: AbsolutePathBuf,
@@ -59,12 +75,15 @@ pub async fn execute(
5975
) -> Result<ExitStatus, Error> {
6076
let shell = detect_shell();
6177

62-
// Handle --unset: remove session override
78+
// Handle --unset: remove session override.
79+
// Always delete the session file: on Windows it lives under VP_HOME and can
80+
// leak across shell windows, so even eval mode must clean it up.
6381
if unset {
82+
config::delete_session_version().await?;
6483
if has_eval_wrapper() {
6584
println!("{}", format_unset(&shell));
66-
} else {
67-
config::delete_session_version().await?;
85+
} else if !can_use_session_file() {
86+
print_windows_eval_wrapper_required();
6887
}
6988
eprintln!("Reverted to file-based Node.js version resolution");
7089
return Ok(ExitStatus::default());
@@ -79,10 +98,13 @@ pub async fn execute(
7998
(resolved, format!("{ver}"))
8099
} else {
81100
// No version argument - unset session override first
101+
config::delete_session_version().await?;
82102
if has_eval_wrapper() {
83103
println!("{}", format_unset(&shell));
84-
} else {
85-
config::delete_session_version().await?;
104+
} else if !can_use_session_file() {
105+
eprintln!("Reverted to file-based Node.js version resolution");
106+
print_windows_eval_wrapper_required();
107+
return Ok(ExitStatus::default());
86108
}
87109
// Now resolve from project files (not from session override)
88110
let resolution = config::resolve_version_from_files(&cwd).await?;
@@ -101,7 +123,11 @@ pub async fn execute(
101123
if current.as_deref() == Some(&resolved_version) {
102124
// Already active — idempotent, skip stderr status message
103125
if has_eval_wrapper() {
126+
config::delete_session_version().await?;
104127
println!("{}", format_export(&shell, &resolved_version));
128+
} else if !can_use_session_file() {
129+
print_windows_eval_wrapper_required();
130+
return Ok(exit_status(1));
105131
} else {
106132
config::write_session_version(&resolved_version).await?;
107133
}
@@ -133,8 +159,12 @@ pub async fn execute(
133159
}
134160

135161
if has_eval_wrapper() {
162+
config::delete_session_version().await?;
136163
// Output the shell command to stdout (consumed by shell wrapper's eval)
137164
println!("{}", format_export(&shell, &resolved_version));
165+
} else if !can_use_session_file() {
166+
print_windows_eval_wrapper_required();
167+
return Ok(exit_status(1));
138168
} else {
139169
// No eval wrapper (CI or direct invocation) — write session file so shims can read it
140170
config::write_session_version(&resolved_version).await?;
@@ -277,4 +307,54 @@ mod tests {
277307
let result = format_unset(&Shell::NuShell);
278308
assert_eq!(result, "hide-env VP_NODE_VERSION");
279309
}
310+
311+
#[cfg(windows)]
312+
#[tokio::test]
313+
async fn test_windows_direct_use_without_eval_wrapper_does_not_write_session_file() {
314+
let temp_dir = tempfile::TempDir::new().unwrap();
315+
let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
316+
let _guard = vite_shared::EnvConfig::test_guard(
317+
vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),
318+
);
319+
320+
let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap();
321+
322+
assert_eq!(status.code(), Some(1));
323+
assert!(config::read_session_version().await.is_none());
324+
}
325+
326+
#[cfg(windows)]
327+
#[tokio::test]
328+
async fn test_windows_ci_direct_use_writes_session_file() {
329+
let temp_dir = tempfile::TempDir::new().unwrap();
330+
let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
331+
let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {
332+
is_ci: true,
333+
..vite_shared::EnvConfig::for_test_with_home(temp_dir.path())
334+
});
335+
336+
let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap();
337+
338+
assert!(status.success());
339+
assert_eq!(config::read_session_version().await.as_deref(), Some("20.18.0"));
340+
}
341+
342+
#[cfg(windows)]
343+
#[tokio::test]
344+
async fn test_windows_eval_wrapper_cleans_legacy_session_file() {
345+
let temp_dir = tempfile::TempDir::new().unwrap();
346+
let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
347+
let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig {
348+
env_use_eval_enable: true,
349+
vp_shell_pwsh: true,
350+
..vite_shared::EnvConfig::for_test_with_home(temp_dir.path())
351+
});
352+
353+
config::write_session_version("22.0.0").await.unwrap();
354+
355+
let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap();
356+
357+
assert!(status.success());
358+
assert!(config::read_session_version().await.is_none());
359+
}
280360
}

0 commit comments

Comments
 (0)