From 6713756db217076491013e82d86c13d64ef9de28 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 09:33:31 +0800 Subject: [PATCH 01/18] feat(env): add shell activation guidance and reminders --- .../command_env_activation/session.fish | 14 + .../command_env_activation/session.nu | 18 + .../command_env_activation/session.sh | 17 + .../command_env_activation/snapshots.toml | 41 +++ .../snapshots/activation_external_zsh.md | 154 +++++++++ .../snapshots/activation_fish.md | 61 ++++ .../snapshots/activation_legacy_bash.md | 61 ++++ .../snapshots/activation_managed_bash.md | 61 ++++ .../snapshots/activation_nushell.md | 61 ++++ .../snapshots/activation_xdg_bash.md | 61 ++++ .../command_env_activation/verify.mjs | 223 ++++++++++++ .../src/commands/env/activation.rs | 318 ++++++++++++++++++ crates/vp_global_cli/src/commands/env/mod.rs | 1 + .../vp_global_cli/src/commands/env/setup.rs | 36 +- crates/vp_global_cli/src/main.rs | 4 + crates/vp_global_cli/src/self_setup.rs | 13 + 16 files changed, 1119 insertions(+), 25 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs create mode 100644 crates/vp_global_cli/src/commands/env/activation.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish new file mode 100644 index 0000000000..e05cbf7034 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish @@ -0,0 +1,14 @@ +command -v node +node +$ACTIVATION_VP env list node; or exit 1 +eval $ACTIVATION_COMMAND; or exit 1 +test (command -v node) = "$ACTIVATION_BIN/node"; or exit 1 +node --version; or exit 1 +vp env list node; or exit 1 +set -gx PATH $ACTIVATION_SYSTEM $ACTIVATION_BIN +node +vp env list node; or exit 1 +eval $ACTIVATION_COMMAND; or exit 1 +test (command -v node) = "$ACTIVATION_BIN/node"; or exit 1 +node --version; or exit 1 +vp env list node; or exit 1 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu new file mode 100644 index 0000000000..7247c3193a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu @@ -0,0 +1,18 @@ +which node | first | get path | print +^node +^$env.ACTIVATION_VP env list node +__ACTIVATION_COMMAND__ +if (which node | first | get path) != ($env.ACTIVATION_BIN | path join node) { + error make {msg: 'node did not resolve through the shim'} +} +^node --version +vp env list node +$env.PATH = [$env.ACTIVATION_SYSTEM $env.ACTIVATION_BIN] +^node +vp env list node +__ACTIVATION_COMMAND__ +if (which node | first | get path) != ($env.ACTIVATION_BIN | path join node) { + error make {msg: 'node did not resolve through the shim'} +} +^node --version +vp env list node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh new file mode 100644 index 0000000000..c16fc8dab9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh @@ -0,0 +1,17 @@ +set -eu +command -v node +node +"$ACTIVATION_VP" env list node +eval "$ACTIVATION_COMMAND" +test "$(command -v node)" = "$ACTIVATION_BIN/node" +node --version +vp env list node +# Existing command lookup caches must be refreshed by sourcing the env file. +PATH="$ACTIVATION_SYSTEM:$ACTIVATION_BIN" +export PATH +node +vp env list node +eval "$ACTIVATION_COMMAND" +test "$(command -v node)" = "$ACTIVATION_BIN/node" +node --version +vp env list node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml new file mode 100644 index 0000000000..b2f637d638 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml @@ -0,0 +1,41 @@ +[[case]] +name = "activation_managed_bash" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [["node", "verify.mjs", "managed", "bash"]] + +[[case]] +name = "activation_external_zsh" +vp = "global" +skip-platforms = ["windows"] +requires = ["zsh"] +steps = [["node", "verify.mjs", "external", "zsh"]] + +[[case]] +name = "activation_xdg_bash" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [["node", "verify.mjs", "xdg", "bash"]] + +[[case]] +name = "activation_legacy_bash" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [["node", "verify.mjs", "legacy", "bash"]] + +[[case]] +name = "activation_fish" +vp = "global" +skip-platforms = ["windows"] +requires = ["fish"] +steps = [["node", "verify.mjs", "managed", "fish"]] + +[[case]] +name = "activation_nushell" +vp = "global" +skip-platforms = ["windows"] +requires = ["nu"] +steps = [["node", "verify.mjs", "managed", "nu"]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md new file mode 100644 index 0000000000..24b53328d1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md @@ -0,0 +1,154 @@ +# activation_external_zsh + +## `node verify.mjs external zsh` + +``` +First setup: + Activate Vite+ in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + If setup already updated your profile, you can open a new terminal instead. +Same terminal: +/activation/system/node +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Another unactivated terminal: +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Preferences, setup state, and installation ownership unchanged. +Equivalent shim directory on PATH: +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +JSON output: +{ + "node": [ + { + "version": "22.18.0", + "current": true, + "default": false + } + ] +} +Shell-evaluated output: +export VP_NODE_VERSION=22.18.0 +Using Node.js (resolved from 22.18.0) +CI with a TTY: +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Redirected stdout, stderr, and stdin: +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Direct node shim: + +Completion protocol: +list +list-remote +Installer capability protocol: +vite-plus-self-setup-v1 +Installer handoff: +Shell assignments only; no duplicate activation reminder. +Internal background protocol: +Quiet mode: +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +Failing eligible command keeps exit status: +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +error: invalid environment scope "invalid-scope"; expected node, pm, npm, pnpm, yarn, or bun +System-first mode: +VITE+ - The Unified Toolchain for the Web + +✓ Node.js and package-manager management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Unknown current shell gets labeled commands despite SHELL=fish: +Vite+ managed tools are not first on PATH. Activate in this terminal: + Bash/Zsh: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + Fish: source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" + Nushell: source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" + PowerShell: . '/activation/home space ''quote'' $cash `tick` "double" \slash/env.ps1' +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Missing environment file needs repair: +Vite+ shell environment files are missing. Run `vp env setup` to recreate them. +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Missing shim needs repair: +Vite+ managed tool shims are missing or unusable. Run `vp env setup --refresh` to repair them. +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md new file mode 100644 index 0000000000..1a63c60a16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md @@ -0,0 +1,61 @@ +# activation_fish + +## `node verify.mjs managed fish` + +``` +First setup: + Activate Vite+ in this terminal: + source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" + If setup already updated your profile, you can open a new terminal instead. +Same terminal: +/activation/system/node +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Another unactivated terminal: +Vite+ managed tools are not first on PATH. Activate in this terminal: + source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Preferences, setup state, and installation ownership unchanged. +Equivalent shim directory on PATH: +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md new file mode 100644 index 0000000000..6590a5904a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md @@ -0,0 +1,61 @@ +# activation_legacy_bash + +## `node verify.mjs legacy bash` + +``` +First setup: + Activate Vite+ in this terminal: + . "/activation/user/.vite-plus/env" + If setup already updated your profile, you can open a new terminal instead. +Same terminal: +/activation/system/node +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/user/.vite-plus/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/user/.vite-plus/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Another unactivated terminal: +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/user/.vite-plus/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Preferences, setup state, and installation ownership unchanged. +Equivalent shim directory on PATH: +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md new file mode 100644 index 0000000000..adc78633e1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md @@ -0,0 +1,61 @@ +# activation_managed_bash + +## `node verify.mjs managed bash` + +``` +First setup: + Activate Vite+ in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + If setup already updated your profile, you can open a new terminal instead. +Same terminal: +/activation/system/node +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Another unactivated terminal: +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Preferences, setup state, and installation ownership unchanged. +Equivalent shim directory on PATH: +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md new file mode 100644 index 0000000000..6872482015 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md @@ -0,0 +1,61 @@ +# activation_nushell + +## `node verify.mjs managed nu` + +``` +First setup: + Activate Vite+ in this terminal: + source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" + If setup already updated your profile, you can open a new terminal instead. +Same terminal: +/activation/system/node +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Another unactivated terminal: +Vite+ managed tools are not first on PATH. Activate in this terminal: + source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Preferences, setup state, and installation ownership unchanged. +Equivalent shim directory on PATH: +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md new file mode 100644 index 0000000000..b9d8891b5a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md @@ -0,0 +1,61 @@ +# activation_xdg_bash + +## `node verify.mjs xdg bash` + +``` +First setup: + Activate Vite+ in this terminal: + . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" + If setup already updated your profile, you can open a new terminal instead. +Same terminal: +/activation/system/node +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +system-node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. + +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Another unactivated terminal: +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Preferences, setup state, and installation ownership unchanged. +Equivalent shim directory on PATH: +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs new file mode 100644 index 0000000000..59ff4a38ce --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const [kind, shell] = process.argv.slice(2); +const root = path.resolve('activation'); +const binary = path.join(root, 'prefix/bin/vp'); +const source = path.join(process.env.VP_HOME, 'current/bin/vp'); +const version = '22.18.0'; +const seedNode = path.join(process.env.VP_HOME, 'js_runtime/node', version, 'bin/node'); +const special = 'space \'quote\' $cash `tick` "double" \\slash'; +const env = { ...process.env }; +for (const key of Object.keys(env)) { + if (key.startsWith('VP_') || key.startsWith('XDG_') || key === 'CI') delete env[key]; +} +Object.assign(env, { + HOME: path.join(root, 'user'), + VP_CLI_TEST: '1', + VP_NO_UPDATE_CHECK: '1', + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + VP_NODE_MANAGER: 'yes', + VP_PM_MANAGER: 'no', + VP_SHELL: shell, + SHELL: '/login-shell-is-not-the-current-shell/fish', + NPM_CONFIG_REGISTRY: 'http://127.0.0.1:9', +}); +fs.mkdirSync(path.dirname(binary), { recursive: true }); +fs.mkdirSync(env.HOME, { recursive: true }); +fs.copyFileSync(source, binary); +fs.chmodSync(binary, 0o555); +if (kind === 'external' || kind === 'xdg') { + const pkg = path.join(root, 'prefix/node_modules/vite-plus'); + fs.mkdirSync(path.join(pkg, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(pkg, 'package.json'), '{"name":"vite-plus"}'); + fs.writeFileSync(path.join(pkg, 'dist/bin.js'), '// External bundle'); + fs.writeFileSync(path.join(root, 'prefix/INSTALL_RECEIPT.json'), '{"homebrew_version":"test"}'); +} else { + env.VP_SKIP_DEPS_INSTALL = '1'; + env.VP_VERSION = 'activation-test'; +} +if (kind === 'xdg') { + env.XDG_CONFIG_HOME = path.join(root, 'config ' + special); + env.XDG_STATE_HOME = path.join(root, 'state'); + env.VP_BIN_DIR = path.join(root, 'bin ' + special); + env.VP_DATA_DIR = path.join(root, 'data'); + env.VP_CACHE_DIR = path.join(root, 'cache'); +} else if (kind === 'legacy') { + const legacy = path.join(env.HOME, '.vite-plus'); + fs.mkdirSync(legacy, { recursive: true }); + fs.symlinkSync('activation-test', path.join(legacy, 'current')); +} else { + env.VP_HOME = path.join(root, 'home ' + special); +} +function captured(args, extra = {}) { + const result = spawnSync(binary, args, { + env: { ...env, ...extra }, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + return result; +} +const dirs = Object.fromEntries( + captured([], { VP_DUMP_DIRS: '1' }) + .stdout.trim() + .split('\n') + .map((line) => line.split('\t')), +); +// Query the authoritative resolver instead of assuming a platform's directory layout. +const bin = dirs.bin; +const config = dirs.config; +const data = dirs.data; +assert.ok(bin && config && data, JSON.stringify(dirs)); +const runtime = path.join(data, 'js_runtime/node', version, 'bin'); +fs.mkdirSync(runtime, { recursive: true }); +fs.symlinkSync(seedNode, path.join(runtime, 'node')); +fs.writeFileSync('.node-version', version); +const system = path.join(root, 'system'); +fs.mkdirSync(system); +fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); +env.PATH = system; +const first = captured(['env', 'list', 'node']); +const setup = first.stderr.replace(/\u001b\[[0-9;]*m/g, ''); +assert.match(setup, /Vite\+ setup complete/); +assert.doesNotMatch(setup, /not first on PATH/); // no duplicate reminder during handoff +const activation = setup + .split('\n') + .find((line) => line.trim().startsWith(shell === 'fish' || shell === 'nu' ? 'source "' : '. "')) + ?.trim(); +assert.ok(activation, setup); +console.log('First setup:'); +console.log( + setup + .split('\n') + .filter((line) => /Activate Vite\+|^ \. |^ source |new terminal/.test(line)) + .join('\n'), +); +const settingsFile = path.join(config, 'config.json'); +const settings = fs.readFileSync(settingsFile, 'utf8'); +const saved = JSON.parse(settings); +assert.equal(saved.nodeShimMode ?? 'managed', 'managed'); +assert.deepEqual(Object.values(saved.packageManagerShimModes), Array(4).fill('system_first')); +const setupState = path.join(dirs.state, 'self-setup'); +const receipts = fs + .readdirSync(setupState) + .map((name) => [name, fs.readFileSync(path.join(setupState, name), 'utf8')]); +const prefixFiles = fs.readdirSync(path.dirname(binary)); + +// Resolve the shell using the runner PATH before switching to the stale PATH. +const shellBin = process.env.PATH.split(path.delimiter) + .map((dir) => path.join(dir, shell)) + .find((file) => fs.existsSync(file)); +assert.ok(shellBin, shell); +function terminal(args, extra = {}, status = 0, executable = binary, stdio = 'inherit') { + const result = spawnSync(executable, args, { + env: { ...env, ...extra }, + stdio, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, status, result.error?.message); + return result; +} +Object.assign(env, { + ACTIVATION_VP: binary, + ACTIVATION_BIN: bin, + ACTIVATION_COMMAND: activation, + ACTIVATION_SYSTEM: system, +}); +console.log('Same terminal:'); +if (shell === 'fish') { + terminal(['--no-config', 'session.fish'], {}, 0, shellBin); +} else if (shell === 'nu') { + fs.writeFileSync( + 'activate.nu', + fs.readFileSync('session.nu', 'utf8').replaceAll('__ACTIVATION_COMMAND__', activation), + ); + terminal(['--no-config-file', 'activate.nu'], {}, 0, shellBin); +} else { + terminal( + [ + shell === 'bash' ? '--noprofile' : '-f', + ...(shell === 'bash' ? ['--norc'] : []), + 'session.sh', + ], + {}, + 0, + shellBin, + ); +} +console.log('Another unactivated terminal:'); +terminal(['env', 'list', 'node']); +assert.equal(fs.readFileSync(settingsFile, 'utf8'), settings); +for (const [name, receipt] of receipts) + assert.equal(fs.readFileSync(path.join(setupState, name), 'utf8'), receipt); +assert.deepEqual(fs.readdirSync(path.dirname(binary)), prefixFiles); +assert.ok(!fs.existsSync(path.join(root, 'prefix/bin/.vp-setup-complete'))); +if (kind === 'external' || kind === 'xdg') { + assert.ok(!fs.existsSync(path.join(data, 'current'))); + assert.equal(fs.realpathSync(path.join(bin, 'node')), fs.realpathSync(binary)); +} else { + assert.ok(fs.existsSync(path.join(data, 'current/bin/.vp-setup-complete'))); +} +console.log('Preferences, setup state, and installation ownership unchanged.'); +fs.symlinkSync(bin, path.join(root, 'bin-alias')); +console.log('Equivalent shim directory on PATH:'); +terminal(['env', 'list', 'node'], { PATH: path.join(root, 'bin-alias') + path.delimiter + system }); + +if (kind === 'external') { + console.log('JSON output:'); + terminal(['env', 'list', 'node', '--json']); + console.log('Shell-evaluated output:'); + terminal(['env', 'use', version, '--no-install'], { VP_ENV_USE_EVAL_ENABLE: '1' }); + console.log('CI with a TTY:'); + terminal(['env', 'list', 'node'], { CI: '1' }); + console.log('Redirected stdout, stderr, and stdin:'); + for (const stdio of [ + ['inherit', 'pipe', 'inherit'], + ['inherit', 'inherit', 'pipe'], + ['pipe', 'inherit', 'inherit'], + ]) { + const result = terminal(['env', 'list', 'node'], {}, 0, binary, stdio); + if (result.stderr !== null) assert.doesNotMatch(result.stderr, /Activate|not first on PATH/); + if (result.stdout !== null) assert.doesNotMatch(result.stdout, /Activate|not first on PATH/); + } + console.log('Direct node shim:'); + terminal(['--version'], {}, 0, path.join(bin, 'node')); + console.log('Completion protocol:'); + terminal(['--', 'vp', 'env', 'li'], { VP_COMPLETE: 'bash', _CLAP_COMPLETE_INDEX: '2' }); + console.log('\nInstaller capability protocol:'); + terminal([], { VP_SELF_SETUP_SUPPORT_CHECK: '1' }); + console.log('Installer handoff:'); + const handoff = captured([], { VP_SELF_SETUP_SHELL: 'sh' }); + assert.doesNotMatch(handoff.stderr, /not first on PATH/); + assert.ok( + handoff.stdout + .trim() + .split('\n') + .every((line) => /^(INSTALL|SHIM|CACHE|CONFIG|STATE)_DIR=/.test(line)), + ); + console.log('Shell assignments only; no duplicate activation reminder.'); + console.log('Internal background protocol:'); + terminal(['upgrade', '--background-check']); + console.log('Quiet mode:'); + terminal(['upgrade', '--silent'], {}, 1); + console.log('Failing eligible command keeps exit status:'); + terminal(['env', 'clean', 'invalid-scope'], {}, 1); + console.log('System-first mode:'); + terminal(['env', 'off']); + terminal(['env', 'list', 'node']); + fs.writeFileSync(settingsFile, settings); + console.log('Unknown current shell gets labeled commands despite SHELL=fish:'); + terminal(['env', 'list', 'node'], { VP_SHELL: '' }); + console.log('Missing environment file needs repair:'); + const envFile = path.join(config, 'env'); + fs.renameSync(envFile, envFile + '.saved'); + terminal(['env', 'list', 'node']); + fs.renameSync(envFile + '.saved', envFile); + console.log('Missing shim needs repair:'); + fs.unlinkSync(path.join(bin, 'node')); + terminal(['env', 'list', 'node']); +} diff --git a/crates/vp_global_cli/src/commands/env/activation.rs b/crates/vp_global_cli/src/commands/env/activation.rs new file mode 100644 index 0000000000..de47e9a9d7 --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/activation.rs @@ -0,0 +1,318 @@ +//! Read-only activation checks for the environment inherited by this invocation. + +use std::{ffi::OsStr, path::Path}; + +use vp_pm_cli::PackageManagerType; +use vp_shared::{EnvConfig, output}; +use vt_path::AbsolutePath; + +use super::{ + config::{self, Config, ShimMode}, + setup, +}; +use crate::{ + cli::{Args, Commands, EnvSubcommands}, + commands::shell::Shell, +}; + +#[derive(Debug, PartialEq, Eq)] +enum Activation { + Active, + Inactive, + NeedsSetup, +} + +/// Compare the entrypoint's directory, not its symlink target: all Unix shims +/// can point at the same vp binary, including links outside the user's bin dir. +fn is_shim_entrypoint(actual: &Path, expected: &Path) -> bool { + let same_name = if cfg!(windows) { + actual + .file_name() + .zip(expected.file_name()) + .is_some_and(|(a, b)| a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy())) + } else { + actual.file_name() == expected.file_name() + }; + same_name + && actual + .parent() + .zip(expected.parent()) + .is_some_and(|(a, b)| same_file::is_same_file(a, b).unwrap_or(false)) +} + +fn check(config: &Config, bin: &AbsolutePath, cwd: &AbsolutePath, path: &OsStr) -> Activation { + let mut inactive = false; + for tool in crate::shim::DEFAULT_SHIM_TOOLS { + let mode = if *tool == "node" { + config.node_shim_mode + } else if let Some(pm) = PackageManagerType::from_tool(tool) { + config.package_manager_shim_mode_for(pm) + } else { + continue; + }; + if mode == ShimMode::SystemFirst { + continue; + } + let expected = bin.join(setup::shim_filename(tool)); + // A deleted/broken/non-executable shim cannot be repaired by sourcing env. + if vp_command::resolve_bin(tool, Some(bin.as_path().as_os_str()), cwd) + .ok() + .is_none_or(|found| !is_shim_entrypoint(found.as_path(), expected.as_path())) + { + return Activation::NeedsSetup; + } + if vp_command::resolve_bin(tool, Some(path), cwd) + .ok() + .is_none_or(|found| !is_shim_entrypoint(found.as_path(), expected.as_path())) + { + inactive = true; + } + } + if inactive { Activation::Inactive } else { Activation::Active } +} + +fn eligible(args: &Args, raw: &[String]) -> bool { + if args.version + || raw.iter().any(|arg| { + matches!( + arg.as_str(), + "--quiet" + | "-q" + | "--silent" + | "-s" + | "--json" + | "--parseable" + | "--help" + | "-h" + | "--format" + | "--reporter" + | "--reporters" + | "--logLevel" + ) || [ + "--quiet=", + "--silent=", + "--json=", + "--format=", + "--reporter=", + "--reporters=", + "--logLevel=", + ] + .iter() + .any(|prefix| arg.starts_with(prefix)) + }) + { + return false; + } + match &args.command { + Some(command) if command.is_quiet_or_machine_readable() => false, + Some( + Commands::Upgrade { background_check: true, .. } + | Commands::Implode { .. } + | Commands::Hooks { .. } + | Commands::Staged { .. }, + ) => false, + Some(Commands::Env(args)) => !matches!( + &args.command, + Some( + EnvSubcommands::Setup { .. } + | EnvSubcommands::Doctor { .. } + | EnvSubcommands::Use { .. } + | EnvSubcommands::Print { .. } + | EnvSubcommands::Which { .. } + | EnvSubcommands::Exec { .. } + | EnvSubcommands::Off { .. } + ) + ), + _ => true, + } +} + +pub(crate) async fn remind(args: &Args, raw: &[String], cwd: &AbsolutePath) { + if !vp_shared::is_interactive_terminal() + || !vp_shared::is_stderr_terminal() + || !eligible(args, raw) + { + return; + } + let Ok(config) = config::load_config().await else { return }; + let env = EnvConfig::get(); + let path = std::env::var_os("PATH").unwrap_or_default(); + match check(&config, &env.dirs.bin, cwd, &path) { + Activation::Active => {} + Activation::NeedsSetup => output::raw_stderr( + "Vite+ managed tool shims are missing or unusable. Run `vp env setup --refresh` to repair them.", + ), + Activation::Inactive => { + if ["env", "env.fish", "env.nu", "env.ps1"] + .iter() + .any(|file| !env.dirs.config.join(file).as_path().is_file()) + { + output::raw_stderr( + "Vite+ shell environment files are missing. Run `vp env setup` to recreate them.", + ); + return; + } + output::raw_stderr( + "Vite+ managed tools are not first on PATH. Activate in this terminal:", + ); + for line in + instructions(&env.dirs.config, env.vp_shell.as_deref().and_then(|s| s.parse().ok())) + { + output::raw_stderr(&line); + } + } + } +} + +/// Use an explicit VP_SHELL when available. Otherwise label the alternatives: +/// SHELL describes the login shell and need not describe this terminal. +pub(crate) fn instructions(env_dir: &AbsolutePath, shell: Option) -> Vec { + let commands = [ + (Shell::Posix, "Bash/Zsh", "env"), + (Shell::Fish, "Fish", "env.fish"), + (Shell::NuShell, "Nushell", "env.nu"), + (Shell::PowerShell, "PowerShell", "env.ps1"), + ]; + if shell == Some(Shell::Cmd) { + // cmd has no sourceable environment file. A new terminal inherits the + // persistent PATH written by setup. + return vec![" In cmd.exe, open a new terminal to load the updated PATH.".into()]; + } + commands + .into_iter() + .filter(|(kind, _, _)| shell.is_none_or(|s| s == *kind)) + .map(|(kind, label, file)| { + let path = env_dir.join(file).to_string(); + let command = match kind { + Shell::Posix => { + format!(". \"{}\"", setup::escape_posix_double_quoted_string(&path)) + } + Shell::Fish => { + format!("source \"{}\"", setup::escape_fish_double_quoted_string(&path)) + } + Shell::NuShell => { + format!("source \"{}\"", setup::escape_nu_double_quoted_string(&path)) + } + Shell::PowerShell => { + format!(". '{}'", setup::escape_powershell_single_quoted_string(&path)) + } + Shell::Cmd => unreachable!(), + }; + if shell.is_some() { format!(" {command}") } else { format!(" {label}: {command}") } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use vt_path::AbsolutePathBuf; + + use super::*; + + fn executable(dir: &AbsolutePath, tool: &str) { + let path = dir.join(setup::shim_filename(tool)); + std::fs::write(&path, b"fake executable").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + + #[test] + fn checks_lookup_order_and_only_managed_tools() { + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let bin = cwd.join("shims"); + let system = cwd.join("system"); + std::fs::create_dir(&bin).unwrap(); + std::fs::create_dir(&system).unwrap(); + let mut config = Config::default(); + config.set_all_package_manager_shim_modes(ShimMode::SystemFirst); + executable(&bin, "node"); + executable(&system, "node"); + let active = std::env::join_paths([bin.as_path(), system.as_path()]).unwrap(); + let shadowed = std::env::join_paths([system.as_path(), bin.as_path()]).unwrap(); + assert_eq!(check(&config, &bin, &cwd, &active), Activation::Active); + assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::Inactive); + assert_eq!(check(&config, &bin, &cwd, system.as_path().as_os_str()), Activation::Inactive); + config.node_shim_mode = ShimMode::SystemFirst; + assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::Active); + config.set_package_manager_shim_mode(PackageManagerType::Npm, ShimMode::Managed); + assert_eq!(check(&config, &bin, &cwd, &active), Activation::NeedsSetup); + executable(&bin, "npm"); + executable(&bin, "npx"); + assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::Active); + executable(&system, "npx"); + assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::Inactive); + std::fs::remove_file(bin.join(setup::shim_filename("npm"))).unwrap(); + assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::NeedsSetup); + } + + #[cfg(unix)] + #[test] + fn directory_aliases_are_active_but_other_links_to_vp_are_not() { + use std::os::unix::fs::symlink; + let temp = tempfile::tempdir().unwrap(); + let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let bin = cwd.join("shims"); + let external = cwd.join("brew"); + std::fs::create_dir(&bin).unwrap(); + std::fs::create_dir(&external).unwrap(); + executable(&external, "vp"); + symlink(external.join("vp"), bin.join("node")).unwrap(); + symlink(external.join("vp"), external.join("node")).unwrap(); + let alias = cwd.join("alias"); + symlink(&bin, &alias).unwrap(); + let mut config = Config::default(); + config.set_all_package_manager_shim_modes(ShimMode::SystemFirst); + for path in [alias.as_path().as_os_str(), OsStr::new("shims/../shims")] { + assert_eq!(check(&config, &bin, &cwd, path), Activation::Active); + } + assert_eq!( + check(&config, &bin, &cwd, external.as_path().as_os_str()), + Activation::Inactive + ); + std::fs::remove_file(external.join("vp")).unwrap(); + assert_eq!(check(&config, &bin, &cwd, bin.as_path().as_os_str()), Activation::NeedsSetup); + } + + #[test] + fn excludes_output_protocols_and_quiet_commands() { + for raw in [ + vec!["env", "current", "--json"], + vec!["env", "list", "--json"], + vec!["env", "print"], + vec!["env", "which", "node"], + vec!["env", "use", "22", "--no-install"], + vec!["env", "exec", "node", "-v"], + vec!["env", "doctor"], + vec!["env", "setup"], + vec!["env", "off"], + vec!["upgrade", "--background-check"], + vec!["upgrade", "--silent"], + vec!["install", "--silent"], + vec!["toolchain", "--json"], + vec!["sync-versions", "--json"], + vec!["build", "--quiet"], + vec!["test", "--reporter=json"], + vec!["run", "--silent", "build"], + vec!["--version"], + ] { + let strings: Vec<_> = raw.iter().map(|s| (*s).to_string()).collect(); + let args = crate::try_parse_args_from( + std::iter::once("vp".to_string()).chain(strings.clone()), + ) + .unwrap(); + assert!(!eligible(&args, &strings), "{raw:?}"); + } + for raw in [vec!["build"], vec!["env", "current"], vec!["env", "list"], vec!["install"]] { + let strings: Vec<_> = raw.iter().map(|s| (*s).to_string()).collect(); + let args = crate::try_parse_args_from( + std::iter::once("vp".to_string()).chain(strings.clone()), + ) + .unwrap(); + assert!(eligible(&args, &strings), "{raw:?}"); + } + } +} diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 3f568cc799..bc549491b3 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -3,6 +3,7 @@ //! This module provides the `vp env` command for managing Node.js environments //! through shim-based version management. +pub(crate) mod activation; pub mod bin_config; mod clean; pub mod config; diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index afc83b1a71..06d6ebdec8 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1193,32 +1193,18 @@ async fn create_env_files() -> Result<(), Error> { /// Print instructions for sourcing the environment files and adding bin to `PATH`. fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { - // Use paths relative to $HOME. POSIX and Fish use $HOME. Nushell cannot - // expand $HOME in the parse-time `source` keyword, so use ~. - let env_path = env_dir.as_path().display().to_string(); - let home = vp_shared::EnvConfig::get().user_home.as_path().display().to_string(); - let (env_path, nu_env_path) = if let Some(suffix) = env_path.strip_prefix(&home) { - (format!("$HOME{suffix}"), format!("~{suffix}")) - } else { - (env_path.clone(), env_path) - }; - output::raw(&help::render_heading("Next Steps")); - output::raw(" Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):"); - output::raw(""); - output::raw(&format!(" . \"{env_path}/env\"")); - output::raw(""); - output::raw(" For fish shell, add to ~/.config/fish/config.fish:"); - output::raw(""); - output::raw(&format!(" source \"{env_path}/env.fish\"")); - output::raw(""); - output::raw(" For Nushell, add to ~/.config/nushell/config.nu:"); - output::raw(""); - output::raw(&format!(" source '{nu_env_path}/env.nu'")); - output::raw(""); - output::raw(" For PowerShell, add to your $PROFILE:"); + output::raw(" Activate Vite+ in this terminal:"); + let env = vp_shared::EnvConfig::get(); + for line in super::activation::instructions( + env_dir, + env.vp_shell.as_deref().and_then(|s| s.parse().ok()), + ) { + output::raw(&line); + } output::raw(""); - output::raw(&format!(" . \"{env_path}/env.ps1\"")); + output::raw(" Add the command for your shell to its profile to activate future terminals."); + output::raw(" If setup already updated your profile, you can open a new terminal instead."); output::raw(""); output::raw(" For IDE support (VS Code, Cursor), ensure bin directory is in system PATH:"); @@ -1239,7 +1225,7 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { output::raw(""); output::raw(&format!( - " Restart your terminal and IDE, then run {} to verify.", + " Restart an already-running IDE to load its environment. Run {} to verify.", help::accent_command("vp env doctor") )); } diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index 4bcae58dea..c9ebf87a11 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -495,6 +495,10 @@ async fn main() -> ExitCode { // Parse CLI arguments (using custom help formatting) let parse_result = try_parse_args_from(normalized_args); + if let Ok(parsed) = &parse_result { + commands::env::activation::remind(parsed, &raw_args, &cwd).await; + } + let should_run_upgrade_check = parse_result.as_ref().is_ok_and(upgrade_check::should_run_for_command); let spawned_upgrade_check = diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index fe65130603..c48bed65fc 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -90,6 +90,19 @@ fn execute_installed( // Re-enter through the marked installation, inheriting cwd, environment and stdio. let mut command = std::process::Command::new(binary.as_path()); command.args(args); + if just_installed { + // Setup already printed activation guidance. Activate only the re-executed + // command's PATH; the parent terminal still needs to source its env file. + // This also avoids repeating guidance during the setup handoff, without + // persisting a suppression flag that could hide it in another terminal. + let path = std::env::var_os("PATH").unwrap_or_default(); + let bin = EnvConfig::get().dirs.bin.as_path().to_path_buf(); + let paths = std::iter::once(bin).chain(std::env::split_paths(&path)); + command.env( + "PATH", + std::env::join_paths(paths).map_err(|error| Error::Other(error.to_string().into()))?, + ); + } #[cfg(unix)] { use std::os::unix::process::CommandExt; From e179b6e84ff36143b0f4d37261ac4b86f8eb34ae Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 10:00:52 +0800 Subject: [PATCH 02/18] fix(env): suppress activation reminders in delegated commands --- .../command_env_activation/session.sh | 17 ++ .../command_env_activation/snapshots.toml | 10 +- .../snapshots/activation_external_zsh.md | 105 ++++++- .../activation_external_zsh_linux.md | 257 ++++++++++++++++++ .../command_env_activation/verify.mjs | 92 ++++--- .../src/commands/env/activation.rs | 9 +- 6 files changed, 452 insertions(+), 38 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh index c16fc8dab9..c3eed48412 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh @@ -1,17 +1,34 @@ set -eu +show() { + if [ -n "${ACTIVATION_SHOW_COMMANDS-}" ]; then + printf '$ %s\n' "$1" + fi +} +show 'command -v node' command -v node +show node node +show 'vp env list node' "$ACTIVATION_VP" env list node +show "$ACTIVATION_COMMAND" eval "$ACTIVATION_COMMAND" test "$(command -v node)" = "$ACTIVATION_BIN/node" +show 'node --version' node --version +show 'vp env list node' vp env list node # Existing command lookup caches must be refreshed by sourcing the env file. +show 'export PATH="$ACTIVATION_SYSTEM:$ACTIVATION_BIN"' PATH="$ACTIVATION_SYSTEM:$ACTIVATION_BIN" export PATH +show node node +show 'vp env list node' vp env list node +show "$ACTIVATION_COMMAND" eval "$ACTIVATION_COMMAND" test "$(command -v node)" = "$ACTIVATION_BIN/node" +show 'node --version' node --version +show 'vp env list node' vp env list node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml index b2f637d638..b5aec0c6d0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml @@ -8,7 +8,7 @@ steps = [["node", "verify.mjs", "managed", "bash"]] [[case]] name = "activation_external_zsh" vp = "global" -skip-platforms = ["windows"] +skip-platforms = ["windows", "linux"] requires = ["zsh"] steps = [["node", "verify.mjs", "external", "zsh"]] @@ -39,3 +39,11 @@ vp = "global" skip-platforms = ["windows"] requires = ["nu"] steps = [["node", "verify.mjs", "managed", "nu"]] + +# Setup includes platform-specific IDE instructions; keep both full transcripts. +[[case]] +name = "activation_external_zsh_linux" +vp = "global" +skip-platforms = ["windows", "macos"] +requires = ["zsh"] +steps = [["node", "verify.mjs", "external", "zsh"]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md index 24b53328d1..15f6f76e70 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md @@ -4,12 +4,48 @@ ``` First setup: +$ vp env list node +Setup: + Preparing vite-plus environment. + +Created Shims: + /activation/home space 'quote' $cash `tick` "double" \slash/bin/node + /activation/home space 'quote' $cash `tick` "double" \slash/bin/npm + /activation/home space 'quote' $cash `tick` "double" \slash/bin/npx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpm + /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarn + /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarnpkg + /activation/home space 'quote' $cash `tick` "double" \slash/bin/bun + /activation/home space 'quote' $cash `tick` "double" \slash/bin/bunx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpr + +Next Steps: Activate Vite+ in this terminal: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + + Add the command for your shell to its profile to activate future terminals. If setup already updated your profile, you can open a new terminal instead. + + For IDE support (VS Code, Cursor), ensure bin directory is in system PATH: + - macOS: Add to ~/.profile or use launchd + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Same terminal: +$ zsh -f session.sh +$ command -v node /activation/system/node +$ node system-node +$ vp env list node Vite+ managed tools are not first on PATH. Activate in this terminal: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" VITE+ - The Unified Toolchain for the Web @@ -18,14 +54,20 @@ Node.js * current note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +$ node --version +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js * current note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ export PATH="$ACTIVATION_SYSTEM:$ACTIVATION_BIN" +$ node system-node +$ vp env list node Vite+ managed tools are not first on PATH. Activate in this terminal: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" VITE+ - The Unified Toolchain for the Web @@ -34,7 +76,10 @@ Node.js * current note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +$ node --version +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js @@ -42,6 +87,7 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Another unactivated terminal: +$ vp env list node Vite+ managed tools are not first on PATH. Activate in this terminal: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" VITE+ - The Unified Toolchain for the Web @@ -52,6 +98,7 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Preferences, setup state, and installation ownership unchanged. Equivalent shim directory on PATH: +$ PATH='/activation/bin-alias:/activation/system' vp env list node VITE+ - The Unified Toolchain for the Web Node.js @@ -59,6 +106,7 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. JSON output: +$ vp env list node --json { "node": [ { @@ -69,9 +117,11 @@ JSON output: ] } Shell-evaluated output: +$ VP_ENV_USE_EVAL_ENABLE=1 vp env use 22.18.0 --no-install export VP_NODE_VERSION=22.18.0 Using Node.js (resolved from 22.18.0) CI with a TTY: +$ CI=1 vp env list node VITE+ - The Unified Toolchain for the Web Node.js @@ -79,12 +129,21 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Redirected stdout, stderr, and stdin: +$ vp env list node note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Redirected stdout: +Node.js + * current + +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js * current +Redirected stderr: +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js @@ -92,24 +151,64 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Direct node shim: +$ node --version Completion protocol: +$ VP_COMPLETE=bash _CLAP_COMPLETE_INDEX=2 vp -- vp env li list list-remote Installer capability protocol: +$ VP_SELF_SETUP_SUPPORT_CHECK=1 vp vite-plus-self-setup-v1 Installer handoff: -Shell assignments only; no duplicate activation reminder. +$ VP_SELF_SETUP_SHELL=sh vp +Setup: + Preparing vite-plus environment. + +Created Shims: + /activation/home space 'quote' $cash `tick` "double" \slash/bin/node + /activation/home space 'quote' $cash `tick` "double" \slash/bin/npm + /activation/home space 'quote' $cash `tick` "double" \slash/bin/npx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpm + /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarn + /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarnpkg + /activation/home space 'quote' $cash `tick` "double" \slash/bin/bun + /activation/home space 'quote' $cash `tick` "double" \slash/bin/bunx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + + Add the command for your shell to its profile to activate future terminals. + If setup already updated your profile, you can open a new terminal instead. + + For IDE support (VS Code, Cursor), ensure bin directory is in system PATH: + - macOS: Add to ~/.profile or use launchd + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +INSTALL_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" +SHIM_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/bin" +CACHE_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/cache" +CONFIG_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" +STATE_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" Internal background protocol: +$ vp upgrade --background-check Quiet mode: +$ vp upgrade --silent error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. Failing eligible command keeps exit status: +$ vp env clean invalid-scope Vite+ managed tools are not first on PATH. Activate in this terminal: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" VITE+ - The Unified Toolchain for the Web error: invalid environment scope "invalid-scope"; expected node, pm, npm, pnpm, yarn, or bun System-first mode: +$ vp env off VITE+ - The Unified Toolchain for the Web ✓ Node.js and package-manager management set to system-first. @@ -117,6 +216,7 @@ VITE+ - The Unified Toolchain for the Web Selected commands and shims will now prefer system tools, falling back to managed tools. Run `vp env on` to always use Vite+ managed tools. +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js @@ -124,6 +224,7 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Unknown current shell gets labeled commands despite SHELL=fish: +$ VP_SHELL='' vp env list node Vite+ managed tools are not first on PATH. Activate in this terminal: Bash/Zsh: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" Fish: source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" @@ -136,6 +237,7 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Missing environment file needs repair: +$ vp env list node Vite+ shell environment files are missing. Run `vp env setup` to recreate them. VITE+ - The Unified Toolchain for the Web @@ -144,6 +246,7 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Missing shim needs repair: +$ vp env list node Vite+ managed tool shims are missing or unusable. Run `vp env setup --refresh` to repair them. VITE+ - The Unified Toolchain for the Web diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md new file mode 100644 index 0000000000..0f5812e3e3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md @@ -0,0 +1,257 @@ +# activation_external_zsh_linux + +## `node verify.mjs external zsh` + +``` +First setup: +$ vp env list node +Setup: + Preparing vite-plus environment. + +Created Shims: + /activation/home space 'quote' $cash `tick` "double" \slash/bin/node + /activation/home space 'quote' $cash `tick` "double" \slash/bin/npm + /activation/home space 'quote' $cash `tick` "double" \slash/bin/npx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpm + /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarn + /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarnpkg + /activation/home space 'quote' $cash `tick` "double" \slash/bin/bun + /activation/home space 'quote' $cash `tick` "double" \slash/bin/bunx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + + Add the command for your shell to its profile to activate future terminals. + If setup already updated your profile, you can open a new terminal instead. + + For IDE support (VS Code, Cursor), ensure bin directory is in system PATH: + - Linux: Add to ~/.profile for display manager integration + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Same terminal: +$ zsh -f session.sh +$ command -v node +/activation/system/node +$ node +system-node +$ vp env list node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +$ node --version + +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ export PATH="$ACTIVATION_SYSTEM:$ACTIVATION_BIN" +$ node +system-node +$ vp env list node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +$ node --version + +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Another unactivated terminal: +$ vp env list node +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Preferences, setup state, and installation ownership unchanged. +Equivalent shim directory on PATH: +$ PATH='/activation/bin-alias:/activation/system' vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +JSON output: +$ vp env list node --json +{ + "node": [ + { + "version": "22.18.0", + "current": true, + "default": false + } + ] +} +Shell-evaluated output: +$ VP_ENV_USE_EVAL_ENABLE=1 vp env use 22.18.0 --no-install +export VP_NODE_VERSION=22.18.0 +Using Node.js (resolved from 22.18.0) +CI with a TTY: +$ CI=1 vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Redirected stdout, stderr, and stdin: +$ vp env list node +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Redirected stdout: +Node.js + * current + +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +Redirected stderr: +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Direct node shim: +$ node --version + +Completion protocol: +$ VP_COMPLETE=bash _CLAP_COMPLETE_INDEX=2 vp -- vp env li +list +list-remote +Installer capability protocol: +$ VP_SELF_SETUP_SUPPORT_CHECK=1 vp +vite-plus-self-setup-v1 +Installer handoff: +$ VP_SELF_SETUP_SHELL=sh vp +Setup: + Preparing vite-plus environment. + +Created Shims: + /activation/home space 'quote' $cash `tick` "double" \slash/bin/node + /activation/home space 'quote' $cash `tick` "double" \slash/bin/npm + /activation/home space 'quote' $cash `tick` "double" \slash/bin/npx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpm + /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarn + /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarnpkg + /activation/home space 'quote' $cash `tick` "double" \slash/bin/bun + /activation/home space 'quote' $cash `tick` "double" \slash/bin/bunx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpx + /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + + Add the command for your shell to its profile to activate future terminals. + If setup already updated your profile, you can open a new terminal instead. + + For IDE support (VS Code, Cursor), ensure bin directory is in system PATH: + - Linux: Add to ~/.profile for display manager integration + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +INSTALL_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" +SHIM_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/bin" +CACHE_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/cache" +CONFIG_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" +STATE_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" +Internal background protocol: +$ vp upgrade --background-check +Quiet mode: +$ vp upgrade --silent +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +Failing eligible command keeps exit status: +$ vp env clean invalid-scope +Vite+ managed tools are not first on PATH. Activate in this terminal: + . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +VITE+ - The Unified Toolchain for the Web + +error: invalid environment scope "invalid-scope"; expected node, pm, npm, pnpm, yarn, or bun +System-first mode: +$ vp env off +VITE+ - The Unified Toolchain for the Web + +✓ Node.js and package-manager management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +$ vp env list node +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Unknown current shell gets labeled commands despite SHELL=fish: +$ VP_SHELL='' vp env list node +Vite+ managed tools are not first on PATH. Activate in this terminal: + Bash/Zsh: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + Fish: source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" + Nushell: source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" + PowerShell: . '/activation/home space ''quote'' $cash `tick` "double" \slash/env.ps1' +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Missing environment file needs repair: +$ vp env list node +Vite+ shell environment files are missing. Run `vp env setup` to recreate them. +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Missing shim needs repair: +$ vp env list node +Vite+ managed tool shims are missing or unusable. Run `vp env setup --refresh` to repair them. +VITE+ - The Unified Toolchain for the Web + +Node.js + * current + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs index 59ff4a38ce..9d4444f850 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs @@ -61,6 +61,28 @@ function captured(args, extra = {}) { assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); return result; } +function terminal(args, extra = {}, status = 0, executable = binary, stdio = 'inherit') { + if (kind === 'external') { + const quote = (value) => + /^[\w./=-]+$/.test(value) ? value : "'" + value.replaceAll("'", "'\\''") + "'"; + const command = + executable === binary + ? 'vp' + : executable === path.join(bin, 'node') + ? 'node' + : path.basename(executable); + const overrides = Object.entries(extra).map(([key, value]) => `${key}=${quote(value)}`); + console.log('$ ' + [...overrides, command, ...args.map(quote)].join(' ')); + } + const result = spawnSync(executable, args, { + env: { ...env, ...extra }, + stdio, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, status, result.error?.message); + return result; +} const dirs = Object.fromEntries( captured([], { VP_DUMP_DIRS: '1' }) .stdout.trim() @@ -80,22 +102,31 @@ const system = path.join(root, 'system'); fs.mkdirSync(system); fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); env.PATH = system; -const first = captured(['env', 'list', 'node']); -const setup = first.stderr.replace(/\u001b\[[0-9;]*m/g, ''); -assert.match(setup, /Vite\+ setup complete/); -assert.doesNotMatch(setup, /not first on PATH/); // no duplicate reminder during handoff -const activation = setup - .split('\n') - .find((line) => line.trim().startsWith(shell === 'fish' || shell === 'nu' ? 'source "' : '. "')) - ?.trim(); -assert.ok(activation, setup); console.log('First setup:'); -console.log( - setup +let activation; +if (kind === 'external') { + terminal(['env', 'list', 'node']); + // The full PTY snapshot records the printed command. Other cases also parse + // the guidance and execute it, so escaping remains covered independently. + const escaped = path.join(config, 'env').replace(/[\\$`"]/g, '\\$&'); + activation = `. "${escaped}"`; +} else { + const first = captured(['env', 'list', 'node']); + const setup = first.stderr.replace(/\u001b\[[0-9;]*m/g, ''); + assert.match(setup, /Vite\+ setup complete/); + assert.doesNotMatch(setup, /not first on PATH/); + activation = setup .split('\n') - .filter((line) => /Activate Vite\+|^ \. |^ source |new terminal/.test(line)) - .join('\n'), -); + .find((line) => line.trim().startsWith(shell === 'fish' || shell === 'nu' ? 'source "' : '. "')) + ?.trim(); + assert.ok(activation, setup); + console.log( + setup + .split('\n') + .filter((line) => /Activate Vite\+|^ \. |^ source |new terminal/.test(line)) + .join('\n'), + ); +} const settingsFile = path.join(config, 'config.json'); const settings = fs.readFileSync(settingsFile, 'utf8'); const saved = JSON.parse(settings); @@ -112,21 +143,12 @@ const shellBin = process.env.PATH.split(path.delimiter) .map((dir) => path.join(dir, shell)) .find((file) => fs.existsSync(file)); assert.ok(shellBin, shell); -function terminal(args, extra = {}, status = 0, executable = binary, stdio = 'inherit') { - const result = spawnSync(executable, args, { - env: { ...env, ...extra }, - stdio, - encoding: 'utf8', - timeout: 30000, - }); - assert.equal(result.status, status, result.error?.message); - return result; -} Object.assign(env, { ACTIVATION_VP: binary, ACTIVATION_BIN: bin, ACTIVATION_COMMAND: activation, ACTIVATION_SYSTEM: system, + ACTIVATION_SHOW_COMMANDS: kind === 'external' ? '1' : '', }); console.log('Same terminal:'); if (shell === 'fish') { @@ -181,8 +203,16 @@ if (kind === 'external') { ['pipe', 'inherit', 'inherit'], ]) { const result = terminal(['env', 'list', 'node'], {}, 0, binary, stdio); - if (result.stderr !== null) assert.doesNotMatch(result.stderr, /Activate|not first on PATH/); - if (result.stdout !== null) assert.doesNotMatch(result.stdout, /Activate|not first on PATH/); + if (result.stderr !== null) { + assert.doesNotMatch(result.stderr, /Activate|not first on PATH/); + console.log('Redirected stderr:'); + process.stdout.write(result.stderr); + } + if (result.stdout !== null) { + assert.doesNotMatch(result.stdout, /Activate|not first on PATH/); + console.log('Redirected stdout:'); + process.stdout.write(result.stdout); + } } console.log('Direct node shim:'); terminal(['--version'], {}, 0, path.join(bin, 'node')); @@ -191,15 +221,7 @@ if (kind === 'external') { console.log('\nInstaller capability protocol:'); terminal([], { VP_SELF_SETUP_SUPPORT_CHECK: '1' }); console.log('Installer handoff:'); - const handoff = captured([], { VP_SELF_SETUP_SHELL: 'sh' }); - assert.doesNotMatch(handoff.stderr, /not first on PATH/); - assert.ok( - handoff.stdout - .trim() - .split('\n') - .every((line) => /^(INSTALL|SHIM|CACHE|CONFIG|STATE)_DIR=/.test(line)), - ); - console.log('Shell assignments only; no duplicate activation reminder.'); + terminal([], { VP_SELF_SETUP_SHELL: 'sh' }); console.log('Internal background protocol:'); terminal(['upgrade', '--background-check']); console.log('Quiet mode:'); diff --git a/crates/vp_global_cli/src/commands/env/activation.rs b/crates/vp_global_cli/src/commands/env/activation.rs index de47e9a9d7..eabe372500 100644 --- a/crates/vp_global_cli/src/commands/env/activation.rs +++ b/crates/vp_global_cli/src/commands/env/activation.rs @@ -3,7 +3,7 @@ use std::{ffi::OsStr, path::Path}; use vp_pm_cli::PackageManagerType; -use vp_shared::{EnvConfig, output}; +use vp_shared::{EnvConfig, env_vars, output}; use vt_path::AbsolutePath; use super::{ @@ -134,6 +134,13 @@ pub(crate) async fn remind(args: &Args, raw: &[String], cwd: &AbsolutePath) { { return; } + // vp run/exec and other delegated commands prepend selected runtime/PM + // directories. A Node directory also contains npm/npx, so checking only + // the recorded tool names would still mistake this child environment for + // an inactive terminal. Sourcing env in the parent cannot fix that PATH. + if std::env::var_os(env_vars::VP_PATH_INJECTED_TOOLS).is_some_and(|tools| !tools.is_empty()) { + return; + } let Ok(config) = config::load_config().await else { return }; let env = EnvConfig::get(); let path = std::env::var_os("PATH").unwrap_or_default(); From 03d53474fca2680b657464215468b47444469ea3 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 10:24:03 +0800 Subject: [PATCH 03/18] refactor(env): limit activation guidance to setup --- .../command_env_activation/session.fish | 17 +- .../command_env_activation/session.nu | 17 +- .../command_env_activation/session.sh | 34 +- .../snapshots/activation_external_zsh.md | 228 +----------- .../activation_external_zsh_linux.md | 228 +----------- .../snapshots/activation_fish.md | 48 +-- .../snapshots/activation_legacy_bash.md | 48 +-- .../snapshots/activation_managed_bash.md | 48 +-- .../snapshots/activation_nushell.md | 48 +-- .../snapshots/activation_xdg_bash.md | 48 +-- .../command_env_activation/verify.mjs | 135 +------- .../src/commands/env/activation.rs | 325 ------------------ crates/vp_global_cli/src/commands/env/mod.rs | 1 - .../vp_global_cli/src/commands/env/setup.rs | 45 ++- crates/vp_global_cli/src/main.rs | 4 - crates/vp_global_cli/src/self_setup.rs | 13 - 16 files changed, 140 insertions(+), 1147 deletions(-) delete mode 100644 crates/vp_global_cli/src/commands/env/activation.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish index e05cbf7034..76bd3ac022 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.fish @@ -1,14 +1,13 @@ +printf '$ command -v node\n' command -v node -node -$ACTIVATION_VP env list node; or exit 1 -eval $ACTIVATION_COMMAND; or exit 1 -test (command -v node) = "$ACTIVATION_BIN/node"; or exit 1 -node --version; or exit 1 -vp env list node; or exit 1 -set -gx PATH $ACTIVATION_SYSTEM $ACTIVATION_BIN -node -vp env list node; or exit 1 +printf '$ node --version\n' +node --version +printf '$ %s\n' "$ACTIVATION_COMMAND" eval $ACTIVATION_COMMAND; or exit 1 +printf '$ command -v node\n' +command -v node test (command -v node) = "$ACTIVATION_BIN/node"; or exit 1 +printf '$ node --version\n' node --version; or exit 1 +printf '$ vp env list node\n' vp env list node; or exit 1 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu index 7247c3193a..9055732e30 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.nu @@ -1,18 +1,15 @@ +print '$ which node' which node | first | get path | print -^node -^$env.ACTIVATION_VP env list node -__ACTIVATION_COMMAND__ -if (which node | first | get path) != ($env.ACTIVATION_BIN | path join node) { - error make {msg: 'node did not resolve through the shim'} -} +print '$ node --version' ^node --version -vp env list node -$env.PATH = [$env.ACTIVATION_SYSTEM $env.ACTIVATION_BIN] -^node -vp env list node +print ('$ ' + $env.ACTIVATION_COMMAND) __ACTIVATION_COMMAND__ +print '$ which node' +which node | first | get path | print if (which node | first | get path) != ($env.ACTIVATION_BIN | path join node) { error make {msg: 'node did not resolve through the shim'} } +print '$ node --version' ^node --version +print '$ vp env list node' vp env list node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh index c3eed48412..17dd8f6075 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/session.sh @@ -1,34 +1,14 @@ set -eu -show() { - if [ -n "${ACTIVATION_SHOW_COMMANDS-}" ]; then - printf '$ %s\n' "$1" - fi -} -show 'command -v node' +printf '$ command -v node\n' command -v node -show node -node -show 'vp env list node' -"$ACTIVATION_VP" env list node -show "$ACTIVATION_COMMAND" -eval "$ACTIVATION_COMMAND" -test "$(command -v node)" = "$ACTIVATION_BIN/node" -show 'node --version' +printf '$ node --version\n' node --version -show 'vp env list node' -vp env list node -# Existing command lookup caches must be refreshed by sourcing the env file. -show 'export PATH="$ACTIVATION_SYSTEM:$ACTIVATION_BIN"' -PATH="$ACTIVATION_SYSTEM:$ACTIVATION_BIN" -export PATH -show node -node -show 'vp env list node' -vp env list node -show "$ACTIVATION_COMMAND" +printf '$ %s\n' "$ACTIVATION_COMMAND" eval "$ACTIVATION_COMMAND" +printf '$ command -v node\n' +command -v node test "$(command -v node)" = "$ACTIVATION_BIN/node" -show 'node --version' +printf '$ node --version\n' node --version -show 'vp env list node' +printf '$ vp env list node\n' vp env list node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md index 15f6f76e70..6c08045f09 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md @@ -9,21 +9,21 @@ Setup: Preparing vite-plus environment. Created Shims: - /activation/home space 'quote' $cash `tick` "double" \slash/bin/node - /activation/home space 'quote' $cash `tick` "double" \slash/bin/npm - /activation/home space 'quote' $cash `tick` "double" \slash/bin/npx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpm - /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarn - /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarnpkg - /activation/home space 'quote' $cash `tick` "double" \slash/bin/bun - /activation/home space 'quote' $cash `tick` "double" \slash/bin/bunx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpr + /activation/home/bin/node + /activation/home/bin/npm + /activation/home/bin/npx + /activation/home/bin/pnpm + /activation/home/bin/pnpx + /activation/home/bin/yarn + /activation/home/bin/yarnpkg + /activation/home/bin/bun + /activation/home/bin/bunx + /activation/home/bin/vpx + /activation/home/bin/vpr Next Steps: Activate Vite+ in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + . "/activation/home/env" Add the command for your shell to its profile to activate future terminals. If setup already updated your profile, you can open a new terminal instead. @@ -40,214 +40,16 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Same terminal: -$ zsh -f session.sh $ command -v node /activation/system/node -$ node -system-node -$ vp env list node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" $ node --version - -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -$ export PATH="$ACTIVATION_SYSTEM:$ACTIVATION_BIN" -$ node system-node -$ vp env list node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -$ node --version - -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Another unactivated terminal: -$ vp env list node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Preferences, setup state, and installation ownership unchanged. -Equivalent shim directory on PATH: -$ PATH='/activation/bin-alias:/activation/system' vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -JSON output: -$ vp env list node --json -{ - "node": [ - { - "version": "22.18.0", - "current": true, - "default": false - } - ] -} -Shell-evaluated output: -$ VP_ENV_USE_EVAL_ENABLE=1 vp env use 22.18.0 --no-install -export VP_NODE_VERSION=22.18.0 -Using Node.js (resolved from 22.18.0) -CI with a TTY: -$ CI=1 vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Redirected stdout, stderr, and stdin: -$ vp env list node -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Redirected stdout: -Node.js - * current - -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -Redirected stderr: -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Direct node shim: +$ . "/activation/home/env" +$ command -v node +/activation/home/bin/node $ node --version -Completion protocol: -$ VP_COMPLETE=bash _CLAP_COMPLETE_INDEX=2 vp -- vp env li -list -list-remote -Installer capability protocol: -$ VP_SELF_SETUP_SUPPORT_CHECK=1 vp -vite-plus-self-setup-v1 -Installer handoff: -$ VP_SELF_SETUP_SHELL=sh vp -Setup: - Preparing vite-plus environment. - -Created Shims: - /activation/home space 'quote' $cash `tick` "double" \slash/bin/node - /activation/home space 'quote' $cash `tick` "double" \slash/bin/npm - /activation/home space 'quote' $cash `tick` "double" \slash/bin/npx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpm - /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarn - /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarnpkg - /activation/home space 'quote' $cash `tick` "double" \slash/bin/bun - /activation/home space 'quote' $cash `tick` "double" \slash/bin/bunx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpr - -Next Steps: - Activate Vite+ in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" - - Add the command for your shell to its profile to activate future terminals. - If setup already updated your profile, you can open a new terminal instead. - - For IDE support (VS Code, Cursor), ensure bin directory is in system PATH: - - macOS: Add to ~/.profile or use launchd - - Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. -✓ Vite+ setup complete. -INSTALL_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" -SHIM_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/bin" -CACHE_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/cache" -CONFIG_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" -STATE_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" -Internal background protocol: -$ vp upgrade --background-check -Quiet mode: -$ vp upgrade --silent -error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. -Failing eligible command keeps exit status: -$ vp env clean invalid-scope -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -error: invalid environment scope "invalid-scope"; expected node, pm, npm, pnpm, yarn, or bun -System-first mode: -$ vp env off -VITE+ - The Unified Toolchain for the Web - -✓ Node.js and package-manager management set to system-first. - -Selected commands and shims will now prefer system tools, falling back to managed tools. - -Run `vp env on` to always use Vite+ managed tools. -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Unknown current shell gets labeled commands despite SHELL=fish: -$ VP_SHELL='' vp env list node -Vite+ managed tools are not first on PATH. Activate in this terminal: - Bash/Zsh: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" - Fish: source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" - Nushell: source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" - PowerShell: . '/activation/home space ''quote'' $cash `tick` "double" \slash/env.ps1' -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Missing environment file needs repair: -$ vp env list node -Vite+ shell environment files are missing. Run `vp env setup` to recreate them. -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Missing shim needs repair: $ vp env list node -Vite+ managed tool shims are missing or unusable. Run `vp env setup --refresh` to repair them. VITE+ - The Unified Toolchain for the Web Node.js diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md index 0f5812e3e3..7189124371 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md @@ -9,21 +9,21 @@ Setup: Preparing vite-plus environment. Created Shims: - /activation/home space 'quote' $cash `tick` "double" \slash/bin/node - /activation/home space 'quote' $cash `tick` "double" \slash/bin/npm - /activation/home space 'quote' $cash `tick` "double" \slash/bin/npx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpm - /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarn - /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarnpkg - /activation/home space 'quote' $cash `tick` "double" \slash/bin/bun - /activation/home space 'quote' $cash `tick` "double" \slash/bin/bunx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpr + /activation/home/bin/node + /activation/home/bin/npm + /activation/home/bin/npx + /activation/home/bin/pnpm + /activation/home/bin/pnpx + /activation/home/bin/yarn + /activation/home/bin/yarnpkg + /activation/home/bin/bun + /activation/home/bin/bunx + /activation/home/bin/vpx + /activation/home/bin/vpr Next Steps: Activate Vite+ in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" + . "/activation/home/env" Add the command for your shell to its profile to activate future terminals. If setup already updated your profile, you can open a new terminal instead. @@ -40,214 +40,16 @@ Node.js note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. Same terminal: -$ zsh -f session.sh $ command -v node /activation/system/node -$ node -system-node -$ vp env list node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" $ node --version - -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -$ export PATH="$ACTIVATION_SYSTEM:$ACTIVATION_BIN" -$ node system-node -$ vp env list node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -$ node --version - -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Another unactivated terminal: -$ vp env list node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Preferences, setup state, and installation ownership unchanged. -Equivalent shim directory on PATH: -$ PATH='/activation/bin-alias:/activation/system' vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -JSON output: -$ vp env list node --json -{ - "node": [ - { - "version": "22.18.0", - "current": true, - "default": false - } - ] -} -Shell-evaluated output: -$ VP_ENV_USE_EVAL_ENABLE=1 vp env use 22.18.0 --no-install -export VP_NODE_VERSION=22.18.0 -Using Node.js (resolved from 22.18.0) -CI with a TTY: -$ CI=1 vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Redirected stdout, stderr, and stdin: -$ vp env list node -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Redirected stdout: -Node.js - * current - -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -Redirected stderr: -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Direct node shim: +$ . "/activation/home/env" +$ command -v node +/activation/home/bin/node $ node --version -Completion protocol: -$ VP_COMPLETE=bash _CLAP_COMPLETE_INDEX=2 vp -- vp env li -list -list-remote -Installer capability protocol: -$ VP_SELF_SETUP_SUPPORT_CHECK=1 vp -vite-plus-self-setup-v1 -Installer handoff: -$ VP_SELF_SETUP_SHELL=sh vp -Setup: - Preparing vite-plus environment. - -Created Shims: - /activation/home space 'quote' $cash `tick` "double" \slash/bin/node - /activation/home space 'quote' $cash `tick` "double" \slash/bin/npm - /activation/home space 'quote' $cash `tick` "double" \slash/bin/npx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpm - /activation/home space 'quote' $cash `tick` "double" \slash/bin/pnpx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarn - /activation/home space 'quote' $cash `tick` "double" \slash/bin/yarnpkg - /activation/home space 'quote' $cash `tick` "double" \slash/bin/bun - /activation/home space 'quote' $cash `tick` "double" \slash/bin/bunx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpx - /activation/home space 'quote' $cash `tick` "double" \slash/bin/vpr - -Next Steps: - Activate Vite+ in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" - - Add the command for your shell to its profile to activate future terminals. - If setup already updated your profile, you can open a new terminal instead. - - For IDE support (VS Code, Cursor), ensure bin directory is in system PATH: - - Linux: Add to ~/.profile for display manager integration - - Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. -✓ Vite+ setup complete. -INSTALL_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" -SHIM_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/bin" -CACHE_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/cache" -CONFIG_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" -STATE_DIR="/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash" -Internal background protocol: -$ vp upgrade --background-check -Quiet mode: -$ vp upgrade --silent -error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. -Failing eligible command keeps exit status: -$ vp env clean invalid-scope -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -error: invalid environment scope "invalid-scope"; expected node, pm, npm, pnpm, yarn, or bun -System-first mode: -$ vp env off -VITE+ - The Unified Toolchain for the Web - -✓ Node.js and package-manager management set to system-first. - -Selected commands and shims will now prefer system tools, falling back to managed tools. - -Run `vp env on` to always use Vite+ managed tools. -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Unknown current shell gets labeled commands despite SHELL=fish: -$ VP_SHELL='' vp env list node -Vite+ managed tools are not first on PATH. Activate in this terminal: - Bash/Zsh: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" - Fish: source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" - Nushell: source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" - PowerShell: . '/activation/home space ''quote'' $cash `tick` "double" \slash/env.ps1' -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Missing environment file needs repair: -$ vp env list node -Vite+ shell environment files are missing. Run `vp env setup` to recreate them. -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Missing shim needs repair: $ vp env list node -Vite+ managed tool shims are missing or unusable. Run `vp env setup --refresh` to repair them. VITE+ - The Unified Toolchain for the Web Node.js diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md index 1a63c60a16..507cba854c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md @@ -8,50 +8,16 @@ First setup: source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" If setup already updated your profile, you can open a new terminal instead. Same terminal: +$ command -v node /activation/system/node +$ node --version system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" +$ command -v node +/activation/home space 'quote' $cash `tick` "double" \slash/bin/node +$ node --version -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. - -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Another unactivated terminal: -Vite+ managed tools are not first on PATH. Activate in this terminal: - source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Preferences, setup state, and installation ownership unchanged. -Equivalent shim directory on PATH: +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md index 6590a5904a..67bd8d7dee 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md @@ -8,50 +8,16 @@ First setup: . "/activation/user/.vite-plus/env" If setup already updated your profile, you can open a new terminal instead. Same terminal: +$ command -v node /activation/system/node +$ node --version system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/user/.vite-plus/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ . "/activation/user/.vite-plus/env" +$ command -v node +/activation/user/.vite-plus/bin/node +$ node --version -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/user/.vite-plus/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. - -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Another unactivated terminal: -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/user/.vite-plus/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Preferences, setup state, and installation ownership unchanged. -Equivalent shim directory on PATH: +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md index adc78633e1..86be29c9ee 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md @@ -8,50 +8,16 @@ First setup: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" If setup already updated your profile, you can open a new terminal instead. Same terminal: +$ command -v node /activation/system/node +$ node --version system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" +$ command -v node +/activation/home space 'quote' $cash `tick` "double" \slash/bin/node +$ node --version -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. - -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Another unactivated terminal: -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Preferences, setup state, and installation ownership unchanged. -Equivalent shim directory on PATH: +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md index 6872482015..3c9d5f95a7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md @@ -8,50 +8,16 @@ First setup: source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" If setup already updated your profile, you can open a new terminal instead. Same terminal: +$ which node /activation/system/node +$ node --version system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" +$ which node +/activation/home space 'quote' $cash `tick` "double" \slash/bin/node +$ node --version -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. - -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Another unactivated terminal: -Vite+ managed tools are not first on PATH. Activate in this terminal: - source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Preferences, setup state, and installation ownership unchanged. -Equivalent shim directory on PATH: +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md index b9d8891b5a..8c1d512881 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md @@ -8,50 +8,16 @@ First setup: . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" If setup already updated your profile, you can open a new terminal instead. Same terminal: +$ command -v node /activation/system/node +$ node --version system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +$ . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" +$ command -v node +/activation/bin space 'quote' $cash `tick` "double" \slash/node +$ node --version -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -system-node -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. - -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Another unactivated terminal: -Vite+ managed tools are not first on PATH. Activate in this terminal: - . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Preferences, setup state, and installation ownership unchanged. -Equivalent shim directory on PATH: +$ vp env list node VITE+ - The Unified Toolchain for the Web Node.js diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs index 9d4444f850..73e23f4c6d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs @@ -50,7 +50,7 @@ if (kind === 'xdg') { fs.mkdirSync(legacy, { recursive: true }); fs.symlinkSync('activation-test', path.join(legacy, 'current')); } else { - env.VP_HOME = path.join(root, 'home ' + special); + env.VP_HOME = path.join(root, kind === 'external' ? 'home' : 'home ' + special); } function captured(args, extra = {}) { const result = spawnSync(binary, args, { @@ -61,27 +61,9 @@ function captured(args, extra = {}) { assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); return result; } -function terminal(args, extra = {}, status = 0, executable = binary, stdio = 'inherit') { - if (kind === 'external') { - const quote = (value) => - /^[\w./=-]+$/.test(value) ? value : "'" + value.replaceAll("'", "'\\''") + "'"; - const command = - executable === binary - ? 'vp' - : executable === path.join(bin, 'node') - ? 'node' - : path.basename(executable); - const overrides = Object.entries(extra).map(([key, value]) => `${key}=${quote(value)}`); - console.log('$ ' + [...overrides, command, ...args.map(quote)].join(' ')); - } - const result = spawnSync(executable, args, { - env: { ...env, ...extra }, - stdio, - encoding: 'utf8', - timeout: 30000, - }); - assert.equal(result.status, status, result.error?.message); - return result; +function terminal(executable, args) { + const result = spawnSync(executable, args, { env, stdio: 'inherit', timeout: 30000 }); + assert.equal(result.status, 0, result.error?.message); } const dirs = Object.fromEntries( captured([], { VP_DUMP_DIRS: '1' }) @@ -105,7 +87,8 @@ env.PATH = system; console.log('First setup:'); let activation; if (kind === 'external') { - terminal(['env', 'list', 'node']); + console.log('$ vp env list node'); + terminal(binary, ['env', 'list', 'node']); // The full PTY snapshot records the printed command. Other cases also parse // the guidance and execute it, so escaping remains covered independently. const escaped = path.join(config, 'env').replace(/[\\$`"]/g, '\\$&'); @@ -114,7 +97,6 @@ if (kind === 'external') { const first = captured(['env', 'list', 'node']); const setup = first.stderr.replace(/\u001b\[[0-9;]*m/g, ''); assert.match(setup, /Vite\+ setup complete/); - assert.doesNotMatch(setup, /not first on PATH/); activation = setup .split('\n') .find((line) => line.trim().startsWith(shell === 'fish' || shell === 'nu' ? 'source "' : '. "')) @@ -127,119 +109,28 @@ if (kind === 'external') { .join('\n'), ); } -const settingsFile = path.join(config, 'config.json'); -const settings = fs.readFileSync(settingsFile, 'utf8'); -const saved = JSON.parse(settings); -assert.equal(saved.nodeShimMode ?? 'managed', 'managed'); -assert.deepEqual(Object.values(saved.packageManagerShimModes), Array(4).fill('system_first')); -const setupState = path.join(dirs.state, 'self-setup'); -const receipts = fs - .readdirSync(setupState) - .map((name) => [name, fs.readFileSync(path.join(setupState, name), 'utf8')]); -const prefixFiles = fs.readdirSync(path.dirname(binary)); - // Resolve the shell using the runner PATH before switching to the stale PATH. const shellBin = process.env.PATH.split(path.delimiter) .map((dir) => path.join(dir, shell)) .find((file) => fs.existsSync(file)); assert.ok(shellBin, shell); Object.assign(env, { - ACTIVATION_VP: binary, ACTIVATION_BIN: bin, ACTIVATION_COMMAND: activation, - ACTIVATION_SYSTEM: system, - ACTIVATION_SHOW_COMMANDS: kind === 'external' ? '1' : '', }); console.log('Same terminal:'); if (shell === 'fish') { - terminal(['--no-config', 'session.fish'], {}, 0, shellBin); + terminal(shellBin, ['--no-config', 'session.fish']); } else if (shell === 'nu') { fs.writeFileSync( 'activate.nu', fs.readFileSync('session.nu', 'utf8').replaceAll('__ACTIVATION_COMMAND__', activation), ); - terminal(['--no-config-file', 'activate.nu'], {}, 0, shellBin); + terminal(shellBin, ['--no-config-file', 'activate.nu']); } else { - terminal( - [ - shell === 'bash' ? '--noprofile' : '-f', - ...(shell === 'bash' ? ['--norc'] : []), - 'session.sh', - ], - {}, - 0, - shellBin, - ); -} -console.log('Another unactivated terminal:'); -terminal(['env', 'list', 'node']); -assert.equal(fs.readFileSync(settingsFile, 'utf8'), settings); -for (const [name, receipt] of receipts) - assert.equal(fs.readFileSync(path.join(setupState, name), 'utf8'), receipt); -assert.deepEqual(fs.readdirSync(path.dirname(binary)), prefixFiles); -assert.ok(!fs.existsSync(path.join(root, 'prefix/bin/.vp-setup-complete'))); -if (kind === 'external' || kind === 'xdg') { - assert.ok(!fs.existsSync(path.join(data, 'current'))); - assert.equal(fs.realpathSync(path.join(bin, 'node')), fs.realpathSync(binary)); -} else { - assert.ok(fs.existsSync(path.join(data, 'current/bin/.vp-setup-complete'))); -} -console.log('Preferences, setup state, and installation ownership unchanged.'); -fs.symlinkSync(bin, path.join(root, 'bin-alias')); -console.log('Equivalent shim directory on PATH:'); -terminal(['env', 'list', 'node'], { PATH: path.join(root, 'bin-alias') + path.delimiter + system }); - -if (kind === 'external') { - console.log('JSON output:'); - terminal(['env', 'list', 'node', '--json']); - console.log('Shell-evaluated output:'); - terminal(['env', 'use', version, '--no-install'], { VP_ENV_USE_EVAL_ENABLE: '1' }); - console.log('CI with a TTY:'); - terminal(['env', 'list', 'node'], { CI: '1' }); - console.log('Redirected stdout, stderr, and stdin:'); - for (const stdio of [ - ['inherit', 'pipe', 'inherit'], - ['inherit', 'inherit', 'pipe'], - ['pipe', 'inherit', 'inherit'], - ]) { - const result = terminal(['env', 'list', 'node'], {}, 0, binary, stdio); - if (result.stderr !== null) { - assert.doesNotMatch(result.stderr, /Activate|not first on PATH/); - console.log('Redirected stderr:'); - process.stdout.write(result.stderr); - } - if (result.stdout !== null) { - assert.doesNotMatch(result.stdout, /Activate|not first on PATH/); - console.log('Redirected stdout:'); - process.stdout.write(result.stdout); - } - } - console.log('Direct node shim:'); - terminal(['--version'], {}, 0, path.join(bin, 'node')); - console.log('Completion protocol:'); - terminal(['--', 'vp', 'env', 'li'], { VP_COMPLETE: 'bash', _CLAP_COMPLETE_INDEX: '2' }); - console.log('\nInstaller capability protocol:'); - terminal([], { VP_SELF_SETUP_SUPPORT_CHECK: '1' }); - console.log('Installer handoff:'); - terminal([], { VP_SELF_SETUP_SHELL: 'sh' }); - console.log('Internal background protocol:'); - terminal(['upgrade', '--background-check']); - console.log('Quiet mode:'); - terminal(['upgrade', '--silent'], {}, 1); - console.log('Failing eligible command keeps exit status:'); - terminal(['env', 'clean', 'invalid-scope'], {}, 1); - console.log('System-first mode:'); - terminal(['env', 'off']); - terminal(['env', 'list', 'node']); - fs.writeFileSync(settingsFile, settings); - console.log('Unknown current shell gets labeled commands despite SHELL=fish:'); - terminal(['env', 'list', 'node'], { VP_SHELL: '' }); - console.log('Missing environment file needs repair:'); - const envFile = path.join(config, 'env'); - fs.renameSync(envFile, envFile + '.saved'); - terminal(['env', 'list', 'node']); - fs.renameSync(envFile + '.saved', envFile); - console.log('Missing shim needs repair:'); - fs.unlinkSync(path.join(bin, 'node')); - terminal(['env', 'list', 'node']); + terminal(shellBin, [ + shell === 'bash' ? '--noprofile' : '-f', + ...(shell === 'bash' ? ['--norc'] : []), + 'session.sh', + ]); } diff --git a/crates/vp_global_cli/src/commands/env/activation.rs b/crates/vp_global_cli/src/commands/env/activation.rs deleted file mode 100644 index eabe372500..0000000000 --- a/crates/vp_global_cli/src/commands/env/activation.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Read-only activation checks for the environment inherited by this invocation. - -use std::{ffi::OsStr, path::Path}; - -use vp_pm_cli::PackageManagerType; -use vp_shared::{EnvConfig, env_vars, output}; -use vt_path::AbsolutePath; - -use super::{ - config::{self, Config, ShimMode}, - setup, -}; -use crate::{ - cli::{Args, Commands, EnvSubcommands}, - commands::shell::Shell, -}; - -#[derive(Debug, PartialEq, Eq)] -enum Activation { - Active, - Inactive, - NeedsSetup, -} - -/// Compare the entrypoint's directory, not its symlink target: all Unix shims -/// can point at the same vp binary, including links outside the user's bin dir. -fn is_shim_entrypoint(actual: &Path, expected: &Path) -> bool { - let same_name = if cfg!(windows) { - actual - .file_name() - .zip(expected.file_name()) - .is_some_and(|(a, b)| a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy())) - } else { - actual.file_name() == expected.file_name() - }; - same_name - && actual - .parent() - .zip(expected.parent()) - .is_some_and(|(a, b)| same_file::is_same_file(a, b).unwrap_or(false)) -} - -fn check(config: &Config, bin: &AbsolutePath, cwd: &AbsolutePath, path: &OsStr) -> Activation { - let mut inactive = false; - for tool in crate::shim::DEFAULT_SHIM_TOOLS { - let mode = if *tool == "node" { - config.node_shim_mode - } else if let Some(pm) = PackageManagerType::from_tool(tool) { - config.package_manager_shim_mode_for(pm) - } else { - continue; - }; - if mode == ShimMode::SystemFirst { - continue; - } - let expected = bin.join(setup::shim_filename(tool)); - // A deleted/broken/non-executable shim cannot be repaired by sourcing env. - if vp_command::resolve_bin(tool, Some(bin.as_path().as_os_str()), cwd) - .ok() - .is_none_or(|found| !is_shim_entrypoint(found.as_path(), expected.as_path())) - { - return Activation::NeedsSetup; - } - if vp_command::resolve_bin(tool, Some(path), cwd) - .ok() - .is_none_or(|found| !is_shim_entrypoint(found.as_path(), expected.as_path())) - { - inactive = true; - } - } - if inactive { Activation::Inactive } else { Activation::Active } -} - -fn eligible(args: &Args, raw: &[String]) -> bool { - if args.version - || raw.iter().any(|arg| { - matches!( - arg.as_str(), - "--quiet" - | "-q" - | "--silent" - | "-s" - | "--json" - | "--parseable" - | "--help" - | "-h" - | "--format" - | "--reporter" - | "--reporters" - | "--logLevel" - ) || [ - "--quiet=", - "--silent=", - "--json=", - "--format=", - "--reporter=", - "--reporters=", - "--logLevel=", - ] - .iter() - .any(|prefix| arg.starts_with(prefix)) - }) - { - return false; - } - match &args.command { - Some(command) if command.is_quiet_or_machine_readable() => false, - Some( - Commands::Upgrade { background_check: true, .. } - | Commands::Implode { .. } - | Commands::Hooks { .. } - | Commands::Staged { .. }, - ) => false, - Some(Commands::Env(args)) => !matches!( - &args.command, - Some( - EnvSubcommands::Setup { .. } - | EnvSubcommands::Doctor { .. } - | EnvSubcommands::Use { .. } - | EnvSubcommands::Print { .. } - | EnvSubcommands::Which { .. } - | EnvSubcommands::Exec { .. } - | EnvSubcommands::Off { .. } - ) - ), - _ => true, - } -} - -pub(crate) async fn remind(args: &Args, raw: &[String], cwd: &AbsolutePath) { - if !vp_shared::is_interactive_terminal() - || !vp_shared::is_stderr_terminal() - || !eligible(args, raw) - { - return; - } - // vp run/exec and other delegated commands prepend selected runtime/PM - // directories. A Node directory also contains npm/npx, so checking only - // the recorded tool names would still mistake this child environment for - // an inactive terminal. Sourcing env in the parent cannot fix that PATH. - if std::env::var_os(env_vars::VP_PATH_INJECTED_TOOLS).is_some_and(|tools| !tools.is_empty()) { - return; - } - let Ok(config) = config::load_config().await else { return }; - let env = EnvConfig::get(); - let path = std::env::var_os("PATH").unwrap_or_default(); - match check(&config, &env.dirs.bin, cwd, &path) { - Activation::Active => {} - Activation::NeedsSetup => output::raw_stderr( - "Vite+ managed tool shims are missing or unusable. Run `vp env setup --refresh` to repair them.", - ), - Activation::Inactive => { - if ["env", "env.fish", "env.nu", "env.ps1"] - .iter() - .any(|file| !env.dirs.config.join(file).as_path().is_file()) - { - output::raw_stderr( - "Vite+ shell environment files are missing. Run `vp env setup` to recreate them.", - ); - return; - } - output::raw_stderr( - "Vite+ managed tools are not first on PATH. Activate in this terminal:", - ); - for line in - instructions(&env.dirs.config, env.vp_shell.as_deref().and_then(|s| s.parse().ok())) - { - output::raw_stderr(&line); - } - } - } -} - -/// Use an explicit VP_SHELL when available. Otherwise label the alternatives: -/// SHELL describes the login shell and need not describe this terminal. -pub(crate) fn instructions(env_dir: &AbsolutePath, shell: Option) -> Vec { - let commands = [ - (Shell::Posix, "Bash/Zsh", "env"), - (Shell::Fish, "Fish", "env.fish"), - (Shell::NuShell, "Nushell", "env.nu"), - (Shell::PowerShell, "PowerShell", "env.ps1"), - ]; - if shell == Some(Shell::Cmd) { - // cmd has no sourceable environment file. A new terminal inherits the - // persistent PATH written by setup. - return vec![" In cmd.exe, open a new terminal to load the updated PATH.".into()]; - } - commands - .into_iter() - .filter(|(kind, _, _)| shell.is_none_or(|s| s == *kind)) - .map(|(kind, label, file)| { - let path = env_dir.join(file).to_string(); - let command = match kind { - Shell::Posix => { - format!(". \"{}\"", setup::escape_posix_double_quoted_string(&path)) - } - Shell::Fish => { - format!("source \"{}\"", setup::escape_fish_double_quoted_string(&path)) - } - Shell::NuShell => { - format!("source \"{}\"", setup::escape_nu_double_quoted_string(&path)) - } - Shell::PowerShell => { - format!(". '{}'", setup::escape_powershell_single_quoted_string(&path)) - } - Shell::Cmd => unreachable!(), - }; - if shell.is_some() { format!(" {command}") } else { format!(" {label}: {command}") } - }) - .collect() -} - -#[cfg(test)] -mod tests { - use vt_path::AbsolutePathBuf; - - use super::*; - - fn executable(dir: &AbsolutePath, tool: &str) { - let path = dir.join(setup::shim_filename(tool)); - std::fs::write(&path, b"fake executable").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); - } - } - - #[test] - fn checks_lookup_order_and_only_managed_tools() { - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - let bin = cwd.join("shims"); - let system = cwd.join("system"); - std::fs::create_dir(&bin).unwrap(); - std::fs::create_dir(&system).unwrap(); - let mut config = Config::default(); - config.set_all_package_manager_shim_modes(ShimMode::SystemFirst); - executable(&bin, "node"); - executable(&system, "node"); - let active = std::env::join_paths([bin.as_path(), system.as_path()]).unwrap(); - let shadowed = std::env::join_paths([system.as_path(), bin.as_path()]).unwrap(); - assert_eq!(check(&config, &bin, &cwd, &active), Activation::Active); - assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::Inactive); - assert_eq!(check(&config, &bin, &cwd, system.as_path().as_os_str()), Activation::Inactive); - config.node_shim_mode = ShimMode::SystemFirst; - assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::Active); - config.set_package_manager_shim_mode(PackageManagerType::Npm, ShimMode::Managed); - assert_eq!(check(&config, &bin, &cwd, &active), Activation::NeedsSetup); - executable(&bin, "npm"); - executable(&bin, "npx"); - assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::Active); - executable(&system, "npx"); - assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::Inactive); - std::fs::remove_file(bin.join(setup::shim_filename("npm"))).unwrap(); - assert_eq!(check(&config, &bin, &cwd, &shadowed), Activation::NeedsSetup); - } - - #[cfg(unix)] - #[test] - fn directory_aliases_are_active_but_other_links_to_vp_are_not() { - use std::os::unix::fs::symlink; - let temp = tempfile::tempdir().unwrap(); - let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); - let bin = cwd.join("shims"); - let external = cwd.join("brew"); - std::fs::create_dir(&bin).unwrap(); - std::fs::create_dir(&external).unwrap(); - executable(&external, "vp"); - symlink(external.join("vp"), bin.join("node")).unwrap(); - symlink(external.join("vp"), external.join("node")).unwrap(); - let alias = cwd.join("alias"); - symlink(&bin, &alias).unwrap(); - let mut config = Config::default(); - config.set_all_package_manager_shim_modes(ShimMode::SystemFirst); - for path in [alias.as_path().as_os_str(), OsStr::new("shims/../shims")] { - assert_eq!(check(&config, &bin, &cwd, path), Activation::Active); - } - assert_eq!( - check(&config, &bin, &cwd, external.as_path().as_os_str()), - Activation::Inactive - ); - std::fs::remove_file(external.join("vp")).unwrap(); - assert_eq!(check(&config, &bin, &cwd, bin.as_path().as_os_str()), Activation::NeedsSetup); - } - - #[test] - fn excludes_output_protocols_and_quiet_commands() { - for raw in [ - vec!["env", "current", "--json"], - vec!["env", "list", "--json"], - vec!["env", "print"], - vec!["env", "which", "node"], - vec!["env", "use", "22", "--no-install"], - vec!["env", "exec", "node", "-v"], - vec!["env", "doctor"], - vec!["env", "setup"], - vec!["env", "off"], - vec!["upgrade", "--background-check"], - vec!["upgrade", "--silent"], - vec!["install", "--silent"], - vec!["toolchain", "--json"], - vec!["sync-versions", "--json"], - vec!["build", "--quiet"], - vec!["test", "--reporter=json"], - vec!["run", "--silent", "build"], - vec!["--version"], - ] { - let strings: Vec<_> = raw.iter().map(|s| (*s).to_string()).collect(); - let args = crate::try_parse_args_from( - std::iter::once("vp".to_string()).chain(strings.clone()), - ) - .unwrap(); - assert!(!eligible(&args, &strings), "{raw:?}"); - } - for raw in [vec!["build"], vec!["env", "current"], vec!["env", "list"], vec!["install"]] { - let strings: Vec<_> = raw.iter().map(|s| (*s).to_string()).collect(); - let args = crate::try_parse_args_from( - std::iter::once("vp".to_string()).chain(strings.clone()), - ) - .unwrap(); - assert!(eligible(&args, &strings), "{raw:?}"); - } - } -} diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index bc549491b3..3f568cc799 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -3,7 +3,6 @@ //! This module provides the `vp env` command for managing Node.js environments //! through shim-based version management. -pub(crate) mod activation; pub mod bin_config; mod clean; pub mod config; diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 06d6ebdec8..7dadf9a9cf 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -25,6 +25,7 @@ use crate::{ commands::{ env::{bin_config::BinConfig, package_metadata::PackageMetadata}, global::{LEGACY_PACKAGE_MANAGER_PACKAGES, install::uninstall}, + shell::Shell, }, error::Error, help, @@ -1196,11 +1197,45 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { output::raw(&help::render_heading("Next Steps")); output::raw(" Activate Vite+ in this terminal:"); let env = vp_shared::EnvConfig::get(); - for line in super::activation::instructions( - env_dir, - env.vp_shell.as_deref().and_then(|s| s.parse().ok()), - ) { - output::raw(&line); + // SHELL describes the login shell, which may differ from this terminal. + let shell = env.vp_shell.as_deref().and_then(|s| s.parse().ok()); + let commands = [ + (Shell::Posix, "Bash/Zsh", "env"), + (Shell::Fish, "Fish", "env.fish"), + (Shell::NuShell, "Nushell", "env.nu"), + (Shell::PowerShell, "PowerShell", "env.ps1"), + ]; + if shell == Some(Shell::Cmd) { + // cmd has no sourceable environment file. A new terminal inherits the + // persistent PATH written by setup. + output::raw(" In cmd.exe, open a new terminal to load the updated PATH."); + } else { + for (kind, label, file) in commands { + if shell.is_some_and(|s| s != kind) { + continue; + } + let path = env_dir.join(file).to_string(); + let command = match kind { + Shell::Posix => { + format!(". \"{}\"", escape_posix_double_quoted_string(&path)) + } + Shell::Fish => { + format!("source \"{}\"", escape_fish_double_quoted_string(&path)) + } + Shell::NuShell => { + format!("source \"{}\"", escape_nu_double_quoted_string(&path)) + } + Shell::PowerShell => { + format!(". '{}'", escape_powershell_single_quoted_string(&path)) + } + Shell::Cmd => unreachable!(), + }; + if shell.is_some() { + output::raw(&format!(" {command}")); + } else { + output::raw(&format!(" {label}: {command}")); + } + } } output::raw(""); output::raw(" Add the command for your shell to its profile to activate future terminals."); diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index c9ebf87a11..4bcae58dea 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -495,10 +495,6 @@ async fn main() -> ExitCode { // Parse CLI arguments (using custom help formatting) let parse_result = try_parse_args_from(normalized_args); - if let Ok(parsed) = &parse_result { - commands::env::activation::remind(parsed, &raw_args, &cwd).await; - } - let should_run_upgrade_check = parse_result.as_ref().is_ok_and(upgrade_check::should_run_for_command); let spawned_upgrade_check = diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index c48bed65fc..fe65130603 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -90,19 +90,6 @@ fn execute_installed( // Re-enter through the marked installation, inheriting cwd, environment and stdio. let mut command = std::process::Command::new(binary.as_path()); command.args(args); - if just_installed { - // Setup already printed activation guidance. Activate only the re-executed - // command's PATH; the parent terminal still needs to source its env file. - // This also avoids repeating guidance during the setup handoff, without - // persisting a suppression flag that could hide it in another terminal. - let path = std::env::var_os("PATH").unwrap_or_default(); - let bin = EnvConfig::get().dirs.bin.as_path().to_path_buf(); - let paths = std::iter::once(bin).chain(std::env::split_paths(&path)); - command.env( - "PATH", - std::env::join_paths(paths).map_err(|error| Error::Other(error.to_string().into()))?, - ); - } #[cfg(unix)] { use std::os::unix::process::CommandExt; From 1e634366e7d0860094cf759b74ca87db74d65f2d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 10:57:54 +0800 Subject: [PATCH 04/18] fix(env): skip profile guidance when already configured --- .../command_env_activation/snapshots.toml | 10 +- .../snapshots/activation_external_zsh.md | 33 ++++- .../activation_external_zsh_linux.md | 59 -------- .../snapshots/activation_fish.md | 4 +- .../snapshots/activation_legacy_bash.md | 4 +- .../snapshots/activation_managed_bash.md | 4 +- .../snapshots/activation_nushell.md | 4 +- .../snapshots/activation_xdg_bash.md | 4 +- .../command_env_activation/verify.mjs | 47 +++++- .../vp_global_cli/src/commands/env/setup.rs | 135 +++++++++++++++--- 10 files changed, 201 insertions(+), 103 deletions(-) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml index b5aec0c6d0..b2f637d638 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml @@ -8,7 +8,7 @@ steps = [["node", "verify.mjs", "managed", "bash"]] [[case]] name = "activation_external_zsh" vp = "global" -skip-platforms = ["windows", "linux"] +skip-platforms = ["windows"] requires = ["zsh"] steps = [["node", "verify.mjs", "external", "zsh"]] @@ -39,11 +39,3 @@ vp = "global" skip-platforms = ["windows"] requires = ["nu"] steps = [["node", "verify.mjs", "managed", "nu"]] - -# Setup includes platform-specific IDE instructions; keep both full transcripts. -[[case]] -name = "activation_external_zsh_linux" -vp = "global" -skip-platforms = ["windows", "macos"] -requires = ["zsh"] -steps = [["node", "verify.mjs", "external", "zsh"]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md index 6c08045f09..1d07b0d9ed 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md @@ -26,10 +26,6 @@ Next Steps: . "/activation/home/env" Add the command for your shell to its profile to activate future terminals. - If setup already updated your profile, you can open a new terminal instead. - - For IDE support (VS Code, Cursor), ensure bin directory is in system PATH: - - macOS: Add to ~/.profile or use launchd Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. ✓ Vite+ setup complete. @@ -39,6 +35,35 @@ Node.js * current note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +Setup with an existing profile entry: +$ vp env setup +VITE+ - The Unified Toolchain for the Web + +Setup: + Preparing vite-plus environment. + +Skipped Shims: + /activation/home/bin/node + /activation/home/bin/npm + /activation/home/bin/npx + /activation/home/bin/pnpm + /activation/home/bin/pnpx + /activation/home/bin/yarn + /activation/home/bin/yarnpkg + /activation/home/bin/bun + /activation/home/bin/bunx + /activation/home/bin/vpx + /activation/home/bin/vpr + + Use --refresh to update existing shims. + +Next Steps: + Activate Vite+ in this terminal: + . "/activation/home/env" + + Or open a new terminal to load your configured shell profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. Same terminal: $ command -v node /activation/system/node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md deleted file mode 100644 index 7189124371..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh_linux.md +++ /dev/null @@ -1,59 +0,0 @@ -# activation_external_zsh_linux - -## `node verify.mjs external zsh` - -``` -First setup: -$ vp env list node -Setup: - Preparing vite-plus environment. - -Created Shims: - /activation/home/bin/node - /activation/home/bin/npm - /activation/home/bin/npx - /activation/home/bin/pnpm - /activation/home/bin/pnpx - /activation/home/bin/yarn - /activation/home/bin/yarnpkg - /activation/home/bin/bun - /activation/home/bin/bunx - /activation/home/bin/vpx - /activation/home/bin/vpr - -Next Steps: - Activate Vite+ in this terminal: - . "/activation/home/env" - - Add the command for your shell to its profile to activate future terminals. - If setup already updated your profile, you can open a new terminal instead. - - For IDE support (VS Code, Cursor), ensure bin directory is in system PATH: - - Linux: Add to ~/.profile for display manager integration - - Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. -✓ Vite+ setup complete. -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -Same terminal: -$ command -v node -/activation/system/node -$ node --version -system-node -$ . "/activation/home/env" -$ command -v node -/activation/home/bin/node -$ node --version - -$ vp env list node -VITE+ - The Unified Toolchain for the Web - -Node.js - * current - -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md index 507cba854c..6881f210b1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_fish.md @@ -6,7 +6,9 @@ First setup: Activate Vite+ in this terminal: source "/activation/home space 'quote' \$cash `tick` \"double\" \\slash/env.fish" - If setup already updated your profile, you can open a new terminal instead. + Add the command for your shell to its profile to activate future terminals. +Setup with an existing profile entry: + Or open a new terminal to load your configured shell profile. Same terminal: $ command -v node /activation/system/node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md index 67bd8d7dee..cac7be49f9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md @@ -6,7 +6,9 @@ First setup: Activate Vite+ in this terminal: . "/activation/user/.vite-plus/env" - If setup already updated your profile, you can open a new terminal instead. + Add the command for your shell to its profile to activate future terminals. +Setup with an existing profile entry: + Or open a new terminal to load your configured shell profile. Same terminal: $ command -v node /activation/system/node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md index 86be29c9ee..4de84270c2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md @@ -6,7 +6,9 @@ First setup: Activate Vite+ in this terminal: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" - If setup already updated your profile, you can open a new terminal instead. + Or open a new terminal to load your configured shell profile. +Setup with an existing profile entry: + Or open a new terminal to load your configured shell profile. Same terminal: $ command -v node /activation/system/node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md index 3c9d5f95a7..4b14b15b49 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_nushell.md @@ -6,7 +6,9 @@ First setup: Activate Vite+ in this terminal: source "/activation/home space 'quote' $cash `tick` \"double\" \\slash/env.nu" - If setup already updated your profile, you can open a new terminal instead. + Add the command for your shell to its profile to activate future terminals. +Setup with an existing profile entry: + Or open a new terminal to load your configured shell profile. Same terminal: $ which node /activation/system/node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md index 8c1d512881..1a348c1498 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md @@ -6,7 +6,9 @@ First setup: Activate Vite+ in this terminal: . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" - If setup already updated your profile, you can open a new terminal instead. + Add the command for your shell to its profile to activate future terminals. +Setup with an existing profile entry: + Or open a new terminal to load your configured shell profile. Same terminal: $ command -v node /activation/system/node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs index 73e23f4c6d..812f17ea3a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs @@ -16,6 +16,9 @@ for (const key of Object.keys(env)) { } Object.assign(env, { HOME: path.join(root, 'user'), + ZDOTDIR: path.join(root, 'user/zsh'), + XDG_CONFIG_HOME: path.join(root, 'user/.config'), + XDG_DATA_HOME: path.join(root, 'user/.local/share'), VP_CLI_TEST: '1', VP_NO_UPDATE_CHECK: '1', VP_SELF_SETUP_NO_MODIFY_PATH: '1', @@ -83,6 +86,17 @@ fs.writeFileSync('.node-version', version); const system = path.join(root, 'system'); fs.mkdirSync(system); fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); +// Resolve the shell using the runner PATH before switching to the stale PATH. +const shellBin = process.env.PATH.split(path.delimiter) + .map((dir) => path.join(dir, shell)) + .find((file) => fs.existsSync(file)); +assert.ok(shellBin, shell); +const automaticProfile = kind === 'managed' && shell === 'bash'; +if (automaticProfile) { + delete env.VP_SELF_SETUP_NO_MODIFY_PATH; + fs.writeFileSync(path.join(env.HOME, '.bashrc'), '# Existing shell configuration\n'); + fs.symlinkSync(shellBin, path.join(system, 'bash')); +} env.PATH = system; console.log('First setup:'); let activation; @@ -97,6 +111,8 @@ if (kind === 'external') { const first = captured(['env', 'list', 'node']); const setup = first.stderr.replace(/\u001b\[[0-9;]*m/g, ''); assert.match(setup, /Vite\+ setup complete/); + if (automaticProfile) assert.doesNotMatch(setup, /Add the command/); + else assert.match(setup, /Add the command/); activation = setup .split('\n') .find((line) => line.trim().startsWith(shell === 'fish' || shell === 'nu' ? 'source "' : '. "')) @@ -105,15 +121,34 @@ if (kind === 'external') { console.log( setup .split('\n') - .filter((line) => /Activate Vite\+|^ \. |^ source |new terminal/.test(line)) + .filter((line) => /Activate Vite\+|^ \. |^ source |new terminal|Add the command/.test(line)) .join('\n'), ); } -// Resolve the shell using the runner PATH before switching to the stale PATH. -const shellBin = process.env.PATH.split(path.delimiter) - .map((dir) => path.join(dir, shell)) - .find((file) => fs.existsSync(file)); -assert.ok(shellBin, shell); +const profile = + shell === 'zsh' + ? path.join(env.ZDOTDIR, '.zshrc') + : shell === 'fish' + ? path.join(env.XDG_CONFIG_HOME, 'fish/config.fish') + : shell === 'nu' + ? path.join(env.XDG_CONFIG_HOME, 'nushell/config.nu') + : path.join(env.HOME, '.bashrc'); +if (automaticProfile) { + assert.ok(fs.readFileSync(profile, 'utf8').includes(activation)); +} else { + fs.mkdirSync(path.dirname(profile), { recursive: true }); + fs.writeFileSync(profile, activation + '\n'); +} +console.log('Setup with an existing profile entry:'); +if (kind === 'external') { + console.log('$ vp env setup'); + terminal(binary, ['env', 'setup']); +} else { + const repeated = captured(['env', 'setup']).stdout.replace(/\u001b\[[0-9;]*m/g, ''); + assert.doesNotMatch(repeated, /Add the command/); + assert.match(repeated, /open a new terminal/); + console.log(repeated.split('\n').find((line) => line.includes('open a new terminal'))); +} Object.assign(env, { ACTIVATION_BIN: bin, ACTIVATION_COMMAND: activation, diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 7dadf9a9cf..218de3fe27 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -25,7 +25,7 @@ use crate::{ commands::{ env::{bin_config::BinConfig, package_metadata::PackageMetadata}, global::{LEGACY_PACKAGE_MANAGER_PACKAGES, install::uninstall}, - shell::Shell, + shell::{ALL_SHELL_PROFILES, Shell, ShellProfileRoot, resolve_profile_path}, }, error::Error, help, @@ -1192,6 +1192,66 @@ async fn create_env_files() -> Result<(), Error> { Ok(()) } +/// Inspect the profiles setup writes, without changing them or using a saved flag. +fn has_configured_profile(config: &vp_shared::EnvConfig) -> bool { + let shell = config.vp_shell.as_deref().map(str::to_ascii_lowercase); + ALL_SHELL_PROFILES.iter().any(|profile| { + let relevant = match shell.as_deref() { + Some("zsh") => matches!(profile.root, ShellProfileRoot::Zsh), + Some("bash") => matches!(profile.root, ShellProfileRoot::Home), + Some("sh") => profile.path == ".profile", + Some("fish") => matches!(profile.root, ShellProfileRoot::Fish), + Some("nu" | "nushell") => profile.env_file == "env.nu", + _ => true, + }; + if !relevant { + return false; + } + let path = resolve_profile_path(profile, &config.user_home); + let Ok(content) = std::fs::read_to_string(path) else { return false }; + let env_file = config.dirs.config.join(profile.env_file); + let absolute = env_file.to_string(); + let relative = render_home_relative_path(env_file.as_path(), config.user_home.as_path()); + let escape = match profile.env_file { + "env.fish" => escape_fish_double_quoted_string, + "env.nu" => escape_nu_double_quoted_string, + _ => escape_posix_double_quoted_string, + }; + let relative = if profile.env_file == "env.nu" { + escape(&render_nu_path_ref(&relative)) + } else { + escape_home_relative_double_quoted_path(&relative, escape) + }; + let mut arguments = vec![ + format!("\"{}\"", escape(&absolute)), + format!("'{absolute}'"), + absolute, + format!("\"{relative}\""), + format!("\"{}\"", relative.replacen("$HOME", "${HOME}", 1)), + render_nu_path_ref(&relative), + ]; + if profile.env_file == "env.nu" { + let relative = + render_home_relative_path(env_file.as_path(), config.user_home.as_path()); + arguments.push(format!("'{}'", render_nu_path_ref(&relative))); + } + content.lines().any(|line| { + let line = line.trim_start(); + let Some(argument) = line.strip_prefix(". ").or_else(|| line.strip_prefix("source ")) + else { + return false; + }; + arguments.iter().any(|expected| { + argument.trim_start().strip_prefix(expected).is_some_and(|rest| { + rest.is_empty() + || rest.starts_with(char::is_whitespace) + || rest.starts_with(';') + }) + }) + }) + }) +} + /// Print instructions for sourcing the environment files and adding bin to `PATH`. fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { output::raw(&help::render_heading("Next Steps")); @@ -1238,27 +1298,18 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { } } output::raw(""); - output::raw(" Add the command for your shell to its profile to activate future terminals."); - output::raw(" If setup already updated your profile, you can open a new terminal instead."); - output::raw(""); - output::raw(" For IDE support (VS Code, Cursor), ensure bin directory is in system PATH:"); - - #[cfg(target_os = "macos")] - { - output::raw(" - macOS: Add to ~/.profile or use launchd"); - } - - #[cfg(target_os = "linux")] - { - output::raw(" - Linux: Add to ~/.profile for display manager integration"); - } - - #[cfg(target_os = "windows")] + if (!cfg!(windows) || shell == Some(Shell::NuShell)) + && !matches!(shell, Some(Shell::Cmd | Shell::PowerShell)) { - output::raw(" - Windows: System Properties -> Environment Variables -> Path"); + if has_configured_profile(&env) { + output::raw(" Or open a new terminal to load your configured shell profile."); + } else { + output::raw( + " Add the command for your shell to its profile to activate future terminals.", + ); + } + output::raw(""); } - - output::raw(""); output::raw(&format!( " Restart an already-running IDE to load its environment. Run {} to verify.", help::accent_command("vp env doctor") @@ -1293,6 +1344,50 @@ mod tests { trampoline } + #[test] + #[cfg(not(windows))] + fn test_configured_profile_checks_the_selected_shell_and_current_install() { + let temp = TempDir::new().unwrap(); + let home = temp.path().join("user"); + let vp_home = home.join(".vite-plus"); + let zsh = temp.path().join("zsh"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&zsh).unwrap(); + vp_shared::EnvConfig::with_vars( + [ + ("HOME", home.as_os_str()), + ("USERPROFILE", home.as_os_str()), + ("VP_HOME", vp_home.as_os_str()), + ("VP_SHELL", std::ffi::OsStr::new("zsh")), + ("ZDOTDIR", zsh.as_os_str()), + ], + |config| { + assert!(!has_configured_profile(&config)); + std::fs::write(home.join(".bashrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + assert!(!has_configured_profile(&config), "Bash does not configure Zsh"); + let profile = zsh.join(".zshrc"); + for line in [ + "# . \"$HOME/.vite-plus/env\"", + ". \"$HOME/other-install/env\"", + ". \"$HOME/.vite-plus/env.fish\"", + ] { + std::fs::write(&profile, line).unwrap(); + assert!(!has_configured_profile(&config), "{line}"); + } + for line in [ + ". \"$HOME/.vite-plus/env\"", + "source \"${HOME}/.vite-plus/env\" # existing setup", + ". ~/.vite-plus/env", + ] { + std::fs::write(&profile, line).unwrap(); + assert!(has_configured_profile(&config), "{line}"); + } + std::fs::remove_file(&profile).unwrap(); + assert!(!has_configured_profile(&config), "Do not cache profile state"); + }, + ); + } + #[test] fn test_render_env_content_does_not_export_split_dir_group() { let temp_dir = TempDir::new().unwrap(); From 071770eb977c9af2a49148b181cabab9076509e9 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 11:23:08 +0800 Subject: [PATCH 05/18] refactor(env): simplify setup guidance and fixtures --- .../command_env_activation/verify.mjs | 64 +++++++------ .../vp_global_cli/src/commands/env/setup.rs | 89 ++++++++++--------- 2 files changed, 81 insertions(+), 72 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs index 812f17ea3a..134bdba4fe 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs @@ -55,7 +55,8 @@ if (kind === 'xdg') { } else { env.VP_HOME = path.join(root, kind === 'external' ? 'home' : 'home ' + special); } -function captured(args, extra = {}) { +/** @returns {import('node:child_process').SpawnSyncReturns} */ +function captureVp(args, extra = {}) { const result = spawnSync(binary, args, { env: { ...env, ...extra }, encoding: 'utf8', @@ -64,20 +65,19 @@ function captured(args, extra = {}) { assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); return result; } -function terminal(executable, args) { +/** @returns {void} */ +function runInTerminal(executable, args) { const result = spawnSync(executable, args, { env, stdio: 'inherit', timeout: 30000 }); assert.equal(result.status, 0, result.error?.message); } const dirs = Object.fromEntries( - captured([], { VP_DUMP_DIRS: '1' }) + captureVp([], { VP_DUMP_DIRS: '1' }) .stdout.trim() .split('\n') .map((line) => line.split('\t')), ); // Query the authoritative resolver instead of assuming a platform's directory layout. -const bin = dirs.bin; -const config = dirs.config; -const data = dirs.data; +const { bin, config, data } = dirs; assert.ok(bin && config && data, JSON.stringify(dirs)); const runtime = path.join(data, 'js_runtime/node', version, 'bin'); fs.mkdirSync(runtime, { recursive: true }); @@ -102,20 +102,24 @@ console.log('First setup:'); let activation; if (kind === 'external') { console.log('$ vp env list node'); - terminal(binary, ['env', 'list', 'node']); + runInTerminal(binary, ['env', 'list', 'node']); // The full PTY snapshot records the printed command. Other cases also parse // the guidance and execute it, so escaping remains covered independently. const escaped = path.join(config, 'env').replace(/[\\$`"]/g, '\\$&'); activation = `. "${escaped}"`; } else { - const first = captured(['env', 'list', 'node']); + const first = captureVp(['env', 'list', 'node']); const setup = first.stderr.replace(/\u001b\[[0-9;]*m/g, ''); assert.match(setup, /Vite\+ setup complete/); - if (automaticProfile) assert.doesNotMatch(setup, /Add the command/); - else assert.match(setup, /Add the command/); + if (automaticProfile) { + assert.doesNotMatch(setup, /Add the command/); + } else { + assert.match(setup, /Add the command/); + } + const sourcePrefix = shell === 'fish' || shell === 'nu' ? 'source "' : '. "'; activation = setup .split('\n') - .find((line) => line.trim().startsWith(shell === 'fish' || shell === 'nu' ? 'source "' : '. "')) + .find((line) => line.trim().startsWith(sourcePrefix)) ?.trim(); assert.ok(activation, setup); console.log( @@ -125,14 +129,20 @@ if (kind === 'external') { .join('\n'), ); } -const profile = - shell === 'zsh' - ? path.join(env.ZDOTDIR, '.zshrc') - : shell === 'fish' - ? path.join(env.XDG_CONFIG_HOME, 'fish/config.fish') - : shell === 'nu' - ? path.join(env.XDG_CONFIG_HOME, 'nushell/config.nu') - : path.join(env.HOME, '.bashrc'); +let profile; +switch (shell) { + case 'zsh': + profile = path.join(env.ZDOTDIR, '.zshrc'); + break; + case 'fish': + profile = path.join(env.XDG_CONFIG_HOME, 'fish/config.fish'); + break; + case 'nu': + profile = path.join(env.XDG_CONFIG_HOME, 'nushell/config.nu'); + break; + default: + profile = path.join(env.HOME, '.bashrc'); +} if (automaticProfile) { assert.ok(fs.readFileSync(profile, 'utf8').includes(activation)); } else { @@ -142,9 +152,9 @@ if (automaticProfile) { console.log('Setup with an existing profile entry:'); if (kind === 'external') { console.log('$ vp env setup'); - terminal(binary, ['env', 'setup']); + runInTerminal(binary, ['env', 'setup']); } else { - const repeated = captured(['env', 'setup']).stdout.replace(/\u001b\[[0-9;]*m/g, ''); + const repeated = captureVp(['env', 'setup']).stdout.replace(/\u001b\[[0-9;]*m/g, ''); assert.doesNotMatch(repeated, /Add the command/); assert.match(repeated, /open a new terminal/); console.log(repeated.split('\n').find((line) => line.includes('open a new terminal'))); @@ -155,17 +165,15 @@ Object.assign(env, { }); console.log('Same terminal:'); if (shell === 'fish') { - terminal(shellBin, ['--no-config', 'session.fish']); + runInTerminal(shellBin, ['--no-config', 'session.fish']); } else if (shell === 'nu') { fs.writeFileSync( 'activate.nu', fs.readFileSync('session.nu', 'utf8').replaceAll('__ACTIVATION_COMMAND__', activation), ); - terminal(shellBin, ['--no-config-file', 'activate.nu']); + runInTerminal(shellBin, ['--no-config-file', 'activate.nu']); +} else if (shell === 'bash') { + runInTerminal(shellBin, ['--noprofile', '--norc', 'session.sh']); } else { - terminal(shellBin, [ - shell === 'bash' ? '--noprofile' : '-f', - ...(shell === 'bash' ? ['--norc'] : []), - 'session.sh', - ]); + runInTerminal(shellBin, ['-f', 'session.sh']); } diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 218de3fe27..40f3db5c05 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -55,6 +55,16 @@ impl EnvShell { EnvShell::Powershell => "env.ps1", } } + + fn source_command(self, env_dir: &vt_path::AbsolutePath) -> String { + let path = env_dir.join(self.env_file_name()).to_string(); + match self { + Self::Posix => format!(". \"{}\"", escape_posix_double_quoted_string(&path)), + Self::Fish => format!("source \"{}\"", escape_fish_double_quoted_string(&path)), + Self::Nu => format!("source \"{}\"", escape_nu_double_quoted_string(&path)), + Self::Powershell => format!(". '{}'", escape_powershell_single_quoted_string(&path)), + } + } } /// Execute the setup command. @@ -1195,7 +1205,7 @@ async fn create_env_files() -> Result<(), Error> { /// Inspect the profiles setup writes, without changing them or using a saved flag. fn has_configured_profile(config: &vp_shared::EnvConfig) -> bool { let shell = config.vp_shell.as_deref().map(str::to_ascii_lowercase); - ALL_SHELL_PROFILES.iter().any(|profile| { + for profile in ALL_SHELL_PROFILES { let relevant = match shell.as_deref() { Some("zsh") => matches!(profile.root, ShellProfileRoot::Zsh), Some("bash") => matches!(profile.root, ShellProfileRoot::Home), @@ -1205,51 +1215,54 @@ fn has_configured_profile(config: &vp_shared::EnvConfig) -> bool { _ => true, }; if !relevant { - return false; + continue; } let path = resolve_profile_path(profile, &config.user_home); - let Ok(content) = std::fs::read_to_string(path) else { return false }; + let Ok(content) = std::fs::read_to_string(path) else { continue }; let env_file = config.dirs.config.join(profile.env_file); let absolute = env_file.to_string(); - let relative = render_home_relative_path(env_file.as_path(), config.user_home.as_path()); + let home_relative = + render_home_relative_path(env_file.as_path(), config.user_home.as_path()); let escape = match profile.env_file { "env.fish" => escape_fish_double_quoted_string, "env.nu" => escape_nu_double_quoted_string, _ => escape_posix_double_quoted_string, }; - let relative = if profile.env_file == "env.nu" { - escape(&render_nu_path_ref(&relative)) + let escaped_relative = if profile.env_file == "env.nu" { + escape(&render_nu_path_ref(&home_relative)) } else { - escape_home_relative_double_quoted_path(&relative, escape) + escape_home_relative_double_quoted_path(&home_relative, escape) }; let mut arguments = vec![ format!("\"{}\"", escape(&absolute)), format!("'{absolute}'"), absolute, - format!("\"{relative}\""), - format!("\"{}\"", relative.replacen("$HOME", "${HOME}", 1)), - render_nu_path_ref(&relative), + format!("\"{escaped_relative}\""), + format!("\"{}\"", escaped_relative.replacen("$HOME", "${HOME}", 1)), + render_nu_path_ref(&escaped_relative), ]; if profile.env_file == "env.nu" { - let relative = - render_home_relative_path(env_file.as_path(), config.user_home.as_path()); - arguments.push(format!("'{}'", render_nu_path_ref(&relative))); + arguments.push(format!("'{}'", render_nu_path_ref(&home_relative))); } - content.lines().any(|line| { + for line in content.lines() { let line = line.trim_start(); let Some(argument) = line.strip_prefix(". ").or_else(|| line.strip_prefix("source ")) else { - return false; + continue; }; - arguments.iter().any(|expected| { - argument.trim_start().strip_prefix(expected).is_some_and(|rest| { + let argument = argument.trim_start(); + if arguments.iter().any(|expected| { + argument.strip_prefix(expected).is_some_and(|rest| { rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with(';') }) - }) - }) - }) + }) { + return true; + } + } + } + false } /// Print instructions for sourcing the environment files and adding bin to `PATH`. @@ -1260,36 +1273,21 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { // SHELL describes the login shell, which may differ from this terminal. let shell = env.vp_shell.as_deref().and_then(|s| s.parse().ok()); let commands = [ - (Shell::Posix, "Bash/Zsh", "env"), - (Shell::Fish, "Fish", "env.fish"), - (Shell::NuShell, "Nushell", "env.nu"), - (Shell::PowerShell, "PowerShell", "env.ps1"), + (Shell::Posix, "Bash/Zsh", EnvShell::Posix), + (Shell::Fish, "Fish", EnvShell::Fish), + (Shell::NuShell, "Nushell", EnvShell::Nu), + (Shell::PowerShell, "PowerShell", EnvShell::Powershell), ]; if shell == Some(Shell::Cmd) { // cmd has no sourceable environment file. A new terminal inherits the // persistent PATH written by setup. output::raw(" In cmd.exe, open a new terminal to load the updated PATH."); } else { - for (kind, label, file) in commands { + for (kind, label, env_shell) in commands { if shell.is_some_and(|s| s != kind) { continue; } - let path = env_dir.join(file).to_string(); - let command = match kind { - Shell::Posix => { - format!(". \"{}\"", escape_posix_double_quoted_string(&path)) - } - Shell::Fish => { - format!("source \"{}\"", escape_fish_double_quoted_string(&path)) - } - Shell::NuShell => { - format!("source \"{}\"", escape_nu_double_quoted_string(&path)) - } - Shell::PowerShell => { - format!(". '{}'", escape_powershell_single_quoted_string(&path)) - } - Shell::Cmd => unreachable!(), - }; + let command = env_shell.source_command(env_dir); if shell.is_some() { output::raw(&format!(" {command}")); } else { @@ -1298,9 +1296,12 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { } } output::raw(""); - if (!cfg!(windows) || shell == Some(Shell::NuShell)) - && !matches!(shell, Some(Shell::Cmd | Shell::PowerShell)) - { + let supports_profile_check = match shell { + Some(Shell::Cmd | Shell::PowerShell) => false, + Some(Shell::NuShell) => true, + _ => !cfg!(windows), + }; + if supports_profile_check { if has_configured_profile(&env) { output::raw(" Or open a new terminal to load your configured shell profile."); } else { From 4a547e0436d0c815b0114543fbcd1090ad82aba4 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 13:24:05 +0800 Subject: [PATCH 06/18] fix(env): qualify shell profile activation guidance --- .../profile-guidance.mjs | 121 ++++++++++++++++++ .../command_env_activation/snapshots.toml | 12 ++ .../snapshots/activation_legacy_bash.md | 5 +- .../snapshots/activation_managed_bash.md | 5 +- .../activation_powershell_profile_guidance.md | 10 ++ .../snapshots/activation_profile_guidance.md | 63 +++++++++ .../snapshots/activation_xdg_bash.md | 5 +- .../command_env_activation/verify.mjs | 10 +- .../vp_global_cli/src/commands/env/setup.rs | 85 ++++++++++-- 9 files changed, 293 insertions(+), 23 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_powershell_profile_guidance.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs new file mode 100644 index 0000000000..061a226393 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const mode = process.argv[2]; +const home = path.resolve('profiles/user'); +const binary = path.join( + process.env.VP_HOME, + 'current/bin', + process.platform === 'win32' ? 'vp.exe' : 'vp', +); +const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + ZDOTDIR: path.join(home, 'zsh'), + XDG_CONFIG_HOME: path.join(home, '.config'), + XDG_DATA_HOME: path.join(home, '.local/share'), + VP_SELF_SETUP_NO_MODIFY_PATH: '1', +}; +// Emulate a fresh terminal, without tool delegation state from this Node process. +delete env.VP_PATH_INJECTED_TOOLS; +delete env.VP_BYPASS; +delete env.VP_SHELL; +fs.mkdirSync(home, { recursive: true }); +fs.writeFileSync('.node-version', '22.18.0\n'); + +/** @returns {string} */ +function captureVp(args, extra = {}) { + const result = spawnSync(binary, args, { + env: { ...env, ...extra }, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + return result.stdout.replace(/\u001b\[[0-9;]*m/g, '').replaceAll('\r\n', '\n'); +} +const dirs = Object.fromEntries( + captureVp([], { VP_DUMP_DIRS: '1' }) + .trim() + .split('\n') + .map((line) => line.split('\t')), +); + +/** @returns {string} */ +function setup(shell) { + if (shell === undefined) delete env.VP_SHELL; + else env.VP_SHELL = shell; + console.log(`VP_SHELL=${shell ?? ''}:`); + const output = captureVp(['env', 'setup']); + const heading = output.indexOf('Next Steps:\n'); + assert.ok(heading >= 0, output); + return output.slice(heading).trimEnd(); +} + +if (mode === 'powershell') { + for (const shell of ['pwsh', undefined]) { + const output = setup(shell); + assert.match(output, /\. '[^\n]*env\.ps1'/); + assert.match(output, /\$PROFILE if it is not already there/); + assert.doesNotMatch(output, /Or open a new terminal/); + console.log(output.split('\n').find((line) => line.includes('$PROFILE'))); + } +} else { + captureVp(['env', 'on', 'node']); + const bash = process.env.PATH.split(path.delimiter) + .map((dir) => path.join(dir, 'bash')) + .find((file) => fs.existsSync(file)); + assert.ok(bash); + env.SHELL = '/bin/bash'; + const system = path.resolve('profiles/system'); + fs.mkdirSync(system); + fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); + env.PATH = system; + const envPath = path.join(dirs.config, 'env').replace(/[\\$`"]/g, '\\$&'); + const source = `. "${envPath}"\n`; + const fish = path.join(env.XDG_CONFIG_HOME, 'fish/config.fish'); + fs.mkdirSync(path.dirname(fish), { recursive: true }); + fs.writeFileSync(fish, `source "${path.join(dirs.config, 'env.fish')}"\n`); + + /** @returns {void} */ + function freshBash(expectedNode) { + console.log("$ bash --noprofile -ic 'command -v node; node --version'"); + const result = spawnSync( + bash, + [ + '--noprofile', + '-ic', + 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version', + ], + { env: { ...env, EXPECTED_NODE: expectedNode }, stdio: 'inherit', timeout: 30000 }, + ); + assert.equal(result.status, 0, result.error?.message); + } + + console.log('Only Fish is configured:'); + for (const shell of [undefined, 'unrecognized']) { + const output = setup(shell); + assert.match(output, /If your shell profile does not already load Vite\+/); + assert.doesNotMatch(output, /Or open a new terminal/); + console.log(output); + freshBash(path.join(system, 'node')); + } + + console.log('Only the Bash login profile is configured:'); + fs.writeFileSync(path.join(home, '.bash_profile'), source); + const loginOnly = setup('bash'); + assert.match(loginOnly, /Add the command to ~\/\.bashrc/); + assert.doesNotMatch(loginOnly, /Or open a new terminal/); + console.log(loginOnly); + freshBash(path.join(system, 'node')); + + console.log('Bash .bashrc is configured:'); + fs.writeFileSync(path.join(home, '.bashrc'), source); + const interactive = setup('bash'); + assert.doesNotMatch(interactive, /Add the command/); + assert.match(interactive, /Or start an interactive non-login Bash shell/); + console.log(interactive); + freshBash(path.join(dirs.bin, 'node')); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml index b2f637d638..5f8ee82e5a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml @@ -39,3 +39,15 @@ vp = "global" skip-platforms = ["windows"] requires = ["nu"] steps = [["node", "verify.mjs", "managed", "nu"]] + +[[case]] +name = "activation_profile_guidance" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [["node", "profile-guidance.mjs", "bash"]] + +[[case]] +name = "activation_powershell_profile_guidance" +vp = "global" +steps = [["node", "profile-guidance.mjs", "powershell"]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md index cac7be49f9..bd12cd8d4e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_legacy_bash.md @@ -6,9 +6,10 @@ First setup: Activate Vite+ in this terminal: . "/activation/user/.vite-plus/env" - Add the command for your shell to its profile to activate future terminals. + Add the command to ~/.bashrc for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. Setup with an existing profile entry: - Or open a new terminal to load your configured shell profile. + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. Same terminal: $ command -v node /activation/system/node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md index 4de84270c2..933dff10d9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_managed_bash.md @@ -6,9 +6,10 @@ First setup: Activate Vite+ in this terminal: . "/activation/home space 'quote' \$cash \`tick\` \"double\" \\slash/env" - Or open a new terminal to load your configured shell profile. + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. + Login Bash shells must also load the command through their login profile. Setup with an existing profile entry: - Or open a new terminal to load your configured shell profile. + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. Same terminal: $ command -v node /activation/system/node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_powershell_profile_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_powershell_profile_guidance.md new file mode 100644 index 0000000000..200bc76714 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_powershell_profile_guidance.md @@ -0,0 +1,10 @@ +# activation_powershell_profile_guidance + +## `node profile-guidance.mjs powershell` + +``` +VP_SHELL=pwsh: + Add this command to $PROFILE if it is not already there, for future PowerShell sessions. +VP_SHELL=: + For PowerShell, add its command to $PROFILE if it is not already there. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md new file mode 100644 index 0000000000..7b7033dee1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md @@ -0,0 +1,63 @@ +# activation_profile_guidance + +## `node profile-guidance.mjs bash` + +``` +Only Fish is configured: +VP_SHELL=: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + Fish: source "/.vite-plus/env.fish" + Nushell: source "/.vite-plus/env.nu" + PowerShell: . '/.vite-plus/env.ps1' + + If your shell profile does not already load Vite+, add the command for your shell. + For PowerShell, add its command to $PROFILE if it is not already there. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ bash --noprofile -ic 'command -v node; node --version' +/profiles/system/node +system-node +VP_SHELL=unrecognized: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + Fish: source "/.vite-plus/env.fish" + Nushell: source "/.vite-plus/env.nu" + PowerShell: . '/.vite-plus/env.ps1' + + If your shell profile does not already load Vite+, add the command for your shell. + For PowerShell, add its command to $PROFILE if it is not already there. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ bash --noprofile -ic 'command -v node; node --version' +/profiles/system/node +system-node +Only the Bash login profile is configured: +VP_SHELL=bash: +Next Steps: + Activate Vite+ in this terminal: + . "/.vite-plus/env" + + Add the command to ~/.bashrc for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ bash --noprofile -ic 'command -v node; node --version' +/profiles/system/node +system-node +Bash .bashrc is configured: +VP_SHELL=bash: +Next Steps: + Activate Vite+ in this terminal: + . "/.vite-plus/env" + + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. + Login Bash shells must also load the command through their login profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ bash --noprofile -ic 'command -v node; node --version' +/.vite-plus/bin/node + +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md index 1a348c1498..df5c2303f4 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_xdg_bash.md @@ -6,9 +6,10 @@ First setup: Activate Vite+ in this terminal: . "/activation/config space 'quote' \$cash \`tick\` \"double\" \\slash/vite-plus/env" - Add the command for your shell to its profile to activate future terminals. + Add the command to ~/.bashrc for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. Setup with an existing profile entry: - Or open a new terminal to load your configured shell profile. + Or start an interactive non-login Bash shell to load your configured ~/.bashrc. Same terminal: $ command -v node /activation/system/node diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs index 134bdba4fe..19d2e21203 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/verify.mjs @@ -125,7 +125,9 @@ if (kind === 'external') { console.log( setup .split('\n') - .filter((line) => /Activate Vite\+|^ \. |^ source |new terminal|Add the command/.test(line)) + .filter((line) => + /Activate Vite\+|^ \. |^ source |new terminal|Add the command|Bash shell/.test(line), + ) .join('\n'), ); } @@ -156,8 +158,10 @@ if (kind === 'external') { } else { const repeated = captureVp(['env', 'setup']).stdout.replace(/\u001b\[[0-9;]*m/g, ''); assert.doesNotMatch(repeated, /Add the command/); - assert.match(repeated, /open a new terminal/); - console.log(repeated.split('\n').find((line) => line.includes('open a new terminal'))); + const configuredAdvice = + shell === 'bash' ? 'interactive non-login Bash shell' : 'open a new terminal'; + assert.ok(repeated.includes(configuredAdvice), repeated); + console.log(repeated.split('\n').find((line) => line.includes(configuredAdvice))); } Object.assign(env, { ACTIVATION_BIN: bin, diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 40f3db5c05..4f9a873fbc 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1202,17 +1202,18 @@ async fn create_env_files() -> Result<(), Error> { Ok(()) } -/// Inspect the profiles setup writes, without changing them or using a saved flag. +/// Inspect the explicitly selected shell without changing its profiles. +/// Bash only checks non-login startup; the printed advice must keep that qualification. fn has_configured_profile(config: &vp_shared::EnvConfig) -> bool { let shell = config.vp_shell.as_deref().map(str::to_ascii_lowercase); for profile in ALL_SHELL_PROFILES { let relevant = match shell.as_deref() { Some("zsh") => matches!(profile.root, ShellProfileRoot::Zsh), - Some("bash") => matches!(profile.root, ShellProfileRoot::Home), + Some("bash") => profile.path == ".bashrc", Some("sh") => profile.path == ".profile", Some("fish") => matches!(profile.root, ShellProfileRoot::Fish), Some("nu" | "nushell") => profile.env_file == "env.nu", - _ => true, + _ => false, }; if !relevant { continue; @@ -1296,20 +1297,48 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { } } output::raw(""); - let supports_profile_check = match shell { - Some(Shell::Cmd | Shell::PowerShell) => false, - Some(Shell::NuShell) => true, - _ => !cfg!(windows), - }; - if supports_profile_check { - if has_configured_profile(&env) { - output::raw(" Or open a new terminal to load your configured shell profile."); - } else { + match shell { + Some(Shell::Cmd) => {} + None => { + output::raw( + " If your shell profile does not already load Vite+, add the command for your shell.", + ); output::raw( - " Add the command for your shell to its profile to activate future terminals.", + " For PowerShell, add its command to $PROFILE if it is not already there.", ); + output::raw(""); + } + Some(Shell::PowerShell) => { + output::raw( + " Add this command to $PROFILE if it is not already there, for future PowerShell sessions.", + ); + output::raw(""); + } + _ if env.vp_shell.as_deref().is_some_and(|s| s.eq_ignore_ascii_case("bash")) => { + if has_configured_profile(&env) { + output::raw( + " Or start an interactive non-login Bash shell to load your configured ~/.bashrc.", + ); + } else { + output::raw( + " Add the command to ~/.bashrc for interactive non-login Bash sessions.", + ); + } + output::raw( + " Login Bash shells must also load the command through their login profile.", + ); + output::raw(""); + } + _ => { + if has_configured_profile(&env) { + output::raw(" Or open a new terminal to load your configured shell profile."); + } else { + output::raw( + " Add the command for your shell to its profile to activate future terminals.", + ); + } + output::raw(""); } - output::raw(""); } output::raw(&format!( " Restart an already-running IDE to load its environment. Run {} to verify.", @@ -1389,6 +1418,34 @@ mod tests { ); } + #[test] + #[cfg(not(windows))] + fn test_profile_check_does_not_infer_unknown_shell_or_bash_startup_mode() { + let temp = TempDir::new().unwrap(); + let home = temp.path().join("user"); + let vp_home = home.join(".vite-plus"); + let xdg = home.join(".config"); + std::fs::create_dir_all(xdg.join("fish")).unwrap(); + std::fs::write(xdg.join("fish/config.fish"), "source \"$HOME/.vite-plus/env.fish\"\n") + .unwrap(); + std::fs::write(home.join(".bash_profile"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + for shell in [None, Some("unknown"), Some("bash")] { + vp_shared::EnvConfig::with_vars( + [ + ("HOME", Some(home.as_os_str())), + ("USERPROFILE", Some(home.as_os_str())), + ("VP_HOME", Some(vp_home.as_os_str())), + ("XDG_CONFIG_HOME", Some(xdg.as_os_str())), + ("VP_SHELL", shell.map(std::ffi::OsStr::new)), + ("SHELL", Some(std::ffi::OsStr::new("/bin/bash"))), + ], + |config| { + assert!(!has_configured_profile(&config), "{shell:?}"); + }, + ); + } + } + #[test] fn test_render_env_content_does_not_export_split_dir_group() { let temp_dir = TempDir::new().unwrap(); From d3e7151d5b4f8450915bad5e6715e9248d33b68f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 13:59:37 +0800 Subject: [PATCH 07/18] test(env): isolate interactive Bash profile sessions --- .../profile-guidance.mjs | 44 ++++++++++--------- .../command_env_activation/snapshots.toml | 8 +++- .../snapshots/activation_profile_guidance.md | 18 +++++++- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs index 061a226393..dbce520a26 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -70,9 +70,10 @@ if (mode === 'powershell') { assert.ok(bash); env.SHELL = '/bin/bash'; const system = path.resolve('profiles/system'); - fs.mkdirSync(system); + fs.mkdirSync(system, { recursive: true }); fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); - env.PATH = system; + // Keep system startup helpers available, with the fake Node first on PATH. + env.PATH = [system, '/usr/bin', '/bin'].join(path.delimiter); const envPath = path.join(dirs.config, 'env').replace(/[\\$`"]/g, '\\$&'); const source = `. "${envPath}"\n`; const fish = path.join(env.XDG_CONFIG_HOME, 'fish/config.fish'); @@ -94,28 +95,29 @@ if (mode === 'powershell') { assert.equal(result.status, 0, result.error?.message); } - console.log('Only Fish is configured:'); - for (const shell of [undefined, 'unrecognized']) { - const output = setup(shell); + if (mode === 'unset' || mode === 'unrecognized') { + console.log('Only Fish is configured:'); + const output = setup(mode === 'unset' ? undefined : mode); assert.match(output, /If your shell profile does not already load Vite\+/); assert.doesNotMatch(output, /Or open a new terminal/); console.log(output); freshBash(path.join(system, 'node')); + } else if (mode === 'bash-login') { + console.log('Only the Bash login profile is configured:'); + fs.writeFileSync(path.join(home, '.bash_profile'), source); + const output = setup('bash'); + assert.match(output, /Add the command to ~\/\.bashrc/); + assert.doesNotMatch(output, /Or open a new terminal/); + console.log(output); + freshBash(path.join(system, 'node')); + } else { + assert.equal(mode, 'bash-interactive'); + console.log('Bash .bashrc is configured:'); + fs.writeFileSync(path.join(home, '.bashrc'), source); + const output = setup('bash'); + assert.doesNotMatch(output, /Add the command/); + assert.match(output, /Or start an interactive non-login Bash shell/); + console.log(output); + freshBash(path.join(dirs.bin, 'node')); } - - console.log('Only the Bash login profile is configured:'); - fs.writeFileSync(path.join(home, '.bash_profile'), source); - const loginOnly = setup('bash'); - assert.match(loginOnly, /Add the command to ~\/\.bashrc/); - assert.doesNotMatch(loginOnly, /Or open a new terminal/); - console.log(loginOnly); - freshBash(path.join(system, 'node')); - - console.log('Bash .bashrc is configured:'); - fs.writeFileSync(path.join(home, '.bashrc'), source); - const interactive = setup('bash'); - assert.doesNotMatch(interactive, /Add the command/); - assert.match(interactive, /Or start an interactive non-login Bash shell/); - console.log(interactive); - freshBash(path.join(dirs.bin, 'node')); } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml index 5f8ee82e5a..eae7935c49 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml @@ -45,7 +45,13 @@ name = "activation_profile_guidance" vp = "global" skip-platforms = ["windows"] requires = ["bash"] -steps = [["node", "profile-guidance.mjs", "bash"]] +# Give each interactive Bash its own PTY so job control cannot affect the next shell. +steps = [ + ["node", "profile-guidance.mjs", "unset"], + ["node", "profile-guidance.mjs", "unrecognized"], + ["node", "profile-guidance.mjs", "bash-login"], + ["node", "profile-guidance.mjs", "bash-interactive"], +] [[case]] name = "activation_powershell_profile_guidance" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md index 7b7033dee1..4fad875bcc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md @@ -1,6 +1,6 @@ # activation_profile_guidance -## `node profile-guidance.mjs bash` +## `node profile-guidance.mjs unset` ``` Only Fish is configured: @@ -19,6 +19,12 @@ Next Steps: $ bash --noprofile -ic 'command -v node; node --version' /profiles/system/node system-node +``` + +## `node profile-guidance.mjs unrecognized` + +``` +Only Fish is configured: VP_SHELL=unrecognized: Next Steps: Activate Vite+ in this terminal: @@ -34,6 +40,11 @@ Next Steps: $ bash --noprofile -ic 'command -v node; node --version' /profiles/system/node system-node +``` + +## `node profile-guidance.mjs bash-login` + +``` Only the Bash login profile is configured: VP_SHELL=bash: Next Steps: @@ -47,6 +58,11 @@ Next Steps: $ bash --noprofile -ic 'command -v node; node --version' /profiles/system/node system-node +``` + +## `node profile-guidance.mjs bash-interactive` + +``` Bash .bashrc is configured: VP_SHELL=bash: Next Steps: From f371c5caa847257dcd946d98da1c5bd014a676dc Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 14:23:05 +0800 Subject: [PATCH 08/18] test(env): suppress Ubuntu startup hint in profile fixture --- .../fixtures/command_env_activation/profile-guidance.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs index dbce520a26..85eefd03f0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -63,6 +63,8 @@ if (mode === 'powershell') { console.log(output.split('\n').find((line) => line.includes('$PROFILE'))); } } else { + // Suppress Ubuntu's sudo hint while still loading the normal Bash startup files. + fs.writeFileSync(path.join(home, '.hushlogin'), ''); captureVp(['env', 'on', 'node']); const bash = process.env.PATH.split(path.delimiter) .map((dir) => path.join(dir, 'bash')) From c6d2fc397bf065e407ab1201f3537ff749fb9216 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 15:54:42 +0800 Subject: [PATCH 09/18] fix(env): preserve PowerShell activation state and priority --- .github/scripts/test-install-bootstrap.ps1 | 27 ++++++++- .github/workflows/ci.yml | 5 ++ .../command_env_powershell/assert.ps1 | 57 ++++++++++++++++++- .../command_env_powershell/snapshots.toml | 1 - .../vp_global_cli/src/commands/env/setup.rs | 31 ++++++---- packages/cli/install.ps1 | 6 +- 6 files changed, 110 insertions(+), 17 deletions(-) diff --git a/.github/scripts/test-install-bootstrap.ps1 b/.github/scripts/test-install-bootstrap.ps1 index fac546d5e0..a5fcbc2e1c 100644 --- a/.github/scripts/test-install-bootstrap.ps1 +++ b/.github/scripts/test-install-bootstrap.ps1 @@ -2,11 +2,36 @@ $ErrorActionPreference = 'Stop' $source = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../../packages/cli/install.ps1') -Raw . ([scriptblock]::Create(($source -replace '(?m)^ Main\r?$', ''))) -function Exit-Installer { param([int]$Code = 1); $script:ExitCode = $Code; throw $script:InstallStopSignal } function Assert($Condition, [string]$Message) { if (-not $Condition) { throw $Message } } +# Exercise the piped entry point without acquiring a payload. +function Test-InstallerEntryPoint { + Assert ($ErrorActionPreference -eq 'Stop') 'Installer did not enable terminating errors' + if ($entryPointFails) { throw 'Expected installer failure' } +} +$entryPointSource = $source -replace '(?m)^ Main\r?$', ' Test-InstallerEntryPoint' +try { + foreach ($preference in @('Continue', 'Stop')) { + foreach ($entryPointFails in @($false, $true)) { + $ErrorActionPreference = $preference + $caught = $false + try { + $entryPointSource | Invoke-Expression + } catch { + Assert ($_.Exception.Message -eq 'Expected installer failure') "Unexpected error: $_" + $caught = $true + } + Assert ($caught -eq $entryPointFails) 'Installer failure was lost' + Assert ($ErrorActionPreference -eq $preference) 'Installer changed the caller error preference' + } + } +} finally { + $ErrorActionPreference = 'Stop' +} +function Exit-Installer { param([int]$Code = 1); $script:ExitCode = $Code; throw $script:InstallStopSignal } + $testRoot = Join-Path $env:TEMP "vite-bootstrap-test-$(Get-Random)" $originalTemp = $env:TEMP $originalCheck = $env:VP_SELF_SETUP_SUPPORT_CHECK diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ed06c97b3..9a335bbc3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1243,6 +1243,11 @@ jobs: SystemTemp='${{ steps.snapshot-temp.outputs.directory }}' \ cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast \ -E 'test(windows_case_sensitive_shims)' || test_exit=$? + + # Exercise the same environment wrapper under Windows PowerShell 5.1. + VP_SNAP_PWSH_BIN="$(cygpath -w "$(command -v powershell.exe)")" \ + cargo-nextest nextest run --archive-file windows-snapshot-tests.tar.zst --workspace-remap . --no-fail-fast \ + -E 'test(command_env_powershell)' || test_exit=$? fi exit "$test_exit" env: diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 index 60b039fa4b..6622ec48e3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 @@ -1,16 +1,34 @@ $ErrorActionPreference = "Stop" +$expectedBin = Join-Path $env:EXPECTED_VP_HOME "bin" +$externalBin = Join-Path $PWD "external-node" +New-Item -ItemType Directory -Path $externalBin | Out-Null +$node = Get-Command node -CommandType Application | Select-Object -First 1 +Copy-Item -LiteralPath $node.Source -Destination $externalBin +$externalNode = Join-Path $externalBin (Split-Path $node.Source -Leaf) +$env:PATH = (@($externalBin, $expectedBin.ToUpperInvariant(), $env:PATH, $expectedBin)) -join [IO.Path]::PathSeparator +if ((Get-Command node -CommandType Application | Select-Object -First 1).Source -ne $externalNode) { + throw "Fixture did not create a Node PATH priority conflict" +} + +# Repeated activation must put one shim entry first, ahead of the external Node. +. (Join-Path $env:EXPECTED_VP_HOME "env.ps1") . (Join-Path $env:EXPECTED_VP_HOME "env.ps1") if ($env:VP_HOME -ne $env:EXPECTED_VP_HOME) { throw "VP_HOME mismatch: expected $env:EXPECTED_VP_HOME, got $env:VP_HOME" } -$expectedBin = Join-Path $env:EXPECTED_VP_HOME "bin" -$binCount = @($env:Path -split [IO.Path]::PathSeparator | Where-Object { $_ -ieq $expectedBin }).Count +$binCount = @($env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -ieq $expectedBin }).Count if ($binCount -ne 1) { throw "PATH contains the Vite+ bin directory $binCount times" } +if (($env:PATH -split [IO.Path]::PathSeparator)[0] -ne $expectedBin) { + throw "Activation did not put the shim directory first on PATH" +} +if ((Get-Command node -CommandType Application | Select-Object -First 1).Source -ne (Join-Path $expectedBin (Split-Path $node.Source -Leaf))) { + throw "Activation did not give the Node shim priority" +} if (-not (Get-Command vp -CommandType Function -ErrorAction SilentlyContinue)) { throw "env.ps1 did not define the vp wrapper" @@ -40,7 +58,38 @@ if ($LASTEXITCODE -ne 0) { if ($env:VP_NODE_VERSION -ne "20.18.0") { throw "VP_NODE_VERSION mismatch: expected 20.18.0, got $env:VP_NODE_VERSION" } +if ($ErrorActionPreference -ne 'Stop' -or (Test-Path Env:VP_ENV_USE_EVAL_ENABLE) -or (Test-Path Env:VP_SHELL)) { + throw "vp env use did not restore the caller's preferences and environment" +} +$env:VP_ENV_USE_EVAL_ENABLE = 'original' +$env:VP_SHELL = 'powershell' +vp env use invalid-version --no-install 6>$null +if ($LASTEXITCODE -eq 0 -or $env:VP_NODE_VERSION -ne '20.18.0') { + throw "Failed vp env use changed the selected Node version or lost its exit code" +} +if ($ErrorActionPreference -ne 'Stop' -or $env:VP_ENV_USE_EVAL_ENABLE -ne 'original' -or $env:VP_SHELL -ne 'powershell') { + throw "Failed vp env use did not restore the caller's preferences and environment" +} + +# A terminating error while displaying native stderr must also restore the environment. +& { + function Write-Host { throw 'Simulated host output failure' } + $caught = $false + try { + vp env use 20.18.0 --no-install + } catch { + if ($_.Exception.Message -notlike '*Simulated host output failure*') { throw } + $caught = $true + } + if (-not $caught) { throw 'The wrapper did not propagate the host output failure' } +} +if ($ErrorActionPreference -ne 'Stop' -or $env:VP_ENV_USE_EVAL_ENABLE -ne 'original' -or $env:VP_SHELL -ne 'powershell') { + throw "Interrupted vp env use did not restore the caller's preferences and environment" +} +Remove-Item Env:VP_ENV_USE_EVAL_ENABLE, Env:VP_SHELL + +$ErrorActionPreference = 'Continue' vp env use --unset if ($LASTEXITCODE -ne 0) { throw "vp env use --unset failed through the PowerShell wrapper" @@ -48,6 +97,10 @@ if ($LASTEXITCODE -ne 0) { if (Test-Path Env:VP_NODE_VERSION) { throw "vp env use --unset did not remove VP_NODE_VERSION" } +if ($ErrorActionPreference -ne 'Continue' -or (Test-Path Env:VP_ENV_USE_EVAL_ENABLE) -or (Test-Path Env:VP_SHELL)) { + throw "vp env use --unset did not restore the caller's preferences and environment" +} +$ErrorActionPreference = 'Stop' vp env use --no-install if ($LASTEXITCODE -ne 0) { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots.toml index d04fa3e3a4..a2f8117f07 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots.toml @@ -1,7 +1,6 @@ [[case]] name = "command_env_powershell" vp = "global" -skip-platforms = ["linux", "macos"] requires = ["pwsh"] steps = [ { argv = ["vp", "env", "setup", "--refresh"], snapshot = false }, diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 4f9a873fbc..3b3451d393 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -901,8 +901,8 @@ export extern "vpr" [...args: string@"nu-complete vpr"] const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) __ENV_EXPORTS__$__vp_bin = '__VP_BIN_WIN__' $__vp_fallback = '__VP_FALLBACK_BIN_WIN__' -$__vp_paths = @($env:Path -split ';' | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback }) -$env:Path = (@($__vp_bin) + $__vp_paths + @($__vp_fallback)) -join ';' +$__vp_paths = @($env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -and $_ -ine $__vp_bin -and $_ -ine $__vp_fallback }) +$env:PATH = (@($__vp_bin) + $__vp_paths + @($__vp_fallback)) -join [IO.Path]::PathSeparator # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -919,17 +919,26 @@ function vp { if ($args -contains "-h" -or $args -contains "--help") { & (Join-Path $__vp_bin "vp") @args; return } - $env:VP_ENV_USE_EVAL_ENABLE = "1" - $env:VP_SHELL = "pwsh" - $output = & (Join-Path $__vp_bin "vp") @args 2>&1 | ForEach-Object { - if ($_ -is [System.Management.Automation.ErrorRecord]) { - Write-Host $_.Exception.Message - } else { - $_ + $previousEvalEnable = $env:VP_ENV_USE_EVAL_ENABLE + $previousShell = $env:VP_SHELL + $previousErrorActionPreference = $ErrorActionPreference + try { + $env:VP_ENV_USE_EVAL_ENABLE = "1" + $env:VP_SHELL = "pwsh" + # Windows PowerShell 5.1 treats native stderr as an error when redirected. + $ErrorActionPreference = "Continue" + $output = & (Join-Path $__vp_bin "vp") @args 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + Write-Host $_.Exception.Message + } else { + $_ + } } + } finally { + $ErrorActionPreference = $previousErrorActionPreference + $env:VP_ENV_USE_EVAL_ENABLE = $previousEvalEnable + $env:VP_SHELL = $previousShell } - Remove-Item Env:VP_ENV_USE_EVAL_ENABLE -ErrorAction SilentlyContinue - Remove-Item Env:VP_SHELL -ErrorAction SilentlyContinue if ($LASTEXITCODE -eq 0 -and $output) { Invoke-Expression ($output -join "`n") } diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 7fb7ee5475..d6f0fcaf0d 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -20,8 +20,6 @@ # When dot-sourced, returns script-scoped InstallDir, ShimDir, CacheDir, ConfigDir, and StateDir. # These are resolved paths, not VP_* overrides for subsequent commands. -$ErrorActionPreference = "Stop" - $ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } # npm registry URL (strip trailing slash if present) $NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { "https://registry.npmjs.org" } @@ -446,7 +444,9 @@ function Invoke-InstallHandoff { } } +$previousErrorActionPreference = $ErrorActionPreference try { + $ErrorActionPreference = "Stop" Main } catch { if (Test-IsInstallStopException $_) { @@ -456,4 +456,6 @@ try { exit $global:LASTEXITCODE } throw +} finally { + $ErrorActionPreference = $previousErrorActionPreference } From 11848555fc62a5e9e2a71d0002bc5bcc844f9d9d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 16:17:13 +0800 Subject: [PATCH 10/18] fix(test): repair PowerShell activation CI coverage --- crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs | 5 ++++- crates/vp_global_cli/src/commands/env/setup.rs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs index d786f786c3..bca3f08eaa 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/flavor.rs @@ -252,7 +252,10 @@ pub fn nushell_path() -> Result, String> { /// Resolves an optional PowerShell binary for fixtures that exercise generated /// `env.ps1` files. pub fn powershell_path() -> Result, String> { - optional_tool_path("VP_SNAP_PWSH_BIN", "pwsh") + // Windows PowerShell 5.1 uses .NET Framework, which cannot load its + // configuration from the verbatim paths returned by std::fs::canonicalize. + Ok(optional_tool_path("VP_SNAP_PWSH_BIN", "pwsh")? + .map(|path| dunce::simplified(&path).to_path_buf())) } /// Resolves an optional cmd.exe for fixtures that exercise generated batch diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 3b3451d393..319f090eb4 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -901,7 +901,7 @@ export extern "vpr" [...args: string@"nu-complete vpr"] const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) __ENV_EXPORTS__$__vp_bin = '__VP_BIN_WIN__' $__vp_fallback = '__VP_FALLBACK_BIN_WIN__' -$__vp_paths = @($env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -and $_ -ine $__vp_bin -and $_ -ine $__vp_fallback }) +$__vp_paths = @($env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback }) $env:PATH = (@($__vp_bin) + $__vp_paths + @($__vp_fallback)) -join [IO.Path]::PathSeparator # Shell function wrapper: intercepts `vp env use` to eval its stdout, From 217e0dd1bda73496ff37b9dbcb9b60c8100b0f5e Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 23:20:23 +0800 Subject: [PATCH 11/18] fix(env): retain Zsh and cmd activation guidance --- .../profile-guidance.mjs | 79 +++++++++++++++++ .../command_env_activation/snapshots.toml | 19 +++++ .../snapshots/activation_cmd_path_guidance.md | 35 ++++++++ .../snapshots/activation_external_zsh.md | 2 +- .../activation_zsh_profile_guidance.md | 37 ++++++++ .../vp_global_cli/src/commands/env/setup.rs | 84 +++++++++---------- 6 files changed, 211 insertions(+), 45 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs index 85eefd03f0..95b11cba05 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -62,6 +62,85 @@ if (mode === 'powershell') { assert.doesNotMatch(output, /Or open a new terminal/); console.log(output.split('\n').find((line) => line.includes('$PROFILE'))); } +} else if (mode.startsWith('cmd')) { + if (mode === 'cmd') delete env.VP_SELF_SETUP_NO_MODIFY_PATH; + console.log(`VP_SELF_SETUP_NO_MODIFY_PATH=${env.VP_SELF_SETUP_NO_MODIFY_PATH ?? ''}:`); + const output = setup('cmd'); + const activation = output + .split('\n') + .find((line) => line.trimStart().startsWith('set "PATH=')) + ?.trim(); + assert.equal(activation, `set "PATH=${dirs.bin};%PATH%"`); + assert.match(output, /user PATH if it is missing/); + assert.match(output, /System Properties -> Environment Variables -> User variables -> Path/); + assert.doesNotMatch(output, /open a new terminal to load/i); + console.log(output); + + if (process.platform === 'win32') { + const system = path.resolve('profiles/system'); + fs.mkdirSync(system, { recursive: true }); + fs.copyFileSync(process.execPath, path.join(system, 'node.exe')); + const staleEnv = { + ...env, + PATH: [system, path.join(process.env.SystemRoot, 'System32')].join(';'), + }; + for (const [command, expected] of [ + ['where.exe node', path.join(system, 'node.exe')], + [`${activation} & where.exe node`, path.join(dirs.bin, 'node.exe')], + ]) { + const result = spawnSync(process.env.ComSpec, ['/d', '/v:off', '/s', '/c', `"${command}"`], { + env: staleEnv, + windowsVerbatimArguments: true, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + assert.equal(result.stdout.trim().split(/\r?\n/)[0].toLowerCase(), expected.toLowerCase()); + } + } +} else if (mode.startsWith('zsh-')) { + captureVp(['env', 'on', 'node']); + const zsh = process.env.PATH.split(path.delimiter) + .map((dir) => path.join(dir, 'zsh')) + .find((file) => fs.existsSync(file)); + assert.ok(zsh); + const system = path.resolve('profiles/system'); + fs.mkdirSync(system, { recursive: true }); + fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); + env.PATH = [system, '/usr/bin', '/bin'].join(path.delimiter); + const envPath = path.join(dirs.config, 'env').replace(/[\\$`"]/g, '\\$&'); + const source = `. "${envPath}"\n`; + fs.mkdirSync(env.ZDOTDIR, { recursive: true }); + fs.writeFileSync(path.join(env.ZDOTDIR, '.zshenv'), source); + // Model PATH changes made by login startup after .zshenv, such as macOS path_helper. + const systemPath = system.replace(/[\\$`"]/g, '\\$&'); + fs.writeFileSync(path.join(env.ZDOTDIR, '.zprofile'), `export PATH="${systemPath}:$PATH"\n`); + const configured = mode === 'zsh-interactive'; + if (configured) fs.writeFileSync(path.join(env.ZDOTDIR, '.zshrc'), source); + console.log(configured ? 'Zsh .zshrc is configured:' : 'Only Zsh .zshenv is configured:'); + const output = setup('zsh'); + if (configured) { + assert.doesNotMatch(output, /Add the command/); + assert.match(output, /Or open a new terminal/); + } else { + assert.match(output, /Add the command to your \.zshrc/); + assert.doesNotMatch(output, /Or open a new terminal/); + } + console.log(output); + console.log("$ zsh -lic 'command -v node; node --version'"); + const result = spawnSync( + zsh, + [ + '-lic', + 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version', + ], + { + env: { ...env, EXPECTED_NODE: path.join(configured ? dirs.bin : system, 'node') }, + stdio: 'inherit', + timeout: 30000, + }, + ); + assert.equal(result.status, 0, result.error?.message); } else { // Suppress Ubuntu's sudo hint while still loading the normal Bash startup files. fs.writeFileSync(path.join(home, '.hushlogin'), ''); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml index eae7935c49..358fbb5c96 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml @@ -57,3 +57,22 @@ steps = [ name = "activation_powershell_profile_guidance" vp = "global" steps = [["node", "profile-guidance.mjs", "powershell"]] + +[[case]] +name = "activation_zsh_profile_guidance" +vp = "global" +comment = "Login startup reorders PATH after .zshenv; .zshrc must activate the shims afterward." +skip-platforms = ["windows"] +requires = ["zsh"] +steps = [ + ["node", "profile-guidance.mjs", "zsh-env"], + ["node", "profile-guidance.mjs", "zsh-interactive"], +] + +[[case]] +name = "activation_cmd_path_guidance" +vp = "global" +steps = [ + ["node", "profile-guidance.mjs", "cmd"], + ["node", "profile-guidance.mjs", "cmd-no-modify-path"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md new file mode 100644 index 0000000000..9f5055ed36 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md @@ -0,0 +1,35 @@ +# activation_cmd_path_guidance + +## `node profile-guidance.mjs cmd` + +``` +VP_SELF_SETUP_NO_MODIFY_PATH=: +VP_SHELL=cmd: +Next Steps: + Activate Vite+ in this terminal: + set "PATH=/.vite-plus/bin;%PATH%" + + For future cmd.exe sessions, add this directory to your user PATH if it is missing: + /.vite-plus/bin + System Properties -> Environment Variables -> User variables -> Path + Open a new terminal after updating PATH. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` + +## `node profile-guidance.mjs cmd-no-modify-path` + +``` +VP_SELF_SETUP_NO_MODIFY_PATH=1: +VP_SHELL=cmd: +Next Steps: + Activate Vite+ in this terminal: + set "PATH=/.vite-plus/bin;%PATH%" + + For future cmd.exe sessions, add this directory to your user PATH if it is missing: + /.vite-plus/bin + System Properties -> Environment Variables -> User variables -> Path + Open a new terminal after updating PATH. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md index 1d07b0d9ed..0a46541527 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md @@ -25,7 +25,7 @@ Next Steps: Activate Vite+ in this terminal: . "/activation/home/env" - Add the command for your shell to its profile to activate future terminals. + Add the command to your .zshrc file to activate future Zsh terminals. Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. ✓ Vite+ setup complete. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md new file mode 100644 index 0000000000..06a3c9b793 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md @@ -0,0 +1,37 @@ +# activation_zsh_profile_guidance + +Login startup reorders PATH after .zshenv; .zshrc must activate the shims afterward. + +## `node profile-guidance.mjs zsh-env` + +``` +Only Zsh .zshenv is configured: +VP_SHELL=zsh: +Next Steps: + Activate Vite+ in this terminal: + . "/.vite-plus/env" + + Add the command to your .zshrc file to activate future Zsh terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ zsh -lic 'command -v node; node --version' +/profiles/system/node +system-node +``` + +## `node profile-guidance.mjs zsh-interactive` + +``` +Zsh .zshrc is configured: +VP_SHELL=zsh: +Next Steps: + Activate Vite+ in this terminal: + . "/.vite-plus/env" + + Or open a new terminal to load your configured shell profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +$ zsh -lic 'command -v node; node --version' +/.vite-plus/bin/node + +``` diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 319f090eb4..4ef851692a 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -901,8 +901,11 @@ export extern "vpr" [...args: string@"nu-complete vpr"] const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) __ENV_EXPORTS__$__vp_bin = '__VP_BIN_WIN__' $__vp_fallback = '__VP_FALLBACK_BIN_WIN__' -$__vp_paths = @($env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback }) -$env:PATH = (@($__vp_bin) + $__vp_paths + @($__vp_fallback)) -join [IO.Path]::PathSeparator +$env:PATH = @( + $__vp_bin + $env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback } + $__vp_fallback +) -join [IO.Path]::PathSeparator # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -1213,11 +1216,12 @@ async fn create_env_files() -> Result<(), Error> { /// Inspect the explicitly selected shell without changing its profiles. /// Bash only checks non-login startup; the printed advice must keep that qualification. +/// Zsh login startup can reorder PATH after `.zshenv`, so require `.zshrc`. fn has_configured_profile(config: &vp_shared::EnvConfig) -> bool { let shell = config.vp_shell.as_deref().map(str::to_ascii_lowercase); for profile in ALL_SHELL_PROFILES { let relevant = match shell.as_deref() { - Some("zsh") => matches!(profile.root, ShellProfileRoot::Zsh), + Some("zsh") => profile.path == ".zshrc", Some("bash") => profile.path == ".bashrc", Some("sh") => profile.path == ".profile", Some("fish") => matches!(profile.root, ShellProfileRoot::Fish), @@ -1289,9 +1293,8 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { (Shell::PowerShell, "PowerShell", EnvShell::Powershell), ]; if shell == Some(Shell::Cmd) { - // cmd has no sourceable environment file. A new terminal inherits the - // persistent PATH written by setup. - output::raw(" In cmd.exe, open a new terminal to load the updated PATH."); + let command = super::format_path_snippet(Shell::Cmd, &[env.dirs.bin.to_string()]); + output::raw(&format!(" {command}")); } else { for (kind, label, env_shell) in commands { if shell.is_some_and(|s| s != kind) { @@ -1306,49 +1309,40 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { } } output::raw(""); - match shell { - Some(Shell::Cmd) => {} - None => { - output::raw( - " If your shell profile does not already load Vite+, add the command for your shell.", - ); - output::raw( - " For PowerShell, add its command to $PROFILE if it is not already there.", - ); - output::raw(""); - } - Some(Shell::PowerShell) => { - output::raw( - " Add this command to $PROFILE if it is not already there, for future PowerShell sessions.", - ); - output::raw(""); - } - _ if env.vp_shell.as_deref().is_some_and(|s| s.eq_ignore_ascii_case("bash")) => { + let profile_instructions: &[&str] = match shell { + Some(Shell::Cmd) => &[ + " For future cmd.exe sessions, add this directory to your user PATH if it is missing:", + &format!(" {}", env.dirs.bin.as_path().display()), + " System Properties -> Environment Variables -> User variables -> Path", + " Open a new terminal after updating PATH.", + ], + None => &[ + " If your shell profile does not already load Vite+, add the command for your shell.", + " For PowerShell, add its command to $PROFILE if it is not already there.", + ], + Some(Shell::PowerShell) => &[ + " Add this command to $PROFILE if it is not already there, for future PowerShell sessions.", + ], + _ if env.vp_shell.as_deref().is_some_and(|s| s.eq_ignore_ascii_case("bash")) => &[ if has_configured_profile(&env) { - output::raw( - " Or start an interactive non-login Bash shell to load your configured ~/.bashrc.", - ); + " Or start an interactive non-login Bash shell to load your configured ~/.bashrc." } else { - output::raw( - " Add the command to ~/.bashrc for interactive non-login Bash sessions.", - ); - } - output::raw( - " Login Bash shells must also load the command through their login profile.", - ); - output::raw(""); + " Add the command to ~/.bashrc for interactive non-login Bash sessions." + }, + " Login Bash shells must also load the command through their login profile.", + ], + _ if has_configured_profile(&env) => { + &[" Or open a new terminal to load your configured shell profile."] } - _ => { - if has_configured_profile(&env) { - output::raw(" Or open a new terminal to load your configured shell profile."); - } else { - output::raw( - " Add the command for your shell to its profile to activate future terminals.", - ); - } - output::raw(""); + _ if env.vp_shell.as_deref().is_some_and(|s| s.eq_ignore_ascii_case("zsh")) => { + &[" Add the command to your .zshrc file to activate future Zsh terminals."] } + _ => &[" Add the command for your shell to its profile to activate future terminals."], + }; + for instruction in profile_instructions { + output::raw(instruction); } + output::raw(""); output::raw(&format!( " Restart an already-running IDE to load its environment. Run {} to verify.", help::accent_command("vp env doctor") @@ -1404,6 +1398,8 @@ mod tests { assert!(!has_configured_profile(&config)); std::fs::write(home.join(".bashrc"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); assert!(!has_configured_profile(&config), "Bash does not configure Zsh"); + std::fs::write(zsh.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); + assert!(!has_configured_profile(&config), ".zshenv runs before login PATH setup"); let profile = zsh.join(".zshrc"); for line in [ "# . \"$HOME/.vite-plus/env\"", From 1b5c6963cd952b38a43c6be6ef99874ea41f3300 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 23:46:03 +0800 Subject: [PATCH 12/18] fix(test): run login Zsh directly in its PTY --- .../profile-guidance.mjs | 18 ------------------ .../command_env_activation/snapshots.toml | 11 +++++++++++ .../activation_zsh_profile_guidance.md | 12 ++++++++++-- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs index 95b11cba05..727115d10a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -100,10 +100,6 @@ if (mode === 'powershell') { } } else if (mode.startsWith('zsh-')) { captureVp(['env', 'on', 'node']); - const zsh = process.env.PATH.split(path.delimiter) - .map((dir) => path.join(dir, 'zsh')) - .find((file) => fs.existsSync(file)); - assert.ok(zsh); const system = path.resolve('profiles/system'); fs.mkdirSync(system, { recursive: true }); fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); @@ -127,20 +123,6 @@ if (mode === 'powershell') { assert.doesNotMatch(output, /Or open a new terminal/); } console.log(output); - console.log("$ zsh -lic 'command -v node; node --version'"); - const result = spawnSync( - zsh, - [ - '-lic', - 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version', - ], - { - env: { ...env, EXPECTED_NODE: path.join(configured ? dirs.bin : system, 'node') }, - stdio: 'inherit', - timeout: 30000, - }, - ); - assert.equal(result.status, 0, result.error?.message); } else { // Suppress Ubuntu's sudo hint while still loading the normal Bash startup files. fs.writeFileSync(path.join(home, '.hushlogin'), ''); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml index 358fbb5c96..36f74cbd7e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml @@ -64,9 +64,20 @@ vp = "global" comment = "Login startup reorders PATH after .zshenv; .zshrc must activate the shims afterward." skip-platforms = ["windows"] requires = ["zsh"] +# Launch each login shell directly as the PTY owner, without Node as its parent. steps = [ ["node", "profile-guidance.mjs", "zsh-env"], + { argv = ["zsh", "-lic", 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version'], envs = [ + ["HOME", "${workspace}/profiles/user"], + ["ZDOTDIR", "${workspace}/profiles/user/zsh"], + ["EXPECTED_NODE", "${workspace}/profiles/system/node"], + ] }, ["node", "profile-guidance.mjs", "zsh-interactive"], + { argv = ["zsh", "-lic", 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version'], envs = [ + ["HOME", "${workspace}/profiles/user"], + ["ZDOTDIR", "${workspace}/profiles/user/zsh"], + ["EXPECTED_NODE", "${VP_HOME}/bin/node"], + ] }, ] [[case]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md index 06a3c9b793..c15197745f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_zsh_profile_guidance.md @@ -14,7 +14,11 @@ Next Steps: Add the command to your .zshrc file to activate future Zsh terminals. Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. -$ zsh -lic 'command -v node; node --version' +``` + +## `HOME=${workspace}/profiles/user ZDOTDIR=${workspace}/profiles/user/zsh EXPECTED_NODE=${workspace}/profiles/system/node zsh -lic 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version'` + +``` /profiles/system/node system-node ``` @@ -31,7 +35,11 @@ Next Steps: Or open a new terminal to load your configured shell profile. Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. -$ zsh -lic 'command -v node; node --version' +``` + +## `HOME=${workspace}/profiles/user ZDOTDIR=${workspace}/profiles/user/zsh EXPECTED_NODE=${VP_HOME}/bin/node zsh -lic 'command -v node; test "$(command -v node)" = "$EXPECTED_NODE" || exit 1; node --version'` + +``` /.vite-plus/bin/node ``` From 998e3ed9dc9524c7f611881d0e5f085d14ef53fc Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 19 Sep 2026 23:56:02 +0800 Subject: [PATCH 13/18] fix(test): skip global completion prompts in Zsh profile test --- .../fixtures/command_env_activation/profile-guidance.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs index 727115d10a..075d3120f4 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -107,7 +107,9 @@ if (mode === 'powershell') { const envPath = path.join(dirs.config, 'env').replace(/[\\$`"]/g, '\\$&'); const source = `. "${envPath}"\n`; fs.mkdirSync(env.ZDOTDIR, { recursive: true }); - fs.writeFileSync(path.join(env.ZDOTDIR, '.zshenv'), source); + // Ubuntu's global compinit can prompt about the runner's completion directories. + // Keep normal profile loading, but skip completion setup in this PATH test. + fs.writeFileSync(path.join(env.ZDOTDIR, '.zshenv'), `skip_global_compinit=1\n${source}`); // Model PATH changes made by login startup after .zshenv, such as macOS path_helper. const systemPath = system.replace(/[\\$`"]/g, '\\$&'); fs.writeFileSync(path.join(env.ZDOTDIR, '.zprofile'), `export PATH="${systemPath}:$PATH"\n`); From b5357e0929fe38322074e32dcdd9cbf1d81148ed Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 20 Sep 2026 01:04:47 +0800 Subject: [PATCH 14/18] fix(env): tailor activation instructions to available shells --- .github/scripts/test-install-bootstrap.ps1 | 7 ++ .../profile-guidance.mjs | 16 ++- .../command_env_activation/shell-guidance.mjs | 99 ++++++++++++++++ .../command_env_activation/snapshots.toml | 12 ++ .../snapshots/activation_available_shells.md | 42 +++++++ .../snapshots/activation_login_shell_hint.md | 71 +++++++++++ .../snapshots/activation_profile_guidance.md | 22 ++-- .../vp_global_cli/src/commands/env/setup.rs | 110 +++++++++++++----- packages/cli/install.ps1 | 3 + 9 files changed, 336 insertions(+), 46 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/shell-guidance.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_available_shells.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_login_shell_hint.md diff --git a/.github/scripts/test-install-bootstrap.ps1 b/.github/scripts/test-install-bootstrap.ps1 index a5fcbc2e1c..ee078fc5cf 100644 --- a/.github/scripts/test-install-bootstrap.ps1 +++ b/.github/scripts/test-install-bootstrap.ps1 @@ -37,6 +37,7 @@ $originalTemp = $env:TEMP $originalCheck = $env:VP_SELF_SETUP_SUPPORT_CHECK $originalPath = $env:Path $originalRegistry = $env:NPM_CONFIG_REGISTRY +$originalVpShell = $env:VP_SHELL $fixtureSha = '0123456789012345678901234567890123456789' New-Item -ItemType Directory -Path "$testRoot/package", "$testRoot/tmp", "$testRoot/scripts" | Out-Null Set-Content -LiteralPath "$testRoot/package/vp.exe" -Value 'Payload fixture' @@ -46,6 +47,8 @@ if ($args.Count -eq 0) { New-Item -ItemType File -Path "$testRoot/binary-invoked" | Out-Null if ($scenario -eq 'failure') { exit 42 } if ($env:VP_SELF_SETUP_SHELL -ne 'powershell') { exit 98 } + $expectedVpShell = if ($scenario -eq 'supported-pr') { 'fish' } else { 'powershell' } + if ($env:VP_SHELL -ne $expectedVpShell) { exit 96 } if ($scenario -eq 'supported-pr' -and $env:NPM_CONFIG_REGISTRY -ne 'https://registry-bridge.viteplus.dev/') { exit 97 } Write-Output ("`$script:InstallDir = '{0}'" -f "$testRoot/data") Write-Output ("`$script:ShimDir = '{0}'" -f "$testRoot/installed bin") @@ -123,6 +126,8 @@ try { foreach ($scenario in @('supported', 'legacy', 'legacy-remote', 'legacy-failure', 'failure', 'pr', 'supported-pr')) { $env:Path = $originalPath $env:NPM_CONFIG_REGISTRY = 'https://custom.example' + $initialVpShell = if ($scenario -eq 'supported-pr') { 'fish' } else { $null } + $env:VP_SHELL = $initialVpShell $script:Requests = New-Object 'System.Collections.Generic.List[string]' $script:ExitCode = 0 $script:PackageMetadata = $null @@ -148,6 +153,7 @@ try { } $expectedExit = if ($scenario -in @('failure', 'legacy-failure')) { 42 } else { 0 } Assert ($script:ExitCode -eq $expectedExit) 'Binary exit code was lost' + Assert ($env:VP_SHELL -eq $initialVpShell) 'Setup changed the caller shell' if ($scenario -eq 'supported') { Assert (($env:Path -split ';')[0] -eq "$testRoot/installed bin") 'Installed bin directory was not added to the current PATH' } elseif ($scenario -eq 'failure') { @@ -169,6 +175,7 @@ try { $env:VP_SELF_SETUP_SUPPORT_CHECK = $originalCheck $env:Path = $originalPath $env:NPM_CONFIG_REGISTRY = $originalRegistry + $env:VP_SHELL = $originalVpShell $env:TEMP = $originalTemp Remove-Item -LiteralPath $testRoot -Recurse -Force $global:LASTEXITCODE = 0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs index 075d3120f4..98411ab4ef 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -55,11 +55,24 @@ function setup(shell) { } if (mode === 'powershell') { + // Control executable discovery independently of the shells installed on the runner. + const shellBin = path.resolve('profiles/shell-bin'); + fs.mkdirSync(shellBin, { recursive: true }); + fs.writeFileSync( + path.join(shellBin, process.platform === 'win32' ? 'powershell.exe' : 'pwsh'), + '', + { + mode: 0o755, + }, + ); + env.PATH = shellBin; + delete env.SHELL; for (const shell of ['pwsh', undefined]) { const output = setup(shell); assert.match(output, /\. '[^\n]*env\.ps1'/); assert.match(output, /\$PROFILE if it is not already there/); assert.doesNotMatch(output, /Or open a new terminal/); + assert.doesNotMatch(output, /Fish:|Nushell:/); console.log(output.split('\n').find((line) => line.includes('$PROFILE'))); } } else if (mode.startsWith('cmd')) { @@ -163,7 +176,8 @@ if (mode === 'powershell') { if (mode === 'unset' || mode === 'unrecognized') { console.log('Only Fish is configured:'); const output = setup(mode === 'unset' ? undefined : mode); - assert.match(output, /If your shell profile does not already load Vite\+/); + assert.match(output, /For Bash, run:/); + assert.match(output, /If your ~\/\.bashrc does not already load Vite\+/); assert.doesNotMatch(output, /Or open a new terminal/); console.log(output); freshBash(path.join(system, 'node')); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/shell-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/shell-guidance.mjs new file mode 100644 index 0000000000..53ea0d997a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/shell-guidance.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const mode = process.argv[2]; +const home = path.resolve('shell-guidance/user'); +const shellBin = path.resolve('shell-guidance/bin'); +const binary = path.join(process.env.VP_HOME, 'current/bin/vp'); +const env = { ...process.env, HOME: home, ZDOTDIR: home, PATH: shellBin }; +delete env.VP_SHELL; +delete env.SHELL; +fs.mkdirSync(home, { recursive: true }); +fs.mkdirSync(shellBin, { recursive: true }); + +/** @returns {string} */ +function capture(args, extra = {}) { + const result = spawnSync(binary, args, { + env: { ...env, ...extra }, + encoding: 'utf8', + timeout: 30000, + }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + return result.stdout.replace(/\u001b\[[0-9;]*m/g, '').replaceAll('\r\n', '\n'); +} + +const dirs = Object.fromEntries( + capture([], { VP_DUMP_DIRS: '1' }) + .trim() + .split('\n') + .map((line) => line.split('\t')), +); + +/** @returns {string} */ +function setup() { + const output = capture(['env', 'setup']); + const heading = output.indexOf('Next Steps:\n'); + assert.ok(heading >= 0, output); + const instructions = output.slice(heading).trimEnd(); + assert.doesNotMatch(instructions, /Or open a new terminal/); + console.log(instructions); + return instructions; +} + +if (mode === 'hints') { + // Even matching profiles must not turn a login-shell hint into a current-shell guarantee. + for (const profile of ['.zshrc', '.bashrc']) { + fs.writeFileSync(path.join(home, profile), `. "${dirs.config}/env"\n`); + } + for (const [shell, label, file] of [ + ['zsh', 'Zsh', 'env'], + ['bash', 'Bash', 'env'], + ['sh', 'sh', 'env'], + ['fish', 'Fish', 'env.fish'], + ['nu', 'Nushell', 'env.nu'], + ['pwsh', 'PowerShell', 'env.ps1'], + ]) { + env.SHELL = `/usr/bin/${shell}`; + console.log(`SHELL=${env.SHELL}, VP_SHELL=:`); + const output = setup(); + assert.ok(output.includes(`For ${label}, run:`)); + assert.ok(output.includes(path.join(dirs.config, file))); + assert.equal(output.split('\n').filter((line) => line.includes(dirs.config)).length, 1); + assert.doesNotMatch(output, /Activate Vite\+ in this terminal/); + } + env.SHELL = '/bin/zsh'; + env.VP_SHELL = 'unrecognized'; + console.log('SHELL=/bin/zsh, VP_SHELL=unrecognized:'); + assert.match(setup(), /For Zsh, run:/); + env.VP_SHELL = 'fish'; + console.log('SHELL=/bin/zsh, VP_SHELL=fish (Fish is not on PATH):'); + const output = setup(); + assert.match(output, /Activate Vite\+ in this terminal:/); + assert.match(output, /env\.fish/); + assert.doesNotMatch(output, /For Zsh|env\.ps1|env\.nu/); +} else { + assert.equal(mode, 'available'); + env.SHELL = '/bin/unrecognized'; + for (const shell of ['fish', 'nu', 'pwsh']) { + fs.writeFileSync(path.join(shellBin, shell), '', { mode: 0o644 }); + } + console.log('Unknown shell; optional shell files are not executable:'); + assert.doesNotMatch(setup(), /Fish:|Nushell:|PowerShell:|\$PROFILE/); + for (const [shell, label] of [ + ['fish', 'Fish'], + ['nu', 'Nushell'], + ['pwsh', 'PowerShell'], + ]) { + fs.chmodSync(path.join(shellBin, shell), 0o755); + console.log(`Unknown shell; only ${label} is available:`); + const output = setup(); + assert.ok(output.includes(`${label}:`)); + for (const other of ['Fish', 'Nushell', 'PowerShell'].filter((name) => name !== label)) { + assert.ok(!output.includes(`${other}:`)); + } + assert.equal(output.includes('$PROFILE'), shell === 'pwsh'); + fs.chmodSync(path.join(shellBin, shell), 0o644); + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml index 36f74cbd7e..6b8e4699fc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots.toml @@ -53,6 +53,18 @@ steps = [ ["node", "profile-guidance.mjs", "bash-interactive"], ] +[[case]] +name = "activation_login_shell_hint" +vp = "global" +skip-platforms = ["windows"] +steps = [["node", "shell-guidance.mjs", "hints"]] + +[[case]] +name = "activation_available_shells" +vp = "global" +skip-platforms = ["windows"] +steps = [["node", "shell-guidance.mjs", "available"]] + [[case]] name = "activation_powershell_profile_guidance" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_available_shells.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_available_shells.md new file mode 100644 index 0000000000..94e4428fb0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_available_shells.md @@ -0,0 +1,42 @@ +# activation_available_shells + +## `node shell-guidance.mjs available` + +``` +Unknown shell; optional shell files are not executable: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +Unknown shell; only Fish is available: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + Fish: source "/.vite-plus/env.fish" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +Unknown shell; only Nushell is available: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + Nushell: source "/.vite-plus/env.nu" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +Unknown shell; only PowerShell is available: +Next Steps: + Activate Vite+ in this terminal: + Bash/Zsh: . "/.vite-plus/env" + PowerShell: . '/.vite-plus/env.ps1' + + If your shell profile does not already load Vite+, add the command for your shell. + For PowerShell, add its command to $PROFILE if it is not already there. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_login_shell_hint.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_login_shell_hint.md new file mode 100644 index 0000000000..77dd1444e1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_login_shell_hint.md @@ -0,0 +1,71 @@ +# activation_login_shell_hint + +## `node shell-guidance.mjs hints` + +``` +SHELL=/usr/bin/zsh, VP_SHELL=: +Next Steps: + For Zsh, run: + . "/.vite-plus/env" + + If your .zshrc does not already load Vite+, add this command. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/bash, VP_SHELL=: +Next Steps: + For Bash, run: + . "/.vite-plus/env" + + If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/sh, VP_SHELL=: +Next Steps: + For sh, run: + . "/.vite-plus/env" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/fish, VP_SHELL=: +Next Steps: + For Fish, run: + source "/.vite-plus/env.fish" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/nu, VP_SHELL=: +Next Steps: + For Nushell, run: + source "/.vite-plus/env.nu" + + If your shell profile does not already load Vite+, add the command for your shell. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/usr/bin/pwsh, VP_SHELL=: +Next Steps: + For PowerShell, run: + . '/.vite-plus/env.ps1' + + Add this command to $PROFILE if it is not already there, for future PowerShell sessions. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/bin/zsh, VP_SHELL=unrecognized: +Next Steps: + For Zsh, run: + . "/.vite-plus/env" + + If your .zshrc does not already load Vite+, add this command. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +SHELL=/bin/zsh, VP_SHELL=fish (Fish is not on PATH): +Next Steps: + Activate Vite+ in this terminal: + source "/.vite-plus/env.fish" + + Add the command for your shell to its profile to activate future terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md index 4fad875bcc..862b5d5b6f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_profile_guidance.md @@ -6,14 +6,11 @@ Only Fish is configured: VP_SHELL=: Next Steps: - Activate Vite+ in this terminal: - Bash/Zsh: . "/.vite-plus/env" - Fish: source "/.vite-plus/env.fish" - Nushell: source "/.vite-plus/env.nu" - PowerShell: . '/.vite-plus/env.ps1' + For Bash, run: + . "/.vite-plus/env" - If your shell profile does not already load Vite+, add the command for your shell. - For PowerShell, add its command to $PROFILE if it is not already there. + If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. $ bash --noprofile -ic 'command -v node; node --version' @@ -27,14 +24,11 @@ system-node Only Fish is configured: VP_SHELL=unrecognized: Next Steps: - Activate Vite+ in this terminal: - Bash/Zsh: . "/.vite-plus/env" - Fish: source "/.vite-plus/env.fish" - Nushell: source "/.vite-plus/env.nu" - PowerShell: . '/.vite-plus/env.ps1' + For Bash, run: + . "/.vite-plus/env" - If your shell profile does not already load Vite+, add the command for your shell. - For PowerShell, add its command to $PROFILE if it is not already there. + If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions. + Login Bash shells must also load the command through their login profile. Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. $ bash --noprofile -ic 'command -v node; node --version' diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 4ef851692a..223f1ec791 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1282,46 +1282,79 @@ fn has_configured_profile(config: &vp_shared::EnvConfig) -> bool { /// Print instructions for sourcing the environment files and adding bin to `PATH`. fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { output::raw(&help::render_heading("Next Steps")); - output::raw(" Activate Vite+ in this terminal:"); let env = vp_shared::EnvConfig::get(); - // SHELL describes the login shell, which may differ from this terminal. - let shell = env.vp_shell.as_deref().and_then(|s| s.parse().ok()); - let commands = [ - (Shell::Posix, "Bash/Zsh", EnvShell::Posix), - (Shell::Fish, "Fish", EnvShell::Fish), - (Shell::NuShell, "Nushell", EnvShell::Nu), - (Shell::PowerShell, "PowerShell", EnvShell::Powershell), + let explicit_shell = env.vp_shell.as_deref().and_then(|s| s.parse::().ok()); + // SHELL is a login-shell hint, not proof of the current shell or its startup mode. + let login_shell = if cfg!(unix) { std::env::var("SHELL").ok() } else { None }; + let shell_name = env + .vp_shell + .as_deref() + .filter(|_| explicit_shell.is_some()) + .or_else(|| login_shell.as_deref().and_then(|path| path.rsplit('/').next())); + let shell = shell_name.and_then(|s| s.parse::().ok()); + let is_hint = explicit_shell.is_none() && shell.is_some(); + let commands: &[(Shell, &str, Option, &[&str])] = &[ + (Shell::Posix, "Bash/Zsh", Some(EnvShell::Posix), &["sh", "bash", "zsh"]), + (Shell::Fish, "Fish", Some(EnvShell::Fish), &["fish"]), + (Shell::NuShell, "Nushell", Some(EnvShell::Nu), &["nu"]), + (Shell::PowerShell, "PowerShell", Some(EnvShell::Powershell), &["pwsh", "powershell"]), + (Shell::Cmd, "cmd.exe", None, &["cmd"]), ]; - if shell == Some(Shell::Cmd) { - let command = super::format_path_snippet(Shell::Cmd, &[env.dirs.bin.to_string()]); - output::raw(&format!(" {command}")); - } else { - for (kind, label, env_shell) in commands { - if shell.is_some_and(|s| s != kind) { - continue; - } - let command = env_shell.source_command(env_dir); - if shell.is_some() { - output::raw(&format!(" {command}")); - } else { - output::raw(&format!(" {label}: {command}")); + let cwd = vt_path::current_dir().ok(); + let default_shell = if cfg!(windows) { Shell::Cmd } else { Shell::Posix }; + let mut shown = Vec::new(); + if !is_hint { + output::raw(" Activate Vite+ in this terminal:"); + } + for &(kind, label, env_shell, binaries) in commands { + let show = match shell { + Some(selected) => selected == kind, + None => { + kind == default_shell + || binaries.iter().any(|bin| { + cwd.as_ref() + .is_some_and(|cwd| vp_command::resolve_bin(bin, None, cwd).is_ok()) + }) } + }; + if !show { + continue; + } + shown.push(kind); + let command = match env_shell { + Some(env_shell) => env_shell.source_command(env_dir), + None => super::format_path_snippet(Shell::Cmd, &[env.dirs.bin.to_string()]), + }; + if is_hint { + let label = match shell_name { + Some(name) if name.eq_ignore_ascii_case("zsh") => "Zsh", + Some(name) if name.eq_ignore_ascii_case("bash") => "Bash", + Some(name) if name.eq_ignore_ascii_case("sh") => "sh", + _ => label, + }; + output::raw(&format!(" For {label}, run:")); + } + if shell.is_some() { + output::raw(&format!(" {command}")); + } else { + output::raw(&format!(" {label}: {command}")); } } output::raw(""); let profile_instructions: &[&str] = match shell { - Some(Shell::Cmd) => &[ - " For future cmd.exe sessions, add this directory to your user PATH if it is missing:", - &format!(" {}", env.dirs.bin.as_path().display()), - " System Properties -> Environment Variables -> User variables -> Path", - " Open a new terminal after updating PATH.", + Some(Shell::Cmd | Shell::PowerShell) => &[], + None if !shown.iter().any(|s| matches!(s, Shell::Posix | Shell::Fish | Shell::NuShell)) => { + &[] + } + _ if is_hint && shell_name.is_some_and(|s| s.eq_ignore_ascii_case("zsh")) => { + &[" If your .zshrc does not already load Vite+, add this command."] + } + _ if is_hint && shell_name.is_some_and(|s| s.eq_ignore_ascii_case("bash")) => &[ + " If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions.", + " Login Bash shells must also load the command through their login profile.", ], - None => &[ + _ if is_hint || shell.is_none() => &[ " If your shell profile does not already load Vite+, add the command for your shell.", - " For PowerShell, add its command to $PROFILE if it is not already there.", - ], - Some(Shell::PowerShell) => &[ - " Add this command to $PROFILE if it is not already there, for future PowerShell sessions.", ], _ if env.vp_shell.as_deref().is_some_and(|s| s.eq_ignore_ascii_case("bash")) => &[ if has_configured_profile(&env) { @@ -1342,6 +1375,21 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { for instruction in profile_instructions { output::raw(instruction); } + if shown.contains(&Shell::PowerShell) { + output::raw(if shell == Some(Shell::PowerShell) { + " Add this command to $PROFILE if it is not already there, for future PowerShell sessions." + } else { + " For PowerShell, add its command to $PROFILE if it is not already there." + }); + } + if shown.contains(&Shell::Cmd) { + output::raw( + " For future cmd.exe sessions, add this directory to your user PATH if it is missing:", + ); + output::raw(&format!(" {}", env.dirs.bin.as_path().display())); + output::raw(" System Properties -> Environment Variables -> User variables -> Path"); + output::raw(" Open a new terminal after updating PATH."); + } output::raw(""); output::raw(&format!( " Restart an already-running IDE to load its environment. Run {} to verify.", diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index d6f0fcaf0d..78386d4e10 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -420,10 +420,12 @@ function Invoke-InstallHandoff { param([string]$BinarySource) $previous = $env:VP_SELF_SETUP_SUPPORT_CHECK $previousShell = $env:VP_SELF_SETUP_SHELL + $previousVpShell = $env:VP_SHELL $previousRegistry = $env:NPM_CONFIG_REGISTRY try { Remove-Item Env:VP_SELF_SETUP_SUPPORT_CHECK -ErrorAction SilentlyContinue $env:VP_SELF_SETUP_SHELL = 'powershell' + if (-not $env:VP_SHELL) { $env:VP_SHELL = 'powershell' } # Preview dependencies must use the same registry as the downloaded binary. if ($PrVersion) { $env:NPM_CONFIG_REGISTRY = $BridgeRegistry @@ -439,6 +441,7 @@ function Invoke-InstallHandoff { } } finally { $env:VP_SELF_SETUP_SHELL = $previousShell + $env:VP_SHELL = $previousVpShell $env:NPM_CONFIG_REGISTRY = $previousRegistry $env:VP_SELF_SETUP_SUPPORT_CHECK = $previous } From a475844a141b89cf3d7c3237311a67d61ec36aec Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 20 Sep 2026 22:54:41 +0800 Subject: [PATCH 15/18] feat(install): show dependency installation progress --- Cargo.lock | 2 +- .../fixtures/download_progress/archive.mjs | 19 ++ .../fixtures/download_progress/download.mjs | 31 +-- .../fixtures/download_progress/install.mjs | 126 ++++++++++ .../fixtures/download_progress/snapshots.toml | 38 +++ .../snapshots/installation_progress_ci.md | 37 +++ .../installation_progress_failure.md | 41 ++++ .../installation_progress_interactive.md | 65 +++++ .../installation_progress_non_tty.md | 37 +++ .../vp_global_cli/src/commands/upgrade/mod.rs | 2 +- crates/vp_global_cli/src/self_setup.rs | 2 +- crates/vp_installer/src/legacy.rs | 2 +- crates/vp_js_runtime/Cargo.toml | 1 - crates/vp_js_runtime/src/download.rs | 19 +- crates/vp_pm_cli/src/request.rs | 26 +- crates/vp_setup/src/install.rs | 45 ++-- crates/vp_shared/Cargo.toml | 2 + crates/vp_shared/src/lib.rs | 1 + crates/vp_shared/src/progress.rs | 224 ++++++++++++++++++ 19 files changed, 647 insertions(+), 73 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md create mode 100644 crates/vp_shared/src/progress.rs diff --git a/Cargo.lock b/Cargo.lock index 8606770648..c18da5b97c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8814,7 +8814,6 @@ dependencies = [ "futures-util", "hex", "httpmock", - "indicatif", "node-semver", "pgp", "reqwest", @@ -8937,6 +8936,7 @@ dependencies = [ "temp-env", "tempfile", "thiserror 2.0.20", + "tokio", "tracing", "tracing-subscriber", "vp_shared", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs new file mode 100644 index 0000000000..3949ec2a16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs @@ -0,0 +1,19 @@ +import { gzipSync } from 'node:zlib'; + +/** Create a small, valid archive to keep download tests offline. */ +export function createArchive(files) { + const blocks = []; + for (const [name, contents] of Object.entries(files)) { + const body = Buffer.from(contents); + const header = Buffer.alloc(512); + header.write(name); + header.write('0000755\0', 100); + header.write(body.length.toString(8).padStart(11, '0') + '\0', 124); + header.fill(' ', 148, 156); + header.write('0', 156); + const checksum = header.reduce((sum, byte) => sum + byte, 0); + header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148); + blocks.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512)); + } + return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/download.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/download.mjs index 976b1a30a8..0ff7e851b6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/download.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/download.mjs @@ -6,29 +6,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { gzipSync } from 'node:zlib'; - -/** - * Small, valid archives keep this test offline. The installed files are never run. - * @param {Record} files - * @returns {Buffer} - */ -function createArchive(files) { - const blocks = []; - for (const [name, contents] of Object.entries(files)) { - const body = Buffer.from(contents); - const header = Buffer.alloc(512); - header.write(name); - header.write('0000755\0', 100); - header.write(body.length.toString(8).padStart(11, '0') + '\0', 124); - header.fill(' ', 148, 156); - header.write('0', 156); - const checksum = header.reduce((sum, byte) => sum + byte, 0); - header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148); - blocks.push(header, body, Buffer.alloc((512 - (body.length % 512)) % 512)); - } - return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])); -} +import { createArchive } from './archive.mjs'; const version = '99.0.0'; const musl = process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime; @@ -91,7 +69,12 @@ try { console.log(`Before ${tool} download: preserve this output.`); const child = spawn('vp', ['env', 'install', `${tool}@${version}`], { stdio: 'inherit', - env: { ...process.env, VP_HOME: home, VP_NODE_DIST_MIRROR: mirror, npm_config_registry: mirror }, + env: { + ...process.env, + VP_HOME: home, + VP_NODE_DIST_MIRROR: mirror, + npm_config_registry: mirror, + }, }); const [code, signal] = await once(child, 'exit'); assert.equal(signal, null); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs new file mode 100644 index 0000000000..8fdc35b9a3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import { copyFileSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { join, resolve } from 'node:path'; +import { createArchive } from './archive.mjs'; + +const interactive = process.argv.includes('interactive'); +const failure = process.argv.includes('failure'); +if (interactive) process.stdin.setRawMode(true); +const home = resolve('home'); +const user = resolve('user'); +const external = resolve('external'); +const nodeBin = join(home, 'js_runtime/node/99.0.0/bin'); +for (const directory of [nodeBin, user, external]) mkdirSync(directory, { recursive: true }); +// Use the runner's real Node for the mock pnpm program, without a network download. +symlinkSync(process.execPath, join(nodeBin, 'node')); +copyFileSync(join(process.env.VP_HOME, 'bin/vp'), join(external, 'vp')); + +const pnpmArchive = createArchive({ + 'package/package.json': JSON.stringify({ + name: 'pnpm', + version: '10.33.0', + bin: { pnpm: 'bin/pnpm.cjs' }, + }), + 'package/bin/pnpm.cjs': ` + const fs = require('node:fs'); + console.log('pnpm captured stdout'); + console.error('pnpm captured stderr'); + fetch(process.env.npm_config_registry + '/install').then(response => { + if (!response.ok) process.exit(17); + fs.mkdirSync('node_modules/vite-plus', { recursive: true }); + fs.writeFileSync('node_modules/vite-plus/package.json', '{"name":"vite-plus"}'); + }); + `, +}); + +// The request is made after the CLI draws each phase. Hold its response until +// the runner captures the milestone, without sleeps or polling terminal text. +async function checkpoint(phase) { + if (!interactive) return; + const nextInput = once(process.stdin, 'data'); + process.stdin.resume(); + const id = randomBytes(16).toString('hex'); + const name = Buffer.from(`install:${phase}:ready`).toString('base64url'); + process.stderr.write(`\x1b]2;pty-terminal-test:${id}:${name}\x1b\\`); + await nextInput; + process.stdin.pause(); +} + +const phases = []; +const server = createServer(async (request, response) => { + if (request.url === '/index.json') { + phases.push('prepare'); + await checkpoint('prepare'); + response.end(JSON.stringify([{ version: 'v99.0.0', lts: 'Fixture' }])); + } else if (/^\/pnpm\/-\/pnpm-[\d.]+\.tgz$/.test(request.url)) { + phases.push('download'); + await checkpoint('download'); + response.setHeader('Content-Length', pnpmArchive.length); + response.end(pnpmArchive); + } else if (request.url === '/install') { + phases.push('dependencies'); + await checkpoint('dependencies'); + response.writeHead(failure ? 500 : 200).end(); + } else { + response.writeHead(404).end(); + } +}); +server.listen(0, '127.0.0.1'); +await once(server, 'listening'); +const mirror = `http://127.0.0.1:${server.address().port}`; + +try { + console.log('Before installation: preserve this output.'); + const child = spawn(join(external, 'vp'), [], { + stdio: ['ignore', 'pipe', 'inherit'], + env: { + ...process.env, + HOME: user, + PATH: nodeBin, + VP_HOME: home, + VP_NODE_DIST_MIRROR: mirror, + npm_config_registry: mirror, + NPM_CONFIG_REGISTRY: mirror, + VP_SELF_SETUP_SHELL: 'sh', + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + VP_NODE_MANAGER: 'no', + VP_SHELL: 'zsh', + VP_SKIP_DEPS_INSTALL: '', + }, + }); + let stdout = ''; + child.stdout.setEncoding('utf8').on('data', (chunk) => { + stdout += chunk; + }); + const [code, signal] = await once(child, 'exit'); + assert.equal(signal, null); + assert.equal(code, failure ? 1 : 0); + assert.deepEqual(phases, ['prepare', 'download', 'dependencies']); + if (failure) { + assert.equal(stdout, ''); + const log = readFileSync(join(home, 'upgrade.log'), 'utf8'); + assert.ok(log.includes('pnpm captured stdout')); + assert.ok(log.includes('pnpm captured stderr')); + console.log('Failure log preserves pnpm stdout and stderr.'); + } else { + assert.deepEqual( + stdout + .trim() + .split('\n') + .map((line) => line.split('=')[0]), + ['INSTALL_DIR', 'SHIM_DIR', 'CACHE_DIR', 'CONFIG_DIR', 'STATE_DIR'], + ); + console.log('Bootstrap stdout contains only shell assignments.'); + } + console.log('After installation.'); +} finally { + if (interactive) process.stdin.setRawMode(false); + server.close(); + server.closeAllConnections(); + for (const directory of [home, user, external]) + rmSync(directory, { recursive: true, force: true }); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml index f9204f7e82..aea82015da 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml @@ -22,3 +22,41 @@ name = "download_progress_ci" vp = "global" skip-platforms = ["windows"] steps = [{ argv = ["node", "download.mjs", "known"], envs = [["CI", "1"]] }] + +[[case]] +name = "installation_progress_interactive" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs", "interactive"], interactions = [ + { expect-milestone = "install:prepare:ready" }, + { write-key = "enter" }, + { expect-milestone = "install:download:ready" }, + { write-key = "enter" }, + { expect-milestone = "install:dependencies:ready" }, + { write-key = "enter" }, +] }] + +[[case]] +name = "installation_progress_failure" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs", "interactive", "failure"], interactions = [ + { expect-milestone = "install:prepare:ready" }, + { write-key = "enter" }, + { expect-milestone = "install:download:ready" }, + { write-key = "enter" }, + { expect-milestone = "install:dependencies:ready" }, + { write-key = "enter" }, +] }] + +[[case]] +name = "installation_progress_non_tty" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs"], tty = false }] + +[[case]] +name = "installation_progress_ci" +vp = "global" +skip-platforms = ["windows"] +steps = [{ argv = ["node", "install.mjs"], envs = [["CI", "1"]] }] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md new file mode 100644 index 0000000000..70472f4f8f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md @@ -0,0 +1,37 @@ +# installation_progress_ci + +## `CI=1 node install.mjs` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +info: Preparing Node.js and pnpm... +info: Installing dependencies... +✓ Dependencies installed. +Setup: + Preparing vite-plus environment. + +Created Shims: + /home/bin/node + /home/bin/npm + /home/bin/npx + /home/bin/pnpm + /home/bin/pnpx + /home/bin/yarn + /home/bin/yarnpkg + /home/bin/bun + /home/bin/bunx + /home/bin/vpx + /home/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/home/env" + + Add the command to your .zshrc file to activate future Zsh terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +Bootstrap stdout contains only shell assignments. +After installation. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md new file mode 100644 index 0000000000..9feb25033d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md @@ -0,0 +1,41 @@ +# installation_progress_failure + +## `node install.mjs interactive failure` + +**→ expect-milestone:** `install:prepare:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Preparing Node.js and pnpm... +``` + +**← write-key:** `enter` + +**→ expect-milestone:** `install:download:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Downloading pnpm ... B ( B/s) +``` + +**← write-key:** `enter` + +**→ expect-milestone:** `install:dependencies:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Installing dependencies... +``` + +**← write-key:** `enter` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +error: Setup error: Failed to install production dependencies (exit code: 17). See log for details: /home/upgrade.log +Failure log preserves pnpm stdout and stderr. +After installation. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md new file mode 100644 index 0000000000..7f9cf4ac39 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md @@ -0,0 +1,65 @@ +# installation_progress_interactive + +## `node install.mjs interactive` + +**→ expect-milestone:** `install:prepare:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Preparing Node.js and pnpm... +``` + +**← write-key:** `enter` + +**→ expect-milestone:** `install:download:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Downloading pnpm ... B ( B/s) +``` + +**← write-key:** `enter` + +**→ expect-milestone:** `install:dependencies:ready` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +⠿ Installing dependencies... +``` + +**← write-key:** `enter` + +``` +Before installation: preserve this output. +info: installing vite-plus@0.3.3... +✓ Dependencies installed. +Setup: + Preparing vite-plus environment. + +Created Shims: + /home/bin/node + /home/bin/npm + /home/bin/npx + /home/bin/pnpm + /home/bin/pnpx + /home/bin/yarn + /home/bin/yarnpkg + /home/bin/bun + /home/bin/bunx + /home/bin/vpx + /home/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/home/env" + + Add the command to your .zshrc file to activate future Zsh terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +Bootstrap stdout contains only shell assignments. +After installation. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md new file mode 100644 index 0000000000..13dcd90ff2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md @@ -0,0 +1,37 @@ +# installation_progress_non_tty + +## `node install.mjs` + +``` +Before installation: preserve this output. +Bootstrap stdout contains only shell assignments. +After installation. +info: installing vite-plus@0.3.3... +info: Preparing Node.js and pnpm... +info: Installing dependencies... +✓ Dependencies installed. +Setup: + Preparing vite-plus environment. + +Created Shims: + /home/bin/node + /home/bin/npm + /home/bin/npx + /home/bin/pnpm + /home/bin/pnpx + /home/bin/yarn + /home/bin/yarnpkg + /home/bin/bun + /home/bin/bunx + /home/bin/vpx + /home/bin/vpr + +Next Steps: + Activate Vite+ in this terminal: + . "/home/env" + + Add the command to your .zshrc file to activate future Zsh terminals. + + Restart an already-running IDE to load its environment. Run `vp env doctor` to verify. +✓ Vite+ setup complete. +``` diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index 1fa8741fb0..94ea88a930 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -213,7 +213,7 @@ async fn install_platform_and_main( install::generate_wrapper_package_json(version_dir, new_version).await?; // Install production dependencies (pnpm installs vite-plus + all transitive deps) - install::install_production_deps(version_dir, registry).await?; + install::install_production_deps(version_dir, registry, !silent).await?; // Save previous version for rollback let previous_version = install::save_previous_version(install_dir).await?; diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index fe65130603..6cca5a3c0b 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -238,7 +238,7 @@ async fn run(source: &Path, bundled: bool) -> Result { output::info(&format!("installing vite-plus@{version}...")); install::generate_wrapper_package_json(&version_dir, version).await?; if !skip_deps { - install::install_production_deps(&version_dir, registry).await?; + install::install_production_deps(&version_dir, registry, true).await?; } } #[cfg(windows)] diff --git a/crates/vp_installer/src/legacy.rs b/crates/vp_installer/src/legacy.rs index fbcdd95155..42bf7bf33e 100644 --- a/crates/vp_installer/src/legacy.rs +++ b/crates/vp_installer/src/legacy.rs @@ -137,7 +137,7 @@ async fn install_new_version( if !opts.quiet { print_info("installing dependencies (this may take a moment)..."); } - install::install_production_deps(version_dir, opts.registry.as_deref()).await?; + install::install_production_deps(version_dir, opts.registry.as_deref(), !opts.quiet).await?; let previous_version = if has_previous { install::save_previous_version(install_dir).await? } else { None }; diff --git a/crates/vp_js_runtime/Cargo.toml b/crates/vp_js_runtime/Cargo.toml index 722873ecd4..fa9f381a52 100644 --- a/crates/vp_js_runtime/Cargo.toml +++ b/crates/vp_js_runtime/Cargo.toml @@ -11,7 +11,6 @@ rust-version.workspace = true backon = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } -indicatif = { workspace = true } hex = { workspace = true } node-semver = { workspace = true } pgp = { workspace = true } diff --git a/crates/vp_js_runtime/src/download.rs b/crates/vp_js_runtime/src/download.rs index 9b4d84a5b5..d29bdba1c4 100644 --- a/crates/vp_js_runtime/src/download.rs +++ b/crates/vp_js_runtime/src/download.rs @@ -7,13 +7,13 @@ use std::{fs::File, time::Duration}; use backon::{ExponentialBuilder, Retryable}; use futures_util::StreamExt; -use indicatif::ProgressBar; use serde::de::DeserializeOwned; use sha2::{Digest, Sha256}; use tokio::{ fs, io::{AsyncSeekExt, AsyncWriteExt}, }; +use vp_shared::progress::Progress; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_str::Str; @@ -47,15 +47,7 @@ pub async fn download_file( // Create progress bar (only in TTY and not in CI). Built once and reused // across retry attempts; its position is reset at the start of every // attempt so a retried download doesn't double-count bytes. - let is_ci = vp_shared::EnvConfig::get().is_ci; - let progress = if vp_shared::is_stderr_terminal() && !is_ci { - let pb = ProgressBar::new_spinner(); - pb.set_style(vp_shared::download_progress::download_style(message)); - pb.enable_steady_tick(Duration::from_millis(100)); - Some(pb) - } else { - None - }; + let progress = Progress::download(message); // Make the request *and* the body stream a single retried unit, so a // truncated download (bytes written != advertised Content-Length) triggers @@ -122,6 +114,7 @@ pub async fn download_file( }; if let Some(ref pb) = progress { + let pb = pb.bar(); pb.set_position(if is_resumed { resume_from } else { 0 }); if let Some(size) = total_size { pb.set_length(size); @@ -149,7 +142,7 @@ pub async fn download_file( let chunk = chunk_result?; bytes_written += chunk.len() as u64; if let Some(ref pb) = progress { - pb.inc(chunk.len() as u64); + pb.bar().inc(chunk.len() as u64); } file.write_all(&chunk).await?; } @@ -183,9 +176,7 @@ pub async fn download_file( reason: vp_shared::format_error_chain(&e).into(), }); - if let Some(pb) = progress { - pb.finish_and_clear(); - } + drop(progress); result?; diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index a7865586a8..382a573c88 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -14,6 +14,7 @@ use sha2::{Digest, Sha224, Sha256, Sha512}; use tar::Archive; use tokio::{fs, io::AsyncWriteExt}; use vp_error::Error; +use vp_shared::progress::Progress; /// HTTP client with built-in retry support #[derive(Clone)] @@ -169,18 +170,7 @@ impl HttpClient { // Progress bar (only in TTY and not in CI). Built once and reused across // retry attempts; its position is reset at the start of every attempt so // a retried download doesn't double-count bytes. - let is_ci = vp_shared::EnvConfig::get().is_ci; - let progress = if let Some(message) = message - && vp_shared::is_stderr_terminal() - && !is_ci - { - let pb = ProgressBar::new_spinner(); - pb.set_style(vp_shared::download_progress::download_style(message)); - pb.enable_steady_tick(Duration::from_millis(100)); - Some(pb) - } else { - None - }; + let progress = message.and_then(Progress::download); // Make the request *and* the body stream a single retried unit. Doing // the request inline (instead of calling `self.get`) avoids a double @@ -194,12 +184,18 @@ impl HttpClient { let result = (|| async { let response = client.get(url).timeout(timeout).send().await?.error_for_status()?; if let Some(ref pb) = progress { + let pb = pb.bar(); pb.set_position(0); if let Some(size) = response.content_length() { pb.set_length(size); } } - Self::write_response_to_file(response, target_path, progress.as_ref()).await + Self::write_response_to_file( + response, + target_path, + progress.as_ref().map(Progress::bar), + ) + .await }) .retry( ExponentialBuilder::default() @@ -209,9 +205,7 @@ impl HttpClient { ) .await; - if let Some(pb) = progress { - pb.finish_and_clear(); - } + drop(progress); result?; tracing::debug!("Download completed: {:?}", target_path); diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index e4970941db..64a0198b35 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -176,6 +176,7 @@ pub async fn write_upgrade_log( pub async fn install_production_deps( version_dir: &AbsolutePath, registry: Option<&str>, + show_progress: bool, ) -> Result<(), Error> { tracing::debug!("Running pnpm install in {}", version_dir.as_path().display()); @@ -189,6 +190,35 @@ pub async fn install_production_deps( args.push(registry_url); } + let (node_runtime, pnpm_entry) = vp_shared::progress::with_spinner( + show_progress.then_some("Preparing Node.js and pnpm..."), + prepare_install_runtime(), + ) + .await?; + let output = vp_shared::progress::with_spinner( + show_progress.then_some("Installing dependencies..."), + run_pnpm_install(version_dir, &node_runtime, &pnpm_entry, &args, registry), + ) + .await?; + + if !output.status.success() { + let log_path = write_upgrade_log(version_dir, &output.stdout, &output.stderr).await; + return Err(Error::Setup( + format_install_failure_message( + vp_shared::exit_code_from_status(output.status), + log_path.as_ref(), + ) + .into(), + )); + } + + if show_progress { + vp_shared::output::success("Dependencies installed."); + } + Ok(()) +} + +async fn prepare_install_runtime() -> Result<(vp_js_runtime::JsRuntime, AbsolutePathBuf), Error> { let node_version = NodeProvider::new().resolve_latest_version().await.map_err(|error| { Error::Setup(format!("Failed to resolve the latest Node.js LTS version: {error}").into()) })?; @@ -210,20 +240,7 @@ pub async fn install_production_deps( format!("pnpm entry not found at {}", pnpm_entry.as_path().display()).into(), )); } - let output = run_pnpm_install(version_dir, &node_runtime, &pnpm_entry, &args, registry).await?; - - if !output.status.success() { - let log_path = write_upgrade_log(version_dir, &output.stdout, &output.stderr).await; - return Err(Error::Setup( - format_install_failure_message( - vp_shared::exit_code_from_status(output.status), - log_path.as_ref(), - ) - .into(), - )); - } - - Ok(()) + Ok((node_runtime, pnpm_entry)) } async fn run_pnpm_install( diff --git a/crates/vp_shared/Cargo.toml b/crates/vp_shared/Cargo.toml index b1f179684b..bfaef1910f 100644 --- a/crates/vp_shared/Cargo.toml +++ b/crates/vp_shared/Cargo.toml @@ -27,6 +27,7 @@ supports-color = "3" temp-env = { workspace = true, optional = true, features = ["async_closure"] } tempfile = { workspace = true, optional = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } vt_path = { workspace = true } @@ -45,6 +46,7 @@ webpki-root-certs = { workspace = true } indicatif = { workspace = true, features = ["in_memory"] } serial_test = { workspace = true } tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } # Enables the `test-utils` feature for this crate's own tests and doctests # (a package's dev-dependencies are active when it is the tested target). vp_shared = { path = ".", features = ["test-utils"] } diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index 539deca36f..eb559c0d97 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -20,6 +20,7 @@ pub mod output; mod package_json; mod path_env; mod process; +pub mod progress; mod stdio; pub mod string_similarity; mod tls; diff --git a/crates/vp_shared/src/progress.rs b/crates/vp_shared/src/progress.rs new file mode 100644 index 0000000000..8c4ed049b5 --- /dev/null +++ b/crates/vp_shared/src/progress.rs @@ -0,0 +1,224 @@ +//! A single progress row for an operation and its sequential downloads. + +use std::{fmt, future::Future, time::Duration}; + +use console::{Term, measure_text_width, truncate_str}; +use indicatif::{ProgressBar, ProgressState, ProgressStyle}; +use vt_str::{Str, format}; + +tokio::task_local! { + static ACTIVE_PROGRESS: ProgressBar; +} + +fn enabled() -> bool { + crate::is_stderr_terminal() && !crate::EnvConfig::get().is_ci +} + +/// Animate a status with elapsed time while awaiting work. Downloads within the +/// future temporarily use the same row, then restore this status. A missing +/// message suppresses the status. +pub async fn with_spinner(message: Option<&str>, future: F) -> F::Output { + let Some(message) = message else { + return future.await; + }; + if !enabled() { + crate::output::info(message); + return future.await; + } + + let term = Term::stderr(); + let progress = Progress::new( + ProgressBar::new_spinner(), + spinner_style(message, move || term.size().1), + None, + ); + progress.run(future).await +} + +/// Clears a download on completion or cancellation, or restores its enclosing +/// operation's spinner. Keep this guard alive until the download finishes. +pub struct Progress { + bar: ProgressBar, + restore: Option<(ProgressStyle, Duration)>, +} + +impl Progress { + pub fn download(message: &str) -> Option { + let (bar, restore) = match ACTIVE_PROGRESS.try_with(Clone::clone) { + Ok(bar) => { + let restore = Some((bar.style(), bar.elapsed())); + (bar, restore) + } + Err(_) if enabled() => (ProgressBar::new_spinner(), None), + Err(_) => return None, + }; + Some(Self::new(bar, crate::download_progress::download_style(message), restore)) + } + + fn new( + bar: ProgressBar, + style: ProgressStyle, + restore: Option<(ProgressStyle, Duration)>, + ) -> Self { + bar.set_style(style.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ")); + bar.reset(); + bar.unset_length(); + bar.enable_steady_tick(Duration::from_millis(100)); + bar.force_draw(); + Self { bar, restore } + } + + pub fn bar(&self) -> &ProgressBar { + &self.bar + } + + async fn run(self, future: F) -> F::Output { + ACTIVE_PROGRESS.scope(self.bar.clone(), future).await + } +} + +impl Drop for Progress { + fn drop(&mut self) { + if let Some((style, elapsed)) = self.restore.take() { + let elapsed = elapsed + self.bar.elapsed(); + self.bar.set_style(style); + self.bar.unset_length(); + self.bar.set_position(0); + self.bar.set_elapsed(elapsed); + self.bar.force_draw(); + } else { + self.bar.finish_and_clear(); + } + } +} + +fn spinner_style( + message: &str, + width: impl Fn() -> u16 + Clone + Send + Sync + 'static, +) -> ProgressStyle { + let message = Str::from(message); + ProgressStyle::with_template("{spinner:.green}{status}") + .expect("valid operation progress template") + .with_key("status", move |state: &ProgressState, output: &mut dyn fmt::Write| { + // Fit one row even after a resize, reserving a column for the spinner. + let available = usize::from(width()).saturating_sub(1); + let elapsed = format!("{}s", state.elapsed().as_secs()); + let message_width = available.saturating_sub(measure_text_width(&elapsed) + 2); + let message = truncate_str(&message, message_width, ""); + let row = format!(" {message} {elapsed}"); + write!(output, "{}", truncate_str(&row, available, "")).unwrap(); + }) +} + +#[cfg(test)] +mod tests { + use std::task::{Context, Poll, Waker}; + + use indicatif::{InMemoryTerm, ProgressDrawTarget, TermLike}; + + use super::*; + + fn spinner(term: &InMemoryTerm) -> Progress { + let draw_term = term.clone(); + Progress::new( + ProgressBar::with_draw_target( + None, + ProgressDrawTarget::term_like(Box::new(term.clone())), + ), + spinner_style("Preparing Node.js and pnpm...", move || draw_term.width()), + None, + ) + } + + fn assert_single_row(term: &InMemoryTerm) { + let screen = term.contents(); + let lines: Vec<_> = screen.lines().collect(); + assert_eq!(lines.len(), 2, "{screen}"); + assert_eq!(lines[0], "A"); + assert!(measure_text_width(lines[1]) <= usize::from(term.width())); + } + + #[tokio::test] + async fn downloads_share_one_row_and_restore_the_elapsed_spinner() { + // Download styles read stderr's width, so the test surface must fit it. + let width = Term::stderr().size().1.max(80); + for width in [width, width.saturating_add(40)] { + let term = InMemoryTerm::new(10, width); + term.write_line("A").unwrap(); + let progress = spinner(&term); + progress.bar.set_elapsed(Duration::from_secs(8)); + progress.bar.force_draw(); + assert_single_row(&term); + + progress + .run(async { + for message in ["Downloading Node.js...", "Downloading pnpm..."] { + let download = Progress::download(message).unwrap(); + download.bar.set_length(100); + download.bar.set_position(70); + download.bar.force_draw(); + assert_single_row(&term); + assert!(term.contents().contains(message)); + assert!(!term.contents().contains("Preparing")); + drop(download); + assert_single_row(&term); + assert!(term.contents().contains("Preparing Node.js and pnpm...")); + ACTIVE_PROGRESS.with(|bar| { + assert!(bar.elapsed() >= Duration::from_secs(8)); + }); + } + }) + .await; + + assert_eq!(term.contents(), "A"); + term.write_line("Next step").unwrap(); + assert_eq!(term.contents(), "A\nNext step"); + } + } + + #[test] + fn narrow_spinners_preserve_earlier_output() { + for width in [1, 2, 3, 10, 20, 40, 80, 138] { + let term = InMemoryTerm::new(10, width); + term.write_line("A").unwrap(); + let progress = spinner(&term); + for elapsed in [0, 8, 1000] { + progress.bar.set_elapsed(Duration::from_secs(elapsed)); + progress.bar.force_draw(); + assert_single_row(&term); + } + drop(progress); + assert_eq!(term.contents(), "A"); + } + } + + #[tokio::test] + async fn errors_clear_progress_and_preserve_the_result() { + let term = InMemoryTerm::new(10, 80); + term.write_line("Earlier output").unwrap(); + let result = spinner(&term) + .run(async { + let _download = Progress::download("Downloading Node.js...").unwrap(); + Err::<(), _>("download failed") + }) + .await; + assert_eq!(result, Err("download failed")); + assert_eq!(term.contents(), "Earlier output"); + assert!(ACTIVE_PROGRESS.try_with(|_| ()).is_err()); + } + + #[test] + fn cancellation_clears_progress() { + let term = InMemoryTerm::new(10, 80); + term.write_line("Earlier output").unwrap(); + let mut future = Box::pin(spinner(&term).run(async { + let _download = Progress::download("Downloading Node.js...").unwrap(); + std::future::pending::<()>().await; + })); + assert_eq!(future.as_mut().poll(&mut Context::from_waker(Waker::noop())), Poll::Pending); + assert!(term.contents().contains("Downloading Node.js...")); + drop(future); + assert_eq!(term.contents(), "Earlier output"); + assert!(ACTIVE_PROGRESS.try_with(|_| ()).is_err()); + } +} From 66d4a5c7394ff12976f0548e012690e168b6b17a Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 20 Sep 2026 23:05:46 +0800 Subject: [PATCH 16/18] refactor(progress): share spinner and download rendering --- crates/vp_shared/src/download_progress.rs | 205 ------------------- crates/vp_shared/src/lib.rs | 1 - crates/vp_shared/src/progress.rs | 57 ++---- crates/vp_shared/src/progress/style.rs | 227 ++++++++++++++++++++++ 4 files changed, 238 insertions(+), 252 deletions(-) delete mode 100644 crates/vp_shared/src/download_progress.rs create mode 100644 crates/vp_shared/src/progress/style.rs diff --git a/crates/vp_shared/src/download_progress.rs b/crates/vp_shared/src/download_progress.rs deleted file mode 100644 index 24a4ac312b..0000000000 --- a/crates/vp_shared/src/download_progress.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Single-row download progress shared by runtime and package-manager downloads. - -use std::fmt; - -use console::{Term, measure_text_width, style, truncate_str}; -use indicatif::{HumanBytes, HumanDuration, ProgressState, ProgressStyle}; -use vt_str::{Str, format}; - -/// Fit the download message and statistics to stderr on every redraw. -/// The same style handles responses with and without a content length. -pub fn download_style(message: &str) -> ProgressStyle { - let term = Term::stderr(); - style_with_width(message, move || term.size().1) -} - -fn style_with_width( - message: &str, - width: impl Fn() -> u16 + Clone + Send + Sync + 'static, -) -> ProgressStyle { - let message = Str::from(message); - ProgressStyle::with_template("{spinner:.green}{download}") - .expect("valid download progress template") - .with_key("download", move |state: &ProgressState, output: &mut dyn fmt::Write| { - // Reserve one column for the spinner. Read the width on each draw - // so a terminal resize also changes the message and statistics. - let available = usize::from(width()).saturating_sub(1); - let row = download_row(state, &message, available); - write!(output, "{}", truncate_str(&row, available, "")).unwrap(); - }) -} - -fn download_row(state: &ProgressState, message: &str, available: usize) -> Str { - let has_total = state.len().is_some(); - let message_width = measure_text_width(message); - let bytes = HumanBytes(state.pos()); - let mut compact_stats = match state.len() { - Some(total) => format!("{bytes}/{}", HumanBytes(total)), - None => format!("{bytes}"), - }; - let speed = HumanBytes(state.per_sec() as u64); - let detailed_stats = if has_total { - format!("{compact_stats} ({speed}/s, {:#})", HumanDuration(state.eta())) - } else { - format!("{compact_stats} ({speed}/s)") - }; - let text_width = message_width + measure_text_width(&detailed_stats); - if has_total { - // Reserve three spaces and two brackets, plus at least four bar columns. - let bar_width = available.saturating_sub(text_width + 5); - if bar_width >= 4 { - let filled = (state.fraction() * bar_width as f32) as usize; - let remaining = bar_width - filled; - let bar = format!("{}{}", "#".repeat(filled), if remaining > 0 { ">" } else { "" }); - return format!( - " {message} [{}{}] {detailed_stats}", - style(bar).blue(), - style("-".repeat(remaining.saturating_sub(1))).white(), - ); - } - } else if text_width + 2 <= available { - return format!(" {message} {detailed_stats}"); - } - - // On narrow terminals, omit the bar, speed, and ETA before shortening the - // message. For very small panes, prefer a percentage to two byte counts. - let compact_width = measure_text_width(&compact_stats) + 2 + message_width.min(8); - if has_total && compact_width > available { - compact_stats = format!("{:.0}%", state.fraction() * 100.0); - } - let available_message_width = available.saturating_sub(measure_text_width(&compact_stats) + 2); - let message = truncate_str(message, available_message_width, ""); - if message.is_empty() { - format!(" {compact_stats}") - } else { - format!(" {message} {compact_stats}") - } -} - -#[cfg(test)] -mod tests { - use std::{ - sync::{ - Arc, - atomic::{AtomicU16, Ordering}, - }, - time::Duration, - }; - - use indicatif::{InMemoryTerm, ProgressBar, ProgressDrawTarget, TermLike}; - - use super::*; - - #[test] - fn redraws_stay_on_one_row_and_preserve_earlier_output() { - for width in [1, 2, 3, 10, 20, 40, 60, 80, 138] { - for message in [ - "Downloading node v26.8.0...", - "Downloading pnpm v11.24.0...", - "Downloading a package with a very long name and wide characters 界界界界界界界界界界界界...", - ] { - for total in [55 * 1024 * 1024, u64::MAX] { - let earlier = if width >= 20 { "Earlier shell output" } else { "A" }; - let term = InMemoryTerm::new(10, width); - term.write_line(earlier).unwrap(); - let progress = ProgressBar::with_draw_target( - None, - ProgressDrawTarget::term_like(Box::new(term.clone())), - ); - let draw_term = term.clone(); - progress.set_style(style_with_width(message, move || draw_term.width())); - - for known_length in [false, true] { - if known_length { - progress.set_length(total); - } - for position in [0, 5 * 1024 * 1024, 25 * 1024 * 1024, total] { - progress.set_elapsed(Duration::from_secs(2)); - progress.set_position(position); - assert_progress_row(&progress, &term, earlier); - progress.reset_eta(); - assert_progress_row(&progress, &term, earlier); - } - } - - progress.finish_and_clear(); - assert_eq!(term.contents(), earlier); - assert!(!term.moves_since_last_check().contains("Up(")); - term.write_line("B").unwrap(); - assert_eq!(term.contents(), vt_str::format!("{earlier}\nB").as_str()); - } - } - } - } - - fn assert_progress_row(progress: &ProgressBar, term: &InMemoryTerm, earlier: &str) { - progress.force_draw(); - let width = term.width(); - let screen = term.contents(); - let lines: Vec<_> = screen.lines().collect(); - assert_eq!(lines.len(), 2, "width {width}: {screen}"); - assert_eq!(lines[0], earlier); - assert!(measure_text_width(lines[1]) <= usize::from(width)); - let moves = term.moves_since_last_check(); - assert!(!moves.contains("Up("), "width {width}: {moves}"); - } - - #[test] - fn compact_layout_preserves_download_counts() { - for (width, has_bar) in [(40, false), (60, false), (80, true), (138, true)] { - let term = InMemoryTerm::new(5, width); - let progress = ProgressBar::with_draw_target( - None, - ProgressDrawTarget::term_like(Box::new(term.clone())), - ); - let draw_term = term.clone(); - progress.set_style(style_with_width("Downloading pnpm v11.24.0...", move || { - draw_term.width() - })); - progress.set_position(25 * 1024 * 1024); - progress.reset_eta(); - progress.force_draw(); - assert!(term.contents().contains("25.00 MiB")); - - progress.set_length(55 * 1024 * 1024); - progress.force_draw(); - let screen = term.contents(); - assert!(screen.contains("25.00 MiB/55.00 MiB"), "{screen}"); - assert_eq!(screen.contains('['), has_bar, "{screen}"); - assert_eq!(screen.contains("0 B/s"), has_bar, "{screen}"); - if width >= 60 { - assert!(screen.contains("Downloading pnpm v11.24.0..."), "{screen}"); - } - } - } - - #[test] - fn layout_reads_the_current_width_on_each_draw() { - let width = Arc::new(AtomicU16::new(138)); - let term = InMemoryTerm::new(5, 138); - let progress = ProgressBar::with_draw_target( - None, - ProgressDrawTarget::term_like(Box::new(term.clone())), - ); - let draw_width = Arc::clone(&width); - progress.set_style(style_with_width("Downloading pnpm v11.24.0...", move || { - draw_width.load(Ordering::Relaxed) - })); - progress.set_position(25 * 1024 * 1024); - progress.reset_eta(); - - for known_length in [false, true] { - if known_length { - progress.set_length(55 * 1024 * 1024); - } - for columns in [138, 60, 40, 1, 80, 138] { - width.store(columns, Ordering::Relaxed); - progress.force_draw(); - let screen = term.contents(); - assert_eq!(screen.lines().count(), 1, "{screen}"); - assert!(measure_text_width(&screen) <= usize::from(columns), "{screen}"); - assert!(!term.moves_since_last_check().contains("Up(")); - } - } - } -} diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index eb559c0d97..049d9e72c5 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -8,7 +8,6 @@ )] mod dirs; -pub mod download_progress; mod env_config; pub mod env_vars; mod error; diff --git a/crates/vp_shared/src/progress.rs b/crates/vp_shared/src/progress.rs index 8c4ed049b5..df18741c64 100644 --- a/crates/vp_shared/src/progress.rs +++ b/crates/vp_shared/src/progress.rs @@ -1,10 +1,12 @@ //! A single progress row for an operation and its sequential downloads. -use std::{fmt, future::Future, time::Duration}; +mod style; -use console::{Term, measure_text_width, truncate_str}; -use indicatif::{ProgressBar, ProgressState, ProgressStyle}; -use vt_str::{Str, format}; +use std::{future::Future, time::Duration}; + +use indicatif::{ProgressBar, ProgressStyle}; + +use self::style::Style; tokio::task_local! { static ACTIVE_PROGRESS: ProgressBar; @@ -26,12 +28,8 @@ pub async fn with_spinner(message: Option<&str>, future: F) -> F::Out return future.await; } - let term = Term::stderr(); - let progress = Progress::new( - ProgressBar::new_spinner(), - spinner_style(message, move || term.size().1), - None, - ); + let progress = + Progress::new(ProgressBar::new_spinner(), Style::Spinner.for_stderr(message), None); progress.run(future).await } @@ -52,7 +50,7 @@ impl Progress { Err(_) if enabled() => (ProgressBar::new_spinner(), None), Err(_) => return None, }; - Some(Self::new(bar, crate::download_progress::download_style(message), restore)) + Some(Self::new(bar, Style::Download.for_stderr(message), restore)) } fn new( @@ -92,28 +90,11 @@ impl Drop for Progress { } } -fn spinner_style( - message: &str, - width: impl Fn() -> u16 + Clone + Send + Sync + 'static, -) -> ProgressStyle { - let message = Str::from(message); - ProgressStyle::with_template("{spinner:.green}{status}") - .expect("valid operation progress template") - .with_key("status", move |state: &ProgressState, output: &mut dyn fmt::Write| { - // Fit one row even after a resize, reserving a column for the spinner. - let available = usize::from(width()).saturating_sub(1); - let elapsed = format!("{}s", state.elapsed().as_secs()); - let message_width = available.saturating_sub(measure_text_width(&elapsed) + 2); - let message = truncate_str(&message, message_width, ""); - let row = format!(" {message} {elapsed}"); - write!(output, "{}", truncate_str(&row, available, "")).unwrap(); - }) -} - #[cfg(test)] mod tests { use std::task::{Context, Poll, Waker}; + use console::{Term, measure_text_width}; use indicatif::{InMemoryTerm, ProgressDrawTarget, TermLike}; use super::*; @@ -125,7 +106,7 @@ mod tests { None, ProgressDrawTarget::term_like(Box::new(term.clone())), ), - spinner_style("Preparing Node.js and pnpm...", move || draw_term.width()), + Style::Spinner.with_width("Preparing Node.js and pnpm...", move || draw_term.width()), None, ) } @@ -176,22 +157,6 @@ mod tests { } } - #[test] - fn narrow_spinners_preserve_earlier_output() { - for width in [1, 2, 3, 10, 20, 40, 80, 138] { - let term = InMemoryTerm::new(10, width); - term.write_line("A").unwrap(); - let progress = spinner(&term); - for elapsed in [0, 8, 1000] { - progress.bar.set_elapsed(Duration::from_secs(elapsed)); - progress.bar.force_draw(); - assert_single_row(&term); - } - drop(progress); - assert_eq!(term.contents(), "A"); - } - } - #[tokio::test] async fn errors_clear_progress_and_preserve_the_result() { let term = InMemoryTerm::new(10, 80); diff --git a/crates/vp_shared/src/progress/style.rs b/crates/vp_shared/src/progress/style.rs new file mode 100644 index 0000000000..79aaedc23d --- /dev/null +++ b/crates/vp_shared/src/progress/style.rs @@ -0,0 +1,227 @@ +//! Width-aware styles for a single progress row. + +use std::fmt; + +use console::{Term, measure_text_width, style, truncate_str}; +use indicatif::{HumanBytes, HumanDuration, ProgressState, ProgressStyle}; +use vt_str::{Str, format}; + +#[derive(Clone, Copy)] +pub(super) enum Style { + Spinner, + Download, +} + +impl Style { + pub(super) fn for_stderr(self, message: &str) -> ProgressStyle { + let term = Term::stderr(); + self.with_width(message, move || term.size().1) + } + + pub(super) fn with_width( + self, + message: &str, + width: impl Fn() -> u16 + Clone + Send + Sync + 'static, + ) -> ProgressStyle { + let message = Str::from(message); + ProgressStyle::with_template("{spinner:.green}{status}") + .expect("valid progress template") + .with_key("status", move |state: &ProgressState, output: &mut dyn fmt::Write| { + // Reserve one column for the spinner. Read the width on each draw + // so a terminal resize also changes the message and statistics. + let available = usize::from(width()).saturating_sub(1); + let row = match self { + Self::Spinner => spinner_row(state, &message, available), + Self::Download => download_row(state, &message, available), + }; + write!(output, "{}", truncate_str(&row, available, "")).unwrap(); + }) + } +} + +fn spinner_row(state: &ProgressState, message: &str, available: usize) -> Str { + let elapsed = format!("{}s", state.elapsed().as_secs()); + let message_width = available.saturating_sub(measure_text_width(&elapsed) + 2); + let message = truncate_str(message, message_width, ""); + format!(" {message} {elapsed}") +} + +fn download_row(state: &ProgressState, message: &str, available: usize) -> Str { + let has_total = state.len().is_some(); + let message_width = measure_text_width(message); + let bytes = HumanBytes(state.pos()); + let mut compact_stats = match state.len() { + Some(total) => format!("{bytes}/{}", HumanBytes(total)), + None => format!("{bytes}"), + }; + let speed = HumanBytes(state.per_sec() as u64); + let detailed_stats = if has_total { + format!("{compact_stats} ({speed}/s, {:#})", HumanDuration(state.eta())) + } else { + format!("{compact_stats} ({speed}/s)") + }; + let text_width = message_width + measure_text_width(&detailed_stats); + if has_total { + // Reserve three spaces and two brackets, plus at least four bar columns. + let bar_width = available.saturating_sub(text_width + 5); + if bar_width >= 4 { + let filled = (state.fraction() * bar_width as f32) as usize; + let remaining = bar_width - filled; + let bar = format!("{}{}", "#".repeat(filled), if remaining > 0 { ">" } else { "" }); + return format!( + " {message} [{}{}] {detailed_stats}", + style(bar).blue(), + style("-".repeat(remaining.saturating_sub(1))).white(), + ); + } + } else if text_width + 2 <= available { + return format!(" {message} {detailed_stats}"); + } + + // On narrow terminals, omit the bar, speed, and ETA before shortening the + // message. For very small panes, prefer a percentage to two byte counts. + let compact_width = measure_text_width(&compact_stats) + 2 + message_width.min(8); + if has_total && compact_width > available { + compact_stats = format!("{:.0}%", state.fraction() * 100.0); + } + let available_message_width = available.saturating_sub(measure_text_width(&compact_stats) + 2); + let message = truncate_str(message, available_message_width, ""); + if message.is_empty() { + format!(" {compact_stats}") + } else { + format!(" {message} {compact_stats}") + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicU16, Ordering}, + }, + time::Duration, + }; + + use indicatif::{InMemoryTerm, ProgressBar, ProgressDrawTarget, TermLike}; + + use super::*; + + #[test] + fn redraws_stay_on_one_row_and_preserve_earlier_output() { + for style in [Style::Download, Style::Spinner] { + for width in [1, 2, 3, 10, 20, 40, 60, 80, 138] { + for message in [ + "Downloading node v26.8.0...", + "Downloading pnpm v11.24.0...", + "Downloading a package with a very long name and wide characters 界界界界界界界界界界界界...", + ] { + for total in [55 * 1024 * 1024, u64::MAX] { + let earlier = if width >= 20 { "Earlier shell output" } else { "A" }; + let term = InMemoryTerm::new(10, width); + term.write_line(earlier).unwrap(); + let progress = ProgressBar::with_draw_target( + None, + ProgressDrawTarget::term_like(Box::new(term.clone())), + ); + let draw_term = term.clone(); + progress.set_style(style.with_width(message, move || draw_term.width())); + + for known_length in [false, true] { + if known_length { + progress.set_length(total); + } + for position in [0, 5 * 1024 * 1024, 25 * 1024 * 1024, total] { + progress.set_elapsed(Duration::from_secs(2)); + progress.set_position(position); + assert_progress_row(&progress, &term, earlier); + progress.reset_eta(); + assert_progress_row(&progress, &term, earlier); + } + } + + progress.finish_and_clear(); + assert_eq!(term.contents(), earlier); + assert!(!term.moves_since_last_check().contains("Up(")); + term.write_line("B").unwrap(); + assert_eq!(term.contents(), vt_str::format!("{earlier}\nB").as_str()); + } + } + } + } + } + + fn assert_progress_row(progress: &ProgressBar, term: &InMemoryTerm, earlier: &str) { + progress.force_draw(); + let width = term.width(); + let screen = term.contents(); + let lines: Vec<_> = screen.lines().collect(); + assert_eq!(lines.len(), 2, "width {width}: {screen}"); + assert_eq!(lines[0], earlier); + assert!(measure_text_width(lines[1]) <= usize::from(width)); + let moves = term.moves_since_last_check(); + assert!(!moves.contains("Up("), "width {width}: {moves}"); + } + + #[test] + fn compact_layout_preserves_download_counts() { + for (width, has_bar) in [(40, false), (60, false), (80, true), (138, true)] { + let term = InMemoryTerm::new(5, width); + let progress = ProgressBar::with_draw_target( + None, + ProgressDrawTarget::term_like(Box::new(term.clone())), + ); + let draw_term = term.clone(); + progress.set_style( + Style::Download + .with_width("Downloading pnpm v11.24.0...", move || draw_term.width()), + ); + progress.set_position(25 * 1024 * 1024); + progress.reset_eta(); + progress.force_draw(); + assert!(term.contents().contains("25.00 MiB")); + + progress.set_length(55 * 1024 * 1024); + progress.force_draw(); + let screen = term.contents(); + assert!(screen.contains("25.00 MiB/55.00 MiB"), "{screen}"); + assert_eq!(screen.contains('['), has_bar, "{screen}"); + assert_eq!(screen.contains("0 B/s"), has_bar, "{screen}"); + if width >= 60 { + assert!(screen.contains("Downloading pnpm v11.24.0..."), "{screen}"); + } + } + } + + #[test] + fn layout_reads_the_current_width_on_each_draw() { + for style in [Style::Download, Style::Spinner] { + let width = Arc::new(AtomicU16::new(138)); + let term = InMemoryTerm::new(5, 138); + let progress = ProgressBar::with_draw_target( + None, + ProgressDrawTarget::term_like(Box::new(term.clone())), + ); + let draw_width = Arc::clone(&width); + progress.set_style(style.with_width("Downloading pnpm v11.24.0...", move || { + draw_width.load(Ordering::Relaxed) + })); + progress.set_position(25 * 1024 * 1024); + progress.reset_eta(); + + for known_length in [false, true] { + if known_length { + progress.set_length(55 * 1024 * 1024); + } + for columns in [138, 60, 40, 1, 80, 138] { + width.store(columns, Ordering::Relaxed); + progress.force_draw(); + let screen = term.contents(); + assert_eq!(screen.lines().count(), 1, "{screen}"); + assert!(measure_text_width(&screen) <= usize::from(columns), "{screen}"); + assert!(!term.moves_since_last_check().contains("Up(")); + } + } + } + } +} From d4fc4f14bc1affc9fde1df05ce81c15ec70baca6 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 20 Sep 2026 23:35:26 +0800 Subject: [PATCH 17/18] refactor(env): simplify shell activation guidance --- .../profile-guidance.mjs | 29 ++++---- .../fixtures/download_progress/archive.mjs | 6 +- .../fixtures/download_progress/install.mjs | 14 ++-- .../vp_global_cli/src/commands/env/setup.rs | 67 +++++++++---------- 4 files changed, 60 insertions(+), 56 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs index 98411ab4ef..7ed7b5fe75 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -54,6 +54,18 @@ function setup(shell) { return output.slice(heading).trimEnd(); } +/** @returns {{ system: string, source: string }} */ +function prepareSystemNode() { + captureVp(['env', 'on', 'node']); + const system = path.resolve('profiles/system'); + fs.mkdirSync(system, { recursive: true }); + fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); + // Keep system startup helpers available, with the fake Node first on PATH. + env.PATH = [system, '/usr/bin', '/bin'].join(path.delimiter); + const envPath = path.join(dirs.config, 'env').replace(/[\\$`"]/g, '\\$&'); + return { system, source: `. "${envPath}"\n` }; +} + if (mode === 'powershell') { // Control executable discovery independently of the shells installed on the runner. const shellBin = path.resolve('profiles/shell-bin'); @@ -112,13 +124,7 @@ if (mode === 'powershell') { } } } else if (mode.startsWith('zsh-')) { - captureVp(['env', 'on', 'node']); - const system = path.resolve('profiles/system'); - fs.mkdirSync(system, { recursive: true }); - fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); - env.PATH = [system, '/usr/bin', '/bin'].join(path.delimiter); - const envPath = path.join(dirs.config, 'env').replace(/[\\$`"]/g, '\\$&'); - const source = `. "${envPath}"\n`; + const { system, source } = prepareSystemNode(); fs.mkdirSync(env.ZDOTDIR, { recursive: true }); // Ubuntu's global compinit can prompt about the runner's completion directories. // Keep normal profile loading, but skip completion setup in this PATH test. @@ -141,19 +147,12 @@ if (mode === 'powershell') { } else { // Suppress Ubuntu's sudo hint while still loading the normal Bash startup files. fs.writeFileSync(path.join(home, '.hushlogin'), ''); - captureVp(['env', 'on', 'node']); + const { system, source } = prepareSystemNode(); const bash = process.env.PATH.split(path.delimiter) .map((dir) => path.join(dir, 'bash')) .find((file) => fs.existsSync(file)); assert.ok(bash); env.SHELL = '/bin/bash'; - const system = path.resolve('profiles/system'); - fs.mkdirSync(system, { recursive: true }); - fs.writeFileSync(path.join(system, 'node'), '#!/bin/sh\necho system-node\n', { mode: 0o755 }); - // Keep system startup helpers available, with the fake Node first on PATH. - env.PATH = [system, '/usr/bin', '/bin'].join(path.delimiter); - const envPath = path.join(dirs.config, 'env').replace(/[\\$`"]/g, '\\$&'); - const source = `. "${envPath}"\n`; const fish = path.join(env.XDG_CONFIG_HOME, 'fish/config.fish'); fs.mkdirSync(path.dirname(fish), { recursive: true }); fs.writeFileSync(fish, `source "${path.join(dirs.config, 'env.fish')}"\n`); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs index 3949ec2a16..32d16431c2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/archive.mjs @@ -1,6 +1,10 @@ import { gzipSync } from 'node:zlib'; -/** Create a small, valid archive to keep download tests offline. */ +/** + * Create a small, valid archive to keep download tests offline. + * @param {Record} files + * @returns {Buffer} + */ export function createArchive(files) { const blocks = []; for (const [name, contents] of Object.entries(files)) { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs index 8fdc35b9a3..187639828f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs @@ -39,7 +39,10 @@ const pnpmArchive = createArchive({ // The request is made after the CLI draws each phase. Hold its response until // the runner captures the milestone, without sleeps or polling terminal text. +const phases = []; +/** @returns {Promise} */ async function checkpoint(phase) { + phases.push(phase); if (!interactive) return; const nextInput = once(process.stdin, 'data'); process.stdin.resume(); @@ -50,25 +53,24 @@ async function checkpoint(phase) { process.stdin.pause(); } -const phases = []; -const server = createServer(async (request, response) => { +/** @returns {Promise} */ +async function handleRequest(request, response) { if (request.url === '/index.json') { - phases.push('prepare'); await checkpoint('prepare'); response.end(JSON.stringify([{ version: 'v99.0.0', lts: 'Fixture' }])); } else if (/^\/pnpm\/-\/pnpm-[\d.]+\.tgz$/.test(request.url)) { - phases.push('download'); await checkpoint('download'); response.setHeader('Content-Length', pnpmArchive.length); response.end(pnpmArchive); } else if (request.url === '/install') { - phases.push('dependencies'); await checkpoint('dependencies'); response.writeHead(failure ? 500 : 200).end(); } else { response.writeHead(404).end(); } -}); +} + +const server = createServer(handleRequest); server.listen(0, '127.0.0.1'); await once(server, 'listening'); const mirror = `http://127.0.0.1:${server.address().port}`; diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 223f1ec791..c5735bd7c3 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1291,7 +1291,8 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { .as_deref() .filter(|_| explicit_shell.is_some()) .or_else(|| login_shell.as_deref().and_then(|path| path.rsplit('/').next())); - let shell = shell_name.and_then(|s| s.parse::().ok()); + let shell = explicit_shell.or_else(|| shell_name.and_then(|s| s.parse::().ok())); + let shell_name = shell_name.map(str::to_ascii_lowercase); let is_hint = explicit_shell.is_none() && shell.is_some(); let commands: &[(Shell, &str, Option, &[&str])] = &[ (Shell::Posix, "Bash/Zsh", Some(EnvShell::Posix), &["sh", "bash", "zsh"]), @@ -1326,10 +1327,10 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { None => super::format_path_snippet(Shell::Cmd, &[env.dirs.bin.to_string()]), }; if is_hint { - let label = match shell_name { - Some(name) if name.eq_ignore_ascii_case("zsh") => "Zsh", - Some(name) if name.eq_ignore_ascii_case("bash") => "Bash", - Some(name) if name.eq_ignore_ascii_case("sh") => "sh", + let label = match shell_name.as_deref() { + Some("zsh") => "Zsh", + Some("bash") => "Bash", + Some("sh") => "sh", _ => label, }; output::raw(&format!(" For {label}, run:")); @@ -1341,39 +1342,37 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { } } output::raw(""); - let profile_instructions: &[&str] = match shell { - Some(Shell::Cmd | Shell::PowerShell) => &[], - None if !shown.iter().any(|s| matches!(s, Shell::Posix | Shell::Fish | Shell::NuShell)) => { - &[] - } - _ if is_hint && shell_name.is_some_and(|s| s.eq_ignore_ascii_case("zsh")) => { - &[" If your .zshrc does not already load Vite+, add this command."] - } - _ if is_hint && shell_name.is_some_and(|s| s.eq_ignore_ascii_case("bash")) => &[ - " If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions.", - " Login Bash shells must also load the command through their login profile.", - ], - _ if is_hint || shell.is_none() => &[ - " If your shell profile does not already load Vite+, add the command for your shell.", - ], - _ if env.vp_shell.as_deref().is_some_and(|s| s.eq_ignore_ascii_case("bash")) => &[ - if has_configured_profile(&env) { + if shown.iter().any(|s| matches!(s, Shell::Posix | Shell::Fish | Shell::NuShell)) { + let conditional = is_hint || shell.is_none(); + let configured = !conditional && has_configured_profile(&env); + if shell_name.as_deref() == Some("bash") { + output::raw(if conditional { + " If your ~/.bashrc does not already load Vite+, add this command for interactive non-login Bash sessions." + } else if configured { " Or start an interactive non-login Bash shell to load your configured ~/.bashrc." } else { " Add the command to ~/.bashrc for interactive non-login Bash sessions." - }, - " Login Bash shells must also load the command through their login profile.", - ], - _ if has_configured_profile(&env) => { - &[" Or open a new terminal to load your configured shell profile."] - } - _ if env.vp_shell.as_deref().is_some_and(|s| s.eq_ignore_ascii_case("zsh")) => { - &[" Add the command to your .zshrc file to activate future Zsh terminals."] + }); + output::raw( + " Login Bash shells must also load the command through their login profile.", + ); + } else { + output::raw(match (shell_name.as_deref(), conditional, configured) { + (Some("zsh"), true, _) => { + " If your .zshrc does not already load Vite+, add this command." + } + (_, true, _) => { + " If your shell profile does not already load Vite+, add the command for your shell." + } + (_, _, true) => " Or open a new terminal to load your configured shell profile.", + (Some("zsh"), _, _) => { + " Add the command to your .zshrc file to activate future Zsh terminals." + } + _ => { + " Add the command for your shell to its profile to activate future terminals." + } + }); } - _ => &[" Add the command for your shell to its profile to activate future terminals."], - }; - for instruction in profile_instructions { - output::raw(instruction); } if shown.contains(&Shell::PowerShell) { output::raw(if shell == Some(Shell::PowerShell) { From 6b3091d9f3f9eb4d1684966d598dd0b219993e30 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 21 Sep 2026 10:17:10 +0800 Subject: [PATCH 18/18] fix(env): adapt activation to fallback shim layout --- .../profile-guidance.mjs | 7 ++-- .../snapshots/activation_cmd_path_guidance.md | 14 ++++---- .../snapshots/activation_external_zsh.md | 32 ++++++++--------- .../command_env_powershell/assert.ps1 | 7 +++- .../snapshots/installation_progress_ci.md | 2 +- .../installation_progress_interactive.md | 2 +- .../installation_progress_non_tty.md | 2 +- .../shell_integration_cwd_templates.md | 34 +++++++++++++------ .../vp_global_cli/src/commands/env/setup.rs | 11 ++++-- 9 files changed, 69 insertions(+), 42 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs index 7ed7b5fe75..e7d985133d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/profile-guidance.mjs @@ -95,8 +95,11 @@ if (mode === 'powershell') { .split('\n') .find((line) => line.trimStart().startsWith('set "PATH=')) ?.trim(); - assert.equal(activation, `set "PATH=${dirs.bin};%PATH%"`); - assert.match(output, /user PATH if it is missing/); + const fallbackBin = path.join(dirs.data, 'fallback-bin'); + assert.equal(activation, `set "PATH=${dirs.bin};%PATH%;${fallbackBin}"`); + assert.match(output, /user PATH if missing/); + assert.ok(output.includes(`At the start: ${dirs.bin}`)); + assert.ok(output.includes(`At the end: ${fallbackBin}`)); assert.match(output, /System Properties -> Environment Variables -> User variables -> Path/); assert.doesNotMatch(output, /open a new terminal to load/i); console.log(output); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md index 9f5055ed36..41b72de002 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_cmd_path_guidance.md @@ -7,10 +7,11 @@ VP_SELF_SETUP_NO_MODIFY_PATH=: VP_SHELL=cmd: Next Steps: Activate Vite+ in this terminal: - set "PATH=/.vite-plus/bin;%PATH%" + set "PATH=/.vite-plus/bin;%PATH%;/.vite-plus/fallback-bin" - For future cmd.exe sessions, add this directory to your user PATH if it is missing: - /.vite-plus/bin + For future cmd.exe sessions, add these directories to your user PATH if missing: + At the start: /.vite-plus/bin + At the end: /.vite-plus/fallback-bin System Properties -> Environment Variables -> User variables -> Path Open a new terminal after updating PATH. @@ -24,10 +25,11 @@ VP_SELF_SETUP_NO_MODIFY_PATH=1: VP_SHELL=cmd: Next Steps: Activate Vite+ in this terminal: - set "PATH=/.vite-plus/bin;%PATH%" + set "PATH=/.vite-plus/bin;%PATH%;/.vite-plus/fallback-bin" - For future cmd.exe sessions, add this directory to your user PATH if it is missing: - /.vite-plus/bin + For future cmd.exe sessions, add these directories to your user PATH if missing: + At the start: /.vite-plus/bin + At the end: /.vite-plus/fallback-bin System Properties -> Environment Variables -> User variables -> Path Open a new terminal after updating PATH. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md index 0a46541527..6a9999cd6d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_activation/snapshots/activation_external_zsh.md @@ -10,14 +10,14 @@ Setup: Created Shims: /activation/home/bin/node - /activation/home/bin/npm - /activation/home/bin/npx - /activation/home/bin/pnpm - /activation/home/bin/pnpx - /activation/home/bin/yarn - /activation/home/bin/yarnpkg - /activation/home/bin/bun - /activation/home/bin/bunx + /activation/home/fallback-bin/npm + /activation/home/fallback-bin/npx + /activation/home/fallback-bin/pnpm + /activation/home/fallback-bin/pnpx + /activation/home/fallback-bin/yarn + /activation/home/fallback-bin/yarnpkg + /activation/home/fallback-bin/bun + /activation/home/fallback-bin/bunx /activation/home/bin/vpx /activation/home/bin/vpr @@ -44,14 +44,14 @@ Setup: Skipped Shims: /activation/home/bin/node - /activation/home/bin/npm - /activation/home/bin/npx - /activation/home/bin/pnpm - /activation/home/bin/pnpx - /activation/home/bin/yarn - /activation/home/bin/yarnpkg - /activation/home/bin/bun - /activation/home/bin/bunx + /activation/home/fallback-bin/npm + /activation/home/fallback-bin/npx + /activation/home/fallback-bin/pnpm + /activation/home/fallback-bin/pnpx + /activation/home/fallback-bin/yarn + /activation/home/fallback-bin/yarnpkg + /activation/home/fallback-bin/bun + /activation/home/fallback-bin/bunx /activation/home/bin/vpx /activation/home/bin/vpr diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 index 6622ec48e3..ebfd4f71c8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/assert.ps1 @@ -1,12 +1,13 @@ $ErrorActionPreference = "Stop" $expectedBin = Join-Path $env:EXPECTED_VP_HOME "bin" +$expectedFallback = Join-Path $env:EXPECTED_VP_HOME "fallback-bin" $externalBin = Join-Path $PWD "external-node" New-Item -ItemType Directory -Path $externalBin | Out-Null $node = Get-Command node -CommandType Application | Select-Object -First 1 Copy-Item -LiteralPath $node.Source -Destination $externalBin $externalNode = Join-Path $externalBin (Split-Path $node.Source -Leaf) -$env:PATH = (@($externalBin, $expectedBin.ToUpperInvariant(), $env:PATH, $expectedBin)) -join [IO.Path]::PathSeparator +$env:PATH = (@($expectedFallback.ToUpperInvariant(), $externalBin, $expectedBin.ToUpperInvariant(), $env:PATH, $expectedBin, $expectedFallback)) -join [IO.Path]::PathSeparator if ((Get-Command node -CommandType Application | Select-Object -First 1).Source -ne $externalNode) { throw "Fixture did not create a Node PATH priority conflict" } @@ -26,6 +27,10 @@ if ($binCount -ne 1) { if (($env:PATH -split [IO.Path]::PathSeparator)[0] -ne $expectedBin) { throw "Activation did not put the shim directory first on PATH" } +$fallbackCount = @($env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -ieq $expectedFallback }).Count +if ($fallbackCount -ne 1 -or ($env:PATH -split [IO.Path]::PathSeparator)[-1] -ne $expectedFallback) { + throw "Activation did not put exactly one fallback directory last on PATH" +} if ((Get-Command node -CommandType Application | Select-Object -First 1).Source -ne (Join-Path $expectedBin (Split-Path $node.Source -Leaf))) { throw "Activation did not give the Node shim priority" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md index 70472f4f8f..db09614c2e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_ci.md @@ -12,7 +12,7 @@ Setup: Preparing vite-plus environment. Created Shims: - /home/bin/node + /home/fallback-bin/node /home/bin/npm /home/bin/npx /home/bin/pnpm diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md index 7f9cf4ac39..d842a66302 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md @@ -40,7 +40,7 @@ Setup: Preparing vite-plus environment. Created Shims: - /home/bin/node + /home/fallback-bin/node /home/bin/npm /home/bin/npx /home/bin/pnpm diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md index 13dcd90ff2..097b4133ed 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_non_tty.md @@ -14,7 +14,7 @@ Setup: Preparing vite-plus environment. Created Shims: - /home/bin/node + /home/fallback-bin/node /home/bin/npm /home/bin/npx /home/bin/pnpm 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 cd19e5f537..3cc19cfa52 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 @@ -256,8 +256,11 @@ PowerShell wrapper and vpr completion keep global -C before env use/run $env:VP_HOME = '/home' $__vp_bin = '/home/bin' $__vp_fallback = '/home/fallback-bin' -$__vp_paths = @($env:Path -split ';' | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback }) -$env:Path = (@($__vp_bin) + $__vp_paths + @($__vp_fallback)) -join ';' +$env:PATH = @( + $__vp_bin + $env:PATH -split [IO.Path]::PathSeparator | Where-Object { $_ -and $_ -ne $__vp_bin -and $_ -ne $__vp_fallback } + $__vp_fallback +) -join [IO.Path]::PathSeparator # Shell function wrapper: intercepts `vp env use` to eval its stdout, # which sets/unsets VP_NODE_VERSION in the current shell session. @@ -274,17 +277,26 @@ function vp { if ($args -contains "-h" -or $args -contains "--help") { & (Join-Path $__vp_bin "vp") @args; return } - $env:VP_ENV_USE_EVAL_ENABLE = "1" - $env:VP_SHELL = "pwsh" - $output = & (Join-Path $__vp_bin "vp") @args 2>&1 | ForEach-Object { - if ($_ -is [System.Management.Automation.ErrorRecord]) { - Write-Host $_.Exception.Message - } else { - $_ + $previousEvalEnable = $env:VP_ENV_USE_EVAL_ENABLE + $previousShell = $env:VP_SHELL + $previousErrorActionPreference = $ErrorActionPreference + try { + $env:VP_ENV_USE_EVAL_ENABLE = "1" + $env:VP_SHELL = "pwsh" + # Windows PowerShell 5.1 treats native stderr as an error when redirected. + $ErrorActionPreference = "Continue" + $output = & (Join-Path $__vp_bin "vp") @args 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + Write-Host $_.Exception.Message + } else { + $_ + } } + } finally { + $ErrorActionPreference = $previousErrorActionPreference + $env:VP_ENV_USE_EVAL_ENABLE = $previousEvalEnable + $env:VP_SHELL = $previousShell } - Remove-Item Env:VP_ENV_USE_EVAL_ENABLE -ErrorAction SilentlyContinue - Remove-Item Env:VP_SHELL -ErrorAction SilentlyContinue if ($LASTEXITCODE -eq 0 -and $output) { Invoke-Expression ($output -join "`n") } diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index c5735bd7c3..af5ef526ad 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1324,7 +1324,11 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { shown.push(kind); let command = match env_shell { Some(env_shell) => env_shell.source_command(env_dir), - None => super::format_path_snippet(Shell::Cmd, &[env.dirs.bin.to_string()]), + None => format!( + "set \"PATH={};%PATH%;{}\"", + env.dirs.bin.to_string().replace('%', "%%"), + env.dirs.fallback_bin().to_string().replace('%', "%%") + ), }; if is_hint { let label = match shell_name.as_deref() { @@ -1383,9 +1387,10 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { } if shown.contains(&Shell::Cmd) { output::raw( - " For future cmd.exe sessions, add this directory to your user PATH if it is missing:", + " For future cmd.exe sessions, add these directories to your user PATH if missing:", ); - output::raw(&format!(" {}", env.dirs.bin.as_path().display())); + output::raw(&format!(" At the start: {}", env.dirs.bin.as_path().display())); + output::raw(&format!(" At the end: {}", env.dirs.fallback_bin().as_path().display())); output::raw(" System Properties -> Environment Variables -> User variables -> Path"); output::raw(" Open a new terminal after updating PATH."); }