Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
12 changes: 8 additions & 4 deletions crates/vp_cli_snapshots/tests/cli_snapshots/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<repo>/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
Expand All @@ -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
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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}"]] },
Comment thread
naokihaba marked this conversation as resolved.
]
Original file line number Diff line number Diff line change
@@ -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 <version> (resolved from 20.18.0)
Reverted to file-based Node.js version resolution
Nushell environment checks passed
```
28 changes: 26 additions & 2 deletions crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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<PathBuf>,
/// 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`.
Expand Down Expand Up @@ -189,6 +193,23 @@ fn vpt_path() -> Result<PathBuf, String> {
})
}

/// 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<Option<PathBuf>, 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";
Expand Down Expand Up @@ -286,10 +307,13 @@ pub fn provision(flavor: Flavor, run_root: &Path) -> Result<FlavorRuntime, Strin
.map_err(|e| format!("failed to create bin dir: {e}"))?;

let vpt = install_runner_tool(&runner_bin_dir, "vpt", &vpt_path()?)?;
let nu = nushell_path()?
.map(|path| install_runner_tool(&runner_bin_dir, "nu", &path))
.transpose()?;
let global_vp = global_vp_path()?;
let cli_package_dir = match flavor {
Flavor::Local => 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 })
}
33 changes: 32 additions & 1 deletion crates/vp_cli_snapshots/tests/cli_snapshots/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -332,6 +348,10 @@ struct Case {
/// Exclude-list of platforms this case does not run on.
#[serde(default, rename = "skip-platforms")]
skip_platforms: Vec<PlatformFilter>,
/// 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<RequiredTool>,
/// Marks the trial `#[ignore]` (runnable with `cargo test -- --ignored`).
#[serde(default)]
ignore: bool,
Expand Down Expand Up @@ -437,6 +457,7 @@ struct CaseInstall {
path_env: OsString,
tool_dirs: Vec<PathBuf>,
vpt: PathBuf,
nu: Option<PathBuf>,
}

impl CaseInstall {
Expand All @@ -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
Expand Down Expand Up @@ -546,6 +573,7 @@ impl CaseHome {
path_env: compose_path_env(&path_dirs),
tool_dirs,
vpt: runtime.vpt.clone(),
nu: runtime.nu.clone(),
})
}

Expand Down Expand Up @@ -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() {
Expand All @@ -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();
Expand Down
55 changes: 51 additions & 4 deletions crates/vp_global_cli/src/commands/env/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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");
Expand All @@ -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)
Expand Down Expand Up @@ -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();
Expand Down