diff --git a/Cargo.lock b/Cargo.lock index c3f1c3c6f8..ff58b5e9a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8625,7 +8625,6 @@ dependencies = [ "tracing-subscriber", "vite_path", "vite_str", - "which", ] [[package]] diff --git a/crates/vite_global_cli/src/commands/env/setup.rs b/crates/vite_global_cli/src/commands/env/setup.rs index 99ac61a1af..bdfabdc1cc 100644 --- a/crates/vite_global_cli/src/commands/env/setup.rs +++ b/crates/vite_global_cli/src/commands/env/setup.rs @@ -526,6 +526,7 @@ pub(crate) async fn cleanup_legacy_windows_shim(bin_dir: &vite_path::AbsolutePat // 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}:"*) @@ -573,6 +574,7 @@ fi "#; const ENV_TEMPLATE_FISH: &str = r#"# Vite+ environment setup (https://viteplus.dev) +set -gx VP_HOME "__VP_HOME__" set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) and set -e PATH[$__vp_idx] set -gx PATH __VP_BIN__ $PATH @@ -608,6 +610,7 @@ complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" // Completions delegate to Fish dynamically (VP_COMPLETE=fish) because clap_complete_nushell // generates multiple rest params (e.g. for `vp install`), which Nushell does not support. const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev) +$env.VP_HOME = ("__VP_HOME__" | path expand --no-symlink) $env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") # Shell function wrapper: intercepts `vp env use` to parse its stdout, @@ -668,6 +671,7 @@ export extern "vpr" [...args: string@"nu-complete vpr"] "#; const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) +$env:VP_HOME = "__VP_HOME_WIN__" $__vp_bin = "__VP_BIN_WIN__" if ($env:Path -split ';' -notcontains $__vp_bin) { $env:Path = "$__vp_bin;$env:Path" @@ -728,37 +732,63 @@ 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`. -const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; - -/// Render the env-file content for `shell` against `vite_plus_home`. -fn render_env_content(shell: EnvShell, vite_plus_home: &vite_path::AbsolutePath) -> String { - let bin_path = vite_plus_home.join("bin"); +const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nset VP_HOME=%~dp0..\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; +fn 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). // This makes the env file portable across sessions where HOME may differ. - let home_dir = vite_shared::EnvConfig::get().user_home; - let bin_path_ref = home_dir - .as_ref() - .and_then(|h| bin_path.as_path().strip_prefix(h).ok()) + home_dir + .and_then(|h| path.strip_prefix(h).ok()) .map(|s| { - // Normalize to forward slashes for $HOME/... paths (POSIX-style) - format!("$HOME/{}", s.display().to_string().replace('\\', "/")) + if s.as_os_str().is_empty() { + "$HOME".to_string() + } else { + // Normalize to forward slashes for $HOME/... paths (POSIX-style) + format!("$HOME/{}", s.display().to_string().replace('\\', "/")) + } }) - .unwrap_or_else(|| bin_path.as_path().display().to_string().replace('\\', "/")); + .unwrap_or_else(|| path.display().to_string().replace('\\', "/")) +} + +fn render_nu_path_ref(path_ref: &str) -> String { + match path_ref.strip_prefix("$HOME") { + Some("") => "~".to_string(), + Some(suffix) if suffix.starts_with('/') => format!("~{suffix}"), + _ => path_ref.to_string(), + } +} + +/// Render the env-file content for `shell` against `vite_plus_home`. +fn render_env_content(shell: EnvShell, vite_plus_home: &vite_path::AbsolutePath) -> String { + let bin_path = vite_plus_home.join("bin"); + let home_dir = vite_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_BIN__", &bin_path_ref), - EnvShell::Fish => ENV_TEMPLATE_FISH.replace("__VP_BIN__", &bin_path_ref), + 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::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 bin_path_ref_nu = bin_path_ref.replace("$HOME/", "~/"); - ENV_TEMPLATE_NU.replace("__VP_BIN__", &bin_path_ref_nu) + 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) } 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_BIN_WIN__", &bin_path_win) + ENV_TEMPLATE_PS1 + .replace("__VP_HOME_WIN__", &home_path_win) + .replace("__VP_BIN_WIN__", &bin_path_win) } } } @@ -919,13 +949,16 @@ 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().to_path_buf()).unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().join("vp_home")).unwrap(); let _guard = home_guard(temp_dir.path()); + tokio::fs::create_dir_all(&home).await.unwrap(); create_env_files(&home).await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); + let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); // Placeholder should be fully replaced assert!( @@ -936,15 +969,45 @@ 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/bin"), - "env file should reference $HOME/bin, got: {env_content}" + env_content.contains("$HOME/vp_home/bin"), + "env file should reference $HOME/vp_home/bin, got: {env_content}" + ); + assert!( + fish_content.contains("$HOME/vp_home/bin"), + "env.fish file should reference $HOME/vp_home/bin, got: {fish_content}" + ); + assert!( + env_content.contains("export VP_HOME=\"$HOME/vp_home\""), + "env file should export VP_HOME, got: {env_content}" ); assert!( - fish_content.contains("$HOME/bin"), - "env.fish file should reference $HOME/bin, got: {fish_content}" + fish_content.contains("set -gx VP_HOME \"$HOME/vp_home\""), + "env.fish file should export VP_HOME, got: {fish_content}" + ); + assert!( + nu_content.contains("$env.VP_HOME = (\"~/vp_home\" | path expand --no-symlink)"), + "env.nu file should set home-relative VP_HOME, got: {nu_content}" + ); + assert!( + nu_content.contains("~/vp_home/bin"), + "env.nu file should reference ~/vp_home/bin, got: {nu_content}" + ); + + let expected_home = home.as_path().display().to_string(); + assert!( + ps1_content.contains(&format!("$env:VP_HOME = \"{expected_home}\"")), + "env.ps1 file should set VP_HOME, got: {ps1_content}" ); } @@ -963,6 +1026,7 @@ mod tests { // Should use absolute path since install dir is not under HOME let expected_bin = home.join("bin"); let expected_str = expected_bin.as_path().display().to_string().replace('\\', "/"); + let expected_home = home.as_path().display().to_string().replace('\\', "/"); assert!( env_content.contains(&expected_str), "env file should use absolute path {expected_str}, got: {env_content}" @@ -971,6 +1035,14 @@ mod tests { fish_content.contains(&expected_str), "env.fish file should use absolute path {expected_str}, got: {fish_content}" ); + assert!( + env_content.contains(&format!("export VP_HOME=\"{expected_home}\"")), + "env file should export absolute VP_HOME {expected_home}, got: {env_content}" + ); + assert!( + fish_content.contains(&format!("set -gx VP_HOME \"{expected_home}\"")), + "env.fish file should export absolute VP_HOME {expected_home}, got: {fish_content}" + ); // Should NOT use $HOME-relative path assert!(!env_content.contains("$HOME/bin"), "env file should not reference $HOME/bin"); @@ -1120,6 +1192,27 @@ mod tests { ); } + #[tokio::test] + async fn test_create_env_files_cmd_wrapper_sets_vp_home_before_env_use() { + let temp_dir = TempDir::new().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _guard = home_guard(temp_dir.path()); + let bin_dir = home.join("bin"); + tokio::fs::create_dir_all(&bin_dir).await.unwrap(); + + create_env_files(&home).await.unwrap(); + + let cmd_content = tokio::fs::read_to_string(bin_dir.join("vp-use.cmd")).await.unwrap(); + assert!( + cmd_content.contains("set VP_HOME=%~dp0..\r\nfor /f"), + "vp-use.cmd should set VP_HOME before invoking vp env use, got: {cmd_content}" + ); + assert!( + cmd_content.contains("%~dp0..\\current\\bin\\vp.exe env use %*"), + "vp-use.cmd should invoke the install-local vp.exe" + ); + } + #[tokio::test] async fn test_execute_env_only_creates_home_dir_and_env_files() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs index 39148da71c..e2f5ac8f30 100644 --- a/crates/vite_global_cli/src/commands/implode.rs +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -38,8 +38,10 @@ 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); + // Collect shell profiles that contain Vite+ lines (content cached for cleaning) - let affected_profiles = collect_affected_profiles(&user_home); + let affected_profiles = collect_affected_profiles(&user_home, &source_matcher); // Confirmation if !yes && !confirm_implode(&home_dir, &affected_profiles)? { @@ -47,7 +49,7 @@ pub fn execute(yes: bool) -> Result { } // Clean shell profiles using cached content (no re-read) - clean_affected_profiles(&affected_profiles); + clean_affected_profiles(&affected_profiles, &source_matcher); // Remove Windows PATH entry #[cfg(windows)] @@ -86,34 +88,38 @@ enum AffectedProfileKind { Main { /// File content read during detection (reused for cleaning). content: Str, + env_file: &'static str, }, } /// Collect shell profiles that contain Vite+ sourcing lines. /// Content is cached so we don't need to re-read during cleaning. -fn collect_affected_profiles(user_home: &AbsolutePathBuf) -> Vec { +fn collect_affected_profiles( + user_home: &AbsolutePathBuf, + source_matcher: &VitePlusSourceMatcher, +) -> Vec { let mut affected = Vec::new(); for profile in ALL_SHELL_PROFILES { let path = resolve_profile_path(profile, user_home); let name = abbreviate_home_path(&path, user_home); - // For snippets, check if the file exists only - if matches!(profile.kind, ShellProfileKind::Snippet) { - if let Ok(true) = std::fs::exists(&path) { - affected.push(AffectedProfile { name, path, kind: AffectedProfileKind::Snippet }) - } - continue; - } // Read directly — if the file doesn't exist, read_to_string returns Err // which .ok().filter() handles gracefully (no redundant exists() check). - if let Some(content) = - std::fs::read_to_string(&path).ok().filter(|c| c.lines().any(is_vite_plus_source_line)) - { + if let Some(content) = std::fs::read_to_string(&path).ok().filter(|c| { + c.lines().any(|line| source_matcher.is_vite_plus_source_line(line, profile.env_file)) + }) { + if matches!(profile.kind, ShellProfileKind::Snippet) { + affected.push(AffectedProfile { name, path, kind: AffectedProfileKind::Snippet }); + continue; + } affected.push(AffectedProfile { name, path, - kind: AffectedProfileKind::Main { content: Str::from(content) }, + kind: AffectedProfileKind::Main { + content: Str::from(content), + env_file: profile.env_file, + }, }); } } @@ -160,11 +166,14 @@ fn confirm_implode( } /// Clean all affected shell profiles using cached content (no re-read). -fn clean_affected_profiles(affected_profiles: &[AffectedProfile]) { +fn clean_affected_profiles( + affected_profiles: &[AffectedProfile], + source_matcher: &VitePlusSourceMatcher, +) { for profile in affected_profiles { match &profile.kind { - AffectedProfileKind::Main { content } => { - let cleaned = remove_vite_plus_lines(content); + AffectedProfileKind::Main { content, env_file } => { + let cleaned = remove_vite_plus_lines(content, source_matcher, env_file); match std::fs::write(&profile.path, cleaned.as_bytes()) { Ok(()) => output::success(&vite_str::format!("Cleaned {}", profile.name)), Err(e) => { @@ -263,27 +272,86 @@ fn spawn_deferred_delete(trash_path: &std::path::Path) -> std::io::Result bool { +/// Matches shell-profile `source` lines that reference *this* install's env +/// files, so a second Vite+ install's lines are left untouched. +/// +/// The recognized home spellings must mirror what the writers emit: +/// `install.sh`/`install.ps1` (shell PATH setup) and `render_env_content` in +/// `env/setup.rs`. `env/doctor.rs::check_profile_files` derives the same +/// variants for its profile scan; keep them in sync. +struct VitePlusSourceMatcher { + /// Home-dir spellings with forward-slash separators: the absolute path, + /// plus `$HOME`- and `~`-relative forms when the home is under `$HOME`. + roots: Vec, +} + +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 = vite_str::format!("{suffix}"); + if suffix.is_empty() { + roots.push(Str::from("$HOME")); + roots.push(Str::from("~")); + } else { + roots.push(vite_str::format!("$HOME/{suffix}")); + roots.push(vite_str::format!("~/{suffix}")); + } + } + + Self { roots } + } + + fn is_vite_plus_source_line(&self, line: &str, env_file: &str) -> bool { + let Some(arg) = source_line_arg(line) else { + return false; + }; + + // Windows profiles may spell the path with backslashes (e.g. Nushell's + // `source '~\.vite-plus\env.nu'`); compare in forward-slash form. + let arg = normalize_path_separators(arg); + self.roots.iter().any(|root| arg == join_path_ref(root, env_file)) + } +} + +fn join_path_ref(root: &str, env_file: &str) -> Str { + let separator = if root.ends_with('/') { "" } else { "/" }; + vite_str::format!("{root}{separator}{env_file}") +} + +fn normalize_path_separators(path: &str) -> Str { + Str::from(path.replace('\\', "/")) +} + +fn source_line_arg(line: &str) -> Option<&str> { + let rest = source_command_remainder(line)?.trim_start(); + if let Some(rest) = rest.strip_prefix('"') { + return rest.find('"').map(|end| &rest[..end]); + } + if let Some(rest) = rest.strip_prefix('\'') { + return rest.find('\'').map(|end| &rest[..end]); + } + rest.split_whitespace().next() +} + +fn source_command_remainder(line: &str) -> Option<&str> { let trimmed = line.trim_start(); - [ - (". ", ".vite-plus/env\""), - ("source ", ".vite-plus/env\""), - ("source ", ".vite-plus/env.fish\""), - ("source ", ".vite-plus/env.nu'"), - ("source ", ".vite-plus\\env.nu'"), - ] - .iter() - .any(|(prefix, suffix)| trimmed.starts_with(prefix) && trimmed.contains(suffix)) + trimmed.strip_prefix(". ").or_else(|| trimmed.strip_prefix("source ")) } /// Remove Vite+ lines from content, returning the cleaned string. -fn remove_vite_plus_lines(content: &str) -> Str { +fn remove_vite_plus_lines( + content: &str, + source_matcher: &VitePlusSourceMatcher, + env_file: &str, +) -> Str { let lines: Vec<&str> = content.lines().collect(); let mut remove_indices = Vec::new(); for (i, line) in lines.iter().enumerate() { - if is_vite_plus_source_line(line) { + if source_matcher.is_vite_plus_source_line(line, env_file) { remove_indices.push(i); // Also remove the comment line above if i > 0 && lines[i - 1].contains(VITE_PLUS_COMMENT) { @@ -343,52 +411,132 @@ mod tests { use super::*; + fn test_absolute_path(posix: &str, windows: &str) -> AbsolutePathBuf { + let path = if cfg!(windows) { windows } else { posix }; + AbsolutePathBuf::new(path.into()).unwrap() + } + + fn default_user_home() -> AbsolutePathBuf { + test_absolute_path("/home/user", r"C:\Users\user") + } + + fn custom_user_home() -> AbsolutePathBuf { + test_absolute_path("/Users/test", r"C:\Users\test") + } + + fn shell_path(path: &AbsolutePathBuf) -> Str { + normalize_path_separators(&path.as_path().display().to_string()) + } + + 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) + } + #[test] fn test_remove_vite_plus_lines_posix() { + let matcher = default_source_matcher(); let content = "# existing config\nexport FOO=bar\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n"; - let result = remove_vite_plus_lines(content); + let result = remove_vite_plus_lines(content, &matcher, "env"); assert_eq!(&*result, "# existing config\nexport FOO=bar\n"); } #[test] fn test_remove_vite_plus_lines_no_match() { + let matcher = default_source_matcher(); let content = "# just a normal config\nexport PATH=/usr/bin\n"; - let result = remove_vite_plus_lines(content); + let result = remove_vite_plus_lines(content, &matcher, "env"); assert_eq!(&*result, content); } #[test] fn test_remove_vite_plus_lines_absolute_path() { - let content = "# existing\n. \"/home/user/.vite-plus/env\"\n"; - let result = remove_vite_plus_lines(content); + let user_home = default_user_home(); + let home_dir = user_home.join(".vite-plus"); + let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let env_path = shell_path(&home_dir.join("env")); + let content = vite_str::format!("# existing\n. \"{env_path}\"\n"); + let result = remove_vite_plus_lines(&content, &matcher, "env"); assert_eq!(&*result, "# existing\n"); } + #[test] + 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 env_path = shell_path(&home_dir.join("env")); + let content = vite_str::format!("# existing\n. \"{env_path}\"\n"); + let result = remove_vite_plus_lines(&content, &matcher, "env"); + assert_eq!(&*result, "# existing\n"); + } + + #[test] + 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 content = "# existing\n. \"$HOME/tools/vp/env\"\n"; + let result = remove_vite_plus_lines(content, &matcher, "env"); + assert_eq!(&*result, "# existing\n"); + } + + #[test] + 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 content = "# existing\nsource '~/tools/vp/env.nu'\n"; + let result = remove_vite_plus_lines(content, &matcher, "env.nu"); + assert_eq!(&*result, "# existing\n"); + } + + #[test] + fn test_remove_vite_plus_lines_ignores_marker_with_unmatched_path() { + let matcher = default_source_matcher(); + let content = "# existing\n\n# Vite+ bin (https://viteplus.dev)\n. \"/opt/old-vp/env\"\n"; + let result = remove_vite_plus_lines(content, &matcher, "env"); + assert_eq!(&*result, content); + } + + #[test] + fn test_remove_vite_plus_lines_env_does_not_match_env_fish() { + let matcher = default_source_matcher(); + let content = "# existing\nsource \"$HOME/.vite-plus/env.fish\"\n"; + let result = remove_vite_plus_lines(content, &matcher, "env"); + assert_eq!(&*result, content); + } + #[test] fn test_remove_vite_plus_lines_fish() { + let matcher = default_source_matcher(); let content = "# existing config\n\n# Vite+ bin (https://viteplus.dev)\nsource \"$HOME/.vite-plus/env.fish\"\n"; - let result = remove_vite_plus_lines(content); + let result = remove_vite_plus_lines(content, &matcher, "env.fish"); assert_eq!(&*result, "# existing config\n"); } #[test] fn test_remove_vite_plus_lines_nushell() { + let matcher = default_source_matcher(); let content = "# existing config\n\n# Vite+ bin (https://viteplus.dev)\nsource '~/.vite-plus/env.nu'\n"; - let result = remove_vite_plus_lines(content); + let result = remove_vite_plus_lines(content, &matcher, "env.nu"); assert_eq!(&*result, "# existing config\n"); } #[test] fn test_remove_vite_plus_lines_nushell_windows_path() { + let matcher = default_source_matcher(); let content = "# existing config\nsource '~\\.vite-plus\\env.nu'\n"; - let result = remove_vite_plus_lines(content); + let result = remove_vite_plus_lines(content, &matcher, "env.nu"); assert_eq!(&*result, "# existing config\n"); } #[test] fn test_remove_vite_plus_lines_preserves_surrounding() { + let matcher = default_source_matcher(); let content = "# before\nexport A=1\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n# after\nexport B=2\n"; - let result = remove_vite_plus_lines(content); + let result = remove_vite_plus_lines(content, &matcher, "env"); assert_eq!(&*result, "# before\nexport A=1\n# after\nexport B=2\n"); } @@ -396,6 +544,8 @@ mod tests { fn test_clean_affected_profiles_integration() { 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 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(); @@ -403,9 +553,9 @@ mod tests { let profiles = vec![AffectedProfile { name: Str::from(".zshrc"), path: profile_path.clone(), - kind: AffectedProfileKind::Main { content: Str::from(original) }, + kind: AffectedProfileKind::Main { content: Str::from(original), env_file: "env" }, }]; - clean_affected_profiles(&profiles); + clean_affected_profiles(&profiles, &matcher); let result = std::fs::read_to_string(&profile_path).unwrap(); assert_eq!(result, "# my config\nexport FOO=bar\n"); @@ -464,6 +614,8 @@ mod tests { fn test_collect_affected_profiles() { 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); // Clear env overrides so the test environment doesn't affect results let _guard = ProfileEnvGuard::new(None, None, None); @@ -472,17 +624,39 @@ mod tests { std::fs::write(home.join(".zshrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); // Unrelated profile (should be ignored) std::fs::write(home.join(".bashrc"), "export PATH=/usr/bin\n").unwrap(); - // Snippet file (just needs to exist) + // Snippet file with a matching Vite+ source line let fish_dir = home.join(".config/fish/conf.d"); std::fs::create_dir_all(&fish_dir).unwrap(); std::fs::write(fish_dir.join("vite-plus.fish"), "source ~/.vite-plus/env.fish\n").unwrap(); - let profiles = collect_affected_profiles(&home); + let profiles = collect_affected_profiles(&home, &matcher); assert_eq!(profiles.len(), 2); assert!(matches!(&profiles[0].kind, AffectedProfileKind::Main { .. })); assert!(matches!(&profiles[1].kind, AffectedProfileKind::Snippet)); } + #[test] + #[serial] + #[cfg(not(windows))] + fn test_collect_affected_profiles_custom_home_relative_path() { + let temp_dir = tempfile::tempdir().unwrap(); + 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 _guard = ProfileEnvGuard::new(None, None, None); + + std::fs::write(home.join(".zshrc"), ". \"$HOME/tools/vp/env\"\n").unwrap(); + std::fs::write(home.join(".bashrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + let fish_dir = home.join(".config/fish/conf.d"); + std::fs::create_dir_all(&fish_dir).unwrap(); + std::fs::write(fish_dir.join("vite-plus.fish"), "source ~/.vite-plus/env.fish\n").unwrap(); + + let profiles = collect_affected_profiles(&home, &matcher); + assert_eq!(profiles.len(), 1); + assert!(matches!(&profiles[0].kind, AffectedProfileKind::Main { .. })); + } + /// Guard that saves and restores profile-related env vars. #[cfg(not(windows))] struct ProfileEnvGuard { @@ -554,8 +728,9 @@ mod tests { std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); let _guard = ProfileEnvGuard::new(Some(&zdotdir), None, None); + let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); - let profiles = collect_affected_profiles(&home); + let profiles = collect_affected_profiles(&home, &matcher); let zdotdir_profiles: Vec<_> = profiles.iter().filter(|p| p.path.as_path().starts_with(&zdotdir)).collect(); assert_eq!(zdotdir_profiles.len(), 1); @@ -573,11 +748,13 @@ mod tests { std::fs::create_dir_all(&home).unwrap(); std::fs::create_dir_all(&fish_dir).unwrap(); - std::fs::write(fish_dir.join("vite-plus.fish"), "").unwrap(); + std::fs::write(fish_dir.join("vite-plus.fish"), "source \"$HOME/.vite-plus/env.fish\"\n") + .unwrap(); let _guard = ProfileEnvGuard::new(None, Some(&xdg_config), None); + let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); - let profiles = collect_affected_profiles(&home); + let profiles = collect_affected_profiles(&home, &matcher); let xdg_profiles: Vec<_> = profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_config)).collect(); assert_eq!(xdg_profiles.len(), 1); @@ -598,8 +775,9 @@ mod tests { std::fs::write(nushell_dir.join("vite-plus.nu"), "source '~/.vite-plus/env.nu'\n").unwrap(); let _guard = ProfileEnvGuard::new(None, None, Some(&xdg_data)); + let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); - let profiles = collect_affected_profiles(&home); + let profiles = collect_affected_profiles(&home, &matcher); let xdg_profiles: Vec<_> = profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_data)).collect(); assert_eq!(xdg_profiles.len(), 1); diff --git a/crates/vite_setup/src/lib.rs b/crates/vite_setup/src/lib.rs index c2e5d4a494..d56561e798 100644 --- a/crates/vite_setup/src/lib.rs +++ b/crates/vite_setup/src/lib.rs @@ -24,5 +24,4 @@ pub mod registry; /// Maximum number of old versions to keep. pub const MAX_VERSIONS_KEEP: usize = 3; -/// Platform-specific binary name for the `vp` CLI. -pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; +pub use vite_shared::VP_BINARY_NAME; diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index b117cb3487..62dbde59a6 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -19,7 +19,6 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } vite_path = { workspace = true } vite_str = { workspace = true } -which = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] reqwest = { workspace = true, features = ["native-tls-vendored", "stream", "json", "system-proxy"] } diff --git a/crates/vite_shared/src/home.rs b/crates/vite_shared/src/home.rs index 8edbc0e34b..f33f0a7e1d 100644 --- a/crates/vite_shared/src/home.rs +++ b/crates/vite_shared/src/home.rs @@ -1,16 +1,20 @@ +use std::env; + use directories::BaseDirs; use vite_path::{AbsolutePathBuf, current_dir}; -use which::which; 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 `node` executable's grandparent directory if it ends with `.vite-plus`, +/// 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 { @@ -21,14 +25,9 @@ pub fn get_vp_home() -> std::io::Result { return Ok(path); } - // Get from `node` executable file's grandparent directory (~/.vite-plus/bin/node) - // For the case where `$HOME` is overridden - if let Ok(path) = which("node") - && let Some(parent) = path.parent() - && let Some(grandparent) = parent.parent() - && grandparent.ends_with(VITE_PLUS_HOME_DIR) - { - return Ok(AbsolutePathBuf::new(grandparent.to_path_buf()).unwrap()); + // 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 @@ -44,10 +43,103 @@ pub fn get_vp_home() -> std::io::Result { } } +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: &vite_path::AbsolutePath, home: &vite_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(); @@ -65,61 +157,50 @@ mod tests { #[test] #[serial_test::serial] - fn test_get_vite_plus_without_home() { - use std::path::PathBuf; - - // Create a temp directory structure: /tmp/xxx/.vite-plus/bin/node - let temp_dir = PathBuf::from( - std::env::temp_dir().join(format!("vp-test-node-path-{}", std::process::id())), - ); + 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(); - // Create a fake node executable with platform-specific extension - #[cfg(windows)] - let node_path = bin_dir.join("node.exe"); - #[cfg(not(windows))] - let node_path = bin_dir.join("node"); - - // Write minimal content - on Windows, the file just needs to exist with .exe extension - // On Unix, we need a shebang and executable permissions - #[cfg(windows)] - std::fs::write(&node_path, b"MZ").unwrap(); // Minimal PE header for Windows - #[cfg(not(windows))] - { - std::fs::write(&node_path, "#!/bin/sh\necho 'fake node'").unwrap(); - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&node_path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&node_path, perms).unwrap(); - } + write_executable(&bin_dir.join(VP_BINARY_NAME)); + write_executable(¤t_bin_dir.join(VP_BINARY_NAME)); - // Set PATH to include the fake node directory FIRST (prepended) - let original_path = std::env::var("PATH").unwrap_or_default(); - #[cfg(windows)] - let path_separator = ';'; - #[cfg(not(windows))] - let path_separator = ':'; - let new_path = format!("{}{}{}", bin_dir.display(), path_separator, original_path); - // SAFETY: restore PATH after test - unsafe { - std::env::set_var("PATH", &new_path); - } + let path = std::env::join_paths([bin_dir.as_os_str()]).unwrap(); + let _path_guard = EnvVarGuard::set("PATH", path); - // Clear any existing VP_HOME env var by using a test config without it + // `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(), || { - // Test: get_vp_home should return /tmp/xxx/.vite-plus let home = get_vp_home().unwrap(); assert_eq!(home.as_path(), vite_plus_home.as_path()); }); - // SAFETY: restore PATH after test - unsafe { - std::env::set_var("PATH", original_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()); + }); - // Cleanup let _ = std::fs::remove_dir_all(&temp_dir); } } diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 7ef476143f..5abf7b727f 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -23,7 +23,7 @@ mod tracing; pub use env_config::{EnvConfig, TestEnvGuard}; pub use error::format_error_chain; -pub use home::get_vp_home; +pub use home::{VP_BINARY_NAME, get_vp_home}; pub use http::shared_http_client; pub use json_edit::{JsonStyle, edit_json_object, insert_after}; pub use package_json::{ diff --git a/docs/guide/env.md b/docs/guide/env.md index e6e8e9622b..2abd62fb3d 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -39,7 +39,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 `~/.vite-plus/`) +- `vp env setup` creates or updates shims in `VP_HOME/bin` (and writes the per-shell setup scripts under `VP_HOME`) - `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 diff --git a/docs/guide/install.md b/docs/guide/install.md index 002812d1be..fc0b6f4b54 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -72,7 +72,7 @@ Use the `-g` flag for installing, updating or removing globally installed packag ::: warning These commands do **NOT** interact with the underlying package manager's global installation directory. -Instead, Vite+ manages its own global packages under `~/.vite-plus/packages`, allowing them to remain available across different Node.js versions. +Instead, Vite+ manages its own global packages under `VP_HOME/packages`, 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/packages/cli/install.ps1 b/packages/cli/install.ps1 index 1a2eca8c3c..a0c4d0cb97 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -18,6 +18,8 @@ $ErrorActionPreference = "Stop" $ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } $InstallDir = if ($env:VP_HOME) { $env:VP_HOME } else { "$env:USERPROFILE\.vite-plus" } +# Use ~ shorthand if install dir is under USERPROFILE, matching the final summary output +$NodeManagerBinDisplay = (Join-Path $InstallDir.TrimEnd('\', '/') "bin") -replace [regex]::Escape($env:USERPROFILE), '~' # 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 @@ -199,6 +201,163 @@ function Write-ReleaseAgeOverride { } } +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 @@ -558,7 +717,7 @@ function Setup-NodeManager { 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 ~/.vite-plus/bin/ and automatically uses the right version." + 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)" @@ -581,6 +740,11 @@ function Main { 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' @@ -798,15 +962,15 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" # Cleanup old versions Cleanup-OldVersions -InstallDir $InstallDir - # Configure Windows-native shell access via the User PATH - $pathResult = Configure-UserPath - - # Configure Nushell autoload if Nushell is installed - $nushellResult = Configure-Nushell - # 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), '~' diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 479b64c00a..fa26b3c0dc 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -132,6 +132,131 @@ write_release_age_override() { 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 @@ -812,7 +937,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 ~/.vite-plus/bin/ and automatically uses the right version." + 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 @@ -880,6 +1005,12 @@ main() { 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) @@ -1065,12 +1196,14 @@ WRAPPER_EOF fi "$vp_bin" env setup --env-only > /dev/null - # Configure shell PATH (always attempted) - configure_shell_path - # 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" diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index cb359f9b28..447834bca1 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -82,7 +82,9 @@ export function installGlobalCli() { } try { - const installDir = path.join(os.homedir(), '.vite-plus'); + const installDir = process.env.VP_HOME + ? path.resolve(process.env.VP_HOME) + : path.join(os.homedir(), '.vite-plus'); // Locate the Rust vp binary (built by cargo or copied by CI) const binaryName = isWindows ? 'vp.exe' : 'vp';