diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index e6aab2c585..64f3086a20 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -8,7 +8,11 @@ on: paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/tools/src/install-global-cli.ts' - 'crates/vp_installer/**' + - 'crates/vp_trampoline/**' + - 'crates/vp_global_cli/**' + - 'crates/vp_shared/**' - 'crates/vp_pm_cli/**' - 'crates/vp_setup/**' - '.github/workflows/test-standalone-install.yml' @@ -40,6 +44,12 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - name: Pin VP_HOME for pre-split published CLI + # This job installs the npm-published CLI, which does not yet resolve + # the split layout. Pin VP_HOME so the new installer still writes a + # single-root tree the released binary understands. + run: echo "VP_HOME=$HOME/.vite-plus" >> $GITHUB_ENV + - name: Run install.sh run: cat packages/cli/install.sh | bash @@ -114,6 +124,71 @@ jobs: vp upgrade --rollback vp --version + test-install-sh-layout: + name: Test install.sh layout (fresh split + grandfather) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - uses: ./.github/actions/clone + - uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17 — Swatinem/rust-cache v2.9.1 for node24) + + - name: Build local vp + run: cargo build --release -p vp_global_cli + + - name: Fresh home uses split layout + env: + VP_LOCAL_BINARY: ${{ github.workspace }}/target/release/vp + run: | + set -euo pipefail + FRESH=$(mktemp -d) + FAKE_TGZ=$(mktemp) + export HOME="$FRESH" + export USERPROFILE="$FRESH" + unset VP_HOME VP_BIN_DIR VP_DATA_DIR VP_CACHE_DIR + unset XDG_BIN_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_CONFIG_HOME XDG_STATE_HOME + VP_LOCAL_TGZ="$FAKE_TGZ" \ + VP_SKIP_DEPS_INSTALL=1 \ + VP_VERSION=local-test \ + CI=true \ + VP_NODE_MANAGER=yes \ + bash packages/cli/install.sh + + test ! -d "$FRESH/.vite-plus" + test -e "$FRESH/.local/share/vite-plus/current" + test -e "$FRESH/.local/bin/vp" + test -f "$FRESH/.config/vite-plus/env" + test "$(readlink "$FRESH/.local/bin/vp")" = "$FRESH/.local/share/vite-plus/current/bin/vp" + "$FRESH/.local/bin/vp" --version + + - name: Existing ~/.vite-plus is reused + env: + VP_LOCAL_BINARY: ${{ github.workspace }}/target/release/vp + run: | + set -euo pipefail + GRAND=$(mktemp -d) + FAKE_TGZ=$(mktemp) + mkdir -p "$GRAND/.vite-plus" + echo keep > "$GRAND/.vite-plus/.keep" + export HOME="$GRAND" + export USERPROFILE="$GRAND" + unset VP_HOME VP_BIN_DIR VP_DATA_DIR VP_CACHE_DIR + unset XDG_BIN_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_CONFIG_HOME XDG_STATE_HOME + VP_LOCAL_TGZ="$FAKE_TGZ" \ + VP_SKIP_DEPS_INSTALL=1 \ + VP_VERSION=local-test \ + CI=true \ + VP_NODE_MANAGER=yes \ + bash packages/cli/install.sh + + test -f "$GRAND/.vite-plus/.keep" + test -e "$GRAND/.vite-plus/current" + test -e "$GRAND/.vite-plus/bin/vp" + test ! -d "$GRAND/.local/share/vite-plus" + test "$(readlink "$GRAND/.vite-plus/bin/vp")" = "../current/bin/vp" + "$GRAND/.vite-plus/bin/vp" --version + test-install-sh-readonly-config: name: Test install.sh (readonly shell config) runs-on: ubuntu-latest @@ -122,6 +197,9 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - name: Pin VP_HOME for pre-split published CLI + run: echo "VP_HOME=$HOME/.vite-plus" >> $GITHUB_ENV + - name: Make shell config files read-only run: | # Simulate Nix-managed or read-only shell configs @@ -169,6 +247,7 @@ jobs: ubuntu:20.04 bash -c " ls -al ~/ apt-get update && apt-get install -y curl ca-certificates + export VP_HOME=\"\$HOME/.vite-plus\" cat /workspace/packages/cli/install.sh | bash if [ -f ~/.profile ]; then source ~/.profile @@ -228,6 +307,7 @@ jobs: alpine:3.21 sh -c " # libstdc++: required by unofficial-builds Node.js musl binary apk add --no-cache bash curl ca-certificates libstdc++ + export VP_HOME=\"\$HOME/.vite-plus\" cat /workspace/packages/cli/install.sh | bash export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" @@ -285,6 +365,7 @@ jobs: alpine:3.21 sh -c " # libstdc++ is needed by unofficial-builds Node.js musl binary apk add --no-cache bash curl ca-certificates libstdc++ + export VP_HOME=\"\$HOME/.vite-plus\" cat /workspace/packages/cli/install.sh | bash export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" @@ -420,6 +501,10 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - name: Pin VP_HOME for pre-split published CLI + shell: bash + run: echo "VP_HOME=$USERPROFILE\.vite-plus" >> $GITHUB_ENV + - name: Run install.ps1 shell: pwsh run: | @@ -664,6 +749,10 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - name: Pin VP_HOME for pre-split published CLI + shell: bash + run: echo "VP_HOME=$USERPROFILE\.vite-plus" >> $GITHUB_ENV + - name: Run install.ps1 shell: pwsh run: | diff --git a/AGENTS.md b/AGENTS.md index 34a7d5d012..03f3790015 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ vite-plus/ └── crates/vp_trampoline/ # Windows shim trampoline ``` +On-disk paths (bin, data, cache, and derived helpers) are resolved centrally via `vp_shared::VpDirs` (`crates/vp_shared/src/dirs.rs`, source chain in `dirs/resolution.rs`) — split XDG/platform roots, or a single root when `VP_HOME` is set or an existing `~/.vite-plus` install is found; no call site constructs category paths or reads `VP_HOME`/`XDG_*` itself. + `packages/test` is no longer tracked. The public test API is `vite-plus/test*`, generated by `packages/cli/build.ts` as shims over upstream `vitest` and `@vitest/browser*` exports. ## Where to Start @@ -108,7 +110,7 @@ pnpm bootstrap-cli # Build packages, compile vp/NAPI, and install the global CL vp --version ``` -Use `pnpm bootstrap-cli` when you need to validate the installed global CLI at `~/.vite-plus`. +Use `pnpm bootstrap-cli` when you need to validate the installed global CLI end-to-end. ### Routine validation diff --git a/Cargo.lock b/Cargo.lock index 532818cc61..1c99a25cda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7664,6 +7664,16 @@ dependencies = [ "xattr", ] +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "futures", + "parking_lot", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -8509,6 +8519,7 @@ dependencies = [ "serde_json", "serial_test", "tar", + "temp-env", "tempfile", "thiserror 2.0.19", "tokio", @@ -8533,6 +8544,7 @@ dependencies = [ "clap", "indicatif", "owo-colors", + "tempfile", "tokio", "vp_pm_cli", "vp_setup", @@ -8669,9 +8681,12 @@ dependencies = [ "serde_json", "serial_test", "supports-color 3.0.2", + "temp-env", + "tempfile", "thiserror 2.0.19", "tracing", "tracing-subscriber", + "vp_shared", "vt_path", "vt_powershell", "vt_str", diff --git a/Cargo.toml b/Cargo.toml index 6ef3e536bd..e45b879a2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -270,6 +270,7 @@ sugar_path = { version = "3", features = ["cached_current_dir"] } supports-color = "3" syn = { version = "2", default-features = false } tar = "0.4.43" +temp-env = "0.3.6" tempfile = "3.14.0" terminal_size = "0.4.2" test-log = { version = "0.2.18", features = ["trace"] } diff --git a/crates/vp_command/src/ps1_shim.rs b/crates/vp_command/src/ps1_shim.rs index f4665da6c0..4b822b9bb9 100644 --- a/crates/vp_command/src/ps1_shim.rs +++ b/crates/vp_command/src/ps1_shim.rs @@ -7,9 +7,9 @@ //! `PowerShell` sidesteps the prompt and lets Ctrl+C propagate cleanly. //! //! The rewrite is scoped to two patterns: -//! - Inside `$VP_HOME` (`~/.vite-plus` by default) — vp's managed shims: -//! - `$VP_HOME/js_runtime/node//{npm,npx}.cmd`, -//! - `$VP_HOME/package_manager////bin/.cmd`. +//! - Inside vp's data root (``) — vp's managed shims: +//! - `/js_runtime/node//{npm,npx}.cmd`, +//! - `/package_manager////bin/.cmd`. //! - Any `<...>/node_modules/.bin/*.cmd` — the canonical layout for //! npm/pnpm/yarn-emitted shims (cmd-shim writes both `.cmd` and `.ps1` //! so the wrappers stay equivalent). @@ -46,8 +46,8 @@ use vt_powershell::{POWERSHELL_PREFIX, find_ps1_sibling, is_stdin_terminal, powe /// - no `PowerShell` host (`pwsh.exe` or `powershell.exe`) is on PATH, /// - stdin is not a terminal (the `.ps1` wrappers hang on piped/null /// stdin and the Ctrl+C concern doesn't apply without a TTY), -/// - the resolved path is outside `$VP_HOME` (or `$VP_HOME` is -/// unresolvable) AND not under any `node_modules/.bin/`, +/// - the resolved path is outside vp's data root AND not under any +/// `node_modules/.bin/`, /// - the resolved path is not a `.cmd` (case-insensitive), /// - the `.cmd` has no sibling `.ps1`. #[must_use] @@ -58,23 +58,14 @@ pub fn rewrite_cmd_to_powershell( // our stdin means a TTY in the child too. `is_stdin_terminal` is shared with // `vt_plan::ps1_shim` via the `vt_powershell` crate. let host = powershell_host()?; - rewrite_in_scope(resolved, vp_home().map(AsRef::as_ref), host, is_stdin_terminal()) -} - -/// Cached `$VP_HOME` (`~/.vite-plus` by default; overridable via env var). -/// Returns `None` if `vp_shared::get_vp_home()` failed; the rewrite still -/// applies to `node_modules/.bin/*.cmd` paths in that case (the two scopes -/// are independent). -fn vp_home() -> Option<&'static AbsolutePathBuf> { - use std::sync::LazyLock; - - static VP_HOME: LazyLock> = - LazyLock::new(|| vp_shared::get_vp_home().ok()); - VP_HOME.as_ref() + // vp's managed shims all live under the data root (`/js_runtime/…`, + // `/package_manager/…`). + let config = vp_shared::EnvConfig::get(); + rewrite_in_scope(resolved, Some(config.dirs.data.as_absolute_path()), host, is_stdin_terminal()) } /// Pure rewrite logic. Factored out so tests can drive it on any platform -/// without depending on a real `powershell.exe` or a real `$VP_HOME`. +/// without depending on a real `powershell.exe` or a real vp data root. fn rewrite_in_scope( resolved: &AbsolutePath, vp_home: Option<&AbsolutePath>, @@ -211,10 +202,9 @@ mod tests { ); } - /// `vp_home` may be unresolvable in unusual environments (CI containers - /// missing $HOME, sandboxed shells); when that happens the - /// `node_modules/.bin` scope must still rewrite, since it is - /// architecturally independent from the `$VP_HOME` scope. + /// When no vp data root participates in the scope check (`None` here), + /// the `node_modules/.bin` scope must still rewrite, since it is + /// architecturally independent from the data-root scope. #[test] fn rewrites_cmd_in_node_modules_bin_when_vp_home_unresolved() { let dir = tempdir().unwrap(); diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index 94dff268b9..0999959b99 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -45,7 +45,9 @@ uuid = { workspace = true, features = ["v4"] } [dev-dependencies] serial_test = { workspace = true } +temp-env = { workspace = true } tempfile = { workspace = true } +vp_shared = { workspace = true, features = ["test-utils"] } [lints] workspace = true diff --git a/crates/vp_global_cli/src/commands/env/bin_config.rs b/crates/vp_global_cli/src/commands/env/bin_config.rs index a1959a22fe..cd21839200 100644 --- a/crates/vp_global_cli/src/commands/env/bin_config.rs +++ b/crates/vp_global_cli/src/commands/env/bin_config.rs @@ -1,7 +1,7 @@ //! Per-binary configuration storage for global packages. //! //! Each binary installed via `vp install -g` gets a config file at -//! `~/.vite-plus/bins/{name}.json` that tracks which package owns it. +//! `/bins/{name}.json` that tracks which package owns it. //! This enables: //! - Deterministic binary-to-package resolution //! - Conflict detection when installing packages with overlapping binaries @@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize}; use vt_path::AbsolutePathBuf; -use super::config::get_vp_home; use crate::error::Error; /// Source that installed a binary. @@ -24,7 +23,7 @@ pub enum BinSource { Npm, } -/// Config for a single binary, stored at ~/.vite-plus/bins/{name}.json +/// Config for a single binary, stored at `/bins/{name}.json` #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BinConfig { @@ -52,9 +51,9 @@ impl BinConfig { Self { name, package, version: String::new(), node_version, source: BinSource::Npm } } - /// Get the bins directory path (~/.vite-plus/bins/). + /// Get the bins directory path (`/bins`). pub fn bins_dir() -> Result { - Ok(get_vp_home()?.join("bins")) + Ok(vp_shared::EnvConfig::get().dirs.data.join("bins")) } /// Get the path to a binary's config file. @@ -154,7 +153,7 @@ impl BinConfig { Self::find_bins_where(|config| config.package == package_name).await } - /// Scan `~/.vite-plus/bins/` and return names of binaries matching a predicate. + /// Scan `/bins` and return names of binaries matching a predicate. async fn find_bins_where(predicate: impl Fn(&BinConfig) -> bool) -> Result, Error> { let bins_dir = Self::bins_dir()?; if !tokio::fs::try_exists(&bins_dir).await.unwrap_or(false) { @@ -184,119 +183,116 @@ impl BinConfig { #[cfg(test)] mod tests { use tempfile::TempDir; + use vp_shared::env_vars; use super::*; #[tokio::test] async fn test_save_and_load() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - let config = BinConfig::new( - "tsc".to_string(), - "typescript".to_string(), - "5.0.0".to_string(), - "20.18.0".to_string(), - ); - config.save().await.unwrap(); - - let loaded = BinConfig::load("tsc").await.unwrap(); - assert!(loaded.is_some()); - let loaded = loaded.unwrap(); - assert_eq!(loaded.name, "tsc"); - assert_eq!(loaded.package, "typescript"); - assert_eq!(loaded.version, "5.0.0"); - assert_eq!(loaded.node_version, "20.18.0"); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + let config = BinConfig::new( + "tsc".to_string(), + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + ); + config.save().await.unwrap(); + + let loaded = BinConfig::load("tsc").await.unwrap(); + assert!(loaded.is_some()); + let loaded = loaded.unwrap(); + assert_eq!(loaded.name, "tsc"); + assert_eq!(loaded.package, "typescript"); + assert_eq!(loaded.version, "5.0.0"); + assert_eq!(loaded.node_version, "20.18.0"); + }) + .await; } #[tokio::test] async fn test_find_by_package() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - // Create configs for typescript (tsc, tsserver) - let tsc = BinConfig::new( - "tsc".to_string(), - "typescript".to_string(), - "5.0.0".to_string(), - "20.18.0".to_string(), - ); - tsc.save().await.unwrap(); - - let tsserver = BinConfig::new( - "tsserver".to_string(), - "typescript".to_string(), - "5.0.0".to_string(), - "20.18.0".to_string(), - ); - tsserver.save().await.unwrap(); - - // Create config for eslint - let eslint = BinConfig::new( - "eslint".to_string(), - "eslint".to_string(), - "9.0.0".to_string(), - "22.0.0".to_string(), - ); - eslint.save().await.unwrap(); - - // Find by package - let ts_bins = BinConfig::find_by_package("typescript").await.unwrap(); - assert_eq!(ts_bins.len(), 2); - assert!(ts_bins.contains(&"tsc".to_string())); - assert!(ts_bins.contains(&"tsserver".to_string())); - - let eslint_bins = BinConfig::find_by_package("eslint").await.unwrap(); - assert_eq!(eslint_bins.len(), 1); - assert!(eslint_bins.contains(&"eslint".to_string())); - - let nonexistent_bins = BinConfig::find_by_package("nonexistent").await.unwrap(); - assert!(nonexistent_bins.is_empty()); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create configs for typescript (tsc, tsserver) + let tsc = BinConfig::new( + "tsc".to_string(), + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + ); + tsc.save().await.unwrap(); + + let tsserver = BinConfig::new( + "tsserver".to_string(), + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + ); + tsserver.save().await.unwrap(); + + // Create config for eslint + let eslint = BinConfig::new( + "eslint".to_string(), + "eslint".to_string(), + "9.0.0".to_string(), + "22.0.0".to_string(), + ); + eslint.save().await.unwrap(); + + // Find by package + let ts_bins = BinConfig::find_by_package("typescript").await.unwrap(); + assert_eq!(ts_bins.len(), 2); + assert!(ts_bins.contains(&"tsc".to_string())); + assert!(ts_bins.contains(&"tsserver".to_string())); + + let eslint_bins = BinConfig::find_by_package("eslint").await.unwrap(); + assert_eq!(eslint_bins.len(), 1); + assert!(eslint_bins.contains(&"eslint".to_string())); + + let nonexistent_bins = BinConfig::find_by_package("nonexistent").await.unwrap(); + assert!(nonexistent_bins.is_empty()); + }) + .await; } #[tokio::test] async fn test_delete() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - let config = BinConfig::new( - "tsc".to_string(), - "typescript".to_string(), - "5.0.0".to_string(), - "20.18.0".to_string(), - ); - config.save().await.unwrap(); - - // Verify it exists - let loaded = BinConfig::load("tsc").await.unwrap(); - assert!(loaded.is_some()); - - // Delete - BinConfig::delete("tsc").await.unwrap(); - - // Verify it's gone - let loaded = BinConfig::load("tsc").await.unwrap(); - assert!(loaded.is_none()); - - // Delete again should not error - BinConfig::delete("tsc").await.unwrap(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + let config = BinConfig::new( + "tsc".to_string(), + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + ); + config.save().await.unwrap(); + + // Verify it exists + let loaded = BinConfig::load("tsc").await.unwrap(); + assert!(loaded.is_some()); + + // Delete + BinConfig::delete("tsc").await.unwrap(); + + // Verify it's gone + let loaded = BinConfig::load("tsc").await.unwrap(); + assert!(loaded.is_none()); + + // Delete again should not error + BinConfig::delete("tsc").await.unwrap(); + }) + .await; } #[tokio::test] async fn test_load_nonexistent() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - let loaded = BinConfig::load("nonexistent").await.unwrap(); - assert!(loaded.is_none()); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + let loaded = BinConfig::load("nonexistent").await.unwrap(); + assert!(loaded.is_none()); + }) + .await; } #[test] @@ -336,77 +332,73 @@ mod tests { #[test] fn test_sync_save_load_delete() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - let config = BinConfig::new_npm( - "codex".to_string(), - "@openai/codex".to_string(), - "22.22.0".to_string(), - ); - config.save_sync().unwrap(); - - let loaded = BinConfig::load_sync("codex").unwrap(); - assert!(loaded.is_some()); - let loaded = loaded.unwrap(); - assert_eq!(loaded.source, BinSource::Npm); - assert_eq!(loaded.package, "@openai/codex"); - - BinConfig::delete_sync("codex").unwrap(); - let loaded = BinConfig::load_sync("codex").unwrap(); - assert!(loaded.is_none()); - - // Delete again should not error - BinConfig::delete_sync("codex").unwrap(); + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, temp_dir.path())], |_| { + let config = BinConfig::new_npm( + "codex".to_string(), + "@openai/codex".to_string(), + "22.22.0".to_string(), + ); + config.save_sync().unwrap(); + + let loaded = BinConfig::load_sync("codex").unwrap(); + assert!(loaded.is_some()); + let loaded = loaded.unwrap(); + assert_eq!(loaded.source, BinSource::Npm); + assert_eq!(loaded.package, "@openai/codex"); + + BinConfig::delete_sync("codex").unwrap(); + let loaded = BinConfig::load_sync("codex").unwrap(); + assert!(loaded.is_none()); + + // Delete again should not error + BinConfig::delete_sync("codex").unwrap(); + }); } #[tokio::test] async fn test_find_all_vp_source() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - // Create Vp-source configs - let tsc = BinConfig::new( - "tsc".to_string(), - "typescript".to_string(), - "5.0.0".to_string(), - "20.18.0".to_string(), - ); - tsc.save().await.unwrap(); - - let corepack = BinConfig::new( - "corepack".to_string(), - "corepack".to_string(), - "0.20.0".to_string(), - "20.18.0".to_string(), - ); - corepack.save().await.unwrap(); - - // Create Npm-source config (should be excluded) - let codex = BinConfig::new_npm( - "codex".to_string(), - "@openai/codex".to_string(), - "22.22.0".to_string(), - ); - codex.save().await.unwrap(); - - let mut vp_bins = BinConfig::find_all_vp_source().await.unwrap(); - vp_bins.sort(); - assert_eq!(vp_bins.len(), 2); - assert_eq!(vp_bins, vec!["corepack", "tsc"]); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create Vp-source configs + let tsc = BinConfig::new( + "tsc".to_string(), + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + ); + tsc.save().await.unwrap(); + + let corepack = BinConfig::new( + "corepack".to_string(), + "corepack".to_string(), + "0.20.0".to_string(), + "20.18.0".to_string(), + ); + corepack.save().await.unwrap(); + + // Create Npm-source config (should be excluded) + let codex = BinConfig::new_npm( + "codex".to_string(), + "@openai/codex".to_string(), + "22.22.0".to_string(), + ); + codex.save().await.unwrap(); + + let mut vp_bins = BinConfig::find_all_vp_source().await.unwrap(); + vp_bins.sort(); + assert_eq!(vp_bins.len(), 2); + assert_eq!(vp_bins, vec!["corepack", "tsc"]); + }) + .await; } #[tokio::test] async fn test_find_all_vp_source_empty_bins_dir() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - let vp_bins = BinConfig::find_all_vp_source().await.unwrap(); - assert!(vp_bins.is_empty()); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + let vp_bins = BinConfig::find_all_vp_source().await.unwrap(); + assert!(vp_bins.is_empty()); + }) + .await; } } diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index e1ca0f74cf..fb15fa4fad 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -13,9 +13,10 @@ use crate::error::Error; /// Execute the clean command. pub async fn execute(cwd: AbsolutePathBuf) -> Result { - let home_dir = vp_shared::get_vp_home()?; - let node_dir = home_dir.join("js_runtime").join("node"); - let package_manager_dir = home_dir.join("package_manager"); + let config = vp_shared::EnvConfig::get(); + let data_dir = &config.dirs.data; + let node_dir = data_dir.join("js_runtime").join("node"); + let package_manager_dir = data_dir.join("package_manager"); let protected_versions = protected_node_versions(&cwd).await?; let corepack_cleaned = run_corepack_cache_clean(&cwd).await?; diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 38cd5805b9..8cb1bffc10 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -1,7 +1,7 @@ //! Configuration and version resolution for the env command. //! //! This module provides: -//! - VP_HOME path resolution +//! - Directory helpers over `EnvConfig::dirs` //! - Version resolution with priority order //! - Config file management @@ -28,7 +28,7 @@ pub enum ShimMode { SystemFirst, } -/// User configuration stored in VP_HOME/config.json +/// User configuration stored in `/config.json` #[derive(Serialize, Deserialize, Default, Debug)] #[serde(rename_all = "camelCase")] pub struct Config { @@ -61,21 +61,14 @@ pub struct VersionResolution { pub is_range: bool, } -/// Get the VP_HOME directory path. -/// -/// Uses `VP_HOME` environment variable if set, otherwise defaults to `~/.vite-plus`. -pub fn get_vp_home() -> Result { - Ok(vp_shared::get_vp_home()?) -} - -/// Get the bin directory path (~/.vite-plus/bin/). +/// Get the bin directory path (``). pub fn get_bin_dir() -> Result { - Ok(get_vp_home()?.join("bin")) + Ok(vp_shared::EnvConfig::get().dirs.bin.clone()) } -/// Get the packages directory path (~/.vite-plus/packages/). +/// Get the packages directory path (`/packages`). pub fn get_packages_dir() -> Result { - Ok(get_vp_home()?.join("packages")) + Ok(vp_shared::EnvConfig::get().dirs.data.join("packages")) } /// Get the node_modules directory path for a package. @@ -110,9 +103,9 @@ pub fn get_node_modules_dir(prefix: &AbsolutePath, package_name: &str) -> Absolu } } -/// Get the config file path. +/// Get the config file path (`/config.json`). pub fn get_config_path() -> Result { - Ok(get_vp_home()?.join(CONFIG_FILE)) + Ok(vp_shared::EnvConfig::get().dirs.config.join(CONFIG_FILE)) } /// Load configuration from disk. @@ -131,10 +124,11 @@ pub async fn load_config() -> Result { /// Save configuration to disk. pub async fn save_config(config: &Config) -> Result<(), Error> { let config_path = get_config_path()?; - let vite_plus_home = get_vp_home()?; // Ensure directory exists - tokio::fs::create_dir_all(&vite_plus_home).await?; + if let Some(parent) = config_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } let content = serde_json::to_string_pretty(config)?; tokio::fs::write(&config_path, content).await?; @@ -148,9 +142,9 @@ pub const VERSION_ENV_VAR: &str = vp_shared::env_vars::VP_NODE_VERSION; /// Session version file name, written by `vp env use` so shims work without the shell eval wrapper. pub const SESSION_VERSION_FILE: &str = ".session-node-version"; -/// Get the path to the session version file (~/.vite-plus/.session-node-version). +/// Get the path to the session version file (`/.session-node-version`). pub fn get_session_version_path() -> Result { - Ok(get_vp_home()?.join(SESSION_VERSION_FILE)) + Ok(vp_shared::EnvConfig::get().dirs.state.join(SESSION_VERSION_FILE)) } /// Read the session version file. Returns `None` if the file is missing or empty. @@ -203,7 +197,7 @@ pub async fn delete_session_version() -> Result<(), Error> { /// 7. Latest LTS version pub async fn resolve_version(cwd: &AbsolutePath) -> Result { // Session override via environment variable (set by `vp env use`) - if let Some(env_version) = vp_shared::EnvConfig::get().node_version { + if let Some(env_version) = vp_shared::EnvConfig::get().node_version.as_deref() { let env_version = env_version.trim(); if !env_version.is_empty() { return Ok(VersionResolution { @@ -456,6 +450,7 @@ pub async fn resolve_version_alias( mod tests { use tempfile::TempDir; use vp_js_runtime::VersionSource; + use vp_shared::env_vars; use vt_path::AbsolutePathBuf; use super::*; @@ -560,33 +555,35 @@ mod tests { async fn test_resolve_version_from_node_version_file() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test()); - - // Create .node-version file - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - let resolution = resolve_version(&temp_path).await.unwrap(); - assert_eq!(resolution.version, "20.18.0"); - assert_eq!(resolution.source, ".node-version"); - assert!(resolution.source_path.is_some()); + let resolution = resolve_version(&temp_path).await.unwrap(); + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + assert!(resolution.source_path.is_some()); + }) + .await; } #[tokio::test] async fn test_resolve_version_walks_up_directory() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test()); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create .node-version in parent + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - // Create .node-version in parent - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + // Create subdirectory + let subdir = temp_path.join("subdir"); + tokio::fs::create_dir(&subdir).await.unwrap(); - // Create subdirectory - let subdir = temp_path.join("subdir"); - tokio::fs::create_dir(&subdir).await.unwrap(); - - let resolution = resolve_version(&subdir).await.unwrap(); - assert_eq!(resolution.version, "20.18.0"); - assert_eq!(resolution.source, ".node-version"); + let resolution = resolve_version(&subdir).await.unwrap(); + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + }) + .await; } #[tokio::test] @@ -687,17 +684,18 @@ mod tests { async fn test_resolve_version_node_version_takes_priority() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test()); - - // Create both .node-version and package.json with engines.node - tokio::fs::write(temp_path.join(".node-version"), "22.0.0\n").await.unwrap(); - let package_json = r#"{"engines":{"node":"20.18.0"}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create both .node-version and package.json with engines.node + tokio::fs::write(temp_path.join(".node-version"), "22.0.0\n").await.unwrap(); + let package_json = r#"{"engines":{"node":"20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let resolution = resolve_version(&temp_path).await.unwrap(); - // .node-version should take priority - assert_eq!(resolution.version, "22.0.0"); - assert_eq!(resolution.source, ".node-version"); + let resolution = resolve_version(&temp_path).await.unwrap(); + // .node-version should take priority + assert_eq!(resolution.version, "22.0.0"); + assert_eq!(resolution.source, ".node-version"); + }) + .await; } #[tokio::test] @@ -713,20 +711,20 @@ mod tests { async fn test_resolve_version_alias_default_no_source_path() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - let config = Config { default_node_version: Some("lts".to_string()), ..Default::default() }; - save_config(&config).await.unwrap(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + let config = + Config { default_node_version: Some("lts".to_string()), ..Default::default() }; + save_config(&config).await.unwrap(); - // Create empty dir to resolve version in (no .node-version) - let test_dir = temp_path.join("test-project"); - tokio::fs::create_dir_all(&test_dir).await.unwrap(); + // Create empty dir to resolve version in (no .node-version) + let test_dir = temp_path.join("test-project"); + tokio::fs::create_dir_all(&test_dir).await.unwrap(); - let resolution = resolve_version(&test_dir).await.unwrap(); - assert_eq!(resolution.source, "default"); - assert!(resolution.source_path.is_none(), "Alias defaults should not have source_path"); + let resolution = resolve_version(&test_dir).await.unwrap(); + assert_eq!(resolution.source, "default"); + assert!(resolution.source_path.is_none(), "Alias defaults should not have source_path"); + }) + .await; } #[tokio::test] @@ -734,132 +732,127 @@ mod tests { async fn test_resolve_version_exact_default_has_source_path() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - let config = - Config { default_node_version: Some("20.18.0".to_string()), ..Default::default() }; - save_config(&config).await.unwrap(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + let config = + Config { default_node_version: Some("20.18.0".to_string()), ..Default::default() }; + save_config(&config).await.unwrap(); - // Create empty dir to resolve version in (no .node-version) - let test_dir = temp_path.join("test-project"); - tokio::fs::create_dir_all(&test_dir).await.unwrap(); + // Create empty dir to resolve version in (no .node-version) + let test_dir = temp_path.join("test-project"); + tokio::fs::create_dir_all(&test_dir).await.unwrap(); - let resolution = resolve_version(&test_dir).await.unwrap(); - assert_eq!(resolution.source, "default"); - assert!(resolution.source_path.is_some(), "Exact version defaults should have source_path"); + let resolution = resolve_version(&test_dir).await.unwrap(); + assert_eq!(resolution.source, "default"); + assert!( + resolution.source_path.is_some(), + "Exact version defaults should have source_path" + ); + }) + .await; } #[tokio::test] async fn test_resolve_version_invalid_node_version_falls_through_to_lts() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create .node-version file with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid-version\n").await.unwrap(); - // Create .node-version file with invalid version - tokio::fs::write(temp_path.join(".node-version"), "invalid-version\n").await.unwrap(); + // resolve_version should NOT fail - it should fall through to LTS + let resolution = resolve_version(&temp_path).await.unwrap(); - // resolve_version should NOT fail - it should fall through to LTS - let resolution = resolve_version(&temp_path).await.unwrap(); - - // Should fall through to LTS since the .node-version is invalid - // and no user default is configured - assert_eq!(resolution.source, "lts"); - assert!(resolution.source_path.is_none()); - assert!(resolution.is_range, "LTS fallback should be marked as range"); + // Should fall through to LTS since the .node-version is invalid + // and no user default is configured + assert_eq!(resolution.source, "lts"); + assert!(resolution.source_path.is_none()); + assert!(resolution.is_range, "LTS fallback should be marked as range"); + }) + .await; } #[tokio::test] async fn test_resolve_version_invalid_node_version_falls_through_to_default() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create .node-version file with invalid version + tokio::fs::write(temp_path.join(".node-version"), "not-a-version\n").await.unwrap(); - // Create .node-version file with invalid version - tokio::fs::write(temp_path.join(".node-version"), "not-a-version\n").await.unwrap(); + // Create config with a default version + let config = + Config { default_node_version: Some("20.18.0".to_string()), ..Default::default() }; + save_config(&config).await.unwrap(); - // Create config with a default version - let config = - Config { default_node_version: Some("20.18.0".to_string()), ..Default::default() }; - save_config(&config).await.unwrap(); + // resolve_version should NOT fail - it should fall through to user default + let resolution = resolve_version(&temp_path).await.unwrap(); - // resolve_version should NOT fail - it should fall through to user default - let resolution = resolve_version(&temp_path).await.unwrap(); - - // Should fall through to user default since .node-version is invalid - assert_eq!(resolution.source, "default"); - assert_eq!(resolution.version, "20.18.0"); + // Should fall through to user default since .node-version is invalid + assert_eq!(resolution.source, "default"); + assert_eq!(resolution.version, "20.18.0"); + }) + .await; } #[tokio::test] async fn test_resolve_version_invalid_node_version_falls_through_to_engines_node() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - // Create .node-version file with invalid version (typo or unsupported alias) - tokio::fs::write(temp_path.join(".node-version"), "laetst\n").await.unwrap(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create .node-version file with invalid version (typo or unsupported alias) + tokio::fs::write(temp_path.join(".node-version"), "laetst\n").await.unwrap(); - // Create package.json with valid engines.node - let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + // Create package.json with valid engines.node + let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // resolve_version should NOT fail - it should fall through to engines.node - let resolution = resolve_version(&temp_path).await.unwrap(); + // resolve_version should NOT fail - it should fall through to engines.node + let resolution = resolve_version(&temp_path).await.unwrap(); - // Should fall through to engines.node since .node-version is invalid - assert_eq!(resolution.source, "engines.node"); - // Version should be resolved from ^20.18.0 (a 20.x version) - assert!( - resolution.version.starts_with("20."), - "Expected version to start with '20.', got: {}", - resolution.version - ); + // Should fall through to engines.node since .node-version is invalid + assert_eq!(resolution.source, "engines.node"); + // Version should be resolved from ^20.18.0 (a 20.x version) + assert!( + resolution.version.starts_with("20."), + "Expected version to start with '20.', got: {}", + resolution.version + ); + }) + .await; } #[tokio::test] async fn test_resolve_version_invalid_node_version_falls_through_to_dev_engines() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create .node-version file with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); - // Create .node-version file with invalid version - tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); + // Create package.json with devEngines.runtime but no engines.node + let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"^20.18.0"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // Create package.json with devEngines.runtime but no engines.node - let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"^20.18.0"}}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + // resolve_version should NOT fail - it should fall through to devEngines.runtime + let resolution = resolve_version(&temp_path).await.unwrap(); - // resolve_version should NOT fail - it should fall through to devEngines.runtime - let resolution = resolve_version(&temp_path).await.unwrap(); - - // Should fall through to devEngines.runtime since .node-version is invalid - assert_eq!(resolution.source, "devEngines.runtime"); - // Version should be resolved from ^20.18.0 (a 20.x version) - assert!( - resolution.version.starts_with("20."), - "Expected version to start with '20.', got: {}", - resolution.version - ); + // Should fall through to devEngines.runtime since .node-version is invalid + assert_eq!(resolution.source, "devEngines.runtime"); + // Version should be resolved from ^20.18.0 (a 20.x version) + assert!( + resolution.version.starts_with("20."), + "Expected version to start with '20.', got: {}", + resolution.version + ); + }) + .await; } #[tokio::test] async fn test_resolve_version_invalid_engines_node_falls_through_to_dev_engines() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { // Create package.json with invalid engines.node but valid devEngines.runtime // No .node-version file — resolve_node_version returns EnginesNode source let package_json = r#"{"engines":{"node":"invalid"},"devEngines":{"runtime":{"name":"node","version":"^20.18.0"}}}"#; @@ -874,6 +867,8 @@ mod tests { "Expected version to start with '20.', got: {}", resolution.version ); + }) + .await; } #[tokio::test] @@ -994,44 +989,49 @@ mod tests { async fn test_resolve_version_latest_alias_in_node_version() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test()); - - // Create .node-version file with "latest" alias - tokio::fs::write(temp_path.join(".node-version"), "latest\n").await.unwrap(); - - let resolution = resolve_version(&temp_path).await.unwrap(); - - // Should resolve from .node-version - assert_eq!(resolution.source, ".node-version"); - // "latest" is a range (should be re-resolved periodically) - assert!(resolution.is_range, "'latest' should be marked as a range"); - // Version should be at least v20.x - assert!( - resolution.version.starts_with("2") || resolution.version.starts_with("3"), - "Expected version to be at least v20.x, got: {}", - resolution.version - ); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create .node-version file with "latest" alias + tokio::fs::write(temp_path.join(".node-version"), "latest\n").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Should resolve from .node-version + assert_eq!(resolution.source, ".node-version"); + // "latest" is a range (should be re-resolved periodically) + assert!(resolution.is_range, "'latest' should be marked as a range"); + // Version should be at least v20.x + assert!( + resolution.version.starts_with("2") || resolution.version.starts_with("3"), + "Expected version to be at least v20.x, got: {}", + resolution.version + ); + }) + .await; } #[tokio::test] async fn test_resolve_version_env_var_takes_priority() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - node_version: Some("22.0.0".into()), - ..vp_shared::EnvConfig::for_test() - }); - - // Create .node-version file - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - - let resolution = resolve_version(&temp_path).await.unwrap(); - - // VP_NODE_VERSION should take priority over .node-version - assert_eq!(resolution.version, "22.0.0"); - assert_eq!(resolution.source, VERSION_ENV_VAR); - assert!(resolution.source_path.is_none()); - assert!(!resolution.is_range); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + (env_vars::VP_NODE_VERSION, std::ffi::OsStr::new("22.0.0")), + ], + |_| async { + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // VP_NODE_VERSION should take priority over .node-version + assert_eq!(resolution.version, "22.0.0"); + assert_eq!(resolution.source, VERSION_ENV_VAR); + assert!(resolution.source_path.is_none()); + assert!(!resolution.is_range); + }, + ) + .await; } /// Verify that the env var source is accepted by `vp env install` (no-arg) source validation. @@ -1041,25 +1041,29 @@ mod tests { async fn test_env_var_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 = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - node_version: Some("22.0.0".into()), - ..vp_shared::EnvConfig::for_test() - }); - - let resolution = resolve_version(&temp_path).await.unwrap(); - - // The install command uses this match to validate sources. - // VERSION_ENV_VAR must be accepted alongside project-file sources. - let accepted = matches!( - resolution.source.as_str(), - ".node-version" | "engines.node" | "devEngines.runtime" | VERSION_ENV_VAR - ); - assert!( - accepted, - "Install source validation should accept '{}' but it was rejected", - resolution.source - ); - assert_eq!(resolution.version, "22.0.0"); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + (env_vars::VP_NODE_VERSION, std::ffi::OsStr::new("22.0.0")), + ], + |_| async { + let resolution = resolve_version(&temp_path).await.unwrap(); + + // The install command uses this match to validate sources. + // VERSION_ENV_VAR must be accepted alongside project-file sources. + let accepted = matches!( + resolution.source.as_str(), + ".node-version" | "engines.node" | "devEngines.runtime" | VERSION_ENV_VAR + ); + assert!( + accepted, + "Install source validation should accept '{}' but it was rejected", + resolution.source + ); + assert_eq!(resolution.version, "22.0.0"); + }, + ) + .await; } // ── Session version file tests ── @@ -1067,164 +1071,163 @@ mod tests { #[tokio::test] async fn test_write_and_read_session_version() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Write a session version + write_session_version("22.0.0").await.unwrap(); - // Write a session version - write_session_version("22.0.0").await.unwrap(); + // Read it back (async) + let version = read_session_version().await; + assert_eq!(version.as_deref(), Some("22.0.0")); - // Read it back (async) - let version = read_session_version().await; - assert_eq!(version.as_deref(), Some("22.0.0")); - - // Read it back (sync) - let version_sync = read_session_version_sync(); - assert_eq!(version_sync.as_deref(), Some("22.0.0")); + // Read it back (sync) + let version_sync = read_session_version_sync(); + assert_eq!(version_sync.as_deref(), Some("22.0.0")); + }) + .await; } #[tokio::test] async fn test_read_session_version_returns_none_when_missing() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - assert!(read_session_version().await.is_none()); - assert!(read_session_version_sync().is_none()); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + assert!(read_session_version().await.is_none()); + assert!(read_session_version_sync().is_none()); + }) + .await; } #[tokio::test] async fn test_read_session_version_returns_none_for_empty_file() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - // Write empty content - let path = get_session_version_path().unwrap(); - tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap(); - tokio::fs::write(&path, "").await.unwrap(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Write empty content + let path = get_session_version_path().unwrap(); + tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap(); + tokio::fs::write(&path, "").await.unwrap(); - assert!(read_session_version().await.is_none()); - assert!(read_session_version_sync().is_none()); + assert!(read_session_version().await.is_none()); + assert!(read_session_version_sync().is_none()); - // Also test whitespace-only content - tokio::fs::write(&path, " \n ").await.unwrap(); - assert!(read_session_version().await.is_none()); - assert!(read_session_version_sync().is_none()); + // Also test whitespace-only content + tokio::fs::write(&path, " \n ").await.unwrap(); + assert!(read_session_version().await.is_none()); + assert!(read_session_version_sync().is_none()); + }) + .await; } #[tokio::test] async fn test_read_session_version_trims_whitespace() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + write_session_version("20.18.0").await.unwrap(); - write_session_version("20.18.0").await.unwrap(); + // Overwrite with whitespace-padded content + let path = get_session_version_path().unwrap(); + tokio::fs::write(&path, " 20.18.0 \n").await.unwrap(); - // Overwrite with whitespace-padded content - let path = get_session_version_path().unwrap(); - tokio::fs::write(&path, " 20.18.0 \n").await.unwrap(); - - assert_eq!(read_session_version().await.as_deref(), Some("20.18.0")); - assert_eq!(read_session_version_sync().as_deref(), Some("20.18.0")); + assert_eq!(read_session_version().await.as_deref(), Some("20.18.0")); + assert_eq!(read_session_version_sync().as_deref(), Some("20.18.0")); + }) + .await; } #[tokio::test] async fn test_delete_session_version() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - // Write then delete - write_session_version("22.0.0").await.unwrap(); - assert!(read_session_version().await.is_some()); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Write then delete + write_session_version("22.0.0").await.unwrap(); + assert!(read_session_version().await.is_some()); - delete_session_version().await.unwrap(); - assert!(read_session_version().await.is_none()); + delete_session_version().await.unwrap(); + assert!(read_session_version().await.is_none()); + }) + .await; } #[tokio::test] async fn test_delete_session_version_ignores_missing_file() { let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - // Deleting a non-existent file should succeed - let result = delete_session_version().await; - assert!(result.is_ok()); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Deleting a non-existent file should succeed + let result = delete_session_version().await; + assert!(result.is_ok()); + }) + .await; } #[tokio::test] 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 = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - is_ci: cfg!(windows), - ..vp_shared::EnvConfig::for_test_with_home(temp_dir.path()) - }); + let vars = vec![ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + #[cfg(windows)] + ("CI", std::ffi::OsStr::new("1")), + ]; + vp_shared::EnvConfig::with_vars_async(vars, |_| async { + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - // Create .node-version file - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + // Write session version file + write_session_version("22.0.0").await.unwrap(); - // Write session version file - write_session_version("22.0.0").await.unwrap(); + let resolution = resolve_version(&temp_path).await.unwrap(); - let resolution = resolve_version(&temp_path).await.unwrap(); - - // Session file should take priority over .node-version - assert_eq!(resolution.version, "22.0.0"); - assert_eq!(resolution.source, SESSION_VERSION_FILE); - assert!(resolution.source_path.is_some()); - assert!(!resolution.is_range); + // Session file should take priority over .node-version + assert_eq!(resolution.version, "22.0.0"); + assert_eq!(resolution.source, SESSION_VERSION_FILE); + assert!(resolution.source_path.is_some()); + assert!(!resolution.is_range); - // Clean up - delete_session_version().await.unwrap(); + // Clean up + delete_session_version().await.unwrap(); + }) + .await; } #[tokio::test] async fn test_resolve_version_env_var_takes_priority_over_session_file() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - node_version: Some("24.0.0".into()), - vite_plus_home: Some(temp_dir.path().into()), - ..vp_shared::EnvConfig::for_test() - }); - - // Write session version file with different version - write_session_version("22.0.0").await.unwrap(); - - let resolution = resolve_version(&temp_path).await.unwrap(); - - // Env var should take priority over session file - assert_eq!(resolution.version, "24.0.0"); - assert_eq!(resolution.source, VERSION_ENV_VAR); - - // Clean up - delete_session_version().await.unwrap(); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + (env_vars::VP_NODE_VERSION, std::ffi::OsStr::new("24.0.0")), + ], + |_| async { + // Write session version file with different version + write_session_version("22.0.0").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Env var should take priority over session file + assert_eq!(resolution.version, "24.0.0"); + assert_eq!(resolution.source, VERSION_ENV_VAR); + + // Clean up + delete_session_version().await.unwrap(); + }, + ) + .await; } #[tokio::test] async fn test_resolve_version_falls_through_when_no_session_file() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_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(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - let resolution = resolve_version(&temp_path).await.unwrap(); + let resolution = resolve_version(&temp_path).await.unwrap(); - // Should fall through to .node-version since no session file exists - assert_eq!(resolution.version, "20.18.0"); - assert_eq!(resolution.source, ".node-version"); + // Should fall through to .node-version since no session file exists + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + }) + .await; } /// Verify that the session file source is accepted by `vp env install` (no-arg) source validation. @@ -1234,74 +1237,85 @@ 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 = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - is_ci: cfg!(windows), - ..vp_shared::EnvConfig::for_test_with_home(temp_dir.path()) - }); - - // Write session version file - write_session_version("22.0.0").await.unwrap(); - - let resolution = resolve_version(&temp_path).await.unwrap(); - - // The install command uses this match to validate sources. - // SESSION_VERSION_FILE must be accepted alongside project-file sources. - let accepted = matches!( - resolution.source.as_str(), - ".node-version" - | "engines.node" - | "devEngines.runtime" - | VERSION_ENV_VAR - | SESSION_VERSION_FILE - ); - assert!( - accepted, - "Install source validation should accept '{}' but it was rejected", - resolution.source - ); - assert_eq!(resolution.version, "22.0.0"); - assert_eq!(resolution.source, SESSION_VERSION_FILE); - - // Clean up - delete_session_version().await.unwrap(); + let vars = vec![ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + #[cfg(windows)] + ("CI", std::ffi::OsStr::new("1")), + ]; + vp_shared::EnvConfig::with_vars_async(vars, |_| async { + // Write session version file + write_session_version("22.0.0").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // The install command uses this match to validate sources. + // SESSION_VERSION_FILE must be accepted alongside project-file sources. + let accepted = matches!( + resolution.source.as_str(), + ".node-version" + | "engines.node" + | "devEngines.runtime" + | VERSION_ENV_VAR + | SESSION_VERSION_FILE + ); + assert!( + accepted, + "Install source validation should accept '{}' but it was rejected", + resolution.source + ); + assert_eq!(resolution.version, "22.0.0"); + assert_eq!(resolution.source, SESSION_VERSION_FILE); + + // Clean up + delete_session_version().await.unwrap(); + }) + .await; } #[tokio::test] async fn test_resolve_version_empty_env_var_is_ignored() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - node_version: Some("".into()), - ..vp_shared::EnvConfig::for_test() - }); - - // Create .node-version file - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - - let resolution = resolve_version(&temp_path).await.unwrap(); - - // Empty env var should be ignored, should fall through to .node-version - assert_eq!(resolution.version, "20.18.0"); - assert_eq!(resolution.source, ".node-version"); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + (env_vars::VP_NODE_VERSION, std::ffi::OsStr::new("")), + ], + |_| async { + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Empty env var should be ignored, should fall through to .node-version + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + }, + ) + .await; } #[tokio::test] async fn test_resolve_version_whitespace_env_var_is_ignored() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - node_version: Some(" ".into()), - ..vp_shared::EnvConfig::for_test() - }); - - // Create .node-version file - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - - let resolution = resolve_version(&temp_path).await.unwrap(); - - // Whitespace env var should be ignored, should fall through to .node-version - assert_eq!(resolution.version, "20.18.0"); - assert_eq!(resolution.source, ".node-version"); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + (env_vars::VP_NODE_VERSION, std::ffi::OsStr::new(" ")), + ], + |_| async { + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Whitespace env var should be ignored, should fall through to .node-version + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + }, + ) + .await; } // ── resolve_version_from_files tests ── @@ -1312,19 +1326,23 @@ mod tests { async fn test_resolve_version_from_files_ignores_env_var() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - node_version: Some("22.0.0".into()), - ..vp_shared::EnvConfig::for_test() - }); - - // Create .node-version file with different version - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - - // resolve_version_from_files should skip env var and use .node-version - let resolution = resolve_version_from_files(&temp_path).await.unwrap(); - - assert_eq!(resolution.version, "20.18.0"); - assert_eq!(resolution.source, ".node-version"); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + (env_vars::VP_NODE_VERSION, std::ffi::OsStr::new("22.0.0")), + ], + |_| async { + // Create .node-version file with different version + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // resolve_version_from_files should skip env var and use .node-version + let resolution = resolve_version_from_files(&temp_path).await.unwrap(); + + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + }, + ) + .await; } /// Verify that `resolve_version_from_files` ignores session file override. @@ -1332,24 +1350,23 @@ mod tests { async fn test_resolve_version_from_files_ignores_session_file() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - // Write session version file - write_session_version("22.0.0").await.unwrap(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + // Write session version file + write_session_version("22.0.0").await.unwrap(); - // Create .node-version file with different version - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + // Create .node-version file with different version + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - // resolve_version_from_files should skip session file and use .node-version - let resolution = resolve_version_from_files(&temp_path).await.unwrap(); + // resolve_version_from_files should skip session file and use .node-version + let resolution = resolve_version_from_files(&temp_path).await.unwrap(); - assert_eq!(resolution.version, "20.18.0"); - assert_eq!(resolution.source, ".node-version"); + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); - // Clean up - delete_session_version().await.unwrap(); + // Clean up + delete_session_version().await.unwrap(); + }) + .await; } /// Verify that `resolve_version_from_files` still respects both env var and session file. @@ -1357,22 +1374,26 @@ mod tests { async fn test_resolve_version_still_respects_overrides() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - node_version: Some("22.0.0".into()), - ..vp_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(); - - // resolve_version should still use env var (existing behavior) - let resolution = resolve_version(&temp_path).await.unwrap(); - assert_eq!(resolution.version, "22.0.0"); - assert_eq!(resolution.source, VERSION_ENV_VAR); - - // But resolve_version_from_files should skip it - let resolution_from_files = resolve_version_from_files(&temp_path).await.unwrap(); - assert_eq!(resolution_from_files.version, "20.18.0"); - assert_eq!(resolution_from_files.source, ".node-version"); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + (env_vars::VP_NODE_VERSION, std::ffi::OsStr::new("22.0.0")), + ], + |_| async { + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // resolve_version should still use env var (existing behavior) + let resolution = resolve_version(&temp_path).await.unwrap(); + assert_eq!(resolution.version, "22.0.0"); + assert_eq!(resolution.source, VERSION_ENV_VAR); + + // But resolve_version_from_files should skip it + let resolution_from_files = resolve_version_from_files(&temp_path).await.unwrap(); + assert_eq!(resolution_from_files.version, "20.18.0"); + assert_eq!(resolution_from_files.source, ".node-version"); + }, + ) + .await; } } diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index d712ea37c0..94757c95c8 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -75,9 +75,13 @@ pub async fn execute(cwd: AbsolutePathBuf, json: bool) -> Result Result { // Section: Installation println!("{}", "Installation".bold()); - has_errors |= !check_vite_plus_home().await; - has_errors |= !check_bin_dir().await; + has_errors |= !check_dirs().await; + has_errors |= !check_shims().await; // Section: Configuration print_section("Configuration"); @@ -110,9 +110,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { Some(EnvSourcingStatus::IdeFound) | None => {} // All good, no guidance needed Some(EnvSourcingStatus::ShellOnly | EnvSourcingStatus::NotFound) => { // Show IDE setup guidance when env is not in IDE-relevant profiles - if let Ok(bin_dir) = get_bin_dir() { - print_ide_setup_guidance(&bin_dir); - } + print_ide_setup_guidance(&vp_shared::EnvConfig::get().dirs.config); } } @@ -130,55 +128,55 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { } } -/// Check VP_HOME directory. -async fn check_vite_plus_home() -> bool { - let home = match get_vp_home() { - Ok(h) => h, - Err(e) => { +/// Report the five resolved category roots. +/// +/// `bin` and `config` are created by `vp env setup`, so a missing one is an +/// error. `data`, `cache`, and `state` are created lazily on first use — a +/// missing one is reported as "not created yet", not an error. +async fn check_dirs() -> bool { + let dirs = &vp_shared::EnvConfig::get().dirs; + let rows: [(&str, &AbsolutePathBuf, bool); 5] = [ + ("Bin dir", &dirs.bin, true), + ("Data dir", &dirs.data, false), + ("Cache dir", &dirs.cache, false), + ("Config dir", &dirs.config, true), + ("State dir", &dirs.state, false), + ]; + + let mut ok = true; + for (label, dir, required) in rows { + let display = abbreviate_home(&dir.as_path().display().to_string()); + if tokio::fs::try_exists(dir).await.unwrap_or(false) { + print_check(&output::CHECK.green().to_string(), label, &display); + } else if required { print_check( &output::CROSS.red().to_string(), - env_vars::VP_HOME, - &format!("{e}").red().to_string(), + label, + &format!("{display} {}", "(does not exist)".red()), + ); + print_hint("Run 'vp env setup' to create it."); + ok = false; + } else { + print_check( + &output::CHECK.green().to_string(), + label, + &format!("{display} {}", "(not created yet)".bright_black()), ); - return false; } - }; - - let display = abbreviate_home(&home.as_path().display().to_string()); - - if tokio::fs::try_exists(&home).await.unwrap_or(false) { - print_check(&output::CHECK.green().to_string(), env_vars::VP_HOME, &display); - true - } else { - print_check( - &output::CROSS.red().to_string(), - env_vars::VP_HOME, - &"does not exist".red().to_string(), - ); - print_hint("Run 'vp env setup' to create it."); - false } + ok } -/// Check bin directory and shim files. -async fn check_bin_dir() -> bool { - let bin_dir = match get_bin_dir() { - Ok(d) => d, - Err(_) => return false, - }; +/// Check shim files in the bin directory. A missing bin directory is +/// already reported by [`check_dirs`]. +async fn check_shims() -> bool { + let config = vp_shared::EnvConfig::get(); + let bin_dir = &config.dirs.bin; - if !tokio::fs::try_exists(&bin_dir).await.unwrap_or(false) { - print_check( - &output::CROSS.red().to_string(), - "Bin directory", - &"does not exist".red().to_string(), - ); - print_hint("Run 'vp env setup' to create bin directory and shims."); + if !tokio::fs::try_exists(bin_dir).await.unwrap_or(false) { return false; } - print_check(&output::CHECK.green().to_string(), "Bin directory", "exists"); - let mut missing = Vec::new(); for tool in SHIM_TOOLS { @@ -265,27 +263,20 @@ async fn check_shim_mode() -> (ShimMode, Option) { /// Tries IDE-relevant profiles first, then falls back to all shell profiles. /// Returns `EnvSourcingStatus` indicating where (if anywhere) the sourcing was found. fn check_env_sourcing() -> EnvSourcingStatus { - let bin_dir = match get_bin_dir() { - Ok(d) => d, - Err(_) => return EnvSourcingStatus::NotFound, - }; - - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); - let home_path = if let Ok(home_dir) = std::env::var("HOME") { - if let Some(suffix) = home_path.strip_prefix(&home_dir) { + let config = vp_shared::EnvConfig::get(); + let env_path = config.dirs.config.as_path().display().to_string(); + let env_path = if let Ok(home_dir) = std::env::var("HOME") { + if let Some(suffix) = env_path.strip_prefix(&home_dir) { format!("$HOME{suffix}") } else { - home_path + env_path } } else { - home_path + env_path }; // First: check IDE-relevant profiles (login/environment files visible to GUI apps) - if let Some(file) = check_profile_files(&home_path, IDE_SHELL_PROFILES) { + if let Some(file) = check_profile_files(&env_path, IDE_SHELL_PROFILES) { print_check( &output::CHECK.green().to_string(), "IDE integration", @@ -295,7 +286,7 @@ fn check_env_sourcing() -> EnvSourcingStatus { } // Second: check all shell profiles (interactive terminal sessions) - if let Some(file) = check_profile_files(&home_path, ALL_SHELL_PROFILES) { + if let Some(file) = check_profile_files(&env_path, ALL_SHELL_PROFILES) { print_check( &output::WARN_SIGN.yellow().to_string(), "IDE integration", @@ -359,7 +350,7 @@ async fn check_path() -> bool { print_check(&output::CROSS.red().to_string(), "vp", &"not in PATH".red().to_string()); print_hint(&format!("Expected: {bin_display}")); println!(); - print_path_fix(&bin_dir); + print_path_fix(&vp_shared::EnvConfig::get().dirs.config); return false; } @@ -396,42 +387,39 @@ fn find_in_path(name: &str) -> Option { } /// Print PATH fix instructions for shell setup. -fn print_path_fix(bin_dir: &vt_path::AbsolutePath) { +fn print_path_fix(env_dir: &vt_path::AbsolutePath) { #[cfg(not(windows))] { - // Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); - let home_path = if let Ok(home_dir) = std::env::var("HOME") { - if let Some(suffix) = home_path.strip_prefix(&home_dir) { + // Point at the env files in the config dir, $HOME-prefixed for readability + let env_path = env_dir.as_path().display().to_string(); + let env_path = if let Ok(home_dir) = std::env::var("HOME") { + if let Some(suffix) = env_path.strip_prefix(&home_dir) { format!("$HOME{suffix}") } else { - home_path + env_path } } else { - home_path + env_path }; println!(" {}", "Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):".dimmed()); println!(); - println!(" . \"{home_path}/env\""); + println!(" . \"{env_path}/env\""); println!(); println!(" {}", "For fish shell, add to ~/.config/fish/config.fish:".dimmed()); println!(); - println!(" source \"{home_path}/env.fish\""); + println!(" source \"{env_path}/env.fish\""); println!(); println!(" {}", "For Nushell, add to ~/.config/nushell/config.nu:".dimmed()); println!(); - println!(" source '{home_path}/env.nu'"); + println!(" source '{env_path}/env.nu'"); println!(); println!(" {}", "Then restart your terminal.".dimmed()); } #[cfg(windows)] { - let _ = bin_dir; + let _ = env_dir; println!(" {}", "Add the bin directory to your PATH via:".dimmed()); println!(" System Properties -> Environment Variables -> Path"); println!(); @@ -446,15 +434,16 @@ fn print_path_fix(bin_dir: &vt_path::AbsolutePath) { /// /// Returns `Some(display_path)` if any profile file contains a reference /// to the vite-plus env file, `None` otherwise. -fn check_profile_files(vite_plus_home: &str, profile_files: &[ShellProfile]) -> Option { - let home_dir = AbsolutePathBuf::new(std::env::var_os("HOME")?.into())?; - let home_dir_display = home_dir.as_path().display().to_string(); +fn check_profile_files(env_dir: &str, profile_files: &[ShellProfile]) -> Option { + let config = vp_shared::EnvConfig::get(); + let user_home = &config.user_home; + let home_dir_display = user_home.as_path().display().to_string(); for profile in profile_files { - let full_path = resolve_profile_path(profile, &home_dir); + let full_path = resolve_profile_path(profile, user_home); if let Ok(content) = std::fs::read_to_string(&full_path) { - let mut search_strings = vec![format!("{vite_plus_home}/{}", profile.env_file)]; - if let Some(suffix) = vite_plus_home.strip_prefix("$HOME") { + let mut search_strings = vec![format!("{env_dir}/{}", profile.env_file)]; + if let Some(suffix) = env_dir.strip_prefix("$HOME") { search_strings.push(format!("{home_dir_display}{suffix}/{}", profile.env_file)); search_strings.push(format!("~{suffix}/{}", profile.env_file)); } @@ -469,20 +458,17 @@ fn check_profile_files(vite_plus_home: &str, profile_files: &[ShellProfile]) -> } /// Print IDE setup guidance for GUI applications. -fn print_ide_setup_guidance(bin_dir: &vt_path::AbsolutePath) { - // Derive vite_plus_home display path from bin_dir.parent(), using $HOME prefix - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); - let home_path = if let Ok(home_dir) = std::env::var("HOME") { - if let Some(suffix) = home_path.strip_prefix(&home_dir) { +fn print_ide_setup_guidance(env_dir: &vt_path::AbsolutePath) { + // Point at the env files in the config dir, $HOME-prefixed for readability + let env_path = env_dir.as_path().display().to_string(); + let env_path = if let Ok(home_dir) = std::env::var("HOME") { + if let Some(suffix) = env_path.strip_prefix(&home_dir) { format!("$HOME{suffix}") } else { - home_path + env_path } } else { - home_path + env_path }; print_section("IDE Setup"); @@ -497,7 +483,7 @@ fn print_ide_setup_guidance(bin_dir: &vt_path::AbsolutePath) { { println!(" {}", "macOS:".dimmed()); println!(" {}", "Add to ~/.zshenv or ~/.profile:".dimmed()); - println!(" . \"{home_path}/env\""); + println!(" . \"{env_path}/env\""); println!(" {}", "Then restart your IDE to apply changes.".dimmed()); } @@ -505,7 +491,7 @@ fn print_ide_setup_guidance(bin_dir: &vt_path::AbsolutePath) { { println!(" {}", "Linux:".dimmed()); println!(" {}", "Add to ~/.profile:".dimmed()); - println!(" . \"{home_path}/env\""); + println!(" . \"{env_path}/env\""); println!(" {}", "Then log out and log back in for changes to take effect.".dimmed()); } @@ -513,7 +499,7 @@ fn print_ide_setup_guidance(bin_dir: &vt_path::AbsolutePath) { #[cfg(not(any(target_os = "macos", target_os = "linux")))] { println!(" {}", "Add to your shell profile:".dimmed()); - println!(" . \"{home_path}/env\""); + println!(" . \"{env_path}/env\""); println!(" {}", "Then restart your IDE to apply changes.".dimmed()); } } @@ -571,10 +557,12 @@ async fn check_current_resolution( print_check(" ", "Version", &resolution.version.bright_green().to_string()); // Check if Node.js is installed - let home_dir = match vp_shared::get_vp_home() { - Ok(d) => d.join("js_runtime").join("node").join(&resolution.version), - Err(_) => return None, - }; + let home_dir = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(&resolution.version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); @@ -1018,7 +1006,6 @@ fn check_conflicts() { #[cfg(test)] mod tests { - use serial_test::serial; use tempfile::TempDir; use super::*; @@ -1519,40 +1506,8 @@ mod tests { path } - /// Helper to save and restore PATH and VP_BYPASS around a test. - struct EnvGuard { - original_path: Option, - original_bypass: Option, - } - - impl EnvGuard { - fn new() -> Self { - Self { - original_path: std::env::var_os("PATH"), - original_bypass: std::env::var_os(env_vars::VP_BYPASS), - } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - unsafe { - match &self.original_path { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - match &self.original_bypass { - Some(v) => std::env::set_var(env_vars::VP_BYPASS, v), - None => std::env::remove_var(env_vars::VP_BYPASS), - } - } - } - } - #[test] - #[serial] fn test_find_system_node_skips_bypass_paths() { - let _guard = EnvGuard::new(); let temp = TempDir::new().unwrap(); let dir_a = temp.path().join("bin_a"); let dir_b = temp.path().join("bin_b"); @@ -1562,37 +1517,33 @@ mod tests { create_fake_executable(&dir_b, "node"); let path = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap(); - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", &path); - std::env::set_var(env_vars::VP_BYPASS, dir_a.as_os_str()); - } - - let result = shim::find_system_tool("node"); - assert!(result.is_some(), "Should find node in non-bypassed directory"); - assert!( - result.unwrap().as_path().starts_with(&dir_b), - "Should find node in dir_b, not dir_a" + temp_env::with_vars( + [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, Some(dir_a.as_os_str()))], + || { + let result = shim::find_system_tool("node"); + assert!(result.is_some(), "Should find node in non-bypassed directory"); + assert!( + result.unwrap().as_path().starts_with(&dir_b), + "Should find node in dir_b, not dir_a" + ); + }, ); } #[test] - #[serial] fn test_find_system_node_returns_none_when_all_paths_bypassed() { - let _guard = EnvGuard::new(); let temp = TempDir::new().unwrap(); let dir_a = temp.path().join("bin_a"); std::fs::create_dir_all(&dir_a).unwrap(); create_fake_executable(&dir_a, "node"); - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", dir_a.as_os_str()); - std::env::set_var(env_vars::VP_BYPASS, dir_a.as_os_str()); - } - - let result = shim::find_system_tool("node"); - assert!(result.is_none(), "Should return None when all paths are bypassed"); + temp_env::with_vars( + [("PATH", Some(dir_a.as_os_str())), (env_vars::VP_BYPASS, Some(dir_a.as_os_str()))], + || { + let result = shim::find_system_tool("node"); + assert!(result.is_none(), "Should return None when all paths are bypassed"); + }, + ); } #[test] @@ -1606,74 +1557,7 @@ mod tests { } } - /// Guard for env vars used by profile file tests. - #[cfg(not(windows))] - struct ProfileEnvGuard { - original_home: Option, - original_zdotdir: Option, - original_xdg_config: Option, - original_xdg_data: Option, - } - - #[cfg(not(windows))] - impl ProfileEnvGuard { - fn new( - home: &std::path::Path, - zdotdir: Option<&std::path::Path>, - xdg_config: Option<&std::path::Path>, - xdg_data: Option<&std::path::Path>, - ) -> Self { - let guard = Self { - original_home: std::env::var_os("HOME"), - original_zdotdir: std::env::var_os("ZDOTDIR"), - original_xdg_config: std::env::var_os("XDG_CONFIG_HOME"), - original_xdg_data: std::env::var_os("XDG_DATA_HOME"), - }; - unsafe { - std::env::set_var("HOME", home); - match zdotdir { - Some(v) => std::env::set_var("ZDOTDIR", v), - None => std::env::remove_var("ZDOTDIR"), - } - match xdg_config { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - match xdg_data { - Some(v) => std::env::set_var("XDG_DATA_HOME", v), - None => std::env::remove_var("XDG_DATA_HOME"), - } - } - guard - } - } - - #[cfg(not(windows))] - impl Drop for ProfileEnvGuard { - fn drop(&mut self) { - unsafe { - match &self.original_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - match &self.original_zdotdir { - Some(v) => std::env::set_var("ZDOTDIR", v), - None => std::env::remove_var("ZDOTDIR"), - } - match &self.original_xdg_config { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - match &self.original_xdg_data { - Some(v) => std::env::set_var("XDG_DATA_HOME", v), - None => std::env::remove_var("XDG_DATA_HOME"), - } - } - } - } - #[test] - #[serial] #[cfg(not(windows))] fn test_check_profile_files_finds_zdotdir() { let temp = TempDir::new().unwrap(); @@ -1684,23 +1568,30 @@ mod tests { std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, Some(&zdotdir), None, None); - - let result = check_profile_files( - "$HOME/.vite-plus", - &[ShellProfile { - root: ShellProfileRoot::Zsh, - path: ".zshenv", - env_file: "env", - kind: ShellProfileKind::Main, - }], + temp_env::with_vars( + [ + ("HOME", Some(fake_home.as_os_str())), + ("ZDOTDIR", Some(zdotdir.as_os_str())), + ("XDG_CONFIG_HOME", None), + ("XDG_DATA_HOME", None), + ], + || { + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::Zsh, + path: ".zshenv", + env_file: "env", + kind: ShellProfileKind::Main, + }], + ); + assert!(result.is_some(), "Should find .zshenv in ZDOTDIR"); + assert!(result.unwrap().ends_with(".zshenv")); + }, ); - assert!(result.is_some(), "Should find .zshenv in ZDOTDIR"); - assert!(result.unwrap().ends_with(".zshenv")); } #[test] - #[serial] #[cfg(not(windows))] fn test_check_profile_files_finds_xdg_fish() { let temp = TempDir::new().unwrap(); @@ -1713,23 +1604,30 @@ mod tests { std::fs::write(fish_dir.join("vite-plus.fish"), "source \"$HOME/.vite-plus/env.fish\"\n") .unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, Some(&xdg_config), None); - - let result = check_profile_files( - "$HOME/.vite-plus", - &[ShellProfile { - root: ShellProfileRoot::Fish, - path: "fish/conf.d/vite-plus.fish", - env_file: "env.fish", - kind: ShellProfileKind::Snippet, - }], + temp_env::with_vars( + [ + ("HOME", Some(fake_home.as_os_str())), + ("ZDOTDIR", None), + ("XDG_CONFIG_HOME", Some(xdg_config.as_os_str())), + ("XDG_DATA_HOME", None), + ], + || { + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::Fish, + path: "fish/conf.d/vite-plus.fish", + env_file: "env.fish", + kind: ShellProfileKind::Snippet, + }], + ); + assert!(result.is_some(), "Should find vite-plus.fish in XDG_CONFIG_HOME"); + assert!(result.unwrap().contains("vite-plus.fish")); + }, ); - assert!(result.is_some(), "Should find vite-plus.fish in XDG_CONFIG_HOME"); - assert!(result.unwrap().contains("vite-plus.fish")); } #[test] - #[serial] #[cfg(not(windows))] fn test_check_profile_files_finds_xdg_nushell() { let temp = TempDir::new().unwrap(); @@ -1741,23 +1639,30 @@ mod tests { std::fs::write(fish_dir.join("vite-plus.nu"), "source '~/.vite-plus/env.nu'\n").unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None, Some(&xdg_data)); - - let result = check_profile_files( - "$HOME/.vite-plus", - &[ShellProfile { - root: ShellProfileRoot::NushellData, - path: "nushell/vendor/autoload/vite-plus.nu", - env_file: "env.nu", - kind: ShellProfileKind::Snippet, - }], + temp_env::with_vars( + [ + ("HOME", Some(fake_home.as_os_str())), + ("ZDOTDIR", None), + ("XDG_CONFIG_HOME", None), + ("XDG_DATA_HOME", Some(xdg_data.as_os_str())), + ], + || { + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::NushellData, + path: "nushell/vendor/autoload/vite-plus.nu", + env_file: "env.nu", + kind: ShellProfileKind::Snippet, + }], + ); + assert!(result.is_some(), "Should find vite-plus.nu in XDG_DATA_HOME"); + assert!(result.unwrap().contains("vite-plus.nu")); + }, ); - assert!(result.is_some(), "Should find vite-plus.nu in XDG_DATA_HOME"); - assert!(result.unwrap().contains("vite-plus.nu")); } #[test] - #[serial] #[cfg(not(windows))] fn test_check_profile_files_finds_posix_env_in_bashrc() { let temp = TempDir::new().unwrap(); @@ -1767,31 +1672,38 @@ mod tests { std::fs::write(fake_home.join(".bashrc"), "# some config\n. \"$HOME/.vite-plus/env\"\n") .unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); - - let result = check_profile_files( - "$HOME/.vite-plus", - &[ - ShellProfile { - root: ShellProfileRoot::Home, - path: ".bashrc", - env_file: "env", - kind: ShellProfileKind::Main, - }, - ShellProfile { - root: ShellProfileRoot::Home, - path: ".profile", - env_file: "env", - kind: ShellProfileKind::Main, - }, + temp_env::with_vars( + [ + ("HOME", Some(fake_home.as_os_str())), + ("ZDOTDIR", None), + ("XDG_CONFIG_HOME", None), + ("XDG_DATA_HOME", None), ], + || { + let result = check_profile_files( + "$HOME/.vite-plus", + &[ + ShellProfile { + root: ShellProfileRoot::Home, + path: ".bashrc", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Home, + path: ".profile", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ], + ); + assert!(result.is_some(), "Should find env sourcing in .bashrc"); + assert_eq!(result.unwrap(), "~/.bashrc"); + }, ); - assert!(result.is_some(), "Should find env sourcing in .bashrc"); - assert_eq!(result.unwrap(), "~/.bashrc"); } #[test] - #[serial] #[cfg(not(windows))] fn test_check_profile_files_finds_fish_env() { let temp = TempDir::new().unwrap(); @@ -1802,23 +1714,30 @@ mod tests { std::fs::write(fish_dir.join("config.fish"), "source \"$HOME/.vite-plus/env.fish\"\n") .unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); - - let result = check_profile_files( - "$HOME/.vite-plus", - &[ShellProfile { - root: ShellProfileRoot::Fish, - path: "fish/config.fish", - env_file: "env.fish", - kind: ShellProfileKind::Main, - }], + temp_env::with_vars( + [ + ("HOME", Some(fake_home.as_os_str())), + ("ZDOTDIR", None), + ("XDG_CONFIG_HOME", None), + ("XDG_DATA_HOME", None), + ], + || { + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::Fish, + path: "fish/config.fish", + env_file: "env.fish", + kind: ShellProfileKind::Main, + }], + ); + assert!(result.is_some(), "Should find env.fish sourcing in fish config"); + assert_eq!(result.unwrap(), "~/.config/fish/config.fish"); + }, ); - assert!(result.is_some(), "Should find env.fish sourcing in fish config"); - assert_eq!(result.unwrap(), "~/.config/fish/config.fish"); } #[test] - #[serial] #[cfg(not(windows))] fn test_check_profile_files_finds_nushell_env() { let temp = TempDir::new().unwrap(); @@ -1834,23 +1753,30 @@ mod tests { std::fs::write(nushell_autoload_dir.join("vite-plus.nu"), "source '~/.vite-plus/env.nu'\n") .unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); - - let result = check_profile_files( - "$HOME/.vite-plus", - &[ShellProfile { - root: ShellProfileRoot::NushellData, - path: "nushell/vendor/autoload/vite-plus.nu", - env_file: "env.nu", - kind: ShellProfileKind::Snippet, - }], + temp_env::with_vars( + [ + ("HOME", Some(fake_home.as_os_str())), + ("ZDOTDIR", None), + ("XDG_CONFIG_HOME", None), + ("XDG_DATA_HOME", None), + ], + || { + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::NushellData, + path: "nushell/vendor/autoload/vite-plus.nu", + env_file: "env.nu", + kind: ShellProfileKind::Snippet, + }], + ); + assert!(result.is_some(), "Should find env.nu sourcing in Nushell autoload"); + assert_eq!(result.unwrap(), format!("~/{nushell_autoload_path}/vite-plus.nu")); + }, ); - assert!(result.is_some(), "Should find env.nu sourcing in Nushell autoload"); - assert_eq!(result.unwrap(), format!("~/{nushell_autoload_path}/vite-plus.nu")); } #[test] - #[serial] #[cfg(not(windows))] fn test_check_profile_files_returns_none_when_not_found() { let temp = TempDir::new().unwrap(); @@ -1860,30 +1786,37 @@ mod tests { // Create a .bashrc without vite-plus sourcing std::fs::write(fake_home.join(".bashrc"), "# no vite-plus here\nexport FOO=bar\n").unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); - - let result = check_profile_files( - "$HOME/.vite-plus", - &[ - ShellProfile { - root: ShellProfileRoot::Home, - path: ".bashrc", - env_file: "env", - kind: ShellProfileKind::Main, - }, - ShellProfile { - root: ShellProfileRoot::Home, - path: ".profile", - env_file: "env", - kind: ShellProfileKind::Main, - }, + temp_env::with_vars( + [ + ("HOME", Some(fake_home.as_os_str())), + ("ZDOTDIR", None), + ("XDG_CONFIG_HOME", None), + ("XDG_DATA_HOME", None), ], + || { + let result = check_profile_files( + "$HOME/.vite-plus", + &[ + ShellProfile { + root: ShellProfileRoot::Home, + path: ".bashrc", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Home, + path: ".profile", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ], + ); + assert!(result.is_none(), "Should return None when env sourcing not found"); + }, ); - assert!(result.is_none(), "Should return None when env sourcing not found"); } #[test] - #[serial] #[cfg(not(windows))] fn test_check_profile_files_finds_absolute_path() { let temp = TempDir::new().unwrap(); @@ -1894,18 +1827,26 @@ mod tests { let abs_path = format!(". \"{}/home/.vite-plus/env\"\n", temp.path().display()); std::fs::write(fake_home.join(".zshenv"), &abs_path).unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); - - let result = check_profile_files( - "$HOME/.vite-plus", - &[ShellProfile { - root: ShellProfileRoot::Zsh, - path: ".zshenv", - env_file: "env", - kind: ShellProfileKind::Main, - }], + temp_env::with_vars( + [ + ("HOME", Some(fake_home.as_os_str())), + ("ZDOTDIR", None), + ("XDG_CONFIG_HOME", None), + ("XDG_DATA_HOME", None), + ], + || { + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::Zsh, + path: ".zshenv", + env_file: "env", + kind: ShellProfileKind::Main, + }], + ); + assert!(result.is_some(), "Should find absolute path form of env sourcing"); + assert_eq!(result.unwrap(), "~/.zshenv"); + }, ); - assert!(result.is_some(), "Should find absolute path form of env sourcing"); - assert_eq!(result.unwrap(), "~/.zshenv"); } } diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index a543c71340..23a6e3fe53 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -201,10 +201,17 @@ fn classify_version(version: &str) -> VersionSelector<'_> { #[cfg(test)] mod tests { - use serial_test::serial; - use super::*; + /// Shared VP_HOME for tests that download a real Node.js runtime: pinning + /// isolates them from concurrent scopes, and one shared root keeps the + /// download cache warm across tests and runs. + fn shared_vp_home() -> std::path::PathBuf { + let dir = std::env::temp_dir().join("vp-global-cli-tests-vp-home"); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + #[tokio::test] async fn test_execute_missing_command() { let result = execute(Some("20.18.0"), None, &[]).await; @@ -214,14 +221,21 @@ mod tests { } #[tokio::test] - #[serial] async fn test_execute_node_version() { - // Run 'node --version' with a specific Node.js version - let command = vec!["node".to_string(), "--version".to_string()]; - let result = execute(Some("20.18.0"), None, &command).await; - assert!(result.is_ok()); - let status = result.unwrap(); - assert!(status.success()); + // Shared root keeps the downloaded Node 20.18.0 warm across runs. + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + // Run 'node --version' with a specific Node.js version + let command = vec!["node".to_string(), "--version".to_string()]; + let result = execute(Some("20.18.0"), None, &command).await; + assert!(result.is_ok()); + let status = result.unwrap(); + assert!(status.success()); + }, + ) + .await; } #[tokio::test] diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 4ef758e70b..27cf29b774 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -44,8 +44,7 @@ pub(super) fn list_installed_versions(node_dir: &std::path::Path) -> Vec /// Execute the list command (local installed versions). pub async fn execute(cwd: AbsolutePathBuf, json_output: bool) -> Result { - let home_dir = vp_shared::get_vp_home()?; - let node_dir = home_dir.join("js_runtime").join("node"); + let node_dir = vp_shared::EnvConfig::get().dirs.data.join("js_runtime").join("node"); let versions = list_installed_versions(node_dir.as_path()); diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index 81b3317c8e..aacfc99797 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -103,10 +103,7 @@ async fn local_markers(cwd: &AbsolutePathBuf, provider: &NodeProvider) -> LocalM /// Collect the set of locally installed Node.js versions (without `v` prefix). fn installed_versions() -> std::collections::HashSet { - let Ok(home_dir) = vp_shared::get_vp_home() else { - return std::collections::HashSet::new(); - }; - let node_dir = home_dir.join("js_runtime").join("node"); + let node_dir = vp_shared::EnvConfig::get().dirs.data.join("js_runtime").join("node"); super::list::list_installed_versions(node_dir.as_path()).into_iter().collect() } diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index bae8bccd8c..f68d8169e5 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -109,8 +109,12 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result { let provider = vp_js_runtime::NodeProvider::new(); let resolved = config::resolve_version_alias(&version, &provider).await?; - let home_dir = vp_shared::get_vp_home()?; - let version_dir = home_dir.join("js_runtime").join("node").join(&resolved); + let version_dir = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(&resolved); if !version_dir.as_path().exists() { eprintln!("Node.js v{} is not installed", resolved); return Ok(exit_status(1)); diff --git a/crates/vp_global_cli/src/commands/env/package_metadata.rs b/crates/vp_global_cli/src/commands/env/package_metadata.rs index 21eadc1048..fbbbe8f0c7 100644 --- a/crates/vp_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vp_global_cli/src/commands/env/package_metadata.rs @@ -221,6 +221,8 @@ async fn list_packages_recursive( #[cfg(test)] mod tests { + use vp_shared::env_vars; + use super::*; #[test] @@ -304,34 +306,32 @@ mod tests { use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - - let legacy = PackageMetadata::installation_dir_for("@scope/pkg", "").unwrap(); - let legacy_identified = PackageMetadata::installation_dir_for( - "@scope/pkg", - "#123e4567-e89b-42d3-a456-426614174000", - ) - .unwrap(); - let identified = PackageMetadata::installation_dir_for( - "@scope/pkg", - "987e6543-e21b-42d3-a456-426614174000", - ) - .unwrap(); - - assert!(legacy.as_path().ends_with("packages/@scope/pkg")); - assert!( - legacy_identified - .as_path() - .ends_with("packages/@scope/pkg#123e4567-e89b-42d3-a456-426614174000") - ); - assert!( - identified - .as_path() - .ends_with("packages/@scope/pkg/987e6543-e21b-42d3-a456-426614174000") - ); - assert!(PackageMetadata::installation_dir_for("@scope/pkg", "invalid").is_err()); + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, temp_dir.path())], |_| { + let legacy = PackageMetadata::installation_dir_for("@scope/pkg", "").unwrap(); + let legacy_identified = PackageMetadata::installation_dir_for( + "@scope/pkg", + "#123e4567-e89b-42d3-a456-426614174000", + ) + .unwrap(); + let identified = PackageMetadata::installation_dir_for( + "@scope/pkg", + "987e6543-e21b-42d3-a456-426614174000", + ) + .unwrap(); + + assert!(legacy.as_path().ends_with("packages/@scope/pkg")); + assert!( + legacy_identified + .as_path() + .ends_with("packages/@scope/pkg#123e4567-e89b-42d3-a456-426614174000") + ); + assert!( + identified + .as_path() + .ends_with("packages/@scope/pkg/987e6543-e21b-42d3-a456-426614174000") + ); + assert!(PackageMetadata::installation_dir_for("@scope/pkg", "invalid").is_err()); + }); } #[tokio::test] @@ -339,28 +339,33 @@ mod tests { use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); - let temp_path = temp_dir.path().to_path_buf(); - let _guard = - vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&temp_path)); - - let metadata = PackageMetadata::new( - "@scope/test-pkg".to_string(), - "1.0.0".to_string(), - "20.18.0".to_string(), - None, - vec!["test-bin".to_string()], - HashSet::from(["test-bin".to_string()]), - "npm".to_string(), - ); - - // This should not fail with "No such file or directory" - // because save() should create the @scope parent directory - let result = metadata.save().await; - assert!(result.is_ok(), "Failed to save scoped package metadata: {:?}", result.err()); - - // Verify the file exists at the correct location - let expected_path = temp_path.join("packages").join("@scope").join("test-pkg.json"); - assert!(expected_path.exists(), "Metadata file not found at {:?}", expected_path); + // VP_HOME pins to the root, so packages live directly under it. + let packages_dir = AbsolutePathBuf::new(temp_dir.path().join("packages")).unwrap(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + let metadata = PackageMetadata::new( + "@scope/test-pkg".to_string(), + "1.0.0".to_string(), + "20.18.0".to_string(), + None, + vec!["test-bin".to_string()], + HashSet::from(["test-bin".to_string()]), + "npm".to_string(), + ); + + // This should not fail with "No such file or directory" + // because save() should create the @scope parent directory + let result = metadata.save().await; + assert!(result.is_ok(), "Failed to save scoped package metadata: {:?}", result.err()); + + // Verify the file exists at the correct location + let expected_path = packages_dir.join("@scope").join("test-pkg.json"); + assert!( + expected_path.as_path().exists(), + "Metadata file not found at {:?}", + expected_path + ); + }) + .await; } #[tokio::test] @@ -369,40 +374,40 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let temp_path = temp_dir.path().to_path_buf(); - let _guard = - vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&temp_path)); - - // Create regular package metadata - let regular = PackageMetadata::new( - "typescript".to_string(), - "5.0.0".to_string(), - "20.18.0".to_string(), - None, - vec!["tsc".to_string()], - HashSet::from(["tsc".to_string()]), - "npm".to_string(), - ); - regular.save().await.unwrap(); - - // Create scoped package metadata - let scoped = PackageMetadata::new( - "@types/node".to_string(), - "20.0.0".to_string(), - "20.18.0".to_string(), - None, - vec![], - HashSet::new(), - "npm".to_string(), - ); - scoped.save().await.unwrap(); - - // list_all should find both - let all = PackageMetadata::list_all().await.unwrap(); - assert_eq!(all.len(), 2, "Expected 2 packages, got {}", all.len()); - - let names: Vec<_> = all.iter().map(|p| p.name.as_str()).collect(); - assert!(names.contains(&"typescript"), "Missing typescript package"); - assert!(names.contains(&"@types/node"), "Missing @types/node package"); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, &temp_path)], |_| async { + // Create regular package metadata + let regular = PackageMetadata::new( + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + None, + vec!["tsc".to_string()], + HashSet::from(["tsc".to_string()]), + "npm".to_string(), + ); + regular.save().await.unwrap(); + + // Create scoped package metadata + let scoped = PackageMetadata::new( + "@types/node".to_string(), + "20.0.0".to_string(), + "20.18.0".to_string(), + None, + vec![], + HashSet::new(), + "npm".to_string(), + ); + scoped.save().await.unwrap(); + + // list_all should find both + let all = PackageMetadata::list_all().await.unwrap(); + assert_eq!(all.len(), 2, "Expected 2 packages, got {}", all.len()); + + let names: Vec<_> = all.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"typescript"), "Missing typescript package"); + assert!(names.contains(&"@types/node"), "Missing @types/node package"); + }) + .await; } #[tokio::test] @@ -411,33 +416,33 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let temp_path = temp_dir.path().to_path_buf(); - let _guard = - vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&temp_path)); - - let zed = PackageMetadata::new( - "zed".to_string(), - "1.0.0".to_string(), - "20.18.0".to_string(), - None, - vec![], - HashSet::new(), - "npm".to_string(), - ); - zed.save().await.unwrap(); - - let alpha = PackageMetadata::new( - "alpha".to_string(), - "1.0.0".to_string(), - "20.18.0".to_string(), - None, - vec![], - HashSet::new(), - "npm".to_string(), - ); - alpha.save().await.unwrap(); - - let all = PackageMetadata::list_all().await.unwrap(); - let names: Vec<_> = all.iter().map(|p| p.name.as_str()).collect(); - assert_eq!(names, vec!["alpha", "zed"]); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, &temp_path)], |_| async { + let zed = PackageMetadata::new( + "zed".to_string(), + "1.0.0".to_string(), + "20.18.0".to_string(), + None, + vec![], + HashSet::new(), + "npm".to_string(), + ); + zed.save().await.unwrap(); + + let alpha = PackageMetadata::new( + "alpha".to_string(), + "1.0.0".to_string(), + "20.18.0".to_string(), + None, + vec![], + HashSet::new(), + "npm".to_string(), + ); + alpha.save().await.unwrap(); + + let all = PackageMetadata::list_all().await.unwrap(); + let names: Vec<_> = all.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "zed"]); + }) + .await; } } diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 23b3468373..84b42ceb0c 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -583,12 +583,21 @@ pub async fn do_unpin( #[cfg(test)] mod tests { - use serial_test::serial; use tempfile::TempDir; + use vp_shared::env_vars; use vt_path::AbsolutePathBuf; use super::*; + /// Shared VP_HOME for tests that hit the real Node.js version index: + /// pinning isolates them from concurrent scopes, and one shared root + /// keeps the index cache warm across tests and runs. + fn shared_vp_home() -> std::path::PathBuf { + let dir = std::env::temp_dir().join("vp-global-cli-tests-vp-home"); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + #[tokio::test] async fn test_show_pinned_no_file() { let temp_dir = TempDir::new().unwrap(); @@ -681,192 +690,221 @@ mod tests { let node_version_path = temp_path.join(".node-version"); tokio::fs::write(&node_version_path, "20.18.0\n").await.unwrap(); - // Unpin - let result = do_unpin(&temp_path, None).await; - assert!(result.is_ok()); + // Unpin (scoped VP_HOME isolates the resolve-cache invalidation) + vp_shared::EnvConfig::scoped_async(|_| async { + let result = do_unpin(&temp_path, None).await; + assert!(result.is_ok()); + }) + .await; // File should be gone assert!(!tokio::fs::try_exists(&node_version_path).await.unwrap()); } #[tokio::test] - // Run serially: mutates VP_HOME env var which affects invalidate_cache() - #[serial] async fn test_do_unpin_invalidates_cache() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Point VP_HOME to temp dir - unsafe { - std::env::set_var(vp_shared::env_vars::VP_HOME, temp_path.as_path()); - } - - // Create cache file manually - let cache_dir = temp_path.join("cache"); - std::fs::create_dir_all(&cache_dir).unwrap(); - let cache_file = cache_dir.join("resolve_cache.json"); - std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); - assert!( - std::fs::metadata(cache_file.as_path()).is_ok(), - "Cache file should exist before unpin" - ); - - // Create .node-version and unpin - let node_version_path = temp_path.join(".node-version"); - tokio::fs::write(&node_version_path, "20.18.0\n").await.unwrap(); - let result = do_unpin(&temp_path, None).await; - assert!(result.is_ok()); - - // Cache file should be removed by invalidate_cache() - assert!( - std::fs::metadata(cache_file.as_path()).is_err(), - "Cache file should be removed after unpin" - ); - - // Cleanup - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } + // Pin VP_HOME to the temp dir so invalidate_cache() targets our file + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, temp_path.as_path())], + |_| async { + // Create cache file manually + let cache_dir = temp_path.join("cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let cache_file = cache_dir.join("resolve_cache.json"); + std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); + assert!( + std::fs::metadata(cache_file.as_path()).is_ok(), + "Cache file should exist before unpin" + ); + + // Create .node-version and unpin + let node_version_path = temp_path.join(".node-version"); + tokio::fs::write(&node_version_path, "20.18.0\n").await.unwrap(); + let result = do_unpin(&temp_path, None).await; + assert!(result.is_ok()); + + // Cache file should be removed by invalidate_cache() + assert!( + std::fs::metadata(cache_file.as_path()).is_err(), + "Cache file should be removed after unpin" + ); + }, + ) + .await; } - // Run serially: mutates VP_HOME env var which affects invalidate_cache() #[tokio::test] - #[serial] async fn test_do_pin_invalidates_cache() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Point VP_HOME to temp dir - unsafe { - std::env::set_var(vp_shared::env_vars::VP_HOME, temp_path.as_path()); - } - - // Create cache file manually - let cache_dir = temp_path.join("cache"); - std::fs::create_dir_all(&cache_dir).unwrap(); - let cache_file = cache_dir.join("resolve_cache.json"); - std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); - assert!( - std::fs::metadata(cache_file.as_path()).is_ok(), - "Cache file should exist before pin" - ); - - // Pin an exact version (no_install=true to skip download, force=true to skip prompt) - let result = do_pin(&temp_path, "20.18.0", true, true, None).await; - assert!(result.is_ok()); - - // .node-version should be created - let node_version_path = temp_path.join(".node-version"); - assert!(tokio::fs::try_exists(&node_version_path).await.unwrap()); - let content = tokio::fs::read_to_string(&node_version_path).await.unwrap(); - assert_eq!(content.trim(), "20.18.0"); - - // Cache file should be removed by invalidate_cache() - assert!( - std::fs::metadata(cache_file.as_path()).is_err(), - "Cache file should be removed after pin" - ); - - // Cleanup - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } + let vp_home = shared_vp_home(); + + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + // Create cache file manually + let cache_dir = vp_home.join("cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let cache_file = cache_dir.join("resolve_cache.json"); + std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); + assert!( + std::fs::metadata(cache_file.as_path()).is_ok(), + "Cache file should exist before pin" + ); + + // Pin an exact version (no_install=true to skip download, force=true to skip prompt) + let result = do_pin(&temp_path, "20.18.0", true, true, None).await; + assert!(result.is_ok()); + + // .node-version should be created + let node_version_path = temp_path.join(".node-version"); + assert!(tokio::fs::try_exists(&node_version_path).await.unwrap()); + let content = tokio::fs::read_to_string(&node_version_path).await.unwrap(); + assert_eq!(content.trim(), "20.18.0"); + + // Cache file should be removed by invalidate_cache() + assert!( + std::fs::metadata(cache_file.as_path()).is_err(), + "Cache file should be removed after pin" + ); + }, + ) + .await; } #[tokio::test] async fn test_do_unpin_no_file() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + vp_shared::EnvConfig::scoped_async(|_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Should not error when no file exists - let result = do_unpin(&temp_path, None).await; - assert!(result.is_ok()); + // Should not error when no file exists + let result = do_unpin(&temp_path, None).await; + assert!(result.is_ok()); + }) + .await; } #[tokio::test] async fn test_do_pin_targets_dev_engines_when_package_json_exists() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // package.json without .node-version: the pin goes into devEngines.runtime - tokio::fs::write( + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // package.json without .node-version: the pin goes into devEngines.runtime + tokio::fs::write( temp_path.join("package.json"), "{\n \"name\": \"test\",\n \"engines\": {\n \"node\": \">=18.0.0\"\n }\n}\n", ) .await .unwrap(); - let result = do_pin(&temp_path, "20.18.0", true, true, None).await; - assert!(result.is_ok()); - - // .node-version is NOT created - assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); - - let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); - let entry = &pkg["devEngines"]["runtime"]; - assert_eq!(entry["name"].as_str().unwrap(), "node"); - assert_eq!(entry["version"].as_str().unwrap(), "20.18.0"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // existing engines.node is kept unchanged - assert_eq!(pkg["engines"]["node"].as_str().unwrap(), ">=18.0.0"); - // devEngines is placed right after engines - let keys: Vec<&str> = pkg.as_object().unwrap().keys().map(String::as_str).collect(); - assert_eq!(keys, ["name", "engines", "devEngines"]); + let result = do_pin(&temp_path, "20.18.0", true, true, None).await; + assert!(result.is_ok()); + + // .node-version is NOT created + assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); + + let content = + tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + let entry = &pkg["devEngines"]["runtime"]; + assert_eq!(entry["name"].as_str().unwrap(), "node"); + assert_eq!(entry["version"].as_str().unwrap(), "20.18.0"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // existing engines.node is kept unchanged + assert_eq!(pkg["engines"]["node"].as_str().unwrap(), ">=18.0.0"); + // devEngines is placed right after engines + let keys: Vec<&str> = pkg.as_object().unwrap().keys().map(String::as_str).collect(); + assert_eq!(keys, ["name", "engines", "devEngines"]); + }, + ) + .await; } #[tokio::test] async fn test_do_pin_keeps_node_version_file_target_when_it_exists() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - tokio::fs::write(temp_path.join(".node-version"), "18.20.0\n").await.unwrap(); - tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") - .await - .unwrap(); - - // force=true skips the overwrite prompt - let result = do_pin(&temp_path, "20.18.0", true, true, None).await; - assert!(result.is_ok()); - - // .node-version keeps winning for writes (compatibility-first) - let content = tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(content.trim(), "20.18.0"); - - // package.json is untouched - let pkg = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - assert!(!pkg.contains("devEngines")); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + tokio::fs::write(temp_path.join(".node-version"), "18.20.0\n").await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") + .await + .unwrap(); + + // force=true skips the overwrite prompt + let result = do_pin(&temp_path, "20.18.0", true, true, None).await; + assert!(result.is_ok()); + + // .node-version keeps winning for writes (compatibility-first) + let content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(content.trim(), "20.18.0"); + + // package.json is untouched + let pkg = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert!(!pkg.contains("devEngines")); + }, + ) + .await; } #[tokio::test] async fn test_do_pin_explicit_dev_engines_target_wins_over_node_version_file() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - tokio::fs::write(temp_path.join(".node-version"), "18.20.0\n").await.unwrap(); - tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") - .await - .unwrap(); - - let result = do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; - assert!(result.is_ok()); - - // devEngines is written; .node-version stays untouched (a warning is printed) - let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(pkg["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); - let node_version = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version.trim(), "18.20.0"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + tokio::fs::write(temp_path.join(".node-version"), "18.20.0\n").await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), "{\n \"name\": \"test\"\n}\n") + .await + .unwrap(); + + let result = + do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; + assert!(result.is_ok()); + + // devEngines is written; .node-version stays untouched (a warning is printed) + let content = + tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(pkg["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); + let node_version = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version.trim(), "18.20.0"); + }, + ) + .await; } #[tokio::test] async fn test_do_pin_dev_engines_target_requires_package_json() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - let result = do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; - assert!(result.is_err()); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + let result = + do_pin(&temp_path, "20.18.0", true, true, Some(PinTarget::DevEngines)).await; + assert!(result.is_err()); + }, + ) + .await; } #[test] @@ -932,30 +970,33 @@ mod tests { #[tokio::test] async fn test_do_unpin_dev_engines_default_when_no_node_version_file() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + vp_shared::EnvConfig::scoped_async(|_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - tokio::fs::write( - temp_path.join("package.json"), - r#"{ + tokio::fs::write( + temp_path.join("package.json"), + r#"{ "name": "test", "devEngines": { "runtime": {"name": "node", "version": "^24.0.0"} } } "#, - ) - .await - .unwrap(); + ) + .await + .unwrap(); - let result = do_unpin(&temp_path, None).await; - assert!(result.is_ok()); + let result = do_unpin(&temp_path, None).await; + assert!(result.is_ok()); - let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); - // the emptied devEngines object is cleaned up entirely - assert!(pkg.get("devEngines").is_none()); - assert_eq!(pkg["name"].as_str().unwrap(), "test"); + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + // the emptied devEngines object is cleaned up entirely + assert!(pkg.get("devEngines").is_none()); + assert_eq!(pkg["name"].as_str().unwrap(), "test"); + }) + .await; } #[tokio::test] @@ -1050,22 +1091,32 @@ mod tests { #[tokio::test] async fn test_resolve_version_for_pin_partial_version() { - let provider = NodeProvider::new(); - - // Partial version "20" should resolve to an exact version like "20.x.y" - let (resolved, was_alias) = resolve_version_for_pin("20", &provider).await.unwrap(); - assert!(was_alias, "partial version should be treated as alias"); - - // The resolved version should be a full semver version starting with "20." - assert!( - resolved.starts_with("20."), - "expected resolved version to start with '20.', got: {resolved}" - ); - - // Should be a valid exact version (major.minor.patch) - let parts: Vec<&str> = resolved.split('.').collect(); - assert_eq!(parts.len(), 3, "expected 3 version parts, got: {resolved}"); - assert!(parts.iter().all(|p| p.parse::().is_ok()), "all parts should be numeric"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let provider = NodeProvider::new(); + + // Partial version "20" should resolve to an exact version like "20.x.y" + let (resolved, was_alias) = resolve_version_for_pin("20", &provider).await.unwrap(); + assert!(was_alias, "partial version should be treated as alias"); + + // The resolved version should be a full semver version starting with "20." + assert!( + resolved.starts_with("20."), + "expected resolved version to start with '20.', got: {resolved}" + ); + + // Should be a valid exact version (major.minor.patch) + let parts: Vec<&str> = resolved.split('.').collect(); + assert_eq!(parts.len(), 3, "expected 3 version parts, got: {resolved}"); + assert!( + parts.iter().all(|p| p.parse::().is_ok()), + "all parts should be numeric" + ); + }, + ) + .await; } #[tokio::test] diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index b99249b124..1e968357b7 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1,8 +1,8 @@ //! Setup command implementation for creating bin directory and shims. //! //! Creates the following structure: -//! - ~/.vite-plus/bin/ - Contains vp symlink and node/npm/npx/corepack shims -//! - ~/.vite-plus/current/ - Contains the actual vp CLI binary +//! - `/` - Contains vp symlink and node/npm/npx/corepack shims +//! - `/current/` - Contains the actual vp CLI binary //! //! On Unix: //! - bin/vp is a symlink to the active vp binary @@ -17,10 +17,9 @@ use std::process::ExitStatus; -use super::config::{get_bin_dir, get_vp_home}; use crate::{error::Error, help}; -/// Shells that get a generated `~/.vite-plus/env.*` setup script. +/// Shells that get a generated `/env.*` setup script. #[derive(Clone, Copy, Debug)] enum EnvShell { Posix, @@ -30,7 +29,7 @@ enum EnvShell { } impl EnvShell { - /// File name written under `~/.vite-plus/` for this shell's setup script. + /// File name written under `/` for this shell's setup script. const fn env_file_name(self) -> &'static str { match self { EnvShell::Posix => "env", @@ -46,13 +45,14 @@ pub(crate) const SHIM_TOOLS: &[&str] = &["node", "npm", "npx", "corepack", "vpx" /// Execute the setup command. pub async fn execute(refresh: bool, env_only: bool) -> Result { - let vite_plus_home = get_vp_home()?; + let config = vp_shared::EnvConfig::get(); + let dirs = &config.dirs; - // Ensure home directory exists (env files are written here) - tokio::fs::create_dir_all(&vite_plus_home).await?; + // Ensure config directory exists (env files are written here) + tokio::fs::create_dir_all(&dirs.config).await?; // Create env files with PATH guard (prevents duplicate PATH entries) - create_env_files(&vite_plus_home).await?; + create_env_files().await?; if env_only { println!("{}", help::render_heading("Setup")); @@ -61,30 +61,30 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result return Ok(ExitStatus::default()); } - let bin_dir = get_bin_dir()?; + let bin_dir = &dirs.bin; println!("{}", help::render_heading("Setup")); println!(" Preparing vite-plus environment."); println!(); // Ensure bin directory exists - tokio::fs::create_dir_all(&bin_dir).await?; + tokio::fs::create_dir_all(bin_dir).await?; #[cfg(windows)] - tokio::fs::write(bin_dir.join("vp-use.cmd"), VP_USE_CMD_CONTENT).await?; + tokio::fs::write(bin_dir.join("vp-use.cmd"), vp_use_cmd_content(&config)).await?; // Get the current executable path (for shims) let current_exe = std::env::current_exe()?; // Create wrapper script in bin/ - setup_vp_wrapper(¤t_exe, &bin_dir, refresh).await?; + setup_vp_wrapper(¤t_exe, bin_dir, refresh).await?; // Create shims for node, npm, npx, corepack let mut created = Vec::new(); let mut skipped = Vec::new(); for tool in SHIM_TOOLS { - let result = create_shim(¤t_exe, &bin_dir, tool, refresh).await?; + let result = create_shim(¤t_exe, bin_dir, tool, refresh).await?; if result { created.push(*tool); } else { @@ -95,7 +95,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result // 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; + cleanup_legacy_windows_shim(bin_dir, tool).await; // Drop stale `npm install -g` link configs for default shim names // (e.g. a pre-default-shim `npm i -g corepack`): the link itself is @@ -110,7 +110,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result #[cfg(windows)] if refresh { - if let Err(e) = refresh_package_shims(&bin_dir).await { + if let Err(e) = refresh_package_shims(bin_dir).await { tracing::warn!("Failed to refresh package shims: {}", e); } } @@ -118,7 +118,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result // Best-effort cleanup of .old files from rename-before-copy on Windows #[cfg(windows)] if refresh { - cleanup_old_files(&bin_dir).await; + cleanup_old_files(bin_dir).await; } // Print results @@ -144,7 +144,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result } println!(); - print_path_instructions(&bin_dir); + print_path_instructions(&dirs.config); Ok(ExitStatus::default()) } @@ -219,14 +219,18 @@ pub(crate) async fn resolve_unix_vp_shim_target( current_exe: &std::path::Path, bin_dir: &vt_path::AbsolutePath, ) -> Result { - if let Some(vite_plus_home) = bin_dir.parent() { - let standalone_vp = vite_plus_home.join("current").join("bin").join("vp"); - if tokio::fs::try_exists(&standalone_vp).await.unwrap_or(false) { - let standalone_vp = tokio::fs::canonicalize(&standalone_vp).await.ok(); - let current_exe = tokio::fs::canonicalize(current_exe).await.ok(); - if standalone_vp.is_some() && standalone_vp == current_exe { + let data = &vp_shared::EnvConfig::get().dirs.data; + let current_vp = data.join("current").join("bin").join("vp"); + if tokio::fs::try_exists(¤t_vp).await.unwrap_or(false) { + let current_vp_canon = tokio::fs::canonicalize(¤t_vp).await.ok(); + let current_exe_canon = tokio::fs::canonicalize(current_exe).await.ok(); + if current_vp_canon.is_some() && current_vp_canon == current_exe_canon { + // Relative only when `` is `/bin` (monolithic). Split + // bins such as `~/.local/bin` must point at `/current/bin/vp`. + if bin_dir.parent().is_some_and(|parent| parent.as_path() == data.as_path()) { return Ok(std::path::PathBuf::from("../current/bin/vp")); } + return Ok(current_vp.as_path().to_path_buf()); } } @@ -293,7 +297,7 @@ pub(crate) async fn create_shim( } /// Get the filename for a shim (platform-specific). -fn shim_filename(tool: &str) -> String { +pub(crate) fn shim_filename(tool: &str) -> String { #[cfg(windows)] { // All tools use trampoline .exe files on Windows @@ -519,8 +523,7 @@ pub(crate) async fn cleanup_legacy_windows_shim(bin_dir: &vt_path::AbsolutePath, // Includes shell completion support const ENV_TEMPLATE_POSIX: &str = r#"#!/bin/sh # Vite+ environment setup (https://viteplus.dev) -export VP_HOME="__VP_HOME__" -__vp_bin="__VP_BIN__" +__ENV_EXPORTS____vp_bin="__VP_BIN__" case ":${PATH}:" in *":${__vp_bin}:"*) __vp_tmp=":${PATH}:" @@ -567,8 +570,7 @@ fi "#; const ENV_TEMPLATE_FISH: &str = r#"# Vite+ environment setup (https://viteplus.dev) -set -gx VP_HOME "__VP_HOME__" -set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) +__ENV_EXPORTS__set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) and set -e PATH[$__vp_idx] set -gx PATH __VP_BIN__ $PATH @@ -603,8 +605,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.VP_HOME = ("__VP_HOME__" | path expand --no-symlink) -$env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") +__ENV_EXPORTS__$env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") # Shell function wrapper: intercepts `vp env use` to parse its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -664,8 +665,7 @@ export extern "vpr" [...args: string@"nu-complete vpr"] "#; const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) -$env:VP_HOME = "__VP_HOME_WIN__" -$__vp_bin = "__VP_BIN_WIN__" +__ENV_EXPORTS__$__vp_bin = "__VP_BIN_WIN__" if ($env:Path -split ';' -notcontains $__vp_bin) { $env:Path = "$__vp_bin;$env:Path" } @@ -726,9 +726,19 @@ Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp // 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`. #[cfg(windows)] -const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nset VP_HOME=%~dp0..\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; +fn vp_use_cmd_content(config: &vp_shared::EnvConfig) -> String { + let vp_exe = config.dirs.data.join("current").join("bin").join("vp.exe"); + let mut exports: Vec<_> = config.dir_envs.iter().collect(); + exports.sort_unstable_by_key(|(name, _)| *name); + let export_lines: String = + exports.into_iter().map(|(name, value)| format!("set {name}={value}\r\n")).collect(); + format!( + "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\n{export_lines}for /f \"delims=\" %%i in ('\"{}\" env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n", + vp_exe.as_path().display() + ) +} -fn render_home_relative_path(path: &std::path::Path, home_dir: Option<&std::path::Path>) -> String { +fn render_home_relative_path(path: &std::path::Path, home_dir: &std::path::Path) -> String { fn render_path(path: &std::path::Path) -> String { let rendered = path.display().to_string(); // Windows: `C:\Users\xxx\.vite-plus` → `C:/Users/xxx/.vite-plus` @@ -738,8 +748,8 @@ fn render_home_relative_path(path: &std::path::Path, home_dir: Option<&std::path // 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. - home_dir - .and_then(|h| path.strip_prefix(h).ok()) + path.strip_prefix(home_dir) + .ok() .map(|s| { if s.as_os_str().is_empty() { "$HOME".to_string() @@ -768,94 +778,125 @@ fn escape_nu_double_quoted_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } -/// Render the env-file content for `shell` against `vite_plus_home`. -fn render_env_content(shell: EnvShell, vite_plus_home: &vt_path::AbsolutePath) -> String { - let bin_path = vite_plus_home.join("bin"); - let home_dir = vp_shared::EnvConfig::get().user_home; - let home_dir = home_dir.as_deref(); - let home_path_ref = render_home_relative_path(vite_plus_home.as_path(), home_dir); - let bin_path_ref = render_home_relative_path(bin_path.as_path(), home_dir); +/// Render the re-export lines for the captured layout overrides +/// ([`EnvConfig::dir_envs`]), sorted by variable name for deterministic +/// output. Empty when no overrides were captured: children then resolve the +/// same roots from the defaults (an existing `~/.vite-plus` or the split +/// platform roots). +fn render_dir_envs(shell: EnvShell, config: &vp_shared::EnvConfig) -> String { + let home_dir = config.user_home.as_path(); + let mut exports: Vec<_> = config.dir_envs.iter().collect(); + exports.sort_unstable_by_key(|(name, _)| *name); + exports + .into_iter() + .map(|(name, value)| match shell { + EnvShell::Posix => format!( + "export {name}=\"{}\"\n", + render_home_relative_path(std::path::Path::new(value), home_dir) + ), + EnvShell::Fish => format!( + "set -gx {name} \"{}\"\n", + render_home_relative_path(std::path::Path::new(value), home_dir) + ), + EnvShell::Nu => { + let path_ref = render_nu_path_ref(&render_home_relative_path( + std::path::Path::new(value), + home_dir, + )); + format!( + "$env.{name} = (\"{}\" | path expand --no-symlink)\n", + escape_nu_double_quoted_string(&path_ref) + ) + } + // PowerShell uses the actual absolute path (not $HOME-relative) + EnvShell::Powershell => format!("$env:{name} = \"{value}\"\n"), + }) + .collect() +} + +/// Render the env-file content for `shell` against the resolved config. +/// +/// PATH is pointed at the resolved bin directory. The layout overrides +/// captured in [`EnvConfig::dir_envs`] are re-exported so child shells +/// resolve the identical roots; with no overrides captured the file carries +/// no export lines at all. +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 dir_envs = render_dir_envs(shell, config); match shell { EnvShell::Posix => ENV_TEMPLATE_POSIX - .replace("__VP_HOME__", &home_path_ref) + .replace("__ENV_EXPORTS__", &dir_envs) .replace("__VP_BIN__", &bin_path_ref), EnvShell::Fish => ENV_TEMPLATE_FISH - .replace("__VP_HOME__", &home_path_ref) + .replace("__ENV_EXPORTS__", &dir_envs) .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 home_path_ref_nu = - escape_nu_double_quoted_string(&render_nu_path_ref(&home_path_ref)); let bin_path_ref_nu = escape_nu_double_quoted_string(&render_nu_path_ref(&bin_path_ref)); ENV_TEMPLATE_NU - .replace("__VP_HOME__", &home_path_ref_nu) + .replace("__ENV_EXPORTS__", &dir_envs) .replace("__VP_BIN__", &bin_path_ref_nu) } EnvShell::Powershell => { // PowerShell uses the actual absolute path (not $HOME-relative) - let home_path_win = vite_plus_home.as_path().display().to_string(); - let bin_path_win = bin_path.as_path().display().to_string(); + let bin_path_win = dirs.bin.as_path().display().to_string(); ENV_TEMPLATE_PS1 - .replace("__VP_HOME_WIN__", &home_path_win) + .replace("__ENV_EXPORTS__", &dir_envs) .replace("__VP_BIN_WIN__", &bin_path_win) } } } -/// Create env files with PATH guard (prevents duplicate PATH entries). +/// Create env files under `/` 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 -async fn create_env_files(vite_plus_home: &vt_path::AbsolutePath) -> Result<(), Error> { +/// - `env` (POSIX shell — bash/zsh) with `vp()` wrapper function +/// - `env.fish` (fish shell) with `vp` wrapper function +/// - `env.nu` (Nushell) with `vp env use` wrapper function +/// - `env.ps1` (PowerShell) with PATH setup + `vp` function +async fn create_env_files() -> Result<(), Error> { + let config = vp_shared::EnvConfig::get(); 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?; + let content = render_env_content(shell, &config); + tokio::fs::write(config.dirs.config.join(shell.env_file_name()), content).await?; } Ok(()) } -/// Print instructions for adding bin directory to PATH. -fn print_path_instructions(bin_dir: &vt_path::AbsolutePath) { - // Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); - let (home_path, nu_home_path) = if let Ok(home_dir) = std::env::var("HOME") { - if let Some(suffix) = home_path.strip_prefix(&home_dir) { - // POSIX/Fish use $HOME; Nushell's `source` is a parse-time keyword - // that cannot expand $HOME (a runtime env var), so use ~ instead. - (format!("$HOME{suffix}"), format!("~{suffix}")) - } else { - (home_path.clone(), home_path) - } +/// Print instructions for sourcing the env files and adding bin to PATH. +fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { + // Use $HOME-relative paths for readability (POSIX/Fish use $HOME; Nushell's + // `source` is a parse-time keyword that cannot expand $HOME, so use ~). + let env_path = env_dir.as_path().display().to_string(); + let home = vp_shared::EnvConfig::get().user_home.as_path().display().to_string(); + let (env_path, nu_env_path) = if let Some(suffix) = env_path.strip_prefix(&home) { + (format!("$HOME{suffix}"), format!("~{suffix}")) } else { - (home_path.clone(), home_path) + (env_path.clone(), env_path) }; println!("{}", help::render_heading("Next Steps")); println!(" Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):"); println!(); - println!(" . \"{home_path}/env\""); + println!(" . \"{env_path}/env\""); println!(); println!(" For fish shell, add to ~/.config/fish/config.fish:"); println!(); - println!(" source \"{home_path}/env.fish\""); + println!(" source \"{env_path}/env.fish\""); println!(); println!(" For Nushell, add to ~/.config/nushell/config.nu:"); println!(); - println!(" source '{nu_home_path}/env.nu'"); + println!(" source '{nu_env_path}/env.nu'"); println!(); println!(" For PowerShell, add to your $PROFILE:"); println!(); - println!(" . \"{home_path}/env.ps1\""); + println!(" . \"{env_path}/env.ps1\""); println!(); println!(" For IDE support (VS Code, Cursor), ensure bin directory is in system PATH:"); @@ -897,61 +938,97 @@ mod tests { assert!(!crate::commands::global::CORE_SHIMS.contains(&"corepack")); } - /// Helper: create a test_guard with user_home set to the given path. - fn home_guard(home: impl Into) -> vp_shared::TestEnvGuard { - vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - user_home: Some(home.into()), - ..vp_shared::EnvConfig::for_test() - }) + /// Helper: vars pinning a single-root install at `root`; `user_home` + /// is set separately (both `HOME` and `USERPROFILE`, for the platforms' + /// differing precedence) to exercise $HOME-relative vs absolute rendering. + fn test_env_vars<'a>( + root: &'a std::path::Path, + user_home: &'a std::path::Path, + ) -> [(&'static str, &'a std::ffi::OsStr); 3] { + [ + (vp_shared::env_vars::VP_HOME, root.as_os_str()), + ("HOME", user_home.as_os_str()), + ("USERPROFILE", user_home.as_os_str()), + ] } #[cfg(windows)] - struct FakeTrampolineGuard(Option); + fn write_fake_trampoline(dir: &std::path::Path) -> std::path::PathBuf { + let trampoline = dir.join("vp-shim.exe"); + std::fs::write(&trampoline, b"fake-trampoline").unwrap(); + trampoline + } - #[cfg(windows)] - impl FakeTrampolineGuard { - fn new(dir: &std::path::Path) -> Self { - let trampoline = dir.join("vp-shim.exe"); - std::fs::write(&trampoline, b"fake-trampoline").unwrap(); - let previous = std::env::var_os(vp_shared::env_vars::VP_TRAMPOLINE_PATH); - // SAFETY: This Windows-only test is serialized and the guard restores the variable. - unsafe { - std::env::set_var(vp_shared::env_vars::VP_TRAMPOLINE_PATH, &trampoline); - } - Self(previous) - } + #[test] + fn test_render_env_content_re_exports_dir_overrides() { + let temp_dir = TempDir::new().unwrap(); + let custom_bin = temp_dir.path().join("custom-bin"); + let custom_data = temp_dir.path().join("custom-data"); + vp_shared::EnvConfig::with_vars( + [ + (vp_shared::env_vars::VP_BIN_DIR, custom_bin.as_os_str()), + (vp_shared::env_vars::VP_DATA_DIR, custom_data.as_os_str()), + ("HOME", temp_dir.path().as_os_str()), + ("USERPROFILE", temp_dir.path().as_os_str()), + ], + |_| { + // dir_envs is captured from the overlaid environment. + let config = vp_shared::EnvConfig::get(); + let content = render_env_content(EnvShell::Posix, &config); + + let bin_line = "export VP_BIN_DIR=\"$HOME/custom-bin\"\n"; + let data_line = "export VP_DATA_DIR=\"$HOME/custom-data\"\n"; + assert!( + content.contains(bin_line), + "env should re-export VP_BIN_DIR, got: {content}" + ); + assert!( + content.contains(data_line), + "env should re-export VP_DATA_DIR, got: {content}" + ); + assert!( + content.find(bin_line) < content.find(data_line), + "export lines should be sorted by variable name, got: {content}" + ); + }, + ); } - #[cfg(windows)] - impl Drop for FakeTrampolineGuard { - fn drop(&mut self) { - // SAFETY: This Windows-only test is serialized and restores the previous value. - unsafe { - if let Some(previous) = self.0.take() { - std::env::set_var(vp_shared::env_vars::VP_TRAMPOLINE_PATH, previous); - } else { - std::env::remove_var(vp_shared::env_vars::VP_TRAMPOLINE_PATH); - } + #[test] + fn test_render_env_content_omits_exports_without_overrides() { + let temp_dir = TempDir::new().unwrap(); + vp_shared::EnvConfig::with_vars([("HOME", temp_dir.path())], |_| { + let config = vp_shared::EnvConfig::get(); + for shell in [EnvShell::Posix, EnvShell::Fish, EnvShell::Nu, EnvShell::Powershell] { + let content = render_env_content(shell, &config); + assert!( + !content.contains("VP_HOME"), + "{shell:?} env file should not mention VP_HOME without overrides, got: {content}" + ); } - } + }); } #[tokio::test] async fn test_create_env_files_creates_all_files() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - create_env_files(&home).await.unwrap(); - - let env_path = home.join("env"); - let env_fish_path = home.join("env.fish"); - let env_nu_path = home.join("env.nu"); - let env_ps1_path = home.join("env.ps1"); - assert!(env_path.as_path().exists(), "env file should be created"); - assert!(env_fish_path.as_path().exists(), "env.fish file should be created"); - assert!(env_nu_path.as_path().exists(), "env.nu file should be created"); - assert!(env_ps1_path.as_path().exists(), "env.ps1 file should be created"); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(temp_dir.path(), temp_dir.path()), + |_| async { + create_env_files().await.unwrap(); + + let env_path = home.join("env"); + let env_fish_path = home.join("env.fish"); + let env_nu_path = home.join("env.nu"); + let env_ps1_path = home.join("env.ps1"); + assert!(env_path.as_path().exists(), "env file should be created"); + assert!(env_fish_path.as_path().exists(), "env.fish file should be created"); + assert!(env_nu_path.as_path().exists(), "env.nu file should be created"); + assert!(env_ps1_path.as_path().exists(), "env.ps1 file should be created"); + }, + ) + .await; } #[test] @@ -965,21 +1042,22 @@ mod tests { #[cfg(unix)] #[test] fn test_render_env_content_escapes_nu_paths() { - let _guard = home_guard("/nonexistent-home-dir"); - let home = AbsolutePathBuf::new(std::path::PathBuf::from(r#"/tmp/vp "home\with spaces""#)) - .unwrap(); - - let content = render_env_content(EnvShell::Nu, &home); - - assert!( - content.contains( - r#"$env.VP_HOME = ("/tmp/vp \"home\\with spaces\"" | path expand --no-symlink)"# - ), - "env.nu should escape VP_HOME for a Nushell string literal, got: {content}" - ); - assert!( - content.contains(r#"prepend "/tmp/vp \"home\\with spaces\"/bin")"#), - "env.nu should escape the bin path for a Nushell string literal, got: {content}" + let home = std::path::PathBuf::from(r#"/tmp/vp "home\with spaces""#); + vp_shared::EnvConfig::with_vars( + [ + (vp_shared::env_vars::VP_HOME, home.as_os_str()), + ("HOME", std::ffi::OsStr::new("/nonexistent-home-dir")), + ("USERPROFILE", std::ffi::OsStr::new("/nonexistent-home-dir")), + ], + |_| { + let config = vp_shared::EnvConfig::get(); + let content = render_env_content(EnvShell::Nu, &config); + + assert!( + content.contains(r#"prepend "/tmp/vp \"home\\with spaces\"/bin""#), + "env.nu should escape the bin path for a Nushell string literal, got: {content}" + ); + }, ); } @@ -987,9 +1065,8 @@ mod tests { async fn test_create_env_files_nu_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - create_env_files(&home).await.unwrap(); + vp_shared::EnvConfig::with_vars_async(test_env_vars(temp_dir.path(), temp_dir.path()), |_| async { + create_env_files().await.unwrap(); let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); assert!( @@ -1009,281 +1086,325 @@ mod tests { "env.nu should use dynamic Fish completion delegation" ); assert!(nu_content.contains("load-env"), "env.nu should use load-env to apply exports"); + }) + .await; } #[tokio::test] async fn test_create_env_files_replaces_placeholder_with_home_relative_path() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().join("vp_home")).unwrap(); - let _guard = home_guard(temp_dir.path()); - tokio::fs::create_dir_all(&home).await.unwrap(); - - create_env_files(&home).await.unwrap(); - - let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); - let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); - let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); - let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); - - // Placeholder should be fully replaced - assert!( - !env_content.contains("__VP_BIN__"), - "env file should not contain __VP_BIN__ placeholder" - ); - assert!( - !fish_content.contains("__VP_BIN__"), - "env.fish file should not contain __VP_BIN__ placeholder" - ); - assert!( - !env_content.contains("__VP_HOME__") && !fish_content.contains("__VP_HOME__"), - "env files should not contain __VP_HOME__ placeholder" - ); - assert!( - !nu_content.contains("__VP_HOME__") && !ps1_content.contains("__VP_HOME_WIN__"), - "env files should not contain VP_HOME placeholders" - ); - - // Should use $HOME-relative path since install dir is under HOME - assert!( - env_content.contains("$HOME/vp_home/bin"), - "env file should reference $HOME/vp_home/bin, got: {env_content}" - ); - assert!( - fish_content.contains("$HOME/vp_home/bin"), - "env.fish file should reference $HOME/vp_home/bin, got: {fish_content}" - ); - assert!( - env_content.contains("export VP_HOME=\"$HOME/vp_home\""), - "env file should export VP_HOME, got: {env_content}" - ); - assert!( - fish_content.contains("set -gx VP_HOME \"$HOME/vp_home\""), - "env.fish file should export VP_HOME, got: {fish_content}" - ); - assert!( - nu_content.contains("$env.VP_HOME = (\"~/vp_home\" | path expand --no-symlink)"), - "env.nu file should set home-relative VP_HOME, got: {nu_content}" - ); - assert!( - nu_content.contains("~/vp_home/bin"), - "env.nu file should reference ~/vp_home/bin, got: {nu_content}" - ); - - let expected_home = home.as_path().display().to_string(); - assert!( - ps1_content.contains(&format!("$env:VP_HOME = \"{expected_home}\"")), - "env.ps1 file should set VP_HOME, got: {ps1_content}" - ); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(home.as_path(), temp_dir.path()), + |_| async { + tokio::fs::create_dir_all(&home).await.unwrap(); + + create_env_files().await.unwrap(); + + let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); + let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); + + // Placeholder should be fully replaced + assert!( + !env_content.contains("__VP_BIN__"), + "env file should not contain __VP_BIN__ placeholder" + ); + assert!( + !fish_content.contains("__VP_BIN__"), + "env.fish file should not contain __VP_BIN__ placeholder" + ); + assert!( + !env_content.contains("__ENV_EXPORTS__") + && !fish_content.contains("__ENV_EXPORTS__"), + "env files should not contain __ENV_EXPORTS__ placeholder" + ); + assert!( + !nu_content.contains("__ENV_EXPORTS__") + && !ps1_content.contains("__ENV_EXPORTS__"), + "env files should not contain VP_HOME placeholders" + ); + + // Should use $HOME-relative path since install dir is under HOME + assert!( + env_content.contains("$HOME/vp_home/bin"), + "env file should reference $HOME/vp_home/bin, got: {env_content}" + ); + assert!( + fish_content.contains("$HOME/vp_home/bin"), + "env.fish file should reference $HOME/vp_home/bin, got: {fish_content}" + ); + assert!( + nu_content.contains("~/vp_home/bin"), + "env.nu file should reference ~/vp_home/bin, got: {nu_content}" + ); + }, + ) + .await; } #[tokio::test] async fn test_create_env_files_uses_absolute_path_when_not_under_home() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Set user_home to a different path so install dir is NOT under HOME - let _guard = home_guard("/nonexistent-home-dir"); - - create_env_files(&home).await.unwrap(); - - let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); - let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); - - // Should use absolute path since install dir is not under HOME - let expected_bin = home.join("bin"); - let expected_str = expected_bin.as_path().display().to_string().replace('\\', "/"); - let expected_home = home.as_path().display().to_string().replace('\\', "/"); - assert!( - env_content.contains(&expected_str), - "env file should use absolute path {expected_str}, got: {env_content}" - ); - assert!( - fish_content.contains(&expected_str), - "env.fish file should use absolute path {expected_str}, got: {fish_content}" - ); - assert!( - env_content.contains(&format!("export VP_HOME=\"{expected_home}\"")), - "env file should export absolute VP_HOME {expected_home}, got: {env_content}" - ); - assert!( - fish_content.contains(&format!("set -gx VP_HOME \"{expected_home}\"")), - "env.fish file should export absolute VP_HOME {expected_home}, got: {fish_content}" - ); - - // Should NOT use $HOME-relative path - assert!(!env_content.contains("$HOME/bin"), "env file should not reference $HOME/bin"); + // Set user_home to a different path so install dir is NOT under HOME. + // A second tempdir keeps it absolute on every platform — Windows + // rejects root-relative fakes like "/nonexistent-home-dir", and the + // home fallback can't save a test that pins both HOME and + // USERPROFILE. + let other_home = TempDir::new().unwrap(); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(home.as_path(), other_home.path()), + |_| async { + create_env_files().await.unwrap(); + + let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + + // Should use absolute path since install dir is not under HOME + let expected_bin = home.join("bin"); + let expected_str = expected_bin.as_path().display().to_string().replace('\\', "/"); + assert!( + env_content.contains(&expected_str), + "env file should use absolute path {expected_str}, got: {env_content}" + ); + assert!( + fish_content.contains(&expected_str), + "env.fish file should use absolute path {expected_str}, got: {fish_content}" + ); + + // Should NOT use $HOME-relative path + assert!( + !env_content.contains("$HOME/bin"), + "env file should not reference $HOME/bin" + ); + }, + ) + .await; } #[tokio::test] async fn test_create_env_files_posix_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - create_env_files(&home).await.unwrap(); - - let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); - - // Verify PATH guard structure: case statement checks for duplicate - assert!( - env_content.contains("case \":${PATH}:\" in"), - "env file should contain PATH guard case statement" - ); - assert!( - env_content.contains("*\":${__vp_bin}:\"*)"), - "env file should check for existing bin in PATH" - ); - // Verify it re-prepends to front when already present - assert!( - env_content.contains("export PATH=\"${__vp_bin}"), - "env file should re-prepend bin to front of PATH" - ); - // Verify simple prepend for new entry - assert!( - env_content.contains("export PATH=\"$__vp_bin:$PATH\""), - "env file should prepend bin to PATH for new entry" - ); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(temp_dir.path(), temp_dir.path()), + |_| async { + create_env_files().await.unwrap(); + + let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + + // Verify PATH guard structure: case statement checks for duplicate + assert!( + env_content.contains("case \":${PATH}:\" in"), + "env file should contain PATH guard case statement" + ); + assert!( + env_content.contains("*\":${__vp_bin}:\"*)"), + "env file should check for existing bin in PATH" + ); + // Verify it re-prepends to front when already present + assert!( + env_content.contains("export PATH=\"${__vp_bin}"), + "env file should re-prepend bin to front of PATH" + ); + // Verify simple prepend for new entry + assert!( + env_content.contains("export PATH=\"$__vp_bin:$PATH\""), + "env file should prepend bin to PATH for new entry" + ); + }, + ) + .await; } #[tokio::test] async fn test_create_env_files_fish_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - create_env_files(&home).await.unwrap(); - - let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); - - // Verify fish PATH guard: remove existing entry before prepending - assert!( - fish_content.contains("contains -i --"), - "env.fish should check for existing bin in PATH" - ); - assert!( - fish_content.contains("set -e PATH[$__vp_idx]"), - "env.fish should remove existing entry" - ); - assert!(fish_content.contains("set -gx PATH"), "env.fish should set PATH globally"); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(temp_dir.path(), temp_dir.path()), + |_| async { + create_env_files().await.unwrap(); + + let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + + // Verify fish PATH guard: remove existing entry before prepending + assert!( + fish_content.contains("contains -i --"), + "env.fish should check for existing bin in PATH" + ); + assert!( + fish_content.contains("set -e PATH[$__vp_idx]"), + "env.fish should remove existing entry" + ); + assert!(fish_content.contains("set -gx PATH"), "env.fish should set PATH globally"); + }, + ) + .await; } #[tokio::test] async fn test_create_env_files_is_idempotent() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - // Create env files twice - create_env_files(&home).await.unwrap(); - let first_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); - let first_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); - let first_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); - - create_env_files(&home).await.unwrap(); - let second_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); - let second_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); - let second_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); - - assert_eq!(first_env, second_env, "env file should be identical after second write"); - assert_eq!(first_fish, second_fish, "env.fish file should be identical after second write"); - assert_eq!(first_ps1, second_ps1, "env.ps1 file should be identical after second write"); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(temp_dir.path(), temp_dir.path()), + |_| async { + // Create env files twice + create_env_files().await.unwrap(); + let first_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + let first_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + let first_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); + + create_env_files().await.unwrap(); + let second_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + let second_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + let second_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); + + assert_eq!( + first_env, second_env, + "env file should be identical after second write" + ); + assert_eq!( + first_fish, second_fish, + "env.fish file should be identical after second write" + ); + assert_eq!( + first_ps1, second_ps1, + "env.ps1 file should be identical after second write" + ); + }, + ) + .await; } #[tokio::test] async fn test_create_env_files_posix_contains_vp_shell_function() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - create_env_files(&home).await.unwrap(); - - let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); - - // Verify vp() shell function wrapper is present - assert!(env_content.contains("vp() {"), "env file should contain vp() shell function"); - assert!( - env_content.contains("\"$1\" = \"env\""), - "env file should check for 'env' subcommand" - ); - assert!( - env_content.contains("\"$2\" = \"use\""), - "env file should check for 'use' subcommand" - ); - assert!(env_content.contains("eval \"$__vp_out\""), "env file should eval the output"); - assert!( - env_content.contains("command vp \"$@\""), - "env file should use 'command vp' for passthrough" - ); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(temp_dir.path(), temp_dir.path()), + |_| async { + create_env_files().await.unwrap(); + + let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + + // Verify vp() shell function wrapper is present + assert!( + env_content.contains("vp() {"), + "env file should contain vp() shell function" + ); + assert!( + env_content.contains("\"$1\" = \"env\""), + "env file should check for 'env' subcommand" + ); + assert!( + env_content.contains("\"$2\" = \"use\""), + "env file should check for 'use' subcommand" + ); + assert!( + env_content.contains("eval \"$__vp_out\""), + "env file should eval the output" + ); + assert!( + env_content.contains("command vp \"$@\""), + "env file should use 'command vp' for passthrough" + ); + }, + ) + .await; } #[tokio::test] async fn test_create_env_files_fish_contains_vp_function() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - create_env_files(&home).await.unwrap(); - - let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); - - // Verify fish vp function wrapper is present - assert!(fish_content.contains("function vp"), "env.fish file should contain vp function"); - assert!( - fish_content.contains("\"$argv[1]\" = \"env\""), - "env.fish should check for 'env' subcommand" - ); - assert!( - fish_content.contains("\"$argv[2]\" = \"use\""), - "env.fish should check for 'use' subcommand" - ); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(temp_dir.path(), temp_dir.path()), + |_| async { + create_env_files().await.unwrap(); + + let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + + // Verify fish vp function wrapper is present + assert!( + fish_content.contains("function vp"), + "env.fish file should contain vp function" + ); + assert!( + fish_content.contains("\"$argv[1]\" = \"env\""), + "env.fish should check for 'env' subcommand" + ); + assert!( + fish_content.contains("\"$argv[2]\" = \"use\""), + "env.fish should check for 'use' subcommand" + ); + }, + ) + .await; } #[tokio::test] async fn test_create_env_files_ps1_contains_vp_function() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - create_env_files(&home).await.unwrap(); - - let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); - - // Verify PowerShell function is present - assert!(ps1_content.contains("function vp {"), "env.ps1 should contain vp function"); - assert!(ps1_content.contains("Invoke-Expression"), "env.ps1 should use Invoke-Expression"); - // Should not contain placeholders - assert!( - !ps1_content.contains("__VP_BIN_WIN__"), - "env.ps1 should not contain __VP_BIN_WIN__ placeholder" - ); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(temp_dir.path(), temp_dir.path()), + |_| async { + create_env_files().await.unwrap(); + + let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); + + // Verify PowerShell function is present + assert!( + ps1_content.contains("function vp {"), + "env.ps1 should contain vp function" + ); + assert!( + ps1_content.contains("Invoke-Expression"), + "env.ps1 should use Invoke-Expression" + ); + // Should not contain placeholders + assert!( + !ps1_content.contains("__VP_BIN_WIN__"), + "env.ps1 should not contain __VP_BIN_WIN__ placeholder" + ); + }, + ) + .await; } #[tokio::test] #[cfg(windows)] - #[serial_test::serial] async fn test_execute_creates_cmd_wrapper_in_fresh_home() { let temp_dir = TempDir::new().unwrap(); let fresh_home = temp_dir.path().join("new-vite-plus"); - let _trampoline_guard = FakeTrampolineGuard::new(temp_dir.path()); - let _env_guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vite_plus_home: Some(fresh_home.clone()), - user_home: Some(temp_dir.path().to_path_buf()), - ..vp_shared::EnvConfig::for_test() - }); - - assert!(!fresh_home.exists(), "VP_HOME should not exist before initial setup"); - let status = execute(false, false).await.unwrap(); - - assert!(status.success(), "initial vp env setup should succeed"); - let bin_dir = AbsolutePathBuf::new(fresh_home.join("bin")).unwrap(); - let cmd_content = tokio::fs::read_to_string(bin_dir.join("vp-use.cmd")).await.unwrap(); - assert!( - cmd_content.contains("set VP_HOME=%~dp0..\r\nfor /f"), - "vp-use.cmd should set VP_HOME before invoking vp env use, got: {cmd_content}" - ); - assert!( - cmd_content.contains("%~dp0..\\current\\bin\\vp.exe env use %*"), - "vp-use.cmd should invoke the install-local vp.exe" - ); + let trampoline = write_fake_trampoline(temp_dir.path()); + vp_shared::EnvConfig::with_vars_async( + [ + (vp_shared::env_vars::VP_HOME, fresh_home.as_os_str()), + ("HOME", temp_dir.path().as_os_str()), + ("USERPROFILE", temp_dir.path().as_os_str()), + (vp_shared::env_vars::VP_TRAMPOLINE_PATH, trampoline.as_os_str()), + ], + |_| async { + assert!(!fresh_home.exists(), "install root should not exist before initial setup"); + let status = execute(false, false).await.unwrap(); + + assert!(status.success(), "initial vp env setup should succeed"); + let bin_dir = AbsolutePathBuf::new(fresh_home.join("bin")).unwrap(); + let cmd_content = + tokio::fs::read_to_string(bin_dir.join("vp-use.cmd")).await.unwrap(); + let expected_exe = fresh_home.join("current").join("bin").join("vp.exe"); + assert!( + cmd_content.contains(&format!("\"{}\" env use %*", expected_exe.display())), + "vp-use.cmd should invoke the install-local vp.exe, got: {cmd_content}" + ); + }, + ) + .await; } #[tokio::test] @@ -1291,16 +1412,21 @@ mod tests { async fn test_create_env_files_does_not_create_cmd_wrapper_on_unix() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - let bin_dir = home.join("bin"); - tokio::fs::create_dir_all(&bin_dir).await.unwrap(); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(temp_dir.path(), temp_dir.path()), + |_| async { + let bin_dir = home.join("bin"); + tokio::fs::create_dir_all(&bin_dir).await.unwrap(); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); - assert!( - !bin_dir.join("vp-use.cmd").as_path().exists(), - "vp-use.cmd should only be created on Windows" - ); + assert!( + !bin_dir.join("vp-use.cmd").as_path().exists(), + "vp-use.cmd should only be created on Windows" + ); + }, + ) + .await; } #[tokio::test] @@ -1308,22 +1434,22 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let fresh_home = temp_dir.path().join("new-vite-plus"); // Directory does NOT exist yet — execute should create it - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vite_plus_home: Some(fresh_home.clone()), - user_home: Some(temp_dir.path().to_path_buf()), - ..vp_shared::EnvConfig::for_test() - }); - - let status = execute(false, true).await.unwrap(); - assert!(status.success(), "execute --env-only should succeed"); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(&fresh_home, temp_dir.path()), + |_| async { + let status = execute(false, true).await.unwrap(); + assert!(status.success(), "execute --env-only should succeed"); - // Directory should now exist - assert!(fresh_home.exists(), "VP_HOME directory should be created"); + // Directory should now exist + assert!(fresh_home.exists(), "config directory should be created"); - // Env files should be written - assert!(fresh_home.join("env").exists(), "env file should be created"); - assert!(fresh_home.join("env.fish").exists(), "env.fish file should be created"); - assert!(fresh_home.join("env.ps1").exists(), "env.ps1 file should be created"); + // Env files should be written + assert!(fresh_home.join("env").exists(), "env file should be created"); + assert!(fresh_home.join("env.fish").exists(), "env.fish file should be created"); + assert!(fresh_home.join("env.ps1").exists(), "env.ps1 file should be created"); + }, + ) + .await; } #[tokio::test] @@ -1338,9 +1464,15 @@ mod tests { tokio::fs::create_dir_all(&bin_dir).await.unwrap(); tokio::fs::write(&standalone_vp, b"vp").await.unwrap(); - let target = resolve_unix_vp_shim_target(standalone_vp.as_path(), &bin_dir).await.unwrap(); - - assert_eq!(target, std::path::Path::new("../current/bin/vp")); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(home.as_path(), temp_dir.path()), + |_| async { + let target = + resolve_unix_vp_shim_target(standalone_vp.as_path(), &bin_dir).await.unwrap(); + assert_eq!(target, std::path::Path::new("../current/bin/vp")); + }, + ) + .await; } #[tokio::test] @@ -1357,9 +1489,14 @@ mod tests { tokio::fs::write(&standalone_vp, b"stale-vp").await.unwrap(); tokio::fs::write(&external_vp, b"active-vp").await.unwrap(); - let target = resolve_unix_vp_shim_target(&external_vp, &bin_dir).await.unwrap(); - - assert_eq!(target, external_vp); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(home.as_path(), temp_dir.path()), + |_| async { + let target = resolve_unix_vp_shim_target(&external_vp, &bin_dir).await.unwrap(); + assert_eq!(target, external_vp); + }, + ) + .await; } #[tokio::test] @@ -1373,9 +1510,47 @@ mod tests { tokio::fs::create_dir_all(&bin_dir).await.unwrap(); tokio::fs::write(&external_vp, b"vp").await.unwrap(); - let target = resolve_unix_vp_shim_target(&external_vp, &bin_dir).await.unwrap(); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(home.as_path(), temp_dir.path()), + |_| async { + let target = resolve_unix_vp_shim_target(&external_vp, &bin_dir).await.unwrap(); + assert_eq!(target, external_vp); + }, + ) + .await; + } + + #[tokio::test] + #[cfg(unix)] + async fn test_unix_vp_shim_target_split_bin_uses_data_current() { + let temp_dir = TempDir::new().unwrap(); + let user_home = temp_dir.path(); + let data = user_home.join(".local/share/vite-plus"); + let bin_dir = AbsolutePathBuf::new(user_home.join(".local/bin")).unwrap(); + let version_vp = data.join("0.1.0").join("bin").join("vp"); + let current = data.join("current"); - assert_eq!(target, external_vp); + tokio::fs::create_dir_all(version_vp.parent().unwrap()).await.unwrap(); + tokio::fs::create_dir_all(&bin_dir).await.unwrap(); + tokio::fs::write(&version_vp, b"vp").await.unwrap(); + tokio::fs::symlink("0.1.0", ¤t).await.unwrap(); + + vp_shared::EnvConfig::with_vars_async( + [ + (vp_shared::env_vars::VP_HOME, None), + (vp_shared::env_vars::VP_BIN_DIR, None), + (vp_shared::env_vars::VP_DATA_DIR, None), + (vp_shared::env_vars::XDG_BIN_HOME, None), + (vp_shared::env_vars::XDG_DATA_HOME, None), + ("HOME", Some(user_home.as_os_str())), + ("USERPROFILE", Some(user_home.as_os_str())), + ], + |_| async { + let target = resolve_unix_vp_shim_target(&version_vp, &bin_dir).await.unwrap(); + assert_eq!(target, data.join("current").join("bin").join("vp").as_path()); + }, + ) + .await; } #[tokio::test] @@ -1394,11 +1569,17 @@ mod tests { tokio::fs::write(&external_vp, b"active-vp").await.unwrap(); tokio::fs::symlink("../current/bin/vp", &node_shim).await.unwrap(); - let created = create_shim(&external_vp, &bin_dir, "node", false).await.unwrap(); - let target = tokio::fs::read_link(&node_shim).await.unwrap(); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(home.as_path(), temp_dir.path()), + |_| async { + let created = create_shim(&external_vp, &bin_dir, "node", false).await.unwrap(); + let target = tokio::fs::read_link(&node_shim).await.unwrap(); - assert!(created, "stale shims should be recreated"); - assert_eq!(target, external_vp); + assert!(created, "stale shims should be recreated"); + assert_eq!(target, external_vp); + }, + ) + .await; } #[tokio::test] @@ -1414,11 +1595,17 @@ mod tests { tokio::fs::write(&external_vp, b"vp").await.unwrap(); tokio::fs::symlink("../current/bin/vp", &node_shim).await.unwrap(); - let created = create_shim(&external_vp, &bin_dir, "node", false).await.unwrap(); - let target = tokio::fs::read_link(&node_shim).await.unwrap(); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(home.as_path(), temp_dir.path()), + |_| async { + let created = create_shim(&external_vp, &bin_dir, "node", false).await.unwrap(); + let target = tokio::fs::read_link(&node_shim).await.unwrap(); - assert!(created, "broken shims should be recreated"); - assert_eq!(target, external_vp); + assert!(created, "broken shims should be recreated"); + assert_eq!(target, external_vp); + }, + ) + .await; } #[tokio::test] @@ -1437,10 +1624,15 @@ mod tests { tokio::fs::write(&external_vp, b"active-vp").await.unwrap(); tokio::fs::symlink("../current/bin/vp", &vp_shim).await.unwrap(); - setup_vp_wrapper(&external_vp, &bin_dir, false).await.unwrap(); - let target = tokio::fs::read_link(&vp_shim).await.unwrap(); - - assert_eq!(target, external_vp); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(home.as_path(), temp_dir.path()), + |_| async { + setup_vp_wrapper(&external_vp, &bin_dir, false).await.unwrap(); + let target = tokio::fs::read_link(&vp_shim).await.unwrap(); + assert_eq!(target, external_vp); + }, + ) + .await; } #[tokio::test] @@ -1456,49 +1648,62 @@ mod tests { tokio::fs::write(&external_vp, b"vp").await.unwrap(); tokio::fs::symlink("../current/bin/vp", &vp_shim).await.unwrap(); - setup_vp_wrapper(&external_vp, &bin_dir, false).await.unwrap(); - let target = tokio::fs::read_link(&vp_shim).await.unwrap(); - - assert_eq!(target, external_vp); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(home.as_path(), temp_dir.path()), + |_| async { + setup_vp_wrapper(&external_vp, &bin_dir, false).await.unwrap(); + let target = tokio::fs::read_link(&vp_shim).await.unwrap(); + assert_eq!(target, external_vp); + }, + ) + .await; } #[tokio::test] async fn test_create_env_files_contains_dynamic_completion() { let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); - - create_env_files(&home).await.unwrap(); - - let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); - let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); - let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); - - assert!( - env_content.contains("VP_COMPLETE=bash") && env_content.contains("VP_COMPLETE=zsh"), - "env file should contain completion for bash and zsh" - ); - assert!( - fish_content.contains("VP_COMPLETE=fish"), - "env.fish file should contain completion for fish" - ); - assert!( - ps1_content.contains("VP_COMPLETE = \"powershell\""), - "env.ps1 file should contain completion for PowerShell" - ); - - assert!( - env_content.contains("compdef _vpr_complete vpr"), - "env should have vpr completion for zsh" - ); - assert!( - env_content.contains("eval '") && env_content.contains("_vpr_complete() {"), - "env should wrap zsh-specific code in eval" - ); - assert!(fish_content.contains("complete -c vpr"), "env.fish should have vpr completion"); - assert!( - ps1_content.contains("Register-ArgumentCompleter -Native -CommandName vpr"), - "env.ps1 should have vpr completion" - ); + vp_shared::EnvConfig::with_vars_async( + test_env_vars(temp_dir.path(), temp_dir.path()), + |_| async { + create_env_files().await.unwrap(); + + let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); + + assert!( + env_content.contains("VP_COMPLETE=bash") + && env_content.contains("VP_COMPLETE=zsh"), + "env file should contain completion for bash and zsh" + ); + assert!( + fish_content.contains("VP_COMPLETE=fish"), + "env.fish file should contain completion for fish" + ); + assert!( + ps1_content.contains("VP_COMPLETE = \"powershell\""), + "env.ps1 file should contain completion for PowerShell" + ); + + assert!( + env_content.contains("compdef _vpr_complete vpr"), + "env should have vpr completion for zsh" + ); + assert!( + env_content.contains("eval '") && env_content.contains("_vpr_complete() {"), + "env should wrap zsh-specific code in eval" + ); + assert!( + fish_content.contains("complete -c vpr"), + "env.fish should have vpr completion" + ); + assert!( + ps1_content.contains("Register-ArgumentCompleter -Native -CommandName vpr"), + "env.ps1 should have vpr completion" + ); + }, + ) + .await; } } diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 7b07cf94d8..5f78efc5ca 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -2,7 +2,7 @@ //! //! Outputs shell-appropriate commands to stdout that set (or unset) //! the `VP_NODE_VERSION` environment variable. The shell function -//! wrapper in `~/.vite-plus/env` evals this output to modify the current +//! wrapper in `/env` evals this output to modify the current //! shell session. //! //! All user-facing status messages go to stderr so they don't interfere @@ -57,11 +57,12 @@ fn can_use_session_file() -> bool { } fn print_windows_eval_wrapper_required() { + let env_ps1 = vp_shared::EnvConfig::get().dirs.config.join("env.ps1"); 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!(" . \"{}\"", env_ps1.as_path().display()); eprintln!("Then dot-source it now (or open a new PowerShell session) to load the wrapper."); } @@ -114,7 +115,8 @@ pub async fn execute( // Check if already active and suppress output if requested if silent_if_unchanged { - let current_env = vp_shared::EnvConfig::get().node_version.map(|v| v.trim().to_string()); + let current_env = + vp_shared::EnvConfig::get().node_version.as_deref().map(|v| v.trim().to_string()); let current = if !has_eval_wrapper() { current_env.or(config::read_session_version().await) } else { @@ -137,8 +139,12 @@ pub async fn execute( // Ensure version is installed (unless --no-install) if !no_install { - let home_dir = - vp_shared::get_vp_home()?.join("js_runtime").join("node").join(&resolved_version); + let home_dir = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(&resolved_version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); @@ -172,46 +178,61 @@ pub async fn execute( #[cfg(test)] mod tests { + use vp_shared::env_vars; + use super::*; #[test] fn test_detect_shell_vp_shell_powershell() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vp_shell: Some("powershell".into()), - ..vp_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - assert_eq!(shell, Shell::PowerShell); + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_SHELL, std::ffi::OsStr::new("powershell")), + ], + |_| { + let shell = detect_shell(); + assert_eq!(shell, Shell::PowerShell); + }, + ); } #[test] fn test_detect_shell_vp_shell_fish() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vp_shell: Some("fish".into()), - ..vp_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - assert_eq!(shell, Shell::Fish); + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_SHELL, std::ffi::OsStr::new("fish")), + ], + |_| { + let shell = detect_shell(); + assert_eq!(shell, Shell::Fish); + }, + ); } #[test] fn test_detect_shell_vp_shell_nu() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vp_shell: Some("nu".into()), - ..vp_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - assert_eq!(shell, Shell::NuShell); + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_SHELL, std::ffi::OsStr::new("nu")), + ], + |_| { + let shell = detect_shell(); + assert_eq!(shell, Shell::NuShell); + }, + ); } #[test] fn test_detect_shell_posix_default() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test()); - let shell = detect_shell(); - #[cfg(not(windows))] - assert_eq!(shell, Shell::Posix); - #[cfg(windows)] - assert_eq!(shell, Shell::Cmd); + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { + let shell = detect_shell(); + #[cfg(not(windows))] + assert_eq!(shell, Shell::Posix); + #[cfg(windows)] + assert_eq!(shell, Shell::Cmd); + }); } #[test] @@ -278,14 +299,20 @@ mod tests { 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 = vp_shared::EnvConfig::test_guard(vp_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()); + // CI runners export `CI` (GitHub Actions always does), and the + // direct-use guard keys off `is_ci` — the `None` pin unsets it to + // exercise the non-CI path. + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, Some(temp_dir.path())), ("CI", None)], + |_| async move { + 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()); + }, + ) + .await; } #[cfg(windows)] @@ -293,15 +320,17 @@ mod tests { 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 = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - is_ci: true, - ..vp_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")); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, temp_dir.path().as_os_str()), ("CI", std::ffi::OsStr::new("1"))], + |_| async { + 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")); + }, + ) + .await; } #[cfg(windows)] @@ -309,17 +338,22 @@ mod tests { 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 = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - env_use_eval_enable: true, - vp_shell: Some("powershell".into()), - ..vp_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()); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_dir.path().as_os_str()), + (env_vars::VP_ENV_USE_EVAL_ENABLE, std::ffi::OsStr::new("1")), + (env_vars::VP_SHELL, std::ffi::OsStr::new("powershell")), + ], + |_| async { + 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()); + }, + ) + .await; } } diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 3d5fbebb01..0165925ccc 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -202,8 +202,12 @@ async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result ../current/bin/vp", shim_path); + // Point at the active vp the same way `vp env setup` does so a split + // `` (e.g. ~/.local/bin) does not use `../current/bin/vp`. + let current_exe = std::env::current_exe()?; + let target = + crate::commands::env::setup::resolve_unix_vp_shim_target(¤t_exe, bin_dir).await?; + tokio::fs::symlink(&target, &shim_path).await?; + tracing::debug!("Created package shim symlink {:?} -> {:?}", shim_path, target); } #[cfg(windows)] @@ -1200,72 +1204,60 @@ async fn remove_package_shim(bin_dir: &vt_path::AbsolutePath, bin_name: &str) -> #[cfg(test)] mod tests { + use vp_shared::env_vars; + use super::*; use crate::commands::global::is_local_package_spec; - /// RAII guard that sets `VP_TRAMPOLINE_PATH` to a fake binary on creation - /// and clears it on drop. Ensures cleanup even on test panics. - #[cfg(windows)] - struct FakeTrampolineGuard; - - #[cfg(windows)] - impl FakeTrampolineGuard { - fn new(dir: &std::path::Path) -> Self { - let trampoline = dir.join("vp-shim.exe"); - std::fs::write(&trampoline, b"fake-trampoline").unwrap(); - unsafe { - std::env::set_var(vp_shared::env_vars::VP_TRAMPOLINE_PATH, &trampoline); - } - Self - } - } - - #[cfg(windows)] - impl Drop for FakeTrampolineGuard { - fn drop(&mut self) { - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_TRAMPOLINE_PATH); - } - } + /// Write a fake `vp-shim.exe` trampoline binary for shim-creation tests. + /// Pair with a `VP_TRAMPOLINE_PATH` pin via `EnvConfig::with_vars[_async]`. + fn write_fake_trampoline(dir: &std::path::Path) -> std::path::PathBuf { + let trampoline = dir.join("vp-shim.exe"); + std::fs::write(&trampoline, b"fake-trampoline").unwrap(); + trampoline } #[tokio::test] - #[cfg_attr(windows, serial_test::serial)] async fn test_create_package_shim_creates_bin_dir() { use tempfile::TempDir; use vt_path::AbsolutePathBuf; // Create a temp directory but don't create the bin subdirectory let temp_dir = TempDir::new().unwrap(); - #[cfg(windows)] - let _guard = FakeTrampolineGuard::new(temp_dir.path()); + let trampoline = write_fake_trampoline(temp_dir.path()); let bin_dir = temp_dir.path().join("bin"); let bin_dir = AbsolutePathBuf::new(bin_dir).unwrap(); - // Verify bin directory doesn't exist - assert!(!bin_dir.as_path().exists()); - - // Create a shim - this should create the bin directory - create_package_shim(&bin_dir, "test-shim", "test-package").await.unwrap(); - - // Verify bin directory was created - assert!(bin_dir.as_path().exists()); - - // Verify shim file was created (on Windows, shims have .exe extension) - // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata - #[cfg(unix)] - { - let shim_path = bin_dir.join("test-shim"); - assert!( - std::fs::symlink_metadata(shim_path.as_path()).is_ok(), - "Symlink shim should exist" - ); - } - #[cfg(windows)] - { - let shim_path = bin_dir.join("test-shim.exe"); - assert!(shim_path.as_path().exists()); - } + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_TRAMPOLINE_PATH, trampoline.as_os_str())], + |_| async { + // Verify bin directory doesn't exist + assert!(!bin_dir.as_path().exists()); + + // Create a shim - this should create the bin directory + create_package_shim(&bin_dir, "test-shim", "test-package").await.unwrap(); + + // Verify bin directory was created + assert!(bin_dir.as_path().exists()); + + // Verify shim file was created (on Windows, shims have .exe extension) + // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata + #[cfg(unix)] + { + let shim_path = bin_dir.join("test-shim"); + assert!( + std::fs::symlink_metadata(shim_path.as_path()).is_ok(), + "Symlink shim should exist" + ); + } + #[cfg(windows)] + { + let shim_path = bin_dir.join("test-shim.exe"); + assert!(shim_path.as_path().exists()); + } + }, + ) + .await; } #[tokio::test] @@ -1330,49 +1322,53 @@ mod tests { } #[tokio::test] - #[cfg_attr(windows, serial_test::serial)] async fn test_remove_package_shim_removes_shim() { use tempfile::TempDir; use vt_path::AbsolutePathBuf; let temp_dir = TempDir::new().unwrap(); - #[cfg(windows)] - let _guard = FakeTrampolineGuard::new(temp_dir.path()); + let trampoline = write_fake_trampoline(temp_dir.path()); let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Create a shim - create_package_shim(&bin_dir, "tsc", "typescript").await.unwrap(); - - // Verify the shim was created - // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata - #[cfg(unix)] - { - let shim_path = bin_dir.join("tsc"); - assert!( - std::fs::symlink_metadata(shim_path.as_path()).is_ok(), - "Shim should exist after creation" - ); - - // Remove the shim - remove_package_shim(&bin_dir, "tsc").await.unwrap(); - - // Verify the shim was removed - assert!( - std::fs::symlink_metadata(shim_path.as_path()).is_err(), - "Shim should be removed" - ); - } - #[cfg(windows)] - { - let shim_path = bin_dir.join("tsc.exe"); - assert!(shim_path.as_path().exists(), "Shim should exist after creation"); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_TRAMPOLINE_PATH, trampoline.as_os_str())], + |_| async { + // Create a shim + create_package_shim(&bin_dir, "tsc", "typescript").await.unwrap(); + + // Verify the shim was created + // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata + #[cfg(unix)] + { + let shim_path = bin_dir.join("tsc"); + assert!( + std::fs::symlink_metadata(shim_path.as_path()).is_ok(), + "Shim should exist after creation" + ); + + // Remove the shim + remove_package_shim(&bin_dir, "tsc").await.unwrap(); + + // Verify the shim was removed + assert!( + std::fs::symlink_metadata(shim_path.as_path()).is_err(), + "Shim should be removed" + ); + } + #[cfg(windows)] + { + let shim_path = bin_dir.join("tsc.exe"); + assert!(shim_path.as_path().exists(), "Shim should exist after creation"); - // Remove the shim - remove_package_shim(&bin_dir, "tsc").await.unwrap(); + // Remove the shim + remove_package_shim(&bin_dir, "tsc").await.unwrap(); - // Verify the shim was removed - assert!(!shim_path.as_path().exists(), "Shim should be removed"); - } + // Verify the shim was removed + assert!(!shim_path.as_path().exists(), "Shim should be removed"); + } + }, + ) + .await; } #[tokio::test] @@ -1388,92 +1384,105 @@ mod tests { } #[tokio::test] - #[cfg_attr(windows, serial_test::serial)] async fn test_uninstall_removes_shims_from_metadata() { use tempfile::TempDir; use vt_path::AbsolutePathBuf; let temp_dir = TempDir::new().unwrap(); let temp_path = temp_dir.path().to_path_buf(); - #[cfg(windows)] - let _trampoline_guard = FakeTrampolineGuard::new(&temp_path); - let _env_guard = - vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&temp_path)); - - // Create bin directory - let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); - tokio::fs::create_dir_all(&bin_dir).await.unwrap(); - - // Create shims for "tsc" and "tsserver" - create_package_shim(&bin_dir, "tsc", "typescript").await.unwrap(); - create_package_shim(&bin_dir, "tsserver", "typescript").await.unwrap(); - - // Verify shims exist - // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata - #[cfg(unix)] - { - assert!( - std::fs::symlink_metadata(bin_dir.join("tsc").as_path()).is_ok(), - "tsc shim should exist" - ); - assert!( - std::fs::symlink_metadata(bin_dir.join("tsserver").as_path()).is_ok(), - "tsserver shim should exist" - ); - } - #[cfg(windows)] - { - assert!(bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should exist"); - assert!( - bin_dir.join("tsserver.exe").as_path().exists(), - "tsserver.exe shim should exist" - ); - } - - // Create metadata with bins - let mut metadata = PackageMetadata::new( - "typescript".to_string(), - "5.9.3".to_string(), - "20.18.0".to_string(), - None, - vec!["tsc".to_string(), "tsserver".to_string()], - HashSet::from(["tsc".to_string(), "tsserver".to_string()]), - "npm".to_string(), - ); - metadata.install_id = "#123e4567-e89b-42d3-a456-426614174000".to_string(); - metadata.save().await.unwrap(); - - // Create identified package directory (needed for uninstall) - let package_dir = metadata.installation_dir().unwrap(); - tokio::fs::create_dir_all(&package_dir).await.unwrap(); - - // Verify metadata was saved - let loaded = PackageMetadata::load("typescript").await.unwrap(); - assert!(loaded.is_some(), "Metadata should be loaded"); - let loaded = loaded.unwrap(); - assert_eq!(loaded.bins, vec!["tsc", "tsserver"], "bins should match"); - - // Run uninstall - uninstall("typescript", false).await.unwrap(); + let trampoline = write_fake_trampoline(&temp_path); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_path.as_os_str()), + (env_vars::VP_TRAMPOLINE_PATH, trampoline.as_os_str()), + ], + |_| async { + // Create bin directory + let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); + tokio::fs::create_dir_all(&bin_dir).await.unwrap(); + + // Create shims for "tsc" and "tsserver" + create_package_shim(&bin_dir, "tsc", "typescript").await.unwrap(); + create_package_shim(&bin_dir, "tsserver", "typescript").await.unwrap(); + + // Verify shims exist + // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata + #[cfg(unix)] + { + assert!( + std::fs::symlink_metadata(bin_dir.join("tsc").as_path()).is_ok(), + "tsc shim should exist" + ); + assert!( + std::fs::symlink_metadata(bin_dir.join("tsserver").as_path()).is_ok(), + "tsserver shim should exist" + ); + } + #[cfg(windows)] + { + assert!( + bin_dir.join("tsc.exe").as_path().exists(), + "tsc.exe shim should exist" + ); + assert!( + bin_dir.join("tsserver.exe").as_path().exists(), + "tsserver.exe shim should exist" + ); + } - // Verify shims were removed - #[cfg(unix)] - { - assert!(!bin_dir.join("tsc").as_path().exists(), "tsc shim should be removed"); - assert!( - !bin_dir.join("tsserver").as_path().exists(), - "tsserver shim should be removed" - ); - } - #[cfg(windows)] - { - assert!(!bin_dir.join("tsc.exe").as_path().exists(), "tsc.exe shim should be removed"); - assert!( - !bin_dir.join("tsserver.exe").as_path().exists(), - "tsserver.exe shim should be removed" - ); - } - assert!(!package_dir.as_path().exists(), "identified package directory should be removed"); + // Create metadata with bins + let mut metadata = PackageMetadata::new( + "typescript".to_string(), + "5.9.3".to_string(), + "20.18.0".to_string(), + None, + vec!["tsc".to_string(), "tsserver".to_string()], + HashSet::from(["tsc".to_string(), "tsserver".to_string()]), + "npm".to_string(), + ); + metadata.install_id = "#123e4567-e89b-42d3-a456-426614174000".to_string(); + metadata.save().await.unwrap(); + + // Create identified package directory (needed for uninstall) + let package_dir = metadata.installation_dir().unwrap(); + tokio::fs::create_dir_all(&package_dir).await.unwrap(); + + // Verify metadata was saved + let loaded = PackageMetadata::load("typescript").await.unwrap(); + assert!(loaded.is_some(), "Metadata should be loaded"); + let loaded = loaded.unwrap(); + assert_eq!(loaded.bins, vec!["tsc", "tsserver"], "bins should match"); + + // Run uninstall + uninstall("typescript", false).await.unwrap(); + + // Verify shims were removed + #[cfg(unix)] + { + assert!(!bin_dir.join("tsc").as_path().exists(), "tsc shim should be removed"); + assert!( + !bin_dir.join("tsserver").as_path().exists(), + "tsserver shim should be removed" + ); + } + #[cfg(windows)] + { + assert!( + !bin_dir.join("tsc.exe").as_path().exists(), + "tsc.exe shim should be removed" + ); + assert!( + !bin_dir.join("tsserver.exe").as_path().exists(), + "tsserver.exe shim should be removed" + ); + } + assert!( + !package_dir.as_path().exists(), + "identified package directory should be removed" + ); + }, + ) + .await; } #[tokio::test] @@ -1481,121 +1490,126 @@ mod tests { use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - let package_name = "test-package"; + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + let package_name = "test-package"; + + let metadata = PackageMetadata::new( + package_name.to_string(), + "1.0.0".to_string(), + "20.0.0".to_string(), + None, + Vec::new(), + HashSet::new(), + "npm".to_string(), + ); + metadata.save().await.unwrap(); - let metadata = PackageMetadata::new( - package_name.to_string(), - "1.0.0".to_string(), - "20.0.0".to_string(), - None, - Vec::new(), - HashSet::new(), - "npm".to_string(), - ); - metadata.save().await.unwrap(); - - let legacy_dir = metadata.installation_dir().unwrap(); - let legacy_package_json = - get_node_modules_dir(&legacy_dir, package_name).join("package.json"); - tokio::fs::create_dir_all(legacy_package_json.parent().unwrap()).await.unwrap(); - tokio::fs::write(&legacy_package_json, "{}").await.unwrap(); - - // Model uninstall starting after npm populated the replacement but before metadata changed. - let replacement_dir = PackageMetadata::installation_dir_for( - package_name, - "123e4567-e89b-42d3-a456-426614174000", - ) - .unwrap(); - let replacement_package_json = - get_node_modules_dir(&replacement_dir, package_name).join("package.json"); - let replacement_lock = lock_install_dir(&replacement_dir).unwrap(); - tokio::fs::create_dir_all(replacement_package_json.parent().unwrap()).await.unwrap(); - tokio::fs::write(&replacement_package_json, "{}").await.unwrap(); - - uninstall(package_name, false).await.unwrap(); - - assert!(!legacy_package_json.as_path().exists()); - assert!(replacement_package_json.as_path().exists()); - assert!(install_dir_lock_path(&replacement_dir).unwrap().as_path().exists()); - drop(replacement_lock); + let legacy_dir = metadata.installation_dir().unwrap(); + let legacy_package_json = + get_node_modules_dir(&legacy_dir, package_name).join("package.json"); + tokio::fs::create_dir_all(legacy_package_json.parent().unwrap()).await.unwrap(); + tokio::fs::write(&legacy_package_json, "{}").await.unwrap(); + + // Model uninstall starting after npm populated the replacement but before metadata changed. + let replacement_dir = PackageMetadata::installation_dir_for( + package_name, + "123e4567-e89b-42d3-a456-426614174000", + ) + .unwrap(); + let replacement_package_json = + get_node_modules_dir(&replacement_dir, package_name).join("package.json"); + let replacement_lock = lock_install_dir(&replacement_dir).unwrap(); + tokio::fs::create_dir_all(replacement_package_json.parent().unwrap()).await.unwrap(); + tokio::fs::write(&replacement_package_json, "{}").await.unwrap(); + + uninstall(package_name, false).await.unwrap(); + + assert!(!legacy_package_json.as_path().exists()); + assert!(replacement_package_json.as_path().exists()); + assert!(install_dir_lock_path(&replacement_dir).unwrap().as_path().exists()); + drop(replacement_lock); + }) + .await; } #[tokio::test] - #[cfg_attr(windows, serial_test::serial)] async fn test_restore_previous_install_state_removes_partial_new_bins() { use tempfile::TempDir; use vt_path::AbsolutePathBuf; let temp_dir = TempDir::new().unwrap(); let temp_path = temp_dir.path().to_path_buf(); - #[cfg(windows)] - let _trampoline_guard = FakeTrampolineGuard::new(&temp_path); - let _env_guard = - vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&temp_path)); - let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); - - let mut previous_metadata = PackageMetadata::new( - "test-package".to_string(), - "1.0.0".to_string(), - "20.0.0".to_string(), - None, - vec!["keep".to_string(), "drop".to_string()], - HashSet::from(["keep".to_string(), "drop".to_string()]), - "npm".to_string(), - ); - previous_metadata.install_id = "#123e4567-e89b-42d3-a456-426614174000".to_string(); + let trampoline = write_fake_trampoline(&temp_path); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp_path.as_os_str()), + (env_vars::VP_TRAMPOLINE_PATH, trampoline.as_os_str()), + ], + |_| async { + let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); + + let mut previous_metadata = PackageMetadata::new( + "test-package".to_string(), + "1.0.0".to_string(), + "20.0.0".to_string(), + None, + vec!["keep".to_string(), "drop".to_string()], + HashSet::from(["keep".to_string(), "drop".to_string()]), + "npm".to_string(), + ); + previous_metadata.install_id = "#123e4567-e89b-42d3-a456-426614174000".to_string(); + + let mut new_metadata = PackageMetadata::new( + "test-package".to_string(), + "2.0.0".to_string(), + "22.0.0".to_string(), + None, + vec!["keep".to_string(), "new".to_string()], + HashSet::from(["keep".to_string(), "new".to_string()]), + "npm".to_string(), + ); + new_metadata.install_id = "#987e6543-e21b-42d3-a456-426614174000".to_string(); + new_metadata.save().await.unwrap(); - let mut new_metadata = PackageMetadata::new( - "test-package".to_string(), - "2.0.0".to_string(), - "22.0.0".to_string(), - None, - vec!["keep".to_string(), "new".to_string()], - HashSet::from(["keep".to_string(), "new".to_string()]), - "npm".to_string(), - ); - new_metadata.install_id = "#987e6543-e21b-42d3-a456-426614174000".to_string(); - new_metadata.save().await.unwrap(); - - for bin_name in ["keep", "new"] { - create_package_shim(&bin_dir, bin_name, "test-package").await.unwrap(); - BinConfig::new( - bin_name.to_string(), - "test-package".to_string(), - "2.0.0".to_string(), - "22.0.0".to_string(), - ) - .save() - .await - .unwrap(); - } + for bin_name in ["keep", "new"] { + create_package_shim(&bin_dir, bin_name, "test-package").await.unwrap(); + BinConfig::new( + bin_name.to_string(), + "test-package".to_string(), + "2.0.0".to_string(), + "22.0.0".to_string(), + ) + .save() + .await + .unwrap(); + } - restore_previous_install_state( - &bin_dir, - "test-package", - Some(&previous_metadata), - &new_metadata.bins, + restore_previous_install_state( + &bin_dir, + "test-package", + Some(&previous_metadata), + &new_metadata.bins, + ) + .await; + + let restored = PackageMetadata::load("test-package").await.unwrap().unwrap(); + assert_eq!(restored.install_id, previous_metadata.install_id); + assert_eq!(BinConfig::load("keep").await.unwrap().unwrap().version, "1.0.0"); + assert_eq!(BinConfig::load("drop").await.unwrap().unwrap().version, "1.0.0"); + assert!(BinConfig::load("new").await.unwrap().is_none()); + #[cfg(unix)] + { + assert!(std::fs::symlink_metadata(bin_dir.join("drop").as_path()).is_ok()); + assert!(std::fs::symlink_metadata(bin_dir.join("new").as_path()).is_err()); + } + #[cfg(windows)] + { + assert!(bin_dir.join("drop.exe").as_path().exists()); + assert!(!bin_dir.join("new.exe").as_path().exists()); + } + }, ) .await; - - let restored = PackageMetadata::load("test-package").await.unwrap().unwrap(); - assert_eq!(restored.install_id, previous_metadata.install_id); - assert_eq!(BinConfig::load("keep").await.unwrap().unwrap().version, "1.0.0"); - assert_eq!(BinConfig::load("drop").await.unwrap().unwrap().version, "1.0.0"); - assert!(BinConfig::load("new").await.unwrap().is_none()); - #[cfg(unix)] - { - assert!(std::fs::symlink_metadata(bin_dir.join("drop").as_path()).is_ok()); - assert!(std::fs::symlink_metadata(bin_dir.join("new").as_path()).is_err()); - } - #[cfg(windows)] - { - assert!(bin_dir.join("drop.exe").as_path().exists()); - assert!(!bin_dir.join("new.exe").as_path().exists()); - } } #[tokio::test] @@ -1603,35 +1617,37 @@ mod tests { use tempfile::TempDir; let temp_dir = TempDir::new().unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - temp_dir.path(), - )); - let package_name = "@scope/test-package"; - let current_id = "123e4567-e89b-42d3-a456-426614174000"; - let stale_id = "987e6543-e21b-42d3-a456-426614174000"; - let legacy_id = "#2753e02c-9319-456f-bc0f-23d6d7b6fba5"; - let package_dir = PackageMetadata::installation_dir_for(package_name, "").unwrap(); - let current_dir = PackageMetadata::installation_dir_for(package_name, current_id).unwrap(); - let stale_dir = PackageMetadata::installation_dir_for(package_name, stale_id).unwrap(); - let legacy_dir = PackageMetadata::installation_dir_for(package_name, legacy_id).unwrap(); - - tokio::fs::create_dir_all(¤t_dir).await.unwrap(); - tokio::fs::create_dir_all(&stale_dir).await.unwrap(); - tokio::fs::create_dir_all(&legacy_dir).await.unwrap(); - tokio::fs::write(install_dir_lock_path(¤t_dir).unwrap(), "").await.unwrap(); - let legacy_package_json = - get_node_modules_dir(&package_dir, package_name).join("package.json"); - tokio::fs::create_dir_all(legacy_package_json.parent().unwrap()).await.unwrap(); - tokio::fs::write(&legacy_package_json, "{}").await.unwrap(); - - cleanup_stale_installations(package_name, current_id).await; - - assert!(current_dir.as_path().is_dir()); - assert!(install_dir_lock_path(¤t_dir).unwrap().as_path().is_file()); - assert!(!stale_dir.as_path().exists()); - assert!(!legacy_dir.as_path().exists()); - assert!(!legacy_package_json.as_path().exists()); - assert!(!install_dir_lock_path(&package_dir).unwrap().as_path().exists()); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, temp_dir.path())], |_| async { + let package_name = "@scope/test-package"; + let current_id = "123e4567-e89b-42d3-a456-426614174000"; + let stale_id = "987e6543-e21b-42d3-a456-426614174000"; + let legacy_id = "#2753e02c-9319-456f-bc0f-23d6d7b6fba5"; + let package_dir = PackageMetadata::installation_dir_for(package_name, "").unwrap(); + let current_dir = + PackageMetadata::installation_dir_for(package_name, current_id).unwrap(); + let stale_dir = PackageMetadata::installation_dir_for(package_name, stale_id).unwrap(); + let legacy_dir = + PackageMetadata::installation_dir_for(package_name, legacy_id).unwrap(); + + tokio::fs::create_dir_all(¤t_dir).await.unwrap(); + tokio::fs::create_dir_all(&stale_dir).await.unwrap(); + tokio::fs::create_dir_all(&legacy_dir).await.unwrap(); + tokio::fs::write(install_dir_lock_path(¤t_dir).unwrap(), "").await.unwrap(); + let legacy_package_json = + get_node_modules_dir(&package_dir, package_name).join("package.json"); + tokio::fs::create_dir_all(legacy_package_json.parent().unwrap()).await.unwrap(); + tokio::fs::write(&legacy_package_json, "{}").await.unwrap(); + + cleanup_stale_installations(package_name, current_id).await; + + assert!(current_dir.as_path().is_dir()); + assert!(install_dir_lock_path(¤t_dir).unwrap().as_path().is_file()); + assert!(!stale_dir.as_path().exists()); + assert!(!legacy_dir.as_path().exists()); + assert!(!legacy_package_json.as_path().exists()); + assert!(!install_dir_lock_path(&package_dir).unwrap().as_path().exists()); + }) + .await; } #[test] diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 47b9a9d070..10197b5453 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -2,7 +2,6 @@ use std::{io::Write, process::ExitStatus}; -use directories::BaseDirs; use owo_colors::OwoColorize; use vp_shared::output; use vt_path::AbsolutePathBuf; @@ -10,8 +9,9 @@ use vt_str::Str; use crate::{ cli::exit_status, - commands::shell::{ - ALL_SHELL_PROFILES, ShellProfileKind, abbreviate_home_path, resolve_profile_path, + commands::{ + env::setup::{SHIM_TOOLS, shim_filename}, + shell::{ALL_SHELL_PROFILES, ShellProfileKind, abbreviate_home_path, resolve_profile_path}, }, error::Error, }; @@ -20,28 +20,40 @@ use crate::{ const VITE_PLUS_COMMENT: &str = "# Vite+ bin"; pub fn execute(yes: bool) -> Result { - let Ok(home_dir) = vp_shared::get_vp_home() else { - output::info("vite-plus is not installed (could not determine home directory)"); - return Ok(exit_status(0)); - }; + let env_config = vp_shared::EnvConfig::get(); + let dirs = &env_config.dirs; + + // The delete set is the vite-plus-owned roots, deduped: under a + // single-root mapping data/config/state are the same directory and cache + // sits inside it, so a naive per-category removal would delete the same + // path twice. `` is never removed wholesale — it may be a shared + // directory (e.g. `~/.local/bin`); only vp-owned shim files are removed + // from it. + let mut roots: Vec<&AbsolutePathBuf> = vec![&dirs.data, &dirs.cache, &dirs.config, &dirs.state]; + roots.sort_by(|a, b| a.as_path().cmp(b.as_path())); + roots.dedup(); + let mut delete_set: Vec<&AbsolutePathBuf> = Vec::new(); + for root in roots { + if !delete_set.iter().any(|kept| root.as_path().starts_with(kept.as_path())) { + delete_set.push(root); + } + } - if !home_dir.as_path().exists() { - output::info("vite-plus is not installed (directory does not exist)"); + if !delete_set.iter().any(|root| root.as_path().exists()) { + output::info("vite-plus is not installed (no installation directory exists)"); return Ok(exit_status(0)); } - // Resolve user home for shell profile paths - let base_dirs = BaseDirs::new() - .ok_or_else(|| Error::Other("Could not determine user home directory".into()))?; - let user_home = AbsolutePathBuf::new(base_dirs.home_dir().to_path_buf()).unwrap(); + // User home for shell profile paths + let user_home = &env_config.user_home; - let source_matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let source_matcher = VitePlusSourceMatcher::new(&dirs.config, user_home); // Collect shell profiles that contain Vite+ lines (content cached for cleaning) - let affected_profiles = collect_affected_profiles(&user_home, &source_matcher); + let affected_profiles = collect_affected_profiles(user_home, &source_matcher); // Confirmation - if !yes && !confirm_implode(&home_dir, &affected_profiles)? { + if !yes && !confirm_implode(&delete_set, &dirs.bin, &affected_profiles)? { return Ok(exit_status(0)); } @@ -51,16 +63,21 @@ pub fn execute(yes: bool) -> Result { // Remove Windows PATH entry #[cfg(windows)] { - let bin_path = home_dir.join("bin"); - if let Err(e) = remove_windows_path_entry(&bin_path) { + if let Err(e) = remove_windows_path_entry(&dirs.bin) { output::warn(&vt_str::format!("Failed to clean Windows PATH: {e}")); } else { output::success("Removed vite-plus from Windows PATH"); } } - // Remove the directory - remove_vite_plus_dir(&home_dir)?; + // Remove vp-owned shim files from the (potentially shared) bin directory, + // then the owned roots. + remove_shim_files(dirs); + for root in &delete_set { + if root.as_path().exists() { + remove_vite_plus_dir(root)?; + } + } output::raw(""); output::success("vite-plus has been removed from your system."); @@ -69,6 +86,49 @@ pub fn execute(yes: bool) -> Result { Ok(exit_status(0)) } +/// Remove the shim files vite-plus owns from the bin directory. +/// +/// The bin directory itself is never touched: it may be shared with other +/// tools (e.g. `~/.local/bin`). Under a single-root mapping this is a +/// harmless no-op subset of the root removal that follows. +fn remove_shim_files(dirs: &vp_shared::VpDirs) { + let mut names: Vec = SHIM_TOOLS.iter().map(|tool| shim_filename(tool)).collect(); + names.push(shim_filename("vp")); + #[cfg(windows)] + names.push("vp-use.cmd".to_string()); + + // Package shims recorded in `/bins/*.json` (best-effort; the data + // root itself is removed right after). + if let Ok(entries) = std::fs::read_dir(dirs.data.join("bins").as_path()) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") + && let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) + { + names.push(shim_filename(stem)); + } + } + } + + let mut removed = 0; + for name in names { + match std::fs::remove_file(dirs.bin.join(&name).as_path()) { + Ok(()) => removed += 1, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + output::warn(&vt_str::format!("Failed to remove shim {name}: {e}")); + } + } + } + if removed > 0 { + output::success(&vt_str::format!( + "Removed {removed} shim{} from {}", + if removed == 1 { "" } else { "s" }, + dirs.bin.as_path().display() + )); + } +} + /// A shell profile that contains Vite+ sourcing lines. struct AffectedProfile { /// Display name (e.g. ".zshrc", ".config/fish/conf.d/vite-plus.fish"). @@ -126,7 +186,8 @@ fn collect_affected_profiles( /// Show confirmation prompt and require the user to type "uninstall". /// Returns `Ok(true)` if confirmed, `Ok(false)` if aborted. fn confirm_implode( - home_dir: &AbsolutePathBuf, + delete_set: &[&AbsolutePathBuf], + bin_dir: &vt_path::AbsolutePath, affected_profiles: &[AffectedProfile], ) -> Result { if !vp_shared::is_stdin_terminal() { @@ -138,7 +199,11 @@ fn confirm_implode( output::warn("This will completely remove vite-plus from your system!"); output::raw(""); - output::raw(&vt_str::format!(" Directory: {}", home_dir.as_path().display())); + output::raw(" Directories to remove:"); + for root in delete_set { + output::raw(&vt_str::format!(" - {}", root.as_path().display())); + } + output::raw(&vt_str::format!(" Shim files to remove from: {}", bin_dir.as_path().display())); if !affected_profiles.is_empty() { output::raw(" Shell profiles to clean:"); for profile in affected_profiles { @@ -188,7 +253,7 @@ fn clean_affected_profiles( } } -/// Remove the ~/.vite-plus directory. +/// Remove a vite-plus root directory. fn remove_vite_plus_dir(home_dir: &AbsolutePathBuf) -> Result<(), Error> { #[cfg(unix)] { @@ -272,21 +337,21 @@ fn spawn_deferred_delete(trash_path: &std::path::Path) -> std::io::Result, } impl VitePlusSourceMatcher { - fn new(home_dir: &AbsolutePathBuf, user_home: &AbsolutePathBuf) -> Self { - let mut roots = vec![normalize_path_separators(&home_dir.as_path().display().to_string())]; + fn new(env_dir: &AbsolutePathBuf, user_home: &AbsolutePathBuf) -> Self { + let mut roots = vec![normalize_path_separators(&env_dir.as_path().display().to_string())]; - if let Ok(Some(suffix)) = home_dir.strip_prefix(user_home) { + if let Ok(Some(suffix)) = env_dir.strip_prefix(user_home) { // `RelativePathBuf` guarantees forward-slash separators. let suffix = vt_str::format!("{suffix}"); if suffix.is_empty() { @@ -382,7 +447,7 @@ fn remove_vite_plus_lines( Str::from(result) } -/// Remove `.vite-plus\bin` from the Windows User PATH via PowerShell. +/// 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(); @@ -403,8 +468,7 @@ fn remove_windows_path_entry(bin_path: &vt_path::AbsolutePath) -> std::io::Resul #[cfg(test)] mod tests { - #[cfg(not(windows))] - use serial_test::serial; + use vp_shared::env_vars; use super::*; @@ -606,7 +670,6 @@ mod tests { } #[test] - #[serial] #[cfg(not(windows))] fn test_collect_affected_profiles() { let temp_dir = tempfile::tempdir().unwrap(); @@ -615,25 +678,25 @@ mod tests { let matcher = VitePlusSourceMatcher::new(&home_dir, &home); // Clear env overrides so the test environment doesn't affect results - let _guard = ProfileEnvGuard::new(None, None, None); - - // Main profile with vite-plus line - std::fs::write(home.join(".zshrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); - // Unrelated profile (should be ignored) - std::fs::write(home.join(".bashrc"), "export PATH=/usr/bin\n").unwrap(); - // Snippet file with a matching Vite+ source line - let fish_dir = home.join(".config/fish/conf.d"); - std::fs::create_dir_all(&fish_dir).unwrap(); - std::fs::write(fish_dir.join("vite-plus.fish"), "source ~/.vite-plus/env.fish\n").unwrap(); - - let profiles = collect_affected_profiles(&home, &matcher); - assert_eq!(profiles.len(), 2); - assert!(matches!(&profiles[0].kind, AffectedProfileKind::Main { .. })); - assert!(matches!(&profiles[1].kind, AffectedProfileKind::Snippet)); + temp_env::with_vars_unset(["ZDOTDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME"], || { + // Main profile with vite-plus line + std::fs::write(home.join(".zshrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + // Unrelated profile (should be ignored) + std::fs::write(home.join(".bashrc"), "export PATH=/usr/bin\n").unwrap(); + // Snippet file with a matching Vite+ source line + let fish_dir = home.join(".config/fish/conf.d"); + std::fs::create_dir_all(&fish_dir).unwrap(); + std::fs::write(fish_dir.join("vite-plus.fish"), "source ~/.vite-plus/env.fish\n") + .unwrap(); + + let profiles = collect_affected_profiles(&home, &matcher); + assert_eq!(profiles.len(), 2); + assert!(matches!(&profiles[0].kind, AffectedProfileKind::Main { .. })); + assert!(matches!(&profiles[1].kind, AffectedProfileKind::Snippet)); + }); } #[test] - #[serial] #[cfg(not(windows))] fn test_collect_affected_profiles_custom_home_relative_path() { let temp_dir = tempfile::tempdir().unwrap(); @@ -641,79 +704,21 @@ mod tests { let home_dir = home.join("tools/vp"); let matcher = VitePlusSourceMatcher::new(&home_dir, &home); - let _guard = ProfileEnvGuard::new(None, None, None); - - std::fs::write(home.join(".zshrc"), ". \"$HOME/tools/vp/env\"\n").unwrap(); - std::fs::write(home.join(".bashrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); - let fish_dir = home.join(".config/fish/conf.d"); - std::fs::create_dir_all(&fish_dir).unwrap(); - std::fs::write(fish_dir.join("vite-plus.fish"), "source ~/.vite-plus/env.fish\n").unwrap(); - - let profiles = collect_affected_profiles(&home, &matcher); - assert_eq!(profiles.len(), 1); - assert!(matches!(&profiles[0].kind, AffectedProfileKind::Main { .. })); - } - - /// Guard that saves and restores profile-related env vars. - #[cfg(not(windows))] - struct ProfileEnvGuard { - original_zdotdir: Option, - original_xdg_config: Option, - original_xdg_data: Option, - } + temp_env::with_vars_unset(["ZDOTDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME"], || { + std::fs::write(home.join(".zshrc"), ". \"$HOME/tools/vp/env\"\n").unwrap(); + std::fs::write(home.join(".bashrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + let fish_dir = home.join(".config/fish/conf.d"); + std::fs::create_dir_all(&fish_dir).unwrap(); + std::fs::write(fish_dir.join("vite-plus.fish"), "source ~/.vite-plus/env.fish\n") + .unwrap(); - #[cfg(not(windows))] - impl ProfileEnvGuard { - fn new( - zdotdir: Option<&std::path::Path>, - xdg_config: Option<&std::path::Path>, - xdg_data: Option<&std::path::Path>, - ) -> Self { - let guard = Self { - original_zdotdir: std::env::var_os("ZDOTDIR"), - original_xdg_config: std::env::var_os("XDG_CONFIG_HOME"), - original_xdg_data: std::env::var_os("XDG_DATA_HOME"), - }; - unsafe { - match zdotdir { - Some(v) => std::env::set_var("ZDOTDIR", v), - None => std::env::remove_var("ZDOTDIR"), - } - match xdg_config { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - match xdg_data { - Some(v) => std::env::set_var("XDG_DATA_HOME", v), - None => std::env::remove_var("XDG_DATA_HOME"), - } - } - guard - } - } - - #[cfg(not(windows))] - impl Drop for ProfileEnvGuard { - fn drop(&mut self) { - unsafe { - match &self.original_zdotdir { - Some(v) => std::env::set_var("ZDOTDIR", v), - None => std::env::remove_var("ZDOTDIR"), - } - match &self.original_xdg_config { - Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - match &self.original_xdg_data { - Some(v) => std::env::set_var("XDG_DATA_HOME", v), - None => std::env::remove_var("XDG_DATA_HOME"), - } - } - } + let profiles = collect_affected_profiles(&home, &matcher); + assert_eq!(profiles.len(), 1); + assert!(matches!(&profiles[0].kind, AffectedProfileKind::Main { .. })); + }); } #[test] - #[serial] #[cfg(not(windows))] fn test_collect_affected_profiles_zdotdir() { let temp_dir = tempfile::tempdir().unwrap(); @@ -724,18 +729,25 @@ mod tests { std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); - let _guard = ProfileEnvGuard::new(Some(&zdotdir), None, None); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); - - let profiles = collect_affected_profiles(&home, &matcher); - let zdotdir_profiles: Vec<_> = - profiles.iter().filter(|p| p.path.as_path().starts_with(&zdotdir)).collect(); - assert_eq!(zdotdir_profiles.len(), 1); - assert!(matches!(&zdotdir_profiles[0].kind, AffectedProfileKind::Main { .. })); + temp_env::with_vars( + [ + ("ZDOTDIR", Some(zdotdir.as_os_str())), + ("XDG_CONFIG_HOME", None), + ("XDG_DATA_HOME", None), + ], + || { + let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + + let profiles = collect_affected_profiles(&home, &matcher); + let zdotdir_profiles: Vec<_> = + profiles.iter().filter(|p| p.path.as_path().starts_with(&zdotdir)).collect(); + assert_eq!(zdotdir_profiles.len(), 1); + assert!(matches!(&zdotdir_profiles[0].kind, AffectedProfileKind::Main { .. })); + }, + ); } #[test] - #[serial] #[cfg(not(windows))] fn test_collect_affected_profiles_xdg_config() { let temp_dir = tempfile::tempdir().unwrap(); @@ -748,18 +760,25 @@ mod tests { std::fs::write(fish_dir.join("vite-plus.fish"), "source \"$HOME/.vite-plus/env.fish\"\n") .unwrap(); - let _guard = ProfileEnvGuard::new(None, Some(&xdg_config), None); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); - - let profiles = collect_affected_profiles(&home, &matcher); - let xdg_profiles: Vec<_> = - profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_config)).collect(); - assert_eq!(xdg_profiles.len(), 1); - assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); + temp_env::with_vars( + [ + ("ZDOTDIR", None), + ("XDG_CONFIG_HOME", Some(xdg_config.as_os_str())), + ("XDG_DATA_HOME", None), + ], + || { + let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + + let profiles = collect_affected_profiles(&home, &matcher); + let xdg_profiles: Vec<_> = + profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_config)).collect(); + assert_eq!(xdg_profiles.len(), 1); + assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); + }, + ); } #[test] - #[serial] #[cfg(not(windows))] fn test_collect_affected_profiles_xdg_data() { let temp_dir = tempfile::tempdir().unwrap(); @@ -771,26 +790,32 @@ mod tests { std::fs::write(nushell_dir.join("vite-plus.nu"), "source '~/.vite-plus/env.nu'\n").unwrap(); - let _guard = ProfileEnvGuard::new(None, None, Some(&xdg_data)); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); - - let profiles = collect_affected_profiles(&home, &matcher); - let xdg_profiles: Vec<_> = - profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_data)).collect(); - assert_eq!(xdg_profiles.len(), 1); - assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); + temp_env::with_vars( + [ + ("ZDOTDIR", None), + ("XDG_CONFIG_HOME", None), + ("XDG_DATA_HOME", Some(xdg_data.as_os_str())), + ], + || { + let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + + let profiles = collect_affected_profiles(&home, &matcher); + let xdg_profiles: Vec<_> = + profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_data)).collect(); + assert_eq!(xdg_profiles.len(), 1); + assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); + }, + ); } #[test] fn test_execute_not_installed() { let temp_dir = tempfile::tempdir().unwrap(); let non_existent = temp_dir.path().join("does-not-exist"); - // Use thread-local test guard instead of mutating process-global env - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - &non_existent, - )); - let result = execute(true); - assert!(result.is_ok()); - assert!(result.unwrap().success()); + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, &non_existent)], |_| { + let result = execute(true); + assert!(result.is_ok()); + assert!(result.unwrap().success()); + }); } } diff --git a/crates/vp_global_cli/src/commands/shell.rs b/crates/vp_global_cli/src/commands/shell.rs index eaa0dd6246..3717672d08 100644 --- a/crates/vp_global_cli/src/commands/shell.rs +++ b/crates/vp_global_cli/src/commands/shell.rs @@ -250,6 +250,8 @@ pub(crate) fn resolve_profile_path( #[cfg(test)] mod tests { + use vp_shared::env_vars; + use super::*; #[test] @@ -282,69 +284,87 @@ mod tests { #[test] fn test_detect_shell_vp_shell_explicit() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vp_shell: Some("nu".into()), - ..vp_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - assert_eq!(shell, Shell::NuShell); + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_SHELL, std::ffi::OsStr::new("nu")), + ], + |_| { + let shell = detect_shell(); + assert_eq!(shell, Shell::NuShell); + }, + ); } #[test] fn test_detect_shell_vp_shell_case_insensitive() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vp_shell: Some("POWERSHELL".into()), - ..vp_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - assert_eq!(shell, Shell::PowerShell); + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_SHELL, std::ffi::OsStr::new("POWERSHELL")), + ], + |_| { + let shell = detect_shell(); + assert_eq!(shell, Shell::PowerShell); + }, + ); } #[test] fn test_detect_shell_vp_shell_pwsh_alias() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vp_shell: Some("pwsh".into()), - ..vp_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - assert_eq!(shell, Shell::PowerShell); + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_SHELL, std::ffi::OsStr::new("pwsh")), + ], + |_| { + let shell = detect_shell(); + assert_eq!(shell, Shell::PowerShell); + }, + ); } #[test] fn test_detect_shell_vp_shell_fish() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vp_shell: Some("fish".into()), - ..vp_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - assert_eq!(shell, Shell::Fish); + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_SHELL, std::ffi::OsStr::new("fish")), + ], + |_| { + let shell = detect_shell(); + assert_eq!(shell, Shell::Fish); + }, + ); } #[test] fn test_detect_shell_defaults_without_vp_shell() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vp_shell: None, - ..vp_shared::EnvConfig::for_test() + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { + let shell = detect_shell(); + if cfg!(windows) { + assert_eq!(shell, Shell::Cmd); + } else { + assert_eq!(shell, Shell::Posix); + } }); - let shell = detect_shell(); - if cfg!(windows) { - assert_eq!(shell, Shell::Cmd); - } else { - assert_eq!(shell, Shell::Posix); - } } #[test] fn test_detect_shell_invalid_vp_shell_falls_back_to_default() { - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vp_shell: Some("invalid".into()), - ..vp_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - if cfg!(windows) { - assert_eq!(shell, Shell::Cmd); - } else { - assert_eq!(shell, Shell::Posix); - } + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_SHELL, std::ffi::OsStr::new("invalid")), + ], + |_| { + let shell = detect_shell(); + if cfg!(windows) { + assert_eq!(shell, Shell::Cmd); + } else { + assert_eq!(shell, Shell::Posix); + } + }, + ); } } diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index c853e84881..946f0df958 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -11,7 +11,7 @@ use vp_setup::{install, integrity, platform, registry}; use vp_shared::output; use vt_path::AbsolutePathBuf; -use crate::{commands::env::config::get_vp_home, error::Error}; +use crate::error::Error; /// Options for the upgrade command. pub struct UpgradeOptions { @@ -34,11 +34,12 @@ pub struct UpgradeOptions { /// Execute the upgrade command. #[allow(clippy::print_stdout, clippy::print_stderr)] pub async fn execute(options: UpgradeOptions) -> Result { - let install_dir = get_vp_home()?; + let config = vp_shared::EnvConfig::get(); + let install_dir = &config.dirs.data; // Handle --rollback if options.rollback { - return execute_rollback(&install_dir, options.silent).await; + return execute_rollback(install_dir, options.silent).await; } // Step 1: Detect platform @@ -113,7 +114,7 @@ pub async fn execute(options: UpgradeOptions) -> Result { // version directory on Windows because the running `vp.exe` is locked. // Install into a unique semver build-metadata directory instead, then // repoint `current` after the install has completed. - let active_install_dir = install::read_current_version(&install_dir).await; + let active_install_dir = install::read_current_version(install_dir).await; let install_dir_name = install::target_install_dir_name( &resolved.version, active_install_dir.as_deref(), @@ -126,7 +127,7 @@ pub async fn execute(options: UpgradeOptions) -> Result { let result = install_platform_and_main( &platform_data, &version_dir, - &install_dir, + install_dir, &install_dir_name, &resolved.version, current_version, diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index e121dd71f7..26cff89b7e 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -228,8 +228,6 @@ mod tests { #[cfg(unix)] use std::{fs, path::Path}; - use serial_test::serial; - #[cfg(unix)] use super::{TOOL_SPECS, find_local_vite_plus, read_toolchain_manifest, resolve_tool_version}; use super::{detect_system_node_version, format_version}; @@ -245,17 +243,24 @@ mod tests { assert_eq!(format_version(None), "Not found"); } - // Run serially: the spawned `node` inherits this process's environment, and - // concurrent #[serial] tests mutate PATH/VP_HOME via std::env::set_var, - // which can make a vp shim on PATH resolve incorrectly mid-test. + // The spawned `node` inherits this process's environment, so hold + // temp_env's lock with PATH/VP_HOME re-pinned to their current values: + // no concurrent with_vars scope can swap them mid-test and make a vp shim + // on PATH resolve incorrectly. #[test] - #[serial] fn detect_system_node_version_returns_version() { - let version = detect_system_node_version(); - assert!(version.is_some(), "expected node to be installed"); - let version = version.unwrap(); - assert!(!version.starts_with('v'), "version should not have v prefix"); - assert!(version.contains('.'), "expected semver-like version, got: {version}"); + let path = std::env::var_os("PATH"); + let vp_home = std::env::var_os(vp_shared::env_vars::VP_HOME); + temp_env::with_vars( + [("PATH", path.as_deref()), (vp_shared::env_vars::VP_HOME, vp_home.as_deref())], + || { + let version = detect_system_node_version(); + assert!(version.is_some(), "expected node to be installed"); + let version = version.unwrap(); + assert!(!version.starts_with('v'), "version should not have v prefix"); + assert!(version.contains('.'), "expected semver-like version, got: {version}"); + }, + ); } #[cfg(unix)] diff --git a/crates/vp_global_cli/src/commands/vpx.rs b/crates/vp_global_cli/src/commands/vpx.rs index c6d6fe8d36..529d8bb3e4 100644 --- a/crates/vp_global_cli/src/commands/vpx.rs +++ b/crates/vp_global_cli/src/commands/vpx.rs @@ -359,7 +359,7 @@ pub fn parse_vpx_args(args: &[String]) -> (VpxFlags, Vec) { #[cfg(test)] mod tests { - use serial_test::serial; + use vp_shared::env_vars; use super::*; @@ -681,35 +681,20 @@ mod tests { } #[test] - #[serial] fn test_find_on_path_finds_tool() { - let original_path = std::env::var_os("PATH"); let temp = tempfile::tempdir().unwrap(); let dir = temp.path().join("bin_test"); std::fs::create_dir_all(&dir).unwrap(); create_fake_executable(&dir, "vpx-test-tool-abc"); - // SAFETY: serial test - unsafe { - std::env::set_var("PATH", &dir); - } - - let result = find_on_path("vpx-test-tool-abc"); - assert!(result.is_some()); - - unsafe { - match &original_path { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } + temp_env::with_var("PATH", Some(dir.as_os_str()), || { + let result = find_on_path("vpx-test-tool-abc"); + assert!(result.is_some()); + }); } #[test] - #[serial] fn test_find_on_path_excludes_vp_bin_dir() { - let original_path = std::env::var_os("PATH"); - let original_home = std::env::var_os("VP_HOME"); let temp = tempfile::tempdir().unwrap(); // Set up a fake vite-plus home with bin dir @@ -725,30 +710,18 @@ mod tests { let path = std::env::join_paths([fake_bin.as_path(), other_dir.as_path()]).unwrap(); - // SAFETY: serial test - unsafe { - std::env::set_var("PATH", &path); - std::env::set_var("VP_HOME", fake_home.as_os_str()); - } - - let result = find_on_path("vpx-excluded-tool"); - assert!(result.is_some()); - // Should find the one in other_dir, not fake_bin - assert!( - result.unwrap().as_path().starts_with(&other_dir), - "Should skip vite-plus bin dir and find tool in other directory" + vp_shared::EnvConfig::with_vars( + [("PATH", path.as_os_str()), (env_vars::VP_HOME, fake_home.as_os_str())], + |_| { + let result = find_on_path("vpx-excluded-tool"); + assert!(result.is_some()); + // Should find the one in other_dir, not fake_bin + assert!( + result.unwrap().as_path().starts_with(&other_dir), + "Should skip vite-plus bin dir and find tool in other directory" + ); + }, ); - - unsafe { - match &original_path { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - match &original_home { - Some(v) => std::env::set_var("VP_HOME", v), - None => std::env::remove_var("VP_HOME"), - } - } } // ========================================================================= @@ -756,9 +729,7 @@ mod tests { // ========================================================================= #[test] - #[serial] fn test_prepend_node_modules_bin_to_path() { - let original_path = std::env::var_os("PATH"); let temp = tempfile::tempdir().unwrap(); let temp_path = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); @@ -771,26 +742,16 @@ mod tests { let nested_bin = nested.join("node_modules").join(".bin"); std::fs::create_dir_all(&nested_bin).unwrap(); - // SAFETY: serial test - unsafe { - std::env::set_var("PATH", "/usr/bin"); - } + temp_env::with_var("PATH", Some(std::ffi::OsStr::new("/usr/bin")), || { + prepend_node_modules_bin_to_path(&nested); - prepend_node_modules_bin_to_path(&nested); + let new_path = std::env::var_os("PATH").unwrap(); + let paths: Vec<_> = std::env::split_paths(&new_path).collect(); - let new_path = std::env::var_os("PATH").unwrap(); - let paths: Vec<_> = std::env::split_paths(&new_path).collect(); - - // Nearest (nested) should be first - assert_eq!(paths[0], nested_bin.as_path().to_path_buf()); - // Root should be second - assert_eq!(paths[1], root_bin.as_path().to_path_buf()); - - unsafe { - match &original_path { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } + // Nearest (nested) should be first + assert_eq!(paths[0], nested_bin.as_path().to_path_buf()); + // Root should be second + assert_eq!(paths[1], root_bin.as_path().to_path_buf()); + }); } } diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 7273cca8c6..d9b65143df 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -87,7 +87,7 @@ impl JsExecutor { // 3. Auto-detect from binary location // JS scripts are at ../node_modules/vite-plus/dist relative to the binary directory - // e.g., ~/.vite-plus//bin/vp -> ~/.vite-plus//node_modules/vite-plus/dist/ + // e.g., //bin/vp -> //node_modules/vite-plus/dist/ let exe_path = std::env::current_exe().map_err(|_| Error::JsScriptsDirNotFound)?; // Resolve symlinks to get the real binary path (Unix only) // Skip on Windows to avoid path resolution issues @@ -193,6 +193,7 @@ impl JsExecutor { // 1–2. Session overrides: env var (from `vp env use`), then file let session_version = if let Some(session_version) = vp_shared::EnvConfig::get() .node_version + .as_deref() .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) { @@ -510,10 +511,17 @@ async fn find_system_node_runtime() -> Option { #[cfg(test)] mod tests { - use serial_test::serial; - use super::*; + /// Shared VP_HOME for tests that download a real Node.js runtime: pinning + /// isolates them from concurrent scopes, and one shared root keeps the + /// download cache warm across tests and runs. + fn shared_vp_home() -> std::path::PathBuf { + let dir = std::env::temp_dir().join("vp-global-cli-tests-vp-home"); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + #[test] fn test_local_vite_plus_is_older() { // Older local should escalate. @@ -602,32 +610,38 @@ mod tests { } #[tokio::test] - #[serial] async fn test_delegate_to_local_cli_prints_node_version() { - use std::io::Write; + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + use std::io::Write; - use tempfile::TempDir; + use tempfile::TempDir; - // Create a temporary directory for the scripts (used as fallback global dir) - let temp_dir = TempDir::new().unwrap(); - let scripts_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + // Create a temporary directory for the scripts (used as fallback global dir) + let temp_dir = TempDir::new().unwrap(); + let scripts_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Keep this delegation test independent of the moving latest-LTS alias. - // The unofficial musl index can advertise a release before its archive exists. - tokio::fs::write(temp_dir.path().join(".node-version"), "22.13.1\n").await.unwrap(); + // Keep this delegation test independent of the moving latest-LTS alias. + // The unofficial musl index can advertise a release before its archive exists. + tokio::fs::write(temp_dir.path().join(".node-version"), "22.13.1\n").await.unwrap(); - // Create a bin.js that prints process.version - let script_path = temp_dir.path().join("bin.js"); - let mut file = std::fs::File::create(&script_path).unwrap(); - writeln!(file, "console.log(process.version);").unwrap(); + // Create a bin.js that prints process.version + let script_path = temp_dir.path().join("bin.js"); + let mut file = std::fs::File::create(&script_path).unwrap(); + writeln!(file, "console.log(process.version);").unwrap(); - // Create executor with the temp scripts directory as global fallback - let mut executor = JsExecutor::new(Some(scripts_dir.clone())); + // Create executor with the temp scripts directory as global fallback + let mut executor = JsExecutor::new(Some(scripts_dir.clone())); - // Delegate — no local vite-plus will be found, so it falls back to global bin.js - let status = executor.delegate_to_local_cli(&scripts_dir, &[]).await.unwrap(); + // Delegate — no local vite-plus will be found, so it falls back to global bin.js + let status = executor.delegate_to_local_cli(&scripts_dir, &[]).await.unwrap(); - assert!(status.success(), "Script should execute successfully"); + assert!(status.success(), "Script should execute successfully"); + }, + ) + .await; } /// Regression for reverting the Node.js version enforcement (#1360): @@ -637,36 +651,39 @@ mod tests { #[tokio::test] async fn ensure_project_runtime_allows_older_unsupported_node() { use tempfile::TempDir; - use vp_shared::EnvConfig; - - // Isolate VP_HOME so config defaults to managed mode (no `vp env off`) - // and the runtime download cache stays inside the test sandbox. - let vp_home = TempDir::new().unwrap(); - let _guard = - EnvConfig::test_guard(EnvConfig::for_test_with_home(vp_home.path().to_path_buf())); - - // Pin Node 20.0.0 via `.node-version`: well below the declared floor and - // exactly the case the removed gate rejected. - let project = TempDir::new().unwrap(); - tokio::fs::write(project.path().join(".node-version"), "20.0.0\n").await.unwrap(); - let project_path = AbsolutePathBuf::new(project.path().to_path_buf()).unwrap(); - - let mut executor = JsExecutor::new(None); - let runtime = executor - .ensure_project_runtime(&project_path) - .await - .expect("older Node 20.0.0 must be usable, not blocked"); - - assert_eq!(runtime.version(), "20.0.0"); - - // The downloaded runtime must actually run. - let output = Command::new(runtime.get_binary_path().as_path()) - .arg("--version") - .output() - .await - .expect("node --version should run"); - assert!(output.status.success(), "node --version failed: {output:?}"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.trim().starts_with("v20.0.0"), "unexpected node version: {stdout}"); + + // Isolate the vp dirs so config defaults to managed mode (no `vp env off`) + // and the runtime download cache stays inside the test sandbox; the + // shared root keeps the downloaded runtime warm across tests and runs. + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + // Pin Node 20.0.0 via `.node-version`: well below the declared floor and + // exactly the case the removed gate rejected. + let project = TempDir::new().unwrap(); + tokio::fs::write(project.path().join(".node-version"), "20.0.0\n").await.unwrap(); + let project_path = AbsolutePathBuf::new(project.path().to_path_buf()).unwrap(); + + let mut executor = JsExecutor::new(None); + let runtime = executor + .ensure_project_runtime(&project_path) + .await + .expect("older Node 20.0.0 must be usable, not blocked"); + + assert_eq!(runtime.version(), "20.0.0"); + + // The downloaded runtime must actually run. + let output = Command::new(runtime.get_binary_path().as_path()) + .arg("--version") + .output() + .await + .expect("node --version should run"); + assert!(output.status.success(), "node --version failed: {output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.trim().starts_with("v20.0.0"), "unexpected node version: {stdout}"); + }, + ) + .await; } } diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index 14deffa4e1..56109292d2 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -336,6 +336,18 @@ fn print_unknown_argument_error(error: &clap::Error) -> bool { true } +fn dump_dirs_from_env_config() -> bool { + if env::var_os(vp_shared::env_vars::VP_DUMP_DIRS).as_deref() != Some(std::ffi::OsStr::new("1")) + { + return false; + } + let dirs = &vp_shared::EnvConfig::get().dirs; + println!("data\t{}", dirs.data.as_path().display()); + println!("bin\t{}", dirs.bin.as_path().display()); + println!("config\t{}", dirs.config.as_path().display()); + true +} + #[tokio::main] async fn main() -> ExitCode { vp_shared::ensure_blocking_stdio(); @@ -343,6 +355,10 @@ async fn main() -> ExitCode { // Initialize tracing vp_shared::init_tracing(); + if dump_dirs_from_env_config() { + return ExitCode::SUCCESS; + } + let mut args: Vec = std::env::args().collect(); // Replace bash completion script to fix completion for items containing ':' diff --git a/crates/vp_global_cli/src/shim/cache.rs b/crates/vp_global_cli/src/shim/cache.rs index 2f97fd4ee3..98ff40d63d 100644 --- a/crates/vp_global_cli/src/shim/cache.rs +++ b/crates/vp_global_cli/src/shim/cache.rs @@ -39,7 +39,7 @@ pub struct ResolveCacheEntry { pub is_range: bool, } -/// Resolution cache stored in VP_HOME/cache/resolve_cache.json. +/// Resolution cache stored in `/resolve_cache.json`. #[derive(Serialize, Deserialize, Debug)] pub struct ResolveCache { /// Cache format version for upgrade compatibility @@ -182,10 +182,9 @@ impl ResolveCache { } } -/// Get the cache file path. +/// Get the cache file path (`/resolve_cache.json`). pub fn get_cache_path() -> Option { - let home = crate::commands::env::config::get_vp_home().ok()?; - Some(home.join("cache").join("resolve_cache.json")) + Some(vp_shared::EnvConfig::get().dirs.cache.join("resolve_cache.json")) } /// Invalidate the entire resolve cache by deleting the cache file. @@ -211,6 +210,7 @@ pub fn now_timestamp() -> u64 { #[cfg(test)] mod tests { use tempfile::TempDir; + use vp_shared::env_vars; use super::*; @@ -344,52 +344,48 @@ mod tests { assert_eq!(cached_entry.unwrap().version, "20.20.0"); } - // Run serially: mutates VP_HOME env var which affects get_cache_path() #[test] - #[serial_test::serial] fn test_invalidate_cache_removes_file() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Set VP_HOME to temp dir so invalidate_cache() targets our test file - let cache_dir = temp_path.join("cache"); - std::fs::create_dir_all(&cache_dir).unwrap(); - let cache_file = cache_dir.join("resolve_cache.json"); - - // Create a cache with an entry and save it - let mut cache = ResolveCache::default(); - cache.insert( - &temp_path, - ResolveCacheEntry { - version: "20.18.0".to_string(), - source: ".node-version".to_string(), - project_root: None, - resolved_at: now_timestamp(), - version_file_mtime: 0, - source_path: None, - is_range: false, - }, - ); - cache.save(&cache_file); - assert!(std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist"); - - // Point VP_HOME to our temp dir and call invalidate_cache - unsafe { - std::env::set_var(vp_shared::env_vars::VP_HOME, temp_path.as_path()); - } - invalidate_cache(); - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } - - // Cache file should be removed - assert!( - std::fs::metadata(cache_file.as_path()).is_err(), - "Cache file should be removed after invalidation" - ); - - // Loading from removed file should return empty default cache - let loaded_cache = ResolveCache::load(&cache_file); - assert!(loaded_cache.get(&temp_path).is_none(), "Cache should be empty after invalidation"); + // Pin VP_HOME to the temp dir so invalidate_cache() targets our test file + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, temp_path.as_path())], |_| { + let cache_dir = temp_path.join("cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let cache_file = cache_dir.join("resolve_cache.json"); + + // Create a cache with an entry and save it + let mut cache = ResolveCache::default(); + cache.insert( + &temp_path, + ResolveCacheEntry { + version: "20.18.0".to_string(), + source: ".node-version".to_string(), + project_root: None, + resolved_at: now_timestamp(), + version_file_mtime: 0, + source_path: None, + is_range: false, + }, + ); + cache.save(&cache_file); + assert!(std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist"); + + invalidate_cache(); + + // Cache file should be removed + assert!( + std::fs::metadata(cache_file.as_path()).is_err(), + "Cache file should be removed after invalidation" + ); + + // Loading from removed file should return empty default cache + let loaded_cache = ResolveCache::load(&cache_file); + assert!( + loaded_cache.get(&temp_path).is_none(), + "Cache should be empty after invalidation" + ); + }); } } diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 0f40b95b65..df1b24a8a2 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -890,37 +890,37 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { if let Some(parsed) = parse_npm_global_install(args) { let exit_code = exec::spawn_tool(&tool_path, args); if exit_code == 0 { - if let Ok(home_dir) = vp_shared::get_vp_home() { - let node_dir = - home_dir.join("js_runtime").join("node").join(&*resolution.version); - let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); - check_npm_global_install_result( - &parsed.packages, - original_path.as_deref(), - &npm_prefix, - &node_dir, - &resolution.version, - ); - } + let node_dir = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(&*resolution.version); + let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); + check_npm_global_install_result( + &parsed.packages, + original_path.as_deref(), + &npm_prefix, + &node_dir, + &resolution.version, + ); } return exit_code; } if let Some(parsed) = parse_npm_global_uninstall(args) { // Collect bin names before uninstall (package.json will be gone after) - let context = if let Ok(home_dir) = vp_shared::get_vp_home() { - let node_dir = home_dir.join("js_runtime").join("node").join(&*resolution.version); - let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); - let bins = collect_bin_names_from_npm(&parsed.packages, &npm_prefix, &node_dir); - Some((bins, npm_prefix)) - } else { - None - }; + let node_dir = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(&*resolution.version); + let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); + let bin_names = collect_bin_names_from_npm(&parsed.packages, &npm_prefix, &node_dir); let exit_code = exec::spawn_tool(&tool_path, args); if exit_code == 0 { - if let Some((bin_names, npm_prefix)) = context { - remove_npm_global_uninstall_links(&bin_names, &npm_prefix); - } + remove_npm_global_uninstall_links(&bin_names, &npm_prefix); } return exit_code; } @@ -1189,7 +1189,7 @@ fn passthrough_to_system(tool: &str, args: &[String]) -> i32 { pub(crate) async fn resolve_with_cache(cwd: &AbsolutePathBuf) -> Result { // Fast-path: VP_NODE_VERSION env var set by `vp env use` // Skip all disk I/O for cache when session override is active - if let Some(env_version) = vp_shared::EnvConfig::get().node_version { + if let Some(env_version) = vp_shared::EnvConfig::get().node_version.as_deref() { let env_version = env_version.trim(); if !env_version.is_empty() { let provider = vp_js_runtime::NodeProvider::new(); @@ -1287,13 +1287,15 @@ async fn cached_project_source_still_current( && entry.source_path.as_deref() == Some(current_source_path.as_str())) } +/// Directory of the managed Node.js installation for `version` +/// (`/js_runtime/node/`). +fn node_install_dir(version: &str) -> AbsolutePathBuf { + vp_shared::EnvConfig::get().dirs.data.join("js_runtime").join("node").join(version) +} + /// Ensure Node.js is installed. pub(crate) async fn ensure_installed(version: &str) -> Result { - let home_dir = vp_shared::get_vp_home() - .map_err(|e| format!("Failed to get vite-plus home dir: {e}"))? - .join("js_runtime") - .join("node") - .join(version); + let home_dir = node_install_dir(version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); @@ -1318,11 +1320,7 @@ pub(crate) async fn ensure_installed(version: &str) -> Result Result { - let home_dir = vp_shared::get_vp_home() - .map_err(|e| format!("Failed to get vite-plus home dir: {e}"))? - .join("js_runtime") - .join("node") - .join(version); + let home_dir = node_install_dir(version); #[cfg(windows)] let tool_path = if tool == "node" { @@ -1420,7 +1418,6 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option, - original_bypass: Option, - } - - impl EnvGuard { - fn new() -> Self { - Self { - original_path: std::env::var_os("PATH"), - original_bypass: std::env::var_os(env_vars::VP_BYPASS), - } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - unsafe { - match &self.original_path { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - match &self.original_bypass { - Some(v) => std::env::set_var(env_vars::VP_BYPASS, v), - None => std::env::remove_var(env_vars::VP_BYPASS), - } - } - } - } - fn cache_entry(source: &str, source_path: Option<&AbsolutePathBuf>) -> ResolveCacheEntry { ResolveCacheEntry { version: "24.18.0".to_string(), @@ -1485,7 +1452,6 @@ mod tests { } #[tokio::test] - #[serial] async fn test_hash_pinned_modern_yarn_rechecks_cached_cli() { let temp = TempDir::new().unwrap(); let vp_home = AbsolutePathBuf::new(temp.path().join("vp-home")).unwrap(); @@ -1499,6 +1465,7 @@ mod tests { ) .unwrap(); + // VP_HOME pins to the root, so the cached install lands here. let bin_dir = vp_home.join("package_manager").join("yarn").join("4.17.1").join("yarn").join("bin"); std::fs::create_dir_all(&bin_dir).unwrap(); @@ -1507,45 +1474,48 @@ mod tests { std::fs::write(bin_dir.join("yarn.ps1"), "shim").unwrap(); std::fs::write(bin_dir.join("yarn.js"), "corrupt").unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - vp_home.as_path(), - )); - - let result = resolve_matching_package_manager_tool(&cwd, "yarn").await; - assert!( - matches!( - result, - Err(Error::Install(vp_error::Error::PackageManagerHashMismatch { .. })) - ), - "the global Yarn shim must reject a corrupted pinned cache: {result:?}" - ); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_path())], + |_| async { + let result = resolve_matching_package_manager_tool(&cwd, "yarn").await; + assert!( + matches!( + result, + Err(Error::Install(vp_error::Error::PackageManagerHashMismatch { .. })) + ), + "the global Yarn shim must reject a corrupted pinned cache: {result:?}" + ); + }, + ) + .await; } #[tokio::test] - #[serial] async fn test_resolve_with_cache_bypasses_stale_lts_after_dev_engines_is_added() { let temp = TempDir::new().unwrap(); let vp_home = AbsolutePathBuf::new(temp.path().join("vp-home")).unwrap(); let cwd = AbsolutePathBuf::new(temp.path().join("project")).unwrap(); std::fs::create_dir(&cwd).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( - vp_home.as_path(), - )); - - let mut cache = ResolveCache::default(); - cache.insert(&cwd, cache_entry("lts", None)); - cache.save(&cache::get_cache_path().unwrap()); - - std::fs::write( - cwd.join("package.json"), - r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_path())], + |_| async { + let mut cache = ResolveCache::default(); + cache.insert(&cwd, cache_entry("lts", None)); + cache.save(&cache::get_cache_path().unwrap()); + + std::fs::write( + cwd.join("package.json"), + r#"{"devEngines":{"runtime":{"name":"node","version":"22.22.0"}}}"#, + ) + .unwrap(); + + let resolved = resolve_with_cache(&cwd).await.unwrap(); + + assert_eq!(resolved.version, "22.22.0"); + assert_eq!(resolved.source, "devEngines.runtime"); + }, ) - .unwrap(); - - let resolved = resolve_with_cache(&cwd).await.unwrap(); - - assert_eq!(resolved.version, "22.22.0"); - assert_eq!(resolved.source, "devEngines.runtime"); + .await; } #[tokio::test] @@ -1553,43 +1523,37 @@ mod tests { let temp = TempDir::new().unwrap(); let cwd = AbsolutePathBuf::new(temp.path().join("project")).unwrap(); std::fs::create_dir(&cwd).unwrap(); - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vite_plus_home: Some(temp.path().join("vp-home")), - node_version: Some("22".into()), - ..vp_shared::EnvConfig::for_test() - }); - - let resolved = resolve_with_cache(&cwd).await.unwrap(); - - assert!(resolved.version.starts_with("22.")); - assert!(vp_js_runtime::NodeProvider::is_exact_version(&resolved.version)); - assert_eq!(resolved.source, config::VERSION_ENV_VAR); + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, temp.path().join("vp-home").as_os_str()), + (env_vars::VP_NODE_VERSION, std::ffi::OsStr::new("22")), + ], + |_| async { + let resolved = resolve_with_cache(&cwd).await.unwrap(); + + assert!(resolved.version.starts_with("22.")); + assert!(vp_js_runtime::NodeProvider::is_exact_version(&resolved.version)); + assert_eq!(resolved.source, config::VERSION_ENV_VAR); + }, + ) + .await; } #[test] - #[serial] fn test_find_system_tool_works_without_bypass() { - let _guard = EnvGuard::new(); let temp = TempDir::new().unwrap(); let dir = temp.path().join("bin_a"); std::fs::create_dir_all(&dir).unwrap(); create_fake_executable(&dir, "mytesttool"); - - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", &dir); - std::env::remove_var(env_vars::VP_BYPASS); - } - - let result = find_system_tool("mytesttool"); - assert!(result.is_some(), "Should find tool when no bypass is set"); - assert!(result.unwrap().as_path().starts_with(&dir)); + temp_env::with_vars([("PATH", Some(dir.as_os_str())), (env_vars::VP_BYPASS, None)], || { + let result = find_system_tool("mytesttool"); + assert!(result.is_some(), "Should find tool when no bypass is set"); + assert!(result.unwrap().as_path().starts_with(&dir)); + }); } #[test] - #[serial] fn test_find_system_tool_skips_single_bypass_path() { - let _guard = EnvGuard::new(); let temp = TempDir::new().unwrap(); let dir_a = temp.path().join("bin_a"); let dir_b = temp.path().join("bin_b"); @@ -1599,18 +1563,16 @@ mod tests { create_fake_executable(&dir_b, "mytesttool"); let path = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap(); - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", &path); - // Bypass dir_a — should skip it and find dir_b's tool - std::env::set_var(env_vars::VP_BYPASS, dir_a.as_os_str()); - } - - let result = find_system_tool("mytesttool"); - assert!(result.is_some(), "Should find tool in non-bypassed directory"); - assert!( - result.unwrap().as_path().starts_with(&dir_b), - "Should find tool in dir_b, not dir_a" + 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"); + assert!(result.is_some(), "Should find tool in non-bypassed directory"); + assert!( + result.unwrap().as_path().starts_with(&dir_b), + "Should find tool in dir_b, not dir_a" + ); + }, ); } @@ -1635,24 +1597,21 @@ mod tests { /// search continues to the real tool later in PATH. #[cfg(unix)] #[test] - #[serial] fn test_find_system_tool_skips_self_symlink_and_keeps_searching() { - let _guard = EnvGuard::new(); let temp = TempDir::new().unwrap(); let (dir_a, dir_b) = setup_self_symlink_dirs(&temp); let path = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap(); - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", &path); - std::env::remove_var(env_vars::VP_BYPASS); - } - - let result = find_system_tool("mytesttool"); - assert!(result.is_some(), "Should skip the self symlink and keep searching"); - assert!( - result.unwrap().as_path().starts_with(&dir_b), - "Should find the real tool in dir_b, not the self symlink in dir_a" + temp_env::with_vars( + [("PATH", Some(path.as_os_str())), (env_vars::VP_BYPASS, None)], + || { + let result = find_system_tool("mytesttool"); + assert!(result.is_some(), "Should skip the self symlink and keep searching"); + assert!( + result.unwrap().as_path().starts_with(&dir_b), + "Should find the real tool in dir_b, not the self symlink in dir_a" + ); + }, ); } @@ -1662,32 +1621,30 @@ mod tests { /// instead of reaching dir_b. #[cfg(unix)] #[test] - #[serial] fn test_find_system_tool_skips_self_symlink_in_relative_path_entry() { - let _guard = EnvGuard::new(); let temp = TempDir::new().unwrap(); let (_dir_a, dir_b) = setup_self_symlink_dirs(&temp); let path = std::env::join_paths([std::path::Path::new("bin_a"), dir_b.as_path()]).unwrap(); - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", &path); - std::env::remove_var(env_vars::VP_BYPASS); - } - - let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - let result = find_system_tool_in("mytesttool", &cwd); - assert!(result.is_some(), "Should skip the relative self-symlink entry and keep searching"); - assert!( - result.unwrap().as_path().starts_with(&dir_b), - "Should find the real tool in dir_b, not the self symlink in relative bin_a" + temp_env::with_vars( + [("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); + assert!( + result.is_some(), + "Should skip the relative self-symlink entry and keep searching" + ); + assert!( + result.unwrap().as_path().starts_with(&dir_b), + "Should find the real tool in dir_b, not the self symlink in relative bin_a" + ); + }, ); } #[test] - #[serial] fn test_find_system_tool_filters_multiple_bypass_paths() { - let _guard = EnvGuard::new(); let temp = TempDir::new().unwrap(); let dir_a = temp.path().join("bin_a"); let dir_b = temp.path().join("bin_b"); @@ -1702,38 +1659,32 @@ mod tests { let path = std::env::join_paths([dir_a.as_path(), dir_b.as_path(), dir_c.as_path()]).unwrap(); let bypass = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap(); - - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", &path); - std::env::set_var(env_vars::VP_BYPASS, &bypass); - } - - let result = find_system_tool("mytesttool"); - assert!(result.is_some(), "Should find tool in dir_c"); - assert!( - result.unwrap().as_path().starts_with(&dir_c), - "Should find tool in dir_c since dir_a and dir_b are bypassed" + 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"); + assert!(result.is_some(), "Should find tool in dir_c"); + assert!( + result.unwrap().as_path().starts_with(&dir_c), + "Should find tool in dir_c since dir_a and dir_b are bypassed" + ); + }, ); } #[test] - #[serial] fn test_find_system_tool_returns_none_when_all_paths_bypassed() { - let _guard = EnvGuard::new(); let temp = TempDir::new().unwrap(); let dir_a = temp.path().join("bin_a"); std::fs::create_dir_all(&dir_a).unwrap(); create_fake_executable(&dir_a, "mytesttool"); - - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", dir_a.as_os_str()); - std::env::set_var(env_vars::VP_BYPASS, dir_a.as_os_str()); - } - - let result = find_system_tool("mytesttool"); - assert!(result.is_none(), "Should return None when all paths are bypassed"); + 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"); + assert!(result.is_none(), "Should return None when all paths are bypassed"); + }, + ); } /// Simulates the SystemFirst loop prevention: Installation A sets VP_BYPASS @@ -1741,9 +1692,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] - #[serial] fn test_find_system_tool_cumulative_bypass_prevents_loop() { - let _guard = EnvGuard::new(); 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"); @@ -1769,26 +1718,22 @@ mod tests { // install_b_bin in the bypass as well (simulating cumulative append). let bypass = std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap(); - - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", &path); - std::env::set_var(env_vars::VP_BYPASS, &bypass); - } - - let result = find_system_tool("mytesttool"); - assert!(result.is_some(), "Should find tool in real_system directory"); - assert!( - result.unwrap().as_path().starts_with(&real_system_bin), - "Should find the real system tool, not any vite-plus installation" + 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"); + assert!(result.is_some(), "Should find tool in real_system directory"); + assert!( + result.unwrap().as_path().starts_with(&real_system_bin), + "Should find the real system tool, not any vite-plus installation" + ); + }, ); } /// When both installations are bypassed and no real system tool exists, should return None. #[test] - #[serial] fn test_find_system_tool_returns_none_with_no_real_system_tool() { - let _guard = EnvGuard::new(); 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"); @@ -1801,17 +1746,15 @@ mod tests { std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap(); let bypass = std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap(); - - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PATH", &path); - std::env::set_var(env_vars::VP_BYPASS, &bypass); - } - - let result = find_system_tool("mytesttool"); - assert!( - result.is_none(), - "Should return None when all dirs are bypassed and no real system tool exists" + 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"); + assert!( + result.is_none(), + "Should return None when all dirs are bypassed and no real system tool exists" + ); + }, ); } @@ -2192,33 +2135,28 @@ mod tests { // --- resolve_npm_prefix tests --- #[test] - #[serial] fn test_resolve_npm_prefix_relative() { let temp = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - // SAFETY: This test runs in isolation with serial_test - unsafe { - std::env::set_var("PWD", temp_path.as_path()); - } - - let parsed = NpmGlobalCommand { - packages: vec!["pkg".to_string()], - explicit_prefix: Some("./custom".to_string()), - }; - // Use a dummy npm_path and node_dir (should not be reached) - let dummy_dir = temp_path.join("dummy"); - let result = resolve_npm_prefix(&parsed, &dummy_dir, &dummy_dir); - // Should resolve relative to cwd, not fall back to get_npm_global_prefix - assert!( - result.as_path().ends_with("custom"), - "Expected path ending with 'custom', got: {}", - result.as_path().display() - ); + temp_env::with_var("PWD", Some(temp_path.as_path().as_os_str()), || { + let parsed = NpmGlobalCommand { + packages: vec!["pkg".to_string()], + explicit_prefix: Some("./custom".to_string()), + }; + // Use a dummy npm_path and node_dir (should not be reached) + let dummy_dir = temp_path.join("dummy"); + let result = resolve_npm_prefix(&parsed, &dummy_dir, &dummy_dir); + // Should resolve relative to cwd, not fall back to get_npm_global_prefix + assert!( + result.as_path().ends_with("custom"), + "Expected path ending with 'custom', got: {}", + result.as_path().display() + ); + }); } #[test] - #[serial] fn test_resolve_npm_prefix_absolute() { let temp = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); diff --git a/crates/vp_global_cli/src/upgrade_check.rs b/crates/vp_global_cli/src/upgrade_check.rs index 6cd8826d18..120c5e0cd5 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -1,7 +1,7 @@ //! Background upgrade check for the vp CLI. //! //! Periodically queries the npm registry for the latest version and caches the -//! result to `~/.vite-plus/.upgrade-check.json`. Displays a one-line notice on +//! result to `/.upgrade-check.json`. Displays a one-line notice on //! stderr when a newer version is available, at most once per 24 hours. use std::time::{SystemTime, UNIX_EPOCH}; @@ -72,17 +72,18 @@ async fn resolve_version_string() -> Option { } pub struct UpgradeCheckResult { - install_dir: vt_path::AbsolutePathBuf, + state_dir: vt_path::AbsolutePathBuf, cache: UpgradeCheckCache, } /// Returns an upgrade check result if a newer version is available and the user /// hasn't been prompted within the last 24 hours. Returns `None` otherwise. pub async fn check_for_update() -> Option { - let install_dir = vp_shared::get_vp_home().ok()?; + let config = vp_shared::EnvConfig::get(); + let state_dir = &config.dirs.state; let current_version = env!("CARGO_PKG_VERSION"); let now = now_secs(); - let mut cache = read_cache(&install_dir); + let mut cache = read_cache(state_dir); if should_check(cache.as_ref(), now) { let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); @@ -90,7 +91,7 @@ pub async fn check_for_update() -> Option { match resolve_version_string().await { Some(latest) => { let new_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &new_cache); + write_cache(state_dir, &new_cache); cache = Some(new_cache); } None => { @@ -98,7 +99,7 @@ pub async fn check_for_update() -> Option { // retrying on every command when the registry is unreachable. let latest = cache.as_ref().map(|c| c.latest.clone()).unwrap_or_default(); let failed_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &failed_cache); + write_cache(state_dir, &failed_cache); cache = Some(failed_cache); } } @@ -114,7 +115,7 @@ pub async fn check_for_update() -> Option { return None; } - Some(UpgradeCheckResult { install_dir, cache }) + Some(UpgradeCheckResult { state_dir: state_dir.clone(), cache }) } /// Print a one-line upgrade notice to stderr and record the prompt time. @@ -133,7 +134,7 @@ pub fn display_upgrade_notice(result: &UpgradeCheckResult) { let mut cache = result.cache.clone(); cache.prompted_at = now_secs(); - write_cache(&result.install_dir, &cache); + write_cache(&result.state_dir, &cache); } /// Whether the upgrade check should run for the given command args. @@ -162,8 +163,6 @@ pub fn should_run_for_command(args: &crate::cli::Args) -> bool { #[cfg(test)] mod tests { - use serial_test::serial; - use super::*; #[test] @@ -197,32 +196,10 @@ mod tests { } fn with_env_vars_cleared(f: F) { - let ci = std::env::var_os("CI"); - let test = std::env::var_os("VP_CLI_TEST"); - let no_check = std::env::var_os("VP_NO_UPDATE_CHECK"); - unsafe { - std::env::remove_var("CI"); - std::env::remove_var("VP_CLI_TEST"); - std::env::remove_var("VP_NO_UPDATE_CHECK"); - } - - f(); - - unsafe { - if let Some(v) = ci { - std::env::set_var("CI", v); - } - if let Some(v) = test { - std::env::set_var("VP_CLI_TEST", v); - } - if let Some(v) = no_check { - std::env::set_var("VP_NO_UPDATE_CHECK", v); - } - } + temp_env::with_vars_unset(["CI", "VP_CLI_TEST", "VP_NO_UPDATE_CHECK"], f); } #[test] - #[serial] fn should_check_returns_true_when_no_cache() { with_env_vars_cleared(|| { assert!(should_check(None, now_secs())); @@ -230,7 +207,6 @@ mod tests { } #[test] - #[serial] fn should_check_returns_false_when_cache_fresh() { with_env_vars_cleared(|| { let now = now_secs(); @@ -241,7 +217,6 @@ mod tests { } #[test] - #[serial] fn should_check_returns_true_when_cache_stale() { with_env_vars_cleared(|| { let now = now_secs(); @@ -256,13 +231,11 @@ mod tests { } #[test] - #[serial] fn should_check_returns_false_when_disabled() { with_env_vars_cleared(|| { - unsafe { - std::env::set_var("VP_NO_UPDATE_CHECK", "1"); - } - assert!(!should_check(None, now_secs())); + temp_env::with_var("VP_NO_UPDATE_CHECK", Some("1"), || { + assert!(!should_check(None, now_secs())); + }); }); } diff --git a/crates/vp_installer/Cargo.toml b/crates/vp_installer/Cargo.toml index 3f200e80f5..3c9f86b2ad 100644 --- a/crates/vp_installer/Cargo.toml +++ b/crates/vp_installer/Cargo.toml @@ -25,5 +25,9 @@ which = { workspace = true } [target.'cfg(windows)'.dependencies] winreg = { workspace = true } +[dev-dependencies] +tempfile = { workspace = true } +vp_shared = { workspace = true, features = ["test-utils"] } + [lints] workspace = true diff --git a/crates/vp_installer/src/cli.rs b/crates/vp_installer/src/cli.rs index 61f7e343f7..00adcede8f 100644 --- a/crates/vp_installer/src/cli.rs +++ b/crates/vp_installer/src/cli.rs @@ -22,7 +22,11 @@ pub struct Options { #[arg(long = "tag", default_value = "latest")] pub tag: String, - /// Custom installation directory (default: ~/.vite-plus) + /// Custom single-root installation directory (sets `VP_HOME`). + /// + /// Default: reuse an existing `~/.vite-plus` if present, otherwise the + /// platform data directory (`~/.local/share/vite-plus` on Unix, + /// `%LOCALAPPDATA%\vite-plus\data` on Windows). #[arg(long = "install-dir")] pub install_dir: Option, @@ -48,9 +52,8 @@ pub fn parse() -> Options { if opts.version.is_none() { opts.version = std::env::var("VP_VERSION").ok(); } - if opts.install_dir.is_none() { - opts.install_dir = std::env::var("VP_HOME").ok(); - } + // `VP_HOME` / `VP_*_DIR` / `XDG_*` are owned by [`vp_shared::EnvConfig`]. + // Do not promote them to `--install-dir` here. if opts.registry.is_none() { opts.registry = std::env::var("NPM_CONFIG_REGISTRY").ok(); } diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 28ce48bc35..9122acb7c4 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -28,6 +28,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; use vp_pm_cli::HttpClient; use vp_setup::{VP_BINARY_NAME, install, integrity, platform, registry}; +use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; /// Restrict DLL search to system32 only to prevent DLL hijacking @@ -105,48 +106,48 @@ fn main() { let opts = cli::parse(); - // Resolve install dir and set VP_HOME before starting the tokio runtime, - // so the unsafe set_var runs while we're still single-threaded. - let install_dir = match resolve_install_dir(&opts) { - Ok(dir) => dir, + // Resolve category roots (and pin VP_HOME only for --install-dir / VP_HOME) + // before starting the tokio runtime, so the unsafe set_var runs while + // we're still single-threaded. + let dirs = match prepare_dirs(&opts) { + Ok(dirs) => dirs, Err(e) => { print_error(&format!("Failed to resolve install directory: {e}")); std::process::exit(1); } }; - // Safety: called in main() before any threads are spawned. - unsafe { std::env::set_var("VP_HOME", install_dir.as_path()) }; let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap_or_else(|e| { print_error(&format!("Failed to create async runtime: {e}")); std::process::exit(1); }); - let code = rt.block_on(run(opts, install_dir)); + let code = rt.block_on(run(opts, dirs)); std::process::exit(code); } #[allow(clippy::print_stdout, clippy::print_stderr)] -async fn run(mut opts: cli::Options, install_dir: AbsolutePathBuf) -> i32 { - let install_dir_display = install_dir.as_path().to_string_lossy().to_string(); +async fn run(mut opts: cli::Options, dirs: VpDirs) -> i32 { + let data_dir_display = dirs.data.as_path().to_string_lossy().to_string(); + let bin_dir_display = dirs.bin.as_path().to_string_lossy().to_string(); // Pre-compute Node.js manager default before showing the menu, // so the user sees the resolved value and can override it. if !opts.no_node_manager { - opts.no_node_manager = !auto_detect_node_manager(&install_dir, !opts.yes); + opts.no_node_manager = !auto_detect_node_manager(&dirs.bin, !opts.yes); } if !opts.yes { - let proceed = show_interactive_menu(&mut opts, &install_dir_display); + let proceed = show_interactive_menu(&mut opts, &data_dir_display, &bin_dir_display); if !proceed { println!("Installation cancelled."); return 0; } } - let code = match do_install(&opts, &install_dir).await { + let code = match do_install(&opts, &dirs).await { Ok(()) => { - print_success(&opts, &install_dir_display); + print_success(&opts, &data_dir_display, &bin_dir_display); 0 } Err(e) => { @@ -165,10 +166,8 @@ async fn run(mut opts: cli::Options, install_dir: AbsolutePathBuf) -> i32 { } #[allow(clippy::print_stdout)] -async fn do_install( - opts: &cli::Options, - install_dir: &AbsolutePathBuf, -) -> Result<(), Box> { +async fn do_install(opts: &cli::Options, dirs: &VpDirs) -> Result<(), Box> { + let install_dir = &dirs.data; let platform_suffix = platform::detect_platform_suffix()?; if !opts.quiet { print_info(&format!("detected platform: {platform_suffix}")); @@ -257,7 +256,7 @@ async fn do_install( if !opts.quiet { print_info("setting up shims..."); } - if let Err(e) = setup_bin_shims(install_dir).await { + if let Err(e) = setup_bin_shims(dirs).await { print_warn(&format!("Shim setup failed (non-fatal): {e}")); } @@ -273,7 +272,7 @@ async fn do_install( } if !opts.no_modify_path { - let bin_dir_str = install_dir.join("bin").as_path().to_string_lossy().to_string(); + let bin_dir_str = dirs.bin.as_path().to_string_lossy().to_string(); if let Err(e) = modify_path(&bin_dir_str, opts.quiet) { print_warn(&format!("PATH modification failed (non-fatal): {e}")); } @@ -295,7 +294,7 @@ async fn do_install( /// 5. System node present, interactive → enable (matching install.ps1's default-Y prompt; /// user can disable via customize menu before proceeding) /// 6. System node present, silent → disable (don't silently take over) -fn auto_detect_node_manager(install_dir: &vt_path::AbsolutePath, interactive: bool) -> bool { +fn auto_detect_node_manager(bin_dir: &vt_path::AbsolutePath, interactive: bool) -> bool { // VP_NODE_MANAGER env var: only "yes" and "no" are recognized; // unrecognized values fall through to normal auto-detection // (matching install.ps1/install.sh behavior). @@ -309,7 +308,7 @@ fn auto_detect_node_manager(install_dir: &vt_path::AbsolutePath, interactive: bo } // Already managing Node (shims exist from a previous install) - let node_shim = install_dir.join("bin").join(if cfg!(windows) { "node.exe" } else { "node" }); + let node_shim = bin_dir.join(if cfg!(windows) { "node.exe" } else { "node" }); if node_shim.as_path().exists() { return true; } @@ -399,23 +398,21 @@ async fn replace_windows_exe( Ok(()) } -/// Set up the `bin/vp` entry point (trampoline copy on Windows, symlink on Unix). -async fn setup_bin_shims( - install_dir: &vt_path::AbsolutePath, -) -> Result<(), Box> { - let bin_dir = install_dir.join("bin"); - tokio::fs::create_dir_all(&bin_dir).await?; +/// Set up the `/vp` entry point (trampoline copy on Windows, symlink on Unix). +async fn setup_bin_shims(dirs: &VpDirs) -> Result<(), Box> { + let bin_dir = &dirs.bin; + tokio::fs::create_dir_all(bin_dir).await?; #[cfg(windows)] { - let shim_src = install_dir.join("current").join("bin").join("vp-shim.exe"); + let shim_src = dirs.data.join("current").join("bin").join("vp-shim.exe"); let shim_dst = bin_dir.join("vp.exe"); // Prefer vp-shim.exe (trampoline); fall back to vp.exe for pre-trampoline releases let src = if tokio::fs::try_exists(&shim_src).await.unwrap_or(false) { shim_src } else { - install_dir.join("current").join("bin").join("vp.exe") + dirs.data.join("current").join("bin").join("vp.exe") }; if tokio::fs::try_exists(&src).await.unwrap_or(false) { @@ -434,7 +431,16 @@ async fn setup_bin_shims( #[cfg(unix)] { - let link_target = std::path::PathBuf::from("../current/bin/vp"); + let current_vp = dirs.data.join("current").join("bin").join("vp"); + let link_target = match bin_dir.parent() { + Some(parent) + if parent.join("current").join("bin").join("vp").as_path() + == current_vp.as_path() => + { + std::path::PathBuf::from("../current/bin/vp") + } + _ => current_vp.as_path().to_path_buf(), + }; let link_path = bin_dir.join("vp"); let _ = tokio::fs::remove_file(&link_path).await; tokio::fs::symlink(&link_target, &link_path).await?; @@ -466,14 +472,21 @@ async fn download_with_progress( Ok(data) } -fn resolve_install_dir(opts: &cli::Options) -> Result> { +/// Resolve install category roots from [`vp_shared::EnvConfig`]. +/// +/// `--install-dir` is the only installer-owned override: it pins `VP_HOME` +/// so EnvConfig's existing chain produces a single-root layout. Directory +/// env vars (`VP_HOME`, `VP_*_DIR`, `XDG_*`) are never read here. +fn prepare_dirs(opts: &cli::Options) -> Result> { if let Some(ref dir) = opts.install_dir { let path = std::path::PathBuf::from(dir); let abs = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; - AbsolutePathBuf::new(abs).ok_or_else(|| "Invalid installation directory".into()) - } else { - Ok(vp_shared::get_vp_home()?) + let abs = AbsolutePathBuf::new(abs).ok_or("Invalid installation directory")?; + // Safety: called in main() before any threads are spawned (or under + // EnvConfig::with_vars in tests, which serializes env mutation). + unsafe { std::env::set_var("VP_HOME", abs.as_path()) }; } + Ok(vp_shared::EnvConfig::get().dirs.clone()) } #[allow(clippy::print_stdout)] @@ -497,17 +510,17 @@ fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box bool { +fn show_interactive_menu(opts: &mut cli::Options, data_dir: &str, bin_dir: &str) -> bool { loop { let version = opts.version.as_deref().unwrap_or(&opts.tag); - let bin_dir = format!("{install_dir}{sep}bin", sep = std::path::MAIN_SEPARATOR); println!(); println!(" {}", "Welcome to Vite+ Installer!".bold()); println!(); println!(" This will install the {} CLI and monorepo task runner.", "vp".cyan()); println!(); - println!(" Install directory: {}", install_dir.cyan()); + println!(" Data directory: {}", data_dir.cyan()); + println!(" Bin directory: {}", bin_dir.cyan()); println!( " PATH modification: {}", if opts.no_modify_path { @@ -594,7 +607,7 @@ fn read_input(prompt: &str) -> String { } #[allow(clippy::print_stdout)] -fn print_success(opts: &cli::Options, install_dir: &str) { +fn print_success(opts: &cli::Options, data_dir: &str, bin_dir: &str) { if opts.quiet { return; } @@ -606,8 +619,9 @@ fn print_success(opts: &cli::Options, install_dir: &str) { println!(); println!(" {}", "vp --help".cyan()); println!(); - println!(" Install directory: {install_dir}"); - println!(" Documentation: {}", "https://viteplus.dev/guide/"); + println!(" Data directory: {data_dir}"); + println!(" Bin directory: {bin_dir}"); + println!(" Documentation: {}", "https://viteplus.dev/guide/"); println!(); } @@ -628,3 +642,119 @@ fn print_error(msg: &str) { eprint!("{}", "error: ".red()); eprintln!("{msg}"); } + +#[cfg(test)] +mod tests { + use vp_shared::{EnvConfig, env_vars}; + + use super::*; + + fn opts(install_dir: Option) -> cli::Options { + cli::Options { + yes: true, + quiet: true, + version: None, + tag: "latest".into(), + install_dir, + registry: None, + no_node_manager: true, + no_modify_path: true, + } + } + + fn with_clean_home(home: &std::path::Path, f: impl FnOnce() -> R) -> R { + EnvConfig::with_vars( + [ + ("HOME", Some(home.as_os_str())), + ("USERPROFILE", Some(home.as_os_str())), + (env_vars::VP_HOME, None), + (env_vars::VP_BIN_DIR, None), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, None), + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, None), + (env_vars::XDG_CACHE_HOME, None), + (env_vars::XDG_CONFIG_HOME, None), + (env_vars::XDG_STATE_HOME, None), + ], + |_| f(), + ) + } + + #[test] + fn fresh_home_uses_resolved_split_dirs_and_does_not_set_vp_home() { + let tmp = tempfile::tempdir().unwrap(); + with_clean_home(tmp.path(), || { + let expected = EnvConfig::get().dirs.clone(); + let dirs = prepare_dirs(&opts(None)).unwrap(); + assert_eq!(dirs, expected); + assert!(std::env::var_os(env_vars::VP_HOME).is_none()); + assert_ne!( + dirs.bin.as_path(), + dirs.data.join("bin").as_path(), + "fresh install must not collapse bin under the data root" + ); + }); + } + + #[test] + fn existing_vite_plus_reuses_single_root_without_setting_vp_home() { + let tmp = tempfile::tempdir().unwrap(); + let legacy = tmp.path().join(".vite-plus"); + std::fs::create_dir_all(&legacy).unwrap(); + + with_clean_home(tmp.path(), || { + let dirs = prepare_dirs(&opts(None)).unwrap(); + assert_eq!(dirs.data.as_path(), legacy.as_path()); + assert_eq!(dirs.bin.as_path(), legacy.join("bin").as_path()); + assert_eq!(dirs.config.as_path(), legacy.as_path()); + assert_eq!(dirs.state.as_path(), legacy.as_path()); + assert_eq!(dirs.cache.as_path(), legacy.join("cache").as_path()); + assert!(std::env::var_os(env_vars::VP_HOME).is_none()); + }); + } + + #[test] + fn custom_install_dir_pins_vp_home_to_single_root() { + let tmp = tempfile::tempdir().unwrap(); + let custom = tmp.path().join("custom"); + std::fs::create_dir_all(&custom).unwrap(); + + with_clean_home(tmp.path(), || { + let dirs = prepare_dirs(&opts(Some(custom.to_string_lossy().into_owned()))).unwrap(); + assert_eq!(std::env::var_os(env_vars::VP_HOME).as_deref(), Some(custom.as_os_str())); + assert_eq!(dirs.data.as_path(), custom.as_path()); + assert_eq!(dirs.bin.as_path(), custom.join("bin").as_path()); + assert_eq!(dirs.config.as_path(), custom.as_path()); + }); + } + + #[test] + fn vp_data_dir_override_is_used_when_fresh() { + let tmp = tempfile::tempdir().unwrap(); + let data = tmp.path().join("data"); + std::fs::create_dir_all(&data).unwrap(); + + EnvConfig::with_vars( + [ + ("HOME", Some(tmp.path().as_os_str())), + ("USERPROFILE", Some(tmp.path().as_os_str())), + (env_vars::VP_HOME, None), + (env_vars::VP_BIN_DIR, None), + (env_vars::VP_DATA_DIR, Some(data.as_os_str())), + (env_vars::VP_CACHE_DIR, None), + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, None), + (env_vars::XDG_CACHE_HOME, None), + (env_vars::XDG_CONFIG_HOME, None), + (env_vars::XDG_STATE_HOME, None), + ], + |config| { + let dirs = prepare_dirs(&opts(None)).unwrap(); + assert_eq!(dirs.data.as_path(), data.as_path()); + assert_eq!(dirs, config.dirs); + assert!(std::env::var_os(env_vars::VP_HOME).is_none()); + }, + ); + } +} diff --git a/crates/vp_js_runtime/Cargo.toml b/crates/vp_js_runtime/Cargo.toml index 833c91a708..0c3f1eff43 100644 --- a/crates/vp_js_runtime/Cargo.toml +++ b/crates/vp_js_runtime/Cargo.toml @@ -37,6 +37,7 @@ reqwest = { workspace = true, features = ["stream", "rustls-no-provider"] } [dev-dependencies] tempfile = { workspace = true } +vp_shared = { workspace = true, features = ["test-utils"] } [lints] workspace = true diff --git a/crates/vp_js_runtime/src/cache.rs b/crates/vp_js_runtime/src/cache.rs index 9308a83d1e..ed2b250ae7 100644 --- a/crates/vp_js_runtime/src/cache.rs +++ b/crates/vp_js_runtime/src/cache.rs @@ -6,7 +6,7 @@ use crate::Error; /// Get the cache directory for JavaScript runtimes. /// -/// Returns `$VP_HOME/js_runtime`. +/// Returns `/js_runtime`. pub fn get_cache_dir() -> Result { - Ok(vp_shared::get_vp_home()?.join("js_runtime")) + Ok(vp_shared::EnvConfig::get().dirs.data.join("js_runtime")) } diff --git a/crates/vp_js_runtime/src/providers/node.rs b/crates/vp_js_runtime/src/providers/node.rs index 73b23daaeb..dc38b3b9e3 100644 --- a/crates/vp_js_runtime/src/providers/node.rs +++ b/crates/vp_js_runtime/src/providers/node.rs @@ -102,7 +102,7 @@ impl NodeProvider { /// /// # Arguments /// * `version_req` - A semver range requirement (e.g., "^20.18.0") - /// * `cache_dir` - The cache directory path (e.g., `~/.cache/vite-plus/js_runtime`) + /// * `cache_dir` - The cache directory path (e.g., `/js_runtime`) /// /// # Returns /// The highest LTS cached version that satisfies the requirement, or the @@ -563,7 +563,7 @@ fn calculate_expires_at(max_age: Option) -> u64 { /// Returns the value of `VP_NODE_DIST_MIRROR` environment variable if set, /// otherwise returns the default `https://nodejs.org/dist`. fn get_dist_url() -> Str { - vp_shared::EnvConfig::get().node_dist_mirror.map_or_else( + vp_shared::EnvConfig::get().node_dist_mirror.as_deref().map_or_else( || DEFAULT_NODE_DIST_URL.into(), |url| Str::from(url.trim_end_matches('/').to_string()), ) @@ -685,9 +685,20 @@ impl JsRuntimeProvider for NodeProvider { #[cfg(test)] mod tests { + use vp_shared::env_vars; + use super::*; use crate::platform::{Arch, Os}; + /// Shared VP_HOME for tests that fetch the real Node.js version index: + /// pinning isolates them from concurrent `with_vars` scopes, and one + /// shared root keeps the index cache warm across tests and runs. + fn shared_vp_home() -> std::path::PathBuf { + let dir = std::env::temp_dir().join("vp-js-runtime-tests-vp-home"); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + #[test] fn test_platform_string() { let provider = NodeProvider::new(); @@ -721,11 +732,12 @@ mod tests { let provider = NodeProvider::new(); let platform = Platform { os: Os::Linux, arch: Arch::X64 }; - // for_test() leaves node_dist_mirror unset, so this exercises the + // VP_NODE_DIST_MIRROR is left unset, so this exercises the // official (default) source where signature verification is required. - let info = vp_shared::EnvConfig::test_scope(vp_shared::EnvConfig::for_test(), || { - provider.get_download_info("22.13.1", platform) - }); + let info = + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { + provider.get_download_info("22.13.1", platform) + }); #[cfg(not(target_env = "musl"))] { @@ -774,12 +786,15 @@ mod tests { let provider = NodeProvider::new(); let platform = Platform { os: Os::Linux, arch: Arch::X64 }; - let info = vp_shared::EnvConfig::test_scope( - vp_shared::EnvConfig { - node_dist_mirror: Some("https://mirror.example/node".into()), - ..vp_shared::EnvConfig::for_test() - }, - || provider.get_download_info("22.13.1", platform), + let info = vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + ( + env_vars::VP_NODE_DIST_MIRROR, + std::ffi::OsStr::new("https://mirror.example/node"), + ), + ], + |_| provider.get_download_info("22.13.1", platform), ); if let HashVerification::ShasumsFile { url, signature } = &info.hash_verification { @@ -802,12 +817,15 @@ mod tests { let provider = NodeProvider::new(); let platform = Platform { os: Os::Linux, arch: Arch::X64 }; - let info = vp_shared::EnvConfig::test_scope( - vp_shared::EnvConfig { - node_dist_mirror: Some("https://nodejs.org/download/release".into()), - ..vp_shared::EnvConfig::for_test() - }, - || provider.get_download_info("22.13.1", platform), + let info = vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + ( + env_vars::VP_NODE_DIST_MIRROR, + std::ffi::OsStr::new("https://nodejs.org/download/release"), + ), + ], + |_| provider.get_download_info("22.13.1", platform), ); if let HashVerification::ShasumsFile { signature, .. } = &info.hash_verification { @@ -910,34 +928,30 @@ fedcba987654 node-v22.13.1-win-x64.zip"; #[test] fn test_get_dist_url_default() { - vp_shared::EnvConfig::test_scope(vp_shared::EnvConfig::for_test(), || { - assert_eq!(get_dist_url(), DEFAULT_NODE_DIST_URL); + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { + assert_eq!(get_dist_url(), DEFAULT_NODE_DIST_URL) }); } #[test] fn test_get_dist_url_with_mirror() { - vp_shared::EnvConfig::test_scope( - vp_shared::EnvConfig { - node_dist_mirror: Some("https://nodejs.org/dist".into()), - ..vp_shared::EnvConfig::for_test() - }, - || { - assert_eq!(get_dist_url(), "https://nodejs.org/dist"); - }, + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_NODE_DIST_MIRROR, std::ffi::OsStr::new("https://nodejs.org/dist")), + ], + |_| assert_eq!(get_dist_url(), "https://nodejs.org/dist"), ); } #[test] fn test_get_dist_url_trims_trailing_slash() { - vp_shared::EnvConfig::test_scope( - vp_shared::EnvConfig { - node_dist_mirror: Some("https://nodejs.org/dist/".into()), - ..vp_shared::EnvConfig::for_test() - }, - || { - assert_eq!(get_dist_url(), "https://nodejs.org/dist"); - }, + vp_shared::EnvConfig::with_vars( + [ + (env_vars::VP_HOME, std::env::temp_dir().as_os_str()), + (env_vars::VP_NODE_DIST_MIRROR, std::ffi::OsStr::new("https://nodejs.org/dist/")), + ], + |_| assert_eq!(get_dist_url(), "https://nodejs.org/dist"), ); } @@ -959,19 +973,26 @@ fedcba987654 node-v22.13.1-win-x64.zip"; #[tokio::test] async fn test_fetch_version_index() { - let provider = NodeProvider::new(); - let versions = provider.fetch_version_index().await.unwrap(); - - // Should have at least some versions - assert!(!versions.is_empty()); - - // First entry should be the latest version - let first = &versions[0]; - assert!(first.version.starts_with('v')); - - // Should contain some known versions - let has_v20 = versions.iter().any(|v| v.version.starts_with("v20.")); - assert!(has_v20, "Should contain Node.js v20.x versions"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let provider = NodeProvider::new(); + let versions = provider.fetch_version_index().await.unwrap(); + + // Should have at least some versions + assert!(!versions.is_empty()); + + // First entry should be the latest version + let first = &versions[0]; + assert!(first.version.starts_with('v')); + + // Should contain some known versions + let has_v20 = versions.iter().any(|v| v.version.starts_with("v20.")); + assert!(has_v20, "Should contain Node.js v20.x versions"); + }, + ) + .await; } #[test] @@ -1223,50 +1244,58 @@ fedcba987654 node-v22.13.1-win-x64.zip"; let temp_dir = TempDir::new().unwrap(); let cache_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let provider = NodeProvider::new(); - - // Initially, no cache exists - let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap(); - assert!(result.is_none()); - - // Create mock cached versions - let node_cache = cache_dir.join("node"); - tokio::fs::create_dir_all(&node_cache).await.unwrap(); - - // Create version directories with mock binary - let platform = Platform::current(); - let binary_path = provider.binary_relative_path(platform); - - for version in ["20.17.0", "20.18.0", "20.19.0", "21.0.0"] { - let version_dir = node_cache.join(version); - let binary_full_path = version_dir.join(&binary_path); - tokio::fs::create_dir_all(binary_full_path.parent().unwrap()).await.unwrap(); - tokio::fs::write(&binary_full_path, "mock binary").await.unwrap(); - } + let vp_home = shared_vp_home(); + + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, Some(vp_home.as_os_str())), (env_vars::VP_NODE_DIST_MIRROR, None)], + |_| async { + let provider = NodeProvider::new(); + + // Initially, no cache exists + let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap(); + assert!(result.is_none()); + + // Create mock cached versions + let node_cache = cache_dir.join("node"); + tokio::fs::create_dir_all(&node_cache).await.unwrap(); + + // Create version directories with mock binary + let platform = Platform::current(); + let binary_path = provider.binary_relative_path(platform); + + for version in ["20.17.0", "20.18.0", "20.19.0", "21.0.0"] { + let version_dir = node_cache.join(version); + let binary_full_path = version_dir.join(&binary_path); + tokio::fs::create_dir_all(binary_full_path.parent().unwrap()).await.unwrap(); + tokio::fs::write(&binary_full_path, "mock binary").await.unwrap(); + } - // Create incomplete installation (no binary) - let incomplete_dir = node_cache.join("20.20.0"); - tokio::fs::create_dir_all(&incomplete_dir).await.unwrap(); + // Create incomplete installation (no binary) + let incomplete_dir = node_cache.join("20.20.0"); + tokio::fs::create_dir_all(&incomplete_dir).await.unwrap(); - // Test: ^20.18.0 should find highest matching version (20.19.0) - let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap(); - assert_eq!(result, Some("20.19.0".into())); + // Test: ^20.18.0 should find highest matching version (20.19.0) + let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap(); + assert_eq!(result, Some("20.19.0".into())); - // Test: ~20.18.0 should find highest 20.18.x (only 20.18.0) - let result = provider.find_cached_version("~20.18.0", &cache_dir).await.unwrap(); - assert_eq!(result, Some("20.18.0".into())); + // Test: ~20.18.0 should find highest 20.18.x (only 20.18.0) + let result = provider.find_cached_version("~20.18.0", &cache_dir).await.unwrap(); + assert_eq!(result, Some("20.18.0".into())); - // Test: ^21.0.0 should find 21.0.0 - let result = provider.find_cached_version("^21.0.0", &cache_dir).await.unwrap(); - assert_eq!(result, Some("21.0.0".into())); + // Test: ^21.0.0 should find 21.0.0 + let result = provider.find_cached_version("^21.0.0", &cache_dir).await.unwrap(); + assert_eq!(result, Some("21.0.0".into())); - // Test: ^22.0.0 should find nothing - let result = provider.find_cached_version("^22.0.0", &cache_dir).await.unwrap(); - assert!(result.is_none()); + // Test: ^22.0.0 should find nothing + let result = provider.find_cached_version("^22.0.0", &cache_dir).await.unwrap(); + assert!(result.is_none()); - // Test: ^20.20.0 should find nothing (20.20.0 exists but no binary) - let result = provider.find_cached_version("^20.20.0", &cache_dir).await.unwrap(); - assert!(result.is_none()); + // Test: ^20.20.0 should find nothing (20.20.0 exists but no binary) + let result = provider.find_cached_version("^20.20.0", &cache_dir).await.unwrap(); + assert!(result.is_none()); + }, + ) + .await; } #[test] @@ -1438,75 +1467,128 @@ fedcba987654 node-v22.13.1-win-x64.zip"; #[tokio::test] async fn test_resolve_lts_alias_latest() { - let provider = NodeProvider::new(); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let provider = NodeProvider::new(); - // lts/* should resolve to the latest LTS version - let version = provider.resolve_lts_alias("lts/*").await.unwrap(); + // lts/* should resolve to the latest LTS version + let version = provider.resolve_lts_alias("lts/*").await.unwrap(); - // Should be a valid semver version - let parsed = Version::parse(&version).expect("Should parse as semver"); + // Should be a valid semver version + let parsed = Version::parse(&version).expect("Should parse as semver"); - // As of 2026, latest LTS is at least v24.x (Krypton) - assert!(parsed.major >= 24, "Latest LTS should be at least v24.x, got {}", version); + // As of 2026, latest LTS is at least v24.x (Krypton) + assert!(parsed.major >= 24, "Latest LTS should be at least v24.x, got {}", version); + }, + ) + .await; } #[tokio::test] async fn test_resolve_lts_alias_codename_iron() { - let provider = NodeProvider::new(); - - // lts/iron should resolve to v20.x - let version = provider.resolve_lts_alias("lts/iron").await.unwrap(); - let parsed = Version::parse(&version).expect("Should parse as semver"); - assert_eq!(parsed.major, 20, "lts/iron should resolve to v20.x, got {}", version); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let provider = NodeProvider::new(); + + // lts/iron should resolve to v20.x + let version = provider.resolve_lts_alias("lts/iron").await.unwrap(); + let parsed = Version::parse(&version).expect("Should parse as semver"); + assert_eq!(parsed.major, 20, "lts/iron should resolve to v20.x, got {}", version); + }, + ) + .await; } #[tokio::test] async fn test_resolve_lts_alias_codename_jod() { - let provider = NodeProvider::new(); - - // lts/jod should resolve to v22.x - let version = provider.resolve_lts_alias("lts/jod").await.unwrap(); - let parsed = Version::parse(&version).expect("Should parse as semver"); - assert_eq!(parsed.major, 22, "lts/jod should resolve to v22.x, got {}", version); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let provider = NodeProvider::new(); + + // lts/jod should resolve to v22.x + let version = provider.resolve_lts_alias("lts/jod").await.unwrap(); + let parsed = Version::parse(&version).expect("Should parse as semver"); + assert_eq!(parsed.major, 22, "lts/jod should resolve to v22.x, got {}", version); + }, + ) + .await; } #[tokio::test] async fn test_resolve_lts_alias_codename_case_insensitive() { - let provider = NodeProvider::new(); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let provider = NodeProvider::new(); - // Should be case-insensitive for codenames - let version_lower = provider.resolve_lts_alias("lts/iron").await.unwrap(); - let version_mixed = provider.resolve_lts_alias("lts/Iron").await.unwrap(); + // Should be case-insensitive for codenames + let version_lower = provider.resolve_lts_alias("lts/iron").await.unwrap(); + let version_mixed = provider.resolve_lts_alias("lts/Iron").await.unwrap(); - assert_eq!(version_lower, version_mixed, "LTS codename should be case-insensitive"); + assert_eq!(version_lower, version_mixed, "LTS codename should be case-insensitive"); + }, + ) + .await; } #[tokio::test] async fn test_resolve_lts_alias_offset() { - let provider = NodeProvider::new(); - - // lts/-1 should resolve to the second-highest LTS line - // As of 2026: lts/* = 24.x (Krypton), lts/-1 = 22.x (Jod) - let version = provider.resolve_lts_alias("lts/-1").await.unwrap(); - let parsed = Version::parse(&version).expect("Should parse as semver"); - assert_eq!(parsed.major, 22, "lts/-1 should resolve to v22.x (Jod), got {}", version); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let provider = NodeProvider::new(); + + // lts/-1 should resolve to the second-highest LTS line + // As of 2026: lts/* = 24.x (Krypton), lts/-1 = 22.x (Jod) + let version = provider.resolve_lts_alias("lts/-1").await.unwrap(); + let parsed = Version::parse(&version).expect("Should parse as semver"); + assert_eq!( + parsed.major, 22, + "lts/-1 should resolve to v22.x (Jod), got {}", + version + ); + }, + ) + .await; } #[tokio::test] async fn test_resolve_lts_alias_unknown_codename() { - let provider = NodeProvider::new(); - - // Unknown codename should error - let result = provider.resolve_lts_alias("lts/unknown").await; - assert!(result.is_err(), "Unknown LTS codename should return error"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let provider = NodeProvider::new(); + + // Unknown codename should error + let result = provider.resolve_lts_alias("lts/unknown").await; + assert!(result.is_err(), "Unknown LTS codename should return error"); + }, + ) + .await; } #[tokio::test] async fn test_resolve_lts_alias_invalid_offset() { - let provider = NodeProvider::new(); - - // Too large offset should error (there aren't 100 LTS lines) - let result = provider.resolve_lts_alias("lts/-100").await; - assert!(result.is_err(), "Invalid LTS offset should return error"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let provider = NodeProvider::new(); + + // Too large offset should error (there aren't 100 LTS lines) + let result = provider.resolve_lts_alias("lts/-100").await; + assert!(result.is_err(), "Invalid LTS offset should return error"); + }, + ) + .await; } } diff --git a/crates/vp_js_runtime/src/runtime.rs b/crates/vp_js_runtime/src/runtime.rs index da4a7bb387..16bf269e7e 100644 --- a/crates/vp_js_runtime/src/runtime.rs +++ b/crates/vp_js_runtime/src/runtime.rs @@ -189,7 +189,7 @@ pub async fn download_runtime_with_provider( let binary_relative_path = provider.binary_relative_path(platform); let bin_dir_relative_path = provider.bin_dir_relative_path(platform); - // Cache path: $CACHE_DIR/vite-plus/js_runtime/{runtime}/{version}/ + // Cache path: /js_runtime/{runtime}/{version}/ let install_dir = cache_dir.join(provider.name()).join(version); // Check if already cached @@ -674,9 +674,19 @@ pub async fn read_package_json( #[cfg(test)] mod tests { use tempfile::TempDir; + use vp_shared::env_vars; use super::*; + /// Shared VP_HOME for tests exercising the real download path: pinning + /// isolates them from concurrent `with_vars` scopes, and one shared root + /// keeps the download cache warm across tests and runs. + fn shared_vp_home() -> std::path::PathBuf { + let dir = std::env::temp_dir().join("vp-js-runtime-tests-vp-home"); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + #[test] fn test_js_runtime_type_display() { assert_eq!(JsRuntimeType::Node.to_string(), "node"); @@ -739,34 +749,46 @@ mod tests { #[tokio::test] async fn test_download_runtime_for_project_with_dev_engines() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with devEngines.runtime - let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"^20.18.0"}}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - // Version should be >= 20.18.0 and < 21.0.0 - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert_eq!(parsed.major, 20); - assert!(parsed.minor >= 18); - - // Verify the binary exists and works - let binary_path = runtime.get_binary_path(); - assert!(tokio::fs::try_exists(&binary_path).await.unwrap()); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with devEngines.runtime + let package_json = + r#"{"devEngines":{"runtime":{"name":"node","version":"^20.18.0"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // Version should be >= 20.18.0 and < 21.0.0 + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + assert!(parsed.minor >= 18); + + // Verify the binary exists and works + let binary_path = runtime.get_binary_path(); + assert!(tokio::fs::try_exists(&binary_path).await.unwrap()); + }, + ) + .await; } #[tokio::test] async fn test_download_runtime_for_project_with_multiple_runtimes() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with array of runtimes - let package_json = r#"{ + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with array of runtimes + let package_json = r#"{ "devEngines": { "runtime": [ {"name": "deno", "version": "^2.0.0"}, @@ -774,15 +796,18 @@ mod tests { ] } }"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - // Should use node runtime (deno is not supported yet) - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert_eq!(parsed.major, 20); + // Should use node runtime (deno is not supported yet) + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + }, + ) + .await; } #[tokio::test] @@ -791,34 +816,42 @@ mod tests { ignore = "latest can outrun the unofficial-builds musl channel" )] async fn test_download_runtime_for_project_no_dev_engines() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json without devEngines (minified, will use default 2-space indent) - let package_json = r#"{"name": "test-project"}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - - // Should download Node.js - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - - // Should have a valid version - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert!(parsed.major >= 20); - - // .node-version is written only if no ancestor has one (write-back is - // suppressed when an ancestor .node-version exists, e.g. in a monorepo) - if tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap() { - let node_version_content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version_content, format!("{version}\n")); - } + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json without devEngines (minified, will use default 2-space indent) + let package_json = r#"{"name": "test-project"}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + // Should download Node.js + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + + // .node-version is written only if no ancestor has one (write-back is + // suppressed when an ancestor .node-version exists, e.g. in a monorepo) + if tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap() { + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, format!("{version}\n")); + } - // package.json should remain unchanged - let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - assert_eq!(pkg_content, package_json); + // package.json should remain unchanged + let pkg_content = + tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(pkg_content, package_json); + }, + ) + .await; } #[tokio::test] @@ -827,11 +860,15 @@ mod tests { ignore = "latest can outrun the unofficial-builds musl channel" )] async fn test_download_runtime_for_project_does_not_write_back_when_no_version() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with runtime but no version - let package_json = r#"{ + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with runtime but no version + let package_json = r#"{ "name": "test-project", "devEngines": { "runtime": { @@ -840,28 +877,36 @@ mod tests { } } "#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let _runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let _runtime = download_runtime_for_project(&temp_path).await.unwrap(); - // .node-version should NOT be written (auto-write was removed) - assert!( - !tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap(), - ".node-version should not be auto-created" - ); + // .node-version should NOT be written (auto-write was removed) + assert!( + !tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap(), + ".node-version should not be auto-created" + ); - // package.json should remain unchanged - let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - assert_eq!(pkg_content, package_json); + // package.json should remain unchanged + let pkg_content = + tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(pkg_content, package_json); + }, + ) + .await; } #[tokio::test] async fn test_download_runtime_for_project_does_not_write_back_when_version_specified() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with version range - let package_json = r#"{ + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with version range + let package_json = r#"{ "name": "test-project", "devEngines": { "runtime": { @@ -871,39 +916,51 @@ mod tests { } } "#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert_eq!(parsed.major, 20); + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); - // Should NOT write .node-version since a version was specified - assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); + // Should NOT write .node-version since a version was specified + assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); - // package.json should remain unchanged - let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); - assert_eq!(pkg_content, package_json); + // package.json should remain unchanged + let pkg_content = + tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(pkg_content, package_json); + }, + ) + .await; } #[tokio::test] async fn test_download_runtime_for_project_with_v_prefix_exact_version() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Create package.json with exact version including 'v' prefix - let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"v20.18.0"}}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + // Create package.json with exact version including 'v' prefix + let package_json = + r#"{"devEngines":{"runtime":{"name":"node","version":"v20.18.0"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - // Version should be normalized (without 'v' prefix) - assert_eq!(runtime.version(), "20.18.0"); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // Version should be normalized (without 'v' prefix) + assert_eq!(runtime.version(), "20.18.0"); - // Verify the binary exists and works - let binary_path = runtime.get_binary_path(); - assert!(tokio::fs::try_exists(&binary_path).await.unwrap()); + // Verify the binary exists and works + let binary_path = runtime.get_binary_path(); + assert!(tokio::fs::try_exists(&binary_path).await.unwrap()); + }, + ) + .await; } #[tokio::test] @@ -912,124 +969,159 @@ mod tests { ignore = "latest can outrun the unofficial-builds musl channel" )] async fn test_download_runtime_for_project_no_package_json() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // No package.json file - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - - // Should download latest Node.js - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - - // Should NOT write .node-version - assert!( - !tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap(), - ".node-version should not be auto-created" - ); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // No package.json file + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + // Should download latest Node.js + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should NOT write .node-version + assert!( + !tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap(), + ".node-version should not be auto-created" + ); + }, + ) + .await; } #[tokio::test] async fn test_download_runtime_for_project_inherits_parent_node_version() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Write .node-version in root (simulating monorepo root) - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - - // Create a sub-package directory with a minimal package.json (no engines/devEngines) - let subdir = temp_path.join("packages").join("foo"); - tokio::fs::create_dir_all(&subdir).await.unwrap(); - tokio::fs::write(subdir.join("package.json"), r#"{"name": "foo"}"#).await.unwrap(); - - let runtime = download_runtime_for_project(&subdir).await.unwrap(); - - // Should inherit version from parent's .node-version - assert_eq!(runtime.version(), "20.18.0"); - - // Should NOT write .node-version in the sub-package - assert!( - !tokio::fs::try_exists(subdir.join(".node-version")).await.unwrap(), - ".node-version should not be written in sub-package when parent already has one" - ); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Write .node-version in root (simulating monorepo root) + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // Create a sub-package directory with a minimal package.json (no engines/devEngines) + let subdir = temp_path.join("packages").join("foo"); + tokio::fs::create_dir_all(&subdir).await.unwrap(); + tokio::fs::write(subdir.join("package.json"), r#"{"name": "foo"}"#).await.unwrap(); + + let runtime = download_runtime_for_project(&subdir).await.unwrap(); + + // Should inherit version from parent's .node-version + assert_eq!(runtime.version(), "20.18.0"); + + // Should NOT write .node-version in the sub-package + assert!( + !tokio::fs::try_exists(subdir.join(".node-version")).await.unwrap(), + ".node-version should not be written in sub-package when parent already has one" + ); + }, + ) + .await; } /// Integration test that downloads a real Node.js version #[tokio::test] async fn test_download_node_integration() { - // Use a small, old version for faster download - let version = "20.18.0"; + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + // Use a small, old version for faster download + let version = "20.18.0"; - let runtime = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + let runtime = download_runtime(JsRuntimeType::Node, version).await.unwrap(); - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - assert_eq!(runtime.version(), version); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + assert_eq!(runtime.version(), version); - // Verify the binary exists - let binary_path = runtime.get_binary_path(); - assert!(tokio::fs::try_exists(&binary_path).await.unwrap()); + // Verify the binary exists + let binary_path = runtime.get_binary_path(); + assert!(tokio::fs::try_exists(&binary_path).await.unwrap()); - // Verify binary is executable by checking version - let output = tokio::process::Command::new(binary_path.as_path()) - .arg("--version") - .output() - .await - .unwrap(); + // Verify binary is executable by checking version + let output = tokio::process::Command::new(binary_path.as_path()) + .arg("--version") + .output() + .await + .unwrap(); - assert!(output.status.success()); - let version_output = String::from_utf8_lossy(&output.stdout); - assert!(version_output.contains(version)); + assert!(output.status.success()); + let version_output = String::from_utf8_lossy(&output.stdout); + assert!(version_output.contains(version)); + }, + ) + .await; } /// Test cache reuse - second call should be instant #[tokio::test] async fn test_download_node_cache_reuse() { - let version = "20.18.0"; + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let version = "20.18.0"; - // First download - let runtime1 = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + // First download + let runtime1 = download_runtime(JsRuntimeType::Node, version).await.unwrap(); - // Second download should use cache - let start = std::time::Instant::now(); - let runtime2 = download_runtime(JsRuntimeType::Node, version).await.unwrap(); - let elapsed = start.elapsed(); + // Second download should use cache + let start = std::time::Instant::now(); + let runtime2 = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + let elapsed = start.elapsed(); - // Cache hit should be very fast (< 100ms) - assert!(elapsed.as_millis() < 100, "Cache reuse took too long: {elapsed:?}"); + // Cache hit should be very fast (< 100ms) + assert!(elapsed.as_millis() < 100, "Cache reuse took too long: {elapsed:?}"); - // Should return same install directory - assert_eq!(runtime1.install_dir, runtime2.install_dir); + // Should return same install directory + assert_eq!(runtime1.install_dir, runtime2.install_dir); + }, + ) + .await; } /// Test that incomplete installations are cleaned up and re-downloaded #[tokio::test] #[ignore] async fn test_incomplete_installation_cleanup() { - // Use a different version to avoid interference with other tests - let version = "20.18.1"; - - // First, ensure we have a valid cached version - let runtime = download_runtime(JsRuntimeType::Node, version).await.unwrap(); - let install_dir = runtime.install_dir.clone(); - let binary_path = runtime.get_binary_path(); - - // Simulate an incomplete installation by removing the binary but keeping the directory - tokio::fs::remove_file(&binary_path).await.unwrap(); - assert!(!tokio::fs::try_exists(&binary_path).await.unwrap()); - assert!(tokio::fs::try_exists(&install_dir).await.unwrap()); - - // Now download again - it should detect the incomplete installation and re-download - let runtime2 = download_runtime(JsRuntimeType::Node, version).await.unwrap(); - - // Verify the binary exists again - assert!(tokio::fs::try_exists(&runtime2.get_binary_path()).await.unwrap()); - - // Verify binary is executable - let output = tokio::process::Command::new(runtime2.get_binary_path().as_path()) - .arg("--version") - .output() - .await - .unwrap(); - assert!(output.status.success()); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + // Use a different version to avoid interference with other tests + let version = "20.18.1"; + + // First, ensure we have a valid cached version + let runtime = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + let install_dir = runtime.install_dir.clone(); + let binary_path = runtime.get_binary_path(); + + // Simulate an incomplete installation by removing the binary but keeping the directory + tokio::fs::remove_file(&binary_path).await.unwrap(); + assert!(!tokio::fs::try_exists(&binary_path).await.unwrap()); + assert!(tokio::fs::try_exists(&install_dir).await.unwrap()); + + // Now download again - it should detect the incomplete installation and re-download + let runtime2 = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + + // Verify the binary exists again + assert!(tokio::fs::try_exists(&runtime2.get_binary_path()).await.unwrap()); + + // Verify binary is executable + let output = tokio::process::Command::new(runtime2.get_binary_path().as_path()) + .arg("--version") + .output() + .await + .unwrap(); + assert!(output.status.success()); + }, + ) + .await; } /// Test concurrent downloads - multiple tasks downloading the same version @@ -1037,71 +1129,78 @@ mod tests { #[tokio::test] #[ignore] async fn test_concurrent_downloads() { - // Use a different version to avoid conflicts with other tests - let version = "20.17.0"; - - // Clear any existing cache for this version - let cache_dir = crate::cache::get_cache_dir().unwrap(); - let install_dir = cache_dir.join("node").join(version); - if tokio::fs::try_exists(&install_dir).await.unwrap_or(false) { - tokio::fs::remove_dir_all(&install_dir).await.unwrap(); - } - - // Spawn multiple concurrent download tasks - let num_concurrent = 4; - let mut handles = Vec::with_capacity(num_concurrent); - - for i in 0..num_concurrent { - let version = version.to_string(); - handles.push(tokio::spawn(async move { - tracing::info!("Starting concurrent download task {i}"); - let result = download_runtime(JsRuntimeType::Node, &version).await; - tracing::info!("Completed concurrent download task {i}"); - result - })); - } + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + // Use a different version to avoid conflicts with other tests + let version = "20.17.0"; + + // Clear any existing cache for this version + let cache_dir = crate::cache::get_cache_dir().unwrap(); + let install_dir = cache_dir.join("node").join(version); + if tokio::fs::try_exists(&install_dir).await.unwrap_or(false) { + tokio::fs::remove_dir_all(&install_dir).await.unwrap(); + } - // Wait for all tasks and collect results - let mut results = Vec::with_capacity(num_concurrent); - for handle in handles { - results.push(handle.await.unwrap()); - } + // Spawn multiple concurrent download tasks + let num_concurrent = 4; + let mut handles = Vec::with_capacity(num_concurrent); + + for i in 0..num_concurrent { + let version = version.to_string(); + handles.push(tokio::spawn(async move { + tracing::info!("Starting concurrent download task {i}"); + let result = download_runtime(JsRuntimeType::Node, &version).await; + tracing::info!("Completed concurrent download task {i}"); + result + })); + } - // All tasks should succeed - for (i, result) in results.iter().enumerate() { - assert!(result.is_ok(), "Task {i} failed: {:?}", result.as_ref().err()); - } + // Wait for all tasks and collect results + let mut results = Vec::with_capacity(num_concurrent); + for handle in handles { + results.push(handle.await.unwrap()); + } - // All tasks should return the same install directory - let first_install_dir = &results[0].as_ref().unwrap().install_dir; - for (i, result) in results.iter().enumerate().skip(1) { - assert_eq!( - &result.as_ref().unwrap().install_dir, - first_install_dir, - "Task {i} has different install_dir" - ); - } + // All tasks should succeed + for (i, result) in results.iter().enumerate() { + assert!(result.is_ok(), "Task {i} failed: {:?}", result.as_ref().err()); + } - // Verify the binary works - let runtime = results.into_iter().next().unwrap().unwrap(); - let binary_path = runtime.get_binary_path(); - assert!( - tokio::fs::try_exists(&binary_path).await.unwrap(), - "Binary should exist at {binary_path:?}" - ); + // All tasks should return the same install directory + let first_install_dir = &results[0].as_ref().unwrap().install_dir; + for (i, result) in results.iter().enumerate().skip(1) { + assert_eq!( + &result.as_ref().unwrap().install_dir, + first_install_dir, + "Task {i} has different install_dir" + ); + } - let output = tokio::process::Command::new(binary_path.as_path()) - .arg("--version") - .output() - .await - .unwrap(); + // Verify the binary works + let runtime = results.into_iter().next().unwrap().unwrap(); + let binary_path = runtime.get_binary_path(); + assert!( + tokio::fs::try_exists(&binary_path).await.unwrap(), + "Binary should exist at {binary_path:?}" + ); - assert!(output.status.success(), "Binary should be executable"); - let version_output = String::from_utf8_lossy(&output.stdout); - assert!( - version_output.contains(version), - "Version output should contain {version}, got: {version_output}" - ); + let output = tokio::process::Command::new(binary_path.as_path()) + .arg("--version") + .output() + .await + .unwrap(); + + assert!(output.status.success(), "Binary should be executable"); + let version_output = String::from_utf8_lossy(&output.stdout); + assert!( + version_output.contains(version), + "Version output should contain {version}, got: {version_output}" + ); + }, + ) + .await; } // ========================================== @@ -1110,103 +1209,138 @@ mod tests { #[tokio::test] async fn test_node_version_file_takes_priority() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Create .node-version with exact version - tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + // Create .node-version with exact version + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); - // Create package.json with engines.node (should be ignored) - let package_json = r#"{"engines":{"node":">=22.0.0"}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + // Create package.json with engines.node (should be ignored) + let package_json = r#"{"engines":{"node":">=22.0.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - assert_eq!(runtime.version(), "20.18.0"); + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.version(), "20.18.0"); - // Should NOT write back since .node-version had exact version - let node_version_content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version_content, "20.18.0\n"); + // Should NOT write back since .node-version had exact version + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "20.18.0\n"); + }, + ) + .await; } #[tokio::test] async fn test_dev_engines_takes_priority_over_engines_node() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with both engines.node and devEngines.runtime - let package_json = r#"{ + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with both engines.node and devEngines.runtime + let package_json = r#"{ "engines": {"node": "^20.18.0"}, "devEngines": {"runtime": {"name": "node", "version": "^22.0.0"}} }"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - // devEngines.runtime is the dev-environment requirement and wins over the - // consumer-facing engines.node range (rfcs/dev-engines.md) - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert_eq!(parsed.major, 22); + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + // devEngines.runtime is the dev-environment requirement and wins over the + // consumer-facing engines.node range (rfcs/dev-engines.md) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 22); + }, + ) + .await; } #[tokio::test] async fn test_only_engines_node_source() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Create package.json with only engines.node - let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + // Create package.json with only engines.node + let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert_eq!(parsed.major, 20); + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); - // Should NOT write .node-version since a version was specified - assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); + // Should NOT write .node-version since a version was specified + assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); + }, + ) + .await; } #[tokio::test] async fn test_node_version_file_partial_version() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create .node-version with partial version (two parts) - tokio::fs::write(temp_path.join(".node-version"), "20.18\n").await.unwrap(); - - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - // Should resolve to a 20.18.x or higher version in 20.x line - assert_eq!(parsed.major, 20); - // Minor version should be at least 18 - assert!(parsed.minor >= 18, "Expected minor >= 18, got {}", parsed.minor); - - // Should NOT write back - .node-version already has a version specified - let node_version_content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version_content, "20.18\n"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with partial version (two parts) + tokio::fs::write(temp_path.join(".node-version"), "20.18\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + // Should resolve to a 20.18.x or higher version in 20.x line + assert_eq!(parsed.major, 20); + // Minor version should be at least 18 + assert!(parsed.minor >= 18, "Expected minor >= 18, got {}", parsed.minor); + + // Should NOT write back - .node-version already has a version specified + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "20.18\n"); + }, + ) + .await; } #[tokio::test] async fn test_node_version_file_single_part_version() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create .node-version with single-part version - tokio::fs::write(temp_path.join(".node-version"), "20\n").await.unwrap(); - - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - // Should resolve to a 20.x.x version - assert_eq!(parsed.major, 20); - - // Should NOT write back - .node-version already has a version specified - let node_version_content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version_content, "20\n"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with single-part version + tokio::fs::write(temp_path.join(".node-version"), "20\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + // Should resolve to a 20.x.x version + assert_eq!(parsed.major, 20); + + // Should NOT write back - .node-version already has a version specified + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "20\n"); + }, + ) + .await; } #[test] @@ -1227,24 +1361,31 @@ mod tests { ignore = "latest can outrun the unofficial-builds musl channel" )] async fn test_invalid_node_version_file_is_ignored() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Create .node-version with invalid version - tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); + // Create .node-version with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); - // Create package.json without any version - let package_json = r#"{"name": "test-project"}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + // Create package.json without any version + let package_json = r#"{"name": "test-project"}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // Should fall through to fetch latest LTS since .node-version is invalid - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // Should fall through to fetch latest LTS since .node-version is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - // Should have a valid version (latest LTS) - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert!(parsed.major >= 20); + // Should have a valid version (latest LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + }, + ) + .await; } #[tokio::test] @@ -1253,21 +1394,28 @@ mod tests { ignore = "latest can outrun the unofficial-builds musl channel" )] async fn test_invalid_engines_node_is_ignored() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with invalid engines.node - let package_json = r#"{"engines":{"node":"invalid"}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - - // Should fall through to fetch latest LTS since engines.node is invalid - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - - // Should have a valid version (latest LTS) - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert!(parsed.major >= 20); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with invalid engines.node + let package_json = r#"{"engines":{"node":"invalid"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should fall through to fetch latest LTS since engines.node is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version (latest LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + }, + ) + .await; } #[tokio::test] @@ -1276,59 +1424,81 @@ mod tests { ignore = "latest can outrun the unofficial-builds musl channel" )] async fn test_invalid_dev_engines_runtime_is_ignored() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with invalid devEngines.runtime version - let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"invalid"}}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - - // Should fall through to fetch latest LTS since devEngines.runtime is invalid - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - - // Should have a valid version (latest LTS) - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert!(parsed.major >= 20); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with invalid devEngines.runtime version + let package_json = + r#"{"devEngines":{"runtime":{"name":"node","version":"invalid"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should fall through to fetch latest LTS since devEngines.runtime is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version (latest LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + }, + ) + .await; } #[tokio::test] async fn test_invalid_node_version_file_falls_through_to_valid_engines() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create .node-version with invalid version - tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); - - // Create package.json with valid engines.node - let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - - // Should use engines.node since .node-version is invalid - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert_eq!(parsed.major, 20); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); + + // Create package.json with valid engines.node + let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should use engines.node since .node-version is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + }, + ) + .await; } #[tokio::test] async fn test_invalid_engines_falls_through_to_valid_dev_engines() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create package.json with invalid engines.node but valid devEngines.runtime - let package_json = r#"{ + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with invalid engines.node but valid devEngines.runtime + let package_json = r#"{ "engines": {"node": "invalid"}, "devEngines": {"runtime": {"name": "node", "version": "^20.18.0"}} }"#; - tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - // Should use devEngines.runtime since engines.node is invalid - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert_eq!(parsed.major, 20); + // Should use devEngines.runtime since engines.node is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + }, + ) + .await; } #[test] @@ -1452,24 +1622,34 @@ mod tests { #[tokio::test] async fn test_download_runtime_for_project_with_lts_alias_in_node_version() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create .node-version with LTS alias - tokio::fs::write(temp_path.join(".node-version"), "lts/iron\n").await.unwrap(); - - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - // lts/iron should resolve to v20.x - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert_eq!(parsed.major, 20, "lts/iron should resolve to v20.x, got {version}"); - - // Should NOT overwrite .node-version - user explicitly specified an LTS alias - let node_version_content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version_content, "lts/iron\n", ".node-version should remain unchanged"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with LTS alias + tokio::fs::write(temp_path.join(".node-version"), "lts/iron\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // lts/iron should resolve to v20.x + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20, "lts/iron should resolve to v20.x, got {version}"); + + // Should NOT overwrite .node-version - user explicitly specified an LTS alias + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!( + node_version_content, "lts/iron\n", + ".node-version should remain unchanged" + ); + }, + ) + .await; } #[tokio::test] @@ -1478,24 +1658,37 @@ mod tests { ignore = "latest can outrun the unofficial-builds musl channel" )] async fn test_download_runtime_for_project_with_lts_latest_alias() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create .node-version with lts/* alias - tokio::fs::write(temp_path.join(".node-version"), "lts/*\n").await.unwrap(); - - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); - - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - // lts/* should resolve to latest LTS (at least v22.x as of 2026) - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - assert!(parsed.major >= 22, "lts/* should resolve to at least v22.x, got {version}"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with lts/* alias + tokio::fs::write(temp_path.join(".node-version"), "lts/*\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // lts/* should resolve to latest LTS (at least v22.x as of 2026) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!( + parsed.major >= 22, + "lts/* should resolve to at least v22.x, got {version}" + ); - // Should NOT overwrite .node-version - let node_version_content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version_content, "lts/*\n", ".node-version should remain unchanged"); + // Should NOT overwrite .node-version + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!( + node_version_content, "lts/*\n", + ".node-version should remain unchanged" + ); + }, + ) + .await; } #[tokio::test] @@ -1510,25 +1703,38 @@ mod tests { ignore = "latest can outrun the unofficial-builds musl channel" )] async fn test_download_runtime_for_project_with_latest_alias_in_node_version() { - let temp_dir = TempDir::new().unwrap(); - let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - // Create .node-version with "latest" alias - tokio::fs::write(temp_path.join(".node-version"), "latest\n").await.unwrap(); - - let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with "latest" alias + tokio::fs::write(temp_path.join(".node-version"), "latest\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // "latest" should resolve to the absolute latest version (including non-LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + // Latest version should be at least v20.x + assert!( + parsed.major >= 20, + "'latest' should resolve to at least v20.x, got {version}" + ); - assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); - // "latest" should resolve to the absolute latest version (including non-LTS) - let version = runtime.version(); - let parsed = node_semver::Version::parse(version).unwrap(); - // Latest version should be at least v20.x - assert!(parsed.major >= 20, "'latest' should resolve to at least v20.x, got {version}"); - - // Should NOT overwrite .node-version - user explicitly specified "latest" - let node_version_content = - tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); - assert_eq!(node_version_content, "latest\n", ".node-version should remain unchanged"); + // Should NOT overwrite .node-version - user explicitly specified "latest" + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!( + node_version_content, "latest\n", + ".node-version should remain unchanged" + ); + }, + ) + .await; } // ========================================== diff --git a/crates/vp_pm_cli/Cargo.toml b/crates/vp_pm_cli/Cargo.toml index c0e5c7b181..2a6994f065 100644 --- a/crates/vp_pm_cli/Cargo.toml +++ b/crates/vp_pm_cli/Cargo.toml @@ -50,6 +50,7 @@ doctest = false [dev-dependencies] httpmock = { workspace = true } test-log = { workspace = true } +vp_shared = { workspace = true, features = ["test-utils"] } [lints] workspace = true diff --git a/crates/vp_pm_cli/src/config.rs b/crates/vp_pm_cli/src/config.rs index 6160ea4ea5..28ebfb5cc3 100644 --- a/crates/vp_pm_cli/src/config.rs +++ b/crates/vp_pm_cli/src/config.rs @@ -3,7 +3,7 @@ use vp_shared::EnvConfig; /// Get the configured NPM registry URL. #[must_use] pub fn npm_registry() -> String { - EnvConfig::get().npm_registry + EnvConfig::get().npm_registry.clone() } /// Get the tgz url of a npm package @@ -30,23 +30,22 @@ pub(crate) fn get_npm_package_metadata_url(name: &str) -> vt_str::Str { #[cfg(test)] mod tests { + use vp_shared::env_vars; + use super::*; #[test] fn test_npm_registry_default() { - EnvConfig::test_scope(EnvConfig::for_test(), || { + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { assert_eq!(npm_registry(), "https://registry.npmjs.org"); }); } #[test] fn test_npm_registry_custom() { - EnvConfig::test_scope( - EnvConfig { - npm_registry: "https://registry.npmmirror.com".into(), - ..EnvConfig::for_test() - }, - || { + EnvConfig::with_vars( + [(env_vars::NPM_CONFIG_REGISTRY, "https://registry.npmmirror.com")], + |_| { assert_eq!(npm_registry(), "https://registry.npmmirror.com"); }, ); @@ -54,7 +53,7 @@ mod tests { #[test] fn test_npm_tgz_url() { - EnvConfig::test_scope(EnvConfig::for_test(), || { + vp_shared::EnvConfig::with_vars([(env_vars::VP_HOME, std::env::temp_dir())], |_| { assert_eq!( get_npm_package_tgz_url("vite", "7.1.3"), "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz" diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 08379569cf..90c3d770d3 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -411,9 +411,9 @@ pub fn package_manager_install_dir( package_manager_type: PackageManagerType, version: &str, ) -> Option { - let home_dir = vp_shared::get_vp_home().ok()?; let bin_name = package_manager_type.to_string(); - Some(home_dir.join("package_manager").join(&bin_name).join(version).join(&bin_name)) + let data_dir = &vp_shared::EnvConfig::get().dirs.data; + Some(data_dir.join("package_manager").join(&bin_name).join(version).join(&bin_name)) } /// Return the executable shim path for a package manager binary inside an install directory. @@ -769,14 +769,14 @@ async fn resolve_latest_satisfying_version( } /// Find the highest already-downloaded package manager version satisfying `range` -/// under `$VP_HOME/package_manager//`. +/// under `/package_manager//`. fn find_cached_package_manager_version( package_manager_type: PackageManagerType, range: &node_semver::Range, ) -> Result, Error> { - let home_dir = vp_shared::get_vp_home()?; let bin_name = package_manager_type.to_string(); - let versions_dir = home_dir.join("package_manager").join(&bin_name); + let versions_dir = + vp_shared::EnvConfig::get().dirs.data.join("package_manager").join(&bin_name); let entries = match fs::read_dir(&versions_dir) { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -840,7 +840,7 @@ async fn resolve_package_manager_range( } /// Download the package manager and extract it to the vite-plus home directory. -/// Return the install directory, e.g. `$VP_HOME/package_manager/pnpm/10.0.0/pnpm` +/// Return the install directory, e.g. `/package_manager/pnpm/10.0.0/pnpm` pub async fn download_package_manager( package_manager_type: PackageManagerType, version_or_latest: &str, @@ -858,7 +858,7 @@ pub async fn download_package_manager( // Reject anything that is not strict semver `major.minor.patch[-prerelease][+build]`. // This prevents path traversal via the version being interpolated into - // `$VP_HOME/package_manager/{name}/{version}` below, since `AbsolutePath::join` + // `/package_manager/{name}/{version}` below, since `AbsolutePath::join` // does not normalize `..` components. Also guards against registry-controlled // "latest" lookups returning a malicious value. let parsed_version = Version::parse(&version).map_err(|_| { @@ -878,7 +878,8 @@ pub async fn download_package_manager( package_name = "@yarnpkg/cli-dist".into(); } - let home_dir = vp_shared::get_vp_home()?; + let config = vp_shared::EnvConfig::get(); + let data_dir = &config.dirs.data; let bin_name = package_manager_type.to_string(); // For bun, use platform-specific download flow. @@ -886,7 +887,7 @@ pub async fn download_package_manager( // not the platform-specific binary, so we don't pass it through; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Bun) { - return download_bun_package_manager(&version, &home_dir).await; + return download_bun_package_manager(&version, data_dir).await; } // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package @@ -894,16 +895,16 @@ pub async fn download_package_manager( // A declared hash names the main tarball and is verified against it; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { - return download_pnpm_native_package_manager(&version, &home_dir, expected_hash).await; + return download_pnpm_native_package_manager(&version, data_dir, expected_hash).await; } let tgz_url = get_npm_package_tgz_url(&package_name, &version); - // $VP_HOME/package_manager/pnpm/10.0.0 - let target_dir = home_dir.join("package_manager").join(&bin_name).join(&version); + // /package_manager/pnpm/10.0.0 + let target_dir = data_dir.join("package_manager").join(&bin_name).join(&version); let install_dir = target_dir.join(&bin_name); // If all shims already exist, return the target directory - // $VP_HOME/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) + // /package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) if is_package_manager_install_complete(&install_dir, &bin_name)? { verify_cached_cli_hash( package_manager_type, @@ -916,7 +917,7 @@ pub async fn download_package_manager( return Ok((install_dir, package_name, version)); } - // $VP_HOME/package_manager/pnpm/{tmp_name} + // /package_manager/pnpm/{tmp_name} // Use tempfile::TempDir for robust temporary directory creation let parent_dir = target_dir.parent().unwrap(); tokio::fs::create_dir_all(parent_dir).await?; @@ -1161,14 +1162,14 @@ fn bun_requires_baseline() -> bool { /// Unlike JS-based package managers (pnpm/npm/yarn), bun is a native binary /// distributed via platform-specific npm packages (`@oven/bun-{os}-{arch}`). /// -/// Layout: `$VP_HOME/package_manager/bun/{version}/bun/bin/bun.native` +/// Layout: `/package_manager/bun/{version}/bun/bin/bun.native` async fn download_bun_package_manager( version: &Str, home_dir: &AbsolutePath, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "bun".into(); - // $VP_HOME/package_manager/bun/{version} + // /package_manager/bun/{version} let target_dir = home_dir.join("package_manager").join("bun").join(version.as_str()); let install_dir = target_dir.join("bun"); @@ -1343,7 +1344,7 @@ async fn fetch_platform_integrity( /// Download pnpm >= 12 (native binary) via its platform-specific npm package. /// -/// Layout: `$VP_HOME/package_manager/pnpm/{version}/pnpm/bin/pnpm.native` +/// Layout: `/package_manager/pnpm/{version}/pnpm/bin/pnpm.native` async fn download_pnpm_native_package_manager( version: &Str, home_dir: &AbsolutePath, @@ -1352,7 +1353,7 @@ async fn download_pnpm_native_package_manager( let package_name: Str = "pnpm".into(); let platform_package_name = get_pnpm_platform_package_name()?; - // $VP_HOME/package_manager/pnpm/{version} + // /package_manager/pnpm/{version} let target_dir = home_dir.join("package_manager").join("pnpm").join(version.as_str()); let install_dir = target_dir.join("pnpm"); @@ -1875,7 +1876,7 @@ mod tests { use semver::VersionReq; use tempfile::{TempDir, tempdir}; - use vp_shared::EnvConfig; + use vp_shared::{EnvConfig, env_vars}; use super::*; @@ -1883,6 +1884,15 @@ mod tests { tempdir().expect("Failed to create temp directory") } + /// Shared VP_HOME root for download-heavy tests: keeps the package-manager + /// download cache warm across tests and runs (matching main's real global + /// cache); concurrent installs under the same root are lock-protected. + fn shared_vp_home() -> std::path::PathBuf { + let dir = std::env::temp_dir().join("vp-pm-cli-tests-vp-home"); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + /// Build an `@yarnpkg/cli-dist` style tarball around `yarn_js`. /// /// `symlink_target` adds a `package/bin/yarn` symlink. An unauthenticated @@ -1965,10 +1975,10 @@ mod tests { } /// Create a fake managed package manager install under - /// `/package_manager////bin/`. - fn write_pm_install(vp_home: &AbsolutePath, name: &str, version: &str, state: InstallState) { + /// `/package_manager////bin/`. + fn write_pm_install(data_dir: &AbsolutePath, name: &str, version: &str, state: InstallState) { let bin_dir = - vp_home.join("package_manager").join(name).join(version).join(name).join("bin"); + data_dir.join("package_manager").join(name).join(version).join(name).join("bin"); fs::create_dir_all(&bin_dir).unwrap(); let bin_file = bin_dir.join(name); if matches!(state, InstallState::BinOnly | InstallState::Complete) { @@ -1982,7 +1992,7 @@ mod tests { fn find_cached_pnpm(vp_home: &AbsolutePath) -> Option { let range = node_semver::Range::parse("^11.0.0").unwrap(); - EnvConfig::test_scope(EnvConfig::for_test_with_home(vp_home.as_path()), || { + EnvConfig::with_vars([(env_vars::VP_HOME, vp_home.as_path())], |_| { find_cached_package_manager_version(PackageManagerType::Pnpm, &range) }) .unwrap() @@ -1992,7 +2002,7 @@ mod tests { fn test_find_cached_package_manager_version_skips_install_without_bin() { let temp_dir = create_temp_dir(); let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - + // VP_HOME pins to the root, so installs land directly under it. // 11.6.0 has no bin shim at all: incomplete on every platform, so the // complete 11.5.1 wins even though 11.6.0 is higher and satisfies the range write_pm_install(&vp_home, "pnpm", "11.5.1", InstallState::Complete); @@ -2005,7 +2015,7 @@ mod tests { fn test_find_cached_package_manager_version_none_when_no_complete_install() { let temp_dir = create_temp_dir(); let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - + // VP_HOME pins to the root, so installs land directly under it. write_pm_install(&vp_home, "pnpm", "11.6.0", InstallState::NoBin); // nothing usable is cached; resolution falls through to the registry @@ -2017,7 +2027,7 @@ mod tests { fn test_find_cached_package_manager_version_skips_missing_windows_shims() { let temp_dir = create_temp_dir(); let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - + // VP_HOME pins to the root, so installs land directly under it. // On Windows the `.cmd`/`.ps1` wrappers are the files actually invoked, so // a bin-only 11.6.0 is incomplete and the complete 11.5.1 wins write_pm_install(&vp_home, "pnpm", "11.5.1", InstallState::Complete); @@ -2031,7 +2041,7 @@ mod tests { fn test_find_cached_package_manager_version_accepts_bin_only_off_windows() { let temp_dir = create_temp_dir(); let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - + // VP_HOME pins to the root, so installs land directly under it. // Off Windows only the plain bin is invoked, so a bin-only 11.6.0 is a // usable install and the highest satisfying version wins write_pm_install(&vp_home, "pnpm", "11.5.1", InstallState::Complete); @@ -2431,165 +2441,215 @@ mod tests { #[tokio::test] async fn test_detect_package_manager_with_pnpm_workspace_yaml() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let workspace_content = "packages:\n - 'packages/*'"; - create_pnpm_workspace_yaml(&temp_dir_path, workspace_content); - - let result = - PackageManager::builder(temp_dir_path).build().await.expect("Should detect pnpm"); - assert_eq!(result.client.to_string(), "pnpm"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let workspace_content = "packages:\n - 'packages/*'"; + create_pnpm_workspace_yaml(&temp_dir_path, workspace_content); + + let result = PackageManager::builder(temp_dir_path) + .build() + .await + .expect("Should detect pnpm"); + assert_eq!(result.client.to_string(), "pnpm"); + }, + ) + .await; } #[tokio::test] async fn test_detect_package_manager_with_pnpm_lock_yaml() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package", "version": "1.0.0"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create pnpm-lock.yaml - fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'") - .expect("Failed to write pnpm-lock.yaml"); - - let result = - PackageManager::builder(temp_dir_path).build().await.expect("Should detect pnpm"); - assert_eq!(result.client.to_string(), "pnpm"); - - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir.path().join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - println!("package_json: {package_json:?}"); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "pnpm"); - assert!(Version::parse(entry["version"].as_str().unwrap()).is_ok()); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // the legacy field is not written - assert!(package_json.get("packageManager").is_none()); - // keep other fields - assert_eq!(package_json["version"].as_str().unwrap(), "1.0.0"); - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package", "version": "1.0.0"}"#; + create_package_json(&temp_dir_path, package_content); + + // Create pnpm-lock.yaml + fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'") + .expect("Failed to write pnpm-lock.yaml"); + + let result = PackageManager::builder(temp_dir_path) + .build() + .await + .expect("Should detect pnpm"); + assert_eq!(result.client.to_string(), "pnpm"); + + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) + let package_json_path = temp_dir.path().join("package.json"); + let package_json: serde_json::Value = + serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); + println!("package_json: {package_json:?}"); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "pnpm"); + assert!(Version::parse(entry["version"].as_str().unwrap()).is_ok()); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // the legacy field is not written + assert!(package_json.get("packageManager").is_none()); + // keep other fields + assert_eq!(package_json["version"].as_str().unwrap(), "1.0.0"); + assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + }, + ) + .await; } #[tokio::test] async fn test_detect_package_manager_with_yarn_lock() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create yarn.lock - fs::write(temp_dir_path.join("yarn.lock"), "# yarn lockfile v1") - .expect("Failed to write yarn.lock"); - - let result = PackageManager::builder(temp_dir_path.to_absolute_path_buf()) - .build() - .await - .expect("Should detect yarn"); - assert_eq!(result.client.to_string(), "yarn"); - assert!( - result.get_bin_prefix().ends_with("yarn/bin"), - "bin_prefix should end with yarn/bin, but got {:?}", - result.get_bin_prefix() - ); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir_path.join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - println!("package_json: {package_json:?}"); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "yarn"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package"}"#; + create_package_json(&temp_dir_path, package_content); + + // Create yarn.lock + fs::write(temp_dir_path.join("yarn.lock"), "# yarn lockfile v1") + .expect("Failed to write yarn.lock"); + + let result = PackageManager::builder(temp_dir_path.to_absolute_path_buf()) + .build() + .await + .expect("Should detect yarn"); + assert_eq!(result.client.to_string(), "yarn"); + assert!( + result.get_bin_prefix().ends_with("yarn/bin"), + "bin_prefix should end with yarn/bin, but got {:?}", + result.get_bin_prefix() + ); + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) + let package_json_path = temp_dir_path.join("package.json"); + let package_json: serde_json::Value = + serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); + println!("package_json: {package_json:?}"); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "yarn"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // keep other fields + assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + }, + ) + .await; } #[tokio::test] #[cfg(not(windows))] // FIXME async fn test_detect_package_manager_with_package_lock_json() { - use std::process::Command; - - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create package-lock.json - fs::write(temp_dir_path.join("package-lock.json"), r#"{"lockfileVersion": 2}"#) - .expect("Failed to write package-lock.json"); - - let result = - PackageManager::builder(temp_dir_path).build().await.expect("Should detect npm"); - assert_eq!(result.client.to_string(), "npm"); - - // check shim files - let bin_prefix = result.get_bin_prefix(); - assert!(is_exists_file(bin_prefix.join("npm")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npm.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npm.ps1")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npx")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npx.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npx.ps1")).unwrap()); - - // run npm --version - let mut paths = - env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); - paths.insert(0, bin_prefix.into_path_buf()); - let output = Command::new("npm") - .arg("--version") - .env("PATH", env::join_paths(&paths).unwrap()) - .output() - .expect("Failed to run npm"); - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); - // println!("npm --version: {:?}", String::from_utf8_lossy(&output.stdout)); - - // run npx --version - let output = Command::new("npx") - .arg("--version") - .env("PATH", env::join_paths(&paths).unwrap()) - .output() - .expect("Failed to run npx"); - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + use std::process::Command; + + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package"}"#; + create_package_json(&temp_dir_path, package_content); + + // Create package-lock.json + fs::write(temp_dir_path.join("package-lock.json"), r#"{"lockfileVersion": 2}"#) + .expect("Failed to write package-lock.json"); + + let result = PackageManager::builder(temp_dir_path) + .build() + .await + .expect("Should detect npm"); + assert_eq!(result.client.to_string(), "npm"); + + // check shim files + let bin_prefix = result.get_bin_prefix(); + assert!(is_exists_file(bin_prefix.join("npm")).unwrap()); + assert!(is_exists_file(bin_prefix.join("npm.cmd")).unwrap()); + assert!(is_exists_file(bin_prefix.join("npm.ps1")).unwrap()); + assert!(is_exists_file(bin_prefix.join("npx")).unwrap()); + assert!(is_exists_file(bin_prefix.join("npx.cmd")).unwrap()); + assert!(is_exists_file(bin_prefix.join("npx.ps1")).unwrap()); + + // run npm --version + let mut paths = + env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); + paths.insert(0, bin_prefix.into_path_buf()); + let output = Command::new("npm") + .arg("--version") + .env("PATH", env::join_paths(&paths).unwrap()) + .output() + .expect("Failed to run npm"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + // println!("npm --version: {:?}", String::from_utf8_lossy(&output.stdout)); + + // run npx --version + let output = Command::new("npx") + .arg("--version") + .env("PATH", env::join_paths(&paths).unwrap()) + .output() + .expect("Failed to run npx"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + }, + ) + .await; } #[tokio::test] #[cfg(not(windows))] // FIXME async fn test_detect_package_manager_with_package_manager_field() { - use std::process::Command; - - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package", "packageManager": "pnpm@8.15.0"}"#; - create_package_json(&temp_dir_path, package_content); - - let result = PackageManager::builder(temp_dir_path) - .build() - .await - .expect("Should detect pnpm with version"); - assert_eq!(result.client.to_string(), "pnpm"); - - // check shim files - let bin_prefix = result.get_bin_prefix(); - assert!(is_exists_file(bin_prefix.join("pnpm.cjs")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpm.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpm.ps1")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpx.cjs")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpx.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpx.ps1")).unwrap()); - - // run pnpm --version - let mut paths = - env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); - paths.insert(0, bin_prefix.into_path_buf()); - let output = Command::new("pnpm") - .arg("--version") - .env("PATH", env::join_paths(paths).unwrap()) - .output() - .expect("Failed to run pnpm"); - // println!("pnpm --version: {:?}", output); - assert!(output.status.success()); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "8.15.0"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + use std::process::Command; + + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = + r#"{"name": "test-package", "packageManager": "pnpm@8.15.0"}"#; + create_package_json(&temp_dir_path, package_content); + + let result = PackageManager::builder(temp_dir_path) + .build() + .await + .expect("Should detect pnpm with version"); + assert_eq!(result.client.to_string(), "pnpm"); + + // check shim files + let bin_prefix = result.get_bin_prefix(); + assert!(is_exists_file(bin_prefix.join("pnpm.cjs")).unwrap()); + assert!(is_exists_file(bin_prefix.join("pnpm.cmd")).unwrap()); + assert!(is_exists_file(bin_prefix.join("pnpm.ps1")).unwrap()); + assert!(is_exists_file(bin_prefix.join("pnpx.cjs")).unwrap()); + assert!(is_exists_file(bin_prefix.join("pnpx.cmd")).unwrap()); + assert!(is_exists_file(bin_prefix.join("pnpx.ps1")).unwrap()); + + // run pnpm --version + let mut paths = + env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); + paths.insert(0, bin_prefix.into_path_buf()); + let output = Command::new("pnpm") + .arg("--version") + .env("PATH", env::join_paths(paths).unwrap()) + .output() + .expect("Failed to run pnpm"); + // println!("pnpm --version: {:?}", output); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "8.15.0"); + }, + ) + .await; } #[tokio::test] @@ -3241,6 +3301,8 @@ mod tests { #[tokio::test] async fn test_download_success_package_manager_with_hash() { + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.as_os_str())], |_| async move { use std::process::Command; let temp_dir = create_temp_dir(); @@ -3280,10 +3342,15 @@ mod tests { // println!("pnpm --version: {:?}", output); assert!(output.status.success()); assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "1.22.22"); + + }) + .await; } #[tokio::test] async fn test_download_failed_package_manager_with_hash() { + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.as_os_str())], |_| async move { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "yarn@1.22.21+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"}"#; @@ -3308,10 +3375,15 @@ mod tests { } other => panic!("Expected PackageManagerHashMismatch error, got {other:?}"), } + + }) + .await; } #[tokio::test] async fn test_download_success_package_manager_with_sha1_and_sha224() { + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.as_os_str())], |_| async move { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "yarn@1.22.20+sha1.167c8ab8d9c8c3826d3725d9579aaea8b47a2b18"}"#; @@ -3333,69 +3405,86 @@ mod tests { .await .expect("Should detect pnpm with version and hash"); assert_eq!(result.client.to_string(), "pnpm"); + + }) + .await; } #[tokio::test] async fn test_detect_package_manager_with_yarn_package_manager_field() { - use std::process::Command; - - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package", "packageManager": "yarn@4.0.0"}"#; - create_package_json(&temp_dir_path, package_content); - - let result = PackageManager::builder(temp_dir_path.clone()) - .build() - .await - .expect("Should detect yarn with version"); - assert_eq!(result.client.to_string(), "yarn"); - - assert_eq!(result.version, "4.0.0"); - assert!( - result.get_bin_prefix().ends_with("yarn/bin"), - "bin_prefix should end with yarn/bin, but got {:?}", - result.get_bin_prefix() - ); - - // check shim files - let bin_prefix = result.get_bin_prefix(); - assert!(is_exists_file(bin_prefix.join("yarn.js")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarn")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarn.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarn.ps1")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarnpkg")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarnpkg.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarnpkg.ps1")).unwrap()); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + use std::process::Command; + + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package", "packageManager": "yarn@4.0.0"}"#; + create_package_json(&temp_dir_path, package_content); + + let result = PackageManager::builder(temp_dir_path.clone()) + .build() + .await + .expect("Should detect yarn with version"); + assert_eq!(result.client.to_string(), "yarn"); + + assert_eq!(result.version, "4.0.0"); + assert!( + result.get_bin_prefix().ends_with("yarn/bin"), + "bin_prefix should end with yarn/bin, but got {:?}", + result.get_bin_prefix() + ); - // run yarn --version - let mut cmd = "yarn"; - if cfg!(windows) { - cmd = "yarn.cmd"; - } - let mut paths = - env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); - paths.insert(0, bin_prefix.into_path_buf()); - let output = Command::new(cmd) - .arg("--version") - .env("PATH", env::join_paths(paths).unwrap()) - .output() - .expect("Failed to run yarn"); - assert!(output.status.success()); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "4.0.0"); + // check shim files + let bin_prefix = result.get_bin_prefix(); + assert!(is_exists_file(bin_prefix.join("yarn.js")).unwrap()); + assert!(is_exists_file(bin_prefix.join("yarn")).unwrap()); + assert!(is_exists_file(bin_prefix.join("yarn.cmd")).unwrap()); + assert!(is_exists_file(bin_prefix.join("yarn.ps1")).unwrap()); + assert!(is_exists_file(bin_prefix.join("yarnpkg")).unwrap()); + assert!(is_exists_file(bin_prefix.join("yarnpkg.cmd")).unwrap()); + assert!(is_exists_file(bin_prefix.join("yarnpkg.ps1")).unwrap()); + + // run yarn --version + let mut cmd = "yarn"; + if cfg!(windows) { + cmd = "yarn.cmd"; + } + let mut paths = + env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); + paths.insert(0, bin_prefix.into_path_buf()); + let output = Command::new(cmd) + .arg("--version") + .env("PATH", env::join_paths(paths).unwrap()) + .output() + .expect("Failed to run yarn"); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "4.0.0"); + }, + ) + .await; } #[tokio::test] async fn test_detect_package_manager_with_npm_package_manager_field() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package", "packageManager": "npm@10.0.0"}"#; - create_package_json(&temp_dir_path, package_content); - - let result = PackageManager::builder(temp_dir_path) - .build() - .await - .expect("Should detect npm with version"); - assert_eq!(result.client.to_string(), "npm"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package", "packageManager": "npm@10.0.0"}"#; + create_package_json(&temp_dir_path, package_content); + + let result = PackageManager::builder(temp_dir_path) + .build() + .await + .expect("Should detect npm with version"); + assert_eq!(result.client.to_string(), "npm"); + }, + ) + .await; } #[tokio::test] @@ -3417,22 +3506,29 @@ mod tests { #[tokio::test] async fn test_detect_package_manager_with_not_exists_version_in_package_manager_field() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = - r#"{"name": "test-package", "packageManager": "yarn@10000000000.0.0"}"#; - create_package_json(&temp_dir_path, package_content); - - let result = PackageManager::builder(temp_dir_path).build().await; - assert!(result.is_err()); - println!("result: {result:?}"); - // Check if it's the expected error type - if let Err(Error::PackageManagerVersionNotFound { name, version, .. }) = result { - assert_eq!(name, "yarn"); - assert_eq!(version, "10000000000.0.0"); - } else { - panic!("Expected PackageManagerVersionNotFound error, got {result:?}"); - } + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = + r#"{"name": "test-package", "packageManager": "yarn@10000000000.0.0"}"#; + create_package_json(&temp_dir_path, package_content); + + let result = PackageManager::builder(temp_dir_path).build().await; + assert!(result.is_err()); + println!("result: {result:?}"); + // Check if it's the expected error type + if let Err(Error::PackageManagerVersionNotFound { name, version, .. }) = result { + assert_eq!(name, "yarn"); + assert_eq!(version, "10000000000.0.0"); + } else { + panic!("Expected PackageManagerVersionNotFound error, got {result:?}"); + } + }, + ) + .await; } #[tokio::test] @@ -3450,26 +3546,33 @@ mod tests { #[tokio::test] async fn test_detect_package_manager_with_default_fallback() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package"}"#; - create_package_json(&temp_dir_path, package_content); - - let result = PackageManager::builder(temp_dir_path.clone()) - .package_manager_type(PackageManagerType::Yarn) - .build() - .await - .expect("Should use default"); - assert_eq!(result.client.to_string(), "yarn"); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir_path.join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "yarn"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package"}"#; + create_package_json(&temp_dir_path, package_content); + + let result = PackageManager::builder(temp_dir_path.clone()) + .package_manager_type(PackageManagerType::Yarn) + .build() + .await + .expect("Should use default"); + assert_eq!(result.client.to_string(), "yarn"); + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) + let package_json_path = temp_dir_path.join("package.json"); + let package_json: serde_json::Value = + serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "yarn"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // keep other fields + assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + }, + ) + .await; } #[tokio::test] @@ -3491,60 +3594,81 @@ mod tests { #[tokio::test] async fn test_detect_package_manager_prioritizes_package_manager_field_over_lock_files() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package", "packageManager": "yarn@4.0.0"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create pnpm-lock.yaml (should be ignored due to packageManager field) - fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'") - .expect("Failed to write pnpm-lock.yaml"); - - let result = PackageManager::builder(temp_dir_path) - .build() - .await - .expect("Should detect yarn from packageManager field"); - assert_eq!(result.client.to_string(), "yarn"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package", "packageManager": "yarn@4.0.0"}"#; + create_package_json(&temp_dir_path, package_content); + + // Create pnpm-lock.yaml (should be ignored due to packageManager field) + fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'") + .expect("Failed to write pnpm-lock.yaml"); + + let result = PackageManager::builder(temp_dir_path) + .build() + .await + .expect("Should detect yarn from packageManager field"); + assert_eq!(result.client.to_string(), "yarn"); + }, + ) + .await; } #[tokio::test] async fn test_detect_package_manager_prioritizes_pnpm_workspace_over_lock_files() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create yarn.lock (should be ignored due to pnpm-workspace.yaml) - fs::write(temp_dir_path.join("yarn.lock"), "# yarn lockfile v1") - .expect("Failed to write yarn.lock"); - - // Create pnpm-workspace.yaml (should take precedence) - let workspace_content = "packages:\n - 'packages/*'"; - create_pnpm_workspace_yaml(&temp_dir_path, workspace_content); - - let result = PackageManager::builder(temp_dir_path) - .build() - .await - .expect("Should detect pnpm from workspace file"); - assert_eq!(result.client.to_string(), "pnpm"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package"}"#; + create_package_json(&temp_dir_path, package_content); + + // Create yarn.lock (should be ignored due to pnpm-workspace.yaml) + fs::write(temp_dir_path.join("yarn.lock"), "# yarn lockfile v1") + .expect("Failed to write yarn.lock"); + + // Create pnpm-workspace.yaml (should take precedence) + let workspace_content = "packages:\n - 'packages/*'"; + create_pnpm_workspace_yaml(&temp_dir_path, workspace_content); + + let result = PackageManager::builder(temp_dir_path) + .build() + .await + .expect("Should detect pnpm from workspace file"); + assert_eq!(result.client.to_string(), "pnpm"); + }, + ) + .await; } #[tokio::test] async fn test_detect_package_manager_from_subdirectory() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let workspace_content = "packages:\n - 'packages/*'"; - create_pnpm_workspace_yaml(&temp_dir_path, workspace_content); - - let sub_dir = temp_dir_path.join("packages").join("app"); - fs::create_dir_all(&sub_dir).expect("Failed to create subdirectory"); - - let result = PackageManager::builder(sub_dir) - .build() - .await - .expect("Should detect pnpm from parent workspace"); - assert_eq!(result.client.to_string(), "pnpm"); - assert!(result.get_bin_prefix().ends_with("pnpm/bin")); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let workspace_content = "packages:\n - 'packages/*'"; + create_pnpm_workspace_yaml(&temp_dir_path, workspace_content); + + let sub_dir = temp_dir_path.join("packages").join("app"); + fs::create_dir_all(&sub_dir).expect("Failed to create subdirectory"); + + let result = PackageManager::builder(sub_dir) + .build() + .await + .expect("Should detect pnpm from parent workspace"); + assert_eq!(result.client.to_string(), "pnpm"); + assert!(result.get_bin_prefix().ends_with("pnpm/bin")); + }, + ) + .await; } #[tokio::test] @@ -3567,46 +3691,63 @@ mod tests { #[tokio::test] async fn test_download_package_manager() { - let result = download_package_manager(PackageManagerType::Yarn, "4.9.2", None).await; - assert!(result.is_ok()); - let (target_dir, package_name, version) = result.unwrap(); - println!("result: {target_dir:?}"); - assert!(is_exists_file(target_dir.join("bin/yarn")).unwrap()); - assert!(is_exists_file(target_dir.join("bin/yarn.cmd")).unwrap()); - assert_eq!(package_name, "@yarnpkg/cli-dist"); - assert_eq!(version, "4.9.2"); - - // again should skip download - let result = download_package_manager(PackageManagerType::Yarn, "4.9.2", None).await; - assert!(result.is_ok()); - let (target_dir, package_name, version) = result.unwrap(); - assert!(is_exists_file(target_dir.join("bin/yarn")).unwrap()); - assert!(is_exists_file(target_dir.join("bin/yarn.cmd")).unwrap()); - assert_eq!(package_name, "@yarnpkg/cli-dist"); - assert_eq!(version, "4.9.2"); - remove_dir_all_force(target_dir).await.unwrap(); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let result = + download_package_manager(PackageManagerType::Yarn, "4.9.2", None).await; + assert!(result.is_ok()); + let (target_dir, package_name, version) = result.unwrap(); + println!("result: {target_dir:?}"); + assert!(is_exists_file(target_dir.join("bin/yarn")).unwrap()); + assert!(is_exists_file(target_dir.join("bin/yarn.cmd")).unwrap()); + assert_eq!(package_name, "@yarnpkg/cli-dist"); + assert_eq!(version, "4.9.2"); + + // again should skip download + let result = + download_package_manager(PackageManagerType::Yarn, "4.9.2", None).await; + assert!(result.is_ok()); + let (target_dir, package_name, version) = result.unwrap(); + assert!(is_exists_file(target_dir.join("bin/yarn")).unwrap()); + assert!(is_exists_file(target_dir.join("bin/yarn.cmd")).unwrap()); + assert_eq!(package_name, "@yarnpkg/cli-dist"); + assert_eq!(version, "4.9.2"); + remove_dir_all_force(target_dir).await.unwrap(); + }, + ) + .await; } #[tokio::test] async fn test_download_package_manager_pnpm_v12_native() { - let result = - download_package_manager(PackageManagerType::Pnpm, "12.0.0-beta.0", None).await; - assert!(result.is_ok(), "{result:?}"); - let (target_dir, package_name, version) = result.unwrap(); - // native binary plus pnpm/pnpx shims, no JS entrypoint - let native_name = if cfg!(windows) { "bin/pnpm.native.exe" } else { "bin/pnpm.native" }; - assert!(is_exists_file(target_dir.join(native_name)).unwrap()); - assert!(is_exists_file(target_dir.join("bin/pnpm")).unwrap()); - assert!(is_exists_file(target_dir.join("bin/pnpm.cmd")).unwrap()); - assert!(is_exists_file(target_dir.join("bin/pnpx")).unwrap()); - assert_eq!(package_name, "pnpm"); - assert_eq!(version, "12.0.0-beta.0"); - - // again should hit the completeness fast-path and skip download - let result = - download_package_manager(PackageManagerType::Pnpm, "12.0.0-beta.0", None).await; - assert!(result.is_ok(), "{result:?}"); - remove_dir_all_force(target_dir).await.unwrap(); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let result = + download_package_manager(PackageManagerType::Pnpm, "12.0.0-beta.0", None).await; + assert!(result.is_ok(), "{result:?}"); + let (target_dir, package_name, version) = result.unwrap(); + // native binary plus pnpm/pnpx shims, no JS entrypoint + let native_name = + if cfg!(windows) { "bin/pnpm.native.exe" } else { "bin/pnpm.native" }; + assert!(is_exists_file(target_dir.join(native_name)).unwrap()); + assert!(is_exists_file(target_dir.join("bin/pnpm")).unwrap()); + assert!(is_exists_file(target_dir.join("bin/pnpm.cmd")).unwrap()); + assert!(is_exists_file(target_dir.join("bin/pnpx")).unwrap()); + assert_eq!(package_name, "pnpm"); + assert_eq!(version, "12.0.0-beta.0"); + + // again should hit the completeness fast-path and skip download + let result = + download_package_manager(PackageManagerType::Pnpm, "12.0.0-beta.0", None).await; + assert!(result.is_ok(), "{result:?}"); + remove_dir_all_force(target_dir).await.unwrap(); + }, + ) + .await; } #[tokio::test] @@ -3624,41 +3765,49 @@ mod tests { }); let expected_hash = format!("sha512.{}", hex::encode(Sha512::digest(yarn_js))); - let _guard = EnvConfig::test_guard(EnvConfig { - npm_registry: server.base_url().into(), - vite_plus_home: Some(vp_home.path().to_path_buf()), - ..EnvConfig::for_test() - }); - - let (install_dir, _, _) = - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&server.base_url())), + ], + |_| async { + let (install_dir, _, _) = download_package_manager( + PackageManagerType::Yarn, + "4.17.1", + Some(&expected_hash), + ) .await .expect("Corepack's Yarn binary hash should be accepted"); - assert_eq!(mock.hits(), 1); - assert_eq!( - fs::read_to_string(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(), - expected_hash, - "the install must record the pin it verified" - ); + assert_eq!(mock.hits(), 1); + assert_eq!( + fs::read_to_string(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)) + .unwrap(), + expected_hash, + "the install must record the pin it verified" + ); - // The same pin on a warm cache reads the record instead of the CLI. - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) - .await - .expect("a recorded pin should be accepted from the cache"); - assert_eq!(mock.hits(), 1, "a cached install must not download again"); - - // A different pin does not match the record, so vp hashes the cached - // CLI and reports the mismatch. - let other_hash = format!("sha512.{}", hex::encode(Sha512::digest(b"other"))); - let result = - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&other_hash)).await; - let Err(error @ Error::PackageManagerHashMismatch { .. }) = result else { - panic!("a pin the cached CLI does not match must fail: {result:?}"); - }; - let message = error.to_string(); - assert!(message.contains("yarn@4.17.1"), "{message}"); - assert!(message.contains("bin/yarn.js"), "{message}"); - assert_eq!(mock.hits(), 1, "a cached install must be checked without downloading"); + // The same pin on a warm cache reads the record instead of the CLI. + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await + .expect("a recorded pin should be accepted from the cache"); + assert_eq!(mock.hits(), 1, "a cached install must not download again"); + + // A different pin does not match the record, so vp hashes the cached + // CLI and reports the mismatch. + let other_hash = format!("sha512.{}", hex::encode(Sha512::digest(b"other"))); + let result = + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&other_hash)) + .await; + let Err(error @ Error::PackageManagerHashMismatch { .. }) = result else { + panic!("a pin the cached CLI does not match must fail: {result:?}"); + }; + let message = error.to_string(); + assert!(message.contains("yarn@4.17.1"), "{message}"); + assert!(message.contains("bin/yarn.js"), "{message}"); + assert_eq!(mock.hits(), 1, "a cached install must be checked without downloading"); + }, + ) + .await; } #[tokio::test] @@ -3676,27 +3825,36 @@ mod tests { }); let expected_hash = format!("sha512.{}", hex::encode(Sha512::digest(yarn_js))); - let _guard = EnvConfig::test_guard(EnvConfig { - npm_registry: server.base_url().into(), - vite_plus_home: Some(vp_home.path().to_path_buf()), - ..EnvConfig::for_test() - }); - - let (install_dir, _, _) = - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&server.base_url())), + ], + |_| async { + let (install_dir, _, _) = download_package_manager( + PackageManagerType::Yarn, + "4.17.1", + Some(&expected_hash), + ) .await .expect("Corepack's Yarn binary hash should be accepted"); - // An install by an older vp has no record. vp falls back to the CLI. - fs::remove_file(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(); - fs::write(install_dir.join("bin/yarn.js"), "corrupt").unwrap(); - let result = - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + // An install by an older vp has no record. vp falls back to the CLI. + fs::remove_file(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(); + fs::write(install_dir.join("bin/yarn.js"), "corrupt").unwrap(); + let result = download_package_manager( + PackageManagerType::Yarn, + "4.17.1", + Some(&expected_hash), + ) .await; - assert!( - matches!(result, Err(Error::PackageManagerHashMismatch { .. })), - "a cache without a record must be hashed: {result:?}" - ); + assert!( + matches!(result, Err(Error::PackageManagerHashMismatch { .. })), + "a cache without a record must be hashed: {result:?}" + ); + }, + ) + .await; } #[tokio::test] @@ -3718,22 +3876,31 @@ mod tests { }); let expected_hash = format!("sha512.{}", hex::encode(Sha512::digest(yarn_js))); - let _guard = EnvConfig::test_guard(EnvConfig { - npm_registry: server.base_url().into(), - vite_plus_home: Some(vp_home.path().to_path_buf()), - ..EnvConfig::for_test() - }); - - let (install_dir, _, _) = - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&server.base_url())), + ], + |_| async { + let (install_dir, _, _) = download_package_manager( + PackageManagerType::Yarn, + "4.17.1", + Some(&expected_hash), + ) .await .expect("the authenticated Yarn CLI should install"); - assert_eq!(fs::read_to_string(&victim).unwrap(), "original"); - assert!( - fs::symlink_metadata(install_dir.join("bin/yarn")).unwrap().file_type().is_file(), - "the generated shim must not reuse an archive-provided symlink" - ); + assert_eq!(fs::read_to_string(&victim).unwrap(), "original"); + assert!( + fs::symlink_metadata(install_dir.join("bin/yarn")) + .unwrap() + .file_type() + .is_file(), + "the generated shim must not reuse an archive-provided symlink" + ); + }, + ) + .await; } #[tokio::test] @@ -3777,139 +3944,176 @@ mod tests { }); let vp_home = create_temp_dir(); - let _guard = EnvConfig::test_guard(EnvConfig { - npm_registry: format!("http://{addr}").into(), - vite_plus_home: Some(vp_home.path().to_path_buf()), - ..EnvConfig::for_test() - }); - - let result = - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&format!("http://{addr}"))), + ], + |_| async { + let result = download_package_manager( + PackageManagerType::Yarn, + "4.17.1", + Some(&expected_hash), + ) .await; - server.abort(); + server.abort(); - assert!(result.is_ok(), "a fresh authenticated response should recover: {result:?}"); - assert_eq!( - attempts.load(Ordering::SeqCst), - 2, - "the bad CLI response should be retried exactly once" - ); + assert!( + result.is_ok(), + "a fresh authenticated response should recover: {result:?}" + ); + assert_eq!( + attempts.load(Ordering::SeqCst), + 2, + "the bad CLI response should be retried exactly once" + ); + }, + ) + .await; } #[tokio::test] async fn test_get_latest_version() { - let result = get_latest_version(PackageManagerType::Yarn).await; - assert!(result.is_ok()); - let version = result.unwrap(); - // println!("version: {:?}", version); - assert!(!version.is_empty()); - // check version should >= 4.0.0 - let version_req = VersionReq::parse(">=4.0.0"); - assert!(version_req.is_ok()); - let version_req = version_req.unwrap(); - assert!(version_req.matches(&Version::parse(&version).unwrap())); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let result = get_latest_version(PackageManagerType::Yarn).await; + assert!(result.is_ok()); + let version = result.unwrap(); + // println!("version: {:?}", version); + assert!(!version.is_empty()); + // check version should >= 4.0.0 + let version_req = VersionReq::parse(">=4.0.0"); + assert!(version_req.is_ok()); + let version_req = version_req.unwrap(); + assert!(version_req.matches(&Version::parse(&version).unwrap())); + }, + ) + .await; } #[tokio::test] async fn test_detect_package_manager_with_yarnrc_yml() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create .yarnrc.yml - fs::write( - temp_dir_path.join(".yarnrc.yml"), - "nodeLinker: node-modules\nyarnPath: .yarn/releases/yarn-4.0.0.cjs", + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package"}"#; + create_package_json(&temp_dir_path, package_content); + + // Create .yarnrc.yml + fs::write( + temp_dir_path.join(".yarnrc.yml"), + "nodeLinker: node-modules\nyarnPath: .yarn/releases/yarn-4.0.0.cjs", + ) + .expect("Failed to write .yarnrc.yml"); + + let result = PackageManager::builder(temp_dir_path.clone()) + .build() + .await + .expect("Should detect yarn from .yarnrc.yml"); + assert_eq!(result.client.to_string(), "yarn"); + assert!( + result.get_bin_prefix().ends_with("yarn/bin"), + "bin_prefix should end with yarn/bin, but got {:?}", + result.get_bin_prefix() + ); + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) + let package_json_path = temp_dir.path().join("package.json"); + let package_json: serde_json::Value = + serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "yarn"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // keep other fields + assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + }, ) - .expect("Failed to write .yarnrc.yml"); - - let result = PackageManager::builder(temp_dir_path.clone()) - .build() - .await - .expect("Should detect yarn from .yarnrc.yml"); - assert_eq!(result.client.to_string(), "yarn"); - assert!( - result.get_bin_prefix().ends_with("yarn/bin"), - "bin_prefix should end with yarn/bin, but got {:?}", - result.get_bin_prefix() - ); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir.path().join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "yarn"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + .await; } #[tokio::test] async fn test_detect_package_manager_with_pnpmfile_cjs() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create pnpmfile.cjs - fs::write(temp_dir_path.join("pnpmfile.cjs"), "module.exports = { hooks: {} }") - .expect("Failed to write pnpmfile.cjs"); - - let result = PackageManager::builder(temp_dir_path.clone()) - .build() - .await - .expect("Should detect pnpm from pnpmfile.cjs"); - assert_eq!(result.client.to_string(), "pnpm"); - assert!( - result.get_bin_prefix().ends_with("pnpm/bin"), - "bin_prefix should end with pnpm/bin, but got {:?}", - result.get_bin_prefix() - ); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir_path.join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "pnpm"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package"}"#; + create_package_json(&temp_dir_path, package_content); + + // Create pnpmfile.cjs + fs::write(temp_dir_path.join("pnpmfile.cjs"), "module.exports = { hooks: {} }") + .expect("Failed to write pnpmfile.cjs"); + + let result = PackageManager::builder(temp_dir_path.clone()) + .build() + .await + .expect("Should detect pnpm from pnpmfile.cjs"); + assert_eq!(result.client.to_string(), "pnpm"); + assert!( + result.get_bin_prefix().ends_with("pnpm/bin"), + "bin_prefix should end with pnpm/bin, but got {:?}", + result.get_bin_prefix() + ); + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) + let package_json_path = temp_dir_path.join("package.json"); + let package_json: serde_json::Value = + serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "pnpm"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // keep other fields + assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + }, + ) + .await; } #[tokio::test] async fn test_detect_package_manager_with_yarn_config_cjs() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create yarn.config.cjs - fs::write( - temp_dir_path.join("yarn.config.cjs"), - "module.exports = { nodeLinker: 'node-modules' }", + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package"}"#; + create_package_json(&temp_dir_path, package_content); + + // Create yarn.config.cjs + fs::write( + temp_dir_path.join("yarn.config.cjs"), + "module.exports = { nodeLinker: 'node-modules' }", + ) + .expect("Failed to write yarn.config.cjs"); + + let result = PackageManager::builder(temp_dir_path.clone()) + .build() + .await + .expect("Should detect yarn from yarn.config.cjs"); + assert_eq!(result.client.to_string(), "yarn"); + assert!( + result.get_bin_prefix().ends_with("yarn/bin"), + "bin_prefix should end with yarn/bin, but got {:?}", + result.get_bin_prefix() + ); + // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) + let package_json_path = temp_dir_path.join("package.json"); + let package_json: serde_json::Value = + serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); + let entry = &package_json["devEngines"]["packageManager"]; + assert_eq!(entry["name"].as_str().unwrap(), "yarn"); + assert_eq!(entry["onFail"].as_str().unwrap(), "download"); + // keep other fields + assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + }, ) - .expect("Failed to write yarn.config.cjs"); - - let result = PackageManager::builder(temp_dir_path.clone()) - .build() - .await - .expect("Should detect yarn from yarn.config.cjs"); - assert_eq!(result.client.to_string(), "yarn"); - assert!( - result.get_bin_prefix().ends_with("yarn/bin"), - "bin_prefix should end with yarn/bin, but got {:?}", - result.get_bin_prefix() - ); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir_path.join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "yarn"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + .await; } #[test] @@ -3954,31 +4158,38 @@ mod tests { #[tokio::test] async fn test_detect_package_manager_pnpmfile_over_yarn_config() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package"}"#; - create_package_json(&temp_dir_path, package_content); - - // Create both pnpmfile.cjs and yarn.config.cjs - fs::write(temp_dir_path.join("pnpmfile.cjs"), "module.exports = { hooks: {} }") - .expect("Failed to write pnpmfile.cjs"); - - fs::write( - temp_dir_path.join("yarn.config.cjs"), - "module.exports = { nodeLinker: 'node-modules' }", + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_content = r#"{"name": "test-package"}"#; + create_package_json(&temp_dir_path, package_content); + + // Create both pnpmfile.cjs and yarn.config.cjs + fs::write(temp_dir_path.join("pnpmfile.cjs"), "module.exports = { hooks: {} }") + .expect("Failed to write pnpmfile.cjs"); + + fs::write( + temp_dir_path.join("yarn.config.cjs"), + "module.exports = { nodeLinker: 'node-modules' }", + ) + .expect("Failed to write yarn.config.cjs"); + + // pnpmfile.cjs should be detected first (before yarn.config.cjs) + let result = PackageManager::builder(temp_dir_path) + .build() + .await + .expect("Should detect pnpm from pnpmfile.cjs"); + assert_eq!( + result.client.to_string(), + "pnpm", + "pnpmfile.cjs should be detected before yarn.config.cjs" + ); + }, ) - .expect("Failed to write yarn.config.cjs"); - - // pnpmfile.cjs should be detected first (before yarn.config.cjs) - let result = PackageManager::builder(temp_dir_path) - .build() - .await - .expect("Should detect pnpm from pnpmfile.cjs"); - assert_eq!( - result.client.to_string(), - "pnpm", - "pnpmfile.cjs should be detected before yarn.config.cjs" - ); + .await; } // Tests for bun package manager detection #[tokio::test] @@ -4132,20 +4343,27 @@ mod tests { #[tokio::test] async fn test_download_bun_package_manager_preserves_existing_install() { - let temp_dir = create_temp_dir(); - let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let version: Str = "1.3.14".into(); - write_pm_install(&vp_home, "bun", version.as_str(), InstallState::Complete); - let native_bin = vp_home - .join("package_manager") - .join("bun") - .join(version.as_str()) - .join("bun/bin/bun.native"); - fs::write(&native_bin, "existing bun").unwrap(); - - download_bun_package_manager(&version, &vp_home).await.unwrap(); - - assert_eq!(fs::read_to_string(native_bin).unwrap(), "existing bun"); + let vp_home = shared_vp_home(); + vp_shared::EnvConfig::with_vars_async( + [(env_vars::VP_HOME, vp_home.as_os_str())], + |_| async move { + let temp_dir = create_temp_dir(); + let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let version: Str = "1.3.14".into(); + write_pm_install(&vp_home, "bun", version.as_str(), InstallState::Complete); + let native_bin = vp_home + .join("package_manager") + .join("bun") + .join(version.as_str()) + .join("bun/bin/bun.native"); + fs::write(&native_bin, "existing bun").unwrap(); + + download_bun_package_manager(&version, &vp_home).await.unwrap(); + + assert_eq!(fs::read_to_string(native_bin).unwrap(), "existing bun"); + }, + ) + .await; } #[test] @@ -4251,29 +4469,34 @@ mod tests { .body("this is not a valid gzip archive"); }); - let _guard = EnvConfig::test_guard(EnvConfig { - npm_registry: server.base_url().into(), - vite_plus_home: Some(vp_home.path().to_path_buf()), - ..EnvConfig::for_test() - }); - - let result = download_package_manager(PackageManagerType::Pnpm, "10.0.0", None).await; - assert!(result.is_err(), "corrupt tarball should fail the install, got {result:?}"); - - // The per-install temp dir must be gone after the failure. + // VP_HOME pins to the root, so installs land directly under it. let pnpm_dir = vp_home.path().join("package_manager").join("pnpm"); - let leftovers: Vec<_> = fs::read_dir(&pnpm_dir) - .map(|rd| { - rd.filter_map(Result::ok) - .filter(|e| e.path().is_dir()) - .map(|e| e.file_name()) - .collect() - }) - .unwrap_or_default(); - assert!( - leftovers.is_empty(), - "failed install leaked temp dir(s) in {pnpm_dir:?}: {leftovers:?}" - ); + EnvConfig::with_vars_async( + [ + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&server.base_url())), + (env_vars::VP_HOME, vp_home.path().as_os_str()), + ], + |_| async { + let result = + download_package_manager(PackageManagerType::Pnpm, "10.0.0", None).await; + assert!(result.is_err(), "corrupt tarball should fail the install, got {result:?}"); + + // The per-install temp dir must be gone after the failure. + let leftovers: Vec<_> = fs::read_dir(&pnpm_dir) + .map(|rd| { + rd.filter_map(Result::ok) + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name()) + .collect() + }) + .unwrap_or_default(); + assert!( + leftovers.is_empty(), + "failed install leaked temp dir(s) in {pnpm_dir:?}: {leftovers:?}" + ); + }, + ) + .await; } #[test] diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index af5aa48aa8..17057f7fd9 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -217,7 +217,7 @@ fn format_install_failure_message( /// Write stdout and stderr from a failed install to `upgrade.log`. /// -/// The log is written to the **parent** of `version_dir` (i.e. `~/.vite-plus/upgrade.log`) +/// The log is written to the **parent** of `version_dir` (i.e. `/upgrade.log`) /// so it survives the cleanup that removes `version_dir` on failure. /// /// Returns the log file path on success, or `None` if writing failed. @@ -790,7 +790,7 @@ mod tests { #[tokio::test] async fn test_write_upgrade_log_creates_log_in_parent_dir() { let temp = tempfile::tempdir().unwrap(); - // Simulate ~/.vite-plus/0.1.15/ structure + // Simulate a `/0.1.15/` install structure let version_dir = AbsolutePathBuf::new(temp.path().join("0.1.15").to_path_buf()).unwrap(); tokio::fs::create_dir(&version_dir).await.unwrap(); diff --git a/crates/vp_shared/Cargo.toml b/crates/vp_shared/Cargo.toml index 7a566fc0c7..bc7e4a24fb 100644 --- a/crates/vp_shared/Cargo.toml +++ b/crates/vp_shared/Cargo.toml @@ -7,6 +7,13 @@ license.workspace = true publish = false rust-version.workspace = true +[features] +# Test configuration helpers (`EnvConfig::with_vars`/`with_vars_async`/ +# `scoped`/`scoped_async`). Downstream crates enable this through +# `[dev-dependencies]` so the helpers exist for their test builds but stay out +# of release binaries. +test-utils = ["dep:temp-env", "dep:tempfile"] + [dependencies] directories = { workspace = true } nix = { workspace = true, features = ["fs", "poll", "term"] } @@ -15,6 +22,8 @@ serde = { workspace = true } # use `preserve_order` feature to preserve the order of the fields in `package.json` serde_json = { workspace = true, features = ["preserve_order"] } supports-color = "3" +temp-env = { workspace = true, optional = true, features = ["async_closure"] } +tempfile = { workspace = true, optional = true } thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -32,6 +41,10 @@ webpki-root-certs = { workspace = true } [dev-dependencies] serial_test = { workspace = true } +tempfile = { workspace = true } +# Enables the `test-utils` feature for this crate's own tests and doctests +# (a package's dev-dependencies are active when it is the tested target). +vp_shared = { path = ".", features = ["test-utils"] } [lints] workspace = true diff --git a/crates/vp_shared/src/dirs.rs b/crates/vp_shared/src/dirs.rs new file mode 100644 index 0000000000..1b4b175590 --- /dev/null +++ b/crates/vp_shared/src/dirs.rs @@ -0,0 +1,72 @@ +//! On-disk path helpers for vite-plus. +//! +//! [`VpDirs`] owns the five **category roots** (`bin`, `data`, `cache`, +//! `config`, `state`), resolved once at construction via the strategy chain +//! in [`resolution`], with the user home injected by the caller +//! ([`EnvConfig`](crate::EnvConfig)). First-level directories under `data` +//! (`current`, `js_runtime`, …) and all deeper paths are joined by the +//! owning feature — not here. +//! +//! Comments and docs refer to category roots with the `/`, `/`, +//! `/`, `/`, `/` placeholders rather than concrete +//! per-layout paths (see `rfcs/directory-layout.md`). + +mod resolution; + +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +/// Platform-specific binary name for the `vp` CLI. +pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; + +/// Subdirectory name appended to XDG base directories and platform defaults. +pub(crate) const APP_DIR_NAME: &str = "vite-plus"; + +/// On-disk category roots for the vite-plus install. +/// +/// Values are resolved once at construction (see [`VpDirs::resolve`]) and +/// stored; process env changes afterwards are not observed. Child processes +/// resolve their own roots from their own environment. +/// +/// The struct carries no layout policy: the resolution chain maps every +/// source onto the same five roots, and features must not branch on how +/// those roots were produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VpDirs { + /// Executables and shims (`/vp`, `/node`, …). + pub bin: AbsolutePathBuf, + /// Payload data: CLI versions, managed runtimes and package managers + /// (`/current`, `/js_runtime`, `/package_manager`, …). + pub data: AbsolutePathBuf, + /// Disposable caches. + pub cache: AbsolutePathBuf, + /// User configuration (`/env`, `/config.json`, …). + pub config: AbsolutePathBuf, + /// State files (session version, upgrade-check cache, …). + pub state: AbsolutePathBuf, +} + +impl VpDirs { + /// Resolve the category roots by walking the source chain in + /// [`resolution`], using `home` for the existing-install probe and the + /// Unix platform defaults. The caller ([`EnvConfig`](crate::EnvConfig)) + /// resolves the home once and passes it in; resolution itself reads only + /// the override env vars, never `HOME`/`USERPROFILE`. Each category is + /// resolved independently, so roots may come from different sources + /// (e.g. `bin` from `VP_BIN_DIR`, `data` from `XDG_DATA_HOME`). + /// + /// Returns `None` only when no chain source proposes a category — with a + /// known home both platform tails are total (Unix defaults under the + /// home; Windows known folders with an `AppData`-under-home fallback), so + /// this is not expected in practice. A CLI without resolvable directories + /// cannot function, so callers treat this as a process-level invariant. + #[must_use] + pub fn resolve(home: &AbsolutePath) -> Option { + Some(Self { + bin: resolution::bin_dir(home)?, + data: resolution::data_dir(home)?, + cache: resolution::cache_dir(home)?, + config: resolution::config_dir(home)?, + state: resolution::state_dir(home)?, + }) + } +} diff --git a/crates/vp_shared/src/dirs/resolution.rs b/crates/vp_shared/src/dirs/resolution.rs new file mode 100644 index 0000000000..481e213c72 --- /dev/null +++ b/crates/vp_shared/src/dirs/resolution.rs @@ -0,0 +1,546 @@ +//! Directory resolution. +//! +//! Each category is resolved by walking an ordered chain of *resolution +//! sources*. A source either proposes a candidate (`Some`) or abstains +//! (`None`); the first proposal wins. +//! +//! Source chain on Unix: +//! [`VpHome`] → [`UserHome`] → [`VpEnvs`] → [`unix::Xdg`] → [`unix::Unix`] +//! (Windows omits XDG; platform tail is [`windows::Windows`]): +//! +//! - [`VpHome`] — `VP_HOME` override: pins the single-root mapping under +//! that root. +//! - [`UserHome`] — `/.vite-plus` (injected home), proposed only when +//! that directory already exists on disk. +//! - [`VpEnvs`] — `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR`. +//! - XDG / platform defaults. +//! +//! Single-root mapping (VpHome / UserHome): +//! `bin` → `/bin`, `data`/`config`/`state` → ``, +//! `cache` → `/cache`. +//! +//! The user home is **injected by the caller** ([`EnvConfig`](crate::EnvConfig) +//! resolves it once and passes it into every chain function); sources here +//! read the override env vars (`VP_HOME`, `VP_*_DIR`, `XDG_*`) but never +//! `HOME`/`USERPROFILE`. The Windows platform tail is the one exception: it +//! queries the OS known folders, which may be redirected independently of the +//! profile directory, and falls back to the conventional `AppData` locations +//! under the injected home when the query is unavailable (restricted service +//! or CI contexts). + +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use super::APP_DIR_NAME; +use crate::env_vars; + +/// Directory name of the single-root install probed under the user home. +pub(super) const VP_HOME_DIR_NAME: &str = ".vite-plus"; + +/// One layer in a resolution chain. +trait DirResolution { + fn bin_dir(&self) -> Option; + fn data_dir(&self) -> Option; + fn cache_dir(&self) -> Option; + fn config_dir(&self) -> Option; + fn state_dir(&self) -> Option; +} + +/// Absolute path from the process environment, or `None` if unset / +/// relative. +fn process_env_var(name: &str) -> Option { + std::env::var_os(name).and_then(|path| AbsolutePathBuf::new(path.into())) +} + +/// Explicit per-category overrides from the `VP_*_DIR` environment variables. +struct VpEnvs { + bin_dir: Option, + data_dir: Option, + cache_dir: Option, +} + +impl VpEnvs { + fn resolver(_home: &AbsolutePath) -> Self { + Self { + bin_dir: process_env_var(env_vars::VP_BIN_DIR), + data_dir: process_env_var(env_vars::VP_DATA_DIR), + cache_dir: process_env_var(env_vars::VP_CACHE_DIR), + } + } +} + +impl DirResolution for VpEnvs { + fn bin_dir(&self) -> Option { + self.bin_dir.clone() + } + + fn data_dir(&self) -> Option { + self.data_dir.clone() + } + + fn cache_dir(&self) -> Option { + self.cache_dir.clone() + } + + fn config_dir(&self) -> Option { + None + } + + fn state_dir(&self) -> Option { + None + } +} + +/// Single-root mapping: every category lives on one install tree. +/// +/// | Category | Path | +/// |----------|-----------------| +/// | bin | `/bin` | +/// | data | `` | +/// | cache | `/cache` | +/// | config | `` | +/// | state | `` | +struct SingleRoot { + root: Option, +} + +impl DirResolution for SingleRoot { + fn bin_dir(&self) -> Option { + self.root.clone().map(|root| root.join("bin")) + } + + fn data_dir(&self) -> Option { + self.root.clone() + } + + fn cache_dir(&self) -> Option { + self.root.clone().map(|root| root.join("cache")) + } + + fn config_dir(&self) -> Option { + self.root.clone() + } + + fn state_dir(&self) -> Option { + self.root.clone() + } +} + +/// `VP_HOME` override: always pins the single-root mapping when set. +struct VpHome; + +impl VpHome { + fn resolver(_home: &AbsolutePath) -> SingleRoot { + SingleRoot { root: process_env_var(env_vars::VP_HOME) } + } +} + +/// An existing single-root install under the injected home, `/.vite-plus`. +struct UserHome; + +impl UserHome { + /// Proposes the root only when it already exists on disk **as a + /// directory** — the installers gate with directory-only checks + /// (`[ -d ]` / `-PathType Container`), so a regular file or dangling + /// artifact must not claim the layout. Gating on the root itself accepts + /// `bin`/`cache` subdirs even when not yet created under an existing + /// install. + fn resolver(home: &AbsolutePath) -> SingleRoot { + let root = home.join(VP_HOME_DIR_NAME); + SingleRoot { root: root.as_path().is_dir().then_some(root) } + } +} + +macro_rules! resolutions { + ($method: ident, [$($resolution: ty),*]) => { + /// Resolve this category by walking the source chain; the first + /// proposal wins. `home` is the user home resolved by the caller; + /// sources that don't need it ignore it. + pub fn $method(home: &AbsolutePath) -> Option { + $({ + let source = <$resolution>::resolver(home); + if let Some(dir) = source.$method() { + return Some(dir); + } + })* + None + } + }; +} + +macro_rules! dir_methods { + ([$($method: ident),*], $resolutions:tt) => { + $( + resolutions!($method, $resolutions); + )* + }; +} + +/// Unix-only sources: XDG env vars and XDG-style platform defaults. +#[cfg(not(target_os = "windows"))] +mod unix { + use vt_path::{AbsolutePath, AbsolutePathBuf}; + + use super::{APP_DIR_NAME, DirResolution}; + use crate::env_vars; + + pub(super) struct Xdg; + + impl Xdg { + pub(super) fn resolver(_home: &AbsolutePath) -> Self { + Self + } + } + + impl DirResolution for Xdg { + fn bin_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_BIN_HOME).or_else(|| { + // `$XDG_DATA_HOME/../bin` fallback, lexically + // normalized so string-equality consumers (dedup, layout + // checks) see the canonical path. + super::process_env_var(env_vars::XDG_DATA_HOME) + .map(|dir| dir.join("../bin").clean()) + }) + } + + fn data_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_DATA_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + + fn cache_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_CACHE_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + + fn config_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_CONFIG_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + + fn state_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_STATE_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + } + + /// Platform default under the injected home directory. + pub(super) struct Unix(AbsolutePathBuf); + + impl Unix { + pub(super) fn resolver(home: &AbsolutePath) -> Self { + Self(home.to_absolute_path_buf()) + } + } + + impl DirResolution for Unix { + fn bin_dir(&self) -> Option { + Some(self.0.join(".local/bin")) + } + + fn data_dir(&self) -> Option { + Some(self.0.join(vt_str::format!(".local/share/{APP_DIR_NAME}"))) + } + + fn cache_dir(&self) -> Option { + Some(self.0.join(vt_str::format!(".cache/{APP_DIR_NAME}"))) + } + + fn config_dir(&self) -> Option { + Some(self.0.join(vt_str::format!(".config/{APP_DIR_NAME}"))) + } + + fn state_dir(&self) -> Option { + Some(self.0.join(vt_str::format!(".local/state/{APP_DIR_NAME}"))) + } + } +} + +/// Windows platform defaults under `%LOCALAPPDATA%` / `%APPDATA%`. +#[cfg(target_os = "windows")] +mod windows { + use directories::BaseDirs; + use vt_path::{AbsolutePath, AbsolutePathBuf}; + + use super::{APP_DIR_NAME, DirResolution}; + + pub(super) struct Windows { + local: Option, + roaming: Option, + } + + impl Windows { + pub(super) fn resolver(home: &AbsolutePath) -> Self { + // Production prefers the actual Windows known folders, which may + // be redirected independently of the user's profile directory. + Self::from_base_dirs(BaseDirs::new().as_ref(), home) + } + + /// Known-folder locations when available, else the conventional + /// `AppData` locations under the injected home — the query can fail + /// in restricted service or CI contexts, and a resolved home must + /// still yield a complete layout. + fn from_base_dirs(base: Option<&BaseDirs>, home: &AbsolutePath) -> Self { + Self { + local: base + .map(|dirs| dirs.data_local_dir().join(APP_DIR_NAME)) + .and_then(AbsolutePathBuf::new) + .or_else(|| Some(home.join("AppData").join("Local").join(APP_DIR_NAME))), + roaming: base + .map(|dirs| dirs.config_dir().join(APP_DIR_NAME)) + .and_then(AbsolutePathBuf::new) + .or_else(|| Some(home.join("AppData").join("Roaming").join(APP_DIR_NAME))), + } + } + } + + impl DirResolution for Windows { + fn bin_dir(&self) -> Option { + self.local.clone().map(|dir| dir.join("bin")) + } + + fn data_dir(&self) -> Option { + self.local.clone().map(|dir| dir.join("data")) + } + + fn cache_dir(&self) -> Option { + self.local.clone().map(|dir| dir.join("cache")) + } + + fn config_dir(&self) -> Option { + self.roaming.clone() + } + + fn state_dir(&self) -> Option { + self.local.clone().map(|dir| dir.join("state")) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn maps_known_folders_to_categories() { + let root = tempfile::tempdir().unwrap(); + let local = AbsolutePathBuf::new(root.path().join("local").join(APP_DIR_NAME)).unwrap(); + let roaming = + AbsolutePathBuf::new(root.path().join("roaming").join(APP_DIR_NAME)).unwrap(); + + let dirs = Windows { local: Some(local.clone()), roaming: Some(roaming.clone()) }; + + assert_eq!(dirs.bin_dir(), Some(local.join("bin"))); + assert_eq!(dirs.data_dir(), Some(local.join("data"))); + assert_eq!(dirs.cache_dir(), Some(local.join("cache"))); + assert_eq!(dirs.config_dir(), Some(roaming)); + assert_eq!(dirs.state_dir(), Some(local.join("state"))); + } + + #[test] + fn falls_back_to_home_app_data_when_known_folders_unavailable() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + + let dirs = Windows::from_base_dirs(None, &home); + + let local = home.join("AppData").join("Local").join(APP_DIR_NAME); + let roaming = home.join("AppData").join("Roaming").join(APP_DIR_NAME); + assert_eq!(dirs.bin_dir(), Some(local.join("bin"))); + assert_eq!(dirs.data_dir(), Some(local.join("data"))); + assert_eq!(dirs.cache_dir(), Some(local.join("cache"))); + assert_eq!(dirs.config_dir(), Some(roaming)); + assert_eq!(dirs.state_dir(), Some(local.join("state"))); + } + } +} + +// VpHome → UserHome → VpEnvs → (Xdg) → platform. +cfg_select! { + target_os = "windows" => { + dir_methods!( + [bin_dir, data_dir, cache_dir, config_dir, state_dir], + [VpHome, UserHome, VpEnvs, windows::Windows] + ); + } + _ => { + dir_methods!( + [bin_dir, data_dir, cache_dir, config_dir, state_dir], + [VpHome, UserHome, VpEnvs, unix::Xdg, unix::Unix] + ); + } +} + +#[cfg(test)] +mod tests { + #![expect(clippy::disallowed_types, reason = "test assertions bridge tempfile std paths")] + + use std::{ffi::OsStr, path::Path}; + + use super::*; + use crate::env_vars; + + fn assert_dir(got: Option, expected: &Path) { + let got = got.expect("resolution should yield a path"); + assert_eq!( + got.as_path(), + expected, + "resolved {} != expected {}", + got.as_path().display(), + expected.display() + ); + } + + #[test] + fn vp_envs_reads_absolute_category_paths() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + let bin = root.path().join("bin"); + let data = root.path().join("data"); + let cache = root.path().join("cache"); + + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(bin.as_os_str())), + (env_vars::VP_DATA_DIR, Some(data.as_os_str())), + (env_vars::VP_CACHE_DIR, Some(cache.as_os_str())), + ], + || { + let envs = VpEnvs::resolver(&home); + assert_dir(envs.bin_dir(), &bin); + assert_dir(envs.data_dir(), &data); + assert_dir(envs.cache_dir(), &cache); + }, + ); + } + + #[test] + fn vp_envs_drops_relative_and_unset() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(OsStr::new("relative/bin"))), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, Some(OsStr::new("relative/cache"))), + ], + || { + let envs = VpEnvs::resolver(&home); + assert!(envs.bin_dir().is_none()); + assert!(envs.data_dir().is_none()); + assert!(envs.cache_dir().is_none()); + }, + ); + } + + #[test] + fn user_home_maps_categories_to_single_root_layout() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + let vp_home = home.join(VP_HOME_DIR_NAME); + std::fs::create_dir_all(&vp_home).unwrap(); + + let place = UserHome::resolver(&home); + assert_dir(place.bin_dir(), vp_home.join("bin").as_path()); + assert_dir(place.data_dir(), vp_home.as_path()); + assert_dir(place.cache_dir(), vp_home.join("cache").as_path()); + assert_dir(place.config_dir(), vp_home.as_path()); + assert_dir(place.state_dir(), vp_home.as_path()); + } + + #[test] + fn user_home_abstains_when_root_missing() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + + let place = UserHome::resolver(&home); + assert!(place.bin_dir().is_none()); + assert!(place.data_dir().is_none()); + assert!(place.cache_dir().is_none()); + assert!(place.config_dir().is_none()); + assert!(place.state_dir().is_none()); + } + + #[test] + fn vp_home_set_pins_single_root_mapping() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + temp_env::with_var(env_vars::VP_HOME, Some(root.path().as_os_str()), || { + let place = VpHome::resolver(&home); + assert_dir(place.bin_dir(), &root.path().join("bin")); + assert_dir(place.data_dir(), root.path()); + assert_dir(place.cache_dir(), &root.path().join("cache")); + }); + } + + #[cfg(not(target_os = "windows"))] + mod unix { + use super::*; + use crate::dirs::resolution::unix::{Unix, Xdg}; + + #[test] + fn xdg_resolves_all_categories() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + let bin = root.path().join("bin-home"); + let data = root.path().join("data-home"); + let cache = root.path().join("cache-home"); + let config = root.path().join("config-home"); + let state = root.path().join("state-home"); + + temp_env::with_vars( + [ + (env_vars::XDG_BIN_HOME, Some(bin.as_os_str())), + (env_vars::XDG_DATA_HOME, Some(data.as_os_str())), + (env_vars::XDG_CACHE_HOME, Some(cache.as_os_str())), + (env_vars::XDG_CONFIG_HOME, Some(config.as_os_str())), + (env_vars::XDG_STATE_HOME, Some(state.as_os_str())), + ], + || { + let xdg = Xdg::resolver(&home); + assert_dir(xdg.bin_dir(), &bin); + assert_dir(xdg.data_dir(), &data.join(APP_DIR_NAME)); + assert_dir(xdg.cache_dir(), &cache.join(APP_DIR_NAME)); + assert_dir(xdg.config_dir(), &config.join(APP_DIR_NAME)); + assert_dir(xdg.state_dir(), &state.join(APP_DIR_NAME)); + }, + ); + } + + #[test] + fn xdg_bin_falls_back_to_normalized_data_home_sibling() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + let data = root.path().join("data-home"); + + temp_env::with_vars( + [(env_vars::XDG_BIN_HOME, None), (env_vars::XDG_DATA_HOME, Some(data.as_os_str()))], + || { + let xdg = Xdg::resolver(&home); + // uv-style `$XDG_DATA_HOME/../bin`, with `..` resolved lexically. + assert_dir(xdg.bin_dir(), &root.path().join("bin")); + }, + ); + } + + #[test] + fn platform_default_proposes_xdg_style_paths_under_home() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + + let unix = Unix::resolver(&home); + assert_dir(unix.bin_dir(), home.join(".local/bin").as_path()); + assert_dir( + unix.data_dir(), + home.join(vt_str::format!(".local/share/{APP_DIR_NAME}")).as_path(), + ); + assert_dir( + unix.cache_dir(), + home.join(vt_str::format!(".cache/{APP_DIR_NAME}")).as_path(), + ); + assert_dir( + unix.config_dir(), + home.join(vt_str::format!(".config/{APP_DIR_NAME}")).as_path(), + ); + assert_dir( + unix.state_dir(), + home.join(vt_str::format!(".local/state/{APP_DIR_NAME}")).as_path(), + ); + } + } +} diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index 6ff07cc398..24c522152b 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -1,60 +1,139 @@ //! Centralized environment variable configuration. //! //! Reads all known env vars once, provides global access via `EnvConfig::get()`. -//! Tests use `EnvConfig::test_scope()` for thread-local overrides — no `unsafe` -//! env mutation, no `#[serial]`, full parallelism. +//! The user home is resolved in [`EnvConfig::from_env`] (`HOME`/`USERPROFILE`, +//! platform-ordered like the installers, with a system base-dirs fallback) and +//! passed into [`VpDirs::resolve`]; directory resolution reads the override +//! env vars (`VP_HOME`, `VP_*_DIR`, `XDG_*`). //! //! # Usage //! //! ```rust //! use vp_shared::EnvConfig; //! -//! // Production: initialize once in main() -//! // EnvConfig::init(); -//! -//! // Access anywhere: +//! // Access anywhere; the process env is read once, lazily: //! let config = EnvConfig::get(); //! ``` //! //! # Tests //! //! ```rust -//! use vp_shared::EnvConfig; +//! use vp_shared::{EnvConfig, env_vars}; //! -//! // Override config for this test (thread-local, parallel-safe) -//! EnvConfig::test_scope( -//! EnvConfig::for_test_with_home("/tmp/test"), -//! || { -//! assert_eq!( -//! EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), -//! "/tmp/test" -//! ); -//! }, -//! ); +//! // Pin variables for a test; the callback receives the config resolved +//! // under them (anything not declared is inherited from the process +//! // environment): +//! EnvConfig::with_vars([(env_vars::VP_HOME, "/vp/home")], |config| { +//! assert_eq!(config.dirs.data.as_path(), std::path::Path::new("/vp/home")); +//! }); +//! +//! // Or run under a fresh temporary root when the concrete location is +//! // irrelevant: +//! EnvConfig::scoped(|config| { +//! assert!(config.dirs.cache.as_path().starts_with(config.dirs.data.as_path())); +//! }); //! ``` -use std::{cell::RefCell, path::PathBuf, sync::OnceLock}; +#[cfg(not(any(test, feature = "test-utils")))] +use std::sync::OnceLock; +use std::{collections::HashMap, ffi::OsString, sync::Arc}; +#[cfg(any(test, feature = "test-utils"))] +use std::{ffi::OsStr, future::Future, path::Path, path::PathBuf}; -use crate::env_vars; +use directories::BaseDirs; +use vt_path::AbsolutePathBuf; -/// Global config initialized once in `main()`. -static ENV_CONFIG: OnceLock = OnceLock::new(); +use crate::{VpDirs, env_vars}; -thread_local! { - /// Thread-local test override. Each test thread gets its own slot. - static TEST_CONFIG: RefCell> = const { RefCell::new(None) }; +/// Process-wide config, lazily initialized on the first [`EnvConfig::get`]. +/// +/// Test builds (including downstream crates with the `test-utils` feature) +/// never touch this: they re-resolve from the process environment on every +/// `get()`, so `temp_env`-scoped mutations are observed immediately. +#[cfg(not(any(test, feature = "test-utils")))] +static ENV_CONFIG: OnceLock> = OnceLock::new(); + +/// Process-env home lookup, mirroring the installers' platform ordering. +/// +/// On Windows `USERPROFILE` wins over `HOME`: `install.ps1` grandfathers +/// `%USERPROFILE%\.vite-plus`, and Unix-style shells (Git Bash, MSYS) set +/// `HOME` to a different directory, so a `HOME`-first lookup would miss an +/// existing single-root install. Matching the installer means the +/// existing-install probe checks `%USERPROFILE%\.vite-plus` only — `$HOME\.vite-plus` is not +/// consulted on Windows when both are set. On Unix `HOME` is authoritative. +#[cfg(target_os = "windows")] +fn home_env_path() -> Option { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .and_then(|path| AbsolutePathBuf::new(path.into())) +} + +/// Process-env home lookup (Unix: `HOME` first). +#[cfg(not(target_os = "windows"))] +fn home_env_path() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .and_then(|path| AbsolutePathBuf::new(path.into())) +} + +/// User home for [`EnvConfig::user_home`] and [`VpDirs`] resolution. +/// +/// Consults process `HOME`/`USERPROFILE` (platform-ordered, see +/// [`home_env_path`]) first, then [`BaseDirs`]. +fn user_home_path() -> Option { + if let Some(home) = home_env_path() { + return Some(home); + } + BaseDirs::new().and_then(|dirs| AbsolutePathBuf::new(dirs.home_dir().to_path_buf())) +} + +/// Capture the layout override variables that are set in the process +/// environment, verbatim, so persisted shell context can re-export them. +/// +/// Mirrors the resolution chain's precedence: a set `VP_HOME` pins every +/// category, making the per-category `VP_*_DIR` overrides dead letters, so +/// the two are never captured together. +fn dir_envs_from_process_env() -> HashMap<&'static str, String> { + /// The variable's value when set. + fn present(name: &'static str) -> Option { + std::env::var(name).ok() + } + + if let Some(home) = present(env_vars::VP_HOME) { + return HashMap::from([(env_vars::VP_HOME, home)]); + } + [env_vars::VP_BIN_DIR, env_vars::VP_DATA_DIR, env_vars::VP_CACHE_DIR] + .into_iter() + .filter_map(|name| Some((name, present(name)?))) + .collect() } /// Centralized configuration read from environment variables. /// /// All known vite-plus environment variables are read once at construction -/// time. Use `EnvConfig::get()` to access the current config from anywhere. +/// time, including the on-disk category roots ([`VpDirs`]). Use +/// `EnvConfig::get()` to access the current config from anywhere. #[derive(Debug, Clone)] pub struct EnvConfig { - /// Override for the vite-plus home directory (`~/.vite-plus`). + /// On-disk category roots, resolved once at construction. + /// + /// Features join their own paths under these roots (`/js_runtime`, + /// `/config.json`, …) instead of constructing install paths ad + /// hoc. + pub dirs: VpDirs, + + /// Layout override variables captured verbatim from the process + /// environment, for re-export to persisted shell context. /// - /// Env: `VP_HOME` - pub vite_plus_home: Option, + /// Contains either `VP_HOME` alone or the `VP_BIN_DIR` / `VP_DATA_DIR` + /// / `VP_CACHE_DIR` overrides that are set — never both: `VP_HOME` + /// precedes the per-category overrides in the resolution chain for + /// every category, so when it is set they are dead letters. Consumers + /// that write shell context (`vp env setup` scripts, the Windows + /// `vp-use.cmd` wrapper) render these so child processes resolve the + /// identical roots. Empty when no overrides are set: children then fall + /// through to the same defaults. + pub dir_envs: HashMap<&'static str, String>, /// NPM registry URL. /// @@ -91,8 +170,11 @@ pub struct EnvConfig { /// User home directory. /// - /// Env: `HOME` (Unix) / `USERPROFILE` (Windows) - pub user_home: Option, + /// Resolved once from `HOME`/`USERPROFILE` (platform-ordered, see + /// [`home_env_path`]) with a system base-dirs fallback. The same value is + /// passed to [`VpDirs::resolve`], so `user_home` and [`Self::dirs`] never + /// disagree. + pub user_home: AbsolutePathBuf, /// Explicitly specify the current shell. /// @@ -103,10 +185,22 @@ pub struct EnvConfig { impl EnvConfig { /// Read configuration from the real process environment. /// - /// Called once in `main()` via `EnvConfig::init()`. - pub fn from_env() -> Self { - Self { - vite_plus_home: std::env::var(env_vars::VP_HOME).ok().map(PathBuf::from), + /// Called lazily on the first [`EnvConfig::get`] (and cached) in non-test + /// builds; test builds call it on every `get()` so env-mutating serial + /// tests see fresh values. + /// + /// # Panics + /// + /// Panics when no user home can be resolved (`HOME`/`USERPROFILE` unset + /// and the system base-dirs query failing) or when directory resolution + /// still fails (see [`VpDirs::resolve`]) — a CLI without a home directory + /// cannot function. + fn from_env() -> Arc { + let user_home = user_home_path() + .expect("vite-plus could not resolve a user home directory: no home available"); + Arc::new(Self { + dirs: VpDirs::resolve(&user_home).expect("vite-plus directories could not be resolved"), + dir_envs: dir_envs_from_process_env(), npm_registry: std::env::var(env_vars::NPM_CONFIG_REGISTRY) .or_else(|_| std::env::var(env_vars::NPM_CONFIG_REGISTRY_UPPER)) .unwrap_or_else(|_| "https://registry.npmjs.org".into()) @@ -118,117 +212,185 @@ impl EnvConfig { is_ci: std::env::var("CI").is_ok(), env_use_eval_enable: std::env::var(env_vars::VP_ENV_USE_EVAL_ENABLE).is_ok(), node_version: std::env::var(env_vars::VP_NODE_VERSION).ok(), - user_home: std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .ok() - .map(PathBuf::from), + user_home, vp_shell: std::env::var(env_vars::VP_SHELL).ok(), - } - } - - /// Initialize the global config from the process environment. - /// - /// Call once at program startup (in `main()`). - /// Subsequent calls are no-ops. - pub fn init() { - let _ = ENV_CONFIG.set(Self::from_env()); + }) } /// Get the current config. /// - /// Priority: thread-local test override > global > `from_env()`. + /// In test builds (`cfg(test)`, or a downstream crate's tests with the + /// `test-utils` feature enabled via dev-dependencies) the config is + /// re-resolved on every call — intentionally **not** cached — so + /// [`with_vars`](Self::with_vars) scopes and env-mutating serial tests + /// are observed immediately. /// - /// This is the primary way to access configuration throughout the codebase. + /// In non-test builds the process env is read once on the first call and + /// cached process-wide. + /// + /// Returns a shared handle — cloning the `Arc` is a refcount bump, so + /// callers should hold or borrow it rather than cloning the underlying + /// config. This is the primary way to access configuration throughout the + /// codebase. #[must_use] - pub fn get() -> Self { - TEST_CONFIG.with(|c| { - c.borrow() - .clone() - .unwrap_or_else(|| ENV_CONFIG.get().cloned().unwrap_or_else(Self::from_env)) - }) + pub fn get() -> Arc { + #[cfg(any(test, feature = "test-utils"))] + { + Self::from_env() + } + #[cfg(not(any(test, feature = "test-utils")))] + { + ENV_CONFIG.get_or_init(Self::from_env).clone() + } } +} - /// Run a closure with a test config override (thread-local, parallel-safe). +/// What to do with one variable in a [`EnvConfig::with_vars`] pin list. +/// +/// Plain values set the variable; an `Option` sets it when `Some` and +/// **unsets** it when `None` — the only way to exercise presence-checked +/// variables (`CI` → `is_ci`, `VP_ENV_USE_EVAL_ENABLE`, …) in their "off" +/// state, since assigning an empty value still counts as set. +/// +/// Implemented for the common string/path types instead of via `ToString`: +/// paths may be non-UTF-8, and a lossy conversion would silently corrupt +/// them. (A blanket `impl>` is impossible — coherence rules +/// it out next to the `Option` impl.) +#[cfg(any(test, feature = "test-utils"))] +pub trait EnvValue { + /// The value to pin, or `None` to unset the variable for the scope. + fn into_var_value(self) -> Option; +} + +#[cfg(any(test, feature = "test-utils"))] +macro_rules! impl_env_value { + ($($t:ty),* $(,)?) => {$( + impl EnvValue for $t { + fn into_var_value(self) -> Option { + Some(AsRef::::as_ref(&self).to_os_string()) + } + } + )*}; +} + +#[cfg(any(test, feature = "test-utils"))] +impl_env_value!(&str, &String, String, &OsStr, OsString, &Path, PathBuf, &PathBuf); + +#[cfg(any(test, feature = "test-utils"))] +impl> EnvValue for Option { + fn into_var_value(self) -> Option { + self.map(|value| value.as_ref().to_os_string()) + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl EnvConfig { + /// Run `f` with the given environment variables set, then restore the + /// previous process environment. /// - /// The override only applies to the current thread. - /// Other test threads see their own overrides or the global config. + /// Any process variable may be pinned; variables not declared here are + /// inherited from the process environment as-is. `f` receives the config + /// resolved under the pinned variables — no [`EnvConfig::get`] call + /// needed (and `get` re-resolves on every call, so code under test + /// observes the same values, including the derived directory roots). /// - /// # Example + /// This delegates to [`temp_env::with_vars`], which holds a process-wide + /// lock for the duration — no `#[serial]` needed between `with_vars` + /// tests, and nested scopes shadow outer ones until they return. /// /// ```rust - /// use vp_shared::EnvConfig; + /// use vp_shared::{EnvConfig, env_vars}; + /// + /// EnvConfig::with_vars([(env_vars::VP_HOME, "/vp/home")], |config| { + /// assert_eq!(config.dirs.bin.as_path(), std::path::Path::new("/vp/home/bin")); + /// assert_eq!(config.dirs.data.as_path(), std::path::Path::new("/vp/home")); + /// }); /// - /// EnvConfig::test_scope( - /// EnvConfig::for_test_with_home("/tmp/test"), - /// || { - /// let config = EnvConfig::get(); - /// assert_eq!( - /// config.vite_plus_home.as_ref().unwrap().to_str().unwrap(), - /// "/tmp/test" - /// ); - /// }, - /// ); + /// // `None` values unset the variable — the only "off" state for + /// // presence-checked variables like `CI`: + /// EnvConfig::with_vars([("CI", Some("true")), (env_vars::VP_SHELL, None)], |config| { + /// assert!(config.is_ci); + /// assert!(config.vp_shell.is_none()); + /// }); /// ``` - pub fn test_scope(config: Self, f: impl FnOnce() -> R) -> R { - TEST_CONFIG.with(|c| { - let prev = c.borrow_mut().replace(config); - let result = f(); - *c.borrow_mut() = prev; - result - }) + pub fn with_vars( + vars: impl IntoIterator, + f: impl FnOnce(Arc) -> R, + ) -> R { + let vars: Vec<(&'static str, Option)> = + vars.into_iter().map(|(name, value)| (name, value.into_var_value())).collect(); + temp_env::with_vars(vars, || f(Self::get())) } - /// Create a test configuration with sensible defaults. + /// [`with_vars`](Self::with_vars) for async tests: the variables stay set + /// across `.await` points and are restored when the future completes. /// - /// No environment variables are read. Use struct update syntax - /// to override specific fields: + /// Requires a current-thread runtime (the `#[tokio::test`] default): + /// `temp_env`'s lock guard is held across the awaited future and is not + /// `Send`. /// - /// ```rust - /// # use vp_shared::EnvConfig; - /// let config = EnvConfig { - /// npm_registry: "https://custom.registry.example".into(), - /// ..EnvConfig::for_test() - /// }; + /// ```no_run + /// # async fn example() { + /// use vp_shared::{EnvConfig, env_vars}; + /// + /// EnvConfig::with_vars_async([("CI", "true")], |config| async move { + /// assert!(config.is_ci); + /// }) + /// .await; + /// # } /// ``` - #[must_use] - pub fn for_test() -> Self { - Self { - vite_plus_home: None, - npm_registry: "https://registry.npmjs.org".into(), - node_dist_mirror: None, - node_skip_signature_verify: false, - is_ci: false, - env_use_eval_enable: false, - node_version: None, - user_home: None, - vp_shell: None, - } - } - - /// Create a test configuration with a custom home directory. - pub fn for_test_with_home(home: impl Into) -> Self { - Self { vite_plus_home: Some(home.into()), ..Self::for_test() } + pub async fn with_vars_async>( + vars: impl IntoIterator, + f: impl FnOnce(Arc) -> Fut, + ) -> R { + let vars: Vec<(&'static str, Option)> = + vars.into_iter().map(|(name, value)| (name, value.into_var_value())).collect(); + // The config must resolve after the variables are pinned, so it is + // built inside the scoped future rather than as a call argument. + temp_env::async_with_vars(vars, async move { f(Self::get()).await }).await } - /// Set a test config override and return a guard that restores the previous on drop. - /// Works with async tests since it uses RAII instead of closures. - #[must_use] - pub fn test_guard(config: Self) -> TestEnvGuard { - let prev = TEST_CONFIG.with(|c| c.borrow_mut().replace(config)); - TestEnvGuard { prev } + /// Run `f` with every vite-plus directory pinned under a fresh temporary + /// directory (via `VP_HOME`), deleted when the scope returns. + /// + /// For tests that read or write through the resolved directories without + /// caring where they live. Tests that need the concrete path — or a + /// shared root that keeps download caches warm across tests — should + /// create their own directory and use [`with_vars`](Self::with_vars) + /// instead. + /// + /// ```rust + /// use vp_shared::EnvConfig; + /// + /// EnvConfig::scoped(|config| { + /// assert!(config.dirs.bin.as_path().starts_with(config.dirs.data.as_path())); + /// }); + /// ``` + pub fn scoped(f: impl FnOnce(Arc) -> R) -> R { + let home = tempfile::tempdir().expect("failed to create a temporary VP_HOME"); + Self::with_vars([(env_vars::VP_HOME, home.path())], f) } -} - -/// RAII guard for test config override. Restores previous config on drop. -pub struct TestEnvGuard { - prev: Option, -} -impl Drop for TestEnvGuard { - fn drop(&mut self) { - TEST_CONFIG.with(|c| { - *c.borrow_mut() = self.prev.take(); - }); + /// [`scoped`](Self::scoped) for async tests: the pin stays active across + /// `.await` points and the temporary directory is deleted when the future + /// completes. + /// + /// Requires a current-thread runtime (the `#[tokio::test`] default), like + /// [`with_vars_async`](Self::with_vars_async). + /// + /// ```no_run + /// # async fn example() { + /// use vp_shared::EnvConfig; + /// + /// EnvConfig::scoped_async(|config| async move { + /// assert!(config.dirs.data.as_path().is_absolute()); + /// }) + /// .await; + /// # } + /// ``` + pub async fn scoped_async>(f: impl FnOnce(Arc) -> Fut) -> R { + let home = tempfile::tempdir().expect("failed to create a temporary VP_HOME"); + Self::with_vars_async([(env_vars::VP_HOME, home.path())], f).await } } @@ -236,69 +398,190 @@ impl Drop for TestEnvGuard { mod tests { use super::*; + /// `VP_HOME` pins every category to the single-root mapping. #[test] - fn test_for_test_returns_defaults() { - let config = EnvConfig::for_test(); - assert!(config.vite_plus_home.is_none()); - assert_eq!(config.npm_registry, "https://registry.npmjs.org"); - assert!(!config.is_ci); - assert!(!config.node_skip_signature_verify); + fn with_vars_vp_home_pins_single_root() { + let root = tempfile::tempdir().unwrap(); + EnvConfig::with_vars([(env_vars::VP_HOME, root.path())], |config| { + assert_eq!(config.dirs.bin.as_path(), root.path().join("bin")); + assert_eq!(config.dirs.data.as_path(), root.path()); + assert_eq!(config.dirs.cache.as_path(), root.path().join("cache")); + assert_eq!(config.dirs.config.as_path(), root.path()); + assert_eq!(config.dirs.state.as_path(), root.path()); + }); + } + + /// `HOME` (with `USERPROFILE` pinned to the same path for Windows' + /// profile-first ordering) yields the platform split layout on Unix. + #[cfg(not(target_os = "windows"))] + #[test] + fn with_vars_home_yields_split_layout() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + EnvConfig::with_vars([("HOME", &home), ("USERPROFILE", &home)], |config| { + assert_eq!(config.user_home.as_path(), home); + assert_eq!(config.dirs.bin.as_path(), home.join(".local/bin")); + assert_eq!(config.dirs.data.as_path(), home.join(".local/share/vite-plus")); + assert_eq!(config.dirs.cache.as_path(), home.join(".cache/vite-plus")); + assert_eq!(config.dirs.config.as_path(), home.join(".config/vite-plus")); + assert_eq!(config.dirs.state.as_path(), home.join(".local/state/vite-plus")); + }); } + /// Known variables the test does not declare are inherited from the + /// process environment as-is. #[test] - fn test_for_test_with_home() { - let config = EnvConfig::for_test_with_home("/tmp/test-home"); - assert_eq!(config.vite_plus_home, Some(PathBuf::from("/tmp/test-home"))); + fn with_vars_inherits_undeclared_vars() { + EnvConfig::with_vars([("CI", "true"), (env_vars::VP_NODE_VERSION, "22.0.0")], |_| { + EnvConfig::with_vars([(env_vars::VP_HOME, "/vp/home")], |config| { + assert!(config.is_ci, "process CI is inherited inside with_vars"); + assert_eq!(config.node_version.as_deref(), Some("22.0.0")); + }); + }); } + /// Declared non-directory variables land on the config. #[test] - fn test_struct_update_syntax() { - let config = EnvConfig { - npm_registry: "https://custom.registry".into(), - is_ci: true, - ..EnvConfig::for_test() - }; - assert_eq!(config.npm_registry, "https://custom.registry"); - assert!(config.is_ci); - assert!(config.vite_plus_home.is_none()); + fn with_vars_sets_scalar_fields() { + EnvConfig::with_vars( + [ + (env_vars::VP_HOME, "/vp/home"), + (env_vars::NPM_CONFIG_REGISTRY, "https://registry.npmmirror.com"), + ("CI", "true"), + (env_vars::VP_SHELL, "fish"), + ], + |config| { + assert_eq!(config.npm_registry, "https://registry.npmmirror.com"); + assert!(config.is_ci); + assert_eq!(config.vp_shell.as_deref(), Some("fish")); + }, + ); } #[test] - fn test_scope_overrides_get() { - EnvConfig::test_scope(EnvConfig::for_test_with_home("/scoped/home"), || { - let config = EnvConfig::get(); - assert_eq!(config.vite_plus_home.as_ref().unwrap().to_str().unwrap(), "/scoped/home"); + fn with_vars_restores_after_scope() { + // The outer scope pins a known baseline under temp_env's lock, so the + // restore assertion is isolated from other env-mutating tests. + EnvConfig::with_vars([(env_vars::NPM_CONFIG_REGISTRY, "https://before")], |config| { + EnvConfig::with_vars( + [(env_vars::NPM_CONFIG_REGISTRY, "https://custom.registry")], + |config| { + assert_eq!(config.npm_registry, "https://custom.registry"); + }, + ); + assert_eq!(config.npm_registry, "https://before"); }); } #[test] - fn test_scope_restores_previous() { - let before = EnvConfig::get(); - EnvConfig::test_scope(EnvConfig::for_test_with_home("/tmp/scope"), || { - assert!(EnvConfig::get().vite_plus_home.is_some()); + fn with_vars_nested_scopes_shadow_outer() { + EnvConfig::with_vars([(env_vars::NPM_CONFIG_REGISTRY, "https://outer")], |config| { + assert_eq!(config.npm_registry, "https://outer"); + EnvConfig::with_vars([(env_vars::NPM_CONFIG_REGISTRY, "https://inner")], |config| { + assert_eq!(config.npm_registry, "https://inner"); + }); + assert_eq!(config.npm_registry, "https://outer"); }); - let after = EnvConfig::get(); - assert_eq!(before.vite_plus_home.is_some(), after.vite_plus_home.is_some()); } + /// `dir_envs` mirrors the declared layout overrides so persisted shell + /// context can re-export them. #[test] - fn test_nested_scopes() { - EnvConfig::test_scope(EnvConfig::for_test_with_home("/outer"), || { - assert_eq!( - EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), - "/outer" - ); - EnvConfig::test_scope(EnvConfig::for_test_with_home("/inner"), || { + fn with_vars_populates_dir_envs() { + let root = tempfile::tempdir().unwrap(); + let cache = root.path().join("cache"); + EnvConfig::with_vars( + [ + (env_vars::VP_BIN_DIR, OsStr::new("relative/bin")), + (env_vars::VP_CACHE_DIR, cache.as_os_str()), + ], + |config| { + assert_eq!(config.dir_envs.len(), 2); + assert_eq!(config.dir_envs[env_vars::VP_BIN_DIR], "relative/bin"); + assert_eq!(config.dir_envs[env_vars::VP_CACHE_DIR], cache.to_string_lossy()); + }, + ); + } + + /// A declared `VP_HOME` pins every category, so per-category `VP_*_DIR` + /// overrides are dead letters and must not be captured alongside it. + #[test] + fn dir_envs_vp_home_excludes_category_overrides() { + let root = tempfile::tempdir().unwrap(); + let custom_bin = root.path().join("custom-bin"); + let custom_data = root.path().join("custom-data"); + EnvConfig::with_vars( + [ + (env_vars::VP_HOME, root.path().as_os_str()), + (env_vars::VP_BIN_DIR, custom_bin.as_os_str()), + (env_vars::VP_DATA_DIR, custom_data.as_os_str()), + ], + |config| { assert_eq!( - EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), - "/inner" + config.dir_envs, + HashMap::from([( + env_vars::VP_HOME, + root.path().to_string_lossy().into_owned() + )]) ); + // ...and resolution honors the pin, not the overrides. + assert_eq!(config.dirs.bin.as_path(), root.path().join("bin")); + }, + ); + } + + /// Unix keeps `HOME` authoritative even when `USERPROFILE` is also set + /// (e.g. exported by a mixed shell environment). + #[cfg(not(target_os = "windows"))] + #[test] + fn user_home_env_prefers_home_over_userprofile() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let profile = root.path().join("profile"); + + EnvConfig::with_vars([("HOME", &home), ("USERPROFILE", &profile)], |_| { + assert_eq!(user_home_path().unwrap().as_path(), home.as_path()); + }); + } + + /// Windows: `%USERPROFILE%` must win over a Git Bash `HOME`, matching + /// install.ps1's grandfathering check. + #[cfg(target_os = "windows")] + #[test] + fn user_home_env_prefers_userprofile_over_home() { + let root = tempfile::tempdir().unwrap(); + let profile = root.path().join("profile"); + let git_bash_home = root.path().join("git-bash-home"); + + EnvConfig::with_vars([("USERPROFILE", &profile), ("HOME", &git_bash_home)], |_| { + assert_eq!(user_home_path().unwrap().as_path(), profile.as_path()); + }); + } + + /// `scoped` pins every category under one fresh temporary root. + #[test] + fn scoped_pins_dirs_under_temp_root() { + EnvConfig::scoped(|config| { + let root = config.dirs.data.as_path(); + assert_eq!(config.dirs.bin.as_path(), root.join("bin")); + assert_eq!(config.dirs.cache.as_path(), root.join("cache")); + assert_eq!(config.dirs.config.as_path(), root); + assert_eq!(config.dirs.state.as_path(), root); + // The root is a real directory while the scope is active. + assert!(root.is_dir()); + }); + } + + /// A `None` value unsets the variable for the scope and restores it + /// afterwards — the only "off" state for presence-checked variables. + #[test] + fn with_vars_none_unsets_variable() { + EnvConfig::with_vars([("CI", "true")], |config| { + assert!(config.is_ci); + EnvConfig::with_vars([("CI", None::<&str>)], |config| { + assert!(!config.is_ci); }); - // Restored to outer - assert_eq!( - EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), - "/outer" - ); + assert!(config.is_ci); }); } diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 21612c38c4..06b16583cf 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -9,12 +9,49 @@ //! //! Standard system variables (`PATH`, `HOME`, `CI`, etc.) are intentionally //! excluded — they're well-known and benefit less from constant definitions. +//! The `XDG_*_HOME` base-directory variables are the exception: they +//! participate in `VpDirs` path resolution, so they get constants too. // ── Config: read once at startup via EnvConfig ────────────────────────── -/// Override for the vite-plus home directory (default: `~/.vite-plus`). +/// Override pinning every category root under one directory. +/// +/// Highest-priority layout rule: when set, `bin` resolves to `/bin`, +/// `cache` to `/cache`, and data/config/state to `` itself. +/// Exported by older env scripts and custom-location installs; fresh +/// installs no longer set it. Prefer `VP_*_DIR` / `XDG_*` variables. pub const VP_HOME: &str = "VP_HOME"; +/// Override directory for executables and shims. +/// +/// Applies within the XDG/platform resolution; a `VP_HOME`-pinned or probed +/// single-root install is all-or-nothing and ignores it. +pub const VP_BIN_DIR: &str = "VP_BIN_DIR"; + +/// Override directory for payload data: CLI versions, Node.js runtimes, and +/// package managers (the disk hogs). +pub const VP_DATA_DIR: &str = "VP_DATA_DIR"; + +/// Override directory for the disposable cache. +pub const VP_CACHE_DIR: &str = "VP_CACHE_DIR"; + +// ── XDG base directories: read by VpDirs resolution ──────────────────── + +/// XDG base directory for executables. +pub const XDG_BIN_HOME: &str = "XDG_BIN_HOME"; + +/// XDG base directory for user configuration. +pub const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME"; + +/// XDG base directory for user data. +pub const XDG_DATA_HOME: &str = "XDG_DATA_HOME"; + +/// XDG base directory for user state. +pub const XDG_STATE_HOME: &str = "XDG_STATE_HOME"; + +/// XDG base directory for disposable caches. +pub const XDG_CACHE_HOME: &str = "XDG_CACHE_HOME"; + /// Log filter string for `tracing_subscriber` (e.g. `"debug"`, `"vt=trace"`). pub const VP_LOG: &str = "VP_LOG"; @@ -131,6 +168,12 @@ pub const VP_INSECURE_TLS: &str = "VP_INSECURE_TLS"; // ── Testing / Development ─────────────────────────────────────────────── +/// When set to `1`, the global CLI prints ``, ``, and `` +/// (one per line) from [`crate::EnvConfig`] and exits. Used by installers +/// that already have a `vp` binary and must not re-implement directory +/// resolution. +pub const VP_DUMP_DIRS: &str = "VP_DUMP_DIRS"; + /// Override the trampoline binary path for tests. /// /// When set, `get_trampoline_path()` uses this path instead of resolving diff --git a/crates/vp_shared/src/home.rs b/crates/vp_shared/src/home.rs deleted file mode 100644 index c0004fdaf9..0000000000 --- a/crates/vp_shared/src/home.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::env; - -use directories::BaseDirs; -use vt_path::{AbsolutePathBuf, current_dir}; - -use crate::EnvConfig; - -/// Default `VP_HOME` directory name -const VITE_PLUS_HOME_DIR: &str = ".vite-plus"; - -/// Platform-specific binary name for the `vp` CLI. -pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; - -/// Get the vite-plus home directory. -/// -/// Uses `EnvConfig::get().vite_plus_home` if set, -/// or the `VP_HOME/bin` directory on `PATH`, -/// otherwise defaults to `~/.vite-plus`. -/// Falls back to `$CWD/.vite-plus` if the home directory cannot be determined. -pub fn get_vp_home() -> std::io::Result { - let config = EnvConfig::get(); - if let Some(ref home) = config.vite_plus_home - && let Some(path) = AbsolutePathBuf::new(home.clone()) - { - return Ok(path); - } - - // Project-local .bin wrappers can shadow Vite+ shims; only trust a full install layout. - if let Some(home) = infer_vp_home_from_path()? { - return Ok(home); - } - - // Default to ~/.vite-plus - match BaseDirs::new() { - Some(dirs) => { - let home = AbsolutePathBuf::new(dirs.home_dir().to_path_buf()).unwrap(); - Ok(home.join(VITE_PLUS_HOME_DIR)) - } - None => { - // Fallback to $CWD/.vite-plus - Ok(current_dir()?.join(VITE_PLUS_HOME_DIR)) - } - } -} - -fn infer_vp_home_from_path() -> std::io::Result> { - let Some(path_env) = env::var_os("PATH") else { - return Ok(None); - }; - - for path_entry in env::split_paths(&path_env) { - if path_entry.as_os_str().is_empty() { - continue; - } - - let bin_dir = if path_entry.is_absolute() { - AbsolutePathBuf::new(path_entry).unwrap() - } else { - current_dir()?.join(path_entry) - }; - if bin_dir.as_path().file_name().is_none_or(|name| name != "bin") { - continue; - } - let Some(home) = bin_dir.parent() else { - continue; - }; - if is_vp_home_layout(&bin_dir, home) { - return Ok(Some(home.to_absolute_path_buf())); - } - } - - Ok(None) -} - -fn is_vp_home_layout(bin_dir: &vt_path::AbsolutePath, home: &vt_path::AbsolutePath) -> bool { - bin_dir.join(VP_BINARY_NAME).as_path().is_file() - && home.join("current").join("bin").join(VP_BINARY_NAME).as_path().is_file() -} - -#[cfg(test)] -mod tests { - use std::ffi::{OsStr, OsString}; - - use super::*; - - struct EnvVarGuard { - name: &'static str, - original: Option, - } - - impl EnvVarGuard { - fn set(name: &'static str, value: impl AsRef) -> Self { - let guard = Self { name, original: std::env::var_os(name) }; - // SAFETY: these serial tests own process environment mutations and restore them on drop. - unsafe { std::env::set_var(name, value) }; - guard - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - // SAFETY: restore the environment snapshot captured by this serial test. - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.name, value), - None => std::env::remove_var(self.name), - } - } - } - } - - struct CurrentDirGuard { - original: AbsolutePathBuf, - } - - impl CurrentDirGuard { - fn set(path: impl AsRef) -> Self { - let guard = Self { original: current_dir().unwrap() }; - std::env::set_current_dir(path).unwrap(); - guard - } - } - - impl Drop for CurrentDirGuard { - fn drop(&mut self) { - std::env::set_current_dir(&self.original).unwrap(); - } - } - - fn write_executable(path: &std::path::Path) { - #[cfg(windows)] - std::fs::write(path, b"MZ").unwrap(); - #[cfg(not(windows))] - { - std::fs::write(path, "#!/bin/sh\necho 'fake vp'").unwrap(); - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(path, perms).unwrap(); - } - } - - #[test] - fn test_get_vp_home() { - let home = get_vp_home().unwrap(); - assert!(home.ends_with(".vite-plus")); - } - - #[test] - fn test_get_vp_home_with_custom_path() { - let temp_dir = std::env::temp_dir().join("vp-test-custom-home"); - EnvConfig::test_scope(EnvConfig::for_test_with_home(&temp_dir), || { - let home = get_vp_home().unwrap(); - assert_eq!(home.as_path(), temp_dir.as_path()); - }); - } - - #[test] - #[serial_test::serial] - fn test_get_vp_home_without_vp_home_infers_from_vp_on_path() { - let temp_dir = std::env::temp_dir().join(format!("vp-test-vp-path-{}", std::process::id())); - let vite_plus_home = temp_dir.join(".vite-plus"); - let bin_dir = vite_plus_home.join("bin"); - let current_bin_dir = vite_plus_home.join("current").join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - std::fs::create_dir_all(¤t_bin_dir).unwrap(); - - write_executable(&bin_dir.join(VP_BINARY_NAME)); - write_executable(¤t_bin_dir.join(VP_BINARY_NAME)); - - let path = std::env::join_paths([bin_dir.as_os_str()]).unwrap(); - let _path_guard = EnvVarGuard::set("PATH", path); - - // `EnvConfig::for_test()` leaves `vite_plus_home` unset, so `get_vp_home` - // ignores any real `VP_HOME` env var and exercises the PATH inference. - EnvConfig::test_scope(EnvConfig::for_test(), || { - let home = get_vp_home().unwrap(); - assert_eq!(home.as_path(), vite_plus_home.as_path()); - }); - - let _ = std::fs::remove_dir_all(&temp_dir); - } - - #[test] - #[serial_test::serial] - fn test_get_vp_home_without_vp_home_ignores_relative_bin_without_current_vp() { - let temp_dir = - std::env::temp_dir().join(format!("vp-test-relative-bin-{}", std::process::id())); - let project_dir = temp_dir.join("project"); - let bin_dir = project_dir.join("tools").join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - - write_executable(&bin_dir.join(VP_BINARY_NAME)); - - let _cwd_guard = CurrentDirGuard::set(&project_dir); - let path = std::env::join_paths([std::path::Path::new("tools/bin")]).unwrap(); - let _path_guard = EnvVarGuard::set("PATH", path); - - EnvConfig::test_scope(EnvConfig::for_test(), || { - let home = get_vp_home().unwrap(); - assert_ne!(home.as_path(), project_dir.join("tools").as_path()); - }); - - let _ = std::fs::remove_dir_all(&temp_dir); - } -} diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index ed64ebf714..c18a612db4 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -7,11 +7,11 @@ clippy::print_stdout )] +mod dirs; mod env_config; pub mod env_vars; mod error; pub mod header; -mod home; mod http; mod interactivity; mod json_edit; @@ -24,9 +24,9 @@ pub mod string_similarity; mod tls; mod tracing; -pub use env_config::{EnvConfig, TestEnvGuard}; +pub use dirs::{VP_BINARY_NAME, VpDirs}; +pub use env_config::EnvConfig; pub use error::format_error_chain; -pub use home::{VP_BINARY_NAME, get_vp_home}; pub use http::{HttpClientError, download_timeout, shared_http_client}; pub use interactivity::{ is_ci_environment, is_interactive_terminal, is_stderr_terminal, is_stdin_terminal, diff --git a/crates/vp_trampoline/src/main.rs b/crates/vp_trampoline/src/main.rs index b0f2aa639f..7348c195a7 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -31,16 +31,38 @@ fn exit_code_from_status(status: ExitStatus) -> i32 { status.code().unwrap_or(1) } +/// Locate `vp.exe` from this trampoline's directory using on-disk layout only. +/// +/// Directory env vars are owned by `EnvConfig` in the child `vp.exe` — this +/// binary must not read `VP_HOME` / `VP_*_DIR`. +/// +/// 1. `/../current/bin/vp.exe` — monolithic (`/bin` next to `/current`) +/// 2. `/../data/current/bin/vp.exe` — Windows split default +/// +/// `VP_HOME` is injected only when the monolithic `current` payload exists, +/// matching the old `%~dp0..` wrappers. Split layouts leave the env alone. +fn resolve_vp_exe(bin_dir: &std::path::Path) -> (std::path::PathBuf, Option) { + let parent = bin_dir.parent().unwrap_or_else(|| process::exit(1)); + let monolithic = parent.join("current").join("bin").join("vp.exe"); + if monolithic.exists() { + return (monolithic, Some(parent.to_path_buf())); + } + let split = parent.join("data").join("current").join("bin").join("vp.exe"); + if split.exists() || parent.join("data").is_dir() { + return (split, None); + } + (monolithic, Some(parent.to_path_buf())) +} + fn main() { // 1. Determine tool name from our own executable filename let exe_path = env::current_exe().unwrap_or_else(|_| process::exit(1)); let tool_name = exe_path.file_stem().and_then(|s| s.to_str()).unwrap_or_else(|| process::exit(1)); - // 2. Locate vp.exe: /../current/bin/vp.exe + // 2. Locate vp.exe (monolithic `/current` or split `/data/current`) let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1)); - let vp_home = bin_dir.parent().unwrap_or_else(|| process::exit(1)); - let vp_exe = vp_home.join("current").join("bin").join("vp.exe"); + let (vp_exe, vp_home) = resolve_vp_exe(bin_dir); // 3. Install Ctrl+C handler that ignores signals (child will handle them). // This prevents the "Terminate batch job (Y/N)?" prompt. @@ -48,13 +70,16 @@ fn main() { install_ctrl_handler(); // 4. Spawn vp.exe - // - Always set VP_HOME so vp.exe uses the correct home directory - // (matches what the old .cmd wrappers did with %~dp0..) + // - Pin VP_HOME only for a monolithic root (matches the old .cmd + // wrappers' `%~dp0..`). Split installs leave the env alone so the + // child resolves XDG / platform category roots itself. // - If tool is "vp", run in normal CLI mode (no VP_SHIM_TOOL) // - Otherwise, set VP_SHIM_TOOL so vp.exe enters shim dispatch let mut cmd = Command::new(&vp_exe); cmd.args(env::args_os().skip(1)); - cmd.env("VP_HOME", vp_home); + if let Some(home) = vp_home { + cmd.env("VP_HOME", home); + } if tool_name != "vp" { cmd.env("VP_SHIM_TOOL", tool_name); @@ -94,6 +119,59 @@ mod tests { } } +#[cfg(test)] +mod resolve_tests { + use std::{fs, path::Path}; + + use super::*; + + fn write_exe(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, b"").unwrap(); + } + + #[test] + fn prefers_monolithic_current_and_pins_home() { + let root = std::env::temp_dir().join(format!("vp-trampoline-mono-{}", process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("bin")).unwrap(); + write_exe(&root.join("current").join("bin").join("vp.exe")); + + let (exe, home) = resolve_vp_exe(&root.join("bin")); + assert_eq!(exe, root.join("current").join("bin").join("vp.exe")); + assert_eq!(home, Some(root.clone())); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn split_data_sibling_does_not_pin_home() { + let root = std::env::temp_dir().join(format!("vp-trampoline-split-{}", process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("bin")).unwrap(); + write_exe(&root.join("data").join("current").join("bin").join("vp.exe")); + + let (exe, home) = resolve_vp_exe(&root.join("bin")); + assert_eq!(exe, root.join("data").join("current").join("bin").join("vp.exe")); + assert_eq!(home, None); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn split_data_dir_without_payload_does_not_pin_home() { + let root = std::env::temp_dir().join(format!("vp-trampoline-data-{}", process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("bin")).unwrap(); + fs::create_dir_all(root.join("data")).unwrap(); + + let (exe, home) = resolve_vp_exe(&root.join("bin")); + assert_eq!(exe, root.join("data").join("current").join("bin").join("vp.exe")); + assert_eq!(home, None); + let _ = fs::remove_dir_all(&root); + } +} + /// Install a console control handler that ignores Ctrl+C, Ctrl+Break, etc. /// /// When Ctrl+C is pressed, Windows sends the event to all processes in the diff --git a/docs/guide/env.md b/docs/guide/env.md index a1580348d5..6721d52c85 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -21,7 +21,7 @@ latest LTS. When a project declares `packageManager` (or `devEngines.packageManager`) in `package.json`, matching package-manager shims also use that package-manager version. For example, `packageManager: "npm@10.9.4"` makes both `npm` and `npx` run through npm 10.9.4. Alias pairs follow the installed package-manager shims: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Vite+ does not translate mismatched commands, so a project pinned to `pnpm` still lets `npm` fall back to the npm that comes with the resolved Node.js runtime. -By default, Vite+ stores its managed runtime and related files in `~/.vite-plus`. If needed, you can override that location with `VP_HOME`. +By default, a fresh install stores managed runtimes and related files in the split platform layout (`~/.local/share/vite-plus` on Unix, `%LOCALAPPDATA%\vite-plus\data` on Windows). An existing `~/.vite-plus` tree is kept in place. `VP_HOME` still pins every category under one custom root. If you want to keep that behavior, run: @@ -43,7 +43,7 @@ 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` (and writes the per-shell setup scripts under `VP_HOME`) +- `vp env setup` creates or updates shims in the resolved bin directory (and writes the per-shell setup scripts under the config directory) - `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 @@ -51,9 +51,11 @@ This switches to system-first mode, where the shims prefer your system Node.js a 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" +. "$env:APPDATA\vite-plus\env.ps1" ``` +If Vite+ was installed into `%USERPROFILE%\.vite-plus` before the split layout, source that directory's `env.ps1` instead. + 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: @@ -76,7 +78,7 @@ node --version vp-use --unset ``` -Only `vp env use` needs this alternate command. Other `vp env` commands work normally in Command Prompt. `vp env setup` creates `vp-use.cmd` under `VP_HOME/bin` on Windows. +Only `vp env use` needs this alternate command. Other `vp env` commands work normally in Command Prompt. `vp env setup` creates `vp-use.cmd` in the bin directory on Windows. 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. diff --git a/docs/guide/installer-env-vars.md b/docs/guide/installer-env-vars.md index d281b6356e..c7cc992fcb 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -25,8 +25,8 @@ These variables control the installer scripts and the standalone Windows install ### `VP_HOME` -- **Purpose**: Installation directory; the installed CLI reads the same variable as the Vite+ home directory (see [Environment](/guide/env)) -- **Default**: `~/.vite-plus` (Unix) or `%USERPROFILE%\.vite-plus` (Windows) +- **Purpose**: Optional single-root pin. When set to an absolute path, every category (bin, data, cache, config, state) lives under that directory. The installed CLI reads the same variable (see [Environment](/guide/env)). +- **Default**: unset. If `~/.vite-plus` (Unix) or `%USERPROFILE%\.vite-plus` (Windows) already exists, that tree is reused. Otherwise a fresh install uses the split platform layout (`~/.local/share/vite-plus` + `~/.local/bin` on Unix; `%LOCALAPPDATA%\vite-plus\data` + `%LOCALAPPDATA%\vite-plus\bin` on Windows). - **CLI equivalent**: `--install-dir` - **Example**: @@ -40,6 +40,16 @@ These variables control the installer scripts and the standalone Windows install $env:VP_HOME = "D:\vite-plus"; irm https://vite.plus/ps1 | iex ``` +### `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR` + +- **Purpose**: Absolute per-category overrides for a split install. Ignored when `VP_HOME` is set or an existing `~/.vite-plus` is being reused. +- **Default**: unset (XDG / platform defaults) +- **Example**: + + ```bash + curl -fsSL https://vite.plus | VP_DATA_DIR=$HOME/vite-plus-data bash + ``` + ### `NPM_CONFIG_REGISTRY` - **Purpose**: Custom npm registry URL @@ -71,7 +81,7 @@ These variables control the installer scripts and the standalone Windows install ### Development variables -When developing Vite+ itself, `VP_LOCAL_TGZ` (path to a local `vite-plus.tgz`) and `VP_LOCAL_BINARY` (path to a local `vp` binary) feed the installer a local build. The installers also set `VP_INSTALL_STOP` themselves; do not set it manually. +When developing Vite+ itself, `VP_LOCAL_TGZ` (path to a local `vite-plus.tgz`) and `VP_LOCAL_BINARY` (path to a local `vp` binary) feed the installer a local build. With a local `vp` binary, installers ask that binary (via `VP_DUMP_DIRS=1`) for the `EnvConfig` data/bin/config roots instead of resolving directory env vars themselves. The installers also set `VP_INSTALL_STOP` themselves; do not set it manually. ## Runtime Variables @@ -195,7 +205,7 @@ Vite+ also respects these standard environment variables: ### `HOME` / `USERPROFILE` - **Purpose**: User home directory -- **Effect**: Base for the default `~/.vite-plus` path +- **Effect**: Base for the existing-install probe (`~/.vite-plus`) and for split platform defaults ## Precedence diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index a8cdf65964..0545a0f2ee 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -46,6 +46,7 @@ napi-build = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } +vp_shared = { workspace = true, features = ["test-utils"] } [lib] crate-type = ["cdylib"] diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 1954d24daa..40ecde1ba1 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -550,6 +550,7 @@ mod tests { SystemTime::now().duration_since(UNIX_EPOCH).expect("time should be valid").as_nanos(); let temp_dir = std::env::temp_dir().join(format!("vite-plus-bad-hash-{suffix}")); let vp_home = temp_dir.join("vp-home"); + // VP_HOME pins to the root, so the cached install lands here. let bin_dir = vp_home.join("package_manager").join("yarn").join("4.17.1").join("yarn").join("bin"); fs::create_dir_all(&bin_dir).expect("cached package manager should be created"); @@ -568,15 +569,16 @@ mod tests { let original_path = std::env::join_paths([temp_dir.join("old-bin")]).expect("valid PATH"); let envs = envs_with_path(original_path.as_os_str()); - let _guard = - vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&vp_home)); - let result = envs_with_explicit_package_manager_path(&cwd, envs).await; + vp_shared::EnvConfig::with_vars_async([(vp_shared::env_vars::VP_HOME, &vp_home)], |_| async { + let result = envs_with_explicit_package_manager_path(&cwd, envs).await; assert!( matches!(result, Err(Error::PackageManagerHashMismatch(_))), "an integrity failure must reach the user instead of a missing command: {result:?}" ); fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); + }) + .await; } #[tokio::test] diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index c37507a314..ee4d45e22d 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -6,7 +6,10 @@ # # Environment variables: # VP_VERSION - Version to install (default: latest) -# VP_HOME - Installation directory (default: $env:USERPROFILE\.vite-plus) +# VP_HOME - Optional single-root pin (monolithic). When unset, an existing +# %USERPROFILE%\.vite-plus is reused; otherwise data/bin/config +# follow VP_*_DIR / %LOCALAPPDATA% / %APPDATA%. +# VP_BIN_DIR / VP_DATA_DIR / VP_CACHE_DIR - Absolute per-category overrides # NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) # VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) # VP_PR_VERSION - PR number or commit SHA to install from the registry bridge @@ -17,9 +20,8 @@ $ErrorActionPreference = "Stop" $ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } -$InstallDir = if ($env:VP_HOME) { $env:VP_HOME } else { "$env:USERPROFILE\.vite-plus" } -# Use ~ shorthand if install dir is under USERPROFILE, matching the final summary output -$NodeManagerBinDisplay = (Join-Path $InstallDir.TrimEnd('\', '/') "bin") -replace [regex]::Escape($env:USERPROFILE), '~' +# $InstallDir (data), $ShimDir (bin), and $ConfigDir are resolved after the +# helper functions are defined — see Resolve-InstallLayout. # npm registry URL (strip trailing slash if present) $NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { "https://registry.npmjs.org" } # Local tarball for development/testing @@ -201,6 +203,80 @@ function Write-ReleaseAgeOverride { } } +function Test-AbsoluteOverridePath { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { + return $false + } + return [System.IO.Path]::IsPathRooted($Path) +} + +function Get-UserHomeDir { + if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + return $env:USERPROFILE + } + if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { + return $env:HOME + } + return [Environment]::GetFolderPath('UserProfile') +} + +# Mirror crates/vp_shared/src/dirs/resolution.rs (Windows): +# VP_HOME → existing %USERPROFILE%\.vite-plus → VP_*_DIR / Local+Roaming defaults +function Resolve-InstallLayout { + $userHome = Get-UserHomeDir + if ([string]::IsNullOrWhiteSpace($userHome)) { + Write-Error-Exit "Could not resolve user home directory" + } + + $legacyRoot = Join-Path $userHome ".vite-plus" + + if (Test-AbsoluteOverridePath $env:VP_HOME) { + return [pscustomobject]@{ + DataDir = $env:VP_HOME + ShimDir = Join-Path $env:VP_HOME "bin" + ConfigDir = $env:VP_HOME + } + } + + if (Test-Path -LiteralPath $legacyRoot -PathType Container) { + return [pscustomobject]@{ + DataDir = $legacyRoot + ShimDir = Join-Path $legacyRoot "bin" + ConfigDir = $legacyRoot + } + } + + $localApp = if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + $env:LOCALAPPDATA + } else { + Join-Path $userHome "AppData\Local" + } + $roamingApp = if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { + $env:APPDATA + } else { + Join-Path $userHome "AppData\Roaming" + } + + $dataDir = if (Test-AbsoluteOverridePath $env:VP_DATA_DIR) { + $env:VP_DATA_DIR + } else { + Join-Path (Join-Path $localApp "vite-plus") "data" + } + + $shimDir = if (Test-AbsoluteOverridePath $env:VP_BIN_DIR) { + $env:VP_BIN_DIR + } else { + Join-Path (Join-Path $localApp "vite-plus") "bin" + } + + return [pscustomobject]@{ + DataDir = $dataDir + ShimDir = $shimDir + ConfigDir = Join-Path $roamingApp "vite-plus" + } +} + function Normalize-InstallDir { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { @@ -573,10 +649,10 @@ function Remove-CurrentLink { } } -# Configure user PATH for ~/.vite-plus/bin +# Configure user PATH for the resolved shim directory # Returns: "true" = added, "already" = already configured function Configure-UserPath { - $binPath = "$InstallDir\bin" + $binPath = $ShimDir $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($userPath -like "*$binPath*") { @@ -632,7 +708,7 @@ function Configure-Nushell { } $autoloadFile = Join-Path $autoloadDir "vite-plus.nu" - $nuEnvRef= (Join-Path $InstallDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' + $nuEnvRef= (Join-Path $ConfigDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' $content = "# Vite+ bin (https://viteplus.dev)`n" + ("source '"+ $nuEnvRef +"'") + "`n" try { @@ -676,7 +752,7 @@ function Refresh-Shims { function Setup-NodeManager { param([string]$BinDir) - $binPath = "$InstallDir\bin" + $binPath = $ShimDir # Explicit override via environment variable if ($env:VP_NODE_MANAGER -eq "yes") { @@ -918,14 +994,14 @@ function Main { # Create new junction pointing to the version directory cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null - # Create bin directory and vp wrapper (always done) - New-Item -ItemType Directory -Force -Path "$InstallDir\bin" | Out-Null + # Create user bin directory and vp wrapper (always done) + New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null $trampolineSrc = "$VersionDir\bin\vp-shim.exe" if (Test-Path $trampolineSrc) { # New versions: use trampoline exe to avoid "Terminate batch job (Y/N)?" on Ctrl+C - Copy-Item -Path $trampolineSrc -Destination "$InstallDir\bin\vp.exe" -Force + Copy-Item -Path $trampolineSrc -Destination (Join-Path $ShimDir "vp.exe") -Force # Remove legacy .cmd and shell script wrappers from previous versions - foreach ($legacy in @("$InstallDir\bin\vp.cmd", "$InstallDir\bin\vp")) { + foreach ($legacy in @((Join-Path $ShimDir "vp.cmd"), (Join-Path $ShimDir "vp"))) { if (Test-Path $legacy) { Remove-Item -Path $legacy -Force -ErrorAction SilentlyContinue } @@ -935,7 +1011,7 @@ function Main { # Remove any stale trampoline .exe shims left by a newer install — .exe wins # over .cmd on Windows PATH, so leftover trampolines would bypass the wrappers. foreach ($stale in @("vp.exe", "node.exe", "npm.exe", "npx.exe", "corepack.exe", "vpx.exe", "vpr.exe")) { - $stalePath = Join-Path "$InstallDir\bin" $stale + $stalePath = Join-Path $ShimDir $stale if (Test-Path $stalePath) { Remove-Item -Path $stalePath -Force -ErrorAction SilentlyContinue } @@ -947,7 +1023,7 @@ set VP_HOME=%~dp0.. "%VP_HOME%\current\bin\vp.exe" %* exit /b %ERRORLEVEL% "@ - Set-Content -Path "$InstallDir\bin\vp.cmd" -Value $wrapperContent -NoNewline + Set-Content -Path (Join-Path $ShimDir "vp.cmd") -Value $wrapperContent -NoNewline # Also create shell script wrapper for Git Bash/MSYS $shContent = @" @@ -956,12 +1032,20 @@ VP_HOME="`$(dirname "`$(dirname "`$(readlink -f "`$0" 2>/dev/null || echo "`$0") export VP_HOME exec "`$VP_HOME/current/bin/vp.exe" "`$@" "@ - Set-Content -Path "$InstallDir\bin\vp" -Value $shContent -NoNewline + Set-Content -Path (Join-Path $ShimDir "vp") -Value $shContent -NoNewline } # Cleanup old versions Cleanup-OldVersions -InstallDir $InstallDir + # Create env files under the resolved config dir (matches install.sh). + # Use current\bin\vp.exe directly instead of the trampoline so a Windows + # refresh cannot overwrite the running wrapper. + $vpBin = Join-Path $InstallDir "current\bin\vp.exe" + if (Test-Path -LiteralPath $vpBin) { + & $vpBin env setup --env-only | Out-Null + } + # Setup Node.js version manager (shims) - separate component $nodeManagerResult = Setup-NodeManager -BinDir $BinDir @@ -971,8 +1055,9 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" $pathResult = Configure-UserPath $nushellResult = Configure-Nushell - # Use ~ shorthand if install dir is under USERPROFILE, otherwise show full path - $displayDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~' + # Use ~ shorthand if the shim dir is under USERPROFILE, otherwise show full path + $displayDir = $ShimDir -replace [regex]::Escape($env:USERPROFILE), '~' + $displayConfigDir = $ConfigDir -replace [regex]::Escape($env:USERPROFILE), '~' # ANSI color codes for consistent output $e = [char]27 @@ -1030,28 +1115,69 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" Write-Host "" Write-Host " ${YELLOW}note${NC}: Some shells still need manual setup." Write-Host "" - Write-Host " vp was installed to: ${BOLD}${displayDir}\bin${NC}" + Write-Host " vp was installed to: ${BOLD}${displayDir}${NC}" Write-Host "" if ($pathResult -eq "failed") { Write-Host " To use vp in Powershell/cmd, manually add it to your PATH:" Write-Host "" - Write-Host " [Environment]::SetEnvironmentVariable('Path', '$InstallDir\bin;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" + Write-Host " [Environment]::SetEnvironmentVariable('Path', '$ShimDir;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" Write-Host "" } if ($nushellResult.Status -eq "failed") { Write-Host " To use vp in Nushell, create a vite-plus.nu file in your preferred vendor autoload directory with:" Write-Host "" - Write-Host " source '$displayDir\env.nu'" + Write-Host " source '$displayConfigDir\env.nu'" Write-Host "" } Write-Host " Or run vp directly:" Write-Host "" - Write-Host " & `"$InstallDir\bin\vp.exe`"" + Write-Host " & `"$(Join-Path $ShimDir 'vp.exe')`"" } Write-Host "" } +function Apply-DirsFromVp { + param([string]$VpBinary) + $previous = $env:VP_DUMP_DIRS + $env:VP_DUMP_DIRS = "1" + try { + $out = & $VpBinary 2>$null + } finally { + if ($null -eq $previous) { + Remove-Item Env:VP_DUMP_DIRS -ErrorAction SilentlyContinue + } else { + $env:VP_DUMP_DIRS = $previous + } + } + $map = @{} + foreach ($line in @($out)) { + $text = "$line" + $sep = $text.IndexOf("`t") + if ($sep -lt 1) { + continue + } + $map[$text.Substring(0, $sep)] = $text.Substring($sep + 1) + } + if (-not $map['data'] -or -not $map['bin'] -or -not $map['config']) { + return $false + } + $script:Layout = [pscustomobject]@{ + DataDir = $map['data'] + ShimDir = $map['bin'] + ConfigDir = $map['config'] + } + return $true +} + +if (-not ($env:VP_LOCAL_BINARY -and (Test-Path -LiteralPath $env:VP_LOCAL_BINARY) -and (Apply-DirsFromVp $env:VP_LOCAL_BINARY))) { + $script:Layout = Resolve-InstallLayout +} +$InstallDir = $script:Layout.DataDir +$ShimDir = $script:Layout.ShimDir +$ConfigDir = $script:Layout.ConfigDir +$NodeManagerBinDisplay = $ShimDir -replace [regex]::Escape($env:USERPROFILE), '~' + try { Main } catch { diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 5aa2244fab..b5eefe6968 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -7,7 +7,11 @@ # # Environment variables: # VP_VERSION - Version to install (default: latest) -# VP_HOME - Installation directory (default: ~/.vite-plus) +# VP_HOME - Optional single-root pin (monolithic). When unset, an existing +# ~/.vite-plus is reused; otherwise data/bin/config follow +# VP_*_DIR / XDG_* / platform defaults. +# VP_BIN_DIR / VP_DATA_DIR / VP_CACHE_DIR - Absolute per-category overrides +# XDG_BIN_HOME / XDG_DATA_HOME / XDG_CONFIG_HOME / … - Unix split defaults # NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) # VP_NODE_MANAGER - Set to "yes" or "no" to skip interactive prompt (for CI/devcontainers) # VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) @@ -19,15 +23,8 @@ set -e VP_VERSION="${VP_VERSION:-latest}" -INSTALL_DIR="${VP_HOME:-$HOME/.vite-plus}" -# Use $HOME-relative path for shell config references (portable across sessions) -if case "$INSTALL_DIR" in "$HOME"/*) true;; *) false;; esac; then - INSTALL_DIR_REF_POSIX="\$HOME${INSTALL_DIR#"$HOME"}" - INSTALL_DIR_REF_NU="~${INSTALL_DIR#"$HOME"}" -else - INSTALL_DIR_REF_POSIX="$INSTALL_DIR" - INSTALL_DIR_REF_NU="$INSTALL_DIR" -fi +# INSTALL_DIR (data), SHIM_DIR (bin), and CONFIG_DIR are resolved after the +# helper functions are defined — see resolve_install_layout. # npm registry URL (strip trailing slash if present) NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" NPM_REGISTRY="${NPM_REGISTRY%/}" @@ -132,6 +129,127 @@ write_release_age_override() { fi } +is_absolute_path() { + case "$1" in + /*) return 0 ;; + [A-Za-z]:[\\/]*) return 0 ;; + *) return 1 ;; + esac +} + +# Print $1 when it is a non-empty absolute path; otherwise print nothing. +absolute_override() { + local val="$1" + if [ -n "$val" ] && is_absolute_path "$val"; then + printf '%s\n' "$val" + fi +} + +is_windows_uname() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + +user_home_dir() { + if is_windows_uname; then + printf '%s\n' "${USERPROFILE:-$HOME}" + else + printf '%s\n' "${HOME:-$USERPROFILE}" + fi +} + +xdg_data_sibling_bin() { + local data_home="${1%/}" + local parent="${data_home%/*}" + if [ -z "$parent" ] || [ "$parent" = "$data_home" ]; then + printf '/bin\n' + else + printf '%s/bin\n' "$parent" + fi +} + +set_config_dir_refs() { + local dir="$1" + local home="$2" + if [ -n "$home" ] && case "$dir" in "$home"/*) true;; *) false;; esac; then + CONFIG_DIR_REF_POSIX="\$HOME${dir#"$home"}" + CONFIG_DIR_REF_NU="~${dir#"$home"}" + else + CONFIG_DIR_REF_POSIX="$dir" + CONFIG_DIR_REF_NU="$dir" + fi +} + +# Mirror crates/vp_shared/src/dirs/resolution.rs: +# VP_HOME → existing ~/.vite-plus → VP_*_DIR / XDG_* / platform defaults +resolve_install_layout() { + local home legacy vp_home data_override bin_override + home="$(user_home_dir)" + [ -n "$home" ] || error "Could not resolve user home directory" + + legacy="$home/.vite-plus" + vp_home="$(absolute_override "${VP_HOME:-}")" + if [ -n "$vp_home" ]; then + INSTALL_DIR="$vp_home" + SHIM_DIR="$vp_home/bin" + CONFIG_DIR="$vp_home" + elif [ -d "$legacy" ]; then + INSTALL_DIR="$legacy" + SHIM_DIR="$legacy/bin" + CONFIG_DIR="$legacy" + else + data_override="$(absolute_override "${VP_DATA_DIR:-}")" + bin_override="$(absolute_override "${VP_BIN_DIR:-}")" + + if [ -n "$data_override" ]; then + INSTALL_DIR="$data_override" + elif is_windows_uname; then + INSTALL_DIR="${LOCALAPPDATA:-$home/AppData/Local}/vite-plus/data" + else + local xdg_data + xdg_data="$(absolute_override "${XDG_DATA_HOME:-}")" + if [ -n "$xdg_data" ]; then + INSTALL_DIR="$xdg_data/vite-plus" + else + INSTALL_DIR="$home/.local/share/vite-plus" + fi + fi + + if [ -n "$bin_override" ]; then + SHIM_DIR="$bin_override" + elif is_windows_uname; then + SHIM_DIR="${LOCALAPPDATA:-$home/AppData/Local}/vite-plus/bin" + else + local xdg_bin xdg_data + xdg_bin="$(absolute_override "${XDG_BIN_HOME:-}")" + xdg_data="$(absolute_override "${XDG_DATA_HOME:-}")" + if [ -n "$xdg_bin" ]; then + SHIM_DIR="$xdg_bin" + elif [ -n "$xdg_data" ]; then + SHIM_DIR="$(xdg_data_sibling_bin "$xdg_data")" + else + SHIM_DIR="$home/.local/bin" + fi + fi + + if is_windows_uname; then + CONFIG_DIR="${APPDATA:-$home/AppData/Roaming}/vite-plus" + else + local xdg_config + xdg_config="$(absolute_override "${XDG_CONFIG_HOME:-}")" + if [ -n "$xdg_config" ]; then + CONFIG_DIR="$xdg_config/vite-plus" + else + CONFIG_DIR="$home/.config/vite-plus" + fi + fi + fi + + set_config_dir_refs "$CONFIG_DIR" "$home" +} + normalize_existing_dir() { local dir="${1%/}" if [ -z "$dir" ]; then @@ -688,7 +806,7 @@ configure_zsh_path() { fi result=0 - append_source_to_file "$zshenv" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$zshenv" ". \"$CONFIG_DIR_REF_POSIX/env\"" "$CONFIG_DIR/env" "$CONFIG_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshenv")") ;; 2) already+=("$(abbreviate_path "$zshenv")") ;; @@ -697,7 +815,7 @@ configure_zsh_path() { if [ -f "$zshrc" ]; then result=0 - append_source_to_file "$zshrc" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$zshrc" ". \"$CONFIG_DIR_REF_POSIX/env\"" "$CONFIG_DIR/env" "$CONFIG_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshrc")") ;; 2) already+=("$(abbreviate_path "$zshrc")") ;; @@ -741,7 +859,7 @@ configure_bash_path() { fi existing=1 result=0 - append_source_to_file "$file" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$file" ". \"$CONFIG_DIR_REF_POSIX/env\"" "$CONFIG_DIR/env" "$CONFIG_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$file")") ;; 2) already+=("$(abbreviate_path "$file")") ;; @@ -776,7 +894,7 @@ configure_bash_path() { configure_fish_path() { local fish_config="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish" local fish_content="# Vite+ bin (https://viteplus.dev) -source \"$INSTALL_DIR_REF_POSIX/env.fish\" +source \"$CONFIG_DIR_REF_POSIX/env.fish\" " local result=0 @@ -811,7 +929,7 @@ configure_nushell_path() { local nushell_autoload="$nushell_dir/vite-plus.nu" local nushell_content="# Vite+ bin (https://viteplus.dev) -source '$INSTALL_DIR_REF_NU/env.nu' +source '$CONFIG_DIR_REF_NU/env.nu' " local result=0 @@ -883,7 +1001,7 @@ refresh_shims() { # Arguments: bin_dir - path to the version's bin directory containing vp setup_node_manager() { local bin_dir="$1" - local bin_path="$INSTALL_DIR/bin" + local bin_path="$SHIM_DIR" NODE_MANAGER_ENABLED="false" # Resolve vp binary name (vp on Unix, vp.exe on Windows) @@ -937,7 +1055,7 @@ setup_node_manager() { if [ -e /dev/tty ] && [ -t 1 ]; then echo "" echo "Would you like Vite+ to manage your Node.js versions?" - echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/ and automatically uses the right version." + echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$SHIM_DIR") and automatically uses the right version." echo "Opt out anytime with \`vp env off\`." echo -n "Press Enter to accept (Y/n): " read -r response < /dev/tty @@ -1172,16 +1290,19 @@ WRAPPER_EOF # Create/update current symlink (use relative path for portability) ln -sfn "$VP_VERSION" "$CURRENT_LINK" - # Create bin directory and vp entrypoint (always done) - mkdir -p "$INSTALL_DIR/bin" + # Create user bin directory and vp entrypoint (always done) + mkdir -p "$SHIM_DIR" if [[ "$platform" == win32* ]]; then # Windows: copy trampoline as vp.exe (matching install.ps1) if [ -f "$INSTALL_DIR/current/bin/vp-shim.exe" ]; then - cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$INSTALL_DIR/bin/vp.exe" + cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$SHIM_DIR/vp.exe" fi + elif [ "$SHIM_DIR" = "$INSTALL_DIR/bin" ]; then + # Monolithic: relative link next to `/current` + ln -sfn "../current/bin/vp" "$SHIM_DIR/vp" else - # Unix: symlink to current/bin/vp - ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" + # Split: bin is not a sibling of current (e.g. ~/.local/bin) + ln -sfn "$INSTALL_DIR/current/bin/vp" "$SHIM_DIR/vp" fi # Cleanup old versions @@ -1204,9 +1325,9 @@ WRAPPER_EOF # Configure shell PATH after the install is otherwise complete. configure_shell_path - # Use ~ shorthand if install dir is under HOME, otherwise show full path - local display_dir="${INSTALL_DIR/#$HOME/~}" - local display_location="${display_dir}/bin" + # Use ~ shorthand if the shim dir is under HOME, otherwise show full path + local display_location + display_location="$(abbreviate_path "$SHIM_DIR")" # Print success message echo "" @@ -1251,11 +1372,11 @@ WRAPPER_EOF echo "" echo " Manual setup instructions:" echo " - Bash/Zsh: add the following to your shell config (~/.bashrc, ~/.zshrc, etc.):" - echo " . \"$INSTALL_DIR_REF_POSIX/env\"" + echo " . \"$CONFIG_DIR_REF_POSIX/env\"" echo " - Fish: create ${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish with:" - echo " source \"$INSTALL_DIR_REF_POSIX/env.fish\"" + echo " source \"$CONFIG_DIR_REF_POSIX/env.fish\"" echo " - Nushell: create a vendor autoload file with:" - echo " source '$INSTALL_DIR_REF_NU/env.nu'" + echo " source '$CONFIG_DIR_REF_NU/env.nu'" echo "" echo " Or run vp directly:" echo "" @@ -1265,4 +1386,20 @@ WRAPPER_EOF echo "" } +apply_dirs_from_vp() { + local vp="$1" + local out + out="$(VP_DUMP_DIRS=1 "$vp")" || return 1 + INSTALL_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "data" { print $2; exit }')" + SHIM_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "bin" { print $2; exit }')" + CONFIG_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "config" { print $2; exit }')" + [ -n "$INSTALL_DIR" ] && [ -n "$SHIM_DIR" ] && [ -n "$CONFIG_DIR" ] || return 1 + set_config_dir_refs "$CONFIG_DIR" "$(user_home_dir)" +} + +if [ -n "${VP_LOCAL_BINARY:-}" ] && [ -f "$VP_LOCAL_BINARY" ] && apply_dirs_from_vp "$VP_LOCAL_BINARY"; then + : +else + resolve_install_layout +fi main "$@" diff --git a/packages/tools/README.md b/packages/tools/README.md index 3b7f8ad36d..a2f70d95bb 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -9,6 +9,6 @@ Run with `tool `: - sync-remote: Sync upstream dependency sources and catalog versions from `.upstream-versions.json` -- install-global-cli: Install the locally built `vp` global CLI into `~/.vite-plus` +- install-global-cli: Install the locally built `vp` global CLI (reuses `~/.vite-plus` when that tree already exists; otherwise the split platform data dir) - brand-vite: Apply Vite+ branding patches to the synced vite source (also runs at the end of sync-remote) - local-npm-registry: Serve locally packed checkout packages behind a real registry HTTP interface for snapshot tests, ecosystem e2e, and local `vp migrate`/`vp create` iteration diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index 3e26c59b16..c81e9b93fc 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -21,6 +21,35 @@ const isWindows = process.platform === 'win32'; const LOCAL_DEV_PREFIX = 'local-dev'; const pad2 = (n: number) => n.toString().padStart(2, '0'); +function seedCiLegacyHome() { + // CI workflows still look for `~/.vite-plus/bin/vp` (#2371). Creating the + // directory engages EnvConfig's existing-install probe so bootstrap stays + // on the monolithic root without setting VP_HOME. + if (process.env.CI == null) { + return; + } + mkdirSync(path.join(os.homedir(), '.vite-plus'), { recursive: true }); +} + +function readDataDirFromVp(vpBinary: string): string { + const output = execFileSync(vpBinary, [], { + encoding: 'utf8', + env: { + ...process.env, + VP_DUMP_DIRS: '1', + }, + }); + const dataDir = output + .split(/\r?\n/) + .map((line) => line.split('\t')) + .find((parts) => parts[0] === 'data' && parts[1])?.[1] + ?.trim(); + if (!dataDir) { + throw new Error(`vp did not print a data directory (VP_DUMP_DIRS=1): ${output}`); + } + return dataDir; +} + function localDevVersion(): string { const now = new Date(); const date = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}`; @@ -82,10 +111,6 @@ export function installGlobalCli() { } try { - const installDir = process.env.VP_HOME - ? path.resolve(process.env.VP_HOME) - : path.join(os.homedir(), '.vite-plus'); - // Locate the Rust vp binary (built by cargo or copied by CI) const binaryName = isWindows ? 'vp.exe' : 'vp'; const binaryPath = findVpBinary(binaryName); @@ -107,8 +132,10 @@ export function installGlobalCli() { } const localDevVer = localDevVersion(); + seedCiLegacyHome(); + const installDir = readDataDirFromVp(binaryPath); - // Clean up old local-dev directories to avoid accumulation + // Clean up old local-dev directories under the EnvConfig data root. if (existsSync(installDir)) { const currentInstallPath = getCurrentInstallPath(installDir); for (const entry of readdirSync(installDir)) { @@ -134,7 +161,6 @@ export function installGlobalCli() { ...(process.env as Record), VP_LOCAL_TGZ: tgzPath, VP_LOCAL_BINARY: binaryPath, - VP_HOME: installDir, VP_VERSION: localDevVer, CI: 'true', // Skip vp install in install.sh — we handle deps ourselves: diff --git a/rfcs/directory-layout.md b/rfcs/directory-layout.md new file mode 100644 index 0000000000..a6135e6c92 --- /dev/null +++ b/rfcs/directory-layout.md @@ -0,0 +1,300 @@ +# RFC: Split Directory Layout via `VpDirs` + +## Status + +**Partially implemented** — fresh-install split layout + centralized resolution ship in [#2346](https://github.com/voidzero-dev/vite-plus/pull/2346) (closes [#827](https://github.com/voidzero-dev/vite-plus/issues/827)). Automatic on-disk migration and full `VP_HOME` cleanup are follow-ups ([#2371](https://github.com/voidzero-dev/vite-plus/issues/2371), [#2372](https://github.com/voidzero-dev/vite-plus/issues/2372)). + +## Background + +Vite+ historically stores the entire global install under a single monolithic root: + +```text +~/.vite-plus/ +├── bin/ # shims (vp, node, npm, …) +├── current → / # active CLI version symlink +├── / # CLI payload (bin/, node_modules/, package.json, pnpm-lock.yaml) +├── js_runtime/ # managed runtimes (node//, *.lock, index_cache.json) +├── package_manager/ # managed package managers (npm/, pnpm/, yarn/, bun/) +├── packages/ # globally installed packages (@scope/#/, *.lock) +├── bins/ # bin metadata for installed packages (.json) +├── cache/ # resolve_cache.json +├── tmp/ # staging (package installs, create-org downloads) +├── env, env.fish, env.nu, env.ps1 # shell env scripts +├── config.json # user config (created on first write) +├── .session-node-version # session Node version (`vp env use`) +├── .previous-version # CLI version before the last upgrade +└── .upgrade-check.json # upgrade-check cache +``` + +That layout is simple to install and document, but it conflicts with platform conventions: + +1. **XDG / platform split** — binaries, data, cache, config, and state belong in different roots (`~/.local/bin`, `~/.local/share`, `~/.cache`, `~/.config`, `~/.local/state` on Unix; analogous Local/Roaming app dirs on Windows). +2. **PATH hygiene** — a dedicated `~/.local/bin` (or `%LOCALAPPDATA%\vite-plus\bin`) is the usual place for user tools; burying shims under a private tree forces a custom PATH entry forever. +3. **Scattered path construction** — call sites historically joined `~/.vite-plus/...` or read `VP_HOME` ad hoc, making layout changes error-prone. +4. **Testing friction** — snapshot and CI setups pin `VP_HOME` to force a single tree, which couples fixtures to the monolithic shape. + +## Goals + +1. **Centralize** all on-disk category roots and first-level data subdirectories in `vp_shared::VpDirs` so no call site invents `~/.vite-plus/...` or reads `XDG_*` itself. +2. **Default fresh installs** to the split XDG / platform layout. +3. **Grandfather** existing default installs that still live at `~/.vite-plus` without moving files in this phase. +4. Keep **`VP_HOME` as a full-root pin** for custom roots and older scripts; prefer `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR` for new configuration. +5. Align **installers** (`install.sh`, `install.ps1`, `vp-setup`, `install-global-cli`) with the same resolution strategy as the CLI. +6. Support **implode, env setup, trampoline, upgrade check**, and related flows on both layouts. + +## Non-Goals (this phase) + +1. **Automatic migration** of an existing `~/.vite-plus` tree into split roots (tracked in [#2372](https://github.com/voidzero-dev/vite-plus/issues/2372); see [Follow-up: layout migrate](#follow-up-layout-migrate-on-vp-upgrade)). +2. Removing the **read** of `VP_HOME` from the resolution chain (cleanup of _setters_ is [#2371](https://github.com/voidzero-dev/vite-plus/issues/2371)). +3. Introducing a new distribution channel or package format. +4. Changing the on-disk _payload_ shape under a version directory (`current`, version dirs, `node_modules`). + +## Design + +### Ownership: `VpDirs` + +`crates/vp_shared/src/dirs.rs` owns only the **category roots**: + +| Root | Contents | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bin` | Executables and shims (`/vp`, `/node`, …) | +| `data` | CLI versions, managed runtimes, package managers (`/current`, `/js_runtime`, `/package_manager`, `/packages`, `/bins`) | +| `cache` | Disposable caches | +| `config` | User configuration (`/env*`, `/config.json`) | +| `state` | State files (session version, upgrade-check cache) | + +`VpDirs` is a **stateful value**: the strategy chain runs once at +construction (`VpDirs::resolve()`), the five roots are stored as public +fields, and process env changes afterwards are not observed (child processes +resolve their own roots from their own environment). The struct carries **no +notion of layout** — every resolution source maps onto the same five roots, +and features must not branch on how the roots were produced. + +First-level directories under `data` (`current`, `js_runtime`, …) and all +deeper trees (`config.json`, `js_runtime/node/`, `resolve_cache.json`, +…) are joined by the owning feature, not by `VpDirs`. + +`EnvConfig` **owns** the resolved `VpDirs` (`EnvConfig::get().dirs`), +constructed in `EnvConfig::from_env()`; the dependency is one-way — +`from_env()` resolves the user home once (`HOME`/`USERPROFILE`, +platform-ordered like the installers, with a system base-dirs fallback), +stores it as `EnvConfig.user_home` (`AbsolutePathBuf`), and passes it into +`VpDirs::resolve(home)`, so `user_home` and `dirs` never disagree. Directory +resolution reads only the override env vars (`VP_HOME`, `VP_*_DIR`, `XDG_*`) +— never `HOME`/`USERPROFILE` — and carries no test-only branches: tests +exercise the same resolution chain through the process environment (see +[Test configuration](#test-configuration)). + +### Test configuration + +`EnvConfig::get()` has two behaviors, selected at compile time: + +- **Release builds** read the process env once, lazily, and cache the config + process-wide (`OnceLock`). +- **Test builds** — `cfg(test)`, or any downstream crate enabling the + `test-utils` feature through `[dev-dependencies]` — re-resolve on **every** + `get()`, so env-scoped tests observe pinned values immediately. The + feature pulls in `temp-env` and `tempfile` as optional dependencies, so + the helpers stay out of release binaries. + +Tests pin the **environment**, never paths: the same env → dirs resolution +chain production uses derives the roots, so fixtures cannot drift from +production resolution. Four `EnvConfig` associated functions (all gated on +`test-utils`) cover the matrix: + +| Helper | Environment | Root | Use | +| ------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| `with_vars(vars, \|config\| …)` | declared vars pinned (`None` values unset); everything else inherited | caller-chosen (e.g. `VP_HOME` → own tempdir, or a per-crate shared root) | Tests asserting on pinned values or concrete paths | +| `with_vars_async` | same, held across `.await` | same | Async tests (requires a current-thread runtime — `temp_env`'s lock guard is `!Send`) | +| `scoped(\|config\| …)` | `VP_HOME` → fresh `tempfile` root | hidden, deleted on scope exit | Dir read/write tests that don't care where the root lives | +| `scoped_async` | same, across `.await` | same | Async equivalent | + +Semantics: + +- The callback **receives the resolved `Arc`** — no manual + `EnvConfig::get()` needed. For the async helpers the config is resolved + inside the scoped future, after the variables are pinned. +- **Any** process variable may be pinned; there is no allowlist. Undeclared + variables are inherited from the process environment as-is. +- Values implement the `EnvValue` trait: plain string/path values set the + variable, and an `Option` sets it when `Some` / **unsets** it when `None` + — the only "off" state for presence-checked variables (`CI` → `is_ci`, + `VP_ENV_USE_EVAL_ENABLE`, …), since assigning a value still counts as set. + `EnvValue` is implemented for the concrete string/path types rather than + via `ToString`: paths may be non-UTF-8 and a lossy conversion would + silently corrupt them. +- The helpers delegate to `temp_env`, which holds a process-wide lock for + the whole scope — no `#[serial]` needed between scope-based tests, and + nested scopes shadow outer ones until they return. +- Download-heavy suites (package managers, Node runtimes) pin a **shared** + `VP_HOME` root per test binary (under `std::env::temp_dir()`) so download + caches stay warm across tests and runs; concurrent installs under one root + are lock-protected. + +One discipline follows from the shared lock: `temp_env`'s lock is independent +of `serial_test`'s. Within a test binary that uses these scopes, **every** +test that mutates the process environment must go through `temp_env` (or +these helpers) — a raw `set_var`/`remove_var` can otherwise rewrite a +variable mid-scope and corrupt another test's pinned state. Tests mutating +only variables that no scope pins (e.g. `VP_SHIM_TOOL`) may stay on +`#[serial]`. + +### Comment convention + +Code comments and docs refer to on-disk locations with **category +placeholders** — `/xxx`, `/xxx`, `/xxx`, `/xxx`, +`/xxx` — never with dual-layout annotations. Do not write +`monolithic: ~/.vite-plus/xxx, split: ~/.local/share/vite-plus/xxx`; the mapping +from placeholder to concrete path is defined once, in +[category mapping](#category-mapping), and must not be restated per call +site. + +### Resolution chain + +Each category walks the following ordered sources. A source either proposes +a path or is skipped; the first proposal wins. The only stateful source is +`~/.vite-plus`: it proposes only when that directory already exists on disk +**as a directory** (checked once at resolution, matching the installers' +`[ -d ]` / `-PathType Container` gates), so grandfathering does not claim +empty paths; bin/cache under an existing root are accepted even if those +subdirs are not created yet. + +**Unix:** + +```text +VP_HOME + → existing ~/.vite-plus + → VP_BIN_DIR / VP_DATA_DIR / VP_CACHE_DIR + → XDG_BIN_HOME / XDG_DATA_HOME / XDG_CACHE_HOME / XDG_CONFIG_HOME / XDG_STATE_HOME + → platform defaults +``` + +**Windows:** same head; no XDG step — after `VP_*_DIR`, fall through to Windows platform defaults (`%LOCALAPPDATA%` / `%APPDATA%`). When the known-folder query is unavailable (restricted service or CI contexts), the platform step falls back to the conventional `AppData\Local` / `AppData\Roaming` locations under the resolved user home, so a known home always yields a complete layout. + +| Source | Behavior | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`VP_HOME`** | When set, pins the **monolithic mapping** under that root for all categories. | +| **`~/.vite-plus`** | When that directory exists, use the monolithic mapping under it. | +| **`VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR`** | Absolute per-category overrides (relative values ignored). Only the categories with a corresponding variable are proposed here. | +| **`XDG_*`** (Unix) | Absolute `XDG_BIN_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, with app name `vite-plus` on data/cache/config/state. Bin may follow uv-style `$XDG_DATA_HOME/../bin` when only data home is set. | +| **Platform defaults** | See [category mapping](#category-mapping) (Unix XDG-style homes under `$HOME`, Windows Local/Roaming app dirs). | + +Relative `VP_*` / `XDG_*` values are treated as unset (per the XDG Base Directory Spec for the spec-defined variables). `XDG_BIN_HOME` is not part of the XDG spec; it is a uv-style convention, as is the `$XDG_DATA_HOME/../bin` bin fallback above. + +### Category mapping + +| Category | Split default (Unix) | Split default (Windows) | Monolithic (`VP_HOME` / existing `~/.vite-plus`) | +| ---------- | -------------------------- | -------------------------------- | ------------------------------------------------ | +| **bin** | `~/.local/bin` | `%LOCALAPPDATA%\vite-plus\bin` | `/bin` | +| **data** | `~/.local/share/vite-plus` | `%LOCALAPPDATA%\vite-plus\data` | `` | +| **cache** | `~/.cache/vite-plus` | `%LOCALAPPDATA%\vite-plus\cache` | `/cache` | +| **config** | `~/.config/vite-plus` | `%APPDATA%\vite-plus` | `` | +| **state** | `~/.local/state/vite-plus` | `%LOCALAPPDATA%\vite-plus\state` | `` | + +Under **data** (both layouts): version directories, `current`, `js_runtime`, `package_manager`, `packages`, `bins`. + +### Installers + +`install.sh` / `install.ps1` (and local `install-global-cli`) mirror the CLI chain: + +1. If `VP_HOME` is set → install into that root as **monolithic**. +2. Else if default `~/.vite-plus` (or Windows equivalent) **exists** → **grandfather** the monolithic root. +3. Else → **split** data/bin/config (and related) using `VP_*_DIR` / `XDG_*` / platform defaults. + +There is a **single** install script per platform (no separate per-layout install script). Local bootstrap does **not** force `VP_HOME`; it resolves the install data dir the same way. + +Env scripts are written under **config** (split: `~/.config/vite-plus/env*`; monolithic: the install root). PATH entries point at the resolved **bin** directory. + +### Global CLI → JS children + +Under the split layout, the global CLI injects `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR` into JS child processes when those vars are unset, so the NAPI / local CLI and JS tools see the same category roots without re-implementing XDG logic. + +### User impact (this phase) + +| Install state | Behavior | +| ----------------------- | ------------------------------------------------------------------------------------- | +| Existing `~/.vite-plus` | Paths unchanged (grandfathered until migrate follow-up). | +| Custom `VP_HOME` | Still works as a full-root pin. | +| Fresh install | Split layout; typically only `~/.local/bin` (or Windows bin dir) needs to be on PATH. | + +### Verified scenarios (manual) + +1. **Fresh split** — empty home, no `VP_HOME`: install lands on `~/.local/share/vite-plus`, shims in `~/.local/bin`, env under `~/.config/vite-plus`; `vp --version` works. +2. **Monolithic reuse** — pre-seeded `~/.vite-plus` with markers: `install-global-cli` upgrades `current` in place, keeps prior version dirs and markers, does not create split roots; runtime writes `resolve_cache.json` under `~/.vite-plus/cache`; `vp env doctor` reports home `~/.vite-plus`. + +## Follow-up: `VP_HOME` cleanup + +**Issue:** [#2371](https://github.com/voidzero-dev/vite-plus/issues/2371) + +Much of the repo still **sets** or **assumes** `VP_HOME` as the primary install root (especially PTY snapshot tests). That fights the split layout. + +**Direction:** + +- Prefer `VP_*_DIR` / XDG / platform defaults in tests, CI, and docs. +- Keep **reading** `VP_HOME` in `VpDirs` as a custom-root pin until a later cleanup. +- Snapshot suite should not require a permanent `VP_HOME=~/.vite-plus` baseline for the happy path. + +## Follow-up: layout migrate on `vp upgrade` + +**Issue:** [#2372](https://github.com/voidzero-dev/vite-plus/issues/2372) + +After the split layout ships, stop grandfathering forever: on `vp upgrade` (and installer reinstall where appropriate), migrate a **default** monolithic install into split roots and remove `~/.vite-plus`. + +### Mapping (Unix defaults; Windows analogous) + +| From under `~/.vite-plus` | To | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Version dirs, `current`, runtimes, package managers, packages, bins metadata | data dir (`~/.local/share/vite-plus`, …) | +| `config.json` (and durable user config) | config dir | +| Session / upgrade-check state | state dir | +| Shims / env scripts | **regenerate** into bin + config (do not copy relative links or stale env text) | +| Resolve cache | **drop** (rebuild on next use) | + +Custom `VP_HOME` roots are **out of auto-migrate** (they stay a manual full-root pin). + +### Locked design constraints + +1. **Copy-first**, then delete the monolithic root (no long-lived tombstone unless Windows file locks force a deferred cleanup). +2. **Never delete** the monolithic root before split `data/current` (and critical shims) are verified. +3. **Conflict** if the split data root already holds a healthy unrelated install — abort with a clear message. +4. Shell profiles that source `~/.vite-plus/env*` must be **rewritten or cleaned** to the new config env path. +5. **N-1 path**: users on a pre-migrate CLI may re-exec after upgrade and/or re-run the install script as the guaranteed fallback. +6. **Immediate** removal of the default monolithic root after a successful migrate (product choice: do not leave an empty grandfather forever). + +### Acceptance (migrate) + +- Machine with only default `~/.vite-plus` runs `vp upgrade` once → split roots populated, `~/.vite-plus` gone, shims/env work after shell restart. +- Fresh install never creates `~/.vite-plus`. +- CI covers monolithic → split upgrade (in addition to released-CLI and fresh-split install paths). + +> Experimental migrate work was sketched on a side branch and **withdrawn** from the dirs PR because moving a live global install is high risk (Windows locks, PATH/profile cutover, concurrent shims). Re-land only behind careful staging and tests. + +## Testing strategy + +| Layer | What | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unit | `vp_shared` resolution / fallthrough / home-ordering cases pin env via `EnvConfig::with_vars`; feature tests use `with_vars` / `scoped` (see [Test configuration](#test-configuration)) | +| Install CI | `test-standalone-install`: released CLI (often `VP_HOME`-pinned for pre-split packages) + local-build fresh split + grandfather / upgrade-adjacent jobs | +| Snapshots | Layout isolation without assuming a permanent monolithic home (improve further in #2371) | +| Manual | Fresh split install; existing monolithic reuse with new CLI | + +## Alternatives considered + +1. **Always split; never grandfather** — breaks existing installs until migrate is perfect. Rejected for the first ship. +2. **Always migrate on first run of any command** — surprising and dangerous mid-script. Prefer explicit `vp upgrade` / installer. +3. **Keep monolithic forever; only document XDG as optional** — fails PATH and platform conventions for new users. +4. **Separate install scripts for monolithic vs split** — duplicated drift; replaced by one script with resolution branching. + +## Open questions (post-migrate) + +1. Timeline for dropping the **read** of `VP_HOME` after most users are on split roots. +2. Windows deferred delete / reboot policy when locked files block monolithic root removal. + +## References + +- Issue: [#827](https://github.com/voidzero-dev/vite-plus/issues/827) +- Implementation PR: [#2346](https://github.com/voidzero-dev/vite-plus/pull/2346) +- Follow-ups: [#2371](https://github.com/voidzero-dev/vite-plus/issues/2371), [#2372](https://github.com/voidzero-dev/vite-plus/issues/2372) +- Code: `crates/vp_shared/src/dirs.rs`, `crates/vp_shared/src/dirs/resolution.rs` +- Installers: `packages/cli/install.sh`, `packages/cli/install.ps1`, `packages/tools/src/install-global-cli.ts` +- Related RFCs: [upgrade-command](./upgrade-command.md), [implode-command](./implode-command.md), [env-command](./env-command.md), [js-runtime](./js-runtime.md)