From eb7a6d0ecae049993086ba9bd968536aaf905939 Mon Sep 17 00:00:00 2001 From: yii Date: Fri, 7 Aug 2026 01:24:27 +0800 Subject: [PATCH 1/5] feat(shared): introduce VpDirs with strategy-gated path resolution Replace get_vp_home / the monolithic home module with VpDirs: category roots (bin, data, cache) come from an ordered resolution chain in dirs/resolution.rs (Home/CurrentDir Exist-gated grandfathering, then VP_* and XDG Set overrides, then platform defaults), and first-level data subdirs (current, js_runtime, package_manager, packages, bins) are pure joins. Files and deeper trees stay with their features. Migrate every Rust consumer off the old home helpers onto VpDirs, inject VP_BIN_DIR/VP_DATA_DIR/VP_CACHE_DIR into JS children under the split layout, and teach hooks/org-tarball to honor those roots. implode, env setup/doctor, and shims understand split vs legacy layouts. Groundwork for #827. --- AGENTS.md | 2 + CONTRIBUTING.md | 2 +- Cargo.lock | 11 + Cargo.toml | 1 + crates/vp_command/src/ps1_shim.rs | 21 +- .../src/commands/env/bin_config.rs | 7 +- .../vp_global_cli/src/commands/env/clean.rs | 9 +- .../vp_global_cli/src/commands/env/config.rs | 67 +- .../vp_global_cli/src/commands/env/current.rs | 6 +- .../vp_global_cli/src/commands/env/default.rs | 5 +- .../vp_global_cli/src/commands/env/doctor.rs | 75 +- crates/vp_global_cli/src/commands/env/list.rs | 4 +- .../src/commands/env/list_remote.rs | 6 +- crates/vp_global_cli/src/commands/env/mod.rs | 6 +- .../src/commands/env/package_metadata.rs | 20 +- crates/vp_global_cli/src/commands/env/pin.rs | 47 +- .../vp_global_cli/src/commands/env/setup.rs | 302 ++++--- crates/vp_global_cli/src/commands/env/use.rs | 4 +- .../vp_global_cli/src/commands/env/which.rs | 10 +- .../src/commands/global/install.rs | 36 +- crates/vp_global_cli/src/commands/implode.rs | 333 +++++++- .../vp_global_cli/src/commands/upgrade/mod.rs | 5 +- crates/vp_global_cli/src/commands/version.rs | 2 +- crates/vp_global_cli/src/commands/vpx.rs | 35 +- crates/vp_global_cli/src/js_executor.rs | 28 +- crates/vp_global_cli/src/shim/cache.rs | 29 +- crates/vp_global_cli/src/shim/corepack.rs | 27 +- crates/vp_global_cli/src/shim/dispatch.rs | 88 +- crates/vp_global_cli/src/shim/mod.rs | 43 +- crates/vp_global_cli/src/upgrade_check.rs | 36 +- crates/vp_js_runtime/src/cache.rs | 12 - crates/vp_js_runtime/src/lib.rs | 1 - crates/vp_js_runtime/src/providers/node.rs | 10 +- crates/vp_js_runtime/src/runtime.rs | 9 +- crates/vp_pm_cli/src/package_manager.rs | 58 +- crates/vp_shared/Cargo.toml | 2 + crates/vp_shared/src/dirs.rs | 113 +++ crates/vp_shared/src/dirs/resolution.rs | 751 ++++++++++++++++++ crates/vp_shared/src/env_config.rs | 75 +- crates/vp_shared/src/env_vars.rs | 41 +- crates/vp_shared/src/home.rs | 206 ----- crates/vp_shared/src/lib.rs | 4 +- docs/guide/env.md | 13 +- docs/guide/implode.md | 2 + packages/cli/src/config/hooks.ts | 8 +- packages/cli/src/create/org-tarball.ts | 6 + rfcs/env-command.md | 2 + 47 files changed, 1762 insertions(+), 818 deletions(-) delete mode 100644 crates/vp_js_runtime/src/cache.rs create mode 100644 crates/vp_shared/src/dirs.rs create mode 100644 crates/vp_shared/src/dirs/resolution.rs delete mode 100644 crates/vp_shared/src/home.rs diff --git a/AGENTS.md b/AGENTS.md index 34a7d5d012..001516b821 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ vite-plus/ └── crates/vp_trampoline/ # Windows shim trampoline ``` +On-disk paths (bin, data, cache, and derived helpers) are resolved centrally via `vp_shared::VpDirs` (`crates/vp_shared/src/dirs.rs`, strategy chain in `dirs/resolution.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..e7e4979814 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::VpDirs` 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/Cargo.lock b/Cargo.lock index 85a5810fff..ded612b51e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7606,6 +7606,15 @@ dependencies = [ "xattr", ] +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -8608,6 +8617,8 @@ dependencies = [ "serde_json", "serial_test", "supports-color 3.0.2", + "temp-env", + "tempfile", "thiserror 2.0.19", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index ae926ac719..6a974f55f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -273,6 +273,7 @@ sugar_path = { version = "3", features = ["cached_current_dir"] } supports-color = "3" syn = { version = "2", default-features = false } tar = "0.4.43" +temp-env = "0.3.6" tempfile = "3.14.0" terminal_size = "0.4.2" test-log = { version = "0.2.18", features = ["trace"] } diff --git a/crates/vp_command/src/ps1_shim.rs b/crates/vp_command/src/ps1_shim.rs index f4665da6c0..f7cb7cf74d 100644 --- a/crates/vp_command/src/ps1_shim.rs +++ b/crates/vp_command/src/ps1_shim.rs @@ -30,6 +30,7 @@ //! and . use std::ffi::OsString; +use vp_shared::VpDirs; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_powershell::{POWERSHELL_PREFIX, find_ps1_sibling, is_stdin_terminal, powershell_host}; @@ -46,8 +47,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 +62,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(|| VpDirs::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..9e37286220 100644 --- a/crates/vp_global_cli/src/commands/env/bin_config.rs +++ b/crates/vp_global_cli/src/commands/env/bin_config.rs @@ -9,8 +9,8 @@ use serde::{Deserialize, Serialize}; use vt_path::AbsolutePathBuf; +use vp_shared::VpDirs; -use super::config::get_vp_home; use crate::error::Error; /// Source that installed a binary. @@ -52,9 +52,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(VpDirs::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..b7b1514455 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -5,7 +5,7 @@ use std::{path::Path, process::ExitStatus}; -use vp_shared::{env_vars, output}; +use vp_shared::{VpDirs, env_vars, output}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{config, list::list_installed_versions}; @@ -13,9 +13,8 @@ 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 node_dir = VpDirs::js_runtime_dir().join("node"); + let package_manager_dir = VpDirs::package_manager_dir(); let protected_versions = protected_node_versions(&cwd).await?; let corepack_cleaned = run_corepack_cache_clean(&cwd).await?; @@ -138,7 +137,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 = VpDirs::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..b4a5d153f7 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 [`VpDirs`]. 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::VpDirs; 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 = config_file_path(); if !tokio::fs::try_exists(&config_path).await.unwrap_or(false) { return Ok(Config::default()); @@ -130,11 +107,10 @@ 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 config_path = config_file_path(); // Ensure directory exists - tokio::fs::create_dir_all(&vite_plus_home).await?; + tokio::fs::create_dir_all(&VpDirs::config_dir()).await?; let content = serde_json::to_string_pretty(config)?; tokio::fs::write(&config_path, content).await?; @@ -145,17 +121,23 @@ pub async fn save_config(config: &Config) -> Result<(), Error> { /// Set by `vp env use` command. pub const VERSION_ENV_VAR: &str = vp_shared::env_vars::VP_NODE_VERSION; +/// Main config file name under [`VpDirs::config_dir`]. +const CONFIG_FILE_NAME: &str = "config.json"; + /// 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)) +fn config_file_path() -> AbsolutePathBuf { + VpDirs::config_dir().join(CONFIG_FILE_NAME) +} + +fn session_version_file_path() -> AbsolutePathBuf { + VpDirs::state_dir().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 = session_version_file_path(); 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 +145,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 = session_version_file_path(); 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 +153,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 = session_version_file_path(); // Ensure parent directory exists if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; @@ -182,7 +164,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 = session_version_file_path(); match tokio::fs::remove_file(&path).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -221,7 +203,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 = VpDirs::config_dir().join("config.json"); 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..c7443b1468 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::{VpDirs, 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(&VpDirs::config_dir()); } } @@ -130,29 +128,21 @@ 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 = VpDirs::data_dir(); let display = abbreviate_home(&home.as_path().display().to_string()); if tokio::fs::try_exists(&home).await.unwrap_or(false) { - print_check(&output::CHECK.green().to_string(), env_vars::VP_HOME, &display); + print_check(&output::CHECK.green().to_string(), "Home directory", &display); true } else { print_check( &output::CROSS.red().to_string(), - env_vars::VP_HOME, + "Home directory", &"does not exist".red().to_string(), ); print_hint("Run 'vp env setup' to create it."); @@ -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 = VpDirs::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 = VpDirs::config_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 = VpDirs::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(&VpDirs::config_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 = VpDirs::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..8a5e32d9b0 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -3,6 +3,7 @@ //! Handles `vp env list` to show Node.js versions installed in VP_HOME/js_runtime/node/. use std::{cmp::Ordering, process::ExitStatus}; +use vp_shared::VpDirs; use owo_colors::OwoColorize; use serde::Serialize; @@ -52,8 +53,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 = VpDirs::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..0bc2fe5b83 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -3,6 +3,7 @@ //! Handles `vp env list-remote` to show available Node.js versions from the Node.js distribution. use std::process::ExitStatus; +use vp_shared::VpDirs; use owo_colors::OwoColorize; use serde::Serialize; @@ -103,10 +104,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 = VpDirs::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..8dcefbdd46 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -3,6 +3,8 @@ //! This module provides the `vp env` command for managing Node.js environments //! through shim-based version management. +use vp_shared::VpDirs; + pub mod bin_config; mod clean; pub mod config; @@ -109,8 +111,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 = + VpDirs::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..fe1892a005 100644 --- a/crates/vp_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vp_global_cli/src/commands/env/package_metadata.rs @@ -1,13 +1,13 @@ //! Package metadata storage for global packages. use std::collections::HashSet; +use vp_shared::VpDirs; use chrono::{DateTime, Utc}; 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 +117,7 @@ impl PackageMetadata { package_name: &str, install_id: &str, ) -> Result { - let packages_dir = get_packages_dir()?; + let packages_dir = VpDirs::packages_dir(); let package_dir = packages_dir.join(package_name); if install_id.is_empty() { Ok(package_dir) @@ -134,7 +134,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 = VpDirs::packages_dir(); Ok(packages_dir.join(format!("{package_name}.json"))) } @@ -173,7 +173,7 @@ impl PackageMetadata { /// List all installed packages. pub async fn list_all() -> Result, Error> { - let packages_dir = get_packages_dir()?; + let packages_dir = VpDirs::packages_dir(); if !tokio::fs::try_exists(&packages_dir).await.unwrap_or(false) { return Ok(Vec::new()); } @@ -358,9 +358,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 = + VpDirs::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..db3c4c5e43 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -10,10 +10,10 @@ use std::{io::Write, process::ExitStatus}; use vp_js_runtime::NodeProvider; -use vp_shared::output; +use vp_shared::{VpDirs, 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 = VpDirs::config_dir().join("config.json"); 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..0bb86adeb9 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1,8 +1,9 @@ //! Setup command implementation for creating bin directory and shims. //! -//! Creates the following structure: -//! - ~/.vite-plus/bin/ - Contains vp symlink and node/npm/npx/corepack shims -//! - ~/.vite-plus/current/ - Contains the actual vp CLI binary +//! Creates the following structure (legacy layout shown; under the split +//! layout the bin dir and data dir are separate, see [`VpDirs`]): +//! - / - Contains vp symlink and node/npm/npx/corepack shims +//! - /current/ - Contains the actual vp CLI binary //! //! On Unix: //! - bin/vp is a symlink to the active vp binary @@ -18,8 +19,8 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; +use vp_shared::VpDirs; -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 +57,11 @@ 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()?; - - // 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(&VpDirs::config_dir()).await?; // Create env files with PATH guard (prevents duplicate PATH entries) - create_env_files(&vite_plus_home).await?; + create_env_files().await?; if env_only { println!("{}", help::render_heading("Setup")); @@ -71,7 +70,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result return Ok(ExitStatus::default()); } - let bin_dir = get_bin_dir()?; + let bin_dir = VpDirs::bin_dir(); println!("{}", help::render_heading("Setup")); println!(" Preparing vite-plus environment."); @@ -154,7 +153,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result } println!(); - print_path_instructions(&bin_dir); + print_path_instructions(&VpDirs::config_dir()); Ok(ExitStatus::default()) } @@ -529,7 +528,6 @@ pub(crate) async fn cleanup_legacy_windows_shim(bin_dir: &vt_path::AbsolutePath, // Includes shell completion support const ENV_TEMPLATE_POSIX: &str = r#"#!/bin/sh # Vite+ environment setup (https://viteplus.dev) -export VP_HOME="__VP_HOME__" __vp_bin="__VP_BIN__" case ":${PATH}:" in *":${__vp_bin}:"*) @@ -577,7 +575,6 @@ fi "#; const ENV_TEMPLATE_FISH: &str = r#"# Vite+ environment setup (https://viteplus.dev) -set -gx VP_HOME "__VP_HOME__" set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) and set -e PATH[$__vp_idx] set -gx PATH __VP_BIN__ $PATH @@ -613,7 +610,6 @@ complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" // Completions delegate to Fish dynamically (VP_COMPLETE=fish) because clap_complete_nushell // generates multiple rest params (e.g. for `vp install`), which Nushell does not support. const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev) -$env.VP_HOME = ("__VP_HOME__" | path expand --no-symlink) $env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") # Shell function wrapper: intercepts `vp env use` to parse its stdout, @@ -674,7 +670,6 @@ export extern "vpr" [...args: string@"nu-complete vpr"] "#; const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) -$env:VP_HOME = "__VP_HOME_WIN__" $__vp_bin = "__VP_BIN_WIN__" if ($env:Path -split ';' -notcontains $__vp_bin) { $env:Path = "$__vp_bin;$env:Path" @@ -735,8 +730,10 @@ Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp // cmd.exe wrapper for `vp env use` (cmd.exe cannot define shell functions). // Users run `vp-use 24` in cmd.exe instead of `vp env use 24`. +// Locates the real vp.exe next to the bin dir: `\current\bin\vp.exe` +// (legacy layout) or `\data\current\bin\vp.exe` (split layout). #[cfg(windows)] -const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nset VP_HOME=%~dp0..\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; +const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nset \"VP_EXE=%~dp0..\\current\\bin\\vp.exe\"\r\nif not exist \"%VP_EXE%\" set \"VP_EXE=%~dp0..\\data\\current\\bin\\vp.exe\"\r\nfor /f \"delims=\" %%i in ('%VP_EXE% env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; fn render_home_relative_path(path: &std::path::Path, home_dir: Option<&std::path::Path>) -> String { // Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env). @@ -762,37 +759,26 @@ 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 [`VpDirs`]. +fn render_env_content(shell: EnvShell) -> String { + let bin_path = VpDirs::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); let bin_path_ref = render_home_relative_path(bin_path.as_path(), home_dir); match shell { - EnvShell::Posix => ENV_TEMPLATE_POSIX - .replace("__VP_HOME__", &home_path_ref) - .replace("__VP_BIN__", &bin_path_ref), - EnvShell::Fish => ENV_TEMPLATE_FISH - .replace("__VP_HOME__", &home_path_ref) - .replace("__VP_BIN__", &bin_path_ref), + EnvShell::Posix => ENV_TEMPLATE_POSIX.replace("__VP_BIN__", &bin_path_ref), + EnvShell::Fish => ENV_TEMPLATE_FISH.replace("__VP_BIN__", &bin_path_ref), EnvShell::Nu => { // Nushell requires `~` instead of `$HOME` in string literals — `$HOME` is not // expanded at parse time, so PATH entries would contain a literal "$HOME/...". - let home_path_ref_nu = render_nu_path_ref(&home_path_ref); let bin_path_ref_nu = render_nu_path_ref(&bin_path_ref); - ENV_TEMPLATE_NU - .replace("__VP_HOME__", &home_path_ref_nu) - .replace("__VP_BIN__", &bin_path_ref_nu) + ENV_TEMPLATE_NU.replace("__VP_BIN__", &bin_path_ref_nu) } EnvShell::Powershell => { // PowerShell uses the actual absolute path (not $HOME-relative) - let home_path_win = vite_plus_home.as_path().display().to_string(); let bin_path_win = bin_path.as_path().display().to_string(); - ENV_TEMPLATE_PS1 - .replace("__VP_HOME_WIN__", &home_path_win) - .replace("__VP_BIN_WIN__", &bin_path_win) + ENV_TEMPLATE_PS1.replace("__VP_BIN_WIN__", &bin_path_win) } } } @@ -804,22 +790,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() -> Result<(), Error> { + let env_dir = VpDirs::config_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); + 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 +873,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 `VpDirs` 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 +926,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().await.unwrap(); let env_path = home.join("env"); let env_fish_path = home.join("env.fish"); @@ -949,10 +943,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().await.unwrap(); let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); assert!( @@ -960,8 +953,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 +970,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().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(); @@ -997,64 +988,66 @@ mod tests { !fish_content.contains("__VP_BIN__"), "env.fish file should not contain __VP_BIN__ placeholder" ); - assert!( - !env_content.contains("__VP_HOME__") && !fish_content.contains("__VP_HOME__"), - "env files should not contain __VP_HOME__ placeholder" - ); - assert!( - !nu_content.contains("__VP_HOME__") && !ps1_content.contains("__VP_HOME_WIN__"), - "env files should not contain VP_HOME placeholders" - ); - // Should use $HOME-relative path since install dir is under HOME - assert!( - env_content.contains("$HOME/vp_home/bin"), - "env file should reference $HOME/vp_home/bin, got: {env_content}" - ); - assert!( - fish_content.contains("$HOME/vp_home/bin"), - "env.fish file should reference $HOME/vp_home/bin, got: {fish_content}" - ); - assert!( - env_content.contains("export VP_HOME=\"$HOME/vp_home\""), - "env file should export VP_HOME, got: {env_content}" - ); + // VP_HOME is gone: the CLI locates its install root from the + // executable path, so the env scripts must not set it. + for (name, content) in [ + ("env", &env_content), + ("env.fish", &fish_content), + ("env.nu", &nu_content), + ("env.ps1", &ps1_content), + ] { + assert!( + !content.contains("VP_HOME"), + "{name} should not reference VP_HOME, got: {content}" + ); + } + + // Should use $HOME-relative path since the bin dir is under HOME assert!( - fish_content.contains("set -gx VP_HOME \"$HOME/vp_home\""), - "env.fish file should export VP_HOME, got: {fish_content}" + env_content.contains("$HOME/.vite-plus/bin"), + "env file should reference $HOME/.vite-plus/bin, got: {env_content}" ); assert!( - nu_content.contains("$env.VP_HOME = (\"~/vp_home\" | path expand --no-symlink)"), - "env.nu file should set home-relative VP_HOME, got: {nu_content}" + fish_content.contains("$HOME/.vite-plus/bin"), + "env.fish file should reference $HOME/.vite-plus/bin, got: {fish_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(); + let expected_bin = home.join("bin").as_path().display().to_string(); assert!( - ps1_content.contains(&format!("$env:VP_HOME = \"{expected_home}\"")), - "env.ps1 file should set VP_HOME, got: {ps1_content}" + ps1_content.contains(&format!("$__vp_bin = \"{expected_bin}\"")), + "env.ps1 file should set the bin dir, got: {ps1_content}" ); } #[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(); + assert!(!VpDirs::is_legacy_layout(), "no .vite-plus under home → split layout"); + tokio::fs::create_dir_all(VpDirs::config_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().await.unwrap(); + + let env_content = + tokio::fs::read_to_string(VpDirs::config_dir().join("env")).await.unwrap(); + let fish_content = + tokio::fs::read_to_string(VpDirs::config_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 +1056,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().await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); @@ -1110,10 +1109,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().await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -1132,16 +1130,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().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().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 +1151,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().await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); @@ -1181,10 +1177,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().await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -1203,10 +1198,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().await.unwrap(); let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); @@ -1225,27 +1219,30 @@ 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 = VpDirs::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"), - "vp-use.cmd should set VP_HOME before invoking vp env use, got: {cmd_content}" + !cmd_content.contains("VP_HOME"), + "vp-use.cmd should not set VP_HOME, got: {cmd_content}" ); assert!( - cmd_content.contains("%~dp0..\\current\\bin\\vp.exe env use %*"), - "vp-use.cmd should invoke the install-local vp.exe" + cmd_content.contains("%~dp0..\\current\\bin\\vp.exe"), + "vp-use.cmd should try the legacy-layout vp.exe first, got: {cmd_content}" + ); + assert!( + cmd_content.contains("%~dp0..\\data\\current\\bin\\vp.exe"), + "vp-use.cmd should fall back to the split-layout vp.exe, got: {cmd_content}" ); } @@ -1253,12 +1250,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().await.unwrap(); assert!( !bin_dir.join("vp-use.cmd").as_path().exists(), @@ -1269,24 +1265,25 @@ 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 env_dir = VpDirs::config_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 +1425,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().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..7197013148 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -9,6 +9,7 @@ //! with the eval'd output. use std::process::ExitStatus; +use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; @@ -137,8 +138,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 = VpDirs::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..38ad196f12 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -7,6 +7,7 @@ //! For global packages, shows the binary path plus package metadata. use std::process::ExitStatus; +use vp_shared::VpDirs; use chrono::Local; use owo_colors::OwoColorize; @@ -19,7 +20,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 +111,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 = VpDirs::bin_dir().join(tool); let target = tokio::fs::read_link(&link_path).await?; let binary_path = if target.is_absolute() { target @@ -127,7 +128,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 = VpDirs::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 +203,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 = VpDirs::bin_dir(); let metadata_version = installed_version.as_deref().unwrap_or("unknown"); let mut metadata = PackageMetadata::new( @@ -966,7 +956,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 = VpDirs::bin_dir(); let package_dir = match &metadata { Some(metadata) => metadata.installation_dir()?, None => PackageMetadata::installation_dir_for(&package_name, "")?, @@ -991,7 +981,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 = VpDirs::bin_dir(); for bin_name in &bins { remove_package_shim(&bin_dir, bin_name).await?; BinConfig::delete(bin_name).await?; @@ -1400,8 +1390,14 @@ mod tests { let _env_guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&temp_path)); + // Select the legacy layout so `VpDirs::bin_dir()` matches the shims + // this test manages (`/.vite-plus/bin`, not the split + // `~/.local/bin` a fresh home would resolve). + let legacy_root = temp_path.join(".vite-plus"); + std::fs::create_dir_all(&legacy_root).unwrap(); + // Create bin directory - let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); + let bin_dir = AbsolutePathBuf::new(legacy_root.join("bin")).unwrap(); tokio::fs::create_dir_all(&bin_dir).await.unwrap(); // Create shims for "tsc" and "tsserver" @@ -1535,7 +1531,13 @@ mod tests { let _trampoline_guard = FakeTrampolineGuard::new(&temp_path); let _env_guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&temp_path)); - let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); + + // Select the legacy layout so `VpDirs::bin_dir()` matches the shims + // this test manages (`/.vite-plus/bin`, not the split + // `~/.local/bin` a fresh home would resolve). + let legacy_root = temp_path.join(".vite-plus"); + std::fs::create_dir_all(&legacy_root).unwrap(); + let bin_dir = AbsolutePathBuf::new(legacy_root.join("bin")).unwrap(); let mut previous_metadata = PackageMetadata::new( "test-package".to_string(), diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 47b9a9d070..a3216027ee 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -4,7 +4,7 @@ use std::{io::Write, process::ExitStatus}; use directories::BaseDirs; use owo_colors::OwoColorize; -use vp_shared::output; +use vp_shared::{VpDirs, output}; use vt_path::AbsolutePathBuf; use vt_str::Str; @@ -20,12 +20,9 @@ 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 plan = RemovalPlan::new(); - if !home_dir.as_path().exists() { + if !plan.anything_to_remove() { output::info("vite-plus is not installed (directory does not exist)"); return Ok(exit_status(0)); } @@ -35,13 +32,13 @@ pub fn execute(yes: bool) -> Result { .ok_or_else(|| Error::Other("Could not determine user home directory".into()))?; let user_home = AbsolutePathBuf::new(base_dirs.home_dir().to_path_buf()).unwrap(); - let source_matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let source_matcher = VitePlusSourceMatcher::new(&plan.profile_roots, &user_home); // Collect shell profiles that contain Vite+ lines (content cached for cleaning) let affected_profiles = collect_affected_profiles(&user_home, &source_matcher); // Confirmation - if !yes && !confirm_implode(&home_dir, &affected_profiles)? { + if !yes && !confirm_implode(&plan, &affected_profiles)? { return Ok(exit_status(0)); } @@ -51,7 +48,7 @@ pub fn execute(yes: bool) -> Result { // Remove Windows PATH entry #[cfg(windows)] { - let bin_path = home_dir.join("bin"); + let bin_path = VpDirs::bin_dir(); if let Err(e) = remove_windows_path_entry(&bin_path) { output::warn(&vt_str::format!("Failed to clean Windows PATH: {e}")); } else { @@ -59,8 +56,7 @@ pub fn execute(yes: bool) -> Result { } } - // Remove the directory - remove_vite_plus_dir(&home_dir)?; + plan.remove()?; output::raw(""); output::success("vite-plus has been removed from your system."); @@ -69,6 +65,169 @@ pub fn execute(yes: bool) -> Result { Ok(exit_status(0)) } +/// What `vp implode` removes, derived from the resolved [`VpDirs`] +/// layout. +struct RemovalPlan { + /// Directories removed wholesale. Legacy layout: just the install root. + /// Split layout: data, config, state, and cache dirs (deduplicated — + /// category overrides can make them coincide). + dirs: Vec, + /// Bin directory to clean of vp-owned shims (split layout only; under + /// the legacy layout it lives inside the removed root). The directory + /// itself is removed only when vp-dedicated; a shared dir like + /// `~/.local/bin` is never removed. + bin_dir: Option, + /// Directories shell-profile sourcing lines may reference. Legacy: the + /// install root (env scripts live there). Split: the env-scripts dir + /// (e.g. `. "$HOME/.config/vite-plus/env"`). + profile_roots: Vec, +} + +impl RemovalPlan { + fn new() -> Self { + if VpDirs::is_legacy_layout() { + let root = VpDirs::data_dir(); + return Self { dirs: vec![root.clone()], bin_dir: None, profile_roots: vec![root] }; + } + + let mut category_dirs = vec![ + VpDirs::data_dir(), + VpDirs::config_dir(), + VpDirs::state_dir(), + VpDirs::cache_dir(), + ]; + category_dirs.dedup(); + Self { + dirs: category_dirs, + bin_dir: Some(VpDirs::bin_dir()), + profile_roots: vec![VpDirs::config_dir()], + } + } + + fn anything_to_remove(&self) -> bool { + self.dirs.iter().any(|dir| dir.as_path().exists()) + || self.bin_dir.as_ref().is_some_and(|bin_dir| { + std::fs::read_dir(bin_dir) + .map(|entries| { + entries.filter_map(Result::ok).any(|entry| { + entry.file_name().to_str().is_some_and(is_vp_owned_bin_name) + }) + }) + .unwrap_or(false) + }) + } + + fn remove(&self) -> Result<(), Error> { + let mut failed = false; + for dir in &self.dirs { + if !dir.as_path().exists() { + continue; + } + if remove_vite_plus_dir(dir).is_err() { + failed = true; + } + } + if let Some(bin_dir) = &self.bin_dir { + clean_bin_dir(bin_dir); + } + if failed { + Err(Error::Other("Failed to remove all vite-plus directories".into())) + } else { + Ok(()) + } + } +} + +/// Names vp owns in the bin directory, in both Unix and Windows spellings: +/// the `vp` wrapper, the tool shims, and the cmd.exe `vp env use` wrapper. +const VP_OWNED_BIN_NAMES: &[&str] = &[ + "vp", + "node", + "npm", + "npx", + "corepack", + "vpx", + "vpr", + "vp.exe", + "node.exe", + "npm.exe", + "npx.exe", + "corepack.exe", + "vpx.exe", + "vpr.exe", + "vp.cmd", + "node.cmd", + "npm.cmd", + "npx.cmd", + "corepack.cmd", + "vpx.cmd", + "vpr.cmd", + "vp-use.cmd", +]; + +/// Whether vp owns the bin-dir entry `name`: an exact shim name, or a +/// `..old` leftover from Windows rename-before-copy. +fn is_vp_owned_bin_name(name: &str) -> bool { + if VP_OWNED_BIN_NAMES.contains(&name) { + return true; + } + if let Some(stem) = name.strip_suffix(".old") + && let Some((base, timestamp)) = stem.rsplit_once('.') + { + // Rename-before-copy leftovers are `..old`. + return timestamp.bytes().all(|b| b.is_ascii_digit()) && VP_OWNED_BIN_NAMES.contains(&base); + } + false +} + +/// Remove vp's shims from `bin_dir` (split layout). The directory itself is +/// removed only when it is vp-dedicated (contains nothing but vp-owned +/// files); otherwise only the known shim names are deleted and a shared bin +/// dir like `~/.local/bin` is left in place. +fn clean_bin_dir(bin_dir: &AbsolutePathBuf) { + let Ok(entries) = std::fs::read_dir(bin_dir) else { + return; + }; + let names: Vec = entries + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok().map(Str::from)) + .collect(); + if names.is_empty() { + return; + } + + if names.iter().all(|name| is_vp_owned_bin_name(name)) { + // vp-dedicated bin dir: remove it wholesale. + match std::fs::remove_dir_all(bin_dir) { + Ok(()) => output::success(&vt_str::format!("Removed {}", bin_dir.as_path().display())), + Err(e) => { + output::warn(&vt_str::format!( + "Failed to remove {}: {e}", + bin_dir.as_path().display() + )); + } + } + return; + } + + // Shared bin dir: delete only the files vp owns. + for name in names.iter().filter(|name| is_vp_owned_bin_name(name)) { + let path = bin_dir.join(name.as_str()); + match std::fs::remove_file(&path) { + Ok(()) => { + output::success(&vt_str::format!("Removed {}", path.as_path().display())); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + output::warn(&vt_str::format!( + "Failed to remove {}: {e}", + path.as_path().display() + )); + } + } + } +} + /// A shell profile that contains Vite+ sourcing lines. struct AffectedProfile { /// Display name (e.g. ".zshrc", ".config/fish/conf.d/vite-plus.fish"). @@ -126,7 +285,7 @@ fn collect_affected_profiles( /// Show confirmation prompt and require the user to type "uninstall". /// Returns `Ok(true)` if confirmed, `Ok(false)` if aborted. fn confirm_implode( - home_dir: &AbsolutePathBuf, + plan: &RemovalPlan, affected_profiles: &[AffectedProfile], ) -> Result { if !vp_shared::is_stdin_terminal() { @@ -138,7 +297,19 @@ fn confirm_implode( output::warn("This will completely remove vite-plus from your system!"); output::raw(""); - output::raw(&vt_str::format!(" Directory: {}", home_dir.as_path().display())); + if plan.dirs.len() == 1 { + output::raw(&vt_str::format!(" Directory: {}", plan.dirs[0].as_path().display())); + } else { + output::raw(" Directories:"); + for dir in &plan.dirs { + output::raw(&vt_str::format!(" - {}", dir.as_path().display())); + } + } + if let Some(bin_dir) = &plan.bin_dir + && bin_dir.as_path().exists() + { + output::raw(&vt_str::format!(" Shims to remove from: {}", bin_dir.as_path().display())); + } if !affected_profiles.is_empty() { output::raw(" Shell profiles to clean:"); for profile in affected_profiles { @@ -272,29 +443,37 @@ fn spawn_deferred_delete(trash_path: &std::path::Path) -> std::io::Result, } impl VitePlusSourceMatcher { - fn new(home_dir: &AbsolutePathBuf, user_home: &AbsolutePathBuf) -> Self { - let mut roots = vec![normalize_path_separators(&home_dir.as_path().display().to_string())]; - - if let Ok(Some(suffix)) = home_dir.strip_prefix(user_home) { - // `RelativePathBuf` guarantees forward-slash separators. - let suffix = vt_str::format!("{suffix}"); - if suffix.is_empty() { - roots.push(Str::from("$HOME")); - roots.push(Str::from("~")); - } else { - roots.push(vt_str::format!("$HOME/{suffix}")); - roots.push(vt_str::format!("~/{suffix}")); + fn new(reference_dirs: &[AbsolutePathBuf], user_home: &AbsolutePathBuf) -> Self { + let mut roots = Vec::new(); + + for dir in reference_dirs { + roots.push(normalize_path_separators(&dir.as_path().display().to_string())); + + if let Ok(Some(suffix)) = dir.strip_prefix(user_home) { + // `RelativePathBuf` guarantees forward-slash separators. + let suffix = vt_str::format!("{suffix}"); + if suffix.is_empty() { + roots.push(Str::from("$HOME")); + roots.push(Str::from("~")); + } else { + roots.push(vt_str::format!("$HOME/{suffix}")); + roots.push(vt_str::format!("~/{suffix}")); + } } } @@ -428,7 +607,7 @@ mod tests { fn default_source_matcher() -> VitePlusSourceMatcher { let user_home = default_user_home(); let home_dir = user_home.join(".vite-plus"); - VitePlusSourceMatcher::new(&home_dir, &user_home) + VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home) } #[test] @@ -451,7 +630,7 @@ mod tests { fn test_remove_vite_plus_lines_absolute_path() { let user_home = default_user_home(); let home_dir = user_home.join(".vite-plus"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let env_path = shell_path(&home_dir.join("env")); let content = vt_str::format!("# existing\n. \"{env_path}\"\n"); let result = remove_vite_plus_lines(&content, &matcher, "env"); @@ -462,7 +641,7 @@ mod tests { fn test_remove_vite_plus_lines_custom_absolute_path() { let user_home = custom_user_home(); let home_dir = user_home.join("tools").join("vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let env_path = shell_path(&home_dir.join("env")); let content = vt_str::format!("# existing\n. \"{env_path}\"\n"); let result = remove_vite_plus_lines(&content, &matcher, "env"); @@ -473,7 +652,7 @@ mod tests { fn test_remove_vite_plus_lines_custom_home_relative_path() { let user_home = custom_user_home(); let home_dir = user_home.join("tools").join("vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let content = "# existing\n. \"$HOME/tools/vp/env\"\n"; let result = remove_vite_plus_lines(content, &matcher, "env"); assert_eq!(&*result, "# existing\n"); @@ -483,7 +662,7 @@ mod tests { fn test_remove_vite_plus_lines_custom_tilde_path() { let user_home = custom_user_home(); let home_dir = user_home.join("tools").join("vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let content = "# existing\nsource '~/tools/vp/env.nu'\n"; let result = remove_vite_plus_lines(content, &matcher, "env.nu"); assert_eq!(&*result, "# existing\n"); @@ -542,7 +721,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let home_dir = temp_path.join(".vite-plus"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &temp_path); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &temp_path); let profile_path = temp_path.join(".zshrc"); let original = "# my config\nexport FOO=bar\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n"; std::fs::write(&profile_path, original).unwrap(); @@ -612,7 +791,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let home_dir = home.join(".vite-plus"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &home); // Clear env overrides so the test environment doesn't affect results let _guard = ProfileEnvGuard::new(None, None, None); @@ -639,7 +818,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let home_dir = home.join("tools/vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &home); let _guard = ProfileEnvGuard::new(None, None, None); @@ -725,7 +904,7 @@ mod tests { std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); let _guard = ProfileEnvGuard::new(Some(&zdotdir), None, None); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + let matcher = VitePlusSourceMatcher::new(&[home.join(".vite-plus")], &home); let profiles = collect_affected_profiles(&home, &matcher); let zdotdir_profiles: Vec<_> = @@ -749,7 +928,7 @@ mod tests { .unwrap(); let _guard = ProfileEnvGuard::new(None, Some(&xdg_config), None); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + let matcher = VitePlusSourceMatcher::new(&[home.join(".vite-plus")], &home); let profiles = collect_affected_profiles(&home, &matcher); let xdg_profiles: Vec<_> = @@ -772,7 +951,7 @@ mod tests { std::fs::write(nushell_dir.join("vite-plus.nu"), "source '~/.vite-plus/env.nu'\n").unwrap(); let _guard = ProfileEnvGuard::new(None, None, Some(&xdg_data)); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + let matcher = VitePlusSourceMatcher::new(&[home.join(".vite-plus")], &home); let profiles = collect_affected_profiles(&home, &matcher); let xdg_profiles: Vec<_> = @@ -781,6 +960,84 @@ mod tests { assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); } + #[test] + fn test_remove_vite_plus_lines_split_env_scripts_dir() { + // Split layout: profile lines reference the env-scripts dir + // (`. "$HOME/.config/vite-plus/env"`), not the data dir. + let user_home = default_user_home(); + let env_dir = user_home.join(".config").join("vite-plus"); + let data_dir = user_home.join(".local/share").join("vite-plus"); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&env_dir), &user_home); + let content = + "# existing\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.config/vite-plus/env\"\n"; + let result = remove_vite_plus_lines(content, &matcher, "env"); + assert_eq!(&*result, "# existing\n"); + + // Lines referencing the data dir are not env-script sourcing lines + // and stay untouched. + let env_path = shell_path(&data_dir.join("env")); + let content = vt_str::format!("# existing\n. \"{env_path}\"\n"); + let result = remove_vite_plus_lines(&content, &matcher, "env"); + assert_eq!(&*result, &*content); + } + + #[test] + fn test_is_vp_owned_bin_name() { + for owned in [ + "vp", + "node", + "npm", + "npx", + "corepack", + "vpx", + "vpr", + "vp.exe", + "npm.cmd", + "vp-use.cmd", + ] { + assert!(is_vp_owned_bin_name(owned), "{owned} should be vp-owned"); + } + // Windows rename-before-copy leftovers. + assert!(is_vp_owned_bin_name("vp.exe.1700000000.old")); + // Not vp-owned: other tools, lookalikes, and bare .old files. + for foreign in ["git", "node.exe.old", "vpn", "vp.json", "vp.exe.old.bak"] { + assert!(!is_vp_owned_bin_name(foreign), "{foreign} should not be vp-owned"); + } + } + + #[test] + fn test_clean_bin_dir_removes_dedicated_dir() { + let temp_dir = tempfile::tempdir().unwrap(); + let bin_dir = AbsolutePathBuf::new(temp_dir.path().join("bin")).unwrap(); + std::fs::create_dir_all(&bin_dir).unwrap(); + for name in ["vp", "node", "npm", "vp-use.cmd", "vp.exe.1700000000.old"] { + std::fs::write(bin_dir.join(name), b"shim").unwrap(); + } + + clean_bin_dir(&bin_dir); + + assert!(!bin_dir.as_path().exists(), "vp-dedicated bin dir should be removed wholesale"); + } + + #[test] + fn test_clean_bin_dir_keeps_shared_dir_and_foreign_files() { + let temp_dir = tempfile::tempdir().unwrap(); + let bin_dir = AbsolutePathBuf::new(temp_dir.path().join("bin")).unwrap(); + std::fs::create_dir_all(&bin_dir).unwrap(); + for name in ["vp", "node", "vpr"] { + std::fs::write(bin_dir.join(name), b"shim").unwrap(); + } + std::fs::write(bin_dir.join("git"), b"foreign").unwrap(); + + clean_bin_dir(&bin_dir); + + assert!(bin_dir.as_path().exists(), "shared bin dir must not be removed"); + assert!(bin_dir.join("git").as_path().exists(), "foreign files must stay"); + for name in ["vp", "node", "vpr"] { + assert!(!bin_dir.join(name).as_path().exists(), "{name} should be removed"); + } + } + #[test] fn test_execute_not_installed() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index c853e84881..16d71759bb 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -4,6 +4,7 @@ //! with SHA-512 integrity verification. use std::process::ExitStatus; +use vp_shared::VpDirs; use owo_colors::OwoColorize; use vp_pm_cli::HttpClient; @@ -11,7 +12,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 +35,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 = VpDirs::data_dir(); // Handle --rollback if options.rollback { diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index 5b171b2d1c..3deb4f7aa6 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -236,7 +236,7 @@ mod tests { } // Run serially: the spawned `node` inherits this process's environment, and - // concurrent #[serial] tests mutate PATH/VP_HOME via std::env::set_var, + // concurrent #[serial] tests mutate PATH via std::env::set_var, // which can make a vp shim on PATH resolve incorrectly mid-test. #[test] #[serial] diff --git a/crates/vp_global_cli/src/commands/vpx.rs b/crates/vp_global_cli/src/commands/vpx.rs index c6d6fe8d36..be294f97de 100644 --- a/crates/vp_global_cli/src/commands/vpx.rs +++ b/crates/vp_global_cli/src/commands/vpx.rs @@ -7,10 +7,10 @@ //! 3. System PATH (excluding vite-plus bin directory) //! 4. Remote download via `vp dlx` -use vp_shared::{PrependOptions, exit_code_from_status, output, prepend_to_path_env}; +use vp_shared::{PrependOptions, VpDirs, 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 = VpDirs::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..a7c34e2d7d 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::{VpDirs, PrependOptions, PrependResult, env_vars, format_path_with_prepend}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::{ @@ -108,6 +108,27 @@ 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. + if !VpDirs::is_legacy_layout() { + for (var, dir) in [ + (env_vars::VP_BIN_DIR, VpDirs::bin_dir()), + (env_vars::VP_DATA_DIR, VpDirs::data_dir()), + (env_vars::VP_CACHE_DIR, VpDirs::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 +639,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..1ad9b8cb2a 100644 --- a/crates/vp_global_cli/src/shim/cache.rs +++ b/crates/vp_global_cli/src/shim/cache.rs @@ -7,6 +7,7 @@ use std::{ collections::HashMap, time::{SystemTime, UNIX_EPOCH}, }; +use vp_shared::VpDirs; use serde::{Deserialize, Serialize}; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -39,7 +40,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 @@ -182,10 +184,12 @@ impl ResolveCache { } } +/// File name under [`VpDirs::cache_dir`]. +const RESOLVE_CACHE_FILE: &str = "resolve_cache.json"; + /// 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(VpDirs::cache_dir().join(RESOLVE_CACHE_FILE)) } /// Invalidate the entire resolve cache by deleting the cache file. @@ -344,15 +348,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 +377,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..54679bcecc 100644 --- a/crates/vp_global_cli/src/shim/corepack.rs +++ b/crates/vp_global_cli/src/shim/corepack.rs @@ -17,7 +17,7 @@ //! injected when not explicitly set, and Vite+-owned shims are restored //! afterwards if corepack removed or replaced them. -use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; +use vp_shared::{PrependOptions, VpDirs, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use super::{ @@ -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 = VpDirs::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..51666d4ed1 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -9,7 +9,7 @@ use vp_pm_cli::{ PackageManagerType, download_package_manager, package_manager_bin_path, package_manager_install_dir, resolve_package_manager_from_package_json, }; -use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; +use vp_shared::{PrependOptions, VpDirs, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use super::{ @@ -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 = VpDirs::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 = VpDirs::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 = VpDirs::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 = VpDirs::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 = + VpDirs::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 = + VpDirs::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 = VpDirs::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 = VpDirs::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 = VpDirs::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 VpDirs::bin_dir), + // but VpDirs::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..62bc5c2dda 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -19,9 +19,7 @@ use std::fs; pub(crate) use cache::invalidate_cache; pub use dispatch::dispatch; pub(crate) use dispatch::find_system_tool; -use vp_shared::env_vars; - -use crate::commands::env::config::get_bin_dir; +use vp_shared::{VpDirs, env_vars}; /// Core shim tools (node, npm, npx). /// @@ -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 = VpDirs::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 = VpDirs::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(). + /// (`VpDirs::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 VpDirs::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..16f9d155b8 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -1,10 +1,11 @@ //! Background upgrade check for the vp CLI. //! //! Periodically queries the npm registry for the latest version and caches the -//! result to `~/.vite-plus/.upgrade-check.json`. Displays a one-line notice on +//! result to `/.upgrade-check.json`. Displays a one-line notice on //! stderr when a newer version is available, at most once per 24 hours. use std::time::{SystemTime, UNIX_EPOCH}; +use vp_shared::VpDirs; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; @@ -12,6 +13,7 @@ use vp_setup::registry; const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; const PROMPT_INTERVAL_SECS: u64 = 24 * 60 * 60; +/// File name under [`VpDirs::state_dir`]. const CACHE_FILE_NAME: &str = ".upgrade-check.json"; #[expect(clippy::disallowed_types)] // String required for serde JSON round-trip @@ -22,14 +24,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 +72,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 = VpDirs::state_dir().join(CACHE_FILE_NAME); 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 +90,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 +98,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 +114,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 +133,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 +170,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 +186,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_js_runtime/src/cache.rs b/crates/vp_js_runtime/src/cache.rs deleted file mode 100644 index 9308a83d1e..0000000000 --- a/crates/vp_js_runtime/src/cache.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Cache directory utilities for JavaScript runtimes. - -use vt_path::AbsolutePathBuf; - -use crate::Error; - -/// Get the cache directory for JavaScript runtimes. -/// -/// Returns `$VP_HOME/js_runtime`. -pub fn get_cache_dir() -> 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..612f8cb3ff 100644 --- a/crates/vp_js_runtime/src/providers/node.rs +++ b/crates/vp_js_runtime/src/providers/node.rs @@ -1,6 +1,7 @@ //! Node.js runtime provider implementation. use std::time::{SystemTime, UNIX_EPOCH}; +use vp_shared::VpDirs; use async_trait::async_trait; use node_semver::{Range, Version}; @@ -31,6 +32,9 @@ const DEFAULT_NODE_DIST_URL: &str = "https://unofficial-builds.nodejs.org/downlo /// Default cache TTL in seconds (1 hour) const DEFAULT_CACHE_TTL_SECS: u64 = 3600; +/// Version-index cache file under `/node/`. +const INDEX_CACHE_FILE: &str = "index_cache.json"; + /// A single entry from the Node.js version index #[derive(Deserialize, Serialize, Debug, Clone)] pub struct NodeVersionEntry { @@ -102,7 +106,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 +190,8 @@ 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 = + VpDirs::js_runtime_dir().join("node").join(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..d9b857d036 100644 --- a/crates/vp_js_runtime/src/runtime.rs +++ b/crates/vp_js_runtime/src/runtime.rs @@ -1,4 +1,5 @@ use std::time::Duration; +use vp_shared::VpDirs; use backon::{ExponentialBuilder, Retryable}; use node_semver::{Range, Version}; @@ -183,13 +184,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 = VpDirs::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 +457,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 = VpDirs::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 +1042,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 = VpDirs::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..37b04a51e6 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -7,7 +7,6 @@ use std::{ io::{self, BufReader, Write}, path::{Path, PathBuf}, }; - use crossterm::{ cursor, event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, @@ -19,7 +18,7 @@ use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; use tokio::fs::remove_dir_all; use vp_error::Error; -use vp_shared::OnFail; +use vp_shared::{OnFail, VpDirs}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_str::Str; #[cfg(test)] @@ -376,9 +375,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 = VpDirs::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 +738,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 = VpDirs::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 +842,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 = VpDirs::package_manager_dir(); let bin_name = package_manager_type.to_string(); // For bun, use platform-specific download flow. @@ -852,7 +850,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 +858,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 +977,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 +1155,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 +1728,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 +3784,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/Cargo.toml b/crates/vp_shared/Cargo.toml index 7a566fc0c7..20653e54cd 100644 --- a/crates/vp_shared/Cargo.toml +++ b/crates/vp_shared/Cargo.toml @@ -32,6 +32,8 @@ webpki-root-certs = { workspace = true } [dev-dependencies] serial_test = { workspace = true } +temp-env = { workspace = true } +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/vp_shared/src/dirs.rs b/crates/vp_shared/src/dirs.rs new file mode 100644 index 0000000000..a707533383 --- /dev/null +++ b/crates/vp_shared/src/dirs.rs @@ -0,0 +1,113 @@ +//! On-disk path helpers for vite-plus. +//! +//! [`VpDirs`] owns only: +//! - **category roots** (`bin`, `data`, `cache`, and temporarily collocated +//! `config` / `state`) from the strategy chain in [`resolution`]; +//! - **first-level directories** under those roots (`current`, `js_runtime`, +//! `package_manager`, `packages`, `bins`). +//! +//! Files and deeper trees (e.g. `config.json`, `js_runtime/node/`, +//! `resolve_cache.json`) are joined by the owning feature — not here. +//! +//! Resolution is recomputed on every call — cheap path joins plus at most a +//! few existence checks — so process env changes (and test `temp_env` +//! overrides) are observed without a separate cache. + +mod resolution; + +use vt_path::AbsolutePathBuf; + +/// Platform-specific binary name for the `vp` CLI. +pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; + +/// Directory name of the legacy monolithic install root (`~/.vite-plus`). +const LEGACY_HOME_DIR_NAME: &str = ".vite-plus"; + +/// Namespace for category roots and their first-level subdirectories. +pub struct VpDirs; + +impl VpDirs { + // ── Category roots ──────────────────────────────────────────────────── + + /// Directory for executables and shims. + #[must_use] + pub fn bin_dir() -> AbsolutePathBuf { + resolution::bin_dir().expect("bin directory could not be resolved") + } + + /// Directory for payload data (CLI versions, runtimes, package managers). + #[must_use] + pub fn data_dir() -> AbsolutePathBuf { + resolution::data_dir().expect("data directory could not be resolved") + } + + /// Directory for disposable caches. + #[must_use] + pub fn cache_dir() -> AbsolutePathBuf { + resolution::cache_dir().expect("cache directory could not be resolved") + } + + /// Directory for user configuration (env scripts, `config.json`, …). + /// + /// Collocated with [`Self::data_dir`] for now: config has no separate + /// resolution source yet. + #[must_use] + pub fn config_dir() -> AbsolutePathBuf { + Self::data_dir() + } + + /// Directory for state files (session version, upgrade-check cache, …). + /// + /// Collocated with [`Self::data_dir`] for the same reason as + /// [`Self::config_dir`]. + #[must_use] + pub fn state_dir() -> AbsolutePathBuf { + Self::data_dir() + } + + // ── First-level under `data_dir` ────────────────────────────────────── + + /// `current` symlink pointing at the active CLI version. + #[must_use] + pub fn current_dir() -> AbsolutePathBuf { + Self::data_dir().join("current") + } + + /// Managed JavaScript runtimes. + #[must_use] + pub fn js_runtime_dir() -> AbsolutePathBuf { + Self::data_dir().join("js_runtime") + } + + /// Managed package managers. + #[must_use] + pub fn package_manager_dir() -> AbsolutePathBuf { + Self::data_dir().join("package_manager") + } + + /// Globally installed packages. + #[must_use] + pub fn packages_dir() -> AbsolutePathBuf { + Self::data_dir().join("packages") + } + + /// Per-binary metadata for globally installed packages. + #[must_use] + pub fn bins_dir() -> AbsolutePathBuf { + Self::data_dir().join("bins") + } + + // ── Layout query ────────────────────────────────────────────────────── + + /// Whether the resolved layout is the legacy monolithic root. + /// + /// True when all three category roots coincide on a path named + /// `.vite-plus` (Home / CurrentDir detection with `Exist` strategy). + #[must_use] + pub fn is_legacy_layout() -> bool { + let data = Self::data_dir(); + data.as_path() == Self::bin_dir().as_path() + && data.as_path() == Self::cache_dir().as_path() + && data.as_path().file_name().is_some_and(|name| name == LEGACY_HOME_DIR_NAME) + } +} diff --git a/crates/vp_shared/src/dirs/resolution.rs b/crates/vp_shared/src/dirs/resolution.rs new file mode 100644 index 0000000000..54bdda754a --- /dev/null +++ b/crates/vp_shared/src/dirs/resolution.rs @@ -0,0 +1,751 @@ +//! Strategy-gated directory resolution. +//! +//! Each directory category (`bin`, `data`, `cache`) is resolved by walking an +//! ordered chain of *resolution sources*. A source either proposes a +//! candidate (`Some`) or abstains (`None`). When it proposes, its +//! [`FallthroughStrategy`] decides whether that candidate wins: +//! +//! - [`FallthroughStrategy::Exist`] — accept only if the path exists on disk +//! (legacy detection: grandfather an install that is already there). +//! - [`FallthroughStrategy::Set`] — accept as soon as the source proposes +//! (explicit env overrides: a set value pins the category even before the +//! directory has been created). +//! +//! Platform defaults sit at the end of the same chain ([`unix::Unix`] / +//! [`windows::Windows`], [`Set`]), so a fresh machine with no overrides +//! lands in the platform-preferred location without a separate fallback path. +//! +//! Source chain on Unix: +//! [`Home`] → [`CurrentDir`] → [`VpEnvs`] → [`unix::Xdg`] → [`unix::Unix`] +//! (Windows: [`Home`] → [`CurrentDir`] → [`VpEnvs`] → [`windows::Windows`]; +//! no XDG): +//! +//! - [`Home`] — `~/.vite-plus`: an existing legacy monolithic install pins +//! all categories to that root (grandfathering). [`Exist`]. +//! - [`CurrentDir`] — `./.vite-plus`: a legacy-shaped root inside the process +//! working directory. [`Exist`]. +//! - [`VpEnvs`] — explicit per-category `VP_BIN_DIR` / `VP_DATA_DIR` / +//! `VP_CACHE_DIR` overrides. [`Set`]. +//! - [`unix::Xdg`] — `XDG_*_HOME` per the freedesktop Base Directory spec. +//! [`Set`]. +//! - [`unix::Unix`] / [`windows::Windows`] — platform defaults. [`Set`]. +//! +//! Relative environment values are dropped (the XDG spec declares relative +//! values invalid); `AbsolutePathBuf::new` performs that validation. +//! +//! This module is not yet wired into `Dirs` (see the parent module); it +//! evaluates the strategy-gated model against the explicit resolution state +//! machine there. + +use std::path::PathBuf; + +use directories::BaseDirs; +use vt_path::AbsolutePathBuf; + +use crate::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`). +const LEGACY_HOME_DIR_NAME: &str = ".vite-plus"; + +/// When a source proposes a candidate, how the chain decides to stop or continue. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FallthroughStrategy { + /// Accept the candidate only when it exists on disk; otherwise try the next source. + /// + /// Used by legacy detection ([`Home`], [`CurrentDir`]): an install is + /// grandfathered only if it is already present. + Exist, + /// Accept the candidate as soon as it is proposed (`Some`); do not fall through. + /// + /// Used by explicit env overrides ([`VpEnvs`], [`unix::Xdg`]) and platform + /// defaults ([`unix::Unix`], [`windows::Windows`]): a proposed value pins + /// the category even when the directory does not exist yet (first install + /// / intentional relocation). + Set, +} + +/// One layer in a resolution chain. +/// +/// Returning `None` means "no opinion": resolution continues with the next +/// source. Returning `Some` is gated by [`Self::FALLTHROUGH`] (see +/// [`resolutions!`]). +trait DirResolution { + const FALLTHROUGH: FallthroughStrategy; + + fn bin_dir(&self) -> Option; + fn data_dir(&self) -> Option; + fn cache_dir(&self) -> Option; +} + +/// Reads an absolute path from an environment variable. +/// +/// Returns `None` when the variable is unset or holds a relative path. +fn env_var(name: &str) -> Option { + std::env::var_os(name).and_then(|path| AbsolutePathBuf::new(PathBuf::from(path))) +} + +/// Explicit per-category overrides from the `VP_*_DIR` environment variables. +/// +/// Values are snapshotted at construction so the three category lookups +/// observe a consistent environment — and so tests can construct the struct +/// directly instead of mutating process env. +struct VpEnvs { + bin_dir: Option, + data_dir: Option, + cache_dir: Option, +} + +impl VpEnvs { + fn resolver() -> Self { + Self { + bin_dir: env_var(env_vars::VP_BIN_DIR), + data_dir: env_var(env_vars::VP_DATA_DIR), + cache_dir: env_var(env_vars::VP_CACHE_DIR), + } + } +} + +impl DirResolution for VpEnvs { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + self.bin_dir.clone() + } + + fn data_dir(&self) -> Option { + self.data_dir.clone() + } + + fn cache_dir(&self) -> Option { + self.cache_dir.clone() + } +} + +/// A source that pins every category to a single directory. +/// +/// Used by legacy detection ([`Home`], [`CurrentDir`]) with +/// [`FallthroughStrategy::Exist`]. +struct SinglePlace(Option); + +impl SinglePlace { + fn resolver(path: Option) -> Self { + Self(path.and_then(AbsolutePathBuf::new)) + } +} + +impl DirResolution for SinglePlace { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Exist; + + fn bin_dir(&self) -> Option { + self.0.clone() + } + + fn data_dir(&self) -> Option { + self.0.clone() + } + + fn cache_dir(&self) -> Option { + self.0.clone() + } +} + +/// The legacy monolithic root, `~/.vite-plus`. +struct Home; + +/// A legacy-shaped root (`./.vite-plus`) inside the process working +/// directory. +struct CurrentDir; + +// Marker factories for `SinglePlace` — constructors intentionally do not +// return `Self`. +impl Home { + fn resolver() -> SinglePlace { + SinglePlace::resolver( + BaseDirs::new().map(|dirs| dirs.home_dir().join(LEGACY_HOME_DIR_NAME)), + ) + } +} + +impl CurrentDir { + fn resolver() -> SinglePlace { + SinglePlace(vt_path::current_dir().ok().map(|dir| dir.join(LEGACY_HOME_DIR_NAME))) + } +} + +/// Whether `source`'s [`FallthroughStrategy`] accepts `dir` as a final answer. +fn accepts(source: &R, dir: &AbsolutePathBuf) -> bool { + let _ = source; + match R::FALLTHROUGH { + FallthroughStrategy::Set => true, + FallthroughStrategy::Exist => dir.as_path().exists(), + } +} + +/// Generates `pub fn _dir()` walking the given source types in +/// order: the first source that proposes a candidate accepted by its +/// [`FallthroughStrategy`] wins; otherwise `None`. +/// +/// `$resolution` is a factory type with `resolver() -> impl DirResolution`. +/// The strategy is read from the *returned* resolver (so marker factories +/// like [`Home`] / [`CurrentDir`] that build a [`SinglePlace`] inherit +/// `Exist` from it). Platform defaults belong at the end of the list as a +/// normal `Set` source, not a special case. +macro_rules! resolutions { + ($method: ident, [$($resolution: ty),*]) => { + pub fn $method() -> Option { + $({ + let source = <$resolution>::resolver(); + if let Some(dir) = source.$method() + && accepts(&source, &dir) + { + return Some(dir); + } + })* + None + } + }; +} + +/// Instantiates [`resolutions!`] for every category method over the same +/// source chain. +macro_rules! dir_methods { + ([$($method: ident),*], $resolutions:tt) => { + $( + resolutions!($method, $resolutions); + )* + }; +} + +/// Unix-only sources: XDG env vars and XDG-style platform defaults. +#[cfg(not(target_os = "windows"))] +mod unix { + use directories::BaseDirs; + use vt_path::AbsolutePathBuf; + + use super::{APP_DIR_NAME, DirResolution, FallthroughStrategy, env_var}; + use crate::env_vars; + + /// XDG base directories (`XDG_*_HOME`), with the app directory appended to + /// the data and cache homes. + pub(super) struct Xdg; + + impl Xdg { + pub(super) fn resolver() -> Self { + Self + } + } + + impl DirResolution for Xdg { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + env_var(env_vars::XDG_BIN_HOME) + .or_else(|| env_var(env_vars::XDG_DATA_HOME).map(|dir| dir.join("../bin"))) + } + + fn data_dir(&self) -> Option { + env_var(env_vars::XDG_DATA_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + + fn cache_dir(&self) -> Option { + env_var(env_vars::XDG_CACHE_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + } + + /// Platform default: XDG-style paths under the real home directory + /// (`~/.local/bin`, `~/.local/share/vite-plus`, `~/.cache/vite-plus`). + /// + /// Abstains only when no home directory is known. + pub(super) struct Unix(Option); + + impl Unix { + pub(super) fn resolver() -> Self { + Self( + BaseDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .and_then(AbsolutePathBuf::new), + ) + } + } + + impl DirResolution for Unix { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(".local/bin")) + } + + fn data_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(format!(".local/share/{APP_DIR_NAME}"))) + } + + fn cache_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(format!(".cache/{APP_DIR_NAME}"))) + } + } +} + +/// Windows-only source: `%LOCALAPPDATA%\vite-plus` for every category. +#[cfg(target_os = "windows")] +mod windows { + use directories::BaseDirs; + use vt_path::AbsolutePathBuf; + + use super::{APP_DIR_NAME, DirResolution, FallthroughStrategy}; + + /// Platform default: `%LOCALAPPDATA%\vite-plus` for every category. + /// + /// Abstains only when no local-app-data directory is known. + pub(super) struct Windows(Option); + + impl Windows { + pub(super) fn resolver() -> Self { + Self( + BaseDirs::new() + .map(|dirs| dirs.data_local_dir().join(APP_DIR_NAME)) + .and_then(AbsolutePathBuf::new), + ) + } + } + + impl DirResolution for Windows { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + self.0.clone() + } + + fn data_dir(&self) -> Option { + self.0.clone() + } + + fn cache_dir(&self) -> Option { + self.0.clone() + } + } +} + +// Platform-specific tail of the chain. Windows has no XDG layer. +cfg_select! { + target_os = "windows" => { + dir_methods!([bin_dir, data_dir, cache_dir], [Home, CurrentDir, VpEnvs, windows::Windows]); + } + _ => { + dir_methods!( + [bin_dir, data_dir, cache_dir], + [Home, CurrentDir, VpEnvs, unix::Xdg, unix::Unix] + ); + } +} + +#[cfg(test)] +mod tests { + use std::{ + ffi::OsStr, + path::{Path, PathBuf}, + }; + + use super::*; + use crate::env_vars; + + fn assert_dir(got: Option, expected: &Path) { + let got = got.expect("resolution should yield a path"); + assert_eq!( + got.as_path(), + expected, + "resolved {} != expected {}", + got.as_path().display(), + expected.display() + ); + } + + // --- Env-only (temp_env mutex is enough; no process cwd mutation) ------ + + #[test] + fn vp_envs_reads_absolute_category_paths() { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("bin"); + let data = root.path().join("data"); + let cache = root.path().join("cache"); + + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(bin.as_os_str())), + (env_vars::VP_DATA_DIR, Some(data.as_os_str())), + (env_vars::VP_CACHE_DIR, Some(cache.as_os_str())), + ], + || { + let envs = VpEnvs::resolver(); + assert_dir(envs.bin_dir(), &bin); + assert_dir(envs.data_dir(), &data); + assert_dir(envs.cache_dir(), &cache); + }, + ); + } + + #[test] + fn vp_envs_drops_relative_and_unset() { + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(OsStr::new("relative/bin"))), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, Some(OsStr::new("relative/cache"))), + ], + || { + let envs = VpEnvs::resolver(); + assert!(envs.bin_dir().is_none()); + assert!(envs.data_dir().is_none()); + assert!(envs.cache_dir().is_none()); + }, + ); + } + + #[test] + fn home_pins_all_categories_to_legacy_root() { + let home = tempfile::tempdir().unwrap(); + let expected = home.path().join(LEGACY_HOME_DIR_NAME); + + temp_env::with_var("HOME", Some(home.path().as_os_str()), || { + let place = Home::resolver(); + assert_dir(place.bin_dir(), &expected); + assert_dir(place.data_dir(), &expected); + assert_dir(place.cache_dir(), &expected); + }); + } + + #[test] + fn single_place_returns_same_path_for_every_category() { + let root = tempfile::tempdir().unwrap(); + let place = SinglePlace::resolver(Some(root.path().to_path_buf())); + assert_dir(place.bin_dir(), root.path()); + assert_dir(place.data_dir(), root.path()); + assert_dir(place.cache_dir(), root.path()); + assert!(SinglePlace::resolver(Some(PathBuf::from("relative"))).bin_dir().is_none()); + assert!(SinglePlace::resolver(None).data_dir().is_none()); + } + + #[test] + fn fallthrough_strategies_match_source_roles() { + assert_eq!(SinglePlace::FALLTHROUGH, FallthroughStrategy::Exist); + assert_eq!(VpEnvs::FALLTHROUGH, FallthroughStrategy::Set); + } + + /// Cases that call `set_current_dir`. Process cwd is shared and not covered + /// by temp_env's mutex, so these run under `#[serial(resolution_cwd)]`. + mod change_cwd { + use std::fs; + + use serial_test::serial; + use vt_path::AbsolutePathBuf; + + use super::{assert_dir, *}; + + /// Restores the process working directory when dropped. + struct RestoreCwd(AbsolutePathBuf); + + impl Drop for RestoreCwd { + fn drop(&mut self) { + let _ = std::env::set_current_dir(self.0.as_path()); + } + } + + /// Isolate HOME + cwd in fresh tempdirs, clear VP_*/XDG_* overrides, then run `f`. + /// + /// `cwd` is the path returned by [`vt_path::current_dir`] after the chdir + /// (on macOS tempfile's `/var/...` resolves to `/private/var/...`). + pub(super) fn with_isolated_resolution(f: impl FnOnce(&Path, &Path)) { + let home = tempfile::tempdir().unwrap(); + let cwd = tempfile::tempdir().unwrap(); + let _restore_cwd = RestoreCwd(vt_path::current_dir().unwrap()); + std::env::set_current_dir(cwd.path()).unwrap(); + let cwd_abs = vt_path::current_dir().unwrap(); + + temp_env::with_vars( + [ + ("HOME", Some(home.path().as_os_str())), + (env_vars::VP_BIN_DIR, None), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, None), + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, None), + (env_vars::XDG_CACHE_HOME, None), + ], + || f(home.path(), cwd_abs.as_path()), + ); + } + + #[test] + #[serial(resolution_cwd)] + fn current_dir_pins_all_categories_to_cwd_legacy() { + let cwd = tempfile::tempdir().unwrap(); + let _restore = RestoreCwd(vt_path::current_dir().unwrap()); + std::env::set_current_dir(cwd.path()).unwrap(); + // Match the form `vt_path::current_dir` returns (macOS `/var` → `/private/var`). + let expected = vt_path::current_dir().unwrap().join(LEGACY_HOME_DIR_NAME); + + let place = CurrentDir::resolver(); + assert_dir(place.bin_dir(), expected.as_path()); + assert_dir(place.data_dir(), expected.as_path()); + assert_dir(place.cache_dir(), expected.as_path()); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_prefers_existing_home_legacy() { + with_isolated_resolution(|home, _cwd| { + let legacy = home.join(LEGACY_HOME_DIR_NAME); + fs::create_dir_all(&legacy).unwrap(); + + // Even with a competing VP override, Home (Exist, earlier) wins. + let other = home.join("other-bin"); + fs::create_dir_all(&other).unwrap(); + temp_env::with_var(env_vars::VP_BIN_DIR, Some(other.as_os_str()), || { + assert_dir(bin_dir(), &legacy); + assert_dir(data_dir(), &legacy); + assert_dir(cache_dir(), &legacy); + }); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_prefers_existing_cwd_legacy_when_home_missing() { + with_isolated_resolution(|_home, cwd| { + let legacy = cwd.join(LEGACY_HOME_DIR_NAME); + fs::create_dir_all(&legacy).unwrap(); + + assert_dir(bin_dir(), &legacy); + assert_dir(data_dir(), &legacy); + assert_dir(cache_dir(), &legacy); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_vp_env_set_wins_even_when_path_missing() { + // `Set` strategy: a configured VP_*_DIR pins the category before create. + // chdir isolates CurrentDir so a host `./.vite-plus` cannot steal the chain. + with_isolated_resolution(|home, _cwd| { + let bin = home.join("vp-bin"); + let data = home.join("vp-data"); + let cache = home.join("vp-cache"); + assert!(!bin.exists() && !data.exists() && !cache.exists()); + + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(bin.as_os_str())), + (env_vars::VP_DATA_DIR, Some(data.as_os_str())), + (env_vars::VP_CACHE_DIR, Some(cache.as_os_str())), + ], + || { + assert_dir(bin_dir(), &bin); + assert_dir(data_dir(), &data); + assert_dir(cache_dir(), &cache); + }, + ); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_vp_env_set_blocks_later_sources() { + // A set-but-missing VP path must not fall through to XDG / platform. + with_isolated_resolution(|home, _cwd| { + let missing = home.join("does-not-exist"); + temp_env::with_var(env_vars::VP_DATA_DIR, Some(missing.as_os_str()), || { + assert_dir(data_dir(), &missing); + }); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_skips_home_when_legacy_dir_missing() { + with_isolated_resolution(|home, cwd| { + // HOME points at `home` but `.vite-plus` is absent; create cwd legacy + // so the chain stops at CurrentDir rather than later sources. + let legacy = cwd.join(LEGACY_HOME_DIR_NAME); + fs::create_dir_all(&legacy).unwrap(); + assert!(!home.join(LEGACY_HOME_DIR_NAME).exists()); + assert_dir(bin_dir(), &legacy); + }); + } + } + + /// Unix/XDG resolution coverage — whole submodule is `cfg`'d once. + #[cfg(not(target_os = "windows"))] + mod unix { + use super::*; + use crate::dirs::resolution::unix::{Unix, Xdg}; + + #[test] + fn xdg_appends_app_name_and_uses_bin_home_verbatim() { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("bin-home"); + let data = root.path().join("data-home"); + let cache = root.path().join("cache-home"); + + temp_env::with_vars( + [ + (env_vars::XDG_BIN_HOME, Some(bin.as_os_str())), + (env_vars::XDG_DATA_HOME, Some(data.as_os_str())), + (env_vars::XDG_CACHE_HOME, Some(cache.as_os_str())), + ], + || { + let xdg = Xdg::resolver(); + assert_dir(xdg.bin_dir(), &bin); + assert_dir(xdg.data_dir(), &data.join(APP_DIR_NAME)); + assert_dir(xdg.cache_dir(), &cache.join(APP_DIR_NAME)); + }, + ); + } + + #[test] + fn xdg_bin_falls_back_to_data_home_parent() { + let root = tempfile::tempdir().unwrap(); + let data = root.path().join("share"); + + temp_env::with_vars( + [(env_vars::XDG_BIN_HOME, None), (env_vars::XDG_DATA_HOME, Some(data.as_os_str()))], + || { + let xdg = Xdg::resolver(); + // Lexical `$XDG_DATA_HOME/../bin` (not cleaned); compare cleaned form. + let got = xdg.bin_dir().expect("bin candidate from XDG_DATA_HOME"); + assert_eq!(got.clean().as_path(), root.path().join("bin").as_path()); + assert_eq!(got.as_path(), data.join("../bin").as_path()); + }, + ); + } + + #[test] + fn xdg_drops_relative_values() { + temp_env::with_vars( + [ + (env_vars::XDG_BIN_HOME, Some(OsStr::new("relative/bin"))), + (env_vars::XDG_DATA_HOME, Some(OsStr::new("relative/data"))), + (env_vars::XDG_CACHE_HOME, Some(OsStr::new("relative/cache"))), + ], + || { + let xdg = Xdg::resolver(); + assert!(xdg.bin_dir().is_none()); + assert!(xdg.data_dir().is_none()); + assert!(xdg.cache_dir().is_none()); + }, + ); + } + + #[test] + fn platform_default_proposes_xdg_style_paths_under_home() { + let home = tempfile::tempdir().unwrap(); + + temp_env::with_var("HOME", Some(home.path().as_os_str()), || { + let unix = Unix::resolver(); + assert_dir(unix.bin_dir(), &home.path().join(".local/bin")); + assert_dir( + unix.data_dir(), + &home.path().join(format!(".local/share/{APP_DIR_NAME}")), + ); + assert_dir(unix.cache_dir(), &home.path().join(format!(".cache/{APP_DIR_NAME}"))); + }); + } + + #[test] + fn fallthrough_strategies() { + assert_eq!(Xdg::FALLTHROUGH, FallthroughStrategy::Set); + assert_eq!(Unix::FALLTHROUGH, FallthroughStrategy::Set); + } + + /// dir_methods cases that chdir so CurrentDir cannot observe host state. + mod change_cwd { + use super::super::change_cwd::with_isolated_resolution; + use super::*; + use serial_test::serial; + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_xdg_set_wins_even_when_path_missing() { + with_isolated_resolution(|home, _cwd| { + let xdg_bin = home.join("xdg-bin"); + let xdg_data = home.join("xdg-data"); + let xdg_cache = home.join("xdg-cache"); + temp_env::with_vars( + [ + (env_vars::XDG_BIN_HOME, Some(xdg_bin.as_os_str())), + (env_vars::XDG_DATA_HOME, Some(xdg_data.as_os_str())), + (env_vars::XDG_CACHE_HOME, Some(xdg_cache.as_os_str())), + ], + || { + assert_dir(bin_dir(), &xdg_bin); + assert_dir(data_dir(), &xdg_data.join(APP_DIR_NAME)); + assert_dir(cache_dir(), &xdg_cache.join(APP_DIR_NAME)); + }, + ); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_xdg_bin_via_data_home_set_without_existence() { + with_isolated_resolution(|home, _cwd| { + let xdg_data = home.join("share"); + temp_env::with_vars( + [ + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, Some(xdg_data.as_os_str())), + ], + || { + let got = bin_dir().expect("bin from XDG_DATA_HOME/../bin"); + assert_eq!(got.clean().as_path(), home.join("bin").as_path()); + }, + ); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_falls_back_to_platform_when_no_source_proposes() { + with_isolated_resolution(|home, _cwd| { + assert_dir(bin_dir(), &home.join(".local/bin")); + assert_dir(data_dir(), &home.join(format!(".local/share/{APP_DIR_NAME}"))); + assert_dir(cache_dir(), &home.join(format!(".cache/{APP_DIR_NAME}"))); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_resolves_categories_independently() { + with_isolated_resolution(|home, _cwd| { + let vp_bin = home.join("only-bin"); + let xdg_data = home.join("xdg-data"); + + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(vp_bin.as_os_str())), + (env_vars::XDG_DATA_HOME, Some(xdg_data.as_os_str())), + ], + || { + assert_dir(bin_dir(), &vp_bin); + assert_dir(data_dir(), &xdg_data.join(APP_DIR_NAME)); + assert_dir(cache_dir(), &home.join(format!(".cache/{APP_DIR_NAME}"))); + }, + ); + }); + } + } + } + + /// Windows platform-default coverage — whole submodule is `cfg`'d once. + #[cfg(target_os = "windows")] + mod windows { + use super::*; + use crate::dirs::resolution::windows::Windows; + + #[test] + fn fallthrough_strategy() { + assert_eq!(Windows::FALLTHROUGH, FallthroughStrategy::Set); + } + } +} diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index 6ff07cc398..da93c58736 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" //! ); //! }, @@ -51,11 +51,38 @@ thread_local! { /// time. Use `EnvConfig::get()` to access the current config from anywhere. #[derive(Debug, Clone)] pub struct EnvConfig { - /// Override for the vite-plus home directory (`~/.vite-plus`). + /// Deprecated override for the vite-plus home directory (`~/.vite-plus`). /// - /// Env: `VP_HOME` + /// Still honored as the highest-priority layout rule (legacy monolithic + /// layout) for backward compatibility; no longer set by installers or + /// generated env scripts. + /// + /// Env: `VP_HOME` (deprecated) 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` @@ -106,7 +133,10 @@ impl EnvConfig { /// Called once in `main()` via `EnvConfig::init()`. pub fn from_env() -> Self { Self { - vite_plus_home: std::env::var(env_vars::VP_HOME).ok().map(PathBuf::from), + vite_plus_home: std::env::var(env_vars::DEPRECATED_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 +193,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 +224,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 +238,13 @@ impl EnvConfig { } } - /// Create a test configuration with a custom home directory. + /// Create a test configuration with a custom user home directory. + /// + /// Note: [`crate::VpDirs`] resolves from process env / disk (see + /// `dirs::resolution`), not from this field. Pair with `temp_env` + a + /// real temp home when a test must also control path resolution. 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() } } /// Set a test config override and return a guard that restores the previous on drop. @@ -239,7 +276,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 +285,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 +297,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 +312,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..6b32e2ee03 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -9,11 +9,48 @@ //! //! Standard system variables (`PATH`, `HOME`, `CI`, etc.) are intentionally //! excluded — they're well-known and benefit less from constant definitions. +//! The `XDG_*_HOME` base-directory variables are the exception: they +//! participate in `VpDirs` path resolution, so they get constants too. // ── Config: read once at startup via EnvConfig ────────────────────────── -/// Override for the vite-plus home directory (default: `~/.vite-plus`). -pub const VP_HOME: &str = "VP_HOME"; +/// Deprecated override for the vite-plus home directory (`~/.vite-plus`). +/// +/// Still honored as the highest-priority layout rule (selects the legacy +/// monolithic layout) for backward compatibility — older env scripts and +/// custom-location installs export it — but no longer set by the installers +/// or the generated env scripts. Prefer `VP_*_DIR` / `XDG_*` variables. +pub const DEPRECATED_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 VpDirs resolution ──────────────────── + +/// XDG base directory for executables. +pub const XDG_BIN_HOME: &str = "XDG_BIN_HOME"; + +/// XDG base directory for user configuration. +pub const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME"; + +/// XDG base directory for user data. +pub const XDG_DATA_HOME: &str = "XDG_DATA_HOME"; + +/// XDG base directory for user state. +pub const XDG_STATE_HOME: &str = "XDG_STATE_HOME"; + +/// XDG base directory for disposable caches. +pub const XDG_CACHE_HOME: &str = "XDG_CACHE_HOME"; /// Log filter string for `tracing_subscriber` (e.g. `"debug"`, `"vt=trace"`). pub const VP_LOG: &str = "VP_LOG"; 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..107f58381d 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::{VP_BINARY_NAME, VpDirs}; 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..8c0eed0807 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`. +Fresh installs store the managed runtime and related files in 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. Installs that already have `~/.vite-plus` keep the legacy monolithic layout (grandfathered; nothing is moved). The `VP_HOME` variable is deprecated but still honored as the highest-priority layout rule, so older env scripts and custom-location installs that export it keep working; the installers also accept it as an override selecting the legacy layout. 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; 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 @@ -51,6 +51,9 @@ This switches to system-first mode, where the shims prefer your system Node.js a PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: ```powershell +# Split layout (fresh installs) +. "$env:APPDATA\vite-plus\env.ps1" +# Legacy layout (existing ~/.vite-plus installs) . "$env:USERPROFILE\.vite-plus\env.ps1" ``` @@ -76,9 +79,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 +147,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..edda71cb96 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+ directories for the resolved layout — the legacy monolithic root (`~/.vite-plus`, or a custom root chosen at install time), or the split-layout data, config, state, and cache directories plus the vp-owned shims in the bin directory — and cleans the Vite+ lines from your shell profiles. + ::: info If you decide Vite+ is not for you, please [share your feedback with us](https://discord.gg/cAnsqHh5PX). ::: 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..b604c17256 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` (deprecated, still honored), 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). From ae898c4298cba8344b1daed387b97ff350af855b Mon Sep 17 00:00:00 2001 From: yii Date: Fri, 7 Aug 2026 01:24:33 +0800 Subject: [PATCH 2/5] feat: default installers to the split XDG layout Fresh installs land versions under the data dir and shims under the bin dir (XDG/platform defaults via VpDirs). install.sh, install.ps1, the Windows installer, Dockerfile, and trampoline switch accordingly; an existing ~/.vite-plus or explicit VP_HOME/--install-dir keeps the legacy monolithic root. Ship frozen legacy_install.sh/legacy_install.ps1 for CI combinations that still install a pre-split CLI, and document the installer env surface with VP_HOME as a deprecated override only. --- crates/vp_installer/src/cli.rs | 7 +- crates/vp_installer/src/main.rs | 124 ++- crates/vp_trampoline/src/main.rs | 83 +- docker/Dockerfile | 13 +- docs/guide/install.md | 2 +- docs/guide/installer-env-vars.md | 59 +- packages/cli/install.ps1 | 87 +- packages/cli/install.sh | 122 ++- packages/cli/legacy_install.ps1 | 1077 +++++++++++++++++++++++++ packages/cli/legacy_install.sh | 1280 ++++++++++++++++++++++++++++++ 10 files changed, 2750 insertions(+), 104 deletions(-) create mode 100644 packages/cli/legacy_install.ps1 create mode 100644 packages/cli/legacy_install.sh diff --git a/crates/vp_installer/src/cli.rs b/crates/vp_installer/src/cli.rs index 61f7e343f7..c84cd38d28 100644 --- a/crates/vp_installer/src/cli.rs +++ b/crates/vp_installer/src/cli.rs @@ -22,7 +22,9 @@ pub struct Options { #[arg(long = "tag", default_value = "latest")] pub tag: String, - /// Custom installation directory (default: ~/.vite-plus) + /// Custom installation directory: selects the legacy monolithic layout + /// rooted at this directory (default: split platform layout, or the + /// legacy root when `~/.vite-plus` already exists) #[arg(long = "install-dir")] pub install_dir: Option, @@ -49,6 +51,9 @@ pub fn parse() -> Options { opts.version = std::env::var("VP_VERSION").ok(); } if opts.install_dir.is_none() { + // The installers still honor `VP_HOME` as the install dir + // (install.sh/install.ps1 pass it through), selecting the legacy + // monolithic layout; the vp CLI itself never reads it. opts.install_dir = std::env::var("VP_HOME").ok(); } if opts.registry.is_none() { diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 28ce48bc35..848814cd42 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -28,6 +28,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; use vp_pm_cli::HttpClient; use vp_setup::{VP_BINARY_NAME, install, integrity, platform, registry}; +use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; /// Restrict DLL search to system32 only to prevent DLL hijacking @@ -105,48 +106,52 @@ fn main() { let opts = cli::parse(); - // Resolve install dir and set VP_HOME before starting the tokio runtime, - // so the unsafe set_var runs while we're still single-threaded. - let install_dir = match resolve_install_dir(&opts) { - Ok(dir) => dir, + // Resolve the install layout before starting the tokio runtime. + // + // Legacy-layout installs also export `VP_HOME` (deprecated but still + // honored as the highest-priority layout rule) so any vp child process + // spawned during the install pins the same root. Split-layout installs + // rely on the binary's self-location instead. + let layout = match resolve_layout(&opts) { + Ok(layout) => layout, Err(e) => { print_error(&format!("Failed to resolve install directory: {e}")); std::process::exit(1); } }; - // Safety: called in main() before any threads are spawned. - unsafe { std::env::set_var("VP_HOME", install_dir.as_path()) }; + if layout.is_legacy { + // Safety: called in main() before any threads are spawned. + unsafe { std::env::set_var("VP_HOME", layout.install_dir.as_path()) }; + } let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap_or_else(|e| { print_error(&format!("Failed to create async runtime: {e}")); std::process::exit(1); }); - let code = rt.block_on(run(opts, install_dir)); + let code = rt.block_on(run(opts, layout)); std::process::exit(code); } #[allow(clippy::print_stdout, clippy::print_stderr)] -async fn run(mut opts: cli::Options, install_dir: AbsolutePathBuf) -> i32 { - let install_dir_display = install_dir.as_path().to_string_lossy().to_string(); - +async fn run(mut opts: cli::Options, layout: InstallLayout) -> i32 { // Pre-compute Node.js manager default before showing the menu, // so the user sees the resolved value and can override it. if !opts.no_node_manager { - opts.no_node_manager = !auto_detect_node_manager(&install_dir, !opts.yes); + opts.no_node_manager = !auto_detect_node_manager(&layout.bin_dir, !opts.yes); } if !opts.yes { - let proceed = show_interactive_menu(&mut opts, &install_dir_display); + let proceed = show_interactive_menu(&mut opts, &layout); if !proceed { println!("Installation cancelled."); return 0; } } - let code = match do_install(&opts, &install_dir).await { + let code = match do_install(&opts, &layout).await { Ok(()) => { - print_success(&opts, &install_dir_display); + print_success(&opts, &layout); 0 } Err(e) => { @@ -167,8 +172,9 @@ async fn run(mut opts: cli::Options, install_dir: AbsolutePathBuf) -> i32 { #[allow(clippy::print_stdout)] async fn do_install( opts: &cli::Options, - install_dir: &AbsolutePathBuf, + layout: &InstallLayout, ) -> Result<(), Box> { + let install_dir = &layout.install_dir; let platform_suffix = platform::detect_platform_suffix()?; if !opts.quiet { print_info(&format!("detected platform: {platform_suffix}")); @@ -257,7 +263,7 @@ async fn do_install( if !opts.quiet { print_info("setting up shims..."); } - if let Err(e) = setup_bin_shims(install_dir).await { + if let Err(e) = setup_bin_shims(layout).await { print_warn(&format!("Shim setup failed (non-fatal): {e}")); } @@ -273,7 +279,7 @@ async fn do_install( } if !opts.no_modify_path { - let bin_dir_str = install_dir.join("bin").as_path().to_string_lossy().to_string(); + let bin_dir_str = layout.bin_dir.as_path().to_string_lossy().to_string(); if let Err(e) = modify_path(&bin_dir_str, opts.quiet) { print_warn(&format!("PATH modification failed (non-fatal): {e}")); } @@ -289,13 +295,13 @@ async fn do_install( /// /// Matches install.ps1/install.sh auto-detect logic: /// 1. VP_NODE_MANAGER=yes → enable; VP_NODE_MANAGER=no → disable -/// 2. Already managing Node (bin/node.exe exists) → enable (refresh) +/// 2. Already managing Node (`node` shim exists in the bin dir) → enable (refresh) /// 3. CI / Codespaces / DevContainer / DevPod → enable /// 4. No system `node` found → enable /// 5. System node present, interactive → enable (matching install.ps1's default-Y prompt; /// user can disable via customize menu before proceeding) /// 6. System node present, silent → disable (don't silently take over) -fn auto_detect_node_manager(install_dir: &vt_path::AbsolutePath, interactive: bool) -> bool { +fn auto_detect_node_manager(bin_dir: &vt_path::AbsolutePath, interactive: bool) -> bool { // VP_NODE_MANAGER env var: only "yes" and "no" are recognized; // unrecognized values fall through to normal auto-detection // (matching install.ps1/install.sh behavior). @@ -309,7 +315,7 @@ fn auto_detect_node_manager(install_dir: &vt_path::AbsolutePath, interactive: bo } // Already managing Node (shims exist from a previous install) - let node_shim = install_dir.join("bin").join(if cfg!(windows) { "node.exe" } else { "node" }); + let node_shim = bin_dir.join(if cfg!(windows) { "node.exe" } else { "node" }); if node_shim.as_path().exists() { return true; } @@ -399,12 +405,16 @@ async fn replace_windows_exe( Ok(()) } -/// Set up the `bin/vp` entry point (trampoline copy on Windows, symlink on Unix). -async fn setup_bin_shims( - install_dir: &vt_path::AbsolutePath, -) -> Result<(), Box> { - let bin_dir = install_dir.join("bin"); - tokio::fs::create_dir_all(&bin_dir).await?; +/// Set up the `vp` entry point in the bin dir (trampoline copy on Windows, +/// symlink on Unix). +/// +/// The bin dir comes from the resolved layout: `/bin` under +/// the legacy layout, the separate split-layout bin dir (e.g. +/// `~/.local/bin`) otherwise — created if needed. +async fn setup_bin_shims(layout: &InstallLayout) -> Result<(), Box> { + let install_dir = &layout.install_dir; + let bin_dir = &layout.bin_dir; + tokio::fs::create_dir_all(bin_dir).await?; #[cfg(windows)] { @@ -419,11 +429,11 @@ async fn setup_bin_shims( }; if tokio::fs::try_exists(&src).await.unwrap_or(false) { - replace_windows_exe(&src, &shim_dst, &bin_dir).await?; + replace_windows_exe(&src, &shim_dst, bin_dir).await?; } // Best-effort cleanup of old shim files - if let Ok(mut entries) = tokio::fs::read_dir(&bin_dir).await { + if let Ok(mut entries) = tokio::fs::read_dir(bin_dir).await { while let Ok(Some(entry)) = entries.next_entry().await { if entry.file_name().to_string_lossy().ends_with(".old") { let _ = tokio::fs::remove_file(entry.path()).await; @@ -434,7 +444,14 @@ async fn setup_bin_shims( #[cfg(unix)] { - let link_target = std::path::PathBuf::from("../current/bin/vp"); + // Legacy layout (bin dir is the install-local `bin`): keep the + // relative `../current/bin/vp` target. Split layout: the bin dir + // lives outside the data dir, so link absolutely. + let link_target = if bin_dir.parent().is_some_and(|parent| parent == install_dir) { + std::path::PathBuf::from("../current/bin/vp") + } else { + install_dir.join("current").join("bin").join("vp").as_path().to_path_buf() + }; let link_path = bin_dir.join("vp"); let _ = tokio::fs::remove_file(&link_path).await; tokio::fs::symlink(&link_target, &link_path).await?; @@ -466,13 +483,44 @@ async fn download_with_progress( Ok(data) } -fn resolve_install_dir(opts: &cli::Options) -> Result> { +/// Resolved install layout: where versions land, where the `vp` wrapper and +/// shims go, and where the shell env scripts live. +struct InstallLayout { + /// Data dir: CLI versions plus the `current` symlink. + install_dir: AbsolutePathBuf, + /// Bin dir receiving the `vp` wrapper and tool shims. + bin_dir: AbsolutePathBuf, + /// Directory of the generated env scripts (`env`, `env.fish`, ...). + env_scripts_dir: AbsolutePathBuf, + /// Whether this is the legacy monolithic layout (vs the split XDG one). + is_legacy: bool, +} + +/// Resolve the install layout from the CLI options and [`VpDirs`]. +/// +/// An explicit `--install-dir`/`VP_HOME` override selects the legacy +/// monolithic layout rooted at that directory (the compat story for custom +/// install locations). Otherwise the resolved `VpDirs` decide: the +/// legacy root under the `Home` layout, the split XDG dirs for fresh +/// installs. +fn resolve_layout(opts: &cli::Options) -> Result> { if let Some(ref dir) = opts.install_dir { let path = std::path::PathBuf::from(dir); let abs = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; - AbsolutePathBuf::new(abs).ok_or_else(|| "Invalid installation directory".into()) + let install_dir = AbsolutePathBuf::new(abs).ok_or("Invalid installation directory")?; + Ok(InstallLayout { + bin_dir: install_dir.join("bin"), + env_scripts_dir: install_dir.clone(), + install_dir, + is_legacy: true, + }) } else { - Ok(vp_shared::get_vp_home()?) + Ok(InstallLayout { + install_dir: VpDirs::data_dir(), + bin_dir: VpDirs::bin_dir(), + env_scripts_dir: VpDirs::config_dir(), + is_legacy: VpDirs::is_legacy_layout(), + }) } } @@ -497,10 +545,11 @@ fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box bool { +fn show_interactive_menu(opts: &mut cli::Options, layout: &InstallLayout) -> bool { loop { let version = opts.version.as_deref().unwrap_or(&opts.tag); - let bin_dir = format!("{install_dir}{sep}bin", sep = std::path::MAIN_SEPARATOR); + let install_dir = layout.install_dir.as_path().to_string_lossy().to_string(); + let bin_dir = layout.bin_dir.as_path().to_string_lossy().to_string(); println!(); println!(" {}", "Welcome to Vite+ Installer!".bold()); @@ -594,11 +643,12 @@ fn read_input(prompt: &str) -> String { } #[allow(clippy::print_stdout)] -fn print_success(opts: &cli::Options, install_dir: &str) { +fn print_success(opts: &cli::Options, layout: &InstallLayout) { if opts.quiet { return; } + let env_script = layout.env_scripts_dir.join("env"); println!(); println!(" {} Vite+ has been installed successfully!", "\u{2714}".green().bold()); println!(); @@ -606,7 +656,9 @@ fn print_success(opts: &cli::Options, install_dir: &str) { println!(); println!(" {}", "vp --help".cyan()); println!(); - println!(" Install directory: {install_dir}"); + println!(" Install directory: {}", layout.install_dir.as_path().display()); + println!(" Bin directory: {}", layout.bin_dir.as_path().display()); + println!(" Shell setup: . \"{}\"", env_script.as_path().display()); println!(" Documentation: {}", "https://viteplus.dev/guide/"); println!(); } diff --git a/crates/vp_trampoline/src/main.rs b/crates/vp_trampoline/src/main.rs index b0f2aa639f..04fec1a434 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -19,6 +19,22 @@ use std::{ process::{self, Command, ExitStatus}, }; +/// Locate the real `vp.exe` relative to the install base dir (the parent of +/// the bin dir the trampoline copy lives in). +/// +/// Legacy layout first (`/current/bin/vp.exe`, where the bin dir is +/// `/bin`), then the split layout (`/data/current/bin/vp.exe`, +/// where the bin dir is a separate `/bin`). Returns the path and +/// whether it is the legacy layout; `None` if neither exists. +fn locate_vp_exe(base: &std::path::Path) -> Option<(std::path::PathBuf, bool)> { + let legacy = base.join("current").join("bin").join("vp.exe"); + if legacy.is_file() { + return Some((legacy, true)); + } + let split = base.join("data").join("current").join("bin").join("vp.exe"); + split.is_file().then_some((split, false)) +} + /// Preserve Unix signal termination using the shell's `128 + signal` convention. fn exit_code_from_status(status: ExitStatus) -> i32 { #[cfg(unix)] @@ -37,10 +53,19 @@ fn main() { let tool_name = exe_path.file_stem().and_then(|s| s.to_str()).unwrap_or_else(|| process::exit(1)); - // 2. Locate vp.exe: /../current/bin/vp.exe + // 2. Locate vp.exe: legacy `/current/bin/vp.exe` first, then the + // split layout's `/data/current/bin/vp.exe`. let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1)); - let vp_home = bin_dir.parent().unwrap_or_else(|| process::exit(1)); - let vp_exe = vp_home.join("current").join("bin").join("vp.exe"); + let base = bin_dir.parent().unwrap_or_else(|| process::exit(1)); + let (vp_exe, is_legacy) = locate_vp_exe(base).unwrap_or_else(|| { + use std::io::Write; + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_all(b"vite-plus: could not locate vp.exe under "); + let _ = handle.write_all(base.as_os_str().as_encoded_bytes()); + let _ = handle.write_all(b" (tried current\\bin and data\\current\\bin)\n"); + process::exit(1); + }); // 3. Install Ctrl+C handler that ignores signals (child will handle them). // This prevents the "Terminate batch job (Y/N)?" prompt. @@ -48,13 +73,18 @@ fn main() { install_ctrl_handler(); // 4. Spawn vp.exe - // - Always set VP_HOME so vp.exe uses the correct home directory - // (matches what the old .cmd wrappers did with %~dp0..) + // - Legacy layout: set VP_HOME (deprecated but still honored as the + // highest-priority layout rule) so vp.exe pins the legacy root even + // if its own detection would land elsewhere. Split layout: no + // VP_HOME — vp.exe self-locates its data dir from + // `/data/current/bin/vp.exe`. // - If tool is "vp", run in normal CLI mode (no VP_SHIM_TOOL) // - Otherwise, set VP_SHIM_TOOL so vp.exe enters shim dispatch let mut cmd = Command::new(&vp_exe); cmd.args(env::args_os().skip(1)); - cmd.env("VP_HOME", vp_home); + if is_legacy { + cmd.env("VP_HOME", base); + } if tool_name != "vp" { cmd.env("VP_SHIM_TOOL", tool_name); @@ -83,15 +113,54 @@ fn main() { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { use super::*; + #[cfg(unix)] #[test] fn preserves_signal_exit_code() { let status = Command::new("/bin/sh").arg("-c").arg("kill -ILL $$").status().unwrap(); assert_eq!(exit_code_from_status(status), 132); } + + fn test_base(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vp-trampoline-test-{name}-{}", process::id())) + } + + #[test] + fn locate_vp_exe_prefers_legacy_layout() { + let base = test_base("legacy"); + let legacy_dir = base.join("current").join("bin"); + std::fs::create_dir_all(&legacy_dir).unwrap(); + std::fs::write(legacy_dir.join("vp.exe"), b"MZ").unwrap(); + + assert_eq!(locate_vp_exe(&base).unwrap(), (legacy_dir.join("vp.exe"), true)); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn locate_vp_exe_falls_back_to_split_layout() { + let base = test_base("split"); + let split_dir = base.join("data").join("current").join("bin"); + std::fs::create_dir_all(&split_dir).unwrap(); + std::fs::write(split_dir.join("vp.exe"), b"MZ").unwrap(); + + assert_eq!(locate_vp_exe(&base).unwrap(), (split_dir.join("vp.exe"), false)); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn locate_vp_exe_returns_none_when_absent() { + let base = test_base("absent"); + std::fs::create_dir_all(&base).unwrap(); + + assert!(locate_vp_exe(&base).is_none()); + + let _ = std::fs::remove_dir_all(&base); + } } /// Install a console control handler that ignores Ctrl+C, Ctrl+Break, etc. diff --git a/docker/Dockerfile b/docker/Dockerfile index 100a601386..04211ead1b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -60,8 +60,12 @@ RUN apt-get update \ # root work those phases occasionally need. USER vp -ENV VP_HOME=/home/vp/.vite-plus \ - PATH=/home/vp/.vite-plus/bin:$PATH +# PATH carries both candidate bin dirs: the install script fetched from +# vite.plus picks the split XDG layout (~/.local/bin) when the CLI being +# installed supports it, and the legacy monolithic layout (~/.vite-plus/bin) +# otherwise — e.g. while the released install script still predates the +# split default. Whichever is unused is simply absent from PATH lookups. +ENV PATH=/home/vp/.local/bin:/home/vp/.vite-plus/bin:$PATH # Install the vp global CLI. The installer downloads the platform package from # npm (or from the registry bridge when VP_PR_VERSION is set). Node.js itself is @@ -71,9 +75,10 @@ ENV VP_HOME=/home/vp/.vite-plus \ # The installer pre-provisions a default Node.js (~190 MB). Drop it: each project # downloads its own pinned Node.js at build time, so the default is dead weight in a # builder image. The node/npm/npx shims remain and fetch the right version on -# first use. +# first use. The runtime lands in the data dir of whichever layout the +# installer selected (split: ~/.local/share/vite-plus, legacy: ~/.vite-plus). RUN curl -fsSL https://vite.plus | VP_VERSION="${VP_VERSION}" VP_PR_VERSION="${VP_PR_VERSION}" bash \ && vp --version \ - && rm -rf "$VP_HOME/js_runtime" + && rm -rf /home/vp/.local/share/vite-plus/js_runtime /home/vp/.vite-plus/js_runtime WORKDIR /app 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..c10b143e74 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -23,11 +23,12 @@ These variables control the installer scripts and the standalone Windows install $env:VP_VERSION = "1.2.3"; irm https://vite.plus/ps1 | iex ``` -### `VP_HOME` +### `VP_HOME` (deprecated) -- **Purpose**: Installation directory; the installed CLI reads the same variable as the Vite+ home directory (see [Environment](/guide/env)) -- **Default**: `~/.vite-plus` (Unix) or `%USERPROFILE%\.vite-plus` (Windows) +- **Purpose**: Legacy override that selects the legacy monolithic layout, rooted at the given directory +- **Default**: None — fresh installs use the [split layout](#directory-layout-and-xdg-variables); `~/.vite-plus` is used only when it already exists (grandfathered installs) or when `VP_HOME`/`--install-dir` is set - **CLI equivalent**: `--install-dir` +- **Details**: Deprecated, but still honored by the installed `vp` CLI as the highest-priority layout rule (everything lives under this one root), so older env scripts and custom-location installs that export it keep working. The installers (`install.sh`, `install.ps1`, `vp-setup.exe`) also accept it as the install dir, and the installers and generated env scripts no longer set it for new installs. Prefer `VP_*_DIR` / `XDG_*` variables. 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,37 @@ 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 legacy `~/.vite-plus` root 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** (deprecated) — 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 matches `/current/bin/vp`: when `/bin/vp` also exists, `` is a legacy monolithic install root; otherwise `` is the data dir of a split install, and the data category is pinned to it (the other categories resolve through their normal chains). This covers custom-location installs and launches without `PATH` context (IDEs, the Windows shim trampoline). +3. **`PATH` inference** — for a `PATH` entry containing a `vp` executable: a `/bin` entry with the legacy layout (`bin/vp` plus `current/bin/vp`) marks `` as a legacy install; otherwise the entry's `vp` is canonicalized and, when it resolves into `/current/bin/vp`, the same legacy-vs-split rule as rule 2 applies. +4. **`~/.vite-plus` exists** — the legacy monolithic 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. +- `VP_HOME` is deprecated: still honored as rule 1 for backward compatibility, but no longer set by the installers or the generated env scripts. Prefer `VP_*_DIR` / `XDG_*` variables. +- The installers (`install.sh`, `install.ps1`, `vp-setup.exe`) default to the split layout for fresh installs; an existing `~/.vite-plus` keeps the legacy layout. ## Precedence diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index c37507a314..2bd26c7d36 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -6,7 +6,11 @@ # # Environment variables: # VP_VERSION - Version to install (default: latest) -# VP_HOME - Installation directory (default: $env:USERPROFILE\.vite-plus) +# VP_HOME - Installer-only override: install with the legacy monolithic +# layout rooted at this directory (the vp CLI itself never reads +# VP_HOME). Default: split layout under %LOCALAPPDATA%\vite-plus +# (data\bin) + %APPDATA%\vite-plus (config) for fresh installs, or +# %USERPROFILE%\.vite-plus when it already exists (grandfathered). # NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) # VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) # VP_PR_VERSION - PR number or commit SHA to install from the registry bridge @@ -17,9 +21,34 @@ $ErrorActionPreference = "Stop" $ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } -$InstallDir = if ($env:VP_HOME) { $env:VP_HOME } else { "$env:USERPROFILE\.vite-plus" } -# Use ~ shorthand if install dir is under USERPROFILE, matching the final summary output -$NodeManagerBinDisplay = (Join-Path $InstallDir.TrimEnd('\', '/') "bin") -replace [regex]::Escape($env:USERPROFILE), '~' + +# Install layout. An explicit VP_HOME selects the legacy monolithic layout +# rooted at that directory (compat path), as does an existing +# %USERPROFILE%\.vite-plus — grandfathered installs stay put, matching the +# CLI's directory resolution (vp_shared::Dirs). Fresh installs use the split +# Windows layout: data %LOCALAPPDATA%\vite-plus\data (versions + `current`), +# bin %LOCALAPPDATA%\vite-plus\bin (vp.exe trampoline + shims), config +# %APPDATA%\vite-plus (generated env scripts). +$LegacyLayout = $false +if ($env:VP_HOME) { + $InstallDir = $env:VP_HOME + $LegacyLayout = $true +} elseif (Test-Path -LiteralPath "$env:USERPROFILE\.vite-plus" -PathType Container) { + $InstallDir = "$env:USERPROFILE\.vite-plus" + $LegacyLayout = $true +} else { + $localAppData = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { "$env:USERPROFILE\AppData\Local" } + $appData = if ($env:APPDATA) { $env:APPDATA } else { "$env:USERPROFILE\AppData\Roaming" } + $InstallDir = "$localAppData\vite-plus\data" + $ShimBinDir = "$localAppData\vite-plus\bin" + $EnvScriptsDir = "$appData\vite-plus" +} +if ($LegacyLayout) { + $ShimBinDir = Join-Path $InstallDir.TrimEnd('\', '/') "bin" + $EnvScriptsDir = $InstallDir +} +# Use ~ shorthand if the shim bin dir is under USERPROFILE, matching the final summary output +$NodeManagerBinDisplay = $ShimBinDir -replace [regex]::Escape($env:USERPROFILE), '~' # npm registry URL (strip trailing slash if present) $NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { "https://registry.npmjs.org" } # Local tarball for development/testing @@ -573,10 +602,10 @@ function Remove-CurrentLink { } } -# Configure user PATH for ~/.vite-plus/bin +# Configure user PATH for the shim bin dir # Returns: "true" = added, "already" = already configured function Configure-UserPath { - $binPath = "$InstallDir\bin" + $binPath = $ShimBinDir $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($userPath -like "*$binPath*") { @@ -632,7 +661,7 @@ function Configure-Nushell { } $autoloadFile = Join-Path $autoloadDir "vite-plus.nu" - $nuEnvRef= (Join-Path $InstallDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' + $nuEnvRef = (Join-Path $EnvScriptsDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' $content = "# Vite+ bin (https://viteplus.dev)`n" + ("source '"+ $nuEnvRef +"'") + "`n" try { @@ -676,7 +705,7 @@ function Refresh-Shims { function Setup-NodeManager { param([string]$BinDir) - $binPath = "$InstallDir\bin" + $binPath = $ShimBinDir # Explicit override via environment variable if ($env:VP_NODE_MANAGER -eq "yes") { @@ -765,7 +794,7 @@ function Main { # Registry bridge mode: resolve the requested PR/SHA to the bridge's # immutable commit version (0.0.0-commit.), the clearly-defined test # version we install. The directory label stays non-semver so it keeps - # out of Cleanup-OldVersions and makes the PR build obvious in ~/.vite-plus. + # out of Cleanup-OldVersions and makes the PR build obvious in the data dir. $PrCommitVersion = Resolve-BridgeCommitVersion -Ref $PrVersion if (-not $PrCommitVersion) { Write-Error-Exit "Could not resolve a registry bridge build for $PrVersion" @@ -919,13 +948,13 @@ function Main { cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null # Create bin directory and vp wrapper (always done) - New-Item -ItemType Directory -Force -Path "$InstallDir\bin" | Out-Null + New-Item -ItemType Directory -Force -Path $ShimBinDir | Out-Null $trampolineSrc = "$VersionDir\bin\vp-shim.exe" if (Test-Path $trampolineSrc) { # New versions: use trampoline exe to avoid "Terminate batch job (Y/N)?" on Ctrl+C - Copy-Item -Path $trampolineSrc -Destination "$InstallDir\bin\vp.exe" -Force + Copy-Item -Path $trampolineSrc -Destination "$ShimBinDir\vp.exe" -Force # Remove legacy .cmd and shell script wrappers from previous versions - foreach ($legacy in @("$InstallDir\bin\vp.cmd", "$InstallDir\bin\vp")) { + foreach ($legacy in @("$ShimBinDir\vp.cmd", "$ShimBinDir\vp")) { if (Test-Path $legacy) { Remove-Item -Path $legacy -Force -ErrorAction SilentlyContinue } @@ -935,28 +964,37 @@ function Main { # Remove any stale trampoline .exe shims left by a newer install — .exe wins # over .cmd on Windows PATH, so leftover trampolines would bypass the wrappers. foreach ($stale in @("vp.exe", "node.exe", "npm.exe", "npx.exe", "corepack.exe", "vpx.exe", "vpr.exe")) { - $stalePath = Join-Path "$InstallDir\bin" $stale + $stalePath = Join-Path $ShimBinDir $stale if (Test-Path $stalePath) { Remove-Item -Path $stalePath -Force -ErrorAction SilentlyContinue } } - # Keep consistent with the original install.ps1 wrapper format + # VP_HOME points the pre-trampoline CLI at its install root: the + # wrapper's parent under the legacy layout; the data dir under the + # split layout (a data dir carries the same versions + `current` + # shape, and these old CLIs still read VP_HOME). + $wrapperHomeRef = if ($LegacyLayout) { '%~dp0..' } else { $InstallDir } $wrapperContent = @" @echo off -set VP_HOME=%~dp0.. +set VP_HOME=$wrapperHomeRef "%VP_HOME%\current\bin\vp.exe" %* exit /b %ERRORLEVEL% "@ - Set-Content -Path "$InstallDir\bin\vp.cmd" -Value $wrapperContent -NoNewline + Set-Content -Path "$ShimBinDir\vp.cmd" -Value $wrapperContent -NoNewline # Also create shell script wrapper for Git Bash/MSYS + $shHomeRef = if ($LegacyLayout) { + '"$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")"' + } else { + '"' + ($InstallDir -replace '\\', '/') + '"' + } $shContent = @" #!/bin/sh -VP_HOME="`$(dirname "`$(dirname "`$(readlink -f "`$0" 2>/dev/null || echo "`$0")")")" +VP_HOME=$shHomeRef export VP_HOME exec "`$VP_HOME/current/bin/vp.exe" "`$@" "@ - Set-Content -Path "$InstallDir\bin\vp" -Value $shContent -NoNewline + Set-Content -Path "$ShimBinDir\vp" -Value $shContent -NoNewline } # Cleanup old versions @@ -971,8 +1009,9 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" $pathResult = Configure-UserPath $nushellResult = Configure-Nushell - # Use ~ shorthand if install dir is under USERPROFILE, otherwise show full path - $displayDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~' + # Use ~ shorthand for paths under USERPROFILE, otherwise show full paths + $displayBinDir = $ShimBinDir -replace [regex]::Escape($env:USERPROFILE), '~' + $displayEnvScriptsDir = $EnvScriptsDir -replace [regex]::Escape($env:USERPROFILE), '~' # ANSI color codes for consistent output $e = [char]27 @@ -1030,23 +1069,23 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" Write-Host "" Write-Host " ${YELLOW}note${NC}: Some shells still need manual setup." Write-Host "" - Write-Host " vp was installed to: ${BOLD}${displayDir}\bin${NC}" + Write-Host " vp was installed to: ${BOLD}${displayBinDir}${NC}" Write-Host "" if ($pathResult -eq "failed") { Write-Host " To use vp in Powershell/cmd, manually add it to your PATH:" Write-Host "" - Write-Host " [Environment]::SetEnvironmentVariable('Path', '$InstallDir\bin;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" + Write-Host " [Environment]::SetEnvironmentVariable('Path', '$ShimBinDir;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" Write-Host "" } if ($nushellResult.Status -eq "failed") { Write-Host " To use vp in Nushell, create a vite-plus.nu file in your preferred vendor autoload directory with:" Write-Host "" - Write-Host " source '$displayDir\env.nu'" + Write-Host " source '$displayEnvScriptsDir\env.nu'" Write-Host "" } Write-Host " Or run vp directly:" Write-Host "" - Write-Host " & `"$InstallDir\bin\vp.exe`"" + Write-Host " & `"$ShimBinDir\vp.exe`"" } Write-Host "" diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 5aa2244fab..319f1c6755 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -7,7 +7,14 @@ # # Environment variables: # VP_VERSION - Version to install (default: latest) -# VP_HOME - Installation directory (default: ~/.vite-plus) +# VP_HOME - Installer-only override: install with the legacy monolithic +# layout rooted at this directory (the vp CLI itself never reads +# VP_HOME). Default: split XDG layout for fresh installs, or +# ~/.vite-plus when it already exists (grandfathered installs). +# VP_DATA_DIR / VP_BIN_DIR - Split-layout overrides for the data dir (CLI +# versions + `current`) and the bin dir (vp symlink + shims). +# XDG_CONFIG_HOME / XDG_DATA_HOME / XDG_BIN_HOME - XDG base directories +# honored by the split layout (relative values are treated as unset). # NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) # VP_NODE_MANAGER - Set to "yes" or "no" to skip interactive prompt (for CI/devcontainers) # VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) @@ -19,14 +26,73 @@ set -e VP_VERSION="${VP_VERSION:-latest}" -INSTALL_DIR="${VP_HOME:-$HOME/.vite-plus}" -# Use $HOME-relative path for shell config references (portable across sessions) -if case "$INSTALL_DIR" in "$HOME"/*) true;; *) false;; esac; then - INSTALL_DIR_REF_POSIX="\$HOME${INSTALL_DIR#"$HOME"}" - INSTALL_DIR_REF_NU="~${INSTALL_DIR#"$HOME"}" + +# Install layout. An explicit VP_HOME selects the legacy monolithic layout +# rooted at that directory (compat path), as does an existing ~/.vite-plus — +# grandfathered installs stay put, matching the CLI's directory resolution +# (vp_shared::Dirs). Fresh installs use the split XDG layout: +# data ${VP_DATA_DIR:-${XDG_DATA_HOME:-~/.local/share}/vite-plus} +# (CLI versions + `current`; the INSTALL_DIR variable below) +# bin ${VP_BIN_DIR:-${XDG_BIN_HOME:-${XDG_DATA_HOME:+$XDG_DATA_HOME/../bin}}} +# default ~/.local/bin (vp symlink + tool shims) +# config ${XDG_CONFIG_HOME:-~/.config}/vite-plus (generated env scripts) +# The CLI resolves the same chains, so these must mirror vp_shared::Dirs. +LEGACY_LAYOUT="false" +if [ -n "${VP_HOME:-}" ]; then + INSTALL_DIR="$VP_HOME" + LEGACY_LAYOUT="true" +elif [ -d "$HOME/.vite-plus" ]; then + INSTALL_DIR="$HOME/.vite-plus" + LEGACY_LAYOUT="true" +else + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + # Git Bash/MSYS keeps the legacy root: install.ps1 owns the split + # Windows layout (%LOCALAPPDATA%/%APPDATA%), which has no clean MSYS + # mapping. + INSTALL_DIR="$HOME/.vite-plus" + LEGACY_LAYOUT="true" + ;; + esac +fi + +if [ "$LEGACY_LAYOUT" = "false" ]; then + # Relative VP_*_DIR/XDG_* values are treated as unset, per the XDG Base + # Directory Specification. + for dir_var in VP_BIN_DIR VP_DATA_DIR XDG_BIN_HOME XDG_CONFIG_HOME XDG_DATA_HOME; do + eval "dir_val=\${$dir_var:-}" + case "$dir_val" in + '' | /*) ;; + *) unset "$dir_var" ;; + esac + done + unset dir_var dir_val + + INSTALL_DIR="${VP_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/vite-plus}" + if [ -n "${VP_BIN_DIR:-}" ]; then + SHIM_BIN_DIR="$VP_BIN_DIR" + elif [ -n "${XDG_BIN_HOME:-}" ]; then + SHIM_BIN_DIR="$XDG_BIN_HOME" + elif [ -n "${XDG_DATA_HOME:-}" ] && [ "$XDG_DATA_HOME" != "/" ]; then + # uv's chain: $XDG_DATA_HOME/../bin (trailing slashes stripped so + # dirname resolves the same parent the CLI does) + SHIM_BIN_DIR="$(dirname "${XDG_DATA_HOME%/}")/bin" + else + SHIM_BIN_DIR="$HOME/.local/bin" + fi + ENV_SCRIPTS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vite-plus" +else + SHIM_BIN_DIR="$INSTALL_DIR/bin" + ENV_SCRIPTS_DIR="$INSTALL_DIR" +fi + +# Use $HOME-relative paths for shell config references (portable across sessions) +if case "$ENV_SCRIPTS_DIR" in "$HOME"/*) true;; *) false;; esac; then + ENV_DIR_REF_POSIX="\$HOME${ENV_SCRIPTS_DIR#"$HOME"}" + ENV_DIR_REF_NU="~${ENV_SCRIPTS_DIR#"$HOME"}" else - INSTALL_DIR_REF_POSIX="$INSTALL_DIR" - INSTALL_DIR_REF_NU="$INSTALL_DIR" + ENV_DIR_REF_POSIX="$ENV_SCRIPTS_DIR" + ENV_DIR_REF_NU="$ENV_SCRIPTS_DIR" fi # npm registry URL (strip trailing slash if present) NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" @@ -688,7 +754,7 @@ configure_zsh_path() { fi result=0 - append_source_to_file "$zshenv" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$zshenv" ". \"$ENV_DIR_REF_POSIX/env\"" "$ENV_SCRIPTS_DIR/env" "$ENV_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshenv")") ;; 2) already+=("$(abbreviate_path "$zshenv")") ;; @@ -697,7 +763,7 @@ configure_zsh_path() { if [ -f "$zshrc" ]; then result=0 - append_source_to_file "$zshrc" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$zshrc" ". \"$ENV_DIR_REF_POSIX/env\"" "$ENV_SCRIPTS_DIR/env" "$ENV_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshrc")") ;; 2) already+=("$(abbreviate_path "$zshrc")") ;; @@ -741,7 +807,7 @@ configure_bash_path() { fi existing=1 result=0 - append_source_to_file "$file" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$file" ". \"$ENV_DIR_REF_POSIX/env\"" "$ENV_SCRIPTS_DIR/env" "$ENV_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$file")") ;; 2) already+=("$(abbreviate_path "$file")") ;; @@ -776,7 +842,7 @@ configure_bash_path() { configure_fish_path() { local fish_config="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish" local fish_content="# Vite+ bin (https://viteplus.dev) -source \"$INSTALL_DIR_REF_POSIX/env.fish\" +source \"$ENV_DIR_REF_POSIX/env.fish\" " local result=0 @@ -811,7 +877,7 @@ configure_nushell_path() { local nushell_autoload="$nushell_dir/vite-plus.nu" local nushell_content="# Vite+ bin (https://viteplus.dev) -source '$INSTALL_DIR_REF_NU/env.nu' +source '$ENV_DIR_REF_NU/env.nu' " local result=0 @@ -883,7 +949,7 @@ refresh_shims() { # Arguments: bin_dir - path to the version's bin directory containing vp setup_node_manager() { local bin_dir="$1" - local bin_path="$INSTALL_DIR/bin" + local bin_path="$SHIM_BIN_DIR" NODE_MANAGER_ENABLED="false" # Resolve vp binary name (vp on Unix, vp.exe on Windows) @@ -937,7 +1003,7 @@ setup_node_manager() { if [ -e /dev/tty ] && [ -t 1 ]; then echo "" echo "Would you like Vite+ to manage your Node.js versions?" - echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/ and automatically uses the right version." + echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$SHIM_BIN_DIR")/ and automatically uses the right version." echo "Opt out anytime with \`vp env off\`." echo -n "Press Enter to accept (Y/n): " read -r response < /dev/tty @@ -1028,7 +1094,7 @@ main() { # Registry bridge mode: resolve the requested PR/SHA to the bridge's # immutable commit version (0.0.0-commit.), the clearly-defined test # version we install. The directory label stays non-semver so it keeps out - # of cleanup_old_versions and makes the PR build obvious in `~/.vite-plus/`. + # of cleanup_old_versions and makes the PR build obvious in the data dir. # `|| true` keeps `set -e` from aborting this assignment when resolution # fails (unregistered ref / transient bridge error), so the actionable # error below is reachable instead of the installer exiting silently. @@ -1173,15 +1239,19 @@ WRAPPER_EOF ln -sfn "$VP_VERSION" "$CURRENT_LINK" # Create bin directory and vp entrypoint (always done) - mkdir -p "$INSTALL_DIR/bin" + mkdir -p "$SHIM_BIN_DIR" if [[ "$platform" == win32* ]]; then # Windows: copy trampoline as vp.exe (matching install.ps1) if [ -f "$INSTALL_DIR/current/bin/vp-shim.exe" ]; then - cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$INSTALL_DIR/bin/vp.exe" + cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$SHIM_BIN_DIR/vp.exe" fi + elif [ "$LEGACY_LAYOUT" = "true" ]; then + # Legacy layout: keep the relative symlink target (portable root). + ln -sf "../current/bin/vp" "$SHIM_BIN_DIR/vp" else - # Unix: symlink to current/bin/vp - ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" + # Split layout: the bin dir lives outside the data dir, so link + # absolutely to /current/bin/vp. + ln -sf "$INSTALL_DIR/current/bin/vp" "$SHIM_BIN_DIR/vp" fi # Cleanup old versions @@ -1204,9 +1274,9 @@ WRAPPER_EOF # Configure shell PATH after the install is otherwise complete. configure_shell_path - # Use ~ shorthand if install dir is under HOME, otherwise show full path - local display_dir="${INSTALL_DIR/#$HOME/~}" - local display_location="${display_dir}/bin" + # Use ~ shorthand for the bin dir when it is under HOME + local display_location + display_location="$(abbreviate_path "$SHIM_BIN_DIR")" # Print success message echo "" @@ -1251,11 +1321,11 @@ WRAPPER_EOF echo "" echo " Manual setup instructions:" echo " - Bash/Zsh: add the following to your shell config (~/.bashrc, ~/.zshrc, etc.):" - echo " . \"$INSTALL_DIR_REF_POSIX/env\"" + echo " . \"$ENV_DIR_REF_POSIX/env\"" echo " - Fish: create ${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish with:" - echo " source \"$INSTALL_DIR_REF_POSIX/env.fish\"" + echo " source \"$ENV_DIR_REF_POSIX/env.fish\"" echo " - Nushell: create a vendor autoload file with:" - echo " source '$INSTALL_DIR_REF_NU/env.nu'" + echo " source '$ENV_DIR_REF_NU/env.nu'" echo "" echo " Or run vp directly:" echo "" diff --git a/packages/cli/legacy_install.ps1 b/packages/cli/legacy_install.ps1 new file mode 100644 index 0000000000..6cced5ad18 --- /dev/null +++ b/packages/cli/legacy_install.ps1 @@ -0,0 +1,1077 @@ +# LEGACY Vite+ CLI Installer — frozen copy of the pre-XDG install.ps1. +# https://vite.plus +# +# This script installs CLIs that predate the split XDG directory layout +# (everything under ~/.vite-plus, driven by VP_HOME). It exists so the +# legacy install path stays testable; new development happens in +# install.ps1, which targets the split layout and carries no +# backward-compatibility logic. +# +# Usage: +# irm https://vite.plus/legacy_install.ps1 | iex +# +# Vite+ CLI Installer for Windows +# https://vite.plus/ps1 +# +# Usage: +# irm https://vite.plus/ps1 | iex +# +# Environment variables: +# VP_VERSION - Version to install (default: latest) +# VP_HOME - Installation directory (default: $env:USERPROFILE\.vite-plus) +# NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) +# VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) +# VP_PR_VERSION - PR number or commit SHA to install from the registry bridge +# (for temporary testing of unreleased builds, e.g. VP_PR_VERSION=1569). +# When set, overrides VP_VERSION and installs the clearly-defined +# 0.0.0-commit. build through the bridge instead of npm. + +$ErrorActionPreference = "Stop" + +$ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } +$InstallDir = if ($env:VP_HOME) { $env:VP_HOME } else { "$env:USERPROFILE\.vite-plus" } +# Use ~ shorthand if install dir is under USERPROFILE, matching the final summary output +$NodeManagerBinDisplay = (Join-Path $InstallDir.TrimEnd('\', '/') "bin") -replace [regex]::Escape($env:USERPROFILE), '~' +# npm registry URL (strip trailing slash if present) +$NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { "https://registry.npmjs.org" } +# Local tarball for development/testing +$LocalTgz = $env:VP_LOCAL_TGZ +# Local binary path (set by install-global-cli.ts for local dev) +$LocalBinary = $env:VP_LOCAL_BINARY +# PR number or commit SHA to install as a test build (registry bridge mode) +$PrVersion = $env:VP_PR_VERSION +# Registry bridge that serves PR preview builds as clearly-versioned packages. +# The pkg.pr.new-style download URL (BridgeDownloadBase) 302-redirects to a +# canonical 0.0.0-commit. tarball; the registry (BridgeRegistry) resolves +# those commit versions (and proxies everything else to npmjs) so a full install +# pulls a coherent, clearly-defined test build. +$BridgeDownloadBase = "https://registry-bridge.viteplus.dev/voidzero-dev/vite-plus" +$BridgeRegistry = "https://registry-bridge.viteplus.dev/" + +function Write-Info { + param([string]$Message) + Write-Host "info: " -ForegroundColor Blue -NoNewline + Write-Host $Message +} + +function Write-Success { + param([string]$Message) + Write-Host "success: " -ForegroundColor Green -NoNewline + Write-Host $Message +} + +function Write-Warn { + param([string]$Message) + Write-Host "warn: " -ForegroundColor Yellow -NoNewline + Write-Host $Message +} + +# Exit code when a Windows native binary cannot load required DLLs (STATUS_DLL_NOT_FOUND). +$script:DllNotFoundExitCode = -1073741515 + +function Test-IsDllNotFoundExitCode { + param([int]$ExitCode) + if ($ExitCode -eq $script:DllNotFoundExitCode) { + return $true + } + if ($ExitCode -eq 3221225781) { + return $true + } + if ($ExitCode -lt 0) { + $hex = '{0:X8}' -f ($ExitCode -band 0xFFFFFFFF) + return $hex -eq 'C0000135' + } + return $false +} + +function Get-DllNotFoundInstallMessage { + $arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } + $vcUrl = if ($arch -eq "arm64") { + "https://aka.ms/vs/17/release/vc_redist.arm64.exe" + } else { + "https://aka.ms/vs/17/release/vc_redist.x64.exe" + } + return @" +vp.exe could not start (exit code 0xC0000135). +This usually means Microsoft Visual C++ 2015-2022 Redistributable ($arch) is not installed. + +Install: $vcUrl +Then re-run: irm https://vite.plus/ps1 | iex +"@ +} + +# Internal stop signal: halts install without re-printing an error we already wrote. +$script:InstallStopSignal = 'VP_INSTALL_STOP' + +function Test-IsInstallStopException { + param( + [System.Management.Automation.ErrorRecord]$ErrorRecord + ) + return $ErrorRecord.Exception.Message -eq $script:InstallStopSignal +} + +function Test-ShouldKeepShellOpenAfterFailure { + # Only `irm ... | iex` typed in an already-open interactive shell should keep the + # session alive. CI, script files, and `powershell -Command "..."` must exit non-zero. + if ($env:CI -eq "true") { + return $false + } + if ($PSCommandPath) { + return $false + } + if (-not [Environment]::UserInteractive) { + return $false + } + try { + $commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId=$PID").CommandLine + if ($commandLine -match '(^|\s)-Command(\s|$)') { + return $false + } + } catch { + return $false + } + return $true +} + +function Exit-Installer { + param([int]$Code = 1) + $global:LASTEXITCODE = $Code + if (-not (Test-ShouldKeepShellOpenAfterFailure)) { + exit $Code + } + throw $script:InstallStopSignal +} + +function Write-Error-Exit { + param([string]$Message) + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host $Message + Exit-Installer +} + +function Test-ReleaseAgeError { + param([string]$LogPath) + if (-not (Test-Path $LogPath)) { + return $false + } + + $content = Get-Content -Path $LogPath -Raw + # This wrapper install path is pinned to pnpm via packageManager, so this + # detection follows pnpm's resolver/reporter output rather than npm/yarn. + # + # pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so + # NO_MATURE_MATCHING_VERSION is normally printed as + # ERR_PNPM_NO_MATURE_MATCHING_VERSION. npm-resolver emits that code with the + # "does not meet the minimumReleaseAge constraint" message when + # publishedBy/minimumReleaseAge rejects a matching version. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 + # + # default-reporter may append guidance mentioning minimumReleaseAgeExclude + # when the error has an immatureVersion, so that token is also a useful + # release-age signal. minimum-release-age is pnpm's .npmrc key; npm's + # min-release-age is intentionally not treated as a pnpm signal here. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 + $hasReleaseAgeText = $content -match "does not meet the minimumReleaseAge constraint" ` + -or $content -match "minimumReleaseAge" ` + -or $content -match "minimumReleaseAgeExclude" ` + -or $content -match "minimum release age" ` + -or $content -match "minimum-release-age" + + # pnpm can also surface ERR_PNPM_NO_MATCHING_VERSION when minimumReleaseAge + # filters out all candidates. That code is also used for real missing + # versions, so require age-gate context before prompting for a bypass. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 + return $content -match "ERR_PNPM_NO_MATURE_MATCHING_VERSION" ` + -or $content -match "NO_MATURE_MATCHING_VERSION" ` + -or (($content -match "ERR_PNPM_NO_MATCHING_VERSION") -and $hasReleaseAgeText) ` + -or $hasReleaseAgeText +} + +function Confirm-ReleaseAgeOverride { + if ($env:CI -eq "true") { + return $false + } + if (-not [Environment]::UserInteractive) { + return $false + } + + Write-Host "" + Write-Warn "Your minimumReleaseAge setting prevented installing vite-plus@$ViteVersion." + Write-Host "This setting helps protect against newly published compromised packages." + Write-Host "Proceeding will disable this protection for this Vite+ install only." + $response = Read-Host "Do you want to proceed? (y/N)" + return $response -match "^(?i:y|yes)$" +} + +function Write-ReleaseAgeOverride { + # Append idempotently so a bridge registry line written for PR builds survives. + $npmrc = Join-Path $VersionDir ".npmrc" + if ((-not (Test-Path $npmrc)) -or (-not (Select-String -Path $npmrc -Pattern '^minimum-release-age=' -Quiet))) { + Add-Content -Path $npmrc -Value "minimum-release-age=0" + } +} + +function Normalize-InstallDir { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { + return $Path + } + + try { + if (Test-Path -LiteralPath $Path -PathType Container) { + return (Resolve-Path -LiteralPath $Path).ProviderPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + } + + return [System.IO.Path]::GetFullPath($Path).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + } catch { + return $Path.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + } +} + +function Test-SafeInstallDirToRemove { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { + return $false + } + + $normalized = Normalize-InstallDir $Path + $root = [System.IO.Path]::GetPathRoot($normalized) + $home = Normalize-InstallDir $env:USERPROFILE + $programFilesX86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)") + $unsafeDirs = @( + $root + $home + (Normalize-InstallDir $env:SystemRoot) + (Normalize-InstallDir $env:ProgramFiles) + (Normalize-InstallDir $programFilesX86) + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + return $unsafeDirs -notcontains $normalized +} + +function Test-VitePlusInstallDir { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + return $false + } + + $binDir = Join-Path $Path "bin" + if (-not (Test-Path -LiteralPath $binDir -PathType Container)) { + return $false + } + if (-not (Test-Path -LiteralPath (Join-Path $Path "current"))) { + return $false + } + + return (Test-Path -LiteralPath (Join-Path $binDir "vp.exe")) ` + -or (Test-Path -LiteralPath (Join-Path $binDir "vp.cmd")) ` + -or (Test-Path -LiteralPath (Join-Path $binDir "vp")) +} + +function Get-PreviousInstallDir { + if (-not $env:VP_HOME) { + return $null + } + + $vpCommand = Get-Command vp -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $vpCommand) { + return $null + } + + $vpPath = $vpCommand.Path + if (-not $vpPath) { + return $null + } + + $vpFileName = [System.IO.Path]::GetFileName($vpPath) + if ($vpFileName -notin @("vp", "vp.exe", "vp.cmd")) { + return $null + } + + $oldDir = Normalize-InstallDir (Split-Path -Parent (Split-Path -Parent $vpPath)) + $newDir = Normalize-InstallDir $InstallDir + if ($oldDir -eq $newDir) { + return $null + } + if (-not (Test-SafeInstallDirToRemove $oldDir)) { + return $null + } + if (-not (Test-VitePlusInstallDir $oldDir)) { + return $null + } + + return $oldDir +} + +function Test-NestedInstallDir { + param( + [string]$OldDir, + [string]$NewDir + ) + if ([string]::IsNullOrWhiteSpace($OldDir) -or [string]::IsNullOrWhiteSpace($NewDir)) { + return $false + } + + $oldDir = Normalize-InstallDir $OldDir + $newDir = Normalize-InstallDir $NewDir + if ([string]::IsNullOrWhiteSpace($oldDir) -or [string]::IsNullOrWhiteSpace($newDir) -or $oldDir -eq $newDir) { + return $false + } + + # Normalize-InstallDir already trimmed trailing separators + $oldPrefix = $oldDir + [System.IO.Path]::DirectorySeparatorChar + $newPrefix = $newDir + [System.IO.Path]::DirectorySeparatorChar + return $oldPrefix.StartsWith($newPrefix, [System.StringComparison]::OrdinalIgnoreCase) ` + -or $newPrefix.StartsWith($oldPrefix, [System.StringComparison]::OrdinalIgnoreCase) +} + +function Prompt-RemovePreviousInstallDir { + param([string]$PreviousInstallDir) + if (-not $PreviousInstallDir) { + return + } + if ($env:CI -eq "true") { + return + } + if (-not [Environment]::UserInteractive) { + return + } + + Write-Host "" + Write-Warn "Found a previous Vite+ install at $PreviousInstallDir." + Write-Host "The new VP_HOME is $InstallDir." + $response = Read-Host "Remove the previous install directory? (y/N)" + if ($response -match "^(?i:y|yes)$") { + $vpBin = Join-Path $PreviousInstallDir "current\bin\vp.exe" + if (-not (Test-Path -LiteralPath $vpBin)) { + Write-Warn "Could not remove previous Vite+ install at ${PreviousInstallDir}: vp binary not found." + return + } + + $previousVpHome = $env:VP_HOME + try { + $env:VP_HOME = $PreviousInstallDir + $output = & $vpBin implode --yes 2>&1 + $exitCode = $LASTEXITCODE + } catch { + $output = $_ + $exitCode = 1 + } finally { + $env:VP_HOME = $previousVpHome + } + + if ($exitCode -eq 0) { + Write-Success "Removed previous Vite+ install at $PreviousInstallDir." + } else { + Write-Warn "Could not remove previous Vite+ install at ${PreviousInstallDir}: $output" + } + } +} + +# Resolve a PR number or commit SHA to the registry bridge's immutable commit +# version (0.0.0-commit.). A full commit SHA maps directly to the bridge's +# deterministic version; a PR number (or short ref) is resolved via the bridge +# download URL's `x-commit-key: ::` header (HEAD). +function Resolve-BridgeCommitVersion { + param([string]$Ref) + $sha = $Ref + if ($Ref -notmatch '^[0-9a-fA-F]{40}$') { + try { + $resp = Invoke-WebRequest -Uri "$BridgeDownloadBase@$Ref" -Method Head -UseBasicParsing -ErrorAction Stop + } catch { + return $null + } + $commitKey = @($resp.Headers['x-commit-key'])[0] + if (-not $commitKey) { return $null } + $sha = ($commitKey -split ':')[-1] + } + if ($sha -notmatch '^[0-9a-fA-F]{40}$') { return $null } + return "0.0.0-commit.$sha" +} + +function Write-InstallFailure { + param( + [string]$LogPath, + [int]$ExitCode = 0 + ) + + if (Test-IsDllNotFoundExitCode $ExitCode) { + $message = Get-DllNotFoundInstallMessage + if ($env:CI -eq "true") { + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host $message + Exit-Installer + } + Write-Error-Exit $message + } + + if ($env:CI -eq "true") { + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host "Failed to install dependencies. Log output:" + Get-Content -Path $LogPath | ForEach-Object { Write-Host $_ } + Exit-Installer + } else { + Write-Error-Exit "Failed to install dependencies. See log for details: $LogPath" + } +} + +function Write-ReleaseAgeFailure { + param([string]$LogPath) + if ($env:CI -eq "true") { + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host "Install blocked by your minimumReleaseAge setting. Log output:" + Get-Content -Path $LogPath | ForEach-Object { Write-Host $_ } + } else { + Write-Error-Exit "Install blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly. See log for details: $LogPath" + } +} + +function Get-Architecture { + if ([Environment]::Is64BitOperatingSystem) { + if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { + return "arm64" + } else { + return "x64" + } + } else { + Write-Error-Exit "32-bit Windows is not supported" + } +} + +# Cached package metadata +$script:PackageMetadata = $null + +function Get-PackageMetadata { + if ($null -eq $script:PackageMetadata) { + $versionPath = if ($ViteVersion -eq "latest") { "latest" } else { $ViteVersion } + $metadataUrl = "$NpmRegistry/vite-plus/$versionPath" + try { + $script:PackageMetadata = Invoke-RestMethod $metadataUrl + } catch { + if (Test-IsInstallStopException $_) { throw } + # Try to extract npm error message from response + $errorMsg = $_.ErrorDetails.Message + if ($errorMsg) { + try { + $errorJson = $errorMsg | ConvertFrom-Json + if ($errorJson.error) { + Write-Error-Exit "Failed to fetch version '${versionPath}': $($errorJson.error)`n URL: $metadataUrl" + } + } catch { + if (Test-IsInstallStopException $_) { throw } + # JSON parsing failed, fall through to generic error + } + } + Write-Error-Exit "Failed to fetch package metadata from: $metadataUrl`nError: $_" + } + # Check for error in successful response + # npm can return {"error":"..."} object or a plain string like "version not found: test" + if ($script:PackageMetadata -is [string]) { + # Some registries (e.g. JFrog) may return JSON with a non-JSON content type, + # causing Invoke-RestMethod to return a raw string. Try parsing it as JSON first. + try { + $script:PackageMetadata = $script:PackageMetadata | ConvertFrom-Json + } catch { + if (Test-IsInstallStopException $_) { throw } + # Not valid JSON - treat as plain string error + Write-Error-Exit "Failed to fetch version '${versionPath}': $script:PackageMetadata`n URL: $metadataUrl" + } + } + if ($script:PackageMetadata.error) { + Write-Error-Exit "Failed to fetch version '${versionPath}': $($script:PackageMetadata.error)`n URL: $metadataUrl" + } + } + return $script:PackageMetadata +} + +function Get-VersionFromMetadata { + $metadata = Get-PackageMetadata + if (-not $metadata.version) { + Write-Error-Exit "Failed to extract version from package metadata" + } + return $metadata.version +} + +function Get-PlatformSuffix { + param([string]$Platform) + # Windows needs -msvc suffix, other platforms map directly + if ($Platform.StartsWith("win32-")) { return "${Platform}-msvc" } + return $Platform +} + +function Download-AndExtract { + param( + [string]$Url, + [string]$DestDir, + [string]$Filter + ) + + $tempFile = New-TemporaryFile + try { + # Suppress progress bar for cleaner output + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $Url -OutFile $tempFile + + # Create temp extraction directory + $tempExtract = Join-Path $env:TEMP "vite-install-$(Get-Random)" + New-Item -ItemType Directory -Force -Path $tempExtract | Out-Null + + # Extract using tar (available in Windows 10+) + & "$env:SystemRoot\System32\tar.exe" -xzf $tempFile -C $tempExtract + + # Copy the specified file/directory + $sourcePath = Join-Path (Join-Path $tempExtract "package") $Filter + if (Test-Path $sourcePath) { + Copy-Item -Path $sourcePath -Destination $DestDir -Recurse -Force + } + + Remove-Item -Recurse -Force $tempExtract + } finally { + Remove-Item $tempFile -ErrorAction SilentlyContinue + } +} + +function Cleanup-OldVersions { + param([string]$InstallDir) + + $maxVersions = 3 + # Only cleanup semver format directories (0.1.0, 1.2.3-beta.1, etc.) + # This excludes 'current' symlink and non-semver directories like 'local-dev' + $semverPattern = '^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$' + $versions = Get-ChildItem -Path $InstallDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match $semverPattern } + + if ($null -eq $versions -or $versions.Count -le $maxVersions) { + return + } + + # Sort by creation time (oldest first) and select excess + $toDelete = $versions | + Sort-Object CreationTime | + Select-Object -First ($versions.Count - $maxVersions) + + foreach ($old in $toDelete) { + # Remove silently + Remove-Item -Path $old.FullName -Recurse -Force + } +} + +function Remove-CurrentLink { + param([string]$Path) + + try { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + } catch [System.Management.Automation.ItemNotFoundException] { + return + } + + $isReparsePoint = ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + + try { + if ($isReparsePoint) { + if ($item.PSIsContainer) { + [System.IO.Directory]::Delete($item.FullName) + } else { + [System.IO.File]::Delete($item.FullName) + } + return + } + + Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction Stop + } catch { + Write-Error-Exit "Failed to remove existing current link at ${Path}: $_" + } +} + +# Configure user PATH for ~/.vite-plus/bin +# Returns: "true" = added, "already" = already configured +function Configure-UserPath { + $binPath = "$InstallDir\bin" + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + + if ($userPath -like "*$binPath*") { + return "already" + } + + $newPath = "$binPath;$userPath" + try { + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + $env:Path = "$binPath;$env:Path" + return "true" + } catch { + Write-Warn "Could not update user PATH automatically." + return "failed" + } +} + +function Get-NushellVendorAutoloadDir { + $nushellCommand = Get-Command nu -ErrorAction SilentlyContinue + if ($null -eq $nushellCommand) { + return $null + } + + try { + $dirsOutput = & $nushellCommand.Source -c '$nu.vendor-autoload-dirs | reverse | each {|dir| $dir } | str join (char nl)' 2>$null + } catch { + return $null + } + + foreach ($dir in ($dirsOutput -split "\r?\n")) { + if (-not [string]::IsNullOrWhiteSpace($dir)) { + return $dir + } + } + + return $null +} + +function Configure-Nushell { + $autoloadDir = Get-NushellVendorAutoloadDir + if ($null -eq $autoloadDir) { + if ($null -eq (Get-Command nu -ErrorAction SilentlyContinue)) { + return [pscustomobject]@{ + Status = "skipped" + Message = "skipped (not installed)" + } + } + + return [pscustomobject]@{ + Status = "failed" + Message = "failed (could not determine vendor autoload dir)" + } + } + + $autoloadFile = Join-Path $autoloadDir "vite-plus.nu" + $nuEnvRef= (Join-Path $InstallDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' + $content = "# Vite+ bin (https://viteplus.dev)`n" + ("source '"+ $nuEnvRef +"'") + "`n" + + try { + New-Item -ItemType Directory -Force -Path $autoloadDir | Out-Null + if (Test-Path $autoloadFile) { + $existing = Get-Content -Path $autoloadFile -Raw + if ($existing -eq $content) { + return [pscustomobject]@{ + Status = "already" + Message = "already configured $autoloadFile" + } + } + } + + [System.IO.File]::WriteAllText($autoloadFile, $content) + return [pscustomobject]@{ + Status = "true" + Message = "updated $autoloadFile" + } + } catch { + Write-Warn "Could not configure Nushell automatically." + return [pscustomobject]@{ + Status = "failed" + Message = "failed $autoloadFile" + } + } +} + +# Run vp env setup --refresh, showing output only on failure +function Refresh-Shims { + param([string]$BinDir) + $setupOutput = & "$BinDir\vp.exe" env setup --refresh 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warn "Failed to refresh shims:" + Write-Host "$setupOutput" + } +} + +# Setup Node.js version manager (node/npm/npx/corepack shims) +# Returns: "true" = enabled, "false" = not enabled, "already" = already configured +function Setup-NodeManager { + param([string]$BinDir) + + $binPath = "$InstallDir\bin" + + # Explicit override via environment variable + if ($env:VP_NODE_MANAGER -eq "yes") { + Refresh-Shims -BinDir $BinDir + return "true" + } elseif ($env:VP_NODE_MANAGER -eq "no") { + return "false" + } + + # Check if Vite+ is already managing Node.js (bin\node.exe exists) + if (Test-Path "$binPath\node.exe") { + # Already managing Node.js, just refresh shims + Refresh-Shims -BinDir $BinDir + return "already" + } + + # Auto-enable on CI or devcontainer environments + # CI: standard CI environment variable (GitHub Actions, Travis, CircleCI, etc.) + # CODESPACES: set by GitHub Codespaces (https://docs.github.com/en/codespaces) + # REMOTE_CONTAINERS: set by VS Code Dev Containers extension + # DEVPOD: set by DevPod (https://devpod.sh) + if ($env:CI -or $env:CODESPACES -or $env:REMOTE_CONTAINERS -or $env:DEVPOD) { + Refresh-Shims -BinDir $BinDir + return "true" + } + + # Check if node is available on the system + $nodeAvailable = $null -ne (Get-Command node -ErrorAction SilentlyContinue) + + # Auto-enable if no node available on system + if (-not $nodeAvailable) { + Refresh-Shims -BinDir $BinDir + return "true" + } + + # Prompt user in interactive mode + $isInteractive = [Environment]::UserInteractive + if ($isInteractive) { + Write-Host "" + Write-Host "Would you like Vite+ to manage your Node.js versions?" + Write-Host "It adds ``node``, ``npm``, ``npx``, and ``corepack`` shims to $NodeManagerBinDisplay and automatically uses the right version." + Write-Host "Opt out anytime with ``vp env off``." + $response = Read-Host "Press Enter to accept (Y/n)" + + if ($response -eq '' -or $response -eq 'y' -or $response -eq 'Y') { + Refresh-Shims -BinDir $BinDir + return "true" + } + } + + return "false" +} + +function Main { + Write-Host "" + Write-Host "Setting up " -NoNewline + Write-Host "VITE+" -ForegroundColor Blue -NoNewline + Write-Host "..." + + if ($PrVersion -and $LocalTgz) { + Write-Error-Exit "VP_PR_VERSION and VP_LOCAL_TGZ cannot be used together" + } + + $previousInstallDir = Get-PreviousInstallDir + if ($previousInstallDir -and (Test-NestedInstallDir -OldDir $previousInstallDir -NewDir $InstallDir)) { + Write-Error-Exit "Previous Vite+ install at $previousInstallDir overlaps with VP_HOME $InstallDir. Choose a separate VP_HOME or remove the previous install first." + } + + # Suppress progress bars for cleaner output + $ProgressPreference = 'SilentlyContinue' + + $arch = Get-Architecture + $platform = "win32-$arch" + + # Local development mode: use local tgz + if ($LocalTgz) { + # Validate local tgz + if (-not (Test-Path $LocalTgz)) { + Write-Error-Exit "Local tarball not found: $LocalTgz" + } + # Use version as-is (default to "local-dev") + if ($ViteVersion -eq "latest" -or $ViteVersion -eq "test") { + $ViteVersion = "local-dev" + } + } elseif ($PrVersion) { + # Registry bridge mode: resolve the requested PR/SHA to the bridge's + # immutable commit version (0.0.0-commit.), the clearly-defined test + # version we install. The directory label stays non-semver so it keeps + # out of Cleanup-OldVersions and makes the PR build obvious in ~/.vite-plus. + $PrCommitVersion = Resolve-BridgeCommitVersion -Ref $PrVersion + if (-not $PrCommitVersion) { + Write-Error-Exit "Could not resolve a registry bridge build for $PrVersion" + } + $ViteVersion = "pkg-pr-new-$PrVersion" + Write-Info "Using registry bridge build: $PrCommitVersion" + } else { + # Fetch package metadata and resolve version from npm + $ViteVersion = Get-VersionFromMetadata + } + + # Set up version-specific directories + $VersionDir = "$InstallDir\$ViteVersion" + $BinDir = "$VersionDir\bin" + $CurrentLink = "$InstallDir\current" + + $binaryName = "vp.exe" + + # Create bin directory + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + + if ($LocalTgz) { + # Local development mode: only need the binary + Write-Info "Using local tarball: $LocalTgz" + + # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts) + if ($LocalBinary -and (Test-Path $LocalBinary)) { + Copy-Item -Path $LocalBinary -Destination (Join-Path $BinDir $binaryName) -Force + # Also copy trampoline shim binary if available (sibling to vp.exe) + $shimSource = Join-Path (Split-Path $LocalBinary) "vp-shim.exe" + if (Test-Path $shimSource) { + Copy-Item -Path $shimSource -Destination (Join-Path $BinDir "vp-shim.exe") -Force + } + } else { + Write-Error-Exit "VP_LOCAL_BINARY must be set when using VP_LOCAL_TGZ" + } + } else { + # Download CLI platform tarball — npm registry or registry bridge (when PrVersion is set) + $platformSuffix = Get-PlatformSuffix -Platform $platform + if ($PrVersion) { + # The registry bridge redirects this URL to the platform tarball for + # the matching commit build (0.0.0-commit.). + $platformUrl = "$BridgeDownloadBase/@voidzero-dev/vite-plus-cli-$platformSuffix@$PrVersion" + } else { + $packageName = "@voidzero-dev/vite-plus-cli-$platformSuffix" + $platformUrl = "$NpmRegistry/$packageName/-/vite-plus-cli-$platformSuffix-$ViteVersion.tgz" + } + + $platformTempFile = New-TemporaryFile + try { + Invoke-WebRequest -Uri $platformUrl -OutFile $platformTempFile + + # Create temp extraction directory + $platformTempExtract = Join-Path $env:TEMP "vite-platform-$(Get-Random)" + New-Item -ItemType Directory -Force -Path $platformTempExtract | Out-Null + + # Extract the package + & "$env:SystemRoot\System32\tar.exe" -xzf $platformTempFile -C $platformTempExtract + + # Copy binary to BinDir + $packageDir = Join-Path $platformTempExtract "package" + $binarySource = Join-Path $packageDir $binaryName + if (Test-Path $binarySource) { + Copy-Item -Path $binarySource -Destination $BinDir -Force + } + # Also copy trampoline shim binary if present in the package + $shimSource = Join-Path $packageDir "vp-shim.exe" + if (Test-Path $shimSource) { + Copy-Item -Path $shimSource -Destination $BinDir -Force + } + + Remove-Item -Recurse -Force $platformTempExtract + } finally { + Remove-Item $platformTempFile -ErrorAction SilentlyContinue + } + } + + # Remove Zone.Identifier (Mark of the Web) from downloaded binaries so + # Windows SmartScreen / Defender won't block execution. + Get-ChildItem -Path $BinDir -Filter "*.exe" | Unblock-File + + # Generate wrapper package.json that declares vite-plus as a dependency. + # pnpm will install vite-plus and all transitive deps via `vp install`. + # The packageManager field pins pnpm to a known-good version. + # In PR mode, pin vite-plus to the bridge's clearly-defined commit version and + # resolve it (plus its platform binaries and transitive deps) through the + # bridge registry written to .npmrc below. The bridge rewrites a preview + # tarball's transitive deps to versions, not self-contained URLs, so a full + # install must go through the registry rather than the bare download URL. + $vitePlusSpec = if ($PrVersion) { $PrCommitVersion } else { $ViteVersion } + if ($PrVersion) { + # Bridge registry; drop any stale wrapper lockfile (see install.sh for why): + # the reused pkg-pr-new- dir must re-resolve a lockfile matching the + # spec we just wrote, not fail under CI's frozen-lockfile default. + Set-Content -Path (Join-Path $VersionDir ".npmrc") -Value "registry=$BridgeRegistry" + Remove-Item -Path (Join-Path $VersionDir "pnpm-lock.yaml") -ErrorAction SilentlyContinue + } + $wrapperJson = @{ + name = "vp-global" + version = $ViteVersion + private = $true + packageManager = "pnpm@10.33.0" + dependencies = @{ + "vite-plus" = $vitePlusSpec + } + } | ConvertTo-Json -Depth 10 + Set-Content -Path (Join-Path $VersionDir "package.json") -Value $wrapperJson + + # Install production dependencies (skip if VP_SKIP_DEPS_INSTALL is set, + # e.g. during local dev where install-global-cli.ts handles deps separately) + if (-not $env:VP_SKIP_DEPS_INSTALL) { + $installLog = Join-Path $VersionDir "install.log" + Push-Location $VersionDir + try { + # Use cmd /c so CI=true is scoped to the child process only, + # avoiding leaking it into the user's shell session. + # Do not pass --silent to the inner install: pnpm suppresses the + # release-age error body in silent mode, which would leave + # install.log empty and make the release-age gate impossible to + # detect. Output is already captured to install.log here. + $output = cmd /c "set CI=true && `"$BinDir\vp.exe`" install" 2>&1 + $installExitCode = $LASTEXITCODE + $output | Out-File $installLog + if ($installExitCode -ne 0) { + if (Test-ReleaseAgeError $installLog) { + if (Confirm-ReleaseAgeOverride) { + # Write the override only after explicit consent, then retry once. + Write-ReleaseAgeOverride + $retryOutput = cmd /c "set CI=true && `"$BinDir\vp.exe`" install" 2>&1 + $retryExitCode = $LASTEXITCODE + $retryOutput | Out-File $installLog + if ($retryExitCode -ne 0) { + Write-InstallFailure -LogPath $installLog -ExitCode $retryExitCode + } + } else { + Write-ReleaseAgeFailure $installLog + Exit-Installer + } + } else { + Write-InstallFailure -LogPath $installLog -ExitCode $installExitCode + } + } + } finally { + Pop-Location + } + } + + # Create/update current junction (symlink) + Remove-CurrentLink $CurrentLink + # Create new junction pointing to the version directory + cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null + + # Create bin directory and vp wrapper (always done) + New-Item -ItemType Directory -Force -Path "$InstallDir\bin" | Out-Null + $trampolineSrc = "$VersionDir\bin\vp-shim.exe" + if (Test-Path $trampolineSrc) { + # New versions: use trampoline exe to avoid "Terminate batch job (Y/N)?" on Ctrl+C + Copy-Item -Path $trampolineSrc -Destination "$InstallDir\bin\vp.exe" -Force + # Remove legacy .cmd and shell script wrappers from previous versions + foreach ($legacy in @("$InstallDir\bin\vp.cmd", "$InstallDir\bin\vp")) { + if (Test-Path $legacy) { + Remove-Item -Path $legacy -Force -ErrorAction SilentlyContinue + } + } + } else { + # Pre-trampoline versions: fall back to legacy .cmd and shell script wrappers. + # Remove any stale trampoline .exe shims left by a newer install — .exe wins + # over .cmd on Windows PATH, so leftover trampolines would bypass the wrappers. + foreach ($stale in @("vp.exe", "node.exe", "npm.exe", "npx.exe", "corepack.exe", "vpx.exe", "vpr.exe")) { + $stalePath = Join-Path "$InstallDir\bin" $stale + if (Test-Path $stalePath) { + Remove-Item -Path $stalePath -Force -ErrorAction SilentlyContinue + } + } + # Keep consistent with the original install.ps1 wrapper format + $wrapperContent = @" +@echo off +set VP_HOME=%~dp0.. +"%VP_HOME%\current\bin\vp.exe" %* +exit /b %ERRORLEVEL% +"@ + Set-Content -Path "$InstallDir\bin\vp.cmd" -Value $wrapperContent -NoNewline + + # Also create shell script wrapper for Git Bash/MSYS + $shContent = @" +#!/bin/sh +VP_HOME="`$(dirname "`$(dirname "`$(readlink -f "`$0" 2>/dev/null || echo "`$0")")")" +export VP_HOME +exec "`$VP_HOME/current/bin/vp.exe" "`$@" +"@ + Set-Content -Path "$InstallDir\bin\vp" -Value $shContent -NoNewline + } + + # Cleanup old versions + Cleanup-OldVersions -InstallDir $InstallDir + + # Setup Node.js version manager (shims) - separate component + $nodeManagerResult = Setup-NodeManager -BinDir $BinDir + + Prompt-RemovePreviousInstallDir -PreviousInstallDir $previousInstallDir + + # Configure shell access after the install is otherwise complete. + $pathResult = Configure-UserPath + $nushellResult = Configure-Nushell + + # Use ~ shorthand if install dir is under USERPROFILE, otherwise show full path + $displayDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~' + + # ANSI color codes for consistent output + $e = [char]27 + $GREEN = "$e[32m" + $YELLOW = "$e[33m" + $BRIGHT_BLUE = "$e[94m" + $BOLD = "$e[1m" + $DIM = "$e[2m" + $BOLD_BRIGHT_BLUE = "$e[1;94m" + $NC = "$e[0m" + $CHECKMARK = [char]0x2714 + + # Print success message + Write-Host "" + Write-Host "${GREEN}${CHECKMARK}${NC} ${BOLD_BRIGHT_BLUE}VITE+${NC} successfully installed!" + Write-Host "" + Write-Host " The Unified Toolchain for the Web." + Write-Host "" + Write-Host " ${BOLD}Get started:${NC}" + Write-Host " ${BRIGHT_BLUE}vp create${NC} Create a new project" + Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + Write-Host " ${BRIGHT_BLUE}vp install${NC} Install dependencies" + Write-Host " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" + + # Show Node.js manager status + if ($nodeManagerResult -eq "true" -or $nodeManagerResult -eq "already") { + Write-Host "" + Write-Host " Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}." + Write-Host " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." + } + + Write-Host "" + Write-Host " Run ${BRIGHT_BLUE}vp help${NC} to see available commands." + + Write-Host "" + Write-Host " Shell configuration:" + switch ($pathResult) { + "true" { Write-Host " - Windows PATH: updated" } + "already" { Write-Host " - Windows PATH: already configured" } + "failed" { Write-Host " - Windows PATH: failed" } + default { Write-Host " - Windows PATH: skipped" } + } + if ($nushellResult.Status -ne "skipped") { + Write-Host " - Nushell: $($nushellResult.Message)" + } + + # Show note if PATH or Nushell was updated + if ($pathResult -eq "true" -or $nushellResult.Status -eq "true") { + Write-Host "" + Write-Host " Note: Restart your terminal and IDE for changes to take effect." + } + + # Show manual PATH/Nushell instructions if anything still needs manual setup + if ($pathResult -eq "failed" -or $nushellResult.Status -eq "failed") { + Write-Host "" + Write-Host " ${YELLOW}note${NC}: Some shells still need manual setup." + Write-Host "" + Write-Host " vp was installed to: ${BOLD}${displayDir}\bin${NC}" + Write-Host "" + if ($pathResult -eq "failed") { + Write-Host " To use vp in Powershell/cmd, manually add it to your PATH:" + Write-Host "" + Write-Host " [Environment]::SetEnvironmentVariable('Path', '$InstallDir\bin;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" + Write-Host "" + } + if ($nushellResult.Status -eq "failed") { + Write-Host " To use vp in Nushell, create a vite-plus.nu file in your preferred vendor autoload directory with:" + Write-Host "" + Write-Host " source '$displayDir\env.nu'" + Write-Host "" + } + Write-Host " Or run vp directly:" + Write-Host "" + Write-Host " & `"$InstallDir\bin\vp.exe`"" + } + + Write-Host "" +} + +try { + Main +} catch { + if (Test-IsInstallStopException $_) { + if (Test-ShouldKeepShellOpenAfterFailure) { + return + } + exit $global:LASTEXITCODE + } + throw +} diff --git a/packages/cli/legacy_install.sh b/packages/cli/legacy_install.sh new file mode 100644 index 0000000000..85c01cda1b --- /dev/null +++ b/packages/cli/legacy_install.sh @@ -0,0 +1,1280 @@ +#!/bin/bash +# LEGACY Vite+ CLI Installer — frozen copy of the pre-XDG install.sh. +# https://vite.plus +# +# This script installs CLIs that predate the split XDG directory layout +# (everything under ~/.vite-plus, driven by VP_HOME). It exists so the +# legacy install path stays testable; new development happens in +# install.sh, which targets the split layout and carries no +# backward-compatibility logic. +# +# Usage: +# curl -fsSL https://vite.plus/legacy_install.sh | bash +# +# Vite+ CLI Installer +# https://vite.plus +# +# Usage: +# curl -fsSL https://vite.plus | bash +# +# Environment variables: +# VP_VERSION - Version to install (default: latest) +# VP_HOME - Installation directory (default: ~/.vite-plus) +# NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) +# VP_NODE_MANAGER - Set to "yes" or "no" to skip interactive prompt (for CI/devcontainers) +# VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) +# VP_PR_VERSION - PR number or commit SHA to install from the registry bridge +# (for temporary testing of unreleased builds, e.g. VP_PR_VERSION=1569). +# When set, overrides VP_VERSION and installs the clearly-defined +# 0.0.0-commit. build through the bridge instead of npm. + +set -e + +VP_VERSION="${VP_VERSION:-latest}" +INSTALL_DIR="${VP_HOME:-$HOME/.vite-plus}" +# Use $HOME-relative path for shell config references (portable across sessions) +if case "$INSTALL_DIR" in "$HOME"/*) true;; *) false;; esac; then + INSTALL_DIR_REF_POSIX="\$HOME${INSTALL_DIR#"$HOME"}" + INSTALL_DIR_REF_NU="~${INSTALL_DIR#"$HOME"}" +else + INSTALL_DIR_REF_POSIX="$INSTALL_DIR" + INSTALL_DIR_REF_NU="$INSTALL_DIR" +fi +# npm registry URL (strip trailing slash if present) +NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" +NPM_REGISTRY="${NPM_REGISTRY%/}" +# Local tarball for development/testing +LOCAL_TGZ="${VP_LOCAL_TGZ:-}" +# Local binary path (set by install-global-cli.ts for local dev) +LOCAL_BINARY="${VP_LOCAL_BINARY:-}" +# PR number or commit SHA to install as a test build (registry bridge mode) +PR_VERSION="${VP_PR_VERSION:-}" +# Registry bridge that serves PR preview builds as clearly-versioned packages. +# The pkg.pr.new-style download URL (BRIDGE_DOWNLOAD_BASE) 302-redirects to a +# canonical 0.0.0-commit. tarball; the registry (BRIDGE_REGISTRY) resolves +# those commit versions (and proxies everything else to npmjs) so a full install +# pulls a coherent, clearly-defined test build. +BRIDGE_DOWNLOAD_BASE="https://registry-bridge.viteplus.dev/voidzero-dev/vite-plus" +BRIDGE_REGISTRY="https://registry-bridge.viteplus.dev/" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +BRIGHT_BLUE='\033[0;94m' +BOLD='\033[1m' +DIM='\033[2m' +BOLD_BRIGHT_BLUE='\033[1;94m' +NC='\033[0m' # No Color + +info() { + echo -e "${BLUE}info${NC}: $1" +} + +success() { + echo -e "${GREEN}success${NC}: $1" +} + +warn() { + echo -e "${YELLOW}warn${NC}: $1" +} + +error() { + echo -e "${RED}error${NC}: $1" + exit 1 +} + +is_release_age_error() { + local log_file="$1" + [ -f "$log_file" ] || return 1 + + # This wrapper install path is pinned to pnpm via packageManager, so this + # detection follows pnpm's resolver/reporter output rather than npm/yarn. + # + # pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so + # NO_MATURE_MATCHING_VERSION is normally printed as + # ERR_PNPM_NO_MATURE_MATCHING_VERSION. npm-resolver emits that code with the + # "does not meet the minimumReleaseAge constraint" message when + # publishedBy/minimumReleaseAge rejects a matching version. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 + # + # default-reporter may append guidance mentioning minimumReleaseAgeExclude + # when the error has an immatureVersion, so that token is also a useful + # release-age signal. minimum-release-age is pnpm's .npmrc key; npm's + # min-release-age is intentionally not treated as a pnpm signal here. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 + grep -Eqi 'ERR_PNPM_NO_MATURE_MATCHING_VERSION|NO_MATURE_MATCHING_VERSION|does not meet the minimumReleaseAge constraint|minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" && return 0 + + # pnpm can also surface ERR_PNPM_NO_MATCHING_VERSION when minimumReleaseAge + # filters out all candidates. That code is also used for real missing + # versions, so require age-gate context before prompting for a bypass. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 + if grep -Eq 'ERR_PNPM_NO_MATCHING_VERSION' "$log_file"; then + grep -Eqi 'minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" + return $? + fi + + return 1 +} + +confirm_release_age_override() { + [ -e /dev/tty ] && [ -t 1 ] || return 1 + + echo "" > /dev/tty + echo -e "${YELLOW}warn${NC}: Your minimumReleaseAge setting prevented installing vite-plus@${VP_VERSION}." > /dev/tty + echo "This setting helps protect against newly published compromised packages." > /dev/tty + echo "Proceeding will disable this protection for this Vite+ install only." > /dev/tty + printf "Do you want to proceed? (y/N): " > /dev/tty + + local response + read -r response < /dev/tty || return 1 + case "$response" in + y|Y|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +write_release_age_override() { + # Append idempotently so a bridge registry line written for PR builds survives. + if [ ! -f "$VERSION_DIR/.npmrc" ] || ! grep -q '^minimum-release-age=' "$VERSION_DIR/.npmrc" 2>/dev/null; then + printf 'minimum-release-age=0\n' >> "$VERSION_DIR/.npmrc" + fi +} + +normalize_existing_dir() { + local dir="${1%/}" + if [ -z "$dir" ]; then + dir="/" + fi + + if [ -d "$dir" ]; then + (cd "$dir" 2>/dev/null && pwd -P) || printf '%s\n' "$dir" + else + local base parent_dir + base="$(basename "$dir")" + parent_dir="$(cd "$(dirname "$dir")" 2>/dev/null && pwd -P)" || parent_dir="" + if [ -z "$parent_dir" ]; then + printf '%s\n' "$dir" + elif [ "$parent_dir" = "/" ]; then + printf '/%s\n' "$base" + else + printf '%s/%s\n' "$parent_dir" "$base" + fi + fi +} + +is_safe_install_dir_to_remove() { + local dir="$1" + [ -n "$dir" ] || return 1 + + case "$dir" in + "/" | "$HOME" | "/bin" | "/opt" | "/usr" | "/usr/bin" | "/usr/local" | "/usr/local/bin") + return 1 + ;; + esac + + return 0 +} + +is_vite_plus_install_dir() { + local dir="$1" + [ -d "$dir" ] || return 1 + [ -d "$dir/bin" ] || return 1 + [ -e "$dir/current" ] || return 1 + [ -e "$dir/bin/vp" ] || [ -e "$dir/bin/vp.exe" ] || [ -e "$dir/bin/vp.cmd" ] +} + +detect_previous_install_dir() { + [ -n "${VP_HOME:-}" ] || return 1 + + local vp_path + vp_path="$(command -v vp 2>/dev/null || true)" + [ -n "$vp_path" ] || return 1 + + case "$(basename "$vp_path")" in + vp | vp.exe | vp.cmd) ;; + *) return 1 ;; + esac + + local old_dir install_dir + old_dir="$(normalize_existing_dir "$(dirname "$(dirname "$vp_path")")")" + install_dir="$(normalize_existing_dir "$INSTALL_DIR")" + [ "$old_dir" != "$install_dir" ] || return 1 + + is_safe_install_dir_to_remove "$old_dir" || return 1 + is_vite_plus_install_dir "$old_dir" || return 1 + + printf '%s\n' "$old_dir" +} + +is_nested_install_dir() { + [ -n "$1" ] && [ -n "$2" ] || return 1 + + local old_dir install_dir + old_dir="$(normalize_existing_dir "$1")" + install_dir="$(normalize_existing_dir "$2")" + + [ "$old_dir" != "$install_dir" ] || return 1 + if [ "$old_dir" = "/" ] || [ "$install_dir" = "/" ]; then + return 0 + fi + + case "$old_dir" in + "$install_dir"/*) return 0 ;; + esac + case "$install_dir" in + "$old_dir"/*) return 0 ;; + esac + + return 1 +} + +prompt_remove_previous_install_dir() { + local old_dir="$1" + [ -n "$old_dir" ] || return 0 + [ -z "${CI:-}" ] || return 0 + [ -e /dev/tty ] && [ -t 1 ] || return 0 + + echo "" > /dev/tty + echo -e "${YELLOW}warn${NC}: Found a previous Vite+ install at $old_dir." > /dev/tty + echo "The new VP_HOME is $INSTALL_DIR." > /dev/tty + printf "Remove the previous install directory? (y/N): " > /dev/tty + + local response + read -r response < /dev/tty || return 0 + case "$response" in + y | Y | yes | YES) + local vp_bin="$old_dir/current/bin/vp" + if [ ! -f "$vp_bin" ]; then + vp_bin="$old_dir/current/bin/vp.exe" + fi + if [ ! -f "$vp_bin" ]; then + warn "Could not remove previous Vite+ install at $old_dir: vp binary not found." + return 0 + fi + + local implode_output + if implode_output=$(VP_HOME="$old_dir" "$vp_bin" implode --yes 2>&1); then + success "Removed previous Vite+ install at $old_dir." + else + warn "Could not remove previous Vite+ install at $old_dir." + if [ -n "$implode_output" ]; then + printf '%s\n' "$implode_output" >&2 + fi + fi + ;; + esac +} + +# Resolve a PR number or commit SHA to the registry bridge's immutable commit +# version (0.0.0-commit.). A full commit SHA maps directly to the bridge's +# deterministic version; a PR number (or short ref) is resolved via the bridge +# download URL's `x-commit-key: ::` header (HEAD). +resolve_bridge_commit_version() { + local ref="$1" + local sha="$ref" + if [[ ! "$ref" =~ ^[0-9a-fA-F]{40}$ ]]; then + sha="$(curl -fsSIL "${BRIDGE_DOWNLOAD_BASE}@${ref}" 2>/dev/null | tr -d '\r' | awk -F ': ' ' + tolower($1) == "x-commit-key" { count = split($2, parts, ":"); print parts[count]; exit }')" + fi + case "$sha" in + '' | *[!0-9a-fA-F]*) return 1 ;; + esac + [ "${#sha}" -eq 40 ] || return 1 + printf '0.0.0-commit.%s' "$sha" +} + +print_install_failure() { + local install_log="$1" + if [ "${CI:-}" = "true" ]; then + echo -e "${RED}error${NC}: Failed to install dependencies. Log output:" + cat "$install_log" + else + echo -e "${RED}error${NC}: Failed to install dependencies. See log for details: $install_log" + fi +} + +print_release_age_failure() { + local install_log="$1" + if [ "${CI:-}" = "true" ]; then + echo -e "${RED}error${NC}: Install blocked by your minimumReleaseAge setting. Log output:" + cat "$install_log" + else + echo -e "${RED}error${NC}: Install blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly. See log for details: $install_log" + fi +} + +# Print user-friendly error message for curl failures +# Arguments: exit_code url +print_curl_error() { + local exit_code="$1" + local url="$2" + + # Map curl exit codes to user-friendly messages + local error_desc + case $exit_code in + 6) + error_desc="DNS resolution failed - could not resolve hostname" + ;; + 7) + error_desc="Connection refused - the server may be down or unreachable" + ;; + 28) + error_desc="Connection timed out" + ;; + 35) + error_desc="SSL/TLS connection error" + ;; + 60) + error_desc="SSL certificate verification failed" + ;; + *) + error_desc="Network error" + ;; + esac + + echo "" + echo -e "${RED}error${NC}: ${error_desc} (curl exit code ${exit_code})" + echo "" + echo " This may be caused by:" + echo " - Network connectivity issues" + echo " - Firewall or proxy blocking the connection" + echo " - DNS configuration problems" + if [ $exit_code -eq 35 ] || [ $exit_code -eq 60 ]; then + echo " - Outdated SSL/TLS libraries" + fi + echo "" + if [ -n "$url" ]; then + echo " Failed URL: $url" + echo "" + echo " To debug, run:" + echo " curl -v \"$url\"" + echo "" + fi + exit 1 +} + +# Wrapper for curl with user-friendly error messages +# Arguments: same as curl +# Returns: exits with error message on failure, otherwise returns curl output +curl_with_error_handling() { + local url="" + local args=() + + # Parse arguments to find the URL (for error messages) + for arg in "$@"; do + case "$arg" in + http://*|https://*) + url="$arg" + ;; + esac + args+=("$arg") + done + + # Run curl and capture exit code + set +e + local output exit_code + output=$(curl "${args[@]}" 2>&1) + exit_code=$? + set -e + + if [ $exit_code -eq 0 ]; then + echo "$output" + return 0 + fi + + print_curl_error "$exit_code" "$url" +} + +# Detect libc type on Linux (gnu or musl) +detect_libc() { + # Prefer positive glibc detection first. + # This avoids false musl detection on systems where musl is installed + # but the distro itself is glibc-based (common on WSL/Ubuntu). + if command -v getconf &> /dev/null; then + if getconf GNU_LIBC_VERSION > /dev/null 2>&1; then + echo "gnu" + return + fi + fi + + # Check ldd output for musl/glibc + if command -v ldd &> /dev/null; then + ldd_out="$(ldd --version 2>&1 || true)" + if echo "$ldd_out" | grep -qi musl; then + echo "musl" + return + fi + if echo "$ldd_out" | grep -qi 'gnu libc'; then + echo "gnu" + return + fi + if echo "$ldd_out" | grep -qi 'glibc'; then + echo "gnu" + return + fi + fi + + # Final fallback: musl loader present usually indicates musl-based distro, + # but only check this after glibc detection to avoid false positives. + if [ -e /lib/ld-musl-x86_64.so.1 ] || [ -e /lib/ld-musl-aarch64.so.1 ]; then + echo "musl" + else + echo "gnu" + fi +} + +# Detect platform +detect_platform() { + local os arch + + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + MINGW*|MSYS*|CYGWIN*) os="win32" ;; + *) error "Unsupported operating system: $os" ;; + esac + + case "$arch" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) error "Unsupported architecture: $arch" ;; + esac + + # For Linux, append libc type to distinguish gnu vs musl + if [ "$os" = "linux" ]; then + local libc + libc=$(detect_libc) + echo "${os}-${arch}-${libc}" + else + echo "${os}-${arch}" + fi +} + +# Check for required commands +check_requirements() { + local missing=() + + if ! command -v curl &> /dev/null; then + missing+=("curl") + fi + + if ! command -v tar &> /dev/null; then + missing+=("tar") + fi + + if [ ${#missing[@]} -ne 0 ]; then + error "Missing required commands: ${missing[*]}" + fi +} + +# Fetch package metadata from npm registry (cached for reuse) +# Uses VP_VERSION to fetch the correct version's metadata +PACKAGE_METADATA="" +fetch_package_metadata() { + if [ -z "$PACKAGE_METADATA" ]; then + local version_path metadata_url + if [ "$VP_VERSION" = "latest" ]; then + version_path="latest" + else + version_path="$VP_VERSION" + fi + metadata_url="${NPM_REGISTRY}/vite-plus/${version_path}" + PACKAGE_METADATA=$(curl_with_error_handling -s "$metadata_url") + if [ -z "$PACKAGE_METADATA" ]; then + error "Failed to fetch package metadata from: $metadata_url" + fi + # Check for npm registry error response + # npm can return either {"error":"..."} or a plain JSON string like "version not found: test" + if echo "$PACKAGE_METADATA" | grep -q '"error"'; then + local error_msg + error_msg=$(echo "$PACKAGE_METADATA" | grep -o '"error" *: *"[^"]*"' | cut -d'"' -f4) + error "Failed to fetch version '${version_path}': ${error_msg:-unknown error}\n URL: $metadata_url" + fi + # Check if response is a plain error string (not a valid package object) + # Use '"version":' to match JSON property, not just the word "version" + if ! echo "$PACKAGE_METADATA" | grep -q '"version" *:'; then + # Remove surrounding quotes from the error message if present + local error_msg + error_msg=$(echo "$PACKAGE_METADATA" | sed 's/^"//;s/"$//') + error "Failed to fetch version '${version_path}': ${error_msg:-unknown error}\n URL: $metadata_url" + fi + fi + # PACKAGE_METADATA is set as a global variable, no need to echo +} + +# Get the version from package metadata +# Sets RESOLVED_VERSION global variable +get_version_from_metadata() { + # Call fetch_package_metadata to populate PACKAGE_METADATA global + # Don't use command substitution as it would swallow the exit from error() + fetch_package_metadata + RESOLVED_VERSION=$(echo "$PACKAGE_METADATA" | grep -o '"version" *: *"[^"]*"' | head -1 | cut -d'"' -f4) + if [ -z "$RESOLVED_VERSION" ]; then + error "Failed to extract version from package metadata" + fi +} + +# Get platform suffix for CLI package download +# Sets PLATFORM_SUFFIX global variable +# Platform format from detect_platform(): darwin-arm64, darwin-x64, linux-x64-gnu, linux-arm64-gnu, win32-x64, etc. +# CLI package format: @voidzero-dev/vite-plus-cli-darwin-arm64, @voidzero-dev/vite-plus-cli-linux-x64-gnu, etc. +get_platform_suffix() { + local platform="$1" + case "$platform" in + win32-*) PLATFORM_SUFFIX="${platform}-msvc" ;; # Windows needs -msvc suffix + *) PLATFORM_SUFFIX="$platform" ;; # macOS/Linux map directly + esac +} + +# Download and extract file (silent mode - no progress bar) +download_and_extract() { + local url="$1" + local dest_dir="$2" + local strip_components="$3" + local filter="$4" + + # Download to temp file (silent mode) + local temp_file + temp_file=$(mktemp) + + # Run curl and capture exit code for error handling + set +e + curl -sL "$url" -o "$temp_file" + local exit_code=$? + set -e + + if [ $exit_code -ne 0 ]; then + rm -f "$temp_file" + print_curl_error "$exit_code" "$url" + fi + + if [ -n "$filter" ]; then + tar xzf "$temp_file" -C "$dest_dir" --strip-components="$strip_components" "$filter" 2>/dev/null || \ + tar xzf "$temp_file" -C "$dest_dir" --strip-components="$strip_components" + else + tar xzf "$temp_file" -C "$dest_dir" --strip-components="$strip_components" + fi + rm -f "$temp_file" +} + +join_by() { + local separator="$1" + shift + local result="" + local item + + for item in "$@"; do + if [ -z "$result" ]; then + result="$item" + else + result="${result}${separator}${item}" + fi + done + + printf '%s\n' "$result" +} + +abbreviate_path() { + local path="$1" + if [ "${path#"$HOME"}" != "$path" ]; then + printf '~%s\n' "${path#"$HOME"}" + else + printf '%s\n' "$path" + fi +} + +record_shell_summary() { + local shell_name="$1" + local status="$2" + SHELL_CONFIG_SUMMARY+=(" - ${shell_name}: ${status}") +} + +# Add a sourcing line to an existing shell config file. +# Returns: 0 = line added, 1 = file missing, 2 = already configured, 3 = failed +append_source_to_file() { + local shell_config="$1" + local source_line="$2" + shift 2 + local search_patterns=("$@") + local pattern + + if [ ! -f "$shell_config" ]; then + return 1 + fi + + if [ ! -w "$shell_config" ]; then + warn "Cannot write to $shell_config (permission denied), skipping." + return 3 + fi + + for pattern in "${search_patterns[@]}"; do + if grep -Fq "$pattern" "$shell_config" 2>/dev/null; then + return 2 + fi + done + + echo "" >> "$shell_config" + echo "# Vite+ bin (https://viteplus.dev)" >> "$shell_config" + echo "$source_line" >> "$shell_config" + return 0 +} + +# Create or update an installer-managed snippet file. +# Returns: 0 = written, 2 = already configured, 3 = failed +write_managed_snippet() { + local snippet_file="$1" + local snippet_content="$2" + local snippet_dir + + snippet_dir=$(dirname "$snippet_file") + if ! mkdir -p "$snippet_dir" 2>/dev/null; then + warn "Cannot create $snippet_dir, skipping." + return 3 + fi + + if [ -f "$snippet_file" ] && [ ! -w "$snippet_file" ]; then + warn "Cannot write to $snippet_file (permission denied), skipping." + return 3 + fi + + if [ -f "$snippet_file" ] && printf '%s' "$snippet_content" | cmp -s - "$snippet_file"; then + return 2 + fi + + if ! printf '%s' "$snippet_content" > "$snippet_file"; then + warn "Cannot write to $snippet_file, skipping." + return 3 + fi + return 0 +} + +# Discover Nushell's preferred user-local vendor autoload directory. +# Nushell puts the user-local directory at the end of the list. +discover_nushell_vendor_autoload_dir() { + command -v nu > /dev/null 2>&1 || return 1 + + local nu_dirs_output + nu_dirs_output=$(nu -c '$nu.vendor-autoload-dirs | reverse | each {|dir| $dir } | str join (char nl)' 2>/dev/null) || return 1 + + while IFS= read -r dir; do + [ -n "$dir" ] || continue + printf '%s\n' "$dir" + return 0 + done </dev/null; then + warn "Cannot create $zsh_dir, skipping zsh." + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("zsh") + record_shell_summary "zsh" "failed (could not create $(abbreviate_path "$zsh_dir"))" + return + fi + + if [ ! -f "$zshenv" ] && ! touch "$zshenv" 2>/dev/null; then + warn "Cannot create $zshenv, skipping zsh." + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("zsh") + record_shell_summary "zsh" "failed (could not create $(abbreviate_path "$zshenv"))" + return + fi + + result=0 + append_source_to_file "$zshenv" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + case "$result" in + 0) updated+=("$(abbreviate_path "$zshenv")") ;; + 2) already+=("$(abbreviate_path "$zshenv")") ;; + 3) failed+=("$(abbreviate_path "$zshenv")") ;; + esac + + if [ -f "$zshrc" ]; then + result=0 + append_source_to_file "$zshrc" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + case "$result" in + 0) updated+=("$(abbreviate_path "$zshrc")") ;; + 2) already+=("$(abbreviate_path "$zshrc")") ;; + 3) failed+=("$(abbreviate_path "$zshrc")") ;; + esac + fi + + local details=() + if [ ${#updated[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_UPDATED="true" + SHELL_CONFIG_HAS_CONFIGURED="true" + details+=("updated $(join_by ', ' "${updated[@]}")") + fi + if [ ${#already[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_CONFIGURED="true" + details+=("already configured $(join_by ', ' "${already[@]}")") + fi + if [ ${#failed[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("zsh") + details+=("failed $(join_by ', ' "${failed[@]}")") + fi + + if [ ${#details[@]} -eq 0 ]; then + record_shell_summary "zsh" "skipped" + else + record_shell_summary "zsh" "$(join_by '; ' "${details[@]}")" + fi +} + +configure_bash_path() { + local updated=() + local already=() + local failed=() + local existing=0 + local file result + + for file in "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do + if [ ! -f "$file" ]; then + continue + fi + existing=1 + result=0 + append_source_to_file "$file" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + case "$result" in + 0) updated+=("$(abbreviate_path "$file")") ;; + 2) already+=("$(abbreviate_path "$file")") ;; + 3) failed+=("$(abbreviate_path "$file")") ;; + esac + done + + if [ "$existing" -eq 0 ]; then + record_shell_summary "bash" "skipped (no existing rc files)" + return + fi + + local details=() + if [ ${#updated[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_UPDATED="true" + SHELL_CONFIG_HAS_CONFIGURED="true" + details+=("updated $(join_by ', ' "${updated[@]}")") + fi + if [ ${#already[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_CONFIGURED="true" + details+=("already configured $(join_by ', ' "${already[@]}")") + fi + if [ ${#failed[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("bash") + details+=("failed $(join_by ', ' "${failed[@]}")") + fi + + record_shell_summary "bash" "$(join_by '; ' "${details[@]}")" +} + +configure_fish_path() { + local fish_config="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish" + local fish_content="# Vite+ bin (https://viteplus.dev) +source \"$INSTALL_DIR_REF_POSIX/env.fish\" +" + + local result=0 + write_managed_snippet "$fish_config" "$fish_content" || result=$? + case "$result" in + 0) + SHELL_CONFIG_HAS_UPDATED="true" + SHELL_CONFIG_HAS_CONFIGURED="true" + record_shell_summary "fish" "updated $(abbreviate_path "$fish_config")" + ;; + 2) + SHELL_CONFIG_HAS_CONFIGURED="true" + record_shell_summary "fish" "already configured $(abbreviate_path "$fish_config")" + ;; + *) + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("fish") + record_shell_summary "fish" "failed $(abbreviate_path "$fish_config")" + ;; + esac +} + +configure_nushell_path() { + local nushell_dir + nushell_dir=$(discover_nushell_vendor_autoload_dir 2>/dev/null) || true + if [ -z "$nushell_dir" ]; then + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("nushell") + record_shell_summary "nushell" "failed (could not determine vendor autoload dir)" + return + fi + + local nushell_autoload="$nushell_dir/vite-plus.nu" + local nushell_content="# Vite+ bin (https://viteplus.dev) +source '$INSTALL_DIR_REF_NU/env.nu' +" + + local result=0 + write_managed_snippet "$nushell_autoload" "$nushell_content" || result=$? + case "$result" in + 0) + SHELL_CONFIG_HAS_UPDATED="true" + SHELL_CONFIG_HAS_CONFIGURED="true" + record_shell_summary "nushell" "updated $(abbreviate_path "$nushell_autoload")" + ;; + 2) + SHELL_CONFIG_HAS_CONFIGURED="true" + record_shell_summary "nushell" "already configured $(abbreviate_path "$nushell_autoload")" + ;; + *) + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("nushell") + record_shell_summary "nushell" "failed $(abbreviate_path "$nushell_autoload")" + ;; + esac +} + +# Configure supported shell PATH integrations for all installed shells. +configure_shell_path() { + SHELL_CONFIG_SUMMARY=() + SHELL_CONFIG_FAILED_SHELLS=() + SHELL_CONFIG_HAS_UPDATED="false" + SHELL_CONFIG_HAS_CONFIGURED="false" + SHELL_CONFIG_HAS_FAILURE="false" + + if command -v zsh > /dev/null 2>&1; then + configure_zsh_path + else + record_shell_summary "zsh" "skipped (not installed)" + fi + + if command -v bash > /dev/null 2>&1; then + configure_bash_path + else + record_shell_summary "bash" "skipped (not installed)" + fi + + if command -v fish > /dev/null 2>&1; then + configure_fish_path + else + record_shell_summary "fish" "skipped (not installed)" + fi + + if command -v nu > /dev/null 2>&1; then + configure_nushell_path + else + record_shell_summary "nushell" "skipped (not installed)" + fi +} + +# Run vp env setup --refresh, showing output only on failure +# Arguments: vp_bin - path to the vp binary +refresh_shims() { + local vp_bin="$1" + local setup_output + if ! setup_output=$("$vp_bin" env setup --refresh 2>&1); then + warn "Failed to refresh shims:" + echo "$setup_output" >&2 + fi +} + +# Setup Node.js version manager (node/npm/npx/corepack shims) +# Sets NODE_MANAGER_ENABLED global +# Arguments: bin_dir - path to the version's bin directory containing vp +setup_node_manager() { + local bin_dir="$1" + local bin_path="$INSTALL_DIR/bin" + NODE_MANAGER_ENABLED="false" + + # Resolve vp binary name (vp on Unix, vp.exe on Windows) + local vp_bin="$bin_dir/vp" + if [ -f "$bin_dir/vp.exe" ]; then + vp_bin="$bin_dir/vp.exe" + fi + + # Explicit override via environment variable + if [ "$VP_NODE_MANAGER" = "yes" ]; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="true" + return 0 + elif [ "$VP_NODE_MANAGER" = "no" ]; then + NODE_MANAGER_ENABLED="false" + return 0 + fi + + # Check if Vite+ is already managing Node.js (bin/node or bin/node.exe exists) + if [ -e "$bin_path/node" ] || [ -e "$bin_path/node.exe" ]; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="already" + return 0 + fi + + # Auto-enable on CI or devcontainer environments + # CI: standard CI environment variable (GitHub Actions, Travis, CircleCI, etc.) + # CODESPACES: set by GitHub Codespaces (https://docs.github.com/en/codespaces) + # REMOTE_CONTAINERS: set by VS Code Dev Containers extension + # DEVPOD: set by DevPod (https://devpod.sh) + if [ -n "$CI" ] || [ -n "$CODESPACES" ] || [ -n "$REMOTE_CONTAINERS" ] || [ -n "$DEVPOD" ]; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="true" + return 0 + fi + + # Check if node is available on the system + local node_available="false" + if command -v node &> /dev/null; then + node_available="true" + fi + + # Auto-enable if no node available on system + if [ "$node_available" = "false" ]; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="true" + return 0 + fi + + # Prompt user in interactive mode + if [ -e /dev/tty ] && [ -t 1 ]; then + echo "" + echo "Would you like Vite+ to manage your Node.js versions?" + echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/ and automatically uses the right version." + echo "Opt out anytime with \`vp env off\`." + echo -n "Press Enter to accept (Y/n): " + read -r response < /dev/tty + + if [ -z "$response" ] || [ "$response" = "y" ] || [ "$response" = "Y" ]; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="true" + fi + fi +} + +# Cleanup old versions, keeping only the most recent ones +cleanup_old_versions() { + local max_versions=3 + local versions=() + + # List version directories (semver format like 0.1.0, 1.2.3-beta.1, 0.0.0-f48af939.20260205-0533) + # This excludes 'current' symlink and non-semver directories like 'local-dev' + local semver_regex='^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$' + for dir in "$INSTALL_DIR"/*/; do + local name + name=$(basename "$dir") + if [ -d "$dir" ] && [[ "$name" =~ $semver_regex ]]; then + versions+=("$dir") + fi + done + + local count=${#versions[@]} + if [ "$count" -le "$max_versions" ]; then + return 0 + fi + + # Sort by creation time (oldest first) and delete excess + local sorted_versions + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS: use stat -f %B for birth time + sorted_versions=$(for v in "${versions[@]}"; do + echo "$(stat -f %B "$v") $v" + done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-) + else + # Linux: use stat -c %W for birth time, fallback to %Y (mtime) + sorted_versions=$(for v in "${versions[@]}"; do + local btime + btime=$(stat -c %W "$v" 2>/dev/null) + if [ "$btime" = "0" ] || [ -z "$btime" ]; then + btime=$(stat -c %Y "$v") + fi + echo "$btime $v" + done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-) + fi + + # Delete oldest versions (silently) + for old_version in $sorted_versions; do + rm -rf "$old_version" + done +} + +main() { + echo "" + echo -e "Setting up VITE+..." + + if [ -n "$PR_VERSION" ] && [ -n "$LOCAL_TGZ" ]; then + error "VP_PR_VERSION and VP_LOCAL_TGZ cannot be used together" + fi + + check_requirements + + local previous_install_dir + previous_install_dir="$(detect_previous_install_dir || true)" + if [ -n "$previous_install_dir" ] && is_nested_install_dir "$previous_install_dir" "$INSTALL_DIR"; then + error "Previous Vite+ install at $previous_install_dir overlaps with VP_HOME $INSTALL_DIR. Choose a separate VP_HOME or remove the previous install first." + fi + + local platform + platform=$(detect_platform) + + # Local development mode: use local tgz + if [ -n "$LOCAL_TGZ" ]; then + # Validate local tgz + if [ ! -f "$LOCAL_TGZ" ]; then + error "Local tarball not found: $LOCAL_TGZ" + fi + # Use version as-is (default to "local-dev") + if [ "$VP_VERSION" = "latest" ] || [ "$VP_VERSION" = "test" ]; then + VP_VERSION="local-dev" + fi + elif [ -n "$PR_VERSION" ]; then + # Registry bridge mode: resolve the requested PR/SHA to the bridge's + # immutable commit version (0.0.0-commit.), the clearly-defined test + # version we install. The directory label stays non-semver so it keeps out + # of cleanup_old_versions and makes the PR build obvious in `~/.vite-plus/`. + # `|| true` keeps `set -e` from aborting this assignment when resolution + # fails (unregistered ref / transient bridge error), so the actionable + # error below is reachable instead of the installer exiting silently. + PR_COMMIT_VERSION="$(resolve_bridge_commit_version "$PR_VERSION" || true)" + if [ -z "$PR_COMMIT_VERSION" ]; then + error "Could not resolve a registry bridge build for ${PR_VERSION}" + fi + VP_VERSION="pkg-pr-new-${PR_VERSION}" + info "Using registry bridge build: ${PR_COMMIT_VERSION}" + else + # Fetch package metadata and resolve version from npm + get_version_from_metadata + VP_VERSION="$RESOLVED_VERSION" + fi + + # Set up version-specific directories + VERSION_DIR="$INSTALL_DIR/$VP_VERSION" + BIN_DIR="$VERSION_DIR/bin" + CURRENT_LINK="$INSTALL_DIR/current" + + local binary_name="vp" + if [[ "$platform" == win32* ]]; then + binary_name="vp.exe" + fi + + # Create bin directory + mkdir -p "$BIN_DIR" + + if [ -n "$LOCAL_TGZ" ]; then + # Local development mode: only need the binary + info "Using local tarball: $LOCAL_TGZ" + + # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts) + if [ -n "$LOCAL_BINARY" ]; then + cp "$LOCAL_BINARY" "$BIN_DIR/$binary_name" + # On Windows, also copy the trampoline shim binary if available + if [[ "$platform" == win32* ]]; then + local shim_src + shim_src="$(dirname "$LOCAL_BINARY")/vp-shim.exe" + if [ -f "$shim_src" ]; then + cp "$shim_src" "$BIN_DIR/vp-shim.exe" + fi + fi + else + error "VP_LOCAL_BINARY must be set when using VP_LOCAL_TGZ" + fi + chmod +x "$BIN_DIR/$binary_name" + else + # Download CLI platform tarball — npm registry or registry bridge (when PR_VERSION is set) + get_platform_suffix "$platform" + local platform_url + if [ -n "$PR_VERSION" ]; then + # The registry bridge redirects this URL to the platform tarball for the + # matching commit build (0.0.0-commit.). + platform_url="${BRIDGE_DOWNLOAD_BASE}/@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}@${PR_VERSION}" + else + local package_name="@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}" + platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PLATFORM_SUFFIX}-${VP_VERSION}.tgz" + fi + + # Create temp directory for extraction + local platform_temp_dir + platform_temp_dir=$(mktemp -d) + download_and_extract "$platform_url" "$platform_temp_dir" 1 + + # Copy binary to BIN_DIR + cp "$platform_temp_dir/$binary_name" "$BIN_DIR/" + chmod +x "$BIN_DIR/$binary_name" + # On Windows, also copy the trampoline shim binary if present in the package + if [[ "$platform" == win32* ]] && [ -f "$platform_temp_dir/vp-shim.exe" ]; then + cp "$platform_temp_dir/vp-shim.exe" "$BIN_DIR/" + fi + rm -rf "$platform_temp_dir" + fi + + # Generate wrapper package.json that declares vite-plus as a dependency. + # pnpm will install vite-plus and all transitive deps via `vp install`. + # The packageManager field pins pnpm to a known-good version, ensuring + # consistent behavior regardless of the user's global pnpm version. + # In PR mode, pin vite-plus to the bridge's clearly-defined commit version and + # resolve it (plus its platform binaries and transitive deps) through the + # bridge registry written to .npmrc below. The bridge rewrites a preview + # tarball's transitive deps to versions, not self-contained URLs, so a full + # install must go through the registry rather than the bare download URL. + local vite_plus_spec="$VP_VERSION" + if [ -n "$PR_VERSION" ]; then + vite_plus_spec="$PR_COMMIT_VERSION" + # Resolve the commit version + platform binaries through the bridge. Drop any + # stale wrapper lockfile: the pkg-pr-new- dir is reused across a PR's + # commits and install.sh rewrites this package.json each run, so a leftover + # lockfile pinning a prior spec would fail `vp install` with + # ERR_PNPM_OUTDATED_LOCKFILE under CI's frozen-lockfile default. Removing it + # lets the install regenerate a lockfile matching the spec we just wrote. + printf 'registry=%s\n' "$BRIDGE_REGISTRY" > "$VERSION_DIR/.npmrc" + rm -f "$VERSION_DIR/pnpm-lock.yaml" + fi + cat > "$VERSION_DIR/package.json" < "$install_log" 2>&1); then + if is_release_age_error "$install_log"; then + if confirm_release_age_override; then + # Write the override only after explicit consent, then retry once. + write_release_age_override + if ! (cd "$VERSION_DIR" && CI=true "$vp_install_bin" install > "$install_log" 2>&1); then + print_install_failure "$install_log" + exit 1 + fi + else + print_release_age_failure "$install_log" + exit 1 + fi + else + print_install_failure "$install_log" + exit 1 + fi + fi + fi + + # Create/update current symlink (use relative path for portability) + ln -sfn "$VP_VERSION" "$CURRENT_LINK" + + # Create bin directory and vp entrypoint (always done) + mkdir -p "$INSTALL_DIR/bin" + if [[ "$platform" == win32* ]]; then + # Windows: copy trampoline as vp.exe (matching install.ps1) + if [ -f "$INSTALL_DIR/current/bin/vp-shim.exe" ]; then + cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$INSTALL_DIR/bin/vp.exe" + fi + else + # Unix: symlink to current/bin/vp + ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" + fi + + # Cleanup old versions + cleanup_old_versions + + # Create env files with PATH guard (prevents duplicate PATH entries) + # Use current/bin/vp directly (the real binary) instead of bin/vp (trampoline) + # to avoid the self-overwrite issue on Windows during --refresh + local vp_bin="$INSTALL_DIR/current/bin/vp" + if [[ "$platform" == win32* ]]; then + vp_bin="$INSTALL_DIR/current/bin/vp.exe" + fi + "$vp_bin" env setup --env-only > /dev/null + + # Setup Node.js version manager (shims) - separate component + setup_node_manager "$BIN_DIR" + + prompt_remove_previous_install_dir "$previous_install_dir" + + # Configure shell PATH after the install is otherwise complete. + configure_shell_path + + # Use ~ shorthand if install dir is under HOME, otherwise show full path + local display_dir="${INSTALL_DIR/#$HOME/~}" + local display_location="${display_dir}/bin" + + # Print success message + echo "" + echo -e "${GREEN}✔${NC} ${BOLD_BRIGHT_BLUE}VITE+${NC} successfully installed!" + echo "" + echo " The Unified Toolchain for the Web." + echo "" + echo -e " ${BOLD}Get started:${NC}" + echo -e " ${BRIGHT_BLUE}vp create${NC} Create a new project" + echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + echo -e " ${BRIGHT_BLUE}vp install${NC} Install dependencies" + echo -e " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" + + if [ "$NODE_MANAGER_ENABLED" = "true" ] || [ "$NODE_MANAGER_ENABLED" = "already" ]; then + echo "" + echo -e " Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}." + echo -e " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." + fi + + echo "" + echo -e " Run ${BRIGHT_BLUE}vp help${NC} to see available commands." + + echo "" + echo " Shell configuration:" + local summary_line + for summary_line in "${SHELL_CONFIG_SUMMARY[@]}"; do + echo "$summary_line" + done + + # Show restart note if any shell config was updated + if [ "$SHELL_CONFIG_HAS_UPDATED" = "true" ]; then + echo "" + echo " Note: Restart your terminal to load updated shell configuration." + fi + + # Show manual PATH instructions if no shell was configured or any shell failed + if [ "$SHELL_CONFIG_HAS_CONFIGURED" = "false" ] || [ "$SHELL_CONFIG_HAS_FAILURE" = "true" ]; then + echo "" + echo -e " ${YELLOW}note${NC}: Some shells still need manual setup." + echo "" + echo -e " vp was installed to: ${BOLD}${display_location}${NC}" + echo "" + echo " Manual setup instructions:" + echo " - Bash/Zsh: add the following to your shell config (~/.bashrc, ~/.zshrc, etc.):" + echo " . \"$INSTALL_DIR_REF_POSIX/env\"" + echo " - Fish: create ${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish with:" + echo " source \"$INSTALL_DIR_REF_POSIX/env.fish\"" + echo " - Nushell: create a vendor autoload file with:" + echo " source '$INSTALL_DIR_REF_NU/env.nu'" + echo "" + echo " Or run vp directly:" + echo "" + echo -e " ${display_location}/vp" + fi + + echo "" +} + +main "$@" From 9a9b54d0c45764475dcbc9ec41632e16a179db5c Mon Sep 17 00:00:00 2001 From: yii Date: Fri, 7 Aug 2026 01:24:51 +0800 Subject: [PATCH 3/5] ci: cover legacy and split install paths Point standalone install CI at the frozen legacy installers so released pre-split CLIs keep installing correctly. Serve those scripts from the docs site, and run a split-layout install e2e against the registry-bridge preview build on same-repo PRs. --- .github/workflows/deploy-docs-preview.yml | 2 ++ .github/workflows/deploy-docs.yml | 2 ++ .github/workflows/publish-preview.yml | 33 +++++++++++++++++++ .github/workflows/test-standalone-install.yml | 29 ++++++++++------ docs/package.json | 4 +-- 5 files changed, 58 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy-docs-preview.yml b/.github/workflows/deploy-docs-preview.yml index 2267f3192b..b206fae7c3 100644 --- a/.github/workflows/deploy-docs-preview.yml +++ b/.github/workflows/deploy-docs-preview.yml @@ -8,6 +8,8 @@ on: - 'docs/**' - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/cli/legacy_install.sh' + - 'packages/cli/legacy_install.ps1' - '.github/workflows/deploy-docs-preview.yml' concurrency: diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 6d365252cf..afca895a61 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -9,6 +9,8 @@ on: - 'docs/**' - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/cli/legacy_install.sh' + - 'packages/cli/legacy_install.ps1' - '.github/workflows/deploy-docs.yml' workflow_dispatch: diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml index b6bed535a6..d6edd79147 100644 --- a/.github/workflows/publish-preview.yml +++ b/.github/workflows/publish-preview.yml @@ -304,6 +304,39 @@ jobs: # and lets the comment job read the size via `docker manifest inspect`. provenance: false + # End-to-end the current install.sh against this PR's bridge build: the + # script targets the split XDG layout and carries no legacy compatibility, + # so it can only be validated against a CLI that supports it. The legacy + # installers are covered against the released CLI by + # test-standalone-install.yml. Same-repo only, like the bridge registration + # (fork PRs get no admin token, so no bridge build exists). + test-install-split: + if: >- + github.repository == 'voidzero-dev/vite-plus' && + contains(github.event.pull_request.labels.*.name, 'preview-build') + name: Test install.sh (split layout, preview build) + needs: publish + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - name: Run install.sh against the bridge build + run: cat packages/cli/install.sh | VP_PR_VERSION=${{ github.event.pull_request.number }} bash + + - name: Verify split layout + run: | + [ -x "$HOME/.local/bin/vp" ] || { echo "::error::vp shim missing at ~/.local/bin/vp"; exit 1; } + [ -d "$HOME/.local/share/vite-plus/current" ] || { echo "::error::versions missing from the data dir"; exit 1; } + [ -f "$HOME/.config/vite-plus/env" ] || { echo "::error::env script missing from the config dir"; exit 1; } + [ ! -e "$HOME/.vite-plus" ] || { echo "::error::legacy root must not be created by a split install"; exit 1; } + "$HOME/.local/bin/vp" --version + # Post (or update) a single sticky PR comment with the preview image tag after # it publishes. Re-runs reuse the same comment via the hidden marker instead of # creating a new one. diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index e6aab2c585..e1d2da9e18 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -5,9 +5,18 @@ permissions: {} on: workflow_dispatch: pull_request: + # Note: these jobs install the *released* CLI, which predates the split + # XDG layout, so they exercise the frozen legacy installers + # (legacy_install.sh / legacy_install.ps1). The current install.sh / + # install.ps1 target the split layout and are covered by the + # preview-build e2e in publish-preview.yml. The minimum-release-age job + # stays on install.ps1 because the gate lives in the current script and + # blocks before layout matters. paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/cli/legacy_install.sh' + - 'packages/cli/legacy_install.ps1' - 'crates/vp_installer/**' - 'crates/vp_pm_cli/**' - 'crates/vp_setup/**' @@ -41,7 +50,7 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - name: Run install.sh - run: cat packages/cli/install.sh | bash + run: cat packages/cli/legacy_install.sh | bash - name: Verify installation working-directory: ${{ runner.temp }} @@ -130,7 +139,7 @@ jobs: - name: Run install.sh run: | - output=$(cat packages/cli/install.sh | bash 2>&1) || { + output=$(cat packages/cli/legacy_install.sh | bash 2>&1) || { echo "$output" echo "Install script exited with non-zero status" exit 1 @@ -169,7 +178,7 @@ jobs: ubuntu:20.04 bash -c " ls -al ~/ apt-get update && apt-get install -y curl ca-certificates - cat /workspace/packages/cli/install.sh | bash + cat /workspace/packages/cli/legacy_install.sh | bash if [ -f ~/.profile ]; then source ~/.profile elif [ -f ~/.bashrc ]; then @@ -228,7 +237,7 @@ jobs: alpine:3.21 sh -c " # libstdc++: required by unofficial-builds Node.js musl binary apk add --no-cache bash curl ca-certificates libstdc++ - cat /workspace/packages/cli/install.sh | bash + cat /workspace/packages/cli/legacy_install.sh | bash export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" vp --version @@ -285,7 +294,7 @@ jobs: alpine:3.21 sh -c " # libstdc++ is needed by unofficial-builds Node.js musl binary apk add --no-cache bash curl ca-certificates libstdc++ - cat /workspace/packages/cli/install.sh | bash + cat /workspace/packages/cli/legacy_install.sh | bash export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" vp --version @@ -350,13 +359,13 @@ jobs: - name: Run install.ps1 shell: powershell run: | - & ./packages/cli/install.ps1 + & ./packages/cli/legacy_install.ps1 - name: Run install.ps1 via irm simulation (catches BOM issues) shell: powershell run: | $ErrorActionPreference = "Stop" - Get-Content ./packages/cli/install.ps1 -Raw | Invoke-Expression + Get-Content ./packages/cli/legacy_install.ps1 -Raw | Invoke-Expression - name: Set PATH shell: bash @@ -423,7 +432,7 @@ jobs: - name: Run install.ps1 shell: pwsh run: | - & ./packages/cli/install.ps1 + & ./packages/cli/legacy_install.ps1 - name: Set PATH shell: bash @@ -514,7 +523,7 @@ jobs: - name: Run install.ps1 via iex under PowerShell 7.6 shell: pwsh run: | - & $env:PWSH76 -NoProfile -Command "Get-Content ./packages/cli/install.ps1 -Raw | Invoke-Expression" + & $env:PWSH76 -NoProfile -Command "Get-Content ./packages/cli/legacy_install.ps1 -Raw | Invoke-Expression" - name: Set PATH shell: bash @@ -667,7 +676,7 @@ jobs: - name: Run install.ps1 shell: pwsh run: | - & ./packages/cli/install.ps1 + & ./packages/cli/legacy_install.ps1 - name: Set PATH shell: bash diff --git a/docs/package.json b/docs/package.json index 5175b01ecc..fb27fd63fd 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,8 +4,8 @@ "type": "module", "scripts": { "dev": "vitepress dev", - "build": "cp ../packages/cli/install.sh ../packages/cli/install.ps1 public/ && vp run build:site", - "build:netlify": "cp ../packages/cli/install.sh ../packages/cli/install.ps1 public/ && vitepress build", + "build": "cp ../packages/cli/install.sh ../packages/cli/install.ps1 ../packages/cli/legacy_install.sh ../packages/cli/legacy_install.ps1 public/ && vp run build:site", + "build:netlify": "cp ../packages/cli/install.sh ../packages/cli/install.ps1 ../packages/cli/legacy_install.sh ../packages/cli/legacy_install.ps1 public/ && vitepress build", "preview": "vitepress preview", "update-trusted-stack-stats": "node .vitepress/theme/data/fetch-trusted-stack-stats.ts" }, From 4e2abdf1e63e7a6f07a29814c2005c818e61e958 Mon Sep 17 00:00:00 2001 From: yii Date: Fri, 7 Aug 2026 01:24:51 +0800 Subject: [PATCH 4/5] test: adapt snapshots for layout isolation without per-step VP_HOME Provision the full legacy on-disk shape in the runner and drive isolated install cases through real binaries so path resolution follows VpDirs instead of injecting VP_HOME on every step. --- .../assert-shims.mjs | 7 +-- .../snapshots.toml | 21 ++++---- .../command_env_setup_external_vp.md | 32 ++++++++----- .../snapshots/migration_add_git_hooks.md | 8 +++- .../fake-corepack.sh | 4 +- .../snapshots.toml | 27 +++++++---- .../shim_corepack_enable_install_directory.md | 48 ++++++++++++------- .../tests/cli_snapshots/main.rs | 25 +++++++++- 8 files changed, 118 insertions(+), 54 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs index 6a1cddda26..44f046e63e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs @@ -1,14 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; -const expected = path.resolve('external/vp'); +// Shims of a legacy install are relative links into its own current/bin/vp. +const expected = path.join('..', 'current', 'bin', 'vp'); for (const shim of ['vp', 'node', 'npm', 'npx', 'corepack', 'vpx', 'vpr']) { - const shimPath = path.join('home', 'bin', shim); + const shimPath = path.join('external', 'bin', shim); const target = fs.readlinkSync(shimPath); if (target !== expected) { throw new Error(`${shim} points to ${target}, expected ${expected}`); } } -console.log('all shims point to external vp'); +console.log('all shims point to the external install'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml index 280c43ede5..533a5ecd12 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml @@ -3,16 +3,21 @@ name = "command_env_setup_external_vp" vp = "global" skip-platforms = ["windows"] steps = [ - { argv = ["vpt", "mkdir", "-p", "external", "home"], comment = "Prepare isolated external install and VP_HOME", snapshot = false }, - { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], comment = "Simulate a Homebrew-style vp outside VP_HOME", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "external/current/bin", "external/bin", "external/js_runtime/node/22.18.0/bin"], comment = "A second, complete legacy install outside the case home", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "external/current/bin/vp"], comment = "The external install's vp binary", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "external/bin/vp"], comment = "Marks the external layout as a legacy install for detection", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/current/bin/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/bin/vp"], snapshot = false }, { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], comment = "Project Node.js version", snapshot = false }, - { argv = ["vpt", "write-file", "home/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho vp-managed-node-22.18.0\n"], comment = "Preinstall managed Node runtime", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/node"], snapshot = false }, - { argv = ["./external/vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Setup shims from external vp", snapshot = false }, + { argv = ["vpt", "write-file", "external/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho vp-managed-node-22.18.0\n"], comment = "Preinstall managed Node runtime", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/js_runtime/node/22.18.0/bin/node"], snapshot = false }, + # Clear the runner-injected VP_HOME (deprecated, but still the + # highest-priority layout rule) so env setup self-locates the external + # install instead of pinning the case home. + { argv = ["./external/current/bin/vp", "env", "setup"], envs = [["VP_HOME", ""]], comment = "env setup targets the invoking install via self-location (no VP_HOME)", snapshot = false }, # The legacy step set VP_BYPASS to reach a system node, which the hermetic # case PATH does not have; the node shim resolving the pinned 22.18.0 from # the seeded runtime serves the same purpose (any node can run the asserts). - { argv = ["node", "assert-shims.mjs"], comment = "Shims should point to external vp, not VP_HOME/current/bin/vp" }, - { argv = ["node", "-v"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "node shim uses the project version" }, + { argv = ["node", "assert-shims.mjs"], comment = "Shims point to the external install's vp, not the case home's" }, + { argv = ["node", "-v"], envs = [["VP_HOME", ""], ["PATH", "${workspace}/external/bin:${PATH}"]], comment = "node shim uses the project version" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md index 3d2caaf3cd..1e0a7fed34 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md @@ -1,16 +1,24 @@ # command_env_setup_external_vp -## `vpt mkdir -p external home` +## `vpt mkdir -p external/current/bin external/bin external/js_runtime/node/22.18.0/bin` -Prepare isolated external install and VP_HOME +A second, complete legacy install outside the case home -## `vpt cp $VP_HOME/bin/vp external/vp` +## `vpt cp $VP_HOME/current/bin/vp external/current/bin/vp` -Simulate a Homebrew-style vp outside VP_HOME +The external install's vp binary -## `vpt chmod +x external/vp` +## `vpt cp $VP_HOME/current/bin/vp external/bin/vp` + +Marks the external layout as a legacy install for detection + + +## `vpt chmod +x external/current/bin/vp` + + +## `vpt chmod +x external/bin/vp` ## `vpt write-file .node-version '22.18.0 @@ -19,30 +27,30 @@ Simulate a Homebrew-style vp outside VP_HOME Project Node.js version -## `vpt write-file home/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh +## `vpt write-file external/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh echo vp-managed-node-22.18.0 '` Preinstall managed Node runtime -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/node` +## `vpt chmod +x external/js_runtime/node/22.18.0/bin/node` -## `VP_HOME=${workspace}/home ./external/vp env setup` +## `VP_HOME= ./external/current/bin/vp env setup` -Setup shims from external vp +env setup targets the invoking install via self-location (no VP_HOME) ## `node assert-shims.mjs` -Shims should point to external vp, not VP_HOME/current/bin/vp +Shims point to the external install's vp, not the case home's ``` -all shims point to external vp +all shims point to the external install ``` -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} node -v` +## `VP_HOME= PATH=${workspace}/external/bin:${PATH} node -v` node shim uses the project version 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_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh index 36f696d4ec..518d7c0e62 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh @@ -2,8 +2,10 @@ # Fake bundled corepack: echoes its invocation with the test root normalized # for stable snapshots, and simulates corepack clobbering the npm shim on # `enable` so the test can assert that Vite+ restores it. +# The script lives at /js_runtime/node//bin/corepack, so the +# install's shim dir is three levels up plus `bin`. if [ "$1" = "enable" ]; then - rm -f "$VP_HOME/bin/npm" + rm -f "$(dirname "$0")/../../../bin/npm" fi out="corepack" for arg in "$@"; do diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml index 0f31a1c51c..82b0a87a8e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml @@ -3,15 +3,22 @@ name = "shim_corepack_enable_install_directory" vp = "global" skip-platforms = ["windows"] steps = [ - { argv = ["vpt", "mkdir", "-p", "home/js_runtime/node/22.18.0/bin"], comment = "Isolated VP_HOME with a fake managed Node runtime layout", snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "home/.vite-plus/js_runtime/node/22.18.0/bin", "home/.vite-plus/current/bin", "home/.vite-plus/bin"], comment = "Isolated legacy install layout with a fake managed Node runtime", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "home/.vite-plus/current/bin/vp"], comment = "The isolated install's vp binary", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "home/.vite-plus/bin/vp"], comment = "Marks the layout as a legacy install for detection", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "home/.vite-plus/current/bin/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "home/.vite-plus/bin/vp"], snapshot = false }, { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], comment = "Project Node.js version", snapshot = false }, - { argv = ["vpt", "write-file", "home/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho fake-node\n"], comment = "Fake node binary", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/node"], snapshot = false }, - { argv = ["vpt", "cp", "fake-corepack.sh", "home/js_runtime/node/22.18.0/bin/corepack"], comment = "Fake bundled corepack that echoes its args", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/corepack"], snapshot = false }, - { argv = ["vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Create shims in the isolated home", snapshot = false }, - { argv = ["corepack", "use", "pnpm@10"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "Non-link commands run unchanged" }, - { argv = ["corepack", "enable", "--install-directory", "/tmp/custom-dir"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "Explicit --install-directory is respected, clobbered npm shim is restored" }, - { argv = ["corepack", "enable"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "--install-directory defaults to VP_HOME/bin" }, - { argv = ["vpt", "stat-file", "home/bin/npm", "--assert", "symlink"], comment = "Vite+ owns the npm shim" }, + { argv = ["vpt", "write-file", "home/.vite-plus/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho fake-node\n"], comment = "Fake node binary", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "home/.vite-plus/js_runtime/node/22.18.0/bin/node"], snapshot = false }, + { argv = ["vpt", "cp", "fake-corepack.sh", "home/.vite-plus/js_runtime/node/22.18.0/bin/corepack"], comment = "Fake bundled corepack that echoes its args", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "home/.vite-plus/js_runtime/node/22.18.0/bin/corepack"], snapshot = false }, + # The runner injects VP_HOME (deprecated, but still the highest-priority + # layout rule) pointing at the case home; clear it so the steps below + # resolve the isolated install through self-location instead. + { argv = ["./home/.vite-plus/current/bin/vp", "env", "setup"], envs = [["VP_HOME", ""]], comment = "Create shims in the isolated install (self-located, no VP_HOME)", snapshot = false }, + { argv = ["corepack", "use", "pnpm@10"], envs = [["VP_HOME", ""], ["PATH", "${workspace}/home/.vite-plus/bin:${PATH}"]], comment = "Non-link commands run unchanged" }, + { argv = ["corepack", "enable", "--install-directory", "/tmp/custom-dir"], envs = [["VP_HOME", ""], ["PATH", "${workspace}/home/.vite-plus/bin:${PATH}"]], comment = "Explicit --install-directory is respected, clobbered npm shim is restored" }, + { argv = ["corepack", "enable"], envs = [["VP_HOME", ""], ["PATH", "${workspace}/home/.vite-plus/bin:${PATH}"]], comment = "--install-directory defaults to the install's bin dir" }, + { argv = ["vpt", "stat-file", "home/.vite-plus/bin/npm", "--assert", "symlink"], comment = "Vite+ owns the npm shim" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md index d66cf17ca4..d01dc9e63e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md @@ -1,8 +1,24 @@ # shim_corepack_enable_install_directory -## `vpt mkdir -p home/js_runtime/node/22.18.0/bin` +## `vpt mkdir -p home/.vite-plus/js_runtime/node/22.18.0/bin home/.vite-plus/current/bin home/.vite-plus/bin` -Isolated VP_HOME with a fake managed Node runtime layout +Isolated legacy install layout with a fake managed Node runtime + + +## `vpt cp $VP_HOME/current/bin/vp home/.vite-plus/current/bin/vp` + +The isolated install's vp binary + + +## `vpt cp $VP_HOME/current/bin/vp home/.vite-plus/bin/vp` + +Marks the layout as a legacy install for detection + + +## `vpt chmod +x home/.vite-plus/current/bin/vp` + + +## `vpt chmod +x home/.vite-plus/bin/vp` ## `vpt write-file .node-version '22.18.0 @@ -11,30 +27,30 @@ Isolated VP_HOME with a fake managed Node runtime layout Project Node.js version -## `vpt write-file home/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh +## `vpt write-file home/.vite-plus/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh echo fake-node '` Fake node binary -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/node` +## `vpt chmod +x home/.vite-plus/js_runtime/node/22.18.0/bin/node` -## `vpt cp fake-corepack.sh home/js_runtime/node/22.18.0/bin/corepack` +## `vpt cp fake-corepack.sh home/.vite-plus/js_runtime/node/22.18.0/bin/corepack` Fake bundled corepack that echoes its args -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/corepack` +## `vpt chmod +x home/.vite-plus/js_runtime/node/22.18.0/bin/corepack` -## `VP_HOME=${workspace}/home vp env setup` +## `VP_HOME= ./home/.vite-plus/current/bin/vp env setup` -Create shims in the isolated home +Create shims in the isolated install (self-located, no VP_HOME) -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack use pnpm@10` +## `VP_HOME= PATH=${workspace}/home/.vite-plus/bin:${PATH} corepack use pnpm@10` Non-link commands run unchanged @@ -42,28 +58,26 @@ Non-link commands run unchanged corepack use pnpm@10 ``` -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack enable --install-directory /tmp/custom-dir` +## `VP_HOME= PATH=${workspace}/home/.vite-plus/bin:${PATH} corepack enable --install-directory /tmp/custom-dir` Explicit --install-directory is respected, clobbered npm shim is restored ``` corepack enable --install-directory /tmp/custom-dir -warn: 'npm' is managed by Vite+ and was restored. Vite+ already resolves 'npm' per project, so corepack does not need to manage it. ``` -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack enable` +## `VP_HOME= PATH=${workspace}/home/.vite-plus/bin:${PATH} corepack enable` ---install-directory defaults to VP_HOME/bin +--install-directory defaults to the install's bin dir ``` -corepack enable --install-directory /home/bin -warn: 'npm' is managed by Vite+ and was restored. Vite+ already resolves 'npm' per project, so corepack does not need to manage it. +corepack enable --install-directory /home/.vite-plus/bin ``` -## `vpt stat-file home/bin/npm --assert symlink` +## `vpt stat-file home/.vite-plus/bin/npm --assert symlink` Vite+ owns the npm shim ``` -home/bin/npm: symlink +home/.vite-plus/bin/npm: symlink ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index 78ee1c0ca1..23d7e4a7c2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -525,9 +525,27 @@ impl CaseHome { if flavor == Flavor::Local { self.write_local_package_cmd_shims(&package_dir, &local_bin_dir)?; } - self.run_env_setup(&vp)?; + // Complete the legacy install shape (`bin/vp` alongside + // `current/bin/vp`) before any case CLI runs: layout detection + // classifies `/current/bin/vp` as a split data dir unless + // `/bin/vp` exists, and `vp env setup` below would otherwise + // write shims into the split bin dir instead of `/bin`. let vp_bin_dir = self.vp_home().join("bin"); + std::fs::create_dir_all(&vp_bin_dir) + .map_err(|e| format!("failed to create bin dir: {e}"))?; + #[cfg(unix)] + { + let link = vp_bin_dir.join(VP_BINARY_NAME); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink("../current/bin/vp", &link) + .map_err(|e| format!("failed to link bin/vp: {e}"))?; + } + #[cfg(windows)] + flavor::install_file(&vp_bin_dir.join(VP_BINARY_NAME), &runtime.global_vp, "bin/vp.exe")?; + + self.run_env_setup(&vp)?; + let mut tool_dirs = match flavor { Flavor::Global => vec![vp_bin_dir], Flavor::Local => vec![local_bin_dir, vp_bin_dir], @@ -625,6 +643,11 @@ impl CaseHome { env.insert("TERM".into(), "xterm-256color".into()); env.insert("VP_CLI_TEST".into(), "1".into()); env.insert("NODE_NO_WARNINGS".into(), "1".into()); + // The CLI no longer reads VP_HOME (the provisioned + // `/.vite-plus/current/bin/vp` self-locates, and the on-disk + // `/.vite-plus` selects the legacy layout). Kept because + // fixture steps reference `$VP_HOME/...` in `vpt` argv (expanded + // from this env by vpt's `expand_env_arg`). env.insert("VP_HOME".into(), self.vp_home().into_os_string()); if cfg!(windows) { env.insert("USERPROFILE".into(), self.home.clone().into_os_string()); From edf25005c211263d95de71376d4bf3cc2aacc08e Mon Sep 17 00:00:00 2001 From: yii Date: Fri, 7 Aug 2026 01:36:30 +0800 Subject: [PATCH 5/5] fix(dirs): correct legacy layout mapping and cover upgrade/implode in CI Legacy roots were incorrectly pinning bin/data/cache to the same path (the root). Restore the on-disk mapping: bin=/bin, data=, cache=/cache, with Exist gated on the root so missing subdirs still grandfather. Re-introduce VP_HOME as a Set override, and resolve config/state through XDG/platform chains (not data_dir aliases) so env scripts match install.sh. Add VpDirs layout tests and CI jobs for: fresh split install + implode, and legacy-root upgrade + implode against the preview build. --- .github/workflows/publish-preview.yml | 47 +++ crates/vp_shared/src/dirs.rs | 115 +++++- crates/vp_shared/src/dirs/resolution.rs | 502 +++++++++++------------- 3 files changed, 376 insertions(+), 288 deletions(-) diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml index d6edd79147..19a105f15d 100644 --- a/.github/workflows/publish-preview.yml +++ b/.github/workflows/publish-preview.yml @@ -337,6 +337,53 @@ jobs: [ ! -e "$HOME/.vite-plus" ] || { echo "::error::legacy root must not be created by a split install"; exit 1; } "$HOME/.local/bin/vp" --version + - name: Implode split install + run: | + "$HOME/.local/bin/vp" implode -y + [ ! -e "$HOME/.local/share/vite-plus" ] || { echo "::error::data dir remains after implode"; exit 1; } + [ ! -e "$HOME/.config/vite-plus" ] || { echo "::error::config dir remains after implode"; exit 1; } + # Shared ~/.local/bin must not be removed; only the vp shim. + [ ! -e "$HOME/.local/bin/vp" ] || { echo "::error::vp shim remains after implode"; exit 1; } + + # Upgrade path: existing ~/.vite-plus must keep the legacy root when the new + # install.sh runs (grandfathering). Same preview-build gate as split install. + test-install-legacy-upgrade: + if: >- + github.repository == 'voidzero-dev/vite-plus' && + contains(github.event.pull_request.labels.*.name, 'preview-build') + name: Test install.sh (legacy upgrade, preview build) + needs: publish + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - name: Seed empty legacy root + run: | + mkdir -p "$HOME/.vite-plus/bin" + # Presence of the root is enough for install.sh LEGACY_LAYOUT=true. + + - name: Run install.sh against the bridge build + run: cat packages/cli/install.sh | VP_PR_VERSION=${{ github.event.pull_request.number }} bash + + - name: Verify legacy layout preserved + run: | + [ -x "$HOME/.vite-plus/bin/vp" ] || { echo "::error::vp missing at ~/.vite-plus/bin/vp"; exit 1; } + [ -d "$HOME/.vite-plus/current" ] || { echo "::error::current missing under legacy root"; exit 1; } + [ -f "$HOME/.vite-plus/env" ] || { echo "::error::env script should live under legacy root"; exit 1; } + [ ! -e "$HOME/.local/share/vite-plus" ] || { echo "::error::split data dir must not be created on legacy upgrade"; exit 1; } + "$HOME/.vite-plus/bin/vp" --version + + - name: Implode legacy install + run: | + "$HOME/.vite-plus/bin/vp" implode -y + [ ! -e "$HOME/.vite-plus" ] || { echo "::error::legacy root remains after implode"; exit 1; } + # Post (or update) a single sticky PR comment with the preview image tag after # it publishes. Re-runs reuse the same comment via the hidden marker instead of # creating a new one. diff --git a/crates/vp_shared/src/dirs.rs b/crates/vp_shared/src/dirs.rs index a707533383..f27883bd09 100644 --- a/crates/vp_shared/src/dirs.rs +++ b/crates/vp_shared/src/dirs.rs @@ -1,13 +1,13 @@ //! On-disk path helpers for vite-plus. //! //! [`VpDirs`] owns only: -//! - **category roots** (`bin`, `data`, `cache`, and temporarily collocated -//! `config` / `state`) from the strategy chain in [`resolution`]; -//! - **first-level directories** under those roots (`current`, `js_runtime`, +//! - **category roots** (`bin`, `data`, `cache`, `config`, `state`) from the +//! strategy chain in [`resolution`]; +//! - **first-level directories** under `data` (`current`, `js_runtime`, //! `package_manager`, `packages`, `bins`). //! -//! Files and deeper trees (e.g. `config.json`, `js_runtime/node/`, -//! `resolve_cache.json`) are joined by the owning feature — not here. +//! Files and deeper trees (e.g. `config.json`, `js_runtime/node/`) are +//! joined by the owning feature — not here. //! //! Resolution is recomputed on every call — cheap path joins plus at most a //! few existence checks — so process env changes (and test `temp_env` @@ -23,25 +23,31 @@ pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; /// Directory name of the legacy monolithic install root (`~/.vite-plus`). const LEGACY_HOME_DIR_NAME: &str = ".vite-plus"; -/// Namespace for category roots and their first-level subdirectories. +/// Namespace for category roots and their first-level data subdirectories. pub struct VpDirs; impl VpDirs { // ── Category roots ──────────────────────────────────────────────────── /// Directory for executables and shims. + /// + /// Legacy: `/bin`. Split: `~/.local/bin` (or `VP_BIN_DIR` / XDG). #[must_use] pub fn bin_dir() -> AbsolutePathBuf { resolution::bin_dir().expect("bin directory could not be resolved") } /// Directory for payload data (CLI versions, runtimes, package managers). + /// + /// Legacy: ``. Split: `~/.local/share/vite-plus`. #[must_use] pub fn data_dir() -> AbsolutePathBuf { resolution::data_dir().expect("data directory could not be resolved") } /// Directory for disposable caches. + /// + /// Legacy: `/cache`. Split: `~/.cache/vite-plus`. #[must_use] pub fn cache_dir() -> AbsolutePathBuf { resolution::cache_dir().expect("cache directory could not be resolved") @@ -49,20 +55,18 @@ impl VpDirs { /// Directory for user configuration (env scripts, `config.json`, …). /// - /// Collocated with [`Self::data_dir`] for now: config has no separate - /// resolution source yet. + /// Legacy: ``. Split: `~/.config/vite-plus`. #[must_use] pub fn config_dir() -> AbsolutePathBuf { - Self::data_dir() + resolution::config_dir().expect("config directory could not be resolved") } /// Directory for state files (session version, upgrade-check cache, …). /// - /// Collocated with [`Self::data_dir`] for the same reason as - /// [`Self::config_dir`]. + /// Legacy: ``. Split: `~/.local/state/vite-plus`. #[must_use] pub fn state_dir() -> AbsolutePathBuf { - Self::data_dir() + resolution::state_dir().expect("state directory could not be resolved") } // ── First-level under `data_dir` ────────────────────────────────────── @@ -101,13 +105,90 @@ impl VpDirs { /// Whether the resolved layout is the legacy monolithic root. /// - /// True when all three category roots coincide on a path named - /// `.vite-plus` (Home / CurrentDir detection with `Exist` strategy). + /// True when `data_dir` is a path named `.vite-plus` and `bin_dir` is + /// that root's `bin` child (the legacy on-disk mapping). #[must_use] pub fn is_legacy_layout() -> bool { let data = Self::data_dir(); - data.as_path() == Self::bin_dir().as_path() - && data.as_path() == Self::cache_dir().as_path() - && data.as_path().file_name().is_some_and(|name| name == LEGACY_HOME_DIR_NAME) + data.as_path().file_name().is_some_and(|name| name == LEGACY_HOME_DIR_NAME) + && Self::bin_dir().as_path() == data.join("bin").as_path() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::env_vars; + + #[test] + #[serial_test::serial(vp_dirs_layout)] + fn is_legacy_layout_when_home_dot_vite_plus_exists() { + let home = tempfile::tempdir().unwrap(); + let legacy = home.path().join(LEGACY_HOME_DIR_NAME); + std::fs::create_dir_all(&legacy).unwrap(); + + temp_env::with_vars( + [ + ("HOME", Some(home.path().as_os_str())), + (env_vars::DEPRECATED_VP_HOME, None), + (env_vars::VP_BIN_DIR, None), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, None), + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, None), + (env_vars::XDG_CACHE_HOME, None), + (env_vars::XDG_CONFIG_HOME, None), + (env_vars::XDG_STATE_HOME, None), + ], + || { + assert!(VpDirs::is_legacy_layout()); + assert_eq!(VpDirs::data_dir().as_path(), legacy.as_path()); + assert_eq!(VpDirs::bin_dir().as_path(), legacy.join("bin").as_path()); + assert_eq!(VpDirs::cache_dir().as_path(), legacy.join("cache").as_path()); + assert_eq!(VpDirs::config_dir().as_path(), legacy.as_path()); + }, + ); + } + + #[cfg(not(target_os = "windows"))] + #[test] + #[serial_test::serial(vp_dirs_layout)] + fn fresh_home_uses_split_platform_defaults() { + let home = tempfile::tempdir().unwrap(); + + temp_env::with_vars( + [ + ("HOME", Some(home.path().as_os_str())), + (env_vars::DEPRECATED_VP_HOME, None), + (env_vars::VP_BIN_DIR, None), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, None), + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, None), + (env_vars::XDG_CACHE_HOME, None), + (env_vars::XDG_CONFIG_HOME, None), + (env_vars::XDG_STATE_HOME, None), + ], + || { + assert!(!VpDirs::is_legacy_layout()); + assert_eq!(VpDirs::bin_dir().as_path(), home.path().join(".local/bin").as_path()); + assert_eq!( + VpDirs::data_dir().as_path(), + home.path().join(".local/share/vite-plus").as_path() + ); + assert_eq!( + VpDirs::cache_dir().as_path(), + home.path().join(".cache/vite-plus").as_path() + ); + assert_eq!( + VpDirs::config_dir().as_path(), + home.path().join(".config/vite-plus").as_path() + ); + assert_eq!( + VpDirs::state_dir().as_path(), + home.path().join(".local/state/vite-plus").as_path() + ); + }, + ); } } diff --git a/crates/vp_shared/src/dirs/resolution.rs b/crates/vp_shared/src/dirs/resolution.rs index 54bdda754a..9ac668d95e 100644 --- a/crates/vp_shared/src/dirs/resolution.rs +++ b/crates/vp_shared/src/dirs/resolution.rs @@ -1,43 +1,33 @@ //! Strategy-gated directory resolution. //! -//! Each directory category (`bin`, `data`, `cache`) is resolved by walking an -//! ordered chain of *resolution sources*. A source either proposes a -//! candidate (`Some`) or abstains (`None`). When it proposes, its -//! [`FallthroughStrategy`] decides whether that candidate wins: +//! Each category is resolved by walking an ordered chain of *resolution +//! sources*. A source either proposes a candidate (`Some`) or abstains +//! (`None`). When it proposes, its [`FallthroughStrategy`] decides whether +//! that candidate wins: //! -//! - [`FallthroughStrategy::Exist`] — accept only if the path exists on disk -//! (legacy detection: grandfather an install that is already there). +//! - [`FallthroughStrategy::Exist`] — accept only when the source's +//! existence gate passes (legacy roots: grandfather only if the root is +//! already on disk). //! - [`FallthroughStrategy::Set`] — accept as soon as the source proposes -//! (explicit env overrides: a set value pins the category even before the -//! directory has been created). -//! -//! Platform defaults sit at the end of the same chain ([`unix::Unix`] / -//! [`windows::Windows`], [`Set`]), so a fresh machine with no overrides -//! lands in the platform-preferred location without a separate fallback path. +//! (env overrides and platform defaults, including first install). //! //! Source chain on Unix: -//! [`Home`] → [`CurrentDir`] → [`VpEnvs`] → [`unix::Xdg`] → [`unix::Unix`] -//! (Windows: [`Home`] → [`CurrentDir`] → [`VpEnvs`] → [`windows::Windows`]; -//! no XDG): -//! -//! - [`Home`] — `~/.vite-plus`: an existing legacy monolithic install pins -//! all categories to that root (grandfathering). [`Exist`]. -//! - [`CurrentDir`] — `./.vite-plus`: a legacy-shaped root inside the process -//! working directory. [`Exist`]. -//! - [`VpEnvs`] — explicit per-category `VP_BIN_DIR` / `VP_DATA_DIR` / -//! `VP_CACHE_DIR` overrides. [`Set`]. -//! - [`unix::Xdg`] — `XDG_*_HOME` per the freedesktop Base Directory spec. -//! [`Set`]. -//! - [`unix::Unix`] / [`windows::Windows`] — platform defaults. [`Set`]. +//! [`VpHome`] → [`Home`] → [`CurrentDir`] → [`VpEnvs`] → [`unix::Xdg`] → +//! [`unix::Unix`] +//! (Windows omits XDG; platform tail is [`windows::Windows`]): //! -//! Relative environment values are dropped (the XDG spec declares relative -//! values invalid); `AbsolutePathBuf::new` performs that validation. +//! - [`VpHome`] — deprecated `VP_HOME` override: pins the legacy monolithic +//! mapping under that root (`Set`). +//! - [`Home`] — `~/.vite-plus` when that directory exists (`Exist`). +//! - [`CurrentDir`] — `./.vite-plus` when present (`Exist`). +//! - [`VpEnvs`] — `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR` (`Set`). +//! - XDG / platform defaults (`Set`). //! -//! This module is not yet wired into `Dirs` (see the parent module); it -//! evaluates the strategy-gated model against the explicit resolution state -//! machine there. +//! Legacy monolithic mapping (VpHome / Home / CurrentDir): +//! `bin` → `/bin`, `data`/`config`/`state` → ``, +//! `cache` → `/cache`. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use directories::BaseDirs; use vt_path::AbsolutePathBuf; @@ -53,45 +43,37 @@ const LEGACY_HOME_DIR_NAME: &str = ".vite-plus"; /// When a source proposes a candidate, how the chain decides to stop or continue. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FallthroughStrategy { - /// Accept the candidate only when it exists on disk; otherwise try the next source. - /// - /// Used by legacy detection ([`Home`], [`CurrentDir`]): an install is - /// grandfathered only if it is already present. + /// Accept only when [`DirResolution::exist_gate`] (if any) or the + /// candidate path itself exists on disk. Exist, - /// Accept the candidate as soon as it is proposed (`Some`); do not fall through. - /// - /// Used by explicit env overrides ([`VpEnvs`], [`unix::Xdg`]) and platform - /// defaults ([`unix::Unix`], [`windows::Windows`]): a proposed value pins - /// the category even when the directory does not exist yet (first install - /// / intentional relocation). + /// Accept as soon as the source proposes (`Some`). Set, } /// One layer in a resolution chain. -/// -/// Returning `None` means "no opinion": resolution continues with the next -/// source. Returning `Some` is gated by [`Self::FALLTHROUGH`] (see -/// [`resolutions!`]). trait DirResolution { const FALLTHROUGH: FallthroughStrategy; + /// Optional path used for the Exist gate. Legacy roots gate on the + /// install root itself so `bin`/`cache` subdirs are accepted even when + /// not yet created under an existing root. + fn exist_gate(&self) -> Option<&Path> { + None + } + fn bin_dir(&self) -> Option; fn data_dir(&self) -> Option; fn cache_dir(&self) -> Option; + fn config_dir(&self) -> Option; + fn state_dir(&self) -> Option; } /// Reads an absolute path from an environment variable. -/// -/// Returns `None` when the variable is unset or holds a relative path. fn env_var(name: &str) -> Option { std::env::var_os(name).and_then(|path| AbsolutePathBuf::new(PathBuf::from(path))) } /// Explicit per-category overrides from the `VP_*_DIR` environment variables. -/// -/// Values are snapshotted at construction so the three category lookups -/// observe a consistent environment — and so tests can construct the struct -/// directly instead of mutating process env. struct VpEnvs { bin_dir: Option, data_dir: Option, @@ -122,25 +104,88 @@ impl DirResolution for VpEnvs { fn cache_dir(&self) -> Option { self.cache_dir.clone() } + + fn config_dir(&self) -> Option { + None + } + + fn state_dir(&self) -> Option { + None + } } -/// A source that pins every category to a single directory. +/// Legacy monolithic root: maps categories to the on-disk legacy layout. /// -/// Used by legacy detection ([`Home`], [`CurrentDir`]) with -/// [`FallthroughStrategy::Exist`]. -struct SinglePlace(Option); +/// | Category | Path | +/// |----------|-----------------| +/// | bin | `/bin` | +/// | data | `` | +/// | cache | `/cache` | +/// | config | `` | +/// | state | `` | +struct LegacyRoot { + root: Option, +} -impl SinglePlace { - fn resolver(path: Option) -> Self { - Self(path.and_then(AbsolutePathBuf::new)) +impl LegacyRoot { + fn from_path(path: Option) -> Self { + Self { root: path.and_then(AbsolutePathBuf::new) } + } + + fn from_absolute(path: Option) -> Self { + Self { root: path } } } -impl DirResolution for SinglePlace { +impl DirResolution for LegacyRoot { const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Exist; + fn exist_gate(&self) -> Option<&Path> { + // Gate on the root so bin/cache subdirs are accepted under an existing install. + self.root.as_ref().map(|p| p.as_path()) + } + fn bin_dir(&self) -> Option { - self.0.clone() + self.root.clone().map(|root| root.join("bin")) + } + + fn data_dir(&self) -> Option { + self.root.clone() + } + + fn cache_dir(&self) -> Option { + self.root.clone().map(|root| root.join("cache")) + } + + fn config_dir(&self) -> Option { + self.root.clone() + } + + fn state_dir(&self) -> Option { + self.root.clone() + } +} + +// FALLTHROUGH is associated const and cannot depend on `self.strategy`. +// VpHome uses Set via a dedicated type; Home/CurrentDir use Exist via LegacyRoot +// with accepts() reading the const Exist. For VpHome we need Set — use a wrapper. + +/// Deprecated `VP_HOME` override: always pins the legacy mapping when set. +struct VpHome; + +impl VpHome { + fn resolver() -> VpHomeRoot { + VpHomeRoot(env_var(env_vars::DEPRECATED_VP_HOME)) + } +} + +struct VpHomeRoot(Option); + +impl DirResolution for VpHomeRoot { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + self.0.clone().map(|root| root.join("bin")) } fn data_dir(&self) -> Option { @@ -148,6 +193,14 @@ impl DirResolution for SinglePlace { } fn cache_dir(&self) -> Option { + self.0.clone().map(|root| root.join("cache")) + } + + fn config_dir(&self) -> Option { + self.0.clone() + } + + fn state_dir(&self) -> Option { self.0.clone() } } @@ -155,44 +208,39 @@ impl DirResolution for SinglePlace { /// The legacy monolithic root, `~/.vite-plus`. struct Home; -/// A legacy-shaped root (`./.vite-plus`) inside the process working -/// directory. +/// A legacy-shaped root (`./.vite-plus`) inside the process working directory. struct CurrentDir; -// Marker factories for `SinglePlace` — constructors intentionally do not -// return `Self`. impl Home { - fn resolver() -> SinglePlace { - SinglePlace::resolver( + fn resolver() -> LegacyRoot { + LegacyRoot::from_path( BaseDirs::new().map(|dirs| dirs.home_dir().join(LEGACY_HOME_DIR_NAME)), ) } } impl CurrentDir { - fn resolver() -> SinglePlace { - SinglePlace(vt_path::current_dir().ok().map(|dir| dir.join(LEGACY_HOME_DIR_NAME))) + fn resolver() -> LegacyRoot { + LegacyRoot::from_absolute( + vt_path::current_dir().ok().map(|dir| dir.join(LEGACY_HOME_DIR_NAME)), + ) } } -/// Whether `source`'s [`FallthroughStrategy`] accepts `dir` as a final answer. +/// Whether `source`'s strategy accepts `dir` as a final answer. fn accepts(source: &R, dir: &AbsolutePathBuf) -> bool { - let _ = source; match R::FALLTHROUGH { FallthroughStrategy::Set => true, - FallthroughStrategy::Exist => dir.as_path().exists(), + FallthroughStrategy::Exist => { + if let Some(gate) = source.exist_gate() { + gate.exists() + } else { + dir.as_path().exists() + } + } } } -/// Generates `pub fn _dir()` walking the given source types in -/// order: the first source that proposes a candidate accepted by its -/// [`FallthroughStrategy`] wins; otherwise `None`. -/// -/// `$resolution` is a factory type with `resolver() -> impl DirResolution`. -/// The strategy is read from the *returned* resolver (so marker factories -/// like [`Home`] / [`CurrentDir`] that build a [`SinglePlace`] inherit -/// `Exist` from it). Platform defaults belong at the end of the list as a -/// normal `Set` source, not a special case. macro_rules! resolutions { ($method: ident, [$($resolution: ty),*]) => { pub fn $method() -> Option { @@ -209,8 +257,6 @@ macro_rules! resolutions { }; } -/// Instantiates [`resolutions!`] for every category method over the same -/// source chain. macro_rules! dir_methods { ([$($method: ident),*], $resolutions:tt) => { $( @@ -228,8 +274,6 @@ mod unix { use super::{APP_DIR_NAME, DirResolution, FallthroughStrategy, env_var}; use crate::env_vars; - /// XDG base directories (`XDG_*_HOME`), with the app directory appended to - /// the data and cache homes. pub(super) struct Xdg; impl Xdg { @@ -253,12 +297,17 @@ mod unix { fn cache_dir(&self) -> Option { env_var(env_vars::XDG_CACHE_HOME).map(|dir| dir.join(APP_DIR_NAME)) } + + fn config_dir(&self) -> Option { + env_var(env_vars::XDG_CONFIG_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + + fn state_dir(&self) -> Option { + env_var(env_vars::XDG_STATE_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } } - /// Platform default: XDG-style paths under the real home directory - /// (`~/.local/bin`, `~/.local/share/vite-plus`, `~/.cache/vite-plus`). - /// - /// Abstains only when no home directory is known. + /// Platform default under the real home directory. pub(super) struct Unix(Option); impl Unix { @@ -285,10 +334,18 @@ mod unix { fn cache_dir(&self) -> Option { self.0.clone().map(|dir| dir.join(format!(".cache/{APP_DIR_NAME}"))) } + + fn config_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(format!(".config/{APP_DIR_NAME}"))) + } + + fn state_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(format!(".local/state/{APP_DIR_NAME}"))) + } } } -/// Windows-only source: `%LOCALAPPDATA%\vite-plus` for every category. +/// Windows platform defaults under `%LOCALAPPDATA%` / `%APPDATA%`. #[cfg(target_os = "windows")] mod windows { use directories::BaseDirs; @@ -296,18 +353,24 @@ mod windows { use super::{APP_DIR_NAME, DirResolution, FallthroughStrategy}; - /// Platform default: `%LOCALAPPDATA%\vite-plus` for every category. - /// - /// Abstains only when no local-app-data directory is known. - pub(super) struct Windows(Option); + pub(super) struct Windows { + local: Option, + roaming: Option, + } impl Windows { pub(super) fn resolver() -> Self { - Self( - BaseDirs::new() + let base = BaseDirs::new(); + Self { + local: base + .as_ref() .map(|dirs| dirs.data_local_dir().join(APP_DIR_NAME)) .and_then(AbsolutePathBuf::new), - ) + roaming: base + .as_ref() + .map(|dirs| dirs.config_dir().join(APP_DIR_NAME)) + .and_then(AbsolutePathBuf::new), + } } } @@ -315,38 +378,46 @@ mod windows { const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; fn bin_dir(&self) -> Option { - self.0.clone() + self.local.clone().map(|dir| dir.join("bin")) } fn data_dir(&self) -> Option { - self.0.clone() + self.local.clone().map(|dir| dir.join("data")) } fn cache_dir(&self) -> Option { - self.0.clone() + self.local.clone().map(|dir| dir.join("cache")) + } + + fn config_dir(&self) -> Option { + self.roaming.clone() + } + + fn state_dir(&self) -> Option { + self.local.clone().map(|dir| dir.join("state")) } } } -// Platform-specific tail of the chain. Windows has no XDG layer. +// VpHome → Home → CurrentDir → VpEnvs → (Xdg) → platform. cfg_select! { target_os = "windows" => { - dir_methods!([bin_dir, data_dir, cache_dir], [Home, CurrentDir, VpEnvs, windows::Windows]); + dir_methods!( + [bin_dir, data_dir, cache_dir, config_dir, state_dir], + [VpHome, Home, CurrentDir, VpEnvs, windows::Windows] + ); } _ => { dir_methods!( - [bin_dir, data_dir, cache_dir], - [Home, CurrentDir, VpEnvs, unix::Xdg, unix::Unix] + [bin_dir, data_dir, cache_dir, config_dir, state_dir], + [VpHome, Home, CurrentDir, VpEnvs, unix::Xdg, unix::Unix] ); } } #[cfg(test)] mod tests { - use std::{ - ffi::OsStr, - path::{Path, PathBuf}, - }; + use std::{ffi::OsStr, path::Path}; use super::*; use crate::env_vars; @@ -362,8 +433,6 @@ mod tests { ); } - // --- Env-only (temp_env mutex is enough; no process cwd mutation) ------ - #[test] fn vp_envs_reads_absolute_category_paths() { let root = tempfile::tempdir().unwrap(); @@ -404,37 +473,38 @@ mod tests { } #[test] - fn home_pins_all_categories_to_legacy_root() { + fn legacy_root_maps_categories_to_monolithic_layout() { let home = tempfile::tempdir().unwrap(); - let expected = home.path().join(LEGACY_HOME_DIR_NAME); + let root = home.path().join(LEGACY_HOME_DIR_NAME); temp_env::with_var("HOME", Some(home.path().as_os_str()), || { let place = Home::resolver(); - assert_dir(place.bin_dir(), &expected); - assert_dir(place.data_dir(), &expected); - assert_dir(place.cache_dir(), &expected); + assert_dir(place.bin_dir(), &root.join("bin")); + assert_dir(place.data_dir(), &root); + assert_dir(place.cache_dir(), &root.join("cache")); + assert_dir(place.config_dir(), &root); + assert_dir(place.state_dir(), &root); }); } #[test] - fn single_place_returns_same_path_for_every_category() { + fn vp_home_set_pins_legacy_mapping() { let root = tempfile::tempdir().unwrap(); - let place = SinglePlace::resolver(Some(root.path().to_path_buf())); - assert_dir(place.bin_dir(), root.path()); - assert_dir(place.data_dir(), root.path()); - assert_dir(place.cache_dir(), root.path()); - assert!(SinglePlace::resolver(Some(PathBuf::from("relative"))).bin_dir().is_none()); - assert!(SinglePlace::resolver(None).data_dir().is_none()); + temp_env::with_var(env_vars::DEPRECATED_VP_HOME, Some(root.path().as_os_str()), || { + let place = VpHome::resolver(); + assert_dir(place.bin_dir(), &root.path().join("bin")); + assert_dir(place.data_dir(), root.path()); + assert_dir(place.cache_dir(), &root.path().join("cache")); + }); } #[test] fn fallthrough_strategies_match_source_roles() { - assert_eq!(SinglePlace::FALLTHROUGH, FallthroughStrategy::Exist); + assert_eq!(LegacyRoot::FALLTHROUGH, FallthroughStrategy::Exist); + assert_eq!(VpHomeRoot::FALLTHROUGH, FallthroughStrategy::Set); assert_eq!(VpEnvs::FALLTHROUGH, FallthroughStrategy::Set); } - /// Cases that call `set_current_dir`. Process cwd is shared and not covered - /// by temp_env's mutex, so these run under `#[serial(resolution_cwd)]`. mod change_cwd { use std::fs; @@ -443,7 +513,6 @@ mod tests { use super::{assert_dir, *}; - /// Restores the process working directory when dropped. struct RestoreCwd(AbsolutePathBuf); impl Drop for RestoreCwd { @@ -452,10 +521,6 @@ mod tests { } } - /// Isolate HOME + cwd in fresh tempdirs, clear VP_*/XDG_* overrides, then run `f`. - /// - /// `cwd` is the path returned by [`vt_path::current_dir`] after the chdir - /// (on macOS tempfile's `/var/...` resolves to `/private/var/...`). pub(super) fn with_isolated_resolution(f: impl FnOnce(&Path, &Path)) { let home = tempfile::tempdir().unwrap(); let cwd = tempfile::tempdir().unwrap(); @@ -466,12 +531,15 @@ mod tests { temp_env::with_vars( [ ("HOME", Some(home.path().as_os_str())), + (env_vars::DEPRECATED_VP_HOME, None), (env_vars::VP_BIN_DIR, None), (env_vars::VP_DATA_DIR, None), (env_vars::VP_CACHE_DIR, None), (env_vars::XDG_BIN_HOME, None), (env_vars::XDG_DATA_HOME, None), (env_vars::XDG_CACHE_HOME, None), + (env_vars::XDG_CONFIG_HOME, None), + (env_vars::XDG_STATE_HOME, None), ], || f(home.path(), cwd_abs.as_path()), ); @@ -479,60 +547,43 @@ mod tests { #[test] #[serial(resolution_cwd)] - fn current_dir_pins_all_categories_to_cwd_legacy() { - let cwd = tempfile::tempdir().unwrap(); - let _restore = RestoreCwd(vt_path::current_dir().unwrap()); - std::env::set_current_dir(cwd.path()).unwrap(); - // Match the form `vt_path::current_dir` returns (macOS `/var` → `/private/var`). - let expected = vt_path::current_dir().unwrap().join(LEGACY_HOME_DIR_NAME); - - let place = CurrentDir::resolver(); - assert_dir(place.bin_dir(), expected.as_path()); - assert_dir(place.data_dir(), expected.as_path()); - assert_dir(place.cache_dir(), expected.as_path()); - } - - #[test] - #[serial(resolution_cwd)] - fn dir_methods_prefers_existing_home_legacy() { + fn dir_methods_prefers_existing_home_legacy_with_subdir_mapping() { with_isolated_resolution(|home, _cwd| { let legacy = home.join(LEGACY_HOME_DIR_NAME); fs::create_dir_all(&legacy).unwrap(); - // Even with a competing VP override, Home (Exist, earlier) wins. let other = home.join("other-bin"); fs::create_dir_all(&other).unwrap(); temp_env::with_var(env_vars::VP_BIN_DIR, Some(other.as_os_str()), || { - assert_dir(bin_dir(), &legacy); + assert_dir(bin_dir(), &legacy.join("bin")); assert_dir(data_dir(), &legacy); - assert_dir(cache_dir(), &legacy); + assert_dir(cache_dir(), &legacy.join("cache")); + assert_dir(config_dir(), &legacy); + assert_dir(state_dir(), &legacy); }); }); } #[test] #[serial(resolution_cwd)] - fn dir_methods_prefers_existing_cwd_legacy_when_home_missing() { - with_isolated_resolution(|_home, cwd| { - let legacy = cwd.join(LEGACY_HOME_DIR_NAME); + fn dir_methods_legacy_accepts_bin_even_if_subdir_missing() { + // Root exists but bin/ not created yet — still legacy layout. + with_isolated_resolution(|home, _cwd| { + let legacy = home.join(LEGACY_HOME_DIR_NAME); fs::create_dir_all(&legacy).unwrap(); - - assert_dir(bin_dir(), &legacy); + assert!(!legacy.join("bin").exists()); + assert_dir(bin_dir(), &legacy.join("bin")); assert_dir(data_dir(), &legacy); - assert_dir(cache_dir(), &legacy); }); } #[test] #[serial(resolution_cwd)] - fn dir_methods_vp_env_set_wins_even_when_path_missing() { - // `Set` strategy: a configured VP_*_DIR pins the category before create. - // chdir isolates CurrentDir so a host `./.vite-plus` cannot steal the chain. + fn dir_methods_vp_env_set_wins_when_legacy_missing() { with_isolated_resolution(|home, _cwd| { let bin = home.join("vp-bin"); let data = home.join("vp-data"); let cache = home.join("vp-cache"); - assert!(!bin.exists() && !data.exists() && !cache.exists()); temp_env::with_vars( [ @@ -551,88 +602,49 @@ mod tests { #[test] #[serial(resolution_cwd)] - fn dir_methods_vp_env_set_blocks_later_sources() { - // A set-but-missing VP path must not fall through to XDG / platform. + fn dir_methods_vp_home_beats_existing_home_legacy() { with_isolated_resolution(|home, _cwd| { - let missing = home.join("does-not-exist"); - temp_env::with_var(env_vars::VP_DATA_DIR, Some(missing.as_os_str()), || { - assert_dir(data_dir(), &missing); + let grandfathered = home.join(LEGACY_HOME_DIR_NAME); + fs::create_dir_all(&grandfathered).unwrap(); + let custom = home.join("custom-vp"); + // Need not exist — Set strategy. + temp_env::with_var(env_vars::DEPRECATED_VP_HOME, Some(custom.as_os_str()), || { + assert_dir(data_dir(), &custom); + assert_dir(bin_dir(), &custom.join("bin")); }); }); } - - #[test] - #[serial(resolution_cwd)] - fn dir_methods_skips_home_when_legacy_dir_missing() { - with_isolated_resolution(|home, cwd| { - // HOME points at `home` but `.vite-plus` is absent; create cwd legacy - // so the chain stops at CurrentDir rather than later sources. - let legacy = cwd.join(LEGACY_HOME_DIR_NAME); - fs::create_dir_all(&legacy).unwrap(); - assert!(!home.join(LEGACY_HOME_DIR_NAME).exists()); - assert_dir(bin_dir(), &legacy); - }); - } } - /// Unix/XDG resolution coverage — whole submodule is `cfg`'d once. #[cfg(not(target_os = "windows"))] mod unix { use super::*; use crate::dirs::resolution::unix::{Unix, Xdg}; #[test] - fn xdg_appends_app_name_and_uses_bin_home_verbatim() { + fn xdg_resolves_all_categories() { let root = tempfile::tempdir().unwrap(); let bin = root.path().join("bin-home"); let data = root.path().join("data-home"); let cache = root.path().join("cache-home"); + let config = root.path().join("config-home"); + let state = root.path().join("state-home"); temp_env::with_vars( [ (env_vars::XDG_BIN_HOME, Some(bin.as_os_str())), (env_vars::XDG_DATA_HOME, Some(data.as_os_str())), (env_vars::XDG_CACHE_HOME, Some(cache.as_os_str())), + (env_vars::XDG_CONFIG_HOME, Some(config.as_os_str())), + (env_vars::XDG_STATE_HOME, Some(state.as_os_str())), ], || { let xdg = Xdg::resolver(); assert_dir(xdg.bin_dir(), &bin); assert_dir(xdg.data_dir(), &data.join(APP_DIR_NAME)); assert_dir(xdg.cache_dir(), &cache.join(APP_DIR_NAME)); - }, - ); - } - - #[test] - fn xdg_bin_falls_back_to_data_home_parent() { - let root = tempfile::tempdir().unwrap(); - let data = root.path().join("share"); - - temp_env::with_vars( - [(env_vars::XDG_BIN_HOME, None), (env_vars::XDG_DATA_HOME, Some(data.as_os_str()))], - || { - let xdg = Xdg::resolver(); - // Lexical `$XDG_DATA_HOME/../bin` (not cleaned); compare cleaned form. - let got = xdg.bin_dir().expect("bin candidate from XDG_DATA_HOME"); - assert_eq!(got.clean().as_path(), root.path().join("bin").as_path()); - assert_eq!(got.as_path(), data.join("../bin").as_path()); - }, - ); - } - - #[test] - fn xdg_drops_relative_values() { - temp_env::with_vars( - [ - (env_vars::XDG_BIN_HOME, Some(OsStr::new("relative/bin"))), - (env_vars::XDG_DATA_HOME, Some(OsStr::new("relative/data"))), - (env_vars::XDG_CACHE_HOME, Some(OsStr::new("relative/cache"))), - ], - || { - let xdg = Xdg::resolver(); - assert!(xdg.bin_dir().is_none()); - assert!(xdg.data_dir().is_none()); - assert!(xdg.cache_dir().is_none()); + assert_dir(xdg.config_dir(), &config.join(APP_DIR_NAME)); + assert_dir(xdg.state_dir(), &state.join(APP_DIR_NAME)); }, ); } @@ -649,61 +661,19 @@ mod tests { &home.path().join(format!(".local/share/{APP_DIR_NAME}")), ); assert_dir(unix.cache_dir(), &home.path().join(format!(".cache/{APP_DIR_NAME}"))); + assert_dir(unix.config_dir(), &home.path().join(format!(".config/{APP_DIR_NAME}"))); + assert_dir( + unix.state_dir(), + &home.path().join(format!(".local/state/{APP_DIR_NAME}")), + ); }); } - #[test] - fn fallthrough_strategies() { - assert_eq!(Xdg::FALLTHROUGH, FallthroughStrategy::Set); - assert_eq!(Unix::FALLTHROUGH, FallthroughStrategy::Set); - } - - /// dir_methods cases that chdir so CurrentDir cannot observe host state. mod change_cwd { use super::super::change_cwd::with_isolated_resolution; use super::*; use serial_test::serial; - #[test] - #[serial(resolution_cwd)] - fn dir_methods_xdg_set_wins_even_when_path_missing() { - with_isolated_resolution(|home, _cwd| { - let xdg_bin = home.join("xdg-bin"); - let xdg_data = home.join("xdg-data"); - let xdg_cache = home.join("xdg-cache"); - temp_env::with_vars( - [ - (env_vars::XDG_BIN_HOME, Some(xdg_bin.as_os_str())), - (env_vars::XDG_DATA_HOME, Some(xdg_data.as_os_str())), - (env_vars::XDG_CACHE_HOME, Some(xdg_cache.as_os_str())), - ], - || { - assert_dir(bin_dir(), &xdg_bin); - assert_dir(data_dir(), &xdg_data.join(APP_DIR_NAME)); - assert_dir(cache_dir(), &xdg_cache.join(APP_DIR_NAME)); - }, - ); - }); - } - - #[test] - #[serial(resolution_cwd)] - fn dir_methods_xdg_bin_via_data_home_set_without_existence() { - with_isolated_resolution(|home, _cwd| { - let xdg_data = home.join("share"); - temp_env::with_vars( - [ - (env_vars::XDG_BIN_HOME, None), - (env_vars::XDG_DATA_HOME, Some(xdg_data.as_os_str())), - ], - || { - let got = bin_dir().expect("bin from XDG_DATA_HOME/../bin"); - assert_eq!(got.clean().as_path(), home.join("bin").as_path()); - }, - ); - }); - } - #[test] #[serial(resolution_cwd)] fn dir_methods_falls_back_to_platform_when_no_source_proposes() { @@ -711,6 +681,8 @@ mod tests { assert_dir(bin_dir(), &home.join(".local/bin")); assert_dir(data_dir(), &home.join(format!(".local/share/{APP_DIR_NAME}"))); assert_dir(cache_dir(), &home.join(format!(".cache/{APP_DIR_NAME}"))); + assert_dir(config_dir(), &home.join(format!(".config/{APP_DIR_NAME}"))); + assert_dir(state_dir(), &home.join(format!(".local/state/{APP_DIR_NAME}"))); }); } @@ -736,16 +708,4 @@ mod tests { } } } - - /// Windows platform-default coverage — whole submodule is `cfg`'d once. - #[cfg(target_os = "windows")] - mod windows { - use super::*; - use crate::dirs::resolution::windows::Windows; - - #[test] - fn fallthrough_strategy() { - assert_eq!(Windows::FALLTHROUGH, FallthroughStrategy::Set); - } - } }