diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 53aef266da..f54e6181fa 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,195 @@ 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 + env: + VP_LOCAL_BINARY: ${{ github.workspace }}/target/release/vp + VP_SKIP_DEPS_INSTALL: '1' + VP_NODE_MANAGER: 'yes' + 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 + 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_VERSION=local-test 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 + run: | + set -euo pipefail + GRAND=$(mktemp -d) + FAKE_TGZ=$(mktemp) + # A real prior install: grandfathering requires the `current` link, + # not a bare ~/.vite-plus directory. + mkdir -p "$GRAND/.vite-plus/0.0.1/bin" + ln -s 0.0.1 "$GRAND/.vite-plus/current" + 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_VERSION=local-test 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")" = "$GRAND/.vite-plus/current/bin/vp" + "$GRAND/.vite-plus/bin/vp" --version + + - name: Stray ~/.vite-plus does not capture a split install + run: | + set -euo pipefail + STRAY=$(mktemp -d) + FAKE_TGZ=$(mktemp) + export HOME="$STRAY" + export USERPROFILE="$STRAY" + 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_VERSION=local-test bash packages/cli/install.sh + + # A pre-split local vite-plus can create ~/.vite-plus at any time + # for caches, config, and managed runtimes. No `current` link, so + # this is a stray tree, not an install. + mkdir -p "$STRAY/.vite-plus/cache" "$STRAY/.vite-plus/js_runtime" + echo '{}' > "$STRAY/.vite-plus/config.json" + + # Resolution must stay on the split roots, with and without the + # env-script pins. + data_root() { VP_DUMP_DIRS=1 "$STRAY/.local/bin/vp" | awk -F'\t' '$1=="data"{print $2}'; } + test "$(data_root)" = "$STRAY/.local/share/vite-plus" + test "$(VP_DATA_DIR="$STRAY/.local/share/vite-plus" VP_BIN_DIR="$STRAY/.local/bin" data_root)" = "$STRAY/.local/share/vite-plus" + + # A reinstall (vp upgrade selects its root through the same chain) + # must keep targeting the split roots and leave the stray tree alone. + VP_LOCAL_TGZ="$FAKE_TGZ" VP_VERSION=local-test2 bash packages/cli/install.sh + + test -x "$STRAY/.local/share/vite-plus/local-test2/bin/vp" + test "$(readlink "$STRAY/.local/share/vite-plus/current")" = "local-test2" + test ! -e "$STRAY/.vite-plus/current" + test ! -e "$STRAY/.vite-plus/bin" + "$STRAY/.local/bin/vp" --version + + test-install-sh-old-version: + name: Test install.sh pre-split release (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + env: + VP_VERSION: '0.2.9' + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: Linux x64 glibc + - os: macos-latest + name: macOS ARM64 + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - name: Run install.sh for a pre-split release + # No VP_HOME pin: a release that predates VpDirs must fall back to the + # monolithic ~/.vite-plus root on its own. + run: | + cat packages/cli/install.sh | bash | tee install-output.txt + grep -F "does not support the split directory layout" install-output.txt + + - name: Verify monolithic layout + run: | + test -x "$HOME/.vite-plus/$VP_VERSION/bin/vp" + test -e "$HOME/.vite-plus/current" + test -f "$HOME/.vite-plus/env" + # No split-layout leftovers. + test ! -d "$HOME/.local/share/vite-plus" + test ! -d "$HOME/.config/vite-plus" + test ! -e "$HOME/.local/bin/vp" + ls -al "$HOME/.vite-plus/bin" + for shim in vp vpr vpx node npm npx corepack; do + test -e "$HOME/.vite-plus/bin/$shim" + done + + - name: Verify PATH-resolved commands work + run: | + export PATH="$HOME/.vite-plus/bin:$PATH" + command -v vp | grep -F "$HOME/.vite-plus/bin/vp" + vp --version + node --version + npm --version + vp env doctor + + test-install-ps1-old-version: + name: Test install.ps1 pre-split release (Windows x64) + # GitHub-hosted runner: %USERPROFILE% matches the OS profile, so the old + # release's own path resolution and `vp env doctor` agree with the install. + runs-on: windows-latest + permissions: + contents: read + env: + VP_VERSION: '0.2.9' + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - name: Run install.ps1 for a pre-split release + # No VP_HOME pin: a release that predates VpDirs must fall back to the + # monolithic %USERPROFILE%\.vite-plus root on its own. + shell: pwsh + run: | + # *>&1 merges the information stream (Write-Host) into the pipeline + # so Tee-Object can capture the installer notices. + & ./packages/cli/install.ps1 *>&1 | Tee-Object -FilePath install-output.txt + if (-not (Select-String -Path install-output.txt -Pattern "does not support the split directory layout" -SimpleMatch -Quiet)) { + Write-Error "Expected the pre-split fallback notice in installer output" + exit 1 + } + + - name: Verify monolithic layout + shell: pwsh + run: | + $root = Join-Path $env:USERPROFILE ".vite-plus" + if (-not (Test-Path "$root\$env:VP_VERSION\bin\vp.exe")) { Write-Error "payload missing"; exit 1 } + if (-not (Test-Path "$root\current")) { Write-Error "current link missing"; exit 1 } + # No split-layout leftovers. + foreach ($split in @((Join-Path $env:LOCALAPPDATA "vite-plus"), (Join-Path $env:APPDATA "vite-plus"))) { + if (Test-Path $split) { Write-Error "unexpected split dir: $split"; exit 1 } + } + Get-ChildItem -Force "$root\bin" + foreach ($shim in @("vp.exe", "vpr.exe", "vpx.exe", "node.exe", "npm.exe", "npx.exe")) { + if (-not (Test-Path "$root\bin\$shim")) { Write-Error "shim missing: $shim"; exit 1 } + } + + - name: Verify PATH-resolved commands work + shell: pwsh + run: | + $env:Path = "$env:USERPROFILE\.vite-plus\bin;$env:Path" + where.exe vp + vp --version + node --version + npm --version + vp env doctor + test-install-sh-readonly-config: name: Test install.sh (readonly shell config) runs-on: ubuntu-latest @@ -122,6 +321,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 +371,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 +431,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 +489,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 +625,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 +873,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 c86cd2578e..977d3252e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7653,6 +7653,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" @@ -8494,10 +8504,12 @@ dependencies = [ "node-semver", "owo-colors", "oxc_resolver", + "rustc-hash", "serde", "serde_json", "serial_test", "tar", + "temp-env", "tempfile", "thiserror 2.0.19", "tokio", @@ -8522,6 +8534,7 @@ dependencies = [ "clap", "indicatif", "owo-colors", + "tempfile", "tokio", "vp_pm_cli", "vp_setup", @@ -8658,9 +8671,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 d94e4453ba..3e4410c8fe 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..894e428721 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -28,6 +28,7 @@ tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } owo-colors = { workspace = true } oxc_resolver = { workspace = true } +rustc-hash = { workspace = true } crossterm = { workspace = true } indexmap = { workspace = true } indicatif = { workspace = true } @@ -45,7 +46,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..953e4d9b10 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,17 @@ 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?; + + #[cfg(windows)] + refresh_owned_shim_pointers(dirs); if env_only { println!("{}", help::render_heading("Setup")); @@ -61,30 +64,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 +98,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 +113,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 +121,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 +147,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()) } @@ -158,7 +161,7 @@ async fn setup_vp_wrapper( #[cfg(unix)] { let bin_vp = bin_dir.join("vp"); - let target = resolve_unix_vp_shim_target(current_exe, bin_dir).await?; + let target = resolve_unix_vp_shim_target(current_exe).await?; let existing = tokio::fs::symlink_metadata(&bin_vp).await.ok(); let should_create_symlink = match existing.as_ref() { @@ -202,6 +205,7 @@ async fn setup_vp_wrapper( } tokio::fs::copy(trampoline_src.as_path(), &bin_vp_exe).await?; + write_shim_pointer_beside(bin_vp_exe.as_path()); tracing::debug!("Created trampoline {:?}", bin_vp_exe); } @@ -217,16 +221,13 @@ async fn setup_vp_wrapper( #[cfg(unix)] 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 { - return Ok(std::path::PathBuf::from("../current/bin/vp")); - } + let current_vp = crate::commands::global::install::package_shim_target(); + 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 { + return Ok(current_vp.as_path().to_path_buf()); } } @@ -245,7 +246,7 @@ pub(crate) async fn create_shim( let shim_path = bin_dir.join(shim_filename(tool)); #[cfg(unix)] - let desired_target = resolve_unix_vp_shim_target(source, bin_dir).await?; + let desired_target = resolve_unix_vp_shim_target(source).await?; let existing = tokio::fs::symlink_metadata(&shim_path).await.ok(); if existing.is_some() { @@ -293,7 +294,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 @@ -316,12 +317,9 @@ async fn create_unix_shim( shim_path: &vt_path::AbsolutePath, tool: &str, ) -> Result<(), Error> { - let bin_dir = shim_path.parent().ok_or_else(|| { - Error::Other(format!("Cannot find parent directory for {tool} shim").into()) - })?; - let target = resolve_unix_vp_shim_target(source, bin_dir).await?; + let target = resolve_unix_vp_shim_target(source).await?; tokio::fs::symlink(&target, shim_path).await?; - tracing::debug!("Created symlink shim at {:?} -> {:?}", shim_path, target); + tracing::debug!("Created {tool} symlink shim at {:?} -> {:?}", shim_path, target); Ok(()) } @@ -343,6 +341,7 @@ async fn create_windows_shim( let trampoline_src = get_trampoline_path()?; let shim_path = bin_dir.join(format!("{tool}.exe")); tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?; + write_shim_pointer_beside(shim_path.as_path()); // Clean up legacy .cmd and shell script wrappers from previous versions cleanup_legacy_windows_shim(bin_dir, tool).await; @@ -382,6 +381,7 @@ async fn refresh_package_shims(bin_dir: &vt_path::AbsolutePath) -> Result<(), Er tracing::warn!("Failed to refresh package shim {}: {}", bin_name, e); continue; } + write_shim_pointer_beside(shim_path.as_path()); // Remove legacy .cmd/shell wrappers that could shadow the .exe in Git Bash. cleanup_legacy_windows_shim(bin_dir, bin_name).await; @@ -392,6 +392,40 @@ async fn refresh_package_shims(bin_dir: &vt_path::AbsolutePath) -> Result<(), Er Ok(()) } +/// Write `.shim` next to a trampoline copy, with this install's data root. +#[cfg(windows)] +fn write_shim_pointer_beside(exe_path: &std::path::Path) { + if let Err(e) = vp_shared::EnvConfig::get().dirs.write_shim_pointer_beside(exe_path) { + tracing::warn!("Failed to write shim pointer for {}: {e}", exe_path.display()); + } +} + +/// Rewrite sidecars for every owned trampoline that is already on disk. +/// +/// Covers `--env-only` and skipped existing shims so a data-root change is +/// picked up without `--refresh`. +#[cfg(windows)] +fn refresh_owned_shim_pointers(dirs: &vp_shared::VpDirs) { + let mut stems = vec!["vp".to_string()]; + stems.extend(SHIM_TOOLS.iter().map(|tool| (*tool).to_string())); + 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()) + { + stems.push(stem.to_string()); + } + } + } + for stem in stems { + let exe = dirs.bin.join(format!("{stem}.exe")); + if exe.as_path().exists() { + write_shim_pointer_beside(exe.as_path()); + } + } +} + /// Get the path to the trampoline template binary (vp-shim.exe). /// /// The trampoline binary is distributed alongside vp.exe in the same directory. @@ -519,8 +553,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 +600,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 +635,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 +695,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 +756,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 +778,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 +808,123 @@ 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. Always contains either `VP_HOME` or the resolved `VP_*_DIR` +/// roots so later shells reproduce this install. +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. +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 +966,118 @@ 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_HOME, None), + (vp_shared::env_vars::VP_BIN_DIR, Some(custom_bin.as_os_str())), + (vp_shared::env_vars::VP_DATA_DIR, Some(custom_data.as_os_str())), + ("HOME", Some(temp_dir.path().as_os_str())), + ("USERPROFILE", Some(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_pins_resolved_dirs_without_vp_home() { + let temp_dir = TempDir::new().unwrap(); + vp_shared::EnvConfig::with_vars( + [ + ("HOME", Some(temp_dir.path().as_os_str())), + ("USERPROFILE", Some(temp_dir.path().as_os_str())), + (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::VP_CACHE_DIR, None), + ], + |_| { + 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 pin VP_HOME when unset, got: {content}" + ); + assert!( + !content.contains("XDG_"), + "{shell:?} env file should not re-export XDG_*, got: {content}" + ); + assert!( + content.contains("VP_BIN_DIR") + && content.contains("VP_DATA_DIR") + && content.contains("VP_CACHE_DIR"), + "{shell:?} env file should pin resolved VP_*_DIR roots, 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 +1091,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 +1114,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 +1135,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 +1461,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 +1483,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 +1513,14 @@ 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()).await.unwrap(); + assert_eq!(target, standalone_vp.as_path()); + }, + ) + .await; } #[tokio::test] @@ -1357,9 +1537,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).await.unwrap(); + assert_eq!(target, external_vp); + }, + ) + .await; } #[tokio::test] @@ -1373,9 +1558,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).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).await.unwrap(); + assert_eq!(target, data.join("current").join("bin").join("vp").as_path()); + }, + ) + .await; } #[tokio::test] @@ -1394,11 +1617,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 +1643,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 +1672,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 +1696,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) -> InstallError { (Some(package_name.to_string()), Box::new(error.into())) } -/// Symlink target used for package shims on Unix (relative to the bin dir). -#[cfg(unix)] -pub(crate) const PACKAGE_SHIM_TARGET: &str = "../current/bin/vp"; - -/// Check whether a bin symlink target points at the vp binary: the standard -/// relative package-shim target, or a resolvable link to a binary named `vp` -/// (absolute paths in external/dev layouts created by `vp env setup`). -#[cfg(unix)] -pub(crate) fn is_vp_shim_target( - target: &std::path::Path, - shim_path: &vt_path::AbsolutePath, -) -> bool { - target == std::path::Path::new(PACKAGE_SHIM_TARGET) - || (target.file_name().is_some_and(|file_name| file_name == "vp") - && std::fs::exists(shim_path.as_path()).unwrap_or(false)) +/// Absolute path of the `vp` binary package shims should link to: +/// `/current/bin/vp` (or `vp.exe` on Windows). +pub(crate) fn package_shim_target() -> AbsolutePathBuf { + vp_shared::EnvConfig::get() + .dirs + .data + .join("current") + .join("bin") + .join(vp_shared::VP_BINARY_NAME) +} + +/// Whether `shim_path` is a Vite+ shim for this install's `vp`. +/// +/// Unix: a symlink whose target (relative links resolved against the shim +/// parent) is [`package_shim_target`], or a working link to a `vp` binary +/// (dev / `vp env setup` layouts). Windows: a symlink to `vp`/`vp.exe`, +/// `vp-use.cmd`, or a trampoline whose matching `.shim` records this +/// install's data root. A regular file alone is not enough: shared +/// `VP_BIN_DIR` directories may contain unrelated `node.exe` / `npm.exe`. +pub(crate) fn is_vp_shim_target(shim_path: &vt_path::AbsolutePath) -> bool { + match std::fs::read_link(shim_path.as_path()) { + Ok(target) => { + if cfg!(windows) { + target + .file_name() + .is_some_and(|name| name == vp_shared::VP_BINARY_NAME || name == "vp") + } else { + let expected = package_shim_target(); + let resolved = shim_path.parent().map(|parent| parent.join(&target).clean()); + resolved.as_ref().is_some_and(|path| path.as_path() == expected.as_path()) + || (target.file_name().is_some_and(|name| name == "vp") + && std::fs::exists(shim_path.as_path()).unwrap_or(false)) + } + } + Err(_) => cfg!(windows) && windows_regular_file_is_vp_shim(shim_path), + } +} + +/// Windows trampoline / `vp-use.cmd` ownership check. +fn windows_regular_file_is_vp_shim(shim_path: &vt_path::AbsolutePath) -> bool { + if !shim_path.as_path().is_file() { + return false; + } + if shim_path.as_path().file_name().is_some_and(|name| name == "vp-use.cmd") { + return true; + } + let Ok(bytes) = + std::fs::read(shim_path.as_path().with_extension(vp_shared::SHIM_POINTER_EXTENSION)) + else { + return false; + }; + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes.as_slice()); + let Ok(text) = std::str::from_utf8(bytes) else { + return false; + }; + let text = text.trim(); + !text.is_empty() + && std::path::Path::new(text) == vp_shared::EnvConfig::get().dirs.data.as_path() } /// Check whether a binary name is a shim Vite+ owns unconditionally: core @@ -1095,7 +1138,7 @@ fn is_javascript_binary(path: &AbsolutePath) -> bool { /// Create a shim for a package binary. /// -/// On Unix: Creates a symlink to ../current/bin/vp +/// On Unix: Creates a symlink to `/current/bin/vp` /// On Windows: Creates a trampoline .exe that forwards to vp.exe pub(crate) async fn create_package_shim( bin_dir: &vt_path::AbsolutePath, @@ -1122,17 +1165,19 @@ pub(crate) async fn create_package_shim( // Keep an existing Vite+ shim: replacing an external/dev-layout link // with the relative target would dangle when VP_HOME/current is absent. - if let Ok(target) = tokio::fs::read_link(&shim_path).await { - if is_vp_shim_target(&target, &shim_path) { + if tokio::fs::read_link(&shim_path).await.is_ok() { + if is_vp_shim_target(&shim_path) { return Ok(()); } // Exists but points elsewhere (e.g., npm-installed direct symlink) — replace it tokio::fs::remove_file(&shim_path).await?; } - // Create symlink to ../current/bin/vp - tokio::fs::symlink(PACKAGE_SHIM_TARGET, &shim_path).await?; - tracing::debug!("Created package shim symlink {:?} -> ../current/bin/vp", shim_path); + // Point at the active vp the same way `vp env setup` does. + let current_exe = std::env::current_exe()?; + let target = crate::commands::env::setup::resolve_unix_vp_shim_target(¤t_exe).await?; + tokio::fs::symlink(&target, &shim_path).await?; + tracing::debug!("Created package shim symlink {:?} -> {:?}", shim_path, target); } #[cfg(windows)] @@ -1151,6 +1196,7 @@ pub(crate) async fn create_package_shim( // VP_SHIM_TOOL env var before spawning vp.exe. let trampoline_src = get_trampoline_path()?; tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?; + vp_shared::EnvConfig::get().dirs.write_shim_pointer_beside(shim_path.as_path())?; // Remove legacy .cmd and shell script wrappers from previous versions. // In Git Bash/MSYS, the extensionless script takes precedence over .exe, @@ -1185,7 +1231,7 @@ async fn remove_package_shim(bin_dir: &vt_path::AbsolutePath, bin_name: &str) -> { // Remove trampoline .exe shim and legacy .cmd / shell script wrappers. // Best-effort: ignore NotFound errors for files that don't exist. - for suffix in &[".exe", ".cmd", ""] { + for suffix in &[".exe", ".cmd", ".shim", ""] { let path = if suffix.is_empty() { bin_dir.join(bin_name) } else { @@ -1200,72 +1246,90 @@ 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(); + 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()); + let pointer = bin_dir.join("test-shim.shim"); + assert!(pointer.as_path().exists(), "per-exe sidecar must be written"); + let contents = std::fs::read_to_string(pointer.as_path()).unwrap(); + assert_eq!( + contents.trim(), + vp_shared::EnvConfig::get().dirs.data.as_path().to_string_lossy() + ); + } + }, + ) + .await; + } - // Verify bin directory was created - assert!(bin_dir.as_path().exists()); + #[cfg(windows)] + #[test] + fn is_vp_shim_target_requires_sidecar_for_windows_exes() { + vp_shared::EnvConfig::scoped(|config| { + let bin = &config.dirs.bin; + std::fs::create_dir_all(bin).unwrap(); + std::fs::write(bin.join("node.exe").as_path(), b"system-node").unwrap(); + assert!( + !is_vp_shim_target(&bin.join("node.exe")), + "unrelated node.exe without a sidecar must not be treated as ours" + ); - // 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"); + config.dirs.write_shim_pointer("node").unwrap(); assert!( - std::fs::symlink_metadata(shim_path.as_path()).is_ok(), - "Symlink shim should exist" + is_vp_shim_target(&bin.join("node.exe")), + "trampoline with this install's sidecar is owned" ); - } - #[cfg(windows)] - { - let shim_path = bin_dir.join("test-shim.exe"); - assert!(shim_path.as_path().exists()); - } + + std::fs::write(bin.join("vp-use.cmd").as_path(), b"@echo off").unwrap(); + assert!(is_vp_shim_target(&bin.join("vp-use.cmd"))); + }); } #[tokio::test] @@ -1330,49 +1394,57 @@ 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"); - - // 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"); - } + 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(); + + // Verify the shim was removed + assert!(!shim_path.as_path().exists(), "Shim should be removed"); + assert!( + !bin_dir.join("tsc.shim").as_path().exists(), + "per-exe sidecar should be removed" + ); + } + }, + ) + .await; } #[tokio::test] @@ -1388,92 +1460,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 +1566,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"; - - 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(); + 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(); - uninstall(package_name, false).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(); - 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); + // 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 +1693,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..e3a4783a43 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -2,16 +2,18 @@ use std::{io::Write, process::ExitStatus}; -use directories::BaseDirs; use owo_colors::OwoColorize; +use rustc_hash::FxHashSet; use vp_shared::output; use vt_path::AbsolutePathBuf; 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}, + global::install::is_vp_shim_target, + shell::{ALL_SHELL_PROFILES, ShellProfileKind, abbreviate_home_path, resolve_profile_path}, }, error::Error, }; @@ -20,28 +22,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 +65,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 +88,71 @@ 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`). Package shims are taken from +/// `/bins/*.json`; `vp` and the default env shims are also +/// considered because they are not recorded there. A candidate is deleted +/// only when it is a symlink to this install's `vp` (Unix) or a trampoline +/// we wrote (Windows). +fn remove_shim_files(dirs: &vp_shared::VpDirs) { + let mut names = recorded_bin_shim_names(dirs); + names.insert(shim_filename("vp")); + names.extend(SHIM_TOOLS.iter().map(|tool| shim_filename(tool))); + #[cfg(windows)] + names.insert("vp-use.cmd".to_string()); + + let mut removed = 0; + for name in names { + let path = dirs.bin.join(&name); + if !is_vp_shim_target(&path) { + continue; + } + match std::fs::remove_file(path.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 let Some(stem) = std::path::Path::new(&name).file_stem().and_then(|stem| stem.to_str()) { + let pointer = dirs.bin.join(vp_shared::shim_pointer_file_name(stem)); + match std::fs::remove_file(pointer.as_path()) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + output::warn(&vt_str::format!("Failed to remove {stem}.shim: {e}")); + } + } + } + } + if removed > 0 { + output::success(&vt_str::format!( + "Removed {removed} shim{} from {}", + if removed == 1 { "" } else { "s" }, + dirs.bin.as_path().display() + )); + } +} + +/// Binary names recorded in `/bins/*.json`. +fn recorded_bin_shim_names(dirs: &vp_shared::VpDirs) -> FxHashSet { + let mut names = FxHashSet::default(); + let Ok(entries) = std::fs::read_dir(dirs.data.join("bins").as_path()) else { + return names; + }; + 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.insert(shim_filename(stem)); + } + } + names +} + /// A shell profile that contains Vite+ sourcing lines. struct AffectedProfile { /// Display name (e.g. ".zshrc", ".config/fish/conf.d/vite-plus.fish"). @@ -126,7 +210,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 +223,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 +277,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 +361,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 +471,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 +492,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 +694,6 @@ mod tests { } #[test] - #[serial] #[cfg(not(windows))] fn test_collect_affected_profiles() { let temp_dir = tempfile::tempdir().unwrap(); @@ -615,25 +702,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 +728,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, - } - - #[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 - } - } + 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 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 +753,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 +784,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 +814,81 @@ 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()); + }); + } + + #[cfg(unix)] + #[test] + fn remove_shim_files_deletes_only_vp_symlinks() { + vp_shared::EnvConfig::scoped(|config| { + let bin = &config.dirs.bin; + let bins_dir = config.dirs.data.join("bins"); + std::fs::create_dir_all(bin).unwrap(); + std::fs::create_dir_all(&bins_dir).unwrap(); + + std::fs::write(bin.join("node").as_path(), b"system-node").unwrap(); + let vp_target = crate::commands::global::install::package_shim_target(); + std::os::unix::fs::symlink(vp_target.as_path(), bin.join("vp").as_path()).unwrap(); + // Leftover relative link from a monolithic `/bin` must still + // resolve to `/current/bin/vp` and be removed. + std::os::unix::fs::symlink("../current/bin/vp", bin.join("npm").as_path()).unwrap(); + + std::fs::write(bins_dir.join("tsc.json").as_path(), "{}").unwrap(); + std::os::unix::fs::symlink(vp_target.as_path(), bin.join("tsc").as_path()).unwrap(); + std::fs::write(bin.join("tsc.shim").as_path(), "data\n").unwrap(); + + std::fs::write(bins_dir.join("eslint.json").as_path(), "{}").unwrap(); + std::os::unix::fs::symlink("/usr/bin/eslint", bin.join("eslint").as_path()).unwrap(); + + remove_shim_files(&config.dirs); + + assert!(bin.join("node").as_path().is_file(), "unrelated node binary must be kept"); + assert!( + std::fs::symlink_metadata(bin.join("eslint").as_path()).is_ok(), + "recorded shim that does not point at vp must be kept" + ); + assert!( + std::fs::symlink_metadata(bin.join("vp").as_path()).is_err(), + "vp symlink must be removed" + ); + assert!( + std::fs::symlink_metadata(bin.join("npm").as_path()).is_err(), + "default env shim that points at vp must be removed" + ); + assert!( + std::fs::symlink_metadata(bin.join("tsc").as_path()).is_err(), + "recorded package shim that points at vp must be removed" + ); + assert!( + !bin.join("tsc.shim").as_path().exists(), + "sidecar next to a removed shim must be removed" + ); + }); } } 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..c7a48ad03f 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -336,6 +336,19 @@ 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; + } + use vp_shared::env_vars::dump_dirs; + let dirs = &vp_shared::EnvConfig::get().dirs; + println!("{}\t{}", dump_dirs::DATA, dirs.data.as_path().display()); + println!("{}\t{}", dump_dirs::BIN, dirs.bin.as_path().display()); + println!("{}\t{}", dump_dirs::CONFIG, dirs.config.as_path().display()); + true +} + #[tokio::main] async fn main() -> ExitCode { vp_shared::ensure_blocking_stdio(); @@ -343,6 +356,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/corepack.rs b/crates/vp_global_cli/src/shim/corepack.rs index 92c74c6bf5..34f83c892d 100644 --- a/crates/vp_global_cli/src/shim/corepack.rs +++ b/crates/vp_global_cli/src/shim/corepack.rs @@ -400,7 +400,7 @@ async fn restore_vp_owned_shims(bin_dir: &AbsolutePath, owned_shims: &[OwnedShim /// Check whether a default shim (npm/npx) is still an intact Vite+ shim. /// -/// Vite+ shims always link to the vp binary (relative `../current/bin/vp` or +/// Vite+ shims always link to the vp binary (`/current/bin/vp` or /// an absolute path in dev layouts); corepack launchers link to corepack's /// `dist/*.js` files. Broken symlinks count as not intact. #[cfg(unix)] @@ -474,10 +474,7 @@ async fn npm_link_source(_bin_dir: &AbsolutePath, _name: &str) -> Option bool { let shim_path = bin_dir.join(name); - match tokio::fs::read_link(&shim_path).await { - Ok(target) => crate::commands::global::install::is_vp_shim_target(&target, &shim_path), - Err(_) => false, - } + crate::commands::global::install::is_vp_shim_target(&shim_path) } /// Check whether the bin entry is an intact Vite+ package shim. 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..36dac4448a 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}; @@ -22,15 +22,16 @@ struct UpgradeCheckCache { prompted_at: u64, } -fn read_cache(install_dir: &vt_path::AbsolutePath) -> Option { - let cache_path = install_dir.join(CACHE_FILE_NAME); +fn read_cache(cache_dir: &vt_path::AbsolutePath) -> Option { + let cache_path = cache_dir.join(CACHE_FILE_NAME); let data = std::fs::read_to_string(cache_path.as_path()).ok()?; serde_json::from_str(&data).ok() } -fn write_cache(install_dir: &vt_path::AbsolutePath, cache: &UpgradeCheckCache) { - let cache_path = install_dir.join(CACHE_FILE_NAME); +fn write_cache(cache_dir: &vt_path::AbsolutePath, cache: &UpgradeCheckCache) { + let cache_path = cache_dir.join(CACHE_FILE_NAME); if let Ok(data) = serde_json::to_string(cache) { + let _ = std::fs::create_dir_all(cache_dir.as_path()); let _ = std::fs::write(cache_path.as_path(), &data); } } @@ -72,17 +73,18 @@ async fn resolve_version_string() -> Option { } pub struct UpgradeCheckResult { - install_dir: vt_path::AbsolutePathBuf, + cache_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 cache_dir = &config.dirs.cache; let current_version = env!("CARGO_PKG_VERSION"); let now = now_secs(); - let mut cache = read_cache(&install_dir); + let mut cache = read_cache(cache_dir); if should_check(cache.as_ref(), now) { let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); @@ -90,7 +92,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(cache_dir, &new_cache); cache = Some(new_cache); } None => { @@ -98,7 +100,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(cache_dir, &failed_cache); cache = Some(failed_cache); } } @@ -114,7 +116,7 @@ pub async fn check_for_update() -> Option { return None; } - Some(UpgradeCheckResult { install_dir, cache }) + Some(UpgradeCheckResult { cache_dir: cache_dir.clone(), cache }) } /// Print a one-line upgrade notice to stderr and record the prompt time. @@ -133,7 +135,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.cache_dir, &cache); } /// Whether the upgrade check should run for the given command args. @@ -162,8 +164,6 @@ pub fn should_run_for_command(args: &crate::cli::Args) -> bool { #[cfg(test)] mod tests { - use serial_test::serial; - use super::*; #[test] @@ -181,6 +181,21 @@ mod tests { assert_eq!(loaded.prompted_at, 900); } + #[test] + fn write_cache_creates_missing_parent() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = + vt_path::AbsolutePathBuf::new(dir.path().join("missing").join("cache")).unwrap(); + assert!(!dir_path.as_path().exists()); + + let cache = + UpgradeCheckCache { latest: "1.2.3".to_owned(), checked_at: 1000, prompted_at: 900 }; + write_cache(&dir_path, &cache); + + let loaded = read_cache(&dir_path).expect("should create parent and write cache"); + assert_eq!(loaded.latest, "1.2.3"); + } + #[test] fn read_cache_returns_none_for_missing_file() { let dir = tempfile::tempdir().unwrap(); @@ -197,32 +212,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 +223,6 @@ mod tests { } #[test] - #[serial] fn should_check_returns_false_when_cache_fresh() { with_env_vars_cleared(|| { let now = now_secs(); @@ -241,7 +233,6 @@ mod tests { } #[test] - #[serial] fn should_check_returns_true_when_cache_stale() { with_env_vars_cleared(|| { let now = now_secs(); @@ -256,13 +247,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..9ad56eec75 100644 --- a/crates/vp_installer/Cargo.toml +++ b/crates/vp_installer/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" clap = { workspace = true, features = ["derive"] } indicatif = { workspace = true } owo-colors = { workspace = true, features = ["supports-colors"] } +tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } vp_pm_cli = { workspace = true } vt_path = { workspace = true } @@ -25,5 +26,8 @@ which = { workspace = true } [target.'cfg(windows)'.dependencies] winreg = { workspace = true } +[dev-dependencies] +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..d8b5a6d8e6 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,57 @@ 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); } +fn dir_displays(dirs: &VpDirs) -> (String, String) { + ( + dirs.data.as_path().to_string_lossy().to_string(), + dirs.bin.as_path().to_string_lossy().to_string(), + ) +} + #[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, bin_dir_display) = dir_displays(&dirs); // 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 { - Ok(()) => { - print_success(&opts, &install_dir_display); + let code = match do_install(&opts, &dirs).await { + Ok(effective_dirs) => { + // A pre-split payload falls back to the monolithic root inside + // do_install; report the directories that were actually used. + let (data_dir_display, bin_dir_display) = dir_displays(&effective_dirs); + print_success(&opts, &data_dir_display, &bin_dir_display); 0 } Err(e) => { @@ -164,19 +174,24 @@ async fn run(mut opts: cli::Options, install_dir: AbsolutePathBuf) -> i32 { code } +/// Install the resolved version and return the directories that were actually +/// used: a pre-split payload falls back to the monolithic root mid-install. #[allow(clippy::print_stdout)] async fn do_install( opts: &cli::Options, - install_dir: &AbsolutePathBuf, -) -> Result<(), Box> { + dirs: &VpDirs, +) -> Result> { + let mut dirs = dirs.clone(); let platform_suffix = platform::detect_platform_suffix()?; if !opts.quiet { print_info(&format!("detected platform: {platform_suffix}")); } - // Check local version first to potentially skip HTTP requests - tokio::fs::create_dir_all(install_dir).await?; - let current_version = install::read_current_version(install_dir).await; + // Check local version first to potentially skip HTTP requests. + // Read-only here: the install root is created only after the downloaded + // payload confirms the layout, so a pre-split fallback leaves no empty + // split directories behind. + let current_version = install::read_current_version(&dirs.data).await; let version_or_tag = opts.version.as_deref().unwrap_or(&opts.tag); @@ -195,7 +210,7 @@ async fn do_install( let same_version = current_version .as_deref() .is_some_and(|current| install::is_install_dir_for_version(current, &target_version)) - && tokio::fs::try_exists(install_dir.join("current").join("bin").join(VP_BINARY_NAME)) + && tokio::fs::try_exists(dirs.data.join("current").join("bin").join(VP_BINARY_NAME)) .await .unwrap_or(false); @@ -230,6 +245,42 @@ async fn do_install( } integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; + // A pre-split release resolves every path from + // VP_HOME (default ~/.vite-plus); its env setup, shims, and + // trampolines cannot follow split roots. Fall back to that monolithic + // root when the payload cannot report split category roots. + let legacy = VpDirs::legacy_single_root(&vp_shared::EnvConfig::get().user_home); + let abandoned_split_data = if legacy.data == dirs.data { + // Pre-split and split-aware payloads target the same monolithic + // root here; skip the probe (a full payload extraction + spawn). + None + } else if let Some(probed) = probe_payload_dirs(&platform_data).await { + // Adopt the payload's own resolution, like install.sh / + // install.ps1, so the layout written and the layout the binary + // resolves cannot drift. cache/state stay self-resolved; the + // installer never touches them. + dirs = VpDirs { + data: probed.data, + bin: probed.bin, + config: probed.config, + cache: dirs.cache, + state: dirs.state, + }; + None + } else { + if !opts.quiet { + print_info(&format!( + "vite-plus {target_version} does not support the split directory layout; the install goes to {}", + legacy.data.as_path().display() + )); + } + let split_data = dirs.data.clone(); + let preexisted = tokio::fs::try_exists(&split_data).await.unwrap_or(true); + dirs = legacy; + (!preexisted).then_some(split_data) + }; + + let install_dir = &dirs.data; let version_dir = install_dir.join(&target_version); tokio::fs::create_dir_all(&version_dir).await?; @@ -247,6 +298,14 @@ async fn do_install( if result.is_err() { let _ = tokio::fs::remove_dir_all(&version_dir).await; } + + // Managed node/pnpm for the wrapper install resolve their paths from + // the process EnvConfig, which was pinned before the payload chose + // the monolithic root. Drop the split data root they landed in when + // this run created it. + if let Some(split_data) = abandoned_split_data { + let _ = tokio::fs::remove_dir_all(&split_data).await; + } result?; } @@ -257,7 +316,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}")); } @@ -265,21 +324,63 @@ async fn do_install( if !opts.quiet { print_info("setting up Node.js version manager..."); } - if let Err(e) = install::refresh_shims(install_dir).await { + if let Err(e) = install::refresh_shims(&dirs.data).await { print_warn(&format!("Node.js manager setup failed (non-fatal): {e}")); } - } else if let Err(e) = install::create_env_files(install_dir).await { + } else if let Err(e) = install::create_env_files(&dirs.data).await { print_warn(&format!("Env file creation failed (non-fatal): {e}")); } 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}")); } } - Ok(()) + Ok(dirs) +} + +/// Category roots a split-aware payload reports via `VP_DUMP_DIRS`. +struct ProbedDirs { + data: AbsolutePathBuf, + bin: AbsolutePathBuf, + config: AbsolutePathBuf, +} + +/// Ask the downloaded payload for its directory layout: a split-aware `vp` +/// prints tab-separated category roots under `VP_DUMP_DIRS=1`, a pre-split +/// release prints its help instead. Errors count as "no answer" — the +/// monolithic root works for every release, so the fallback direction is +/// safe. +async fn probe_payload_dirs(platform_data: &[u8]) -> Option { + let temp = tempfile::tempdir().ok()?; + let temp_root = AbsolutePathBuf::new(temp.path().to_path_buf())?; + install::extract_platform_package(platform_data, &temp_root).await.ok()?; + + let vp_binary = temp_root.join("bin").join(VP_BINARY_NAME); + let output = tokio::process::Command::new(vp_binary.as_path()) + .env(vp_shared::env_vars::VP_DUMP_DIRS, "1") + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + + use vp_shared::env_vars::dump_dirs; + let stdout = String::from_utf8_lossy(&output.stdout); + let category = |key: &str| { + stdout.lines().find_map(|line| { + let root = line.strip_prefix(key)?.strip_prefix('\t')?; + AbsolutePathBuf::new(root.into()) + }) + }; + Some(ProbedDirs { + data: category(dump_dirs::DATA)?, + bin: category(dump_dirs::BIN)?, + config: category(dump_dirs::CONFIG)?, + }) } /// Auto-detect whether the Node.js version manager should be enabled. @@ -295,7 +396,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 +410,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,28 +500,27 @@ 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) { replace_windows_exe(&src, &shim_dst, &bin_dir).await?; } + dirs.write_shim_pointer("vp")?; // Best-effort cleanup of old shim files if let Ok(mut entries) = tokio::fs::read_dir(&bin_dir).await { @@ -434,10 +534,10 @@ 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_path = bin_dir.join("vp"); let _ = tokio::fs::remove_file(&link_path).await; - tokio::fs::symlink(&link_target, &link_path).await?; + tokio::fs::symlink(current_vp.as_path(), &link_path).await?; } Ok(()) @@ -466,14 +566,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 +604,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 +701,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 +713,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 +736,109 @@ 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 { + let mut vars = + vec![("HOME", Some(home.as_os_str())), ("USERPROFILE", Some(home.as_os_str()))]; + vars.extend(env_vars::LAYOUT_OVERRIDE_VARS.iter().map(|name| (*name, None))); + EnvConfig::with_vars(vars, |_| 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"); + // Grandfathering requires a real install: the `current` link, not a + // bare directory. + std::fs::create_dir_all(legacy.join("current")).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..779cfe0468 --- /dev/null +++ b/crates/vp_shared/src/dirs.rs @@ -0,0 +1,169 @@ +//! 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" }; + +/// Extension of the per-exe sidecar that records the data root for Windows +/// trampolines (`/.shim` next to `/.exe`). +/// +/// Independent `VP_BIN_DIR` / `VP_DATA_DIR` put the shim and payload under +/// different parents. The trampoline must not read dir env vars, so +/// installers and `vp env setup` write this UTF-8 one-line file beside +/// every trampoline copy. +pub const SHIM_POINTER_EXTENSION: &str = "shim"; + +/// Sidecar filename for a trampoline named `.exe`. +#[must_use] +pub fn shim_pointer_file_name(exe_stem: &str) -> String { + format!("{exe_stem}.{SHIM_POINTER_EXTENSION}") +} + +/// 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, …). + 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)?, + }) + } + + /// Single-root mapping for releases that predate the split layout. + /// + /// Those binaries resolve every path from `VP_HOME` (default + /// `/.vite-plus`); their env setup, shims, and trampolines cannot + /// follow split roots. Installers use this mapping when the downloaded + /// payload cannot report split category roots via `VP_DUMP_DIRS`. + #[must_use] + pub fn legacy_single_root(home: &AbsolutePath) -> Self { + let root = resolution::vp_home_override() + .unwrap_or_else(|| home.join(resolution::VP_HOME_DIR_NAME)); + resolution::single_root_dirs(root) + } + + /// Write `/.shim` so the trampoline can find ``. + pub fn write_shim_pointer(&self, exe_stem: &str) -> std::io::Result<()> { + self.write_shim_pointer_beside(self.bin.join(format!("{exe_stem}.exe")).as_path()) + } + + /// Write `.shim` next to an existing trampoline copy. + pub fn write_shim_pointer_beside(&self, exe_path: &std::path::Path) -> std::io::Result<()> { + if let Some(parent) = exe_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut line = self.data.as_path().to_string_lossy().into_owned(); + line.push('\n'); + std::fs::write(exe_path.with_extension(SHIM_POINTER_EXTENSION), line) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::EnvConfig; + + #[test] + fn write_shim_pointer_records_data_root_per_exe() { + EnvConfig::scoped(|config| { + config.dirs.write_shim_pointer("vp").unwrap(); + config.dirs.write_shim_pointer("node").unwrap(); + let data = config.dirs.data.as_path().to_string_lossy(); + for stem in ["vp", "node"] { + let path = config.dirs.bin.join(shim_pointer_file_name(stem)); + let contents = std::fs::read_to_string(path.as_path()).unwrap(); + assert_eq!(contents.trim(), data); + } + }); + } + + #[test] + fn shim_pointer_file_name_uses_stem_and_extension() { + assert_eq!(shim_pointer_file_name("vp"), "vp.shim"); + assert_eq!(shim_pointer_file_name("node"), "node.shim"); + } + + #[test] + fn legacy_single_root_defaults_to_home_root() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + temp_env::with_var(crate::env_vars::VP_HOME, None::<&str>, || { + let dirs = VpDirs::legacy_single_root(&home); + let expected = home.join(resolution::VP_HOME_DIR_NAME); + assert_eq!(dirs.data, expected); + assert_eq!(dirs.bin, expected.join("bin")); + assert_eq!(dirs.cache, expected.join("cache")); + assert_eq!(dirs.config, expected); + assert_eq!(dirs.state, expected); + }); + } + + #[test] + fn legacy_single_root_honors_vp_home() { + let root = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + let pinned = home.join("custom-root"); + temp_env::with_var(crate::env_vars::VP_HOME, Some(pinned.as_path().as_os_str()), || { + let dirs = VpDirs::legacy_single_root(&home); + assert_eq!(dirs.data, pinned); + assert_eq!(dirs.bin, pinned.join("bin")); + assert_eq!(dirs.config, pinned); + }); + } +} diff --git a/crates/vp_shared/src/dirs/resolution.rs b/crates/vp_shared/src/dirs/resolution.rs new file mode 100644 index 0000000000..1a4a90a0d6 --- /dev/null +++ b/crates/vp_shared/src/dirs/resolution.rs @@ -0,0 +1,604 @@ +//! 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 contains a `current` link (a real install, not a stray +//! tree left by a pre-split local CLI). +//! - [`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())) +} + +/// Absolute `VP_HOME` override from the process environment, if set. +pub(super) fn vp_home_override() -> Option { + process_env_var(env_vars::VP_HOME) +} + +/// 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() + } +} + +/// The single-root mapping as a full [`super::VpDirs`] value, for callers +/// outside the chain (the installer fallback for pre-split payloads). +pub(super) fn single_root_dirs(root: AbsolutePathBuf) -> super::VpDirs { + let place = SingleRoot { root: Some(root) }; + super::VpDirs { + bin: place.bin_dir().expect("single-root mapping is total"), + data: place.data_dir().expect("single-root mapping is total"), + cache: place.cache_dir().expect("single-root mapping is total"), + config: place.config_dir().expect("single-root mapping is total"), + state: place.state_dir().expect("single-root mapping is total"), + } +} + +/// `VP_HOME` override: always pins the single-root mapping when set. +struct VpHome; + +impl VpHome { + fn resolver(_home: &AbsolutePath) -> SingleRoot { + SingleRoot { root: vp_home_override() } + } +} + +/// An existing single-root install under the injected home, `/.vite-plus`. +struct UserHome; + +impl UserHome { + /// Proposes the root only when it contains the `current` link every + /// global install activates. Bare existence of `~/.vite-plus` is not + /// enough: pre-split local CLIs create that directory for caches, + /// config, and managed runtimes. Such a stray tree must not capture a + /// split install, or a later `vp upgrade` would silently move to the + /// monolithic root while the split PATH entries go stale. The gate + /// checks the link without following it, so an install with a dangling + /// `current` (crash mid-upgrade) still grandfathers. The installers + /// gate the same way. + fn resolver(home: &AbsolutePath) -> SingleRoot { + let root = home.join(VP_HOME_DIR_NAME); + let is_install = std::fs::symlink_metadata(root.join("current").as_path()).is_ok(); + SingleRoot { root: is_install.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.join("current")).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()); + } + + /// A `~/.vite-plus` without a `current` link is not an install: pre-split + /// local CLIs create the directory for caches, config, and managed + /// runtimes, and such a stray tree must not capture a split install. + #[test] + fn user_home_abstains_for_stray_root_without_current() { + 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.join("cache")).unwrap(); + std::fs::create_dir_all(vp_home.join("js_runtime")).unwrap(); + std::fs::write(vp_home.join("config.json"), "{}").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()); + } + + /// A dangling `current` link still marks an install (for example a crash + /// mid-upgrade); the gate checks link presence without following it. + #[cfg(unix)] + #[test] + fn user_home_accepts_dangling_current_link() { + 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(); + std::os::unix::fs::symlink("0.0.0-missing", vp_home.join("current")).unwrap(); + + let place = UserHome::resolver(&home); + assert_dir(place.data_dir(), vp_home.as_path()); + } + + #[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..8dac8d0276 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -1,60 +1,138 @@ //! 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 directories::BaseDirs; +use vt_path::AbsolutePathBuf; + +use crate::{VpDirs, env_vars}; + +/// 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())) +} -use crate::env_vars; +/// 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())) +} -/// Global config initialized once in `main()`. -static ENV_CONFIG: OnceLock = OnceLock::new(); +/// 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())) +} -thread_local! { - /// Thread-local test override. Each test thread gets its own slot. - static TEST_CONFIG: RefCell> = const { RefCell::new(None) }; +/// Layout variables to re-export in persisted shell context. +/// +/// An *absolute* `VP_HOME` pins every category, so it is captured alone +/// (verbatim from the process environment). Relative `VP_HOME` is ignored +/// by resolution and must not be re-exported, or later shells would lose +/// the resolved `VP_*_DIR` roots. Otherwise the *resolved* `bin` / `data` / +/// `cache` roots are stored as `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR`. +/// That pins this install for later shells without re-exporting `XDG_*` +/// (those are user/session policy for every tool, not Vite+ overrides). +fn dir_envs_from_resolved(dirs: &VpDirs) -> HashMap<&'static str, String> { + if let Some(home) = std::env::var_os(env_vars::VP_HOME).and_then(|path| { + let display = path.to_string_lossy().into_owned(); + AbsolutePathBuf::new(path.into()).map(|_| display) + }) { + return HashMap::from([(env_vars::VP_HOME, home)]); + } + HashMap::from([ + (env_vars::VP_BIN_DIR, dirs.bin.as_path().to_string_lossy().into_owned()), + (env_vars::VP_DATA_DIR, dirs.data.as_path().to_string_lossy().into_owned()), + (env_vars::VP_CACHE_DIR, dirs.cache.as_path().to_string_lossy().into_owned()), + ]) } /// 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 variables to re-export to persisted shell context. /// - /// Env: `VP_HOME` - pub vite_plus_home: Option, + /// Contains either `VP_HOME` alone (when that override is an absolute + /// path) or the resolved `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR` + /// roots — never both, and never `XDG_*`. Consumers that write shell context + /// (`vp env setup` scripts, the Windows `vp-use.cmd` wrapper) render + /// these so child processes resolve the identical roots even when the + /// later session has different XDG variables. + pub dir_envs: HashMap<&'static str, String>, /// NPM registry URL. /// @@ -91,8 +169,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 +184,24 @@ 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"); + let dirs = + VpDirs::resolve(&user_home).expect("vite-plus directories could not be resolved"); + Arc::new(Self { + dir_envs: dir_envs_from_resolved(&dirs), + dirs, 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 +213,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() + } + } +} + +/// 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()) } +} - /// Run a closure with a test config override (thread-local, parallel-safe). +#[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, - } + 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 } - /// 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() } - } - - /// 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 +399,262 @@ impl Drop for TestEnvGuard { mod tests { use super::*; + /// `VP_HOME` pins every category to the single-root mapping. + #[test] + 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. + /// Layout override vars are cleared: a developer shell can export + /// `VP_HOME` (vp's env script does) or `XDG_*`, which would win over + /// the platform tail. + #[cfg(not(target_os = "windows"))] #[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_home_yields_split_layout() { + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let mut vars = + vec![("HOME", Some(home.as_os_str())), ("USERPROFILE", Some(home.as_os_str()))]; + vars.extend(env_vars::LAYOUT_OVERRIDE_VARS.iter().map(|name| (*name, None))); + EnvConfig::with_vars(vars, |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()); } + /// Without `VP_HOME`, `dir_envs` pins the resolved roots as `VP_*_DIR` + /// so persisted shell context reproduces this install. Relative + /// `VP_BIN_DIR` is ignored by resolution and is not captured raw. #[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_HOME, None), + (env_vars::VP_BIN_DIR, Some(OsStr::new("relative/bin"))), + (env_vars::VP_CACHE_DIR, Some(cache.as_os_str())), + ("HOME", Some(root.path().as_os_str())), + ("USERPROFILE", Some(root.path().as_os_str())), + ], + |config| { + assert_eq!(config.dir_envs.len(), 3); + assert_eq!( + config.dir_envs[env_vars::VP_BIN_DIR], + config.dirs.bin.as_path().to_string_lossy() + ); + assert_eq!( + config.dir_envs[env_vars::VP_DATA_DIR], + config.dirs.data.as_path().to_string_lossy() + ); + assert_eq!(config.dir_envs[env_vars::VP_CACHE_DIR], cache.to_string_lossy()); + assert_ne!(config.dir_envs[env_vars::VP_BIN_DIR], "relative/bin"); + }, + ); + } + + /// XDG inputs affect resolution but are not re-exported; the resolved + /// `VP_*_DIR` values are what later shells need. + #[test] + fn dir_envs_pins_resolved_roots_not_xdg() { + let root = tempfile::tempdir().unwrap(); + let xdg_data = root.path().join("xdg-data"); + EnvConfig::with_vars( + [ + (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_DATA_HOME, Some(xdg_data.as_os_str())), + ("HOME", Some(root.path().as_os_str())), + ("USERPROFILE", Some(root.path().as_os_str())), + ], + |config| { + assert!(!config.dir_envs.keys().any(|name| name.starts_with("XDG_"))); assert_eq!( - EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), - "/inner" + config.dir_envs[env_vars::VP_DATA_DIR], + config.dirs.data.as_path().to_string_lossy() ); + #[cfg(not(target_os = "windows"))] + assert_eq!(config.dirs.data.as_path(), xdg_data.join("vite-plus").as_path()); + }, + ); + } + + /// 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!( + 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")); + }, + ); + } + + /// Relative `VP_HOME` is ignored by resolution; persist the resolved + /// `VP_*_DIR` roots instead of re-exporting the rejected value. + #[test] + fn dir_envs_ignores_relative_vp_home() { + let root = tempfile::tempdir().unwrap(); + let data = root.path().join("custom-data"); + EnvConfig::with_vars( + [ + (env_vars::VP_HOME, Some(OsStr::new("relative-home"))), + (env_vars::VP_DATA_DIR, Some(data.as_os_str())), + (env_vars::VP_BIN_DIR, None), + (env_vars::VP_CACHE_DIR, None), + ("HOME", Some(root.path().as_os_str())), + ("USERPROFILE", Some(root.path().as_os_str())), + ], + |config| { + assert!(!config.dir_envs.contains_key(env_vars::VP_HOME)); + assert_eq!( + config.dir_envs[env_vars::VP_DATA_DIR], + config.dirs.data.as_path().to_string_lossy() + ); + assert_eq!(config.dirs.data.as_path(), data.as_path()); + }, + ); + } + + /// 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..44e74ebcbc 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -9,12 +9,64 @@ //! //! 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"; + +/// Every environment variable the `VpDirs` resolution chain reads. Tests +/// clear these to isolate layout resolution from the developer's shell +/// (which typically exports `VP_HOME` via vp's own env script). +pub const LAYOUT_OVERRIDE_VARS: &[&str] = &[ + VP_HOME, + VP_BIN_DIR, + VP_DATA_DIR, + VP_CACHE_DIR, + XDG_BIN_HOME, + XDG_DATA_HOME, + XDG_CACHE_HOME, + XDG_CONFIG_HOME, + XDG_STATE_HOME, +]; + /// Log filter string for `tracing_subscriber` (e.g. `"debug"`, `"vt=trace"`). pub const VP_LOG: &str = "VP_LOG"; @@ -131,6 +183,21 @@ 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"; + +/// Category keys in [`VP_DUMP_DIRS`] output, one `\t` line per +/// category. Shared by the printer (`vp_global_cli`) and the Rust parser +/// (`vp-setup`); `install.sh` / `install.ps1` spell the same keys. +pub mod dump_dirs { + pub const DATA: &str = "data"; + pub const BIN: &str = "bin"; + pub const CONFIG: &str = "config"; +} + /// 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..e410a8cd11 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::{SHIM_POINTER_EXTENSION, VP_BINARY_NAME, VpDirs, shim_pointer_file_name}; +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..5bea859c52 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -31,16 +31,70 @@ fn exit_code_from_status(status: ExitStatus) -> i32 { status.code().unwrap_or(1) } +/// Must match [`vp_shared::SHIM_POINTER_EXTENSION`]. Duplicated here so this +/// binary stays dependency-free. Each trampoline reads `.shim` next to +/// itself (`node.exe` → `node.shim`). +const SHIM_POINTER_EXTENSION: &str = "shim"; + +struct VpLocation { + exe: std::path::PathBuf, + /// Data root from `.shim`. + vp_data_dir: std::path::PathBuf, +} + +/// How the child `vp.exe` should resolve category roots. +enum ChildDirPins { + /// Bin is `/bin`: single-root (`VP_HOME` / `--install-dir`). + SingleRoot, + /// Independent bin and data roots. + Split, +} + +fn child_dir_pins(bin_dir: &std::path::Path, data: &std::path::Path) -> ChildDirPins { + if bin_dir == data.join("bin").as_path() { + ChildDirPins::SingleRoot + } else { + ChildDirPins::Split + } +} + +/// Locate `vp.exe` from `/.shim`. +/// +/// Directory env vars are owned by `EnvConfig` in the child `vp.exe` — this +/// binary must not read `VP_HOME` / `VP_*_DIR`. Every trampoline copy has a +/// sidecar written at install / `vp env setup`, so sibling-layout probing +/// is not needed. +fn resolve_vp_exe(exe_path: &std::path::Path) -> Option { + let data = read_shim_pointer(exe_path)?; + let exe = data.join("current").join("bin").join("vp.exe"); + exe.exists().then_some(VpLocation { exe, vp_data_dir: data }) +} + +fn read_shim_pointer(exe_path: &std::path::Path) -> Option { + let bytes = std::fs::read(exe_path.with_extension(SHIM_POINTER_EXTENSION)).ok()?; + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes.as_slice()); + let text = std::str::from_utf8(bytes).ok()?.trim(); + if text.is_empty() { + return None; + } + Some(std::path::PathBuf::from(text)) +} + 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 via `.shim` (written next to every trampoline). 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 Some(location) = resolve_vp_exe(&exe_path) else { + use std::io::Write; + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_all(b"vite-plus: failed to locate vp.exe via .shim\n"); + process::exit(1); + }; // 3. Install Ctrl+C handler that ignores signals (child will handle them). // This prevents the "Terminate batch job (Y/N)?" prompt. @@ -48,13 +102,22 @@ 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..) + // - Single-root (`/bin`): pin VP_HOME so cache/config/state + // stay on that root when the process has no inherited VP_HOME. + // - Split: pin VP_DATA_DIR / VP_BIN_DIR. Do not set VP_HOME. // - 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); + let mut cmd = Command::new(&location.exe); cmd.args(env::args_os().skip(1)); - cmd.env("VP_HOME", vp_home); + match child_dir_pins(bin_dir, &location.vp_data_dir) { + ChildDirPins::SingleRoot => { + cmd.env("VP_HOME", &location.vp_data_dir); + } + ChildDirPins::Split => { + cmd.env("VP_DATA_DIR", &location.vp_data_dir); + cmd.env("VP_BIN_DIR", bin_dir); + } + } if tool_name != "vp" { cmd.env("VP_SHIM_TOOL", tool_name); @@ -76,7 +139,7 @@ fn main() { let stderr = std::io::stderr(); let mut handle = stderr.lock(); let _ = handle.write_all(b"vite-plus: failed to execute "); - let _ = handle.write_all(vp_exe.as_os_str().as_encoded_bytes()); + let _ = handle.write_all(location.exe.as_os_str().as_encoded_bytes()); let _ = handle.write_all(b"\n"); process::exit(1); } @@ -94,6 +157,114 @@ 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 missing_pointer_does_not_probe_sibling_layout() { + let root = std::env::temp_dir().join(format!("vp-trampoline-no-ptr-{}", 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")); + write_exe(&root.join("data").join("current").join("bin").join("vp.exe")); + + assert!(resolve_vp_exe(&root.join("bin").join("vp.exe")).is_none()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn pointer_without_payload_is_none() { + let root = std::env::temp_dir().join(format!("vp-trampoline-empty-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("bin"); + let data = root.join("data-root"); + fs::create_dir_all(&bin).unwrap(); + fs::create_dir_all(&data).unwrap(); + fs::write(bin.join("vp.shim"), format!("{}\n", data.display())).unwrap(); + + assert!(resolve_vp_exe(&bin.join("vp.exe")).is_none()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn pointer_file_locates_data_root() { + let root = std::env::temp_dir().join(format!("vp-trampoline-ptr-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("custom-bin"); + let data = root.join("custom-data"); + fs::create_dir_all(&bin).unwrap(); + write_exe(&data.join("current").join("bin").join("vp.exe")); + write_exe(&root.join("data").join("current").join("bin").join("vp.exe")); + fs::write(bin.join("vp.shim"), format!("{}\n", data.display())).unwrap(); + + let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); + assert_eq!(location.exe, data.join("current").join("bin").join("vp.exe")); + assert_eq!(location.vp_data_dir, data); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn pointer_file_is_per_exe_name() { + let root = std::env::temp_dir().join(format!("vp-trampoline-per-exe-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("bin"); + let node_data = root.join("node-data"); + let decoy_data = root.join("decoy-data"); + fs::create_dir_all(&bin).unwrap(); + write_exe(&node_data.join("current").join("bin").join("vp.exe")); + write_exe(&decoy_data.join("current").join("bin").join("vp.exe")); + fs::write(bin.join("vp.shim"), format!("{}\n", decoy_data.display())).unwrap(); + fs::write(bin.join("node.shim"), format!("{}\n", node_data.display())).unwrap(); + + let location = resolve_vp_exe(&bin.join("node.exe")).unwrap(); + assert_eq!(location.exe, node_data.join("current").join("bin").join("vp.exe")); + assert_eq!(location.vp_data_dir, node_data); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn pointer_file_ignores_utf8_bom_and_crlf() { + let root = std::env::temp_dir().join(format!("vp-trampoline-bom-{}", process::id())); + let _ = fs::remove_dir_all(&root); + let bin = root.join("bin"); + let data = root.join("data-root"); + fs::create_dir_all(&bin).unwrap(); + write_exe(&data.join("current").join("bin").join("vp.exe")); + let mut contents = vec![0xEF, 0xBB, 0xBF]; + contents.extend_from_slice(data.to_string_lossy().as_bytes()); + contents.extend_from_slice(b"\r\n"); + fs::write(bin.join("vp.shim"), contents).unwrap(); + + let location = resolve_vp_exe(&bin.join("vp.exe")).unwrap(); + assert_eq!(location.exe, data.join("current").join("bin").join("vp.exe")); + assert_eq!(location.vp_data_dir, data); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn child_dir_pins_single_root_when_bin_is_under_data() { + let data = std::path::PathBuf::from("/install/root"); + assert!(matches!(child_dir_pins(&data.join("bin"), &data), ChildDirPins::SingleRoot)); + } + + #[test] + fn child_dir_pins_split_when_bin_is_independent() { + let data = std::path::PathBuf::from("/data/root"); + let bin = std::path::PathBuf::from("/other/bin"); + assert!(matches!(child_dir_pins(&bin, &data), ChildDirPins::Split)); + } +} + /// 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..fd99899219 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -11,6 +11,7 @@ These variables control the installer scripts and the standalone Windows install - **Purpose**: Version to install - **Default**: `latest` - **CLI equivalent**: `--version` +- **Note**: Releases that do not support the split directory layout (0.2.x and earlier) always install into the monolithic root (`VP_HOME` or `~/.vite-plus`), even on a fresh machine. The installer detects this from the downloaded binary and prints a notice. - **Example**: ```bash @@ -25,8 +26,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) holds an existing install (it contains a `current` link), 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 +41,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 +82,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 +206,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/index.cjs b/packages/cli/binding/index.cjs index 1521b26d0e..4e4cd2ae35 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -963,6 +963,7 @@ module.exports.startAsyncRuntime = nativeBinding.startAsyncRuntime; module.exports.detectWorkspace = nativeBinding.detectWorkspace; module.exports.downloadPackageManager = nativeBinding.downloadPackageManager; module.exports.ensureBlockingStdio = nativeBinding.ensureBlockingStdio; +module.exports.getVpDirs = nativeBinding.getVpDirs; module.exports.hasConfigKey = nativeBinding.hasConfigKey; module.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig; module.exports.mergeTsdownConfig = nativeBinding.mergeTsdownConfig; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 54660f8ba8..c2c88b984b 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3475,6 +3475,14 @@ export interface DownloadPackageManagerResult { /** Re-enable blocking stdio after Node.js has initialized its lazy standard streams. */ export declare function ensureBlockingStdio(): void; +/** + * Resolved on-disk category roots from [`vp_shared::EnvConfig`]. + * + * JavaScript must not read `VP_HOME` / `VP_*_DIR` / `XDG_*` itself; + * this is the JS surface of the same `EnvConfig::get().dirs` Rust uses. + */ +export declare function getVpDirs(): VpDirsJs; + /** * Whether `config_key` is already declared as a top-level property in the * vite config's `defineConfig({...})` (or equivalent) object literal. @@ -3793,6 +3801,15 @@ export declare function upsertJsonConfig( /** Render the Vite+ header using the Rust implementation. */ export declare function vitePlusHeader(): string; +/** Resolved on-disk category roots from [`vp_shared::EnvConfig`]. */ +export interface VpDirsJs { + bin: string; + data: string; + cache: string; + config: string; + state: string; +} + /** * Wrap safe inline `plugins: [...]` arrays in recognized Vite config objects * with `lazyPlugins(() => [...])` and add a `lazyPlugins` import from 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/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index 4c5b3a8f2f..c0b43b6fc3 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -234,6 +234,32 @@ pub async fn run(options: CliOptions) -> Result { } } +/// Resolved on-disk category roots from [`vp_shared::EnvConfig`]. +#[napi(object)] +pub struct VpDirsJs { + pub bin: String, + pub data: String, + pub cache: String, + pub config: String, + pub state: String, +} + +/// Resolved on-disk category roots from [`vp_shared::EnvConfig`]. +/// +/// JavaScript must not read `VP_HOME` / `VP_*_DIR` / `XDG_*` itself; +/// this is the JS surface of the same `EnvConfig::get().dirs` Rust uses. +#[napi] +pub fn get_vp_dirs() -> VpDirsJs { + let dirs = &vp_shared::EnvConfig::get().dirs; + VpDirsJs { + bin: dirs.bin.as_path().to_string_lossy().into_owned(), + data: dirs.data.as_path().to_string_lossy().into_owned(), + cache: dirs.cache.as_path().to_string_lossy().into_owned(), + config: dirs.config.as_path().to_string_lossy().into_owned(), + state: dirs.state.as_path().to_string_lossy().into_owned(), + } +} + /// Render the Vite+ header using the Rust implementation. #[napi] pub fn vite_plus_header() -> String { diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index c37507a314..2ad29858e9 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 install is reused; otherwise data/bin/ +# config follow VP_*_DIR / Windows known Local+Roaming folders. +# 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,127 @@ 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') +} + +# Monolithic mapping: every category on one root. +function New-MonolithicLayout { + param([string]$Root) + return [pscustomobject]@{ + DataDir = $Root + ShimDir = Join-Path $Root "bin" + ConfigDir = $Root + } +} + +# Mirror crates/vp_shared/src/dirs/resolution.rs (Windows): +# VP_HOME → existing %USERPROFILE%\.vite-plus → VP_*_DIR / known Local+Roaming folders +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 New-MonolithicLayout $env:VP_HOME + } + + # Grandfather only a real install: the `current` link every install + # activates, checked without following it so a dangling link from a + # crashed upgrade still counts. A bare %USERPROFILE%\.vite-plus left by + # a pre-split local CLI must not claim the layout. Matches + # vp_shared::dirs resolution. + $currentLink = Join-Path $legacyRoot "current" + if ($null -ne (Get-Item -LiteralPath $currentLink -Force -ErrorAction SilentlyContinue)) { + return New-MonolithicLayout $legacyRoot + } + + # Match EnvConfig / directories::BaseDirs: known folders, not process + # %LOCALAPPDATA% / %APPDATA% which can be redirected independently. + $localApp = [Environment]::GetFolderPath('LocalApplicationData') + if ([string]::IsNullOrWhiteSpace($localApp)) { + $localApp = Join-Path $userHome "AppData\Local" + } + $roamingApp = [Environment]::GetFolderPath('ApplicationData') + if ([string]::IsNullOrWhiteSpace($roamingApp)) { + $roamingApp = 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 Set-LayoutVars { + $script:InstallDir = $script:Layout.DataDir + $script:ShimDir = $script:Layout.ShimDir + $script:ConfigDir = $script:Layout.ConfigDir + $script:NodeManagerBinDisplay = $script:ShimDir -replace [regex]::Escape($env:USERPROFILE), '~' +} + +# Releases that predate the split layout resolve every path from VP_HOME +# (default %USERPROFILE%\.vite-plus). Install them into that monolithic root +# so their env setup, shims, and trampolines agree with where the installer +# wrote them. +function Use-LegacyLayout { + $userHome = Get-UserHomeDir + if ([string]::IsNullOrWhiteSpace($userHome)) { + Write-Error-Exit "Could not resolve user home directory" + } + + $root = if (Test-AbsoluteOverridePath $env:VP_HOME) { + $env:VP_HOME + } else { + Join-Path $userHome ".vite-plus" + } + $script:Layout = New-MonolithicLayout $root + Set-LayoutVars +} + +# Record the data root next to a trampoline so independent VP_BIN_DIR / +# VP_DATA_DIR installs do not rely on sibling-path probing. +function Write-ShimPointer { + param( + [string]$BinDir, + [string]$DataDir, + [string]$Name = "vp" + ) + $path = Join-Path $BinDir "$Name.shim" + $utf8 = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($path, ($DataDir.TrimEnd('\', '/') + "`n"), $utf8) +} + function Normalize-InstallDir { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { @@ -226,11 +349,12 @@ function Test-SafeInstallDirToRemove { $normalized = Normalize-InstallDir $Path $root = [System.IO.Path]::GetPathRoot($normalized) - $home = Normalize-InstallDir $env:USERPROFILE + # Do not use $home: PowerShell is case-insensitive and $HOME is read-only on 5.1. + $userHome = Normalize-InstallDir $env:USERPROFILE $programFilesX86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)") $unsafeDirs = @( $root - $home + $userHome (Normalize-InstallDir $env:SystemRoot) (Normalize-InstallDir $env:ProgramFiles) (Normalize-InstallDir $programFilesX86) @@ -573,10 +697,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 +756,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 +800,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") { @@ -777,33 +901,13 @@ function Main { $ViteVersion = Get-VersionFromMetadata } - # Set up version-specific directories - $VersionDir = "$InstallDir\$ViteVersion" - $BinDir = "$VersionDir\bin" - $CurrentLink = "$InstallDir\current" - $binaryName = "vp.exe" - # Create bin directory - New-Item -ItemType Directory -Force -Path $BinDir | Out-Null - - if ($LocalTgz) { - # Local development mode: only need the binary - Write-Info "Using local tarball: $LocalTgz" - - # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts) - if ($LocalBinary -and (Test-Path $LocalBinary)) { - Copy-Item -Path $LocalBinary -Destination (Join-Path $BinDir $binaryName) -Force - # Also copy trampoline shim binary if available (sibling to vp.exe) - $shimSource = Join-Path (Split-Path $LocalBinary) "vp-shim.exe" - if (Test-Path $shimSource) { - Copy-Item -Path $shimSource -Destination (Join-Path $BinDir "vp-shim.exe") -Force - } - } else { - Write-Error-Exit "VP_LOCAL_BINARY must be set when using VP_LOCAL_TGZ" - } - } else { - # Download CLI platform tarball — npm registry or registry bridge (when PrVersion is set) + # Download the CLI platform tarball before the layout is final: the + # downloaded binary decides which layout it supports (see below). + $platformTempExtract = $null + if (-not $LocalTgz) { + # npm registry or registry bridge (when PrVersion is set) $platformSuffix = Get-PlatformSuffix -Platform $platform if ($PrVersion) { # The registry bridge redirects this URL to the platform tarball for @@ -824,23 +928,62 @@ function Main { # Extract the package & "$env:SystemRoot\System32\tar.exe" -xzf $platformTempFile -C $platformTempExtract + } finally { + Remove-Item $platformTempFile -ErrorAction SilentlyContinue + } - # Copy binary to BinDir - $packageDir = Join-Path $platformTempExtract "package" - $binarySource = Join-Path $packageDir $binaryName - if (Test-Path $binarySource) { - Copy-Item -Path $binarySource -Destination $BinDir -Force - } - # Also copy trampoline shim binary if present in the package - $shimSource = Join-Path $packageDir "vp-shim.exe" + # Ask the downloaded binary for its layout (VP_DUMP_DIRS). A + # pre-split release cannot answer; give it the monolithic root so the + # installed PATH commands work. + $packageDir = Join-Path $platformTempExtract "package" + $binarySource = Join-Path $packageDir $binaryName + if (Test-Path $binarySource) { + # Remove Zone.Identifier (Mark of the Web) so the probe can run. + Unblock-File -LiteralPath $binarySource + } + if ((Test-Path $binarySource) -and (Apply-DirsFromVp $binarySource)) { + Set-LayoutVars + } else { + Use-LegacyLayout + Write-Info "vite-plus $ViteVersion does not support the split directory layout; the install goes to $InstallDir" + } + } + + # Set up version-specific directories + $VersionDir = "$InstallDir\$ViteVersion" + $BinDir = "$VersionDir\bin" + $CurrentLink = "$InstallDir\current" + + # Create bin directory + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + + if ($LocalTgz) { + # Local development mode: only need the binary + Write-Info "Using local tarball: $LocalTgz" + + # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts) + if ($LocalBinary -and (Test-Path $LocalBinary)) { + Copy-Item -Path $LocalBinary -Destination (Join-Path $BinDir $binaryName) -Force + # Also copy trampoline shim binary if available (sibling to vp.exe) + $shimSource = Join-Path (Split-Path $LocalBinary) "vp-shim.exe" if (Test-Path $shimSource) { - Copy-Item -Path $shimSource -Destination $BinDir -Force + Copy-Item -Path $shimSource -Destination (Join-Path $BinDir "vp-shim.exe") -Force } - - Remove-Item -Recurse -Force $platformTempExtract - } finally { - Remove-Item $platformTempFile -ErrorAction SilentlyContinue + } else { + Write-Error-Exit "VP_LOCAL_BINARY must be set when using VP_LOCAL_TGZ" + } + } else { + # Copy binary to BinDir + if (Test-Path $binarySource) { + Copy-Item -Path $binarySource -Destination $BinDir -Force + } + # Also copy trampoline shim binary if present in the package + $shimSource = Join-Path $packageDir "vp-shim.exe" + if (Test-Path $shimSource) { + Copy-Item -Path $shimSource -Destination $BinDir -Force } + + Remove-Item -Recurse -Force $platformTempExtract } # Remove Zone.Identifier (Mark of the Web) from downloaded binaries so @@ -918,14 +1061,15 @@ 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 + Write-ShimPointer -BinDir $ShimDir -DataDir $InstallDir -Name "vp" # 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,33 +1079,43 @@ 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 } } - # Keep consistent with the original install.ps1 wrapper format + # Pin VP_HOME to the data root. On a split install $ShimDir is not + # `$InstallDir\bin`, so `%~dp0..` would miss `\current`. $wrapperContent = @" @echo off -set VP_HOME=%~dp0.. +set VP_HOME=$InstallDir "%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 + $installDirUnix = $InstallDir -replace '\\', '/' $shContent = @" #!/bin/sh -VP_HOME="`$(dirname "`$(dirname "`$(readlink -f "`$0" 2>/dev/null || echo "`$0")")")" +VP_HOME="$installDirUnix" 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 +1125,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 +1185,66 @@ 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 +} +Set-LayoutVars + try { Main } catch { diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 5aa2244fab..c5379ebeee 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 install 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,162 @@ 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 +} + +# Match EnvConfig / directories::BaseDirs: known folders, not process +# LOCALAPPDATA / APPDATA which can be redirected independently. +windows_known_folder() { + local name="$1" + local fallback="$2" + local dir="" + if command -v powershell.exe >/dev/null 2>&1; then + dir="$(powershell.exe -NoProfile -Command "[Environment]::GetFolderPath('${name}')" 2>/dev/null | tr -d '\r')" + fi + if [ -n "$dir" ]; then + printf '%s\n' "$dir" + else + printf '%s\n' "$fallback" + fi +} + +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 +} + +# Monolithic mapping: every category on one root. +set_monolithic_layout() { + INSTALL_DIR="$1" + SHIM_DIR="$1/bin" + CONFIG_DIR="$1" +} + +# Releases that predate the split layout resolve every path from VP_HOME +# (default ~/.vite-plus). Install them into that monolithic root so their +# env setup, shims, and upgrades agree with where the installer wrote them. +use_legacy_layout() { + local home vp_home + home="$(user_home_dir)" + [ -n "$home" ] || error "Could not resolve user home directory" + vp_home="$(absolute_override "${VP_HOME:-}")" + set_monolithic_layout "${vp_home:-$home/.vite-plus}" + set_config_dir_refs "$CONFIG_DIR" "$home" +} + +# 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 + set_monolithic_layout "$vp_home" + # Grandfather only a real install: the `current` link every install + # activates (-L also accepts a dangling link from a crashed upgrade). + # A bare ~/.vite-plus left by a pre-split local CLI must not claim the + # layout. Matches vp_shared::dirs resolution. + elif [ -e "$legacy/current" ] || [ -L "$legacy/current" ]; then + set_monolithic_layout "$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="$(windows_known_folder LocalApplicationData "$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="$(windows_known_folder LocalApplicationData "$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="$(windows_known_folder ApplicationData "$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 +841,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 +850,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 +894,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 +929,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 +964,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 +1036,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 +1090,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 @@ -1044,16 +1197,46 @@ main() { VP_VERSION="$RESOLVED_VERSION" fi - # Set up version-specific directories - VERSION_DIR="$INSTALL_DIR/$VP_VERSION" - BIN_DIR="$VERSION_DIR/bin" - CURRENT_LINK="$INSTALL_DIR/current" - local binary_name="vp" if [[ "$platform" == win32* ]]; then binary_name="vp.exe" fi + # Download the CLI platform tarball before the layout is final: the + # downloaded binary decides which layout it supports (see below). + local platform_temp_dir="" + if [ -z "$LOCAL_TGZ" ]; then + # npm registry or registry bridge (when PR_VERSION is set) + get_platform_suffix "$platform" + local platform_url + if [ -n "$PR_VERSION" ]; then + # The registry bridge redirects this URL to the platform tarball for the + # matching commit build (0.0.0-commit.). + platform_url="${BRIDGE_DOWNLOAD_BASE}/@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}@${PR_VERSION}" + else + local package_name="@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}" + platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PLATFORM_SUFFIX}-${VP_VERSION}.tgz" + fi + + # Create temp directory for extraction + platform_temp_dir=$(mktemp -d) + download_and_extract "$platform_url" "$platform_temp_dir" 1 + chmod +x "$platform_temp_dir/$binary_name" + + # Ask the downloaded binary for its layout (VP_DUMP_DIRS). A pre-split + # release cannot answer; give it the monolithic root so the installed + # PATH commands work. + if ! apply_dirs_from_vp "$platform_temp_dir/$binary_name"; then + use_legacy_layout + info "vite-plus ${VP_VERSION} does not support the split directory layout; the install goes to $(abbreviate_path "$INSTALL_DIR")" + fi + fi + + # Set up version-specific directories + VERSION_DIR="$INSTALL_DIR/$VP_VERSION" + BIN_DIR="$VERSION_DIR/bin" + CURRENT_LINK="$INSTALL_DIR/current" + # Create bin directory mkdir -p "$BIN_DIR" @@ -1077,23 +1260,6 @@ main() { fi chmod +x "$BIN_DIR/$binary_name" else - # Download CLI platform tarball — npm registry or registry bridge (when PR_VERSION is set) - get_platform_suffix "$platform" - local platform_url - if [ -n "$PR_VERSION" ]; then - # The registry bridge redirects this URL to the platform tarball for the - # matching commit build (0.0.0-commit.). - platform_url="${BRIDGE_DOWNLOAD_BASE}/@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}@${PR_VERSION}" - else - local package_name="@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}" - platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PLATFORM_SUFFIX}-${VP_VERSION}.tgz" - fi - - # Create temp directory for extraction - local platform_temp_dir - platform_temp_dir=$(mktemp -d) - download_and_extract "$platform_url" "$platform_temp_dir" 1 - # Copy binary to BIN_DIR cp "$platform_temp_dir/$binary_name" "$BIN_DIR/" chmod +x "$BIN_DIR/$binary_name" @@ -1172,16 +1338,17 @@ 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" + # Independent VP_BIN_DIR / VP_DATA_DIR: trampoline reads .shim, not env vars. + printf '%s\n' "$INSTALL_DIR" >"$SHIM_DIR/vp.shim" fi else - # Unix: symlink to current/bin/vp - ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" + ln -sfn "$INSTALL_DIR/current/bin/vp" "$SHIM_DIR/vp" fi # Cleanup old versions @@ -1204,9 +1371,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 +1418,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 +1432,20 @@ WRAPPER_EOF echo "" } +apply_dirs_from_vp() { + local vp="$1" + local out + out="$(VP_DUMP_DIRS=1 "$vp" 2>/dev/null)" || 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/cli/src/create/org-tarball.ts b/packages/cli/src/create/org-tarball.ts index 66f15bd371..0a2db67987 100644 --- a/packages/cli/src/create/org-tarball.ts +++ b/packages/cli/src/create/org-tarball.ts @@ -1,16 +1,15 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { parseTarGzip } from 'nanotar'; +import { getVpDirs } from '../../binding/index.js'; import { fetchNpmResource } from '../utils/npm-config.ts'; import type { OrgManifest } from './org-manifest.ts'; function getCacheRoot(): string { - const home = process.env.VP_HOME || path.join(os.homedir(), '.vite-plus'); - return path.join(home, 'tmp', 'create-org'); + return path.join(getVpDirs().cache, 'create-org'); } /** diff --git a/packages/cli/src/utils/__tests__/editor.spec.ts b/packages/cli/src/utils/__tests__/editor.spec.ts index a3609f2349..0d5f16c908 100644 --- a/packages/cli/src/utils/__tests__/editor.spec.ts +++ b/packages/cli/src/utils/__tests__/editor.spec.ts @@ -6,6 +6,7 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { parse as parseJsonc } from 'jsonc-parser'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getVpDirs } from '../../../binding/index.js'; import { detectExistingEditors, selectEditor, @@ -651,9 +652,8 @@ describe('writeEditorConfigs', () => { const workspaceXml = fs.readFileSync(path.join(projectRoot, '.idea', 'workspace.xml'), 'utf8'); expect(workspaceXml).toContain(''); expect(workspaceXml).toContain('"javascript.preferred.runtime.type.id": "node"'); - expect(workspaceXml).toContain( - `"nodejs_interpreter_path": "$USER_HOME$/.vite-plus/bin/node.exe"`, - ); + const nodeShim = path.join(getVpDirs().bin, process.platform === 'win32' ? 'node.exe' : 'node'); + expect(workspaceXml).toContain(`"nodejs_interpreter_path": ${JSON.stringify(nodeShim)}`); expect(workspaceXml).toContain('"nodejs_package_manager_path": "pnpm"'); const oxfmtSettingsXml = fs.readFileSync( diff --git a/packages/cli/src/utils/editor.ts b/packages/cli/src/utils/editor.ts index bc23d8cb99..cdf4e63ab0 100644 --- a/packages/cli/src/utils/editor.ts +++ b/packages/cli/src/utils/editor.ts @@ -13,6 +13,7 @@ import { parse as parseJsonc, } from 'jsonc-parser'; +import { getVpDirs } from '../../binding/index.js'; import { PackageManager } from '../types/package.ts'; import { detectFormattingOptions, writeJsonFile } from './json.ts'; @@ -102,6 +103,10 @@ const JETBRAINS_EXTERNAL_DEPENDENCIES = ` `; +function jetbrainsNodeInterpreterPath(): string { + return path.join(getVpDirs().bin, process.platform === 'win32' ? 'node.exe' : 'node'); +} + function jetbrainsWorkspaceConfig(packageManager: PackageManager): string { return ` @@ -110,7 +115,7 @@ function jetbrainsWorkspaceConfig(packageManager: PackageManager): string { { keyToString: { 'javascript.preferred.runtime.type.id': 'node', - nodejs_interpreter_path: '$USER_HOME$/.vite-plus/bin/node.exe', + nodejs_interpreter_path: jetbrainsNodeInterpreterPath(), nodejs_package_manager_path: packageManager, }, }, 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..be2285fbd2 --- /dev/null +++ b/rfcs/directory-layout.md @@ -0,0 +1,325 @@ +# 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 (`resolve_cache.json`, `.upgrade-check.json`, create-org tarballs) | +| `config` | User configuration (`/env*`, `/config.json`) | +| `state` | State files (session version) | + +`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 contains the `current` +link every global install activates. The gate runs once at resolution, does +not follow the link, and matches the installers' gates. Bare existence is +not enough: a pre-split local vite-plus creates `~/.vite-plus` for caches, +config, and managed runtimes, and this source outranks `VP_*_DIR`. A stray +tree would otherwise capture an existing split install. A later +`vp upgrade` or reinstall would then silently move to the monolithic root, +and the split PATH entries would keep serving the old binary. + +**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 contains a `current` link (a real install), 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) **contains a `current` link** → **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. + +### Compatibility with pre-split releases + +The installers accept any published `VP_VERSION`. Until the first split-aware release (planned 0.3.0) ships, `latest` also resolves to a pre-split version. A pre-split binary resolves every path from `VP_HOME` (default `~/.vite-plus`): its env setup, shims, trampoline, and `vp upgrade` all assume that monolithic root. An install of such a binary into split roots is broken, but the installer still exits 0: + +- The PATH trampoline points at `/../current`, which does not exist. +- Shell startup sources an env script from a config dir the binary never writes. +- The binary's own env setup builds a second, incomplete `~/.vite-plus` tree. + +**Detection.** Every installer (`install.sh`, `install.ps1`, `vp-setup`) downloads the platform payload before the layout is final. It then runs the payload binary once with `VP_DUMP_DIRS=1`: + +- A split-aware binary prints one tab-separated line per category root (`data\t`, `bin\t`, `config\t`) and exits. The installer **adopts these paths verbatim**, so the layout the installer writes and the layout the binary resolves cannot drift. +- A pre-split binary does not know the variable, prints its help, and exits 0 without those lines. The installer then installs into the **monolithic root**: `VP_HOME` if set, else `~/.vite-plus`. It prints a notice: `vite-plus does not support the split directory layout; the install goes to ~/.vite-plus`. Everything the binary later does agrees with that root, so the installed `vp`, `vpr`, `vpx`, `node`, and `npm` commands work. + +**Stray legacy trees.** A pre-split local vite-plus (a project dependency) can create `~/.vite-plus` at any time on a machine whose global install is split: it writes caches, config, and managed runtimes there. The grandfather gate requires the `current` link, not bare directory existence, so such a stray tree does not flip an existing split install. `vp upgrade` and reinstalls keep the split roots. The `test-install-sh-layout` CI job covers this case: it makes a split install, creates a stray `~/.vite-plus`, and checks that resolution and a reinstall stay split. + +**Failure direction.** Probe failure also covers a payload that cannot run at all (wrong platform, missing VC++ runtime). The installer then picks the monolithic root, and the dependency-install step fails with the real error, as before. The monolithic root works for every release, old and new, because a split-aware binary grandfathers an existing `~/.vite-plus`. A false "pre-split" answer therefore degrades gracefully. A false "split-aware" answer is impossible: only a binary that implements `VP_DUMP_DIRS` can print the roots. + +**`vp-setup` specifics.** `vp-setup` resolves its `EnvConfig` at process start, so the fallback happens mid-install: `do_install` swaps to the monolithic mapping after the probe and returns the effective directories for the success summary. The managed Node.js and pnpm for the wrapper install still resolve their paths from the process-wide `EnvConfig`, pinned before the fallback. These tools land in the abandoned split data root; `do_install` removes that root when this run created it. Known limit: the interactive menu shows the split directories before the download. A pinned pre-split version thus confirms one location and then installs to the monolithic root, with the notice. + +**Coverage.** The `test-install-sh-old-version` (Linux, macOS) and `test-install-ps1-old-version` (Windows) CI jobs install a pinned pre-split release with no `VP_HOME`. They assert the monolithic layout, the absence of split roots, and working PATH-resolved commands. This mechanism also keeps fresh default installs of `latest` functional in the window between the merge of this RFC and the 0.3.0 release. + +### 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. | +| Pre-split version (pinned, or `latest` until 0.3.0) | Monolithic `~/.vite-plus`, detected via probe (see [Compatibility with pre-split releases](#compatibility-with-pre-split-releases)). | + +### 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 state | state dir | +| Shims / env scripts | **regenerate** into bin + config (do not copy relative links or stale env text) | +| Resolve cache, upgrade-check cache, create-org tarballs | cache dir | + +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)