Skip to content

Commit b6fd3ef

Browse files
committed
feat(cli): add Nushell support
1 parent 19d0f9c commit b6fd3ef

File tree

10 files changed

+1193
-370
lines changed

10 files changed

+1193
-370
lines changed

crates/vite_global_cli/src/commands/env/doctor.rs

Lines changed: 168 additions & 100 deletions
Large diffs are not rendered by default.

crates/vite_global_cli/src/commands/env/mod.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use vite_path::AbsolutePathBuf;
2828

2929
use crate::{
3030
cli::{EnvArgs, EnvSubcommands},
31+
commands::shell::{Shell, detect_shell},
3132
error::Error,
3233
};
3334

@@ -166,14 +167,24 @@ async fn print_env(cwd: AbsolutePathBuf) -> Result<ExitStatus, Error> {
166167
.await?;
167168

168169
let bin_dir = runtime.get_bin_prefix();
170+
let snippet = format_print_snippet(detect_shell(), &bin_dir);
169171

170172
// Print shell snippet
171173
println!("# Add to your shell to use this Node.js version for this session:");
172-
println!("export PATH=\"{}:$PATH\"", bin_dir.as_path().display());
174+
println!("{snippet}");
173175

174176
Ok(ExitStatus::default())
175177
}
176178

179+
fn format_print_snippet(shell: Shell, bin_dir: &vite_path::AbsolutePath) -> String {
180+
match shell {
181+
Shell::Nushell => {
182+
format!("$env.PATH = ($env.PATH | prepend \"{}\")", bin_dir.as_path().display())
183+
}
184+
_ => format!("export PATH=\"{}:$PATH\"", bin_dir.as_path().display()),
185+
}
186+
}
187+
177188
/// Create an exit status with the given code.
178189
fn exit_status(code: i32) -> ExitStatus {
179190
#[cfg(unix)]
@@ -187,3 +198,24 @@ fn exit_status(code: i32) -> ExitStatus {
187198
ExitStatus::from_raw(code as u32)
188199
}
189200
}
201+
202+
#[cfg(test)]
203+
mod tests {
204+
use vite_path::AbsolutePathBuf;
205+
206+
use super::{Shell, format_print_snippet};
207+
208+
#[test]
209+
fn test_format_print_snippet_posix() {
210+
let bin_dir = AbsolutePathBuf::new("/tmp/vp/bin".into()).unwrap();
211+
let snippet = format_print_snippet(Shell::Posix, &bin_dir);
212+
assert_eq!(snippet, "export PATH=\"/tmp/vp/bin:$PATH\"");
213+
}
214+
215+
#[test]
216+
fn test_format_print_snippet_nushell() {
217+
let bin_dir = AbsolutePathBuf::new("/tmp/vp/bin".into()).unwrap();
218+
let snippet = format_print_snippet(Shell::Nushell, &bin_dir);
219+
assert_eq!(snippet, "$env.PATH = ($env.PATH | prepend \"/tmp/vp/bin\")");
220+
}
221+
}

crates/vite_global_cli/src/commands/env/setup.rs

Lines changed: 163 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -388,25 +388,31 @@ async fn cleanup_legacy_completion_dir(vite_plus_home: &vite_path::AbsolutePath)
388388
/// Creates:
389389
/// - `~/.vite-plus/env` (POSIX shell — bash/zsh) with `vp()` wrapper function
390390
/// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function
391+
/// - `~/.vite-plus/env.nu` (Nushell) with PATH setup + `vp` wrapper function
391392
/// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function
392393
/// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`)
393394
async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> {
394395
let bin_path = vite_plus_home.join("bin");
395396

396397
// Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env)
397398
// This makes the env file portable across sessions where HOME may differ
398-
let home_dir = vite_shared::EnvConfig::get().user_home;
399-
let to_ref = |path: &vite_path::AbsolutePath| -> String {
400-
home_dir
399+
let (bin_path_ref, bin_path_nu) = {
400+
let bin_path_ref = &bin_path;
401+
vite_shared::EnvConfig::get()
402+
.user_home
401403
.as_ref()
402-
.and_then(|h| path.as_path().strip_prefix(h).ok())
403-
.map(|s| {
404-
// Normalize to forward slashes for $HOME/... paths (POSIX-style)
405-
format!("$HOME/{}", s.display().to_string().replace('\\', "/"))
406-
})
407-
.unwrap_or_else(|| path.as_path().display().to_string())
404+
.and_then(|h| bin_path_ref.as_path().strip_prefix(h).ok())
405+
.map_or_else(
406+
|| (bin_path_ref.as_path().display().to_string(), None),
407+
|s| {
408+
(
409+
// Normalize to forward slashes for $HOME/... paths (POSIX-style)
410+
format!("$HOME/{}", s.display().to_string().replace('\\', "/")),
411+
Some(format!("~/{}", s.display())),
412+
)
413+
},
414+
)
408415
};
409-
let bin_path_ref = to_ref(&bin_path);
410416

411417
// POSIX env file (bash/zsh)
412418
// 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)"
499505
let env_fish_file = vite_plus_home.join("env.fish");
500506
tokio::fs::write(&env_fish_file, env_fish_content).await?;
501507

508+
// Nushell env file with vp wrapper function
509+
let env_nu_content = r#"# Vite+ environment setup (https://viteplus.dev)
510+
let __vp_bin = $"__VP_BIN_NU__"
511+
$env.PATH = (($env.PATH | where {|entry| $entry != $__vp_bin }) | prepend $__vp_bin)
512+
513+
# Helper function to process `vp env use` stdout payload
514+
# to set/unset VP_NODE_VERSION in the current shell session.
515+
def --env __vp_apply_env_use_output [payload: string] {
516+
let __vp_payload = ($payload | str trim)
517+
if (($__vp_payload | str length) == 0) {
518+
return
519+
}
520+
521+
# `vp env use` emits JSONL for Nushell so we can apply it without string parsing.
522+
for __line in ($__vp_payload | lines) {
523+
let __vp_data = try {
524+
$__line | from json
525+
} catch {
526+
error make { msg: $"Invalid Vite+ env payload: ($__vp_payload)" }
527+
}
528+
529+
if 'set' in $__vp_data {
530+
load-env $__vp_data.set
531+
} else if 'unset' in $__vp_data {
532+
for name in $__vp_data.unset {
533+
hide-env -i $name
534+
}
535+
} else {
536+
error make { msg: $"Unsupported Vite+ env payload: ($__vp_payload)" }
537+
}
538+
}
539+
}
540+
541+
# Shell function wrapper: intercepts `vp env use` to consume its stdout,
542+
# Which then used to set/unset VP_NODE_VERSION in the current shell session.
543+
def --env vp [...args: string] {
544+
if (($args | length) >= 2) and $args.0 == "env" and $args.1 == "use" {
545+
if ($args | any {|arg| $arg == "-h" or $arg == "--help"}) {
546+
^vp ...$args
547+
return
548+
}
549+
550+
let __vp_result = (with-env { VP_ENV_USE_EVAL_ENABLE: "1" } {
551+
do { ^vp ...$args } | complete
552+
})
553+
554+
if (($__vp_result.stderr | str length) > 0) {
555+
print --stderr --raw $__vp_result.stderr
556+
}
557+
558+
if $__vp_result.exit_code != 0 {
559+
let __vp_error = ($__vp_result.stderr | str trim)
560+
if (($__vp_error | str length) > 0) {
561+
error make { msg: $__vp_error }
562+
} else {
563+
error make { msg: $"vp env use exited with code ($__vp_result.exit_code)" }
564+
}
565+
}
566+
567+
__vp_apply_env_use_output $__vp_result.stdout
568+
} else {
569+
^vp ...$args
570+
}
571+
}
572+
"#
573+
.replace("__VP_BIN_NU__", &bin_path_nu.unwrap_or(bin_path_ref));
574+
let env_nu_file = vite_plus_home.join("env.nu");
575+
tokio::fs::write(&env_nu_file, env_nu_content).await?;
576+
502577
// PowerShell env file
503578
let env_ps1_content = r#"# Vite+ environment setup (https://viteplus.dev)
504579
$__vp_bin = "__VP_BIN_WIN__"
@@ -577,20 +652,21 @@ Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp
577652

578653
/// Print instructions for adding bin directory to PATH.
579654
fn print_path_instructions(bin_dir: &vite_path::AbsolutePath) {
580-
// Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability
655+
// Derive vite_plus_home from bin_dir (parent), using a HOME-relative path for readability.
581656
let home_path = bin_dir
582657
.parent()
583658
.map(|p| p.as_path().display().to_string())
584659
.unwrap_or_else(|| bin_dir.as_path().display().to_string());
585-
let home_path = if let Ok(home_dir) = std::env::var("HOME") {
660+
let (home_path, nu_home_path) = if let Ok(home_dir) = std::env::var("HOME") {
586661
if let Some(suffix) = home_path.strip_prefix(&home_dir) {
587-
format!("$HOME{suffix}")
662+
(format!("$HOME{suffix}"), Some(format!("~{suffix}")))
588663
} else {
589-
home_path
664+
(home_path.clone(), None)
590665
}
591666
} else {
592-
home_path
667+
(home_path.clone(), None)
593668
};
669+
let nu_home_path = nu_home_path.unwrap_or(home_path.clone());
594670

595671
println!("{}", help::render_heading("Next Steps"));
596672
println!(" Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):");
@@ -601,6 +677,10 @@ fn print_path_instructions(bin_dir: &vite_path::AbsolutePath) {
601677
println!();
602678
println!(" source \"{home_path}/env.fish\"");
603679
println!();
680+
println!(" For Nushell, add to ~/.config/nushell/config.nu:");
681+
println!();
682+
println!(" source \"{nu_home_path}/env.nu\"");
683+
println!();
604684
println!(" For PowerShell, add to your $PROFILE:");
605685
println!();
606686
println!(" . \"{home_path}/env.ps1\"");
@@ -654,9 +734,11 @@ mod tests {
654734

655735
let env_path = home.join("env");
656736
let env_fish_path = home.join("env.fish");
737+
let env_nu_path = home.join("env.nu");
657738
let env_ps1_path = home.join("env.ps1");
658739
assert!(env_path.as_path().exists(), "env file should be created");
659740
assert!(env_fish_path.as_path().exists(), "env.fish file should be created");
741+
assert!(env_nu_path.as_path().exists(), "env.nu file should be created");
660742
assert!(env_ps1_path.as_path().exists(), "env.ps1 file should be created");
661743
}
662744

@@ -670,6 +752,7 @@ mod tests {
670752

671753
let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap();
672754
let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap();
755+
let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap();
673756

674757
// Placeholder should be fully replaced
675758
assert!(
@@ -680,6 +763,10 @@ mod tests {
680763
!fish_content.contains("__VP_BIN__"),
681764
"env.fish file should not contain __VP_BIN__ placeholder"
682765
);
766+
assert!(
767+
!nu_content.contains("__VP_BIN_NU__"),
768+
"env.nu file should not contain __VP_BIN_NU__ placeholder"
769+
);
683770

684771
// Should use $HOME-relative path since install dir is under HOME
685772
assert!(
@@ -690,6 +777,10 @@ mod tests {
690777
fish_content.contains("$HOME/bin"),
691778
"env.fish file should reference $HOME/bin, got: {fish_content}"
692779
);
780+
assert!(
781+
nu_content.contains("~/bin"),
782+
"env.nu file should reference ~/bin, got: {nu_content}"
783+
);
693784
}
694785

695786
#[tokio::test]
@@ -703,6 +794,7 @@ mod tests {
703794

704795
let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap();
705796
let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap();
797+
let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap();
706798

707799
// Should use absolute path since install dir is not under HOME
708800
let expected_bin = home.join("bin");
@@ -715,9 +807,14 @@ mod tests {
715807
fish_content.contains(&expected_str),
716808
"env.fish file should use absolute path {expected_str}, got: {fish_content}"
717809
);
810+
assert!(
811+
nu_content.contains(&expected_str),
812+
"env.nu file should use absolute path {expected_str}, got: {nu_content}"
813+
);
718814

719815
// Should NOT use $HOME-relative path
720816
assert!(!env_content.contains("$HOME/bin"), "env file should not reference $HOME/bin");
817+
assert!(!nu_content.contains("~/bin"), "env.nu file should not reference ~/bin");
721818
}
722819

723820
#[tokio::test]
@@ -773,6 +870,23 @@ mod tests {
773870
assert!(fish_content.contains("set -gx PATH"), "env.fish should set PATH globally");
774871
}
775872

873+
#[tokio::test]
874+
async fn test_create_env_files_nushell_contains_path_guard() {
875+
let temp_dir = TempDir::new().unwrap();
876+
let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
877+
let _guard = home_guard(temp_dir.path());
878+
879+
create_env_files(&home).await.unwrap();
880+
881+
let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap();
882+
883+
// Verify Nushell PATH guard
884+
assert!(
885+
nu_content.contains("$env.PATH = (($env.PATH | where {|entry| $entry != $__vp_bin }) | prepend $__vp_bin)"),
886+
"env.nu should dedupe and prepend the bin path"
887+
);
888+
}
889+
776890
#[tokio::test]
777891
async fn test_create_env_files_is_idempotent() {
778892
let temp_dir = TempDir::new().unwrap();
@@ -783,15 +897,18 @@ mod tests {
783897
create_env_files(&home).await.unwrap();
784898
let first_env = tokio::fs::read_to_string(home.join("env")).await.unwrap();
785899
let first_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap();
900+
let first_nu = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap();
786901
let first_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap();
787902

788903
create_env_files(&home).await.unwrap();
789904
let second_env = tokio::fs::read_to_string(home.join("env")).await.unwrap();
790905
let second_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap();
906+
let second_nu = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap();
791907
let second_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap();
792908

793909
assert_eq!(first_env, second_env, "env file should be identical after second write");
794910
assert_eq!(first_fish, second_fish, "env.fish file should be identical after second write");
911+
assert_eq!(first_nu, second_nu, "env.nu file should be identical after second write");
795912
assert_eq!(first_ps1, second_ps1, "env.ps1 file should be identical after second write");
796913
}
797914

@@ -868,6 +985,36 @@ mod tests {
868985
);
869986
}
870987

988+
#[tokio::test]
989+
async fn test_create_env_files_nushell_contains_vp_function() {
990+
let temp_dir = TempDir::new().unwrap();
991+
let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
992+
let _guard = home_guard(temp_dir.path());
993+
994+
create_env_files(&home).await.unwrap();
995+
996+
let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap();
997+
998+
// Verify Nushell vp function wrapper is present
999+
assert!(nu_content.contains("def --env vp"), "env.nu should contain vp function");
1000+
assert!(
1001+
nu_content.contains("do { ^vp ...$args } | complete"),
1002+
"env.nu should capture stdout/stderr from vp env use"
1003+
);
1004+
assert!(
1005+
nu_content.contains("from json"),
1006+
"env.nu should parse the env use payload as JSON"
1007+
);
1008+
assert!(
1009+
nu_content.contains("load-env $__vp_data.set"),
1010+
"env.nu should load env changes from the payload record"
1011+
);
1012+
assert!(
1013+
nu_content.contains("hide-env -i $name"),
1014+
"env.nu should hide env vars based on the payload list"
1015+
);
1016+
}
1017+
8711018
#[tokio::test]
8721019
async fn test_execute_env_only_creates_home_dir_and_env_files() {
8731020
let temp_dir = TempDir::new().unwrap();
@@ -888,6 +1035,7 @@ mod tests {
8881035
// Env files should be written
8891036
assert!(fresh_home.join("env").exists(), "env file should be created");
8901037
assert!(fresh_home.join("env.fish").exists(), "env.fish file should be created");
1038+
assert!(fresh_home.join("env.nu").exists(), "env.nu file should be created");
8911039
assert!(fresh_home.join("env.ps1").exists(), "env.ps1 file should be created");
8921040
}
8931041

0 commit comments

Comments
 (0)