From 96bd5809140cab1392d82c23b522bf235162ed17 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:37:30 +0900 Subject: [PATCH 1/7] feat(env): add VP_NU_VERSION to set the Nushell version for env setup `vp env setup` will select the `env.nu` syntax from the Nushell version. VP_NU_VERSION gives that version directly, so `vp env setup` does not run `nu`. Tests use it to make the generated `env.nu` independent of the installed Nushell. --- crates/vp_shared/src/env_config.rs | 6 ++++++ crates/vp_shared/src/env_vars.rs | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index 103b26c041..2d78672ea8 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -180,6 +180,11 @@ pub struct EnvConfig { /// /// Env: `VP_SHELL` pub vp_shell: Option, + + /// Nushell version for `vp env setup`, used instead of running `nu`. + /// + /// Env: `VP_NU_VERSION` + pub nu_version: Option, } impl EnvConfig { @@ -220,6 +225,7 @@ impl EnvConfig { bun_version: std::env::var(env_vars::VP_BUN_VERSION).ok(), user_home, vp_shell: std::env::var(env_vars::VP_SHELL).ok(), + nu_version: std::env::var(env_vars::VP_NU_VERSION).ok(), }) } diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 3b3a1fd908..a31fb5db87 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -110,6 +110,11 @@ pub const VP_ENV_USE_EVAL_ENABLE: &str = "VP_ENV_USE_EVAL_ENABLE"; /// Explicitly specify the current shell. pub const VP_SHELL: &str = "VP_SHELL"; +/// Nushell version that `vp env setup` uses to select the `env.nu` syntax, +/// instead of running `nu`. Tests set it to make the output independent of +/// the installed Nushell. +pub const VP_NU_VERSION: &str = "VP_NU_VERSION"; + /// Filter for update task types. pub const VP_UPDATE_TASK_TYPES: &str = "VP_UPDATE_TASK_TYPES"; From 4192d2ad8f4fd6c0d846313aeda3a8411b628027 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:37:39 +0900 Subject: [PATCH 2/7] fix(env): call Nushell built-ins with the % sigil in env.nu Nushell resolves command names at parse time, and it parses the Vite+ env.nu after the user's config. A user command with the name of a built-in, such as `def update []`, replaced the built-in that env.nu calls, and Nushell stopped at startup with `nu::parser::extra_positional`. env.nu now calls each built-in that a user can shadow with the `%` sigil, and calls `fish` as `^fish`. Nushell 0.112.1 added the sigil, and earlier versions cannot parse it. Thus, `vp env setup` asks the `nu` on PATH for its version and writes the sigils only for 0.112.1 or later. In other cases, it writes the same env.nu as before. Fixes #3092 --- .../command_env_nushell/assert_shadowed.nu | 47 ++++ .../command_env_nushell/snapshots.toml | 11 + .../command_env_nushell_shadowed_builtins.md | 17 ++ .../snapshots.toml | 2 +- .../shell_integration_cwd_templates.md | 62 ++--- .../vp_global_cli/src/commands/env/setup.rs | 221 ++++++++++++++---- 6 files changed, 282 insertions(+), 78 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_shadowed.nu create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell_shadowed_builtins.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_shadowed.nu b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_shadowed.nu new file mode 100644 index 0000000000..05a004a4db --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/assert_shadowed.nu @@ -0,0 +1,47 @@ +# User commands that shadow the built-ins env.nu uses (#3092). Nushell resolves +# names at parse time, so env.nu must call the built-ins with the `%` sigil. +# This script itself uses only keywords and commands that are not shadowed. +def update [] { "user update" } +def length [] { "user length" } +def skip [] { } +def lines [] { } +def parse [] { } +def get [] { } +def default [] { } +def insert [] { } +def reduce [] { } +def is-not-empty [] { } +def load-env [] { } +def hide-env [] { } +def with-env [] { } +def prepend [] { } +def append [] { } +def "str starts-with" [] { } +def "str replace" [] { } +def "path expand" [] { } +def "path exists" [] { } +def "from tsv" [] { } +def rename [] { } +def any [] { } +def fish [] { } + +source env.nu + +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" } +} + +if (update) != "user update" { + error make { msg: "env.nu replaced the user's update command" } +} + +print "env.nu loads and works with shadowed built-ins" 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 index 27d2b29bc5..2ccd3b5ab3 100644 --- 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 @@ -20,3 +20,14 @@ steps = [ { argv = ["vpt", "cp", "assert_dirs.nu", "config-$USER-\"quote\"-'single'-`tick`-\\slash/vite-plus/assert_dirs.nu"], snapshot = false }, { argv = ["nu", "config-$USER-\"quote\"-'single'-`tick`-\\slash/vite-plus/assert_dirs.nu"], comment = "Nushell loads env.nu, preserves a PATH with shell metacharacters, and omits internal directory variables", envs = [["HOME", "${workspace}/shell-home"], ["USERPROFILE", "${workspace}/shell-home"], ["VP_HOME", ""], ["EXPECTED_VP_BIN_DIR", "${workspace}/bin-$USER-\"quote\"-'single'-`tick`-\\slash"], ["PATH", "${workspace}/bin-$USER-\"quote\"-'single'-`tick`-\\slash:${workspace}/bin-$USER-\"quote\"-'single'-`tick`-\\slash:${PATH}"]] }, ] + +[[case]] +name = "command_env_nushell_shadowed_builtins" +vp = "global" +skip-platforms = ["windows"] +requires = ["nu"] +steps = [ + { argv = ["vp", "env", "setup", "--refresh"], envs = [["VP_HOME", "${workspace}/home"]], snapshot = false }, + { argv = ["vpt", "cp", "assert_shadowed.nu", "home/assert_shadowed.nu"], snapshot = false }, + { argv = ["nu", "assert_shadowed.nu"], cwd = "home", comment = "env.nu loads and runs `vp env use` when user commands shadow the built-ins it calls (#3092)", envs = [["VP_HOME", "${workspace}/home"]] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell_shadowed_builtins.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell_shadowed_builtins.md new file mode 100644 index 0000000000..f65012d3f8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell_shadowed_builtins.md @@ -0,0 +1,17 @@ +# command_env_nushell_shadowed_builtins + +## `VP_HOME=${workspace}/home vp env setup --refresh` + + +## `vpt cp assert_shadowed.nu home/assert_shadowed.nu` + + +## `cd home && VP_HOME=${workspace}/home nu assert_shadowed.nu` + +env.nu loads and runs `vp env use` when user commands shadow the built-ins it calls (#3092) + +``` +Using Node.js (resolved from 20.18.0) +Reverted selected components to project environment resolution +env.nu loads and works with shadowed built-ins +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots.toml index 1493805cea..629a9ac76a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots.toml @@ -3,7 +3,7 @@ name = "shell_integration_cwd_templates" vp = "global" skip-platforms = ["windows"] steps = [ - { argv = ["vp", "env", "setup", "--refresh"], envs = [["VP_HOME", "${workspace}/home"]], snapshot = false }, + { argv = ["vp", "env", "setup", "--refresh"], envs = [["VP_HOME", "${workspace}/home"], ["VP_NU_VERSION", "0.112.1"]], comment = "VP_NU_VERSION keeps env.nu independent of the installed Nushell", snapshot = false }, { argv = ["vpt", "print-file", "home/env"], comment = "POSIX wrapper and zsh vpr completion keep global -C before env use/run" }, { argv = ["vpt", "print-file", "home/env.fish"], comment = "fish wrapper and vpr completion keep global -C before env use/run" }, { argv = ["vpt", "print-file", "home/env.nu"], comment = "Nushell wrapper and vpr completion keep global -C before env use/run" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md index 27fef99b13..0365e9964b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shell_integration_cwd_templates/snapshots/shell_integration_cwd_templates.md @@ -1,6 +1,8 @@ # shell_integration_cwd_templates -## `VP_HOME=${workspace}/home vp env setup --refresh` +## `VP_HOME=${workspace}/home VP_NU_VERSION=0.112.1 vp env setup --refresh` + +VP_NU_VERSION keeps env.nu independent of the installed Nushell ## `vpt print-file home/env` @@ -206,39 +208,39 @@ Nushell wrapper and vpr completion keep global -C before env use/run ``` # Vite+ environment setup (https://viteplus.dev) -$env.VP_HOME = ("/home" | path expand --no-symlink) -$env.PATH = ($env.PATH | where { $in != "/home/bin" and $in != "/home/fallback-bin" } | prepend "/home/bin" | append "/home/fallback-bin") +$env.VP_HOME = ("/home" | %path expand --no-symlink) +$env.PATH = ($env.PATH | where { $in != "/home/bin" and $in != "/home/fallback-bin" } | %prepend "/home/bin" | %append "/home/fallback-bin") # Shell function wrapper: intercepts `vp env use` to parse its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. def --env --wrapped vp [...args: string@"nu-complete vp"] { - let command_args = if ($args | length) >= 2 and $args.0 == "-C" { - $args | skip 2 - } else if ($args | length) >= 1 and ($args.0 | str starts-with "-C") and $args.0 != "-C" { - $args | skip 1 + let command_args = if ($args | %length) >= 2 and $args.0 == "-C" { + $args | %skip 2 + } else if ($args | %length) >= 1 and ($args.0 | %str starts-with "-C") and $args.0 != "-C" { + $args | %skip 1 } else { $args } - if ($command_args | length) >= 2 and $command_args.0 == "env" and $command_args.1 == "use" { + if ($command_args | %length) >= 2 and $command_args.0 == "env" and $command_args.1 == "use" { if ("-h" in $args) or ("--help" in $args) { ^vp ...$args return } - let out = (with-env { VP_ENV_USE_EVAL_ENABLE: "1", VP_SHELL: "nu" } { + let out = (%with-env { VP_ENV_USE_EVAL_ENABLE: "1", VP_SHELL: "nu" } { ^vp ...$args }) - let lines = ($out | lines) - let exports = ($lines | where { $in =~ '^\$env\.' } | parse '$env.{key} = "{value}"') - let export_keys = ($exports | get key? | default []) + let lines = ($out | %lines) + let exports = ($lines | where { $in =~ '^\$env\.' } | %parse '$env.{key} = "{value}"') + let export_keys = ($exports | %get key? | %default []) # Exclude keys that also appear in exports: when vp emits `hide-env X` then # `$env.X = "v"` (e.g. `vp env use` with no args resolving from .node-version), # the set should win. - let unsets = ($lines | where { $in =~ '^hide-env ' } | parse 'hide-env {key}' | get key? | default [] | where { $in not-in $export_keys }) - if ($exports | is-not-empty) { - load-env ($exports | reduce -f {} {|it, acc| $acc | insert $it.key $it.value}) + let unsets = ($lines | where { $in =~ '^hide-env ' } | %parse 'hide-env {key}' | %get key? | %default [] | where { $in not-in $export_keys }) + if ($exports | %is-not-empty) { + %load-env ($exports | %reduce -f {} {|it, acc| $acc | %insert $it.key $it.value}) } for key in $unsets { - if ($key in $env) { hide-env $key } + if ($key in $env) { %hide-env $key } } } else { ^vp ...$args @@ -248,33 +250,33 @@ def --env --wrapped vp [...args: string@"nu-complete vp"] { # Shell completion for nushell (delegates to fish completions dynamically) def "nu-complete vp" [context: string] { let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($context)'" - fish --command $fish_cmd | from tsv --flexible --noheaders --no-infer | rename value description | update value {|row| + ^fish --command $fish_cmd | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| let value = $row.value - let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | any {$in in $value} - if ($need_quote and ($value | path exists)) { - let expanded_path = if ($value starts-with ~) {$value | path expand --no-symlink} else {$value} - $'"($expanded_path | str replace --all "\"" "\\\"")"' + let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | %any {$in in $value} + if ($need_quote and ($value | %path exists)) { + let expanded_path = if ($value starts-with ~) {$value | %path expand --no-symlink} else {$value} + $'"($expanded_path | %str replace --all "\"" "\\\"")"' } else {$value} } } # Completion logic for vpr (translates context to 'vp run ...') def "nu-complete vpr" [context: string] { let modified_context = if ($context =~ '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { - $context | str replace -r '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' + $context | %str replace -r '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' } else if ($context =~ '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { - $context | str replace -r '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' + $context | %str replace -r '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' } else if ($context =~ '^vpr\s+-C') { - $context | str replace -r '^vpr' 'vp' + $context | %str replace -r '^vpr' 'vp' } else { - $context | str replace -r '^vpr' 'vp run' + $context | %str replace -r '^vpr' 'vp run' } let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($modified_context)'" - fish --command $fish_cmd | from tsv --flexible --noheaders --no-infer | rename value description | update value {|row| + ^fish --command $fish_cmd | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| let value = $row.value - let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | any {$in in $value} - if ($need_quote and ($value | path exists)) { - let expanded_path = if ($value starts-with ~) {$value | path expand --no-symlink} else {$value} - $'"($expanded_path | str replace --all "\"" "\\\"")"' + let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | %any {$in in $value} + if ($need_quote and ($value | %path exists)) { + let expanded_path = if ($value starts-with ~) {$value | %path expand --no-symlink} else {$value} + $'"($expanded_path | %str replace --all "\"" "\\\"")"' } else {$value} } } diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 52ae6487db..65ea92dae6 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -45,6 +45,64 @@ enum EnvShell { const UTF8_BOM: char = '\u{feff}'; +/// First Nushell release with the `%` sigil, which calls a built-in even when a +/// user command or alias has the same name. +const NU_BUILTIN_SIGIL_VERSION: &str = "0.112.1"; + +/// Nushell syntax that the generated `env.nu` uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NuSyntax { + /// Prefix built-ins with `%`, so user commands cannot shadow them. + BuiltinSigil, + /// Use plain built-in names. Nushell before 0.112.1 reads `%update` as an + /// external command, and the rest of the line then fails to parse. + Legacy, +} + +impl NuSyntax { + /// Choose the syntax for a `nu` version string. Unknown versions get + /// `Legacy`, because every Nushell version can parse it. + fn for_version(version: &str) -> Self { + let minimum = js_semver::Version::parse(NU_BUILTIN_SIGIL_VERSION); + match (js_semver::Version::parse(version.trim()), minimum) { + (Ok(version), Ok(minimum)) if version >= minimum => Self::BuiltinSigil, + _ => Self::Legacy, + } + } + + /// Detect the syntax from `VP_NU_VERSION`, or else from the `nu` on `PATH`. + /// When `nu` is not installed, use `Legacy`. + async fn detect(config: &vp_shared::EnvConfig) -> Self { + if let Some(version) = &config.nu_version { + return Self::for_version(version); + } + let Ok(cwd) = vt_path::current_dir() else { return Self::Legacy }; + let Ok(nu) = vp_command::resolve_bin("nu", None, &cwd) else { return Self::Legacy }; + let mut command = vp_command::build_command(&nu, &cwd); + // `-c` does not load the user's config or autoload files, so a broken + // env.nu or a user `def version` cannot affect the result. + command + .args(["-c", "version | get version"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + match command.output().await { + Ok(result) if result.status.success() => { + Self::for_version(&String::from_utf8_lossy(&result.stdout)) + } + _ => Self::Legacy, + } + } + + /// The prefix for a built-in command name. + const fn builtin_prefix(self) -> &'static str { + match self { + Self::BuiltinSigil => "%", + Self::Legacy => "", + } + } +} + impl EnvShell { /// File name written under `/` for this shell's setup script. const fn env_file_name(self) -> &'static str { @@ -909,39 +967,42 @@ complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" // Nushell env file with vp wrapper function. // Completions delegate to Fish dynamically (VP_COMPLETE=fish) because clap_complete_nushell // generates multiple rest params (e.g. for `vp install`), which Nushell does not support. +// Every shadowable built-in carries the `%` sigil: Nushell resolves names at parse time, and +// env.nu is parsed after the user's config, so a user `def update []` would otherwise replace +// the built-in and stop Nushell at startup. `NuSyntax::Legacy` strips the sigils. const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev) -__ENV_EXPORTS__$env.PATH = ($env.PATH | where { $in != "__VP_BIN__" and $in != "__VP_FALLBACK_BIN__" } | prepend "__VP_BIN__" | append "__VP_FALLBACK_BIN__") +__ENV_EXPORTS__$env.PATH = ($env.PATH | where { $in != "__VP_BIN__" and $in != "__VP_FALLBACK_BIN__" } | %prepend "__VP_BIN__" | %append "__VP_FALLBACK_BIN__") # Shell function wrapper: intercepts `vp env use` to parse its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. def --env --wrapped vp [...args: string@"nu-complete vp"] { - let command_args = if ($args | length) >= 2 and $args.0 == "-C" { - $args | skip 2 - } else if ($args | length) >= 1 and ($args.0 | str starts-with "-C") and $args.0 != "-C" { - $args | skip 1 + let command_args = if ($args | %length) >= 2 and $args.0 == "-C" { + $args | %skip 2 + } else if ($args | %length) >= 1 and ($args.0 | %str starts-with "-C") and $args.0 != "-C" { + $args | %skip 1 } else { $args } - if ($command_args | length) >= 2 and $command_args.0 == "env" and $command_args.1 == "use" { + if ($command_args | %length) >= 2 and $command_args.0 == "env" and $command_args.1 == "use" { if ("-h" in $args) or ("--help" in $args) { ^vp ...$args return } - let out = (with-env { VP_ENV_USE_EVAL_ENABLE: "1", VP_SHELL: "nu" } { + let out = (%with-env { VP_ENV_USE_EVAL_ENABLE: "1", VP_SHELL: "nu" } { ^vp ...$args }) - let lines = ($out | lines) - let exports = ($lines | where { $in =~ '^\$env\.' } | parse '$env.{key} = "{value}"') - let export_keys = ($exports | get key? | default []) + let lines = ($out | %lines) + let exports = ($lines | where { $in =~ '^\$env\.' } | %parse '$env.{key} = "{value}"') + let export_keys = ($exports | %get key? | %default []) # Exclude keys that also appear in exports: when vp emits `hide-env X` then # `$env.X = "v"` (e.g. `vp env use` with no args resolving from .node-version), # the set should win. - let unsets = ($lines | where { $in =~ '^hide-env ' } | parse 'hide-env {key}' | get key? | default [] | where { $in not-in $export_keys }) - if ($exports | is-not-empty) { - load-env ($exports | reduce -f {} {|it, acc| $acc | insert $it.key $it.value}) + let unsets = ($lines | where { $in =~ '^hide-env ' } | %parse 'hide-env {key}' | %get key? | %default [] | where { $in not-in $export_keys }) + if ($exports | %is-not-empty) { + %load-env ($exports | %reduce -f {} {|it, acc| $acc | %insert $it.key $it.value}) } for key in $unsets { - if ($key in $env) { hide-env $key } + if ($key in $env) { %hide-env $key } } } else { ^vp ...$args @@ -951,33 +1012,33 @@ def --env --wrapped vp [...args: string@"nu-complete vp"] { # Shell completion for nushell (delegates to fish completions dynamically) def "nu-complete vp" [context: string] { let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($context)'" - fish --command $fish_cmd | from tsv --flexible --noheaders --no-infer | rename value description | update value {|row| + ^fish --command $fish_cmd | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| let value = $row.value - let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | any {$in in $value} - if ($need_quote and ($value | path exists)) { - let expanded_path = if ($value starts-with ~) {$value | path expand --no-symlink} else {$value} - $'"($expanded_path | str replace --all "\"" "\\\"")"' + let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | %any {$in in $value} + if ($need_quote and ($value | %path exists)) { + let expanded_path = if ($value starts-with ~) {$value | %path expand --no-symlink} else {$value} + $'"($expanded_path | %str replace --all "\"" "\\\"")"' } else {$value} } } # Completion logic for vpr (translates context to 'vp run ...') def "nu-complete vpr" [context: string] { let modified_context = if ($context =~ '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { - $context | str replace -r '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' + $context | %str replace -r '^vpr(?\s+-C\s+(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' } else if ($context =~ '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s') { - $context | str replace -r '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' + $context | %str replace -r '^vpr(?\s+-C=?(?:"[^"]*"|\x27[^\x27]*\x27|\S+))\s' 'vp$cwd run ' } else if ($context =~ '^vpr\s+-C') { - $context | str replace -r '^vpr' 'vp' + $context | %str replace -r '^vpr' 'vp' } else { - $context | str replace -r '^vpr' 'vp run' + $context | %str replace -r '^vpr' 'vp run' } let fish_cmd = $"VP_COMPLETE=fish command vp | source; complete '--do-complete=($modified_context)'" - fish --command $fish_cmd | from tsv --flexible --noheaders --no-infer | rename value description | update value {|row| + ^fish --command $fish_cmd | %from tsv --flexible --noheaders --no-infer | %rename value description | %update value {|row| let value = $row.value - let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | any {$in in $value} - if ($need_quote and ($value | path exists)) { - let expanded_path = if ($value starts-with ~) {$value | path expand --no-symlink} else {$value} - $'"($expanded_path | str replace --all "\"" "\\\"")"' + let need_quote = ['\' ',' '[' ']' '(' ')' ' ' '\t' "'" '"' "`"] | %any {$in in $value} + if ($need_quote and ($value | %path exists)) { + let expanded_path = if ($value starts-with ~) {$value | %path expand --no-symlink} else {$value} + $'"($expanded_path | %str replace --all "\"" "\\\"")"' } else {$value} } } @@ -1177,7 +1238,7 @@ pub(crate) fn escape_powershell_single_quoted_string(value: &str) -> String { /// Render export lines for the persisted layout values in /// [`EnvConfig::dir_envs`]. The map contains only an explicit absolute /// `VP_HOME`. Sort the lines to keep the output stable. -fn render_dir_envs(shell: EnvShell, config: &vp_shared::EnvConfig) -> String { +fn render_dir_envs(shell: EnvShell, config: &vp_shared::EnvConfig, nu_syntax: NuSyntax) -> String { let home_dir = config.user_home.as_path(); let mut exports: Vec<_> = config.dir_envs.iter().collect(); exports.sort_unstable_by_key(|(name, _)| *name); @@ -1206,8 +1267,9 @@ fn render_dir_envs(shell: EnvShell, config: &vp_shared::EnvConfig) -> String { home_dir, )); format!( - "$env.{name} = (\"{}\" | path expand --no-symlink)\n", - escape_nu_double_quoted_string(&path_ref) + "$env.{name} = (\"{}\" | {}path expand --no-symlink)\n", + escape_nu_double_quoted_string(&path_ref), + nu_syntax.builtin_prefix() ) } // PowerShell uses the actual absolute path (not $HOME-relative) @@ -1223,12 +1285,16 @@ fn render_dir_envs(shell: EnvShell, config: &vp_shared::EnvConfig) -> String { /// /// Put the resolved bin directory on `PATH`. Persist an explicit `VP_HOME`, but /// do not export the internal split-directory group. -fn render_env_content(shell: EnvShell, config: &vp_shared::EnvConfig) -> String { +fn render_env_content( + shell: EnvShell, + config: &vp_shared::EnvConfig, + nu_syntax: NuSyntax, +) -> String { let dirs = &config.dirs; let home_dir = config.user_home.as_path(); let bin_path_ref = render_home_relative_path(dirs.bin.as_path(), home_dir); let fallback_path_ref = render_home_relative_path(dirs.fallback_bin().as_path(), home_dir); - let dir_envs = render_dir_envs(shell, config); + let dir_envs = render_dir_envs(shell, config, nu_syntax); match shell { EnvShell::Posix => { @@ -1264,7 +1330,9 @@ fn render_env_content(shell: EnvShell, config: &vp_shared::EnvConfig) -> String // expanded at parse time, so PATH entries would contain a literal "$HOME/...". let bin_path_ref_nu = escape_nu_double_quoted_string(&render_nu_path_ref(&bin_path_ref)); + // Strip the sigils before substitution: paths can contain `%`. ENV_TEMPLATE_NU + .replace('%', nu_syntax.builtin_prefix()) .replace( "__VP_FALLBACK_BIN__", &escape_nu_double_quoted_string(&render_nu_path_ref(&fallback_path_ref)), @@ -1303,8 +1371,9 @@ async fn create_env_files() -> Result<(), Error> { ); // Like env.ps1, the wrapper needs a BOM for non-ASCII paths on PowerShell 5.1. tokio::fs::write(config.dirs.config.join("vp.ps1"), format!("{UTF8_BOM}{ps1_wrapper}")).await?; + let nu_syntax = NuSyntax::detect(&config).await; for shell in [EnvShell::Posix, EnvShell::Fish, EnvShell::Nu, EnvShell::Powershell] { - let mut content = render_env_content(shell, &config); + let mut content = render_env_content(shell, &config, nu_syntax); if matches!(shell, EnvShell::Powershell) { // Windows PowerShell 5.1 uses the active ANSI code page for a // UTF-8 script without a BOM. The BOM preserves non-ASCII paths. @@ -1516,11 +1585,13 @@ mod tests { fn test_env_vars<'a>( root: &'a std::path::Path, user_home: &'a std::path::Path, - ) -> [(&'static str, &'a std::ffi::OsStr); 3] { + ) -> [(&'static str, &'a std::ffi::OsStr); 4] { [ (vp_shared::env_vars::VP_HOME, root.as_os_str()), ("HOME", user_home.as_os_str()), ("USERPROFILE", user_home.as_os_str()), + // Keep env.nu independent of the developer's installed Nushell. + (vp_shared::env_vars::VP_NU_VERSION, std::ffi::OsStr::new(NU_BUILTIN_SIGIL_VERSION)), ] } @@ -1622,7 +1693,7 @@ mod tests { ], |_| { let config = vp_shared::EnvConfig::get(); - let content = render_env_content(EnvShell::Posix, &config); + let content = render_env_content(EnvShell::Posix, &config, NuSyntax::BuiltinSigil); assert!(!content.contains("VP_BIN_DIR")); assert!(!content.contains("VP_DATA_DIR")); assert!(!content.contains("VP_CACHE_DIR")); @@ -1646,7 +1717,7 @@ mod tests { |_| { let config = vp_shared::EnvConfig::get(); for shell in [EnvShell::Posix, EnvShell::Fish, EnvShell::Nu, EnvShell::Powershell] { - let content = render_env_content(shell, &config); + let content = render_env_content(shell, &config, NuSyntax::BuiltinSigil); assert!( !content.contains("VP_HOME="), "{shell:?} env file should not pin VP_HOME when unset, got: {content}" @@ -1778,10 +1849,13 @@ mod tests { let ps_bin_ref = escape_powershell_single_quoted_string( &custom_bin.as_path().display().to_string(), ); - let posix_content = render_env_content(EnvShell::Posix, &config); - let fish_content = render_env_content(EnvShell::Fish, &config); - let nu_content = render_env_content(EnvShell::Nu, &config); - let ps_content = render_env_content(EnvShell::Powershell, &config); + let posix_content = + render_env_content(EnvShell::Posix, &config, NuSyntax::BuiltinSigil); + let fish_content = + render_env_content(EnvShell::Fish, &config, NuSyntax::BuiltinSigil); + let nu_content = render_env_content(EnvShell::Nu, &config, NuSyntax::BuiltinSigil); + let ps_content = + render_env_content(EnvShell::Powershell, &config, NuSyntax::BuiltinSigil); assert!(posix_content.contains(&format!("__vp_bin=\"{posix_bin_ref}\""))); assert!(fish_content.contains(&format!("contains -i -- \"{fish_bin_ref}\""))); assert!(nu_content.contains(&format!("prepend \"{nu_bin_ref}\""))); @@ -1834,7 +1908,7 @@ mod tests { ], |_| { let config = vp_shared::EnvConfig::get(); - let content = render_env_content(EnvShell::Nu, &config); + let content = render_env_content(EnvShell::Nu, &config, NuSyntax::BuiltinSigil); assert!( content.contains(r#"prepend "/tmp/vp \"home\\with spaces\"/bin""#), @@ -1844,6 +1918,49 @@ mod tests { ); } + #[test] + fn test_nu_syntax_for_version() { + assert_eq!(NuSyntax::for_version("0.112.1"), NuSyntax::BuiltinSigil); + assert_eq!(NuSyntax::for_version("0.116.1\n"), NuSyntax::BuiltinSigil); + assert_eq!(NuSyntax::for_version("1.0.0"), NuSyntax::BuiltinSigil); + // 0.112.0 was never released; its pre-release builds may lack `%`. + assert_eq!(NuSyntax::for_version("0.112.0"), NuSyntax::Legacy); + assert_eq!(NuSyntax::for_version("0.111.0"), NuSyntax::Legacy); + assert_eq!(NuSyntax::for_version(""), NuSyntax::Legacy); + assert_eq!(NuSyntax::for_version("not a version"), NuSyntax::Legacy); + } + + // A user `def update []` must not stop Nushell at startup (#3092). + #[cfg(unix)] + #[test] + fn test_render_env_content_nu_syntax() { + let home = std::path::PathBuf::from("/tmp/vp%home"); + vp_shared::EnvConfig::with_vars( + [ + (vp_shared::env_vars::VP_HOME, home.as_os_str()), + ("HOME", std::ffi::OsStr::new("/nonexistent-home-dir")), + ("USERPROFILE", std::ffi::OsStr::new("/nonexistent-home-dir")), + ], + |_| { + let config = vp_shared::EnvConfig::get(); + let sigil = render_env_content(EnvShell::Nu, &config, NuSyntax::BuiltinSigil); + assert!(sigil.contains("| %update value {|row|"), "got: {sigil}"); + assert!(sigil.contains(r#"("/tmp/vp%home" | %path expand"#), "got: {sigil}"); + assert!(sigil.contains("^fish --command"), "got: {sigil}"); + + let legacy = render_env_content(EnvShell::Nu, &config, NuSyntax::Legacy); + assert!(legacy.contains("| update value {|row|"), "got: {legacy}"); + assert!(legacy.contains("^fish --command"), "got: {legacy}"); + assert_eq!( + legacy.replace("/tmp/vp%home", "").matches('%').count(), + 0, + "Nushell before 0.112.1 cannot parse the `%` sigil, got: {legacy}" + ); + assert!(legacy.contains(r#"$env.VP_HOME = ("/tmp/vp%home" | path expand"#)); + }, + ); + } + #[tokio::test] async fn test_create_env_files_nu_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); @@ -2657,9 +2774,11 @@ mod tests { let temp_dir = TempDir::new().unwrap(); vp_shared::EnvConfig::with_vars(test_env_vars(temp_dir.path(), temp_dir.path()), |_| { let config = vp_shared::EnvConfig::get(); - let posix_content = render_env_content(EnvShell::Posix, &config); - let nu_content = render_env_content(EnvShell::Nu, &config); - let ps1_content = render_env_content(EnvShell::Powershell, &config); + let posix_content = + render_env_content(EnvShell::Posix, &config, NuSyntax::BuiltinSigil); + let nu_content = render_env_content(EnvShell::Nu, &config, NuSyntax::BuiltinSigil); + let ps1_content = + render_env_content(EnvShell::Powershell, &config, NuSyntax::BuiltinSigil); assert!(posix_content.contains("if (( CURRENT >= 4 )); then")); assert!(posix_content.contains("if (( CURRENT >= 3 )); then")); @@ -2752,7 +2871,11 @@ vp let env_file = home.join("env"); std::fs::write( &env_file, - render_env_content(EnvShell::Posix, &vp_shared::EnvConfig::get()), + render_env_content( + EnvShell::Posix, + &vp_shared::EnvConfig::get(), + NuSyntax::BuiltinSigil, + ), ) .unwrap(); @@ -2821,7 +2944,11 @@ echo ok let env_file = home.join("env"); std::fs::write( &env_file, - render_env_content(EnvShell::Posix, &vp_shared::EnvConfig::get()), + render_env_content( + EnvShell::Posix, + &vp_shared::EnvConfig::get(), + NuSyntax::BuiltinSigil, + ), ) .unwrap(); From 48034d4ff37198439db90db5f712e2d625883e8f Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:37:46 +0900 Subject: [PATCH 3/7] docs(env): document the Nushell version requirement Recommend Nushell 0.112.1 or later, and tell users with an earlier version to run `vp env setup` again after they upgrade Nushell. --- docs/guide/env.md | 2 +- docs/guide/global-cli.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/guide/env.md b/docs/guide/env.md index 7b84c37511..99fd3a8273 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -181,7 +181,7 @@ In CI, `vp env use` can run without shell initialization. It writes a temporary The global CLI supports Tab completion in Bash, Zsh, Fish, Nushell, and PowerShell. Run `vp env setup` and follow its printed shell setup instructions to enable it. Press Tab to complete commands, or project task names after `vp run` and `vpr`. - **Zsh**: Run `autoload -Uz compinit; compinit` before loading the setup script if your shell has not initialized completion. -- **Nushell**: Completion requires `fish` on `PATH`. +- **Nushell**: Completion requires `fish` on `PATH`. Use Nushell 0.112.1 or later; see [Nushell version](/guide/global-cli#nushell-version). ### Manage diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index a89a91a0f5..55ba216be4 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -347,6 +347,12 @@ source ~/.config/vite-plus/env.nu For a custom `XDG_CONFIG_HOME`, use the absolute path to `/vite-plus/env.nu` as resolved during installation. For an installation under `VP_HOME` or an existing `~/.vite-plus` installation, use `/env.nu` or `~/.vite-plus/env.nu` instead. Replace placeholders with actual paths and quote paths containing spaces. Open a new Nushell session and run `vp help` to verify the configuration. +#### Nushell version + +Use Nushell 0.112.1 or later. In Nushell, a custom command or alias can have the same name as a built-in command, for example `def update []`. Nushell reads the Vite+ `env.nu` after your configuration. Thus, `env.nu` must call the built-in commands with the [`%` sigil](https://www.nushell.sh/lang-guide/chapters/custom_commands.html#calling-custom-commands), which Nushell 0.112.1 added. + +`vp env setup` checks the `nu` on `PATH`. With an earlier Nushell, it writes `env.nu` without the sigil. That `env.nu` still works, but a custom command with the name of a built-in command can stop Nushell at startup with `nu::parser::extra_positional` or a similar parse error. After you upgrade Nushell, run `vp env setup` again. + #### `CI` - **Purpose**: Indicates running in CI environment From e928845b7739618916d3141e7e6f7d4404098cee Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:41:45 +0900 Subject: [PATCH 4/7] fix(env): stop the Nushell version check after a timeout `vp env setup` waited for `nu -c "version | get version"` without a limit. A `nu` that did not answer stopped `vp env setup`. Stop the check after 5 seconds, kill `nu`, and write the env.nu without the `%` sigil. Also cite the Nushell 0.112.1 release notes for the version that added the `%` sigil. --- crates/vp_global_cli/src/commands/env/setup.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 65ea92dae6..eaa6555444 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -46,9 +46,13 @@ enum EnvShell { const UTF8_BOM: char = '\u{feff}'; /// First Nushell release with the `%` sigil, which calls a built-in even when a -/// user command or alias has the same name. +/// user command or alias has the same name. See "No more shadows, call internals +/// with %" in . const NU_BUILTIN_SIGIL_VERSION: &str = "0.112.1"; +/// Maximum time to wait for `nu` to print its version. +const NU_VERSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + /// Nushell syntax that the generated `env.nu` uses. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum NuSyntax { @@ -71,7 +75,7 @@ impl NuSyntax { } /// Detect the syntax from `VP_NU_VERSION`, or else from the `nu` on `PATH`. - /// When `nu` is not installed, use `Legacy`. + /// When `nu` is not installed, fails, or does not answer in time, use `Legacy`. async fn detect(config: &vp_shared::EnvConfig) -> Self { if let Some(version) = &config.nu_version { return Self::for_version(version); @@ -85,9 +89,11 @@ impl NuSyntax { .args(["-c", "version | get version"]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()); - match command.output().await { - Ok(result) if result.status.success() => { + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + // A `nu` that does not answer must not stop `vp env setup`. + match tokio::time::timeout(NU_VERSION_TIMEOUT, command.output()).await { + Ok(Ok(result)) if result.status.success() => { Self::for_version(&String::from_utf8_lossy(&result.stdout)) } _ => Self::Legacy, From 4b1c88e27686d7374bc1da7c3f77ccf253f4be66 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:41:45 +0900 Subject: [PATCH 5/7] docs(env): explain Nushell version mismatch between setup and startup `vp env setup` selects the env.nu syntax from the `nu` on PATH. A Nushell earlier than 0.112.1 cannot parse an env.nu written for a later version, so tell users with more than one installation to use the same `nu`. --- docs/guide/global-cli.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index 55ba216be4..2ca33c3695 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -353,6 +353,8 @@ Use Nushell 0.112.1 or later. In Nushell, a custom command or alias can have the `vp env setup` checks the `nu` on `PATH`. With an earlier Nushell, it writes `env.nu` without the sigil. That `env.nu` still works, but a custom command with the name of a built-in command can stop Nushell at startup with `nu::parser::extra_positional` or a similar parse error. After you upgrade Nushell, run `vp env setup` again. +If you have more than one Nushell installation, make sure that the `nu` on `PATH` during `vp env setup` is the Nushell that you start. A Nushell earlier than 0.112.1 cannot parse an `env.nu` that `vp env setup` wrote for a later version. + #### `CI` - **Purpose**: Indicates running in CI environment From 8ba135b5c3ba7f0853f30be75fbc63b1995152b1 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:09:47 +0900 Subject: [PATCH 6/7] perf(env): read the Nushell version with `nu --version` `nu --version` prints the same bare version as `nu -c "version | get version"`, but it exits before the engine starts. Thus, the user's config, autoload files, and plugins cannot affect the check, and the check is faster. --- crates/vp_global_cli/src/commands/env/setup.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 809f2ef57f..c271d0de3a 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -83,10 +83,10 @@ impl NuSyntax { let Ok(cwd) = vt_path::current_dir() else { return Self::Legacy }; let Ok(nu) = vp_command::resolve_bin("nu", None, &cwd) else { return Self::Legacy }; let mut command = vp_command::build_command(&nu, &cwd); - // `-c` does not load the user's config or autoload files, so a broken - // env.nu or a user `def version` cannot affect the result. + // `--version` exits before the engine starts, so the user's config, + // autoload files, and plugins cannot affect the result. command - .args(["-c", "version | get version"]) + .arg("--version") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) From 3adf1146d020f36db6506ac430a584fdcc0d9a5c Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:26:19 +0900 Subject: [PATCH 7/7] ci: retrigger CI after macOS snapshot install timeouts