From 0874b28f96263874b2e289a0df4ae0f4e30deee2 Mon Sep 17 00:00:00 2001 From: Iha Shin Date: Mon, 16 Mar 2026 19:35:46 +0900 Subject: [PATCH] feat(cli): add Nushell support --- .../src/commands/env/doctor.rs | 267 +++++++---- .../vite_global_cli/src/commands/env/mod.rs | 34 +- .../vite_global_cli/src/commands/env/setup.rs | 178 ++++++- .../vite_global_cli/src/commands/env/use.rs | 91 +--- .../vite_global_cli/src/commands/implode.rs | 148 +++--- crates/vite_global_cli/src/commands/mod.rs | 1 + crates/vite_global_cli/src/commands/shell.rs | 287 ++++++++++++ crates/vite_shared/src/env_config.rs | 7 + packages/cli/install.ps1 | 110 ++++- packages/cli/install.sh | 437 +++++++++++++----- 10 files changed, 1191 insertions(+), 369 deletions(-) create mode 100644 crates/vite_global_cli/src/commands/shell.rs diff --git a/crates/vite_global_cli/src/commands/env/doctor.rs b/crates/vite_global_cli/src/commands/env/doctor.rs index f54fe09ae9..0ee6a52b5c 100644 --- a/crates/vite_global_cli/src/commands/env/doctor.rs +++ b/crates/vite_global_cli/src/commands/env/doctor.rs @@ -7,39 +7,11 @@ use vite_path::{AbsolutePathBuf, current_dir}; use vite_shared::{env_vars, output}; use super::config::{self, ShimMode, get_bin_dir, get_vp_home, load_config, resolve_version}; -use crate::{error::Error, shim}; - -/// IDE-relevant profile files that GUI-launched applications can see. -/// GUI apps don't run through an interactive shell, so only login/environment -/// files reliably affect them. -/// - macOS: `.zshenv` is sourced for all zsh invocations (including IDE env resolution) -/// - Linux: `.profile` is sourced by X11 display managers; `.zshenv` covers Wayland + zsh -#[cfg(not(windows))] -#[cfg(target_os = "macos")] -const IDE_PROFILES: &[(&str, bool)] = &[(".zshenv", false), (".profile", false)]; - -#[cfg(not(windows))] -#[cfg(target_os = "linux")] -const IDE_PROFILES: &[(&str, bool)] = &[(".profile", false), (".zshenv", false)]; - -#[cfg(not(windows))] -#[cfg(not(any(target_os = "macos", target_os = "linux")))] -const IDE_PROFILES: &[(&str, bool)] = &[(".profile", false)]; - -/// All shell profile files that interactive terminal sessions may source. -/// This matches the files that `install.sh` writes to and `vp implode` cleans. -/// The bool flag indicates whether the file uses fish-style sourcing (`env.fish` -/// instead of `env`). #[cfg(not(windows))] -const ALL_SHELL_PROFILES: &[(&str, bool)] = &[ - (".zshenv", false), - (".zshrc", false), - (".bash_profile", false), - (".bashrc", false), - (".profile", false), - (".config/fish/config.fish", true), - (".config/fish/conf.d/vite-plus.fish", true), -]; +use crate::commands::shell::{ + ALL_SHELL_PROFILES, IDE_SHELL_PROFILES, ShellProfile, resolve_profile_path, +}; +use crate::{error::Error, shim}; /// Result of checking profile files for env sourcing. #[cfg(not(windows))] @@ -317,7 +289,7 @@ fn check_env_sourcing() -> EnvSourcingStatus { }; // First: check IDE-relevant profiles (login/environment files visible to GUI apps) - if let Some(file) = check_profile_files(&home_path, IDE_PROFILES) { + if let Some(file) = check_profile_files(&home_path, IDE_SHELL_PROFILES) { print_check( &output::CHECK.green().to_string(), "IDE integration", @@ -475,60 +447,21 @@ fn print_path_fix(bin_dir: &vite_path::AbsolutePath) { /// Returns `Some(display_path)` if any profile file contains a reference /// to the vite-plus env file, `None` otherwise. #[cfg(not(windows))] -fn check_profile_files(vite_plus_home: &str, profile_files: &[(&str, bool)]) -> Option { - let home_dir = std::env::var("HOME").ok()?; +fn check_profile_files(vite_plus_home: &str, profile_files: &[ShellProfile]) -> Option { + let home_dir = AbsolutePathBuf::new(std::env::var_os("HOME")?.into())?; + let home_dir_display = home_dir.as_path().display().to_string(); - for &(file, is_fish) in profile_files { - let full_path = format!("{home_dir}/{file}"); + for profile in profile_files { + let full_path = resolve_profile_path(profile, &home_dir); if let Ok(content) = std::fs::read_to_string(&full_path) { - // Build candidate strings: both $HOME/... and /absolute/... - let env_suffix = if is_fish { "/env.fish" } else { "/env" }; - let mut search_strings = vec![format!("{vite_plus_home}{env_suffix}")]; + let mut search_strings = vec![format!("{vite_plus_home}/{}", profile.env_file)]; if let Some(suffix) = vite_plus_home.strip_prefix("$HOME") { - search_strings.push(format!("{home_dir}{suffix}{env_suffix}")); + search_strings.push(format!("{home_dir_display}{suffix}/{}", profile.env_file)); + search_strings.push(format!("~{suffix}/{}", profile.env_file)); } if search_strings.iter().any(|s| content.contains(s)) { - return Some(format!("~/{file}")); - } - } - } - - // 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 { - let env_suffix = "/env"; - let mut search_strings = vec![format!("{vite_plus_home}{env_suffix}")]; - if let Some(suffix) = vite_plus_home.strip_prefix("$HOME") { - search_strings.push(format!("{home_dir}{suffix}{env_suffix}")); - } - - 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 fish_suffix = "/env.fish"; - let mut search_strings = vec![format!("{vite_plus_home}{fish_suffix}")]; - if let Some(suffix) = vite_plus_home.strip_prefix("$HOME") { - search_strings.push(format!("{home_dir}{suffix}{fish_suffix}")); - } - - 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)); - } + return Some(abbreviate_home(&full_path.as_path().display().to_string())); } } } @@ -726,6 +659,8 @@ mod tests { use tempfile::TempDir; use super::*; + #[cfg(not(windows))] + use crate::commands::shell::{ShellProfileKind, ShellProfileRoot}; #[test] fn test_shim_filename_consistency() { @@ -861,6 +796,7 @@ mod tests { original_home: Option, original_zdotdir: Option, original_xdg_config: Option, + original_xdg_data: Option, } #[cfg(not(windows))] @@ -869,11 +805,13 @@ mod tests { home: &std::path::Path, zdotdir: Option<&std::path::Path>, xdg_config: Option<&std::path::Path>, + xdg_data: Option<&std::path::Path>, ) -> Self { let guard = Self { original_home: std::env::var_os("HOME"), original_zdotdir: std::env::var_os("ZDOTDIR"), original_xdg_config: std::env::var_os("XDG_CONFIG_HOME"), + original_xdg_data: std::env::var_os("XDG_DATA_HOME"), }; unsafe { std::env::set_var("HOME", home); @@ -885,6 +823,10 @@ mod tests { Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), None => std::env::remove_var("XDG_CONFIG_HOME"), } + match xdg_data { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } } guard } @@ -906,6 +848,10 @@ mod tests { Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), None => std::env::remove_var("XDG_CONFIG_HOME"), } + match &self.original_xdg_data { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } } } } @@ -922,10 +868,17 @@ mod tests { std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, Some(&zdotdir), None); + let _guard = ProfileEnvGuard::new(&fake_home, Some(&zdotdir), None, None); - // Pass an empty base list so only ZDOTDIR fallback is triggered - let result = check_profile_files("$HOME/.vite-plus", &[]); + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::Zsh, + path: ".zshenv", + env_file: "env", + kind: ShellProfileKind::Main, + }], + ); assert!(result.is_some(), "Should find .zshenv in ZDOTDIR"); assert!(result.unwrap().ends_with(".zshenv")); } @@ -944,14 +897,49 @@ mod tests { std::fs::write(fish_dir.join("vite-plus.fish"), "source \"$HOME/.vite-plus/env.fish\"\n") .unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, Some(&xdg_config)); + let _guard = ProfileEnvGuard::new(&fake_home, None, Some(&xdg_config), None); - // Pass an empty base list so only XDG fallback is triggered - let result = check_profile_files("$HOME/.vite-plus", &[]); + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::Fish, + path: "fish/conf.d/vite-plus.fish", + env_file: "env.fish", + kind: ShellProfileKind::Snippet, + }], + ); assert!(result.is_some(), "Should find vite-plus.fish in XDG_CONFIG_HOME"); assert!(result.unwrap().contains("vite-plus.fish")); } + #[test] + #[serial] + #[cfg(not(windows))] + fn test_check_profile_files_finds_xdg_nushell() { + let temp = TempDir::new().unwrap(); + let fake_home = temp.path().join("home"); + let xdg_data = temp.path().join("xdg_data"); + let fish_dir = xdg_data.join("nushell/vendor/autoload"); + std::fs::create_dir_all(&fake_home).unwrap(); + std::fs::create_dir_all(&fish_dir).unwrap(); + + std::fs::write(fish_dir.join("vite-plus.nu"), "source \"~/.vite-plus/env.nu\"\n").unwrap(); + + let _guard = ProfileEnvGuard::new(&fake_home, None, None, Some(&xdg_data)); + + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::NushellData, + path: "nushell/vendor/autoload/vite-plus.nu", + env_file: "env.nu", + kind: ShellProfileKind::Snippet, + }], + ); + assert!(result.is_some(), "Should find vite-plus.nu in XDG_DATA_HOME"); + assert!(result.unwrap().contains("vite-plus.nu")); + } + #[test] #[serial] #[cfg(not(windows))] @@ -963,10 +951,25 @@ mod tests { std::fs::write(fake_home.join(".bashrc"), "# some config\n. \"$HOME/.vite-plus/env\"\n") .unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None); - - let result = - check_profile_files("$HOME/.vite-plus", &[(".bashrc", false), (".profile", false)]); + let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); + + let result = check_profile_files( + "$HOME/.vite-plus", + &[ + ShellProfile { + root: ShellProfileRoot::Home, + path: ".bashrc", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Home, + path: ".profile", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ], + ); assert!(result.is_some(), "Should find env sourcing in .bashrc"); assert_eq!(result.unwrap(), "~/.bashrc"); } @@ -983,13 +986,56 @@ mod tests { std::fs::write(fish_dir.join("config.fish"), "source \"$HOME/.vite-plus/env.fish\"\n") .unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None); + let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); - let result = check_profile_files("$HOME/.vite-plus", &[(".config/fish/config.fish", true)]); + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::Fish, + path: "fish/config.fish", + env_file: "env.fish", + kind: ShellProfileKind::Main, + }], + ); assert!(result.is_some(), "Should find env.fish sourcing in fish config"); assert_eq!(result.unwrap(), "~/.config/fish/config.fish"); } + #[test] + #[serial] + #[cfg(not(windows))] + fn test_check_profile_files_finds_nushell_env() { + let temp = TempDir::new().unwrap(); + let fake_home = temp.path().join("home"); + let nushell_autoload_path = if cfg!(target_os = "macos") { + "Library/Application Support/nushell/vendor/autoload" + } else { + ".local/share/nushell/vendor/autoload" + }; + let nushell_autoload_dir = fake_home.join(nushell_autoload_path); + std::fs::create_dir_all(&nushell_autoload_dir).unwrap(); + + std::fs::write( + nushell_autoload_dir.join("vite-plus.nu"), + "source \"~/.vite-plus/env.nu\"\n", + ) + .unwrap(); + + let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); + + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::NushellData, + path: "nushell/vendor/autoload/vite-plus.nu", + env_file: "env.nu", + kind: ShellProfileKind::Snippet, + }], + ); + assert!(result.is_some(), "Should find env.nu sourcing in Nushell autoload"); + assert_eq!(result.unwrap(), format!("~/{nushell_autoload_path}/vite-plus.nu")); + } + #[test] #[serial] #[cfg(not(windows))] @@ -1001,10 +1047,25 @@ mod tests { // Create a .bashrc without vite-plus sourcing std::fs::write(fake_home.join(".bashrc"), "# no vite-plus here\nexport FOO=bar\n").unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None); - - let result = - check_profile_files("$HOME/.vite-plus", &[(".bashrc", false), (".profile", false)]); + let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); + + let result = check_profile_files( + "$HOME/.vite-plus", + &[ + ShellProfile { + root: ShellProfileRoot::Home, + path: ".bashrc", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Home, + path: ".profile", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ], + ); assert!(result.is_none(), "Should return None when env sourcing not found"); } @@ -1020,9 +1081,17 @@ mod tests { let abs_path = format!(". \"{}/home/.vite-plus/env\"\n", temp.path().display()); std::fs::write(fake_home.join(".zshenv"), &abs_path).unwrap(); - let _guard = ProfileEnvGuard::new(&fake_home, None, None); + let _guard = ProfileEnvGuard::new(&fake_home, None, None, None); - let result = check_profile_files("$HOME/.vite-plus", &[(".zshenv", false)]); + let result = check_profile_files( + "$HOME/.vite-plus", + &[ShellProfile { + root: ShellProfileRoot::Zsh, + path: ".zshenv", + env_file: "env", + kind: ShellProfileKind::Main, + }], + ); assert!(result.is_some(), "Should find absolute path form of env sourcing"); assert_eq!(result.unwrap(), "~/.zshenv"); } diff --git a/crates/vite_global_cli/src/commands/env/mod.rs b/crates/vite_global_cli/src/commands/env/mod.rs index 690e11c618..5b98ae20e7 100644 --- a/crates/vite_global_cli/src/commands/env/mod.rs +++ b/crates/vite_global_cli/src/commands/env/mod.rs @@ -28,6 +28,7 @@ use vite_path::AbsolutePathBuf; use crate::{ cli::{EnvArgs, EnvSubcommands}, + commands::shell::{Shell, detect_shell}, error::Error, }; @@ -166,14 +167,24 @@ async fn print_env(cwd: AbsolutePathBuf) -> Result { .await?; let bin_dir = runtime.get_bin_prefix(); + let snippet = format_print_snippet(detect_shell(), &bin_dir); // Print shell snippet println!("# Add to your shell to use this Node.js version for this session:"); - println!("export PATH=\"{}:$PATH\"", bin_dir.as_path().display()); + println!("{snippet}"); Ok(ExitStatus::default()) } +fn format_print_snippet(shell: Shell, bin_dir: &vite_path::AbsolutePath) -> String { + match shell { + Shell::Nushell => { + format!("$env.PATH = ($env.PATH | prepend \"{}\")", bin_dir.as_path().display()) + } + _ => format!("export PATH=\"{}:$PATH\"", bin_dir.as_path().display()), + } +} + /// Create an exit status with the given code. fn exit_status(code: i32) -> ExitStatus { #[cfg(unix)] @@ -187,3 +198,24 @@ fn exit_status(code: i32) -> ExitStatus { ExitStatus::from_raw(code as u32) } } + +#[cfg(test)] +mod tests { + use vite_path::AbsolutePathBuf; + + use super::{Shell, format_print_snippet}; + + #[test] + fn test_format_print_snippet_posix() { + let bin_dir = AbsolutePathBuf::new("/tmp/vp/bin".into()).unwrap(); + let snippet = format_print_snippet(Shell::Posix, &bin_dir); + assert_eq!(snippet, "export PATH=\"/tmp/vp/bin:$PATH\""); + } + + #[test] + fn test_format_print_snippet_nushell() { + let bin_dir = AbsolutePathBuf::new("/tmp/vp/bin".into()).unwrap(); + let snippet = format_print_snippet(Shell::Nushell, &bin_dir); + assert_eq!(snippet, "$env.PATH = ($env.PATH | prepend \"/tmp/vp/bin\")"); + } +} diff --git a/crates/vite_global_cli/src/commands/env/setup.rs b/crates/vite_global_cli/src/commands/env/setup.rs index 229931775f..5a41645866 100644 --- a/crates/vite_global_cli/src/commands/env/setup.rs +++ b/crates/vite_global_cli/src/commands/env/setup.rs @@ -388,6 +388,7 @@ async fn cleanup_legacy_completion_dir(vite_plus_home: &vite_path::AbsolutePath) /// Creates: /// - `~/.vite-plus/env` (POSIX shell — bash/zsh) with `vp()` wrapper function /// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function +/// - `~/.vite-plus/env.nu` (Nushell) with PATH setup + `vp` wrapper function /// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function /// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`) async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> { @@ -395,18 +396,23 @@ async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<() // 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 to_ref = |path: &vite_path::AbsolutePath| -> String { - home_dir + let (bin_path_ref, bin_path_nu) = { + let bin_path_ref = &bin_path; + vite_shared::EnvConfig::get() + .user_home .as_ref() - .and_then(|h| path.as_path().strip_prefix(h).ok()) - .map(|s| { - // Normalize to forward slashes for $HOME/... paths (POSIX-style) - format!("$HOME/{}", s.display().to_string().replace('\\', "/")) - }) - .unwrap_or_else(|| path.as_path().display().to_string()) + .and_then(|h| bin_path_ref.as_path().strip_prefix(h).ok()) + .map_or_else( + || (bin_path_ref.as_path().display().to_string(), None), + |s| { + ( + // Normalize to forward slashes for $HOME/... paths (POSIX-style) + format!("$HOME/{}", s.display().to_string().replace('\\', "/")), + Some(format!("~/{}", s.display())), + ) + }, + ) }; - let bin_path_ref = to_ref(&bin_path); // POSIX env file (bash/zsh) // When sourced multiple times, removes existing entry and re-prepends to front @@ -499,6 +505,75 @@ complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" let env_fish_file = vite_plus_home.join("env.fish"); tokio::fs::write(&env_fish_file, env_fish_content).await?; + // Nushell env file with vp wrapper function + let env_nu_content = r#"# Vite+ environment setup (https://viteplus.dev) +let __vp_bin = $"__VP_BIN_NU__" +$env.PATH = (($env.PATH | where {|entry| $entry != $__vp_bin }) | prepend $__vp_bin) + +# Helper function to process `vp env use` stdout payload +# to set/unset VP_NODE_VERSION in the current shell session. +def --env __vp_apply_env_use_output [payload: string] { + let __vp_payload = ($payload | str trim) + if (($__vp_payload | str length) == 0) { + return + } + + # `vp env use` emits JSONL for Nushell so we can apply it without string parsing. + for __line in ($__vp_payload | lines) { + let __vp_data = try { + $__line | from json + } catch { + error make { msg: $"Invalid Vite+ env payload: ($__vp_payload)" } + } + + if 'set' in $__vp_data { + load-env $__vp_data.set + } else if 'unset' in $__vp_data { + for name in $__vp_data.unset { + hide-env -i $name + } + } else { + error make { msg: $"Unsupported Vite+ env payload: ($__vp_payload)" } + } + } +} + +# Shell function wrapper: intercepts `vp env use` to consume its stdout, +# Which then used to set/unset VP_NODE_VERSION in the current shell session. +def --env vp [...args: string] { + if (($args | length) >= 2) and $args.0 == "env" and $args.1 == "use" { + if ($args | any {|arg| $arg == "-h" or $arg == "--help"}) { + ^vp ...$args + return + } + + let __vp_result = (with-env { VP_ENV_USE_EVAL_ENABLE: "1" } { + do { ^vp ...$args } | complete + }) + + if (($__vp_result.stderr | str length) > 0) { + print --stderr --raw $__vp_result.stderr + } + + if $__vp_result.exit_code != 0 { + let __vp_error = ($__vp_result.stderr | str trim) + if (($__vp_error | str length) > 0) { + error make { msg: $__vp_error } + } else { + error make { msg: $"vp env use exited with code ($__vp_result.exit_code)" } + } + } + + __vp_apply_env_use_output $__vp_result.stdout + } else { + ^vp ...$args + } +} +"# + .replace("__VP_BIN_NU__", &bin_path_nu.unwrap_or(bin_path_ref)); + let env_nu_file = vite_plus_home.join("env.nu"); + tokio::fs::write(&env_nu_file, env_nu_content).await?; + // PowerShell env file let env_ps1_content = r#"# Vite+ environment setup (https://viteplus.dev) $__vp_bin = "__VP_BIN_WIN__" @@ -577,20 +652,21 @@ Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp /// Print instructions for adding bin directory to PATH. fn print_path_instructions(bin_dir: &vite_path::AbsolutePath) { - // Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability + // Derive vite_plus_home from bin_dir (parent), using a HOME-relative path for readability. let home_path = bin_dir .parent() .map(|p| p.as_path().display().to_string()) .unwrap_or_else(|| bin_dir.as_path().display().to_string()); - let home_path = if let Ok(home_dir) = std::env::var("HOME") { + 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) { - format!("$HOME{suffix}") + (format!("$HOME{suffix}"), Some(format!("~{suffix}"))) } else { - home_path + (home_path.clone(), None) } } else { - home_path + (home_path.clone(), None) }; + let nu_home_path = nu_home_path.unwrap_or(home_path.clone()); println!("{}", help::render_heading("Next Steps")); println!(" Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):"); @@ -601,6 +677,10 @@ fn print_path_instructions(bin_dir: &vite_path::AbsolutePath) { println!(); println!(" source \"{home_path}/env.fish\""); println!(); + println!(" For Nushell, add to ~/.config/nushell/config.nu:"); + println!(); + println!(" source \"{nu_home_path}/env.nu\""); + println!(); println!(" For PowerShell, add to your $PROFILE:"); println!(); println!(" . \"{home_path}/env.ps1\""); @@ -654,9 +734,11 @@ mod tests { let env_path = home.join("env"); let env_fish_path = home.join("env.fish"); + let env_nu_path = home.join("env.nu"); let env_ps1_path = home.join("env.ps1"); assert!(env_path.as_path().exists(), "env file should be created"); assert!(env_fish_path.as_path().exists(), "env.fish file should be created"); + assert!(env_nu_path.as_path().exists(), "env.nu file should be created"); assert!(env_ps1_path.as_path().exists(), "env.ps1 file should be created"); } @@ -670,6 +752,7 @@ mod tests { 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(); // Placeholder should be fully replaced assert!( @@ -680,6 +763,10 @@ mod tests { !fish_content.contains("__VP_BIN__"), "env.fish file should not contain __VP_BIN__ placeholder" ); + assert!( + !nu_content.contains("__VP_BIN_NU__"), + "env.nu file should not contain __VP_BIN_NU__ placeholder" + ); // Should use $HOME-relative path since install dir is under HOME assert!( @@ -690,6 +777,10 @@ mod tests { fish_content.contains("$HOME/bin"), "env.fish file should reference $HOME/bin, got: {fish_content}" ); + assert!( + nu_content.contains("~/bin"), + "env.nu file should reference ~/bin, got: {nu_content}" + ); } #[tokio::test] @@ -703,6 +794,7 @@ mod tests { 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(); // Should use absolute path since install dir is not under HOME let expected_bin = home.join("bin"); @@ -715,9 +807,14 @@ mod tests { fish_content.contains(&expected_str), "env.fish file should use absolute path {expected_str}, got: {fish_content}" ); + assert!( + nu_content.contains(&expected_str), + "env.nu file should use absolute path {expected_str}, got: {nu_content}" + ); // Should NOT use $HOME-relative path assert!(!env_content.contains("$HOME/bin"), "env file should not reference $HOME/bin"); + assert!(!nu_content.contains("~/bin"), "env.nu file should not reference ~/bin"); } #[tokio::test] @@ -773,6 +870,23 @@ mod tests { assert!(fish_content.contains("set -gx PATH"), "env.fish should set PATH globally"); } + #[tokio::test] + async fn test_create_env_files_nushell_contains_path_guard() { + let temp_dir = TempDir::new().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _guard = home_guard(temp_dir.path()); + + create_env_files(&home).await.unwrap(); + + let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); + + // Verify Nushell PATH guard + assert!( + nu_content.contains("$env.PATH = (($env.PATH | where {|entry| $entry != $__vp_bin }) | prepend $__vp_bin)"), + "env.nu should dedupe and prepend the bin path" + ); + } + #[tokio::test] async fn test_create_env_files_is_idempotent() { let temp_dir = TempDir::new().unwrap(); @@ -783,15 +897,18 @@ mod tests { create_env_files(&home).await.unwrap(); let first_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let first_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + let first_nu = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); let first_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); create_env_files(&home).await.unwrap(); let second_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let second_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + let second_nu = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); let second_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); assert_eq!(first_env, second_env, "env file should be identical after second write"); assert_eq!(first_fish, second_fish, "env.fish file should be identical after second write"); + assert_eq!(first_nu, second_nu, "env.nu file should be identical after second write"); assert_eq!(first_ps1, second_ps1, "env.ps1 file should be identical after second write"); } @@ -868,6 +985,36 @@ mod tests { ); } + #[tokio::test] + async fn test_create_env_files_nushell_contains_vp_function() { + let temp_dir = TempDir::new().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let _guard = home_guard(temp_dir.path()); + + create_env_files(&home).await.unwrap(); + + let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); + + // Verify Nushell vp function wrapper is present + assert!(nu_content.contains("def --env vp"), "env.nu should contain vp function"); + assert!( + nu_content.contains("do { ^vp ...$args } | complete"), + "env.nu should capture stdout/stderr from vp env use" + ); + assert!( + nu_content.contains("from json"), + "env.nu should parse the env use payload as JSON" + ); + assert!( + nu_content.contains("load-env $__vp_data.set"), + "env.nu should load env changes from the payload record" + ); + assert!( + nu_content.contains("hide-env -i $name"), + "env.nu should hide env vars based on the payload list" + ); + } + #[tokio::test] async fn test_execute_env_only_creates_home_dir_and_env_files() { let temp_dir = TempDir::new().unwrap(); @@ -888,6 +1035,7 @@ mod tests { // 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.nu").exists(), "env.nu file should be created"); assert!(fresh_home.join("env.ps1").exists(), "env.ps1 file should be created"); } diff --git a/crates/vite_global_cli/src/commands/env/use.rs b/crates/vite_global_cli/src/commands/env/use.rs index 398fb57379..c06ecdcd08 100644 --- a/crates/vite_global_cli/src/commands/env/use.rs +++ b/crates/vite_global_cli/src/commands/env/use.rs @@ -1,51 +1,29 @@ //! Implementation of `vp env use` command. //! //! Outputs shell-appropriate commands to stdout that set (or unset) -//! the `VP_NODE_VERSION` environment variable. The shell function -//! wrapper in `~/.vite-plus/env` evals this output to modify the current -//! shell session. +//! the `VP_NODE_VERSION` environment variable. The shell wrapper +//! scripts consume this output to modify the current shell session. //! //! All user-facing status messages go to stderr so they don't interfere -//! with the eval'd output. +//! with the wrapper-consumed output. use std::process::ExitStatus; +use serde_json::json; use vite_path::AbsolutePathBuf; use super::config::{self, VERSION_ENV_VAR}; -use crate::error::Error; - -/// Detected shell type for output formatting. -enum Shell { - /// POSIX shell (bash, zsh, sh) - Posix, - /// Fish shell - Fish, - /// PowerShell - PowerShell, - /// Windows cmd.exe - Cmd, -} - -/// Detect the current shell from environment variables. -fn detect_shell() -> Shell { - let config = vite_shared::EnvConfig::get(); - if config.fish_version.is_some() { - Shell::Fish - } else if cfg!(windows) && config.ps_module_path.is_some() { - Shell::PowerShell - } else if cfg!(windows) { - Shell::Cmd - } else { - Shell::Posix - } -} +use crate::{ + commands::shell::{Shell, detect_shell}, + error::Error, +}; /// Format a shell export command for the detected shell. fn format_export(shell: &Shell, value: &str) -> String { match shell { Shell::Posix => format!("export {VERSION_ENV_VAR}={value}"), Shell::Fish => format!("set -gx {VERSION_ENV_VAR} {value}"), + Shell::Nushell => json!({ "set": { VERSION_ENV_VAR: value } }).to_string(), Shell::PowerShell => format!("$env:{VERSION_ENV_VAR} = \"{value}\""), Shell::Cmd => format!("set {VERSION_ENV_VAR}={value}"), } @@ -56,6 +34,7 @@ fn format_unset(shell: &Shell) -> String { match shell { Shell::Posix => format!("unset {VERSION_ENV_VAR}"), Shell::Fish => format!("set -e {VERSION_ENV_VAR}"), + Shell::Nushell => json!({ "unset": [VERSION_ENV_VAR] }).to_string(), Shell::PowerShell => { format!("Remove-Item Env:{VERSION_ENV_VAR} -ErrorAction SilentlyContinue") } @@ -63,8 +42,8 @@ fn format_unset(shell: &Shell) -> String { } } -/// Whether the shell eval wrapper is active. -/// When true, the wrapper will eval our stdout to set env vars — no session file needed. +/// Whether the shell wrapper is active. +/// When true, the wrapper will consume our stdout to set env vars — no session file needed. /// When false (CI, direct invocation), we write a session file so shims can read it. fn has_eval_wrapper() -> bool { vite_shared::EnvConfig::get().env_use_eval_enable @@ -171,40 +150,6 @@ pub async fn execute( mod tests { use super::*; - #[test] - fn test_detect_shell_posix_even_with_psmodulepath() { - let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { - ps_module_path: Some("/some/path".into()), - ..vite_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - #[cfg(not(windows))] - assert!(matches!(shell, Shell::Posix)); - #[cfg(windows)] - assert!(matches!(shell, Shell::PowerShell)); - } - - #[test] - fn test_detect_shell_fish() { - let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { - fish_version: Some("3.7.0".into()), - ..vite_shared::EnvConfig::for_test() - }); - let shell = detect_shell(); - assert!(matches!(shell, Shell::Fish)); - } - - #[test] - fn test_detect_shell_posix_default() { - // All shell detection fields None → defaults - let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig::for_test()); - let shell = detect_shell(); - #[cfg(not(windows))] - assert!(matches!(shell, Shell::Posix)); - #[cfg(windows)] - assert!(matches!(shell, Shell::Cmd)); - } - #[test] fn test_format_export_posix() { let result = format_export(&Shell::Posix, "20.18.0"); @@ -217,6 +162,12 @@ mod tests { assert_eq!(result, "set -gx VP_NODE_VERSION 20.18.0"); } + #[test] + fn test_format_export_nushell() { + let result = format_export(&Shell::Nushell, "20.18.0"); + assert_eq!(result, r#"{"set":{"VP_NODE_VERSION":"20.18.0"}}"#); + } + #[test] fn test_format_export_powershell() { let result = format_export(&Shell::PowerShell, "20.18.0"); @@ -241,6 +192,12 @@ mod tests { assert_eq!(result, "set -e VP_NODE_VERSION"); } + #[test] + fn test_format_unset_nushell() { + let result = format_unset(&Shell::Nushell); + assert_eq!(result, r#"{"unset":["VP_NODE_VERSION"]}"#); + } + #[test] fn test_format_unset_powershell() { let result = format_unset(&Shell::PowerShell); diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs index d5dd762518..3e5d90bfed 100644 --- a/crates/vite_global_cli/src/commands/implode.rs +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -7,29 +7,17 @@ use std::{ use directories::BaseDirs; use owo_colors::OwoColorize; -use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_path::AbsolutePathBuf; use vite_shared::output; use vite_str::Str; -use crate::{cli::exit_status, error::Error}; - -/// All shell profile paths to check, with `is_snippet` flag. -const SHELL_PROFILES: &[(&str, bool)] = &[ - (".zshenv", false), - (".zshrc", false), - (".bash_profile", false), - (".bashrc", false), - (".profile", false), - (".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()), - } -} +use crate::{ + cli::exit_status, + commands::shell::{ + ALL_SHELL_PROFILES, ShellProfileKind, abbreviate_home_path, resolve_profile_path, + }, + error::Error, +}; /// Comment marker written by the install script above the sourcing line. const VITE_PLUS_COMMENT: &str = "# Vite+ bin"; @@ -106,39 +94,12 @@ enum AffectedProfileKind { fn collect_affected_profiles(user_home: &AbsolutePathBuf) -> Vec { let mut affected = Vec::new(); - // 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 profile in ALL_SHELL_PROFILES { + let path = resolve_profile_path(profile, user_home); + let name = abbreviate_home_path(&path, user_home); - for (name, path, is_snippet) in profiles { // For snippets, check if the file exists only - if is_snippet { + if matches!(profile.kind, ShellProfileKind::Snippet) { if let Ok(true) = std::fs::exists(&path) { affected.push(AffectedProfile { name, path, kind: AffectedProfileKind::Snippet }) } @@ -147,7 +108,7 @@ fn collect_affected_profiles(user_home: &AbsolutePathBuf) -> Vec std::io::Result bool { - let pattern = ".vite-plus/env\""; - content.lines().any(|line| line.contains(pattern)) +fn is_vite_plus_source_line(line: &str) -> bool { + let trimmed = line.trim_start(); + (trimmed.starts_with(". ") || trimmed.starts_with("source ")) + && ["env", "env.fish", "env.nu"].iter().any(|env_file| { + trimmed.contains(&format!(".vite-plus/{env_file}\"")) + || trimmed.contains(&format!(".vite-plus\\{env_file}\"")) + }) } /// Remove Vite+ lines from content, returning the cleaned string. fn remove_vite_plus_lines(content: &str) -> Str { - let pattern = ".vite-plus/env\""; let lines: Vec<&str> = content.lines().collect(); let mut remove_indices = Vec::new(); for (i, line) in lines.iter().enumerate() { - if line.contains(pattern) { + if is_vite_plus_source_line(line) { remove_indices.push(i); // Also remove the comment line above if i > 0 && lines[i - 1].contains(VITE_PLUS_COMMENT) { @@ -396,6 +360,27 @@ mod tests { assert_eq!(&*result, "# existing\n"); } + #[test] + fn test_remove_vite_plus_lines_fish() { + 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); + assert_eq!(&*result, "# existing config\n"); + } + + #[test] + fn test_remove_vite_plus_lines_nushell() { + let content = "# existing config\n\n# Vite+ bin (https://viteplus.dev)\nsource \"~/.vite-plus/env.nu\"\n"; + let result = remove_vite_plus_lines(content); + assert_eq!(&*result, "# existing config\n"); + } + + #[test] + fn test_remove_vite_plus_lines_nushell_windows_path() { + let content = "# existing config\nsource \"~\\.vite-plus\\env.nu\"\n"; + let result = remove_vite_plus_lines(content); + assert_eq!(&*result, "# existing config\n"); + } + #[test] fn test_remove_vite_plus_lines_preserves_surrounding() { let content = "# before\nexport A=1\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n# after\nexport B=2\n"; @@ -476,8 +461,8 @@ mod tests { 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); + // Clear env overrides so the test environment doesn't affect results + let _guard = ProfileEnvGuard::new(None, None, None); // Main profile with vite-plus line std::fs::write(home.join(".zshrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); @@ -494,19 +479,25 @@ mod tests { assert!(matches!(&profiles[1].kind, AffectedProfileKind::Snippet)); } - /// Guard that saves and restores ZDOTDIR and XDG_CONFIG_HOME env vars. + /// Guard that saves and restores profile-related env vars. #[cfg(not(windows))] struct ProfileEnvGuard { original_zdotdir: Option, original_xdg_config: Option, + original_xdg_data: Option, } #[cfg(not(windows))] impl ProfileEnvGuard { - fn new(zdotdir: Option<&std::path::Path>, xdg_config: Option<&std::path::Path>) -> Self { + fn new( + zdotdir: Option<&std::path::Path>, + xdg_config: Option<&std::path::Path>, + xdg_data: Option<&std::path::Path>, + ) -> Self { let guard = Self { original_zdotdir: std::env::var_os("ZDOTDIR"), original_xdg_config: std::env::var_os("XDG_CONFIG_HOME"), + original_xdg_data: std::env::var_os("XDG_DATA_HOME"), }; unsafe { match zdotdir { @@ -517,6 +508,10 @@ mod tests { Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), None => std::env::remove_var("XDG_CONFIG_HOME"), } + match xdg_data { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } } guard } @@ -534,6 +529,10 @@ mod tests { Some(v) => std::env::set_var("XDG_CONFIG_HOME", v), None => std::env::remove_var("XDG_CONFIG_HOME"), } + match &self.original_xdg_data { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } } } } @@ -550,7 +549,7 @@ mod tests { std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); - let _guard = ProfileEnvGuard::new(Some(&zdotdir), None); + let _guard = ProfileEnvGuard::new(Some(&zdotdir), None, None); let profiles = collect_affected_profiles(&home); let zdotdir_profiles: Vec<_> = @@ -572,7 +571,7 @@ mod tests { std::fs::write(fish_dir.join("vite-plus.fish"), "").unwrap(); - let _guard = ProfileEnvGuard::new(None, Some(&xdg_config)); + let _guard = ProfileEnvGuard::new(None, Some(&xdg_config), None); let profiles = collect_affected_profiles(&home); let xdg_profiles: Vec<_> = @@ -581,6 +580,29 @@ mod tests { assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); } + #[test] + #[serial] + #[cfg(not(windows))] + fn test_collect_affected_profiles_xdg_data() { + let temp_dir = tempfile::tempdir().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().join("home")).unwrap(); + let xdg_data = temp_dir.path().join("xdg_data"); + let nushell_dir = xdg_data.join("nushell/vendor/autoload"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&nushell_dir).unwrap(); + + 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 profiles = collect_affected_profiles(&home); + let xdg_profiles: Vec<_> = + profiles.iter().filter(|p| p.path.as_path().starts_with(&xdg_data)).collect(); + assert_eq!(xdg_profiles.len(), 1); + assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); + } + #[test] fn test_execute_not_installed() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 7d0f45a839..ac50847211 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -171,6 +171,7 @@ pub mod version; // Category D: Environment Management pub mod env; +pub mod shell; // Standalone binary commands pub mod vpr; diff --git a/crates/vite_global_cli/src/commands/shell.rs b/crates/vite_global_cli/src/commands/shell.rs new file mode 100644 index 0000000000..8d66789ba2 --- /dev/null +++ b/crates/vite_global_cli/src/commands/shell.rs @@ -0,0 +1,287 @@ +//! Shared shell detection and profile helpers. + +use directories::BaseDirs; +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_str::Str; + +/// Detected shell type for output formatting. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Shell { + /// POSIX shell (bash, zsh, sh) + Posix, + /// Fish shell + Fish, + /// Nushell + Nushell, + /// PowerShell + PowerShell, + /// Windows cmd.exe + Cmd, +} + +/// Detect the current shell from environment variables. +#[must_use] +pub fn detect_shell() -> Shell { + let config = vite_shared::EnvConfig::get(); + if config.fish_version.is_some() { + Shell::Fish + } else if config.nu_version.is_some() { + Shell::Nushell + } else if cfg!(windows) && config.ps_module_path.is_some() { + Shell::PowerShell + } else if cfg!(windows) { + Shell::Cmd + } else { + Shell::Posix + } +} + +/// All shell profile files that interactive terminal sessions may source. +/// This matches the files that `install.sh` writes to and `vp implode` cleans. +#[cfg(not(windows))] +pub const ALL_SHELL_PROFILES: &[ShellProfile] = &[ + ShellProfile { + root: ShellProfileRoot::Zsh, + path: ".zshenv", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Zsh, + path: ".zshrc", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Home, + path: ".bash_profile", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Home, + path: ".bashrc", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Home, + path: ".profile", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Fish, + path: "fish/config.fish", + env_file: "env.fish", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Fish, + path: "fish/conf.d/vite-plus.fish", + env_file: "env.fish", + kind: ShellProfileKind::Snippet, + }, + ShellProfile { + root: ShellProfileRoot::NushellConfig, + path: "nushell/config.nu", + env_file: "env.nu", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::NushellConfig, + path: "nushell/env.nu", + env_file: "env.nu", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::NushellData, + path: "nushell/vendor/autoload/vite-plus.nu", + env_file: "env.nu", + kind: ShellProfileKind::Snippet, + }, +]; + +#[cfg(windows)] +pub const ALL_SHELL_PROFILES: &[ShellProfile] = &[ + ShellProfile { + root: ShellProfileRoot::NushellConfig, + path: "nushell/config.nu", + env_file: "env.nu", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::NushellConfig, + path: "nushell/env.nu", + env_file: "env.nu", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::NushellData, + path: "nushell/vendor/autoload/vite-plus.nu", + env_file: "env.nu", + kind: ShellProfileKind::Snippet, + }, +]; + +/// IDE-relevant profile files that GUI-launched applications can see. +/// GUI apps don't run through an interactive shell, so only login/environment +/// files reliably affect them. +/// - macOS: `.zshenv` is sourced for all zsh invocations (including IDE env resolution) +/// - Linux: `.profile` is sourced by X11 display managers; `.zshenv` covers Wayland + zsh +#[cfg(target_os = "macos")] +pub const IDE_SHELL_PROFILES: &[ShellProfile] = &[ + ShellProfile { + root: ShellProfileRoot::Zsh, + path: ".zshenv", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Home, + path: ".profile", + env_file: "env", + kind: ShellProfileKind::Main, + }, +]; + +#[cfg(target_os = "linux")] +pub const IDE_SHELL_PROFILES: &[ShellProfile] = &[ + ShellProfile { + root: ShellProfileRoot::Home, + path: ".profile", + env_file: "env", + kind: ShellProfileKind::Main, + }, + ShellProfile { + root: ShellProfileRoot::Zsh, + path: ".zshenv", + env_file: "env", + kind: ShellProfileKind::Main, + }, +]; + +#[cfg(windows)] +pub const IDE_SHELL_PROFILES: &[ShellProfile] = &[]; + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +pub const IDE_SHELL_PROFILES: &[ShellProfile] = &[ShellProfile { + root: ShellProfileRoot::Home, + path: ".profile", + env_file: "env", + kind: ShellProfileKind::Main, +}]; + +pub struct ShellProfile { + pub root: ShellProfileRoot, + pub path: &'static str, + pub env_file: &'static str, + pub kind: ShellProfileKind, +} + +#[derive(Clone, Copy)] +pub enum ShellProfileRoot { + Home, + Zsh, + Fish, + NushellConfig, + NushellData, +} + +#[derive(Clone, Copy)] +pub enum ShellProfileKind { + Main, + Snippet, +} + +/// Abbreviate a path for display: replace `$HOME` prefix with `~`. +pub(crate) 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()), + } +} + +pub(crate) fn resolve_profile_path( + profile: &ShellProfile, + user_home: &AbsolutePathBuf, +) -> AbsolutePathBuf { + let base_dirs = BaseDirs::new(); + let root = match profile.root { + ShellProfileRoot::Home => user_home.clone(), + ShellProfileRoot::Zsh => std::env::var_os("ZDOTDIR") + .and_then(|value| AbsolutePathBuf::new(value.into())) + .unwrap_or_else(|| user_home.clone()), + ShellProfileRoot::Fish => std::env::var_os("XDG_CONFIG_HOME") + .and_then(|value| AbsolutePathBuf::new(value.into())) + .unwrap_or_else(|| user_home.join(".config")), + ShellProfileRoot::NushellConfig => std::env::var_os("XDG_CONFIG_HOME") + .and_then(|value| AbsolutePathBuf::new(value.into())) + .or_else(|| AbsolutePathBuf::new(base_dirs?.config_dir().into())) + .unwrap_or_else(|| user_home.join(".config")), + ShellProfileRoot::NushellData => std::env::var_os("XDG_DATA_HOME") + .and_then(|value| AbsolutePathBuf::new(value.into())) + .or_else(|| AbsolutePathBuf::new(base_dirs?.data_dir().into())) + .unwrap_or_else(|| user_home.join(".local/share")), + }; + root.join(profile.path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_shell_posix_even_with_psmodulepath() { + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + ps_module_path: Some("/some/path".into()), + ..vite_shared::EnvConfig::for_test() + }); + let shell = detect_shell(); + #[cfg(not(windows))] + assert!(matches!(shell, Shell::Posix)); + #[cfg(windows)] + assert!(matches!(shell, Shell::PowerShell)); + } + + #[test] + fn test_detect_shell_fish() { + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + fish_version: Some("3.7.0".into()), + ..vite_shared::EnvConfig::for_test() + }); + let shell = detect_shell(); + assert!(matches!(shell, Shell::Fish)); + } + + #[test] + fn test_detect_shell_nushell() { + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + nu_version: Some("0.101.0".into()), + ..vite_shared::EnvConfig::for_test() + }); + let shell = detect_shell(); + assert!(matches!(shell, Shell::Nushell)); + } + + #[test] + fn test_detect_shell_nushell_wins_over_powershell() { + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig { + nu_version: Some("0.101.0".into()), + ps_module_path: Some("/some/path".into()), + ..vite_shared::EnvConfig::for_test() + }); + let shell = detect_shell(); + assert!(matches!(shell, Shell::Nushell)); + } + + #[test] + fn test_detect_shell_posix_default() { + let _guard = vite_shared::EnvConfig::test_guard(vite_shared::EnvConfig::for_test()); + let shell = detect_shell(); + #[cfg(not(windows))] + assert!(matches!(shell, Shell::Posix)); + #[cfg(windows)] + assert!(matches!(shell, Shell::Cmd)); + } +} diff --git a/crates/vite_shared/src/env_config.rs b/crates/vite_shared/src/env_config.rs index 95b198e1b2..d6aabb26da 100644 --- a/crates/vite_shared/src/env_config.rs +++ b/crates/vite_shared/src/env_config.rs @@ -118,6 +118,11 @@ pub struct EnvConfig { /// Env: `FISH_VERSION` pub fish_version: Option, + /// Nushell version (indicates running under Nushell). + /// + /// Env: `NU_VERSION` + pub nu_version: Option, + /// PowerShell module path (indicates running under PowerShell on Windows). /// /// Env: `PSModulePath` @@ -150,6 +155,7 @@ impl EnvConfig { .ok() .map(PathBuf::from), fish_version: std::env::var("FISH_VERSION").ok(), + nu_version: std::env::var("NU_VERSION").ok(), ps_module_path: std::env::var("PSModulePath").ok(), } } @@ -232,6 +238,7 @@ impl EnvConfig { node_version: None, user_home: None, fish_version: None, + nu_version: None, ps_module_path: None, } } diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 9385a0f2d1..cdc44b0433 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -194,6 +194,73 @@ function Configure-UserPath { } } +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 $(Format-DisplayPath -Path $autoloadFile)" + } + } + } + + [System.IO.File]::WriteAllText($autoloadFile, $content) + return [pscustomobject]@{ + Status = "true" + Message = "updated $(Format-DisplayPath -Path $autoloadFile)" + } + } catch { + Write-Warn "Could not configure Nushell automatically." + return [pscustomobject]@{ + Status = "failed" + Message = "failed $(Format-DisplayPath -Path $autoloadFile)" + } + } +} + # Run vp env setup --refresh, showing output only on failure function Refresh-Shims { param([string]$BinDir) @@ -438,9 +505,12 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" # Cleanup old versions Cleanup-OldVersions -InstallDir $InstallDir - # Configure user PATH (always attempted) + # 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 @@ -480,23 +550,43 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" Write-Host "" Write-Host " Run ${BRIGHT_BLUE}vp help${NC} to see available commands." - # Show note if PATH was updated - if ($pathResult -eq "true") { + 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 instructions if PATH could not be configured - if ($pathResult -eq "failed") { + # 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}: Could not automatically add vp to your PATH." + 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 "" - Write-Host " To use vp, manually add it to your PATH:" - Write-Host "" - Write-Host " [Environment]::SetEnvironmentVariable('Path', '$InstallDir\bin;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" - 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`"" diff --git a/packages/cli/install.sh b/packages/cli/install.sh index b8be70a286..1f6eeb27fa 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -18,9 +18,11 @@ 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="\$HOME${INSTALL_DIR#"$HOME"}" + INSTALL_DIR_REF_POSIX="\$HOME${INSTALL_DIR#"$HOME"}" + INSTALL_DIR_REF_NU="~${INSTALL_DIR#"$HOME"}" else - INSTALL_DIR_REF="$INSTALL_DIR" + 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}" @@ -315,107 +317,319 @@ download_and_extract() { rm -f "$temp_file" } -# Add bin to shell profile by sourcing the env file -# Returns: 0 = path added, 1 = file not found, 2 = path already exists -add_bin_to_path() { - local shell_config="$1" - local env_file="$INSTALL_DIR_REF/env" - # Escape both absolute and $HOME-relative forms for grep (backward compat) - local abs_pattern ref_pattern - abs_pattern=$(printf '%s' "$INSTALL_DIR" | sed 's/[.[\*^$()+?{|]/\\&/g') - ref_pattern=$(printf '%s' "$INSTALL_DIR_REF" | sed 's/[.[\*^$()+?{|]/\\&/g') - - if [ -f "$shell_config" ]; then - if [ ! -w "$shell_config" ]; then - warn "Cannot write to $shell_config (permission denied), skipping." - return 1 +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 - if grep -q "${abs_pattern}/env" "$shell_config" 2>/dev/null || \ - grep -q "${ref_pattern}/env" "$shell_config" 2>/dev/null; then + 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 - echo "" >> "$shell_config" - echo "# Vite+ bin (https://viteplus.dev)" >> "$shell_config" - echo ". \"$env_file\"" >> "$shell_config" - return 0 + 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")" ;; - */bash) - # Add to .bash_profile, .bashrc, AND .profile for maximum compatibility - # - .bash_profile: login shells (macOS default) - # - .bashrc: interactive non-login shells (Linux default) - # - .profile: fallback for systems without .bash_profile (Ubuntu minimal, etc.) - local bash_profile_result=0 bashrc_result=0 profile_result=0 - add_bin_to_path "$HOME/.bash_profile" || bash_profile_result=$? - add_bin_to_path "$HOME/.bashrc" || bashrc_result=$? - add_bin_to_path "$HOME/.profile" || profile_result=$? - # Prioritize .bashrc for user notification (most commonly edited) - if [ $bashrc_result -eq 0 ]; then - result=0 - SHELL_CONFIG_UPDATED="$HOME/.bashrc" - elif [ $bash_profile_result -eq 0 ]; then - result=0 - SHELL_CONFIG_UPDATED="$HOME/.bash_profile" - elif [ $profile_result -eq 0 ]; then - result=0 - 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 + 2) + SHELL_CONFIG_HAS_CONFIGURED="true" + record_shell_summary "fish" "already configured $(abbreviate_path "$fish_config")" ;; - */fish) - 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 - else - mkdir -p "$fish_dir" - echo "# Vite+ bin (https://viteplus.dev)" >> "$fish_config" - echo "source \"$INSTALL_DIR_REF/env.fish\"" >> "$fish_config" - result=0 - SHELL_CONFIG_UPDATED="$fish_config" - fi + *) + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("fish") + record_shell_summary "fish" "failed $(abbreviate_path "$fish_config")" ;; esac +} - if [ $result -eq 0 ]; then - PATH_CONFIGURED="true" - elif [ $result -eq 2 ]; then - PATH_CONFIGURED="already" +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 - # If result is still 1, PATH_CONFIGURED remains "false" (set at function start) } # Run vp env setup --refresh, showing output only on failure @@ -723,37 +937,32 @@ NPMRC_EOF echo "" echo -e " Run ${BRIGHT_BLUE}vp help${NC} to see available commands." - # 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 " 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 "" - 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 + echo " Note: Restart your terminal to load updated shell configuration." fi - # Show warning if PATH could not be automatically configured - if [ "$PATH_CONFIGURED" = "false" ]; then + # 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}: Could not automatically add vp to your PATH." + echo -e " ${YELLOW}note${NC}: Some shells still need manual setup." echo "" echo -e " vp was installed to: ${BOLD}${display_location}${NC}" echo "" - echo " To use vp, add this line to your shell config file:" - echo "" - echo " . \"$INSTALL_DIR_REF/env\"" - echo "" - echo " Common config files:" - echo " - Bash: ~/.bashrc or ~/.bash_profile" - echo " - Zsh: ~/.zshrc" - echo " - Fish: source \"$INSTALL_DIR_REF/env.fish\" in ~/.config/fish/config.fish" + echo " Manual setup instructions:" + echo " - Bash/Zsh: add \"$INSTALL_DIR_REF_POSIX/env\" to your shell config (~/.bashrc, ~/.zshrc, etc.)" + 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 ""