diff --git a/.github/scripts/test-nushell-env.sh b/.github/scripts/test-nushell-env.sh new file mode 100755 index 0000000000..93e07ba895 --- /dev/null +++ b/.github/scripts/test-nushell-env.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${VP_BIN_UNDER_TEST:?Set VP_BIN_UNDER_TEST to the vp binary to test}" + +nu_bin="$(command -v "${NU_BIN:-nu}")" +test_root="$(mktemp -d)" +trap 'rm -rf -- "$test_root"' EXIT + +test_home="$test_root/vp \"home\\with spaces\"" + +VP_HOME="$test_home" "$VP_BIN_UNDER_TEST" env setup --refresh >/dev/null + +( + cd "$test_home" + env -u VP_HOME \ + EXPECTED_VP_HOME="$test_home" \ + PATH="$test_home/bin:$test_home/bin:/usr/bin:/bin" \ + "$nu_bin" --commands ' + source env.nu + + let expected_home = ($env.EXPECTED_VP_HOME | path expand --no-symlink) + if $env.VP_HOME != $expected_home { + error make { + msg: $"VP_HOME mismatch: expected ($expected_home), got ($env.VP_HOME)" + } + } + + let expected_bin = ($expected_home | path join "bin") + let actual_bin = ($env.PATH | first) + if $actual_bin != $expected_bin { + error make { + msg: $"PATH mismatch: expected first entry ($expected_bin), got ($actual_bin)" + } + } + let bin_count = ($env.PATH | where { $in == $expected_bin } | length) + if $bin_count != 1 { + error make { + msg: $"PATH contains the Vite+ bin directory ($bin_count) times" + } + } + + let vp_output = (vp --version) + if $env.LAST_EXIT_CODE != 0 { + error make { + msg: "vp --version failed through the Nushell wrapper" + } + } + if ($vp_output | is-empty) { + error make { + msg: "vp --version returned no output" + } + } + + vp env use 20.18.0 --no-install + if ("VP_NODE_VERSION" not-in $env) { + error make { + msg: "vp env use did not set VP_NODE_VERSION" + } + } + if $env.VP_NODE_VERSION != "20.18.0" { + error make { + msg: $"VP_NODE_VERSION mismatch: expected 20.18.0, got ($env.VP_NODE_VERSION)" + } + } + + vp env use --unset + if ("VP_NODE_VERSION" in $env) { + error make { + msg: "vp env use --unset did not remove VP_NODE_VERSION" + } + } + ' +) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3855fcfd22..490f336da9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -945,6 +945,17 @@ jobs: - name: Install Global CLI vp run: pnpm bootstrap-cli:ci + # https://github.com/marketplace/actions/setup-nu + - name: Install Nushell + if: runner.os == 'Linux' + uses: hustcer/setup-nu@ccd5bb5426b05a32009c2ba967946231f3919c97 # v3.25 + with: + version: '*' + + - name: Test generated Nushell environment + if: runner.os == 'Linux' + run: VP_BIN_UNDER_TEST="$HOME/.vite-plus/bin/vp" .github/scripts/test-nushell-env.sh + # Provision the managed runtime once into the real home so cases can # seed from it (seed-runtime) instead of each downloading ~50MB. # Best-effort: without a seed, cases that need the runtime download it diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index ad4fd9c237..78a3ecc893 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -739,6 +739,11 @@ Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nset VP_HOME=%~dp0..\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; fn render_home_relative_path(path: &std::path::Path, home_dir: Option<&std::path::Path>) -> String { + fn render_path(path: &std::path::Path) -> String { + let rendered = path.display().to_string(); + if cfg!(windows) { rendered.replace('\\', "/") } else { rendered } + } + // 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. home_dir @@ -748,10 +753,10 @@ fn render_home_relative_path(path: &std::path::Path, home_dir: Option<&std::path "$HOME".to_string() } else { // Normalize to forward slashes for $HOME/... paths (POSIX-style) - format!("$HOME/{}", s.display().to_string().replace('\\', "/")) + format!("$HOME/{}", render_path(s)) } }) - .unwrap_or_else(|| path.display().to_string().replace('\\', "/")) + .unwrap_or_else(|| render_path(path)) } fn render_nu_path_ref(path_ref: &str) -> String { @@ -762,6 +767,10 @@ fn render_nu_path_ref(path_ref: &str) -> String { } } +fn escape_nu_double_quoted_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + /// Render the env-file content for `shell` against `vite_plus_home`. fn render_env_content(shell: EnvShell, vite_plus_home: &vt_path::AbsolutePath) -> String { let bin_path = vite_plus_home.join("bin"); @@ -780,8 +789,10 @@ fn render_env_content(shell: EnvShell, vite_plus_home: &vt_path::AbsolutePath) - EnvShell::Nu => { // Nushell requires `~` instead of `$HOME` in string literals — `$HOME` is not // expanded at parse time, so PATH entries would contain a literal "$HOME/...". - let home_path_ref_nu = render_nu_path_ref(&home_path_ref); - let bin_path_ref_nu = render_nu_path_ref(&bin_path_ref); + let home_path_ref_nu = + escape_nu_double_quoted_string(&render_nu_path_ref(&home_path_ref)); + let bin_path_ref_nu = + escape_nu_double_quoted_string(&render_nu_path_ref(&bin_path_ref)); ENV_TEMPLATE_NU .replace("__VP_HOME__", &home_path_ref_nu) .replace("__VP_BIN__", &bin_path_ref_nu) @@ -946,6 +957,35 @@ mod tests { assert!(env_ps1_path.as_path().exists(), "env.ps1 file should be created"); } + #[test] + fn test_escape_nu_double_quoted_string() { + assert_eq!( + escape_nu_double_quoted_string(r#"vp "home\with spaces""#), + r#"vp \"home\\with spaces\""# + ); + } + + #[cfg(unix)] + #[test] + fn test_render_env_content_escapes_nu_paths() { + let _guard = home_guard("/nonexistent-home-dir"); + let home = AbsolutePathBuf::new(std::path::PathBuf::from(r#"/tmp/vp "home\with spaces""#)) + .unwrap(); + + let content = render_env_content(EnvShell::Nu, &home); + + assert!( + content.contains( + r#"$env.VP_HOME = ("/tmp/vp \"home\\with spaces\"" | path expand --no-symlink)"# + ), + "env.nu should escape VP_HOME for a Nushell string literal, got: {content}" + ); + assert!( + content.contains(r#"prepend "/tmp/vp \"home\\with spaces\"/bin")"#), + "env.nu should escape the bin path for a Nushell string literal, got: {content}" + ); + } + #[tokio::test] async fn test_create_env_files_nu_contains_path_guard() { let temp_dir = TempDir::new().unwrap();