Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions crates/vite_global_cli/src/commands/env/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
});
Comment thread
fengmk2 marked this conversation as resolved.

// Create .node-version file
tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap();
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/vite_global_cli/src/commands/env/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result<ExitStatus,
};
}

// No flags provided - show unified help to match `vp env --help`.
// No subcommand provided - show unified help to match `vp env --help`.
if !crate::help::print_unified_clap_help_for_path(&["env"]) {
// Fallback to clap's built-in help printer if unified rendering fails.
use clap::CommandFactory;
Expand Down
157 changes: 90 additions & 67 deletions crates/vite_global_cli/src/commands/env/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@ use owo_colors::OwoColorize;
use super::config::{get_bin_dir, get_vp_home};
use crate::{error::Error, help};

/// Shells that get a generated `~/.vite-plus/env.*` setup script.
#[derive(Clone, Copy, Debug)]
enum EnvShell {
Posix,
Fish,
Nu,
Powershell,
}

impl EnvShell {
/// File name written under `~/.vite-plus/` for this shell's setup script.
const fn env_file_name(self) -> &'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"];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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(())
Expand Down
92 changes: 86 additions & 6 deletions crates/vite_global_cli/src/commands/env/use.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -59,12 +75,15 @@ pub async fn execute(
) -> Result<ExitStatus, Error> {
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();
}
Comment thread
fengmk2 marked this conversation as resolved.
eprintln!("Reverted to file-based Node.js version resolution");
return Ok(ExitStatus::default());
Expand All @@ -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?;
Expand All @@ -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?;
}
Expand Down Expand Up @@ -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));
Comment thread
fengmk2 marked this conversation as resolved.
} else {
// No eval wrapper (CI or direct invocation) — write session file so shims can read it
config::write_session_version(&resolved_version).await?;
Expand Down Expand Up @@ -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());
}
}
Loading