diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3855fcfd22..64367b2468 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -945,6 +945,12 @@ jobs: - name: Install Global CLI vp run: pnpm bootstrap-cli:ci + # https://github.com/marketplace/actions/setup-nu + - name: Install Nushell + uses: hustcer/setup-nu@ccd5bb5426b05a32009c2ba967946231f3919c97 # v3.25 + with: + version: '*' + # 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 @@ -963,6 +969,7 @@ jobs: run: | VP_SNAP_GLOBAL_VP="$HOME/.vite-plus/bin/vp" \ VP_SNAP_JS_RUNTIME_DIR="$HOME/.vite-plus/js_runtime" \ + VP_SNAP_NU_BIN="$(command -v nu)" \ cargo test -p vp_cli_snapshots env: RUST_BACKTRACE: '1' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/README.md b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md index f117bd86ac..0005c5c2e6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md @@ -63,6 +63,7 @@ Environment overrides, mainly for CI: | `VP_SNAP_GLOBAL_VP` | Path to a prebuilt global `vp` binary (skips the target-dir lookup) | | `VP_SNAP_LOCAL_CLI_BIN_DIR` | Local CLI bin dir (default `/packages/cli/bin`) | | `VP_SNAP_JS_RUNTIME_DIR` | Provisioned managed runtime to seed case homes with | +| `VP_SNAP_NU_BIN` | Nushell binary for cases that execute generated `env.nu` files | | `VP_SNAP_SKIP_FLAVORS` | Comma-separated flavors to skip registering (e.g. `local`) | ## Case reference @@ -74,6 +75,7 @@ vp = "local" # "local" | "global" | ["local", "global"] comment = "What this proves." # rendered into the snapshot cwd = "packages/app" # optional, relative to the fixture root skip-platforms = ["windows"] # or { os = "linux", libc = "musl" } +requires = ["nu"] # ignore when an optional runner tool is absent ignore = false # true: only runs with `-- --ignored` seed-runtime = true # false: start from an empty VP_HOME link-node-modules = false # true: expose the run-root node_modules as @@ -116,9 +118,10 @@ A step is a bare argv array or a table: interactions = [ ... ] } ``` -`argv[0]` may be `vpt` or any executable exposed by the case's Vite+ -installation, including default shims such as `vp`, `node`, and `corepack` -and globally installed package binaries. There is no shell: no `&&`, no +`argv[0]` may be `vpt`, a runner-provisioned tool such as `nu`, or any +executable exposed by the case's Vite+ installation, including default shims +such as `vp`, `node`, and `corepack` and globally installed package binaries. +There is no shell: no `&&`, no redirects, no globs. File setup and assertions go through `vpt` so behavior is identical on every platform: @@ -182,7 +185,8 @@ case-owned tool dirs, then a system tail for child processes and direct `git` st `TERM=xterm-256color`, `VP_CLI_TEST=1`, `VP_EMIT_MILESTONES=1`, a fresh `HOME`, `VP_HOME`, and npm prefix. The runner still rejects direct step tools that resolve outside the case-owned dirs, except for `git`; `vpt` is the only -runner helper on PATH. `CI` and `NO_COLOR` are deliberately NOT set: with a PTY +required runner helper on PATH, while optional tools such as `nu` are linked +there when available. `CI` and `NO_COLOR` are deliberately NOT set: with a PTY attached, the CLI behaves interactively by default, which is the point. `seed-runtime = true` (default) symlinks a provisioned managed Node runtime into the case `VP_HOME` so commands do not download ~50MB per case. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert.nu b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert.nu new file mode 100644 index 0000000000..d834942056 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert.nu @@ -0,0 +1,56 @@ +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" + } +} + +print "Nushell environment checks passed" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots.toml new file mode 100644 index 0000000000..93e3c071aa --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots.toml @@ -0,0 +1,10 @@ +[[case]] +name = "command_env_nushell" +vp = "global" +skip-platforms = ["windows"] +requires = ["nu"] +steps = [ + { argv = ["vp", "env", "setup", "--refresh"], envs = [["VP_HOME", '${workspace}/vp "home\with spaces"']], snapshot = false }, + { argv = ["vpt", "cp", "assert.nu", 'vp "home\with spaces"/assert.nu'], snapshot = false }, + { argv = ["nu", "assert.nu"], cwd = 'vp "home\with spaces"', comment = "loads the generated env.nu and verifies the Nushell wrapper", envs = [["EXPECTED_VP_HOME", "${workspace}"], ["PATH", "${workspace}/bin:${workspace}/bin:${PATH}"]] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md new file mode 100644 index 0000000000..2b8d9bcebe --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md @@ -0,0 +1,17 @@ +# command_env_nushell + +## `VP_HOME=${workspace}/vp "home\with spaces" vp env setup --refresh` + + +## `vpt cp assert.nu 'vp "home\with spaces"/assert.nu'` + + +## `cd 'vp "home\with spaces"' && EXPECTED_VP_HOME=${workspace} PATH=${workspace}/bin:${workspace}/bin:${PATH} nu assert.nu` + +loads the generated env.nu and verifies the Nushell wrapper + +``` +Using Node.js (resolved from 20.18.0) +Reverted to file-based Node.js version resolution +Nushell environment checks passed +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs index 2cfbf66c4d..9f5f8455f4 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs @@ -5,7 +5,8 @@ //! checkout package's JS bin directory from inside that same case home. //! //! Each flavor gets one runner bin directory per run (created under the run -//! temp root) for runner-owned helpers. Only `vpt` lives there. +//! temp root) for runner-owned helpers. `vpt` always lives there; optional +//! external tools such as Nushell are linked there when available. use std::path::{Path, PathBuf}; @@ -29,6 +30,9 @@ impl Flavor { pub struct FlavorRuntime { pub runner_bin_dir: PathBuf, pub vpt: PathBuf, + /// Runner-owned Nushell binary used by fixtures that execute generated + /// `env.nu` files. CI supplies it through `VP_SNAP_NU_BIN`. + pub nu: Option, /// Source global `vp` binary to install into each case's `VP_HOME/current`. pub global_vp: PathBuf, /// Source package installed into each case's `VP_HOME/current/node_modules`. @@ -189,6 +193,23 @@ fn vpt_path() -> Result { }) } +/// Resolves an optional Nushell binary for fixtures that exercise generated +/// `env.nu` files. The explicit override keeps CI deterministic; a developer's +/// PATH is the local fallback. +pub fn nushell_path() -> Result, String> { + if let Some(nu) = std::env::var_os("VP_SNAP_NU_BIN") { + let nu = PathBuf::from(nu); + if nu.is_file() { + return std::fs::canonicalize(&nu).map(Some).map_err(|e| { + format!("failed to canonicalize VP_SNAP_NU_BIN {}: {e}", nu.display()) + }); + } + return Err(format!("VP_SNAP_NU_BIN is set but {} does not exist", nu.display())); + } + + Ok(which::which("nu").ok()) +} + /// Home-layout names, shared with `CaseHome` in main.rs so the product's /// `~/.vite-plus/js_runtime` layout is spelled once. pub const VP_HOME_DIR: &str = ".vite-plus"; @@ -286,10 +307,13 @@ pub fn provision(flavor: Flavor, run_root: &Path) -> Result local_cli_package_dir()?, Flavor::Global => repo_root().join("packages/cli"), }; - Ok(FlavorRuntime { runner_bin_dir, vpt, global_vp, cli_package_dir }) + Ok(FlavorRuntime { runner_bin_dir, vpt, nu, global_vp, cli_package_dir }) } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index 78ee1c0ca1..c0991f0014 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -316,6 +316,22 @@ impl PlatformFilter { } } +#[derive(Clone, Copy, serde::Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +enum RequiredTool { + Nu, +} + +impl RequiredTool { + /// A configuration error counts as available here so the trial runs and + /// reports that error instead of silently hiding a bad override. + fn is_missing(self) -> bool { + match self { + Self::Nu => matches!(flavor::nushell_path(), Ok(None)), + } + } +} + #[derive(serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] struct Case { @@ -332,6 +348,10 @@ struct Case { /// Exclude-list of platforms this case does not run on. #[serde(default, rename = "skip-platforms")] skip_platforms: Vec, + /// Optional runner-owned tools needed by this case. The trial is ignored + /// when a tool is unavailable, while invalid explicit overrides still fail. + #[serde(default)] + requires: Vec, /// Marks the trial `#[ignore]` (runnable with `cargo test -- --ignored`). #[serde(default)] ignore: bool, @@ -437,6 +457,7 @@ struct CaseInstall { path_env: OsString, tool_dirs: Vec, vpt: PathBuf, + nu: Option, } impl CaseInstall { @@ -451,6 +472,12 @@ impl CaseInstall { if program == "vpt" { return Ok(self.vpt.clone()); } + if program == "nu" { + return self.nu.clone().ok_or_else(|| { + "`nu` is required by this snapshot case; install Nushell or set VP_SNAP_NU_BIN" + .to_owned() + }); + } // An explicit `./`-prefixed program runs a file the case itself // produced inside the staged workspace (a packed executable); the @@ -546,6 +573,7 @@ impl CaseHome { path_env: compose_path_env(&path_dirs), tool_dirs, vpt: runtime.vpt.clone(), + nu: runtime.nu.clone(), }) } @@ -1594,6 +1622,7 @@ fn main() { if case.skip_platforms.iter().any(PlatformFilter::matches_current) { continue; } + let required_tool_missing = case.requires.iter().any(|tool| tool.is_missing()); let multi = case.vp.is_multi(); let case = Arc::new(case); for flavor in case.vp.flavors() { @@ -1615,7 +1644,9 @@ fn main() { let fixture_name = Arc::clone(&fixture_name); let tmp_dir_path = Arc::clone(&tmp_dir_path); let case = Arc::clone(&case); - let ignored = case.ignore || (case.local_registry && !local_build_present); + let ignored = case.ignore + || required_tool_missing + || (case.local_registry && !local_build_present); let isolated = case_needs_isolation(&case); let timings = Arc::clone(&timings); let timing_name = trial_name.clone(); diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 7baee7c074..b99249b124 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -729,6 +729,13 @@ 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(); + // Windows: `C:\Users\xxx\.vite-plus` → `C:/Users/xxx/.vite-plus` + // Unix: `/tmp/vp\home` → `/tmp/vp\home` (the backslash is preserved) + 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 @@ -738,10 +745,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 { @@ -752,6 +759,15 @@ fn render_nu_path_ref(path_ref: &str) -> String { } } +/// Escapes a value so it can be safely embedded in a Nushell double-quoted string. +/// +/// Example: `vp "home\with spaces"` → `vp \"home\\with spaces\"` +/// https://www.nushell.sh/book/working_with_strings.html#double-quoted-strings +fn escape_nu_double_quoted_string(value: &str) -> String { + // `vp "home\with spaces"` → `vp \"home\\with spaces\"` + 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"); @@ -770,8 +786,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) @@ -936,6 +954,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();