Skip to content

Commit 0469fb8

Browse files
committed
feat(env): add vp env use command for per-shell session Node.js version override
- Add `vp env use [VERSION]` command that outputs shell-appropriate export/unset commands to stdout (eval'd by shell function wrapper) - Add VITE_PLUS_NODE_VERSION env var as priority 0 in version resolution - Add fast-path in shim cache to skip disk I/O when env var is set - Extend env files (POSIX, fish, PowerShell) with shell function wrappers that intercept `vp env use` and eval its stdout - Add -h/--help bypass in shell function wrappers to avoid eval'ing help text - Patch env files for vp-dev binary name in install-global-cli.ts - Add friendly error message for invalid version inputs (e.g., `vp env use d`) - Update doctor diagnostics to report active session override - Add snap tests for command output and shell wrapper behavior
1 parent b39ddf7 commit 0469fb8

16 files changed

Lines changed: 775 additions & 14 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ jobs:
242242
Get-ChildItem "$env:USERPROFILE\.vite-plus-dev\bin\"
243243
244244
# Test 3: Uninstall
245-
npm uninstall -g typescript
245+
vp uninstall -g typescript
246246
247247
# Test 4: Verify uninstall removed shim
248248
Write-Host "Checking bin dir after uninstall:"
@@ -274,7 +274,7 @@ jobs:
274274
dir "%USERPROFILE%\.vite-plus-dev\bin\"
275275
276276
:: Test 3: Uninstall
277-
npm uninstall -g typescript
277+
vp uninstall -g typescript
278278
279279
:: Test 4: Verify uninstall removed shim (.cmd wrapper)
280280
echo Checking bin dir after uninstall:
@@ -312,7 +312,7 @@ jobs:
312312
ls -la ~/.vite-plus-dev/bin/
313313
314314
# Test 3: Uninstall
315-
npm uninstall -g typescript
315+
vp uninstall -g typescript
316316
317317
# Test 4: Verify uninstall removed shim
318318
echo "Checking bin dir after uninstall:"

crates/vite_global_cli/src/cli.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,25 @@ pub enum EnvSubcommands {
727727
/// If not provided, installs the version from .node-version or package.json
728728
version: Option<String>,
729729
},
730+
731+
/// Use a specific Node.js version for this shell session
732+
Use {
733+
/// Version to use (e.g., "20", "20.18.0", "lts", "latest")
734+
/// If not provided, reads from .node-version or package.json
735+
version: Option<String>,
736+
737+
/// Remove session override (revert to file-based resolution)
738+
#[arg(long)]
739+
unset: bool,
740+
741+
/// Skip auto-installation if version not present
742+
#[arg(long)]
743+
no_install: bool,
744+
745+
/// Suppress output if version is already active
746+
#[arg(long)]
747+
silent_if_unchanged: bool,
748+
},
730749
}
731750

732751
/// Package manager subcommands

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

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,34 @@ pub async fn save_config(config: &Config) -> Result<(), Error> {
145145
Ok(())
146146
}
147147

148+
/// Environment variable for per-shell session Node.js version override.
149+
/// Set by `vp env use` command.
150+
pub const VERSION_ENV_VAR: &str = "VITE_PLUS_NODE_VERSION";
151+
148152
/// Resolve Node.js version for a directory.
149153
///
150154
/// Resolution order:
155+
/// 0. `VITE_PLUS_NODE_VERSION` env var (session override from `vp env use`)
151156
/// 1. `.node-version` file in current or parent directories
152157
/// 2. `package.json#engines.node` in current or parent directories
153158
/// 3. `package.json#devEngines.runtime` in current or parent directories
154159
/// 4. User default from config.json
155160
/// 5. Latest LTS version
156161
pub async fn resolve_version(cwd: &AbsolutePath) -> Result<VersionResolution, Error> {
162+
// Session override via environment variable (set by `vp env use`)
163+
if let Ok(env_version) = std::env::var(VERSION_ENV_VAR) {
164+
let env_version = env_version.trim();
165+
if !env_version.is_empty() {
166+
return Ok(VersionResolution {
167+
version: env_version.to_string(),
168+
source: VERSION_ENV_VAR.into(),
169+
source_path: None,
170+
project_root: None,
171+
is_range: false,
172+
});
173+
}
174+
}
175+
157176
let provider = NodeProvider::new();
158177

159178
// Use shared version resolution with directory walking
@@ -291,11 +310,13 @@ async fn resolve_version_string(version: &str, provider: &NodeProvider) -> Resul
291310
}
292311

293312
/// Resolve version alias (lts, latest) to an exact version.
313+
///
314+
/// Wraps resolution errors with a user-friendly message showing valid examples.
294315
pub async fn resolve_version_alias(
295316
version: &str,
296317
provider: &NodeProvider,
297318
) -> Result<String, Error> {
298-
match version.to_lowercase().as_str() {
319+
let result = match version.to_lowercase().as_str() {
299320
"lts" => {
300321
let resolved = provider.resolve_latest_version().await?;
301322
Ok(resolved.to_string())
@@ -306,7 +327,24 @@ pub async fn resolve_version_alias(
306327
Ok(resolved.to_string())
307328
}
308329
_ => resolve_version_string(version, provider).await,
309-
}
330+
};
331+
result.map_err(|e| match e {
332+
Error::RuntimeDownload(
333+
vite_js_runtime::Error::SemverRange(_)
334+
| vite_js_runtime::Error::NoMatchingVersion { .. },
335+
) => Error::Other(
336+
format!(
337+
"Invalid Node.js version: \"{version}\"\n\n\
338+
Valid examples:\n \
339+
vp env use 20 # Latest Node.js 20.x\n \
340+
vp env use 20.18.0 # Exact version\n \
341+
vp env use lts # Latest LTS version\n \
342+
vp env use latest # Latest version"
343+
)
344+
.into(),
345+
),
346+
other => other,
347+
})
310348
}
311349

312350
#[cfg(test)]
@@ -723,4 +761,83 @@ mod tests {
723761
resolution.version
724762
);
725763
}
764+
765+
#[tokio::test]
766+
#[serial]
767+
async fn test_resolve_version_env_var_takes_priority() {
768+
let temp_dir = TempDir::new().unwrap();
769+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
770+
771+
// Create .node-version file
772+
tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap();
773+
774+
// SAFETY: This test runs in isolation with serial_test
775+
unsafe {
776+
std::env::set_var(VERSION_ENV_VAR, "22.0.0");
777+
}
778+
779+
let resolution = resolve_version(&temp_path).await.unwrap();
780+
781+
// VITE_PLUS_NODE_VERSION should take priority over .node-version
782+
assert_eq!(resolution.version, "22.0.0");
783+
assert_eq!(resolution.source, VERSION_ENV_VAR);
784+
assert!(resolution.source_path.is_none());
785+
assert!(!resolution.is_range);
786+
787+
unsafe {
788+
std::env::remove_var(VERSION_ENV_VAR);
789+
}
790+
}
791+
792+
#[tokio::test]
793+
#[serial]
794+
async fn test_resolve_version_empty_env_var_is_ignored() {
795+
let temp_dir = TempDir::new().unwrap();
796+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
797+
798+
// Create .node-version file
799+
tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap();
800+
801+
// Set empty env var - should be ignored
802+
// SAFETY: This test runs in isolation with serial_test
803+
unsafe {
804+
std::env::set_var(VERSION_ENV_VAR, "");
805+
}
806+
807+
let resolution = resolve_version(&temp_path).await.unwrap();
808+
809+
// Empty env var should be ignored, should fall through to .node-version
810+
assert_eq!(resolution.version, "20.18.0");
811+
assert_eq!(resolution.source, ".node-version");
812+
813+
unsafe {
814+
std::env::remove_var(VERSION_ENV_VAR);
815+
}
816+
}
817+
818+
#[tokio::test]
819+
#[serial]
820+
async fn test_resolve_version_whitespace_env_var_is_ignored() {
821+
let temp_dir = TempDir::new().unwrap();
822+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
823+
824+
// Create .node-version file
825+
tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap();
826+
827+
// Set whitespace-only env var - should be ignored
828+
// SAFETY: This test runs in isolation with serial_test
829+
unsafe {
830+
std::env::set_var(VERSION_ENV_VAR, " ");
831+
}
832+
833+
let resolution = resolve_version(&temp_path).await.unwrap();
834+
835+
// Whitespace env var should be ignored, should fall through to .node-version
836+
assert_eq!(resolution.version, "20.18.0");
837+
assert_eq!(resolution.source, ".node-version");
838+
839+
unsafe {
840+
std::env::remove_var(VERSION_ENV_VAR);
841+
}
842+
}
726843
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result<ExitStatus, Error> {
3434
// Check shim mode
3535
check_shim_mode().await;
3636

37+
// Check session override
38+
check_session_override();
39+
3740
// Check PATH
3841
has_errors |= !check_path().await;
3942

@@ -199,6 +202,24 @@ fn find_system_node() -> Option<std::path::PathBuf> {
199202
which::which_in("node", Some(filtered_path), cwd).ok()
200203
}
201204

205+
/// Check for active session override via VITE_PLUS_NODE_VERSION.
206+
fn check_session_override() {
207+
if let Ok(version) = std::env::var(super::config::VERSION_ENV_VAR) {
208+
let version = version.trim();
209+
if !version.is_empty() {
210+
println!();
211+
println!("Session Override:");
212+
println!(
213+
" {}",
214+
format!("\u{2139} VITE_PLUS_NODE_VERSION={} (set by `vp env use`)", version)
215+
.yellow()
216+
);
217+
println!(" This overrides all file-based version resolution.");
218+
println!(" Run 'vp env use --unset' to remove.");
219+
}
220+
}
221+
}
222+
202223
/// Check PATH configuration.
203224
async fn check_path() -> bool {
204225
println!();

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mod pin;
1818
mod run;
1919
mod setup;
2020
mod unpin;
21+
mod r#use;
2122
mod which;
2223

2324
use std::process::ExitStatus;
@@ -72,6 +73,9 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result<ExitStatus,
7273
println!("Uninstalled Node.js v{}", resolved);
7374
Ok(ExitStatus::default())
7475
}
76+
crate::cli::EnvSubcommands::Use { version, unset, no_install, silent_if_unchanged } => {
77+
r#use::execute(cwd, version, unset, no_install, silent_if_unchanged).await
78+
}
7579
crate::cli::EnvSubcommands::Install { version } => {
7680
let resolved = if let Some(version) = version {
7781
let provider = vite_js_runtime::NodeProvider::new();
@@ -126,6 +130,7 @@ fn print_help() {
126130
println!(" pin [VERSION] Pin a Node.js version in current directory");
127131
println!(" unpin Remove the .node-version file from current directory");
128132
println!(" list [PATTERN] List available Node.js versions");
133+
println!(" use [VERSION] Use a Node.js version for this shell session");
129134
println!(" run [--node <VER>] Run a command (--node optional for shim tools)");
130135
println!(" packages List installed global packages");
131136
println!(" install [VERSION] Install a Node.js version (reads project config if omitted)");
@@ -154,6 +159,10 @@ fn print_help() {
154159
println!(" vp env install # Install version from .node-version / package.json");
155160
println!(" vp env install lts # Install latest LTS version");
156161
println!(" vp env uninstall 20.18.0 # Uninstall Node.js 20.18.0");
162+
println!(" vp env use 20 # Use Node.js 20 for this shell session");
163+
println!(" vp env use lts # Use latest LTS for this shell session");
164+
println!(" vp env use # Use project version for this shell session");
165+
println!(" vp env use --unset # Remove session override");
157166
println!(" vp env run --node 20 node -v # Run 'node -v' with Node.js 20");
158167
println!(" vp env run --node lts npm i # Run 'npm i' with latest LTS");
159168
println!(" vp env run node -v # Shim mode (version auto-resolved)");

0 commit comments

Comments
 (0)