From 2f8be039cf9463b69db1e4dfc4e80065f6eca649 Mon Sep 17 00:00:00 2001 From: yii Date: Wed, 5 Aug 2026 21:05:22 +0800 Subject: [PATCH] feat: introduce Dirs for unified XDG-aware path resolution Add vp_shared::Dirs as the single owner of on-disk placement decisions. An internal DirsInner enum selects the layout once per resolution (first match wins): - Home (legacy monolithic root): VP_HOME is set, the vp binary self-locates at /current/bin/vp, a legacy layout is found on PATH, or ~/.vite-plus exists on disk. Existing installs keep working untouched, byte-identical paths. - Custom (split XDG layout, fresh installs): each category resolves through its own VP_*_DIR override -> XDG_* -> platform-default chain (bin: VP_BIN_DIR -> XDG_BIN_HOME -> XDG_DATA_HOME/../bin -> ~/.local/bin, mirroring uv). Only VP_BIN_DIR/VP_DATA_DIR/VP_CACHE_DIR exist as dedicated overrides; config and state rely on XDG_CONFIG_HOME / XDG_STATE_HOME. Relative values are ignored per the spec. XDG_* names are defined alongside vp's own variables in env_vars.rs, but only vp_* path overrides live in EnvConfig; Dirs reads XDG vars itself behind an injectable, parallel-safe resolution core (no env mutation or serial_test anywhere in Dirs tests; test helpers live in a #[cfg(test)] impl block, and cross-crate tests sandbox through EnvConfig::for_test_with_home). Migrate every Rust call site to Dirs category accessors and named helpers, delete get_vp_home() and the home.rs module (folded into dirs.rs), and drop per-crate wrapper helpers so Dirs is the sole path source. bins/*.json metadata moves to the data category. On the TS side, the global CLI injects the resolved VP_BIN_DIR / VP_DATA_DIR / VP_CACHE_DIR into JS child processes under the split layout only (never overriding user-set vars); org-tarball.ts prefers VP_CACHE_DIR; generated git hook scripts fall back through VP_BIN_DIR, VP_HOME/bin, ~/.vite-plus/bin, ~/.local/bin. VP_HOME, the installers, and the generated env* shell scripts are unchanged in behavior: fresh installs still land in ~/.vite-plus, so the split layout is opt-in via VP_*_DIR/XDG until the follow-up stack removes the legacy variables and switches installer defaults. Groundwork for #827. --- AGENTS.md | 2 + CONTRIBUTING.md | 2 +- .../snapshots/migration_add_git_hooks.md | 8 +- crates/vp_command/src/ps1_shim.rs | 20 +- .../src/commands/env/bin_config.rs | 6 +- .../vp_global_cli/src/commands/env/clean.rs | 8 +- .../vp_global_cli/src/commands/env/config.rs | 61 +- .../vp_global_cli/src/commands/env/current.rs | 5 +- .../vp_global_cli/src/commands/env/default.rs | 4 +- .../vp_global_cli/src/commands/env/doctor.rs | 71 +- crates/vp_global_cli/src/commands/env/list.rs | 3 +- .../src/commands/env/list_remote.rs | 5 +- crates/vp_global_cli/src/commands/env/mod.rs | 4 +- .../src/commands/env/package_metadata.rs | 19 +- crates/vp_global_cli/src/commands/env/pin.rs | 45 +- .../vp_global_cli/src/commands/env/setup.rs | 223 ++-- crates/vp_global_cli/src/commands/env/use.rs | 3 +- .../vp_global_cli/src/commands/env/which.rs | 9 +- .../src/commands/global/install.rs | 17 +- crates/vp_global_cli/src/commands/implode.rs | 11 +- .../vp_global_cli/src/commands/upgrade/mod.rs | 4 +- crates/vp_global_cli/src/commands/vpx.rs | 33 +- crates/vp_global_cli/src/js_executor.rs | 29 +- crates/vp_global_cli/src/shim/cache.rs | 25 +- crates/vp_global_cli/src/shim/corepack.rs | 25 +- crates/vp_global_cli/src/shim/dispatch.rs | 86 +- crates/vp_global_cli/src/shim/mod.rs | 41 +- crates/vp_global_cli/src/upgrade_check.rs | 37 +- crates/vp_installer/src/main.rs | 4 +- crates/vp_js_runtime/src/cache.rs | 12 - crates/vp_js_runtime/src/lib.rs | 1 - crates/vp_js_runtime/src/providers/node.rs | 5 +- crates/vp_js_runtime/src/runtime.rs | 8 +- crates/vp_pm_cli/src/package_manager.rs | 55 +- crates/vp_shared/src/dirs.rs | 1041 +++++++++++++++++ crates/vp_shared/src/env_config.rs | 79 +- crates/vp_shared/src/env_vars.rs | 35 + crates/vp_shared/src/home.rs | 206 ---- crates/vp_shared/src/lib.rs | 4 +- docs/guide/env.md | 10 +- docs/guide/implode.md | 2 + docs/guide/install.md | 2 +- docs/guide/installer-env-vars.md | 54 +- packages/cli/src/config/hooks.ts | 8 +- packages/cli/src/create/org-tarball.ts | 6 + rfcs/env-command.md | 2 + 46 files changed, 1615 insertions(+), 725 deletions(-) delete mode 100644 crates/vp_js_runtime/src/cache.rs create mode 100644 crates/vp_shared/src/dirs.rs delete mode 100644 crates/vp_shared/src/home.rs diff --git a/AGENTS.md b/AGENTS.md index 34a7d5d012..3e34d4587d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ vite-plus/ └── crates/vp_trampoline/ # Windows shim trampoline ``` +On-disk paths (bin, config, data, state, cache) are resolved centrally via `vp_shared::Dirs` (`crates/vp_shared/src/dirs.rs`) — legacy monolithic `~/.vite-plus` root or split XDG/platform layout; no call site constructs `~/.vite-plus/...` or reads `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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3db7e9a7ab..1e7fe89877 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ pnpm bootstrap-cli vp --version ``` -This builds all packages, compiles the Rust `vp` binary, and installs the CLI to `~/.vite-plus`. +This builds all packages, compiles the Rust `vp` binary, and installs the CLI to `~/.vite-plus` (the legacy monolithic layout; on-disk paths are resolved by `vp_shared::Dirs` in `crates/vp_shared/src/dirs.rs`). To switch back to a release version, use `vp upgrade --force` (`current` points to `local-dev-*` but the binary version may still match the release, so `--force` is needed) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md index bdb8767398..7c97f2a0d2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md @@ -111,10 +111,14 @@ d="$(dirname "$(dirname "$(dirname "$0")")")" __vp_shell=/bin/sh [ -x "$__vp_shell" ] || __vp_shell=$(command -v sh) -if [ -n "${VP_HOME-}" ]; then +if [ -n "${VP_BIN_DIR-}" ]; then + __vp_bin="$VP_BIN_DIR" +elif [ -n "${VP_HOME-}" ]; then __vp_bin="$VP_HOME/bin" -elif [ -n "${HOME-}" ]; then +elif [ -n "${HOME-}" ] && [ -d "$HOME/.vite-plus/bin" ]; then __vp_bin="$HOME/.vite-plus/bin" +elif [ -n "${HOME-}" ]; then + __vp_bin="$HOME/.local/bin" else __vp_bin="" fi diff --git a/crates/vp_command/src/ps1_shim.rs b/crates/vp_command/src/ps1_shim.rs index f4665da6c0..aabf51bbaa 100644 --- a/crates/vp_command/src/ps1_shim.rs +++ b/crates/vp_command/src/ps1_shim.rs @@ -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 the vite-plus install 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] @@ -61,16 +61,18 @@ pub fn rewrite_cmd_to_powershell( 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). +/// Cached vite-plus install root (`~/.vite-plus` under the legacy layout; the +/// data directory under the split layout). +/// +/// The returned value is always `Some`; the `Option` only exists because the +/// rewrite scope check also applies to `node_modules/.bin/*.cmd` paths, which +/// are independent of the install root. 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() + static INSTALL_ROOT: LazyLock = + LazyLock::new(|| vp_shared::Dirs::get().data_dir()); + Some(&INSTALL_ROOT) } /// Pure rewrite logic. Factored out so tests can drive it on any platform 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..b26c4ea4d5 100644 --- a/crates/vp_global_cli/src/commands/env/bin_config.rs +++ b/crates/vp_global_cli/src/commands/env/bin_config.rs @@ -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. @@ -52,9 +51,10 @@ 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/`; `~/.vite-plus/bins/` under + /// the legacy layout — identical on disk). pub fn bins_dir() -> Result { - Ok(get_vp_home()?.join("bins")) + Ok(vp_shared::Dirs::get().bins_dir()) } /// Get the path to a binary's config file. diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index e1ca0f74cf..e8d754d85f 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -13,9 +13,9 @@ 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 dirs = vp_shared::Dirs::get(); + let node_dir = dirs.js_runtime_dir().join("node"); + let package_manager_dir = dirs.package_manager_dir(); let protected_versions = protected_node_versions(&cwd).await?; let corepack_cleaned = run_corepack_cache_clean(&cwd).await?; @@ -138,7 +138,7 @@ async fn corepack_cache_clean_would_auto_install( cwd: &AbsolutePathBuf, corepack_path: &AbsolutePath, ) -> Result { - let bin_dir = config::get_bin_dir()?; + let bin_dir = vp_shared::Dirs::get().bin_dir(); if corepack_path.parent() != Some(&bin_dir) { return Ok(false); } diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 38cd5805b9..125e19318e 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -1,22 +1,21 @@ //! Configuration and version resolution for the env command. //! //! This module provides: -//! - VP_HOME path resolution //! - Version resolution with priority order //! - Config file management +//! +//! On-disk locations come from [`vp_shared::Dirs`]. use serde::{Deserialize, Serialize}; use vp_js_runtime::{ NodeProvider, VersionSource, is_valid_version, normalize_version, read_nvmrc_file, read_package_json, resolve_node_version, }; +use vp_shared::Dirs; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::error::Error; -/// Config file name -const CONFIG_FILE: &str = "config.json"; - /// Shim mode determines how shims resolve tools. #[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -61,23 +60,6 @@ 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/). -pub fn get_bin_dir() -> Result { - Ok(get_vp_home()?.join("bin")) -} - -/// Get the packages directory path (~/.vite-plus/packages/). -pub fn get_packages_dir() -> Result { - Ok(get_vp_home()?.join("packages")) -} - /// Get the node_modules directory path for a package. /// /// npm uses different layouts on Unix vs Windows: @@ -110,14 +92,9 @@ pub fn get_node_modules_dir(prefix: &AbsolutePath, package_name: &str) -> Absolu } } -/// Get the config file path. -pub fn get_config_path() -> Result { - Ok(get_vp_home()?.join(CONFIG_FILE)) -} - /// Load configuration from disk. pub async fn load_config() -> Result { - let config_path = get_config_path()?; + let config_path = Dirs::get().config_file(); if !tokio::fs::try_exists(&config_path).await.unwrap_or(false) { return Ok(Config::default()); @@ -130,11 +107,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()?; + let dirs = Dirs::get(); + let config_path = dirs.config_file(); // Ensure directory exists - tokio::fs::create_dir_all(&vite_plus_home).await?; + tokio::fs::create_dir_all(&dirs.config_dir()).await?; let content = serde_json::to_string_pretty(config)?; tokio::fs::write(&config_path, content).await?; @@ -148,14 +125,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). -pub fn get_session_version_path() -> Result { - Ok(get_vp_home()?.join(SESSION_VERSION_FILE)) -} - /// Read the session version file. Returns `None` if the file is missing or empty. pub async fn read_session_version() -> Option { - let path = get_session_version_path().ok()?; + let path = Dirs::get().session_node_version_file(); let content = tokio::fs::read_to_string(&path).await.ok()?; let trimmed = content.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } @@ -163,7 +135,7 @@ pub async fn read_session_version() -> Option { /// Read the session version file synchronously. Returns `None` if the file is missing or empty. pub fn read_session_version_sync() -> Option { - let path = get_session_version_path().ok()?; + let path = Dirs::get().session_node_version_file(); let content = std::fs::read_to_string(path.as_path()).ok()?; let trimmed = content.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } @@ -171,7 +143,7 @@ pub fn read_session_version_sync() -> Option { /// Write the resolved version to the session version file. pub async fn write_session_version(version: &str) -> Result<(), Error> { - let path = get_session_version_path()?; + let path = Dirs::get().session_node_version_file(); // Ensure parent directory exists if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; @@ -182,7 +154,7 @@ pub async fn write_session_version(version: &str) -> Result<(), Error> { /// Delete the session version file. Ignores "not found" errors. pub async fn delete_session_version() -> Result<(), Error> { - let path = get_session_version_path()?; + let path = Dirs::get().session_node_version_file(); match tokio::fs::remove_file(&path).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -221,7 +193,7 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result Result Result Result { match config.default_node_version { Some(version) => { println!("Default Node.js version: {version}"); - let config_path = get_config_path()?; + let config_path = vp_shared::Dirs::get().config_file(); println!(" Set via: {}", config_path.as_path().display()); // If it's an alias, also show the resolved version diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 6ce79f0473..3861748909 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -3,10 +3,10 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; -use vp_shared::{env_vars, output}; +use vp_shared::{Dirs, env_vars, output}; use vt_path::{AbsolutePathBuf, current_dir}; -use super::config::{self, ShimMode, get_bin_dir, get_vp_home, load_config, resolve_version}; +use super::config::{self, ShimMode, load_config, resolve_version}; use crate::{ commands::shell::{ALL_SHELL_PROFILES, IDE_SHELL_PROFILES, ShellProfile, resolve_profile_path}, error::Error, @@ -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(&Dirs::get().env_scripts_dir()); } } @@ -130,19 +128,11 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { } } -/// Check VP_HOME directory. +/// Check the vite-plus home directory (the legacy root under the `Home` +/// layout, the data directory under the split layout — same path on disk +/// under `Home`). async fn check_vite_plus_home() -> bool { - let home = match get_vp_home() { - Ok(h) => h, - Err(e) => { - print_check( - &output::CROSS.red().to_string(), - env_vars::VP_HOME, - &format!("{e}").red().to_string(), - ); - return false; - } - }; + let home = Dirs::get().data_dir(); let display = abbreviate_home(&home.as_path().display().to_string()); @@ -162,10 +152,7 @@ async fn check_vite_plus_home() -> bool { /// 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, - }; + let bin_dir = Dirs::get().bin_dir(); if !tokio::fs::try_exists(&bin_dir).await.unwrap_or(false) { print_check( @@ -265,15 +252,9 @@ 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 env_dir = Dirs::get().env_scripts_dir(); - 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 = env_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) { format!("$HOME{suffix}") @@ -339,10 +320,7 @@ fn check_session_override() { /// Check PATH configuration. async fn check_path() -> bool { - let bin_dir = match get_bin_dir() { - Ok(d) => d, - Err(_) => return false, - }; + let bin_dir = Dirs::get().bin_dir(); let path_var = std::env::var_os("PATH").unwrap_or_default(); let paths: Vec<_> = std::env::split_paths(&path_var).collect(); @@ -359,7 +337,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(&Dirs::get().env_scripts_dir()); return false; } @@ -396,14 +374,11 @@ 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()); + // Use the $HOME prefix for readability when the env dir is under $HOME + let home_path = env_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) { format!("$HOME{suffix}") @@ -431,7 +406,7 @@ fn print_path_fix(bin_dir: &vt_path::AbsolutePath) { #[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!(); @@ -469,12 +444,9 @@ 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()); +fn print_ide_setup_guidance(env_dir: &vt_path::AbsolutePath) { + // Use the $HOME prefix for readability when the env dir is under $HOME + let home_path = env_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) { format!("$HOME{suffix}") @@ -571,10 +543,7 @@ 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 = Dirs::get().js_runtime_dir().join("node").join(&resolution.version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 2bd20a2a8b..8251f77972 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -52,8 +52,7 @@ fn compare_versions(a: &str, b: &str) -> Ordering { /// 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::Dirs::get().js_runtime_dir().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..ca88f73b09 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::Dirs::get().js_runtime_dir().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..939a38383b 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -109,8 +109,8 @@ 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::Dirs::get().js_runtime_dir().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..2076c199dd 100644 --- a/crates/vp_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vp_global_cli/src/commands/env/package_metadata.rs @@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize}; use uuid::{Uuid, Version}; use vt_path::AbsolutePathBuf; -use super::config::get_packages_dir; use crate::error::Error; // This is legacy, for old Vite+ version's compatibility @@ -117,7 +116,7 @@ impl PackageMetadata { package_name: &str, install_id: &str, ) -> Result { - let packages_dir = get_packages_dir()?; + let packages_dir = vp_shared::Dirs::get().packages_dir(); let package_dir = packages_dir.join(package_name); if install_id.is_empty() { Ok(package_dir) @@ -134,7 +133,7 @@ impl PackageMetadata { /// Get the metadata file path for a package. pub fn metadata_path(package_name: &str) -> Result { - let packages_dir = get_packages_dir()?; + let packages_dir = vp_shared::Dirs::get().packages_dir(); Ok(packages_dir.join(format!("{package_name}.json"))) } @@ -173,7 +172,7 @@ impl PackageMetadata { /// List all installed packages. pub async fn list_all() -> Result, Error> { - let packages_dir = get_packages_dir()?; + let packages_dir = vp_shared::Dirs::get().packages_dir(); if !tokio::fs::try_exists(&packages_dir).await.unwrap_or(false) { return Ok(Vec::new()); } @@ -358,9 +357,15 @@ mod tests { 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); + // Verify the file exists at the correct location (under the resolved + // packages directory for the sandboxed home). + let expected_path = + vp_shared::Dirs::get().packages_dir().join("@scope").join("test-pkg.json"); + assert!( + expected_path.as_path().exists(), + "Metadata file not found at {:?}", + expected_path.as_path() + ); } #[tokio::test] diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 23b3468373..fc752843da 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -13,7 +13,7 @@ use vp_js_runtime::NodeProvider; use vp_shared::output; use vt_path::AbsolutePathBuf; -use super::config::{get_config_path, load_config}; +use super::config::load_config; use crate::{cli::PinTarget, error::Error}; /// Node version file name @@ -76,7 +76,7 @@ async fn show_pinned(cwd: &AbsolutePathBuf) -> Result { let config = load_config().await?; match config.default_node_version { Some(version) => { - let config_path = get_config_path()?; + let config_path = vp_shared::Dirs::get().config_file(); println!("No version pinned."); println!(" Using default: {version} (from {})", config_path.as_path().display()); } @@ -583,7 +583,6 @@ pub async fn do_unpin( #[cfg(test)] mod tests { - use serial_test::serial; use tempfile::TempDir; use vt_path::AbsolutePathBuf; @@ -690,19 +689,14 @@ mod tests { } #[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"); + // Sandboxed legacy layout: the on-disk `.vite-plus` under the + // overridden user home selects it, so the resolve cache lives at + // `/.vite-plus/cache/resolve_cache.json`. + let cache_dir = temp_path.join(".vite-plus").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(); @@ -710,6 +704,9 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist before unpin" ); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_path.as_path(), + )); // Create .node-version and unpin let node_version_path = temp_path.join(".node-version"); @@ -722,27 +719,15 @@ mod tests { 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); - } } - // 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"); + // Sandboxed legacy layout (see test_do_unpin_invalidates_cache). + let cache_dir = temp_path.join(".vite-plus").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(); @@ -750,6 +735,9 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist before pin" ); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_path.as_path(), + )); // 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; @@ -766,11 +754,6 @@ mod tests { 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); - } } #[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 ad4fd9c237..41b866ebf1 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -18,8 +18,8 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; +use vp_shared::Dirs; -use super::config::{get_bin_dir, get_vp_home}; use crate::{error::Error, help}; /// Shells that get a generated `~/.vite-plus/env.*` setup script. @@ -56,13 +56,13 @@ fn accent_command(command: &str) -> String { /// Execute the setup command. pub async fn execute(refresh: bool, env_only: bool) -> Result { - let vite_plus_home = get_vp_home()?; + let dirs = Dirs::get(); - // Ensure home directory exists (env files are written here) - tokio::fs::create_dir_all(&vite_plus_home).await?; + // Ensure the env-scripts directory exists (env files are written here) + tokio::fs::create_dir_all(&dirs.env_scripts_dir()).await?; // Create env files with PATH guard (prevents duplicate PATH entries) - create_env_files(&vite_plus_home).await?; + create_env_files(&dirs).await?; if env_only { println!("{}", help::render_heading("Setup")); @@ -71,7 +71,7 @@ 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_dir(); println!("{}", help::render_heading("Setup")); println!(" Preparing vite-plus environment."); @@ -154,7 +154,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result } println!(); - print_path_instructions(&bin_dir); + print_path_instructions(&dirs.env_scripts_dir()); Ok(ExitStatus::default()) } @@ -762,9 +762,10 @@ fn render_nu_path_ref(path_ref: &str) -> String { } } -/// 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"); +/// Render the env-file content for `shell` against the resolved [`Dirs`]. +fn render_env_content(shell: EnvShell, dirs: &Dirs) -> String { + let vite_plus_home = dirs.env_scripts_dir(); + let bin_path = dirs.bin_dir(); 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); @@ -804,22 +805,20 @@ fn render_env_content(shell: EnvShell, vite_plus_home: &vt_path::AbsolutePath) - /// - `~/.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> { +async fn create_env_files(dirs: &Dirs) -> Result<(), Error> { + let env_dir = dirs.env_scripts_dir(); 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, dirs); + tokio::fs::write(env_dir.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()); +/// Print instructions for sourcing the env files from `env_dir`. +fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { + // Use the $HOME prefix for readability when the env dir is under $HOME + let home_path = env_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 @@ -889,6 +888,17 @@ mod tests { assert!(!crate::commands::global::CORE_SHIMS.contains(&"corepack")); } + /// Set up a sandboxed legacy layout for env-file rendering tests: user + /// home at `home` with the legacy root `/.vite-plus` created on + /// disk so `Dirs` selects the monolithic layout. Returns the EnvConfig + /// guard and the legacy root. + fn legacy_home(home: &std::path::Path) -> (vp_shared::TestEnvGuard, AbsolutePathBuf) { + let root = home.join(".vite-plus"); + std::fs::create_dir_all(&root).unwrap(); + let guard = home_guard(home); + (guard, AbsolutePathBuf::new(root).unwrap()) + } + /// 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 { @@ -931,10 +941,9 @@ mod tests { #[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()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let env_path = home.join("env"); let env_fish_path = home.join("env.fish"); @@ -949,10 +958,9 @@ mod tests { #[tokio::test] 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()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); assert!( @@ -960,8 +968,8 @@ mod tests { "env.nu should not contain __VP_BIN__ placeholder" ); assert!( - nu_content.contains("~/bin"), - "env.nu should reference ~/bin (not $HOME/bin — Nushell does not expand $HOME in string literals)" + nu_content.contains("~/.vite-plus/bin"), + "env.nu should reference ~/.vite-plus/bin (not $HOME/bin — Nushell does not expand $HOME in string literals)" ); assert!( nu_content.contains("VP_ENV_USE_EVAL_ENABLE"), @@ -977,11 +985,9 @@ mod tests { #[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(); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).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(); @@ -1008,28 +1014,28 @@ mod tests { // 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}" + env_content.contains("$HOME/.vite-plus/bin"), + "env file should reference $HOME/.vite-plus/bin, got: {env_content}" ); assert!( - fish_content.contains("$HOME/vp_home/bin"), - "env.fish file should reference $HOME/vp_home/bin, got: {fish_content}" + fish_content.contains("$HOME/.vite-plus/bin"), + "env.fish file should reference $HOME/.vite-plus/bin, got: {fish_content}" ); assert!( - env_content.contains("export VP_HOME=\"$HOME/vp_home\""), + env_content.contains("export VP_HOME=\"$HOME/.vite-plus\""), "env file should export VP_HOME, got: {env_content}" ); assert!( - fish_content.contains("set -gx VP_HOME \"$HOME/vp_home\""), + fish_content.contains("set -gx VP_HOME \"$HOME/.vite-plus\""), "env.fish file should export VP_HOME, got: {fish_content}" ); assert!( - nu_content.contains("$env.VP_HOME = (\"~/vp_home\" | path expand --no-symlink)"), + nu_content.contains("$env.VP_HOME = (\"~/.vite-plus\" | 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}" + nu_content.contains("~/.vite-plus/bin"), + "env.nu file should reference ~/.vite-plus/bin, got: {nu_content}" ); let expected_home = home.as_path().display().to_string(); @@ -1040,21 +1046,30 @@ mod tests { } #[tokio::test] - async fn test_create_env_files_uses_absolute_path_when_not_under_home() { + async fn test_create_env_files_uses_absolute_path_when_bin_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"); + let home = temp_dir.path().join("home"); + // Bin directory outside HOME via VP_BIN_DIR override (split layout). + let outside_bin = temp_dir.path().join("outside-bin"); + std::fs::create_dir_all(&outside_bin).unwrap(); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { + vp_bin_dir: Some(outside_bin.clone()), + ..vp_shared::EnvConfig::for_test_with_home(&home) + }); - create_env_files(&home).await.unwrap(); + let dirs = Dirs::get(); + assert!(!dirs.is_legacy_layout(), "no .vite-plus under home → split layout"); + tokio::fs::create_dir_all(dirs.env_scripts_dir().as_path()).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(); + create_env_files(&dirs).await.unwrap(); + + let env_content = + tokio::fs::read_to_string(dirs.env_scripts_dir().join("env")).await.unwrap(); + let fish_content = + tokio::fs::read_to_string(dirs.env_scripts_dir().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('\\', "/"); + // Should use the absolute path since the bin dir is not under HOME + let expected_str = outside_bin.display().to_string().replace('\\', "/"); assert!( env_content.contains(&expected_str), "env file should use absolute path {expected_str}, got: {env_content}" @@ -1063,26 +1078,32 @@ mod tests { fish_content.contains(&expected_str), "env.fish file should use absolute path {expected_str}, got: {fish_content}" ); + + // Should NOT use a $HOME-relative path for the bin dir 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}" + !env_content.contains("export PATH=\"$HOME"), + "env file should not reference a $HOME-relative bin, got: {env_content}" ); + } - // Should NOT use $HOME-relative path - assert!(!env_content.contains("$HOME/bin"), "env file should not reference $HOME/bin"); + #[test] + fn test_render_home_relative_path_falls_back_to_absolute_outside_home() { + let (path, home) = if cfg!(windows) { + (r"C:\install\vp", r"C:\Users\vp") + } else { + ("/opt/vp", "/home/vp") + }; + let rendered = + render_home_relative_path(std::path::Path::new(path), Some(std::path::Path::new(home))); + assert_eq!(rendered, path.replace('\\', "/")); } #[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()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); @@ -1110,10 +1131,9 @@ mod tests { #[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()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -1132,16 +1152,15 @@ mod tests { #[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()); + let (_guard, home) = legacy_home(temp_dir.path()); // Create env files twice - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).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(); + create_env_files(&Dirs::get()).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(); @@ -1154,10 +1173,9 @@ mod tests { #[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()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); @@ -1181,10 +1199,9 @@ mod tests { #[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()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -1203,10 +1220,9 @@ mod tests { #[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()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); @@ -1225,19 +1241,18 @@ mod tests { #[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() - }); + // Fresh home (no `.vite-plus` yet): the split layout is selected and + // setup creates the bin directory. + let _env_guard = vp_shared::EnvConfig::test_guard( + vp_shared::EnvConfig::for_test_with_home(temp_dir.path()), + ); - assert!(!fresh_home.exists(), "VP_HOME should not exist before initial setup"); + let bin_dir = Dirs::get().bin_dir(); + assert!(!bin_dir.as_path().exists(), "bin dir 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"), @@ -1253,12 +1268,11 @@ mod tests { #[cfg(unix)] 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 (_guard, home) = legacy_home(temp_dir.path()); let bin_dir = home.join("bin"); tokio::fs::create_dir_all(&bin_dir).await.unwrap(); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); assert!( !bin_dir.join("vp-use.cmd").as_path().exists(), @@ -1269,24 +1283,26 @@ mod tests { #[tokio::test] async fn test_execute_env_only_creates_home_dir_and_env_files() { 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() - }); + // Fresh home (no `.vite-plus` yet): the split layout is selected and + // execute creates the env-scripts directory it needs. + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_dir.path(), + )); + + let dirs = Dirs::get(); + let env_dir = dirs.env_scripts_dir(); + assert!(!env_dir.as_path().exists(), "env dir should not exist before initial setup"); 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"); + assert!(env_dir.as_path().exists(), "env 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"); + assert!(env_dir.join("env").as_path().exists(), "env file should be created"); + assert!(env_dir.join("env.fish").as_path().exists(), "env.fish file should be created"); + assert!(env_dir.join("env.ps1").as_path().exists(), "env.ps1 file should be created"); } #[tokio::test] @@ -1428,10 +1444,9 @@ mod tests { #[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()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).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(); diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 7b07cf94d8..78ffd5cbf3 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -137,8 +137,7 @@ 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::Dirs::get().js_runtime_dir().join("node").join(&resolved_version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 3d5fbebb01..97e1cc4e69 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -19,7 +19,7 @@ use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{ bin_config::{BinConfig, BinSource}, - config::{VERSION_ENV_VAR, get_bin_dir, get_node_modules_dir, resolve_version}, + config::{VERSION_ENV_VAR, get_node_modules_dir, resolve_version}, package_metadata::PackageMetadata, }; use crate::{cli::exit_status, error::Error}; @@ -110,7 +110,7 @@ async fn execute_npm_link_binary(tool: &str, bin_config: &BinConfig) -> Result Result { - let link_path = get_bin_dir()?.join(tool); + let link_path = vp_shared::Dirs::get().bin_dir().join(tool); let target = tokio::fs::read_link(&link_path).await?; let binary_path = if target.is_absolute() { target @@ -127,7 +127,7 @@ async fn locate_npm_link_binary(tool: &str) -> Result { #[cfg(windows)] async fn locate_npm_link_binary(tool: &str) -> Result { - let cmd_path = get_bin_dir()?.join(format!("{tool}.cmd")); + let cmd_path = vp_shared::Dirs::get().bin_dir().join(format!("{tool}.cmd")); let content = tokio::fs::read_to_string(&cmd_path).await?; let mut lines = content.lines(); let source = match (lines.next(), lines.next(), lines.next(), lines.next()) { @@ -202,8 +202,7 @@ async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result bin_dir, - Err(error) => { - let _ = cleanup_failed_install(&install_dir).await; - if first_error.is_none() { - first_error = Some(error); - } - continue; - } - }; + let bin_dir = vp_shared::Dirs::get().bin_dir(); let metadata_version = installed_version.as_deref().unwrap_or("unknown"); let mut metadata = PackageMetadata::new( @@ -966,7 +957,7 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { }; if dry_run { - let bin_dir = get_bin_dir()?; + let bin_dir = vp_shared::Dirs::get().bin_dir(); let package_dir = match &metadata { Some(metadata) => metadata.installation_dir()?, None => PackageMetadata::installation_dir_for(&package_name, "")?, @@ -991,7 +982,7 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { } // Remove shims and bin configs - let bin_dir = get_bin_dir()?; + let bin_dir = vp_shared::Dirs::get().bin_dir(); for bin_name in &bins { remove_package_shim(&bin_dir, bin_name).await?; BinConfig::delete(bin_name).await?; diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 47b9a9d070..b601f284b6 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -20,10 +20,11 @@ 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 dirs = vp_shared::Dirs::get(); + // Under the legacy layout this is the whole `~/.vite-plus` root; under the + // split layout only the data dir is removed for now (full split-layout + // removal is follow-up scope). + let home_dir = dirs.data_dir(); if !home_dir.as_path().exists() { output::info("vite-plus is not installed (directory does not exist)"); @@ -51,7 +52,7 @@ pub fn execute(yes: bool) -> Result { // Remove Windows PATH entry #[cfg(windows)] { - let bin_path = home_dir.join("bin"); + let bin_path = dirs.bin_dir(); if let Err(e) = remove_windows_path_entry(&bin_path) { output::warn(&vt_str::format!("Failed to clean Windows PATH: {e}")); } else { diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index c853e84881..8e0c24aa60 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,7 +34,7 @@ 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 install_dir = vp_shared::Dirs::get().versions_dir(); // Handle --rollback if options.rollback { diff --git a/crates/vp_global_cli/src/commands/vpx.rs b/crates/vp_global_cli/src/commands/vpx.rs index c6d6fe8d36..a1f406e13d 100644 --- a/crates/vp_global_cli/src/commands/vpx.rs +++ b/crates/vp_global_cli/src/commands/vpx.rs @@ -10,7 +10,7 @@ use vp_shared::{PrependOptions, exit_code_from_status, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use crate::{commands::env::config, shim::dispatch}; +use crate::shim::dispatch; /// Parsed vpx flags. #[derive(Debug, Default)] @@ -184,20 +184,12 @@ async fn execute_global_binary(bin: GlobalBinary, args: &[String], cwd: &Absolut /// /// This prevents vpx from finding itself (or other vite-plus shims) on PATH. fn find_on_path(cmd: &str) -> Option { - let bin_dir = config::get_bin_dir().ok(); + let bin_dir = vp_shared::Dirs::get().bin_dir(); let path_var = std::env::var_os("PATH")?; // Filter PATH to exclude vite-plus bin directory - let filtered_paths: Vec<_> = std::env::split_paths(&path_var) - .filter(|p| { - if let Some(ref bin) = bin_dir { - if p == bin.as_path() { - return false; - } - } - true - }) - .collect(); + let filtered_paths: Vec<_> = + std::env::split_paths(&path_var).filter(|p| p != bin_dir.as_path()).collect(); let filtered_path = std::env::join_paths(filtered_paths).ok()?; let cwd = vt_path::current_dir().ok()?; @@ -709,12 +701,12 @@ mod tests { #[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 - let fake_home = temp.path().join("vite-plus-home"); - let fake_bin = fake_home.join("bin"); + // Set up a fake vite-plus home with bin dir. The on-disk `.vite-plus` + // under the overridden user home selects the legacy layout, so the + // vp bin dir is `/.vite-plus/bin`. + let fake_bin = temp.path().join(".vite-plus").join("bin"); std::fs::create_dir_all(&fake_bin).unwrap(); create_fake_executable(&fake_bin, "vpx-excluded-tool"); @@ -723,13 +715,14 @@ mod tests { std::fs::create_dir_all(&other_dir).unwrap(); create_fake_executable(&other_dir, "vpx-excluded-tool"); - let path = std::env::join_paths([fake_bin.as_path(), other_dir.as_path()]).unwrap(); + let path = std::env::join_paths([fake_bin.as_os_str(), other_dir.as_os_str()]).unwrap(); // SAFETY: serial test unsafe { std::env::set_var("PATH", &path); - std::env::set_var("VP_HOME", fake_home.as_os_str()); } + let _guard = + vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(temp.path())); let result = find_on_path("vpx-excluded-tool"); assert!(result.is_some()); @@ -744,10 +737,6 @@ mod tests { 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"), - } } } diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 66c263ec49..b8679942b4 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -7,7 +7,7 @@ use std::process::{ExitStatus, Output}; use tokio::process::Command; use vp_js_runtime::{JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project}; -use vp_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend}; +use vp_shared::{Dirs, PrependOptions, PrependResult, env_vars, format_path_with_prepend}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::{ @@ -108,6 +108,28 @@ impl JsExecutor { cmd.env(env_vars::VP_CLI_BIN, bin_path.as_path()); } + // Split (XDG) layout: hand JS scripts the resolved dirs so TS code + // that reads paths directly (the create-org tarball cache, generated + // git hook scripts) agrees with the Rust side, and nested vp + // processes resolve the same layout. Legacy installs self-locate + // their root (executable path / `PATH` inference / the grandfathered + // `~/.vite-plus`), and the legacy layout intentionally ignores these + // vars, so only inject them for the split layout. Explicit user + // overrides always win. + let dirs = Dirs::get(); + if !dirs.is_legacy_layout() { + for (var, dir) in [ + (env_vars::VP_BIN_DIR, dirs.bin_dir()), + (env_vars::VP_DATA_DIR, dirs.data_dir()), + (env_vars::VP_CACHE_DIR, dirs.cache_dir()), + ] { + if std::env::var_os(var).is_none() { + tracing::debug!("Set {var} to {dir:?}"); + cmd.env(var, dir.as_path()); + } + } + } + // Prepend runtime bin to PATH so child processes can find the JS runtime let options = PrependOptions { dedupe_anywhere: true }; if let PrependResult::Prepended(new_path) = @@ -618,8 +640,9 @@ mod tests { 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. + // Isolate the user home so config defaults to managed mode (no + // `vp env off`) and the runtime download cache stays inside the test + // sandbox (split layout under the temp home). let vp_home = TempDir::new().unwrap(); let _guard = EnvConfig::test_guard(EnvConfig::for_test_with_home(vp_home.path().to_path_buf())); diff --git a/crates/vp_global_cli/src/shim/cache.rs b/crates/vp_global_cli/src/shim/cache.rs index 2f97fd4ee3..911a69e285 100644 --- a/crates/vp_global_cli/src/shim/cache.rs +++ b/crates/vp_global_cli/src/shim/cache.rs @@ -39,7 +39,8 @@ pub struct ResolveCacheEntry { pub is_range: bool, } -/// Resolution cache stored in VP_HOME/cache/resolve_cache.json. +/// Resolution cache stored in `/resolve_cache.json` +/// (`~/.vite-plus/cache/resolve_cache.json` under the legacy layout). #[derive(Serialize, Deserialize, Debug)] pub struct ResolveCache { /// Cache format version for upgrade compatibility @@ -184,8 +185,7 @@ impl ResolveCache { /// Get the cache file path. 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::Dirs::get().resolve_cache_file()) } /// Invalidate the entire resolve cache by deleting the cache file. @@ -344,15 +344,15 @@ 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"); + // Sandboxed legacy layout: the on-disk `.vite-plus` under the + // overridden user home selects it, so the resolve cache lives at + // `/.vite-plus/cache/resolve_cache.json`. + let cache_dir = temp_path.join(".vite-plus").join("cache"); std::fs::create_dir_all(&cache_dir).unwrap(); let cache_file = cache_dir.join("resolve_cache.json"); @@ -373,14 +373,11 @@ mod tests { 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()); - } + // Point the sandboxed home at our temp dir and call invalidate_cache + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_path.as_path(), + )); invalidate_cache(); - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } // Cache file should be removed assert!( diff --git a/crates/vp_global_cli/src/shim/corepack.rs b/crates/vp_global_cli/src/shim/corepack.rs index 92c74c6bf5..39b664c95b 100644 --- a/crates/vp_global_cli/src/shim/corepack.rs +++ b/crates/vp_global_cli/src/shim/corepack.rs @@ -29,7 +29,7 @@ use super::{ }; use crate::commands::env::{ bin_config::{BinConfig, BinSource}, - config, setup, + setup, }; /// Binary names corepack `enable`/`disable` may create or remove in the @@ -58,23 +58,12 @@ pub(crate) async fn dispatch_corepack(args: &[String]) -> i32 { // restore any Vite+-owned shims corepack removed or replaced. The arg // check runs first so the common path skips bin-dir resolution entirely. if is_corepack_link_command(args) { - match config::get_bin_dir() { - Ok(bin_dir) => { - full_args.extend(inject_install_directory(args, &bin_dir)); - let owned_shims = snapshot_vp_owned_shims(&bin_dir).await; - let exit_code = exec::spawn_tool(&program, &full_args); - restore_vp_owned_shims(&bin_dir, &owned_shims).await; - return exit_code; - } - Err(e) => { - // Without a bin dir there is nothing to inject or restore; - // run corepack as-is, but say so instead of failing silently. - output::warn(&format!( - "Cannot resolve the Vite+ bin directory ({e}); running corepack without \ - an --install-directory default, created launchers may not be on PATH" - )); - } - } + let bin_dir = vp_shared::Dirs::get().bin_dir(); + full_args.extend(inject_install_directory(args, &bin_dir)); + let owned_shims = snapshot_vp_owned_shims(&bin_dir).await; + let exit_code = exec::spawn_tool(&program, &full_args); + restore_vp_owned_shims(&bin_dir, &owned_shims).await; + return exit_code; } // The bundled corepack and native binaries have no leading args; exec diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index f072073a74..49a59673b4 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -229,7 +229,7 @@ fn check_npm_global_install_result( node_dir: &AbsolutePath, node_version: &str, ) { - let Ok(bin_dir) = config::get_bin_dir() else { return }; + let bin_dir = vp_shared::Dirs::get().bin_dir(); // Derive bin dir from prefix (Unix: prefix/bin, Windows: prefix itself) #[cfg(unix)] @@ -364,7 +364,11 @@ fn check_npm_global_install_result( let bin_display = bin_list.join(", "); output::raw(&vt_str::format!("'{bin_display}' is not available on your PATH.")); - output::raw_inline("Create a link in ~/.vite-plus/bin/ to make it available? [Y/n] "); + let link_dir = vp_shared::Dirs::get().bin_dir(); + output::raw_inline(&vt_str::format!( + "Create a link in {}/ to make it available? [Y/n] ", + link_dir.as_path().display() + )); let _ = std::io::Write::flush(&mut std::io::stdout()); let mut input = String::new(); @@ -518,7 +522,7 @@ fn dedup_missing_bins( /// still delete its binary from `npm_bin_dir`, leaving our symlink dangling. In that /// case we repair the link by pointing directly at the surviving package's binary. fn remove_npm_global_uninstall_links(bin_entries: &[(String, String)], npm_prefix: &AbsolutePath) { - let Ok(bin_dir) = config::get_bin_dir() else { return }; + let bin_dir = vp_shared::Dirs::get().bin_dir(); for (bin_name, package_name) in bin_entries { // Skip protected shims: a stale Npm BinConfig (e.g. a pre-default-shim @@ -777,7 +781,8 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Append current bin_dir to VP_BYPASS to prevent infinite loops // when multiple vite-plus installations exist in PATH. // The next installation will filter all accumulated paths. - if let Ok(bin_dir) = config::get_bin_dir() { + { + let bin_dir = vp_shared::Dirs::get().bin_dir(); let bypass_val = match std::env::var_os(env_vars::VP_BYPASS) { Some(existing) => { let mut paths: Vec<_> = std::env::split_paths(&existing).collect(); @@ -901,37 +906,32 @@ 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::Dirs::get().js_runtime_dir().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 (bins, npm_prefix) = { + let node_dir = + vp_shared::Dirs::get().js_runtime_dir().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 + (bins, npm_prefix) }; 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(&bins, &npm_prefix); } return exit_code; } @@ -1296,16 +1296,12 @@ async fn cached_project_source_still_current( /// 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 version_dir = vp_shared::Dirs::get().js_runtime_dir().join("node").join(version); #[cfg(windows)] - let binary_path = home_dir.join("node.exe"); + let binary_path = version_dir.join("node.exe"); #[cfg(not(windows))] - let binary_path = home_dir.join("bin").join("node"); + let binary_path = version_dir.join("bin").join("node"); // Check if already installed if binary_path.as_path().exists() { @@ -1325,22 +1321,18 @@ 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 version_dir = vp_shared::Dirs::get().js_runtime_dir().join("node").join(version); #[cfg(windows)] let tool_path = if tool == "node" { - home_dir.join("node.exe") + version_dir.join("node.exe") } else { // npm and npx are .cmd scripts on Windows - home_dir.join(format!("{tool}.cmd")) + version_dir.join(format!("{tool}.cmd")) }; #[cfg(not(windows))] - let tool_path = home_dir.join("bin").join(tool); + let tool_path = version_dir.join("bin").join(tool); if !tool_path.as_path().exists() { return Err(format!("Tool '{}' not found at {}", tool, tool_path.as_path().display())); @@ -1367,7 +1359,7 @@ pub(crate) fn find_system_tool(tool: &str) -> Option { /// `cwd` only resolves relative PATH entries; it is a parameter so tests can /// exercise them without mutating the process-wide working directory. fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option { - let bin_dir = config::get_bin_dir().ok(); + let bin_dir = vp_shared::Dirs::get().bin_dir(); let path_var = std::env::var_os("PATH")?; tracing::debug!("path_var: {:?}", path_var); @@ -1384,10 +1376,8 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option = std::env::split_paths(&path_var) .filter(|p| { - if let Some(ref bin) = bin_dir { - if p == bin.as_path() { - return false; - } + if p == bin_dir.as_path() { + return false; } !bypass_paths.iter().any(|bp| p == bp) }) @@ -1395,7 +1385,7 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option - // Installation B also needs to filter install_b_bin (via get_bin_dir), - // but get_bin_dir returns the real vite-plus home. So we test by putting + // Installation B also needs to filter install_b_bin (via Dirs::bin_dir), + // but Dirs::bin_dir returns the real vite-plus home. So we test by putting // 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(); diff --git a/crates/vp_global_cli/src/shim/mod.rs b/crates/vp_global_cli/src/shim/mod.rs index c6f8a5a977..967a5e728d 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -21,8 +21,6 @@ pub use dispatch::dispatch; pub(crate) use dispatch::find_system_tool; use vp_shared::env_vars; -use crate::commands::env::config::get_bin_dir; - /// Core shim tools (node, npm, npx). /// /// `corepack` is also a default shim (see `commands::env::setup::SHIM_TOOLS`) @@ -48,20 +46,18 @@ pub fn extract_tool_name(argv0: &str) -> String { if cfg!(target_os = "linux") { stem } else { - let bin_dir = get_bin_dir(); - if let Ok(bin_dir) = bin_dir { - if let Ok(read_dir) = fs::read_dir(&bin_dir) { - for bin in read_dir.flatten() { - if bin.path().file_stem().unwrap_or_default().to_string_lossy().to_lowercase() - == stem.to_lowercase() - { - return bin - .path() - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - } + let bin_dir = vp_shared::Dirs::get().bin_dir(); + if let Ok(read_dir) = fs::read_dir(&bin_dir) { + for bin in read_dir.flatten() { + if bin.path().file_stem().unwrap_or_default().to_string_lossy().to_lowercase() + == stem.to_lowercase() + { + return bin + .path() + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); } } } @@ -106,12 +102,8 @@ pub fn is_shim_tool(tool: &str) -> bool { /// because when running through a wrapper script (e.g., current/bin/vp), the current_exe() /// returns the wrapper's location, not the original shim's location. fn is_potential_package_binary(tool: &str) -> bool { - use crate::commands::env::config; - - // Get the configured bin directory (respects VP_HOME env var) - let Ok(configured_bin) = config::get_bin_dir() else { - return false; - }; + // Get the configured bin directory + let configured_bin = vp_shared::Dirs::get().bin_dir(); // Check if the shim exists in the configured bin directory. // Use symlink_metadata to detect symlinks (even broken ones). @@ -241,12 +233,11 @@ mod tests { /// Test that is_potential_package_binary checks the configured bin directory. /// /// The function now checks if a shim exists in the configured bin directory - /// (from VP_HOME/bin) instead of relying on current_exe(). + /// (`Dirs::get().bin_dir()`) instead of relying on current_exe(). /// This allows it to work correctly with wrapper scripts. #[test] fn test_is_potential_package_binary_checks_configured_bin() { - // The function checks config::get_bin_dir() which respects VP_HOME. - // Without setting VP_HOME, it defaults to ~/.vite-plus/bin. + // The function checks Dirs::get().bin_dir(). // // Since we can't easily create test shims in the actual bin directory, // we just verify the function doesn't panic and returns false for diff --git a/crates/vp_global_cli/src/upgrade_check.rs b/crates/vp_global_cli/src/upgrade_check.rs index 6cd8826d18..d0a5d6ecbf 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -1,7 +1,8 @@ //! 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 the state directory's `.upgrade-check.json` (see +//! [`vp_shared::Dirs::upgrade_check_file`]). 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}; @@ -12,6 +13,8 @@ use vp_setup::registry; const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; const PROMPT_INTERVAL_SECS: u64 = 24 * 60 * 60; +/// Cache file name; see [`vp_shared::Dirs::upgrade_check_file`]. +#[cfg(test)] const CACHE_FILE_NAME: &str = ".upgrade-check.json"; #[expect(clippy::disallowed_types)] // String required for serde JSON round-trip @@ -22,14 +25,12 @@ 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_path: &vt_path::AbsolutePath) -> Option { 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_path: &vt_path::AbsolutePath, cache: &UpgradeCheckCache) { if let Ok(data) = serde_json::to_string(cache) { let _ = std::fs::write(cache_path.as_path(), &data); } @@ -72,17 +73,17 @@ async fn resolve_version_string() -> Option { } pub struct UpgradeCheckResult { - install_dir: vt_path::AbsolutePathBuf, + cache_path: 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 cache_path = vp_shared::Dirs::get().upgrade_check_file(); let current_version = env!("CARGO_PKG_VERSION"); let now = now_secs(); - let mut cache = read_cache(&install_dir); + let mut cache = read_cache(&cache_path); if should_check(cache.as_ref(), now) { let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); @@ -90,7 +91,7 @@ pub async fn check_for_update() -> Option { match resolve_version_string().await { Some(latest) => { let new_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &new_cache); + write_cache(&cache_path, &new_cache); cache = Some(new_cache); } None => { @@ -98,7 +99,7 @@ pub async fn check_for_update() -> Option { // retrying on every command when the registry is unreachable. let latest = cache.as_ref().map(|c| c.latest.clone()).unwrap_or_default(); let failed_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &failed_cache); + write_cache(&cache_path, &failed_cache); cache = Some(failed_cache); } } @@ -114,7 +115,7 @@ pub async fn check_for_update() -> Option { return None; } - Some(UpgradeCheckResult { install_dir, cache }) + Some(UpgradeCheckResult { cache_path, cache }) } /// Print a one-line upgrade notice to stderr and record the prompt time. @@ -133,7 +134,7 @@ pub fn display_upgrade_notice(result: &UpgradeCheckResult) { let mut cache = result.cache.clone(); cache.prompted_at = now_secs(); - write_cache(&result.install_dir, &cache); + write_cache(&result.cache_path, &cache); } /// Whether the upgrade check should run for the given command args. @@ -170,12 +171,13 @@ mod tests { fn cache_round_trip() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + let cache_file = dir_path.join(CACHE_FILE_NAME); let cache = UpgradeCheckCache { latest: "1.2.3".to_owned(), checked_at: 1000, prompted_at: 900 }; - write_cache(&dir_path, &cache); + write_cache(&cache_file, &cache); - let loaded = read_cache(&dir_path).expect("should read back cache"); + let loaded = read_cache(&cache_file).expect("should read back cache"); assert_eq!(loaded.latest, "1.2.3"); assert_eq!(loaded.checked_at, 1000); assert_eq!(loaded.prompted_at, 900); @@ -185,15 +187,16 @@ mod tests { fn read_cache_returns_none_for_missing_file() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - assert!(read_cache(&dir_path).is_none()); + assert!(read_cache(&dir_path.join(CACHE_FILE_NAME)).is_none()); } #[test] fn read_cache_returns_none_for_corrupt_file() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - std::fs::write(dir_path.join(CACHE_FILE_NAME).as_path(), "not json").unwrap(); - assert!(read_cache(&dir_path).is_none()); + let cache_file = dir_path.join(CACHE_FILE_NAME); + std::fs::write(cache_file.as_path(), "not json").unwrap(); + assert!(read_cache(&cache_file).is_none()); } fn with_env_vars_cleared(f: F) { diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 28ce48bc35..0a3a3ea8db 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -472,7 +472,9 @@ fn resolve_install_dir(opts: &cli::Options) -> Result Result { - Ok(vp_shared::get_vp_home()?.join("js_runtime")) -} diff --git a/crates/vp_js_runtime/src/lib.rs b/crates/vp_js_runtime/src/lib.rs index 56a6e03189..efe136ff2e 100644 --- a/crates/vp_js_runtime/src/lib.rs +++ b/crates/vp_js_runtime/src/lib.rs @@ -43,7 +43,6 @@ clippy::print_stdout )] -mod cache; mod dev_engines; mod download; mod error; diff --git a/crates/vp_js_runtime/src/providers/node.rs b/crates/vp_js_runtime/src/providers/node.rs index 73b23daaeb..23130353f3 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 managed runtime install dir (e.g., `~/.vite-plus/js_runtime`) /// /// # Returns /// The highest LTS cached version that satisfies the requirement, or the @@ -186,8 +186,7 @@ impl NodeProvider { /// /// Returns an error only if the download fails and no local cache exists. pub async fn fetch_version_index(&self) -> Result, Error> { - let cache_dir = crate::cache::get_cache_dir()?; - let cache_path = cache_dir.join("node/index_cache.json"); + let cache_path = vp_shared::Dirs::get().node_index_cache_file(); // Try to load from cache let Some(cache) = load_cache(&cache_path).await else { diff --git a/crates/vp_js_runtime/src/runtime.rs b/crates/vp_js_runtime/src/runtime.rs index da4a7bb387..37a4ba6bfd 100644 --- a/crates/vp_js_runtime/src/runtime.rs +++ b/crates/vp_js_runtime/src/runtime.rs @@ -183,13 +183,13 @@ pub async fn download_runtime_with_provider( version: &str, ) -> Result { let platform = Platform::current(); - let cache_dir = crate::cache::get_cache_dir()?; + let cache_dir = vp_shared::Dirs::get().js_runtime_dir(); // Get paths from 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}/ + // Install path: /{runtime}/{version}/ let install_dir = cache_dir.join(provider.name()).join(version); // Check if already cached @@ -456,7 +456,7 @@ pub async fn resolve_node_version( /// Currently only supports Node.js runtime. pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result { let provider = NodeProvider::new(); - let cache_dir = crate::cache::get_cache_dir()?; + let cache_dir = vp_shared::Dirs::get().js_runtime_dir(); // Resolve version from the project directory, walking up to inherit from ancestors let resolution = resolve_node_version(project_path, true).await?; @@ -1041,7 +1041,7 @@ mod tests { let version = "20.17.0"; // Clear any existing cache for this version - let cache_dir = crate::cache::get_cache_dir().unwrap(); + let cache_dir = vp_shared::Dirs::get().js_runtime_dir(); 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(); diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 4bb7dfee2f..66620eac43 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -376,9 +376,9 @@ pub fn package_manager_install_dir( package_manager_type: PackageManagerType, version: &str, ) -> Option { - let home_dir = vp_shared::get_vp_home().ok()?; + let package_manager_dir = vp_shared::Dirs::get().package_manager_dir(); let bin_name = package_manager_type.to_string(); - Some(home_dir.join("package_manager").join(&bin_name).join(version).join(&bin_name)) + Some(package_manager_dir.join(&bin_name).join(version).join(&bin_name)) } /// Return the executable shim path for a package manager binary inside an install directory. @@ -739,9 +739,8 @@ 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::Dirs::get().package_manager_dir().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), @@ -844,7 +843,7 @@ pub async fn download_package_manager( package_name = "@yarnpkg/cli-dist".into(); } - let home_dir = vp_shared::get_vp_home()?; + let package_manager_dir = vp_shared::Dirs::get().package_manager_dir(); let bin_name = package_manager_type.to_string(); // For bun, use platform-specific download flow. @@ -852,7 +851,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, &package_manager_dir).await; } // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package @@ -860,12 +859,13 @@ 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, &package_manager_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); + // /pnpm/10.0.0 + let target_dir = package_manager_dir.join(&bin_name).join(&version); let install_dir = target_dir.join(&bin_name); // If all shims already exist, return the target directory @@ -978,13 +978,13 @@ fn get_bun_platform_package_name() -> Result<&'static str, Error> { /// Layout: `$VP_HOME/package_manager/bun/{version}/bun/bin/bun.native` async fn download_bun_package_manager( version: &Str, - home_dir: &AbsolutePath, + package_manager_dir: &AbsolutePath, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "bun".into(); let platform_package_name = get_bun_platform_package_name()?; - // $VP_HOME/package_manager/bun/{version} - let target_dir = home_dir.join("package_manager").join("bun").join(version.as_str()); + // /bun/{version} + let target_dir = package_manager_dir.join("bun").join(version.as_str()); let install_dir = target_dir.join("bun"); // If shims already exist, return early (same completeness check as the cache @@ -1156,14 +1156,14 @@ async fn fetch_platform_integrity( /// Layout: `$VP_HOME/package_manager/pnpm/{version}/pnpm/bin/pnpm.native` async fn download_pnpm_native_package_manager( version: &Str, - home_dir: &AbsolutePath, + package_manager_dir: &AbsolutePath, expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "pnpm".into(); let platform_package_name = get_pnpm_platform_package_name()?; - // $VP_HOME/package_manager/pnpm/{version} - let target_dir = home_dir.join("package_manager").join("pnpm").join(version.as_str()); + // /pnpm/{version} + let target_dir = package_manager_dir.join("pnpm").join(version.as_str()); let install_dir = target_dir.join("pnpm"); // If shims already exist, return early (same completeness check as the cache @@ -1729,11 +1729,18 @@ mod tests { Complete, } - /// Create a fake managed package manager install under - /// `/package_manager////bin/`. + /// Create a fake managed package manager install under the legacy root + /// `/.vite-plus/package_manager////bin/`. + /// The on-disk `.vite-plus` selects the legacy layout for the overridden + /// user home. fn write_pm_install(vp_home: &AbsolutePath, name: &str, version: &str, state: InstallState) { - let bin_dir = - vp_home.join("package_manager").join(name).join(version).join(name).join("bin"); + let bin_dir = vp_home + .join(".vite-plus") + .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) { @@ -3778,17 +3785,21 @@ mod tests { .body("this is not a valid gzip archive"); }); + // The on-disk `.vite-plus` under the overridden user home selects + // the legacy layout, so package managers install under + // `/.vite-plus/package_manager/`. + let legacy_root = vp_home.path().join(".vite-plus"); + std::fs::create_dir_all(&legacy_root).unwrap(); 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() + ..EnvConfig::for_test_with_home(vp_home.path().to_path_buf()) }); 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 pnpm_dir = vp_home.path().join("package_manager").join("pnpm"); + let pnpm_dir = legacy_root.join("package_manager").join("pnpm"); let leftovers: Vec<_> = fs::read_dir(&pnpm_dir) .map(|rd| { rd.filter_map(Result::ok) diff --git a/crates/vp_shared/src/dirs.rs b/crates/vp_shared/src/dirs.rs new file mode 100644 index 0000000000..86e1dca02b --- /dev/null +++ b/crates/vp_shared/src/dirs.rs @@ -0,0 +1,1041 @@ +//! Unified on-disk path resolution for vite-plus. +//! +//! [`Dirs`] owns every placement decision for files vite-plus installs or +//! creates: executables and shims, configuration, payload data (CLI versions, +//! Node.js runtimes, package managers), state files, and disposable caches. +//! No call site constructs `~/.vite-plus/...` or reads `XDG_*` itself. +//! +//! Two layouts are supported, selected once per resolution (first match +//! wins): +//! +//! 0. **Explicit `VP_HOME`** — selects the legacy monolithic `Home` layout +//! rooted at its value. Takes priority over every other rule. +//! 1. **Executable self-location** — the canonicalized `current_exe` path +//! matches `/current/bin/vp[.exe]` → legacy `Home(root)`. Covers +//! custom-location installs and launches without `PATH` context (IDEs, +//! the Windows trampoline). +//! 2. **Legacy `PATH` inference** — a `/bin` entry on `PATH` with the +//! legacy layout (`bin/vp` plus `current/bin/vp`) → `Home(root)`. +//! 3. **Existing legacy root** — `/.vite-plus` exists on disk → +//! `Home`, so existing installs keep working untouched. +//! 4. **Split XDG/platform layout** (`Custom`) — fresh installs. Each +//! category resolves independently through its own `VP_*_DIR` override → +//! `XDG_*` → platform-default chain. +//! +//! The `XDG_*_HOME` variables are read directly from the process +//! environment here — they are the one exception to [`EnvConfig`] +//! centralization, because they participate in `Dirs` resolution. +//! +//! The access pattern mirrors [`EnvConfig`]: [`Dirs::get`] for global +//! access. Tests override the environment through +//! [`EnvConfig::test_scope`] / [`EnvConfig::test_guard`]: while a test +//! override is active, the host-environment rules (1–2 and the XDG reads) +//! are skipped and the home directory comes from the overridden +//! [`EnvConfig::user_home`], so resolution stays hermetic and +//! parallel-safe. +//! +//! Unlike [`EnvConfig`], there is intentionally no global `OnceLock` cache +//! and no `Dirs::init()`: [`Dirs::get`] recomputes from [`EnvConfig::get`] +//! on every call. Resolution is cheap (a few path joins plus at most one +//! filesystem `exists` check), and recomputing keeps +//! [`EnvConfig::test_scope`] overrides observable without a second cache +//! that could go stale. + +use std::{env, ffi::OsStr, path::PathBuf}; + +use directories::BaseDirs; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use crate::{EnvConfig, env_vars}; + +/// Subdirectory name appended to XDG base directories and platform defaults. +const APP_DIR_NAME: &str = "vite-plus"; + +/// Directory name of the legacy monolithic install root (`~/.vite-plus`). +pub(crate) const LEGACY_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" }; + +#[cfg(test)] +thread_local! { + /// Thread-local test override. Each test thread gets its own slot. + static TEST_DIRS: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// Resolved on-disk locations for every vite-plus file category. +/// +/// Obtain via [`Dirs::get`]; query through the category accessors +/// (`bin_dir`, `config_dir`, `data_dir`, `state_dir`, `cache_dir`) or the +/// named helpers for well-known subpaths (`js_runtime_dir`, `config_file`, +/// ...). The layout variant is an implementation detail. +#[derive(Debug, Clone)] +pub struct Dirs { + inner: DirsInner, +} + +/// Layout strategy, resolved once per [`Dirs::get`] call. Compatibility +/// handling lives entirely in which variant is selected — the accessors are +/// just a fixed mapping over it. +#[derive(Debug, Clone)] +enum DirsInner { + /// Monolithic legacy root (`~/.vite-plus` layout). Hit when a legacy + /// root is detected from the executable location or `PATH`, or when + /// `~/.vite-plus` already exists on disk (existing installs). + Home(AbsolutePathBuf), + /// Split XDG/platform layout (fresh installs). Each category resolved + /// independently via its own override → XDG → platform-default chain. + Custom { + /// Executables and shims (node, npm, npx, corepack, vpx, vpr, vp wrapper). + bin: AbsolutePathBuf, + /// User configuration: config.json, env scripts. + config: AbsolutePathBuf, + /// Payload data: CLI versions + `current`, js_runtime, + /// package_manager, packages, per-binary bins/*.json metadata. + data: AbsolutePathBuf, + /// State: .session-node-version, .upgrade-check.json. + state: AbsolutePathBuf, + /// Disposable cache: resolve_cache, tmp/create-org. + /// + /// The Node.js version index cache stays under `js_runtime_dir()` + /// (data) to preserve the legacy on-disk layout. + cache: AbsolutePathBuf, + }, +} + +/// Platform-specific defaults for the `Custom` layout, computed once per +/// platform. Keeping these behind a small injected core leaves the +/// resolution logic in [`resolve`] platform-neutral and unit-testable on any +/// OS. +#[derive(Debug, Clone)] +struct PlatformDefaults { + /// Executables and shims. + bin: AbsolutePathBuf, + /// User configuration. + config: AbsolutePathBuf, + /// Payload data. + data: AbsolutePathBuf, + /// State files. + state: AbsolutePathBuf, + /// Disposable cache. + cache: AbsolutePathBuf, +} + +impl PlatformDefaults { + /// Unix-style defaults derived from the home directory. + /// + /// Also used on macOS (`~/.config`, `~/.local/share`, ... rather than + /// `~/Library/...`), matching uv/fnm community expectations. + fn unix(home_dir: &AbsolutePath) -> Self { + Self { + bin: home_dir.join(".local/bin"), + config: home_dir.join(".config").join(APP_DIR_NAME), + data: home_dir.join(".local/share").join(APP_DIR_NAME), + state: home_dir.join(".local/state").join(APP_DIR_NAME), + cache: home_dir.join(".cache").join(APP_DIR_NAME), + } + } + + /// Windows defaults: everything under `%LOCALAPPDATA%\vite-plus`, except + /// configuration which lives under `%APPDATA%\vite-plus`. + /// + /// Compiled on every platform for tests so the Windows mapping stays + /// unit-tested on Unix. + #[cfg(any(windows, test))] + fn windows(local_app_data: &AbsolutePath, app_data: &AbsolutePath) -> Self { + let base = local_app_data.join(APP_DIR_NAME); + Self { + bin: base.join("bin"), + config: app_data.join(APP_DIR_NAME), + data: base.join("data"), + state: base.join("state"), + cache: base.join("cache"), + } + } + + /// Compute the defaults for the current platform. + #[cfg(not(windows))] + fn detect(home_dir: &AbsolutePath, _base_dirs: Option<&BaseDirs>) -> Self { + Self::unix(home_dir) + } + + /// Compute the defaults for the current platform. + #[cfg(windows)] + fn detect(home_dir: &AbsolutePath, base_dirs: Option<&BaseDirs>) -> Self { + match base_dirs { + // Both roots are absolute whenever `BaseDirs` resolved successfully. + Some(base_dirs) => { + let local_app_data = AbsolutePath::new(base_dirs.data_local_dir()).unwrap(); + let app_data = AbsolutePath::new(base_dirs.config_dir()).unwrap(); + Self::windows(local_app_data, app_data) + } + // No `BaseDirs`: derive the standard locations from the profile. + None => Self::windows( + &home_dir.join("AppData").join("Local"), + &home_dir.join("AppData").join("Roaming"), + ), + } + } +} + +/// XDG base directory values, injected into [`resolve`] so unit tests stay +/// parallel-safe. All values are raw; relative ones are ignored during +/// resolution, per the XDG Base Directory Specification. +#[derive(Debug, Clone, Default)] +struct XdgDirs { + /// `XDG_BIN_HOME` + bin: Option, + /// `XDG_CONFIG_HOME` + config: Option, + /// `XDG_DATA_HOME` + data: Option, + /// `XDG_STATE_HOME` + state: Option, + /// `XDG_CACHE_HOME` + cache: Option, +} + +impl XdgDirs { + /// Read the XDG base directory variables from the process environment. + fn from_env() -> Self { + Self { + bin: env::var(env_vars::XDG_BIN_HOME).ok().map(PathBuf::from), + config: env::var(env_vars::XDG_CONFIG_HOME).ok().map(PathBuf::from), + data: env::var(env_vars::XDG_DATA_HOME).ok().map(PathBuf::from), + state: env::var(env_vars::XDG_STATE_HOME).ok().map(PathBuf::from), + cache: env::var(env_vars::XDG_CACHE_HOME).ok().map(PathBuf::from), + } + } +} + +/// Convert an optional configured path into an absolute path, ignoring +/// relative values (treated as unset). +fn absolute(value: &Option) -> Option { + value.as_deref().and_then(AbsolutePath::new).map(AbsolutePath::to_absolute_path_buf) +} + +/// Detect a legacy install root from the running executable's own location: +/// a canonicalized `/current/bin/vp[.exe]` means `` is a legacy +/// monolithic install. This covers custom-location installs (previously +/// located via `VP_HOME`) and launches without `PATH` context (IDEs, the +/// Windows trampoline). Cheap suffix check on the path components; any +/// failure falls through to the next rule. +fn self_located_legacy_root() -> Option { + let exe = AbsolutePathBuf::new(env::current_exe().ok()?.canonicalize().ok()?)?; + if exe.as_path().file_name() != Some(OsStr::new(VP_BINARY_NAME)) { + return None; + } + let bin_dir = exe.parent()?; + if bin_dir.as_path().file_name() != Some(OsStr::new("bin")) { + return None; + } + let current_dir = bin_dir.parent()?; + if current_dir.as_path().file_name() != Some(OsStr::new("current")) { + return None; + } + current_dir.parent().map(AbsolutePath::to_absolute_path_buf) +} + +/// Infer a legacy install root from a `/bin` entry on `PATH`. +/// +/// Pure: takes the `PATH` value and the current directory as parameters, so +/// tests need no environment mutation (and no serialization). Only +/// recognizes the monolithic legacy layout (`/bin/vp` plus +/// `/current/bin/vp`). Inference for the split XDG layout is +/// intentionally not implemented; it lands with the installer cutover. +fn infer_legacy_home_from_path( + path_env: Option<&OsStr>, + cwd: &AbsolutePath, +) -> Option { + 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 { + cwd.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 Some(home.to_absolute_path_buf()); + } + } + + None +} + +fn is_vp_home_layout(bin_dir: &AbsolutePath, home: &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() +} + +/// Platform-neutral resolution core, injectable for tests. +/// +/// `detected_legacy_root` is the result of the host-environment legacy +/// detection (executable self-location, then `PATH` inference; rules 1–2). +/// `legacy_exists` reports whether the legacy `~/.vite-plus` root exists on +/// disk (rule 3); injected so tests exercise the grandfathering branch +/// without touching host state (or against real tempdirs). +fn resolve( + config: &EnvConfig, + home_dir: &AbsolutePath, + xdg: &XdgDirs, + defaults: &PlatformDefaults, + detected_legacy_root: Option, + legacy_exists: impl Fn(&AbsolutePath) -> bool, +) -> Dirs { + // 0. Explicit `VP_HOME` always selects the monolithic legacy layout. + if let Some(root) = absolute(&config.vite_plus_home) { + return Dirs::home(root); + } + + // 1/2. A legacy root detected from the executable location or `PATH` + // selects the monolithic legacy layout. + if let Some(root) = detected_legacy_root { + return Dirs::home(root); + } + + // 3. Grandfathered installs: an existing `~/.vite-plus` keeps working + // untouched; nothing is moved. + let legacy_root = home_dir.join(LEGACY_HOME_DIR); + if legacy_exists(&legacy_root) { + return Dirs::home(legacy_root); + } + + // 3. Fresh installs: per-category `VP_*_DIR` override → XDG → + // platform-default chains, first match per category. + let bin = absolute(&config.vp_bin_dir) + .or_else(|| absolute(&xdg.bin)) + .or_else(|| { + // uv's chain: `$XDG_DATA_HOME/../bin`. + absolute(&xdg.data).and_then(|data_home| data_home.parent().map(|p| p.join("bin"))) + }) + .unwrap_or_else(|| defaults.bin.clone()); + let config_dir = absolute(&xdg.config) + .map(|dir| dir.join(APP_DIR_NAME)) + .unwrap_or_else(|| defaults.config.clone()); + let data = absolute(&config.vp_data_dir) + .or_else(|| absolute(&xdg.data).map(|dir| dir.join(APP_DIR_NAME))) + .unwrap_or_else(|| defaults.data.clone()); + let state = absolute(&xdg.state) + .map(|dir| dir.join(APP_DIR_NAME)) + .unwrap_or_else(|| defaults.state.clone()); + let cache = absolute(&config.vp_cache_dir) + .or_else(|| absolute(&xdg.cache).map(|dir| dir.join(APP_DIR_NAME))) + .unwrap_or_else(|| defaults.cache.clone()); + + Dirs { inner: DirsInner::Custom { bin, config: config_dir, data, state, cache } } +} + +impl Dirs { + fn home(root: AbsolutePathBuf) -> Self { + Self { inner: DirsInner::Home(root) } + } + + /// Resolve the on-disk layout for the current environment. + /// + /// Priority: thread-local test override (test builds only) > fresh + /// resolution from [`EnvConfig::get`]. There is no global cache: each + /// call recomputes from the current [`EnvConfig`], so + /// [`EnvConfig::test_scope`] overrides are observed immediately. + /// Callers in hot loops should keep the returned value rather than + /// calling repeatedly. + #[must_use] + pub fn get() -> Self { + #[cfg(test)] + if let Some(dirs) = TEST_DIRS.with(|c| c.borrow().clone()) { + return dirs; + } + Self::resolve_from_env() + } + + fn resolve_from_env() -> Self { + let config = EnvConfig::get(); + + // Rules 1–2 and the XDG variables read the real process environment. + // Skip them while the thread runs under an `EnvConfig` test override + // so tests resolve purely from the injected config: hermetic, + // parallel-safe, and free of host state (a developer machine can + // have a real legacy install on `PATH`). + let under_test_override = EnvConfig::is_test_override_active(); + + let detected_legacy_root = if under_test_override { + None + } else { + self_located_legacy_root().or_else(|| { + vt_path::current_dir().ok().and_then(|cwd| { + infer_legacy_home_from_path(env::var_os("PATH").as_deref(), &cwd) + }) + }) + }; + + // Home directory: `EnvConfig::user_home` first, then the platform + // base dirs, then the historic `$CWD` fallback. + let base_dirs = BaseDirs::new(); + let home_dir = absolute(&config.user_home).or_else(|| { + base_dirs.as_ref().and_then(|dirs| AbsolutePathBuf::new(dirs.home_dir().to_path_buf())) + }); + + let Some(home_dir) = home_dir else { + // No home directory: preserve the historic fallback of a legacy + // root at `$CWD/.vite-plus`. + if let Some(root) = detected_legacy_root { + return Self::home(root); + } + let cwd = vt_path::current_dir() + .expect("no home directory and current directory unavailable"); + return Self::home(cwd.join(LEGACY_HOME_DIR)); + }; + + let xdg = if under_test_override { XdgDirs::default() } else { XdgDirs::from_env() }; + // Under a test override, also keep the platform defaults off the + // host `BaseDirs` (matters on Windows, where they come from the real + // `%APPDATA%`/`%LOCALAPPDATA%`) so everything derives from the + // injected home directory. + let defaults_base_dirs = if under_test_override { None } else { base_dirs.as_ref() }; + resolve( + &config, + &home_dir, + &xdg, + &PlatformDefaults::detect(&home_dir, defaults_base_dirs), + detected_legacy_root, + |path| path.as_path().exists(), + ) + } + + /// Directory for executables and shims. + #[must_use] + pub fn bin_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.join("bin"), + DirsInner::Custom { bin, .. } => bin.clone(), + } + } + + /// Directory for user configuration. + #[must_use] + pub fn config_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.clone(), + DirsInner::Custom { config, .. } => config.clone(), + } + } + + /// Directory for payload data (CLI versions, runtimes, package managers). + /// + /// Under the legacy layout every category hangs off the one root, so + /// this is the legacy root itself there. + #[must_use] + pub fn data_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.clone(), + DirsInner::Custom { data, .. } => data.clone(), + } + } + + /// Directory for state files. + #[must_use] + pub fn state_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.clone(), + DirsInner::Custom { state, .. } => state.clone(), + } + } + + /// Directory for disposable caches. + #[must_use] + pub fn cache_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.join("cache"), + DirsInner::Custom { cache, .. } => cache.clone(), + } + } + + /// Root under which CLI versions are installed. + /// + /// CLI versions are direct children of the data directory (of the legacy + /// root itself under the `Home` layout), so this currently returns + /// [`Dirs::data_dir`] unchanged. Kept as a named helper so call sites + /// express intent and a future `/versions` move stays local. + #[must_use] + pub fn versions_dir(&self) -> AbsolutePathBuf { + self.data_dir() + } + + /// `current` symlink pointing at the active CLI version (`/current`). + #[must_use] + pub fn current_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("current") + } + + /// Managed JavaScript runtimes (`/js_runtime`). + #[must_use] + pub fn js_runtime_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("js_runtime") + } + + /// Managed package managers (`/package_manager`). + #[must_use] + pub fn package_manager_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("package_manager") + } + + /// Globally installed packages (`/packages`). + #[must_use] + pub fn packages_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("packages") + } + + /// Per-binary metadata for globally installed packages (`/bins`). + #[must_use] + pub fn bins_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("bins") + } + + /// Directory for the shell env scripts (`env`, `env.fish`, `env.nu`, + /// `env.ps1`). + /// + /// These live at the legacy root today, which maps to the config + /// category. + #[must_use] + pub fn env_scripts_dir(&self) -> AbsolutePathBuf { + self.config_dir() + } + + /// Main configuration file (`/config.json`). + #[must_use] + pub fn config_file(&self) -> AbsolutePathBuf { + self.config_dir().join("config.json") + } + + /// Session Node.js version override written by `vp env use` + /// (`/.session-node-version`). + #[must_use] + pub fn session_node_version_file(&self) -> AbsolutePathBuf { + self.state_dir().join(".session-node-version") + } + + /// Upgrade-check result cache (`/.upgrade-check.json`). + #[must_use] + pub fn upgrade_check_file(&self) -> AbsolutePathBuf { + self.state_dir().join(".upgrade-check.json") + } + + /// Shim resolution cache (`/resolve_cache.json`). + #[must_use] + pub fn resolve_cache_file(&self) -> AbsolutePathBuf { + self.cache_dir().join("resolve_cache.json") + } + + /// Node.js version index cache + /// (`/js_runtime/node/index_cache.json`). + #[must_use] + pub fn node_index_cache_file(&self) -> AbsolutePathBuf { + self.js_runtime_dir().join("node").join("index_cache.json") + } + + /// Whether the resolved layout is the legacy monolithic root. + /// + /// Used by migration/compat logic and `vp doctor`. + #[must_use] + pub fn is_legacy_layout(&self) -> bool { + matches!(self.inner, DirsInner::Home(_)) + } +} + +/// Test-only helpers. Kept out of the public API: other crates override the +/// environment through [`EnvConfig::test_scope`] / [`EnvConfig::test_guard`] +/// (see the module docs for why that stays hermetic). +#[cfg(test)] +impl Dirs { + /// Run a closure with a test override (thread-local, parallel-safe). + /// + /// The override only applies to the current thread. + /// Other test threads see their own overrides or a fresh resolution. + pub fn test_scope(dirs: Self, f: impl FnOnce() -> R) -> R { + TEST_DIRS.with(|c| { + let prev = c.borrow_mut().replace(dirs); + let result = f(); + *c.borrow_mut() = prev; + result + }) + } + + /// Set a test override and return a guard that restores the previous one on drop. + /// Works with async tests since it uses RAII instead of closures. + #[must_use] + pub fn test_guard(dirs: Self) -> TestDirsGuard { + let prev = TEST_DIRS.with(|c| c.borrow_mut().replace(dirs)); + TestDirsGuard { prev } + } + + /// Build a legacy-layout (`Home`) `Dirs` rooted at `path`, for tests. + /// + /// # Panics + /// + /// Panics if `path` is not absolute. + #[must_use] + pub fn for_test_with_root(path: impl Into) -> Self { + let root = AbsolutePathBuf::new(path.into()).expect("test root must be absolute"); + Self::home(root) + } +} + +/// RAII guard for a test override. Restores the previous override on drop. +#[cfg(test)] +pub struct TestDirsGuard { + prev: Option, +} + +#[cfg(test)] +impl Drop for TestDirsGuard { + fn drop(&mut self) { + TEST_DIRS.with(|c| { + *c.borrow_mut() = self.prev.take(); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An absolute fake home directory for the current platform. + fn test_home() -> AbsolutePathBuf { + let path = if cfg!(windows) { "C:\\Users\\vp" } else { "/home/vp" }; + AbsolutePathBuf::new(PathBuf::from(path)).unwrap() + } + + /// Turn a unix-style test path into an absolute path for the current + /// platform (`/x/y` stays as-is on Unix, becomes `C:\x\y` on Windows). + fn abs(path: &str) -> PathBuf { + #[cfg(windows)] + { + let mut converted = String::from("C:"); + for part in path.split('/') { + if part.is_empty() { + continue; + } + converted.push('\\'); + converted.push_str(part); + } + PathBuf::from(converted) + } + #[cfg(not(windows))] + { + PathBuf::from(path) + } + } + + fn unix_defaults() -> PlatformDefaults { + PlatformDefaults::unix(&test_home()) + } + + fn no_xdg() -> XdgDirs { + XdgDirs::default() + } + + fn never_exists(_: &AbsolutePath) -> bool { + false + } + + 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 detected_legacy_root_selects_home_layout_with_legacy_mapping() { + let config = EnvConfig::for_test(); + let detected = Some(AbsolutePathBuf::new(abs("/vp-home")).unwrap()); + let dirs = + resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), detected, never_exists); + + assert!(dirs.is_legacy_layout()); + let root = abs("/vp-home"); + + // Category accessors reproduce the legacy monolithic layout. + assert_eq!(dirs.bin_dir().as_path(), abs("/vp-home/bin").as_path()); + assert_eq!(dirs.config_dir().as_path(), root.as_path()); + assert_eq!(dirs.data_dir().as_path(), root.as_path()); + assert_eq!(dirs.state_dir().as_path(), root.as_path()); + assert_eq!(dirs.cache_dir().as_path(), abs("/vp-home/cache").as_path()); + } + + #[test] + fn home_layout_named_helpers_reproduce_current_on_disk_layout() { + let dirs = Dirs::for_test_with_root(abs("/vp-home")); + + assert_eq!(dirs.versions_dir().as_path(), abs("/vp-home").as_path()); + assert_eq!(dirs.current_dir().as_path(), abs("/vp-home/current").as_path()); + assert_eq!(dirs.js_runtime_dir().as_path(), abs("/vp-home/js_runtime").as_path()); + assert_eq!(dirs.package_manager_dir().as_path(), abs("/vp-home/package_manager").as_path()); + assert_eq!(dirs.packages_dir().as_path(), abs("/vp-home/packages").as_path()); + assert_eq!(dirs.bins_dir().as_path(), abs("/vp-home/bins").as_path()); + assert_eq!(dirs.env_scripts_dir().as_path(), abs("/vp-home").as_path()); + assert_eq!(dirs.config_file().as_path(), abs("/vp-home/config.json").as_path()); + assert_eq!( + dirs.session_node_version_file().as_path(), + abs("/vp-home/.session-node-version").as_path() + ); + assert_eq!( + dirs.upgrade_check_file().as_path(), + abs("/vp-home/.upgrade-check.json").as_path() + ); + assert_eq!( + dirs.resolve_cache_file().as_path(), + abs("/vp-home/cache/resolve_cache.json").as_path() + ); + assert_eq!( + dirs.node_index_cache_file().as_path(), + abs("/vp-home/js_runtime/node/index_cache.json").as_path() + ); + } + + #[test] + fn existing_legacy_root_selects_home_layout() { + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), None, |_| true); + + assert!(dirs.is_legacy_layout()); + let expected = test_home().join(LEGACY_HOME_DIR); + assert_eq!(dirs.data_dir(), expected); + } + + #[test] + fn detected_legacy_root_wins_over_existing_legacy_root() { + let config = EnvConfig::for_test(); + let detected = Some(AbsolutePathBuf::new(abs("/vp-home")).unwrap()); + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), detected, |_| true); + + assert!(dirs.is_legacy_layout()); + assert_eq!(dirs.data_dir().as_path(), abs("/vp-home").as_path()); + } + + #[test] + fn vp_home_selects_home_layout_with_legacy_mapping() { + let config = EnvConfig { vite_plus_home: Some(abs("/vp-home")), ..EnvConfig::for_test() }; + // VP_HOME outranks even a detected/existing legacy root. + let detected = Some(AbsolutePathBuf::new(abs("/detected")).unwrap()); + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), detected, |_| true); + + assert!(dirs.is_legacy_layout()); + let root = dirs.data_dir(); + assert_eq!(root.as_path(), abs("/vp-home").as_path()); + assert_eq!(dirs.bin_dir().as_path(), abs("/vp-home/bin").as_path()); + assert_eq!(dirs.config_dir().as_path(), root.as_path()); + assert_eq!(dirs.state_dir().as_path(), root.as_path()); + assert_eq!(dirs.cache_dir().as_path(), abs("/vp-home/cache").as_path()); + } + + #[test] + fn relative_vp_home_is_ignored() { + let config = EnvConfig { + vite_plus_home: Some(PathBuf::from("relative/vp")), + ..EnvConfig::for_test() + }; + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), None, never_exists); + assert!(!dirs.is_legacy_layout()); + } + + #[test] + fn legacy_root_detection_against_real_tempdir() { + let temp_dir = + std::env::temp_dir().join(format!("vp-dirs-test-legacy-{}", std::process::id())); + let legacy_root = temp_dir.join(LEGACY_HOME_DIR); + std::fs::create_dir_all(&legacy_root).unwrap(); + + let home_dir = AbsolutePathBuf::new(temp_dir.clone()).unwrap(); + let config = EnvConfig::for_test(); + let dirs = resolve( + &config, + &home_dir, + &no_xdg(), + &PlatformDefaults::unix(&home_dir), + None, + |path| path.as_path().exists(), + ); + + assert!(dirs.is_legacy_layout()); + assert_eq!(dirs.data_dir().as_path(), legacy_root.as_path()); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn fresh_install_uses_platform_defaults() { + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), None, never_exists); + + assert!(!dirs.is_legacy_layout()); + let home = test_home(); + assert_eq!(dirs.bin_dir(), home.join(".local/bin")); + assert_eq!(dirs.config_dir(), home.join(".config").join(APP_DIR_NAME)); + assert_eq!(dirs.data_dir(), home.join(".local/share").join(APP_DIR_NAME)); + assert_eq!(dirs.state_dir(), home.join(".local/state").join(APP_DIR_NAME)); + assert_eq!(dirs.cache_dir(), home.join(".cache").join(APP_DIR_NAME)); + } + + #[test] + fn custom_layout_named_helpers_hang_off_category_roots() { + let config = EnvConfig { + vp_bin_dir: Some(abs("/ov/bin")), + vp_data_dir: Some(abs("/ov/data")), + vp_cache_dir: Some(abs("/ov/cache")), + ..EnvConfig::for_test() + }; + let xdg = XdgDirs { + config: Some(abs("/xdg/config")), + state: Some(abs("/xdg/state")), + ..XdgDirs::default() + }; + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + assert_eq!(dirs.bin_dir().as_path(), abs("/ov/bin").as_path()); + assert_eq!(dirs.config_dir().as_path(), abs("/xdg/config/vite-plus").as_path()); + assert_eq!(dirs.data_dir().as_path(), abs("/ov/data").as_path()); + assert_eq!(dirs.state_dir().as_path(), abs("/xdg/state/vite-plus").as_path()); + assert_eq!(dirs.cache_dir().as_path(), abs("/ov/cache").as_path()); + + assert_eq!(dirs.current_dir().as_path(), abs("/ov/data/current").as_path()); + assert_eq!(dirs.js_runtime_dir().as_path(), abs("/ov/data/js_runtime").as_path()); + assert_eq!(dirs.bins_dir().as_path(), abs("/ov/data/bins").as_path()); + assert_eq!( + dirs.config_file().as_path(), + abs("/xdg/config/vite-plus/config.json").as_path() + ); + assert_eq!( + dirs.session_node_version_file().as_path(), + abs("/xdg/state/vite-plus/.session-node-version").as_path() + ); + assert_eq!( + dirs.resolve_cache_file().as_path(), + abs("/ov/cache/resolve_cache.json").as_path() + ); + assert_eq!( + dirs.node_index_cache_file().as_path(), + abs("/ov/data/js_runtime/node/index_cache.json").as_path() + ); + } + + #[test] + fn vp_overrides_apply_per_category() { + // Only VP_DATA_DIR set: data resolves to it, everything else defaults. + let config = EnvConfig { vp_data_dir: Some(abs("/custom/data")), ..EnvConfig::for_test() }; + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), None, never_exists); + + assert_eq!(dirs.data_dir().as_path(), abs("/custom/data").as_path()); + let home = test_home(); + assert_eq!(dirs.bin_dir(), home.join(".local/bin")); + assert_eq!(dirs.cache_dir(), home.join(".cache").join(APP_DIR_NAME)); + } + + #[test] + fn xdg_vars_apply_with_app_subdir() { + let xdg = XdgDirs { + bin: Some(abs("/xdg/bin")), + config: Some(abs("/xdg/config")), + data: Some(abs("/xdg/data")), + state: Some(abs("/xdg/state")), + cache: Some(abs("/xdg/cache")), + }; + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + // XDG_BIN_HOME is used verbatim (like uv); base dirs get `vite-plus`. + assert_eq!(dirs.bin_dir().as_path(), abs("/xdg/bin").as_path()); + assert_eq!(dirs.config_dir().as_path(), abs("/xdg/config/vite-plus").as_path()); + assert_eq!(dirs.data_dir().as_path(), abs("/xdg/data/vite-plus").as_path()); + assert_eq!(dirs.state_dir().as_path(), abs("/xdg/state/vite-plus").as_path()); + assert_eq!(dirs.cache_dir().as_path(), abs("/xdg/cache/vite-plus").as_path()); + } + + #[test] + fn bin_falls_back_to_xdg_data_home_parent() { + // uv's chain: `$XDG_DATA_HOME/../bin` when XDG_BIN_HOME is unset. + let xdg = XdgDirs { data: Some(abs("/xdg/data")), ..XdgDirs::default() }; + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + assert_eq!(dirs.bin_dir().as_path(), abs("/xdg/bin").as_path()); + } + + #[test] + fn vp_overrides_beat_xdg() { + let config = EnvConfig { + vp_data_dir: Some(abs("/ov/data")), + vp_cache_dir: Some(abs("/ov/cache")), + ..EnvConfig::for_test() + }; + let xdg = XdgDirs { + data: Some(abs("/xdg/data")), + cache: Some(abs("/xdg/cache")), + ..XdgDirs::default() + }; + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + assert_eq!(dirs.data_dir().as_path(), abs("/ov/data").as_path()); + assert_eq!(dirs.cache_dir().as_path(), abs("/ov/cache").as_path()); + } + + #[test] + fn relative_xdg_values_are_ignored() { + let xdg = XdgDirs { + bin: Some(PathBuf::from("relative/bin")), + config: Some(PathBuf::from("relative/config")), + data: Some(PathBuf::from("relative/data")), + state: Some(PathBuf::from("relative/state")), + cache: Some(PathBuf::from("relative/cache")), + }; + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + // All relative values ignored → platform defaults. + let home = test_home(); + assert_eq!(dirs.bin_dir(), home.join(".local/bin")); + assert_eq!(dirs.config_dir(), home.join(".config").join(APP_DIR_NAME)); + assert_eq!(dirs.data_dir(), home.join(".local/share").join(APP_DIR_NAME)); + assert_eq!(dirs.state_dir(), home.join(".local/state").join(APP_DIR_NAME)); + assert_eq!(dirs.cache_dir(), home.join(".cache").join(APP_DIR_NAME)); + } + + #[test] + fn relative_vp_dir_overrides_are_ignored() { + let config = EnvConfig { + vp_bin_dir: Some(PathBuf::from("relative/bin")), + vp_data_dir: Some(PathBuf::from("relative/data")), + vp_cache_dir: Some(PathBuf::from("relative/cache")), + ..EnvConfig::for_test() + }; + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), None, never_exists); + + // All relative values ignored → platform defaults. + let home = test_home(); + assert_eq!(dirs.bin_dir(), home.join(".local/bin")); + assert_eq!(dirs.data_dir(), home.join(".local/share").join(APP_DIR_NAME)); + assert_eq!(dirs.cache_dir(), home.join(".cache").join(APP_DIR_NAME)); + } + + #[test] + fn windows_defaults_follow_platform_conventions() { + let local = AbsolutePathBuf::new(abs("/AppData/Local")).unwrap(); + let roaming = AbsolutePathBuf::new(abs("/AppData/Roaming")).unwrap(); + let defaults = PlatformDefaults::windows(&local, &roaming); + + let base = local.join(APP_DIR_NAME); + assert_eq!(defaults.bin, base.join("bin")); + assert_eq!(defaults.data, base.join("data")); + assert_eq!(defaults.state, base.join("state")); + assert_eq!(defaults.cache, base.join("cache")); + assert_eq!(defaults.config, roaming.join(APP_DIR_NAME)); + + // The platform-neutral resolution core consumes them unchanged. + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &no_xdg(), &defaults, None, never_exists); + assert_eq!(dirs.bin_dir(), base.join("bin")); + assert_eq!(dirs.config_dir(), roaming.join(APP_DIR_NAME)); + } + + #[test] + fn infers_legacy_home_from_vp_on_path() { + let temp_dir = std::env::temp_dir().join(format!("vp-test-vp-path-{}", std::process::id())); + let legacy_home = temp_dir.join(LEGACY_HOME_DIR); + let bin_dir = legacy_home.join("bin"); + let current_bin_dir = legacy_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 = env::join_paths([bin_dir.as_os_str()]).unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.clone()).unwrap(); + let inferred = infer_legacy_home_from_path(Some(&path), &cwd); + assert_eq!(inferred.unwrap().as_path(), legacy_home.as_path()); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn inference_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)); + + // `tools/bin` has a `vp` but no `current/bin/vp` sibling layout. + let path = env::join_paths([std::path::Path::new("tools/bin")]).unwrap(); + let cwd = AbsolutePathBuf::new(project_dir.clone()).unwrap(); + assert!(infer_legacy_home_from_path(Some(&path), &cwd).is_none()); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn inference_returns_none_without_path() { + assert!(infer_legacy_home_from_path(None, &test_home()).is_none()); + } + + #[test] + fn self_location_does_not_fire_for_test_binary() { + // The test binary is `/debug/deps/-`, never + // `/current/bin/vp`. + assert!(self_located_legacy_root().is_none()); + } + + #[test] + fn test_scope_overrides_get() { + let override_dirs = Dirs::for_test_with_root(abs("/scoped/root")); + Dirs::test_scope(override_dirs, || { + let dirs = Dirs::get(); + assert!(dirs.is_legacy_layout()); + assert_eq!(dirs.data_dir().as_path(), abs("/scoped/root").as_path()); + }); + } + + #[test] + fn test_guard_restores_previous() { + let before = Dirs::get().is_legacy_layout(); + { + let _guard = Dirs::test_guard(Dirs::for_test_with_root(abs("/guarded/root"))); + assert_eq!(Dirs::get().data_dir().as_path(), abs("/guarded/root").as_path()); + } + assert_eq!(Dirs::get().is_legacy_layout(), before); + } + + #[test] + fn get_recomputes_from_env_config_test_scope() { + // No Dirs override installed: Dirs::get() must observe + // EnvConfig::test_scope overrides on every call. A `.vite-plus` + // under the overridden home selects the legacy layout. + let temp_dir = + std::env::temp_dir().join(format!("vp-dirs-test-scope-{}", std::process::id())); + let legacy_root = temp_dir.join(LEGACY_HOME_DIR); + std::fs::create_dir_all(&legacy_root).unwrap(); + + EnvConfig::test_scope(EnvConfig::for_test_with_home(&temp_dir), || { + let dirs = Dirs::get(); + assert!(dirs.is_legacy_layout()); + assert_eq!(dirs.data_dir().as_path(), legacy_root.as_path()); + }); + + let _ = std::fs::remove_dir_all(&temp_dir); + } +} diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index 6ff07cc398..8d9bc41177 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -26,7 +26,7 @@ //! EnvConfig::for_test_with_home("/tmp/test"), //! || { //! assert_eq!( -//! EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), +//! EnvConfig::get().user_home.as_ref().unwrap().to_str().unwrap(), //! "/tmp/test" //! ); //! }, @@ -53,9 +53,35 @@ thread_local! { pub struct EnvConfig { /// Override for the vite-plus home directory (`~/.vite-plus`). /// + /// Selects the legacy monolithic layout; takes priority over all + /// `VP_*_DIR`/`XDG_*` resolution. + /// /// Env: `VP_HOME` pub vite_plus_home: Option, + /// Override for the directory where executables and shims are installed. + /// + /// Only applies to the split XDG/platform layout (fresh installs); a + /// legacy `~/.vite-plus` layout is all-or-nothing. + /// + /// Env: `VP_BIN_DIR` + pub vp_bin_dir: Option, + + /// Override for the payload data directory (CLI versions, Node.js + /// runtimes, package managers). + /// + /// Only applies to the split XDG/platform layout (fresh installs). + /// + /// Env: `VP_DATA_DIR` + pub vp_data_dir: Option, + + /// Override for the disposable cache directory. + /// + /// Only applies to the split XDG/platform layout (fresh installs). + /// + /// Env: `VP_CACHE_DIR` + pub vp_cache_dir: Option, + /// NPM registry URL. /// /// Env: `npm_config_registry` or `NPM_CONFIG_REGISTRY` @@ -107,6 +133,9 @@ impl EnvConfig { pub fn from_env() -> Self { Self { vite_plus_home: std::env::var(env_vars::VP_HOME).ok().map(PathBuf::from), + vp_bin_dir: std::env::var(env_vars::VP_BIN_DIR).ok().map(PathBuf::from), + vp_data_dir: std::env::var(env_vars::VP_DATA_DIR).ok().map(PathBuf::from), + vp_cache_dir: std::env::var(env_vars::VP_CACHE_DIR).ok().map(PathBuf::from), 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()) @@ -163,7 +192,7 @@ impl EnvConfig { /// || { /// let config = EnvConfig::get(); /// assert_eq!( - /// config.vite_plus_home.as_ref().unwrap().to_str().unwrap(), + /// config.user_home.as_ref().unwrap().to_str().unwrap(), /// "/tmp/test" /// ); /// }, @@ -194,6 +223,9 @@ impl EnvConfig { pub fn for_test() -> Self { Self { vite_plus_home: None, + vp_bin_dir: None, + vp_data_dir: None, + vp_cache_dir: None, npm_registry: "https://registry.npmjs.org".into(), node_dist_mirror: None, node_skip_signature_verify: false, @@ -205,9 +237,24 @@ impl EnvConfig { } } - /// Create a test configuration with a custom home directory. + /// Create a test configuration with a custom user home directory. + /// + /// `Dirs` resolves entirely under this home: with no `/.vite-plus` + /// on disk the split XDG/platform layout lands under `` (fully + /// sandboxed, no host filesystem access); create `/.vite-plus/` + /// to select the legacy monolithic layout instead. pub fn for_test_with_home(home: impl Into) -> Self { - Self { vite_plus_home: Some(home.into()), ..Self::for_test() } + Self { user_home: Some(home.into()), ..Self::for_test() } + } + + /// Whether the current thread runs under a `test_scope`/`test_guard` + /// override. + /// + /// `Dirs` uses this to skip host-environment detection (executable + /// self-location, `PATH` inference, XDG variables) so test threads + /// resolve purely from the injected config and stay hermetic. + pub(crate) fn is_test_override_active() -> bool { + TEST_CONFIG.with(|c| c.borrow().is_some()) } /// Set a test config override and return a guard that restores the previous on drop. @@ -239,7 +286,7 @@ mod tests { #[test] fn test_for_test_returns_defaults() { let config = EnvConfig::for_test(); - assert!(config.vite_plus_home.is_none()); + assert!(config.user_home.is_none()); assert_eq!(config.npm_registry, "https://registry.npmjs.org"); assert!(!config.is_ci); assert!(!config.node_skip_signature_verify); @@ -248,7 +295,7 @@ mod tests { #[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"))); + assert_eq!(config.user_home, Some(PathBuf::from("/tmp/test-home"))); } #[test] @@ -260,14 +307,14 @@ mod tests { }; assert_eq!(config.npm_registry, "https://custom.registry"); assert!(config.is_ci); - assert!(config.vite_plus_home.is_none()); + assert!(config.user_home.is_none()); } #[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"); + assert_eq!(config.user_home.as_ref().unwrap().to_str().unwrap(), "/scoped/home"); }); } @@ -275,30 +322,24 @@ mod tests { 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()); + assert!(EnvConfig::get().user_home.is_some()); }); let after = EnvConfig::get(); - assert_eq!(before.vite_plus_home.is_some(), after.vite_plus_home.is_some()); + assert_eq!(before.user_home.is_some(), after.user_home.is_some()); } #[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" - ); + assert_eq!(EnvConfig::get().user_home.as_ref().unwrap().to_str().unwrap(), "/outer"); EnvConfig::test_scope(EnvConfig::for_test_with_home("/inner"), || { assert_eq!( - EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), + EnvConfig::get().user_home.as_ref().unwrap().to_str().unwrap(), "/inner" ); }); // Restored to outer - assert_eq!( - EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), - "/outer" - ); + assert_eq!(EnvConfig::get().user_home.as_ref().unwrap().to_str().unwrap(), "/outer"); }); } diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 0588b56322..aaa08a0039 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -9,12 +9,47 @@ //! //! 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 `Dirs` path resolution, so they get constants too. // ── Config: read once at startup via EnvConfig ────────────────────────── /// Override for the vite-plus home directory (default: `~/.vite-plus`). +/// +/// Selects the legacy monolithic layout: every category lives under this one +/// root. Takes priority over all `VP_*_DIR`/`XDG_*` resolution. pub const VP_HOME: &str = "VP_HOME"; +/// Override directory for executables and shims. +/// +/// Only applies to the split XDG/platform layout (fresh installs); a legacy +/// `~/.vite-plus` layout is all-or-nothing. +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 Dirs resolution ─────────────────────── + +/// XDG base directory for executables. +pub const XDG_BIN_HOME: &str = "XDG_BIN_HOME"; + +/// XDG base directory for user configuration. +pub const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME"; + +/// XDG base directory for user data. +pub const XDG_DATA_HOME: &str = "XDG_DATA_HOME"; + +/// XDG base directory for user state. +pub const XDG_STATE_HOME: &str = "XDG_STATE_HOME"; + +/// XDG base directory for disposable caches. +pub const XDG_CACHE_HOME: &str = "XDG_CACHE_HOME"; + /// Log filter string for `tracing_subscriber` (e.g. `"debug"`, `"vt=trace"`). pub const VP_LOG: &str = "VP_LOG"; 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 bcac140c23..824291e571 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 dirs::{Dirs, VP_BINARY_NAME}; pub use env_config::{EnvConfig, TestEnvGuard}; pub use error::format_error_chain; -pub use home::{VP_BINARY_NAME, get_vp_home}; pub use http::{HttpClientError, shared_http_client}; pub use interactivity::{ is_ci_environment, is_interactive_terminal, is_stderr_terminal, is_stdin_terminal, diff --git a/docs/guide/env.md b/docs/guide/env.md index a1580348d5..5f188d67b3 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, Vite+ stores its managed runtime and related files in `~/.vite-plus` (or `$VP_HOME` when set). The CLI also supports a split XDG-style layout — resolved per category from `VP_BIN_DIR`/`VP_DATA_DIR`/`VP_CACHE_DIR`, the `XDG_*` base directories, and platform defaults — for environments without an existing `~/.vite-plus` directory; existing installs are grandfathered and nothing is moved. See [Directory Layout and XDG Variables](/guide/installer-env-vars#directory-layout-and-xdg-variables). References to `VP_HOME` paths below use the legacy layout, which is what most users see today; under the split layout, substitute the corresponding bin/config/data/state directory. 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 Vite+ bin directory (`VP_HOME/bin` in the legacy layout) and writes the per-shell setup scripts to the config directory (`VP_HOME` in the legacy layout) - `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 @@ -76,9 +76,9 @@ 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 Vite+ bin directory (`VP_HOME/bin` in the legacy layout) 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. +In CI, `vp env use` can still run without shell initialization. It writes a temporary session file to the Vite+ state directory (`VP_HOME` in the legacy layout) so later shim calls in the same job can resolve the selected Node.js version. ### Manage @@ -144,7 +144,7 @@ Vite+ creates a `corepack` shim by default, so corepack works without a system N - On Node.js 25 and later, where corepack is no longer bundled, Vite+ installs corepack as a managed global package on first use. Only the `corepack` binary is linked; run `vp install -g corepack` yourself if you also want the package's pnpm/yarn launchers exposed directly. - If you install corepack explicitly with `vp install -g corepack`, that installation is always preferred. -`corepack enable` normally creates `pnpm`/`yarn` launchers next to the corepack binary, which under Vite+ would not be on `PATH`. The shim fixes this by defaulting `--install-directory` to `VP_HOME/bin`, so after `corepack enable` the launchers are available everywhere and still resolve the project's Node.js and package-manager versions: +`corepack enable` normally creates `pnpm`/`yarn` launchers next to the corepack binary, which under Vite+ would not be on `PATH`. The shim fixes this by defaulting `--install-directory` to the Vite+ bin directory (`VP_HOME/bin` in the legacy layout), so after `corepack enable` the launchers are available everywhere and still resolve the project's Node.js and package-manager versions: ```bash corepack enable # pnpm and yarn now resolve via corepack diff --git a/docs/guide/implode.md b/docs/guide/implode.md index 02a019f5f6..23c26a1e2d 100644 --- a/docs/guide/implode.md +++ b/docs/guide/implode.md @@ -6,6 +6,8 @@ Use `vp implode` to remove `vp` and all related Vite+ data from your machine. `vp implode` is the cleanup command for removing a Vite+ installation and its managed data. Use it if you no longer want Vite+ to manage your runtime, package manager, and related local tooling state. +It removes the Vite+ home directory — `~/.vite-plus` by default, or `$VP_HOME` when set — and cleans the Vite+ lines from your shell profiles. It currently removes the legacy monolithic root only. + ::: info If you decide Vite+ is not for you, please [share your feedback with us](https://discord.gg/cAnsqHh5PX). ::: diff --git a/docs/guide/install.md b/docs/guide/install.md index 7eb21a015a..ab6645ffc8 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -74,7 +74,7 @@ Updates keep the version spec a package was installed with: a package installed ::: warning These commands do **NOT** interact with the underlying package manager's global installation directory. -Instead, Vite+ manages its own global packages under `VP_HOME/packages`, allowing them to remain available across different Node.js versions. +Instead, Vite+ manages its own global packages in the `packages` subdirectory of its data directory (`VP_HOME/packages` in the legacy `~/.vite-plus` layout; see [Directory Layout and XDG Variables](/guide/installer-env-vars#directory-layout-and-xdg-variables)), allowing them to remain available across different Node.js versions. As a result, commands such as `vp link` do not affect Vite+'s global packages and will not appear in `vp list -g`. ::: diff --git a/docs/guide/installer-env-vars.md b/docs/guide/installer-env-vars.md index b4b087116b..3b6d5bc3bd 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -25,9 +25,10 @@ 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)) +- **Purpose**: Installation directory - **Default**: `~/.vite-plus` (Unix) or `%USERPROFILE%\.vite-plus` (Windows) - **CLI equivalent**: `--install-dir` +- **Details**: Installer scripts use it as the install directory, and the installed CLI reads it as the highest-priority layout rule: everything lives under this one root (the legacy monolithic layout). See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). - **Example**: ```bash @@ -75,7 +76,25 @@ When developing Vite+ itself, `VP_LOCAL_TGZ` (path to a local `vite-plus.tgz`) a ## Runtime Variables -These variables configure the installed Vite+ CLI. `VP_HOME` (above) also applies at runtime. +These variables configure the installed Vite+ CLI. + +### `VP_BIN_DIR` + +- **Purpose**: Directory for executables and shims (`node`, `npm`, `npx`, `corepack`, `vpx`, `vpr`, the `vp` wrapper) +- **Default**: `XDG_BIN_HOME` if set, then `XDG_DATA_HOME/../bin`, otherwise `~/.local/bin` (Unix) or `%LOCALAPPDATA%\vite-plus\bin` (Windows) +- **Details**: Only applies in the split layout; ignored when the legacy monolithic layout is selected. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). + +### `VP_DATA_DIR` + +- **Purpose**: Payload data directory (CLI versions, managed Node.js runtimes, package managers, global packages) +- **Default**: `XDG_DATA_HOME/vite-plus` if set, otherwise `~/.local/share/vite-plus` (Unix) or `%LOCALAPPDATA%\vite-plus\data` (Windows) +- **Details**: Only applies in the split layout; ignored when the legacy monolithic layout is selected. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). + +### `VP_CACHE_DIR` + +- **Purpose**: Disposable cache directory +- **Default**: `XDG_CACHE_HOME/vite-plus` if set, otherwise `~/.cache/vite-plus` (Unix) or `%LOCALAPPDATA%\vite-plus\cache` (Windows) +- **Details**: Only applies in the split layout; ignored when the legacy monolithic layout is selected. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). ### `VP_NODE_DIST_MIRROR` @@ -184,7 +203,36 @@ 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 default `~/.vite-plus` path and the Unix platform defaults (`~/.local/bin`, `~/.config`, ...) + +### `XDG_BIN_HOME` / `XDG_CONFIG_HOME` / `XDG_DATA_HOME` / `XDG_STATE_HOME` / `XDG_CACHE_HOME` + +- **Purpose**: XDG base directories honored when resolving the split layout +- **Details**: Read directly from the process environment during directory resolution. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). + +## Directory Layout and XDG Variables + +The installed CLI resolves where its files live by picking one of two layouts; the first match wins: + +1. **`VP_HOME` is set** — the legacy monolithic layout rooted at its value; every category lives under this one root. +2. **Executable self-location** — the running `vp` binary's own (canonicalized) path is `/current/bin/vp`: `` is a legacy monolithic install. This covers launches without `PATH` context (IDEs, the Windows shim trampoline). +3. **`PATH` inference** — a `/bin` entry on `PATH` that contains the legacy layout (`bin/vp` plus `current/bin/vp`) marks `` as a legacy install. +4. **`~/.vite-plus` exists** — the same legacy layout, grandfathered: existing installs keep working untouched and nothing is moved. +5. **Otherwise (fresh installs)** — a split layout where each category resolves independently through its own override → XDG → platform-default chain: + +| Category | Contents | Resolution (first match wins) | Unix default | Windows default | +| --- | --- | --- | --- | --- | +| Executables and shims | `node`, `npm`, `npx`, `corepack`, `vpx`, `vpr`, the `vp` wrapper | `VP_BIN_DIR` → `XDG_BIN_HOME` → `XDG_DATA_HOME/../bin` | `~/.local/bin` | `%LOCALAPPDATA%\vite-plus\bin` | +| Configuration | `config.json`, shell env scripts | `XDG_CONFIG_HOME/vite-plus` | `~/.config/vite-plus` | `%APPDATA%\vite-plus` | +| Data | CLI versions, managed Node.js runtimes, package managers, global packages, per-binary `bins/*.json` metadata | `VP_DATA_DIR` → `XDG_DATA_HOME/vite-plus` | `~/.local/share/vite-plus` | `%LOCALAPPDATA%\vite-plus\data` | +| State | Session and upgrade-check files | `XDG_STATE_HOME/vite-plus` | `~/.local/state/vite-plus` | `%LOCALAPPDATA%\vite-plus\state` | +| Cache | Disposable caches | `VP_CACHE_DIR` → `XDG_CACHE_HOME/vite-plus` | `~/.cache/vite-plus` | `%LOCALAPPDATA%\vite-plus\cache` | + +Notes: + +- Relative values in the `VP_*_DIR` and `XDG_*` variables are ignored, per the XDG Base Directory specification. +- `VP_BIN_DIR`, `VP_DATA_DIR`, and `VP_CACHE_DIR` only apply in the split layout; the legacy layout (rule 1) is all-or-nothing. +- The installer scripts currently still default to installing under `~/.vite-plus`, so fresh installs today land in the legacy layout (rule 4). The split layout becomes effective for fresh installs once the installer defaults are updated. ## Precedence diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 4dd43e59d7..118468700e 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -55,10 +55,14 @@ d=${rootExpr} __vp_shell=/bin/sh [ -x "$__vp_shell" ] || __vp_shell=$(command -v sh) -if [ -n "\${VP_HOME-}" ]; then +if [ -n "\${VP_BIN_DIR-}" ]; then + __vp_bin="$VP_BIN_DIR" +elif [ -n "\${VP_HOME-}" ]; then __vp_bin="$VP_HOME/bin" -elif [ -n "\${HOME-}" ]; then +elif [ -n "\${HOME-}" ] && [ -d "$HOME/.vite-plus/bin" ]; then __vp_bin="$HOME/.vite-plus/bin" +elif [ -n "\${HOME-}" ]; then + __vp_bin="$HOME/.local/bin" else __vp_bin="" fi diff --git a/packages/cli/src/create/org-tarball.ts b/packages/cli/src/create/org-tarball.ts index 66f15bd371..596114c411 100644 --- a/packages/cli/src/create/org-tarball.ts +++ b/packages/cli/src/create/org-tarball.ts @@ -9,6 +9,12 @@ import { fetchNpmResource } from '../utils/npm-config.ts'; import type { OrgManifest } from './org-manifest.ts'; function getCacheRoot(): string { + // The global CLI injects VP_CACHE_DIR under the split (XDG) layout; legacy + // installs resolve through VP_HOME / ~/.vite-plus as before. + const cacheDir = process.env.VP_CACHE_DIR; + if (cacheDir) { + return path.join(cacheDir, 'create-org'); + } const home = process.env.VP_HOME || path.join(os.homedir(), '.vite-plus'); return path.join(home, 'tmp', 'create-org'); } diff --git a/rfcs/env-command.md b/rfcs/env-command.md index 96c6cf55be..7862375361 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -2414,6 +2414,8 @@ The following decisions have been made: 1. **VP_HOME Default Location**: `~/.vite-plus` - Simple, memorable path that's easy for users to find and configure. + > **Note (superseded):** Superseded by [#827](https://github.com/voidzero-dev/vite-plus/issues/827). Path resolution now lives in `crates/vp_shared/src/dirs.rs` (`Dirs`, with `Home`/`Custom` layout variants): `VP_HOME`, a legacy root detected from the `vp` binary's own path or from `PATH`, or an existing `~/.vite-plus` still selects the legacy monolithic layout (existing installs are grandfathered; nothing is moved), while fresh installs resolve a split XDG/platform layout per category. + 2. **Windows Shim Strategy**: Trampoline `.exe` files that set `VP_SHIM_TOOL` and spawn `vp.exe` - Avoids "Terminate batch job?" prompt, works in all shells. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md). 3. **Corepack Handling**: Included as a default shim (revisited in [#1309](https://github.com/voidzero-dev/vite-plus/issues/1309), originally excluded). The shim prefers a vp-managed global corepack, falls back to the Node-bundled binary (Node.js ≤ 24), and auto-installs a managed copy on Node.js 25+ where corepack is no longer bundled. See [Corepack Shim](#corepack-shim).