diff --git a/crates/vite_global_cli/src/commands/env/doctor.rs b/crates/vite_global_cli/src/commands/env/doctor.rs index 90c3c84728..c1ace3df55 100644 --- a/crates/vite_global_cli/src/commands/env/doctor.rs +++ b/crates/vite_global_cli/src/commands/env/doctor.rs @@ -476,6 +476,33 @@ fn check_profile_files(vite_plus_home: &str) -> Option { } } + // If ZDOTDIR is set and differs from $HOME, also check $ZDOTDIR/.zshenv and .zshrc + if let Ok(zdotdir) = std::env::var("ZDOTDIR") { + if !zdotdir.is_empty() && zdotdir != home_dir { + for file in [".zshenv", ".zshrc"] { + let path = format!("{zdotdir}/{file}"); + if let Ok(content) = std::fs::read_to_string(&path) { + if search_strings.iter().any(|s| content.contains(s)) { + return Some(abbreviate_home(&path)); + } + } + } + } + } + + // If XDG_CONFIG_HOME is set and differs from default, also check fish conf.d + if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") { + let default_config = format!("{home_dir}/.config"); + if !xdg_config.is_empty() && xdg_config != default_config { + let path = format!("{xdg_config}/fish/conf.d/vite-plus.fish"); + if let Ok(content) = std::fs::read_to_string(&path) { + if search_strings.iter().any(|s| content.contains(s)) { + return Some(abbreviate_home(&path)); + } + } + } + } + None } @@ -766,4 +793,99 @@ mod tests { assert_eq!(abbreviate_home("/usr/local/bin"), "/usr/local/bin"); } } + + /// Guard for env vars used by profile file tests. + #[cfg(not(windows))] + struct ProfileEnvGuard { + original_home: Option, + original_zdotdir: Option, + original_xdg_config: Option, + } + + #[cfg(not(windows))] + impl ProfileEnvGuard { + fn new( + home: &std::path::Path, + zdotdir: Option<&std::path::Path>, + xdg_config: Option<&std::path::Path>, + ) -> Self { + let guard = Self { + original_home: std::env::var_os("HOME"), + original_zdotdir: std::env::var_os("ZDOTDIR"), + original_xdg_config: std::env::var_os("XDG_CONFIG_HOME"), + }; + unsafe { + std::env::set_var("HOME", home); + match zdotdir { + Some(v) => std::env::set_var("ZDOTDIR", v), + None => std::env::remove_var("ZDOTDIR"), + } + match xdg_config { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + guard + } + } + + #[cfg(not(windows))] + impl Drop for ProfileEnvGuard { + fn drop(&mut self) { + unsafe { + match &self.original_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match &self.original_zdotdir { + Some(v) => std::env::set_var("ZDOTDIR", v), + None => std::env::remove_var("ZDOTDIR"), + } + match &self.original_xdg_config { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + } + } + + #[test] + #[serial] + #[cfg(not(windows))] + fn test_check_profile_files_finds_zdotdir() { + let temp = TempDir::new().unwrap(); + let fake_home = temp.path().join("home"); + let zdotdir = temp.path().join("zdotdir"); + std::fs::create_dir_all(&fake_home).unwrap(); + std::fs::create_dir_all(&zdotdir).unwrap(); + + std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + + let _guard = ProfileEnvGuard::new(&fake_home, Some(&zdotdir), None); + + let result = check_profile_files("$HOME/.vite-plus"); + assert!(result.is_some(), "Should find .zshenv in ZDOTDIR"); + assert!(result.unwrap().ends_with(".zshenv")); + } + + #[test] + #[serial] + #[cfg(not(windows))] + fn test_check_profile_files_finds_xdg_fish() { + let temp = TempDir::new().unwrap(); + let fake_home = temp.path().join("home"); + let xdg_config = temp.path().join("xdg_config"); + let fish_dir = xdg_config.join("fish/conf.d"); + std::fs::create_dir_all(&fake_home).unwrap(); + std::fs::create_dir_all(&fish_dir).unwrap(); + + std::fs::write(fish_dir.join("vite-plus.fish"), "source \"$HOME/.vite-plus/env.fish\"\n") + .unwrap(); + + let _guard = ProfileEnvGuard::new(&fake_home, None, Some(&xdg_config)); + + let result = check_profile_files("$HOME/.vite-plus"); + assert!(result.is_some(), "Should find vite-plus.fish in XDG_CONFIG_HOME"); + assert!(result.unwrap().contains("vite-plus.fish")); + } } diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs index fc5a91ff13..78c15e1b2d 100644 --- a/crates/vite_global_cli/src/commands/implode.rs +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -7,7 +7,7 @@ use std::{ use directories::BaseDirs; use owo_colors::OwoColorize; -use vite_path::AbsolutePathBuf; +use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_shared::output; use vite_str::Str; @@ -23,6 +23,14 @@ const SHELL_PROFILES: &[(&str, bool)] = &[ (".config/fish/conf.d/vite-plus.fish", true), ]; +/// Abbreviate a path for display: replace `$HOME` prefix with `~`. +fn abbreviate_home_path(path: &AbsolutePath, user_home: &AbsolutePath) -> Str { + match path.strip_prefix(user_home) { + Ok(Some(suffix)) => vite_str::format!("~/{suffix}"), + _ => Str::from(path.to_string()), + } +} + /// Comment marker written by the install script above the sourcing line. const VITE_PLUS_COMMENT: &str = "# Vite+ bin"; @@ -97,26 +105,52 @@ enum AffectedProfileKind { /// Content is cached so we don't need to re-read during cleaning. fn collect_affected_profiles(user_home: &AbsolutePathBuf) -> Vec { let mut affected = Vec::new(); - for &(name, is_snippet) in SHELL_PROFILES { - let path = user_home.join(name); + + // Build full list of (display_name, path, is_snippet) from the base set + let mut profiles: Vec<(Str, AbsolutePathBuf, bool)> = SHELL_PROFILES + .iter() + .map(|&(name, is_snippet)| { + (vite_str::format!("~/{name}"), user_home.join(name), is_snippet) + }) + .collect(); + + // If ZDOTDIR is set and differs from $HOME, also check there. + if let Ok(zdotdir) = std::env::var("ZDOTDIR") + && let Some(zdotdir_path) = AbsolutePathBuf::new(zdotdir.into()) + && zdotdir_path != *user_home + { + for name in [".zshenv", ".zshrc"] { + let path = zdotdir_path.join(name); + let display = abbreviate_home_path(&path, user_home); + profiles.push((display, path, false)); + } + } + + // If XDG_CONFIG_HOME is set and differs from $HOME/.config, also check there. + if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") + && let Some(xdg_path) = AbsolutePathBuf::new(xdg_config.into()) + && xdg_path != user_home.join(".config") + { + let path = xdg_path.join("fish/conf.d/vite-plus.fish"); + let display = abbreviate_home_path(&path, user_home); + profiles.push((display, path, true)); + } + + for (name, path, is_snippet) in profiles { // For snippets, check if the file exists only if is_snippet { - if let Some(true) = std::fs::exists(&path).ok() { - affected.push(AffectedProfile { - name: Str::from(name), - path, - kind: AffectedProfileKind::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 is_ok_and handles gracefully (no redundant exists() check). + // which .ok().filter() handles gracefully (no redundant exists() check). if let Some(content) = std::fs::read_to_string(&path).ok().filter(|c| has_vite_plus_lines(c)) { affected.push(AffectedProfile { - name: Str::from(name), + name, path, kind: AffectedProfileKind::Main { content: Str::from(content) }, }); @@ -144,7 +178,7 @@ fn confirm_implode( if !affected_profiles.is_empty() { output::raw(" Shell profiles to clean:"); for profile in affected_profiles { - output::raw(&vite_str::format!(" - ~/{}", profile.name)); + output::raw(&vite_str::format!(" - {}", profile.name)); } } output::raw(""); @@ -171,16 +205,16 @@ fn clean_affected_profiles(affected_profiles: &[AffectedProfile]) { AffectedProfileKind::Main { content } => { let cleaned = remove_vite_plus_lines(content); match std::fs::write(&profile.path, cleaned.as_bytes()) { - Ok(()) => output::success(&vite_str::format!("Cleaned ~/{}", profile.name)), + Ok(()) => output::success(&vite_str::format!("Cleaned {}", profile.name)), Err(e) => { - output::warn(&vite_str::format!("Failed to clean ~/{}: {e}", profile.name)); + output::warn(&vite_str::format!("Failed to clean {}: {e}", profile.name)); } } } AffectedProfileKind::Snippet => match std::fs::remove_file(&profile.path) { - Ok(()) => output::success(&vite_str::format!("Removed ~/{}", profile.name)), + Ok(()) => output::success(&vite_str::format!("Removed {}", profile.name)), Err(e) => { - output::warn(&vite_str::format!("Failed to remove ~/{}: {e}", profile.name)); + output::warn(&vite_str::format!("Failed to remove {}: {e}", profile.name)); } }, } @@ -336,6 +370,9 @@ fn remove_windows_path_entry(bin_path: &vite_path::AbsolutePath) -> std::io::Res #[cfg(test)] mod tests { + #[cfg(not(windows))] + use serial_test::serial; + use super::*; #[test] @@ -420,6 +457,130 @@ mod tests { assert!(script.contains("timeout /T 1 /NOBREAK")); } + #[test] + #[cfg(not(windows))] + fn test_abbreviate_home_path() { + let home = AbsolutePathBuf::new("/home/user".into()).unwrap(); + // Under home → ~/... + let under = AbsolutePathBuf::new("/home/user/.zshrc".into()).unwrap(); + assert_eq!(&*abbreviate_home_path(&under, &home), "~/.zshrc"); + // Outside home → absolute path as-is + let outside = AbsolutePathBuf::new("/opt/zdotdir/.zshenv".into()).unwrap(); + assert_eq!(&*abbreviate_home_path(&outside, &home), "/opt/zdotdir/.zshenv"); + } + + #[test] + #[serial] + #[cfg(not(windows))] + fn test_collect_affected_profiles() { + let temp_dir = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Clear ZDOTDIR/XDG_CONFIG_HOME so the test environment doesn't affect results + let _guard = ProfileEnvGuard::new(None, None); + + // Main profile with vite-plus line + std::fs::write(home.join(".zshrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + // Unrelated profile (should be ignored) + std::fs::write(home.join(".bashrc"), "export PATH=/usr/bin\n").unwrap(); + // Snippet file (just needs to exist) + 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); + assert_eq!(profiles.len(), 2); + assert!(matches!(&profiles[0].kind, AffectedProfileKind::Main { .. })); + assert!(matches!(&profiles[1].kind, AffectedProfileKind::Snippet)); + } + + /// Guard that saves and restores ZDOTDIR and XDG_CONFIG_HOME env vars. + #[cfg(not(windows))] + struct ProfileEnvGuard { + original_zdotdir: Option, + original_xdg_config: Option, + } + + #[cfg(not(windows))] + impl ProfileEnvGuard { + fn new(zdotdir: Option<&std::path::Path>, xdg_config: Option<&std::path::Path>) -> Self { + let guard = Self { + original_zdotdir: std::env::var_os("ZDOTDIR"), + original_xdg_config: std::env::var_os("XDG_CONFIG_HOME"), + }; + unsafe { + match zdotdir { + Some(v) => std::env::set_var("ZDOTDIR", v), + None => std::env::remove_var("ZDOTDIR"), + } + match xdg_config { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + guard + } + } + + #[cfg(not(windows))] + impl Drop for ProfileEnvGuard { + fn drop(&mut self) { + unsafe { + match &self.original_zdotdir { + Some(v) => std::env::set_var("ZDOTDIR", v), + None => std::env::remove_var("ZDOTDIR"), + } + match &self.original_xdg_config { + Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + } + } + } + + #[test] + #[serial] + #[cfg(not(windows))] + fn test_collect_affected_profiles_zdotdir() { + let temp_dir = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().join("home")).unwrap(); + let zdotdir = temp_dir.path().join("zdotdir"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&zdotdir).unwrap(); + + std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + + let _guard = ProfileEnvGuard::new(Some(&zdotdir), None); + + let profiles = collect_affected_profiles(&home); + let zdotdir_profiles: Vec<_> = + profiles.iter().filter(|p| p.path.as_path().starts_with(&zdotdir)).collect(); + assert_eq!(zdotdir_profiles.len(), 1); + assert!(matches!(&zdotdir_profiles[0].kind, AffectedProfileKind::Main { .. })); + } + + #[test] + #[serial] + #[cfg(not(windows))] + fn test_collect_affected_profiles_xdg_config() { + let temp_dir = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().join("home")).unwrap(); + let xdg_config = temp_dir.path().join("xdg_config"); + let fish_dir = xdg_config.join("fish/conf.d"); + 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(); + + let _guard = ProfileEnvGuard::new(None, Some(&xdg_config)); + + let profiles = collect_affected_profiles(&home); + let xdg_profiles: Vec<_> = + profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_config)).collect(); + assert_eq!(xdg_profiles.len(), 1); + assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); + } + #[test] fn test_execute_not_installed() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 8155a0379e..8d4fd05fac 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -350,17 +350,19 @@ configure_shell_path() { # Add to both .zshenv (for all shells including IDE) and .zshrc (to ensure PATH is at front) # Create .zshenv if missing — it's the canonical place for PATH in zsh # and is sourced by all session types (interactive, non-interactive, IDE) - [ -f "$HOME/.zshenv" ] || touch "$HOME/.zshenv" + local zsh_dir="${ZDOTDIR:-$HOME}" + mkdir -p "$zsh_dir" + [ -f "$zsh_dir/.zshenv" ] || touch "$zsh_dir/.zshenv" local zshenv_result=0 zshrc_result=0 - add_bin_to_path "$HOME/.zshenv" || zshenv_result=$? - add_bin_to_path "$HOME/.zshrc" || zshrc_result=$? + add_bin_to_path "$zsh_dir/.zshenv" || zshenv_result=$? + add_bin_to_path "$zsh_dir/.zshrc" || zshrc_result=$? # Prioritize .zshrc for user notification (easier to source) if [ $zshrc_result -eq 0 ]; then result=0 - SHELL_CONFIG_UPDATED=".zshrc" + SHELL_CONFIG_UPDATED="$zsh_dir/.zshrc" elif [ $zshenv_result -eq 0 ]; then result=0 - SHELL_CONFIG_UPDATED=".zshenv" + SHELL_CONFIG_UPDATED="$zsh_dir/.zshenv" elif [ $zshenv_result -eq 2 ] || [ $zshrc_result -eq 2 ]; then result=2 # already configured in at least one file fi @@ -377,19 +379,19 @@ configure_shell_path() { # Prioritize .bashrc for user notification (most commonly edited) if [ $bashrc_result -eq 0 ]; then result=0 - SHELL_CONFIG_UPDATED=".bashrc" + SHELL_CONFIG_UPDATED="$HOME/.bashrc" elif [ $bash_profile_result -eq 0 ]; then result=0 - SHELL_CONFIG_UPDATED=".bash_profile" + SHELL_CONFIG_UPDATED="$HOME/.bash_profile" elif [ $profile_result -eq 0 ]; then result=0 - SHELL_CONFIG_UPDATED=".profile" + SHELL_CONFIG_UPDATED="$HOME/.profile" elif [ $bash_profile_result -eq 2 ] || [ $bashrc_result -eq 2 ] || [ $profile_result -eq 2 ]; then result=2 # already configured in at least one file fi ;; */fish) - local fish_dir="$HOME/.config/fish/conf.d" + local fish_dir="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d" local fish_config="$fish_dir/vite-plus.fish" if [ -f "$fish_config" ]; then result=2 @@ -398,7 +400,7 @@ configure_shell_path() { echo "# Vite+ bin (https://viteplus.dev)" >> "$fish_config" echo "source \"$INSTALL_DIR_REF/env.fish\"" >> "$fish_config" result=0 - SHELL_CONFIG_UPDATED=".config/fish/conf.d/vite-plus.fish" + SHELL_CONFIG_UPDATED="$fish_config" fi ;; esac @@ -650,8 +652,18 @@ NPMRC_EOF # Show restart note if PATH was added to shell config if [ "$PATH_CONFIGURED" = "true" ] && [ -n "$SHELL_CONFIG_UPDATED" ]; then + local display_config + if [ "${SHELL_CONFIG_UPDATED#"$HOME"}" != "$SHELL_CONFIG_UPDATED" ]; then + display_config="~${SHELL_CONFIG_UPDATED#"$HOME"}" + else + display_config="$SHELL_CONFIG_UPDATED" + fi echo "" - echo " Note: Run \`source ~/$SHELL_CONFIG_UPDATED\` or restart your terminal." + if [ "${display_config#"~"}" != "$display_config" ]; then + echo " Note: Run \`source $display_config\` or restart your terminal." + else + echo " Note: Run \`source \"$display_config\"\` or restart your terminal." + fi fi # Show warning if PATH could not be automatically configured