From 04ed700c6b9cec845cab0c39a7a2c66ef9697622 Mon Sep 17 00:00:00 2001 From: Liang Date: Wed, 12 Aug 2026 13:46:45 +0800 Subject: [PATCH 01/37] feat(env)!: manage package manager environments --- README.md | 4 +- .../snapshots/cli_helper_message.md | 53 +- .../fixtures/command_env_clean/snapshots.toml | 3 + .../snapshots/command_env_clean.md | 30 + .../command_env_exec_shim_mode/snapshots.toml | 2 +- .../snapshots/command_env_exec_shim_mode.md | 10 +- .../snapshots.toml | 2 +- .../command_env_install_no_arg_fail.md | 9 +- .../command_env_list_remote/snapshots.toml | 2 +- .../snapshots/command_env_list_remote.md | 6 +- .../snapshots/command_env_nushell.md | 2 +- .../snapshots/command_env_off_on.md | 12 +- .../snapshots.toml | 12 + ..._env_package_manager_session_provenance.md | 44 ++ .../package.json | 5 + .../snapshots.toml | 49 ++ ...list_ignores_package_manager_resolution.md | 35 + ...in_warns_for_lockfile_selection_offline.md | 20 + ..._pin_warns_when_package_manager_differs.md | 13 + ...use_does_not_warn_for_different_default.md | 17 + ..._use_warns_when_package_manager_differs.md | 11 + .../package.json | 21 + .../packages/app/package.json | 4 + .../snapshots.toml | 20 + ...requested_dev_engines_manager_effective.md | 63 ++ ...n_package_manager_from_nested_workspace.md | 72 ++ .../fixtures/command_env_unified/package.json | 11 + .../command_env_unified/snapshots.toml | 16 + .../snapshots/command_env_unified.md | 160 +++++ .../fixtures/command_env_use/.node-version | 1 + .../fixtures/command_env_use/package.json | 3 +- .../fixtures/command_env_use/snapshots.toml | 1 + .../snapshots/command_env_use.md | 21 +- .../command_env_use_shells/snapshots.toml | 18 +- .../snapshots/command_env_use_shells.md | 48 +- .../command_pm_no_package_json/snapshots.toml | 1 + .../delegate_respects_default_node_version.md | 2 +- .../fallback_all_invalid_to_user_default.md | 2 +- .../install_yarn_corepack_hash_mismatch.md | 2 +- .../snapshots.toml | 11 + ...him_bun_default_latest_matches_fallback.md | 15 + .../fixtures/vp_help/snapshots/help.global.md | 2 +- .../tests/cli_snapshots/redact.rs | 25 + crates/vp_cli_snapshots/tests/redact_unit.rs | 22 + crates/vp_global_cli/src/cli.rs | 165 +++-- crates/vp_global_cli/src/command_picker.rs | 2 +- .../vp_global_cli/src/commands/env/clean.rs | 131 ++-- .../vp_global_cli/src/commands/env/config.rs | 95 +++ .../vp_global_cli/src/commands/env/current.rs | 297 +++++--- .../vp_global_cli/src/commands/env/default.rs | 172 +++-- .../vp_global_cli/src/commands/env/doctor.rs | 127 +++- crates/vp_global_cli/src/commands/env/exec.rs | 135 ++-- .../src/commands/env/lifecycle.rs | 104 +++ crates/vp_global_cli/src/commands/env/list.rs | 262 ++++--- .../src/commands/env/list_remote.rs | 672 ++++++++---------- crates/vp_global_cli/src/commands/env/mod.rs | 199 +++--- crates/vp_global_cli/src/commands/env/off.rs | 38 +- crates/vp_global_cli/src/commands/env/on.rs | 36 +- .../src/commands/env/package_manager.rs | 135 ++++ crates/vp_global_cli/src/commands/env/pin.rs | 371 +++++++++- crates/vp_global_cli/src/commands/env/spec.rs | 143 ++++ .../vp_global_cli/src/commands/env/unpin.rs | 9 +- crates/vp_global_cli/src/commands/env/use.rs | 237 ++++-- .../vp_global_cli/src/commands/env/which.rs | 50 +- crates/vp_global_cli/src/commands/mod.rs | 24 + crates/vp_global_cli/src/help.rs | 59 +- crates/vp_global_cli/src/shim/dispatch.rs | 49 +- crates/vp_pm_cli/src/dispatch.rs | 85 ++- crates/vp_pm_cli/src/helpers.rs | 11 + crates/vp_pm_cli/src/lib.rs | 11 +- crates/vp_pm_cli/src/package_manager.rs | 241 ++++++- crates/vp_shared/src/env_config.rs | 6 + crates/vp_shared/src/env_vars.rs | 3 + docs/guide/env.md | 137 +++- docs/guide/index.md | 2 +- docs/guide/installer-env-vars.md | 10 + packages/cli/README.md | 4 +- packages/cli/install.ps1 | 4 +- packages/cli/install.sh | 4 +- rfcs/dev-engines.md | 2 +- rfcs/env-command.md | 22 +- 81 files changed, 3648 insertions(+), 1288 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_session_provenance.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_does_not_warn_for_different_default.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_warns_when_package_manager_differs.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/packages/app/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/.node-version create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_bun_default_latest_matches_fallback.md create mode 100644 crates/vp_global_cli/src/commands/env/lifecycle.rs create mode 100644 crates/vp_global_cli/src/commands/env/package_manager.rs create mode 100644 crates/vp_global_cli/src/commands/env/spec.rs diff --git a/README.md b/README.md index d94567baf8..c6ed460310 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ _runtime and package management, create, dev, check, test, build, pack, and mono Vite+ is the unified entry point for local web development. It combines [Vite](https://vite.dev/), [Vitest](https://vitest.dev/), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Rolldown](https://rolldown.rs/), [tsdown](https://tsdown.dev/), and [Vite Task](https://github.com/voidzero-dev/vite-task) into one zero-config toolchain that also manages runtime and package manager workflows: -- **`vp env`:** Manage Node.js globally and per project +- **`vp env`:** Manage Node.js and package managers globally and per project - **`vp install`:** Install dependencies with automatic package manager detection - **`vp dev`:** Run Vite's fast native ESM dev server with instant HMR - **`vp check`:** Run formatting, linting, and type checks in one command @@ -105,7 +105,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **hooks** - Manage the Git hook dispatcher - **staged** - Run linters on staged files - **install** (`i`) - Install dependencies -- **env** - Manage Node.js versions +- **env** - Manage Node.js and package managers #### Develop diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 1b463e087d..85c12b4bbf 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -16,7 +16,7 @@ Start: hooks Manage the Git hook dispatcher staged Run linters on staged files install, i Install all dependencies, or add packages if package names are provided - env Manage Node.js versions + env Manage Node.js and package managers Develop: dev Run the development server @@ -460,54 +460,57 @@ VITE+ - The Unified Toolchain for the Web Usage: vp env [COMMAND] -Manage Node.js versions +Manage Node.js and package-manager environments Setup: setup Create or update shims in VP_HOME/bin - on Enable managed mode - shims always use vite-plus managed Node.js - off Enable system-first mode - shims prefer system Node.js, fallback to managed - print Print shell snippet to set environment for current session + on Enable managed mode for Node.js and package managers + off Enable system-first mode for Node.js and package managers + print Print PATH setup for the resolved environment Manage: - default Set or show the global default Node.js version - pin Pin a Node.js version in the current directory - unpin Remove the Node.js pin from the current directory (alias for `pin --unpin`) - use Use a specific Node.js version for this shell session - install, i Install a Node.js version - uninstall, uni Uninstall a Node.js version - clean Remove unused managed runtimes and package manager caches - exec, run Execute a command with a specific Node.js version + default Set or show global environment defaults + pin Pin Node.js and package-manager versions in the project + unpin Remove project environment pins (alias for `pin --unpin`) + use Activate an environment for this shell session + install, i Install a resolved or explicit environment + uninstall, uni Uninstall explicit component versions + clean Remove unused runtimes and package managers + exec, run Execute a command in a resolved or explicit environment Inspect: current Show current environment information doctor Run diagnostics and show environment status which Show path to the tool that would be executed - list, ls List locally installed Node.js versions - list-remote, ls-remote List available Node.js versions from the registry + list, ls List locally installed environment components + list-remote, ls-remote List available versions from component registries Examples: Setup: vp env setup # Create Node.js and package-manager shims - vp env on # Use vite-plus managed Node.js - vp env print # Print shell snippet for this session + vp env on # Manage Node.js and package managers + vp env off pm # Prefer system package managers only + vp env print # Print PATH setup for both components Manage: - vp env pin lts # Pin to latest LTS version - vp env install # Install version from .node-version / package.json / .nvmrc - vp env use 20 # Use Node.js 20 for this shell session - vp env use --unset # Remove session override - vp env clean # Remove unused managed caches + vp env default 22.19.0 # Set the Node.js default + vp env default pnpm@12 # Set the package-manager default + vp env pin 22.19.0 # Pin Node.js for this project + vp env use 22.19.0 # Use Node.js in this shell + vp env clean # Clean all unused managed versions Inspect: vp env current # Show current resolved environment vp env current --json # JSON output for automation vp env doctor # Check environment configuration vp env which node # Show which node binary will be used - vp env list-remote --lts # List only LTS versions + vp env list node # List only Node.js installations + vp env list-remote --lts # List only Node.js LTS versions Execute: - vp env exec --node lts npm i # Execute 'npm i' with latest LTS - vp env exec node -v # Shim mode (version auto-resolved) + vp env exec --node lts node -v # Override Node.js + vp env exec --package-manager pnpm@12 pnpm i # Override the package manager + vp env exec node -v # Resolve both components Related Commands: vp install -g # Install a package globally diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml index d3c0d5fe0e..fbc4aaf244 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml @@ -4,4 +4,7 @@ vp = "global" seed-runtime = false steps = [ { argv = ["vp", "env", "clean"], comment = "Clean isolated Vite+ caches" }, + { argv = ["vp", "env", "clean", "node"], comment = "Clean only Node.js runtimes" }, + { argv = ["vp", "env", "clean", "pm"], comment = "Clean all package-manager families" }, + { argv = ["vp", "env", "clean", "pnpm"], comment = "Clean one package-manager family" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/command_env_clean.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/command_env_clean.md index 0f6d1c5db5..003946eeaa 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/command_env_clean.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/command_env_clean.md @@ -10,3 +10,33 @@ VITE+ - The Unified Toolchain for the Web ✓ Removed 0 Node.js runtimes ✓ Removed 0 package manager installs ``` + +## `vp env clean node` + +Clean only Node.js runtimes + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed 0 Node.js runtimes +``` + +## `vp env clean pm` + +Clean all package-manager families + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed 0 package manager installs +``` + +## `vp env clean pnpm` + +Clean one package-manager family + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed 0 package manager installs +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml index bd23501846..e76dd34273 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml @@ -5,5 +5,5 @@ steps = [ { argv = ["vp", "env", "exec", "node", "-v"], comment = "Shim mode: version resolved from package.json engines.node", continue-on-failure = true }, { argv = ["vp", "env", "exec", "npm", "-v"], comment = "Shim mode: npm uses same version", continue-on-failure = true }, { argv = ["vp", "env", "exec", "node", "-e", "console.log('Hello from shim mode')"], comment = "Shim mode: run inline script", continue-on-failure = true }, - { argv = ["vp", "env", "exec", "nonexistent-tool", "--version"], comment = "expected error: non-shim command requires --node", continue-on-failure = true }, + { argv = ["vp", "env", "exec", "nonexistent-tool", "--version"], comment = "automatic mode resolves the environment before reporting a missing command", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md index e27e71449d..9e2002b1c7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md @@ -26,16 +26,10 @@ Hello from shim mode ## `vp env exec nonexistent-tool --version` -expected error: non-shim command requires --node +automatic mode resolves the environment before reporting a missing command **Exit code:** 1 ``` -vp env exec: --node is required when running non-shim commands -Usage: vp env exec --node [args...] - -For shim tools, --node is optional (version resolved automatically): - vp env exec node script.js # Core tool - vp env exec npm install # Core tool - vp env exec tsc --version # Global package +error: Command execution failed: program not found ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml index 7cbe64e00f..195415b26c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml @@ -4,5 +4,5 @@ vp = "global" skip-platforms = ["windows"] seed-runtime = false steps = [ - { argv = ["vp", "env", "install"], comment = "No version config - should error", continue-on-failure = true }, + { argv = ["vp", "env", "install"], comment = "No project declaration - install the resolved fallback environment", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md index 8a5a801a4f..09e3b01c3b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md @@ -2,14 +2,11 @@ ## `vp env install` -No version config - should error - -**Exit code:** 1 +No project declaration - install the resolved fallback environment ``` VITE+ - The Unified Toolchain for the Web -No Node.js version found in current project. -Specify a version: vp env install -Or pin one: vp env pin +Installing Node.js ... +Installed Node.js ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml index c0c6ddc924..b3a1f51d3e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml @@ -6,5 +6,5 @@ seed-runtime = false steps = [ { argv = ["vp", "env", "install", "lts"], comment = "Install an LTS Node.js version locally", continue-on-failure = true }, { argv = ["vp", "env", "default", "lts"], comment = "Set it as the global default (stored as the `lts` alias)", continue-on-failure = true }, - { argv = ["node", "-e", "const {execFileSync}=require('node:child_process'); const {versions}=JSON.parse(execFileSync('vp',['env','list-remote','--lts','--json'],{encoding:'utf8'})); console.log('installed marked:', versions.some(v=>v.installed)); console.log('current marked:', versions.some(v=>v.current)); console.log('default marked:', versions.some(v=>v.default));"], comment = "installed/current/default flags should all resolve, including the `lts` default alias", continue-on-failure = true }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process'); const {node}=JSON.parse(execFileSync('vp',['env','list-remote','--lts','--json'],{encoding:'utf8'})); console.log('installed marked:', node.some(v=>v.installed)); console.log('current marked:', node.some(v=>v.current)); console.log('default marked:', node.some(v=>v.default));"], comment = "the unified JSON node entries resolve installed/current/default flags, including the `lts` default alias", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md index 5b3ceac068..593ef4bd1a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md @@ -18,12 +18,12 @@ Set it as the global default (stored as the `lts` alias) ``` VITE+ - The Unified Toolchain for the Web -✓ Default Node.js version set to lts (currently ) +✓ Environment defaults updated. ``` -## `node -e 'const {execFileSync}=require('\''node:child_process'\''); const {versions}=JSON.parse(execFileSync('\''vp'\'',['\''env'\'','\''list-remote'\'','\''--lts'\'','\''--json'\''],{encoding:'\''utf8'\''})); console.log('\''installed marked:'\'', versions.some(v=>v.installed)); console.log('\''current marked:'\'', versions.some(v=>v.current)); console.log('\''default marked:'\'', versions.some(v=>v.default));'` +## `node -e 'const {execFileSync}=require('\''node:child_process'\''); const {node}=JSON.parse(execFileSync('\''vp'\'',['\''env'\'','\''list-remote'\'','\''--lts'\'','\''--json'\''],{encoding:'\''utf8'\''})); console.log('\''installed marked:'\'', node.some(v=>v.installed)); console.log('\''current marked:'\'', node.some(v=>v.current)); console.log('\''default marked:'\'', node.some(v=>v.default));'` -installed/current/default flags should all resolve, including the `lts` default alias +the unified JSON node entries resolve installed/current/default flags, including the `lts` default alias ``` installed marked: true diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md index 770e42262b..547e5d6bfe 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md @@ -16,7 +16,7 @@ loads the generated env.nu and verifies setup, explicit use, unset, and file-bas ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) Nushell environment checks passed ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md index 3ad582ca09..6a701c5266 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md @@ -18,11 +18,11 @@ Switch to system-first mode ``` VITE+ - The Unified Toolchain for the Web -✓ Node.js management set to system-first. +✓ Node.js and package-manager management set to system-first. -All vp commands and shims will now prefer system Node.js, falling back to managed if not found. +Selected commands and shims will now prefer system tools, falling back to managed tools. -Run `vp env on` to always use Vite+ managed Node.js. +Run `vp env on` to always use Vite+ managed tools. ``` ## `vp run assert-not-managed` @@ -45,11 +45,11 @@ Switch back to managed mode ``` VITE+ - The Unified Toolchain for the Web -✓ Node.js management set to managed. +✓ Node.js and package-manager management set to managed. -All vp commands and shims will now always use Vite+ managed Node.js. +Selected commands and shims will now use Vite+ managed tools. -Run `vp env off` to prefer system Node.js instead. +Run `vp env off` to prefer system tools instead. ``` ## `vp run assert-managed` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index 1a967c1d6e..35e45ba7ce 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -6,3 +6,15 @@ steps = [ { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','which','npm'],{encoding:'utf8'});if(!text.includes('Package:')||!text.includes('npm@10.9.4')||!text.includes('package.json'))process.exit(1);console.log('which reports npm packageManager')"], comment = "which reports the npm packageManager pin" }, { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','which','npx'],{encoding:'utf8'});if(!text.includes('Package:')||!text.includes('npm@10.9.4')||!text.includes('package.json'))process.exit(1);console.log('which reports npx packageManager')"], comment = "which reports the npx packageManager pin" }, ] + +[[case]] +name = "command_env_package_manager_session_provenance" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "env", "use", "npm@10.9.4", "--no-install"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npm"], snapshot = false }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "current reports the package-manager session file path" }, + { argv = ["vp", "env", "which", "npm"], comment = "which reports the package-manager session file as its source" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_session_provenance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_session_provenance.md new file mode 100644 index 0000000000..534ce55cd2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_session_provenance.md @@ -0,0 +1,44 @@ +# command_env_package_manager_session_provenance + +## `vp env use npm@10.9.4 --no-install` + + +## `vpt write-file $VP_HOME/package_manager/npm/10.9.4/npm/bin/npm '#'\!'/bin/sh +'` + + +## `vpt chmod +x $VP_HOME/package_manager/npm/10.9.4/npm/bin/npm` + + +## `vp env current pm --json` + +current reports the package-manager session file path + +``` +{ + "package_manager": { + "name": "npm", + "version": "", + "source": ".session-package-manager", + "source_path": "/.vite-plus/.session-package-manager", + "bin_paths": { + "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", + "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vp env which npm` + +which reports the package-manager session file as its source + +``` +VITE+ - The Unified Toolchain for the Web + +/.vite-plus/package_manager/npm//npm/bin/npm + Package: npm@10.9.4 + Source: /.vite-plus/.session-package-manager +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/package.json new file mode 100644 index 0000000000..f073822851 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-env-package-manager-mismatch", + "private": true, + "packageManager": "pnpm@10.18.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml new file mode 100644 index 0000000000..0c715e35e6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -0,0 +1,49 @@ +[[case]] +name = "env_pin_warns_when_package_manager_differs" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "an explicit project manager warns before a different manager is pinned" }, +] + +[[case]] +name = "env_use_warns_when_package_manager_differs" +vp = "global" +seed-runtime = false +skip-platforms = ["windows"] +env = { VP_ENV_USE_EVAL_ENABLE = "1" } +steps = [ + { argv = ["vp", "env", "use", "yarn@4.12.0", "--no-install"], comment = "an explicit project manager warns before a different session manager is used" }, +] + +[[case]] +name = "env_use_does_not_warn_for_different_default" +vp = "global" +seed-runtime = false +skip-platforms = ["windows"] +env = { VP_ENV_USE_EVAL_ENABLE = "1" } +steps = [ + { argv = ["vp", "env", "default", "pnpm@10.18.0"], snapshot = false }, + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true}\n"], snapshot = false }, + { argv = ["vp", "env", "use", "yarn@4.12.0", "--no-install"], comment = "a different fallback manager does not warn" }, +] + +[[case]] +name = "env_pin_warns_for_lockfile_selection_offline" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true}\n"], snapshot = false }, + { argv = ["vpt", "touch-file", "pnpm-lock.yaml"], snapshot = false }, + { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "lockfile mismatch warning does not depend on registry resolution", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] + +[[case]] +name = "env_node_list_ignores_package_manager_resolution" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "list", "node", "--json"], comment = "the node selector does not resolve an excluded package manager", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, + { argv = ["vp", "env", "list-remote", "20.18.0", "--lts", "--json"], comment = "the implicit node selector does not resolve an excluded package manager", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md new file mode 100644 index 0000000000..f1a848d0dc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md @@ -0,0 +1,35 @@ +# env_node_list_ignores_package_manager_resolution + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list node --json` + +the node selector does not resolve an excluded package manager + +``` +{ + "node": [] +} +``` + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list-remote 20.18.0 --lts --json` + +the implicit node selector does not resolve an excluded package manager + +``` +{ + "node": [ + { + "version": "20.18.0", + "lts": "Iron", + "latest": false, + "latest_lts": false, + "installed": false, + "current": false, + "default": false + } + ] +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md new file mode 100644 index 0000000000..e64b42fbfb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md @@ -0,0 +1,20 @@ +# env_pin_warns_for_lockfile_selection_offline + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true} +'` + + +## `vpt touch-file pnpm-lock.yaml` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env pin yarn@4.12.0 --no-install` + +lockfile mismatch warning does not depend on registry resolution + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to pnpm from lockfile or config, but yarn was requested. +✓ Pinned package manager to yarn@4.12.0 +note: Package manager will be downloaded on first use. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md new file mode 100644 index 0000000000..bbb0438058 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md @@ -0,0 +1,13 @@ +# env_pin_warns_when_package_manager_differs + +## `vp env pin yarn@4.12.0 --no-install` + +an explicit project manager warns before a different manager is pinned + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to pnpm from packageManager, but yarn was requested. +✓ Pinned package manager to yarn@4.12.0 +note: Package manager will be downloaded on first use. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_does_not_warn_for_different_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_does_not_warn_for_different_default.md new file mode 100644 index 0000000000..c1ff7e9dbf --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_does_not_warn_for_different_default.md @@ -0,0 +1,17 @@ +# env_use_does_not_warn_for_different_default + +## `vp env default pnpm@10.18.0` + + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true} +'` + + +## `vp env use yarn@4.12.0 --no-install` + +a different fallback manager does not warn + +``` +export VP_PACKAGE_MANAGER=yarn@4.12.0 +Using yarn (resolved from 4.12.0) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_warns_when_package_manager_differs.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_warns_when_package_manager_differs.md new file mode 100644 index 0000000000..5608d80719 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_warns_when_package_manager_differs.md @@ -0,0 +1,11 @@ +# env_use_warns_when_package_manager_differs + +## `vp env use yarn@4.12.0 --no-install` + +an explicit project manager warns before a different session manager is used + +``` +warn: Current environment resolves to pnpm from packageManager, but yarn was requested. +export VP_PACKAGE_MANAGER=yarn@4.12.0 +Using yarn (resolved from 4.12.0) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/package.json new file mode 100644 index 0000000000..cbf02f5c3c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/package.json @@ -0,0 +1,21 @@ +{ + "name": "command-env-pin-package-manager", + "private": true, + "workspaces": [ + "packages/*" + ], + "devEngines": { + "packageManager": [ + { + "name": "npm", + "version": "11.0.0", + "onFail": "error" + }, + { + "name": "pnpm", + "version": "10.17.0", + "onFail": "download" + } + ] + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/packages/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/packages/app/package.json new file mode 100644 index 0000000000..0a2d4152f2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/packages/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "app", + "private": true +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml new file mode 100644 index 0000000000..29a844fada --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml @@ -0,0 +1,20 @@ +[[case]] +name = "env_pin_makes_requested_dev_engines_manager_effective" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vp", "env", "pin", "pnpm@10.18.0", "--no-install"], comment = "pinning an existing later option makes it the effective first supported entry" }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "current resolves the newly pinned manager" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the pin preserves sibling options and their policy" }, +] + +[[case]] +name = "env_pin_package_manager_from_nested_workspace" +vp = "global" +seed-runtime = false +cwd = "packages/app" +steps = [ + { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "a nested workspace pin updates the resolver-owned root manifest" }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "the nested project resolves the new root pin" }, + { argv = ["vpt", "print-file", "../../package.json", "package.json"], comment = "only the workspace manifest owns the package-manager pin" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md new file mode 100644 index 0000000000..7c0461090c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md @@ -0,0 +1,63 @@ +# env_pin_makes_requested_dev_engines_manager_effective + +## `vp env pin pnpm@10.18.0 --no-install` + +pinning an existing later option makes it the effective first supported entry + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to npm from devEngines.packageManager, but pnpm was requested. +✓ Pinned package manager to pnpm@10.18.0 +note: Package manager will be downloaded on first use. +``` + +## `vp env current pm --json` + +current resolves the newly pinned manager + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "devEngines.packageManager", + "source_path": "/package.json", + "project_root": "", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vpt print-file package.json` + +the pin preserves sibling options and their policy + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "workspaces": [ + "packages/*" + ], + "devEngines": { + "packageManager": [ + { + "name": "pnpm", + "version": "", + "onFail": "download" + }, + { + "name": "npm", + "version": "", + "onFail": "error" + } + ] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md new file mode 100644 index 0000000000..5422195f05 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md @@ -0,0 +1,72 @@ +# env_pin_package_manager_from_nested_workspace + +## `vp env pin yarn@4.12.0 --no-install` + +a nested workspace pin updates the resolver-owned root manifest + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to npm from devEngines.packageManager, but yarn was requested. +✓ Pinned package manager to yarn@4.12.0 +note: Package manager will be downloaded on first use. +``` + +## `vp env current pm --json` + +the nested project resolves the new root pin + +``` +{ + "package_manager": { + "name": "yarn", + "version": "", + "source": "devEngines.packageManager", + "source_path": "/package.json", + "project_root": "", + "bin_paths": { + "yarn": "/.vite-plus/package_manager/yarn//yarn/bin/yarn", + "yarnpkg": "/.vite-plus/package_manager/yarn//yarn/bin/yarnpkg" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vpt print-file ../../package.json package.json` + +only the workspace manifest owns the package-manager pin + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "workspaces": [ + "packages/*" + ], + "devEngines": { + "packageManager": [ + { + "name": "yarn", + "version": "", + "onFail": "download" + }, + { + "name": "npm", + "version": "", + "onFail": "error" + }, + { + "name": "pnpm", + "version": "", + "onFail": "download" + } + ] + } +} +{ + "name": "app", + "private": true +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/package.json new file mode 100644 index 0000000000..a0011e07b3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/package.json @@ -0,0 +1,11 @@ +{ + "name": "command-env-unified", + "private": true, + "devEngines": { + "runtime": { + "name": "node", + "version": "20.0.0", + "onFail": "download" + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots.toml new file mode 100644 index 0000000000..4d3473010a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots.toml @@ -0,0 +1,16 @@ +[[case]] +name = "command_env_unified" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vp", "env", "pin", "22.0.0", "--no-install", "--force"], comment = "legacy unqualified versions still pin only Node.js" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the Node.js pin preserves the package manifest structure" }, + { argv = ["vp", "env", "pin", "pnpm@10.18.0", "--no-install"], comment = "a qualified spec pins only the package manager" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the PM pin is written beside the runtime declaration" }, + { argv = ["vp", "env", "current", "--json"], comment = "current JSON exposes peer Node.js and package-manager objects" }, + { argv = ["vp", "env", "list", "--json"], comment = "bare list JSON includes Node.js and every PM family" }, + { argv = ["vp", "env", "list", "node", "--json"], comment = "the node selector omits package managers" }, + { argv = ["vp", "env", "list", "pm", "--json"], comment = "the pm selector omits Node.js" }, + { argv = ["vp", "env", "unpin"], comment = "bare unpin removes both effective project pins" }, + { argv = ["vpt", "print-file", "package.json"], comment = "both devEngines declarations were removed" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md new file mode 100644 index 0000000000..f9af7ba490 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md @@ -0,0 +1,160 @@ +# command_env_unified + +## `vp env pin 22.0.0 --no-install --force` + +legacy unqualified versions still pin only Node.js + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Pinned Node.js version to 22.0.0 + Updated devEngines.runtime in /package.json +note: Version will be downloaded on first use. +``` + +## `vpt print-file package.json` + +the Node.js pin preserves the package manifest structure + +``` +{ + "name": "command-env-unified", + "private": true, + "devEngines": { + "runtime": { + "name": "node", + "version": "", + "onFail": "download" + } + } +} +``` + +## `vp env pin pnpm@10.18.0 --no-install` + +a qualified spec pins only the package manager + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Pinned package manager to pnpm@10.18.0 +note: Package manager will be downloaded on first use. +``` + +## `vpt print-file package.json` + +the PM pin is written beside the runtime declaration + +``` +{ + "name": "command-env-unified", + "private": true, + "devEngines": { + "runtime": { + "name": "node", + "version": "", + "onFail": "download" + }, + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` + +## `vp env current --json` + +current JSON exposes peer Node.js and package-manager objects + +``` +{ + "node": { + "version": "22.0.0", + "source": "devEngines.runtime", + "source_path": "/package.json", + "project_root": "", + "bin_path": "/.vite-plus/js_runtime/node//bin/node", + "installed": false, + "mode": "managed" + }, + "package_manager": { + "name": "pnpm", + "version": "", + "source": "devEngines.packageManager", + "source_path": "/package.json", + "project_root": "", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vp env list --json` + +bare list JSON includes Node.js and every PM family + +``` +{ + "node": [], + "package_managers": { + "bun": [], + "npm": [], + "pnpm": [], + "yarn": [] + } +} +``` + +## `vp env list node --json` + +the node selector omits package managers + +``` +{ + "node": [] +} +``` + +## `vp env list pm --json` + +the pm selector omits Node.js + +``` +{ + "package_managers": { + "bun": [], + "npm": [], + "pnpm": [], + "yarn": [] + } +} +``` + +## `vp env unpin` + +bare unpin removes both effective project pins + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed devEngines.runtime node entry from /package.json +✓ Removed package-manager pin +``` + +## `vpt print-file package.json` + +both devEngines declarations were removed + +``` +{ + "name": "command-env-unified", + "private": true, + "devEngines": {} +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/.node-version new file mode 100644 index 0000000000..2a393af592 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/.node-version @@ -0,0 +1 @@ +20.18.0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/package.json index 30895fc620..20211df413 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/package.json @@ -1,5 +1,6 @@ { "name": "command-env-use", "version": "1.0.0", - "private": true + "private": true, + "packageManager": "npm@10.9.4" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml index 546644b3b9..9fbd8b6be9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml @@ -9,4 +9,5 @@ steps = [ { argv = ["vp", "env", "use", "--unset"], comment = "should output unset command to stdout", continue-on-failure = true }, { argv = ["vp", "env", "use", "d"], comment = "should show friendly error for invalid version", continue-on-failure = true }, { argv = ["vp", "env", "use", "abc"], comment = "should show friendly error for invalid version", continue-on-failure = true }, + { argv = ["vp", "env", "use", "--silent-if-unchanged", "--no-install"], comment = "an unchanged project environment emits no shell mutations", envs = [["VP_NODE_VERSION", "20.18.0"], ["VP_PACKAGE_MANAGER", "npm@10.9.4"]] }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md index 90d459cd38..4933ff95cd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md @@ -7,12 +7,12 @@ should show help ``` VITE+ - The Unified Toolchain for the Web -Usage: vp env use [OPTIONS] [VERSION] +Usage: vp env use [OPTIONS] [REQUESTS]... -Use a specific Node.js version for this shell session +Activate Node.js and package-manager versions for this shell session Arguments: - [VERSION] Version to use (e.g., "20", "20.18.0", "lts", "latest"). If omitted, reads from .node-version, package.json, or .nvmrc + [REQUESTS]... Component selectors or explicit versions to activate Options: --unset Remove session override (revert to file-based resolution) @@ -21,8 +21,9 @@ Options: -h, --help Print help (see a summary with '-h') Examples: - vp env use lts # Override session with latest LTS - vp env use --unset # Clear the session override + vp env use 22.19.0 # Override Node.js for this session + vp env use pnpm@12 # Override the package manager + vp env use --unset # Clear both session overrides Documentation: https://viteplus.dev/guide/env ``` @@ -42,7 +43,8 @@ should output unset command to stdout ``` unset VP_NODE_VERSION -Reverted to file-based Node.js version resolution +unset VP_PACKAGE_MANAGER +Reverted selected components to project environment resolution ``` ## `vp env use d` @@ -76,3 +78,10 @@ Valid examples: vp env use lts # Latest LTS version vp env use latest # Latest version ``` + +## `VP_NODE_VERSION=20.18.0 VP_PACKAGE_MANAGER=npm@10.9.4 vp env use --silent-if-unchanged --no-install` + +an unchanged project environment emits no shell mutations + +``` +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml index 2ba92803b3..52b8c20b4a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml @@ -4,13 +4,13 @@ vp = "global" skip-platforms = ["windows"] env = { VP_ENV_USE_EVAL_ENABLE = "1" } steps = [ - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect bash and output posix export", envs = [["VP_SHELL", "bash"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect zsh and output posix export", envs = [["VP_SHELL", "zsh"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect fish and output fish export", envs = [["VP_SHELL", "fish"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect nushell and output nushell export", envs = [["VP_SHELL", "nu"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect powershell and output powershell export", envs = [["VP_SHELL", "pwsh"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect cmd and output cmd export", envs = [["VP_SHELL", "cmd"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect case-insensitive bash", envs = [["VP_SHELL", "BASH"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect case-insensitive fish", envs = [["VP_SHELL", "FISH"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect case-insensitive powershell", envs = [["VP_SHELL", "POWERSHELL"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect bash and output both posix exports", envs = [["VP_SHELL", "bash"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect zsh and output both posix exports", envs = [["VP_SHELL", "zsh"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect fish and output both fish exports", envs = [["VP_SHELL", "fish"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect nushell and output both nushell exports", envs = [["VP_SHELL", "nu"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect powershell and output both powershell exports", envs = [["VP_SHELL", "pwsh"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect cmd and output both cmd exports", envs = [["VP_SHELL", "cmd"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect case-insensitive bash", envs = [["VP_SHELL", "BASH"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect case-insensitive fish", envs = [["VP_SHELL", "FISH"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect case-insensitive powershell", envs = [["VP_SHELL", "POWERSHELL"]], continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/command_env_use_shells.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/command_env_use_shells.md index 6ff02cb5a1..f96a1999f0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/command_env_use_shells.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/command_env_use_shells.md @@ -1,82 +1,100 @@ # command_env_use_shells -## `VP_SHELL=bash vp env use 20.18.0 --no-install` +## `VP_SHELL=bash vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect bash and output posix export +should detect bash and output both posix exports ``` export VP_NODE_VERSION=20.18.0 +export VP_PACKAGE_MANAGER=pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=zsh vp env use 20.18.0 --no-install` +## `VP_SHELL=zsh vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect zsh and output posix export +should detect zsh and output both posix exports ``` export VP_NODE_VERSION=20.18.0 +export VP_PACKAGE_MANAGER=pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=fish vp env use 20.18.0 --no-install` +## `VP_SHELL=fish vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect fish and output fish export +should detect fish and output both fish exports ``` set -gx VP_NODE_VERSION 20.18.0 +set -gx VP_PACKAGE_MANAGER pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=nu vp env use 20.18.0 --no-install` +## `VP_SHELL=nu vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect nushell and output nushell export +should detect nushell and output both nushell exports ``` $env.VP_NODE_VERSION = "20.18.0" +$env.VP_PACKAGE_MANAGER = "pnpm@10.18.0" Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=pwsh vp env use 20.18.0 --no-install` +## `VP_SHELL=pwsh vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect powershell and output powershell export +should detect powershell and output both powershell exports ``` $env:VP_NODE_VERSION = "20.18.0" +$env:VP_PACKAGE_MANAGER = "pnpm@10.18.0" Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=cmd vp env use 20.18.0 --no-install` +## `VP_SHELL=cmd vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect cmd and output cmd export +should detect cmd and output both cmd exports ``` set VP_NODE_VERSION=20.18.0 +set VP_PACKAGE_MANAGER=pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=BASH vp env use 20.18.0 --no-install` +## `VP_SHELL=BASH vp env use 20.18.0 pnpm@10.18.0 --no-install` should detect case-insensitive bash ``` export VP_NODE_VERSION=20.18.0 +export VP_PACKAGE_MANAGER=pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=FISH vp env use 20.18.0 --no-install` +## `VP_SHELL=FISH vp env use 20.18.0 pnpm@10.18.0 --no-install` should detect case-insensitive fish ``` set -gx VP_NODE_VERSION 20.18.0 +set -gx VP_PACKAGE_MANAGER pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=POWERSHELL vp env use 20.18.0 --no-install` +## `VP_SHELL=POWERSHELL vp env use 20.18.0 pnpm@10.18.0 --no-install` should detect case-insensitive powershell ``` $env:VP_NODE_VERSION = "20.18.0" +$env:VP_PACKAGE_MANAGER = "pnpm@10.18.0" Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_no_package_json/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_no_package_json/snapshots.toml index c1d2526a3a..81e9b46c69 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_no_package_json/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_no_package_json/snapshots.toml @@ -2,6 +2,7 @@ name = "command_pm_no_package_json" vp = ["local", "global"] skip-platforms = ["windows"] +env = { VP_PACKAGE_MANAGER = "npm@10.9.4" } steps = [ { argv = ["vp", "pm", "ls"], comment = "should show friendly error", continue-on-failure = true }, { argv = ["vp", "pm", "prune"], comment = "should show friendly error", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/delegate_respects_default_node_version/snapshots/delegate_respects_default_node_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/delegate_respects_default_node_version/snapshots/delegate_respects_default_node_version.md index 41809bb945..ecc30fdad7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/delegate_respects_default_node_version/snapshots/delegate_respects_default_node_version.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/delegate_respects_default_node_version/snapshots/delegate_respects_default_node_version.md @@ -7,7 +7,7 @@ Set global default to 22.18.0 ``` VITE+ - The Unified Toolchain for the Web -✓ Default Node.js version set to 22.18.0 +✓ Environment defaults updated. ``` ## `vp run check-node` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/fallback_all_invalid_to_user_default/snapshots/fallback_all_invalid_to_user_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/fallback_all_invalid_to_user_default/snapshots/fallback_all_invalid_to_user_default.md index 1028e8cbdf..35dbfcc471 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/fallback_all_invalid_to_user_default/snapshots/fallback_all_invalid_to_user_default.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/fallback_all_invalid_to_user_default/snapshots/fallback_all_invalid_to_user_default.md @@ -7,7 +7,7 @@ Set user default ``` VITE+ - The Unified Toolchain for the Web -✓ Default Node.js version set to 22.18.0 +✓ Environment defaults updated. ``` ## `vp exec node -e console.log(process.version)` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md index 6c1beb91ed..76389c7928 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md @@ -24,6 +24,6 @@ The error names the artifact that the hash covers. vp does not download the CLI ``` VITE+ - The Unified Toolchain for the Web -error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +error: Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml index 4ef7aa3caa..1b391d5892 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml @@ -12,3 +12,14 @@ steps = [ { argv = ["vpt", "write-file", ".node-version", ">=999.0.0\n"], snapshot = false }, { argv = ["pnpm", "--version"], envs = [["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]], comment = "JS package-manager shims report project Node resolution failures", continue-on-failure = true }, ] + +[[case]] +name = "shim_bun_default_latest_matches_fallback" +vp = "global" +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const {writeFileSync}=require('node:fs');writeFileSync(process.env.VP_HOME+'/bun-fallback-version',execFileSync('bun',['--version'],{encoding:'utf8'}))"], snapshot = false }, + { argv = ["vp", "env", "default", "bun@latest"], snapshot = false }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const {readFileSync}=require('node:fs');const expected=readFileSync(process.env.VP_HOME+'/bun-fallback-version','utf8');const actual=execFileSync('bun',['--version'],{encoding:'utf8'});if(actual!==expected)throw new Error(`expected ${expected.trim()}, got ${actual.trim()}`);console.log('default bun@latest matches the unconfigured fallback')"], envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "the explicit floating default behaves like the unconfigured Bun fallback" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_bun_default_latest_matches_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_bun_default_latest_matches_fallback.md new file mode 100644 index 0000000000..a25c2cbdfa --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_bun_default_latest_matches_fallback.md @@ -0,0 +1,15 @@ +# shim_bun_default_latest_matches_fallback + +## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const {writeFileSync}=require('\''node:fs'\'');writeFileSync(process.env.VP_HOME+'\''/bun-fallback-version'\'',execFileSync('\''bun'\'',['\''--version'\''],{encoding:'\''utf8'\''}))'` + + +## `vp env default bun@latest` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 node -e 'const {execFileSync}=require('\''node:child_process'\'');const {readFileSync}=require('\''node:fs'\'');const expected=readFileSync(process.env.VP_HOME+'\''/bun-fallback-version'\'','\''utf8'\'');const actual=execFileSync('\''bun'\'',['\''--version'\''],{encoding:'\''utf8'\''});if(actual'\!'==expected)throw new Error(`expected ${expected.trim()}, got ${actual.trim()}`);console.log('\''default bun@latest matches the unconfigured fallback'\'')'` + +the explicit floating default behaves like the unconfigured Bun fallback + +``` +default bun@latest matches the unconfigured fallback +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md index 89680db7a3..2308149db8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md @@ -16,7 +16,7 @@ Start: hooks Manage the Git hook dispatcher staged Run linters on staged files install, i Install all dependencies, or add packages if package names are provided - env Manage Node.js versions + env Manage Node.js and package managers Develop: dev Run the development server diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index 70e661e3ad..f43ab06c1f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -57,6 +57,25 @@ static TOOL_VERSION_RE: LazyLock = LazyLock::new(|| { static BUN_BUILD_HASH_RE: LazyLock = LazyLock::new(|| { regex::Regex::new(r"(\bbun(?: [a-z-]+)* )\([0-9a-f]{6,12}\)").unwrap() }); +// Environment diagnostics report executable paths using the platform's +// distribution layout. Normalize Windows `.exe`/`.cmd` locations to the Unix +// spelling used by the shared snapshots. +static WINDOWS_MANAGED_NODE_BIN_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"(/.vite-plus/js_runtime/node/(?:|\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?))/node\.exe\b", + ) + .unwrap() +}); +static WINDOWS_MANAGED_PM_BIN_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r#"(/.vite-plus/package_manager/[^"\r\n]+/bin/[A-Za-z0-9._-]+)\.cmd\b"#) + .unwrap() +}); +static COMMAND_NOT_FOUND_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"(Command execution failed: )(?:No such file or directory \(os error 2\)|program not found)", + ) + .unwrap() +}); // The workspace's own vite-plus / @voidzero-dev/vite-plus-core version is // written verbatim into scaffolded catalogs and manifests (`vite-plus: 0.2.3`, // `"vite-plus": "0.2.3"`, `npm:@voidzero-dev/vite-plus-core@0.2.3`). Unlike @@ -412,6 +431,12 @@ pub fn redact_output( redactions.iter().map(|(from, to)| (from.as_str(), *to)).collect(); redact_string(&mut output, &borrowed, normalize_separators); + // Normalize platform-specific managed executable paths and missing-command + // diagnostics before applying the general version redactions below. + output = WINDOWS_MANAGED_NODE_BIN_RE.replace_all(&output, "${1}/bin/node").into_owned(); + output = WINDOWS_MANAGED_PM_BIN_RE.replace_all(&output, "${1}").into_owned(); + output = COMMAND_NOT_FOUND_RE.replace_all(&output, "${1}program not found").into_owned(); + // Redact UUIDs to "" output = UUID_RE.replace_all(&output, "").into_owned(); diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index c67c707599..d072050c74 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -87,6 +87,28 @@ fn masks_bun_build_hash_only_in_bun_banners() { assert_eq!(redact_output(unrelated.clone(), &[], true), unrelated); } +#[test] +fn normalizes_managed_executable_paths_and_missing_commands() { + let input = concat!( + r#""bin_path": "/.vite-plus/js_runtime/node/24.18.1/node.exe""#, + "\n", + r#""pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm.cmd""#, + "\n", + "error: Command execution failed: No such file or directory (os error 2)\n", + ) + .to_owned(); + assert_eq!( + redact_output(input, &[], true), + concat!( + r#""bin_path": "/.vite-plus/js_runtime/node//bin/node""#, + "\n", + r#""pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm""#, + "\n", + "error: Command execution failed: program not found\n", + ) + ); +} + #[test] fn masks_managed_node_versions_in_environment_output() { let input = concat!( diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 00f28b5bc8..2ac1622235 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -217,7 +217,7 @@ pub enum Commands { global: bool, }, - /// Manage Node.js versions + /// Manage Node.js and package-manager environments Env(EnvArgs), // ========================================================================= @@ -295,30 +295,46 @@ pub struct EnvArgs { pub enum EnvSubcommands { /// Show current environment information Current { + /// Limit output to node, pm, or a package-manager family + scope: Option, + /// Output in JSON format #[arg(long)] json: bool, }, /// Print shell snippet to set environment for current session - Print, + Print { + /// Limit output to node, pm, or a package-manager family + scope: Option, + }, - /// Set or show the global default Node.js version + /// Set or show global Node.js and package-manager defaults #[command(after_long_help = "\ Examples: - vp env default # Show the current default - vp env default lts # Set the default")] + vp env default # Show both current defaults + vp env default 22.19.0 # Set the Node.js default + vp env default pnpm@12 # Set the package-manager default")] Default { - /// Version to set as default (e.g., "20.18.0", "lts", "latest"). - /// If omitted, prints the current default. - version: Option, + /// Defaults or component selectors. Bare versions select Node.js. + values: Vec, + + /// Clear defaults instead of setting them + #[arg(long)] + unset: bool, }, - /// Enable managed mode - shims always use vite-plus managed Node.js - On, + /// Enable managed mode for Node.js and package managers + On { + /// Change only node or package-manager mode + scope: Option, + }, - /// Enable system-first mode - shims prefer system Node.js, fallback to managed - Off, + /// Enable system-first mode for Node.js and package managers + Off { + /// Change only node or package-manager mode + scope: Option, + }, /// Create or update shims in VP_HOME/bin Setup { @@ -331,20 +347,24 @@ Examples: }, /// Run diagnostics and show environment status - Doctor, + Doctor { + /// Limit diagnostics to node or package managers + scope: Option, + }, /// Show path to the tool that would be executed Which { - /// Tool name (node, npm, or npx) + /// Tool name resolved through the environment shims tool: String, }, - /// Pin a Node.js version in the current directory - /// (updates .node-version or package.json#devEngines.runtime) + /// Pin Node.js and package-manager versions in the current directory #[command(after_long_help = "\ Examples: - vp env pin lts # Pin to latest LTS - vp env pin --unpin # Remove the pin + vp env pin lts # Pin Node.js to latest LTS + vp env pin pnpm@10 # Pin the package manager + vp env pin 22 pnpm@10 # Pin both components + vp env pin --unpin # Remove both effective pins vp env pin \"^20.0.0\" --force # Overwrite existing pin vp env pin 24 --target node-version # Force the .node-version file @@ -352,9 +372,8 @@ The write target follows the compatibility-first rule: an existing .node-version keeps being updated; otherwise the pin is written to package.json#devEngines.runtime; .node-version is only created when the directory has no package.json.")] Pin { - /// Version to pin (e.g., "20.18.0", "lts", "latest", "^20.0.0"). - /// If omitted, prints the currently pinned version. - version: Option, + /// Versions to pin. Bare versions select Node.js; package managers use name@version. + specs: Vec, /// Remove the pin from the current directory #[arg(long)] @@ -373,26 +392,32 @@ keeps being updated; otherwise the pin is written to package.json#devEngines.run target: Option, }, - /// Remove the Node.js pin from current directory (alias for `pin --unpin`) + /// Remove environment pins from the current directory (alias for `pin --unpin`) Unpin { + /// Limit removal to node, pm, or a package-manager family + scope: Option, + /// Explicitly choose which pin source to remove #[arg(long, value_enum)] target: Option, }, - /// List locally installed Node.js versions + /// List locally installed Node.js and package-manager versions #[command(visible_alias = "ls")] List { + /// Limit output to node, pm, or a package-manager family + scope: Option, + /// Output as JSON #[arg(long)] json: bool, }, - /// List available Node.js versions from the registry + /// List available Node.js and package-manager versions from registries #[command(name = "list-remote", visible_alias = "ls-remote")] ListRemote { - /// Filter versions by pattern (e.g., "20" for 20.x versions) - pattern: Option, + /// Optional component selector followed by a version pattern + values: Vec, /// Show only LTS versions #[arg(long)] @@ -411,13 +436,14 @@ keeps being updated; otherwise the pin is written to package.json#devEngines.run sort: SortingMethod, }, - /// Execute a command with a specific Node.js version + /// Execute a command in a resolved or explicit environment #[command( visible_alias = "run", after_long_help = "\ Examples: - vp env exec --node lts npm install # Pin version for this invocation - vp env exec node -v # Shim mode: version auto-resolved" + vp env exec --node lts node -v # Override Node.js + vp env exec --package-manager pnpm@12 pnpm install # Override the package manager + vp env exec node -v # Resolve the full environment" )] Exec { /// Node.js version to use (e.g., "20.18.0", "lts", "^20.0.0"). @@ -426,43 +452,49 @@ Examples: #[arg(long)] node: Option, - /// npm version to use (optional, defaults to bundled) + /// npm version to use (alias for --package-manager npm@) #[arg(long)] npm: Option, + /// Package manager and version to use (for example, pnpm@10) + #[arg(long)] + package_manager: Option, + /// Command and arguments to run #[arg(trailing_var_arg = true, allow_hyphen_values = true)] command: Vec, }, - /// Uninstall a Node.js version + /// Uninstall explicit Node.js or package-manager versions #[command(visible_alias = "uni")] Uninstall { - /// Version to uninstall (e.g., "20.18.0") + /// Versions to uninstall. Bare versions select Node.js. #[arg(required = true)] - version: String, + specs: Vec, }, /// Remove unused managed runtimes and package manager caches - Clean, + Clean { + /// Limit cleanup to node, pm, or a package-manager family + scope: Option, + }, - /// Install a Node.js version + /// Install a resolved or explicit environment #[command(visible_alias = "i")] Install { - /// Version to install (e.g., "20", "20.18.0", "lts", "latest") - /// If not provided, installs the version from .node-version, package.json, or .nvmrc - version: Option, + /// Component selectors or explicit versions to install + requests: Vec, }, - /// Use a specific Node.js version for this shell session + /// Activate Node.js and package-manager versions for this shell session #[command(after_long_help = "\ Examples: - vp env use lts # Override session with latest LTS - vp env use --unset # Clear the session override")] + vp env use 22.19.0 # Override Node.js for this session + vp env use pnpm@12 # Override the package manager + vp env use --unset # Clear both session overrides")] Use { - /// Version to use (e.g., "20", "20.18.0", "lts", "latest"). - /// If omitted, reads from .node-version, package.json, or .nvmrc. - version: Option, + /// Component selectors or explicit versions to activate + requests: Vec, /// Remove session override (revert to file-based resolution) #[arg(long)] @@ -481,7 +513,9 @@ Examples: impl EnvSubcommands { fn is_quiet_or_machine_readable(&self) -> bool { match self { - Self::Current { json } | Self::List { json } | Self::ListRemote { json, .. } => *json, + Self::Current { json, .. } + | Self::List { json, .. } + | Self::ListRemote { json, .. } => *json, _ => false, } } @@ -494,6 +528,8 @@ pub enum PinTarget { NodeVersion, /// Pin via package.json#devEngines.runtime DevEngines, + /// Pin via the top-level packageManager field + PackageManager, } /// Version sorting order for list-remote command @@ -638,7 +674,29 @@ async fn run_package_manager_command( } commands::prepend_js_runtime_to_path_env(&cwd).await?; - let result = vp_pm_cli::dispatch_with_metadata(&cwd, command).await?; + let selected = commands::env::package_manager::resolve_current(&cwd).await?; + let result = if let Some(selected) = selected.as_ref() + && commands::env::config::load_config().await?.package_manager_shim_mode() + == commands::env::config::ShimMode::SystemFirst + && let Some(system_path) = + crate::shim::dispatch::find_system_tool(&selected.package_manager_type.to_string()) + && let Some(manager) = + system_package_manager(selected.package_manager_type, &system_path).await + { + vp_pm_cli::dispatch_with_resolved_package_manager(&cwd, command, manager).await? + } else { + match selected { + Some(selected) => { + vp_pm_cli::dispatch_with_package_manager( + &cwd, + command, + (selected.package_manager_type, &selected.version, selected.hash.as_deref()), + ) + .await? + } + None => vp_pm_cli::dispatch_with_metadata(&cwd, command).await?, + } + }; if result.status.success() && let Some(packages) = result.why_hint_packages.as_deref() { @@ -668,6 +726,21 @@ fn active_toolchain_manifest(cwd: &vt_path::AbsolutePath) -> Option Option { + let output = + tokio::process::Command::new(executable.as_path()).arg("--version").output().await.ok()?; + if !output.status.success() { + return None; + } + let version = std::str::from_utf8(&output.stdout).ok()?.trim(); + node_semver::Version::parse(version).ok()?; + let install_dir = executable.parent()?.parent()?.to_absolute_path_buf(); + Some(vp_pm_cli::PackageManager::from_install_dir(kind, version, install_dir)) +} + async fn managed_install( packages: &[String], node: Option<&str>, diff --git a/crates/vp_global_cli/src/command_picker.rs b/crates/vp_global_cli/src/command_picker.rs index 815fe9d88f..da3d0af487 100644 --- a/crates/vp_global_cli/src/command_picker.rs +++ b/crates/vp_global_cli/src/command_picker.rs @@ -109,7 +109,7 @@ const COMMANDS: &[CommandEntry] = &[ CommandEntry { label: "env", command: "env", - summary: "Manage Node.js versions.", + summary: "Manage Node.js and package managers.", append_help: false, }, CommandEntry { diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index 8faad4c0fc..efd12c143c 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -5,36 +5,75 @@ use std::{path::Path, process::ExitStatus}; +use vp_pm_cli::{PackageManagerType, resolve_package_manager_version}; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use super::{config, list::list_installed_versions}; +use super::{ + config, + list::list_installed_versions, + package_manager::{self, ALL_PACKAGE_MANAGERS}, + spec::{EnvScope, parse_package_manager_spec}, +}; use crate::error::Error; /// Execute the clean command. -pub async fn execute(cwd: AbsolutePathBuf) -> Result { +pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; let config = vp_shared::EnvConfig::get(); let data_dir = &config.dirs.data; let node_dir = data_dir.join("js_runtime").join("node"); let package_manager_dir = data_dir.join("package_manager"); - let protected_versions = protected_node_versions(&cwd).await?; - - let node_runtimes_removed = - clean_node_runtimes(node_dir.as_path(), &protected_versions).await?; - output::success(&format!( - "Removed {node_runtimes_removed} Node.js runtime{}", - plural(node_runtimes_removed) - )); + if scope.includes_node() { + let protected_versions = protected_node_versions(&cwd).await?; + let removed = clean_node_runtimes(node_dir.as_path(), &protected_versions).await?; + output::success(&format!("Removed {removed} Node.js runtime{}", plural(removed))); + } - let package_managers_removed = clean_package_managers(package_manager_dir.as_path()).await?; - output::success(&format!( - "Removed {package_managers_removed} package manager install{}", - plural(package_managers_removed) - )); + if scope.includes_package_managers() { + let protected = match protected_package_manager(&cwd).await { + Ok(protected) => protected, + Err(error) => { + output::warn(&format!( + "Could not resolve the protected package manager; package-manager cleanup was skipped: {error}" + )); + return Ok(ExitStatus::default()); + } + }; + let selected = match scope { + EnvScope::PackageManager(kind) => vec![kind], + _ => ALL_PACKAGE_MANAGERS.to_vec(), + }; + let removed = + clean_package_managers(package_manager_dir.as_path(), &selected, &protected).await?; + output::success(&format!("Removed {removed} package manager install{}", plural(removed))); + } Ok(ExitStatus::default()) } +async fn protected_package_manager( + cwd: &AbsolutePath, +) -> Result)>, Error> { + let current = package_manager::resolve_current(cwd).await?; + let config = config::load_config().await?; + let default = + config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + let mut protected = Vec::new(); + if let Some(current) = current { + protected.push((current.package_manager_type, vec![current.version.to_string()])); + } + if let Some((kind, version)) = default { + let version = resolve_package_manager_version(kind, &version).await?.to_string(); + if let Some((_, versions)) = protected.iter_mut().find(|(current, _)| *current == kind) { + push_unique_version(versions, version); + } else { + protected.push((kind, vec![version])); + } + } + Ok(protected) +} + async fn protected_node_versions(cwd: &AbsolutePath) -> Result, Error> { let mut versions = Vec::new(); push_unique_version(&mut versions, config::resolve_version(cwd).await?.version); @@ -65,36 +104,29 @@ async fn clean_node_runtimes( Ok(removed) } -async fn clean_package_managers(package_manager_dir: &Path) -> Result { - let installs = count_package_manager_installs(package_manager_dir).await?; - if installs > 0 { - remove_dir_all_if_exists(package_manager_dir).await?; - } - Ok(installs) -} - -async fn count_package_manager_installs(package_manager_dir: &Path) -> Result { - let mut package_manager_entries = match tokio::fs::read_dir(package_manager_dir).await { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(e) => return Err(e.into()), - }; - - let mut count = 0; - while let Some(package_manager_entry) = package_manager_entries.next_entry().await? { - if !package_manager_entry.file_type().await?.is_dir() { - continue; - } - - let mut version_entries = tokio::fs::read_dir(package_manager_entry.path()).await?; - while let Some(version_entry) = version_entries.next_entry().await? { - if version_entry.file_type().await?.is_dir() { - count += 1; +async fn clean_package_managers( + package_manager_dir: &Path, + selected: &[PackageManagerType], + protected: &[(PackageManagerType, Vec)], +) -> Result { + let mut removed = 0; + for kind in selected { + let family = package_manager_dir.join(kind.to_string()); + let protected_versions = protected + .iter() + .find(|(protected_kind, _)| protected_kind == kind) + .map(|(_, versions)| versions.as_slice()) + .unwrap_or_default(); + for version in list_installed_versions(&family) { + if protected_versions.contains(&version) { + continue; + } + if remove_dir_all_if_exists(&family.join(version)).await? { + removed += 1; } } } - - Ok(count) + Ok(removed) } async fn remove_dir_all_if_exists(path: &Path) -> Result { @@ -144,16 +176,23 @@ mod tests { } #[tokio::test] - async fn clean_package_managers_removes_all_cached_installs() { + async fn clean_package_managers_preserves_selected_version() { let temp_dir = TempDir::new().unwrap(); let package_manager_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); tokio::fs::create_dir_all(package_manager_dir.join("pnpm").join("10.0.0")).await.unwrap(); tokio::fs::create_dir_all(package_manager_dir.join("npm").join("11.0.0")).await.unwrap(); tokio::fs::write(package_manager_dir.join("pnpm").join("10.0.0.lock"), "").await.unwrap(); - let removed = clean_package_managers(package_manager_dir.as_path()).await.unwrap(); + let removed = clean_package_managers( + package_manager_dir.as_path(), + &[PackageManagerType::Npm, PackageManagerType::Pnpm], + &[(PackageManagerType::Pnpm, vec!["10.0.0".into()])], + ) + .await + .unwrap(); - assert_eq!(removed, 2); - assert!(!package_manager_dir.as_path().exists()); + assert_eq!(removed, 1); + assert!(package_manager_dir.join("pnpm").join("10.0.0").as_path().exists()); + assert!(!package_manager_dir.join("npm").join("11.0.0").as_path().exists()); } } diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 8cb1bffc10..b166aa96e1 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -35,9 +35,34 @@ pub struct Config { /// Default Node.js version when no project version file is found #[serde(default, skip_serializing_if = "Option::is_none")] pub default_node_version: Option, + /// Default package manager when the project does not select one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_package_manager: Option, /// Shim mode for tool resolution #[serde(default, skip_serializing_if = "is_default_shim_mode")] pub shim_mode: ShimMode, + /// Package-manager shim mode. Inherits `shim_mode` when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_manager_shim_mode: Option, +} + +impl Config { + #[must_use] + pub fn package_manager_shim_mode(&self) -> ShimMode { + self.package_manager_shim_mode.unwrap_or(self.shim_mode) + } + + pub fn set_shim_modes(&mut self, node: bool, package_manager: bool, mode: ShimMode) { + if node && !package_manager && self.package_manager_shim_mode.is_none() { + self.package_manager_shim_mode = Some(self.package_manager_shim_mode()); + } + if node { + self.shim_mode = mode; + } + if package_manager { + self.package_manager_shim_mode = Some(mode); + } + } } /// Check if shim mode is the default (for skip_serializing_if) @@ -139,14 +164,24 @@ pub async fn save_config(config: &Config) -> Result<(), Error> { /// Set by `vp env use` command. pub const VERSION_ENV_VAR: &str = vp_shared::env_vars::VP_NODE_VERSION; +/// Environment variable for the per-shell package-manager override. +pub const PACKAGE_MANAGER_ENV_VAR: &str = vp_shared::env_vars::VP_PACKAGE_MANAGER; + /// Session version file name, written by `vp env use` so shims work without the shell eval wrapper. pub const SESSION_VERSION_FILE: &str = ".session-node-version"; +/// Package-manager session override file name. +pub const SESSION_PACKAGE_MANAGER_FILE: &str = ".session-package-manager"; + /// Get the path to the session version file (`/.session-node-version`). pub fn get_session_version_path() -> Result { Ok(vp_shared::EnvConfig::get().dirs.state.join(SESSION_VERSION_FILE)) } +pub fn get_session_package_manager_path() -> Result { + Ok(vp_shared::EnvConfig::get().dirs.state.join(SESSION_PACKAGE_MANAGER_FILE)) +} + /// Read the session version file. Returns `None` if the file is missing or empty. pub async fn read_session_version() -> Option { let path = get_session_version_path().ok()?; @@ -155,6 +190,13 @@ pub async fn read_session_version() -> Option { if trimmed.is_empty() { None } else { Some(trimmed) } } +pub async fn read_session_package_manager() -> Option { + let path = get_session_package_manager_path().ok()?; + let content = tokio::fs::read_to_string(path).await.ok()?; + let trimmed = content.trim().to_string(); + if trimmed.is_empty() { None } else { Some(trimmed) } +} + /// Read the session version file synchronously. Returns `None` if the file is missing or empty. pub fn read_session_version_sync() -> Option { let path = get_session_version_path().ok()?; @@ -174,6 +216,15 @@ pub async fn write_session_version(version: &str) -> Result<(), Error> { Ok(()) } +pub async fn write_session_package_manager(spec: &str) -> Result<(), Error> { + let path = get_session_package_manager_path()?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, spec).await?; + Ok(()) +} + /// Delete the session version file. Ignores "not found" errors. pub async fn delete_session_version() -> Result<(), Error> { let path = get_session_version_path()?; @@ -184,6 +235,15 @@ pub async fn delete_session_version() -> Result<(), Error> { } } +pub async fn delete_session_package_manager() -> Result<(), Error> { + let path = get_session_package_manager_path()?; + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + /// Resolve Node.js version for a directory. /// /// Resolution order: @@ -1396,4 +1456,39 @@ mod tests { ) .await; } + + #[test] + fn node_only_mode_change_preserves_inherited_package_manager_mode() { + let mut config = Config { + shim_mode: ShimMode::Managed, + package_manager_shim_mode: None, + ..Config::default() + }; + config.set_shim_modes(true, false, ShimMode::SystemFirst); + assert_eq!(config.shim_mode, ShimMode::SystemFirst); + assert_eq!(config.package_manager_shim_mode(), ShimMode::Managed); + } + + #[test] + fn package_manager_only_mode_change_preserves_node_mode() { + let mut config = Config { shim_mode: ShimMode::SystemFirst, ..Config::default() }; + config.set_shim_modes(false, true, ShimMode::Managed); + assert_eq!(config.shim_mode, ShimMode::SystemFirst); + assert_eq!(config.package_manager_shim_mode(), ShimMode::Managed); + } + + #[tokio::test] + async fn package_manager_session_file_round_trip() { + let temp_dir = TempDir::new().unwrap(); + vp_shared::EnvConfig::with_vars_async( + [(vp_shared::env_vars::VP_HOME, temp_dir.path())], + |_| async { + write_session_package_manager("pnpm@10.18.0").await.unwrap(); + assert_eq!(read_session_package_manager().await.as_deref(), Some("pnpm@10.18.0")); + delete_session_package_manager().await.unwrap(); + assert!(read_session_package_manager().await.is_none()); + }, + ) + .await; + } } diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 94757c95c8..ce561a8218 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -1,64 +1,49 @@ -//! Current environment information command. -//! -//! Shows information about the current Node.js environment. - -use std::process::ExitStatus; +use std::{collections::BTreeMap, process::ExitStatus}; use serde::Serialize; -use vp_pm_cli::{ - PackageManagerResolution, package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_from_package_json, -}; +use vp_pm_cli::{package_manager_bin_path, package_manager_install_dir}; use vt_path::AbsolutePathBuf; -use super::config::resolve_version; +use super::{ + config::{self, ShimMode, resolve_version}, + package_manager, + spec::EnvScope, +}; use crate::{error::Error, help}; -/// JSON output structure for `vp env current --json` #[derive(Serialize)] struct CurrentEnvInfo { - version: String, - source: String, #[serde(skip_serializing_if = "Option::is_none")] - project_root: Option, - node_path: String, - tool_paths: ToolPaths, + node: Option, #[serde(skip_serializing_if = "Option::is_none")] package_manager: Option, } #[derive(Serialize)] -struct ToolPaths { - node: String, - npm: String, - npx: String, +struct NodeInfo { + version: String, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + source_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + project_root: Option, + bin_path: String, + installed: bool, + mode: ShimMode, } -#[derive(Clone, Serialize)] +#[derive(Serialize)] struct PackageManagerInfo { name: String, version: String, source: String, - source_path: String, - project_root: String, - bin_path: String, -} - -impl PackageManagerInfo { - fn from_resolution(resolution: PackageManagerResolution) -> Option { - let install_dir = - package_manager_install_dir(resolution.package_manager_type, &resolution.version)?; - let name = resolution.package_manager_type.to_string(); - let bin_path = package_manager_bin_path(&install_dir, &name); - Some(Self { - name, - version: resolution.version.to_string(), - source: resolution.source.to_string(), - source_path: resolution.source_path.as_path().display().to_string(), - project_root: resolution.project_root.as_path().display().to_string(), - bin_path: bin_path.as_path().display().to_string(), - }) - } + #[serde(skip_serializing_if = "Option::is_none")] + source_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + project_root: Option, + bin_paths: BTreeMap, + installed: bool, + mode: ShimMode, } fn print_rows(title: &str, rows: &[(&str, String)]) { @@ -70,90 +55,180 @@ fn print_rows(title: &str, rows: &[(&str, String)]) { } } -/// Execute the current command. -pub async fn execute(cwd: AbsolutePathBuf, json: bool) -> Result { - let resolution = resolve_version(&cwd).await?; - let package_manager = resolve_package_manager_info(&cwd); - - // Get the install directory for this version - let home_dir = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(&resolution.version); - - #[cfg(windows)] - let (node_path, npm_path, npx_path) = - { (home_dir.join("node.exe"), home_dir.join("npm.cmd"), home_dir.join("npx.cmd")) }; +pub async fn execute( + cwd: AbsolutePathBuf, + scope: Option, + json: bool, +) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; + let config = config::load_config().await?; + + let node = if scope.includes_node() + && config.shim_mode == ShimMode::SystemFirst + && let Some(bin_path) = crate::shim::dispatch::find_system_tool("node") + { + Some(NodeInfo { + version: read_tool_version(&bin_path).await.unwrap_or_else(|| "unknown".into()), + source: "system PATH".into(), + source_path: None, + project_root: None, + bin_path: bin_path.as_path().display().to_string(), + installed: true, + mode: config.shim_mode, + }) + } else if scope.includes_node() { + let resolution = resolve_version(&cwd).await?; + let home = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(&resolution.version); + #[cfg(windows)] + let bin_path = home.join("node.exe"); + #[cfg(not(windows))] + let bin_path = home.join("bin").join("node"); + Some(NodeInfo { + version: resolution.version, + source: resolution.source, + source_path: resolution.source_path.map(|path| path.as_path().display().to_string()), + project_root: resolution.project_root.map(|path| path.as_path().display().to_string()), + installed: bin_path.as_path().exists(), + bin_path: bin_path.as_path().display().to_string(), + mode: config.shim_mode, + }) + } else { + None + }; - #[cfg(not(windows))] - let (node_path, npm_path, npx_path) = { - ( - home_dir.join("bin").join("node"), - home_dir.join("bin").join("npm"), - home_dir.join("bin").join("npx"), - ) + let package_manager = if scope.includes_package_managers() { + package_manager::resolve_current(&cwd).await?.and_then(|resolution| { + if let EnvScope::PackageManager(expected) = scope + && expected != resolution.package_manager_type + { + return None; + } + let system_paths = (config.package_manager_shim_mode() == ShimMode::SystemFirst) + .then(|| { + resolution + .package_manager_type + .bin_names() + .iter() + .filter_map(|name| { + crate::shim::dispatch::find_system_tool(name).map(|path| { + ((*name).to_string(), path.as_path().display().to_string()) + }) + }) + .collect::>() + }) + .filter(|paths| !paths.is_empty()); + let install_dir = + package_manager_install_dir(resolution.package_manager_type, &resolution.version)?; + let bin_paths = resolution + .package_manager_type + .bin_names() + .iter() + .map(|name| { + ( + (*name).to_string(), + package_manager_bin_path(&install_dir, name) + .as_path() + .display() + .to_string(), + ) + }) + .collect::>(); + let bin_paths = system_paths.unwrap_or(bin_paths); + let installed = bin_paths.values().all(|path| std::path::Path::new(path).exists()); + let system_version = bin_paths + .get(resolution.package_manager_type.to_string().as_str()) + .filter(|_| config.package_manager_shim_mode() == ShimMode::SystemFirst) + .and_then(|path| vt_path::AbsolutePathBuf::new(path.into())); + let is_system = system_version.is_some(); + Some(PackageManagerInfo { + name: resolution.package_manager_type.to_string(), + version: system_version + .as_ref() + .and_then(|path| read_tool_version_sync(path)) + .unwrap_or_else(|| resolution.version.to_string()), + source: if is_system { + "system PATH".into() + } else { + resolution.source.to_string() + }, + source_path: if is_system { + None + } else { + resolution.source_path.map(|path| path.as_path().display().to_string()) + }, + project_root: resolution + .project_root + .map(|path| path.as_path().display().to_string()), + bin_paths, + installed, + mode: config.package_manager_shim_mode(), + }) + }) + } else { + None }; if json { - let info = CurrentEnvInfo { - version: resolution.version.clone(), - source: resolution.source.clone(), - project_root: resolution - .project_root - .as_ref() - .map(|p| p.as_path().display().to_string()), - node_path: node_path.as_path().display().to_string(), - tool_paths: ToolPaths { - node: node_path.as_path().display().to_string(), - npm: npm_path.as_path().display().to_string(), - npx: npx_path.as_path().display().to_string(), - }, - package_manager: package_manager.clone(), - }; - - let json_str = serde_json::to_string_pretty(&info)?; - println!("{json_str}"); - } else { - let mut environment_rows = - vec![("Version", resolution.version.clone()), ("Source", resolution.source.clone())]; - if let Some(path) = &resolution.source_path { - environment_rows.push(("Source Path", path.as_path().display().to_string())); - } - if let Some(root) = &resolution.project_root { - environment_rows.push(("Project Root", root.as_path().display().to_string())); - } + println!("{}", serde_json::to_string_pretty(&CurrentEnvInfo { node, package_manager })?); + return Ok(ExitStatus::default()); + } - print_rows("Environment", &environment_rows); - println!(); + if let Some(node) = node { print_rows( - "Tool Paths", + "Node.js", &[ - ("node", node_path.as_path().display().to_string()), - ("npm", npm_path.as_path().display().to_string()), - ("npx", npx_path.as_path().display().to_string()), + ("Version", node.version), + ("Source", node.source), + ("Bin Path", node.bin_path), + ("Installed", node.installed.to_string()), + ("Mode", mode_name(node.mode).into()), ], ); - if let Some(package_manager) = package_manager { + } + if let Some(package_manager) = package_manager { + if scope.includes_node() { println!(); - print_rows( - "Package Manager", - &[ - ("Name", package_manager.name), - ("Version", package_manager.version), - ("Source", package_manager.source), - ("Source Path", package_manager.source_path), - ("Project Root", package_manager.project_root), - ("Bin Path", package_manager.bin_path), - ], - ); } + print_rows( + "Package Manager", + &[ + ("Name", package_manager.name), + ("Version", package_manager.version), + ("Source", package_manager.source), + ("Installed", package_manager.installed.to_string()), + ("Mode", mode_name(package_manager.mode).into()), + ], + ); } Ok(ExitStatus::default()) } -fn resolve_package_manager_info(cwd: &AbsolutePathBuf) -> Option { - PackageManagerInfo::from_resolution(resolve_package_manager_from_package_json(cwd).ok()??) +async fn read_tool_version(path: &vt_path::AbsolutePath) -> Option { + let output = + tokio::process::Command::new(path.as_path()).arg("--version").output().await.ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +} + +fn read_tool_version_sync(path: &vt_path::AbsolutePath) -> Option { + let output = std::process::Command::new(path.as_path()).arg("--version").output().ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +} + +fn mode_name(mode: ShimMode) -> &'static str { + match mode { + ShimMode::Managed => "managed", + ShimMode::SystemFirst => "system_first", + } } diff --git a/crates/vp_global_cli/src/commands/env/default.rs b/crates/vp_global_cli/src/commands/env/default.rs index 6638469eb7..38be85f675 100644 --- a/crates/vp_global_cli/src/commands/env/default.rs +++ b/crates/vp_global_cli/src/commands/env/default.rs @@ -1,108 +1,104 @@ -//! Default version management command. -//! -//! Handles `vp env default [VERSION]` to set or show the global default Node.js version. - use std::process::ExitStatus; -use vt_path::AbsolutePathBuf; +use vp_pm_cli::resolve_package_manager_version; -use super::config::{get_config_path, load_config, save_config}; +use super::{ + config::{get_config_path, load_config, save_config}, + spec::{EnvScope, EnvSpecs}, +}; use crate::error::Error; -/// Execute the default command. -pub async fn execute(_cwd: AbsolutePathBuf, version: Option) -> Result { - match version { - Some(v) => set_default(&v).await, - None => show_default().await, - } -} - -/// Show the current default version. -async fn show_default() -> Result { - let config = load_config().await?; - - match config.default_node_version { - Some(version) => { - println!("Default Node.js version: {version}"); - let config_path = get_config_path()?; - println!(" Set via: {}", config_path.as_path().display()); - - // If it's an alias, also show the resolved version - if version == "lts" || version == "latest" { - let provider = vp_js_runtime::NodeProvider::new(); - match resolve_alias(&version, &provider).await { - Ok(resolved) => println!(" Currently resolves to: {resolved}"), - Err(_) => {} - } - } +pub async fn execute(values: Vec, unset: bool) -> Result { + if unset { + let scope = match values.as_slice() { + [] => EnvScope::All, + [scope] => EnvScope::parse(Some(scope))?, + _ => return Err(Error::Other("default --unset accepts at most one scope".into())), + }; + let mut config = load_config().await?; + if scope.includes_node() { + config.default_node_version = None; } - None => { - // No default configured - show what would be used - let provider = vp_js_runtime::NodeProvider::new(); - match provider.resolve_latest_version().await { - Ok(lts_version) => { - println!("No default version configured. Using latest LTS ({lts_version})."); - println!(" Run 'vp env default ' to set a default."); - } - Err(_) => { - println!("No default version configured."); - println!(" Run 'vp env default ' to set a default."); - } + if scope.includes_package_managers() { + let should_clear = match scope { + EnvScope::PackageManager(expected) => config + .default_package_manager + .as_deref() + .and_then(|value| super::spec::parse_package_manager_spec(value).ok()) + .is_some_and(|(kind, _)| kind == expected), + _ => true, + }; + if should_clear { + config.default_package_manager = None; } } + save_config(&config).await?; + crate::shim::invalidate_cache(); + println!("Cleared selected environment defaults."); + return Ok(ExitStatus::default()); } - Ok(ExitStatus::default()) -} - -/// Set the default version. -async fn set_default(version: &str) -> Result { - let provider = vp_js_runtime::NodeProvider::new(); - - // Validate the version - let (display_version, store_version) = match version.to_lowercase().as_str() { - "lts" => { - // Resolve to show current value, but store "lts" as alias - let current_lts = provider.resolve_latest_version().await?; - (format!("lts (currently {})", current_lts), "lts".to_string()) - } - "latest" => { - // Resolve to show current value, but store "latest" as alias - let current_latest = provider.resolve_absolute_latest_version().await?; - (format!("latest (currently {})", current_latest), "latest".to_string()) - } - _ => { - // Validate version exists - let resolved = if vp_js_runtime::NodeProvider::is_exact_version(version) { - version.to_string() - } else { - provider.resolve_version(version).await?.to_string() - }; - (resolved.clone(), resolved) - } - }; + if values.is_empty() { + return show_default(EnvScope::All).await; + } + if values.len() == 1 + && let Ok(scope) = EnvScope::parse(values.first().map(String::as_str)) + { + return show_default(scope).await; + } - // Save to config + let specs = EnvSpecs::parse(&values)?; let mut config = load_config().await?; - config.default_node_version = Some(store_version); + if let Some(version) = specs.node { + config.default_node_version = Some(resolve_node_default(&version).await?); + } + if let Some((package_manager, version)) = specs.package_manager { + let stored = if version == "latest" { + version + } else { + resolve_package_manager_version(package_manager, &version).await?.to_string() + }; + config.default_package_manager = Some(format!("{package_manager}@{stored}")); + } save_config(&config).await?; - - // Invalidate resolve cache so the new default takes effect immediately crate::shim::invalidate_cache(); + println!("\u{2713} Environment defaults updated."); + Ok(ExitStatus::default()) +} - println!("\u{2713} Default Node.js version set to {display_version}"); - +async fn show_default(scope: EnvScope) -> Result { + let config = load_config().await?; + let config_path = get_config_path()?; + if scope.includes_node() { + match config.default_node_version { + Some(version) => println!("Default Node.js version: {version}"), + None => println!("Default Node.js version: latest LTS"), + } + } + if scope.includes_package_managers() { + let configured = config.default_package_manager.filter(|spec| match scope { + EnvScope::PackageManager(expected) => super::spec::parse_package_manager_spec(spec) + .is_ok_and(|(kind, _)| kind == expected), + _ => true, + }); + match configured { + Some(spec) => println!("Default package manager: {spec}"), + None => match scope { + EnvScope::PackageManager(kind) => { + println!("Default {kind} version: not configured") + } + _ => println!("Default package manager: not configured"), + }, + } + } + println!(" Set via: {}", config_path.as_path().display()); Ok(ExitStatus::default()) } -/// Resolve version alias to actual version. -async fn resolve_alias( - alias: &str, - provider: &vp_js_runtime::NodeProvider, -) -> Result { - match alias { - "lts" => Ok(provider.resolve_latest_version().await?.to_string()), - "latest" => Ok(provider.resolve_absolute_latest_version().await?.to_string()), - _ => Ok(alias.to_string()), +async fn resolve_node_default(version: &str) -> Result { + let provider = vp_js_runtime::NodeProvider::new(); + match version.to_lowercase().as_str() { + "lts" | "latest" => Ok(version.to_lowercase()), + _ => super::config::resolve_version_alias(version, &provider).await, } } diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 4ddcd6564f..6e1e840d54 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -3,10 +3,15 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; +use vp_pm_cli::{package_manager_bin_path, package_manager_install_dir}; use vp_shared::{env_vars, output}; use vt_path::{AbsolutePathBuf, current_dir}; -use super::config::{self, ShimMode, get_bin_dir, load_config, resolve_version}; +use super::{ + config::{self, ShimMode, get_bin_dir, load_config, resolve_version}, + package_manager, + spec::EnvScope, +}; use crate::{ commands::shell::{ALL_SHELL_PROFILES, IDE_SHELL_PROFILES, ShellProfile, resolve_profile_path}, error::Error, @@ -74,7 +79,8 @@ fn abbreviate_home(path: &str) -> String { } /// Execute the doctor command. -pub async fn execute(cwd: AbsolutePathBuf) -> Result { +pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; let mut has_errors = false; // Section: Installation @@ -84,20 +90,33 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { // Section: Configuration print_section("Configuration"); - let (shim_mode, system_node_path) = check_shim_mode().await; + let (shim_mode, system_node_path) = check_shim_mode(scope).await; // Check env sourcing: IDE-relevant profiles first, then all shell profiles let env_status = cfg!(not(windows)).then(check_env_sourcing); - check_session_override(); + if scope.includes_node() { + check_session_override(); + } + if scope.includes_package_managers() { + check_package_manager_session_override().await; + } // Section: PATH print_section("PATH"); has_errors |= !check_path().await; // Section: Version Resolution - print_section("Version Resolution"); - let resolution = check_current_resolution(&cwd, shim_mode, system_node_path).await; + let resolution = if scope.includes_node() { + print_section("Node.js Resolution"); + check_current_resolution(&cwd, shim_mode, system_node_path).await + } else { + None + }; + if scope.includes_package_managers() { + print_section("Package Manager Resolution"); + check_package_manager_resolution(&cwd, scope).await; + } // Section: devEngines (conditional, see rfcs/dev-engines.md) check_dev_engines(&cwd, resolution.as_ref()).await; @@ -215,7 +234,7 @@ fn shim_filename(tool: &str) -> String { } /// Check and display shim mode. Returns the mode and any found system node path. -async fn check_shim_mode() -> (ShimMode, Option) { +async fn check_shim_mode(scope: EnvScope) -> (ShimMode, Option) { let config = match load_config().await { Ok(c) => c, Err(e) => { @@ -230,34 +249,89 @@ async fn check_shim_mode() -> (ShimMode, Option) { let mut system_node_path = None; - match config.shim_mode { - ShimMode::Managed => { - print_check(&output::CHECK.green().to_string(), "Node.js mode", "managed"); - } - ShimMode::SystemFirst => { - print_check( - &output::CHECK.green().to_string(), - "Node.js mode", - &"system-first".bright_blue().to_string(), - ); - - // Check if system Node.js is available - if let Some(system_node) = shim::find_system_tool("node") { - print_check(" ", "System Node.js", &system_node.as_path().display().to_string()); - system_node_path = Some(system_node); - } else { + if scope.includes_node() { + match config.shim_mode { + ShimMode::Managed => { + print_check(&output::CHECK.green().to_string(), "Node.js mode", "managed"); + } + ShimMode::SystemFirst => { print_check( - &output::WARN_SIGN.yellow().to_string(), - "System Node.js", - &"not found (will fall back to managed)".yellow().to_string(), + &output::CHECK.green().to_string(), + "Node.js mode", + &"system-first".bright_blue().to_string(), ); + + // Check if system Node.js is available + if let Some(system_node) = shim::find_system_tool("node") { + print_check( + " ", + "System Node.js", + &system_node.as_path().display().to_string(), + ); + system_node_path = Some(system_node); + } else { + print_check( + &output::WARN_SIGN.yellow().to_string(), + "System Node.js", + &"not found (will fall back to managed)".yellow().to_string(), + ); + } } } } + if scope.includes_package_managers() { + let mode = match config.package_manager_shim_mode() { + ShimMode::Managed => "managed", + ShimMode::SystemFirst => "system-first", + }; + print_check(&output::CHECK.green().to_string(), "Package manager mode", mode); + } (config.shim_mode, system_node_path) } +async fn check_package_manager_session_override() { + let environment = vp_shared::EnvConfig::get().package_manager; + let session = config::read_session_package_manager().await; + if let Some(value) = environment.or(session) { + print_check(" ", "PM session", &value); + } +} + +async fn check_package_manager_resolution(cwd: &AbsolutePathBuf, scope: EnvScope) { + match package_manager::resolve_current(cwd).await { + Ok(Some(resolution)) if !matches!(scope, EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) => + { + print_check(" ", "Source", &resolution.source); + print_check( + " ", + "Version", + &format!("{}@{}", resolution.package_manager_type, resolution.version) + .bright_green() + .to_string(), + ); + let installed = + package_manager_install_dir(resolution.package_manager_type, &resolution.version) + .is_some_and(|directory| { + resolution.package_manager_type.bin_names().iter().all(|name| { + package_manager_bin_path(&directory, name).as_path().exists() + }) + }); + let status = if installed { "installed" } else { "not installed" }; + let indicator = if installed { + output::CHECK.green().to_string() + } else { + output::WARN_SIGN.yellow().to_string() + }; + print_check(&indicator, "PM binaries", status); + } + Ok(_) => print_check(" ", "Package manager", "not selected"), + Err(error) => { + print_check(&output::CROSS.red().to_string(), "Package manager", &error.to_string()) + } + } +} + /// Check profile files for env sourcing and classify where it was found. /// /// Tries IDE-relevant profiles first, then falls back to all shell profiles. @@ -1547,6 +1621,7 @@ mod tests { } #[test] + #[serial] fn test_abbreviate_home() { if let Ok(home) = std::env::var("HOME") { let path = format!("{home}/.vite-plus"); diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index 10a82a9d1e..6100cded13 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -10,8 +10,13 @@ use std::process::ExitStatus; use vp_js_runtime::NodeProvider; -use vp_shared::{env_vars, format_path_prepended}; +use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; +use vp_shared::env_vars; +use vt_path::AbsolutePath; +use super::{ + config, package_manager as package_manager_resolution, spec::parse_package_manager_spec, +}; use crate::{ cli::exit_status, error::Error, @@ -24,8 +29,10 @@ use crate::{ /// When `--node` is not provided and the command is a shim tool (node/npm/npx or global package), /// uses the same shim dispatch logic as Unix symlinks. pub async fn execute( + cwd: &AbsolutePath, node_version: Option<&str>, npm_version: Option<&str>, + package_manager: Option<&str>, command: &[String], ) -> Result { let command = normalize_wrapper_command(command); @@ -37,8 +44,15 @@ pub async fn execute( } // If --node is provided, use explicit version mode (existing behavior) - if let Some(version) = node_version { - return execute_with_version(version, npm_version, &command).await; + if npm_version.is_some() && package_manager.is_some() { + return Err(Error::Other("--npm and --package-manager cannot be used together".into())); + } + let package_manager = package_manager + .map(str::to_string) + .or_else(|| npm_version.map(|version| format!("npm@{version}"))); + + if node_version.is_some() || package_manager.is_some() { + return execute_with_version(cwd, node_version, package_manager.as_deref(), &command).await; } // No --node provided - check if first command is a shim tool @@ -71,15 +85,7 @@ pub async fn execute( return Ok(exit_status(exit_code)); } - // Not a shim tool and no --node - error - eprintln!("vp env exec: --node is required when running non-shim commands"); - eprintln!("Usage: vp env exec --node [args...]"); - eprintln!(); - eprintln!("For shim tools, --node is optional (version resolved automatically):"); - eprintln!(" vp env exec node script.js # Core tool"); - eprintln!(" vp env exec npm install # Core tool"); - eprintln!(" vp env exec tsc --version # Global package"); - Ok(exit_status(1)) + execute_with_version(cwd, None, None, &command).await } /// Normalize arguments when invoked via Windows shim wrappers. @@ -112,23 +118,61 @@ fn normalize_wrapper_command_inner(command: &[String], from_wrapper: bool) -> Ve /// Execute a command with an explicitly specified Node.js version. async fn execute_with_version( - node_version: &str, - npm_version: Option<&str>, + cwd: &AbsolutePath, + node_version: Option<&str>, + package_manager: Option<&str>, command: &[String], ) -> Result { - // Warn about unsupported --npm flag - if npm_version.is_some() { - eprintln!("Warning: --npm flag is not yet implemented, using bundled npm"); + let mut path_prefixes = Vec::new(); + let modes = config::load_config().await?; + let (resolved_node, system_node_bin) = if let Some(node_version) = node_version { + (resolve_version(node_version, &NodeProvider::new()).await?, None) + } else if modes.shim_mode == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool("node") + { + ( + read_tool_version(&path).await.unwrap_or_else(|| "unknown".into()), + path.parent().map(vt_path::AbsolutePath::to_absolute_path_buf), + ) + } else { + (config::resolve_version(cwd).await?.version, None) + }; + if let Some(bin_dir) = system_node_bin { + path_prefixes.push(bin_dir.into_path_buf()); + } else { + let runtime = + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolved_node) + .await?; + path_prefixes.push(runtime.get_bin_prefix().as_path().to_path_buf()); } - - // 1. Resolve version - let provider = NodeProvider::new(); - let resolved_version = resolve_version(node_version, &provider).await?; - - // 2. Ensure installed (download if needed) - let runtime = - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolved_version) - .await?; + let explicit_package_manager = package_manager.is_some(); + let selected_package_manager = if let Some(package_manager) = package_manager { + let (kind, selector) = parse_package_manager_spec(package_manager)?; + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + Some((kind, version, None)) + } else { + package_manager_resolution::resolve_current(cwd).await?.map(|resolution| { + (resolution.package_manager_type, resolution.version.to_string(), resolution.hash) + }) + }; + let resolved_package_manager = if let Some((kind, version, hash)) = selected_package_manager { + if !explicit_package_manager + && modes.package_manager_shim_mode() == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool(&kind.to_string()) + && let Some(bin_dir) = path.parent() + { + let system_version = read_tool_version(&path).await.unwrap_or(version); + path_prefixes.insert(0, bin_dir.as_path().to_path_buf()); + Some(format!("{kind}@{system_version}")) + } else { + let (install_dir, _, _) = + download_package_manager(kind, &version, hash.as_deref()).await?; + path_prefixes.insert(0, install_dir.join("bin").into_path_buf()); + Some(format!("{kind}@{version}")) + } + } else { + None + }; // 3. Clear recursion env var to force re-evaluation in child processes // SAFETY: This is safe because we're about to spawn a child process and we want @@ -138,16 +182,19 @@ async fn execute_with_version( std::env::remove_var(env_vars::VP_TOOL_RECURSION); } - // 4. Build PATH with node bin dir first (uses platform-specific separator) - // Always prepend to ensure the requested Node version is first in PATH - let node_bin_dir = runtime.get_bin_prefix(); - let new_path = format_path_prepended(node_bin_dir.as_path()); + let mut paths = path_prefixes; + paths.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())); + let new_path = std::env::join_paths(paths) + .map_err(|error| Error::Other(format!("failed to construct PATH: {error}").into()))?; // 5. Execute command let (cmd, args) = command.split_first().unwrap(); let mut child = tokio::process::Command::new(cmd); - child.args(args).env("PATH", new_path).env(env_vars::VP_NODE_VERSION, &resolved_version); + child.args(args).env("PATH", new_path).env(env_vars::VP_NODE_VERSION, &resolved_node); + if let Some(package_manager) = resolved_package_manager { + child.env(env_vars::VP_PACKAGE_MANAGER, package_manager); + } // The child runs in the inherited cwd, which a leading `-C ` changes // without touching our own environment; align its `PWD` accordingly. if let Ok(cwd) = vt_path::current_dir() { @@ -158,6 +205,15 @@ async fn execute_with_version( Ok(status) } +async fn read_tool_version(path: &AbsolutePath) -> Option { + let output = + tokio::process::Command::new(path.as_path()).arg("--version").output().await.ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +} + /// Resolve version to an exact version. /// /// Handles aliases (lts, latest) and version ranges. @@ -214,7 +270,8 @@ mod tests { #[tokio::test] async fn test_execute_missing_command() { - let result = execute(Some("20.18.0"), None, &[]).await; + let cwd = vt_path::current_dir().unwrap(); + let result = execute(&cwd, Some("20.18.0"), None, None, &[]).await; assert!(result.is_ok()); let status = result.unwrap(); assert!(!status.success()); @@ -229,7 +286,8 @@ mod tests { |_| async { // Run 'node --version' with a specific Node.js version let command = vec!["node".to_string(), "--version".to_string()]; - let result = execute(Some("20.18.0"), None, &command).await; + let cwd = vt_path::current_dir().unwrap(); + let result = execute(&cwd, Some("20.18.0"), None, None, &command).await; assert!(result.is_ok()); let status = result.unwrap(); assert!(status.success()); @@ -269,17 +327,6 @@ mod tests { assert_eq!(classify_version("latest"), VersionSelector::AbsoluteLatest); } - #[tokio::test] - async fn test_shim_mode_error_for_non_shim_command() { - // Running a non-shim command without --node should error - let command = vec!["python".to_string(), "--version".to_string()]; - let result = execute(None, None, &command).await; - assert!(result.is_ok()); - let status = result.unwrap(); - // Should fail because python is not a shim tool and --node was not provided - assert!(!status.success(), "Non-shim command without --node should fail"); - } - #[test] fn test_normalize_wrapper_command_strips_only_wrapper_separator() { let command = vec!["node".to_string(), "--".to_string(), "--version".to_string()]; diff --git a/crates/vp_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs new file mode 100644 index 0000000000..ce12c41589 --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -0,0 +1,104 @@ +use std::process::ExitStatus; + +use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; +use vt_path::AbsolutePathBuf; + +use super::{ + config, package_manager, + spec::{EnvScope, EnvSpecs}, +}; +use crate::error::Error; + +pub(crate) async fn install( + cwd: AbsolutePathBuf, + requests: Vec, +) -> Result { + let (scope, specs) = EnvSpecs::parse_requests(&requests)?; + + if scope.includes_node() { + let version = match specs.node { + Some(version) => { + let provider = vp_js_runtime::NodeProvider::new(); + config::resolve_version_alias(&version, &provider).await? + } + None => config::resolve_version(&cwd).await?.version, + }; + println!("Installing Node.js v{version}..."); + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &version).await?; + println!("Installed Node.js v{version}"); + } + + if scope.includes_package_managers() { + let requested = if let Some(spec) = specs.package_manager { + Some(spec) + } else if let EnvScope::PackageManager(kind) = scope { + match package_manager::resolve_current(&cwd).await? { + Some(current) if current.package_manager_type == kind => { + Some((kind, current.version.to_string())) + } + _ => Some((kind, "latest".into())), + } + } else { + package_manager::resolve_current(&cwd) + .await? + .map(|current| (current.package_manager_type, current.version.to_string())) + }; + if let Some((kind, selector)) = requested { + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + println!("Installing {kind} v{version}..."); + download_package_manager(kind, &version, None).await?; + println!("Installed {kind} v{version}"); + } + } + + Ok(ExitStatus::default()) +} + +pub(crate) async fn uninstall(specs: Vec) -> Result { + let specs = EnvSpecs::parse(&specs)?; + let node = specs + .node + .map(|version| { + if vp_js_runtime::NodeProvider::is_exact_version(&version) { + Ok(version.strip_prefix('v').unwrap_or(&version).to_string()) + } else { + Err(Error::Other("uninstall requires exact Node.js versions".into())) + } + }) + .transpose()?; + let package_manager = specs + .package_manager + .map(|(kind, version)| { + node_semver::Version::parse(&version) + .map(|version| (kind, version.to_string())) + .map_err(|_| { + Error::Other("uninstall requires exact package-manager versions".into()) + }) + }) + .transpose()?; + + let home = vp_shared::EnvConfig::get().dirs.data.clone(); + let mut targets = Vec::new(); + if let Some(version) = node { + targets.push(( + format!("Node.js v{version}"), + home.join("js_runtime").join("node").join(version), + )); + } + if let Some((kind, version)) = package_manager { + targets.push(( + format!("{kind} v{version}"), + home.join("package_manager").join(kind.to_string()).join(version), + )); + } + for (label, target) in &targets { + if !target.as_path().exists() { + return Err(Error::Other(format!("{label} is not installed").into())); + } + } + for (label, target) in targets { + tokio::fs::remove_dir_all(target.as_path()).await?; + println!("Uninstalled {label}"); + } + Ok(ExitStatus::default()) +} diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 27cf29b774..9e7a18867e 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -1,17 +1,18 @@ -//! List command for displaying locally installed Node.js versions. -//! -//! Handles `vp env list` to show Node.js versions installed in VP_HOME/js_runtime/node/. +use std::{collections::BTreeMap, process::ExitStatus}; -use std::process::ExitStatus; - -use owo_colors::OwoColorize; use serde::Serialize; +use vp_pm_cli::{ + PackageManagerType, package_manager_bin_path, package_manager_install_dir, + resolve_package_manager_version, +}; use vt_path::AbsolutePathBuf; -use super::config; +use super::{ + config, package_manager, + spec::{EnvScope, parse_package_manager_spec}, +}; use crate::error::Error; -/// JSON output format for a single installed version #[derive(Serialize)] struct InstalledVersionJson { version: String, @@ -19,135 +20,174 @@ struct InstalledVersionJson { default: bool, } -/// Scan the node versions directory and return sorted version strings. -pub(super) fn list_installed_versions(node_dir: &std::path::Path) -> Vec { - let entries = match std::fs::read_dir(node_dir) { +#[derive(Serialize)] +struct InstalledEnvironmentJson { + #[serde(skip_serializing_if = "Option::is_none")] + node: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + package_managers: Option>>, +} + +pub(super) fn list_installed_versions(directory: &std::path::Path) -> Vec { + let entries = match std::fs::read_dir(directory) { Ok(entries) => entries, Err(_) => return Vec::new(), }; - - let mut versions: Vec = entries + let mut versions = entries .filter_map(|entry| { let entry = entry.ok()?; let name = entry.file_name().into_string().ok()?; - // Skip hidden directories and non-directories - if name.starts_with('.') || !entry.path().is_dir() { - return None; - } - Some(name) + (!name.starts_with('.') && entry.path().is_dir()).then_some(name) }) - .collect(); - - versions.sort_by_cached_key(|v| node_semver::Version::parse(v).ok()); + .collect::>(); + versions.sort_by_cached_key(|version| node_semver::Version::parse(version).ok()); versions } -/// Execute the list command (local installed versions). -pub async fn execute(cwd: AbsolutePathBuf, json_output: bool) -> Result { - let node_dir = vp_shared::EnvConfig::get().dirs.data.join("js_runtime").join("node"); +pub async fn execute( + cwd: AbsolutePathBuf, + scope: Option, + json: bool, +) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; + let home = vp_shared::EnvConfig::get().dirs.data.clone(); + let config = config::load_config().await?; + let current_node = if scope.includes_node() { + config::resolve_version(&cwd).await.ok().map(|resolution| resolution.version) + } else { + None + }; + let current_pm = if scope.includes_package_managers() { + package_manager::resolve_current(&cwd).await? + } else { + None + }; + let default_node = if scope.includes_node() { + match config.default_node_version.as_deref() { + Some(selector) => Some( + config::resolve_version_alias(selector, &vp_js_runtime::NodeProvider::new()) + .await?, + ), + None => None, + } + } else { + None + }; + let default_pm = if scope.includes_package_managers() { + let default = config + .default_package_manager + .as_deref() + .map(parse_package_manager_spec) + .transpose()?; + match default { + Some((kind, selector)) => { + Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) + } + None => None, + } + } else { + None + }; - let versions = list_installed_versions(node_dir.as_path()); + let node = scope.includes_node().then(|| { + list_installed_versions(home.join("js_runtime").join("node").as_path()) + .into_iter() + .map(|version| InstalledVersionJson { + current: current_node.as_deref() == Some(version.as_str()), + default: default_node.as_deref() == Some(version.as_str()), + version, + }) + .collect::>() + }); + + let package_managers = if scope.includes_package_managers() { + let selected = package_manager::selected(scope); + Some( + selected + .into_iter() + .map(|kind| { + let versions = list_complete_package_manager_versions(&home, kind) + .into_iter() + .map(|version| InstalledVersionJson { + current: current_pm.as_ref().is_some_and(|current| { + current.package_manager_type == kind + && current.version.as_str() == version + }), + default: default_pm.as_ref().is_some_and(|(default, value)| { + *default == kind && value == &version + }), + version, + }) + .collect(); + (kind.to_string(), versions) + }) + .collect(), + ) + } else { + None + }; - if versions.is_empty() { - if json_output { - println!("[]"); - } else { - println!("No Node.js versions installed."); - println!(); - println!("Install a version with: vp env install "); - } + if json { + println!( + "{}", + serde_json::to_string_pretty(&InstalledEnvironmentJson { node, package_managers })? + ); return Ok(ExitStatus::default()); } - // Resolve current version (gracefully handle errors) - let current_version = config::resolve_version(&cwd).await.ok().map(|r| r.version); - - // Load default version - let default_version = config::load_config().await.ok().and_then(|c| c.default_node_version); - - if json_output { - print_json(&versions, current_version.as_deref(), default_version.as_deref()); - } else { - print_human(&versions, current_version.as_deref(), default_version.as_deref()); + if let Some(node) = node { + print_section("Node.js", &node); + } + if let Some(mut package_managers) = package_managers { + for kind in package_manager::selected(scope) { + let name = kind.to_string(); + if scope.includes_node() || kind != PackageManagerType::Npm { + println!(); + } + print_section( + package_manager::title(kind), + &package_managers.remove(&name).unwrap_or_default(), + ); + } } - Ok(ExitStatus::default()) } -/// Print installed versions as JSON. -fn print_json(versions: &[String], current: Option<&str>, default: Option<&str>) { - let entries: Vec = versions - .iter() - .map(|v| InstalledVersionJson { - version: v.clone(), - current: current.is_some_and(|c| c == v), - default: default.is_some_and(|d| d == v), +pub(super) fn list_complete_package_manager_versions( + home: &AbsolutePathBuf, + package_manager: PackageManagerType, +) -> Vec { + list_installed_versions( + home.join("package_manager").join(package_manager.to_string()).as_path(), + ) + .into_iter() + .filter(|version| { + package_manager_install_dir(package_manager, version).is_some_and(|directory| { + package_manager + .bin_names() + .iter() + .all(|name| package_manager_bin_path(&directory, name).as_path().exists()) }) - .collect(); - - // unwrap is safe here since we're serializing simple structs - println!("{}", serde_json::to_string_pretty(&entries).unwrap()); + }) + .collect() } -/// Print installed versions in human-readable format. -fn print_human(versions: &[String], current: Option<&str>, default: Option<&str>) { - for v in versions { - let is_current = current.is_some_and(|c| c == v); - let is_default = default.is_some_and(|d| d == v); - +fn print_section(title: &str, versions: &[InstalledVersionJson]) { + println!("{title}"); + if versions.is_empty() { + println!(" No versions installed."); + return; + } + for version in versions { let mut markers = Vec::new(); - if is_current { + if version.current { markers.push("current"); } - if is_default { + if version.default { markers.push("default"); } - - let marker_str = if markers.is_empty() { - String::new() - } else { - format!(" {}", markers.join(" ").dimmed()) - }; - - let line = format!("* v{v}{marker_str}"); - if is_current { - println!("{}", line.bright_blue()); - } else { - println!("{line}"); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_list_installed_versions_nonexistent_dir() { - let versions = list_installed_versions(std::path::Path::new("/nonexistent/path")); - assert!(versions.is_empty()); - } - - #[test] - fn test_list_installed_versions_empty_dir() { - let dir = tempfile::tempdir().unwrap(); - let versions = list_installed_versions(dir.path()); - assert!(versions.is_empty()); - } - - #[test] - fn test_list_installed_versions_with_versions() { - let dir = tempfile::tempdir().unwrap(); - // Create version directories - std::fs::create_dir(dir.path().join("20.18.0")).unwrap(); - std::fs::create_dir(dir.path().join("22.13.0")).unwrap(); - std::fs::create_dir(dir.path().join("18.20.0")).unwrap(); - // Create a hidden dir that should be skipped - std::fs::create_dir(dir.path().join(".tmp")).unwrap(); - // Create a file that should be skipped - std::fs::write(dir.path().join("some-file"), "").unwrap(); - - let versions = list_installed_versions(dir.path()); - assert_eq!(versions, vec!["18.20.0", "20.18.0", "22.13.0"]); + let suffix = + if markers.is_empty() { String::new() } else { format!(" ({})", markers.join(", ")) }; + println!(" * {}{suffix}", version.version); } } diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index aacfc99797..efccfe2431 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -1,29 +1,31 @@ -//! List-remote command for displaying available Node.js versions from the registry. -//! -//! Handles `vp env list-remote` to show available Node.js versions from the Node.js distribution. +use std::{collections::BTreeMap, process::ExitStatus}; -use std::process::ExitStatus; - -use owo_colors::OwoColorize; +use futures::future::try_join_all; use serde::Serialize; use vp_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry}; +use vp_pm_cli::{fetch_package_manager_versions, resolve_package_manager_version}; use vt_path::AbsolutePathBuf; -use super::config; +use super::{ + config, + list::{list_complete_package_manager_versions, list_installed_versions}, + package_manager, + spec::{EnvScope, parse_package_manager_spec}, +}; use crate::{cli::SortingMethod, error::Error}; -/// Default number of major versions to show const DEFAULT_MAJOR_VERSIONS: usize = 10; -/// JSON output format for version list #[derive(Serialize)] -struct VersionListJson { - versions: Vec, +struct RemoteEnvironmentJson { + #[serde(skip_serializing_if = "Option::is_none")] + node: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + package_managers: Option>>, } -/// JSON format for a single version entry #[derive(Serialize)] -struct VersionJson { +struct NodeVersionJson { version: String, lts: Option, latest: bool, @@ -33,432 +35,316 @@ struct VersionJson { default: bool, } -/// Locally-derived markers used to annotate remote versions. -struct LocalMarkers { - /// Versions installed under `VP_HOME/js_runtime/node/` (without `v` prefix). - installed: std::collections::HashSet, - /// Version resolved for the current project/cwd (same logic as `vp env current`). - current: Option, - /// Global default version, if configured. - default: Option, +#[derive(Serialize)] +struct PackageManagerVersionJson { + version: String, + latest: bool, + installed: bool, + current: bool, + default: bool, } -/// Execute the list-remote command. pub async fn execute( cwd: AbsolutePathBuf, - pattern: Option, + values: Vec, lts_only: bool, show_all: bool, - json_output: bool, + json: bool, sort: SortingMethod, ) -> Result { - let provider = NodeProvider::new(); - let versions = provider.fetch_version_index().await?; + let (mut scope, pattern) = parse_scope_and_pattern(&values)?; + if lts_only { + if matches!(scope, EnvScope::PackageManagers | EnvScope::PackageManager(_)) { + return Err(Error::Other("--lts can only be used with Node.js".into())); + } + scope = EnvScope::Node; + } - if versions.is_empty() { - println!("No versions found."); + let provider = NodeProvider::new(); + let package_manager_types = package_manager::selected(scope); + let node_future = async { + if scope.includes_node() { + provider.fetch_version_index().await.map(Some).map_err(|error| { + Error::Other(format!("failed to fetch Node.js versions: {error}").into()) + }) + } else { + Ok(None) + } + }; + let package_manager_future = + try_join_all(package_manager_types.iter().copied().map(|kind| async move { + fetch_package_manager_versions(kind).await.map(|versions| (kind, versions)).map_err( + |error| Error::Other(format!("failed to fetch {kind} versions: {error}").into()), + ) + })); + let (node_versions, package_manager_versions) = + futures::join!(node_future, package_manager_future); + let node_versions = node_versions?; + let package_manager_versions = package_manager_versions?; + + let config = config::load_config().await?; + let current_node = if scope.includes_node() { + config::resolve_version(&cwd).await.ok().map(|resolution| resolution.version) + } else { + None + }; + let current_pm = if scope.includes_package_managers() { + package_manager::resolve_current(&cwd).await? + } else { + None + }; + let default_node = if scope.includes_node() { + match config.default_node_version.as_deref() { + Some(selector) => Some(config::resolve_version_alias(selector, &provider).await?), + None => None, + } + } else { + None + }; + let default_pm = if scope.includes_package_managers() { + let default = config + .default_package_manager + .as_deref() + .map(parse_package_manager_spec) + .transpose()?; + match default { + Some((kind, selector)) => { + Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) + } + None => None, + } + } else { + None + }; + let home = vp_shared::EnvConfig::get().dirs.data.clone(); + + let node = node_versions.map(|versions| { + build_node_versions( + &versions, + pattern.as_deref(), + lts_only, + show_all, + &sort, + current_node.as_deref(), + default_node.as_deref(), + &list_installed_versions(home.join("js_runtime").join("node").as_path()), + ) + }); + let package_managers = scope.includes_package_managers().then(|| { + package_manager_versions + .into_iter() + .map(|(kind, versions)| { + let installed = list_complete_package_manager_versions(&home, kind); + let entries = build_package_manager_versions( + versions, + pattern.as_deref(), + show_all, + &sort, + &installed, + current_pm.as_ref().and_then(|current| { + (current.package_manager_type == kind).then_some(current.version.as_str()) + }), + default_pm.as_ref().and_then(|(default, version)| { + (*default == kind).then_some(version.as_str()) + }), + ); + (kind.to_string(), entries) + }) + .collect() + }); + + if json { + println!( + "{}", + serde_json::to_string_pretty(&RemoteEnvironmentJson { node, package_managers })? + ); return Ok(ExitStatus::default()); } - - // Locally-derived markers (installed / current / default) used to annotate output. - let markers = local_markers(&cwd, &provider).await; - - // Filter versions based on options - let mut filtered = filter_versions(&versions, pattern.as_deref(), lts_only, show_all); - - // fetch_version_index() returns newest-first (desc). - // For asc (default), reverse to show oldest-first. - if matches!(sort, SortingMethod::Asc) { - filtered.reverse(); + if let Some(node) = node { + println!("Node.js"); + for entry in node { + println!( + " {}{}", + entry.version, + marker(entry.installed, entry.current, entry.default) + ); + } } - - if json_output { - print_json(&filtered, &versions, &markers)?; - } else { - print_human(&filtered, &markers); + if let Some(mut package_managers) = package_managers { + for kind in package_manager::selected(scope) { + let name = kind.to_string(); + let versions = package_managers.remove(&name).unwrap_or_default(); + println!(); + println!("{}", package_manager::title(kind)); + for entry in versions { + println!( + " {}{}", + entry.version, + marker(entry.installed, entry.current, entry.default) + ); + } + } } - Ok(ExitStatus::default()) } -/// Collect the locally-derived markers (installed / current / default). -/// -/// All lookups degrade gracefully: failures yield empty/none so the registry -/// listing still renders. -async fn local_markers(cwd: &AbsolutePathBuf, provider: &NodeProvider) -> LocalMarkers { - let installed = installed_versions(); - // Version resolved for the current project/cwd (same logic as `vp env current`); - // this is already a concrete version, never an alias. - let current = config::resolve_version(cwd).await.ok().map(|r| r.version); - // Global default may be stored as an alias (e.g. `lts`/`latest`) by - // `vp env default`, so resolve it to a concrete version before comparing - // against exact remote versions. - let default = match config::load_config().await.ok().and_then(|c| c.default_node_version) { - Some(alias) => config::resolve_version_alias(&alias, provider).await.ok(), - None => None, - }; - - LocalMarkers { installed, current, default } -} - -/// Collect the set of locally installed Node.js versions (without `v` prefix). -fn installed_versions() -> std::collections::HashSet { - let node_dir = vp_shared::EnvConfig::get().dirs.data.join("js_runtime").join("node"); - super::list::list_installed_versions(node_dir.as_path()).into_iter().collect() -} - -/// Strip a leading `v` from a version string, if present. -fn strip_v(version: &str) -> &str { - version.strip_prefix('v').unwrap_or(version) -} - -/// Whether colored output should be emitted on stdout. -fn use_color() -> bool { - vp_shared::is_stdout_terminal() && std::env::var_os("NO_COLOR").is_none() +fn parse_scope_and_pattern(values: &[String]) -> Result<(EnvScope, Option), Error> { + match values { + [] => Ok((EnvScope::All, None)), + [value] => match EnvScope::parse(Some(value)) { + Ok(scope) => Ok((scope, None)), + Err(_) => Ok((EnvScope::All, Some(value.clone()))), + }, + [scope, pattern] => Ok((EnvScope::parse(Some(scope))?, Some(pattern.clone()))), + _ => Err(Error::Other("list-remote accepts at most a scope and version pattern".into())), + } } -/// Filter versions based on criteria. -fn filter_versions<'a>( - versions: &'a [NodeVersionEntry], +fn build_node_versions( + versions: &[NodeVersionEntry], pattern: Option<&str>, lts_only: bool, show_all: bool, -) -> Vec<&'a NodeVersionEntry> { - let mut filtered: Vec<&'a NodeVersionEntry> = versions.iter().collect(); - - // Filter by LTS if requested - if lts_only { - filtered.retain(|v| v.is_lts()); - } - - // Filter by pattern (major version) - if let Some(pattern) = pattern { - filtered.retain(|v| { - let version_str = v.version.strip_prefix('v').unwrap_or(&v.version); - version_str.starts_with(pattern) || version_str.starts_with(&format!("{pattern}.")) - }); - } - - // Limit to recent major versions unless --all is specified - if !show_all && pattern.is_none() { - filtered = limit_to_recent_majors(filtered, DEFAULT_MAJOR_VERSIONS); + sort: &SortingMethod, + current: Option<&str>, + default: Option<&str>, + installed: &[String], +) -> Vec { + let latest = versions.first().map(|entry| entry.version.as_str()); + let latest_lts = + versions.iter().find(|entry| entry.is_lts()).map(|entry| entry.version.as_str()); + let mut filtered = filter_recent( + versions.iter().filter(|entry| { + (!lts_only || entry.is_lts()) && matches_pattern(&entry.version, pattern) + }), + show_all || pattern.is_some(), + |entry| &entry.version, + ); + if matches!(sort, SortingMethod::Asc) { + filtered.reverse(); } - filtered -} - -/// Extract major version from a version string like "v20.18.0" or "20.18.0" -fn extract_major(version: &str) -> Option { - let version_str = version.strip_prefix('v').unwrap_or(version); - version_str.split('.').next()?.parse().ok() -} - -/// Limit versions to the N most recent major versions. -fn limit_to_recent_majors( - versions: Vec<&NodeVersionEntry>, - max_majors: usize, -) -> Vec<&NodeVersionEntry> { - // Get unique major versions - let mut majors: Vec = versions.iter().filter_map(|v| extract_major(&v.version)).collect(); - - majors.sort_unstable(); - majors.dedup(); - majors.reverse(); - - // Keep only the most recent N majors - let recent_majors: std::collections::HashSet = - majors.into_iter().take(max_majors).collect(); - - versions .into_iter() - .filter(|v| extract_major(&v.version).is_some_and(|m| recent_majors.contains(&m))) + .map(|entry| { + let version = entry.version.strip_prefix('v').unwrap_or(&entry.version).to_string(); + NodeVersionJson { + lts: match &entry.lts { + LtsInfo::Codename(name) => Some(name.to_string()), + _ => None, + }, + latest: latest == Some(entry.version.as_str()), + latest_lts: latest_lts == Some(entry.version.as_str()), + installed: installed.contains(&version), + current: current == Some(version.as_str()), + default: default == Some(version.as_str()), + version, + } + }) .collect() } -/// Build the JSON entries for the given versions. -fn build_json( - versions: &[&NodeVersionEntry], - all_versions: &[NodeVersionEntry], - markers: &LocalMarkers, -) -> Vec { - // Find the latest version and latest LTS - let latest_version = all_versions.first().map(|v| &v.version); - let latest_lts_version = all_versions.iter().find(|v| v.is_lts()).map(|v| &v.version); - +fn build_package_manager_versions( + mut versions: Vec, + pattern: Option<&str>, + show_all: bool, + sort: &SortingMethod, + installed: &[String], + current: Option<&str>, + default: Option<&str>, +) -> Vec { + let latest = + versions.iter().rev().find(|version| !version.is_prerelease()).map(ToString::to_string); + versions.retain(|version| { + !version.is_prerelease() && matches_pattern(&version.to_string(), pattern) + }); + if !show_all && pattern.is_none() { + let recent_majors = versions + .iter() + .rev() + .map(|version| version.major) + .collect::>() + .into_iter() + .rev() + .take(DEFAULT_MAJOR_VERSIONS) + .collect::>(); + versions.retain(|version| recent_majors.contains(&version.major)); + } + if matches!(sort, SortingMethod::Desc) { + versions.reverse(); + } versions - .iter() - .map(|v| { - let lts = match &v.lts { - LtsInfo::Codename(name) => Some(name.to_string()), - _ => None, - }; - let is_latest = latest_version.is_some_and(|lv| lv == &v.version); - let is_latest_lts = latest_lts_version.is_some_and(|llv| llv == &v.version); - let version = strip_v(&v.version).to_string(); - let is_installed = markers.installed.contains(&version); - let is_current = markers.current.as_deref() == Some(version.as_str()); - let is_default = markers.default.as_deref() == Some(version.as_str()); - - VersionJson { + .into_iter() + .map(|version| { + let version = version.to_string(); + PackageManagerVersionJson { + latest: latest.as_deref() == Some(version.as_str()), + installed: installed.contains(&version), + current: current == Some(version.as_str()), + default: default == Some(version.as_str()), version, - lts, - latest: is_latest, - latest_lts: is_latest_lts, - installed: is_installed, - current: is_current, - default: is_default, } }) .collect() } -/// Print versions as JSON. -fn print_json( - versions: &[&NodeVersionEntry], - all_versions: &[NodeVersionEntry], - markers: &LocalMarkers, -) -> Result<(), Error> { - let output = VersionListJson { versions: build_json(versions, all_versions, markers) }; - println!("{}", serde_json::to_string_pretty(&output)?); - - Ok(()) -} - -/// Print versions in human-readable format (fnm-style). -/// -/// Installed versions are highlighted (green, blue for the current project version) -/// when stdout supports color, and marked with a leading `*` otherwise so the -/// distinction survives piped output. The current/default versions are annotated -/// with trailing `current`/`default` labels. -fn print_human(versions: &[&NodeVersionEntry], markers: &LocalMarkers) { - if versions.is_empty() { - eprintln!("{}", "No versions were found!".red()); - return; +fn filter_recent<'a, T: 'a>( + values: impl Iterator, + show_all: bool, + version: impl Fn(&T) -> &str, +) -> Vec<&'a T> { + let values = values.collect::>(); + if show_all { + return values; } + let majors = values + .iter() + .filter_map(|value| major(version(value))) + .collect::>() + .into_iter() + .rev() + .take(DEFAULT_MAJOR_VERSIONS) + .collect::>(); + values + .into_iter() + .filter(|value| major(version(value)).is_some_and(|v| majors.contains(&v))) + .collect() +} - let colorize = use_color(); - - for version in versions { - let version_str = &version.version; - let stripped = strip_v(version_str); - // Ensure v prefix - let display = if version_str.starts_with('v') { - version_str.to_string() - } else { - format!("v{version_str}") - }; - let is_installed = markers.installed.contains(stripped); - let is_current = markers.current.as_deref() == Some(stripped); - let is_default = markers.default.as_deref() == Some(stripped); - - let lts_suffix = match &version.lts { - LtsInfo::Codename(name) => format!(" ({name})"), - _ => String::new(), - }; +fn major(version: &str) -> Option { + version.strip_prefix('v').unwrap_or(version).split('.').next()?.parse().ok() +} - let mut labels = Vec::new(); - if is_current { - labels.push("current"); - } - if is_default { - labels.push("default"); - } - let label_suffix = - if labels.is_empty() { String::new() } else { format!(" {}", labels.join(" ")) }; +fn matches_pattern(version: &str, pattern: Option<&str>) -> bool { + let Some(pattern) = pattern else { + return true; + }; + let version = version.strip_prefix('v').unwrap_or(version); + version.starts_with(pattern) || version.starts_with(&format!("{pattern}.")) +} - if colorize { - // Color each segment independently to avoid nested ANSI resets. - // Current project version takes precedence (blue), else installed (green). - let version_part = if is_current { - display.bright_blue().to_string() - } else if is_installed { - display.green().to_string() - } else { - display - }; - let lts_part = if lts_suffix.is_empty() { - String::new() - } else { - lts_suffix.bright_blue().to_string() - }; - let label_part = if label_suffix.is_empty() { - String::new() - } else { - label_suffix.dimmed().to_string() - }; - println!("{version_part}{lts_part}{label_part}"); - } else { - // No color: use a `*` marker with an aligned gutter for plain rows. - let marker = if is_installed { "* " } else { " " }; - println!("{marker}{display}{lts_suffix}{label_suffix}"); - } +fn marker(installed: bool, current: bool, default: bool) -> String { + let mut markers = Vec::new(); + if installed { + markers.push("installed"); + } + if current { + markers.push("current"); } + if default { + markers.push("default"); + } + if markers.is_empty() { String::new() } else { format!(" ({})", markers.join(", ")) } } #[cfg(test)] mod tests { use super::*; - fn make_version(version: &str, lts: Option<&str>) -> NodeVersionEntry { - NodeVersionEntry { - version: version.into(), - lts: match lts { - Some(name) => LtsInfo::Codename(name.into()), - None => LtsInfo::Boolean(false), - }, - } - } - - fn markers(installed: &[&str], current: Option<&str>, default: Option<&str>) -> LocalMarkers { - LocalMarkers { - installed: installed.iter().map(|s| (*s).to_string()).collect(), - current: current.map(str::to_string), - default: default.map(str::to_string), - } - } - - #[test] - fn test_filter_versions_lts_only() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - ]; - - let filtered = filter_versions(&versions, None, true, false); - assert_eq!(filtered.len(), 2); - assert!(filtered.iter().all(|v| v.is_lts())); - } - - #[test] - fn test_filter_versions_by_pattern() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v22.12.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - ]; - - let filtered = filter_versions(&versions, Some("22"), false, true); - assert_eq!(filtered.len(), 2); - assert!(filtered.iter().all(|v| v.version.starts_with("v22."))); - } - - #[test] - fn test_limit_to_recent_majors() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v23.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v21.0.0", None), - make_version("v20.18.0", Some("Iron")), - ]; - - let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); - let limited = limit_to_recent_majors(refs, 2); - - // Should only have v24 and v23 - assert_eq!(limited.len(), 2); - assert!(limited.iter().any(|v| v.version.starts_with("v24."))); - assert!(limited.iter().any(|v| v.version.starts_with("v23."))); - } - #[test] - fn test_filter_versions_show_all_returns_all_versions() { - // Create versions spanning many major versions (more than DEFAULT_MAJOR_VERSIONS) - let versions = vec![ - make_version("v25.0.0", None), - make_version("v24.0.0", None), - make_version("v23.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v21.0.0", None), - make_version("v20.18.0", Some("Iron")), - make_version("v19.0.0", None), - make_version("v18.20.0", Some("Hydrogen")), - make_version("v17.0.0", None), - make_version("v16.20.0", Some("Gallium")), - make_version("v15.0.0", None), - make_version("v14.0.0", None), - ]; - - // Without show_all, should be limited to DEFAULT_MAJOR_VERSIONS (10) - let filtered_limited = filter_versions(&versions, None, false, false); - assert_eq!(filtered_limited.len(), 10); - - // With show_all=true, should return all versions - let filtered_all = filter_versions(&versions, None, false, true); - assert_eq!(filtered_all.len(), 12); - } - - #[test] - fn test_build_json_marks_installed_versions() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - ]; - let all_versions = versions.clone(); - let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); - - // Installed dirs are stored without the leading `v`. - let json = build_json(&refs, &all_versions, &markers(&["22.13.0"], None, None)); - - let installed_entry = json.iter().find(|v| v.version == "22.13.0").unwrap(); - assert!(installed_entry.installed); - - let not_installed = json.iter().find(|v| v.version == "24.0.0").unwrap(); - assert!(!not_installed.installed); - } - - #[test] - fn test_build_json_empty_installed_set() { - let versions = vec![make_version("v24.0.0", None)]; - let all_versions = versions.clone(); - let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); - - let json = build_json(&refs, &all_versions, &markers(&[], None, None)); - assert!(json.iter().all(|v| !v.installed && !v.current && !v.default)); - } - - #[test] - fn test_build_json_marks_current_and_default() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - ]; - let all_versions = versions.clone(); - let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); - - // Current project resolves to 22.13.0; global default is 20.18.0. - let json = build_json( - &refs, - &all_versions, - &markers(&["22.13.0", "20.18.0"], Some("22.13.0"), Some("20.18.0")), + fn legacy_pattern_keeps_all_components_selected() { + assert_eq!( + parse_scope_and_pattern(&["20".into()]).unwrap(), + (EnvScope::All, Some("20".into())) ); - - let current = json.iter().find(|v| v.version == "22.13.0").unwrap(); - assert!(current.current && current.installed && !current.default); - - let default = json.iter().find(|v| v.version == "20.18.0").unwrap(); - assert!(default.default && default.installed && !default.current); - - let plain = json.iter().find(|v| v.version == "24.0.0").unwrap(); - assert!(!plain.current && !plain.default && !plain.installed); - } - - #[test] - fn test_filter_versions_show_all_with_lts_filter() { - let versions = vec![ - make_version("v25.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - make_version("v18.20.0", Some("Hydrogen")), - ]; - - // With lts_only and show_all, should return all LTS versions - let filtered = filter_versions(&versions, None, true, true); - assert_eq!(filtered.len(), 3); - assert!(filtered.iter().all(|v| v.is_lts())); } } diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index f68d8169e5..6ce3d6ec94 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -10,13 +10,16 @@ mod current; mod default; mod doctor; mod exec; +mod lifecycle; mod list; mod list_remote; mod off; mod on; +pub(crate) mod package_manager; pub mod package_metadata; mod pin; pub(crate) mod setup; +mod spec; mod unpin; mod r#use; mod which; @@ -46,8 +49,8 @@ fn print_env_clean_tip() { fn should_print_env_header(subcommand: &EnvSubcommands) -> bool { match subcommand { - EnvSubcommands::Current { json } => !json, - EnvSubcommands::List { json } => !json, + EnvSubcommands::Current { json, .. } => !json, + EnvSubcommands::List { json, .. } => !json, EnvSubcommands::ListRemote { json, .. } => !json, // Keep these machine-consumable / passthrough commands header-free. EnvSubcommands::Use { .. } | EnvSubcommands::Exec { .. } => false, @@ -57,24 +60,12 @@ fn should_print_env_header(subcommand: &EnvSubcommands) -> bool { fn should_print_env_clean_tip(subcommand: &EnvSubcommands) -> bool { match subcommand { - EnvSubcommands::List { json } => !json, + EnvSubcommands::List { json, .. } => !json, EnvSubcommands::ListRemote { json, .. } => !json, _ => false, } } -fn is_installable_version_source(source: &str) -> bool { - matches!( - source, - ".node-version" - | ".nvmrc" - | "engines.node" - | "devEngines.runtime" - | config::VERSION_ENV_VAR - | config::SESSION_VERSION_FILE - ) -} - /// Execute the env command based on the provided arguments. pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result { // Handle subcommands first @@ -85,79 +76,52 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result current::execute(cwd, json).await, - crate::cli::EnvSubcommands::Print => print_env(cwd).await, - crate::cli::EnvSubcommands::Default { version } => default::execute(cwd, version).await, - crate::cli::EnvSubcommands::On => on::execute().await, - crate::cli::EnvSubcommands::Off => off::execute().await, + crate::cli::EnvSubcommands::Current { scope, json } => { + current::execute(cwd, scope, json).await + } + crate::cli::EnvSubcommands::Print { scope } => print_env(cwd, scope).await, + crate::cli::EnvSubcommands::Default { values, unset } => { + default::execute(values, unset).await + } + crate::cli::EnvSubcommands::On { scope } => on::execute(scope).await, + crate::cli::EnvSubcommands::Off { scope } => off::execute(scope).await, crate::cli::EnvSubcommands::Setup { refresh, env_only } => { setup::execute(refresh, env_only).await } - crate::cli::EnvSubcommands::Doctor => doctor::execute(cwd).await, + crate::cli::EnvSubcommands::Doctor { scope } => doctor::execute(cwd, scope).await, crate::cli::EnvSubcommands::Which { tool } => which::execute(cwd, &tool).await, - crate::cli::EnvSubcommands::Pin { version, unpin, no_install, force, target } => { - pin::execute(cwd, version, unpin, no_install, force, target).await + crate::cli::EnvSubcommands::Pin { specs, unpin, no_install, force, target } => { + pin::execute(cwd, specs, unpin, no_install, force, target).await } - crate::cli::EnvSubcommands::Unpin { target } => unpin::execute(cwd, target).await, - crate::cli::EnvSubcommands::List { json } => list::execute(cwd, json).await, - crate::cli::EnvSubcommands::ListRemote { pattern, lts, all, json, sort } => { - list_remote::execute(cwd, pattern, lts, all, json, sort).await + crate::cli::EnvSubcommands::Unpin { scope, target } => { + unpin::execute(cwd, scope, target).await } - crate::cli::EnvSubcommands::Exec { node, npm, command } => { - exec::execute(node.as_deref(), npm.as_deref(), &command).await + crate::cli::EnvSubcommands::List { scope, json } => { + list::execute(cwd, scope, json).await } - crate::cli::EnvSubcommands::Uninstall { version } => { - let provider = vp_js_runtime::NodeProvider::new(); - let resolved = config::resolve_version_alias(&version, &provider).await?; - let version_dir = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(&resolved); - if !version_dir.as_path().exists() { - eprintln!("Node.js v{} is not installed", resolved); - return Ok(exit_status(1)); - } - tokio::fs::remove_dir_all(version_dir.as_path()).await.map_err(|e| { - crate::error::Error::Other( - format!("Failed to remove Node.js v{}: {}", resolved, e).into(), - ) - })?; - println!("Uninstalled Node.js v{}", resolved); - Ok(ExitStatus::default()) + crate::cli::EnvSubcommands::ListRemote { values, lts, all, json, sort } => { + list_remote::execute(cwd, values, lts, all, json, sort).await } - crate::cli::EnvSubcommands::Clean => clean::execute(cwd).await, - crate::cli::EnvSubcommands::Use { version, unset, no_install, silent_if_unchanged } => { - r#use::execute(cwd, version, unset, no_install, silent_if_unchanged).await + crate::cli::EnvSubcommands::Exec { node, npm, package_manager, command } => { + exec::execute( + &cwd, + node.as_deref(), + npm.as_deref(), + package_manager.as_deref(), + &command, + ) + .await } - crate::cli::EnvSubcommands::Install { version } => { - let (resolved, from_session_override) = if let Some(version) = version { - let provider = vp_js_runtime::NodeProvider::new(); - (config::resolve_version_alias(&version, &provider).await?, false) - } else { - let resolution = config::resolve_version(&cwd).await?; - let from_session_override = matches!( - resolution.source.as_str(), - config::VERSION_ENV_VAR | config::SESSION_VERSION_FILE - ); - if !is_installable_version_source(&resolution.source) { - eprintln!("No Node.js version found in current project."); - eprintln!("Specify a version: vp env install "); - eprintln!("Or pin one: vp env pin "); - return Ok(exit_status(1)); - } - (resolution.version, from_session_override) - }; - println!("Installing Node.js v{}...", resolved); - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolved) - .await?; - println!("Installed Node.js v{}", resolved); - if from_session_override { - eprintln!("Note: Installed from session override."); - eprintln!("Run `vp env use --unset` to revert to project version resolution."); - } - Ok(ExitStatus::default()) + crate::cli::EnvSubcommands::Uninstall { specs } => lifecycle::uninstall(specs).await, + crate::cli::EnvSubcommands::Clean { scope } => clean::execute(cwd, scope).await, + crate::cli::EnvSubcommands::Use { + requests, + unset, + no_install, + silent_if_unchanged, + } => r#use::execute(cwd, requests, unset, no_install, silent_if_unchanged).await, + crate::cli::EnvSubcommands::Install { requests } => { + lifecycle::install(cwd, requests).await } }; @@ -185,36 +149,71 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result Result { +async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result { + let scope = spec::EnvScope::parse(scope.as_deref())?; + let modes = config::load_config().await?; // Resolve the Node.js version for the current directory - let resolution = config::resolve_version(&cwd).await?; + let resolution = scope.includes_node().then(|| config::resolve_version(&cwd)); + let resolution = match resolution { + Some(resolution) => Some(resolution.await?), + None => None, + }; // Get the node bin directory - let runtime = - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolution.version) + let mut bin_dirs = Vec::new(); + if let Some(resolution) = resolution { + if modes.shim_mode == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool("node") + && let Some(bin_dir) = path.parent() + { + bin_dirs.push(bin_dir.as_path().display().to_string()); + } else { + let runtime = vp_js_runtime::download_runtime( + vp_js_runtime::JsRuntimeType::Node, + &resolution.version, + ) .await?; - - let bin_dir = runtime.get_bin_prefix(); - let snippet = match detect_shell() { - Shell::NuShell => { - format!("$env.PATH = ($env.PATH | prepend \"{}\")", bin_dir.as_path().display()) + bin_dirs.push(runtime.get_bin_prefix().as_path().display().to_string()); } - _ => format!("export PATH=\"{}:$PATH\"", bin_dir.as_path().display()), + } + if scope.includes_package_managers() + && let Some(resolution) = package_manager::resolve_current(&cwd).await? + && !matches!(scope, spec::EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) + { + if modes.package_manager_shim_mode() == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool( + &resolution.package_manager_type.to_string(), + ) + && let Some(bin_dir) = path.parent() + { + bin_dirs.insert(0, bin_dir.as_path().display().to_string()); + } else { + let (install_dir, _, _) = vp_pm_cli::download_package_manager( + resolution.package_manager_type, + &resolution.version, + resolution.hash.as_deref(), + ) + .await?; + bin_dirs.insert(0, install_dir.join("bin").as_path().display().to_string()); + } + } + if bin_dirs.is_empty() { + return Err(Error::Other("no selected environment component could be resolved".into())); + } + let snippet = match detect_shell() { + Shell::Posix => format!("export PATH=\"{}:$PATH\"", bin_dirs.join(":")), + Shell::Fish => format!("set -gx PATH {} $PATH", bin_dirs.join(" ")), + Shell::PowerShell => format!("$env:PATH = \"{};$env:PATH\"", bin_dirs.join(";")), + Shell::Cmd => format!("set PATH={};%PATH%", bin_dirs.join(";")), + Shell::NuShell => format!( + "$env.PATH = ($env.PATH | prepend [{}])", + bin_dirs.iter().map(|path| format!("\"{path}\"")).collect::>().join(", ") + ), }; // Print shell snippet - println!("# Add to your shell to use this Node.js version for this session:"); + println!("# Add to your shell to use this environment for this session:"); println!("{snippet}"); Ok(ExitStatus::default()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn nvmrc_is_an_installable_version_source() { - assert!(is_installable_version_source(".nvmrc")); - } -} diff --git a/crates/vp_global_cli/src/commands/env/off.rs b/crates/vp_global_cli/src/commands/env/off.rs index 461fdef0bf..099e31bf12 100644 --- a/crates/vp_global_cli/src/commands/env/off.rs +++ b/crates/vp_global_cli/src/commands/env/off.rs @@ -5,31 +5,39 @@ use std::process::ExitStatus; -use super::config::{ShimMode, load_config, save_config}; +use super::{ + config::{ShimMode, load_config, save_config}, + spec::EnvScope, +}; use crate::{error::Error, help}; /// Execute the `vp env off` command. -pub async fn execute() -> Result { - let mut config = load_config().await?; - - if config.shim_mode == ShimMode::SystemFirst { - println!("Node.js management is already set to system-first."); - println!( - "All vp commands and shims will prefer system Node.js, falling back to managed if not found." - ); - return Ok(ExitStatus::default()); +pub async fn execute(scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; + if matches!(scope, EnvScope::PackageManager(_)) { + return Err(Error::Other("off accepts only node or pm as a scope".into())); } - - config.shim_mode = ShimMode::SystemFirst; + let mut config = load_config().await?; + config.set_shim_modes( + scope.includes_node(), + scope.includes_package_managers(), + ShimMode::SystemFirst, + ); save_config(&config).await?; - println!("\u{2713} Node.js management set to system-first."); + let component = match scope { + EnvScope::All => "Node.js and package-manager management", + EnvScope::Node => "Node.js management", + EnvScope::PackageManagers => "Package-manager management", + EnvScope::PackageManager(_) => unreachable!(), + }; + println!("\u{2713} {component} set to system-first."); println!(); println!( - "All vp commands and shims will now prefer system Node.js, falling back to managed if not found." + "Selected commands and shims will now prefer system tools, falling back to managed tools." ); println!(); - println!("Run {} to always use Vite+ managed Node.js.", help::accent_command("vp env on")); + println!("Run {} to always use Vite+ managed tools.", help::accent_command("vp env on")); Ok(ExitStatus::default()) } diff --git a/crates/vp_global_cli/src/commands/env/on.rs b/crates/vp_global_cli/src/commands/env/on.rs index 6ded635215..a4f74afd76 100644 --- a/crates/vp_global_cli/src/commands/env/on.rs +++ b/crates/vp_global_cli/src/commands/env/on.rs @@ -4,27 +4,37 @@ use std::process::ExitStatus; -use super::config::{ShimMode, load_config, save_config}; +use super::{ + config::{ShimMode, load_config, save_config}, + spec::EnvScope, +}; use crate::{error::Error, help}; /// Execute the `vp env on` command. -pub async fn execute() -> Result { - let mut config = load_config().await?; - - if config.shim_mode == ShimMode::Managed { - println!("Node.js management is already set to managed."); - println!("All vp commands and shims will always use Vite+ managed Node.js."); - return Ok(ExitStatus::default()); +pub async fn execute(scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; + if matches!(scope, EnvScope::PackageManager(_)) { + return Err(Error::Other("on accepts only node or pm as a scope".into())); } - - config.shim_mode = ShimMode::Managed; + let mut config = load_config().await?; + config.set_shim_modes( + scope.includes_node(), + scope.includes_package_managers(), + ShimMode::Managed, + ); save_config(&config).await?; - println!("\u{2713} Node.js management set to managed."); + let component = match scope { + EnvScope::All => "Node.js and package-manager management", + EnvScope::Node => "Node.js management", + EnvScope::PackageManagers => "Package-manager management", + EnvScope::PackageManager(_) => unreachable!(), + }; + println!("\u{2713} {component} set to managed."); println!(); - println!("All vp commands and shims will now always use Vite+ managed Node.js."); + println!("Selected commands and shims will now use Vite+ managed tools."); println!(); - println!("Run {} to prefer system Node.js instead.", help::accent_command("vp env off")); + println!("Run {} to prefer system tools instead.", help::accent_command("vp env off")); Ok(ExitStatus::default()) } diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs new file mode 100644 index 0000000000..8647d05660 --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -0,0 +1,135 @@ +use vp_pm_cli::{ + EnvironmentPackageManagerResolution, PackageManagerType, resolve_environment_package_manager, + resolve_environment_package_manager_spec, +}; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use super::{config, spec::parse_package_manager_spec}; +use crate::error::Error; + +pub(crate) async fn resolve_current( + cwd: &AbsolutePath, +) -> Result, Error> { + let specs = current_specs().await?; + + let mut resolution = + resolve_environment_package_manager(cwd, specs.session_spec(), specs.default_spec()) + .await + .map_err(Error::from)?; + specs.apply_session_source(&mut resolution); + Ok(resolution) +} + +pub(crate) async fn resolve_current_spec( + cwd: &AbsolutePath, +) -> Result, Error> { + let specs = current_specs().await?; + + let mut resolution = + resolve_environment_package_manager_spec(cwd, specs.session_spec(), specs.default_spec()) + .map_err(Error::from)?; + specs.apply_session_source(&mut resolution); + Ok(resolution) +} + +type PackageManagerSpec = (PackageManagerType, String); + +struct CurrentSpecs { + session: Option, + session_source: Option<&'static str>, + session_source_path: Option, + default: Option, +} + +impl CurrentSpecs { + fn session_spec(&self) -> Option<(PackageManagerType, &str)> { + self.session.as_ref().map(|(kind, version)| (*kind, version.as_str())) + } + + fn default_spec(&self) -> Option<(PackageManagerType, &str)> { + self.default.as_ref().map(|(kind, version)| (*kind, version.as_str())) + } + + fn apply_session_source(&self, resolution: &mut Option) { + if let (Some(resolution), Some(source)) = (resolution, self.session_source) { + resolution.source = source.into(); + resolution.source_path.clone_from(&self.session_source_path); + } + } +} + +async fn current_specs() -> Result { + let (session, session_source, session_source_path) = + if let Some(spec) = vp_shared::EnvConfig::get().package_manager { + ( + Some(parse_package_manager_spec(spec.trim())?), + Some(config::PACKAGE_MANAGER_ENV_VAR), + None, + ) + } else if let Some(spec) = config::read_session_package_manager().await { + ( + Some(parse_package_manager_spec(spec.trim())?), + Some(config::SESSION_PACKAGE_MANAGER_FILE), + config::get_session_package_manager_path().ok(), + ) + } else { + (None, None, None) + }; + let config = config::load_config().await?; + let default = + config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + Ok(CurrentSpecs { session, session_source, session_source_path, default }) +} + +pub(crate) async fn resolve_from_files( + cwd: &AbsolutePath, +) -> Result, Error> { + let config = config::load_config().await?; + let default = + config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + resolve_environment_package_manager( + cwd, + None, + default.as_ref().map(|(kind, version)| (*kind, version.as_str())), + ) + .await + .map_err(Error::from) +} + +pub(crate) async fn warn_if_target_differs(cwd: &AbsolutePath, target: PackageManagerType) { + let Ok(Some(current)) = resolve_current_spec(cwd).await else { + return; + }; + if current.source != "default" && current.package_manager_type != target { + vp_shared::output::warn(&format!( + "Current environment resolves to {} from {}, but {target} was requested.", + current.package_manager_type, current.source + )); + } +} + +pub(crate) const ALL_PACKAGE_MANAGERS: [PackageManagerType; 4] = [ + PackageManagerType::Npm, + PackageManagerType::Pnpm, + PackageManagerType::Yarn, + PackageManagerType::Bun, +]; + +pub(crate) fn selected(scope: super::spec::EnvScope) -> Vec { + match scope { + super::spec::EnvScope::All | super::spec::EnvScope::PackageManagers => { + ALL_PACKAGE_MANAGERS.to_vec() + } + super::spec::EnvScope::PackageManager(kind) => vec![kind], + super::spec::EnvScope::Node => Vec::new(), + } +} + +pub(crate) const fn title(kind: PackageManagerType) -> &'static str { + match kind { + PackageManagerType::Npm => "npm", + PackageManagerType::Pnpm => "pnpm", + PackageManagerType::Yarn => "Yarn", + PackageManagerType::Bun => "Bun", + } +} diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 84b42ceb0c..a5754611a6 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -10,10 +10,18 @@ use std::{io::Write, process::ExitStatus}; use vp_js_runtime::NodeProvider; +use vp_pm_cli::{ + PackageManagerType, download_package_manager, resolve_package_manager_from_package_json, + resolve_package_manager_version, +}; use vp_shared::output; use vt_path::AbsolutePathBuf; -use super::config::{get_config_path, load_config}; +use super::{ + config::{get_config_path, load_config}, + package_manager, + spec::{EnvScope, EnvSpecs}, +}; use crate::{cli::PinTarget, error::Error}; /// Node version file name @@ -25,7 +33,7 @@ const PACKAGE_JSON_FILE: &str = "package.json"; /// Execute the pin command. pub async fn execute( cwd: AbsolutePathBuf, - version: Option, + specs: Vec, unpin: bool, no_install: bool, force: bool, @@ -33,13 +41,64 @@ pub async fn execute( ) -> Result { // Handle --unpin flag if unpin { - return do_unpin(&cwd, target).await; + let scope = match specs.as_slice() { + [] => EnvScope::All, + [scope] => EnvScope::parse(Some(scope))?, + _ => return Err(Error::Other("pin --unpin accepts at most one scope".into())), + }; + return do_unpin_scope(&cwd, scope, target).await; + } + + if specs.is_empty() { + show_pinned(&cwd).await?; + println!(); + return show_package_manager_pin(&cwd).await; + } + + let specs = EnvSpecs::parse(&specs)?; + let package_manager_root = if specs.package_manager.is_some() { + workspace_root(&cwd)?.ok_or_else(|| { + Error::Other("cannot pin a package manager without package.json".into()) + })? + } else { + cwd.clone() + }; + if specs.node.is_some() + && specs.package_manager.is_some() + && matches!(target, Some(PinTarget::NodeVersion | PinTarget::PackageManager)) + { + return Err(Error::Other( + "mixed Node.js and package-manager pins require the default targets or --target dev-engines" + .into(), + )); } - match version { - Some(v) => do_pin(&cwd, &v, no_install, force, target).await, - None => show_pinned(&cwd).await, + if let Some(version) = specs.node { + do_pin(&cwd, &version, no_install, force, target).await?; } + if let Some((package_manager, version)) = specs.package_manager { + pin_package_manager(&package_manager_root, package_manager, &version, no_install, target) + .await?; + } + Ok(ExitStatus::default()) +} + +async fn show_package_manager_pin(cwd: &AbsolutePathBuf) -> Result { + match resolve_package_manager_from_package_json(cwd)? { + Some(resolution) => { + println!( + "Pinned package manager: {}@{}", + resolution.package_manager_type, resolution.version + ); + println!( + " Source: {} ({})", + resolution.source_path.as_path().display(), + resolution.source + ); + } + None => println!("No package manager pinned."), + } + Ok(ExitStatus::default()) } /// Show the current pinned version. @@ -172,6 +231,11 @@ async fn do_pin( } pinned } + PinTarget::PackageManager => { + return Err(Error::Other( + "--target package-manager requires a package-manager spec".into(), + )); + } }; if !pinned { @@ -576,11 +640,236 @@ pub async fn do_unpin( println!("No Node.js pin found in current directory."); } } + PinTarget::PackageManager => { + return Err(Error::Other( + "--target package-manager requires package-manager scope".into(), + )); + } + } + + Ok(ExitStatus::default()) +} + +pub async fn do_unpin_scope( + cwd: &AbsolutePathBuf, + scope: EnvScope, + target: Option, +) -> Result { + if matches!(scope, EnvScope::Node) && matches!(target, Some(PinTarget::PackageManager)) { + return Err(Error::Other( + "--target package-manager is incompatible with node scope".into(), + )); + } + if scope.includes_package_managers() + && !scope.includes_node() + && matches!(target, Some(PinTarget::NodeVersion)) + { + return Err(Error::Other( + "--target node-version is incompatible with package-manager scope".into(), + )); } + if scope.includes_node() && !matches!(target, Some(PinTarget::PackageManager)) { + do_unpin(cwd, target).await?; + } + if scope.includes_package_managers() { + unpin_package_manager(cwd, scope, target).await?; + } + Ok(ExitStatus::default()) +} +async fn pin_package_manager( + cwd: &AbsolutePathBuf, + package_manager: PackageManagerType, + version: &str, + no_install: bool, + target: Option, +) -> Result { + if matches!(target, Some(PinTarget::NodeVersion)) { + return Err(Error::Other("--target node-version cannot pin a package manager".into())); + } + let resolved = resolve_package_manager_version(package_manager, version).await?; + package_manager::warn_if_target_differs(cwd, package_manager).await; + let package_json_path = cwd.join(PACKAGE_JSON_FILE); + let content = tokio::fs::read_to_string(&package_json_path).await?; + let mut changed = false; + let updated = vp_shared::edit_json_object(&content, |obj| { + let use_top_level = matches!(target, Some(PinTarget::PackageManager)) + || (target.is_none() && obj.get("packageManager").is_some()); + if use_top_level { + let existing = obj.get("packageManager").and_then(serde_json::Value::as_str); + let prefix = format!("{package_manager}@{resolved}"); + let next = existing + .filter(|value| { + *value == prefix + || value.strip_prefix(&prefix).is_some_and(|suffix| suffix.starts_with('+')) + }) + .unwrap_or(&prefix) + .to_string(); + if obj.get("packageManager").and_then(serde_json::Value::as_str) != Some(&next) { + obj.insert("packageManager".into(), serde_json::Value::String(next)); + changed = true; + } + } else { + set_dev_engines_package_manager(obj, package_manager, &resolved); + changed = true; + } + }) + .map_err(|error| Error::Other(format!("failed to update package.json: {error}").into()))?; + if !changed { + println!("Already pinned to {package_manager}@{resolved}"); + return Ok(ExitStatus::default()); + } + tokio::fs::write(&package_json_path, updated).await?; + crate::shim::invalidate_cache(); + output::success(&format!("Pinned package manager to {package_manager}@{resolved}")); + if no_install { + output::note("Package manager will be downloaded on first use."); + } else if let Err(error) = download_package_manager(package_manager, &resolved, None).await { + output::warn(&format!("Failed to download {package_manager} {resolved}: {error}")); + } Ok(ExitStatus::default()) } +fn set_dev_engines_package_manager( + obj: &mut serde_json::Map, + package_manager: PackageManagerType, + version: &str, +) { + use serde_json::Value; + + let entry = vp_shared::dev_engine_entry(&package_manager.to_string(), version); + let Some(dev_engines) = obj.get_mut("devEngines").and_then(Value::as_object_mut) else { + vp_shared::insert_after( + obj, + "engines", + "devEngines", + serde_json::json!({ "packageManager": entry }), + ); + return; + }; + let Some(field) = dev_engines.get_mut("packageManager") else { + dev_engines.insert("packageManager".into(), entry); + return; + }; + match field { + Value::Object(value) + if value.get("name").and_then(Value::as_str) + == Some(package_manager.to_string().as_str()) => + { + value.insert("version".into(), Value::String(version.into())); + } + Value::Object(_) => { + let previous = std::mem::take(field); + *field = Value::Array(vec![entry, previous]); + } + Value::Array(entries) => { + let name = package_manager.to_string(); + let mut entry = entries + .iter() + .position(|value| value.get("name").and_then(Value::as_str) == Some(name.as_str())) + .map(|index| entries.remove(index)) + .unwrap_or(entry); + if let Some(value) = entry.as_object_mut() { + value.insert("version".into(), Value::String(version.into())); + } + entries.insert(0, entry); + } + _ => *field = entry, + } +} + +async fn unpin_package_manager( + cwd: &AbsolutePathBuf, + scope: EnvScope, + target: Option, +) -> Result<(), Error> { + if matches!(target, Some(PinTarget::NodeVersion)) { + return Ok(()); + } + let root = workspace_root(cwd)?.unwrap_or_else(|| cwd.clone()); + let package_json_path = root.join(PACKAGE_JSON_FILE); + let Ok(content) = tokio::fs::read_to_string(&package_json_path).await else { + println!("No package manager pin found in current directory."); + return Ok(()); + }; + let effective = resolve_package_manager_from_package_json(&root)?; + let expected = match scope { + EnvScope::PackageManager(package_manager) => Some(package_manager), + _ => effective.as_ref().map(|resolution| resolution.package_manager_type), + }; + let mut changed = false; + let updated = vp_shared::edit_json_object(&content, |obj| { + let remove_top_level = matches!(target, Some(PinTarget::PackageManager)) + || (target.is_none() && obj.get("packageManager").is_some()); + let top_level_matches = expected.is_none_or(|expected| { + obj.get("packageManager") + .and_then(serde_json::Value::as_str) + .and_then(|value| value.split_once('@')) + .and_then(|(name, _)| PackageManagerType::from_name(name)) + == Some(expected) + }); + if remove_top_level && top_level_matches { + changed = obj.remove("packageManager").is_some(); + } else if let Some(expected) = expected { + changed = remove_dev_engines_package_manager(obj, expected); + } + }) + .map_err(|error| Error::Other(format!("failed to update package.json: {error}").into()))?; + if changed { + tokio::fs::write(&package_json_path, updated).await?; + crate::shim::invalidate_cache(); + output::success("Removed package-manager pin"); + } else { + println!("No package manager pin found in current directory."); + } + Ok(()) +} + +fn workspace_root(cwd: &AbsolutePathBuf) -> Result, Error> { + match vt_workspace::find_workspace_root(cwd) { + Ok((workspace, _)) => Ok(Some(workspace.path.to_absolute_path_buf())), + Err(vt_workspace::Error::PackageJsonNotFound(_)) => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn remove_dev_engines_package_manager( + obj: &mut serde_json::Map, + expected: PackageManagerType, +) -> bool { + let Some(dev_engines) = obj.get_mut("devEngines").and_then(serde_json::Value::as_object_mut) + else { + return false; + }; + let Some(field) = dev_engines.get_mut("packageManager") else { + return false; + }; + let expected = expected.to_string(); + let changed = match field { + serde_json::Value::Object(entry) => { + entry.get("name").and_then(serde_json::Value::as_str) == Some(expected.as_str()) + } + serde_json::Value::Array(entries) => { + let before = entries.len(); + entries.retain(|entry| { + entry.get("name").and_then(serde_json::Value::as_str) != Some(expected.as_str()) + }); + before != entries.len() + } + _ => false, + }; + let remove_field = changed + && match field { + serde_json::Value::Object(_) => true, + serde_json::Value::Array(entries) => entries.is_empty(), + _ => false, + }; + if remove_field { + dev_engines.remove("packageManager"); + } + changed +} + #[cfg(test)] mod tests { use tempfile::TempDir; @@ -598,6 +887,76 @@ mod tests { dir } + #[tokio::test] + async fn package_manager_pin_preserves_matching_integrity_suffix() { + let temp_dir = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write( + cwd.join("package.json"), + "{\n \"packageManager\": \"pnpm@10.18.0+sha512.keep\"\n}\n", + ) + .await + .unwrap(); + + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, None).await.unwrap(); + + let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(content.contains("pnpm@10.18.0+sha512.keep")); + } + + #[tokio::test] + async fn package_manager_pin_drops_stale_integrity_suffix() { + let temp_dir = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write( + cwd.join("package.json"), + "{\n \"packageManager\": \"pnpm@10.17.0+sha512.stale\"\n}\n", + ) + .await + .unwrap(); + + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, None).await.unwrap(); + + let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(content.contains("pnpm@10.18.0")); + assert!(!content.contains("sha512.stale")); + } + + #[test] + fn remove_last_package_manager_array_entry_removes_field() { + let mut manifest = serde_json::json!({ + "devEngines": { + "packageManager": [{ "name": "pnpm", "version": "10.18.0" }], + "runtime": { "name": "node", "version": "22.0.0" } + } + }); + assert!(remove_dev_engines_package_manager( + manifest.as_object_mut().unwrap(), + PackageManagerType::Pnpm, + )); + assert!(manifest["devEngines"].get("packageManager").is_none()); + assert_eq!(manifest["devEngines"]["runtime"]["version"], "22.0.0"); + } + + #[tokio::test] + async fn package_manager_unpin_target_does_not_remove_node_pin() { + let temp_dir = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write(cwd.join(".node-version"), "22.0.0\n").await.unwrap(); + tokio::fs::write( + cwd.join("package.json"), + "{\n \"packageManager\": \"pnpm@10.18.0\"\n}\n", + ) + .await + .unwrap(); + + do_unpin_scope(&cwd, EnvScope::All, Some(PinTarget::PackageManager)).await.unwrap(); + + assert!(cwd.join(".node-version").as_path().exists()); + let manifest = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(!manifest.contains("packageManager")); + } + #[tokio::test] async fn test_show_pinned_no_file() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vp_global_cli/src/commands/env/spec.rs b/crates/vp_global_cli/src/commands/env/spec.rs new file mode 100644 index 0000000000..b64563f294 --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/spec.rs @@ -0,0 +1,143 @@ +use vp_pm_cli::PackageManagerType; + +use crate::error::Error; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EnvScope { + All, + Node, + PackageManagers, + PackageManager(PackageManagerType), +} + +impl EnvScope { + pub(crate) fn parse(value: Option<&str>) -> Result { + let Some(value) = value else { + return Ok(Self::All); + }; + match value { + "node" => Ok(Self::Node), + "pm" => Ok(Self::PackageManagers), + name => PackageManagerType::from_name(name) + .map(Self::PackageManager) + .ok_or_else(|| invalid_scope(name)), + } + } + + pub(crate) fn includes_node(self) -> bool { + matches!(self, Self::All | Self::Node) + } + + pub(crate) fn includes_package_managers(self) -> bool { + !matches!(self, Self::Node) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct EnvSpecs { + pub(crate) node: Option, + pub(crate) package_manager: Option<(PackageManagerType, String)>, +} + +impl EnvSpecs { + pub(crate) fn parse(values: &[String]) -> Result { + let mut parsed = Self::default(); + for value in values { + if let Some((name, version)) = value.split_once('@') { + if version.is_empty() { + return Err(invalid_spec(value)); + } + if name == "node" { + if parsed.node.replace(version.to_string()).is_some() { + return Err(duplicate("Node.js")); + } + } else { + let package_manager = + PackageManagerType::from_name(name).ok_or_else(|| invalid_spec(value))?; + if parsed + .package_manager + .replace((package_manager, version.to_string())) + .is_some() + { + return Err(duplicate("package manager")); + } + } + } else if EnvScope::parse(Some(value)).is_ok() { + return Err(Error::Other( + format!("{value:?} is a component selector, not a version specification") + .into(), + )); + } else if parsed.node.replace(value.clone()).is_some() { + return Err(duplicate("Node.js")); + } + } + Ok(parsed) + } + + pub(crate) fn parse_requests(values: &[String]) -> Result<(EnvScope, Self), Error> { + if values.len() == 1 + && let Ok(scope) = EnvScope::parse(Some(&values[0])) + { + return Ok((scope, Self::default())); + } + let specs = Self::parse(values)?; + let scope = match (&specs.node, &specs.package_manager) { + (Some(_), Some(_)) | (None, None) => EnvScope::All, + (Some(_), None) => EnvScope::Node, + (None, Some((kind, _))) => EnvScope::PackageManager(*kind), + }; + Ok((scope, specs)) + } +} + +pub(crate) fn parse_package_manager_spec( + value: &str, +) -> Result<(PackageManagerType, String), Error> { + let Some((name, version)) = value.split_once('@') else { + return Err(invalid_spec(value)); + }; + let package_manager = PackageManagerType::from_name(name).ok_or_else(|| invalid_spec(value))?; + if version.is_empty() { + return Err(invalid_spec(value)); + } + Ok((package_manager, version.to_string())) +} + +fn invalid_scope(value: &str) -> Error { + Error::Other( + format!("invalid environment scope {value:?}; expected node, pm, npm, pnpm, yarn, or bun") + .into(), + ) +} + +fn invalid_spec(value: &str) -> Error { + Error::Other( + format!( + "invalid environment specification {value:?}; expected a Node.js version or node|npm|pnpm|yarn|bun@" + ) + .into(), + ) +} + +fn duplicate(component: &str) -> Error { + Error::Other(format!("only one {component} specification may be supplied").into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_legacy_node_and_package_manager_specs() { + let parsed = EnvSpecs::parse(&["22.0.0".into(), "pnpm@10.18.0".into()]).unwrap(); + assert_eq!(parsed.node.as_deref(), Some("22.0.0")); + assert_eq!(parsed.package_manager, Some((PackageManagerType::Pnpm, "10.18.0".into()))); + } + + #[test] + fn bare_version_request_selects_node() { + let (scope, specs) = EnvSpecs::parse_requests(&["22.0.0".into()]).unwrap(); + assert_eq!(scope, EnvScope::Node); + assert_eq!(specs.node.as_deref(), Some("22.0.0")); + } +} diff --git a/crates/vp_global_cli/src/commands/env/unpin.rs b/crates/vp_global_cli/src/commands/env/unpin.rs index 234bac5202..44a3b919a5 100644 --- a/crates/vp_global_cli/src/commands/env/unpin.rs +++ b/crates/vp_global_cli/src/commands/env/unpin.rs @@ -8,9 +8,14 @@ use std::process::ExitStatus; use vt_path::AbsolutePathBuf; +use super::spec::EnvScope; use crate::{cli::PinTarget, error::Error}; /// Execute the unpin command. -pub async fn execute(cwd: AbsolutePathBuf, target: Option) -> Result { - super::pin::do_unpin(&cwd, target).await +pub async fn execute( + cwd: AbsolutePathBuf, + scope: Option, + target: Option, +) -> Result { + super::pin::do_unpin_scope(&cwd, EnvScope::parse(scope.as_deref())?, target).await } diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 405d322575..795dab5980 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -1,7 +1,7 @@ //! Implementation of `vp env use` command. //! //! Outputs shell-appropriate commands to stdout that set (or unset) -//! the `VP_NODE_VERSION` environment variable. The shell function +//! the Node.js and package-manager environment variables. The shell function //! wrapper in `/env` evals this output to modify the current //! shell session. //! @@ -10,11 +10,13 @@ use std::process::ExitStatus; +use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; use vt_path::AbsolutePathBuf; use super::{ - config::{self, VERSION_ENV_VAR}, - exit_status, + config::{self, PACKAGE_MANAGER_ENV_VAR, VERSION_ENV_VAR}, + exit_status, package_manager, + spec::{EnvScope, EnvSpecs}, }; use crate::{ commands::shell::{Shell, detect_shell}, @@ -22,29 +24,29 @@ use crate::{ }; /// Format a shell export command for the detected shell. -fn format_export(shell: &Shell, value: &str) -> String { +fn format_export(shell: &Shell, variable: &str, value: &str) -> String { match shell { - Shell::Posix => format!("export {VERSION_ENV_VAR}={value}"), - Shell::Fish => format!("set -gx {VERSION_ENV_VAR} {value}"), - Shell::PowerShell => format!("$env:{VERSION_ENV_VAR} = \"{value}\""), - Shell::Cmd => format!("set {VERSION_ENV_VAR}={value}"), - Shell::NuShell => format!("$env.{VERSION_ENV_VAR} = \"{value}\""), + Shell::Posix => format!("export {variable}={value}"), + Shell::Fish => format!("set -gx {variable} {value}"), + Shell::PowerShell => format!("$env:{variable} = \"{value}\""), + Shell::Cmd => format!("set {variable}={value}"), + Shell::NuShell => format!("$env.{variable} = \"{value}\""), } } /// Format a shell unset command for the detected shell. -fn format_unset(shell: &Shell) -> String { +fn format_unset(shell: &Shell, variable: &str) -> String { match shell { - Shell::Posix => format!("unset {VERSION_ENV_VAR}"), + Shell::Posix => format!("unset {variable}"), // Fish returns a nonzero status when the variable is already absent. // Keep the unset idempotent so wrappers can continue evaluating any // following command, such as the project-file export from `vp env use`. - Shell::Fish => format!("set -e {VERSION_ENV_VAR}; or true"), + Shell::Fish => format!("set -e {variable}; or true"), Shell::PowerShell => { - format!("Remove-Item Env:{VERSION_ENV_VAR} -ErrorAction SilentlyContinue") + format!("Remove-Item Env:{variable} -ErrorAction SilentlyContinue") } - Shell::Cmd => format!("set {VERSION_ENV_VAR}="), - Shell::NuShell => format!("hide-env {VERSION_ENV_VAR}"), + Shell::Cmd => format!("set {variable}="), + Shell::NuShell => format!("hide-env {variable}"), } } @@ -72,82 +74,143 @@ fn print_windows_eval_wrapper_required() { /// Execute the `vp env use` command. pub async fn execute( cwd: AbsolutePathBuf, - version: Option, + requests: Vec, unset: bool, no_install: bool, silent_if_unchanged: bool, ) -> Result { let shell = detect_shell(); + let (scope, specs) = EnvSpecs::parse_requests(&requests)?; + let uses_project_environment = specs.node.is_none() && specs.package_manager.is_none(); // Handle --unset: remove session override. // Always delete the session file: on Windows it lives under VP_HOME and can // leak across shell windows, so even eval mode must clean it up. if unset { - config::delete_session_version().await?; + let unset_package_manager = match scope { + EnvScope::PackageManager(expected) => current_override( + config::read_session_package_manager().await, + vp_shared::EnvConfig::get().package_manager, + ) + .and_then(|value| super::spec::parse_package_manager_spec(&value).ok()) + .is_some_and(|(kind, _)| kind == expected), + _ => scope.includes_package_managers(), + }; + if scope.includes_node() { + config::delete_session_version().await?; + } + if unset_package_manager { + config::delete_session_package_manager().await?; + } if has_eval_wrapper() { - println!("{}", format_unset(&shell)); + if scope.includes_node() { + println!("{}", format_unset(&shell, VERSION_ENV_VAR)); + } + if unset_package_manager { + println!("{}", format_unset(&shell, PACKAGE_MANAGER_ENV_VAR)); + } } else if !can_use_session_file() { print_windows_eval_wrapper_required(); } - eprintln!("Reverted to file-based Node.js version resolution"); + eprintln!("Reverted selected components to project environment resolution"); return Ok(ExitStatus::default()); } let provider = vp_js_runtime::NodeProvider::new(); - - // Resolve version: explicit argument or from project files - // When no argument provided, unset session override and resolve from project files - let (resolved_version, source_desc) = if let Some(ref ver) = version { - let resolved = config::resolve_version_alias(ver, &provider).await?; - (resolved, format!("{ver}")) + let node = if scope.includes_node() { + let (version, source) = if let Some(selector) = specs.node.as_deref() { + (config::resolve_version_alias(selector, &provider).await?, selector.to_string()) + } else { + let resolution = config::resolve_version_from_files(&cwd).await?; + (resolution.version, resolution.source) + }; + Some((version, source)) } else { - // No version argument - unset session override first - config::delete_session_version().await?; - if has_eval_wrapper() { - println!("{}", format_unset(&shell)); - } else if !can_use_session_file() { - eprintln!("Reverted to file-based Node.js version resolution"); - print_windows_eval_wrapper_required(); - return Ok(ExitStatus::default()); + None + }; + + let package_manager = if scope.includes_package_managers() { + let resolved = if let Some((kind, selector)) = specs.package_manager { + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + package_manager::warn_if_target_differs(&cwd, kind).await; + Some((kind, version, selector)) + } else { + if let EnvScope::PackageManager(kind) = scope { + package_manager::warn_if_target_differs(&cwd, kind).await; + } + package_manager::resolve_from_files(&cwd).await?.and_then(|resolution| { + if matches!(scope, EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) { + None + } else { + Some(( + resolution.package_manager_type, + resolution.version.to_string(), + resolution.source.to_string(), + )) + } + }) + }; + if let EnvScope::PackageManager(kind) = scope + && resolved.is_none() + { + let version = resolve_package_manager_version(kind, "latest").await?.to_string(); + Some((kind, version, "latest".into())) + } else { + resolved } - // Now resolve from project files (not from session override) - let resolution = config::resolve_version_from_files(&cwd).await?; - let source = resolution.source.clone(); - (resolution.version, source) + } else { + None }; // Check if already active and suppress output if requested if silent_if_unchanged { - let current_env = - vp_shared::EnvConfig::get().node_version.as_deref().map(|v| v.trim().to_string()); - let current = if !has_eval_wrapper() { - current_env.or(config::read_session_version().await) - } else { - current_env + let node_unchanged = match &node { + Some((version, _)) => { + current_override( + config::read_session_version().await, + vp_shared::EnvConfig::get().node_version, + ) + .as_deref() + == Some(version) + } + None => true, }; - if current.as_deref() == Some(&resolved_version) { - // Already active — idempotent, skip stderr status message - if has_eval_wrapper() { - config::delete_session_version().await?; - println!("{}", format_export(&shell, &resolved_version)); - } else if !can_use_session_file() { - print_windows_eval_wrapper_required(); - return Ok(exit_status(1)); - } else { - config::write_session_version(&resolved_version).await?; + let package_manager_unchanged = match &package_manager { + Some((kind, version, _)) => { + current_override( + config::read_session_package_manager().await, + vp_shared::EnvConfig::get().package_manager, + ) + .as_deref() + == Some(format!("{kind}@{version}").as_str()) } + None => true, + }; + if node_unchanged && package_manager_unchanged { return Ok(ExitStatus::default()); } } + if uses_project_environment && !has_eval_wrapper() && !can_use_session_file() { + if scope.includes_node() { + config::delete_session_version().await?; + } + if scope.includes_package_managers() { + config::delete_session_package_manager().await?; + } + eprintln!("Reverted selected components to project environment resolution"); + print_windows_eval_wrapper_required(); + return Ok(ExitStatus::default()); + } + // Ensure version is installed (unless --no-install) - if !no_install { + if !no_install && let Some((resolved_version, _)) = &node { let home_dir = vp_shared::EnvConfig::get() .dirs .data .join("js_runtime") .join("node") - .join(&resolved_version); + .join(resolved_version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); @@ -156,29 +219,53 @@ pub async fn execute( if !binary_path.as_path().exists() { eprintln!("Installing Node.js v{}...", resolved_version); - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolved_version) + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, resolved_version) .await?; } } + if !no_install && let Some((kind, version, _)) = &package_manager { + download_package_manager(*kind, version, None).await?; + } if has_eval_wrapper() { - config::delete_session_version().await?; - // Output the shell command to stdout (consumed by shell wrapper's eval) - println!("{}", format_export(&shell, &resolved_version)); + if let Some((version, _)) = &node { + config::delete_session_version().await?; + println!("{}", format_export(&shell, VERSION_ENV_VAR, version)); + } + if let Some((kind, version, _)) = &package_manager { + config::delete_session_package_manager().await?; + println!( + "{}", + format_export(&shell, PACKAGE_MANAGER_ENV_VAR, &format!("{kind}@{version}")) + ); + } } else if !can_use_session_file() { print_windows_eval_wrapper_required(); return Ok(exit_status(1)); } else { // No eval wrapper (CI or direct invocation) — write session file so shims can read it - config::write_session_version(&resolved_version).await?; + if let Some((version, _)) = &node { + config::write_session_version(version).await?; + } + if let Some((kind, version, _)) = &package_manager { + config::write_session_package_manager(&format!("{kind}@{version}")).await?; + } } - // Status message to stderr (visible to user) - eprintln!("Using Node.js v{} (resolved from {})", resolved_version, source_desc); + if let Some((version, source)) = node { + eprintln!("Using Node.js v{version} (resolved from {source})"); + } + if let Some((kind, version, source)) = package_manager { + eprintln!("Using {kind} v{version} (resolved from {source})"); + } Ok(ExitStatus::default()) } +fn current_override(session: Option, environment: Option) -> Option { + environment.map(|value| value.trim().to_string()).or(session) +} + #[cfg(test)] mod tests { use vp_shared::env_vars; @@ -240,60 +327,60 @@ mod tests { #[test] fn test_format_export_posix() { - let result = format_export(&Shell::Posix, "20.18.0"); + let result = format_export(&Shell::Posix, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "export VP_NODE_VERSION=20.18.0"); } #[test] fn test_format_export_fish() { - let result = format_export(&Shell::Fish, "20.18.0"); + let result = format_export(&Shell::Fish, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "set -gx VP_NODE_VERSION 20.18.0"); } #[test] fn test_format_export_powershell() { - let result = format_export(&Shell::PowerShell, "20.18.0"); + let result = format_export(&Shell::PowerShell, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "$env:VP_NODE_VERSION = \"20.18.0\""); } #[test] fn test_format_export_cmd() { - let result = format_export(&Shell::Cmd, "20.18.0"); + let result = format_export(&Shell::Cmd, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "set VP_NODE_VERSION=20.18.0"); } #[test] fn test_format_unset_posix() { - let result = format_unset(&Shell::Posix); + let result = format_unset(&Shell::Posix, VERSION_ENV_VAR); assert_eq!(result, "unset VP_NODE_VERSION"); } #[test] fn test_format_unset_fish() { - let result = format_unset(&Shell::Fish); + let result = format_unset(&Shell::Fish, VERSION_ENV_VAR); assert_eq!(result, "set -e VP_NODE_VERSION; or true"); } #[test] fn test_format_unset_powershell() { - let result = format_unset(&Shell::PowerShell); + let result = format_unset(&Shell::PowerShell, VERSION_ENV_VAR); assert_eq!(result, "Remove-Item Env:VP_NODE_VERSION -ErrorAction SilentlyContinue"); } #[test] fn test_format_unset_cmd() { - let result = format_unset(&Shell::Cmd); + let result = format_unset(&Shell::Cmd, VERSION_ENV_VAR); assert_eq!(result, "set VP_NODE_VERSION="); } #[test] fn test_format_export_nushell() { - let result = format_export(&Shell::NuShell, "20.18.0"); + let result = format_export(&Shell::NuShell, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "$env.VP_NODE_VERSION = \"20.18.0\""); } #[test] fn test_format_unset_nushell() { - let result = format_unset(&Shell::NuShell); + let result = format_unset(&Shell::NuShell, VERSION_ENV_VAR); assert_eq!(result, "hide-env VP_NODE_VERSION"); } @@ -309,7 +396,7 @@ mod tests { [(env_vars::VP_HOME, Some(temp_dir.path())), ("CI", None)], |_| async move { let status = - execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + execute(cwd, vec!["20.18.0".into()], false, true, false).await.unwrap(); assert_eq!(status.code(), Some(1)); assert!(config::read_session_version().await.is_none()); @@ -327,7 +414,7 @@ mod tests { [(env_vars::VP_HOME, temp_dir.path().as_os_str()), ("CI", std::ffi::OsStr::new("1"))], |_| async { let status = - execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + execute(cwd, vec!["20.18.0".into()], false, true, false).await.unwrap(); assert!(status.success()); assert_eq!(config::read_session_version().await.as_deref(), Some("20.18.0")); @@ -351,7 +438,7 @@ mod tests { config::write_session_version("22.0.0").await.unwrap(); let status = - execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + execute(cwd, vec!["20.18.0".into()], false, true, false).await.unwrap(); assert!(status.success()); assert!(config::read_session_version().await.is_none()); diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 6b6a3a3091..6c58cf789e 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -12,17 +12,18 @@ use chrono::Local; use owo_colors::OwoColorize; use vp_pm_cli::{ PackageManagerType, package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_from_package_json, + resolve_package_manager_version, }; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{ bin_config::{BinConfig, BinSource}, - config::{VERSION_ENV_VAR, get_bin_dir, get_node_modules_dir, resolve_version}, + config::{ShimMode, VERSION_ENV_VAR, get_bin_dir, get_node_modules_dir, resolve_version}, + package_manager, package_metadata::PackageMetadata, }; -use crate::{cli::exit_status, error::Error}; +use crate::{cli::exit_status, error::Error, shim}; /// Core tools (node, npm, npx) const CORE_TOOLS: &[&str] = &["node", "npm", "npx"]; @@ -32,6 +33,18 @@ const LABEL_WIDTH: usize = 10; /// Execute the which command. pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result { + let config = super::config::load_config().await?; + let mode = if PackageManagerType::from_tool(tool).is_some() { + config.package_manager_shim_mode() + } else { + config.shim_mode + }; + if mode == ShimMode::SystemFirst + && let Some(path) = shim::dispatch::find_system_tool(tool) + { + println!("{}", path.as_path().display()); + return Ok(ExitStatus::default()); + } if let Some(status) = execute_package_manager_tool(&cwd, tool).await? { return Ok(status); } @@ -138,14 +151,23 @@ async fn execute_package_manager_tool( let Some(expected_type) = PackageManagerType::from_tool(tool) else { return Ok(None); }; - let Some(resolution) = resolve_package_manager_from_package_json(cwd)? else { - return Ok(None); + let resolution = package_manager::resolve_current(cwd).await?; + let (version, source) = match &resolution { + Some(resolution) if resolution.package_manager_type == expected_type => ( + resolution.version.to_string(), + resolution.source_path.as_ref().map_or_else( + || resolution.source.to_string(), + |path| path.as_path().display().to_string(), + ), + ), + Some(_) | None if expected_type == PackageManagerType::Npm => return Ok(None), + Some(_) | None => ( + resolve_package_manager_version(expected_type, "latest").await?.to_string(), + "registry fallback".into(), + ), }; - if resolution.package_manager_type != expected_type { - return Ok(None); - } - let Some(install_dir) = package_manager_install_dir(expected_type, &resolution.version) else { + let Some(install_dir) = package_manager_install_dir(expected_type, &version) else { return Ok(None); }; let bin_name = expected_type.bin_name_for_tool(tool); @@ -153,7 +175,7 @@ async fn execute_package_manager_tool( if !tokio::fs::try_exists(&tool_path).await.unwrap_or(false) { output::error(&format!("{} not found", tool.bold())); - eprintln!("{expected_type} {} is not installed.", resolution.version); + eprintln!("{expected_type} {version} is not installed."); eprintln!("Run 'vp install' inside the project to download it."); return Ok(Some(exit_status(1))); } @@ -162,13 +184,9 @@ async fn execute_package_manager_tool( println!( " {: Resu if prepend_to_path_env(&node_bin_prefix, options) { tracing::debug!("Set PATH to include {:?}", node_bin_prefix); } + if let Some(package_manager) = env::package_manager::resolve_current(project_path).await? { + let config = env::config::load_config().await?; + if config.package_manager_shim_mode() == env::config::ShimMode::SystemFirst + && let Some(system_path) = crate::shim::dispatch::find_system_tool( + &package_manager.package_manager_type.to_string(), + ) + && let Some(bin_dir) = system_path.parent() + { + if prepend_to_path_env(bin_dir, PrependOptions { dedupe_anywhere: true }) { + tracing::debug!("Set PATH to include system package manager {:?}", bin_dir); + } + return Ok(()); + } + let (install_dir, _, _) = vp_pm_cli::download_package_manager( + package_manager.package_manager_type, + &package_manager.version, + package_manager.hash.as_deref(), + ) + .await?; + let bin_dir = install_dir.join("bin"); + if prepend_to_path_env(&bin_dir, PrependOptions { dedupe_anywhere: true }) { + tracing::debug!("Set PATH to include {:?}", bin_dir); + } + } Ok(()) } diff --git a/crates/vp_global_cli/src/help.rs b/crates/vp_global_cli/src/help.rs index 6445cc204b..c0442f098a 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_global_cli/src/help.rs @@ -266,7 +266,7 @@ pub fn top_level_help_doc() -> HelpDoc { "install, i", "Install all dependencies, or add packages if package names are provided", ), - row("env", "Manage Node.js versions"), + row("env", "Manage Node.js and package managers"), ], ), section_rows( @@ -330,34 +330,28 @@ pub fn top_level_help_doc() -> HelpDoc { fn env_help_doc() -> HelpDoc { HelpDoc { usage: "vp env [COMMAND]".into(), - summary: vec!["Manage Node.js versions".into()], + summary: vec!["Manage Node.js and package-manager environments".into()], sections: vec![ section_rows( "Setup", vec![ row("setup", "Create or update shims in VP_HOME/bin"), - row("on", "Enable managed mode - shims always use vite-plus managed Node.js"), - row( - "off", - "Enable system-first mode - shims prefer system Node.js, fallback to managed", - ), - row("print", "Print shell snippet to set environment for current session"), + row("on", "Enable managed mode for Node.js and package managers"), + row("off", "Enable system-first mode for Node.js and package managers"), + row("print", "Print PATH setup for the resolved environment"), ], ), section_rows( "Manage", vec![ - row("default", "Set or show the global default Node.js version"), - row("pin", "Pin a Node.js version in the current directory"), - row( - "unpin", - "Remove the Node.js pin from the current directory (alias for `pin --unpin`)", - ), - row("use", "Use a specific Node.js version for this shell session"), - row("install, i", "Install a Node.js version"), - row("uninstall, uni", "Uninstall a Node.js version"), - row("clean", "Remove unused managed runtimes and package manager caches"), - row("exec, run", "Execute a command with a specific Node.js version"), + row("default", "Set or show global environment defaults"), + row("pin", "Pin Node.js and package-manager versions in the project"), + row("unpin", "Remove project environment pins (alias for `pin --unpin`)"), + row("use", "Activate an environment for this shell session"), + row("install, i", "Install a resolved or explicit environment"), + row("uninstall, uni", "Uninstall explicit component versions"), + row("clean", "Remove unused runtimes and package managers"), + row("exec, run", "Execute a command in a resolved or explicit environment"), ], ), section_rows( @@ -366,10 +360,10 @@ fn env_help_doc() -> HelpDoc { row("current", "Show current environment information"), row("doctor", "Run diagnostics and show environment status"), row("which", "Show path to the tool that would be executed"), - row("list, ls", "List locally installed Node.js versions"), + row("list, ls", "List locally installed environment components"), row( "list-remote, ls-remote", - "List available Node.js versions from the registry", + "List available versions from component registries", ), ], ), @@ -378,26 +372,29 @@ fn env_help_doc() -> HelpDoc { vec![ " Setup:", " vp env setup # Create Node.js and package-manager shims", - " vp env on # Use vite-plus managed Node.js", - " vp env print # Print shell snippet for this session", + " vp env on # Manage Node.js and package managers", + " vp env off pm # Prefer system package managers only", + " vp env print # Print PATH setup for both components", "", " Manage:", - " vp env pin lts # Pin to latest LTS version", - " vp env install # Install version from .node-version / package.json / .nvmrc", - " vp env use 20 # Use Node.js 20 for this shell session", - " vp env use --unset # Remove session override", - " vp env clean # Remove unused managed caches", + " vp env default 22.19.0 # Set the Node.js default", + " vp env default pnpm@12 # Set the package-manager default", + " vp env pin 22.19.0 # Pin Node.js for this project", + " vp env use 22.19.0 # Use Node.js in this shell", + " vp env clean # Clean all unused managed versions", "", " Inspect:", " vp env current # Show current resolved environment", " vp env current --json # JSON output for automation", " vp env doctor # Check environment configuration", " vp env which node # Show which node binary will be used", - " vp env list-remote --lts # List only LTS versions", + " vp env list node # List only Node.js installations", + " vp env list-remote --lts # List only Node.js LTS versions", "", " Execute:", - " vp env exec --node lts npm i # Execute 'npm i' with latest LTS", - " vp env exec node -v # Shim mode (version auto-resolved)", + " vp env exec --node lts node -v # Override Node.js", + " vp env exec --package-manager pnpm@12 pnpm i # Override the package manager", + " vp env exec node -v # Resolve both components", ], ), section_lines( diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 807e64af0d..22e8d4b8ed 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -5,9 +5,7 @@ //! 2. Node.js installation (if needed) //! 3. Tool execution (core shims and package binaries) -use vp_pm_cli::{ - PackageManagerType, ensure_package_manager_bin, resolve_package_manager_from_package_json, -}; +use vp_pm_cli::{PackageManagerType, ensure_package_manager_bin}; use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; @@ -20,6 +18,7 @@ use crate::{ env::{ bin_config::{BinConfig, BinSource}, config::{self, ShimMode}, + package_manager, package_metadata::PackageMetadata, }, global::install::is_protected_shim, @@ -660,7 +659,8 @@ async fn resolve_package_manager_tool( return Ok(None); }; - let (version, hash) = match resolve_package_manager_from_package_json(cwd)? { + let resolution = package_manager::resolve_current(cwd).await?; + let (version, hash) = match resolution { Some(resolution) if resolution.package_manager_type == expected_type => { (resolution.version, resolution.hash) } @@ -737,11 +737,17 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } // Check shim mode from config - let shim_mode = load_shim_mode().await; + let shim_mode = load_shim_mode(tool).await; if shim_mode == ShimMode::SystemFirst { tracing::debug!("system-first mode enabled"); // In system-first mode, try to find system tool first if let Some(system_path) = find_system_tool(tool) { + if PackageManagerType::from_tool(tool).is_some() + && let Err(error) = prepare_node_path_for_system_package_manager().await + { + eprintln!("vp: Failed to prepare Node.js for system package manager: {error}"); + return 1; + } // Append current bin_dir to VP_BYPASS to prevent infinite loops // when multiple vite-plus installations exist in PATH. // The next installation will filter all accumulated paths. @@ -899,6 +905,26 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { exec::exec_tool(&tool_path, args) } +async fn prepare_node_path_for_system_package_manager() -> Result<(), Error> { + let config = config::load_config().await?; + if config.shim_mode == ShimMode::SystemFirst + && let Some(node) = find_system_tool("node") + && let Some(bin_dir) = node.parent() + { + let _ = prepend_to_path_env(bin_dir, PrependOptions::default()); + return Ok(()); + } + + let cwd = current_dir()?; + let resolution = resolve_with_cache(&cwd).await.map_err(|error| Error::Other(error.into()))?; + let node = + ensure_installed(&resolution.version).await.map_err(|error| Error::Other(error.into()))?; + let bin_dir = + node.parent().ok_or_else(|| Error::Other("Node.js has no bin directory".into()))?; + let _ = prepend_to_path_env(bin_dir, PrependOptions::default()); + Ok(()) +} + /// Dispatch a package binary shim. /// /// Finds the package that provides this binary and executes it with the @@ -1232,8 +1258,17 @@ pub(crate) fn locate_tool(version: &str, tool: &str) -> Result ShimMode { - config::load_config().await.map(|c| c.shim_mode).unwrap_or_default() +async fn load_shim_mode(tool: &str) -> ShimMode { + config::load_config() + .await + .map(|config| { + if PackageManagerType::from_tool(tool).is_some() { + config.package_manager_shim_mode() + } else { + config.shim_mode + } + }) + .unwrap_or_default() } /// Find a system tool in PATH, skipping the vite-plus bin directory and any diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index 3e1413df6a..175fc6b34f 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -10,8 +10,12 @@ use vt_path::AbsolutePath; use crate::{ PackageManager, cli::{PackageManagerCommand, PmCommand}, + download_package_manager, error::Error, - helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, + helpers::{ + build_package_manager, build_package_manager_or_npm_default, ensure_package_json, + require_package_json, + }, resolution::{DlxArgs, StageCommand, run_resolution}, }; @@ -28,6 +32,12 @@ enum ManagerPolicy { AllowNpmFallback, } +enum ManagerSource<'a> { + Detect, + Selected((PackageManagerType, &'a str, Option<&'a str>)), + Resolved(PackageManager), +} + pub async fn dispatch( cwd: &AbsolutePath, command: PackageManagerCommand, @@ -38,24 +48,61 @@ pub async fn dispatch( pub async fn dispatch_with_metadata( cwd: &AbsolutePath, command: PackageManagerCommand, +) -> Result { + dispatch_with_manager(cwd, command, ManagerSource::Detect).await +} + +pub async fn dispatch_with_package_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + package_manager: (PackageManagerType, &str, Option<&str>), +) -> Result { + dispatch_with_manager(cwd, command, ManagerSource::Selected(package_manager)).await +} + +pub async fn dispatch_with_resolved_package_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + manager: PackageManager, +) -> Result { + dispatch_with_manager(cwd, command, ManagerSource::Resolved(manager)).await +} + +async fn dispatch_with_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + source: ManagerSource<'_>, ) -> Result { let render_diagnostics = command.should_render_diagnostics(); let command = match command { PackageManagerCommand::Dlx(args) => { - return dispatch_dlx(cwd, args, render_diagnostics).await; + let manager = match source { + ManagerSource::Detect => return dispatch_dlx(cwd, args, render_diagnostics).await, + source => resolve_manager(source).await?, + }; + let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; + let status = run_resolution(cwd, resolution, render_diagnostics).await?; + return Ok(DispatchResult { status, package_manager: manager.client }); } command => command, }; - let manager = match manager_policy(&command) { - ManagerPolicy::CreateIfMissing => { - ensure_package_json(cwd).await?; - build_package_manager(cwd).await? - } - ManagerPolicy::RequireProject => build_package_manager(cwd).await?, - ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, - }; + let policy = manager_policy(&command); + match policy { + ManagerPolicy::CreateIfMissing => ensure_package_json(cwd).await?, + ManagerPolicy::RequireProject => require_package_json(cwd)?, + ManagerPolicy::AllowNpmFallback => {} + } + let manager = match source { + ManagerSource::Detect => match policy { + ManagerPolicy::CreateIfMissing | ManagerPolicy::RequireProject => { + build_package_manager(cwd).await? + } + ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, + }, + source => resolve_manager(source).await?, + }; let package_manager = manager.client; let why_hint_packages = command.why_hint_packages(package_manager).map(<[String]>::to_vec); let resolution = command.resolve_for_manager(&manager)?; @@ -63,6 +110,24 @@ pub async fn dispatch_with_metadata( Ok(DispatchResult { status, why_hint_packages }) } +async fn resolve_manager(source: ManagerSource<'_>) -> Result { + match source { + ManagerSource::Selected(package_manager) => { + build_selected_package_manager(package_manager).await + } + ManagerSource::Resolved(manager) => Ok(manager), + ManagerSource::Detect => unreachable!("detected managers are resolved from the cwd"), + } +} + +async fn build_selected_package_manager( + (kind, version, hash): (PackageManagerType, &str, Option<&str>), +) -> Result { + let (install_dir, _, version) = + download_package_manager(kind, version, hash).await.map_err(Error::Install)?; + Ok(PackageManager { client: kind, version, install_dir }) +} + async fn dispatch_dlx( cwd: &AbsolutePath, args: DlxArgs, diff --git a/crates/vp_pm_cli/src/helpers.rs b/crates/vp_pm_cli/src/helpers.rs index ae47fb238d..a76a5814b7 100644 --- a/crates/vp_pm_cli/src/helpers.rs +++ b/crates/vp_pm_cli/src/helpers.rs @@ -16,6 +16,17 @@ pub async fn build_package_manager(cwd: &AbsolutePath) -> Result Result<(), Error> { + match vt_workspace::find_workspace_root(cwd) { + Ok(_) => Ok(()), + Err(vt_workspace::Error::PackageJsonNotFound(_)) => { + Err(Error::UserMessage("No package.json found.".into())) + } + Err(error) => Err(Error::Install(error.into())), + } +} + /// Build a `PackageManager`, falling back to a default npm instance when no /// package.json is found. Uses `build()` instead of `build_with_default()` /// to skip the interactive package manager selection prompt on the fallback path. diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index 9105a7e8ea..fe0ec60df7 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -19,12 +19,17 @@ mod shim; pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; pub use config::npm_registry; -pub use dispatch::{DispatchResult, dispatch, dispatch_with_metadata}; +pub use dispatch::{ + DispatchResult, dispatch, dispatch_with_metadata, dispatch_with_package_manager, + dispatch_with_resolved_package_manager, +}; pub use error::Error; pub use package_manager::{ - PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, - PackageManagerType, download_package_manager, ensure_package_manager_bin, + EnvironmentPackageManagerResolution, PackageManager, PackageManagerBuilder, + PackageManagerResolution, PackageManagerSource, PackageManagerType, download_package_manager, + ensure_package_manager_bin, fetch_package_manager_versions, get_package_manager_type_and_version, package_manager_bin_path, package_manager_install_dir, + resolve_environment_package_manager, resolve_environment_package_manager_spec, resolve_package_manager_from_package_json, resolve_package_manager_version, }; pub use request::HttpClient; diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index a6ca89d9f2..1fea99e882 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -120,6 +120,16 @@ impl PackageManagerType { pub fn hashes_cli_binary_of(self, version: &Version) -> bool { matches!(self, Self::Yarn) && is_yarn_berry(version) } + + #[must_use] + pub const fn bin_names(self) -> &'static [&'static str] { + match self { + Self::Npm => &["npm", "npx"], + Self::Pnpm => &["pnpm", "pnpx"], + Self::Yarn => &["yarn", "yarnpkg"], + Self::Bun => &["bun", "bunx"], + } + } } /// Name of the file that records the pin vp verified when it installed a @@ -154,6 +164,16 @@ pub struct PackageManagerResolution { pub project_root: AbsolutePathBuf, } +#[derive(Debug, Clone)] +pub struct EnvironmentPackageManagerResolution { + pub package_manager_type: PackageManagerType, + pub version: Str, + pub hash: Option, + pub source: Str, + pub source_path: Option, + pub project_root: Option, +} + /// Where the package manager selection came from (see rfcs/dev-engines.md). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PackageManagerSource { @@ -167,6 +187,18 @@ pub enum PackageManagerSource { Default, } +impl PackageManagerSource { + #[must_use] + pub const fn description(self) -> &'static str { + match self { + Self::PackageManagerField => "packageManager", + Self::DevEnginesPackageManager => "devEngines.packageManager", + Self::LockfileOrConfig => "lockfile or config", + Self::Default => "default", + } + } +} + /// The package manager. /// Use `PackageManager::builder()` to create a package manager. /// Command argument resolution and execution live in `vp_pm_cli`. @@ -246,6 +278,15 @@ impl PackageManager { PackageManagerBuilder::new(cwd) } + #[must_use] + pub fn from_install_dir( + client: PackageManagerType, + version: impl Into, + install_dir: AbsolutePathBuf, + ) -> Self { + Self { client, version: version.into(), install_dir } + } + #[must_use] pub fn get_bin_prefix(&self) -> AbsolutePathBuf { self.install_dir.join("bin") @@ -406,6 +447,108 @@ pub fn resolve_package_manager_from_package_json( })) } +/// Read the package manager selected by an explicit/session override, project files, or default. +/// +/// The returned version is the declared requirement. It is intentionally not resolved against the +/// registry or managed installs, so callers can inspect the selection without network access. +pub fn resolve_environment_package_manager_spec( + cwd: impl AsRef, + override_spec: Option<(PackageManagerType, &str)>, + default_spec: Option<(PackageManagerType, &str)>, +) -> Result, Error> { + if let Some((package_manager_type, version)) = override_spec { + return Ok(Some(EnvironmentPackageManagerResolution { + package_manager_type, + version: version.into(), + hash: None, + source: "session".into(), + source_path: None, + project_root: None, + })); + } + + let (workspace_root, _) = match find_workspace_root(cwd.as_ref()) { + Ok(result) => result, + Err(vt_workspace::Error::PackageJsonNotFound(_)) => { + return Ok(default_spec.map(environment_package_manager_default)); + } + Err(error) => return Err(error.into()), + }; + + if let Some(project) = get_package_manager_from_package_json(&workspace_root)? { + return Ok(Some(EnvironmentPackageManagerResolution { + package_manager_type: project.package_manager_type, + version: project.version, + hash: project.hash, + source: project.source, + source_path: Some(project.source_path), + project_root: Some(project.project_root), + })); + } + + if let Some((package_manager_type, version_req)) = + get_package_manager_from_dev_engines(&workspace_root)? + { + let version_req = version_req.unwrap_or_else(|| "*".into()); + return Ok(Some(EnvironmentPackageManagerResolution { + package_manager_type, + version: version_req, + hash: None, + source: "devEngines.packageManager".into(), + source_path: Some(workspace_root.path.join("package.json").to_absolute_path_buf()), + project_root: Some(workspace_root.path.to_absolute_path_buf()), + })); + } + + match get_package_manager_type_and_version(&workspace_root, None) { + Ok((package_manager_type, version_req, hash, source)) => { + Ok(Some(EnvironmentPackageManagerResolution { + package_manager_type, + version: version_req, + hash, + source: source.description().into(), + source_path: None, + project_root: Some(workspace_root.path.to_absolute_path_buf()), + })) + } + Err(Error::UnrecognizedPackageManager) => { + Ok(default_spec.map(environment_package_manager_default)) + } + Err(error) => Err(error), + } +} + +fn environment_package_manager_default( + (package_manager_type, version): (PackageManagerType, &str), +) -> EnvironmentPackageManagerResolution { + EnvironmentPackageManagerResolution { + package_manager_type, + version: version.into(), + hash: None, + source: "default".into(), + source_path: None, + project_root: None, + } +} + +/// Resolve an environment package-manager requirement to an exact version for managed-runtime +/// operations such as `vp env install` and package-manager shims. +pub async fn resolve_environment_package_manager( + cwd: impl AsRef, + override_spec: Option<(PackageManagerType, &str)>, + default_spec: Option<(PackageManagerType, &str)>, +) -> Result, Error> { + let Some(mut resolution) = + resolve_environment_package_manager_spec(cwd, override_spec, default_spec)? + else { + return Ok(None); + }; + resolution.version = + resolve_package_manager_version(resolution.package_manager_type, &resolution.version) + .await?; + Ok(Some(resolution)) +} + /// Return the managed install directory for a package manager version. #[must_use] pub fn package_manager_install_dir( @@ -773,6 +916,20 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result< } } +/// Resolve an exact, range, or `latest` package-manager version without downloading it. +pub async fn resolve_package_manager_version( + package_manager_type: PackageManagerType, + version: &str, +) -> Result { + if version == "latest" { + get_latest_version(package_manager_type).await + } else if Version::parse(version).is_ok() { + Ok(version.into()) + } else { + resolve_package_manager_range(package_manager_type, version).await + } +} + /// Abbreviated registry metadata: only the version list is needed. #[derive(Deserialize)] struct RegistryPackument { @@ -798,6 +955,19 @@ async fn fetch_registry_versions(package_name: &str) -> Result Result, Error> { + let mut versions = fetch_registry_versions(&package_manager_type.to_string()).await?; + if matches!(package_manager_type, PackageManagerType::Yarn) { + versions.extend(fetch_registry_versions("@yarnpkg/cli-dist").await?); + } + versions.sort(); + versions.dedup(); + Ok(versions) +} + /// Whether a version requirement explicitly asks for prereleases. /// /// A prerelease marker attaches the hyphen directly to a version @@ -920,20 +1090,6 @@ async fn resolve_package_manager_range( resolve_latest_satisfying_version(package_manager_type, &range, version_req).await } -/// Resolve an exact, range, or floating `latest` package-manager version. -pub async fn resolve_package_manager_version( - package_manager_type: PackageManagerType, - version: &str, -) -> Result { - if version == "latest" { - get_latest_version(package_manager_type).await - } else if Version::parse(version).is_ok() { - Ok(version.into()) - } else { - resolve_package_manager_range(package_manager_type, version).await - } -} - /// Download the package manager and extract it to the vite-plus home directory. /// Return the install directory, e.g. `/package_manager/pnpm/10.0.0/pnpm` pub async fn download_package_manager( @@ -2021,6 +2177,63 @@ mod tests { assert_eq!(PackageManagerType::from_tool("tsc"), None); } + #[tokio::test] + async fn environment_resolution_prefers_override_to_manifest() { + let temp_dir = create_temp_dir(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + create_package_json(&cwd, r#"{"packageManager":"pnpm@10.18.0"}"#); + + let resolution = resolve_environment_package_manager( + &cwd, + Some((PackageManagerType::Yarn, "1.22.22")), + Some((PackageManagerType::Bun, "1.2.0")), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Yarn); + assert_eq!(resolution.version, "1.22.22"); + assert_eq!(resolution.source, "session"); + } + + #[tokio::test] + async fn environment_resolution_uses_default_without_project_selection() { + let temp_dir = create_temp_dir(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + create_package_json(&cwd, r#"{"name":"example"}"#); + + let resolution = resolve_environment_package_manager( + &cwd, + None, + Some((PackageManagerType::Bun, "1.2.0")), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Bun); + assert_eq!(resolution.version, "1.2.0"); + assert_eq!(resolution.source, "default"); + } + + #[test] + fn environment_spec_keeps_declared_version_range() { + let temp_dir = create_temp_dir(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + create_package_json( + &cwd, + r#"{"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}}"#, + ); + + let resolution = + resolve_environment_package_manager_spec(&cwd, None, None).unwrap().unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Pnpm); + assert_eq!(resolution.version, "^10.0.0"); + assert_eq!(resolution.source, "devEngines.packageManager"); + } + /// How fully a fake package manager install is written. enum InstallState { /// No shim files at all (`bin/` exists but is empty). diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index b9f460e66e..af2f23d434 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -157,6 +157,11 @@ pub struct EnvConfig { /// Env: `VP_NODE_VERSION` pub node_version: Option, + /// Override package manager and version. + /// + /// Env: `VP_PACKAGE_MANAGER` + pub package_manager: Option, + /// User home directory. /// /// Resolved once from `HOME` or `USERPROFILE` in platform order. See @@ -202,6 +207,7 @@ impl EnvConfig { is_ci: std::env::var("CI").is_ok(), env_use_eval_enable: std::env::var(env_vars::VP_ENV_USE_EVAL_ENABLE).is_ok(), node_version: std::env::var(env_vars::VP_NODE_VERSION).ok(), + package_manager: std::env::var(env_vars::VP_PACKAGE_MANAGER).ok(), user_home, vp_shell: std::env::var(env_vars::VP_SHELL).ok(), }) diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index ae8398eac1..5446d6b954 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -85,6 +85,9 @@ pub const VP_NODE_SKIP_SIGNATURE_VERIFY: &str = "VP_NODE_SKIP_SIGNATURE_VERIFY"; /// Override Node.js version (takes highest priority in version resolution). pub const VP_NODE_VERSION: &str = "VP_NODE_VERSION"; +/// Override package manager and version (for example, `pnpm@10.18.0`). +pub const VP_PACKAGE_MANAGER: &str = "VP_PACKAGE_MANAGER"; + /// Enable debug output for shim dispatch. pub const VP_DEBUG_SHIM: &str = "VP_DEBUG_SHIM"; diff --git a/docs/guide/env.md b/docs/guide/env.md index c7f29aa5f0..a5cd02ca24 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -1,10 +1,20 @@ # Environment -`vp env` manages Node.js versions globally and per project. +`vp env` manages the complete JavaScript environment: one Node.js runtime and one selected package manager. npm, pnpm, Yarn, and Bun are peer package-manager families. ## Overview -Managed mode is on by default, so `node`, `npm`, and related shims resolve through Vite+ and pick the right Node.js version for the current project. +Managed mode is on by default, so Node.js and package-manager shims resolve through Vite+ and pick the right versions for the current project. + +Most commands operate on both components when no selector is given. Add `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the command. `pm` means all four families for listing and cleanup, but the single selected package manager for project operations. + +Unqualified versions remain Node.js versions for compatibility: + +```bash +vp env pin 22.0.0 # Node.js only +vp env pin pnpm@10.18.0 # pnpm only +vp env pin 22.0.0 pnpm@10.18.0 # Both components +``` Vite+ checks the current directory first, then walks up through its parents. The nearest directory with a supported declaration wins. Within each directory, sources are checked in this order: @@ -19,7 +29,17 @@ latest LTS. `devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. `vp env doctor` warns when declared sources conflict. -When a project declares `packageManager` (or `devEngines.packageManager`) in `package.json`, matching package-manager shims also use that package-manager version. For example, `packageManager: "npm@10.9.4"` makes both `npm` and `npx` run through npm 10.9.4. Alias pairs follow the installed package-manager shims: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a package-manager declaration, invoking `pnpm`, `yarn`, or `bun` uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. Vite+ does not translate mismatched commands, so a project pinned to `pnpm` still lets `npm` fall back to the npm that comes with the resolved Node.js runtime. +Package-manager selection uses this priority: + +1. Explicit command override +2. `VP_PACKAGE_MANAGER` or the shell-session override +3. Top-level `packageManager` +4. `devEngines.packageManager` +5. Lockfile or manager-specific configuration +6. The global package-manager default +7. The named shim's latest release + +A selected manager controls only its named shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Alias pairs are `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a package-manager selection, invoking `pnpm`, `yarn`, or `bun` uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. npm instead falls back to the version bundled with the resolved Node.js runtime. A fresh install uses the split platform layout by default. On Unix, Vite+ stores managed runtimes and related files in `~/.local/share/vite-plus`. It @@ -34,7 +54,12 @@ If you want to keep that behavior, run: vp env on ``` -This enables managed mode, where the shims always use the Vite+-managed Node.js installation. +This enables managed mode for both components. Their modes can also be changed independently: + +```bash +vp env on node +vp env off pm +``` If you do not want Vite+ to manage Node.js first, run: @@ -42,16 +67,15 @@ If you do not want Vite+ to manage Node.js first, run: vp env off ``` -This switches to system-first mode, where the shims prefer your system Node.js and only fall back to the Vite+-managed runtime when needed. +This switches both components to system-first mode. Vite+ prefers system tools and falls back to managed installations. Mixed configurations compose: a system package-manager launcher receives the Node.js selected by the Node mode. ## Commands ### Setup - `vp env setup` creates or updates the `node`, `npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`, `bun`, `bunx`, `vpx`, and `vpr` shims in the resolved bin directory. It writes shell setup scripts in the config directory. -- `vp env on` enables managed mode so shims always use Vite+-managed Node.js -- `vp env off` enables system-first mode so shims prefer system Node.js first -- `vp env print` prints the shell snippet for the current session +- `vp env on` / `vp env off` changes both modes; append `node` or `pm` to change one +- `vp env print` prints PATH setup for both components; append a selector to print one PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: @@ -87,28 +111,28 @@ vp-use --unset Only `vp env use` needs this alternate command. Other `vp env` commands work normally in Command Prompt. `vp env setup` creates `vp-use.cmd` in the bin directory on Windows. In CI, `vp env use` can run without shell initialization. It writes a temporary -session file in the resolved state directory. Later shim calls in the same job -use this file to select the Node.js version. +Node.js or package-manager session file in the resolved state directory. Later +shim calls in the same job use these files to resolve the same environment. ### Manage -- `vp env default` sets or shows the global default Node.js version -- `vp env pin` pins a Node.js version in the current directory: an existing `.node-version` keeps being updated; otherwise the pin is written to `package.json#devEngines.runtime`; `.node-version` is only created when the directory has no `package.json`. Use `--target node-version` or `--target dev-engines` to choose explicitly. An existing `engines.node` is never modified. -- `vp env unpin` removes the pin from the same source `vp env pin` would write -- `vp env use` sets a Node.js version for the current shell session -- `vp env install` installs a Node.js version -- `vp env uninstall` removes an installed Node.js version -- `vp env clean` removes unused managed Node.js runtimes and all downloaded package managers. -- `vp env exec` runs a command with a specific Node.js version -- `vp node` runs a Node.js script — shorthand for `vp env exec node` +- `vp env default` shows both global defaults. Bare versions set Node.js; qualified specs such as `pnpm@10.18.0` set the PM fallback. `--unset` clears both unless scoped. +- `vp env pin` shows or writes project pins. Existing `.node-version` and top-level `packageManager` fields keep being updated for compatibility; otherwise Vite+ writes the matching `devEngines` entry. Use `--target node-version`, `--target dev-engines`, or `--target package-manager` to choose explicitly. +- `vp env unpin` removes both effective pins by default; append a selector to remove one. Lower-priority declarations are not deleted. +- `vp env use` activates the complete project environment. Explicit specs override selected components; `--unset` clears both unless scoped. +- `vp env install` installs the complete resolved environment, a selected component, or explicit specs. +- `vp env uninstall` removes explicit exact Node.js or qualified package-manager versions. +- `vp env clean` removes unused installs. Use `clean node`, `clean pm`, or a concrete manager. Current and configured-default versions are preserved. +- `vp env exec` runs a command in the resolved environment. Use `--node` and `--package-manager`; `--npm` is an alias for `--package-manager npm@…`. +- `vp node` uses the resolved Node.js runtime and exposes the selected package-manager path to child processes. ### Inspect - `vp env current` shows the current resolved environment - `vp env doctor` runs environment diagnostics - `vp env which` shows which tool path will be used -- `vp env list` shows locally installed Node.js versions -- `vp env list-remote` shows available Node.js versions from the registry +- `vp env list` shows separate Node.js, npm, pnpm, Yarn, and Bun sections; selectors narrow output +- `vp env list-remote` fetches Node.js and all four PM registries concurrently; selectors narrow network work. `--lts` implicitly selects Node.js. ## Project Setup @@ -121,31 +145,82 @@ use this file to select the Node.js version. ```bash # Setup vp env setup # Create Node.js and package-manager shims -vp env on # Use Vite+ managed Node.js -vp env print # Print shell snippet for this session +vp env on # Manage Node.js and package managers +vp env off pm # Prefer system package managers only +vp env print # Print PATH setup for both components # Manage -vp env pin lts # Pin the project to the latest LTS release -vp env install # Install the version from .node-version, package.json, or .nvmrc -vp env default lts # Set the global default version -vp env use 20 # Use Node.js 20 for the current shell session -vp env use --unset # Remove the session override -vp env clean # Remove unused managed caches +vp env pin lts pnpm@10 # Pin both project components to exact versions +vp env install # Install the complete resolved environment +vp env default lts # Set the global Node.js default +vp env default pnpm@10 # Set the global package-manager fallback +vp env use 20 pnpm@10 # Override both components for this shell +vp env use --unset pm # Remove only the PM session override +vp env clean yarn # Remove unused managed Yarn versions # Inspect vp env current # Show current resolved environment vp env current --json # JSON output for automation vp env which node # Show which node binary will be used vp env which npx # Show pinned package-manager alias when packageManager matches -vp env list-remote --lts # List only LTS versions +vp env list # Show every locally installed component +vp env list node # Show only Node.js installations +vp env list-remote --lts # List only Node.js LTS versions # Execute -vp env exec --node lts npm i # Execute npm with latest LTS +vp env exec --node lts --package-manager pnpm@10 pnpm install vp env exec node -v # Use shim mode with automatic version resolution vp node script.js # Shorthand: run a Node.js script with the resolved version vp node -e "console.log(1+1)" # Shorthand: forward any node flag or argument ``` +## JSON output + +This release intentionally changes the JSON contracts for `current`, `list`, and `list-remote`. `current --json` returns peer component objects: + +```json +{ + "node": { + "version": "22.0.0", + "source": "devEngines.runtime", + "source_path": "/project/package.json", + "project_root": "/project", + "bin_path": "/home/.vite-plus/js_runtime/node/22.0.0/bin/node", + "installed": true, + "mode": "managed" + }, + "package_manager": { + "name": "pnpm", + "version": "10.18.0", + "source": "packageManager", + "source_path": "/project/package.json", + "project_root": "/project", + "bin_paths": { + "pnpm": "/home/.vite-plus/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", + "pnpx": "/home/.vite-plus/package_manager/pnpm/10.18.0/pnpm/bin/pnpx" + }, + "installed": true, + "mode": "managed" + } +} +``` + +`list --json` and `list-remote --json` group the component arrays: + +```json +{ + "node": [], + "package_managers": { + "npm": [], + "pnpm": [], + "yarn": [], + "bun": [] + } +} +``` + +Selectors omit unselected top-level fields or PM families. Registry listing is all-or-error: Vite+ prints no partial human or JSON result when any selected registry request fails. + ## Custom Node.js Mirror By default, Vite+ downloads Node.js from `https://nodejs.org/dist`. If you're behind a corporate proxy or need to use an internal mirror (e.g., Artifactory), set the `VP_NODE_DIST_MIRROR` environment variable: diff --git a/docs/guide/index.md b/docs/guide/index.md index 7cd00d7905..10f8bacec9 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -96,7 +96,7 @@ Vite+ can handle the entire local frontend development cycle from starting a pro - [`vp hooks`](/guide/commit-hooks) manages the Git hook dispatcher (`enable`, `disable`, `status`). - [`vp staged`](/guide/commit-hooks) runs checks on staged files. - [`vp install`](/guide/install) installs dependencies with the right package manager. -- [`vp env`](/guide/env) manages Node.js versions. +- [`vp env`](/guide/env) manages Node.js and package-manager environments. ### Develop diff --git a/docs/guide/installer-env-vars.md b/docs/guide/installer-env-vars.md index 253ba3b658..802af64636 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -127,6 +127,16 @@ These variables configure the installed Vite+ CLI. `VP_HOME` (above) also applie VP_NODE_VERSION=22 vp env exec node -v ``` +### `VP_PACKAGE_MANAGER` + +- **Purpose**: Override the selected package manager and version +- **Default**: None (resolved from the project or global default) +- **Format**: `npm|pnpm|yarn|bun@` +- **Example**: + ```bash + VP_PACKAGE_MANAGER=pnpm@10.18.0 vp install + ``` + ### `VP_NODE_SKIP_SIGNATURE_VERIFY` - **Purpose**: Skip PGP signature verification of Node.js downloads diff --git a/packages/cli/README.md b/packages/cli/README.md index 1c436fcb12..c4c12a6942 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9,7 +9,7 @@ This package provides the project-local version of Vite+. The global `vp` comman Vite+ is the unified entry point for local web development. It combines [Vite](https://vite.dev/), [Vitest](https://vitest.dev/), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Rolldown](https://rolldown.rs/), [tsdown](https://tsdown.dev/), and [Vite Task](https://github.com/voidzero-dev/vite-task) into one zero-config toolchain that also manages runtime and package manager workflows: -- **`vp env`:** Manage Node.js globally and per project +- **`vp env`:** Manage Node.js and package managers globally and per project - **`vp install`:** Install dependencies with automatic package manager detection - **`vp dev`:** Run Vite's fast native ESM dev server with instant HMR - **`vp check`:** Run formatting, linting, and type checks in one command @@ -99,7 +99,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **hooks** - Manage the Git hook dispatcher - **staged** - Run linters on staged files - **install** (`i`) - Install dependencies -- **env** - Manage Node.js versions +- **env** - Manage Node.js and package managers #### Develop diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index dfee1e7fd4..650793e3df 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -1201,14 +1201,14 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" Write-Host "" Write-Host " ${BOLD}Get started:${NC}" Write-Host " ${BRIGHT_BLUE}vp create${NC} Create a new project" - Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" Write-Host " ${BRIGHT_BLUE}vp install${NC} Install dependencies" Write-Host " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" # Show Node.js manager status if ($nodeManagerResult -eq "true" -or $nodeManagerResult -eq "already") { Write-Host "" - Write-Host " Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}." + Write-Host " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." Write-Host " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." } diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 7c5f1e29d9..7704e2a3dc 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -1436,13 +1436,13 @@ WRAPPER_EOF echo "" echo -e " ${BOLD}Get started:${NC}" echo -e " ${BRIGHT_BLUE}vp create${NC} Create a new project" - echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" echo -e " ${BRIGHT_BLUE}vp install${NC} Install dependencies" echo -e " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" if [ "$NODE_MANAGER_ENABLED" = "true" ] || [ "$NODE_MANAGER_ENABLED" = "already" ]; then echo "" - echo -e " Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}." + echo -e " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." echo -e " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." fi diff --git a/rfcs/dev-engines.md b/rfcs/dev-engines.md index a2a8f85508..08a75f03b7 100644 --- a/rfcs/dev-engines.md +++ b/rfcs/dev-engines.md @@ -373,7 +373,7 @@ A small shared Rust helper (in `vp_shared`) will own "edit one field in package. - Managing non-Node runtimes (`deno`, `bun` as a runtime) via `devEngines.runtime`. - Validating `devEngines.os` / `cpu` / `libc`. - Acting as a general enforcement layer for arbitrary package manager names beyond pnpm / yarn / npm / bun. -- Changing session-override behavior (`vp env use`, `VP_NODE_VERSION`). +- Node session override behavior remains compatible; unified environments additionally support `VP_PACKAGE_MANAGER` and `.session-package-manager`. ## Deferred / Future Work diff --git a/rfcs/env-command.md b/rfcs/env-command.md index d45af48db9..be88e98ec0 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -1,8 +1,26 @@ -# RFC: `vp env` - Shim-Based Node Version Management +# RFC: `vp env` - Unified JavaScript Environment Management ## Summary -This RFC proposes adding a `vp env` command that provides system-wide, IDE-safe Node.js version management through a shim-based architecture. The shims intercept `node`, `npm`, and `npx` commands, automatically resolving and executing the correct Node.js version based on project configuration. +This RFC defines system-wide, IDE-safe Node.js and package-manager management through a shim-based architecture. The environment contains one Node.js runtime and one selected package manager; npm, pnpm, Yarn, and Bun remain independently callable families. + +## Breaking revision: unified environments + +The original Node.js-only command model was extended as a breaking change. Bare component-wide commands now operate on Node.js and package managers together, while unqualified version arguments remain Node.js for compatibility: + +```bash +vp env pin 22.0.0 # Node.js only (legacy-compatible) +vp env pin pnpm@10.18.0 # Package manager only +vp env pin 22.0.0 pnpm@10.18.0 # Both +``` + +Selectors are `node`, `pm`, `npm`, `pnpm`, `yarn`, and `bun`. `pm` selects every family for listing and cleanup, but the single project-selected manager for `current`, `pin`, `unpin`, `use`, and execution. + +Node and package-manager modes persist independently. Existing `shimMode` remains the Node mode; optional `packageManagerShimMode` inherits it when absent. A Node-only mode change materializes the inherited PM value before changing `shimMode`, so it cannot accidentally change PM behavior. + +Package-manager resolution priority is explicit override, `VP_PACKAGE_MANAGER` or `.session-package-manager`, top-level `packageManager`, `devEngines.packageManager`, lockfile/config detection, `defaultPackageManager`, then the existing fallback. The resolver is non-mutating and shared by env inspection, shims, `vp install`, `use`, and `exec`. + +The JSON contracts for `current`, `list`, and `list-remote` are intentionally breaking. `current` exposes `node` and `package_manager` objects. Local and remote lists expose `node` plus a `package_managers` object keyed by family. Scoped calls omit unselected fields, and multi-registry remote listing emits no partial output on failure. ## Motivation From 01e90c94161be0e035692647fa62cca67f0f401e Mon Sep 17 00:00:00 2001 From: Liang Date: Wed, 12 Aug 2026 18:48:17 +0800 Subject: [PATCH 02/37] fix(env): restore node lifecycle compatibility --- .../command_env_exec_shim_mode/snapshots.toml | 2 +- .../snapshots/command_env_exec_shim_mode.md | 10 +++- .../command_env_install_no_arg/snapshots.toml | 3 ++ .../snapshots/command_env_install_no_arg.md | 26 +++++++++ .../snapshots.toml | 2 +- .../command_env_install_no_arg_fail.md | 9 ++-- crates/vp_global_cli/src/commands/env/exec.rs | 9 +++- .../src/commands/env/lifecycle.rs | 54 ++++++++++++++----- 8 files changed, 93 insertions(+), 22 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml index e76dd34273..bd23501846 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml @@ -5,5 +5,5 @@ steps = [ { argv = ["vp", "env", "exec", "node", "-v"], comment = "Shim mode: version resolved from package.json engines.node", continue-on-failure = true }, { argv = ["vp", "env", "exec", "npm", "-v"], comment = "Shim mode: npm uses same version", continue-on-failure = true }, { argv = ["vp", "env", "exec", "node", "-e", "console.log('Hello from shim mode')"], comment = "Shim mode: run inline script", continue-on-failure = true }, - { argv = ["vp", "env", "exec", "nonexistent-tool", "--version"], comment = "automatic mode resolves the environment before reporting a missing command", continue-on-failure = true }, + { argv = ["vp", "env", "exec", "nonexistent-tool", "--version"], comment = "expected error: non-shim command requires --node", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md index 9e2002b1c7..e27e71449d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md @@ -26,10 +26,16 @@ Hello from shim mode ## `vp env exec nonexistent-tool --version` -automatic mode resolves the environment before reporting a missing command +expected error: non-shim command requires --node **Exit code:** 1 ``` -error: Command execution failed: program not found +vp env exec: --node is required when running non-shim commands +Usage: vp env exec --node [args...] + +For shim tools, --node is optional (version resolved automatically): + vp env exec node script.js # Core tool + vp env exec npm install # Core tool + vp env exec tsc --version # Global package ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml index bdfa290e49..33e5a13008 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml @@ -5,4 +5,7 @@ skip-platforms = ["windows"] seed-runtime = false steps = [ { argv = ["vp", "env", "install"], comment = "Install version from .node-version (22.x)", continue-on-failure = true }, + { argv = ["vp", "env", "use", "22", "--no-install"], snapshot = false }, + { argv = ["vp", "env", "install"], comment = "An implicit install identifies an active session override", continue-on-failure = true }, + { argv = ["vp", "env", "uninstall", "22"], comment = "Node.js uninstall accepts the same major-version selectors as install", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_no_arg.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_no_arg.md index 371e8c018c..d109808977 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_no_arg.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_no_arg.md @@ -10,3 +10,29 @@ VITE+ - The Unified Toolchain for the Web Installing Node.js ... Installed Node.js ``` + +## `vp env use 22 --no-install` + + +## `vp env install` + +An implicit install identifies an active session override + +``` +VITE+ - The Unified Toolchain for the Web + +Installing Node.js ... +Installed Node.js +Note: Installed from session override. +Run `vp env use --unset` to revert to project version resolution. +``` + +## `vp env uninstall 22` + +Node.js uninstall accepts the same major-version selectors as install + +``` +VITE+ - The Unified Toolchain for the Web + +Uninstalled Node.js +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml index 195415b26c..7cbe64e00f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml @@ -4,5 +4,5 @@ vp = "global" skip-platforms = ["windows"] seed-runtime = false steps = [ - { argv = ["vp", "env", "install"], comment = "No project declaration - install the resolved fallback environment", continue-on-failure = true }, + { argv = ["vp", "env", "install"], comment = "No version config - should error", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md index 09e3b01c3b..8a5a801a4f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md @@ -2,11 +2,14 @@ ## `vp env install` -No project declaration - install the resolved fallback environment +No version config - should error + +**Exit code:** 1 ``` VITE+ - The Unified Toolchain for the Web -Installing Node.js ... -Installed Node.js +No Node.js version found in current project. +Specify a version: vp env install +Or pin one: vp env pin ``` diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index 6100cded13..b081a8c927 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -85,7 +85,14 @@ pub async fn execute( return Ok(exit_status(exit_code)); } - execute_with_version(cwd, None, None, &command).await + eprintln!("vp env exec: --node is required when running non-shim commands"); + eprintln!("Usage: vp env exec --node [args...]"); + eprintln!(); + eprintln!("For shim tools, --node is optional (version resolved automatically):"); + eprintln!(" vp env exec node script.js # Core tool"); + eprintln!(" vp env exec npm install # Core tool"); + eprintln!(" vp env exec tsc --version # Global package"); + Ok(exit_status(1)) } /// Normalize arguments when invoked via Windows shim wrappers. diff --git a/crates/vp_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs index ce12c41589..0139eb77e0 100644 --- a/crates/vp_global_cli/src/commands/env/lifecycle.rs +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -7,7 +7,19 @@ use super::{ config, package_manager, spec::{EnvScope, EnvSpecs}, }; -use crate::error::Error; +use crate::{cli::exit_status, error::Error}; + +fn is_installable_node_source(source: &str) -> bool { + matches!( + source, + ".node-version" + | ".nvmrc" + | "engines.node" + | "devEngines.runtime" + | config::VERSION_ENV_VAR + | config::SESSION_VERSION_FILE + ) +} pub(crate) async fn install( cwd: AbsolutePathBuf, @@ -16,16 +28,33 @@ pub(crate) async fn install( let (scope, specs) = EnvSpecs::parse_requests(&requests)?; if scope.includes_node() { - let version = match specs.node { + let (version, from_session_override) = match specs.node { Some(version) => { let provider = vp_js_runtime::NodeProvider::new(); - config::resolve_version_alias(&version, &provider).await? + (config::resolve_version_alias(&version, &provider).await?, false) + } + None => { + let resolution = config::resolve_version(&cwd).await?; + if !is_installable_node_source(&resolution.source) { + eprintln!("No Node.js version found in current project."); + eprintln!("Specify a version: vp env install "); + eprintln!("Or pin one: vp env pin "); + return Ok(exit_status(1)); + } + let from_session_override = matches!( + resolution.source.as_str(), + config::VERSION_ENV_VAR | config::SESSION_VERSION_FILE + ); + (resolution.version, from_session_override) } - None => config::resolve_version(&cwd).await?.version, }; println!("Installing Node.js v{version}..."); vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &version).await?; println!("Installed Node.js v{version}"); + if from_session_override { + eprintln!("Note: Installed from session override."); + eprintln!("Run `vp env use --unset` to revert to project version resolution."); + } } if scope.includes_package_managers() { @@ -56,16 +85,6 @@ pub(crate) async fn install( pub(crate) async fn uninstall(specs: Vec) -> Result { let specs = EnvSpecs::parse(&specs)?; - let node = specs - .node - .map(|version| { - if vp_js_runtime::NodeProvider::is_exact_version(&version) { - Ok(version.strip_prefix('v').unwrap_or(&version).to_string()) - } else { - Err(Error::Other("uninstall requires exact Node.js versions".into())) - } - }) - .transpose()?; let package_manager = specs .package_manager .map(|(kind, version)| { @@ -76,6 +95,13 @@ pub(crate) async fn uninstall(specs: Vec) -> Result { }) }) .transpose()?; + let node = match specs.node { + Some(version) => { + let provider = vp_js_runtime::NodeProvider::new(); + Some(config::resolve_version_alias(&version, &provider).await?) + } + None => None, + }; let home = vp_shared::EnvConfig::get().dirs.data.clone(); let mut targets = Vec::new(); From 39facbff5ac50af715c5317941c90387177b2bb5 Mon Sep 17 00:00:00 2001 From: Liang Date: Wed, 12 Aug 2026 18:48:27 +0800 Subject: [PATCH 03/37] fix(env): respect doctor component scope --- .../snapshots.toml | 2 + .../command_env_doctor_dev_engines.md | 19 +++++ .../vp_global_cli/src/commands/env/doctor.rs | 79 +++++++++++++++---- 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots.toml index 003f042bac..e8dc3ab0c6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots.toml @@ -4,4 +4,6 @@ vp = "global" skip-platforms = ["windows"] steps = [ { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','doctor'],{encoding:'utf8'}).replace(/\\u001b\\[[0-9;]*m/g,'');const lines=text.split('\\n');const start=lines.findIndex(l=>l.trim()==='devEngines');if(start===-1){console.error('devEngines section not found in doctor output');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()===''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\\n'));"], comment = "print only the deterministic devEngines section of vp env doctor (the other sections are environment-dependent)", continue-on-failure = true }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','doctor','node'],{encoding:'utf8'}).replace(/\\u001b\\[[0-9;]*m/g,'');const lines=text.split('\\n');const start=lines.findIndex(l=>l.trim()==='devEngines');if(start===-1){console.error('devEngines section not found in doctor output');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()===''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\\n'));"], comment = "node-scoped doctor output excludes package-manager findings", continue-on-failure = true }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','doctor','pm'],{encoding:'utf8'}).replace(/\\u001b\\[[0-9;]*m/g,'');const lines=text.split('\\n');const start=lines.findIndex(l=>l.trim()==='devEngines');if(start===-1){console.error('devEngines section not found in doctor output');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()===''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\\n'));"], comment = "package-manager-scoped doctor output excludes runtime findings", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots/command_env_doctor_dev_engines.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots/command_env_doctor_dev_engines.md index ccbfd823cd..600f83ee10 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots/command_env_doctor_dev_engines.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots/command_env_doctor_dev_engines.md @@ -10,3 +10,22 @@ devEngines ⚠ PackageManager packageManager is "npm@10.5.0" but devEngines.packageManager requires "pnpm" note: This will become an error in a future release. ``` + +## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const text=execFileSync('\''vp'\'',['\''env'\'','\''doctor'\'','\''node'\''],{encoding:'\''utf8'\''}).replace(/\u001b\[[0-9;]*m/g,'\'''\'');const lines=text.split('\''\n'\'');const start=lines.findIndex(l=>l.trim()==='\''devEngines'\'');if(start===-1){console.error('\''devEngines section not found in doctor output'\'');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()==='\'''\''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\''\n'\''));'` + +node-scoped doctor output excludes package-manager findings + +``` +devEngines + ⚠ Runtime .node-version (20.18.0) does not satisfy devEngines.runtime "^24.0.0" +``` + +## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const text=execFileSync('\''vp'\'',['\''env'\'','\''doctor'\'','\''pm'\''],{encoding:'\''utf8'\''}).replace(/\u001b\[[0-9;]*m/g,'\'''\'');const lines=text.split('\''\n'\'');const start=lines.findIndex(l=>l.trim()==='\''devEngines'\'');if(start===-1){console.error('\''devEngines section not found in doctor output'\'');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()==='\'''\''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\''\n'\''));'` + +package-manager-scoped doctor output excludes runtime findings + +``` +devEngines + ⚠ PackageManager packageManager is "npm@10.5.0" but devEngines.packageManager requires "pnpm" + note: This will become an error in a future release. +``` diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 6e1e840d54..18c2b47508 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -119,7 +119,7 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result Option< /// All checks are semver-aware: an exact version satisfying a declared range is /// not a conflict. Findings are warnings or notes; they never fail the doctor run /// and are never auto-fixed. -async fn check_dev_engines(cwd: &AbsolutePathBuf, resolution: Option<&config::VersionResolution>) { - let findings = collect_dev_engines_findings(cwd, resolution).await; +async fn check_dev_engines( + cwd: &AbsolutePathBuf, + resolution: Option<&config::VersionResolution>, + scope: EnvScope, +) { + let findings = collect_dev_engines_findings(cwd, resolution, scope).await; if findings.is_empty() { return; } @@ -802,9 +806,12 @@ async fn nvmrc_conflict_finding( async fn collect_dev_engines_findings( cwd: &AbsolutePathBuf, resolution: Option<&config::VersionResolution>, + scope: EnvScope, ) -> Vec { + let check_node = scope.includes_node(); + let check_package_managers = scope.includes_package_managers(); let mut findings = Vec::new(); - if let Some(finding) = nvmrc_conflict_finding(resolution).await { + if check_node && let Some(finding) = nvmrc_conflict_finding(resolution).await { findings.push(finding); } @@ -823,22 +830,29 @@ async fn collect_dev_engines_findings( // monorepo it can be a different (higher) file than the nearest package.json // used by the Node.js runtime checks above. let nearest_pkg_path = pkg_dir.join("package.json"); - let root_doc = read_workspace_root_doc(cwd, &nearest_pkg_path).await; + let root_doc = if check_package_managers { + read_workspace_root_doc(cwd, &nearest_pkg_path).await + } else { + None + }; let (pm_raw, pm_pkg): (&serde_json::Value, &vp_shared::PackageJson) = match &root_doc { Some((root_raw, root_pkg)) => (root_raw, root_pkg), None => (&raw, &pkg), }; - let runtime_field = pkg.dev_engines.as_ref().and_then(|de| de.runtime.as_ref()); - let package_manager_field = - pm_pkg.dev_engines.as_ref().and_then(|de| de.package_manager.as_ref()); + let runtime_field = + check_node.then(|| pkg.dev_engines.as_ref().and_then(|de| de.runtime.as_ref())).flatten(); + let package_manager_field = check_package_managers + .then(|| pm_pkg.dev_engines.as_ref().and_then(|de| de.package_manager.as_ref())) + .flatten(); // .node-version vs devEngines.runtime (semver-aware: only exact .node-version // values can conflict with a declared range). Both sides follow the resolution // walk: the check fires only when a .node-version actually wins resolution, and // the devEngines.runtime declaration may live in an ancestor manifest rather // than the nearest package.json. - if let Ok(Some(resolution)) = vp_js_runtime::resolve_node_version(cwd, true).await + if check_node + && let Ok(Some(resolution)) = vp_js_runtime::resolve_node_version(cwd, true).await && resolution.source == vp_js_runtime::VersionSource::NodeVersionFile && let Ok(version) = node_semver::Version::parse(&resolution.version) && let Some(declared) = find_nearest_dev_engines_node_version(cwd).await @@ -855,7 +869,8 @@ async fn collect_dev_engines_findings( } // Resolved Node.js version vs engines.node - if let Some(resolution) = resolution + if check_node + && let Some(resolution) = resolution && let Some(engines_node) = pkg.engines.as_ref().and_then(|e| e.node.as_ref()) && let Ok(version) = node_semver::Version::parse(&resolution.version) && let Ok(range) = node_semver::Range::parse(engines_node.as_str()) @@ -971,12 +986,15 @@ async fn collect_dev_engines_findings( // Malformed entries that lenient parsing skipped (raw JSON inspection): // runtime entries come from the nearest package.json, packageManager entries // from the workspace root package.json - if let Some(raw_dev_engines) = raw.get("devEngines").and_then(serde_json::Value::as_object) + if check_node + && let Some(raw_dev_engines) = raw.get("devEngines").and_then(serde_json::Value::as_object) && let Some(value) = raw_dev_engines.get("runtime") { collect_malformed_entry_findings("runtime", value, &mut findings); } - if let Some(raw_dev_engines) = pm_raw.get("devEngines").and_then(serde_json::Value::as_object) + if check_package_managers + && let Some(raw_dev_engines) = + pm_raw.get("devEngines").and_then(serde_json::Value::as_object) && let Some(value) = raw_dev_engines.get("packageManager") { collect_malformed_entry_findings("packageManager", value, &mut findings); @@ -1090,6 +1108,14 @@ mod tests { async fn dev_engines_findings_for( files: &[(&str, &str)], resolved: Option<(&str, &str)>, + ) -> Vec { + dev_engines_findings_for_scope(files, resolved, EnvScope::All).await + } + + async fn dev_engines_findings_for_scope( + files: &[(&str, &str)], + resolved: Option<(&str, &str)>, + scope: EnvScope, ) -> Vec { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -1103,7 +1129,28 @@ mod tests { project_root: Some(temp_path.clone()), is_range: false, }); - collect_dev_engines_findings(&temp_path, resolution.as_ref()).await + collect_dev_engines_findings(&temp_path, resolution.as_ref(), scope).await + } + + #[tokio::test] + async fn test_dev_engines_findings_respect_scope() { + let package_json = r#"{ + "packageManager": "npm@10.5.0", + "devEngines": { + "runtime": {"name": "node", "version": "^24.0.0"}, + "packageManager": {"name": "pnpm", "version": "^11.0.0"} + } + }"#; + let files = [(".node-version", "20.18.0\n"), ("package.json", package_json)]; + + let node = dev_engines_findings_for_scope(&files, None, EnvScope::Node).await; + assert_eq!(node.len(), 1, "findings: {:?}", messages(&node)); + assert_eq!(node[0].key, "Runtime"); + + let package_managers = + dev_engines_findings_for_scope(&files, None, EnvScope::PackageManagers).await; + assert_eq!(package_managers.len(), 1, "findings: {:?}", messages(&package_managers)); + assert_eq!(package_managers[0].key, "PackageManager"); } // npm-install-checks: "semver version is not in range" (via .node-version) @@ -1410,7 +1457,7 @@ mod tests { tokio::fs::write(app_dir.join("package.json"), r#"{"name": "app"}"#).await.unwrap(); tokio::fs::write(app_dir.join(".node-version"), "20.18.0\n").await.unwrap(); - let findings = collect_dev_engines_findings(&app_dir, None).await; + let findings = collect_dev_engines_findings(&app_dir, None, EnvScope::All).await; assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); assert!(findings[0].warn); assert!( @@ -1437,7 +1484,7 @@ mod tests { .await .unwrap(); - let findings = collect_dev_engines_findings(&app_dir, None).await; + let findings = collect_dev_engines_findings(&app_dir, None, EnvScope::All).await; assert!(findings.is_empty(), "findings: {:?}", messages(&findings)); } @@ -1469,7 +1516,7 @@ mod tests { // running from the nested package still diagnoses the workspace root's // packageManager vs devEngines.packageManager conflict - let findings = collect_dev_engines_findings(&app_dir, None).await; + let findings = collect_dev_engines_findings(&app_dir, None, EnvScope::All).await; assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); assert!(findings[0].warn); assert_eq!(findings[0].key, "PackageManager"); From 2f9c2a97cf279108d098b06ea08199707baf73ae Mon Sep 17 00:00:00 2001 From: Liang Date: Wed, 12 Aug 2026 18:48:36 +0800 Subject: [PATCH 04/37] fix(env): restore informative node output --- .../command_env_list_remote/snapshots.toml | 5 ++ .../snapshots/command_env_list_remote.md | 61 +++++++++++++++- .../tests/cli_snapshots/redact.rs | 11 +-- crates/vp_cli_snapshots/tests/redact_unit.rs | 4 ++ .../vp_global_cli/src/commands/env/default.rs | 70 +++++++++++++++---- .../src/commands/env/list_remote.rs | 65 +++++++++++++++-- 6 files changed, 190 insertions(+), 26 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml index b3a1f51d3e..ecb6673238 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml @@ -4,7 +4,12 @@ vp = "global" skip-platforms = ["windows"] seed-runtime = false steps = [ + { argv = ["vp", "env", "default", "node"], comment = "An unconfigured Node.js default explains the effective fallback without claiming it was set in config", continue-on-failure = true }, { argv = ["vp", "env", "install", "lts"], comment = "Install an LTS Node.js version locally", continue-on-failure = true }, { argv = ["vp", "env", "default", "lts"], comment = "Set it as the global default (stored as the `lts` alias)", continue-on-failure = true }, + { argv = ["vp", "env", "default", "node"], comment = "A configured Node.js alias shows its current resolution and config source", continue-on-failure = true }, + { argv = ["vp", "env", "default", "pnpm@10.18.0"], comment = "Package-manager default updates identify the selected package and version", continue-on-failure = true }, { argv = ["node", "-e", "const {execFileSync}=require('node:child_process'); const {node}=JSON.parse(execFileSync('vp',['env','list-remote','--lts','--json'],{encoding:'utf8'})); console.log('installed marked:', node.some(v=>v.installed)); console.log('current marked:', node.some(v=>v.current)); console.log('default marked:', node.some(v=>v.default));"], comment = "the unified JSON node entries resolve installed/current/default flags, including the `lts` default alias", continue-on-failure = true }, + { argv = ["vp", "env", "list-remote", "node", "22.11.0"], comment = "Human-readable Node.js results keep the v prefix and LTS codename", continue-on-failure = true }, + { argv = ["vp", "env", "list-remote", "node", "999"], comment = "An empty Node.js result includes actionable feedback", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md index 593ef4bd1a..334fce1d57 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md @@ -1,5 +1,16 @@ # command_env_list_remote +## `vp env default node` + +An unconfigured Node.js default explains the effective fallback without claiming it was set in config + +``` +VITE+ - The Unified Toolchain for the Web + +No default Node.js version configured. Using latest LTS (). + Run 'vp env default ' to set a default. +``` + ## `vp env install lts` Install an LTS Node.js version locally @@ -18,7 +29,29 @@ Set it as the global default (stored as the `lts` alias) ``` VITE+ - The Unified Toolchain for the Web -✓ Environment defaults updated. +✓ Default Node.js version set to lts (currently ) +``` + +## `vp env default node` + +A configured Node.js alias shows its current resolution and config source + +``` +VITE+ - The Unified Toolchain for the Web + +Default Node.js version: lts + Currently resolves to: + Set via: /.vite-plus/config.json +``` + +## `vp env default pnpm@10.18.0` + +Package-manager default updates identify the selected package and version + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Default package manager set to pnpm@10.18.0 ``` ## `node -e 'const {execFileSync}=require('\''node:child_process'\''); const {node}=JSON.parse(execFileSync('\''vp'\'',['\''env'\'','\''list-remote'\'','\''--lts'\'','\''--json'\''],{encoding:'\''utf8'\''})); console.log('\''installed marked:'\'', node.some(v=>v.installed)); console.log('\''current marked:'\'', node.some(v=>v.current)); console.log('\''default marked:'\'', node.some(v=>v.default));'` @@ -30,3 +63,29 @@ installed marked: true current marked: true default marked: true ``` + +## `vp env list-remote node 22.11.0` + +Human-readable Node.js results keep the v prefix and LTS codename + +``` +VITE+ - The Unified Toolchain for the Web + +Node.js + (Jod) + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` + +## `vp env list-remote node 999` + +An empty Node.js result includes actionable feedback + +``` +VITE+ - The Unified Toolchain for the Web + +Node.js + No versions were found! + +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/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index f43ab06c1f..1301e2dc33 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -154,13 +154,14 @@ static MANAGED_TEST_VERSION_RE: LazyLock = LazyLock::new(|| { }); // Environment-management output prints the resolving runtime as a labelled // `Node:` field, an installed-package table column, or the current `lts` -// target. The npm shim also records the node it ran under into a BinConfig's -// `"nodeVersion"` value. All track the environment's managed default (not a -// fixture pin), so they churn with runtime upgrades; mask by context so -// fixture-pinned versions elsewhere stay assertable. +// target. Default inspection also prints the current target in its fallback +// and alias-resolution messages. The npm shim records the node it ran under +// into a BinConfig's `"nodeVersion"` value. All track the environment's managed +// default (not a fixture pin), so they churn with runtime upgrades; mask by +// context so fixture-pinned versions elsewhere stay assertable. static WHICH_NODE_VERSION_RE: LazyLock = LazyLock::new(|| { regex::Regex::new( - r#"(?m)(Node:\s+|"nodeVersion":\s*"|Default Node\.js version set to [^()\n]+ \(currently |^\S+@\S+\s{2,})\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?"#, + r#"(?m)(Node:\s+|"nodeVersion":\s*"|Default Node\.js version set to [^()\n]+ \(currently |No default Node\.js version configured\. Using latest LTS \(|Currently resolves to:\s+|^\S+@\S+\s{2,})\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?"#, ) .unwrap() }); diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index d072050c74..5c264e6756 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -115,6 +115,8 @@ fn masks_managed_node_versions_in_environment_output() { "Node: 24.18.1\n", "\"nodeVersion\": \"24.18.1\"\n", "✓ Default Node.js version set to lts (currently 24.18.1)\n", + "No default Node.js version configured. Using latest LTS (24.18.1).\n", + " Currently resolves to: 24.18.1\n", "just-a-normal-package@0.0.0 24.18.1 just-a-normal-package\n", "fixture pin: 22.18.0\n", ) @@ -125,6 +127,8 @@ fn masks_managed_node_versions_in_environment_output() { "Node: \n", "\"nodeVersion\": \"\"\n", "✓ Default Node.js version set to lts (currently )\n", + "No default Node.js version configured. Using latest LTS ().\n", + " Currently resolves to: \n", "just-a-normal-package@0.0.0 just-a-normal-package\n", "fixture pin: 22.18.0\n", ) diff --git a/crates/vp_global_cli/src/commands/env/default.rs b/crates/vp_global_cli/src/commands/env/default.rs index 38be85f675..0e85186a03 100644 --- a/crates/vp_global_cli/src/commands/env/default.rs +++ b/crates/vp_global_cli/src/commands/env/default.rs @@ -49,8 +49,11 @@ pub async fn execute(values: Vec, unset: bool) -> Result, unset: bool) -> Result Result { let config = load_config().await?; - let config_path = get_config_path()?; + let mut has_configured_default = false; if scope.includes_node() { - match config.default_node_version { - Some(version) => println!("Default Node.js version: {version}"), - None => println!("Default Node.js version: latest LTS"), + match config.default_node_version.as_deref() { + Some(version) => { + has_configured_default = true; + println!("Default Node.js version: {version}"); + if matches!(version, "lts" | "latest") { + let provider = vp_js_runtime::NodeProvider::new(); + if let Ok(resolved) = + super::config::resolve_version_alias(version, &provider).await + { + println!(" Currently resolves to: {resolved}"); + } + } + } + None => { + let provider = vp_js_runtime::NodeProvider::new(); + match provider.resolve_latest_version().await { + Ok(version) => { + println!( + "No default Node.js version configured. Using latest LTS ({version})." + ); + } + Err(_) => println!("No default Node.js version configured."), + } + println!(" Run 'vp env default ' to set a default."); + } } } if scope.includes_package_managers() { - let configured = config.default_package_manager.filter(|spec| match scope { + let configured = config.default_package_manager.as_deref().filter(|spec| match scope { EnvScope::PackageManager(expected) => super::spec::parse_package_manager_spec(spec) .is_ok_and(|(kind, _)| kind == expected), _ => true, }); match configured { - Some(spec) => println!("Default package manager: {spec}"), + Some(spec) => { + has_configured_default = true; + println!("Default package manager: {spec}"); + } None => match scope { EnvScope::PackageManager(kind) => { println!("Default {kind} version: not configured") @@ -91,14 +123,26 @@ async fn show_default(scope: EnvScope) -> Result { }, } } - println!(" Set via: {}", config_path.as_path().display()); + if has_configured_default { + println!(" Set via: {}", get_config_path()?.as_path().display()); + } Ok(ExitStatus::default()) } -async fn resolve_node_default(version: &str) -> Result { +async fn resolve_node_default(version: &str) -> Result<(String, String), Error> { let provider = vp_js_runtime::NodeProvider::new(); match version.to_lowercase().as_str() { - "lts" | "latest" => Ok(version.to_lowercase()), - _ => super::config::resolve_version_alias(version, &provider).await, + "lts" => { + let current = provider.resolve_latest_version().await?; + Ok(("lts".into(), format!("lts (currently {current})"))) + } + "latest" => { + let current = provider.resolve_absolute_latest_version().await?; + Ok(("latest".into(), format!("latest (currently {current})"))) + } + _ => { + let resolved = super::config::resolve_version_alias(version, &provider).await?; + Ok((resolved.clone(), resolved)) + } } } diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index efccfe2431..e7bbce7843 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -1,6 +1,7 @@ use std::{collections::BTreeMap, process::ExitStatus}; use futures::future::try_join_all; +use owo_colors::OwoColorize; use serde::Serialize; use vp_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry}; use vp_pm_cli::{fetch_package_manager_versions, resolve_package_manager_version}; @@ -162,13 +163,7 @@ pub async fn execute( } if let Some(node) = node { println!("Node.js"); - for entry in node { - println!( - " {}{}", - entry.version, - marker(entry.installed, entry.current, entry.default) - ); - } + print_node_versions(&node); } if let Some(mut package_managers) = package_managers { for kind in package_manager::selected(scope) { @@ -188,6 +183,47 @@ pub async fn execute( Ok(ExitStatus::default()) } +fn print_node_versions(versions: &[NodeVersionJson]) { + if versions.is_empty() { + eprintln!(" {}", "No versions were found!".red()); + return; + } + + let colorize = vp_shared::is_stdout_terminal() && std::env::var_os("NO_COLOR").is_none(); + for entry in versions { + println!(" {}", format_node_version(entry, colorize)); + } +} + +fn format_node_version(entry: &NodeVersionJson, colorize: bool) -> String { + let display = format!("v{}", entry.version); + let lts = entry.lts.as_ref().map(|name| format!(" ({name})")).unwrap_or_default(); + let mut labels = Vec::new(); + if entry.current { + labels.push("current"); + } + if entry.default { + labels.push("default"); + } + let labels = if labels.is_empty() { String::new() } else { format!(" {}", labels.join(" ")) }; + + if colorize { + let display = if entry.current { + display.bright_blue().to_string() + } else if entry.installed { + display.green().to_string() + } else { + display + }; + let lts = if lts.is_empty() { lts } else { lts.bright_blue().to_string() }; + let labels = if labels.is_empty() { labels } else { labels.dimmed().to_string() }; + format!("{display}{lts}{labels}") + } else { + let marker = if entry.installed { "* " } else { " " }; + format!("{marker}{display}{lts}{labels}") + } +} + fn parse_scope_and_pattern(values: &[String]) -> Result<(EnvScope, Option), Error> { match values { [] => Ok((EnvScope::All, None)), @@ -340,6 +376,21 @@ fn marker(installed: bool, current: bool, default: bool) -> String { mod tests { use super::*; + #[test] + fn human_node_version_keeps_prefix_and_lts_codename() { + let entry = NodeVersionJson { + version: "22.11.0".into(), + lts: Some("Jod".into()), + latest: false, + latest_lts: false, + installed: false, + current: false, + default: false, + }; + + assert_eq!(format_node_version(&entry, false), " v22.11.0 (Jod)"); + } + #[test] fn legacy_pattern_keeps_all_components_selected() { assert_eq!( From 25fe26d0af92ef7a1fe08303d5114431c76c2056 Mon Sep 17 00:00:00 2001 From: Liang Date: Wed, 12 Aug 2026 21:14:53 +0800 Subject: [PATCH 05/37] fix(env): preserve scoped package manager state --- .../snapshots.toml | 9 +++ ...er_list_ignores_other_family_resolution.md | 17 ++++++ .../delegate_respects_default_node_version.md | 2 +- .../fallback_all_invalid_to_user_default.md | 2 +- .../install_yarn_corepack_hash/snapshots.toml | 14 +++++ .../snapshots/env_package_manager_hash.md | 57 +++++++++++++++++ .../vp_global_cli/src/commands/env/clean.rs | 13 ++-- .../vp_global_cli/src/commands/env/current.rs | 9 +-- .../vp_global_cli/src/commands/env/doctor.rs | 17 +++--- .../src/commands/env/lifecycle.rs | 20 +++--- crates/vp_global_cli/src/commands/env/list.rs | 5 +- .../src/commands/env/list_remote.rs | 5 +- crates/vp_global_cli/src/commands/env/mod.rs | 4 +- .../src/commands/env/package_manager.rs | 59 ++++++++++++------ .../vp_global_cli/src/commands/env/setup.rs | 10 +-- crates/vp_global_cli/src/commands/env/spec.rs | 37 ++++++++++- crates/vp_global_cli/src/commands/env/use.rs | 61 +++++++++++++------ .../vp_global_cli/src/commands/env/which.rs | 8 +-- .../src/commands/global/install.rs | 6 +- .../vp_global_cli/src/commands/global/mod.rs | 4 -- crates/vp_global_cli/src/shim/dispatch.rs | 12 ++-- crates/vp_global_cli/src/shim/mod.rs | 8 +-- crates/vp_pm_cli/src/package_manager.rs | 35 +++++++---- 23 files changed, 295 insertions(+), 119 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml index 0c715e35e6..bb24d5035d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -47,3 +47,12 @@ steps = [ { argv = ["vp", "env", "list", "node", "--json"], comment = "the node selector does not resolve an excluded package manager", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, { argv = ["vp", "env", "list-remote", "20.18.0", "--lts", "--json"], comment = "the implicit node selector does not resolve an excluded package manager", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, ] + +[[case]] +name = "env_package_manager_list_ignores_other_family_resolution" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "list", "yarn", "--json"], comment = "a package-manager selector does not resolve an excluded package-manager family", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md new file mode 100644 index 0000000000..b20b5de7bc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md @@ -0,0 +1,17 @@ +# env_package_manager_list_ignores_other_family_resolution + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list yarn --json` + +a package-manager selector does not resolve an excluded package-manager family + +``` +{ + "package_managers": { + "yarn": [] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/delegate_respects_default_node_version/snapshots/delegate_respects_default_node_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/delegate_respects_default_node_version/snapshots/delegate_respects_default_node_version.md index ecc30fdad7..41809bb945 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/delegate_respects_default_node_version/snapshots/delegate_respects_default_node_version.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/delegate_respects_default_node_version/snapshots/delegate_respects_default_node_version.md @@ -7,7 +7,7 @@ Set global default to 22.18.0 ``` VITE+ - The Unified Toolchain for the Web -✓ Environment defaults updated. +✓ Default Node.js version set to 22.18.0 ``` ## `vp run check-node` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/fallback_all_invalid_to_user_default/snapshots/fallback_all_invalid_to_user_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/fallback_all_invalid_to_user_default/snapshots/fallback_all_invalid_to_user_default.md index 35dbfcc471..1028e8cbdf 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/fallback_all_invalid_to_user_default/snapshots/fallback_all_invalid_to_user_default.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/fallback_all_invalid_to_user_default/snapshots/fallback_all_invalid_to_user_default.md @@ -7,7 +7,7 @@ Set user default ``` VITE+ - The Unified Toolchain for the Web -✓ Environment defaults updated. +✓ Default Node.js version set to 22.18.0 ``` ## `vp exec node -e console.log(process.version)` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index aefcf8c16f..1c3d6a2f48 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -20,6 +20,20 @@ steps = [ { argv = ["vp", "install"], comment = "The error names the artifact that the hash covers. vp does not download the CLI again", continue-on-failure = true }, ] +[[case]] +name = "env_package_manager_hash" +vp = "global" +env = { YARN_ENABLE_TELEMETRY = "0" } +steps = [ + { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, + { argv = ["vp", "env", "install", "pm"], comment = "The explicit environment install accepts and verifies the project hash", timeout = 120000 }, + { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the project pin to a hash that the cached CLI does not match", snapshot = false }, + { argv = ["vp", "env", "install", "pm"], comment = "The explicit environment install rejects the mismatched project hash", continue-on-failure = true }, + { argv = ["vp", "env", "use", "pm", "--no-install"], comment = "The session override preserves the project hash for first use" }, + { argv = ["yarn", "--version"], comment = "The package-manager shim enforces the hash retained by env use", continue-on-failure = true }, + { argv = ["vp", "env", "use", "pm", "--unset"], snapshot = false }, +] + [[case]] name = "run_yarn_corepack_hash" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md new file mode 100644 index 0000000000..7ca64279e6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md @@ -0,0 +1,57 @@ +# env_package_manager_hash + +## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` + +Ensure the Corepack-pinned Yarn version is not cached + + +## `vp env install pm` + +The explicit environment install accepts and verifies the project hash + +``` +VITE+ - The Unified Toolchain for the Web + +Installing yarn ... +Installed yarn +``` + +## `vpt replace-file-content package.json b7ad4697 b7ad4698` + +Change the project pin to a hash that the cached CLI does not match + + +## `vp env install pm` + +The explicit environment install rejects the mismatched project hash + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +Installing yarn ... +error: Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. +``` + +## `vp env use pm --no-install` + +The session override preserves the project hash for first use + +``` +Using yarn (resolved from packageManager) +``` + +## `yarn --version` + +The package-manager shim enforces the hash retained by env use + +**Exit code:** 1 + +``` +vp: Failed to resolve package manager for 'yarn': Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. +``` + +## `vp env use pm --unset` diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index efd12c143c..6194e0586f 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -31,7 +31,7 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result protected, Err(error) => { output::warn(&format!( @@ -54,11 +54,16 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result Result)>, Error> { - let current = package_manager::resolve_current(cwd).await?; + let current = package_manager::resolve_current_for(cwd, scope.package_manager()).await?; let config = config::load_config().await?; - let default = - config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + let default = config + .default_package_manager + .as_deref() + .map(parse_package_manager_spec) + .transpose()? + .filter(|(kind, _)| scope.includes_package_manager(*kind)); let mut protected = Vec::new(); if let Some(current) = current { protected.push((current.package_manager_type, vec![current.version.to_string()])); diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index ce561a8218..79b63b05b4 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -102,12 +102,9 @@ pub async fn execute( }; let package_manager = if scope.includes_package_managers() { - package_manager::resolve_current(&cwd).await?.and_then(|resolution| { - if let EnvScope::PackageManager(expected) = scope - && expected != resolution.package_manager_type - { - return None; - } + let resolution = + package_manager::resolve_current_for(&cwd, scope.package_manager()).await?; + resolution.and_then(|resolution| { let system_paths = (config.package_manager_shim_mode() == ShimMode::SystemFirst) .then(|| { resolution diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 18c2b47508..d413e87cb8 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -38,8 +38,6 @@ const KNOWN_VERSION_MANAGERS: &[(&str, &str)] = &[ ("n", "N_PREFIX"), ]; -use super::setup::SHIM_TOOLS; - /// Column width for left-side keys in aligned output const KEY_WIDTH: usize = 18; @@ -198,7 +196,7 @@ async fn check_shims() -> bool { let mut missing = Vec::new(); - for tool in SHIM_TOOLS { + for tool in crate::shim::DEFAULT_SHIM_TOOLS { let shim_path = bin_dir.join(shim_filename(tool)); if !tokio::fs::try_exists(&shim_path).await.unwrap_or(false) { missing.push(*tool); @@ -206,7 +204,11 @@ async fn check_shims() -> bool { } if missing.is_empty() { - print_check(&output::CHECK.green().to_string(), "Shims", &SHIM_TOOLS.join(", ")); + print_check( + &output::CHECK.green().to_string(), + "Shims", + &crate::shim::DEFAULT_SHIM_TOOLS.join(", "), + ); true } else { print_check( @@ -299,9 +301,8 @@ async fn check_package_manager_session_override() { } async fn check_package_manager_resolution(cwd: &AbsolutePathBuf, scope: EnvScope) { - match package_manager::resolve_current(cwd).await { - Ok(Some(resolution)) if !matches!(scope, EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) => - { + match package_manager::resolve_current_for(cwd, scope.package_manager()).await { + Ok(Some(resolution)) => { print_check(" ", "Source", &resolution.source); print_check( " ", @@ -429,7 +430,7 @@ async fn check_path() -> bool { } // Show which tool would be executed for each shim - for tool in SHIM_TOOLS { + for tool in crate::shim::DEFAULT_SHIM_TOOLS { if let Some(tool_path) = find_in_path(tool) { let expected = bin_dir.join(shim_filename(tool)); let display = abbreviate_home(&tool_path.display().to_string()); diff --git a/crates/vp_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs index 0139eb77e0..7c233bbbf4 100644 --- a/crates/vp_global_cli/src/commands/env/lifecycle.rs +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -58,24 +58,22 @@ pub(crate) async fn install( } if scope.includes_package_managers() { - let requested = if let Some(spec) = specs.package_manager { - Some(spec) + let requested = if let Some((kind, selector)) = specs.package_manager { + let version = resolve_package_manager_version(kind, &selector).await?; + Some((kind, version, None)) } else if let EnvScope::PackageManager(kind) = scope { - match package_manager::resolve_current(&cwd).await? { - Some(current) if current.package_manager_type == kind => { - Some((kind, current.version.to_string())) - } - _ => Some((kind, "latest".into())), + match package_manager::resolve_current_for(&cwd, Some(kind)).await? { + Some(current) => Some((kind, current.version, current.hash)), + None => Some((kind, resolve_package_manager_version(kind, "latest").await?, None)), } } else { package_manager::resolve_current(&cwd) .await? - .map(|current| (current.package_manager_type, current.version.to_string())) + .map(|current| (current.package_manager_type, current.version, current.hash)) }; - if let Some((kind, selector)) = requested { - let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + if let Some((kind, version, hash)) = requested { println!("Installing {kind} v{version}..."); - download_package_manager(kind, &version, None).await?; + download_package_manager(kind, &version, hash.as_deref()).await?; println!("Installed {kind} v{version}"); } } diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 9e7a18867e..2f55ba9acd 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -58,7 +58,7 @@ pub async fn execute( None }; let current_pm = if scope.includes_package_managers() { - package_manager::resolve_current(&cwd).await? + package_manager::resolve_current_for(&cwd, scope.package_manager()).await? } else { None }; @@ -78,7 +78,8 @@ pub async fn execute( .default_package_manager .as_deref() .map(parse_package_manager_spec) - .transpose()?; + .transpose()? + .filter(|(kind, _)| scope.includes_package_manager(*kind)); match default { Some((kind, selector)) => { Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index e7bbce7843..420bcf2570 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -90,7 +90,7 @@ pub async fn execute( None }; let current_pm = if scope.includes_package_managers() { - package_manager::resolve_current(&cwd).await? + package_manager::resolve_current_for(&cwd, scope.package_manager()).await? } else { None }; @@ -107,7 +107,8 @@ pub async fn execute( .default_package_manager .as_deref() .map(parse_package_manager_spec) - .transpose()?; + .transpose()? + .filter(|(kind, _)| scope.includes_package_manager(*kind)); match default { Some((kind, selector)) => { Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 6ce3d6ec94..e6fd91a416 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -177,8 +177,8 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result Result, Error> { - let specs = current_specs().await?; + resolve_current_for(cwd, None).await +} - let mut resolution = - resolve_environment_package_manager(cwd, specs.session_spec(), specs.default_spec()) - .await - .map_err(Error::from)?; +pub(crate) async fn resolve_current_for( + cwd: &AbsolutePath, + expected: Option, +) -> Result, Error> { + let specs = current_specs().await?; + let mut resolution = resolve_environment_package_manager( + cwd, + specs.session_spec(), + specs.default_spec(), + expected, + ) + .await?; specs.apply_session_source(&mut resolution); Ok(resolution) } @@ -32,7 +41,7 @@ pub(crate) async fn resolve_current_spec( Ok(resolution) } -type PackageManagerSpec = (PackageManagerType, String); +type PackageManagerSpec = (PackageManagerType, String, Option); struct CurrentSpecs { session: Option, @@ -42,12 +51,16 @@ struct CurrentSpecs { } impl CurrentSpecs { - fn session_spec(&self) -> Option<(PackageManagerType, &str)> { - self.session.as_ref().map(|(kind, version)| (*kind, version.as_str())) + fn session_spec(&self) -> Option<(PackageManagerType, &str, Option<&str>)> { + self.session + .as_ref() + .map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())) } - fn default_spec(&self) -> Option<(PackageManagerType, &str)> { - self.default.as_ref().map(|(kind, version)| (*kind, version.as_str())) + fn default_spec(&self) -> Option<(PackageManagerType, &str, Option<&str>)> { + self.default + .as_ref() + .map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())) } fn apply_session_source(&self, resolution: &mut Option) { @@ -62,13 +75,13 @@ async fn current_specs() -> Result { let (session, session_source, session_source_path) = if let Some(spec) = vp_shared::EnvConfig::get().package_manager { ( - Some(parse_package_manager_spec(spec.trim())?), + Some(parse_package_manager_spec_with_hash(spec.trim())?), Some(config::PACKAGE_MANAGER_ENV_VAR), None, ) } else if let Some(spec) = config::read_session_package_manager().await { ( - Some(parse_package_manager_spec(spec.trim())?), + Some(parse_package_manager_spec_with_hash(spec.trim())?), Some(config::SESSION_PACKAGE_MANAGER_FILE), config::get_session_package_manager_path().ok(), ) @@ -76,21 +89,29 @@ async fn current_specs() -> Result { (None, None, None) }; let config = config::load_config().await?; - let default = - config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + let default = config + .default_package_manager + .as_deref() + .map(parse_package_manager_spec_with_hash) + .transpose()?; Ok(CurrentSpecs { session, session_source, session_source_path, default }) } -pub(crate) async fn resolve_from_files( +pub(crate) async fn resolve_from_files_for( cwd: &AbsolutePath, + expected: Option, ) -> Result, Error> { let config = config::load_config().await?; - let default = - config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + let default = config + .default_package_manager + .as_deref() + .map(parse_package_manager_spec_with_hash) + .transpose()?; resolve_environment_package_manager( cwd, None, - default.as_ref().map(|(kind, version)| (*kind, version.as_str())), + default.as_ref().map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())), + expected, ) .await .map_err(Error::from) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 40d6e9d59d..a7d27725ff 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -52,10 +52,6 @@ impl EnvShell { } } -/// Tools to create shims for during setup. -pub(crate) const SHIM_TOOLS: &[&str] = - &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vpx", "vpr"]; - /// Execute the setup command. pub async fn execute(refresh: bool, env_only: bool) -> Result { let config = vp_shared::EnvConfig::get(); @@ -100,7 +96,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result let mut created = Vec::new(); let mut skipped = Vec::new(); - for tool in SHIM_TOOLS { + for tool in crate::shim::DEFAULT_SHIM_TOOLS { let result = create_shim(¤t_exe, bin_dir, tool, refresh).await?; if result { created.push(*tool); @@ -433,8 +429,8 @@ async fn refresh_package_shims(bin_dir: &vt_path::AbsolutePath) -> Result<(), Er let trampoline_src = get_trampoline_path()?; for bin_name in &package_bins { - // Core shims (SHIM_TOOLS + vp) are already refreshed by the main loop. - if bin_name == "vp" || SHIM_TOOLS.contains(&bin_name.as_str()) { + // Default shims and vp are already refreshed by the main loop. + if bin_name == "vp" || crate::shim::DEFAULT_SHIM_TOOLS.contains(&bin_name.as_str()) { continue; } diff --git a/crates/vp_global_cli/src/commands/env/spec.rs b/crates/vp_global_cli/src/commands/env/spec.rs index b64563f294..a57911767c 100644 --- a/crates/vp_global_cli/src/commands/env/spec.rs +++ b/crates/vp_global_cli/src/commands/env/spec.rs @@ -31,6 +31,18 @@ impl EnvScope { pub(crate) fn includes_package_managers(self) -> bool { !matches!(self, Self::Node) } + + pub(crate) fn package_manager(self) -> Option { + match self { + Self::PackageManager(kind) => Some(kind), + _ => None, + } + } + + pub(crate) fn includes_package_manager(self, kind: PackageManagerType) -> bool { + self.includes_package_managers() + && self.package_manager().is_none_or(|selected| selected == kind) + } } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -93,6 +105,13 @@ impl EnvSpecs { pub(crate) fn parse_package_manager_spec( value: &str, ) -> Result<(PackageManagerType, String), Error> { + let (package_manager, version, _) = parse_package_manager_spec_with_hash(value)?; + Ok((package_manager, version)) +} + +pub(crate) fn parse_package_manager_spec_with_hash( + value: &str, +) -> Result<(PackageManagerType, String, Option), Error> { let Some((name, version)) = value.split_once('@') else { return Err(invalid_spec(value)); }; @@ -100,7 +119,13 @@ pub(crate) fn parse_package_manager_spec( if version.is_empty() { return Err(invalid_spec(value)); } - Ok((package_manager, version.to_string())) + let (version, hash) = version + .split_once('+') + .map_or((version, None), |(version, hash)| (version, Some(hash.to_string()))); + if version.is_empty() || hash.as_deref() == Some("") { + return Err(invalid_spec(value)); + } + Ok((package_manager, version.to_string(), hash)) } fn invalid_scope(value: &str) -> Error { @@ -140,4 +165,14 @@ mod tests { assert_eq!(scope, EnvScope::Node); assert_eq!(specs.node.as_deref(), Some("22.0.0")); } + + #[test] + fn package_manager_session_spec_preserves_hash() { + let parsed = + parse_package_manager_spec_with_hash("yarn@4.17.1+sha512.0123456789abcdef").unwrap(); + assert_eq!( + parsed, + (PackageManagerType::Yarn, "4.17.1".into(), Some("sha512.0123456789abcdef".into())) + ); + } } diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 795dab5980..21a3910d57 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -10,7 +10,7 @@ use std::process::ExitStatus; -use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; +use vp_pm_cli::{PackageManagerType, download_package_manager, resolve_package_manager_version}; use vt_path::AbsolutePathBuf; use super::{ @@ -71,6 +71,19 @@ fn print_windows_eval_wrapper_required() { eprintln!("Then dot-source it now (or open a new PowerShell session) to load the wrapper."); } +fn package_manager_spec( + package_manager: PackageManagerType, + version: &str, + hash: Option<&str>, +) -> String { + let mut spec = format!("{package_manager}@{version}"); + if let Some(hash) = hash { + spec.push('+'); + spec.push_str(hash); + } + spec +} + /// Execute the `vp env use` command. pub async fn execute( cwd: AbsolutePathBuf, @@ -133,28 +146,27 @@ pub async fn execute( let resolved = if let Some((kind, selector)) = specs.package_manager { let version = resolve_package_manager_version(kind, &selector).await?.to_string(); package_manager::warn_if_target_differs(&cwd, kind).await; - Some((kind, version, selector)) + Some((kind, version, selector, None)) } else { if let EnvScope::PackageManager(kind) = scope { package_manager::warn_if_target_differs(&cwd, kind).await; } - package_manager::resolve_from_files(&cwd).await?.and_then(|resolution| { - if matches!(scope, EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) { - None - } else { - Some(( + package_manager::resolve_from_files_for(&cwd, scope.package_manager()).await?.map( + |resolution| { + ( resolution.package_manager_type, resolution.version.to_string(), resolution.source.to_string(), - )) - } - }) + resolution.hash.map(|hash| hash.to_string()), + ) + }, + ) }; if let EnvScope::PackageManager(kind) = scope && resolved.is_none() { let version = resolve_package_manager_version(kind, "latest").await?.to_string(); - Some((kind, version, "latest".into())) + Some((kind, version, "latest".into(), None)) } else { resolved } @@ -176,13 +188,13 @@ pub async fn execute( None => true, }; let package_manager_unchanged = match &package_manager { - Some((kind, version, _)) => { + Some((kind, version, _, hash)) => { current_override( config::read_session_package_manager().await, vp_shared::EnvConfig::get().package_manager, ) .as_deref() - == Some(format!("{kind}@{version}").as_str()) + == Some(package_manager_spec(*kind, version, hash.as_deref()).as_str()) } None => true, }; @@ -223,8 +235,8 @@ pub async fn execute( .await?; } } - if !no_install && let Some((kind, version, _)) = &package_manager { - download_package_manager(*kind, version, None).await?; + if !no_install && let Some((kind, version, _, hash)) = &package_manager { + download_package_manager(*kind, version, hash.as_deref()).await?; } if has_eval_wrapper() { @@ -232,11 +244,15 @@ pub async fn execute( config::delete_session_version().await?; println!("{}", format_export(&shell, VERSION_ENV_VAR, version)); } - if let Some((kind, version, _)) = &package_manager { + if let Some((kind, version, _, hash)) = &package_manager { config::delete_session_package_manager().await?; println!( "{}", - format_export(&shell, PACKAGE_MANAGER_ENV_VAR, &format!("{kind}@{version}")) + format_export( + &shell, + PACKAGE_MANAGER_ENV_VAR, + &package_manager_spec(*kind, version, hash.as_deref()) + ) ); } } else if !can_use_session_file() { @@ -247,15 +263,20 @@ pub async fn execute( if let Some((version, _)) = &node { config::write_session_version(version).await?; } - if let Some((kind, version, _)) = &package_manager { - config::write_session_package_manager(&format!("{kind}@{version}")).await?; + if let Some((kind, version, _, hash)) = &package_manager { + config::write_session_package_manager(&package_manager_spec( + *kind, + version, + hash.as_deref(), + )) + .await?; } } if let Some((version, source)) = node { eprintln!("Using Node.js v{version} (resolved from {source})"); } - if let Some((kind, version, source)) = package_manager { + if let Some((kind, version, source, _)) = package_manager { eprintln!("Using {kind} v{version} (resolved from {source})"); } diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 6c58cf789e..7d4436dcd7 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -151,17 +151,17 @@ async fn execute_package_manager_tool( let Some(expected_type) = PackageManagerType::from_tool(tool) else { return Ok(None); }; - let resolution = package_manager::resolve_current(cwd).await?; + let resolution = package_manager::resolve_current_for(cwd, Some(expected_type)).await?; let (version, source) = match &resolution { - Some(resolution) if resolution.package_manager_type == expected_type => ( + Some(resolution) => ( resolution.version.to_string(), resolution.source_path.as_ref().map_or_else( || resolution.source.to_string(), |path| path.as_path().display().to_string(), ), ), - Some(_) | None if expected_type == PackageManagerType::Npm => return Ok(None), - Some(_) | None => ( + None if expected_type == PackageManagerType::Npm => return Ok(None), + None => ( resolve_package_manager_version(expected_type, "latest").await?.to_string(), "registry fallback".into(), ), diff --git a/crates/vp_global_cli/src/commands/global/install.rs b/crates/vp_global_cli/src/commands/global/install.rs index ccbaa443c1..e3c793d290 100644 --- a/crates/vp_global_cli/src/commands/global/install.rs +++ b/crates/vp_global_cli/src/commands/global/install.rs @@ -26,7 +26,7 @@ use crate::{ package_metadata::{PackageMetadata, is_legacy_install_id, is_nested_install_id}, }, global::{ - CORE_SHIMS, LEGACY_PACKAGE_MANAGER_PACKAGES, is_local_package_spec, parse_package_spec, + LEGACY_PACKAGE_MANAGER_PACKAGES, is_local_package_spec, parse_package_spec, update_version_spec, }, }, @@ -110,7 +110,7 @@ fn windows_regular_file_is_vp_shim(shim_path: &vt_path::AbsolutePath) -> bool { pub(crate) fn is_protected_shim(bin_name: &str, ignore_case: bool) -> bool { let bin_name = if cfg!(target_os = "linux") || !ignore_case { bin_name } else { &bin_name.to_lowercase() }; - CORE_SHIMS.contains(&bin_name) || crate::commands::env::setup::SHIM_TOOLS.contains(&bin_name) + bin_name == "vp" || crate::shim::DEFAULT_SHIM_TOOLS.contains(&bin_name) } /// Options for [`install`]. @@ -1229,7 +1229,7 @@ mod tests { #[test] fn test_default_shims_are_protected() { - for shim in CORE_SHIMS.iter().chain(crate::commands::env::setup::SHIM_TOOLS) { + for shim in crate::shim::DEFAULT_SHIM_TOOLS.iter().chain([&"vp"]) { assert!(is_protected_shim(shim, false), "{shim} should be protected"); } } diff --git a/crates/vp_global_cli/src/commands/global/mod.rs b/crates/vp_global_cli/src/commands/global/mod.rs index 6e9431fb96..e7b3a2de4a 100644 --- a/crates/vp_global_cli/src/commands/global/mod.rs +++ b/crates/vp_global_cli/src/commands/global/mod.rs @@ -16,10 +16,6 @@ pub mod install; pub mod outdated; pub mod packages; -/// Core shims that should not be overwritten by package binaries. -pub(crate) const CORE_SHIMS: &[&str] = - &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vp"]; - /// Legacy managed globals superseded by the default package-manager shims. pub(crate) const LEGACY_PACKAGE_MANAGER_PACKAGES: &[&str] = &["yarn", "pnpm", "bun", "corepack"]; diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 22e8d4b8ed..657b1f7ef0 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -253,7 +253,7 @@ fn check_npm_global_install_result( // the user for non-core names: npm installed the package, but the // binary stays unlinked. if is_protected_shim(&bin_name, false) { - if !crate::commands::global::CORE_SHIMS.contains(&bin_name.as_str()) { + if bin_name != "vp" && !crate::shim::is_core_shim_tool(&bin_name) { output::note(&vt_str::format!( "'{bin_name}' is a Vite+ default shim; the npm-installed copy is not \ linked." @@ -659,13 +659,11 @@ async fn resolve_package_manager_tool( return Ok(None); }; - let resolution = package_manager::resolve_current(cwd).await?; + let resolution = package_manager::resolve_current_for(cwd, Some(expected_type)).await?; let (version, hash) = match resolution { - Some(resolution) if resolution.package_manager_type == expected_type => { - (resolution.version, resolution.hash) - } - Some(_) | None if expected_type == PackageManagerType::Npm => return Ok(None), - Some(_) | None => ("latest".into(), None), + Some(resolution) => (resolution.version, resolution.hash), + None if expected_type == PackageManagerType::Npm => return Ok(None), + None => ("latest".into(), None), }; let bin_name = expected_type.bin_name_for_tool(tool); diff --git a/crates/vp_global_cli/src/shim/mod.rs b/crates/vp_global_cli/src/shim/mod.rs index 10b58551be..cb235dba3f 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -21,9 +21,9 @@ use vp_shared::env_vars; use crate::commands::env::config::get_bin_dir; -/// Core shim tools managed directly by the main dispatch path. -pub const CORE_SHIM_TOOLS: &[&str] = - &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx"]; +/// Default shims created by `vp env setup`. +pub const DEFAULT_SHIM_TOOLS: &[&str] = + &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vpx", "vpr"]; /// Extract the tool name from argv[0]. /// We hope all bins should be put under $VP_HOME/bin @@ -67,7 +67,7 @@ pub fn extract_tool_name(argv0: &str) -> String { /// Check if the given tool name is managed directly by the core shim path. #[must_use] pub fn is_core_shim_tool(tool: &str) -> bool { - CORE_SHIM_TOOLS.contains(&tool) + tool == "node" || vp_pm_cli::PackageManagerType::from_tool(tool).is_some() } /// Check if the given tool name is a shim tool (core or package binary). diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 1fea99e882..609b7f359a 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -453,14 +453,14 @@ pub fn resolve_package_manager_from_package_json( /// registry or managed installs, so callers can inspect the selection without network access. pub fn resolve_environment_package_manager_spec( cwd: impl AsRef, - override_spec: Option<(PackageManagerType, &str)>, - default_spec: Option<(PackageManagerType, &str)>, + override_spec: Option<(PackageManagerType, &str, Option<&str>)>, + default_spec: Option<(PackageManagerType, &str, Option<&str>)>, ) -> Result, Error> { - if let Some((package_manager_type, version)) = override_spec { + if let Some((package_manager_type, version, hash)) = override_spec { return Ok(Some(EnvironmentPackageManagerResolution { package_manager_type, version: version.into(), - hash: None, + hash: hash.map(Str::from), source: "session".into(), source_path: None, project_root: None, @@ -519,12 +519,12 @@ pub fn resolve_environment_package_manager_spec( } fn environment_package_manager_default( - (package_manager_type, version): (PackageManagerType, &str), + (package_manager_type, version, hash): (PackageManagerType, &str, Option<&str>), ) -> EnvironmentPackageManagerResolution { EnvironmentPackageManagerResolution { package_manager_type, version: version.into(), - hash: None, + hash: hash.map(Str::from), source: "default".into(), source_path: None, project_root: None, @@ -532,14 +532,20 @@ fn environment_package_manager_default( } /// Resolve an environment package-manager requirement to an exact version for managed-runtime -/// operations such as `vp env install` and package-manager shims. +/// operations such as `vp env install` and package-manager shims. When `expected` is set, a +/// different selected family is discarded before any registry lookup. pub async fn resolve_environment_package_manager( cwd: impl AsRef, - override_spec: Option<(PackageManagerType, &str)>, - default_spec: Option<(PackageManagerType, &str)>, + override_spec: Option<(PackageManagerType, &str, Option<&str>)>, + default_spec: Option<(PackageManagerType, &str, Option<&str>)>, + expected: Option, ) -> Result, Error> { let Some(mut resolution) = - resolve_environment_package_manager_spec(cwd, override_spec, default_spec)? + resolve_environment_package_manager_spec(cwd, override_spec, default_spec)?.filter( + |resolution| { + expected.is_none_or(|expected| expected == resolution.package_manager_type) + }, + ) else { return Ok(None); }; @@ -2185,8 +2191,9 @@ mod tests { let resolution = resolve_environment_package_manager( &cwd, - Some((PackageManagerType::Yarn, "1.22.22")), - Some((PackageManagerType::Bun, "1.2.0")), + Some((PackageManagerType::Yarn, "1.22.22", Some("sha512.example"))), + Some((PackageManagerType::Bun, "1.2.0", None)), + None, ) .await .unwrap() @@ -2194,6 +2201,7 @@ mod tests { assert_eq!(resolution.package_manager_type, PackageManagerType::Yarn); assert_eq!(resolution.version, "1.22.22"); + assert_eq!(resolution.hash.as_deref(), Some("sha512.example")); assert_eq!(resolution.source, "session"); } @@ -2206,7 +2214,8 @@ mod tests { let resolution = resolve_environment_package_manager( &cwd, None, - Some((PackageManagerType::Bun, "1.2.0")), + Some((PackageManagerType::Bun, "1.2.0", None)), + None, ) .await .unwrap() From 6186f0c224a63201cc7a31159aec9d2f3cd9ca56 Mon Sep 17 00:00:00 2001 From: Liang Date: Wed, 12 Aug 2026 21:47:54 +0800 Subject: [PATCH 06/37] test(env): enable session fallback in hash snapshot --- .../fixtures/install_yarn_corepack_hash/snapshots.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index 1c3d6a2f48..b7dbbbf754 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -23,7 +23,7 @@ steps = [ [[case]] name = "env_package_manager_hash" vp = "global" -env = { YARN_ENABLE_TELEMETRY = "0" } +env = { CI = "true", YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "env", "install", "pm"], comment = "The explicit environment install accepts and verifies the project hash", timeout = 120000 }, From 984f31350a6e86a9e1531290087e3eeaadb18d30 Mon Sep 17 00:00:00 2001 From: Liang Date: Wed, 12 Aug 2026 22:35:08 +0800 Subject: [PATCH 07/37] fix(env): separate doctor labels from values Long labels could exceed the minimum key width and run directly into their values. Keep the existing aligned column for short labels while adding an explicit separator for every key. --- .../snapshots.toml | 7 +++ .../command_env_doctor_configuration.md | 44 +++++++++++++++++++ .../vp_global_cli/src/commands/env/doctor.rs | 8 ++-- 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml new file mode 100644 index 0000000000..69dbbd2910 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml @@ -0,0 +1,7 @@ +[[case]] +name = "command_env_doctor_configuration" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "env", "doctor", "pm"], comment = "long configuration labels stay separated from their values" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md new file mode 100644 index 0000000000..fd70c9703a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md @@ -0,0 +1,44 @@ +# command_env_doctor_configuration + +## `vp env doctor pm` + +long configuration labels stay separated from their values + +``` +VITE+ - The Unified Toolchain for the Web + +Installation + ✓ VP_HOME ~/.vite-plus + ✓ Bin directory exists + ✓ Shims node, npm, npx, pnpm, pnpx, yarn, yarnpkg, bun, bunx, vpx, vpr + +Configuration + ✓ Package manager mode managed + +PATH + ✓ vp in PATH + ✓ node ~/.vite-plus/bin/node (vp shim) + ✓ npm ~/.vite-plus/bin/npm (vp shim) + ✓ npx ~/.vite-plus/bin/npx (vp shim) + ✓ pnpm ~/.vite-plus/bin/pnpm (vp shim) + ✓ pnpx ~/.vite-plus/bin/pnpx (vp shim) + ✓ yarn ~/.vite-plus/bin/yarn (vp shim) + ✓ yarnpkg ~/.vite-plus/bin/yarnpkg (vp shim) + ✓ bun ~/.vite-plus/bin/bun (vp shim) + ✓ bunx ~/.vite-plus/bin/bunx (vp shim) + ✓ vpx ~/.vite-plus/bin/vpx (vp shim) + ✓ vpr ~/.vite-plus/bin/vpr (vp shim) + +Package Manager Resolution + Package manager not selected + +IDE Setup + ⚠ GUI applications may not see shell PATH changes. + + macOS: + Add to ~/.zshenv or ~/.profile: + . "$HOME/.vite-plus/env" + Then restart your IDE to apply changes. + +✓ All checks passed +``` diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index d413e87cb8..ec51049fdd 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -38,8 +38,8 @@ const KNOWN_VERSION_MANAGERS: &[(&str, &str)] = &[ ("n", "N_PREFIX"), ]; -/// Column width for left-side keys in aligned output -const KEY_WIDTH: usize = 18; +/// Column width for left-side keys before the value separator. +const KEY_WIDTH: usize = 17; /// Print a section header (bold, with blank line before). fn print_section(name: &str) { @@ -53,11 +53,11 @@ fn print_section(name: &str) { /// Use `" "` for informational lines with no status. fn print_check(status: &str, key: &str, value: &str) { if status.trim().is_empty() { - println!(" {key: Date: Wed, 12 Aug 2026 22:44:05 +0800 Subject: [PATCH 08/37] fix(env): present doctor modes as values Keep configuration labels within the existing output column and render managed and system-first as mode values. This avoids special casing the shared formatter and reads more naturally. --- .../.node-version | 1 + .../snapshots.toml | 2 +- .../command_env_doctor_configuration.md | 14 +++++++++--- .../vp_global_cli/src/commands/env/doctor.rs | 22 +++++++++---------- rfcs/env-command.md | 12 +++++----- 5 files changed, 30 insertions(+), 21 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/.node-version diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/.node-version new file mode 100644 index 0000000000..2a393af592 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/.node-version @@ -0,0 +1 @@ +20.18.0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml index 69dbbd2910..af6b7d114e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml @@ -3,5 +3,5 @@ name = "command_env_doctor_configuration" vp = "global" skip-platforms = ["windows"] steps = [ - { argv = ["vp", "env", "doctor", "pm"], comment = "long configuration labels stay separated from their values" }, + { argv = ["vp", "env", "doctor"], comment = "configuration modes read naturally without extending their labels" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md index fd70c9703a..6c1422b097 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md @@ -1,8 +1,8 @@ # command_env_doctor_configuration -## `vp env doctor pm` +## `vp env doctor` -long configuration labels stay separated from their values +configuration modes read naturally without extending their labels ``` VITE+ - The Unified Toolchain for the Web @@ -13,7 +13,8 @@ Installation ✓ Shims node, npm, npx, pnpm, pnpx, yarn, yarnpkg, bun, bunx, vpx, vpr Configuration - ✓ Package manager mode managed + ✓ Node.js managed mode + ✓ Package manager managed mode PATH ✓ vp in PATH @@ -29,6 +30,13 @@ PATH ✓ vpx ~/.vite-plus/bin/vpx (vp shim) ✓ vpr ~/.vite-plus/bin/vpr (vp shim) +Node.js Resolution + Directory + Source /.node-version + Version 20.18.0 + ⚠ Node binary not installed + note: Version will be downloaded on first use. + Package Manager Resolution Package manager not selected diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index ec51049fdd..bf82128a50 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -38,8 +38,8 @@ const KNOWN_VERSION_MANAGERS: &[(&str, &str)] = &[ ("n", "N_PREFIX"), ]; -/// Column width for left-side keys before the value separator. -const KEY_WIDTH: usize = 17; +/// Column width for left-side keys in aligned output +const KEY_WIDTH: usize = 18; /// Print a section header (bold, with blank line before). fn print_section(name: &str) { @@ -53,11 +53,11 @@ fn print_section(name: &str) { /// Use `" "` for informational lines with no status. fn print_check(status: &str, key: &str, value: &str) { if status.trim().is_empty() { - println!(" {key: (ShimMode, Option) Err(e) => { print_check( &output::WARN_SIGN.yellow().to_string(), - "Node.js mode", + "Node.js", &format!("config error: {e}").yellow().to_string(), ); return (ShimMode::default(), None); @@ -254,13 +254,13 @@ async fn check_shim_mode(scope: EnvScope) -> (ShimMode, Option) if scope.includes_node() { match config.shim_mode { ShimMode::Managed => { - print_check(&output::CHECK.green().to_string(), "Node.js mode", "managed"); + print_check(&output::CHECK.green().to_string(), "Node.js", "managed mode"); } ShimMode::SystemFirst => { print_check( &output::CHECK.green().to_string(), - "Node.js mode", - &"system-first".bright_blue().to_string(), + "Node.js", + &"system-first mode".bright_blue().to_string(), ); // Check if system Node.js is available @@ -283,10 +283,10 @@ async fn check_shim_mode(scope: EnvScope) -> (ShimMode, Option) } if scope.includes_package_managers() { let mode = match config.package_manager_shim_mode() { - ShimMode::Managed => "managed", - ShimMode::SystemFirst => "system-first", + ShimMode::Managed => "managed mode", + ShimMode::SystemFirst => "system-first mode", }; - print_check(&output::CHECK.green().to_string(), "Package manager mode", mode); + print_check(&output::CHECK.green().to_string(), "Package manager", mode); } (config.shim_mode, system_node_path) diff --git a/rfcs/env-command.md b/rfcs/env-command.md index be88e98ec0..35ffee8928 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -864,7 +864,7 @@ Installation ✓ Shims node, npm, npx Configuration - ✓ Node.js mode managed + ✓ Node.js managed mode PATH ✗ vp not in PATH @@ -962,7 +962,7 @@ Installation ✓ Shims node, npm, npx Configuration - ✓ Node.js mode managed + ✓ Node.js managed mode ✓ IDE integration env sourced in ~/.zshenv PATH @@ -987,7 +987,7 @@ $ vp env doctor ... Configuration - ✓ Node.js mode managed + ✓ Node.js managed mode ✓ IDE integration env sourced in ~/.zshenv ⚠ Session override VP_NODE_VERSION=20.18.0 Overrides all file-based resolution. @@ -1005,7 +1005,7 @@ $ vp env doctor ... Configuration - ✓ Node.js mode system-first + ✓ Node.js system-first mode System Node.js /usr/local/bin/node ✓ IDE integration env sourced in ~/.zshenv @@ -1027,7 +1027,7 @@ $ vp env doctor ... Configuration - ✓ Node.js mode system-first + ✓ Node.js system-first mode ⚠ System Node.js not found (will fall back to managed) ... @@ -1044,7 +1044,7 @@ Installation Run 'vp env setup' to create bin directory and shims. Configuration - ✓ Node.js mode managed + ✓ Node.js managed mode PATH ✗ vp not in PATH From 2df86060e620a6ef327eefd97a6d168e5c0fa314 Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 13 Aug 2026 08:17:04 +0800 Subject: [PATCH 09/37] test(env): remove platform-specific doctor snapshot --- .../.node-version | 1 - .../snapshots.toml | 7 --- .../command_env_doctor_configuration.md | 52 ------------------- 3 files changed, 60 deletions(-) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/.node-version delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/.node-version deleted file mode 100644 index 2a393af592..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/.node-version +++ /dev/null @@ -1 +0,0 @@ -20.18.0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml deleted file mode 100644 index af6b7d114e..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots.toml +++ /dev/null @@ -1,7 +0,0 @@ -[[case]] -name = "command_env_doctor_configuration" -vp = "global" -skip-platforms = ["windows"] -steps = [ - { argv = ["vp", "env", "doctor"], comment = "configuration modes read naturally without extending their labels" }, -] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md deleted file mode 100644 index 6c1422b097..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_configuration/snapshots/command_env_doctor_configuration.md +++ /dev/null @@ -1,52 +0,0 @@ -# command_env_doctor_configuration - -## `vp env doctor` - -configuration modes read naturally without extending their labels - -``` -VITE+ - The Unified Toolchain for the Web - -Installation - ✓ VP_HOME ~/.vite-plus - ✓ Bin directory exists - ✓ Shims node, npm, npx, pnpm, pnpx, yarn, yarnpkg, bun, bunx, vpx, vpr - -Configuration - ✓ Node.js managed mode - ✓ Package manager managed mode - -PATH - ✓ vp in PATH - ✓ node ~/.vite-plus/bin/node (vp shim) - ✓ npm ~/.vite-plus/bin/npm (vp shim) - ✓ npx ~/.vite-plus/bin/npx (vp shim) - ✓ pnpm ~/.vite-plus/bin/pnpm (vp shim) - ✓ pnpx ~/.vite-plus/bin/pnpx (vp shim) - ✓ yarn ~/.vite-plus/bin/yarn (vp shim) - ✓ yarnpkg ~/.vite-plus/bin/yarnpkg (vp shim) - ✓ bun ~/.vite-plus/bin/bun (vp shim) - ✓ bunx ~/.vite-plus/bin/bunx (vp shim) - ✓ vpx ~/.vite-plus/bin/vpx (vp shim) - ✓ vpr ~/.vite-plus/bin/vpr (vp shim) - -Node.js Resolution - Directory - Source /.node-version - Version 20.18.0 - ⚠ Node binary not installed - note: Version will be downloaded on first use. - -Package Manager Resolution - Package manager not selected - -IDE Setup - ⚠ GUI applications may not see shell PATH changes. - - macOS: - Add to ~/.zshenv or ~/.profile: - . "$HOME/.vite-plus/env" - Then restart your IDE to apply changes. - -✓ All checks passed -``` From f293ff5a7f258373ff4af1ace0c5b6a0eb3f3679 Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 20 Aug 2026 17:01:30 +0800 Subject: [PATCH 10/37] fix(env): preserve main compatibility --- crates/vp_global_cli/src/commands/env/doctor.rs | 3 ++- crates/vp_global_cli/src/commands/env/package_manager.rs | 3 ++- crates/vp_global_cli/src/commands/env/use.rs | 6 +++--- crates/vp_global_cli/src/commands/implode.rs | 5 +++-- crates/vp_pm_cli/src/dispatch.rs | 4 ++-- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index bf82128a50..d38a536b53 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -293,7 +293,7 @@ async fn check_shim_mode(scope: EnvScope) -> (ShimMode, Option) } async fn check_package_manager_session_override() { - let environment = vp_shared::EnvConfig::get().package_manager; + let environment = vp_shared::EnvConfig::get().package_manager.clone(); let session = config::read_session_package_manager().await; if let Some(value) = environment.or(session) { print_check(" ", "PM session", &value); @@ -1099,6 +1099,7 @@ fn check_conflicts() { #[cfg(test)] mod tests { + use serial_test::serial; use tempfile::TempDir; use super::*; diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index 40ffddd636..ec4dddf248 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -72,8 +72,9 @@ impl CurrentSpecs { } async fn current_specs() -> Result { + let config = vp_shared::EnvConfig::get(); let (session, session_source, session_source_path) = - if let Some(spec) = vp_shared::EnvConfig::get().package_manager { + if let Some(spec) = config.package_manager.as_deref() { ( Some(parse_package_manager_spec_with_hash(spec.trim())?), Some(config::PACKAGE_MANAGER_ENV_VAR), diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 21a3910d57..791652b7aa 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -103,7 +103,7 @@ pub async fn execute( let unset_package_manager = match scope { EnvScope::PackageManager(expected) => current_override( config::read_session_package_manager().await, - vp_shared::EnvConfig::get().package_manager, + vp_shared::EnvConfig::get().package_manager.clone(), ) .and_then(|value| super::spec::parse_package_manager_spec(&value).ok()) .is_some_and(|(kind, _)| kind == expected), @@ -180,7 +180,7 @@ pub async fn execute( Some((version, _)) => { current_override( config::read_session_version().await, - vp_shared::EnvConfig::get().node_version, + vp_shared::EnvConfig::get().node_version.clone(), ) .as_deref() == Some(version) @@ -191,7 +191,7 @@ pub async fn execute( Some((kind, version, _, hash)) => { current_override( config::read_session_package_manager().await, - vp_shared::EnvConfig::get().package_manager, + vp_shared::EnvConfig::get().package_manager.clone(), ) .as_deref() == Some(package_manager_spec(*kind, version, hash.as_deref()).as_str()) diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 9ad0244734..9254010ca4 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -15,11 +15,12 @@ use vt_str::Str; use crate::{ cli::exit_status, commands::{ - env::setup::{SHIM_TOOLS, shim_filename}, + env::setup::shim_filename, global::install::is_vp_shim_target, shell::{ALL_SHELL_PROFILES, ShellProfileKind, abbreviate_home_path, resolve_profile_path}, }, error::Error, + shim::DEFAULT_SHIM_TOOLS, }; /// Comment marker written by the install script above the sourcing line. @@ -151,7 +152,7 @@ pub fn execute(yes: bool) -> Result { fn remove_shim_files(dirs: &vp_shared::VpDirs) { let mut names = recorded_bin_shim_names(dirs); names.insert(shim_filename("vp")); - names.extend(SHIM_TOOLS.iter().map(|tool| shim_filename(tool))); + names.extend(DEFAULT_SHIM_TOOLS.iter().map(|tool| shim_filename(tool))); #[cfg(windows)] names.insert("vp-use.cmd".to_string()); diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index 175fc6b34f..543112d5c3 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -8,7 +8,7 @@ use std::process::ExitStatus; use vt_path::AbsolutePath; use crate::{ - PackageManager, + PackageManager, PackageManagerType, cli::{PackageManagerCommand, PmCommand}, download_package_manager, error::Error, @@ -82,7 +82,7 @@ async fn dispatch_with_manager( }; let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; let status = run_resolution(cwd, resolution, render_diagnostics).await?; - return Ok(DispatchResult { status, package_manager: manager.client }); + return Ok(DispatchResult { status, why_hint_packages: None }); } command => command, }; From c3a4b941c943941251075fc8c62a68e0fe644fed Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 20 Aug 2026 19:12:43 +0800 Subject: [PATCH 11/37] test(env): refresh component management snapshots --- .../snapshots/command_env_fish_use.md | 2 +- .../snapshots/command_env_posix_bash.md | 2 +- .../snapshots/command_env_posix_sh.md | 2 +- .../snapshots/command_env_posix_zsh.md | 2 +- .../snapshots/command_upgrade_background_notice.md | 13 ++++++++----- .../snapshots/env_package_manager_hash.md | 1 + 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_fish/snapshots/command_env_fish_use.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_fish/snapshots/command_env_fish_use.md index 5e2ae74cfc..79b34f6528 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_fish/snapshots/command_env_fish_use.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_fish/snapshots/command_env_fish_use.md @@ -15,7 +15,7 @@ verifies the Fish wrapper help, explicit use, unset, file-based use, and failure ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) error: Unexpected argument '--invalid-option' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_bash.md index ca99219676..bd76e34400 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_bash.md @@ -16,7 +16,7 @@ loads the generated env file in Bash and verifies PATH, wrapper, completions, an ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) POSIX environment checks passed (bash) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_sh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_sh.md index a0b2947de9..d7b64f609d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_sh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_sh.md @@ -16,7 +16,7 @@ loads the generated env file in sh and verifies PATH, wrapper, and version switc ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) POSIX environment checks passed (sh) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_zsh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_zsh.md index bd3c223b65..77ca5b05e9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_zsh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_zsh.md @@ -16,7 +16,7 @@ loads the generated env file in Zsh and verifies PATH, wrapper, completions, and ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) POSIX environment checks passed (zsh) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md index 84ab24569f..96ff3840a1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md @@ -25,11 +25,11 @@ The next interactive command displays the cached update notice. ``` VITE+ - The Unified Toolchain for the Web -✓ Node.js management set to system-first. +✓ Node.js and package-manager management set to system-first. -All vp commands and shims will now prefer system Node.js, falling back to managed if not found. +Selected commands and shims will now prefer system tools, falling back to managed tools. -Run `vp env on` to always use Vite+ managed Node.js. +Run `vp env on` to always use Vite+ managed tools. vp update available: → 999.0.0, run vp upgrade ``` @@ -41,6 +41,9 @@ A subsequent command stays quiet after the notice timestamp is recorded. ``` VITE+ - The Unified Toolchain for the Web -Node.js management is already set to system-first. -All vp commands and shims will prefer system Node.js, falling back to managed if not found. +✓ 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. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md index 7ca64279e6..df7099dd6f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md @@ -55,3 +55,4 @@ The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack ``` ## `vp env use pm --unset` + From 8b41c8ba42dfa735aef70aaee2f5629356d39d7f Mon Sep 17 00:00:00 2001 From: Liang Date: Thu, 20 Aug 2026 20:11:57 +0800 Subject: [PATCH 12/37] test(env): refresh Windows shell snapshots --- .../fixtures/command_env_cmd/snapshots/command_env_cmd.md | 2 +- .../command_env_powershell/snapshots/command_env_powershell.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_cmd/snapshots/command_env_cmd.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_cmd/snapshots/command_env_cmd.md index 9cb89526b2..dcfa899c39 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_cmd/snapshots/command_env_cmd.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_cmd/snapshots/command_env_cmd.md @@ -6,7 +6,7 @@ verifies vp-use.cmd explicit, unset, file-based, failure status, and session-onl ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) Command Prompt environment use checks passed ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots/command_env_powershell.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots/command_env_powershell.md index a211b81d65..ca9210d7e9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots/command_env_powershell.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots/command_env_powershell.md @@ -13,7 +13,7 @@ dot-sources env.ps1 and verifies PowerShell environment setup and wrapper behavi ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) PowerShell environment checks passed ``` From b20cf03c6a641de37124cbd757148c03193de8ba Mon Sep 17 00:00:00 2001 From: Liang Date: Fri, 21 Aug 2026 14:23:41 +0800 Subject: [PATCH 13/37] wip --- .../command_env_list_remote/snapshots.toml | 17 +++- .../snapshots/command_env_list_remote.md | 19 ++++- crates/vp_global_cli/src/commands/env/list.rs | 28 +++++-- .../src/commands/env/list_remote.rs | 82 ++++++++++++------- 4 files changed, 107 insertions(+), 39 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml index ecb6673238..d518df989c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml @@ -10,6 +10,21 @@ steps = [ { argv = ["vp", "env", "default", "node"], comment = "A configured Node.js alias shows its current resolution and config source", continue-on-failure = true }, { argv = ["vp", "env", "default", "pnpm@10.18.0"], comment = "Package-manager default updates identify the selected package and version", continue-on-failure = true }, { argv = ["node", "-e", "const {execFileSync}=require('node:child_process'); const {node}=JSON.parse(execFileSync('vp',['env','list-remote','--lts','--json'],{encoding:'utf8'})); console.log('installed marked:', node.some(v=>v.installed)); console.log('current marked:', node.some(v=>v.current)); console.log('default marked:', node.some(v=>v.default));"], comment = "the unified JSON node entries resolve installed/current/default flags, including the `lts` default alias", continue-on-failure = true }, - { argv = ["vp", "env", "list-remote", "node", "22.11.0"], comment = "Human-readable Node.js results keep the v prefix and LTS codename", continue-on-failure = true }, + { argv = ["vp", "env", "list-remote", "node", "22.11.0"], comment = "Human-readable Node.js results keep the v prefix, LTS codename, and interactive formatting", formatted-snapshot = true, continue-on-failure = true }, + { argv = ["vp", "env", "list-remote", "pnpm", "10.18.0"], comment = "Human-readable package-manager results retain current-version formatting", formatted-snapshot = true, continue-on-failure = true }, { argv = ["vp", "env", "list-remote", "node", "999"], comment = "An empty Node.js result includes actionable feedback", continue-on-failure = true }, ] + +[[case]] +name = "command_env_list_format" +vp = "global" +skip-platforms = ["windows"] +seed-runtime = false +steps = [ + { argv = ["vpt", "mkdir", "-p", "$VP_HOME/js_runtime/node/22.11.0"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vp", "env", "default", "22.11.0", "pnpm@10.18.0"], snapshot = false }, + { argv = ["vp", "env", "list", "node"], comment = "Installed Node.js versions retain their interactive formatting", formatted-snapshot = true }, + { argv = ["vp", "env", "list", "pnpm"], comment = "Installed package-manager versions use the same interactive formatting", formatted-snapshot = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md index 334fce1d57..f9726f5aad 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md @@ -66,15 +66,28 @@ default marked: true ## `vp env list-remote node 22.11.0` -Human-readable Node.js results keep the v prefix and LTS codename +Human-readable Node.js results keep the v prefix, LTS codename, and interactive formatting ``` VITE+ - The Unified Toolchain for the Web Node.js - (Jod) + \x1b[94m (Jod) -note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +\x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` + +## `vp env list-remote pnpm 10.18.0` + +Human-readable package-manager results retain current-version formatting + +``` +VITE+ - The Unified Toolchain for the Web + +pnpm + \x1b[94m10.18.0\x1b[39;2m current default + +\x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. ``` ## `vp env list-remote node 999` diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 2f55ba9acd..c2f1df13a6 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -1,5 +1,6 @@ use std::{collections::BTreeMap, process::ExitStatus}; +use owo_colors::OwoColorize; use serde::Serialize; use vp_pm_cli::{ PackageManagerType, package_manager_bin_path, package_manager_install_dir, @@ -137,7 +138,7 @@ pub async fn execute( } if let Some(node) = node { - print_section("Node.js", &node); + print_section("Node.js", &node, true); } if let Some(mut package_managers) = package_managers { for kind in package_manager::selected(scope) { @@ -148,6 +149,7 @@ pub async fn execute( print_section( package_manager::title(kind), &package_managers.remove(&name).unwrap_or_default(), + false, ); } } @@ -173,12 +175,13 @@ pub(super) fn list_complete_package_manager_versions( .collect() } -fn print_section(title: &str, versions: &[InstalledVersionJson]) { +fn print_section(title: &str, versions: &[InstalledVersionJson], node: bool) { println!("{title}"); if versions.is_empty() { println!(" No versions installed."); return; } + let colorize = use_color(); for version in versions { let mut markers = Vec::new(); if version.current { @@ -187,8 +190,23 @@ fn print_section(title: &str, versions: &[InstalledVersionJson]) { if version.default { markers.push("default"); } - let suffix = - if markers.is_empty() { String::new() } else { format!(" ({})", markers.join(", ")) }; - println!(" * {}{suffix}", version.version); + let suffix = if markers.is_empty() { + String::new() + } else if colorize { + format!(" {}", markers.join(" ").dimmed()) + } else { + format!(" {}", markers.join(" ")) + }; + let display = if node { format!("v{}", version.version) } else { version.version.clone() }; + let line = format!("* {display}"); + if version.current && colorize { + println!(" {}{suffix}", line.bright_blue()); + } else { + println!(" {line}{suffix}"); + } } } + +pub(super) fn use_color() -> bool { + vp_shared::is_stdout_terminal() && std::env::var_os("NO_COLOR").is_none() +} diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index 420bcf2570..b75902ea74 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -9,7 +9,7 @@ use vt_path::AbsolutePathBuf; use super::{ config, - list::{list_complete_package_manager_versions, list_installed_versions}, + list::{list_complete_package_manager_versions, list_installed_versions, use_color}, package_manager, spec::{EnvScope, parse_package_manager_spec}, }; @@ -173,11 +173,7 @@ pub async fn execute( println!(); println!("{}", package_manager::title(kind)); for entry in versions { - println!( - " {}{}", - entry.version, - marker(entry.installed, entry.current, entry.default) - ); + println!(" {}", format_package_manager_version(&entry, use_color())); } } } @@ -190,38 +186,65 @@ fn print_node_versions(versions: &[NodeVersionJson]) { return; } - let colorize = vp_shared::is_stdout_terminal() && std::env::var_os("NO_COLOR").is_none(); + let colorize = use_color(); for entry in versions { println!(" {}", format_node_version(entry, colorize)); } } +fn format_package_manager_version(entry: &PackageManagerVersionJson, colorize: bool) -> String { + format_remote_version( + &entry.version, + "", + entry.installed, + entry.current, + entry.default, + colorize, + ) +} + fn format_node_version(entry: &NodeVersionJson, colorize: bool) -> String { let display = format!("v{}", entry.version); let lts = entry.lts.as_ref().map(|name| format!(" ({name})")).unwrap_or_default(); + format_remote_version(&display, <s, entry.installed, entry.current, entry.default, colorize) +} + +fn format_remote_version( + display: &str, + annotation: &str, + installed: bool, + current: bool, + default: bool, + colorize: bool, +) -> String { let mut labels = Vec::new(); - if entry.current { + if current { labels.push("current"); } - if entry.default { + if default { labels.push("default"); } let labels = if labels.is_empty() { String::new() } else { format!(" {}", labels.join(" ")) }; if colorize { - let display = if entry.current { + let display = if current { display.bright_blue().to_string() - } else if entry.installed { + } else if installed { display.green().to_string() } else { - display + display.to_string() + }; + let annotation = if annotation.is_empty() { + String::new() + } else { + annotation.bright_blue().to_string() }; - let lts = if lts.is_empty() { lts } else { lts.bright_blue().to_string() }; let labels = if labels.is_empty() { labels } else { labels.dimmed().to_string() }; - format!("{display}{lts}{labels}") + format!("{display}{annotation}{labels}") } else { - let marker = if entry.installed { "* " } else { " " }; - format!("{marker}{display}{lts}{labels}") + // Preserve installed state in redirected output, where color is unavailable. + let marker = if installed { "* " } else { " " }; + format!("{marker}{display}{annotation}{labels}") } } @@ -359,20 +382,6 @@ fn matches_pattern(version: &str, pattern: Option<&str>) -> bool { version.starts_with(pattern) || version.starts_with(&format!("{pattern}.")) } -fn marker(installed: bool, current: bool, default: bool) -> String { - let mut markers = Vec::new(); - if installed { - markers.push("installed"); - } - if current { - markers.push("current"); - } - if default { - markers.push("default"); - } - if markers.is_empty() { String::new() } else { format!(" ({})", markers.join(", ")) } -} - #[cfg(test)] mod tests { use super::*; @@ -392,6 +401,19 @@ mod tests { assert_eq!(format_node_version(&entry, false), " v22.11.0 (Jod)"); } + #[test] + fn human_package_manager_version_keeps_plain_text_markers() { + let entry = PackageManagerVersionJson { + version: "10.18.0".into(), + latest: false, + installed: true, + current: true, + default: true, + }; + + assert_eq!(format_package_manager_version(&entry, false), "* 10.18.0 current default"); + } + #[test] fn legacy_pattern_keeps_all_components_selected() { assert_eq!( From 3b3cf016a1a16356e76b4f4f92b956716f20b9c6 Mon Sep 17 00:00:00 2001 From: Liang Date: Fri, 21 Aug 2026 14:23:47 +0800 Subject: [PATCH 14/37] wip --- .../snapshots/command_env_list_format.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md new file mode 100644 index 0000000000..f8d84f4015 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md @@ -0,0 +1,41 @@ +# command_env_list_format + +## `vpt mkdir -p $VP_HOME/js_runtime/node/22.11.0` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx '#'\!'/bin/sh +'` + + +## `vp env default 22.11.0 pnpm@10.18.0` + + +## `vp env list node` + +Installed Node.js versions retain their interactive formatting + +``` +VITE+ - The Unified Toolchain for the Web + +Node.js + * \x1b[94mv22.11.0 \x1b[2mcurrent default + +\x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` + +## `vp env list pnpm` + +Installed package-manager versions use the same interactive formatting + +``` +VITE+ - The Unified Toolchain for the Web + +pnpm + * \x1b[94m10.18.0 \x1b[2mcurrent default + +\x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` From db1f4f56b8947598646759a75f357140cf7ea709 Mon Sep 17 00:00:00 2001 From: Liang Date: Fri, 28 Aug 2026 22:43:12 +0800 Subject: [PATCH 15/37] test(env): align list formatting snapshot --- .../snapshots/command_env_list_format.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md index f8d84f4015..803486c6e8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md @@ -22,7 +22,7 @@ Installed Node.js versions retain their interactive formatting VITE+ - The Unified Toolchain for the Web Node.js - * \x1b[94mv22.11.0 \x1b[2mcurrent default + \x1b[94m* \x1b[2mcurrent default \x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. ``` @@ -35,7 +35,7 @@ Installed package-manager versions use the same interactive formatting VITE+ - The Unified Toolchain for the Web pnpm - * \x1b[94m10.18.0 \x1b[2mcurrent default + \x1b[94m* 10.18.0 \x1b[2mcurrent default \x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. ``` From b8cf946469a25bcd9bd57aa942d74fb5a8905bca Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 12:52:28 +0800 Subject: [PATCH 16/37] support pm famaliy --- .../snapshots/cli_helper_message.md | 5 +- .../print-doctor-configuration.cjs | 12 +++ .../snapshots.toml | 26 ++++++ ..._env_doctor_mixed_package_manager_modes.md | 17 ++++ ..._env_doctor_shared_package_manager_mode.md | 11 +++ .../command_env_package_manager_modes.md | 89 +++++++++++++++++++ crates/vp_global_cli/src/cli.rs | 12 +-- .../vp_global_cli/src/commands/env/config.rs | 53 +++++++++++ .../vp_global_cli/src/commands/env/current.rs | 8 +- .../vp_global_cli/src/commands/env/doctor.rs | 31 +++++-- crates/vp_global_cli/src/commands/env/exec.rs | 2 +- crates/vp_global_cli/src/commands/env/mod.rs | 3 +- crates/vp_global_cli/src/commands/env/off.rs | 25 +++--- crates/vp_global_cli/src/commands/env/on.rs | 25 +++--- .../vp_global_cli/src/commands/env/which.rs | 4 +- crates/vp_global_cli/src/commands/mod.rs | 3 +- crates/vp_global_cli/src/help.rs | 5 +- crates/vp_global_cli/src/shim/dispatch.rs | 4 +- docs/guide/env.md | 9 +- 19 files changed, 294 insertions(+), 50 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_mixed_package_manager_modes.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_shared_package_manager_mode.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 85c12b4bbf..5281024f66 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -464,8 +464,8 @@ Manage Node.js and package-manager environments Setup: setup Create or update shims in VP_HOME/bin - on Enable managed mode for Node.js and package managers - off Enable system-first mode for Node.js and package managers + on Enable managed mode for selected environment scopes + off Enable system-first mode for selected environment scopes print Print PATH setup for the resolved environment Manage: @@ -490,6 +490,7 @@ Examples: vp env setup # Create Node.js and package-manager shims vp env on # Manage Node.js and package managers vp env off pm # Prefer system package managers only + vp env off pnpm # Prefer system pnpm only vp env print # Print PATH setup for both components Manage: diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs new file mode 100644 index 0000000000..779ada9e22 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs @@ -0,0 +1,12 @@ +const { spawnSync } = require('node:child_process') + +const result = spawnSync('vp', ['env', 'doctor'], { encoding: 'utf8' }) +if (result.error) + throw result.error + +const output = result.stdout.replace(/\u001b\[[0-9;]*m/g, '') +const lines = output.split('\n') +const start = lines.findIndex(line => line.trim() === 'Configuration') +const end = lines.findIndex((line, index) => index > start && line.trim() === 'PATH') + +console.log(lines.slice(start, end).filter(line => !line.includes('IDE integration')).join('\n').trimEnd()) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml new file mode 100644 index 0000000000..2e7b0b3e52 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml @@ -0,0 +1,26 @@ +[[case]] +name = "command_env_package_manager_modes" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vp", "env", "off", "pnpm"], comment = "switch only pnpm to system-first mode" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"]], comment = "pnpm uses its individual mode" }, + { argv = ["vp", "env", "current", "bun", "--json"], envs = [["VP_PACKAGE_MANAGER", "bun@1.2.3"]], comment = "bun keeps the shared managed mode" }, + { argv = ["vp", "env", "on", "pnpm"], comment = "restore only pnpm to managed mode" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"]], comment = "pnpm returns to the shared managed mode" }, +] + +[[case]] +name = "command_env_doctor_shared_package_manager_mode" +vp = "global" +steps = [ + { argv = ["node", "print-doctor-configuration.cjs"], comment = "doctor keeps one package-manager row when every mode matches" }, +] + +[[case]] +name = "command_env_doctor_mixed_package_manager_modes" +vp = "global" +steps = [ + { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, + { argv = ["node", "print-doctor-configuration.cjs"], comment = "doctor prints each package manager when their modes differ" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_mixed_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_mixed_package_manager_modes.md new file mode 100644 index 0000000000..449174647b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_mixed_package_manager_modes.md @@ -0,0 +1,17 @@ +# command_env_doctor_mixed_package_manager_modes + +## `vp env off pnpm` + + +## `node print-doctor-configuration.cjs` + +doctor prints each package manager when their modes differ + +``` +Configuration + ✓ Node.js managed mode + ✓ npm managed mode + ✓ pnpm system-first mode + ✓ Yarn managed mode + ✓ Bun managed mode +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_shared_package_manager_mode.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_shared_package_manager_mode.md new file mode 100644 index 0000000000..85b3ea02ea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_shared_package_manager_mode.md @@ -0,0 +1,11 @@ +# command_env_doctor_shared_package_manager_mode + +## `node print-doctor-configuration.cjs` + +doctor keeps one package-manager row when every mode matches + +``` +Configuration + ✓ Node.js managed mode + ✓ Package manager managed mode +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md new file mode 100644 index 0000000000..c8cd2d1c6d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md @@ -0,0 +1,89 @@ +# command_env_package_manager_modes + +## `vp env off pnpm` + +switch only pnpm to system-first mode + +``` +VITE+ - The Unified Toolchain for the Web + +✓ pnpm 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_PACKAGE_MANAGER=pnpm@10.18.0 vp env current pnpm --json` + +pnpm uses its individual mode + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "system PATH", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": false, + "mode": "system_first" + } +} +``` + +## `VP_PACKAGE_MANAGER=bun@1.2.3 vp env current bun --json` + +bun keeps the shared managed mode + +``` +{ + "package_manager": { + "name": "bun", + "version": "", + "source": "VP_PACKAGE_MANAGER", + "bin_paths": { + "bun": "/.vite-plus/package_manager/bun//bun/bin/bun", + "bunx": "/.vite-plus/package_manager/bun//bun/bin/bunx" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vp env on pnpm` + +restore only pnpm to managed mode + +``` +VITE+ - The Unified Toolchain for the Web + +✓ pnpm management set to managed. + +Selected commands and shims will now use Vite+ managed tools. + +Run `vp env off` to prefer system tools instead. +``` + +## `VP_PACKAGE_MANAGER=pnpm@10.18.0 vp env current pnpm --json` + +pnpm returns to the shared managed mode + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "VP_PACKAGE_MANAGER", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": false, + "mode": "managed" + } +} +``` diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 2ac1622235..f05b4dab81 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -324,15 +324,15 @@ Examples: unset: bool, }, - /// Enable managed mode for Node.js and package managers + /// Enable managed mode for Node.js, package managers, or one package manager On { - /// Change only node or package-manager mode + /// Change only node, pm, npm, pnpm, yarn, or bun mode scope: Option, }, - /// Enable system-first mode for Node.js and package managers + /// Enable system-first mode for Node.js, package managers, or one package manager Off { - /// Change only node or package-manager mode + /// Change only node, pm, npm, pnpm, yarn, or bun mode scope: Option, }, @@ -676,7 +676,9 @@ async fn run_package_manager_command( commands::prepend_js_runtime_to_path_env(&cwd).await?; let selected = commands::env::package_manager::resolve_current(&cwd).await?; let result = if let Some(selected) = selected.as_ref() - && commands::env::config::load_config().await?.package_manager_shim_mode() + && commands::env::config::load_config() + .await? + .package_manager_shim_mode_for(selected.package_manager_type) == commands::env::config::ShimMode::SystemFirst && let Some(system_path) = crate::shim::dispatch::find_system_tool(&selected.package_manager_type.to_string()) diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index b166aa96e1..de68b12d92 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -5,11 +5,14 @@ //! - Version resolution with priority order //! - Config file management +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use vp_js_runtime::{ NodeProvider, VersionSource, is_valid_version, normalize_version, read_nvmrc_file, read_package_json, resolve_node_version, }; +use vp_pm_cli::PackageManagerType; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::error::Error; @@ -44,6 +47,9 @@ pub struct Config { /// Package-manager shim mode. Inherits `shim_mode` when absent. #[serde(default, skip_serializing_if = "Option::is_none")] pub package_manager_shim_mode: Option, + /// Per-package-manager shim modes. Each inherits the shared package-manager mode when absent. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub package_manager_shim_modes: BTreeMap, } impl Config { @@ -52,6 +58,14 @@ impl Config { self.package_manager_shim_mode.unwrap_or(self.shim_mode) } + #[must_use] + pub fn package_manager_shim_mode_for(&self, package_manager: PackageManagerType) -> ShimMode { + self.package_manager_shim_modes + .get(&package_manager.to_string()) + .copied() + .unwrap_or_else(|| self.package_manager_shim_mode()) + } + pub fn set_shim_modes(&mut self, node: bool, package_manager: bool, mode: ShimMode) { if node && !package_manager && self.package_manager_shim_mode.is_none() { self.package_manager_shim_mode = Some(self.package_manager_shim_mode()); @@ -61,6 +75,20 @@ impl Config { } if package_manager { self.package_manager_shim_mode = Some(mode); + self.package_manager_shim_modes.clear(); + } + } + + pub fn set_package_manager_shim_mode( + &mut self, + package_manager: PackageManagerType, + mode: ShimMode, + ) { + let package_manager = package_manager.to_string(); + if mode == self.package_manager_shim_mode() { + self.package_manager_shim_modes.remove(&package_manager); + } else { + self.package_manager_shim_modes.insert(package_manager, mode); } } } @@ -1469,6 +1497,31 @@ mod tests { assert_eq!(config.package_manager_shim_mode(), ShimMode::Managed); } + #[test] + fn package_manager_mode_change_preserves_other_package_manager_modes() { + let mut config = Config::default(); + config.set_package_manager_shim_mode(PackageManagerType::Pnpm, ShimMode::SystemFirst); + + assert_eq!( + config.package_manager_shim_mode_for(PackageManagerType::Pnpm), + ShimMode::SystemFirst + ); + assert_eq!( + config.package_manager_shim_mode_for(PackageManagerType::Bun), + ShimMode::Managed + ); + + config.set_shim_modes(false, true, ShimMode::Managed); + assert_eq!( + config.package_manager_shim_mode_for(PackageManagerType::Pnpm), + ShimMode::Managed + ); + assert_eq!( + config.package_manager_shim_mode_for(PackageManagerType::Bun), + ShimMode::Managed + ); + } + #[test] fn package_manager_only_mode_change_preserves_node_mode() { let mut config = Config { shim_mode: ShimMode::SystemFirst, ..Config::default() }; diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 79b63b05b4..ca0c003b7f 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -105,7 +105,9 @@ pub async fn execute( let resolution = package_manager::resolve_current_for(&cwd, scope.package_manager()).await?; resolution.and_then(|resolution| { - let system_paths = (config.package_manager_shim_mode() == ShimMode::SystemFirst) + let package_manager_mode = + config.package_manager_shim_mode_for(resolution.package_manager_type); + let system_paths = (package_manager_mode == ShimMode::SystemFirst) .then(|| { resolution .package_manager_type @@ -139,7 +141,7 @@ pub async fn execute( let installed = bin_paths.values().all(|path| std::path::Path::new(path).exists()); let system_version = bin_paths .get(resolution.package_manager_type.to_string().as_str()) - .filter(|_| config.package_manager_shim_mode() == ShimMode::SystemFirst) + .filter(|_| package_manager_mode == ShimMode::SystemFirst) .and_then(|path| vt_path::AbsolutePathBuf::new(path.into())); let is_system = system_version.is_some(); Some(PackageManagerInfo { @@ -163,7 +165,7 @@ pub async fn execute( .map(|path| path.as_path().display().to_string()), bin_paths, installed, - mode: config.package_manager_shim_mode(), + mode: package_manager_mode, }) }) } else { diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index d38a536b53..24a89c339d 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -61,6 +61,14 @@ fn print_check(status: &str, key: &str, value: &str) { } } +fn print_package_manager_mode(key: &str, mode: ShimMode) { + let mode = match mode { + ShimMode::Managed => "managed mode", + ShimMode::SystemFirst => "system-first mode", + }; + print_check(&output::CHECK.green().to_string(), key, mode); +} + /// Print a continuation/hint line (dimmed). fn print_hint(text: &str) { println!(" {}", format!("note: {text}").dimmed()); @@ -282,11 +290,24 @@ async fn check_shim_mode(scope: EnvScope) -> (ShimMode, Option) } } if scope.includes_package_managers() { - let mode = match config.package_manager_shim_mode() { - ShimMode::Managed => "managed mode", - ShimMode::SystemFirst => "system-first mode", - }; - print_check(&output::CHECK.green().to_string(), "Package manager", mode); + if let Some(package_manager) = scope.package_manager() { + print_package_manager_mode( + "Package manager", + config.package_manager_shim_mode_for(package_manager), + ); + } else { + let modes = package_manager::ALL_PACKAGE_MANAGERS.map(|package_manager| { + (package_manager, config.package_manager_shim_mode_for(package_manager)) + }); + let shared_mode = modes[0].1; + if modes.iter().all(|(_, mode)| *mode == shared_mode) { + print_package_manager_mode("Package manager", shared_mode); + } else { + for (package_manager, mode) in modes { + print_package_manager_mode(package_manager::title(package_manager), mode); + } + } + } } (config.shim_mode, system_node_path) diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index b081a8c927..29b34a94bc 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -164,7 +164,7 @@ async fn execute_with_version( }; let resolved_package_manager = if let Some((kind, version, hash)) = selected_package_manager { if !explicit_package_manager - && modes.package_manager_shim_mode() == config::ShimMode::SystemFirst + && modes.package_manager_shim_mode_for(kind) == config::ShimMode::SystemFirst && let Some(path) = crate::shim::dispatch::find_system_tool(&kind.to_string()) && let Some(bin_dir) = path.parent() { diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index e6fd91a416..01efdfa409 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -180,7 +180,8 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result) -> Result { let scope = EnvScope::parse(scope.as_deref())?; - if matches!(scope, EnvScope::PackageManager(_)) { - return Err(Error::Other("off accepts only node or pm as a scope".into())); - } let mut config = load_config().await?; - config.set_shim_modes( - scope.includes_node(), - scope.includes_package_managers(), - ShimMode::SystemFirst, - ); + if let EnvScope::PackageManager(package_manager) = scope { + config.set_package_manager_shim_mode(package_manager, ShimMode::SystemFirst); + } else { + config.set_shim_modes( + scope.includes_node(), + scope.includes_package_managers(), + ShimMode::SystemFirst, + ); + } save_config(&config).await?; let component = match scope { - EnvScope::All => "Node.js and package-manager management", - EnvScope::Node => "Node.js management", - EnvScope::PackageManagers => "Package-manager management", - EnvScope::PackageManager(_) => unreachable!(), + EnvScope::All => "Node.js and package-manager management".into(), + EnvScope::Node => "Node.js management".into(), + EnvScope::PackageManagers => "Package-manager management".into(), + EnvScope::PackageManager(package_manager) => format!("{package_manager} management"), }; println!("\u{2713} {component} set to system-first."); println!(); diff --git a/crates/vp_global_cli/src/commands/env/on.rs b/crates/vp_global_cli/src/commands/env/on.rs index a4f74afd76..b414c57185 100644 --- a/crates/vp_global_cli/src/commands/env/on.rs +++ b/crates/vp_global_cli/src/commands/env/on.rs @@ -13,22 +13,23 @@ use crate::{error::Error, help}; /// Execute the `vp env on` command. pub async fn execute(scope: Option) -> Result { let scope = EnvScope::parse(scope.as_deref())?; - if matches!(scope, EnvScope::PackageManager(_)) { - return Err(Error::Other("on accepts only node or pm as a scope".into())); - } let mut config = load_config().await?; - config.set_shim_modes( - scope.includes_node(), - scope.includes_package_managers(), - ShimMode::Managed, - ); + if let EnvScope::PackageManager(package_manager) = scope { + config.set_package_manager_shim_mode(package_manager, ShimMode::Managed); + } else { + config.set_shim_modes( + scope.includes_node(), + scope.includes_package_managers(), + ShimMode::Managed, + ); + } save_config(&config).await?; let component = match scope { - EnvScope::All => "Node.js and package-manager management", - EnvScope::Node => "Node.js management", - EnvScope::PackageManagers => "Package-manager management", - EnvScope::PackageManager(_) => unreachable!(), + EnvScope::All => "Node.js and package-manager management".into(), + EnvScope::Node => "Node.js management".into(), + EnvScope::PackageManagers => "Package-manager management".into(), + EnvScope::PackageManager(package_manager) => format!("{package_manager} management"), }; println!("\u{2713} {component} set to managed."); println!(); diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 7d4436dcd7..127fd1a154 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -34,8 +34,8 @@ const LABEL_WIDTH: usize = 10; /// Execute the which command. pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result { let config = super::config::load_config().await?; - let mode = if PackageManagerType::from_tool(tool).is_some() { - config.package_manager_shim_mode() + let mode = if let Some(package_manager) = PackageManagerType::from_tool(tool) { + config.package_manager_shim_mode_for(package_manager) } else { config.shim_mode }; diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index b5f3d9bfa3..e18d89aaf1 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -132,7 +132,8 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu } if let Some(package_manager) = env::package_manager::resolve_current(project_path).await? { let config = env::config::load_config().await?; - if config.package_manager_shim_mode() == env::config::ShimMode::SystemFirst + if config.package_manager_shim_mode_for(package_manager.package_manager_type) + == env::config::ShimMode::SystemFirst && let Some(system_path) = crate::shim::dispatch::find_system_tool( &package_manager.package_manager_type.to_string(), ) diff --git a/crates/vp_global_cli/src/help.rs b/crates/vp_global_cli/src/help.rs index c0442f098a..785c41082b 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_global_cli/src/help.rs @@ -336,8 +336,8 @@ fn env_help_doc() -> HelpDoc { "Setup", vec![ row("setup", "Create or update shims in VP_HOME/bin"), - row("on", "Enable managed mode for Node.js and package managers"), - row("off", "Enable system-first mode for Node.js and package managers"), + row("on", "Enable managed mode for selected environment scopes"), + row("off", "Enable system-first mode for selected environment scopes"), row("print", "Print PATH setup for the resolved environment"), ], ), @@ -374,6 +374,7 @@ fn env_help_doc() -> HelpDoc { " vp env setup # Create Node.js and package-manager shims", " vp env on # Manage Node.js and package managers", " vp env off pm # Prefer system package managers only", + " vp env off pnpm # Prefer system pnpm only", " vp env print # Print PATH setup for both components", "", " Manage:", diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 657b1f7ef0..83b522c2f1 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -1260,8 +1260,8 @@ async fn load_shim_mode(tool: &str) -> ShimMode { config::load_config() .await .map(|config| { - if PackageManagerType::from_tool(tool).is_some() { - config.package_manager_shim_mode() + if let Some(package_manager) = PackageManagerType::from_tool(tool) { + config.package_manager_shim_mode_for(package_manager) } else { config.shim_mode } diff --git a/docs/guide/env.md b/docs/guide/env.md index a5cd02ca24..cdf0a8fc7f 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -54,11 +54,13 @@ If you want to keep that behavior, run: vp env on ``` -This enables managed mode for both components. Their modes can also be changed independently: +This enables managed mode for both components. Their modes can also be changed independently, including one package-manager family: ```bash vp env on node vp env off pm +vp env off pnpm +vp env on bun ``` If you do not want Vite+ to manage Node.js first, run: @@ -69,12 +71,14 @@ vp env off This switches both components to system-first mode. Vite+ prefers system tools and falls back to managed installations. Mixed configurations compose: a system package-manager launcher receives the Node.js selected by the Node mode. +Using `pm` changes all package managers and replaces any individual package-manager choices. An unscoped `on` or `off` does the same while also changing Node.js. + ## Commands ### Setup - `vp env setup` creates or updates the `node`, `npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`, `bun`, `bunx`, `vpx`, and `vpr` shims in the resolved bin directory. It writes shell setup scripts in the config directory. -- `vp env on` / `vp env off` changes both modes; append `node` or `pm` to change one +- `vp env on` / `vp env off` changes both modes; append `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the change - `vp env print` prints PATH setup for both components; append a selector to print one PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: @@ -147,6 +151,7 @@ shim calls in the same job use these files to resolve the same environment. vp env setup # Create Node.js and package-manager shims vp env on # Manage Node.js and package managers vp env off pm # Prefer system package managers only +vp env off pnpm # Prefer system pnpm only vp env print # Print PATH setup for both components # Manage From 2881870255ed4e099b6a64e0b7c8b3a546ff969e Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 14:40:19 +0800 Subject: [PATCH 17/37] feat(env): add package manager shim preferences --- Cargo.lock | 1 + .../snapshots.toml | 35 +++++++ ...e_use_preserves_system_without_deciding.md | 23 +++++ ...fers_existing_family_and_records_choice.md | 95 ++++++++++++++++++ .../system-bin/pnpm | 2 + .../system-bin/yarn | 2 + .../tests/cli_snapshots/main.rs | 20 +++- crates/vp_global_cli/Cargo.toml | 1 + .../vp_global_cli/src/commands/env/config.rs | 52 +++++----- crates/vp_global_cli/src/shim/dispatch.rs | 97 +++++++++++++++++-- crates/vp_installer/src/main.rs | 23 ++++- docs/guide/env.md | 6 +- packages/cli/install.ps1 | 8 +- packages/cli/install.sh | 7 +- 14 files changed, 323 insertions(+), 49 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/pnpm create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/yarn diff --git a/Cargo.lock b/Cargo.lock index 6701c1784d..d3d0360777 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8529,6 +8529,7 @@ dependencies = [ name = "vp_global_cli" version = "0.3.0" dependencies = [ + "base64-simd", "chrono", "clap", "clap_complete", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml new file mode 100644 index 0000000000..001189a592 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -0,0 +1,35 @@ +[[case]] +name = "prefers_existing_family_and_records_choice" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-bin/yarn"], snapshot = false }, + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "an upgraded install asks before replacing an existing pnpm", interactions = [ + { "expect-milestone" = "pm-shim-choice:pnpm" }, + { "write-key" = "down" }, + { "write-key" = "down" }, + { "write-key" = "enter" }, + ] }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "the explicit system choice records only pnpm" }, + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "later pnpm invocations use the recorded choice without prompting" }, + { argv = ["yarn", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "an undecided Yarn family still prompts", interactions = [ + { "expect-milestone" = "pm-shim-choice:yarn" }, + { "write-key" = "down" }, + { "write-key" = "down" }, + { "write-key" = "enter" }, + ] }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "Yarn records its own decision without changing pnpm" }, +] + +[[case]] +name = "non_interactive_use_preserves_system_without_deciding" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, + { argv = ["pnpm", "--version"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "a non-interactive upgrade keeps using system pnpm without prompting" }, + { argv = ["vpt", "stat-file", "$VP_HOME/config.json", "--assert", "missing"], comment = "non-interactive use does not record a choice" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md new file mode 100644 index 0000000000..63a733b39f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md @@ -0,0 +1,23 @@ +# non_interactive_use_preserves_system_without_deciding + +## `vpt rm -f $VP_HOME/config.json` + + +## `vpt chmod +x system-bin/pnpm` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` + +a non-interactive upgrade keeps using system pnpm without prompting + +``` +system-pnpm +``` + +## `vpt stat-file $VP_HOME/config.json --assert missing` + +non-interactive use does not record a choice + +``` +/.vite-plus/config.json: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md new file mode 100644 index 0000000000..5097552043 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md @@ -0,0 +1,95 @@ +# prefers_existing_family_and_records_choice + +## `vpt rm -f $VP_HOME/config.json` + + +## `vpt chmod +x system-bin/pnpm` + + +## `vpt chmod +x system-bin/yarn` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` + +an upgraded install asks before replacing an existing pnpm + +**→ expect-milestone:** `pm-shim-choice:pnpm` + +``` +vp: Vite+ now can manage package-manager versions for each project. +Existing pnpm: /system-bin/pnpm +``` + +**← write-key:** `down` + +**← write-key:** `down` + +**← write-key:** `enter` + +``` +vp: Vite+ now can manage package-manager versions for each project. +Existing pnpm: /system-bin/pnpm + +? How should pnpm run? › +✔ How should pnpm run? · Use system pnpm +system-pnpm +``` + +## `vpt print-file $VP_HOME/config.json` + +the explicit system choice records only pnpm + +``` +{ + "packageManagerShimModes": { + "pnpm": "system_first" + } +} +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` + +later pnpm invocations use the recorded choice without prompting + +``` +system-pnpm +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} yarn --version` + +an undecided Yarn family still prompts + +**→ expect-milestone:** `pm-shim-choice:yarn` + +``` +vp: Vite+ now can manage package-manager versions for each project. +Existing yarn: /system-bin/yarn +``` + +**← write-key:** `down` + +**← write-key:** `down` + +**← write-key:** `enter` + +``` +vp: Vite+ now can manage package-manager versions for each project. +Existing yarn: /system-bin/yarn + +? How should yarn run? › +✔ How should yarn run? · Use system yarn +system-yarn +``` + +## `vpt print-file $VP_HOME/config.json` + +Yarn records its own decision without changing pnpm + +``` +{ + "packageManagerShimModes": { + "pnpm": "system_first", + "yarn": "system_first" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/pnpm b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/pnpm new file mode 100644 index 0000000000..a8b43d75ca --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/pnpm @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'system-pnpm\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/yarn b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/yarn new file mode 100644 index 0000000000..2af7f82425 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/yarn @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'system-yarn\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index cbe09b519d..f5e7d8ea6c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -693,12 +693,28 @@ impl CaseHome { .envs(&env) .output() .map_err(|e| format!("failed to run `vp env setup`: {e}"))?; + if !output.status.success() { + return Err(format!( + "`vp env setup` failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + + // Cases start from fresh-install consent. A dedicated first-use fixture + // removes this config before exercising upgrade compatibility. + let output = std::process::Command::new(vp) + .args(["env", "on", "pm"]) + .env_clear() + .envs(&env) + .output() + .map_err(|e| format!("failed to run `vp env on pm`: {e}"))?; if output.status.success() { return Ok(()); } - Err(format!( - "`vp env setup` failed with status {}\nstdout:\n{}\nstderr:\n{}", + "`vp env on pm` failed with status {}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index bb411ba613..06f8b7acd8 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -12,6 +12,7 @@ name = "vp" path = "src/main.rs" [dependencies] +base64-simd = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true, features = ["unstable-dynamic"] } diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index de68b12d92..6799fb3a55 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -15,6 +15,7 @@ use vp_js_runtime::{ use vp_pm_cli::PackageManagerType; use vt_path::{AbsolutePath, AbsolutePathBuf}; +use super::package_manager::ALL_PACKAGE_MANAGERS; use crate::error::Error; /// Config file name @@ -44,38 +45,38 @@ pub struct Config { /// Shim mode for tool resolution #[serde(default, skip_serializing_if = "is_default_shim_mode")] pub shim_mode: ShimMode, - /// Package-manager shim mode. Inherits `shim_mode` when absent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub package_manager_shim_mode: Option, - /// Per-package-manager shim modes. Each inherits the shared package-manager mode when absent. + /// Explicit per-package-manager shim modes. An absent family has not been configured yet. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub package_manager_shim_modes: BTreeMap, } impl Config { #[must_use] - pub fn package_manager_shim_mode(&self) -> ShimMode { - self.package_manager_shim_mode.unwrap_or(self.shim_mode) + pub fn configured_package_manager_shim_mode_for( + &self, + package_manager: PackageManagerType, + ) -> Option { + self.package_manager_shim_modes.get(&package_manager.to_string()).copied() } #[must_use] pub fn package_manager_shim_mode_for(&self, package_manager: PackageManagerType) -> ShimMode { - self.package_manager_shim_modes - .get(&package_manager.to_string()) - .copied() - .unwrap_or_else(|| self.package_manager_shim_mode()) + self.configured_package_manager_shim_mode_for(package_manager).unwrap_or_default() } pub fn set_shim_modes(&mut self, node: bool, package_manager: bool, mode: ShimMode) { - if node && !package_manager && self.package_manager_shim_mode.is_none() { - self.package_manager_shim_mode = Some(self.package_manager_shim_mode()); - } if node { self.shim_mode = mode; } if package_manager { - self.package_manager_shim_mode = Some(mode); - self.package_manager_shim_modes.clear(); + self.set_all_package_manager_shim_modes(mode); + } + } + + pub fn set_all_package_manager_shim_modes(&mut self, mode: ShimMode) { + self.package_manager_shim_modes.clear(); + for package_manager in ALL_PACKAGE_MANAGERS { + self.set_package_manager_shim_mode(package_manager, mode); } } @@ -84,12 +85,7 @@ impl Config { package_manager: PackageManagerType, mode: ShimMode, ) { - let package_manager = package_manager.to_string(); - if mode == self.package_manager_shim_mode() { - self.package_manager_shim_modes.remove(&package_manager); - } else { - self.package_manager_shim_modes.insert(package_manager, mode); - } + self.package_manager_shim_modes.insert(package_manager.to_string(), mode); } } @@ -1486,15 +1482,11 @@ mod tests { } #[test] - fn node_only_mode_change_preserves_inherited_package_manager_mode() { - let mut config = Config { - shim_mode: ShimMode::Managed, - package_manager_shim_mode: None, - ..Config::default() - }; + fn node_only_mode_change_leaves_package_managers_unconfigured() { + let mut config = Config::default(); config.set_shim_modes(true, false, ShimMode::SystemFirst); assert_eq!(config.shim_mode, ShimMode::SystemFirst); - assert_eq!(config.package_manager_shim_mode(), ShimMode::Managed); + assert_eq!(config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), None); } #[test] @@ -1510,6 +1502,7 @@ mod tests { config.package_manager_shim_mode_for(PackageManagerType::Bun), ShimMode::Managed ); + assert_eq!(config.configured_package_manager_shim_mode_for(PackageManagerType::Bun), None); config.set_shim_modes(false, true, ShimMode::Managed); assert_eq!( @@ -1520,6 +1513,7 @@ mod tests { config.package_manager_shim_mode_for(PackageManagerType::Bun), ShimMode::Managed ); + assert_eq!(config.package_manager_shim_modes.len(), ALL_PACKAGE_MANAGERS.len()); } #[test] @@ -1527,7 +1521,7 @@ mod tests { let mut config = Config { shim_mode: ShimMode::SystemFirst, ..Config::default() }; config.set_shim_modes(false, true, ShimMode::Managed); assert_eq!(config.shim_mode, ShimMode::SystemFirst); - assert_eq!(config.package_manager_shim_mode(), ShimMode::Managed); + assert_eq!(config.package_manager_shim_modes.len(), ALL_PACKAGE_MANAGERS.len()); } #[tokio::test] diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 83b522c2f1..51fe2a10da 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -5,6 +5,7 @@ //! 2. Node.js installation (if needed) //! 3. Tool execution (core shims and package binaries) +use dialoguer::{Select, theme::ColorfulTheme}; use vp_pm_cli::{PackageManagerType, ensure_package_manager_bin}; use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; @@ -1257,16 +1258,94 @@ pub(crate) fn locate_tool(version: &str, tool: &str) -> Result ShimMode { - config::load_config() - .await - .map(|config| { - if let Some(package_manager) = PackageManagerType::from_tool(tool) { - config.package_manager_shim_mode_for(package_manager) + let Some(package_manager) = PackageManagerType::from_tool(tool) else { + return config::load_config().await.map(|config| config.shim_mode).unwrap_or_default(); + }; + resolve_package_manager_shim_mode(tool, package_manager).await +} + +async fn resolve_package_manager_shim_mode( + tool: &str, + package_manager: PackageManagerType, +) -> ShimMode { + let mut config = match config::load_config().await { + Ok(config) => config, + Err(error) => { + output::warn(&format!("Could not read package-manager shim preferences: {error}")); + return if find_system_tool(tool).is_some() { + ShimMode::SystemFirst } else { - config.shim_mode - } - }) - .unwrap_or_default() + ShimMode::Managed + }; + } + }; + if let Some(mode) = config.configured_package_manager_shim_mode_for(package_manager) { + return mode; + } + + let Some(system_path) = find_system_tool(tool) else { + return config.shim_mode; + }; + + if !vp_shared::is_interactive_terminal() { + return ShimMode::SystemFirst; + } + + let (mode, apply_to_all) = prompt_package_manager_shim_mode(package_manager, &system_path); + if apply_to_all { + config.set_all_package_manager_shim_modes(mode); + } else { + config.set_package_manager_shim_mode(package_manager, mode); + } + if let Err(error) = config::save_config(&config).await { + output::warn(&format!("Could not save package-manager shim preferences: {error}")); + } + mode +} + +fn prompt_package_manager_shim_mode( + package_manager: PackageManagerType, + system_path: &AbsolutePath, +) -> (ShimMode, bool) { + let options = [ + "Use Vite+ for all package managers".to_string(), + format!("Use Vite+ for {package_manager}"), + format!("Use system {package_manager}"), + "Use system package managers".to_string(), + ]; + + output::raw_stderr("vp: Vite+ now can manage package-manager versions for each project."); + output::raw_stderr(&format!("Existing {package_manager}: {}", system_path.as_path().display())); + output::raw_stderr(""); + emit_prompt_milestone(&format!("pm-shim-choice:{package_manager}")); + let choice = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("How should {package_manager} run?")) + .items(&options) + .default(0) + .interact() + .unwrap_or(0); + + match choice { + 0 => (ShimMode::Managed, true), + 1 => (ShimMode::Managed, false), + 2 => (ShimMode::SystemFirst, false), + _ => (ShimMode::SystemFirst, true), + } +} + +/// Emit an invisible synchronization point for the PTY snapshot suite. +#[expect(clippy::disallowed_macros)] +fn emit_prompt_milestone(name: &str) { + use std::io::Write as _; + + if std::env::var_os(env_vars::VP_EMIT_MILESTONES).is_none_or(|value| value != "1") { + return; + } + let id = uuid::Uuid::new_v4(); + let encoded_name = base64_simd::URL_SAFE_NO_PAD.encode_to_string(name.as_bytes()); + let mut stderr = std::io::stderr().lock(); + let _ = write!(stderr, "\x1b]2;pty-terminal-test:{}:{encoded_name}\x1b\\", id.simple()); + let _ = stderr.flush(); } /// Find a system tool in PATH, skipping the vite-plus bin directory and any diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 22a15a5e04..6b6d86466b 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -221,10 +221,23 @@ async fn do_install(opts: &cli::Options, dirs: &VpDirs) -> Result<(), Box { + let vp_binary = dirs.data.join("current").join("bin").join(VP_BINARY_NAME); + let preference_result = tokio::process::Command::new(vp_binary.as_path()) + .args(["env", "on", "pm"]) + .output() + .await; + if !preference_result.is_ok_and(|output| output.status.success()) { + print_warn("Failed to record package-manager management preference."); + } + } + Ok(()) => {} + Err(e) => { + print_warn(&format!("Node.js and package-manager setup failed (non-fatal): {e}")) + } } } else if let Err(e) = install::create_env_files(&dirs.data).await { print_warn(&format!("Env file creation failed (non-fatal): {e}")); @@ -515,7 +528,7 @@ fn show_interactive_menu(opts: &mut cli::Options, data_dir: &str, bin_dir: &str) ); println!(" Version: {}", style(version).cyan()); println!( - " Node.js manager: {}", + " Node.js / package managers: {}", style(if opts.no_node_manager { "disabled" } else { "enabled" }).cyan() ); println!(); @@ -548,7 +561,7 @@ fn show_customize_menu(opts: &mut cli::Options) { println!(" 1) Version: [{}]", style(version_display).cyan()); println!(" 2) npm registry: [{}]", style(registry_display).cyan()); println!( - " 3) Node.js manager: [{}]", + " 3) Node.js / package managers: [{}]", style(if opts.no_node_manager { "disabled" } else { "enabled" }).cyan() ); println!( diff --git a/docs/guide/env.md b/docs/guide/env.md index cdf0a8fc7f..946cc313b6 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -4,7 +4,9 @@ ## Overview -Managed mode is on by default, so Node.js and package-manager shims resolve through Vite+ and pick the right versions for the current project. +Managed mode is on by default, so Node.js and configured package-manager shims resolve through Vite+ and pick the right versions for the current project. Fresh installers record managed mode for npm, pnpm, Yarn, and Bun after the user enables environment management. + +When an upgrade adds a package-manager shim that has no recorded mode, its first interactive invocation asks what to do only when the corresponding system binary is already on PATH. If no system binary is found, the package manager inherits `shimMode`. Non-interactive invocations preserve an existing system command without recording a choice. Most commands operate on both components when no selector is given. Add `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the command. `pm` means all four families for listing and cleanup, but the single selected package manager for project operations. @@ -71,7 +73,7 @@ vp env off This switches both components to system-first mode. Vite+ prefers system tools and falls back to managed installations. Mixed configurations compose: a system package-manager launcher receives the Node.js selected by the Node mode. -Using `pm` changes all package managers and replaces any individual package-manager choices. An unscoped `on` or `off` does the same while also changing Node.js. +Using `pm` records the selected mode for all currently supported package managers and replaces their individual choices. An unscoped `on` or `off` does the same while also changing Node.js. A family without a recorded mode remains undecided until its shim is first used or an `on` / `off` command configures it. ## Commands diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 650793e3df..07a3edda67 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -879,7 +879,7 @@ function Setup-NodeManager { $isInteractive = [Environment]::UserInteractive -and -not $env:CI if ($isInteractive) { Write-Host "" - Write-Host "Would you like Vite+ to manage your Node.js versions?" + Write-Host "Would you like Vite+ to manage your Node.js and package-manager versions?" Write-Host "Vite+ adds ``node``, ``npm``, ``npx``, ``pnpm``, ``pnpx``, ``yarn``, ``yarnpkg``, ``bun``, and ``bunx`` shims to $NodeManagerBinDisplay." Write-Host "It selects the required version automatically." Write-Host "Opt out anytime with ``vp env off``." @@ -1169,6 +1169,12 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" # Setup Node.js version manager (shims) - separate component $nodeManagerResult = Setup-NodeManager -BinDir $BinDir + if ($nodeManagerResult -eq "true") { + & "$BinDir\vp.exe" env on pm *> $null + if ($LASTEXITCODE -ne 0) { + Write-Warn "Failed to record package-manager management preference." + } + } Prompt-RemovePreviousInstallDir -PreviousInstallDir $previousInstallDir diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 7704e2a3dc..a28e31e441 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -1130,7 +1130,7 @@ setup_node_manager() { # Prompt user in interactive mode if [ -e /dev/tty ] && [ -t 1 ]; then echo "" - echo "Would you like Vite+ to manage your Node.js versions?" + echo "Would you like Vite+ to manage your Node.js and package-manager versions?" echo "Vite+ adds \`node\`, \`npm\`, \`npx\`, \`pnpm\`, \`pnpx\`, \`yarn\`, \`yarnpkg\`, \`bun\`, and \`bunx\` shims to $(abbreviate_path "$SHIM_DIR")." echo "It selects the required version automatically." echo "Opt out anytime with \`vp env off\`." @@ -1417,6 +1417,11 @@ WRAPPER_EOF # Setup Node.js version manager (shims) - separate component setup_node_manager "$BIN_DIR" + if [ "$NODE_MANAGER_ENABLED" = "true" ]; then + if ! "$vp_bin" env on pm > /dev/null 2>&1; then + warn "Failed to record package-manager management preference." + fi + fi prompt_remove_previous_install_dir "$previous_install_dir" From 2704c6e19ead6fbc66024c9863e1673998532af0 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 14:46:34 +0800 Subject: [PATCH 18/37] fix(installer): support older Vite+ payloads --- .../command_env_package_manager_modes/snapshots.toml | 2 +- .../snapshots/command_env_package_manager_modes.md | 2 +- packages/cli/install.ps1 | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml index 2e7b0b3e52..51847f685c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml @@ -4,7 +4,7 @@ vp = "global" seed-runtime = false steps = [ { argv = ["vp", "env", "off", "pnpm"], comment = "switch only pnpm to system-first mode" }, - { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"]], comment = "pnpm uses its individual mode" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"], ["VP_BYPASS", "${PATH}"]], comment = "pnpm uses its individual mode" }, { argv = ["vp", "env", "current", "bun", "--json"], envs = [["VP_PACKAGE_MANAGER", "bun@1.2.3"]], comment = "bun keeps the shared managed mode" }, { argv = ["vp", "env", "on", "pnpm"], comment = "restore only pnpm to managed mode" }, { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"]], comment = "pnpm returns to the shared managed mode" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md index c8cd2d1c6d..6c751e3ec1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md @@ -14,7 +14,7 @@ Selected commands and shims will now prefer system tools, falling back to manage Run `vp env on` to always use Vite+ managed tools. ``` -## `VP_PACKAGE_MANAGER=pnpm@10.18.0 vp env current pnpm --json` +## `VP_PACKAGE_MANAGER=pnpm@10.18.0 VP_BYPASS=${PATH} vp env current pnpm --json` pnpm uses its individual mode diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 07a3edda67..4c8f23a2c0 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -1170,8 +1170,16 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" # Setup Node.js version manager (shims) - separate component $nodeManagerResult = Setup-NodeManager -BinDir $BinDir if ($nodeManagerResult -eq "true") { - & "$BinDir\vp.exe" env on pm *> $null - if ($LASTEXITCODE -ne 0) { + $previousErrorActionPreference = $ErrorActionPreference + try { + # Older Vite+ payloads do not support the pm scope. + $ErrorActionPreference = "Continue" + & $vpBin env on pm *> $null + $preferenceExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($preferenceExitCode -ne 0) { Write-Warn "Failed to record package-manager management preference." } } From a2ec9ba6c9334cbb7969b429e7695b5bc5f41304 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 14:48:02 +0800 Subject: [PATCH 19/37] fix(installer): clear handled preference errors --- packages/cli/install.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 4c8f23a2c0..f1f40c62a3 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -1182,6 +1182,7 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" if ($preferenceExitCode -ne 0) { Write-Warn "Failed to record package-manager management preference." } + $LASTEXITCODE = 0 } Prompt-RemovePreviousInstallDir -PreviousInstallDir $previousInstallDir From 770152f58b267ca7dbbd4e58b2cf72fff3a4cc36 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 14:49:57 +0800 Subject: [PATCH 20/37] fix(installer): reset global payload exit code --- packages/cli/install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index f1f40c62a3..540da31024 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -1182,7 +1182,7 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" if ($preferenceExitCode -ne 0) { Write-Warn "Failed to record package-manager management preference." } - $LASTEXITCODE = 0 + $global:LASTEXITCODE = 0 } Prompt-RemovePreviousInstallDir -PreviousInstallDir $previousInstallDir From c9b5e6af9af6ca40c6c794bc167b6148342c0169 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 14:54:16 +0800 Subject: [PATCH 21/37] fix(installer): enable the complete managed environment --- crates/vp_installer/src/main.rs | 4 ++-- packages/cli/install.ps1 | 5 ++--- packages/cli/install.sh | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 6b6d86466b..05208888f2 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -227,11 +227,11 @@ async fn do_install(opts: &cli::Options, dirs: &VpDirs) -> Result<(), Box { let vp_binary = dirs.data.join("current").join("bin").join(VP_BINARY_NAME); let preference_result = tokio::process::Command::new(vp_binary.as_path()) - .args(["env", "on", "pm"]) + .args(["env", "on"]) .output() .await; if !preference_result.is_ok_and(|output| output.status.success()) { - print_warn("Failed to record package-manager management preference."); + print_warn("Failed to record environment management preference."); } } Ok(()) => {} diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 540da31024..858ba4f9e6 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -1172,15 +1172,14 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" if ($nodeManagerResult -eq "true") { $previousErrorActionPreference = $ErrorActionPreference try { - # Older Vite+ payloads do not support the pm scope. $ErrorActionPreference = "Continue" - & $vpBin env on pm *> $null + & $vpBin env on *> $null $preferenceExitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorActionPreference } if ($preferenceExitCode -ne 0) { - Write-Warn "Failed to record package-manager management preference." + Write-Warn "Failed to record environment management preference." } $global:LASTEXITCODE = 0 } diff --git a/packages/cli/install.sh b/packages/cli/install.sh index a28e31e441..7f0c88f5be 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -1418,8 +1418,8 @@ WRAPPER_EOF # Setup Node.js version manager (shims) - separate component setup_node_manager "$BIN_DIR" if [ "$NODE_MANAGER_ENABLED" = "true" ]; then - if ! "$vp_bin" env on pm > /dev/null 2>&1; then - warn "Failed to record package-manager management preference." + if ! "$vp_bin" env on > /dev/null 2>&1; then + warn "Failed to record environment management preference." fi fi From c6239833a6b5e06ee1c524e732a49e93c0a641ff Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 15:18:54 +0800 Subject: [PATCH 22/37] fix(env): address package manager review regressions --- .../snapshots.toml | 20 ++++- ...r_list_stays_offline_for_selected_range.md | 20 +++++ ...n_package_manager_requires_confirmation.md | 26 ++++++ ..._pin_warns_when_package_manager_differs.md | 2 +- .../snapshots.toml | 14 +++- ...requested_dev_engines_manager_effective.md | 2 +- ...n_package_manager_from_nested_workspace.md | 2 +- ...in_targets_shadowed_dev_engines_manager.md | 28 +++++++ .../command_env_use_shells/.node-version | 1 + .../command_env_use_shells/snapshots.toml | 9 +++ ...e_clears_stale_package_manager_override.md | 11 +++ .../fixtures/command_env_which/snapshots.toml | 9 +++ ...print_concrete_package_manager_fallback.md | 12 +++ .../snapshots.toml | 12 +++ ...stall_auto_pins_lockfile_manager.global.md | 33 ++++++++ ...nstall_auto_pins_lockfile_manager.local.md | 33 ++++++++ .../snapshots.toml | 13 +++ ..._package_manager_uses_system_first_node.md | 27 +++++++ .../system-dispatch-bin/node | 2 + .../system-dispatch-bin/pnpm | 6 ++ crates/vp_global_cli/src/cli.rs | 9 +-- crates/vp_global_cli/src/commands/env/list.rs | 2 +- crates/vp_global_cli/src/commands/env/mod.rs | 79 +++++++++++++------ crates/vp_global_cli/src/commands/env/pin.rs | 62 +++++++++++++-- .../vp_global_cli/src/commands/env/setup.rs | 2 +- crates/vp_global_cli/src/commands/env/use.rs | 45 +++++++++-- crates/vp_global_cli/src/commands/mod.rs | 23 ++++-- crates/vp_pm_cli/src/dispatch.rs | 75 ++++++++++++++---- crates/vp_pm_cli/src/package_manager.rs | 2 +- 29 files changed, 508 insertions(+), 73 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/.node-version create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_clears_stale_package_manager_override.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_concrete_package_manager_fallback.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/node create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/pnpm diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml index bb24d5035d..69e84ccf5d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -3,7 +3,7 @@ name = "env_pin_warns_when_package_manager_differs" vp = "global" seed-runtime = false steps = [ - { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "an explicit project manager warns before a different manager is pinned" }, + { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install", "--force"], comment = "an explicit project manager warns before a different manager is pinned" }, ] [[case]] @@ -56,3 +56,21 @@ steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, { argv = ["vp", "env", "list", "yarn", "--json"], comment = "a package-manager selector does not resolve an excluded package-manager family", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, ] + +[[case]] +name = "env_package_manager_list_stays_offline_for_selected_range" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "list", "pm", "--json"], comment = "local listing remains available when the selected range cannot reach the registry", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] + +[[case]] +name = "env_pin_package_manager_requires_confirmation" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "pipe-stdin", "n\n", "--", "vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "declining the overwrite prompt preserves the existing package-manager pin" }, + { argv = ["vpt", "print-file", "package.json"], comment = "package.json was not rewritten" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md new file mode 100644 index 0000000000..3c80bbcc0c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md @@ -0,0 +1,20 @@ +# env_package_manager_list_stays_offline_for_selected_range + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list pm --json` + +local listing remains available when the selected range cannot reach the registry + +``` +{ + "package_managers": { + "bun": [], + "npm": [], + "pnpm": [], + "yarn": [] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md new file mode 100644 index 0000000000..5d80c7b545 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md @@ -0,0 +1,26 @@ +# env_pin_package_manager_requires_confirmation + +## `vpt pipe-stdin 'n +' -- vp env pin yarn@4.12.0 --no-install` + +declining the overwrite prompt preserves the existing package-manager pin + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to pnpm from packageManager, but yarn was requested. +Package manager already pinned to pnpm@10.18.0 +Overwrite with yarn@4.12.0? (Y/n): Cancelled. +``` + +## `vpt print-file package.json` + +package.json was not rewritten + +``` +{ + "name": "command-env-package-manager-mismatch", + "private": true, + "packageManager": "pnpm@10.18.0" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md index bbb0438058..feae6ff454 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md @@ -1,6 +1,6 @@ # env_pin_warns_when_package_manager_differs -## `vp env pin yarn@4.12.0 --no-install` +## `vp env pin yarn@4.12.0 --no-install --force` an explicit project manager warns before a different manager is pinned diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml index 29a844fada..e84c5c8ada 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml @@ -3,7 +3,7 @@ name = "env_pin_makes_requested_dev_engines_manager_effective" vp = "global" seed-runtime = false steps = [ - { argv = ["vp", "env", "pin", "pnpm@10.18.0", "--no-install"], comment = "pinning an existing later option makes it the effective first supported entry" }, + { argv = ["vp", "env", "pin", "pnpm@10.18.0", "--no-install", "--force"], comment = "pinning an existing later option makes it the effective first supported entry" }, { argv = ["vp", "env", "current", "pm", "--json"], comment = "current resolves the newly pinned manager" }, { argv = ["vpt", "print-file", "package.json"], comment = "the pin preserves sibling options and their policy" }, ] @@ -14,7 +14,17 @@ vp = "global" seed-runtime = false cwd = "packages/app" steps = [ - { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "a nested workspace pin updates the resolver-owned root manifest" }, + { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install", "--force"], comment = "a nested workspace pin updates the resolver-owned root manifest" }, { argv = ["vp", "env", "current", "pm", "--json"], comment = "the nested project resolves the new root pin" }, { argv = ["vpt", "print-file", "../../package.json", "package.json"], comment = "only the workspace manifest owns the package-manager pin" }, ] + +[[case]] +name = "env_unpin_targets_shadowed_dev_engines_manager" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-pin-package-manager\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\",\"devEngines\":{\"packageManager\":{\"name\":\"yarn\",\"version\":\"4.12.0\",\"onFail\":\"download\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "unpin", "pm", "--target", "dev-engines"], comment = "an explicit target removes the devEngines manager even when packageManager shadows it" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the effective top-level packageManager remains intact" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md index 7c0461090c..ba3655a086 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md @@ -1,6 +1,6 @@ # env_pin_makes_requested_dev_engines_manager_effective -## `vp env pin pnpm@10.18.0 --no-install` +## `vp env pin pnpm@10.18.0 --no-install --force` pinning an existing later option makes it the effective first supported entry diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md index 5422195f05..b5c1d47dc1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md @@ -1,6 +1,6 @@ # env_pin_package_manager_from_nested_workspace -## `vp env pin yarn@4.12.0 --no-install` +## `vp env pin yarn@4.12.0 --no-install --force` a nested workspace pin updates the resolver-owned root manifest diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md new file mode 100644 index 0000000000..eac0ebfc87 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md @@ -0,0 +1,28 @@ +# env_unpin_targets_shadowed_dev_engines_manager + +## `vpt write-file package.json '{"name":"command-env-pin-package-manager","private":true,"packageManager":"pnpm@10.18.0","devEngines":{"packageManager":{"name":"yarn","version":"4.12.0","onFail":"download"}}} +'` + + +## `vp env unpin pm --target dev-engines` + +an explicit target removes the devEngines manager even when packageManager shadows it + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed package-manager pin +``` + +## `vpt print-file package.json` + +the effective top-level packageManager remains intact + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "packageManager": "pnpm@10.18.0", + "devEngines": {} +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/.node-version new file mode 100644 index 0000000000..2a393af592 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/.node-version @@ -0,0 +1 @@ +20.18.0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml index 52b8c20b4a..42d1d16309 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml @@ -14,3 +14,12 @@ steps = [ { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect case-insensitive fish", envs = [["VP_SHELL", "FISH"]], continue-on-failure = true }, { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect case-insensitive powershell", envs = [["VP_SHELL", "POWERSHELL"]], continue-on-failure = true }, ] + +[[case]] +name = "env_use_clears_stale_package_manager_override" +vp = "global" +skip-platforms = ["windows"] +env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_PACKAGE_MANAGER = "pnpm@10.18.0" } +steps = [ + { argv = ["vp", "env", "use", "--no-install"], comment = "activating a project with no package-manager selection clears the previous override", envs = [["VP_SHELL", "bash"]] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_clears_stale_package_manager_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_clears_stale_package_manager_override.md new file mode 100644 index 0000000000..7ddcc11086 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_clears_stale_package_manager_override.md @@ -0,0 +1,11 @@ +# env_use_clears_stale_package_manager_override + +## `VP_SHELL=bash vp env use --no-install` + +activating a project with no package-manager selection clears the previous override + +``` +export VP_NODE_VERSION=20.18.0 +unset VP_PACKAGE_MANAGER +Using Node.js (resolved from .node-version) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml index 6f83226fca..9f27abfd77 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml @@ -13,3 +13,12 @@ steps = [ { argv = ["vp", "remove", "-g", "cowsay"], comment = "Cleanup", continue-on-failure = true }, { argv = ["vp", "env", "which", "unknown-tool"], comment = "Unknown tool - error message", continue-on-failure = true }, ] + +[[case]] +name = "command_env_print_concrete_package_manager_fallback" +vp = "global" +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "env", "print", "pnpm"], comment = "a concrete family falls back to its latest release and Fish keeps a spaced install path intact", envs = [["VP_HOME", "${workspace}/custom home"], ["VP_SHELL", "fish"]] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_concrete_package_manager_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_concrete_package_manager_fallback.md new file mode 100644 index 0000000000..61117a08ea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_concrete_package_manager_fallback.md @@ -0,0 +1,12 @@ +# command_env_print_concrete_package_manager_fallback + +## `VP_HOME=${workspace}/custom home VP_SHELL=fish vp env print pnpm` + +a concrete family falls back to its latest release and Fish keeps a spaced install path intact + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +set -gx PATH "/custom home/package_manager/pnpm//pnpm/bin" $PATH +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml index c959e6c0d3..187a08b12d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml @@ -10,3 +10,15 @@ steps = [ { tty = false, argv = ["vp", "add", "testnpm2", "-D"], comment = "should add package to auto-created package.json" }, { argv = ["vpt", "print-file", "package.json"], continue-on-failure = true }, ] + +[[case]] +name = "command_install_auto_pins_lockfile_manager" +vp = ["local", "global"] +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"lockfile-project\",\"private\":true}\n"], snapshot = false }, + { argv = ["vpt", "touch-file", "pnpm-lock.yaml"], snapshot = false }, + { tty = false, argv = ["vp", "install", "--silent"], comment = "installing with a lockfile-inferred manager records the exact resolved version" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the inferred manager is pinned in devEngines" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md new file mode 100644 index 0000000000..7347877f99 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md @@ -0,0 +1,33 @@ +# command_install_auto_pins_lockfile_manager + +## `vpt write-file package.json '{"name":"lockfile-project","private":true} +'` + + +## `vpt touch-file pnpm-lock.yaml` + + +## `vp install --silent` + +installing with a lockfile-inferred manager records the exact resolved version + +``` +``` + +## `vpt print-file package.json` + +the inferred manager is pinned in devEngines + +``` +{ + "name": "lockfile-project", + "private": true, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md new file mode 100644 index 0000000000..7347877f99 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md @@ -0,0 +1,33 @@ +# command_install_auto_pins_lockfile_manager + +## `vpt write-file package.json '{"name":"lockfile-project","private":true} +'` + + +## `vpt touch-file pnpm-lock.yaml` + + +## `vp install --silent` + +installing with a lockfile-inferred manager records the exact resolved version + +``` +``` + +## `vpt print-file package.json` + +the inferred manager is pinned in devEngines + +``` +{ + "name": "lockfile-project", + "private": true, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index 001189a592..be81086280 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -23,6 +23,19 @@ steps = [ { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "Yarn records its own decision without changing pnpm" }, ] +[[case]] +name = "system_package_manager_uses_system_first_node" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"system-package-manager\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/node"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/pnpm"], snapshot = false }, + { argv = ["vp", "env", "off", "node"], snapshot = false }, + { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, + { argv = ["vp", "install"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"]], comment = "a system-first package manager receives the Node.js selected by system-first Node mode" }, +] + [[case]] name = "non_interactive_use_preserves_system_without_deciding" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md new file mode 100644 index 0000000000..a6b0cfd769 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md @@ -0,0 +1,27 @@ +# system_package_manager_uses_system_first_node + +## `vpt write-file package.json '{"name":"system-package-manager","private":true,"packageManager":"pnpm@10.18.0"} +'` + + +## `vpt chmod +x system-dispatch-bin/node` + + +## `vpt chmod +x system-dispatch-bin/pnpm` + + +## `vp env off node` + + +## `vp env off pnpm` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} vp install` + +a system-first package manager receives the Node.js selected by system-first Node mode + +``` +VITE+ - The Unified Toolchain for the Web + +system-node +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/node b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/node new file mode 100644 index 0000000000..e5c7245721 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/node @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'system-node\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/pnpm b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/pnpm new file mode 100644 index 0000000000..a18e39da1b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/pnpm @@ -0,0 +1,6 @@ +#!/bin/sh +if [ "${1-}" = "--version" ]; then + printf '10.18.0\n' +else + node --version +fi diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index f05b4dab81..dfce5e53e5 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -685,16 +685,11 @@ async fn run_package_manager_command( && let Some(manager) = system_package_manager(selected.package_manager_type, &system_path).await { - vp_pm_cli::dispatch_with_resolved_package_manager(&cwd, command, manager).await? + vp_pm_cli::dispatch_with_resolved_package_manager(&cwd, command, manager, selected).await? } else { match selected { Some(selected) => { - vp_pm_cli::dispatch_with_package_manager( - &cwd, - command, - (selected.package_manager_type, &selected.version, selected.hash.as_deref()), - ) - .await? + vp_pm_cli::dispatch_with_package_manager(&cwd, command, &selected).await? } None => vp_pm_cli::dispatch_with_metadata(&cwd, command).await?, } diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index c2f1df13a6..fcefd6d733 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -59,7 +59,7 @@ pub async fn execute( None }; let current_pm = if scope.includes_package_managers() { - package_manager::resolve_current_for(&cwd, scope.package_manager()).await? + package_manager::resolve_current_for(&cwd, scope.package_manager()).await.ok().flatten() } else { None }; diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 01efdfa409..8e065ef4f8 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -176,45 +176,80 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result { + Some((resolution.package_manager_type, resolution.version, resolution.hash)) + } + None => match scope.package_manager() { + Some(package_manager) => Some(( + package_manager, + vp_pm_cli::resolve_package_manager_version(package_manager, "latest").await?, + None, + )), + None => None, + }, + } + } else { + None + }; + if let Some((package_manager, version, hash)) = package_manager { + if modes.package_manager_shim_mode_for(package_manager) == config::ShimMode::SystemFirst + && let Some(path) = + crate::shim::dispatch::find_system_tool(&package_manager.to_string()) && let Some(bin_dir) = path.parent() { bin_dirs.insert(0, bin_dir.as_path().display().to_string()); } else { - let (install_dir, _, _) = vp_pm_cli::download_package_manager( - resolution.package_manager_type, - &resolution.version, - resolution.hash.as_deref(), - ) - .await?; + let (install_dir, _, _) = + vp_pm_cli::download_package_manager(package_manager, &version, hash.as_deref()) + .await?; bin_dirs.insert(0, install_dir.join("bin").as_path().display().to_string()); } } if bin_dirs.is_empty() { return Err(Error::Other("no selected environment component could be resolved".into())); } - let snippet = match detect_shell() { + let snippet = format_path_snippet(detect_shell(), &bin_dirs); + + // Print shell snippet + println!("# Add to your shell to use this environment for this session:"); + println!("{snippet}"); + + Ok(ExitStatus::default()) +} + +fn format_path_snippet(shell: Shell, bin_dirs: &[String]) -> String { + match shell { Shell::Posix => format!("export PATH=\"{}:$PATH\"", bin_dirs.join(":")), - Shell::Fish => format!("set -gx PATH {} $PATH", bin_dirs.join(" ")), + Shell::Fish => format!( + "set -gx PATH {} $PATH", + bin_dirs + .iter() + .map(|path| format!("\"{}\"", setup::escape_fish_double_quoted_string(path))) + .collect::>() + .join(" ") + ), Shell::PowerShell => format!("$env:PATH = \"{};$env:PATH\"", bin_dirs.join(";")), Shell::Cmd => format!("set PATH={};%PATH%", bin_dirs.join(";")), Shell::NuShell => format!( "$env.PATH = ($env.PATH | prepend [{}])", bin_dirs.iter().map(|path| format!("\"{path}\"")).collect::>().join(", ") ), - }; + } +} - // Print shell snippet - println!("# Add to your shell to use this environment for this session:"); - println!("{snippet}"); +#[cfg(test)] +mod tests { + use super::*; - Ok(ExitStatus::default()) + #[test] + fn fish_path_snippet_quotes_each_directory() { + let snippet = format_path_snippet( + Shell::Fish, + &["/Users/Example User/node/bin".into(), "/tmp/pm/bin".into()], + ); + + assert_eq!(snippet, "set -gx PATH \"/Users/Example User/node/bin\" \"/tmp/pm/bin\" $PATH"); + } } diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index a5754611a6..3318f19d57 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -77,8 +77,15 @@ pub async fn execute( do_pin(&cwd, &version, no_install, force, target).await?; } if let Some((package_manager, version)) = specs.package_manager { - pin_package_manager(&package_manager_root, package_manager, &version, no_install, target) - .await?; + pin_package_manager( + &package_manager_root, + package_manager, + &version, + no_install, + force, + target, + ) + .await?; } Ok(ExitStatus::default()) } @@ -682,6 +689,7 @@ async fn pin_package_manager( package_manager: PackageManagerType, version: &str, no_install: bool, + force: bool, target: Option, ) -> Result { if matches!(target, Some(PinTarget::NodeVersion)) { @@ -691,10 +699,20 @@ async fn pin_package_manager( package_manager::warn_if_target_differs(cwd, package_manager).await; let package_json_path = cwd.join(PACKAGE_JSON_FILE); let content = tokio::fs::read_to_string(&package_json_path).await?; + let package_json: serde_json::Value = serde_json::from_str(&content)?; + let use_top_level = matches!(target, Some(PinTarget::PackageManager)) + || (target.is_none() && package_json.get("packageManager").is_some()); + if let Some((name, version)) = existing_package_manager_pin(&content, use_top_level) { + let existing = format!("{name}@{version}"); + let next = format!("{package_manager}@{resolved}"); + if existing != next + && !confirm_overwrite_pin("Package manager already pinned to", &existing, &next, force)? + { + return Ok(ExitStatus::default()); + } + } let mut changed = false; let updated = vp_shared::edit_json_object(&content, |obj| { - let use_top_level = matches!(target, Some(PinTarget::PackageManager)) - || (target.is_none() && obj.get("packageManager").is_some()); if use_top_level { let existing = obj.get("packageManager").and_then(serde_json::Value::as_str); let prefix = format!("{package_manager}@{resolved}"); @@ -730,6 +748,23 @@ async fn pin_package_manager( Ok(ExitStatus::default()) } +fn existing_package_manager_pin(content: &str, top_level: bool) -> Option<(String, String)> { + if top_level { + let package_json: serde_json::Value = serde_json::from_str(content).ok()?; + let value = package_json.get("packageManager")?.as_str()?; + let (name, version) = value.split_once('@')?; + return Some(( + name.into(), + version.split_once('+').map_or(version, |(version, _)| version).into(), + )); + } + + let package_json: vp_shared::PackageJson = serde_json::from_str(content).ok()?; + let package_manager = package_json.dev_engines?.package_manager?; + let entry = package_manager.entries().first()?; + Some((entry.name.to_string(), entry.version.as_deref().unwrap_or("*").to_string())) +} + fn set_dev_engines_package_manager( obj: &mut serde_json::Map, package_manager: PackageManagerType, @@ -795,6 +830,17 @@ async fn unpin_package_manager( let effective = resolve_package_manager_from_package_json(&root)?; let expected = match scope { EnvScope::PackageManager(package_manager) => Some(package_manager), + _ if matches!(target, Some(PinTarget::DevEngines)) => { + let package_json: vp_shared::PackageJson = serde_json::from_str(&content)?; + package_json.dev_engines.and_then(|dev_engines| dev_engines.package_manager).and_then( + |field| { + field + .entries() + .iter() + .find_map(|entry| PackageManagerType::from_name(&entry.name)) + }, + ) + } _ => effective.as_ref().map(|resolution| resolution.package_manager_type), }; let mut changed = false; @@ -898,7 +944,9 @@ mod tests { .await .unwrap(); - pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, None).await.unwrap(); + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, true, None) + .await + .unwrap(); let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); assert!(content.contains("pnpm@10.18.0+sha512.keep")); @@ -915,7 +963,9 @@ mod tests { .await .unwrap(); - pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, None).await.unwrap(); + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, true, None) + .await + .unwrap(); let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); assert!(content.contains("pnpm@10.18.0")); diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index a7d27725ff..dba839e290 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -948,7 +948,7 @@ fn escape_posix_double_quoted_string(value: &str) -> String { } /// Escape a value for a Fish double-quoted string. -fn escape_fish_double_quoted_string(value: &str) -> String { +pub(super) fn escape_fish_double_quoted_string(value: &str) -> String { value.replace('\\', "\\\\").replace('$', "\\$").replace('"', "\\\"") } diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 791652b7aa..65e048e5fc 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -75,13 +75,23 @@ fn package_manager_spec( package_manager: PackageManagerType, version: &str, hash: Option<&str>, -) -> String { +) -> Result { let mut spec = format!("{package_manager}@{version}"); if let Some(hash) = hash { + if hash.is_empty() + || !hash.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'.' | b'-' | b'_' | b'/' | b'+' | b'=') + }) + { + return Err(Error::Other( + format!("invalid package-manager integrity suffix {hash:?}").into(), + )); + } spec.push('+'); spec.push_str(hash); } - spec + Ok(spec) } /// Execute the `vp env use` command. @@ -189,12 +199,20 @@ pub async fn execute( }; let package_manager_unchanged = match &package_manager { Some((kind, version, _, hash)) => { + let spec = package_manager_spec(*kind, version, hash.as_deref())?; current_override( config::read_session_package_manager().await, vp_shared::EnvConfig::get().package_manager.clone(), ) .as_deref() - == Some(package_manager_spec(*kind, version, hash.as_deref()).as_str()) + == Some(spec.as_str()) + } + None if uses_project_environment && scope.includes_package_managers() => { + current_override( + config::read_session_package_manager().await, + vp_shared::EnvConfig::get().package_manager.clone(), + ) + .is_none() } None => true, }; @@ -251,9 +269,12 @@ pub async fn execute( format_export( &shell, PACKAGE_MANAGER_ENV_VAR, - &package_manager_spec(*kind, version, hash.as_deref()) + &package_manager_spec(*kind, version, hash.as_deref())? ) ); + } else if uses_project_environment && scope.includes_package_managers() { + config::delete_session_package_manager().await?; + println!("{}", format_unset(&shell, PACKAGE_MANAGER_ENV_VAR)); } } else if !can_use_session_file() { print_windows_eval_wrapper_required(); @@ -268,8 +289,10 @@ pub async fn execute( *kind, version, hash.as_deref(), - )) + )?) .await?; + } else if uses_project_environment && scope.includes_package_managers() { + config::delete_session_package_manager().await?; } } @@ -405,6 +428,18 @@ mod tests { assert_eq!(result, "hide-env VP_NODE_VERSION"); } + #[test] + fn package_manager_spec_rejects_shell_metacharacters() { + let error = package_manager_spec( + PackageManagerType::Pnpm, + "10.18.0", + Some("sha512.valid; touch injected"), + ) + .unwrap_err(); + + assert!(error.to_string().contains("invalid package-manager integrity suffix")); + } + #[cfg(windows)] #[tokio::test] async fn test_windows_direct_use_without_eval_wrapper_does_not_write_session_file() { diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index e18d89aaf1..51bd8ebb03 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -108,30 +108,37 @@ pub(crate) fn warn_missing_local_cli_if_project(cwd: &AbsolutePath) { } } -/// Ensure the JS runtime is downloaded and prepend its bin directory to PATH. +/// Select the configured JS runtime and prepend its bin directory to PATH. /// This should be called before executing any package manager command. /// /// If `project_path` contains a package.json, uses the project's runtime /// (based on devEngines.runtime). Otherwise, falls back to the CLI's runtime. pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Result<(), Error> { + let config = env::config::load_config().await?; let mut executor = JsExecutor::new(None); - // Use project runtime if package.json exists, otherwise use CLI runtime - let package_json_path = project_path.join("package.json"); - let runtime = if package_json_path.as_path().exists() { - executor.ensure_project_runtime(project_path).await? + let node_bin_prefix = if config.shim_mode == env::config::ShimMode::SystemFirst + && let Some(system_node) = crate::shim::dispatch::find_system_tool("node") + && let Some(bin_dir) = system_node.parent() + { + bin_dir.to_absolute_path_buf() } else { - executor.ensure_cli_runtime().await? + // Use project runtime if package.json exists, otherwise use CLI runtime + let package_json_path = project_path.join("package.json"); + let runtime = if package_json_path.as_path().exists() { + executor.ensure_project_runtime(project_path).await? + } else { + executor.ensure_cli_runtime().await? + }; + runtime.get_bin_prefix() }; - let node_bin_prefix = runtime.get_bin_prefix(); // Use dedupe_anywhere=true to check if node bin already exists anywhere in PATH let options = PrependOptions { dedupe_anywhere: true }; if prepend_to_path_env(&node_bin_prefix, options) { tracing::debug!("Set PATH to include {:?}", node_bin_prefix); } if let Some(package_manager) = env::package_manager::resolve_current(project_path).await? { - let config = env::config::load_config().await?; if config.package_manager_shim_mode_for(package_manager.package_manager_type) == env::config::ShimMode::SystemFirst && let Some(system_path) = crate::shim::dispatch::find_system_tool( diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index 543112d5c3..f60aa85b8b 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -8,7 +8,7 @@ use std::process::ExitStatus; use vt_path::AbsolutePath; use crate::{ - PackageManager, PackageManagerType, + EnvironmentPackageManagerResolution, PackageManager, cli::{PackageManagerCommand, PmCommand}, download_package_manager, error::Error, @@ -34,8 +34,8 @@ enum ManagerPolicy { enum ManagerSource<'a> { Detect, - Selected((PackageManagerType, &'a str, Option<&'a str>)), - Resolved(PackageManager), + Environment(&'a EnvironmentPackageManagerResolution), + ResolvedEnvironment(PackageManager, &'a EnvironmentPackageManagerResolution), } pub async fn dispatch( @@ -55,17 +55,23 @@ pub async fn dispatch_with_metadata( pub async fn dispatch_with_package_manager( cwd: &AbsolutePath, command: PackageManagerCommand, - package_manager: (PackageManagerType, &str, Option<&str>), + package_manager: &EnvironmentPackageManagerResolution, ) -> Result { - dispatch_with_manager(cwd, command, ManagerSource::Selected(package_manager)).await + dispatch_with_manager(cwd, command, ManagerSource::Environment(package_manager)).await } pub async fn dispatch_with_resolved_package_manager( cwd: &AbsolutePath, command: PackageManagerCommand, manager: PackageManager, + package_manager: &EnvironmentPackageManagerResolution, ) -> Result { - dispatch_with_manager(cwd, command, ManagerSource::Resolved(manager)).await + dispatch_with_manager( + cwd, + command, + ManagerSource::ResolvedEnvironment(manager, package_manager), + ) + .await } async fn dispatch_with_manager( @@ -78,7 +84,7 @@ async fn dispatch_with_manager( PackageManagerCommand::Dlx(args) => { let manager = match source { ManagerSource::Detect => return dispatch_dlx(cwd, args, render_diagnostics).await, - source => resolve_manager(source).await?, + source => resolve_manager(cwd, source).await?, }; let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; let status = run_resolution(cwd, resolution, render_diagnostics).await?; @@ -101,7 +107,7 @@ async fn dispatch_with_manager( } ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, }, - source => resolve_manager(source).await?, + source => resolve_manager(cwd, source).await?, }; let package_manager = manager.client; let why_hint_packages = command.why_hint_packages(package_manager).map(<[String]>::to_vec); @@ -110,22 +116,59 @@ async fn dispatch_with_manager( Ok(DispatchResult { status, why_hint_packages }) } -async fn resolve_manager(source: ManagerSource<'_>) -> Result { +async fn resolve_manager( + cwd: &AbsolutePath, + source: ManagerSource<'_>, +) -> Result { match source { - ManagerSource::Selected(package_manager) => { - build_selected_package_manager(package_manager).await + ManagerSource::Environment(package_manager) => { + let manager = build_selected_package_manager(package_manager).await?; + auto_pin_environment_package_manager(cwd, package_manager, &manager).await?; + Ok(manager) + } + ManagerSource::ResolvedEnvironment(manager, package_manager) => { + auto_pin_environment_package_manager(cwd, package_manager, &manager).await?; + Ok(manager) } - ManagerSource::Resolved(manager) => Ok(manager), ManagerSource::Detect => unreachable!("detected managers are resolved from the cwd"), } } async fn build_selected_package_manager( - (kind, version, hash): (PackageManagerType, &str, Option<&str>), + package_manager: &EnvironmentPackageManagerResolution, ) -> Result { - let (install_dir, _, version) = - download_package_manager(kind, version, hash).await.map_err(Error::Install)?; - Ok(PackageManager { client: kind, version, install_dir }) + let (install_dir, _, version) = download_package_manager( + package_manager.package_manager_type, + &package_manager.version, + package_manager.hash.as_deref(), + ) + .await + .map_err(Error::Install)?; + Ok(PackageManager { client: package_manager.package_manager_type, version, install_dir }) +} + +async fn auto_pin_environment_package_manager( + cwd: &AbsolutePath, + resolution: &EnvironmentPackageManagerResolution, + manager: &PackageManager, +) -> Result<(), Error> { + if matches!(resolution.source.as_str(), "lockfile or config" | "default") { + let project_root = resolution.project_root.clone().or_else(|| { + vt_workspace::find_workspace_root(cwd) + .ok() + .map(|(workspace, _)| workspace.path.to_absolute_path_buf()) + }); + let Some(project_root) = project_root else { + return Ok(()); + }; + super::package_manager::set_dev_engines_package_manager_field( + &project_root.join("package.json"), + resolution.package_manager_type, + &manager.version, + ) + .await?; + } + Ok(()) } async fn dispatch_dlx( diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 609b7f359a..155f2c2dbb 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -1818,7 +1818,7 @@ async fn create_bun_shim_files(bin_prefix: &AbsolutePath) -> Result<(), Error> { /// the exact resolved version is recorded with `onFail: "download"` so future /// runs are deterministic. Preserves the file's key order and formatting style, /// placing `devEngines` next to `engines` when present. -async fn set_dev_engines_package_manager_field( +pub(crate) async fn set_dev_engines_package_manager_field( package_json_path: impl AsRef, package_manager_type: PackageManagerType, version: &str, From 67b4cfb3f7e527a5780b3b388266f3238cb95276 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 15:33:10 +0800 Subject: [PATCH 23/37] fix(env): address follow-up review regressions --- .../assert-package-manager-installed.cjs | 17 +++ .../snapshots.toml | 11 ++ ...nv_install_no_node_with_package_manager.md | 17 +++ .../assert-doctor-fails.cjs | 13 ++ .../snapshots.toml | 7 ++ ...ctor_package_manager_resolution_failure.md | 9 ++ .../assert-doctor-system-package-manager.cjs | 10 ++ .../snapshots.toml | 11 ++ ...mmand_env_doctor_system_package_manager.md | 19 +++ .../system-bin/pnpm | 2 + .../snapshots.toml | 4 +- ..._package_manager_uses_system_first_node.md | 6 +- crates/vp_global_cli/src/cli.rs | 3 +- .../vp_global_cli/src/commands/env/doctor.rs | 113 ++++++++++++++---- .../src/commands/env/lifecycle.rs | 35 +++--- crates/vp_global_cli/src/commands/mod.rs | 4 +- 16 files changed, 238 insertions(+), 43 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/assert-package-manager-installed.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/assert-doctor-fails.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_doctor_package_manager_resolution_failure.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/system-bin/pnpm diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/assert-package-manager-installed.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/assert-package-manager-installed.cjs new file mode 100644 index 0000000000..a0adec8b2b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/assert-package-manager-installed.cjs @@ -0,0 +1,17 @@ +const { spawnSync } = require('node:child_process') +const { writeFileSync } = require('node:fs') + +writeFileSync('package.json', JSON.stringify({ + name: 'command-env-install-no-node-with-package-manager', + private: true, + packageManager: 'pnpm@10.18.0', +})) + +const result = spawnSync('vp', ['env', 'install'], { encoding: 'utf8' }) +const output = `${result.stdout}${result.stderr}` +if (result.status !== 1 || !output.includes('Installed pnpm v10.18.0')) { + process.stderr.write(output) + process.exit(1) +} + +console.log('installed the declared package manager after reporting the missing Node.js pin') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml index 7cbe64e00f..7d7f7b0447 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml @@ -6,3 +6,14 @@ seed-runtime = false steps = [ { argv = ["vp", "env", "install"], comment = "No version config - should error", continue-on-failure = true }, ] + +[[case]] +name = "command_env_install_no_node_with_package_manager" +vp = "global" +skip-platforms = ["windows"] +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\n"], snapshot = false }, + { argv = ["node", "assert-package-manager-installed.cjs"], comment = "a bare install still installs the declared package manager when Node.js is unpinned" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md new file mode 100644 index 0000000000..a5ecfc13fa --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md @@ -0,0 +1,17 @@ +# command_env_install_no_node_with_package_manager + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx '#'\!'/bin/sh +'` + + +## `node assert-package-manager-installed.cjs` + +a bare install still installs the declared package manager when Node.js is unpinned + +``` +installed the declared package manager after reporting the missing Node.js pin +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/assert-doctor-fails.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/assert-doctor-fails.cjs new file mode 100644 index 0000000000..5ff5ea222f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/assert-doctor-fails.cjs @@ -0,0 +1,13 @@ +const { spawnSync } = require('node:child_process') + +const result = spawnSync('vp', ['env', 'doctor', 'pm'], { + encoding: 'utf8', + env: { ...process.env, VP_PACKAGE_MANAGER: 'unknown@1.0.0' }, +}) +const output = result.stdout.replace(/\u001B\[[0-9;]*m/g, '') +if (result.status !== 1 || !output.includes('Package manager') || !output.includes('Some issues found')) { + process.stderr.write(`${result.stdout}${result.stderr}`) + process.exit(1) +} + +console.log('doctor returns a failing status for package-manager resolution errors') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index 35e45ba7ce..e2b23d36bd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -18,3 +18,10 @@ steps = [ { argv = ["vp", "env", "current", "pm", "--json"], comment = "current reports the package-manager session file path" }, { argv = ["vp", "env", "which", "npm"], comment = "which reports the package-manager session file as its source" }, ] + +[[case]] +name = "command_env_doctor_package_manager_resolution_failure" +vp = "global" +steps = [ + { argv = ["node", "assert-doctor-fails.cjs"], comment = "package-manager resolution errors make doctor fail" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_doctor_package_manager_resolution_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_doctor_package_manager_resolution_failure.md new file mode 100644 index 0000000000..03e5efceed --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_doctor_package_manager_resolution_failure.md @@ -0,0 +1,9 @@ +# command_env_doctor_package_manager_resolution_failure + +## `node assert-doctor-fails.cjs` + +package-manager resolution errors make doctor fail + +``` +doctor returns a failing status for package-manager resolution errors +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs new file mode 100644 index 0000000000..e20056d0ba --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs @@ -0,0 +1,10 @@ +const { spawnSync } = require('node:child_process') + +const result = spawnSync('vp', ['env', 'doctor', 'pnpm'], { encoding: 'utf8' }) +const output = result.stdout.replace(/\u001B\[[0-9;]*m/g, '') +if (!output.includes('Source') || !output.includes('system PATH') || !output.includes('PM binary') || !output.includes('system-bin/pnpm')) { + process.stderr.write(`${result.stdout}${result.stderr}`) + process.exit(1) +} + +console.log('doctor reports the system pnpm binary without resolving the declared range') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml index 51847f685c..5ab953cc5d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml @@ -24,3 +24,14 @@ steps = [ { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, { argv = ["node", "print-doctor-configuration.cjs"], comment = "doctor prints each package manager when their modes differ" }, ] + +[[case]] +name = "command_env_doctor_system_package_manager" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"doctor-system-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, + { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, + { argv = ["node", "assert-doctor-system-package-manager.cjs"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "doctor checks a system-first package manager without registry access" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md new file mode 100644 index 0000000000..2f99190403 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md @@ -0,0 +1,19 @@ +# command_env_doctor_system_package_manager + +## `vpt write-file package.json '{"name":"doctor-system-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `vpt chmod +x system-bin/pnpm` + + +## `vp env off pnpm` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 node assert-doctor-system-package-manager.cjs` + +doctor checks a system-first package manager without registry access + +``` +doctor reports the system pnpm binary without resolving the declared range +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/system-bin/pnpm b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/system-bin/pnpm new file mode 100644 index 0000000000..1d0b624c16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/system-bin/pnpm @@ -0,0 +1,2 @@ +#!/bin/sh +printf '10.18.0\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index be81086280..86e6c325ff 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -28,12 +28,12 @@ name = "system_package_manager_uses_system_first_node" vp = "global" skip-platforms = ["windows"] steps = [ - { argv = ["vpt", "write-file", "package.json", "{\"name\":\"system-package-manager\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"system-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/node"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/pnpm"], snapshot = false }, { argv = ["vp", "env", "off", "node"], snapshot = false }, { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, - { argv = ["vp", "install"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"]], comment = "a system-first package manager receives the Node.js selected by system-first Node mode" }, + { argv = ["vp", "install"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "a system-first package manager resolves offline and receives the Node.js selected by system-first Node mode" }, ] [[case]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md index a6b0cfd769..aa0bd8a7b2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md @@ -1,6 +1,6 @@ # system_package_manager_uses_system_first_node -## `vpt write-file package.json '{"name":"system-package-manager","private":true,"packageManager":"pnpm@10.18.0"} +## `vpt write-file package.json '{"name":"system-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} '` @@ -16,9 +16,9 @@ ## `vp env off pnpm` -## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} vp install` +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp install` -a system-first package manager receives the Node.js selected by system-first Node mode +a system-first package manager resolves offline and receives the Node.js selected by system-first Node mode ``` VITE+ - The Unified Toolchain for the Web diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index dfce5e53e5..89bd40bbac 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -674,7 +674,7 @@ async fn run_package_manager_command( } commands::prepend_js_runtime_to_path_env(&cwd).await?; - let selected = commands::env::package_manager::resolve_current(&cwd).await?; + let selected = commands::env::package_manager::resolve_current_spec(&cwd).await?; let result = if let Some(selected) = selected.as_ref() && commands::env::config::load_config() .await? @@ -687,6 +687,7 @@ async fn run_package_manager_command( { vp_pm_cli::dispatch_with_resolved_package_manager(&cwd, command, manager, selected).await? } else { + let selected = commands::env::package_manager::resolve_current(&cwd).await?; match selected { Some(selected) => { vp_pm_cli::dispatch_with_package_manager(&cwd, command, &selected).await? diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 24a89c339d..6a48f27f10 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -92,11 +92,11 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result) -> Result bool { /// Check shim files in the bin directory. A missing bin directory is /// already reported by [`check_dirs`]. -async fn check_shims() -> bool { +fn selected_shim_tools(scope: EnvScope) -> Vec<&'static str> { + match scope { + EnvScope::All => crate::shim::DEFAULT_SHIM_TOOLS.to_vec(), + EnvScope::Node => vec!["node"], + EnvScope::PackageManagers => package_manager::ALL_PACKAGE_MANAGERS + .into_iter() + .flat_map(|package_manager| package_manager.bin_names().iter().copied()) + .collect(), + EnvScope::PackageManager(package_manager) => package_manager.bin_names().to_vec(), + } +} + +async fn check_shims(scope: EnvScope) -> bool { let config = vp_shared::EnvConfig::get(); let bin_dir = &config.dirs.bin; @@ -204,7 +216,8 @@ async fn check_shims() -> bool { let mut missing = Vec::new(); - for tool in crate::shim::DEFAULT_SHIM_TOOLS { + let tools = selected_shim_tools(scope); + for tool in &tools { let shim_path = bin_dir.join(shim_filename(tool)); if !tokio::fs::try_exists(&shim_path).await.unwrap_or(false) { missing.push(*tool); @@ -212,11 +225,7 @@ async fn check_shims() -> bool { } if missing.is_empty() { - print_check( - &output::CHECK.green().to_string(), - "Shims", - &crate::shim::DEFAULT_SHIM_TOOLS.join(", "), - ); + print_check(&output::CHECK.green().to_string(), "Shims", &tools.join(", ")); true } else { print_check( @@ -244,7 +253,7 @@ fn shim_filename(tool: &str) -> String { } /// Check and display shim mode. Returns the mode and any found system node path. -async fn check_shim_mode(scope: EnvScope) -> (ShimMode, Option) { +async fn check_shim_mode(scope: EnvScope) -> (config::Config, Option) { let config = match load_config().await { Ok(c) => c, Err(e) => { @@ -253,7 +262,7 @@ async fn check_shim_mode(scope: EnvScope) -> (ShimMode, Option) "Node.js", &format!("config error: {e}").yellow().to_string(), ); - return (ShimMode::default(), None); + return (config::Config::default(), None); } }; @@ -310,7 +319,7 @@ async fn check_shim_mode(scope: EnvScope) -> (ShimMode, Option) } } - (config.shim_mode, system_node_path) + (config, system_node_path) } async fn check_package_manager_session_override() { @@ -321,7 +330,46 @@ async fn check_package_manager_session_override() { } } -async fn check_package_manager_resolution(cwd: &AbsolutePathBuf, scope: EnvScope) { +async fn check_package_manager_resolution( + cwd: &AbsolutePathBuf, + scope: EnvScope, + config: &config::Config, +) -> bool { + let selected = match package_manager::resolve_current_spec(cwd).await { + Ok(selected) => selected.filter(|resolution| { + scope + .package_manager() + .is_none_or(|expected| expected == resolution.package_manager_type) + }), + Err(error) => { + print_check(&output::CROSS.red().to_string(), "Package manager", &error.to_string()); + return false; + } + }; + let Some(selected) = selected else { + print_check(" ", "Package manager", "not selected"); + return true; + }; + + if config.package_manager_shim_mode_for(selected.package_manager_type) == ShimMode::SystemFirst + && let Some(system_binary) = + shim::find_system_tool(&selected.package_manager_type.to_string()) + { + let version = get_tool_version(&system_binary).await; + print_check(" ", "Source", "system PATH"); + print_check( + " ", + "Version", + &format!("{}@{version}", selected.package_manager_type).bright_green().to_string(), + ); + print_check( + &output::CHECK.green().to_string(), + "PM binary", + &system_binary.as_path().display().to_string(), + ); + return true; + } + match package_manager::resolve_current_for(cwd, scope.package_manager()).await { Ok(Some(resolution)) => { print_check(" ", "Source", &resolution.source); @@ -346,10 +394,15 @@ async fn check_package_manager_resolution(cwd: &AbsolutePathBuf, scope: EnvScope output::WARN_SIGN.yellow().to_string() }; print_check(&indicator, "PM binaries", status); + true + } + Ok(_) => { + print_check(" ", "Package manager", "not selected"); + true } - Ok(_) => print_check(" ", "Package manager", "not selected"), Err(error) => { - print_check(&output::CROSS.red().to_string(), "Package manager", &error.to_string()) + print_check(&output::CROSS.red().to_string(), "Package manager", &error.to_string()); + false } } } @@ -425,7 +478,7 @@ fn check_session_override() { } /// Check PATH configuration. -async fn check_path() -> bool { +async fn check_path(scope: EnvScope) -> bool { let bin_dir = match get_bin_dir() { Ok(d) => d, Err(_) => return false, @@ -451,7 +504,7 @@ async fn check_path() -> bool { } // Show which tool would be executed for each shim - for tool in crate::shim::DEFAULT_SHIM_TOOLS { + for tool in selected_shim_tools(scope) { if let Some(tool_path) = find_in_path(tool) { let expected = bin_dir.join(shim_filename(tool)); let display = abbreviate_home(&tool_path.display().to_string()); @@ -690,7 +743,11 @@ async fn check_current_resolution( /// Get the version string from a Node.js binary. async fn get_node_version(node_path: &vt_path::AbsolutePath) -> String { - match tokio::process::Command::new(node_path.as_path()).arg("--version").output().await { + get_tool_version(node_path).await +} + +async fn get_tool_version(tool_path: &vt_path::AbsolutePath) -> String { + match tokio::process::Command::new(tool_path.as_path()).arg("--version").output().await { Ok(output) if output.status.success() => { String::from_utf8_lossy(&output.stdout).trim().to_string() } @@ -1127,6 +1184,20 @@ mod tests { #[cfg(not(windows))] use crate::commands::shell::{ShellProfileKind, ShellProfileRoot}; + #[test] + fn test_selected_shim_tools_respect_scope() { + assert_eq!(selected_shim_tools(EnvScope::Node), vec!["node"]); + assert_eq!( + selected_shim_tools(EnvScope::PackageManager(vp_pm_cli::PackageManagerType::Pnpm)), + vec!["pnpm", "pnpx"] + ); + assert_eq!( + selected_shim_tools(EnvScope::PackageManagers), + vec!["npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx"] + ); + assert_eq!(selected_shim_tools(EnvScope::All), crate::shim::DEFAULT_SHIM_TOOLS); + } + /// Test helper: write `files` into a temp project and collect devEngines findings. async fn dev_engines_findings_for( files: &[(&str, &str)], diff --git a/crates/vp_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs index 7c233bbbf4..b723989f1f 100644 --- a/crates/vp_global_cli/src/commands/env/lifecycle.rs +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -26,12 +26,13 @@ pub(crate) async fn install( requests: Vec, ) -> Result { let (scope, specs) = EnvSpecs::parse_requests(&requests)?; + let mut status = ExitStatus::default(); if scope.includes_node() { - let (version, from_session_override) = match specs.node { + let resolved = match specs.node { Some(version) => { let provider = vp_js_runtime::NodeProvider::new(); - (config::resolve_version_alias(&version, &provider).await?, false) + Some((config::resolve_version_alias(&version, &provider).await?, false)) } None => { let resolution = config::resolve_version(&cwd).await?; @@ -39,21 +40,25 @@ pub(crate) async fn install( eprintln!("No Node.js version found in current project."); eprintln!("Specify a version: vp env install "); eprintln!("Or pin one: vp env pin "); - return Ok(exit_status(1)); + status = exit_status(1); + None + } else { + let from_session_override = matches!( + resolution.source.as_str(), + config::VERSION_ENV_VAR | config::SESSION_VERSION_FILE + ); + Some((resolution.version, from_session_override)) } - let from_session_override = matches!( - resolution.source.as_str(), - config::VERSION_ENV_VAR | config::SESSION_VERSION_FILE - ); - (resolution.version, from_session_override) } }; - println!("Installing Node.js v{version}..."); - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &version).await?; - println!("Installed Node.js v{version}"); - if from_session_override { - eprintln!("Note: Installed from session override."); - eprintln!("Run `vp env use --unset` to revert to project version resolution."); + if let Some((version, from_session_override)) = resolved { + println!("Installing Node.js v{version}..."); + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &version).await?; + println!("Installed Node.js v{version}"); + if from_session_override { + eprintln!("Note: Installed from session override."); + eprintln!("Run `vp env use --unset` to revert to project version resolution."); + } } } @@ -78,7 +83,7 @@ pub(crate) async fn install( } } - Ok(ExitStatus::default()) + Ok(status) } pub(crate) async fn uninstall(specs: Vec) -> Result { diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index 51bd8ebb03..e5afab4594 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -138,7 +138,7 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu if prepend_to_path_env(&node_bin_prefix, options) { tracing::debug!("Set PATH to include {:?}", node_bin_prefix); } - if let Some(package_manager) = env::package_manager::resolve_current(project_path).await? { + if let Some(package_manager) = env::package_manager::resolve_current_spec(project_path).await? { if config.package_manager_shim_mode_for(package_manager.package_manager_type) == env::config::ShimMode::SystemFirst && let Some(system_path) = crate::shim::dispatch::find_system_tool( @@ -151,6 +151,8 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu } return Ok(()); } + } + if let Some(package_manager) = env::package_manager::resolve_current(project_path).await? { let (install_dir, _, _) = vp_pm_cli::download_package_manager( package_manager.package_manager_type, &package_manager.version, From 6fe1499d7cd5b8ca6c1885ad1bb0b5395d62a4e7 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 16:10:26 +0800 Subject: [PATCH 24/37] fix(env): address system mode review regressions --- .../snapshots.toml | 7 + ...mand_env_empty_package_manager_override.md | 23 +++ .../snapshots.toml | 11 ++ ...list_stays_offline_for_floating_default.md | 30 ++++ .../assert-doctor-system-package-manager.cjs | 13 ++ ...mmand_env_doctor_system_package_manager.md | 1 + .../command_env_package_manager_modes.md | 2 +- .../snapshots.toml | 10 ++ ...ge_manager_target_preserves_dev_engines.md | 23 +++ .../shim_package_manager_first_use/bin/pnpm | 2 + .../snapshots.toml | 17 ++ ..._package_manager_uses_system_first_node.md | 38 +++++ ..._package_manager_uses_system_first_node.md | 3 + crates/vp_global_cli/src/cli.rs | 4 +- .../vp_global_cli/src/commands/env/current.rs | 145 +++++++++--------- crates/vp_global_cli/src/commands/env/list.rs | 7 +- crates/vp_global_cli/src/commands/env/mod.rs | 46 +++++- .../src/commands/env/package_manager.rs | 33 ++-- crates/vp_global_cli/src/commands/env/pin.rs | 4 +- .../vp_global_cli/src/commands/env/setup.rs | 6 +- crates/vp_global_cli/src/shim/dispatch.rs | 26 +++- crates/vp_pm_cli/src/cli.rs | 2 +- crates/vp_pm_cli/src/dispatch.rs | 6 +- crates/vp_pm_cli/src/helpers.rs | 2 +- crates/vp_pm_cli/src/package_manager.rs | 21 ++- crates/vp_pm_cli/src/resolution/resolve.rs | 2 +- 26 files changed, 369 insertions(+), 115 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_empty_package_manager_override.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/bin/pnpm create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index e2b23d36bd..e11132a1e6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -25,3 +25,10 @@ vp = "global" steps = [ { argv = ["node", "assert-doctor-fails.cjs"], comment = "package-manager resolution errors make doctor fail" }, ] + +[[case]] +name = "command_env_empty_package_manager_override" +vp = "global" +steps = [ + { argv = ["vp", "env", "current", "pm", "--json"], envs = [["VP_PACKAGE_MANAGER", " "]], comment = "an empty package-manager environment override falls through to project resolution" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_empty_package_manager_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_empty_package_manager_override.md new file mode 100644 index 0000000000..adcc337af0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_empty_package_manager_override.md @@ -0,0 +1,23 @@ +# command_env_empty_package_manager_override + +## `VP_PACKAGE_MANAGER= vp env current pm --json` + +an empty package-manager environment override falls through to project resolution + +``` +{ + "package_manager": { + "name": "npm", + "version": "", + "source": "packageManager", + "source_path": "/package.json", + "project_root": "", + "bin_paths": { + "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", + "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" + }, + "installed": false, + "mode": "managed" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml index 69e84ccf5d..92bc29842a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -66,6 +66,17 @@ steps = [ { argv = ["vp", "env", "list", "pm", "--json"], comment = "local listing remains available when the selected range cannot reach the registry", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, ] +[[case]] +name = "env_package_manager_list_stays_offline_for_floating_default" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vp", "env", "default", "pnpm@latest"], snapshot = false }, + { argv = ["vp", "env", "list", "pnpm", "--json"], comment = "local listing remains available when a floating default cannot reach the registry", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] + [[case]] name = "env_pin_package_manager_requires_confirmation" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md new file mode 100644 index 0000000000..4b7fe0dcf6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md @@ -0,0 +1,30 @@ +# env_package_manager_list_stays_offline_for_floating_default + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx '#'\!'/bin/sh +'` + + +## `vp env default pnpm@latest` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list pnpm --json` + +local listing remains available when a floating default cannot reach the registry + +``` +{ + "package_managers": { + "pnpm": [ + { + "version": "10.18.0", + "current": true, + "default": false + } + ] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs index e20056d0ba..6de5f93f47 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs @@ -8,3 +8,16 @@ if (!output.includes('Source') || !output.includes('system PATH') || !output.inc } console.log('doctor reports the system pnpm binary without resolving the declared range') + +const current = spawnSync('vp', ['env', 'current', 'pnpm', '--json'], { encoding: 'utf8' }) +if (current.status !== 0) { + process.stderr.write(`${current.stdout}${current.stderr}`) + process.exit(1) +} +const info = JSON.parse(current.stdout) +if (info.package_manager?.source !== 'system PATH' || !info.package_manager?.bin_paths?.pnpm?.includes('system-bin/pnpm')) { + process.stderr.write(current.stdout) + process.exit(1) +} + +console.log('current reports the system pnpm binary without resolving the declared range') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md index 2f99190403..6a89cfbc80 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md @@ -16,4 +16,5 @@ doctor checks a system-first package manager without registry access ``` doctor reports the system pnpm binary without resolving the declared range +current reports the system pnpm binary without resolving the declared range ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md index 6c751e3ec1..c3fb82526c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md @@ -23,7 +23,7 @@ pnpm uses its individual mode "package_manager": { "name": "pnpm", "version": "", - "source": "system PATH", + "source": "VP_PACKAGE_MANAGER", "bin_paths": { "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml index e84c5c8ada..c074035c5d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml @@ -28,3 +28,13 @@ steps = [ { argv = ["vp", "env", "unpin", "pm", "--target", "dev-engines"], comment = "an explicit target removes the devEngines manager even when packageManager shadows it" }, { argv = ["vpt", "print-file", "package.json"], comment = "the effective top-level packageManager remains intact" }, ] + +[[case]] +name = "env_unpin_package_manager_target_preserves_dev_engines" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-pin-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"10.18.0\",\"onFail\":\"download\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "unpin", "pm", "--target", "package-manager"], comment = "an explicit top-level target does not remove a devEngines-only package-manager pin" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the devEngines package-manager pin remains unchanged" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md new file mode 100644 index 0000000000..5539bee4f0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md @@ -0,0 +1,23 @@ +# env_unpin_package_manager_target_preserves_dev_engines + +## `vpt write-file package.json '{"name":"command-env-pin-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"10.18.0","onFail":"download"}}} +'` + + +## `vp env unpin pm --target package-manager` + +an explicit top-level target does not remove a devEngines-only package-manager pin + +``` +VITE+ - The Unified Toolchain for the Web + +No package manager pin found in current directory. +``` + +## `vpt print-file package.json` + +the devEngines package-manager pin remains unchanged + +``` +{"name":"command-env-pin-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"","onFail":"download"}}} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/bin/pnpm b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/bin/pnpm new file mode 100644 index 0000000000..1dc0842dbb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/bin/pnpm @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'wrong-system-pnpm\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index 86e6c325ff..d5822bca18 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -31,11 +31,28 @@ steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"system-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/node"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/pnpm"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "bin/pnpm"], snapshot = false }, { argv = ["vp", "env", "off", "node"], snapshot = false }, { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, { argv = ["vp", "install"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "a system-first package manager resolves offline and receives the Node.js selected by system-first Node mode" }, ] +[[case]] +name = "managed_package_manager_uses_system_first_node" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"managed-package-manager\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\nnode --version\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\nnode --version\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/node"], snapshot = false }, + { argv = ["vp", "env", "off", "node"], snapshot = false }, + { argv = ["vp", "env", "on", "pnpm"], snapshot = false }, + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"]], comment = "a managed package manager receives the Node.js selected by system-first Node mode" }, +] + [[case]] name = "non_interactive_use_preserves_system_without_deciding" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md new file mode 100644 index 0000000000..b1a7e5798f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md @@ -0,0 +1,38 @@ +# managed_package_manager_uses_system_first_node + +## `vpt write-file package.json '{"name":"managed-package-manager","private":true,"packageManager":"pnpm@10.18.0"} +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh +node --version +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx '#'\!'/bin/sh +node --version +'` + + +## `vpt chmod +x $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm` + + +## `vpt chmod +x $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx` + + +## `vpt chmod +x system-dispatch-bin/node` + + +## `vp env off node` + + +## `vp env on pnpm` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} pnpm --version` + +a managed package manager receives the Node.js selected by system-first Node mode + +``` +system-node +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md index aa0bd8a7b2..14ed00d497 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md @@ -10,6 +10,9 @@ ## `vpt chmod +x system-dispatch-bin/pnpm` +## `vpt chmod +x bin/pnpm` + + ## `vp env off node` diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 89bd40bbac..68d08e3217 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -735,8 +735,8 @@ async fn system_package_manager( } let version = std::str::from_utf8(&output.stdout).ok()?.trim(); node_semver::Version::parse(version).ok()?; - let install_dir = executable.parent()?.parent()?.to_absolute_path_buf(); - Some(vp_pm_cli::PackageManager::from_install_dir(kind, version, install_dir)) + let bin_prefix = executable.parent()?.to_absolute_path_buf(); + Some(vp_pm_cli::PackageManager::from_bin_prefix(kind, version, bin_prefix)) } async fn managed_install( diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index ca0c003b7f..384323a13d 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -102,72 +102,7 @@ pub async fn execute( }; let package_manager = if scope.includes_package_managers() { - let resolution = - package_manager::resolve_current_for(&cwd, scope.package_manager()).await?; - resolution.and_then(|resolution| { - let package_manager_mode = - config.package_manager_shim_mode_for(resolution.package_manager_type); - let system_paths = (package_manager_mode == ShimMode::SystemFirst) - .then(|| { - resolution - .package_manager_type - .bin_names() - .iter() - .filter_map(|name| { - crate::shim::dispatch::find_system_tool(name).map(|path| { - ((*name).to_string(), path.as_path().display().to_string()) - }) - }) - .collect::>() - }) - .filter(|paths| !paths.is_empty()); - let install_dir = - package_manager_install_dir(resolution.package_manager_type, &resolution.version)?; - let bin_paths = resolution - .package_manager_type - .bin_names() - .iter() - .map(|name| { - ( - (*name).to_string(), - package_manager_bin_path(&install_dir, name) - .as_path() - .display() - .to_string(), - ) - }) - .collect::>(); - let bin_paths = system_paths.unwrap_or(bin_paths); - let installed = bin_paths.values().all(|path| std::path::Path::new(path).exists()); - let system_version = bin_paths - .get(resolution.package_manager_type.to_string().as_str()) - .filter(|_| package_manager_mode == ShimMode::SystemFirst) - .and_then(|path| vt_path::AbsolutePathBuf::new(path.into())); - let is_system = system_version.is_some(); - Some(PackageManagerInfo { - name: resolution.package_manager_type.to_string(), - version: system_version - .as_ref() - .and_then(|path| read_tool_version_sync(path)) - .unwrap_or_else(|| resolution.version.to_string()), - source: if is_system { - "system PATH".into() - } else { - resolution.source.to_string() - }, - source_path: if is_system { - None - } else { - resolution.source_path.map(|path| path.as_path().display().to_string()) - }, - project_root: resolution - .project_root - .map(|path| path.as_path().display().to_string()), - bin_paths, - installed, - mode: package_manager_mode, - }) - }) + resolve_package_manager_info(&cwd, scope, &config).await? } else { None }; @@ -217,12 +152,78 @@ async fn read_tool_version(path: &vt_path::AbsolutePath) -> Option { .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) } -fn read_tool_version_sync(path: &vt_path::AbsolutePath) -> Option { - let output = std::process::Command::new(path.as_path()).arg("--version").output().ok()?; - output - .status - .success() - .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +async fn resolve_package_manager_info( + cwd: &vt_path::AbsolutePath, + scope: EnvScope, + config: &config::Config, +) -> Result, Error> { + let selected = package_manager::resolve_current_spec(cwd).await?.filter(|resolution| { + scope.package_manager().is_none_or(|expected| expected == resolution.package_manager_type) + }); + let Some(selected) = selected else { + return Ok(None); + }; + let mode = config.package_manager_shim_mode_for(selected.package_manager_type); + if mode == ShimMode::SystemFirst { + let bin_paths = selected + .package_manager_type + .bin_names() + .iter() + .filter_map(|name| { + crate::shim::dispatch::find_system_tool(name) + .map(|path| ((*name).to_string(), path.as_path().display().to_string())) + }) + .collect::>(); + if let Some(primary) = bin_paths.get(selected.package_manager_type.to_string().as_str()) + && let Some(primary) = AbsolutePathBuf::new(primary.into()) + { + return Ok(Some(PackageManagerInfo { + name: selected.package_manager_type.to_string(), + version: read_tool_version(&primary).await.unwrap_or_else(|| "unknown".into()), + source: "system PATH".into(), + source_path: None, + project_root: selected + .project_root + .map(|path| path.as_path().display().to_string()), + installed: true, + bin_paths, + mode, + })); + } + } + + let Some(resolution) = + package_manager::resolve_current_for(cwd, scope.package_manager()).await? + else { + return Ok(None); + }; + let Some(install_dir) = + package_manager_install_dir(resolution.package_manager_type, &resolution.version) + else { + return Ok(None); + }; + let bin_paths = resolution + .package_manager_type + .bin_names() + .iter() + .map(|name| { + ( + (*name).to_string(), + package_manager_bin_path(&install_dir, name).as_path().display().to_string(), + ) + }) + .collect::>(); + let installed = bin_paths.values().all(|path| std::path::Path::new(path).exists()); + Ok(Some(PackageManagerInfo { + name: resolution.package_manager_type.to_string(), + version: resolution.version.to_string(), + source: resolution.source.to_string(), + source_path: resolution.source_path.map(|path| path.as_path().display().to_string()), + project_root: resolution.project_root.map(|path| path.as_path().display().to_string()), + bin_paths, + installed, + mode, + })) } fn mode_name(mode: ShimMode) -> &'static str { diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index fcefd6d733..3a63ee254c 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -82,9 +82,10 @@ pub async fn execute( .transpose()? .filter(|(kind, _)| scope.includes_package_manager(*kind)); match default { - Some((kind, selector)) => { - Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) - } + Some((kind, selector)) => resolve_package_manager_version(kind, &selector) + .await + .ok() + .map(|version| (kind, version.to_string())), None => None, } } else { diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 8e065ef4f8..9a6914458f 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -221,7 +221,14 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result String { match shell { - Shell::Posix => format!("export PATH=\"{}:$PATH\"", bin_dirs.join(":")), + Shell::Posix => format!( + "export PATH=\"{}:$PATH\"", + bin_dirs + .iter() + .map(|path| setup::escape_posix_double_quoted_string(path)) + .collect::>() + .join(":") + ), Shell::Fish => format!( "set -gx PATH {} $PATH", bin_dirs @@ -230,11 +237,22 @@ fn format_path_snippet(shell: Shell, bin_dirs: &[String]) -> String { .collect::>() .join(" ") ), - Shell::PowerShell => format!("$env:PATH = \"{};$env:PATH\"", bin_dirs.join(";")), - Shell::Cmd => format!("set PATH={};%PATH%", bin_dirs.join(";")), + Shell::PowerShell => format!( + "$env:PATH = '{};' + $env:PATH", + bin_dirs + .iter() + .map(|path| setup::escape_powershell_single_quoted_string(path)) + .collect::>() + .join(";") + ), + Shell::Cmd => format!("set \"PATH={};%PATH%\"", bin_dirs.join(";")), Shell::NuShell => format!( "$env.PATH = ($env.PATH | prepend [{}])", - bin_dirs.iter().map(|path| format!("\"{path}\"")).collect::>().join(", ") + bin_dirs + .iter() + .map(|path| format!("\"{}\"", setup::escape_nu_double_quoted_string(path))) + .collect::>() + .join(", ") ), } } @@ -252,4 +270,24 @@ mod tests { assert_eq!(snippet, "set -gx PATH \"/Users/Example User/node/bin\" \"/tmp/pm/bin\" $PATH"); } + + #[test] + fn path_snippets_escape_shell_metacharacters() { + assert_eq!( + format_path_snippet(Shell::Posix, &[r#"/tmp/$USER `tick` \ dir"#.into()]), + r#"export PATH="/tmp/\$USER \`tick\` \\ dir:$PATH""# + ); + assert_eq!( + format_path_snippet(Shell::PowerShell, &[r#"C:\A&B's"#.into()]), + r#"$env:PATH = 'C:\A&B''s;' + $env:PATH"# + ); + assert_eq!( + format_path_snippet(Shell::Cmd, &[r#"C:\A&B"#.into()]), + r#"set "PATH=C:\A&B;%PATH%""# + ); + assert_eq!( + format_path_snippet(Shell::NuShell, &[r#"C:\A "B""#.into()]), + r#"$env.PATH = ($env.PATH | prepend ["C:\\A \"B\""])"# + ); + } } diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index ec4dddf248..c12de7bb27 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -73,22 +73,23 @@ impl CurrentSpecs { async fn current_specs() -> Result { let config = vp_shared::EnvConfig::get(); - let (session, session_source, session_source_path) = - if let Some(spec) = config.package_manager.as_deref() { - ( - Some(parse_package_manager_spec_with_hash(spec.trim())?), - Some(config::PACKAGE_MANAGER_ENV_VAR), - None, - ) - } else if let Some(spec) = config::read_session_package_manager().await { - ( - Some(parse_package_manager_spec_with_hash(spec.trim())?), - Some(config::SESSION_PACKAGE_MANAGER_FILE), - config::get_session_package_manager_path().ok(), - ) - } else { - (None, None, None) - }; + let (session, session_source, session_source_path) = if let Some(spec) = + config.package_manager.as_deref().map(str::trim).filter(|spec| !spec.is_empty()) + { + ( + Some(parse_package_manager_spec_with_hash(spec)?), + Some(config::PACKAGE_MANAGER_ENV_VAR), + None, + ) + } else if let Some(spec) = config::read_session_package_manager().await { + ( + Some(parse_package_manager_spec_with_hash(spec.trim())?), + Some(config::SESSION_PACKAGE_MANAGER_FILE), + config::get_session_package_manager_path().ok(), + ) + } else { + (None, None, None) + }; let config = config::load_config().await?; let default = config .default_package_manager diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 3318f19d57..8f83b1f7b9 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -856,7 +856,9 @@ async fn unpin_package_manager( }); if remove_top_level && top_level_matches { changed = obj.remove("packageManager").is_some(); - } else if let Some(expected) = expected { + } else if !matches!(target, Some(PinTarget::PackageManager)) + && let Some(expected) = expected + { changed = remove_dev_engines_package_manager(obj, expected); } }) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index dba839e290..5206c24fda 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -943,7 +943,7 @@ fn render_nu_path_ref(path_ref: &str) -> String { } /// Escape a value for a POSIX-shell double-quoted string. -fn escape_posix_double_quoted_string(value: &str) -> String { +pub(super) fn escape_posix_double_quoted_string(value: &str) -> String { value.replace('\\', "\\\\").replace('$', "\\$").replace('`', "\\`").replace('"', "\\\"") } @@ -964,13 +964,13 @@ fn escape_home_relative_double_quoted_path(path_ref: &str, escape: fn(&str) -> S /// /// Example: `vp "home\with spaces"` → `vp \"home\\with spaces\"` /// https://www.nushell.sh/book/working_with_strings.html#double-quoted-strings -fn escape_nu_double_quoted_string(value: &str) -> String { +pub(super) fn escape_nu_double_quoted_string(value: &str) -> String { // `vp "home\with spaces"` → `vp \"home\\with spaces\"` value.replace('\\', "\\\\").replace('"', "\\\"") } /// Escape a value for a PowerShell single-quoted string. -fn escape_powershell_single_quoted_string(value: &str) -> String { +pub(super) fn escape_powershell_single_quoted_string(value: &str) -> String { value.replace('\'', "''") } diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 51fe2a10da..a3704ba21a 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -797,11 +797,27 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Ensure Node.js is installed and locate its binary for PATH preparation. // Package-manager shims can use their own declared version, but JS-based // package managers still need the project-resolved Node.js runtime. - let node_path = match ensure_installed(&resolution.version).await { - Ok(p) => p, - Err(e) => { - eprintln!("vp: Failed to install Node {}: {e}", resolution.version); - return 1; + let system_node = if PackageManagerType::from_tool(tool).is_some() { + match config::load_config().await { + Ok(config) if config.shim_mode == ShimMode::SystemFirst => find_system_tool("node"), + Ok(_) => None, + Err(error) => { + eprintln!("vp: Failed to load Node.js shim mode: {error}"); + return 1; + } + } + } else { + None + }; + let node_path = if let Some(system_node) = system_node { + system_node + } else { + match ensure_installed(&resolution.version).await { + Ok(path) => path, + Err(error) => { + eprintln!("vp: Failed to install Node {}: {error}", resolution.version); + return 1; + } } }; diff --git a/crates/vp_pm_cli/src/cli.rs b/crates/vp_pm_cli/src/cli.rs index a895edc99b..561fe77f88 100644 --- a/crates/vp_pm_cli/src/cli.rs +++ b/crates/vp_pm_cli/src/cli.rs @@ -416,7 +416,7 @@ mod tests { PackageManager { client, version: version.into(), - install_dir: workspace_root.join(".test-package-manager"), + bin_prefix: workspace_root.join(".test-package-manager").join("bin"), } } diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index f60aa85b8b..e2ea4d0e63 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -144,7 +144,11 @@ async fn build_selected_package_manager( ) .await .map_err(Error::Install)?; - Ok(PackageManager { client: package_manager.package_manager_type, version, install_dir }) + Ok(PackageManager { + client: package_manager.package_manager_type, + version, + bin_prefix: install_dir.join("bin"), + }) } async fn auto_pin_environment_package_manager( diff --git a/crates/vp_pm_cli/src/helpers.rs b/crates/vp_pm_cli/src/helpers.rs index a76a5814b7..75c3ec229f 100644 --- a/crates/vp_pm_cli/src/helpers.rs +++ b/crates/vp_pm_cli/src/helpers.rs @@ -49,7 +49,7 @@ pub(crate) fn default_npm_package_manager(cwd: &AbsolutePath) -> PackageManager PackageManager { client: PackageManagerType::Npm, version: "latest".into(), - install_dir: cwd.to_absolute_path_buf(), + bin_prefix: cwd.join("bin"), } } diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 155f2c2dbb..923428592f 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -206,7 +206,7 @@ impl PackageManagerSource { pub struct PackageManager { pub(crate) client: PackageManagerType, pub(crate) version: Str, - pub(crate) install_dir: AbsolutePathBuf, + pub(crate) bin_prefix: AbsolutePathBuf, } #[derive(Debug)] @@ -254,7 +254,11 @@ impl PackageManagerBuilder { .await?; } - Ok(PackageManager { client: package_manager_type, version, install_dir }) + Ok(PackageManager { + client: package_manager_type, + version, + bin_prefix: install_dir.join("bin"), + }) } /// Build the package manager with default package manager. @@ -284,12 +288,21 @@ impl PackageManager { version: impl Into, install_dir: AbsolutePathBuf, ) -> Self { - Self { client, version: version.into(), install_dir } + Self { client, version: version.into(), bin_prefix: install_dir.join("bin") } + } + + #[must_use] + pub fn from_bin_prefix( + client: PackageManagerType, + version: impl Into, + bin_prefix: AbsolutePathBuf, + ) -> Self { + Self { client, version: version.into(), bin_prefix } } #[must_use] pub fn get_bin_prefix(&self) -> AbsolutePathBuf { - self.install_dir.join("bin") + self.bin_prefix.clone() } } diff --git a/crates/vp_pm_cli/src/resolution/resolve.rs b/crates/vp_pm_cli/src/resolution/resolve.rs index 8a013e1c10..896d1a461f 100644 --- a/crates/vp_pm_cli/src/resolution/resolve.rs +++ b/crates/vp_pm_cli/src/resolution/resolve.rs @@ -83,7 +83,7 @@ mod tests { PackageManager { client, version: version.into(), - install_dir: workspace_root.join(".test-package-manager"), + bin_prefix: workspace_root.join(".test-package-manager").join("bin"), } } From 8e8fb9f8c727c44b55045b47c63e2adbd2921c7d Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 16:30:02 +0800 Subject: [PATCH 25/37] fix(env): address follow-up environment regressions --- .../assert-doctor-system-package-manager.cjs | 8 ++ ...mmand_env_doctor_system_package_manager.md | 1 + .../snapshots.toml | 10 ++ ...warns_when_top_level_version_shadows_it.md | 36 +++++++ .../snapshots.toml | 4 +- ..._package_manager_uses_system_first_node.md | 15 ++- crates/vp_global_cli/src/commands/env/mod.rs | 77 ++++++++------- crates/vp_global_cli/src/commands/env/pin.rs | 11 +++ crates/vp_global_cli/src/shim/dispatch.rs | 95 ++++++++++++------- 9 files changed, 185 insertions(+), 72 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs index 6de5f93f47..4a22b56ca2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs @@ -21,3 +21,11 @@ if (info.package_manager?.source !== 'system PATH' || !info.package_manager?.bin } console.log('current reports the system pnpm binary without resolving the declared range') + +const printed = spawnSync('vp', ['env', 'print', 'pnpm'], { encoding: 'utf8' }) +if (printed.status !== 0 || !printed.stdout.includes('system-bin')) { + process.stderr.write(`${printed.stdout}${printed.stderr}`) + process.exit(1) +} + +console.log('print uses the system pnpm binary without resolving the declared range') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md index 6a89cfbc80..85b4833219 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md @@ -17,4 +17,5 @@ doctor checks a system-first package manager without registry access ``` doctor reports the system pnpm binary without resolving the declared range current reports the system pnpm binary without resolving the declared range +print uses the system pnpm binary without resolving the declared range ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml index c074035c5d..b12e93f5d7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml @@ -38,3 +38,13 @@ steps = [ { argv = ["vp", "env", "unpin", "pm", "--target", "package-manager"], comment = "an explicit top-level target does not remove a devEngines-only package-manager pin" }, { argv = ["vpt", "print-file", "package.json"], comment = "the devEngines package-manager pin remains unchanged" }, ] + +[[case]] +name = "env_pin_dev_engines_warns_when_top_level_version_shadows_it" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-pin-package-manager\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, + { argv = ["vp", "env", "pin", "pnpm@11.0.0", "--target", "dev-engines", "--no-install", "--force"], comment = "pinning a shadowed same-family devEngines version explains which declaration remains effective" }, + { argv = ["vpt", "print-file", "package.json"], comment = "both explicitly targeted declarations remain unchanged except for the new devEngines pin" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md new file mode 100644 index 0000000000..004acf8cb4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md @@ -0,0 +1,36 @@ +# env_pin_dev_engines_warns_when_top_level_version_shadows_it + +## `vpt write-file package.json '{"name":"command-env-pin-package-manager","private":true,"packageManager":"pnpm@10.18.0"} +'` + + +## `vp env pin pnpm@11.0.0 --target dev-engines --no-install --force` + +pinning a shadowed same-family devEngines version explains which declaration remains effective + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Pinned package manager to pnpm@11.0.0 +warn: Top-level packageManager pnpm@10.18.0 remains effective; remove or update it to use devEngines.packageManager pnpm@11.0.0. +note: Package manager will be downloaded on first use. +``` + +## `vpt print-file package.json` + +both explicitly targeted declarations remain unchanged except for the new devEngines pin + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "packageManager": "pnpm@10.18.0", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index d5822bca18..a43ca62768 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -40,6 +40,7 @@ steps = [ [[case]] name = "managed_package_manager_uses_system_first_node" vp = "global" +seed-runtime = false skip-platforms = ["windows"] steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"managed-package-manager\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, @@ -50,7 +51,8 @@ steps = [ { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/node"], snapshot = false }, { argv = ["vp", "env", "off", "node"], snapshot = false }, { argv = ["vp", "env", "on", "pnpm"], snapshot = false }, - { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"]], comment = "a managed package manager receives the Node.js selected by system-first Node mode" }, + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]], comment = "a managed package manager receives system-first Node.js without resolving a managed runtime" }, + { argv = ["vp", "env", "print", "node"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]], comment = "Node environment printing uses the system runtime without resolving a managed runtime" }, ] [[case]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md index b1a7e5798f..8f63706990 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md @@ -29,10 +29,21 @@ node --version ## `vp env on pnpm` -## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} pnpm --version` +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} VP_NODE_DIST_MIRROR=http://127.0.0.1:9 pnpm --version` -a managed package manager receives the Node.js selected by system-first Node mode +a managed package manager receives system-first Node.js without resolving a managed runtime ``` system-node ``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} VP_NODE_DIST_MIRROR=http://127.0.0.1:9 vp env print node` + +Node environment printing uses the system runtime without resolving a managed runtime + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +export PATH="/system-dispatch-bin:$PATH" +``` diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 9a6914458f..7417b2595b 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -152,22 +152,15 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result) -> Result { let scope = spec::EnvScope::parse(scope.as_deref())?; let modes = config::load_config().await?; - // Resolve the Node.js version for the current directory - let resolution = scope.includes_node().then(|| config::resolve_version(&cwd)); - let resolution = match resolution { - Some(resolution) => Some(resolution.await?), - None => None, - }; - - // Get the node bin directory let mut bin_dirs = Vec::new(); - if let Some(resolution) = resolution { + if scope.includes_node() { if modes.shim_mode == config::ShimMode::SystemFirst && let Some(path) = crate::shim::dispatch::find_system_tool("node") && let Some(bin_dir) = path.parent() { bin_dirs.push(bin_dir.as_path().display().to_string()); } else { + let resolution = config::resolve_version(&cwd).await?; let runtime = vp_js_runtime::download_runtime( vp_js_runtime::JsRuntimeType::Node, &resolution.version, @@ -176,35 +169,49 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result { - Some((resolution.package_manager_type, resolution.version, resolution.hash)) - } - None => match scope.package_manager() { - Some(package_manager) => Some(( - package_manager, - vp_pm_cli::resolve_package_manager_version(package_manager, "latest").await?, - None, - )), - None => None, - }, - } - } else { - None - }; - if let Some((package_manager, version, hash)) = package_manager { - if modes.package_manager_shim_mode_for(package_manager) == config::ShimMode::SystemFirst - && let Some(path) = + if scope.includes_package_managers() { + let selected = package_manager::resolve_current_spec(&cwd).await?.filter(|resolution| { + scope + .package_manager() + .is_none_or(|expected| expected == resolution.package_manager_type) + }); + let selected_type = selected + .as_ref() + .map(|resolution| resolution.package_manager_type) + .or_else(|| scope.package_manager()); + let system_bin_dir = selected_type.and_then(|package_manager| { + if modes.package_manager_shim_mode_for(package_manager) == config::ShimMode::SystemFirst + { crate::shim::dispatch::find_system_tool(&package_manager.to_string()) - && let Some(bin_dir) = path.parent() - { + .and_then(|path| path.parent().map(vt_path::AbsolutePath::to_absolute_path_buf)) + } else { + None + } + }); + if let Some(bin_dir) = system_bin_dir { bin_dirs.insert(0, bin_dir.as_path().display().to_string()); } else { - let (install_dir, _, _) = - vp_pm_cli::download_package_manager(package_manager, &version, hash.as_deref()) - .await?; - bin_dirs.insert(0, install_dir.join("bin").as_path().display().to_string()); + let package_manager = + match package_manager::resolve_current_for(&cwd, scope.package_manager()).await? { + Some(resolution) => { + Some((resolution.package_manager_type, resolution.version, resolution.hash)) + } + None => match scope.package_manager() { + Some(package_manager) => Some(( + package_manager, + vp_pm_cli::resolve_package_manager_version(package_manager, "latest") + .await?, + None, + )), + None => None, + }, + }; + if let Some((package_manager, version, hash)) = package_manager { + let (install_dir, _, _) = + vp_pm_cli::download_package_manager(package_manager, &version, hash.as_deref()) + .await?; + bin_dirs.insert(0, install_dir.join("bin").as_path().display().to_string()); + } } } if bin_dirs.is_empty() { diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 8f83b1f7b9..0019e637a4 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -702,6 +702,12 @@ async fn pin_package_manager( let package_json: serde_json::Value = serde_json::from_str(&content)?; let use_top_level = matches!(target, Some(PinTarget::PackageManager)) || (target.is_none() && package_json.get("packageManager").is_some()); + let shadowing_top_level = + if use_top_level { None } else { existing_package_manager_pin(&content, true) }; + let shadowing_top_level = shadowing_top_level.filter(|(name, version)| { + PackageManagerType::from_name(name) == Some(package_manager) + && version.as_str() != resolved.as_str() + }); if let Some((name, version)) = existing_package_manager_pin(&content, use_top_level) { let existing = format!("{name}@{version}"); let next = format!("{package_manager}@{resolved}"); @@ -740,6 +746,11 @@ async fn pin_package_manager( tokio::fs::write(&package_json_path, updated).await?; crate::shim::invalidate_cache(); output::success(&format!("Pinned package manager to {package_manager}@{resolved}")); + if let Some((name, version)) = shadowing_top_level { + output::warn(&format!( + "Top-level packageManager {name}@{version} remains effective; remove or update it to use devEngines.packageManager {package_manager}@{resolved}." + )); + } if no_install { output::note("Package manager will be downloaded on first use."); } else if let Err(error) = download_package_manager(package_manager, &resolved, None).await { diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index a3704ba21a..fb520a05d5 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -784,19 +784,9 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } }; - // Resolve version (with caching) - let resolution = match resolve_with_cache(&cwd).await { - Ok(r) => r, - Err(e) => { - eprintln!("vp: Failed to resolve Node version: {e}"); - eprintln!("vp: Run 'vp env doctor' for diagnostics"); - return 1; - } - }; - // Ensure Node.js is installed and locate its binary for PATH preparation. // Package-manager shims can use their own declared version, but JS-based - // package managers still need the project-resolved Node.js runtime. + // package managers still need the Node.js runtime selected by its mode. let system_node = if PackageManagerType::from_tool(tool).is_some() { match config::load_config().await { Ok(config) if config.shim_mode == ShimMode::SystemFirst => find_system_tool("node"), @@ -809,9 +799,22 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } else { None }; + let resolution = if system_node.is_none() { + match resolve_with_cache(&cwd).await { + Ok(resolution) => Some(resolution), + Err(error) => { + eprintln!("vp: Failed to resolve Node version: {error}"); + eprintln!("vp: Run 'vp env doctor' for diagnostics"); + return 1; + } + } + } else { + None + }; let node_path = if let Some(system_node) = system_node { system_node } else { + let resolution = resolution.as_ref().expect("managed Node.js has no resolution"); match ensure_installed(&resolution.version).await { Ok(path) => path, Err(error) => { @@ -825,13 +828,19 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // fallback. Node and bundled npm tools come from the selected Node.js runtime. let tool_path = match resolve_package_manager_tool(&cwd, tool).await { Ok(Some(path)) => path, - Ok(None) => match locate_tool(&resolution.version, tool) { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Tool '{tool}' not found: {e}"); - return 1; + Ok(None) => { + let path = match resolution.as_ref() { + Some(resolution) => locate_tool(&resolution.version, tool), + None => find_system_tool(tool).ok_or_else(|| format!("system '{tool}' not found")), + }; + match path { + Ok(path) => path, + Err(error) => { + eprintln!("vp: Tool '{tool}' not found: {error}"); + return 1; + } } - }, + } Err(e) => { eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); return 1; @@ -858,10 +867,18 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Optional debug env vars if std::env::var(env_vars::VP_DEBUG_SHIM).is_ok() { - // SAFETY: Setting env vars at this point before exec is safe - unsafe { - std::env::set_var(env_vars::VP_ACTIVE_NODE, &resolution.version); - std::env::set_var(env_vars::VP_RESOLVE_SOURCE, &resolution.source); + if let Some(resolution) = resolution.as_ref() { + // SAFETY: Setting env vars at this point before exec is safe + unsafe { + std::env::set_var(env_vars::VP_ACTIVE_NODE, &resolution.version); + std::env::set_var(env_vars::VP_RESOLVE_SOURCE, &resolution.source); + } + } else if let Some(version) = read_node_version(&node_path) { + // SAFETY: Setting env vars at this point before exec is safe + unsafe { + std::env::set_var(env_vars::VP_ACTIVE_NODE, version); + std::env::set_var(env_vars::VP_RESOLVE_SOURCE, "system PATH"); + } } } @@ -880,19 +897,18 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { if let Some(parsed) = parse_npm_global_install(args) { let exit_code = exec::spawn_tool(&tool_path, args); if exit_code == 0 { - let node_dir = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(&*resolution.version); + let node_dir = node_prefix_from_binary(&node_path); + let node_version = resolution.as_ref().map_or_else( + || read_node_version(&node_path).unwrap_or_else(|| "unknown".into()), + |resolution| resolution.version.clone(), + ); let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); check_npm_global_install_result( &parsed.packages, original_path.as_deref(), &npm_prefix, &node_dir, - &resolution.version, + &node_version, ); } return exit_code; @@ -900,12 +916,7 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { if let Some(parsed) = parse_npm_global_uninstall(args) { // Collect bin names before uninstall (package.json will be gone after) - let node_dir = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(&*resolution.version); + let node_dir = node_prefix_from_binary(&node_path); let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); let bin_names = collect_bin_names_from_npm(&parsed.packages, &npm_prefix, &node_dir); let exit_code = exec::spawn_tool(&tool_path, args); @@ -920,6 +931,22 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { exec::exec_tool(&tool_path, args) } +fn node_prefix_from_binary(node_path: &AbsolutePath) -> AbsolutePathBuf { + #[cfg(windows)] + let prefix = node_path.parent(); + #[cfg(not(windows))] + let prefix = node_path.parent().and_then(AbsolutePath::parent); + prefix.expect("Node.js has no installation prefix").to_absolute_path_buf() +} + +fn read_node_version(node_path: &AbsolutePath) -> Option { + let output = std::process::Command::new(node_path.as_path()).arg("--version").output().ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +} + async fn prepare_node_path_for_system_package_manager() -> Result<(), Error> { let config = config::load_config().await?; if config.shim_mode == ShimMode::SystemFirst From 1788ae2b298b72cd323e17670db473bf3dd4b9b1 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 16:47:36 +0800 Subject: [PATCH 26/37] fix(env): align concrete component fallbacks --- .../snapshots.toml | 9 +++ ...nv_unset_empty_package_manager_override.md | 15 +++++ .../snapshots.toml | 10 +++ ...list_stays_offline_for_floating_default.md | 25 +++++++ .../fixtures/command_env_which/snapshots.toml | 15 +++++ ...rrent_concrete_package_manager_fallback.md | 21 ++++++ ...command_env_print_npm_uses_node_runtime.md | 12 ++++ .../vp_global_cli/src/commands/env/current.rs | 67 ++++++++++++------- crates/vp_global_cli/src/commands/env/list.rs | 7 +- crates/vp_global_cli/src/commands/env/mod.rs | 44 ++++++++---- crates/vp_global_cli/src/commands/env/use.rs | 10 ++- 11 files changed, 193 insertions(+), 42 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_empty_package_manager_override.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_node_runtime.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index e11132a1e6..5047ed1e6e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -32,3 +32,12 @@ vp = "global" steps = [ { argv = ["vp", "env", "current", "pm", "--json"], envs = [["VP_PACKAGE_MANAGER", " "]], comment = "an empty package-manager environment override falls through to project resolution" }, ] + +[[case]] +name = "command_env_unset_empty_package_manager_override" +vp = "global" +steps = [ + { argv = ["vp", "env", "use", "pnpm@10.18.0", "--no-install"], snapshot = false }, + { argv = ["vp", "env", "use", "--unset", "pnpm"], envs = [["VP_PACKAGE_MANAGER", " "]], snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/.session-package-manager", "--assert", "missing"], comment = "an empty environment override does not prevent clearing the matching session selection" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_empty_package_manager_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_empty_package_manager_override.md new file mode 100644 index 0000000000..6349a0e296 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_empty_package_manager_override.md @@ -0,0 +1,15 @@ +# command_env_unset_empty_package_manager_override + +## `vp env use pnpm@10.18.0 --no-install` + + +## `VP_PACKAGE_MANAGER= vp env use --unset pnpm` + + +## `vpt stat-file $VP_HOME/.session-package-manager --assert missing` + +an empty environment override does not prevent clearing the matching session selection + +``` +/.vite-plus/.session-package-manager: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml index 92bc29842a..8aba685a7b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -77,6 +77,16 @@ steps = [ { argv = ["vp", "env", "list", "pnpm", "--json"], comment = "local listing remains available when a floating default cannot reach the registry", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, ] +[[case]] +name = "env_node_list_stays_offline_for_floating_default" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "$VP_HOME/js_runtime/node/20.18.0/bin/node", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/config.json", "{\"defaultNodeVersion\":\"latest\"}\n"], snapshot = false }, + { argv = ["vp", "env", "list", "node", "--json"], comment = "local Node.js listing remains available when a floating default cannot reach its mirror", envs = [["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]] }, +] + [[case]] name = "env_pin_package_manager_requires_confirmation" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md new file mode 100644 index 0000000000..6f3d29f2e2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md @@ -0,0 +1,25 @@ +# env_node_list_stays_offline_for_floating_default + +## `vpt write-file $VP_HOME/js_runtime/node/20.18.0/bin/node '#'\!'/bin/sh +'` + + +## `vpt write-file $VP_HOME/config.json '{"defaultNodeVersion":"latest"} +'` + + +## `VP_NODE_DIST_MIRROR=http://127.0.0.1:9 vp env list node --json` + +local Node.js listing remains available when a floating default cannot reach its mirror + +``` +{ + "node": [ + { + "version": "20.18.0", + "current": false, + "default": false + } + ] +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml index 9f27abfd77..05cb79621c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml @@ -22,3 +22,18 @@ skip-platforms = ["windows"] steps = [ { argv = ["vp", "env", "print", "pnpm"], comment = "a concrete family falls back to its latest release and Fish keeps a spaced install path intact", envs = [["VP_HOME", "${workspace}/custom home"], ["VP_SHELL", "fish"]] }, ] + +[[case]] +name = "command_env_print_npm_uses_node_runtime" +vp = "global" +steps = [ + { argv = ["vp", "env", "print", "npm"], comment = "unselected npm comes from the resolved Node.js runtime" }, +] + +[[case]] +name = "command_env_current_concrete_package_manager_fallback" +vp = "global" +local-registry = true +steps = [ + { argv = ["vp", "env", "current", "pnpm", "--json"], comment = "a concrete family reports the same registry fallback as its shim" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md new file mode 100644 index 0000000000..0f6b9564d9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md @@ -0,0 +1,21 @@ +# command_env_current_concrete_package_manager_fallback + +## `vp env current pnpm --json` + +a concrete family reports the same registry fallback as its shim + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "registry fallback", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": false, + "mode": "managed" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_node_runtime.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_node_runtime.md new file mode 100644 index 0000000000..f36a726af0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_node_runtime.md @@ -0,0 +1,12 @@ +# command_env_print_npm_uses_node_runtime + +## `vp env print npm` + +unselected npm comes from the resolved Node.js runtime + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +export PATH="/.vite-plus/js_runtime/node//bin:$PATH" +``` diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 384323a13d..496f8595c5 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -1,7 +1,10 @@ use std::{collections::BTreeMap, process::ExitStatus}; use serde::Serialize; -use vp_pm_cli::{package_manager_bin_path, package_manager_install_dir}; +use vp_pm_cli::{ + PackageManagerType, package_manager_bin_path, package_manager_install_dir, + resolve_package_manager_version, +}; use vt_path::AbsolutePathBuf; use super::{ @@ -160,13 +163,16 @@ async fn resolve_package_manager_info( let selected = package_manager::resolve_current_spec(cwd).await?.filter(|resolution| { scope.package_manager().is_none_or(|expected| expected == resolution.package_manager_type) }); - let Some(selected) = selected else { + let selected_type = selected + .as_ref() + .map(|resolution| resolution.package_manager_type) + .or_else(|| scope.package_manager()); + let Some(selected_type) = selected_type else { return Ok(None); }; - let mode = config.package_manager_shim_mode_for(selected.package_manager_type); + let mode = config.package_manager_shim_mode_for(selected_type); if mode == ShimMode::SystemFirst { - let bin_paths = selected - .package_manager_type + let bin_paths = selected_type .bin_names() .iter() .filter_map(|name| { @@ -174,17 +180,20 @@ async fn resolve_package_manager_info( .map(|path| ((*name).to_string(), path.as_path().display().to_string())) }) .collect::>(); - if let Some(primary) = bin_paths.get(selected.package_manager_type.to_string().as_str()) + if let Some(primary) = bin_paths.get(selected_type.to_string().as_str()) && let Some(primary) = AbsolutePathBuf::new(primary.into()) { return Ok(Some(PackageManagerInfo { - name: selected.package_manager_type.to_string(), + name: selected_type.to_string(), version: read_tool_version(&primary).await.unwrap_or_else(|| "unknown".into()), source: "system PATH".into(), source_path: None, - project_root: selected - .project_root - .map(|path| path.as_path().display().to_string()), + project_root: selected.as_ref().and_then(|resolution| { + resolution + .project_root + .as_ref() + .map(|path| path.as_path().display().to_string()) + }), installed: true, bin_paths, mode, @@ -192,18 +201,28 @@ async fn resolve_package_manager_info( } } - let Some(resolution) = - package_manager::resolve_current_for(cwd, scope.package_manager()).await? - else { - return Ok(None); + let resolution = package_manager::resolve_current_for(cwd, scope.package_manager()).await?; + let (package_manager_type, version, source, source_path, project_root) = match resolution { + Some(resolution) => ( + resolution.package_manager_type, + resolution.version.to_string(), + resolution.source.to_string(), + resolution.source_path.map(|path| path.as_path().display().to_string()), + resolution.project_root.map(|path| path.as_path().display().to_string()), + ), + None if scope.package_manager().is_some() && selected_type != PackageManagerType::Npm => ( + selected_type, + resolve_package_manager_version(selected_type, "latest").await?.to_string(), + "registry fallback".into(), + None, + None, + ), + None => return Ok(None), }; - let Some(install_dir) = - package_manager_install_dir(resolution.package_manager_type, &resolution.version) - else { + let Some(install_dir) = package_manager_install_dir(package_manager_type, &version) else { return Ok(None); }; - let bin_paths = resolution - .package_manager_type + let bin_paths = package_manager_type .bin_names() .iter() .map(|name| { @@ -215,11 +234,11 @@ async fn resolve_package_manager_info( .collect::>(); let installed = bin_paths.values().all(|path| std::path::Path::new(path).exists()); Ok(Some(PackageManagerInfo { - name: resolution.package_manager_type.to_string(), - version: resolution.version.to_string(), - source: resolution.source.to_string(), - source_path: resolution.source_path.map(|path| path.as_path().display().to_string()), - project_root: resolution.project_root.map(|path| path.as_path().display().to_string()), + name: package_manager_type.to_string(), + version, + source, + source_path, + project_root, bin_paths, installed, mode, diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 3a63ee254c..eacac94337 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -65,10 +65,11 @@ pub async fn execute( }; let default_node = if scope.includes_node() { match config.default_node_version.as_deref() { - Some(selector) => Some( + Some(selector) => { config::resolve_version_alias(selector, &vp_js_runtime::NodeProvider::new()) - .await?, - ), + .await + .ok() + } None => None, } } else { diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 7417b2595b..07fbcd8f8b 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -154,20 +154,7 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result) -> Result { Some((resolution.package_manager_type, resolution.version, resolution.hash)) } + None if scope.package_manager() == Some(vp_pm_cli::PackageManagerType::Npm) => { + bin_dirs.insert( + 0, + resolve_node_bin_dir(&cwd, &modes) + .await? + .as_path() + .display() + .to_string(), + ); + None + } None => match scope.package_manager() { Some(package_manager) => Some(( package_manager, @@ -226,6 +224,24 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result Result { + if config.shim_mode == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool("node") + && let Some(bin_dir) = path.parent() + { + return Ok(bin_dir.to_absolute_path_buf()); + } + + let resolution = config::resolve_version(cwd).await?; + let runtime = + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolution.version) + .await?; + Ok(runtime.get_bin_prefix()) +} + fn format_path_snippet(shell: Shell, bin_dirs: &[String]) -> String { match shell { Shell::Posix => format!( diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 65e048e5fc..78a62efacb 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -307,7 +307,7 @@ pub async fn execute( } fn current_override(session: Option, environment: Option) -> Option { - environment.map(|value| value.trim().to_string()).or(session) + environment.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()).or(session) } #[cfg(test)] @@ -330,6 +330,14 @@ mod tests { ); } + #[test] + fn empty_environment_override_falls_back_to_session() { + assert_eq!( + current_override(Some("pnpm@10.18.0".into()), Some(" ".into())).as_deref(), + Some("pnpm@10.18.0") + ); + } + #[test] fn test_detect_shell_vp_shell_fish() { vp_shared::EnvConfig::with_vars( From 80e29560a306ecca58fc6328408e55d5709af49b Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 17:04:46 +0800 Subject: [PATCH 27/37] fix(env): preserve component lifecycle contracts --- .../command_env_install_no_arg/snapshots.toml | 11 +++ .../command_env_install_bundled_npm.md | 24 +++++ .../snapshots.toml | 9 ++ ...unset_session_independently_of_override.md | 15 +++ .../fixtures/command_env_use/snapshots.toml | 11 +++ ...v_use_silent_unchanged_installs_missing.md | 16 ++++ .../fixtures/command_env_which/snapshots.toml | 7 ++ ...mmand_env_current_npm_uses_node_runtime.md | 23 +++++ .../vp_global_cli/src/commands/env/current.rs | 68 +++++++++++++ .../src/commands/env/lifecycle.rs | 13 ++- crates/vp_global_cli/src/commands/env/use.rs | 95 ++++++++++++------- 11 files changed, 255 insertions(+), 37 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_bundled_npm.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_installs_missing.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_node_runtime.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml index 33e5a13008..dfc3363e37 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml @@ -9,3 +9,14 @@ steps = [ { argv = ["vp", "env", "install"], comment = "An implicit install identifies an active session override", continue-on-failure = true }, { argv = ["vp", "env", "uninstall", "22"], comment = "Node.js uninstall accepts the same major-version selectors as install", continue-on-failure = true }, ] + +[[case]] +name = "command_env_install_bundled_npm" +vp = "global" +skip-platforms = ["windows"] +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], snapshot = false }, + { argv = ["vp", "env", "install", "npm"], comment = "unselected npm installs the resolved Node.js runtime that bundles it" }, + { argv = ["vpt", "stat-file", "$VP_HOME/js_runtime/node/22.18.0/bin/npm", "--assert", "symlink"], comment = "the installed npm is the Node.js-bundled executable" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_bundled_npm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_bundled_npm.md new file mode 100644 index 0000000000..4aef38ce0f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_bundled_npm.md @@ -0,0 +1,24 @@ +# command_env_install_bundled_npm + +## `vpt write-file .node-version '22.18.0 +'` + + +## `vp env install npm` + +unselected npm installs the resolved Node.js runtime that bundles it + +``` +VITE+ - The Unified Toolchain for the Web + +Installing npm bundled with Node.js ... +Installed npm bundled with Node.js +``` + +## `vpt stat-file $VP_HOME/js_runtime/node/22.18.0/bin/npm --assert symlink` + +the installed npm is the Node.js-bundled executable + +``` +/.vite-plus/js_runtime/node//bin/npm: symlink +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index 5047ed1e6e..74f35e2c9e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -41,3 +41,12 @@ steps = [ { argv = ["vp", "env", "use", "--unset", "pnpm"], envs = [["VP_PACKAGE_MANAGER", " "]], snapshot = false }, { argv = ["vpt", "stat-file", "$VP_HOME/.session-package-manager", "--assert", "missing"], comment = "an empty environment override does not prevent clearing the matching session selection" }, ] + +[[case]] +name = "command_env_unset_session_independently_of_override" +vp = "global" +steps = [ + { argv = ["vp", "env", "use", "pnpm@10.18.0", "--no-install"], snapshot = false }, + { argv = ["vp", "env", "use", "--unset", "pnpm"], envs = [["VP_PACKAGE_MANAGER", "yarn@4.12.0"]], snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/.session-package-manager", "--assert", "missing"], comment = "a different environment override does not hide the matching session file from scoped cleanup" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md new file mode 100644 index 0000000000..3d63000a15 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md @@ -0,0 +1,15 @@ +# command_env_unset_session_independently_of_override + +## `vp env use pnpm@10.18.0 --no-install` + + +## `VP_PACKAGE_MANAGER=yarn@4.12.0 vp env use --unset pnpm` + + +## `vpt stat-file $VP_HOME/.session-package-manager --assert missing` + +a different environment override does not hide the matching session file from scoped cleanup + +``` +/.vite-plus/.session-package-manager: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml index 9fbd8b6be9..a07f399a38 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml @@ -11,3 +11,14 @@ steps = [ { argv = ["vp", "env", "use", "abc"], comment = "should show friendly error for invalid version", continue-on-failure = true }, { argv = ["vp", "env", "use", "--silent-if-unchanged", "--no-install"], comment = "an unchanged project environment emits no shell mutations", envs = [["VP_NODE_VERSION", "20.18.0"], ["VP_PACKAGE_MANAGER", "npm@10.9.4"]] }, ] + +[[case]] +name = "command_env_use_silent_unchanged_installs_missing" +vp = "global" +local-registry = true +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-use\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, + { argv = ["vp", "env", "use", "pm", "--silent-if-unchanged"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"]], snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "--assert", "file"], comment = "silent unchanged mode still performs the default installation" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_installs_missing.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_installs_missing.md new file mode 100644 index 0000000000..01d2b9bc64 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_installs_missing.md @@ -0,0 +1,16 @@ +# command_env_use_silent_unchanged_installs_missing + +## `vpt write-file package.json '{"name":"command-env-use","private":true,"packageManager":"pnpm@10.18.0"} +'` + + +## `VP_PACKAGE_MANAGER=pnpm@10.18.0 vp env use pm --silent-if-unchanged` + + +## `vpt stat-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm --assert file` + +silent unchanged mode still performs the default installation + +``` +/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm: file +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml index 05cb79621c..603e7e7c62 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml @@ -37,3 +37,10 @@ local-registry = true steps = [ { argv = ["vp", "env", "current", "pnpm", "--json"], comment = "a concrete family reports the same registry fallback as its shim" }, ] + +[[case]] +name = "command_env_current_npm_uses_node_runtime" +vp = "global" +steps = [ + { argv = ["vp", "env", "current", "npm", "--json"], comment = "unselected npm reports the executable bundled with the resolved Node.js runtime" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_node_runtime.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_node_runtime.md new file mode 100644 index 0000000000..9e433f7177 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_node_runtime.md @@ -0,0 +1,23 @@ +# command_env_current_npm_uses_node_runtime + +## `vp env current npm --json` + +unselected npm reports the executable bundled with the resolved Node.js runtime + +``` +{ + "package_manager": { + "name": "npm", + "version": "", + "source": "Node.js ", + "source_path": "/.node-version", + "project_root": "", + "bin_paths": { + "npm": "/.vite-plus/js_runtime/node//bin/npm", + "npx": "/.vite-plus/js_runtime/node//bin/npx" + }, + "installed": true, + "mode": "managed" + } +} +``` diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 496f8595c5..f7eae1f1be 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -217,6 +217,9 @@ async fn resolve_package_manager_info( None, None, ), + None if selected_type == PackageManagerType::Npm => { + return resolve_bundled_npm_info(cwd, config, mode).await; + } None => return Ok(None), }; let Some(install_dir) = package_manager_install_dir(package_manager_type, &version) else { @@ -245,6 +248,71 @@ async fn resolve_package_manager_info( })) } +async fn resolve_bundled_npm_info( + cwd: &vt_path::AbsolutePath, + config: &config::Config, + mode: ShimMode, +) -> Result, Error> { + if config.shim_mode == ShimMode::SystemFirst + && crate::shim::dispatch::find_system_tool("node").is_some() + { + let bin_paths = PackageManagerType::Npm + .bin_names() + .iter() + .filter_map(|name| { + crate::shim::dispatch::find_system_tool(name) + .map(|path| ((*name).to_string(), path.as_path().display().to_string())) + }) + .collect::>(); + let Some(primary) = bin_paths.get("npm").and_then(|path| AbsolutePathBuf::new(path.into())) + else { + return Ok(None); + }; + return Ok(Some(PackageManagerInfo { + name: "npm".into(), + version: read_tool_version(&primary).await.unwrap_or_else(|| "unknown".into()), + source: "system PATH".into(), + source_path: None, + project_root: None, + installed: true, + bin_paths, + mode, + })); + } + + let resolution = resolve_version(cwd).await?; + let home = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(&resolution.version); + let bin_paths = PackageManagerType::Npm + .bin_names() + .iter() + .map(|name| { + #[cfg(windows)] + let path = home.join(format!("{name}.cmd")); + #[cfg(not(windows))] + let path = home.join("bin").join(name); + ((*name).to_string(), path.as_path().display().to_string()) + }) + .collect::>(); + let primary = AbsolutePathBuf::new(bin_paths["npm"].clone().into()) + .expect("managed npm path is absolute"); + let installed = bin_paths.values().all(|path| std::path::Path::new(path).exists()); + Ok(Some(PackageManagerInfo { + name: "npm".into(), + version: read_tool_version(&primary).await.unwrap_or_else(|| "unknown".into()), + source: format!("Node.js {}", resolution.version), + source_path: resolution.source_path.map(|path| path.as_path().display().to_string()), + project_root: resolution.project_root.map(|path| path.as_path().display().to_string()), + installed, + bin_paths, + mode, + })) +} + fn mode_name(mode: ShimMode) -> &'static str { match mode { ShimMode::Managed => "managed", diff --git a/crates/vp_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs index b723989f1f..5423d5a89e 100644 --- a/crates/vp_global_cli/src/commands/env/lifecycle.rs +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -1,6 +1,6 @@ use std::process::ExitStatus; -use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; +use vp_pm_cli::{PackageManagerType, download_package_manager, resolve_package_manager_version}; use vt_path::AbsolutePathBuf; use super::{ @@ -69,6 +69,17 @@ pub(crate) async fn install( } else if let EnvScope::PackageManager(kind) = scope { match package_manager::resolve_current_for(&cwd, Some(kind)).await? { Some(current) => Some((kind, current.version, current.hash)), + None if kind == PackageManagerType::Npm => { + let resolution = config::resolve_version(&cwd).await?; + println!("Installing npm bundled with Node.js v{}...", resolution.version); + vp_js_runtime::download_runtime( + vp_js_runtime::JsRuntimeType::Node, + &resolution.version, + ) + .await?; + println!("Installed npm bundled with Node.js v{}", resolution.version); + None + } None => Some((kind, resolve_package_manager_version(kind, "latest").await?, None)), } } else { diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 78a62efacb..5813339e52 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -110,26 +110,26 @@ pub async fn execute( // Always delete the session file: on Windows it lives under VP_HOME and can // leak across shell windows, so even eval mode must clean it up. if unset { - let unset_package_manager = match scope { - EnvScope::PackageManager(expected) => current_override( - config::read_session_package_manager().await, - vp_shared::EnvConfig::get().package_manager.clone(), - ) - .and_then(|value| super::spec::parse_package_manager_spec(&value).ok()) - .is_some_and(|(kind, _)| kind == expected), - _ => scope.includes_package_managers(), + let session_package_manager = config::read_session_package_manager().await; + let environment_package_manager = vp_shared::EnvConfig::get().package_manager.clone(); + let (delete_session_package_manager, unset_environment_package_manager) = match scope { + EnvScope::PackageManager(expected) => ( + package_manager_matches(session_package_manager.as_deref(), expected), + package_manager_matches(environment_package_manager.as_deref(), expected), + ), + _ => (scope.includes_package_managers(), scope.includes_package_managers()), }; if scope.includes_node() { config::delete_session_version().await?; } - if unset_package_manager { + if delete_session_package_manager { config::delete_session_package_manager().await?; } if has_eval_wrapper() { if scope.includes_node() { println!("{}", format_unset(&shell, VERSION_ENV_VAR)); } - if unset_package_manager { + if unset_environment_package_manager { println!("{}", format_unset(&shell, PACKAGE_MANAGER_ENV_VAR)); } } else if !can_use_session_file() { @@ -184,8 +184,8 @@ pub async fn execute( None }; - // Check if already active and suppress output if requested - if silent_if_unchanged { + // Check if already active and suppress output if requested. + let unchanged = if silent_if_unchanged { let node_unchanged = match &node { Some((version, _)) => { current_override( @@ -216,9 +216,15 @@ pub async fn execute( } None => true, }; - if node_unchanged && package_manager_unchanged { - return Ok(ExitStatus::default()); + node_unchanged && package_manager_unchanged + } else { + false + }; + if unchanged { + if !no_install { + ensure_components_installed(&node, &package_manager).await?; } + return Ok(ExitStatus::default()); } if uses_project_environment && !has_eval_wrapper() && !can_use_session_file() { @@ -233,28 +239,8 @@ pub async fn execute( return Ok(ExitStatus::default()); } - // Ensure version is installed (unless --no-install) - if !no_install && let Some((resolved_version, _)) = &node { - let home_dir = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(resolved_version); - - #[cfg(windows)] - let binary_path = home_dir.join("node.exe"); - #[cfg(not(windows))] - let binary_path = home_dir.join("bin").join("node"); - - if !binary_path.as_path().exists() { - eprintln!("Installing Node.js v{}...", resolved_version); - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, resolved_version) - .await?; - } - } - if !no_install && let Some((kind, version, _, hash)) = &package_manager { - download_package_manager(*kind, version, hash.as_deref()).await?; + if !no_install { + ensure_components_installed(&node, &package_manager).await?; } if has_eval_wrapper() { @@ -306,6 +292,43 @@ pub async fn execute( Ok(ExitStatus::default()) } +fn package_manager_matches(value: Option<&str>, expected: PackageManagerType) -> bool { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| super::spec::parse_package_manager_spec(value).ok()) + .is_some_and(|(kind, _)| kind == expected) +} + +async fn ensure_components_installed( + node: &Option<(String, String)>, + package_manager: &Option<(PackageManagerType, String, String, Option)>, +) -> Result<(), Error> { + if let Some((resolved_version, _)) = node { + let home_dir = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(resolved_version); + + #[cfg(windows)] + let binary_path = home_dir.join("node.exe"); + #[cfg(not(windows))] + let binary_path = home_dir.join("bin").join("node"); + + if !binary_path.as_path().exists() { + eprintln!("Installing Node.js v{}...", resolved_version); + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, resolved_version) + .await?; + } + } + if let Some((kind, version, _, hash)) = package_manager { + download_package_manager(*kind, version, hash.as_deref()).await?; + } + Ok(()) +} + fn current_override(session: Option, environment: Option) -> Option { environment.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()).or(session) } From 9c79b9d41c61fd3b4497fd3e6e9680a208d450b2 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 18:38:25 +0800 Subject: [PATCH 28/37] fix(env): align undecided manager behavior --- .../assert-one-pnpm-version.cjs | 13 ++++++++ .../fixtures/command_env_clean/snapshots.toml | 12 +++++++ ...erves_concrete_package_manager_fallback.md | 26 ++++++++++++++++ .../snapshots.toml | 2 ++ ...e_use_preserves_system_without_deciding.md | 19 ++++++++++++ ..._package_manager_uses_system_first_node.md | 8 +++++ crates/vp_global_cli/src/cli.rs | 2 +- .../vp_global_cli/src/commands/env/clean.rs | 6 ++++ .../vp_global_cli/src/commands/env/config.rs | 31 ++++++++++++------- .../vp_global_cli/src/commands/env/current.rs | 2 +- .../vp_global_cli/src/commands/env/doctor.rs | 7 +++-- crates/vp_global_cli/src/commands/env/exec.rs | 29 ++++++++++++++--- crates/vp_global_cli/src/commands/env/mod.rs | 12 ++++--- .../vp_global_cli/src/commands/env/which.rs | 2 +- crates/vp_global_cli/src/commands/mod.rs | 2 +- 15 files changed, 145 insertions(+), 28 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/assert-one-pnpm-version.cjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/assert-one-pnpm-version.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/assert-one-pnpm-version.cjs new file mode 100644 index 0000000000..c95e8d5d3b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/assert-one-pnpm-version.cjs @@ -0,0 +1,13 @@ +const { readdirSync } = require('node:fs') +const { join } = require('node:path') + +const versions = readdirSync(join(process.env.VP_HOME, 'package_manager', 'pnpm'), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + +if (versions.length !== 1 || versions[0] === '0.0.1') { + process.stderr.write(`unexpected pnpm installs: ${versions.join(', ')}\n`) + process.exit(1) +} + +console.log('kept one concrete pnpm fallback') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml index fbc4aaf244..55bfb9e05b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml @@ -8,3 +8,15 @@ steps = [ { argv = ["vp", "env", "clean", "pm"], comment = "Clean all package-manager families" }, { argv = ["vp", "env", "clean", "pnpm"], comment = "Clean one package-manager family" }, ] + +[[case]] +name = "env_clean_preserves_concrete_package_manager_fallback" +vp = "global" +local-registry = true +seed-runtime = false +steps = [ + { argv = ["vp", "env", "print", "pnpm"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/0.0.1/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vp", "env", "clean", "pnpm"], comment = "cleanup removes stale installs but preserves the concrete family's current fallback" }, + { argv = ["node", "assert-one-pnpm-version.cjs"], comment = "the cached registry fallback remains available" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md new file mode 100644 index 0000000000..70ec279b7e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md @@ -0,0 +1,26 @@ +# env_clean_preserves_concrete_package_manager_fallback + +## `vp env print pnpm` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/0.0.1/pnpm/bin/pnpm '#'\!'/bin/sh +'` + + +## `vp env clean pnpm` + +cleanup removes stale installs but preserves the concrete family's current fallback + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed 1 package manager install +``` + +## `node assert-one-pnpm-version.cjs` + +the cached registry fallback remains available + +``` +kept one concrete pnpm fallback +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index a43ca62768..b1d4a9ab89 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -35,6 +35,7 @@ steps = [ { argv = ["vp", "env", "off", "node"], snapshot = false }, { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, { argv = ["vp", "install"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "a system-first package manager resolves offline and receives the Node.js selected by system-first Node mode" }, + { argv = ["vp", "env", "exec", "--node", "20.18.0", "pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "explicit Node execution inspects the system manager before resolving its declared range" }, ] [[case]] @@ -63,5 +64,6 @@ steps = [ { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, { argv = ["pnpm", "--version"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "a non-interactive upgrade keeps using system pnpm without prompting" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "non-interactive environment inspection follows the undecided shim's system-preserving policy" }, { argv = ["vpt", "stat-file", "$VP_HOME/config.json", "--assert", "missing"], comment = "non-interactive use does not record a choice" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md index 63a733b39f..32466481e6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md @@ -14,6 +14,25 @@ a non-interactive upgrade keeps using system pnpm without prompting system-pnpm ``` +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env current pnpm --json` + +non-interactive environment inspection follows the undecided shim's system-preserving policy + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "system-pnpm", + "source": "system PATH", + "bin_paths": { + "pnpm": "/system-bin/pnpm" + }, + "installed": true, + "mode": "system_first" + } +} +``` + ## `vpt stat-file $VP_HOME/config.json --assert missing` non-interactive use does not record a choice diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md index 14ed00d497..f8a09941f5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md @@ -28,3 +28,11 @@ VITE+ - The Unified Toolchain for the Web system-node ``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env exec --node 20.18.0 pnpm --version` + +explicit Node execution inspects the system manager before resolving its declared range + +``` +10.18.0 +``` diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 68d08e3217..51160e7c6f 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -678,7 +678,7 @@ async fn run_package_manager_command( let result = if let Some(selected) = selected.as_ref() && commands::env::config::load_config() .await? - .package_manager_shim_mode_for(selected.package_manager_type) + .effective_package_manager_shim_mode_for(selected.package_manager_type) == commands::env::config::ShimMode::SystemFirst && let Some(system_path) = crate::shim::dispatch::find_system_tool(&selected.package_manager_type.to_string()) diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index 6194e0586f..ba4f0c0371 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -67,6 +67,12 @@ async fn protected_package_manager( let mut protected = Vec::new(); if let Some(current) = current { protected.push((current.package_manager_type, vec![current.version.to_string()])); + } else if let EnvScope::PackageManager(kind) = scope + && kind != PackageManagerType::Npm + && config.effective_package_manager_shim_mode_for(kind) == config::ShimMode::Managed + { + protected + .push((kind, vec![resolve_package_manager_version(kind, "latest").await?.to_string()])); } if let Some((kind, version)) = default { let version = resolve_package_manager_version(kind, &version).await?.to_string(); diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 6799fb3a55..36721b0318 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -60,8 +60,19 @@ impl Config { } #[must_use] - pub fn package_manager_shim_mode_for(&self, package_manager: PackageManagerType) -> ShimMode { - self.configured_package_manager_shim_mode_for(package_manager).unwrap_or_default() + pub fn effective_package_manager_shim_mode_for( + &self, + package_manager: PackageManagerType, + ) -> ShimMode { + self.configured_package_manager_shim_mode_for(package_manager).unwrap_or_else(|| { + if !vp_shared::is_interactive_terminal() + && crate::shim::dispatch::find_system_tool(&package_manager.to_string()).is_some() + { + ShimMode::SystemFirst + } else { + self.shim_mode + } + }) } pub fn set_shim_modes(&mut self, node: bool, package_manager: bool, mode: ShimMode) { @@ -1495,23 +1506,19 @@ mod tests { config.set_package_manager_shim_mode(PackageManagerType::Pnpm, ShimMode::SystemFirst); assert_eq!( - config.package_manager_shim_mode_for(PackageManagerType::Pnpm), - ShimMode::SystemFirst - ); - assert_eq!( - config.package_manager_shim_mode_for(PackageManagerType::Bun), - ShimMode::Managed + config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), + Some(ShimMode::SystemFirst) ); assert_eq!(config.configured_package_manager_shim_mode_for(PackageManagerType::Bun), None); config.set_shim_modes(false, true, ShimMode::Managed); assert_eq!( - config.package_manager_shim_mode_for(PackageManagerType::Pnpm), - ShimMode::Managed + config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), + Some(ShimMode::Managed) ); assert_eq!( - config.package_manager_shim_mode_for(PackageManagerType::Bun), - ShimMode::Managed + config.configured_package_manager_shim_mode_for(PackageManagerType::Bun), + Some(ShimMode::Managed) ); assert_eq!(config.package_manager_shim_modes.len(), ALL_PACKAGE_MANAGERS.len()); } diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index f7eae1f1be..2b9c0c040e 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -170,7 +170,7 @@ async fn resolve_package_manager_info( let Some(selected_type) = selected_type else { return Ok(None); }; - let mode = config.package_manager_shim_mode_for(selected_type); + let mode = config.effective_package_manager_shim_mode_for(selected_type); if mode == ShimMode::SystemFirst { let bin_paths = selected_type .bin_names() diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 6a48f27f10..95add1cbe9 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -302,11 +302,11 @@ async fn check_shim_mode(scope: EnvScope) -> (config::Config, Option) -> Result String { .collect::>() .join(";") ), - Shell::Cmd => format!("set \"PATH={};%PATH%\"", bin_dirs.join(";")), + Shell::Cmd => format!( + "set \"PATH={};%PATH%\"", + bin_dirs.iter().map(|path| path.replace('%', "%%")).collect::>().join(";") + ), Shell::NuShell => format!( "$env.PATH = ($env.PATH | prepend [{}])", bin_dirs @@ -305,8 +309,8 @@ mod tests { r#"$env:PATH = 'C:\A&B''s;' + $env:PATH"# ); assert_eq!( - format_path_snippet(Shell::Cmd, &[r#"C:\A&B"#.into()]), - r#"set "PATH=C:\A&B;%PATH%""# + format_path_snippet(Shell::Cmd, &[r#"C:\%literal%\A&B"#.into()]), + r#"set "PATH=C:\%%literal%%\A&B;%PATH%""# ); assert_eq!( format_path_snippet(Shell::NuShell, &[r#"C:\A "B""#.into()]), diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 127fd1a154..4d1a82cfa1 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -35,7 +35,7 @@ const LABEL_WIDTH: usize = 10; pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result { let config = super::config::load_config().await?; let mode = if let Some(package_manager) = PackageManagerType::from_tool(tool) { - config.package_manager_shim_mode_for(package_manager) + config.effective_package_manager_shim_mode_for(package_manager) } else { config.shim_mode }; diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index e5afab4594..89a0557e56 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -139,7 +139,7 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu tracing::debug!("Set PATH to include {:?}", node_bin_prefix); } if let Some(package_manager) = env::package_manager::resolve_current_spec(project_path).await? { - if config.package_manager_shim_mode_for(package_manager.package_manager_type) + if config.effective_package_manager_shim_mode_for(package_manager.package_manager_type) == env::config::ShimMode::SystemFirst && let Some(system_path) = crate::shim::dispatch::find_system_tool( &package_manager.package_manager_type.to_string(), From d928446815a6f9ef9ff993c00700ff8505f21ea2 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 20:31:19 +0800 Subject: [PATCH 29/37] fix(env): complete package manager fallback contracts --- .../snapshots.toml | 2 + .../snapshots.toml | 1 + .../fixtures/command_env_which/snapshots.toml | 2 + .../snapshots.toml | 4 +- ...fers_existing_family_and_records_choice.md | 46 -------------- .../vp_global_cli/src/commands/env/config.rs | 15 ++++- .../vp_global_cli/src/commands/env/default.rs | 8 ++- .../vp_global_cli/src/commands/env/doctor.rs | 25 +++++--- crates/vp_global_cli/src/commands/env/exec.rs | 13 ++-- .../src/commands/env/lifecycle.rs | 20 ++++--- crates/vp_global_cli/src/commands/env/pin.rs | 60 +++++++++++++++---- crates/vp_global_cli/src/commands/env/spec.rs | 18 +++--- crates/vp_global_cli/src/commands/env/use.rs | 12 +++- .../vp_global_cli/src/commands/env/which.rs | 8 +++ crates/vp_global_cli/src/shim/dispatch.rs | 15 +++-- 15 files changed, 149 insertions(+), 100 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index 74f35e2c9e..ef9778c03f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -36,6 +36,7 @@ steps = [ [[case]] name = "command_env_unset_empty_package_manager_override" vp = "global" +skip-platforms = ["windows"] steps = [ { argv = ["vp", "env", "use", "pnpm@10.18.0", "--no-install"], snapshot = false }, { argv = ["vp", "env", "use", "--unset", "pnpm"], envs = [["VP_PACKAGE_MANAGER", " "]], snapshot = false }, @@ -45,6 +46,7 @@ steps = [ [[case]] name = "command_env_unset_session_independently_of_override" vp = "global" +skip-platforms = ["windows"] steps = [ { argv = ["vp", "env", "use", "pnpm@10.18.0", "--no-install"], snapshot = false }, { argv = ["vp", "env", "use", "--unset", "pnpm"], envs = [["VP_PACKAGE_MANAGER", "yarn@4.12.0"]], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml index 8aba685a7b..12fe730b89 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -70,6 +70,7 @@ steps = [ name = "env_package_manager_list_stays_offline_for_floating_default" vp = "global" seed-runtime = false +skip-platforms = ["windows"] steps = [ { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\n"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml index 603e7e7c62..9bb634233e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml @@ -26,6 +26,7 @@ steps = [ [[case]] name = "command_env_print_npm_uses_node_runtime" vp = "global" +skip-platforms = ["windows"] steps = [ { argv = ["vp", "env", "print", "npm"], comment = "unselected npm comes from the resolved Node.js runtime" }, ] @@ -41,6 +42,7 @@ steps = [ [[case]] name = "command_env_current_npm_uses_node_runtime" vp = "global" +skip-platforms = ["windows"] steps = [ { argv = ["vp", "env", "current", "npm", "--json"], comment = "unselected npm reports the executable bundled with the resolved Node.js runtime" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index b1d4a9ab89..d4ef6bdd15 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -6,7 +6,7 @@ steps = [ { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/yarn"], snapshot = false }, - { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "an upgraded install asks before replacing an existing pnpm", interactions = [ + { argv = ["pnpm", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ { "expect-milestone" = "pm-shim-choice:pnpm" }, { "write-key" = "down" }, { "write-key" = "down" }, @@ -14,7 +14,7 @@ steps = [ ] }, { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "the explicit system choice records only pnpm" }, { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "later pnpm invocations use the recorded choice without prompting" }, - { argv = ["yarn", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "an undecided Yarn family still prompts", interactions = [ + { argv = ["yarn", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ { "expect-milestone" = "pm-shim-choice:yarn" }, { "write-key" = "down" }, { "write-key" = "down" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md index 5097552043..acdd1e77e2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md @@ -11,29 +11,6 @@ ## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` -an upgraded install asks before replacing an existing pnpm - -**→ expect-milestone:** `pm-shim-choice:pnpm` - -``` -vp: Vite+ now can manage package-manager versions for each project. -Existing pnpm: /system-bin/pnpm -``` - -**← write-key:** `down` - -**← write-key:** `down` - -**← write-key:** `enter` - -``` -vp: Vite+ now can manage package-manager versions for each project. -Existing pnpm: /system-bin/pnpm - -? How should pnpm run? › -✔ How should pnpm run? · Use system pnpm -system-pnpm -``` ## `vpt print-file $VP_HOME/config.json` @@ -57,29 +34,6 @@ system-pnpm ## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} yarn --version` -an undecided Yarn family still prompts - -**→ expect-milestone:** `pm-shim-choice:yarn` - -``` -vp: Vite+ now can manage package-manager versions for each project. -Existing yarn: /system-bin/yarn -``` - -**← write-key:** `down` - -**← write-key:** `down` - -**← write-key:** `enter` - -``` -vp: Vite+ now can manage package-manager versions for each project. -Existing yarn: /system-bin/yarn - -? How should yarn run? › -✔ How should yarn run? · Use system yarn -system-yarn -``` ## `vpt print-file $VP_HOME/config.json` diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 36721b0318..c2ae5334c7 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -76,6 +76,13 @@ impl Config { } pub fn set_shim_modes(&mut self, node: bool, package_manager: bool, mode: ShimMode) { + if node && !package_manager { + for package_manager in ALL_PACKAGE_MANAGERS { + self.package_manager_shim_modes + .entry(package_manager.to_string()) + .or_insert(self.shim_mode); + } + } if node { self.shim_mode = mode; } @@ -1493,11 +1500,15 @@ mod tests { } #[test] - fn node_only_mode_change_leaves_package_managers_unconfigured() { + fn node_only_mode_change_materializes_inherited_package_manager_modes() { let mut config = Config::default(); config.set_shim_modes(true, false, ShimMode::SystemFirst); assert_eq!(config.shim_mode, ShimMode::SystemFirst); - assert_eq!(config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), None); + assert_eq!( + config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), + Some(ShimMode::Managed) + ); + assert_eq!(config.package_manager_shim_modes.len(), ALL_PACKAGE_MANAGERS.len()); } #[test] diff --git a/crates/vp_global_cli/src/commands/env/default.rs b/crates/vp_global_cli/src/commands/env/default.rs index 0e85186a03..d357037429 100644 --- a/crates/vp_global_cli/src/commands/env/default.rs +++ b/crates/vp_global_cli/src/commands/env/default.rs @@ -55,13 +55,17 @@ pub async fn execute(values: Vec, unset: bool) -> Result String { } async fn get_tool_version(tool_path: &vt_path::AbsolutePath) -> String { - match tokio::process::Command::new(tool_path.as_path()).arg("--version").output().await { - Ok(output) if output.status.success() => { - String::from_utf8_lossy(&output.stdout).trim().to_string() - } - _ => "unknown".to_string(), - } + try_get_tool_version(tool_path).await.unwrap_or_else(|| "unknown".to_string()) +} + +async fn try_get_tool_version(tool_path: &vt_path::AbsolutePath) -> Option { + let output = + tokio::process::Command::new(tool_path.as_path()).arg("--version").output().await.ok()?; + output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) } /// One devEngines doctor finding. diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index e60e60af48..0a90258361 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -15,7 +15,8 @@ use vp_shared::env_vars; use vt_path::AbsolutePath; use super::{ - config, package_manager as package_manager_resolution, spec::parse_package_manager_spec, + config, package_manager as package_manager_resolution, + spec::parse_package_manager_spec_with_hash, }; use crate::{ cli::exit_status, @@ -155,9 +156,9 @@ async fn execute_with_version( let explicit_package_manager = package_manager.is_some(); let mut system_package_manager = None; let selected_package_manager = if let Some(package_manager) = package_manager { - let (kind, selector) = parse_package_manager_spec(package_manager)?; + let (kind, selector, hash) = parse_package_manager_spec_with_hash(package_manager)?; let version = resolve_package_manager_version(kind, &selector).await?.to_string(); - Some((kind, version, None)) + Some((kind, version, hash)) } else { let selected = package_manager_resolution::resolve_current_spec(cwd).await?; if let Some(selected) = selected @@ -175,7 +176,11 @@ async fn execute_with_version( None } else { package_manager_resolution::resolve_current(cwd).await?.map(|resolution| { - (resolution.package_manager_type, resolution.version.to_string(), resolution.hash) + ( + resolution.package_manager_type, + resolution.version.to_string(), + resolution.hash.map(|hash| hash.to_string()), + ) }) } }; diff --git a/crates/vp_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs index 5423d5a89e..3ff292d178 100644 --- a/crates/vp_global_cli/src/commands/env/lifecycle.rs +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -63,12 +63,14 @@ pub(crate) async fn install( } if scope.includes_package_managers() { - let requested = if let Some((kind, selector)) = specs.package_manager { + let requested = if let Some((kind, selector, hash)) = specs.package_manager { let version = resolve_package_manager_version(kind, &selector).await?; - Some((kind, version, None)) + Some((kind, version, hash)) } else if let EnvScope::PackageManager(kind) = scope { match package_manager::resolve_current_for(&cwd, Some(kind)).await? { - Some(current) => Some((kind, current.version, current.hash)), + Some(current) => { + Some((kind, current.version, current.hash.map(|hash| hash.to_string()))) + } None if kind == PackageManagerType::Npm => { let resolution = config::resolve_version(&cwd).await?; println!("Installing npm bundled with Node.js v{}...", resolution.version); @@ -83,9 +85,13 @@ pub(crate) async fn install( None => Some((kind, resolve_package_manager_version(kind, "latest").await?, None)), } } else { - package_manager::resolve_current(&cwd) - .await? - .map(|current| (current.package_manager_type, current.version, current.hash)) + package_manager::resolve_current(&cwd).await?.map(|current| { + ( + current.package_manager_type, + current.version, + current.hash.map(|hash| hash.to_string()), + ) + }) }; if let Some((kind, version, hash)) = requested { println!("Installing {kind} v{version}..."); @@ -101,7 +107,7 @@ pub(crate) async fn uninstall(specs: Vec) -> Result { let specs = EnvSpecs::parse(&specs)?; let package_manager = specs .package_manager - .map(|(kind, version)| { + .map(|(kind, version, _)| { node_semver::Version::parse(&version) .map(|version| (kind, version.to_string())) .map_err(|_| { diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 0019e637a4..490c6cffbc 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -76,11 +76,12 @@ pub async fn execute( if let Some(version) = specs.node { do_pin(&cwd, &version, no_install, force, target).await?; } - if let Some((package_manager, version)) = specs.package_manager { + if let Some((package_manager, version, hash)) = specs.package_manager { pin_package_manager( &package_manager_root, package_manager, &version, + hash.as_deref(), no_install, force, target, @@ -688,6 +689,7 @@ async fn pin_package_manager( cwd: &AbsolutePathBuf, package_manager: PackageManagerType, version: &str, + hash: Option<&str>, no_install: bool, force: bool, target: Option, @@ -720,15 +722,22 @@ async fn pin_package_manager( let mut changed = false; let updated = vp_shared::edit_json_object(&content, |obj| { if use_top_level { - let existing = obj.get("packageManager").and_then(serde_json::Value::as_str); let prefix = format!("{package_manager}@{resolved}"); - let next = existing - .filter(|value| { - *value == prefix - || value.strip_prefix(&prefix).is_some_and(|suffix| suffix.starts_with('+')) - }) - .unwrap_or(&prefix) - .to_string(); + let existing = obj.get("packageManager").and_then(serde_json::Value::as_str); + let next = hash.map_or_else( + || { + existing + .filter(|value| { + *value == prefix + || value + .strip_prefix(&prefix) + .is_some_and(|suffix| suffix.starts_with('+')) + }) + .unwrap_or(&prefix) + .to_string() + }, + |hash| format!("{prefix}+{hash}"), + ); if obj.get("packageManager").and_then(serde_json::Value::as_str) != Some(&next) { obj.insert("packageManager".into(), serde_json::Value::String(next)); changed = true; @@ -753,7 +762,7 @@ async fn pin_package_manager( } if no_install { output::note("Package manager will be downloaded on first use."); - } else if let Err(error) = download_package_manager(package_manager, &resolved, None).await { + } else if let Err(error) = download_package_manager(package_manager, &resolved, hash).await { output::warn(&format!("Failed to download {package_manager} {resolved}: {error}")); } Ok(ExitStatus::default()) @@ -957,7 +966,7 @@ mod tests { .await .unwrap(); - pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, true, None) + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", None, true, true, None) .await .unwrap(); @@ -976,7 +985,7 @@ mod tests { .await .unwrap(); - pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, true, None) + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", None, true, true, None) .await .unwrap(); @@ -985,6 +994,33 @@ mod tests { assert!(!content.contains("sha512.stale")); } + #[tokio::test] + async fn package_manager_pin_uses_explicit_integrity_suffix() { + let temp_dir = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write( + cwd.join("package.json"), + "{\n \"packageManager\": \"pnpm@10.17.0\"\n}\n", + ) + .await + .unwrap(); + + pin_package_manager( + &cwd, + PackageManagerType::Pnpm, + "10.18.0", + Some("sha512.explicit"), + true, + true, + None, + ) + .await + .unwrap(); + + let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(content.contains("pnpm@10.18.0+sha512.explicit")); + } + #[test] fn remove_last_package_manager_array_entry_removes_field() { let mut manifest = serde_json::json!({ diff --git a/crates/vp_global_cli/src/commands/env/spec.rs b/crates/vp_global_cli/src/commands/env/spec.rs index a57911767c..7390acadc2 100644 --- a/crates/vp_global_cli/src/commands/env/spec.rs +++ b/crates/vp_global_cli/src/commands/env/spec.rs @@ -48,7 +48,7 @@ impl EnvScope { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct EnvSpecs { pub(crate) node: Option, - pub(crate) package_manager: Option<(PackageManagerType, String)>, + pub(crate) package_manager: Option<(PackageManagerType, String, Option)>, } impl EnvSpecs { @@ -64,13 +64,8 @@ impl EnvSpecs { return Err(duplicate("Node.js")); } } else { - let package_manager = - PackageManagerType::from_name(name).ok_or_else(|| invalid_spec(value))?; - if parsed - .package_manager - .replace((package_manager, version.to_string())) - .is_some() - { + let package_manager = parse_package_manager_spec_with_hash(value)?; + if parsed.package_manager.replace(package_manager).is_some() { return Err(duplicate("package manager")); } } @@ -96,7 +91,7 @@ impl EnvSpecs { let scope = match (&specs.node, &specs.package_manager) { (Some(_), Some(_)) | (None, None) => EnvScope::All, (Some(_), None) => EnvScope::Node, - (None, Some((kind, _))) => EnvScope::PackageManager(*kind), + (None, Some((kind, _, _))) => EnvScope::PackageManager(*kind), }; Ok((scope, specs)) } @@ -156,7 +151,10 @@ mod tests { fn parses_legacy_node_and_package_manager_specs() { let parsed = EnvSpecs::parse(&["22.0.0".into(), "pnpm@10.18.0".into()]).unwrap(); assert_eq!(parsed.node.as_deref(), Some("22.0.0")); - assert_eq!(parsed.package_manager, Some((PackageManagerType::Pnpm, "10.18.0".into()))); + assert_eq!( + parsed.package_manager, + Some((PackageManagerType::Pnpm, "10.18.0".into(), None)) + ); } #[test] diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 5813339e52..358e1c83aa 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -140,7 +140,12 @@ pub async fn execute( } let provider = vp_js_runtime::NodeProvider::new(); - let node = if scope.includes_node() { + let use_bundled_npm = matches!(scope, EnvScope::PackageManager(PackageManagerType::Npm)) + && specs.package_manager.is_none() + && package_manager::resolve_from_files_for(&cwd, Some(PackageManagerType::Npm)) + .await? + .is_none(); + let node = if scope.includes_node() || use_bundled_npm { let (version, source) = if let Some(selector) = specs.node.as_deref() { (config::resolve_version_alias(selector, &provider).await?, selector.to_string()) } else { @@ -153,10 +158,10 @@ pub async fn execute( }; let package_manager = if scope.includes_package_managers() { - let resolved = if let Some((kind, selector)) = specs.package_manager { + let resolved = if let Some((kind, selector, hash)) = specs.package_manager { let version = resolve_package_manager_version(kind, &selector).await?.to_string(); package_manager::warn_if_target_differs(&cwd, kind).await; - Some((kind, version, selector, None)) + Some((kind, version, selector, hash)) } else { if let EnvScope::PackageManager(kind) = scope { package_manager::warn_if_target_differs(&cwd, kind).await; @@ -174,6 +179,7 @@ pub async fn execute( }; if let EnvScope::PackageManager(kind) = scope && resolved.is_none() + && kind != PackageManagerType::Npm { let version = resolve_package_manager_version(kind, "latest").await?.to_string(); Some((kind, version, "latest".into(), None)) diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 4d1a82cfa1..cfd9bd8284 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -193,6 +193,14 @@ async fn execute_package_manager_tool( /// Execute which for a core tool (node, npm, npx). async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result { + if matches!(tool, "npm" | "npx") + && super::config::load_config().await?.shim_mode == ShimMode::SystemFirst + && let Some(path) = shim::dispatch::find_system_tool(tool) + { + println!("{}", path.as_path().display()); + return Ok(ExitStatus::default()); + } + // Resolve version for current directory let resolution = resolve_version(&cwd).await?; diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index fb520a05d5..fd452bc487 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -1334,7 +1334,12 @@ async fn resolve_package_manager_shim_mode( return ShimMode::SystemFirst; } - let (mode, apply_to_all) = prompt_package_manager_shim_mode(package_manager, &system_path); + let Some((mode, apply_to_all)) = + prompt_package_manager_shim_mode(package_manager, &system_path) + else { + output::note("Package-manager preference was not saved; using the system tool this time."); + return ShimMode::SystemFirst; + }; if apply_to_all { config.set_all_package_manager_shim_modes(mode); } else { @@ -1349,7 +1354,7 @@ async fn resolve_package_manager_shim_mode( fn prompt_package_manager_shim_mode( package_manager: PackageManagerType, system_path: &AbsolutePath, -) -> (ShimMode, bool) { +) -> Option<(ShimMode, bool)> { let options = [ "Use Vite+ for all package managers".to_string(), format!("Use Vite+ for {package_manager}"), @@ -1366,14 +1371,14 @@ fn prompt_package_manager_shim_mode( .items(&options) .default(0) .interact() - .unwrap_or(0); + .ok()?; - match choice { + Some(match choice { 0 => (ShimMode::Managed, true), 1 => (ShimMode::Managed, false), 2 => (ShimMode::SystemFirst, false), _ => (ShimMode::SystemFirst, true), - } + }) } /// Emit an invisible synchronization point for the PTY snapshot suite. From f536a1e74c0acdb1b3cd664b1afca4573e341c41 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 21:27:51 +0800 Subject: [PATCH 30/37] fix(env): store per-manager default versions --- .../snapshots/cli_helper_message.md | 2 +- .../command_env_list_remote/snapshots.toml | 2 +- .../snapshots/command_env_list_remote.md | 4 +- .../snapshots.toml | 12 +++ ...ackage_manager_defaults_are_independent.md | 34 +++++++++ crates/vp_global_cli/src/cli.rs | 4 +- .../vp_global_cli/src/commands/env/clean.rs | 15 ++-- .../vp_global_cli/src/commands/env/config.rs | 75 ++++++++++++++++++- .../vp_global_cli/src/commands/env/default.rs | 56 +++++++------- crates/vp_global_cli/src/commands/env/list.rs | 37 ++++----- .../src/commands/env/list_remote.rs | 31 +++----- .../src/commands/env/package_manager.rs | 38 ++++++---- crates/vp_global_cli/src/commands/env/spec.rs | 5 -- crates/vp_global_cli/src/help.rs | 2 +- crates/vp_pm_cli/src/package_manager.rs | 40 ++++++++-- docs/guide/env.md | 8 +- 16 files changed, 244 insertions(+), 121 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 5281024f66..370f04858e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -495,7 +495,7 @@ Examples: Manage: vp env default 22.19.0 # Set the Node.js default - vp env default pnpm@12 # Set the package-manager default + vp env default pnpm@12 # Set pnpm's default version vp env pin 22.19.0 # Pin Node.js for this project vp env use 22.19.0 # Use Node.js in this shell vp env clean # Clean all unused managed versions diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml index d518df989c..ecbef22d4e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml @@ -8,7 +8,7 @@ steps = [ { argv = ["vp", "env", "install", "lts"], comment = "Install an LTS Node.js version locally", continue-on-failure = true }, { argv = ["vp", "env", "default", "lts"], comment = "Set it as the global default (stored as the `lts` alias)", continue-on-failure = true }, { argv = ["vp", "env", "default", "node"], comment = "A configured Node.js alias shows its current resolution and config source", continue-on-failure = true }, - { argv = ["vp", "env", "default", "pnpm@10.18.0"], comment = "Package-manager default updates identify the selected package and version", continue-on-failure = true }, + { argv = ["vp", "env", "default", "pnpm@10.18.0"], comment = "Package-manager default updates identify the selected family and version", continue-on-failure = true }, { argv = ["node", "-e", "const {execFileSync}=require('node:child_process'); const {node}=JSON.parse(execFileSync('vp',['env','list-remote','--lts','--json'],{encoding:'utf8'})); console.log('installed marked:', node.some(v=>v.installed)); console.log('current marked:', node.some(v=>v.current)); console.log('default marked:', node.some(v=>v.default));"], comment = "the unified JSON node entries resolve installed/current/default flags, including the `lts` default alias", continue-on-failure = true }, { argv = ["vp", "env", "list-remote", "node", "22.11.0"], comment = "Human-readable Node.js results keep the v prefix, LTS codename, and interactive formatting", formatted-snapshot = true, continue-on-failure = true }, { argv = ["vp", "env", "list-remote", "pnpm", "10.18.0"], comment = "Human-readable package-manager results retain current-version formatting", formatted-snapshot = true, continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md index f9726f5aad..772f8c81c5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md @@ -46,12 +46,12 @@ Default Node.js version: lts ## `vp env default pnpm@10.18.0` -Package-manager default updates identify the selected package and version +Package-manager default updates identify the selected family and version ``` VITE+ - The Unified Toolchain for the Web -✓ Default package manager set to pnpm@10.18.0 +✓ Default pnpm version set to 10.18.0 ``` ## `node -e 'const {execFileSync}=require('\''node:child_process'\''); const {node}=JSON.parse(execFileSync('\''vp'\'',['\''env'\'','\''list-remote'\'','\''--lts'\'','\''--json'\''],{encoding:'\''utf8'\''})); console.log('\''installed marked:'\'', node.some(v=>v.installed)); console.log('\''current marked:'\'', node.some(v=>v.current)); console.log('\''default marked:'\'', node.some(v=>v.default));'` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml index 1b391d5892..4fd7e91982 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml @@ -23,3 +23,15 @@ steps = [ { argv = ["vp", "env", "default", "bun@latest"], snapshot = false }, { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const {readFileSync}=require('node:fs');const expected=readFileSync(process.env.VP_HOME+'/bun-fallback-version','utf8');const actual=execFileSync('bun',['--version'],{encoding:'utf8'});if(actual!==expected)throw new Error(`expected ${expected.trim()}, got ${actual.trim()}`);console.log('default bun@latest matches the unconfigured fallback')"], envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "the explicit floating default behaves like the unconfigured Bun fallback" }, ] + +[[case]] +name = "shim_package_manager_defaults_are_independent" +vp = "global" +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "env", "default", "pnpm@10.18.0"], snapshot = false }, + { argv = ["vp", "env", "default", "bun@1.2.0"], snapshot = false }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "pnpm and Bun defaults are persisted independently" }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const pnpm=execFileSync('pnpm',['--version'],{encoding:'utf8'}).trim();const bun=execFileSync('bun',['--version'],{encoding:'utf8'}).trim();if(pnpm!=='10.18.0'||bun!=='1.2.0')throw new Error(`expected pnpm 10.18.0 and bun 1.2.0, got pnpm ${pnpm} and bun ${bun}`);console.log('direct shims use independent defaults')"], comment = "direct package-manager shims use their own configured versions" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md new file mode 100644 index 0000000000..2780cb68ea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md @@ -0,0 +1,34 @@ +# shim_package_manager_defaults_are_independent + +## `vp env default pnpm@10.18.0` + + +## `vp env default bun@1.2.0` + + +## `vpt print-file $VP_HOME/config.json` + +pnpm and Bun defaults are persisted independently + +``` +{ + "defaultPackageManagerVersions": { + "bun": "1.2.0", + "pnpm": "10.18.0" + }, + "packageManagerShimModes": { + "bun": "managed", + "npm": "managed", + "pnpm": "managed", + "yarn": "managed" + } +} +``` + +## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const pnpm=execFileSync('\''pnpm'\'',['\''--version'\''],{encoding:'\''utf8'\''}).trim();const bun=execFileSync('\''bun'\'',['\''--version'\''],{encoding:'\''utf8'\''}).trim();if(pnpm'\!'=='\''10.18.0'\''||bun'\!'=='\''1.2.0'\'')throw new Error(`expected pnpm 10.18.0 and bun 1.2.0, got pnpm ${pnpm} and bun ${bun}`);console.log('\''direct shims use independent defaults'\'')'` + +direct package-manager shims use their own configured versions + +``` +direct shims use independent defaults +``` diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 51160e7c6f..8820f488df 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -309,12 +309,12 @@ pub enum EnvSubcommands { scope: Option, }, - /// Set or show global Node.js and package-manager defaults + /// Set or show global Node.js and per-package-manager defaults #[command(after_long_help = "\ Examples: vp env default # Show both current defaults vp env default 22.19.0 # Set the Node.js default - vp env default pnpm@12 # Set the package-manager default")] + vp env default pnpm@12 # Set pnpm's default version")] Default { /// Defaults or component selectors. Bare versions select Node.js. values: Vec, diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index ba4f0c0371..ade83b0a94 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -13,7 +13,7 @@ use super::{ config, list::list_installed_versions, package_manager::{self, ALL_PACKAGE_MANAGERS}, - spec::{EnvScope, parse_package_manager_spec}, + spec::EnvScope, }; use crate::error::Error; @@ -58,12 +58,6 @@ async fn protected_package_manager( ) -> Result)>, Error> { let current = package_manager::resolve_current_for(cwd, scope.package_manager()).await?; let config = config::load_config().await?; - let default = config - .default_package_manager - .as_deref() - .map(parse_package_manager_spec) - .transpose()? - .filter(|(kind, _)| scope.includes_package_manager(*kind)); let mut protected = Vec::new(); if let Some(current) = current { protected.push((current.package_manager_type, vec![current.version.to_string()])); @@ -74,8 +68,11 @@ async fn protected_package_manager( protected .push((kind, vec![resolve_package_manager_version(kind, "latest").await?.to_string()])); } - if let Some((kind, version)) = default { - let version = resolve_package_manager_version(kind, &version).await?.to_string(); + for kind in package_manager::selected(scope) { + let Some((_, selector, _)) = package_manager::configured_default_for(&config, kind)? else { + continue; + }; + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); if let Some((_, versions)) = protected.iter_mut().find(|(current, _)| *current == kind) { push_unique_version(versions, version); } else { diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index c2ae5334c7..6b2437b333 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de}; use vp_js_runtime::{ NodeProvider, VersionSource, is_valid_version, normalize_version, read_nvmrc_file, read_package_json, resolve_node_version, @@ -39,9 +39,14 @@ pub struct Config { /// Default Node.js version when no project version file is found #[serde(default, skip_serializing_if = "Option::is_none")] pub default_node_version: Option, - /// Default package manager when the project does not select one. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_package_manager: Option, + /// Default versions used by package-manager shims when the project does not select that family. + #[serde( + default, + alias = "defaultPackageManager", + deserialize_with = "deserialize_package_manager_default_versions", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub default_package_manager_versions: BTreeMap, /// Shim mode for tool resolution #[serde(default, skip_serializing_if = "is_default_shim_mode")] pub shim_mode: ShimMode, @@ -51,6 +56,26 @@ pub struct Config { } impl Config { + #[must_use] + pub fn default_package_manager_version_for( + &self, + package_manager: PackageManagerType, + ) -> Option<&str> { + self.default_package_manager_versions.get(&package_manager.to_string()).map(String::as_str) + } + + pub fn set_default_package_manager_version( + &mut self, + package_manager: PackageManagerType, + version: String, + ) { + self.default_package_manager_versions.insert(package_manager.to_string(), version); + } + + pub fn clear_default_package_manager_version(&mut self, package_manager: PackageManagerType) { + self.default_package_manager_versions.remove(&package_manager.to_string()); + } + #[must_use] pub fn configured_package_manager_shim_mode_for( &self, @@ -107,6 +132,33 @@ impl Config { } } +#[derive(Deserialize)] +#[serde(untagged)] +enum PackageManagerDefaultVersions { + Versions(BTreeMap), + Legacy(String), +} + +fn deserialize_package_manager_default_versions<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + match PackageManagerDefaultVersions::deserialize(deserializer)? { + PackageManagerDefaultVersions::Versions(versions) => Ok(versions), + PackageManagerDefaultVersions::Legacy(spec) => { + let (name, version) = spec + .split_once('@') + .filter(|(name, version)| { + PackageManagerType::from_name(name).is_some() && !version.is_empty() + }) + .ok_or_else(|| de::Error::custom("invalid legacy package-manager default"))?; + Ok(BTreeMap::from([(name.to_string(), version.to_string())])) + } + } +} + /// Check if shim mode is the default (for skip_serializing_if) fn is_default_shim_mode(mode: &ShimMode) -> bool { *mode == ShimMode::Managed @@ -1542,6 +1594,21 @@ mod tests { assert_eq!(config.package_manager_shim_modes.len(), ALL_PACKAGE_MANAGERS.len()); } + #[test] + fn legacy_package_manager_default_migrates_to_version_map() { + let config: Config = + serde_json::from_str(r#"{"defaultPackageManager":"pnpm@10.18.0"}"#).unwrap(); + + assert_eq!( + config.default_package_manager_version_for(PackageManagerType::Pnpm), + Some("10.18.0") + ); + assert_eq!( + serde_json::to_value(config).unwrap()["defaultPackageManagerVersions"]["pnpm"], + "10.18.0" + ); + } + #[tokio::test] async fn package_manager_session_file_round_trip() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vp_global_cli/src/commands/env/default.rs b/crates/vp_global_cli/src/commands/env/default.rs index d357037429..74bbf31627 100644 --- a/crates/vp_global_cli/src/commands/env/default.rs +++ b/crates/vp_global_cli/src/commands/env/default.rs @@ -20,16 +20,11 @@ pub async fn execute(values: Vec, unset: bool) -> Result config - .default_package_manager - .as_deref() - .and_then(|value| super::spec::parse_package_manager_spec(value).ok()) - .is_some_and(|(kind, _)| kind == expected), - _ => true, - }; - if should_clear { - config.default_package_manager = None; + match scope { + EnvScope::PackageManager(package_manager) => { + config.clear_default_package_manager_version(package_manager); + } + _ => config.default_package_manager_versions.clear(), } } save_config(&config).await?; @@ -61,13 +56,13 @@ pub async fn execute(values: Vec, unset: bool) -> Result Result { } } if scope.includes_package_managers() { - let configured = config.default_package_manager.as_deref().filter(|spec| match scope { - EnvScope::PackageManager(expected) => super::spec::parse_package_manager_spec(spec) - .is_ok_and(|(kind, _)| kind == expected), - _ => true, - }); - match configured { - Some(spec) => { - has_configured_default = true; - println!("Default package manager: {spec}"); - } - None => match scope { + let selected = super::package_manager::selected(scope); + let configured = selected + .into_iter() + .filter_map(|package_manager| { + config + .default_package_manager_version_for(package_manager) + .map(|version| (package_manager, version)) + }) + .collect::>(); + if configured.is_empty() { + match scope { EnvScope::PackageManager(kind) => { println!("Default {kind} version: not configured") } - _ => println!("Default package manager: not configured"), - }, + _ => println!("Package manager defaults: not configured"), + } + } else { + for (package_manager, version) in configured { + has_configured_default = true; + println!("Default {package_manager} version: {version}"); + } } } if has_configured_default { diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index eacac94337..b9d923cf43 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -8,10 +8,7 @@ use vp_pm_cli::{ }; use vt_path::AbsolutePathBuf; -use super::{ - config, package_manager, - spec::{EnvScope, parse_package_manager_spec}, -}; +use super::{config, package_manager, spec::EnvScope}; use crate::error::Error; #[derive(Serialize)] @@ -75,23 +72,18 @@ pub async fn execute( } else { None }; - let default_pm = if scope.includes_package_managers() { - let default = config - .default_package_manager - .as_deref() - .map(parse_package_manager_spec) - .transpose()? - .filter(|(kind, _)| scope.includes_package_manager(*kind)); - match default { - Some((kind, selector)) => resolve_package_manager_version(kind, &selector) - .await - .ok() - .map(|version| (kind, version.to_string())), - None => None, + let mut default_package_manager_versions = BTreeMap::new(); + if scope.includes_package_managers() { + for kind in package_manager::selected(scope) { + let Some((_, selector, _)) = package_manager::configured_default_for(&config, kind)? + else { + continue; + }; + if let Ok(version) = resolve_package_manager_version(kind, &selector).await { + default_package_manager_versions.insert(kind.to_string(), version.to_string()); + } } - } else { - None - }; + } let node = scope.includes_node().then(|| { list_installed_versions(home.join("js_runtime").join("node").as_path()) @@ -117,9 +109,8 @@ pub async fn execute( current.package_manager_type == kind && current.version.as_str() == version }), - default: default_pm.as_ref().is_some_and(|(default, value)| { - *default == kind && value == &version - }), + default: default_package_manager_versions.get(&kind.to_string()) + == Some(&version), version, }) .collect(); diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index b75902ea74..2f0f0e693a 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -11,7 +11,7 @@ use super::{ config, list::{list_complete_package_manager_versions, list_installed_versions, use_color}, package_manager, - spec::{EnvScope, parse_package_manager_spec}, + spec::EnvScope, }; use crate::{cli::SortingMethod, error::Error}; @@ -102,22 +102,17 @@ pub async fn execute( } else { None }; - let default_pm = if scope.includes_package_managers() { - let default = config - .default_package_manager - .as_deref() - .map(parse_package_manager_spec) - .transpose()? - .filter(|(kind, _)| scope.includes_package_manager(*kind)); - match default { - Some((kind, selector)) => { - Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) - } - None => None, + let mut default_package_manager_versions = BTreeMap::new(); + if scope.includes_package_managers() { + for kind in package_manager::selected(scope) { + let Some((_, selector, _)) = package_manager::configured_default_for(&config, kind)? + else { + continue; + }; + let version = resolve_package_manager_version(kind, &selector).await?; + default_package_manager_versions.insert(kind.to_string(), version.to_string()); } - } else { - None - }; + } let home = vp_shared::EnvConfig::get().dirs.data.clone(); let node = node_versions.map(|versions| { @@ -146,9 +141,7 @@ pub async fn execute( current_pm.as_ref().and_then(|current| { (current.package_manager_type == kind).then_some(current.version.as_str()) }), - default_pm.as_ref().and_then(|(default, version)| { - (*default == kind).then_some(version.as_str()) - }), + default_package_manager_versions.get(&kind.to_string()).map(String::as_str), ); (kind.to_string(), entries) }) diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index c12de7bb27..a0b52a5d42 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -17,7 +17,7 @@ pub(crate) async fn resolve_current_for( cwd: &AbsolutePath, expected: Option, ) -> Result, Error> { - let specs = current_specs().await?; + let specs = current_specs(expected).await?; let mut resolution = resolve_environment_package_manager( cwd, specs.session_spec(), @@ -32,7 +32,7 @@ pub(crate) async fn resolve_current_for( pub(crate) async fn resolve_current_spec( cwd: &AbsolutePath, ) -> Result, Error> { - let specs = current_specs().await?; + let specs = current_specs(None).await?; let mut resolution = resolve_environment_package_manager_spec(cwd, specs.session_spec(), specs.default_spec()) @@ -41,7 +41,7 @@ pub(crate) async fn resolve_current_spec( Ok(resolution) } -type PackageManagerSpec = (PackageManagerType, String, Option); +pub(crate) type PackageManagerSpec = (PackageManagerType, String, Option); struct CurrentSpecs { session: Option, @@ -71,7 +71,7 @@ impl CurrentSpecs { } } -async fn current_specs() -> Result { +async fn current_specs(expected: Option) -> Result { let config = vp_shared::EnvConfig::get(); let (session, session_source, session_source_path) = if let Some(spec) = config.package_manager.as_deref().map(str::trim).filter(|spec| !spec.is_empty()) @@ -91,24 +91,34 @@ async fn current_specs() -> Result { (None, None, None) }; let config = config::load_config().await?; - let default = config - .default_package_manager - .as_deref() - .map(parse_package_manager_spec_with_hash) - .transpose()?; + let default = expected + .map(|package_manager| configured_default_for(&config, package_manager)) + .transpose()? + .flatten(); Ok(CurrentSpecs { session, session_source, session_source_path, default }) } +pub(crate) fn configured_default_for( + config: &config::Config, + package_manager: PackageManagerType, +) -> Result, Error> { + config + .default_package_manager_version_for(package_manager) + .map(|version| { + parse_package_manager_spec_with_hash(&format!("{package_manager}@{version}")) + }) + .transpose() +} + pub(crate) async fn resolve_from_files_for( cwd: &AbsolutePath, expected: Option, ) -> Result, Error> { let config = config::load_config().await?; - let default = config - .default_package_manager - .as_deref() - .map(parse_package_manager_spec_with_hash) - .transpose()?; + let default = expected + .map(|package_manager| configured_default_for(&config, package_manager)) + .transpose()? + .flatten(); resolve_environment_package_manager( cwd, None, diff --git a/crates/vp_global_cli/src/commands/env/spec.rs b/crates/vp_global_cli/src/commands/env/spec.rs index 7390acadc2..6fe97f67f7 100644 --- a/crates/vp_global_cli/src/commands/env/spec.rs +++ b/crates/vp_global_cli/src/commands/env/spec.rs @@ -38,11 +38,6 @@ impl EnvScope { _ => None, } } - - pub(crate) fn includes_package_manager(self, kind: PackageManagerType) -> bool { - self.includes_package_managers() - && self.package_manager().is_none_or(|selected| selected == kind) - } } #[derive(Clone, Debug, Default, PartialEq, Eq)] diff --git a/crates/vp_global_cli/src/help.rs b/crates/vp_global_cli/src/help.rs index 785c41082b..faa9d26040 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_global_cli/src/help.rs @@ -379,7 +379,7 @@ fn env_help_doc() -> HelpDoc { "", " Manage:", " vp env default 22.19.0 # Set the Node.js default", - " vp env default pnpm@12 # Set the package-manager default", + " vp env default pnpm@12 # Set pnpm's default version", " vp env pin 22.19.0 # Pin Node.js for this project", " vp env use 22.19.0 # Use Node.js in this shell", " vp env clean # Clean all unused managed versions", diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 923428592f..6f9c3de506 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -546,20 +546,23 @@ fn environment_package_manager_default( /// Resolve an environment package-manager requirement to an exact version for managed-runtime /// operations such as `vp env install` and package-manager shims. When `expected` is set, a -/// different selected family is discarded before any registry lookup. +/// different selected family falls back to the matching configured default before registry lookup. pub async fn resolve_environment_package_manager( cwd: impl AsRef, override_spec: Option<(PackageManagerType, &str, Option<&str>)>, default_spec: Option<(PackageManagerType, &str, Option<&str>)>, expected: Option, ) -> Result, Error> { - let Some(mut resolution) = - resolve_environment_package_manager_spec(cwd, override_spec, default_spec)?.filter( - |resolution| { - expected.is_none_or(|expected| expected == resolution.package_manager_type) - }, - ) - else { + let mut resolution = + resolve_environment_package_manager_spec(cwd, override_spec, default_spec)?; + if let Some(expected) = expected + && resolution.as_ref().is_some_and(|resolution| resolution.package_manager_type != expected) + { + resolution = default_spec + .filter(|(package_manager, _, _)| *package_manager == expected) + .map(environment_package_manager_default); + } + let Some(mut resolution) = resolution else { return Ok(None); }; resolution.version = @@ -2239,6 +2242,27 @@ mod tests { assert_eq!(resolution.source, "default"); } + #[tokio::test] + async fn environment_resolution_uses_expected_default_for_different_project_manager() { + let temp_dir = create_temp_dir(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + create_package_json(&cwd, r#"{"packageManager":"bun@1.2.0"}"#); + + let resolution = resolve_environment_package_manager( + &cwd, + None, + Some((PackageManagerType::Pnpm, "10.18.0", None)), + Some(PackageManagerType::Pnpm), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Pnpm); + assert_eq!(resolution.version, "10.18.0"); + assert_eq!(resolution.source, "default"); + } + #[test] fn environment_spec_keeps_declared_version_range() { let temp_dir = create_temp_dir(); diff --git a/docs/guide/env.md b/docs/guide/env.md index 946cc313b6..2f6c13564e 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -38,10 +38,10 @@ Package-manager selection uses this priority: 3. Top-level `packageManager` 4. `devEngines.packageManager` 5. Lockfile or manager-specific configuration -6. The global package-manager default +6. The named package manager's global default version 7. The named shim's latest release -A selected manager controls only its named shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Alias pairs are `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a package-manager selection, invoking `pnpm`, `yarn`, or `bun` uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. npm instead falls back to the version bundled with the resolved Node.js runtime. +A selected manager controls only its named shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Alias pairs are `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a matching project selection, a named shim uses its configured default version and otherwise uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. npm instead falls back to the version bundled with the resolved Node.js runtime. A fresh install uses the split platform layout by default. On Unix, Vite+ stores managed runtimes and related files in `~/.local/share/vite-plus`. It @@ -122,7 +122,7 @@ shim calls in the same job use these files to resolve the same environment. ### Manage -- `vp env default` shows both global defaults. Bare versions set Node.js; qualified specs such as `pnpm@10.18.0` set the PM fallback. `--unset` clears both unless scoped. +- `vp env default` shows the global Node.js default and each configured package-manager version. Bare versions set Node.js; qualified specs such as `pnpm@10.18.0` set that package manager's shim default without replacing the defaults for Bun, Yarn, or npm. `--unset` clears all defaults unless scoped. - `vp env pin` shows or writes project pins. Existing `.node-version` and top-level `packageManager` fields keep being updated for compatibility; otherwise Vite+ writes the matching `devEngines` entry. Use `--target node-version`, `--target dev-engines`, or `--target package-manager` to choose explicitly. - `vp env unpin` removes both effective pins by default; append a selector to remove one. Lower-priority declarations are not deleted. - `vp env use` activates the complete project environment. Explicit specs override selected components; `--unset` clears both unless scoped. @@ -160,7 +160,7 @@ vp env print # Print PATH setup for both components vp env pin lts pnpm@10 # Pin both project components to exact versions vp env install # Install the complete resolved environment vp env default lts # Set the global Node.js default -vp env default pnpm@10 # Set the global package-manager fallback +vp env default pnpm@10 # Set pnpm's global default version vp env use 20 pnpm@10 # Override both components for this shell vp env use --unset pm # Remove only the PM session override vp env clean yarn # Remove unused managed Yarn versions From a2a87c8aee4071cfc6575e7a61a99dcc6a5dcf50 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 22:06:39 +0800 Subject: [PATCH 31/37] fix(env): restore stable component contracts --- .../fixtures/command_env_clean/snapshots.toml | 3 +- ...erves_concrete_package_manager_fallback.md | 5 +- .../command_env_install_no_arg/snapshots.toml | 9 +- .../command_env_install_bundled_npm.md | 24 ---- ...and_env_install_standalone_npm_fallback.md | 40 +++++++ .../fixtures/command_env_use/snapshots.toml | 5 +- ...nd_env_use_silent_unchanged_stays_noop.md} | 8 +- .../command_env_use_shells/snapshots.toml | 10 ++ .../env_use_npm_uses_standalone_fallback.md | 10 ++ .../fixtures/command_env_which/snapshots.toml | 11 +- ...mmand_env_current_npm_uses_node_runtime.md | 23 ---- ...nv_current_npm_uses_standalone_fallback.md | 21 ++++ ...command_env_print_npm_uses_node_runtime.md | 12 -- ..._env_print_npm_uses_standalone_fallback.md | 12 ++ .../snapshots.toml | 18 ++- ...e_does_not_decide_package_manager_modes.md | 17 +++ ...e_defaults_to_managed_without_deciding.md} | 17 +-- crates/vp_global_cli/src/cli.rs | 2 +- .../vp_global_cli/src/commands/env/clean.rs | 68 ++++------- .../vp_global_cli/src/commands/env/config.rs | 55 ++++----- .../vp_global_cli/src/commands/env/current.rs | 109 +++--------------- .../vp_global_cli/src/commands/env/doctor.rs | 11 +- crates/vp_global_cli/src/commands/env/exec.rs | 6 +- .../src/commands/env/lifecycle.rs | 25 ++-- crates/vp_global_cli/src/commands/env/list.rs | 25 ++-- crates/vp_global_cli/src/commands/env/mod.rs | 48 +++----- .../src/commands/env/package_manager.rs | 37 +++++- crates/vp_global_cli/src/commands/env/use.rs | 34 ++---- .../vp_global_cli/src/commands/env/which.rs | 6 +- crates/vp_global_cli/src/commands/mod.rs | 4 +- crates/vp_global_cli/src/js_executor.rs | 4 +- crates/vp_global_cli/src/shim/dispatch.rs | 20 ++-- docs/guide/env.md | 4 +- rfcs/env-command.md | 9 +- 34 files changed, 332 insertions(+), 380 deletions(-) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_bundled_npm.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md rename crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/{command_env_use_silent_unchanged_installs_missing.md => command_env_use_silent_unchanged_stays_noop.md} (69%) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_npm_uses_standalone_fallback.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_node_runtime.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_standalone_fallback.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_node_runtime.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_standalone_fallback.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/node_mode_does_not_decide_package_manager_modes.md rename crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/{non_interactive_use_preserves_system_without_deciding.md => non_interactive_use_defaults_to_managed_without_deciding.md} (57%) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml index 55bfb9e05b..d9ee4427d8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml @@ -16,7 +16,8 @@ local-registry = true seed-runtime = false steps = [ { argv = ["vp", "env", "print", "pnpm"], snapshot = false }, + { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/0.0.1/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, - { argv = ["vp", "env", "clean", "pnpm"], comment = "cleanup removes stale installs but preserves the concrete family's current fallback" }, + { argv = ["vp", "env", "clean", "pnpm"], comment = "cleanup removes stale installs but preserves the concrete family's managed fallback even in system-first mode" }, { argv = ["node", "assert-one-pnpm-version.cjs"], comment = "the cached registry fallback remains available" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md index 70ec279b7e..9667b83aeb 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md @@ -3,13 +3,16 @@ ## `vp env print pnpm` +## `vp env off pnpm` + + ## `vpt write-file $VP_HOME/package_manager/pnpm/0.0.1/pnpm/bin/pnpm '#'\!'/bin/sh '` ## `vp env clean pnpm` -cleanup removes stale installs but preserves the concrete family's current fallback +cleanup removes stale installs but preserves the concrete family's managed fallback even in system-first mode ``` VITE+ - The Unified Toolchain for the Web diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml index dfc3363e37..aa17092c03 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml @@ -11,12 +11,13 @@ steps = [ ] [[case]] -name = "command_env_install_bundled_npm" +name = "command_env_install_standalone_npm_fallback" vp = "global" +local-registry = true skip-platforms = ["windows"] seed-runtime = false steps = [ - { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], snapshot = false }, - { argv = ["vp", "env", "install", "npm"], comment = "unselected npm installs the resolved Node.js runtime that bundles it" }, - { argv = ["vpt", "stat-file", "$VP_HOME/js_runtime/node/22.18.0/bin/npm", "--assert", "symlink"], comment = "the installed npm is the Node.js-bundled executable" }, + { argv = ["vp", "env", "install", "npm"], comment = "an explicit npm scope installs the standalone registry fallback" }, + { argv = ["vp", "env", "current", "npm", "--json"], comment = "the standalone npm fallback is installed" }, + { argv = ["vpt", "stat-file", "$VP_HOME/js_runtime/node", "--assert", "missing"], comment = "installing standalone npm does not install Node.js" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_bundled_npm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_bundled_npm.md deleted file mode 100644 index 4aef38ce0f..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_bundled_npm.md +++ /dev/null @@ -1,24 +0,0 @@ -# command_env_install_bundled_npm - -## `vpt write-file .node-version '22.18.0 -'` - - -## `vp env install npm` - -unselected npm installs the resolved Node.js runtime that bundles it - -``` -VITE+ - The Unified Toolchain for the Web - -Installing npm bundled with Node.js ... -Installed npm bundled with Node.js -``` - -## `vpt stat-file $VP_HOME/js_runtime/node/22.18.0/bin/npm --assert symlink` - -the installed npm is the Node.js-bundled executable - -``` -/.vite-plus/js_runtime/node//bin/npm: symlink -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md new file mode 100644 index 0000000000..02e027d301 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md @@ -0,0 +1,40 @@ +# command_env_install_standalone_npm_fallback + +## `vp env install npm` + +an explicit npm scope installs the standalone registry fallback + +``` +VITE+ - The Unified Toolchain for the Web + +Installing npm ... +Installed npm +``` + +## `vp env current npm --json` + +the standalone npm fallback is installed + +``` +{ + "package_manager": { + "name": "npm", + "version": "", + "source": "registry fallback", + "bin_paths": { + "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", + "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" + }, + "installed": true, + "mode": "managed" + } +} +``` + +## `vpt stat-file $VP_HOME/js_runtime/node --assert missing` + +installing standalone npm does not install Node.js + +``` +/.vite-plus/js_runtime/node: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml index a07f399a38..4fd03ab8bc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml @@ -13,12 +13,11 @@ steps = [ ] [[case]] -name = "command_env_use_silent_unchanged_installs_missing" +name = "command_env_use_silent_unchanged_stays_noop" vp = "global" -local-registry = true seed-runtime = false steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-use\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, { argv = ["vp", "env", "use", "pm", "--silent-if-unchanged"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"]], snapshot = false }, - { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "--assert", "file"], comment = "silent unchanged mode still performs the default installation" }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "--assert", "missing"], comment = "silent unchanged mode preserves the legacy no-op behavior" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_installs_missing.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_stays_noop.md similarity index 69% rename from crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_installs_missing.md rename to crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_stays_noop.md index 01d2b9bc64..a8624b0cd6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_installs_missing.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_stays_noop.md @@ -1,4 +1,4 @@ -# command_env_use_silent_unchanged_installs_missing +# command_env_use_silent_unchanged_stays_noop ## `vpt write-file package.json '{"name":"command-env-use","private":true,"packageManager":"pnpm@10.18.0"} '` @@ -7,10 +7,10 @@ ## `VP_PACKAGE_MANAGER=pnpm@10.18.0 vp env use pm --silent-if-unchanged` -## `vpt stat-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm --assert file` +## `vpt stat-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm --assert missing` -silent unchanged mode still performs the default installation +silent unchanged mode preserves the legacy no-op behavior ``` -/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm: file +/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm: missing ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml index 42d1d16309..ed43d6e77d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml @@ -23,3 +23,13 @@ env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_PACKAGE_MANAGER = "pnpm@10.18.0" } steps = [ { argv = ["vp", "env", "use", "--no-install"], comment = "activating a project with no package-manager selection clears the previous override", envs = [["VP_SHELL", "bash"]] }, ] + +[[case]] +name = "env_use_npm_uses_standalone_fallback" +vp = "global" +local-registry = true +skip-platforms = ["windows"] +env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_SHELL = "bash" } +steps = [ + { argv = ["vp", "env", "use", "npm", "--no-install"], comment = "an explicit npm scope exports only the standalone npm fallback" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_npm_uses_standalone_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_npm_uses_standalone_fallback.md new file mode 100644 index 0000000000..f4c716b120 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_npm_uses_standalone_fallback.md @@ -0,0 +1,10 @@ +# env_use_npm_uses_standalone_fallback + +## `vp env use npm --no-install` + +an explicit npm scope exports only the standalone npm fallback + +``` +export VP_PACKAGE_MANAGER=npm@12.0.2 +Using npm (resolved from registry fallback) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml index 9bb634233e..a61e8488a0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml @@ -24,11 +24,12 @@ steps = [ ] [[case]] -name = "command_env_print_npm_uses_node_runtime" +name = "command_env_print_npm_uses_standalone_fallback" vp = "global" +local-registry = true skip-platforms = ["windows"] steps = [ - { argv = ["vp", "env", "print", "npm"], comment = "unselected npm comes from the resolved Node.js runtime" }, + { argv = ["vp", "env", "print", "npm"], comment = "an explicit npm scope uses the standalone registry fallback" }, ] [[case]] @@ -40,9 +41,9 @@ steps = [ ] [[case]] -name = "command_env_current_npm_uses_node_runtime" +name = "command_env_current_npm_uses_standalone_fallback" vp = "global" -skip-platforms = ["windows"] +local-registry = true steps = [ - { argv = ["vp", "env", "current", "npm", "--json"], comment = "unselected npm reports the executable bundled with the resolved Node.js runtime" }, + { argv = ["vp", "env", "current", "npm", "--json"], comment = "an explicit npm scope reports the standalone registry fallback" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_node_runtime.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_node_runtime.md deleted file mode 100644 index 9e433f7177..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_node_runtime.md +++ /dev/null @@ -1,23 +0,0 @@ -# command_env_current_npm_uses_node_runtime - -## `vp env current npm --json` - -unselected npm reports the executable bundled with the resolved Node.js runtime - -``` -{ - "package_manager": { - "name": "npm", - "version": "", - "source": "Node.js ", - "source_path": "/.node-version", - "project_root": "", - "bin_paths": { - "npm": "/.vite-plus/js_runtime/node//bin/npm", - "npx": "/.vite-plus/js_runtime/node//bin/npx" - }, - "installed": true, - "mode": "managed" - } -} -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_standalone_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_standalone_fallback.md new file mode 100644 index 0000000000..d72773a4ea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_standalone_fallback.md @@ -0,0 +1,21 @@ +# command_env_current_npm_uses_standalone_fallback + +## `vp env current npm --json` + +an explicit npm scope reports the standalone registry fallback + +``` +{ + "package_manager": { + "name": "npm", + "version": "", + "source": "registry fallback", + "bin_paths": { + "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", + "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" + }, + "installed": false, + "mode": "managed" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_node_runtime.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_node_runtime.md deleted file mode 100644 index f36a726af0..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_node_runtime.md +++ /dev/null @@ -1,12 +0,0 @@ -# command_env_print_npm_uses_node_runtime - -## `vp env print npm` - -unselected npm comes from the resolved Node.js runtime - -``` -VITE+ - The Unified Toolchain for the Web - -# Add to your shell to use this environment for this session: -export PATH="/.vite-plus/js_runtime/node//bin:$PATH" -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_standalone_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_standalone_fallback.md new file mode 100644 index 0000000000..5d511fd9ff --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_standalone_fallback.md @@ -0,0 +1,12 @@ +# command_env_print_npm_uses_standalone_fallback + +## `vp env print npm` + +an explicit npm scope uses the standalone registry fallback + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +export PATH="/.vite-plus/package_manager/npm//npm/bin:$PATH" +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index d4ef6bdd15..119b258e9c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -9,7 +9,6 @@ steps = [ { argv = ["pnpm", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ { "expect-milestone" = "pm-shim-choice:pnpm" }, { "write-key" = "down" }, - { "write-key" = "down" }, { "write-key" = "enter" }, ] }, { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "the explicit system choice records only pnpm" }, @@ -17,7 +16,6 @@ steps = [ { argv = ["yarn", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ { "expect-milestone" = "pm-shim-choice:yarn" }, { "write-key" = "down" }, - { "write-key" = "down" }, { "write-key" = "enter" }, ] }, { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "Yarn records its own decision without changing pnpm" }, @@ -57,13 +55,23 @@ steps = [ ] [[case]] -name = "non_interactive_use_preserves_system_without_deciding" +name = "non_interactive_use_defaults_to_managed_without_deciding" vp = "global" +local-registry = true skip-platforms = ["windows"] steps = [ { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, - { argv = ["pnpm", "--version"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "a non-interactive upgrade keeps using system pnpm without prompting" }, - { argv = ["vp", "env", "current", "pnpm", "--json"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "non-interactive environment inspection follows the undecided shim's system-preserving policy" }, + { argv = ["pnpm", "--version"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "an undecided non-interactive shim uses managed pnpm without prompting" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "environment inspection uses the same stable managed default" }, { argv = ["vpt", "stat-file", "$VP_HOME/config.json", "--assert", "missing"], comment = "non-interactive use does not record a choice" }, ] + +[[case]] +name = "node_mode_does_not_decide_package_manager_modes" +vp = "global" +steps = [ + { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, + { argv = ["vp", "env", "off", "node"], snapshot = false }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "a Node-only mode change writes only the Node mode" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/node_mode_does_not_decide_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/node_mode_does_not_decide_package_manager_modes.md new file mode 100644 index 0000000000..d43e3c2ba7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/node_mode_does_not_decide_package_manager_modes.md @@ -0,0 +1,17 @@ +# node_mode_does_not_decide_package_manager_modes + +## `vpt rm -f $VP_HOME/config.json` + + +## `vp env off node` + + +## `vpt print-file $VP_HOME/config.json` + +a Node-only mode change writes only the Node mode + +``` +{ + "nodeShimMode": "system_first" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md similarity index 57% rename from crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md rename to crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md index 32466481e6..7372c2d56e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_preserves_system_without_deciding.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md @@ -1,4 +1,4 @@ -# non_interactive_use_preserves_system_without_deciding +# non_interactive_use_defaults_to_managed_without_deciding ## `vpt rm -f $VP_HOME/config.json` @@ -8,27 +8,28 @@ ## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` -a non-interactive upgrade keeps using system pnpm without prompting +an undecided non-interactive shim uses managed pnpm without prompting ``` -system-pnpm +11.24.0 ``` ## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env current pnpm --json` -non-interactive environment inspection follows the undecided shim's system-preserving policy +environment inspection uses the same stable managed default ``` { "package_manager": { "name": "pnpm", - "version": "system-pnpm", - "source": "system PATH", + "version": "", + "source": "registry fallback", "bin_paths": { - "pnpm": "/system-bin/pnpm" + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" }, "installed": true, - "mode": "system_first" + "mode": "managed" } } ``` diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 8820f488df..8c7fcab0cb 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -678,7 +678,7 @@ async fn run_package_manager_command( let result = if let Some(selected) = selected.as_ref() && commands::env::config::load_config() .await? - .effective_package_manager_shim_mode_for(selected.package_manager_type) + .package_manager_shim_mode_for(selected.package_manager_type) == commands::env::config::ShimMode::SystemFirst && let Some(system_path) = crate::shim::dispatch::find_system_tool(&selected.package_manager_type.to_string()) diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index ade83b0a94..bb79fdc6db 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -9,12 +9,7 @@ use vp_pm_cli::{PackageManagerType, resolve_package_manager_version}; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use super::{ - config, - list::list_installed_versions, - package_manager::{self, ALL_PACKAGE_MANAGERS}, - spec::EnvScope, -}; +use super::{config, list::list_installed_versions, package_manager, spec::EnvScope}; use crate::error::Error; /// Execute the clean command. @@ -31,21 +26,24 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result protected, - Err(error) => { - output::warn(&format!( - "Could not resolve the protected package manager; package-manager cleanup was skipped: {error}" - )); - return Ok(ExitStatus::default()); - } - }; - let selected = match scope { - EnvScope::PackageManager(kind) => vec![kind], - _ => ALL_PACKAGE_MANAGERS.to_vec(), - }; - let removed = - clean_package_managers(package_manager_dir.as_path(), &selected, &protected).await?; + let mut removed = 0; + for kind in package_manager::selected(scope) { + let protected = match protected_package_manager(&cwd, kind).await { + Ok(protected) => protected, + Err(error) => { + output::warn(&format!( + "Could not resolve protected {kind} versions; {kind} cleanup was skipped: {error}" + )); + continue; + } + }; + removed += clean_package_managers( + package_manager_dir.as_path(), + &[kind], + &[(kind, protected)], + ) + .await?; + } output::success(&format!("Removed {removed} package manager install{}", plural(removed))); } @@ -54,30 +52,14 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result Result)>, Error> { - let current = package_manager::resolve_current_for(cwd, scope.package_manager()).await?; + kind: PackageManagerType, +) -> Result, Error> { + let current = package_manager::resolve_current_or_fallback_for(cwd, kind).await?; + let mut protected = vec![current.version.to_string()]; let config = config::load_config().await?; - let mut protected = Vec::new(); - if let Some(current) = current { - protected.push((current.package_manager_type, vec![current.version.to_string()])); - } else if let EnvScope::PackageManager(kind) = scope - && kind != PackageManagerType::Npm - && config.effective_package_manager_shim_mode_for(kind) == config::ShimMode::Managed - { - protected - .push((kind, vec![resolve_package_manager_version(kind, "latest").await?.to_string()])); - } - for kind in package_manager::selected(scope) { - let Some((_, selector, _)) = package_manager::configured_default_for(&config, kind)? else { - continue; - }; + if let Some((_, selector, _)) = package_manager::configured_default_for(&config, kind)? { let version = resolve_package_manager_version(kind, &selector).await?.to_string(); - if let Some((_, versions)) = protected.iter_mut().find(|(current, _)| *current == kind) { - push_unique_version(versions, version); - } else { - protected.push((kind, vec![version])); - } + push_unique_version(&mut protected, version); } Ok(protected) } diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 6b2437b333..7df905e760 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -47,9 +47,9 @@ pub struct Config { skip_serializing_if = "BTreeMap::is_empty" )] pub default_package_manager_versions: BTreeMap, - /// Shim mode for tool resolution - #[serde(default, skip_serializing_if = "is_default_shim_mode")] - pub shim_mode: ShimMode, + /// Node.js shim mode. `shimMode` is accepted as the legacy field name. + #[serde(default, alias = "shimMode", skip_serializing_if = "is_default_shim_mode")] + pub node_shim_mode: ShimMode, /// Explicit per-package-manager shim modes. An absent family has not been configured yet. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub package_manager_shim_modes: BTreeMap, @@ -85,31 +85,13 @@ impl Config { } #[must_use] - pub fn effective_package_manager_shim_mode_for( - &self, - package_manager: PackageManagerType, - ) -> ShimMode { - self.configured_package_manager_shim_mode_for(package_manager).unwrap_or_else(|| { - if !vp_shared::is_interactive_terminal() - && crate::shim::dispatch::find_system_tool(&package_manager.to_string()).is_some() - { - ShimMode::SystemFirst - } else { - self.shim_mode - } - }) + pub fn package_manager_shim_mode_for(&self, package_manager: PackageManagerType) -> ShimMode { + self.configured_package_manager_shim_mode_for(package_manager).unwrap_or_default() } pub fn set_shim_modes(&mut self, node: bool, package_manager: bool, mode: ShimMode) { - if node && !package_manager { - for package_manager in ALL_PACKAGE_MANAGERS { - self.package_manager_shim_modes - .entry(package_manager.to_string()) - .or_insert(self.shim_mode); - } - } if node { - self.shim_mode = mode; + self.node_shim_mode = mode; } if package_manager { self.set_all_package_manager_shim_modes(mode); @@ -1552,15 +1534,15 @@ mod tests { } #[test] - fn node_only_mode_change_materializes_inherited_package_manager_modes() { + fn node_only_mode_change_leaves_package_managers_undecided() { let mut config = Config::default(); config.set_shim_modes(true, false, ShimMode::SystemFirst); - assert_eq!(config.shim_mode, ShimMode::SystemFirst); + assert_eq!(config.node_shim_mode, ShimMode::SystemFirst); + assert_eq!(config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), None); assert_eq!( - config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), - Some(ShimMode::Managed) + config.package_manager_shim_mode_for(PackageManagerType::Pnpm), + ShimMode::Managed ); - assert_eq!(config.package_manager_shim_modes.len(), ALL_PACKAGE_MANAGERS.len()); } #[test] @@ -1588,12 +1570,23 @@ mod tests { #[test] fn package_manager_only_mode_change_preserves_node_mode() { - let mut config = Config { shim_mode: ShimMode::SystemFirst, ..Config::default() }; + let mut config = Config { node_shim_mode: ShimMode::SystemFirst, ..Config::default() }; config.set_shim_modes(false, true, ShimMode::Managed); - assert_eq!(config.shim_mode, ShimMode::SystemFirst); + assert_eq!(config.node_shim_mode, ShimMode::SystemFirst); assert_eq!(config.package_manager_shim_modes.len(), ALL_PACKAGE_MANAGERS.len()); } + #[test] + fn legacy_shim_mode_loads_as_node_shim_mode() { + let config: Config = serde_json::from_str(r#"{"shimMode":"system_first"}"#).unwrap(); + + assert_eq!(config.node_shim_mode, ShimMode::SystemFirst); + assert_eq!( + serde_json::to_value(config).unwrap(), + serde_json::json!({ "nodeShimMode": "system_first" }) + ); + } + #[test] fn legacy_package_manager_default_migrates_to_version_map() { let config: Config = diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 2b9c0c040e..8466a864f0 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -1,10 +1,7 @@ use std::{collections::BTreeMap, process::ExitStatus}; use serde::Serialize; -use vp_pm_cli::{ - PackageManagerType, package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_version, -}; +use vp_pm_cli::{package_manager_bin_path, package_manager_install_dir}; use vt_path::AbsolutePathBuf; use super::{ @@ -67,7 +64,7 @@ pub async fn execute( let config = config::load_config().await?; let node = if scope.includes_node() - && config.shim_mode == ShimMode::SystemFirst + && config.node_shim_mode == ShimMode::SystemFirst && let Some(bin_path) = crate::shim::dispatch::find_system_tool("node") { Some(NodeInfo { @@ -77,7 +74,7 @@ pub async fn execute( project_root: None, bin_path: bin_path.as_path().display().to_string(), installed: true, - mode: config.shim_mode, + mode: config.node_shim_mode, }) } else if scope.includes_node() { let resolution = resolve_version(&cwd).await?; @@ -98,7 +95,7 @@ pub async fn execute( project_root: resolution.project_root.map(|path| path.as_path().display().to_string()), installed: bin_path.as_path().exists(), bin_path: bin_path.as_path().display().to_string(), - mode: config.shim_mode, + mode: config.node_shim_mode, }) } else { None @@ -170,7 +167,7 @@ async fn resolve_package_manager_info( let Some(selected_type) = selected_type else { return Ok(None); }; - let mode = config.effective_package_manager_shim_mode_for(selected_type); + let mode = config.package_manager_shim_mode_for(selected_type); if mode == ShimMode::SystemFirst { let bin_paths = selected_type .bin_names() @@ -201,27 +198,20 @@ async fn resolve_package_manager_info( } } - let resolution = package_manager::resolve_current_for(cwd, scope.package_manager()).await?; - let (package_manager_type, version, source, source_path, project_root) = match resolution { - Some(resolution) => ( - resolution.package_manager_type, - resolution.version.to_string(), - resolution.source.to_string(), - resolution.source_path.map(|path| path.as_path().display().to_string()), - resolution.project_root.map(|path| path.as_path().display().to_string()), - ), - None if scope.package_manager().is_some() && selected_type != PackageManagerType::Npm => ( - selected_type, - resolve_package_manager_version(selected_type, "latest").await?.to_string(), - "registry fallback".into(), - None, - None, - ), - None if selected_type == PackageManagerType::Npm => { - return resolve_bundled_npm_info(cwd, config, mode).await; + let resolution = match scope.package_manager() { + Some(package_manager) => { + Some(package_manager::resolve_current_or_fallback_for(cwd, package_manager).await?) } - None => return Ok(None), + None => package_manager::resolve_current_for(cwd, None).await?, + }; + let Some(resolution) = resolution else { + return Ok(None); }; + let package_manager_type = resolution.package_manager_type; + let version = resolution.version.to_string(); + let source = resolution.source.to_string(); + let source_path = resolution.source_path.map(|path| path.as_path().display().to_string()); + let project_root = resolution.project_root.map(|path| path.as_path().display().to_string()); let Some(install_dir) = package_manager_install_dir(package_manager_type, &version) else { return Ok(None); }; @@ -248,71 +238,6 @@ async fn resolve_package_manager_info( })) } -async fn resolve_bundled_npm_info( - cwd: &vt_path::AbsolutePath, - config: &config::Config, - mode: ShimMode, -) -> Result, Error> { - if config.shim_mode == ShimMode::SystemFirst - && crate::shim::dispatch::find_system_tool("node").is_some() - { - let bin_paths = PackageManagerType::Npm - .bin_names() - .iter() - .filter_map(|name| { - crate::shim::dispatch::find_system_tool(name) - .map(|path| ((*name).to_string(), path.as_path().display().to_string())) - }) - .collect::>(); - let Some(primary) = bin_paths.get("npm").and_then(|path| AbsolutePathBuf::new(path.into())) - else { - return Ok(None); - }; - return Ok(Some(PackageManagerInfo { - name: "npm".into(), - version: read_tool_version(&primary).await.unwrap_or_else(|| "unknown".into()), - source: "system PATH".into(), - source_path: None, - project_root: None, - installed: true, - bin_paths, - mode, - })); - } - - let resolution = resolve_version(cwd).await?; - let home = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(&resolution.version); - let bin_paths = PackageManagerType::Npm - .bin_names() - .iter() - .map(|name| { - #[cfg(windows)] - let path = home.join(format!("{name}.cmd")); - #[cfg(not(windows))] - let path = home.join("bin").join(name); - ((*name).to_string(), path.as_path().display().to_string()) - }) - .collect::>(); - let primary = AbsolutePathBuf::new(bin_paths["npm"].clone().into()) - .expect("managed npm path is absolute"); - let installed = bin_paths.values().all(|path| std::path::Path::new(path).exists()); - Ok(Some(PackageManagerInfo { - name: "npm".into(), - version: read_tool_version(&primary).await.unwrap_or_else(|| "unknown".into()), - source: format!("Node.js {}", resolution.version), - source_path: resolution.source_path.map(|path| path.as_path().display().to_string()), - project_root: resolution.project_root.map(|path| path.as_path().display().to_string()), - installed, - bin_paths, - mode, - })) -} - fn mode_name(mode: ShimMode) -> &'static str { match mode { ShimMode::Managed => "managed", diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 44a7641534..a332b60058 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -115,7 +115,7 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result (config::Config, Option { print_check(&output::CHECK.green().to_string(), "Node.js", "managed mode"); } @@ -302,11 +302,11 @@ async fn check_shim_mode(scope: EnvScope) -> (config::Config, Option { - Some((kind, current.version, current.hash.map(|hash| hash.to_string()))) - } - None if kind == PackageManagerType::Npm => { - let resolution = config::resolve_version(&cwd).await?; - println!("Installing npm bundled with Node.js v{}...", resolution.version); - vp_js_runtime::download_runtime( - vp_js_runtime::JsRuntimeType::Node, - &resolution.version, - ) - .await?; - println!("Installed npm bundled with Node.js v{}", resolution.version); - None - } - None => Some((kind, resolve_package_manager_version(kind, "latest").await?, None)), - } + let resolution = package_manager::resolve_current_or_fallback_for(&cwd, kind).await?; + Some(( + resolution.package_manager_type, + resolution.version, + resolution.hash.map(|hash| hash.to_string()), + )) } else { package_manager::resolve_current(&cwd).await?.map(|current| { ( diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index b9d923cf43..62624fa772 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -2,10 +2,7 @@ use std::{collections::BTreeMap, process::ExitStatus}; use owo_colors::OwoColorize; use serde::Serialize; -use vp_pm_cli::{ - PackageManagerType, package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_version, -}; +use vp_pm_cli::{PackageManagerType, package_manager_bin_path, package_manager_install_dir}; use vt_path::AbsolutePathBuf; use super::{config, package_manager, spec::EnvScope}; @@ -56,22 +53,16 @@ pub async fn execute( None }; let current_pm = if scope.includes_package_managers() { - package_manager::resolve_current_for(&cwd, scope.package_manager()).await.ok().flatten() - } else { - None - }; - let default_node = if scope.includes_node() { - match config.default_node_version.as_deref() { - Some(selector) => { - config::resolve_version_alias(selector, &vp_js_runtime::NodeProvider::new()) - .await - .ok() + match scope.package_manager() { + Some(package_manager) => { + package_manager::resolve_current_or_fallback_for(&cwd, package_manager).await.ok() } - None => None, + None => package_manager::resolve_current_for(&cwd, None).await.ok().flatten(), } } else { None }; + let default_node = scope.includes_node().then(|| config.default_node_version.clone()).flatten(); let mut default_package_manager_versions = BTreeMap::new(); if scope.includes_package_managers() { for kind in package_manager::selected(scope) { @@ -79,9 +70,7 @@ pub async fn execute( else { continue; }; - if let Ok(version) = resolve_package_manager_version(kind, &selector).await { - default_package_manager_versions.insert(kind.to_string(), version.to_string()); - } + default_package_manager_versions.insert(kind.to_string(), selector); } } diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 4d8b6f2a94..82778a3ed8 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -167,8 +167,7 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result) -> Result { - Some((resolution.package_manager_type, resolution.version, resolution.hash)) - } - None if scope.package_manager() == Some(vp_pm_cli::PackageManagerType::Npm) => { - bin_dirs.insert( - 0, - resolve_node_bin_dir(&cwd, &modes) - .await? - .as_path() - .display() - .to_string(), - ); - None - } - None => match scope.package_manager() { - Some(package_manager) => Some(( - package_manager, - vp_pm_cli::resolve_package_manager_version(package_manager, "latest") - .await?, - None, - )), - None => None, - }, - }; - if let Some((package_manager, version, hash)) = package_manager { - let (install_dir, _, _) = - vp_pm_cli::download_package_manager(package_manager, &version, hash.as_deref()) - .await?; + let resolution = match scope.package_manager() { + Some(package_manager) => Some( + package_manager::resolve_current_or_fallback_for(&cwd, package_manager).await?, + ), + None => package_manager::resolve_current_for(&cwd, None).await?, + }; + if let Some(resolution) = resolution { + let (install_dir, _, _) = vp_pm_cli::download_package_manager( + resolution.package_manager_type, + &resolution.version, + resolution.hash.as_deref(), + ) + .await?; bin_dirs.insert(0, install_dir.join("bin").as_path().display().to_string()); } } @@ -229,7 +211,7 @@ async fn resolve_node_bin_dir( cwd: &vt_path::AbsolutePath, config: &config::Config, ) -> Result { - if config.shim_mode == config::ShimMode::SystemFirst + if config.node_shim_mode == config::ShimMode::SystemFirst && let Some(path) = crate::shim::dispatch::find_system_tool("node") && let Some(bin_dir) = path.parent() { diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index a0b52a5d42..0559ded0bf 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -1,6 +1,6 @@ use vp_pm_cli::{ EnvironmentPackageManagerResolution, PackageManagerType, resolve_environment_package_manager, - resolve_environment_package_manager_spec, + resolve_environment_package_manager_spec, resolve_package_manager_version, }; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -29,6 +29,17 @@ pub(crate) async fn resolve_current_for( Ok(resolution) } +pub(crate) async fn resolve_current_or_fallback_for( + cwd: &AbsolutePath, + package_manager: PackageManagerType, +) -> Result { + if let Some(resolution) = resolve_current_for(cwd, Some(package_manager)).await? { + return Ok(resolution); + } + + registry_fallback_for(package_manager).await +} + pub(crate) async fn resolve_current_spec( cwd: &AbsolutePath, ) -> Result, Error> { @@ -129,6 +140,30 @@ pub(crate) async fn resolve_from_files_for( .map_err(Error::from) } +pub(crate) async fn resolve_from_files_or_fallback_for( + cwd: &AbsolutePath, + package_manager: PackageManagerType, +) -> Result { + if let Some(resolution) = resolve_from_files_for(cwd, Some(package_manager)).await? { + return Ok(resolution); + } + + registry_fallback_for(package_manager).await +} + +async fn registry_fallback_for( + package_manager: PackageManagerType, +) -> Result { + Ok(EnvironmentPackageManagerResolution { + package_manager_type: package_manager, + version: resolve_package_manager_version(package_manager, "latest").await?, + hash: None, + source: "registry fallback".into(), + source_path: None, + project_root: None, + }) +} + pub(crate) async fn warn_if_target_differs(cwd: &AbsolutePath, target: PackageManagerType) { let Ok(Some(current)) = resolve_current_spec(cwd).await else { return; diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 358e1c83aa..bf6d0aa565 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -140,12 +140,7 @@ pub async fn execute( } let provider = vp_js_runtime::NodeProvider::new(); - let use_bundled_npm = matches!(scope, EnvScope::PackageManager(PackageManagerType::Npm)) - && specs.package_manager.is_none() - && package_manager::resolve_from_files_for(&cwd, Some(PackageManagerType::Npm)) - .await? - .is_none(); - let node = if scope.includes_node() || use_bundled_npm { + let node = if scope.includes_node() { let (version, source) = if let Some(selector) = specs.node.as_deref() { (config::resolve_version_alias(selector, &provider).await?, selector.to_string()) } else { @@ -158,14 +153,21 @@ pub async fn execute( }; let package_manager = if scope.includes_package_managers() { - let resolved = if let Some((kind, selector, hash)) = specs.package_manager { + if let Some((kind, selector, hash)) = specs.package_manager { let version = resolve_package_manager_version(kind, &selector).await?.to_string(); package_manager::warn_if_target_differs(&cwd, kind).await; Some((kind, version, selector, hash)) + } else if let EnvScope::PackageManager(kind) = scope { + package_manager::warn_if_target_differs(&cwd, kind).await; + let resolution = + package_manager::resolve_from_files_or_fallback_for(&cwd, kind).await?; + Some(( + resolution.package_manager_type, + resolution.version.to_string(), + resolution.source.to_string(), + resolution.hash.map(|hash| hash.to_string()), + )) } else { - if let EnvScope::PackageManager(kind) = scope { - package_manager::warn_if_target_differs(&cwd, kind).await; - } package_manager::resolve_from_files_for(&cwd, scope.package_manager()).await?.map( |resolution| { ( @@ -176,15 +178,6 @@ pub async fn execute( ) }, ) - }; - if let EnvScope::PackageManager(kind) = scope - && resolved.is_none() - && kind != PackageManagerType::Npm - { - let version = resolve_package_manager_version(kind, "latest").await?.to_string(); - Some((kind, version, "latest".into(), None)) - } else { - resolved } } else { None @@ -227,9 +220,6 @@ pub async fn execute( false }; if unchanged { - if !no_install { - ensure_components_installed(&node, &package_manager).await?; - } return Ok(ExitStatus::default()); } diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index cfd9bd8284..9aa3c894dc 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -35,9 +35,9 @@ const LABEL_WIDTH: usize = 10; pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result { let config = super::config::load_config().await?; let mode = if let Some(package_manager) = PackageManagerType::from_tool(tool) { - config.effective_package_manager_shim_mode_for(package_manager) + config.package_manager_shim_mode_for(package_manager) } else { - config.shim_mode + config.node_shim_mode }; if mode == ShimMode::SystemFirst && let Some(path) = shim::dispatch::find_system_tool(tool) @@ -194,7 +194,7 @@ async fn execute_package_manager_tool( /// Execute which for a core tool (node, npm, npx). async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result { if matches!(tool, "npm" | "npx") - && super::config::load_config().await?.shim_mode == ShimMode::SystemFirst + && super::config::load_config().await?.node_shim_mode == ShimMode::SystemFirst && let Some(path) = shim::dispatch::find_system_tool(tool) { println!("{}", path.as_path().display()); diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index 89a0557e56..007d351332 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -117,7 +117,7 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu let config = env::config::load_config().await?; let mut executor = JsExecutor::new(None); - let node_bin_prefix = if config.shim_mode == env::config::ShimMode::SystemFirst + let node_bin_prefix = if config.node_shim_mode == env::config::ShimMode::SystemFirst && let Some(system_node) = crate::shim::dispatch::find_system_tool("node") && let Some(bin_dir) = system_node.parent() { @@ -139,7 +139,7 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu tracing::debug!("Set PATH to include {:?}", node_bin_prefix); } if let Some(package_manager) = env::package_manager::resolve_current_spec(project_path).await? { - if config.effective_package_manager_shim_mode_for(package_manager.package_manager_type) + if config.package_manager_shim_mode_for(package_manager.package_manager_type) == env::config::ShimMode::SystemFirst && let Some(system_path) = crate::shim::dispatch::find_system_tool( &package_manager.package_manager_type.to_string(), diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 31459ed92b..2bdf387ebf 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -506,14 +506,14 @@ async fn has_valid_version_source(project_path: &AbsolutePath) -> Result Option { let config = config::load_config().await.ok()?; - if config.shim_mode != ShimMode::SystemFirst { + if config.node_shim_mode != ShimMode::SystemFirst { return None; } let system_node = shim::find_system_tool("node")?; diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index fd452bc487..0c67e85c1b 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -789,7 +789,9 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // package managers still need the Node.js runtime selected by its mode. let system_node = if PackageManagerType::from_tool(tool).is_some() { match config::load_config().await { - Ok(config) if config.shim_mode == ShimMode::SystemFirst => find_system_tool("node"), + Ok(config) if config.node_shim_mode == ShimMode::SystemFirst => { + find_system_tool("node") + } Ok(_) => None, Err(error) => { eprintln!("vp: Failed to load Node.js shim mode: {error}"); @@ -949,7 +951,7 @@ fn read_node_version(node_path: &AbsolutePath) -> Option { async fn prepare_node_path_for_system_package_manager() -> Result<(), Error> { let config = config::load_config().await?; - if config.shim_mode == ShimMode::SystemFirst + if config.node_shim_mode == ShimMode::SystemFirst && let Some(node) = find_system_tool("node") && let Some(bin_dir) = node.parent() { @@ -1302,7 +1304,7 @@ pub(crate) fn locate_tool(version: &str, tool: &str) -> Result ShimMode { let Some(package_manager) = PackageManagerType::from_tool(tool) else { - return config::load_config().await.map(|config| config.shim_mode).unwrap_or_default(); + return config::load_config().await.map(|config| config.node_shim_mode).unwrap_or_default(); }; resolve_package_manager_shim_mode(tool, package_manager).await } @@ -1315,11 +1317,7 @@ async fn resolve_package_manager_shim_mode( Ok(config) => config, Err(error) => { output::warn(&format!("Could not read package-manager shim preferences: {error}")); - return if find_system_tool(tool).is_some() { - ShimMode::SystemFirst - } else { - ShimMode::Managed - }; + return ShimMode::Managed; } }; if let Some(mode) = config.configured_package_manager_shim_mode_for(package_manager) { @@ -1327,11 +1325,11 @@ async fn resolve_package_manager_shim_mode( } let Some(system_path) = find_system_tool(tool) else { - return config.shim_mode; + return ShimMode::Managed; }; if !vp_shared::is_interactive_terminal() { - return ShimMode::SystemFirst; + return ShimMode::Managed; } let Some((mode, apply_to_all)) = @@ -1369,7 +1367,7 @@ fn prompt_package_manager_shim_mode( let choice = Select::with_theme(&ColorfulTheme::default()) .with_prompt(format!("How should {package_manager} run?")) .items(&options) - .default(0) + .default(1) .interact() .ok()?; diff --git a/docs/guide/env.md b/docs/guide/env.md index 2f6c13564e..794a998e7e 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -6,7 +6,7 @@ Managed mode is on by default, so Node.js and configured package-manager shims resolve through Vite+ and pick the right versions for the current project. Fresh installers record managed mode for npm, pnpm, Yarn, and Bun after the user enables environment management. -When an upgrade adds a package-manager shim that has no recorded mode, its first interactive invocation asks what to do only when the corresponding system binary is already on PATH. If no system binary is found, the package manager inherits `shimMode`. Non-interactive invocations preserve an existing system command without recording a choice. +When an upgrade adds a package-manager shim that has no recorded mode, its first interactive invocation asks what to do only when the corresponding system binary is already on PATH. The current family defaults to managed mode; choosing a system tool or applying a choice to every family remains explicit. Non-interactive invocations use managed mode without recording a choice. Most commands operate on both components when no selector is given. Add `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the command. `pm` means all four families for listing and cleanup, but the single selected package manager for project operations. @@ -41,7 +41,7 @@ Package-manager selection uses this priority: 6. The named package manager's global default version 7. The named shim's latest release -A selected manager controls only its named shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Alias pairs are `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a matching project selection, a named shim uses its configured default version and otherwise uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. npm instead falls back to the version bundled with the resolved Node.js runtime. +A selected manager controls only its named shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Alias pairs are `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a matching project selection, a named shim uses its configured default version and otherwise uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. The directly invoked npm shim keeps its Node-bundled fallback, while an explicit `vp env ... npm` family scope uses standalone npm's latest release. A fresh install uses the split platform layout by default. On Unix, Vite+ stores managed runtimes and related files in `~/.local/share/vite-plus`. It diff --git a/rfcs/env-command.md b/rfcs/env-command.md index 35ffee8928..c8b8e0c10b 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -16,7 +16,7 @@ vp env pin 22.0.0 pnpm@10.18.0 # Both Selectors are `node`, `pm`, `npm`, `pnpm`, `yarn`, and `bun`. `pm` selects every family for listing and cleanup, but the single project-selected manager for `current`, `pin`, `unpin`, `use`, and execution. -Node and package-manager modes persist independently. Existing `shimMode` remains the Node mode; optional `packageManagerShimMode` inherits it when absent. A Node-only mode change materializes the inherited PM value before changing `shimMode`, so it cannot accidentally change PM behavior. +Node and package-manager modes persist independently. `nodeShimMode` stores the Node mode and accepts the legacy `shimMode` field while reading older configurations. Missing package-manager modes default to managed without inheriting Node state, and only package-manager mode commands or first-use choices persist them. Package-manager resolution priority is explicit override, `VP_PACKAGE_MANAGER` or `.session-package-manager`, top-level `packageManager`, `devEngines.packageManager`, lockfile/config detection, `defaultPackageManager`, then the existing fallback. The resolver is non-mutating and shared by env inspection, shims, `vp install`, `use`, and `exec`. @@ -456,7 +456,12 @@ VP_HOME/ # Default: ~/.vite-plus // Set via: vp env on (managed) or vp env off (system_first) // - "managed" (default): All vp commands and shims use vite-plus managed Node.js // - "system_first": All vp commands and shims prefer system Node.js, fallback to managed if not found - "shimMode": "managed" + "nodeShimMode": "managed", + + // `shimMode` is accepted as the legacy Node.js field name but is no longer written. + "packageManagerShimModes": { + "pnpm": "managed" + } } ``` From 2c9c326c9c5a338f1f84ce314b06e2dbb61c19be Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 22:59:57 +0800 Subject: [PATCH 32/37] test(env): streamline package manager snapshots --- .../prepare-pnpm-versions.cjs | 10 +++ .../fixtures/command_env_clean/snapshots.toml | 4 +- ...erves_concrete_package_manager_fallback.md | 8 +-- .../command_env_install_no_arg/snapshots.toml | 3 + ...and_env_install_standalone_npm_fallback.md | 11 +++ .../snapshots.toml | 1 + ...nv_install_no_node_with_package_manager.md | 2 + .../snapshots.toml | 21 +++--- ...command_env_package_manager_diagnostics.md | 50 +++++++++++-- ...nv_unset_empty_package_manager_override.md | 15 ---- ...unset_session_independently_of_override.md | 2 + .../snapshots.toml | 6 ++ ...list_ignores_package_manager_resolution.md | 2 + ...list_stays_offline_for_floating_default.md | 2 + ...er_list_ignores_other_family_resolution.md | 2 + ...list_stays_offline_for_floating_default.md | 2 + ...r_list_stays_offline_for_selected_range.md | 2 + .../assert-doctor-system-package-manager.cjs | 31 -------- .../snapshots.toml | 14 ++-- ..._env_doctor_mixed_package_manager_modes.md | 17 ----- ...ommand_env_doctor_package_manager_modes.md | 29 ++++++++ ..._env_doctor_shared_package_manager_mode.md | 11 --- ...mmand_env_doctor_system_package_manager.md | 72 +++++++++++++++++-- .../fixtures/command_env_use/snapshots.toml | 1 + ...and_env_use_silent_unchanged_stays_noop.md | 2 + .../command_env_use_shells/snapshots.toml | 10 --- .../env_use_npm_uses_standalone_fallback.md | 10 --- .../fixtures/command_env_which/snapshots.toml | 27 +------ ...rrent_concrete_package_manager_fallback.md | 2 + ...nv_current_npm_uses_standalone_fallback.md | 21 ------ ...print_concrete_package_manager_fallback.md | 12 ---- ..._env_print_npm_uses_standalone_fallback.md | 12 ---- .../snapshots.toml | 1 + ...stall_auto_pins_lockfile_manager.global.md | 2 + ...nstall_auto_pins_lockfile_manager.local.md | 2 + .../install_yarn_corepack_hash/snapshots.toml | 15 +--- .../snapshots/env_package_manager_hash.md | 58 --------------- .../install_yarn_corepack_hash_mismatch.md | 46 ++++++++++++ .../snapshots.toml | 12 +--- ..._package_manager_uses_system_first_node.md | 2 + ...e_does_not_decide_package_manager_modes.md | 17 ----- ...se_defaults_to_managed_without_deciding.md | 2 + ..._package_manager_uses_system_first_node.md | 2 + .../snapshots.toml | 1 + ...ackage_manager_defaults_are_independent.md | 2 + 45 files changed, 275 insertions(+), 301 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/prepare-pnpm-versions.cjs delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_empty_package_manager_override.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_mixed_package_manager_modes.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_package_manager_modes.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_shared_package_manager_mode.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_npm_uses_standalone_fallback.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_standalone_fallback.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_concrete_package_manager_fallback.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_standalone_fallback.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/node_mode_does_not_decide_package_manager_modes.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/prepare-pnpm-versions.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/prepare-pnpm-versions.cjs new file mode 100644 index 0000000000..f1229d22da --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/prepare-pnpm-versions.cjs @@ -0,0 +1,10 @@ +const { execFileSync } = require('node:child_process') +const { mkdirSync } = require('node:fs') +const { join } = require('node:path') + +const output = execFileSync('vp', ['env', 'current', 'pnpm', '--json'], { encoding: 'utf8' }) +const info = JSON.parse(output) +const pnpmRoot = join(process.env.VP_HOME, 'package_manager', 'pnpm') + +mkdirSync(join(pnpmRoot, info.package_manager.version), { recursive: true }) +mkdirSync(join(pnpmRoot, '0.0.1'), { recursive: true }) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml index d9ee4427d8..bd0f73a50c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml @@ -12,12 +12,12 @@ steps = [ [[case]] name = "env_clean_preserves_concrete_package_manager_fallback" vp = "global" +comment = "System-first changes dispatch preference, not cache ownership; clean must retain the managed fallback used when no system manager is available." local-registry = true seed-runtime = false steps = [ - { argv = ["vp", "env", "print", "pnpm"], snapshot = false }, + { argv = ["node", "prepare-pnpm-versions.cjs"], snapshot = false }, { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, - { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/0.0.1/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, { argv = ["vp", "env", "clean", "pnpm"], comment = "cleanup removes stale installs but preserves the concrete family's managed fallback even in system-first mode" }, { argv = ["node", "assert-one-pnpm-version.cjs"], comment = "the cached registry fallback remains available" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md index 9667b83aeb..e088c49d94 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md @@ -1,13 +1,11 @@ # env_clean_preserves_concrete_package_manager_fallback -## `vp env print pnpm` +System-first changes dispatch preference, not cache ownership; clean must retain the managed fallback used when no system manager is available. - -## `vp env off pnpm` +## `node prepare-pnpm-versions.cjs` -## `vpt write-file $VP_HOME/package_manager/pnpm/0.0.1/pnpm/bin/pnpm '#'\!'/bin/sh -'` +## `vp env off pnpm` ## `vp env clean pnpm` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml index aa17092c03..8987a7ce34 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml @@ -13,10 +13,13 @@ steps = [ [[case]] name = "command_env_install_standalone_npm_fallback" vp = "global" +comment = "Explicit npm family scopes use standalone registry npm; only the directly invoked npm shim keeps Node.js' bundled npm fallback." local-registry = true skip-platforms = ["windows"] seed-runtime = false +env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_SHELL = "bash" } steps = [ + { argv = ["vp", "env", "use", "npm", "--no-install"], comment = "an explicit npm scope exports the standalone npm fallback" }, { argv = ["vp", "env", "install", "npm"], comment = "an explicit npm scope installs the standalone registry fallback" }, { argv = ["vp", "env", "current", "npm", "--json"], comment = "the standalone npm fallback is installed" }, { argv = ["vpt", "stat-file", "$VP_HOME/js_runtime/node", "--assert", "missing"], comment = "installing standalone npm does not install Node.js" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md index 02e027d301..0f15d9154e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md @@ -1,5 +1,16 @@ # command_env_install_standalone_npm_fallback +Explicit npm family scopes use standalone registry npm; only the directly invoked npm shim keeps Node.js' bundled npm fallback. + +## `vp env use npm --no-install` + +an explicit npm scope exports the standalone npm fallback + +``` +export VP_PACKAGE_MANAGER=npm@12.0.2 +Using npm (resolved from registry fallback) +``` + ## `vp env install npm` an explicit npm scope installs the standalone registry fallback diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml index 7d7f7b0447..8b99a89c33 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml @@ -10,6 +10,7 @@ steps = [ [[case]] name = "command_env_install_no_node_with_package_manager" vp = "global" +comment = "Node.js and package-manager installation are independent components: a missing Node pin must not skip the declared package manager." skip-platforms = ["windows"] seed-runtime = false steps = [ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md index a5ecfc13fa..173688b3c6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md @@ -1,5 +1,7 @@ # command_env_install_no_node_with_package_manager +Node.js and package-manager installation are independent components: a missing Node pin must not skip the declared package manager. + ## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index ef9778c03f..390cb40045 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -2,9 +2,13 @@ name = "command_env_package_manager_diagnostics" vp = "global" steps = [ - { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const info=JSON.parse(execFileSync('vp',['env','current','--json'],{encoding:'utf8'}));if(info.package_manager?.name!=='npm'||info.package_manager?.version!=='10.9.4'||info.package_manager?.source!=='packageManager')process.exit(1);console.log('current reports npm packageManager')"], comment = "current reports the npm packageManager pin" }, - { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','which','npm'],{encoding:'utf8'});if(!text.includes('Package:')||!text.includes('npm@10.9.4')||!text.includes('package.json'))process.exit(1);console.log('which reports npm packageManager')"], comment = "which reports the npm packageManager pin" }, - { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','which','npx'],{encoding:'utf8'});if(!text.includes('Package:')||!text.includes('npm@10.9.4')||!text.includes('package.json'))process.exit(1);console.log('which reports npx packageManager')"], comment = "which reports the npx packageManager pin" }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npx", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npm"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npx"], snapshot = false }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "current reports the npm packageManager pin" }, + { argv = ["vp", "env", "which", "npm"], comment = "which reports the npm packageManager pin" }, + { argv = ["vp", "env", "which", "npx"], comment = "the npx alias reports the same npm packageManager pin" }, ] [[case]] @@ -33,19 +37,10 @@ steps = [ { argv = ["vp", "env", "current", "pm", "--json"], envs = [["VP_PACKAGE_MANAGER", " "]], comment = "an empty package-manager environment override falls through to project resolution" }, ] -[[case]] -name = "command_env_unset_empty_package_manager_override" -vp = "global" -skip-platforms = ["windows"] -steps = [ - { argv = ["vp", "env", "use", "pnpm@10.18.0", "--no-install"], snapshot = false }, - { argv = ["vp", "env", "use", "--unset", "pnpm"], envs = [["VP_PACKAGE_MANAGER", " "]], snapshot = false }, - { argv = ["vpt", "stat-file", "$VP_HOME/.session-package-manager", "--assert", "missing"], comment = "an empty environment override does not prevent clearing the matching session selection" }, -] - [[case]] name = "command_env_unset_session_independently_of_override" vp = "global" +comment = "Scoped unset must inspect and clear the session file independently of any different environment override." skip-platforms = ["windows"] steps = [ { argv = ["vp", "env", "use", "pnpm@10.18.0", "--no-install"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md index c40269096b..67ddcd0316 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md @@ -1,25 +1,61 @@ # command_env_package_manager_diagnostics -## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const info=JSON.parse(execFileSync('\''vp'\'',['\''env'\'','\''current'\'','\''--json'\''],{encoding:'\''utf8'\''}));if(info.package_manager?.name'\!'=='\''npm'\''||info.package_manager?.version'\!'=='\''10.9.4'\''||info.package_manager?.source'\!'=='\''packageManager'\'')process.exit(1);console.log('\''current reports npm packageManager'\'')'` +## `vpt write-file $VP_HOME/package_manager/npm/10.9.4/npm/bin/npm '#'\!'/bin/sh +'` + + +## `vpt write-file $VP_HOME/package_manager/npm/10.9.4/npm/bin/npx '#'\!'/bin/sh +'` + + +## `vpt chmod +x $VP_HOME/package_manager/npm/10.9.4/npm/bin/npm` + + +## `vpt chmod +x $VP_HOME/package_manager/npm/10.9.4/npm/bin/npx` + + +## `vp env current pm --json` current reports the npm packageManager pin ``` -current reports npm packageManager +{ + "package_manager": { + "name": "npm", + "version": "", + "source": "packageManager", + "source_path": "/package.json", + "project_root": "", + "bin_paths": { + "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", + "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" + }, + "installed": true, + "mode": "managed" + } +} ``` -## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const text=execFileSync('\''vp'\'',['\''env'\'','\''which'\'','\''npm'\''],{encoding:'\''utf8'\''});if('\!'text.includes('\''Package:'\'')||'\!'text.includes('\''npm@10.9.4'\'')||'\!'text.includes('\''package.json'\''))process.exit(1);console.log('\''which reports npm packageManager'\'')'` +## `vp env which npm` which reports the npm packageManager pin ``` -which reports npm packageManager +VITE+ - The Unified Toolchain for the Web + +/.vite-plus/package_manager/npm//npm/bin/npm + Package: npm@10.9.4 + Source: /package.json ``` -## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const text=execFileSync('\''vp'\'',['\''env'\'','\''which'\'','\''npx'\''],{encoding:'\''utf8'\''});if('\!'text.includes('\''Package:'\'')||'\!'text.includes('\''npm@10.9.4'\'')||'\!'text.includes('\''package.json'\''))process.exit(1);console.log('\''which reports npx packageManager'\'')'` +## `vp env which npx` -which reports the npx packageManager pin +the npx alias reports the same npm packageManager pin ``` -which reports npx packageManager +VITE+ - The Unified Toolchain for the Web + +/.vite-plus/package_manager/npm//npm/bin/npx + Package: npm@10.9.4 + Source: /package.json ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_empty_package_manager_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_empty_package_manager_override.md deleted file mode 100644 index 6349a0e296..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_empty_package_manager_override.md +++ /dev/null @@ -1,15 +0,0 @@ -# command_env_unset_empty_package_manager_override - -## `vp env use pnpm@10.18.0 --no-install` - - -## `VP_PACKAGE_MANAGER= vp env use --unset pnpm` - - -## `vpt stat-file $VP_HOME/.session-package-manager --assert missing` - -an empty environment override does not prevent clearing the matching session selection - -``` -/.vite-plus/.session-package-manager: missing -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md index 3d63000a15..cc8ad9f29d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md @@ -1,5 +1,7 @@ # command_env_unset_session_independently_of_override +Scoped unset must inspect and clear the session file independently of any different environment override. + ## `vp env use pnpm@10.18.0 --no-install` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml index 12fe730b89..a4342a3fa6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -41,6 +41,7 @@ steps = [ [[case]] name = "env_node_list_ignores_package_manager_resolution" vp = "global" +comment = "A component selector must not resolve excluded components or turn an unrelated local listing into network work." seed-runtime = false steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, @@ -51,6 +52,7 @@ steps = [ [[case]] name = "env_package_manager_list_ignores_other_family_resolution" vp = "global" +comment = "A concrete family selector must not resolve the project-selected manager from another family." seed-runtime = false steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, @@ -60,6 +62,7 @@ steps = [ [[case]] name = "env_package_manager_list_stays_offline_for_selected_range" vp = "global" +comment = "Local inventory must remain available offline even when marking the selected package-manager range requires best-effort resolution." seed-runtime = false steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, @@ -69,6 +72,8 @@ steps = [ [[case]] name = "env_package_manager_list_stays_offline_for_floating_default" vp = "global" +comment = "Local inventory reuses the cached concrete result of a floating default instead of requiring registry access." +local-registry = true seed-runtime = false skip-platforms = ["windows"] steps = [ @@ -81,6 +86,7 @@ steps = [ [[case]] name = "env_node_list_stays_offline_for_floating_default" vp = "global" +comment = "Local Node.js inventory must not require mirror access merely to mark a floating default." seed-runtime = false steps = [ { argv = ["vpt", "write-file", "$VP_HOME/js_runtime/node/20.18.0/bin/node", "#!/bin/sh\n"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md index f1a848d0dc..9c39958567 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md @@ -1,5 +1,7 @@ # env_node_list_ignores_package_manager_resolution +A component selector must not resolve excluded components or turn an unrelated local listing into network work. + ## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md index 6f3d29f2e2..89544bf6b4 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md @@ -1,5 +1,7 @@ # env_node_list_stays_offline_for_floating_default +Local Node.js inventory must not require mirror access merely to mark a floating default. + ## `vpt write-file $VP_HOME/js_runtime/node/20.18.0/bin/node '#'\!'/bin/sh '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md index b20b5de7bc..f5f8364a4a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md @@ -1,5 +1,7 @@ # env_package_manager_list_ignores_other_family_resolution +A concrete family selector must not resolve the project-selected manager from another family. + ## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md index 4b7fe0dcf6..52cc32ab21 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md @@ -1,5 +1,7 @@ # env_package_manager_list_stays_offline_for_floating_default +Local inventory reuses the cached concrete result of a floating default instead of requiring registry access. + ## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md index 3c80bbcc0c..2edb5fbe7c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md @@ -1,5 +1,7 @@ # env_package_manager_list_stays_offline_for_selected_range +Local inventory must remain available offline even when marking the selected package-manager range requires best-effort resolution. + ## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs deleted file mode 100644 index 4a22b56ca2..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/assert-doctor-system-package-manager.cjs +++ /dev/null @@ -1,31 +0,0 @@ -const { spawnSync } = require('node:child_process') - -const result = spawnSync('vp', ['env', 'doctor', 'pnpm'], { encoding: 'utf8' }) -const output = result.stdout.replace(/\u001B\[[0-9;]*m/g, '') -if (!output.includes('Source') || !output.includes('system PATH') || !output.includes('PM binary') || !output.includes('system-bin/pnpm')) { - process.stderr.write(`${result.stdout}${result.stderr}`) - process.exit(1) -} - -console.log('doctor reports the system pnpm binary without resolving the declared range') - -const current = spawnSync('vp', ['env', 'current', 'pnpm', '--json'], { encoding: 'utf8' }) -if (current.status !== 0) { - process.stderr.write(`${current.stdout}${current.stderr}`) - process.exit(1) -} -const info = JSON.parse(current.stdout) -if (info.package_manager?.source !== 'system PATH' || !info.package_manager?.bin_paths?.pnpm?.includes('system-bin/pnpm')) { - process.stderr.write(current.stdout) - process.exit(1) -} - -console.log('current reports the system pnpm binary without resolving the declared range') - -const printed = spawnSync('vp', ['env', 'print', 'pnpm'], { encoding: 'utf8' }) -if (printed.status !== 0 || !printed.stdout.includes('system-bin')) { - process.stderr.write(`${printed.stdout}${printed.stderr}`) - process.exit(1) -} - -console.log('print uses the system pnpm binary without resolving the declared range') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml index 5ab953cc5d..c5f0a277d4 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml @@ -11,16 +11,11 @@ steps = [ ] [[case]] -name = "command_env_doctor_shared_package_manager_mode" +name = "command_env_doctor_package_manager_modes" vp = "global" +comment = "Doctor may collapse identical family modes, but must expose per-family rows as soon as one package manager differs." steps = [ { argv = ["node", "print-doctor-configuration.cjs"], comment = "doctor keeps one package-manager row when every mode matches" }, -] - -[[case]] -name = "command_env_doctor_mixed_package_manager_modes" -vp = "global" -steps = [ { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, { argv = ["node", "print-doctor-configuration.cjs"], comment = "doctor prints each package manager when their modes differ" }, ] @@ -28,10 +23,13 @@ steps = [ [[case]] name = "command_env_doctor_system_package_manager" vp = "global" +comment = "System-first inspection commands must use an available system manager without resolving its project range through the registry." skip-platforms = ["windows"] steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"doctor-system-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, - { argv = ["node", "assert-doctor-system-package-manager.cjs"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "doctor checks a system-first package manager without registry access" }, + { argv = ["vp", "env", "doctor", "pnpm"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "doctor reports the system pnpm binary without resolving the declared range" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "current reports the same system pnpm selection" }, + { argv = ["vp", "env", "print", "pnpm"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "print exports the system pnpm directory" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_mixed_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_mixed_package_manager_modes.md deleted file mode 100644 index 449174647b..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_mixed_package_manager_modes.md +++ /dev/null @@ -1,17 +0,0 @@ -# command_env_doctor_mixed_package_manager_modes - -## `vp env off pnpm` - - -## `node print-doctor-configuration.cjs` - -doctor prints each package manager when their modes differ - -``` -Configuration - ✓ Node.js managed mode - ✓ npm managed mode - ✓ pnpm system-first mode - ✓ Yarn managed mode - ✓ Bun managed mode -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_package_manager_modes.md new file mode 100644 index 0000000000..84ee4bd073 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_package_manager_modes.md @@ -0,0 +1,29 @@ +# command_env_doctor_package_manager_modes + +Doctor may collapse identical family modes, but must expose per-family rows as soon as one package manager differs. + +## `node print-doctor-configuration.cjs` + +doctor keeps one package-manager row when every mode matches + +``` +Configuration + ✓ Node.js managed mode + ✓ Package manager managed mode +``` + +## `vp env off pnpm` + + +## `node print-doctor-configuration.cjs` + +doctor prints each package manager when their modes differ + +``` +Configuration + ✓ Node.js managed mode + ✓ npm managed mode + ✓ pnpm system-first mode + ✓ Yarn managed mode + ✓ Bun managed mode +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_shared_package_manager_mode.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_shared_package_manager_mode.md deleted file mode 100644 index 85b3ea02ea..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_shared_package_manager_mode.md +++ /dev/null @@ -1,11 +0,0 @@ -# command_env_doctor_shared_package_manager_mode - -## `node print-doctor-configuration.cjs` - -doctor keeps one package-manager row when every mode matches - -``` -Configuration - ✓ Node.js managed mode - ✓ Package manager managed mode -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md index 85b4833219..f7bdc95793 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md @@ -1,5 +1,7 @@ # command_env_doctor_system_package_manager +System-first inspection commands must use an available system manager without resolving its project range through the registry. + ## `vpt write-file package.json '{"name":"doctor-system-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} '` @@ -10,12 +12,72 @@ ## `vp env off pnpm` -## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 node assert-doctor-system-package-manager.cjs` +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env doctor pnpm` -doctor checks a system-first package manager without registry access +doctor reports the system pnpm binary without resolving the declared range ``` -doctor reports the system pnpm binary without resolving the declared range -current reports the system pnpm binary without resolving the declared range -print uses the system pnpm binary without resolving the declared range +VITE+ - The Unified Toolchain for the Web + +Installation + ✓ Bin dir ~/.vite-plus/bin + ✓ Data dir ~/.vite-plus + ✓ Cache dir ~/.vite-plus/cache (not created yet) + ✓ Config dir ~/.vite-plus + ✓ State dir ~/.vite-plus + ✓ Shims pnpm, pnpx + +Configuration + ✓ Package manager system-first mode + +PATH + ✓ vp in PATH + ✓ pnpm ~/.vite-plus/bin/pnpm (vp shim) + ✓ pnpx ~/.vite-plus/bin/pnpx (vp shim) + +Package Manager Resolution + Source system PATH + Version pnpm@10.18.0 + ✓ PM binary /system-bin/pnpm + +IDE Setup + ⚠ GUI applications may not see shell PATH changes. + + macOS: + Add to ~/.zshenv or ~/.profile: + . "$HOME/.vite-plus/env" + Then restart your IDE to apply changes. + +✓ All checks passed +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env current pnpm --json` + +current reports the same system pnpm selection + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "system PATH", + "project_root": "", + "bin_paths": { + "pnpm": "/system-bin/pnpm" + }, + "installed": true, + "mode": "system_first" + } +} +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env print pnpm` + +print exports the system pnpm directory + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +export PATH="/system-bin:$PATH" ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml index 4fd03ab8bc..f550341122 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml @@ -15,6 +15,7 @@ steps = [ [[case]] name = "command_env_use_silent_unchanged_stays_noop" vp = "global" +comment = "The unchanged guard must return before installation so --silent-if-unchanged remains free of downloads and filesystem side effects." seed-runtime = false steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-use\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_stays_noop.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_stays_noop.md index a8624b0cd6..ab71a2a075 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_stays_noop.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_stays_noop.md @@ -1,5 +1,7 @@ # command_env_use_silent_unchanged_stays_noop +The unchanged guard must return before installation so --silent-if-unchanged remains free of downloads and filesystem side effects. + ## `vpt write-file package.json '{"name":"command-env-use","private":true,"packageManager":"pnpm@10.18.0"} '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml index ed43d6e77d..42d1d16309 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml @@ -23,13 +23,3 @@ env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_PACKAGE_MANAGER = "pnpm@10.18.0" } steps = [ { argv = ["vp", "env", "use", "--no-install"], comment = "activating a project with no package-manager selection clears the previous override", envs = [["VP_SHELL", "bash"]] }, ] - -[[case]] -name = "env_use_npm_uses_standalone_fallback" -vp = "global" -local-registry = true -skip-platforms = ["windows"] -env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_SHELL = "bash" } -steps = [ - { argv = ["vp", "env", "use", "npm", "--no-install"], comment = "an explicit npm scope exports only the standalone npm fallback" }, -] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_npm_uses_standalone_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_npm_uses_standalone_fallback.md deleted file mode 100644 index f4c716b120..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_npm_uses_standalone_fallback.md +++ /dev/null @@ -1,10 +0,0 @@ -# env_use_npm_uses_standalone_fallback - -## `vp env use npm --no-install` - -an explicit npm scope exports only the standalone npm fallback - -``` -export VP_PACKAGE_MANAGER=npm@12.0.2 -Using npm (resolved from registry fallback) -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml index a61e8488a0..f4e7ee037a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml @@ -14,36 +14,11 @@ steps = [ { argv = ["vp", "env", "which", "unknown-tool"], comment = "Unknown tool - error message", continue-on-failure = true }, ] -[[case]] -name = "command_env_print_concrete_package_manager_fallback" -vp = "global" -local-registry = true -skip-platforms = ["windows"] -steps = [ - { argv = ["vp", "env", "print", "pnpm"], comment = "a concrete family falls back to its latest release and Fish keeps a spaced install path intact", envs = [["VP_HOME", "${workspace}/custom home"], ["VP_SHELL", "fish"]] }, -] - -[[case]] -name = "command_env_print_npm_uses_standalone_fallback" -vp = "global" -local-registry = true -skip-platforms = ["windows"] -steps = [ - { argv = ["vp", "env", "print", "npm"], comment = "an explicit npm scope uses the standalone registry fallback" }, -] - [[case]] name = "command_env_current_concrete_package_manager_fallback" vp = "global" +comment = "A named family has an effective registry fallback even when no project, session, or default selection exists." local-registry = true steps = [ { argv = ["vp", "env", "current", "pnpm", "--json"], comment = "a concrete family reports the same registry fallback as its shim" }, ] - -[[case]] -name = "command_env_current_npm_uses_standalone_fallback" -vp = "global" -local-registry = true -steps = [ - { argv = ["vp", "env", "current", "npm", "--json"], comment = "an explicit npm scope reports the standalone registry fallback" }, -] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md index 0f6b9564d9..28c23a95cb 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md @@ -1,5 +1,7 @@ # command_env_current_concrete_package_manager_fallback +A named family has an effective registry fallback even when no project, session, or default selection exists. + ## `vp env current pnpm --json` a concrete family reports the same registry fallback as its shim diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_standalone_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_standalone_fallback.md deleted file mode 100644 index d72773a4ea..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_npm_uses_standalone_fallback.md +++ /dev/null @@ -1,21 +0,0 @@ -# command_env_current_npm_uses_standalone_fallback - -## `vp env current npm --json` - -an explicit npm scope reports the standalone registry fallback - -``` -{ - "package_manager": { - "name": "npm", - "version": "", - "source": "registry fallback", - "bin_paths": { - "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", - "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" - }, - "installed": false, - "mode": "managed" - } -} -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_concrete_package_manager_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_concrete_package_manager_fallback.md deleted file mode 100644 index 61117a08ea..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_concrete_package_manager_fallback.md +++ /dev/null @@ -1,12 +0,0 @@ -# command_env_print_concrete_package_manager_fallback - -## `VP_HOME=${workspace}/custom home VP_SHELL=fish vp env print pnpm` - -a concrete family falls back to its latest release and Fish keeps a spaced install path intact - -``` -VITE+ - The Unified Toolchain for the Web - -# Add to your shell to use this environment for this session: -set -gx PATH "/custom home/package_manager/pnpm//pnpm/bin" $PATH -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_standalone_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_standalone_fallback.md deleted file mode 100644 index 5d511fd9ff..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_print_npm_uses_standalone_fallback.md +++ /dev/null @@ -1,12 +0,0 @@ -# command_env_print_npm_uses_standalone_fallback - -## `vp env print npm` - -an explicit npm scope uses the standalone registry fallback - -``` -VITE+ - The Unified Toolchain for the Web - -# Add to your shell to use this environment for this session: -export PATH="/.vite-plus/package_manager/npm//npm/bin:$PATH" -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml index 187a08b12d..157dadd27b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml @@ -14,6 +14,7 @@ steps = [ [[case]] name = "command_install_auto_pins_lockfile_manager" vp = ["local", "global"] +comment = "A lockfile-inferred manager must become an exact project pin so later installs cannot drift to a newer release." local-registry = true skip-platforms = ["windows"] steps = [ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md index 7347877f99..79ece6ff71 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md @@ -1,5 +1,7 @@ # command_install_auto_pins_lockfile_manager +A lockfile-inferred manager must become an exact project pin so later installs cannot drift to a newer release. + ## `vpt write-file package.json '{"name":"lockfile-project","private":true} '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md index 7347877f99..79ece6ff71 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md @@ -1,5 +1,7 @@ # command_install_auto_pins_lockfile_manager +A lockfile-inferred manager must become an exact project pin so later installs cannot drift to a newer release. + ## `vpt write-file package.json '{"name":"lockfile-project","private":true} '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index b7dbbbf754..a29b06792f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -12,26 +12,17 @@ steps = [ [[case]] name = "install_yarn_corepack_hash_mismatch" vp = "global" +comment = "Every environment entry point must preserve and verify the same Corepack integrity suffix without repeating the expensive cache setup in separate cases." env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, + { argv = ["vp", "env", "install", "pm"], comment = "The explicit environment install accepts the verified project hash" }, { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, { argv = ["vp", "install"], comment = "The error names the artifact that the hash covers. vp does not download the CLI again", continue-on-failure = true }, -] - -[[case]] -name = "env_package_manager_hash" -vp = "global" -env = { CI = "true", YARN_ENABLE_TELEMETRY = "0" } -steps = [ - { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, - { argv = ["vp", "env", "install", "pm"], comment = "The explicit environment install accepts and verifies the project hash", timeout = 120000 }, - { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the project pin to a hash that the cached CLI does not match", snapshot = false }, { argv = ["vp", "env", "install", "pm"], comment = "The explicit environment install rejects the mismatched project hash", continue-on-failure = true }, - { argv = ["vp", "env", "use", "pm", "--no-install"], comment = "The session override preserves the project hash for first use" }, + { argv = ["vp", "env", "use", "pm", "--no-install"], envs = [["CI", "true"]], comment = "The session override preserves the project hash for first use" }, { argv = ["yarn", "--version"], comment = "The package-manager shim enforces the hash retained by env use", continue-on-failure = true }, - { argv = ["vp", "env", "use", "pm", "--unset"], snapshot = false }, ] [[case]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md deleted file mode 100644 index df7099dd6f..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/env_package_manager_hash.md +++ /dev/null @@ -1,58 +0,0 @@ -# env_package_manager_hash - -## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` - -Ensure the Corepack-pinned Yarn version is not cached - - -## `vp env install pm` - -The explicit environment install accepts and verifies the project hash - -``` -VITE+ - The Unified Toolchain for the Web - -Installing yarn ... -Installed yarn -``` - -## `vpt replace-file-content package.json b7ad4697 b7ad4698` - -Change the project pin to a hash that the cached CLI does not match - - -## `vp env install pm` - -The explicit environment install rejects the mismatched project hash - -**Exit code:** 1 - -``` -VITE+ - The Unified Toolchain for the Web - -Installing yarn ... -error: Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 -The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. -``` - -## `vp env use pm --no-install` - -The session override preserves the project hash for first use - -``` -Using yarn (resolved from packageManager) -``` - -## `yarn --version` - -The package-manager shim enforces the hash retained by env use - -**Exit code:** 1 - -``` -vp: Failed to resolve package manager for 'yarn': Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 -The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. -``` - -## `vp env use pm --unset` - diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md index 76389c7928..f9eab7a2bb 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md @@ -1,5 +1,7 @@ # install_yarn_corepack_hash_mismatch +Every environment entry point must preserve and verify the same Corepack integrity suffix without repeating the expensive cache setup in separate cases. + ## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` Ensure the Corepack-pinned Yarn version is not cached @@ -10,6 +12,17 @@ Ensure the Corepack-pinned Yarn version is not cached Cache the verified Yarn CLI +## `vp env install pm` + +The explicit environment install accepts the verified project hash + +``` +VITE+ - The Unified Toolchain for the Web + +Installing yarn ... +Installed yarn +``` + ## `vpt replace-file-content package.json b7ad4697 b7ad4698` Change the pin to a hash that the cached CLI does not match @@ -27,3 +40,36 @@ VITE+ - The Unified Toolchain for the Web error: Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` + +## `vp env install pm` + +The explicit environment install rejects the mismatched project hash + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +Installing yarn ... +error: Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. +``` + +## `CI=true vp env use pm --no-install` + +The session override preserves the project hash for first use + +``` +Using yarn (resolved from packageManager) +``` + +## `yarn --version` + +The package-manager shim enforces the hash retained by env use + +**Exit code:** 1 + +``` +vp: Failed to resolve package manager for 'yarn': Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml index 119b258e9c..1f4bbee6f9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -24,6 +24,7 @@ steps = [ [[case]] name = "system_package_manager_uses_system_first_node" vp = "global" +comment = "Node and package-manager modes are independent: a system package manager must receive the Node.js selected by Node mode without forcing registry resolution." skip-platforms = ["windows"] steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"system-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, @@ -39,6 +40,7 @@ steps = [ [[case]] name = "managed_package_manager_uses_system_first_node" vp = "global" +comment = "A managed package manager must still receive the system Node.js selected by Node mode." seed-runtime = false skip-platforms = ["windows"] steps = [ @@ -57,6 +59,7 @@ steps = [ [[case]] name = "non_interactive_use_defaults_to_managed_without_deciding" vp = "global" +comment = "Upgraded installations may have no recorded family mode; non-interactive use must stay deterministic without persisting consent on the user's behalf." local-registry = true skip-platforms = ["windows"] steps = [ @@ -66,12 +69,3 @@ steps = [ { argv = ["vp", "env", "current", "pnpm", "--json"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "environment inspection uses the same stable managed default" }, { argv = ["vpt", "stat-file", "$VP_HOME/config.json", "--assert", "missing"], comment = "non-interactive use does not record a choice" }, ] - -[[case]] -name = "node_mode_does_not_decide_package_manager_modes" -vp = "global" -steps = [ - { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, - { argv = ["vp", "env", "off", "node"], snapshot = false }, - { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "a Node-only mode change writes only the Node mode" }, -] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md index 8f63706990..b87cf8a21f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md @@ -1,5 +1,7 @@ # managed_package_manager_uses_system_first_node +A managed package manager must still receive the system Node.js selected by Node mode. + ## `vpt write-file package.json '{"name":"managed-package-manager","private":true,"packageManager":"pnpm@10.18.0"} '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/node_mode_does_not_decide_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/node_mode_does_not_decide_package_manager_modes.md deleted file mode 100644 index d43e3c2ba7..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/node_mode_does_not_decide_package_manager_modes.md +++ /dev/null @@ -1,17 +0,0 @@ -# node_mode_does_not_decide_package_manager_modes - -## `vpt rm -f $VP_HOME/config.json` - - -## `vp env off node` - - -## `vpt print-file $VP_HOME/config.json` - -a Node-only mode change writes only the Node mode - -``` -{ - "nodeShimMode": "system_first" -} -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md index 7372c2d56e..98d0daba90 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md @@ -1,5 +1,7 @@ # non_interactive_use_defaults_to_managed_without_deciding +Upgraded installations may have no recorded family mode; non-interactive use must stay deterministic without persisting consent on the user's behalf. + ## `vpt rm -f $VP_HOME/config.json` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md index f8a09941f5..85cade8fb3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md @@ -1,5 +1,7 @@ # system_package_manager_uses_system_first_node +Node and package-manager modes are independent: a system package manager must receive the Node.js selected by Node mode without forcing registry resolution. + ## `vpt write-file package.json '{"name":"system-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} '` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml index 4fd7e91982..b4a74e1431 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml @@ -27,6 +27,7 @@ steps = [ [[case]] name = "shim_package_manager_defaults_are_independent" vp = "global" +comment = "Package-manager defaults are per family so changing Bun cannot silently replace the pnpm shim's configured version." local-registry = true skip-platforms = ["windows"] steps = [ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md index 2780cb68ea..f27180a8e8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md @@ -1,5 +1,7 @@ # shim_package_manager_defaults_are_independent +Package-manager defaults are per family so changing Bun cannot silently replace the pnpm shim's configured version. + ## `vp env default pnpm@10.18.0` From 3175b8bea5261c6d33e4556f7ea81a66a276bb67 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 23:11:55 +0800 Subject: [PATCH 33/37] test(env): normalize doctor snapshot across platforms --- .../print-doctor-configuration.cjs | 12 ++++++++-- .../snapshots.toml | 2 +- ...mmand_env_doctor_system_package_manager.md | 22 +------------------ 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs index 779ada9e22..eb729bfddc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs @@ -1,12 +1,20 @@ const { spawnSync } = require('node:child_process') -const result = spawnSync('vp', ['env', 'doctor'], { encoding: 'utf8' }) +const component = process.argv[2] +const args = ['env', 'doctor', ...(component ? [component] : [])] +const result = spawnSync('vp', args, { encoding: 'utf8' }) if (result.error) throw result.error +if (result.status !== 0) { + process.stderr.write(result.stdout) + process.stderr.write(result.stderr) + process.exit(result.status ?? 1) +} const output = result.stdout.replace(/\u001b\[[0-9;]*m/g, '') const lines = output.split('\n') const start = lines.findIndex(line => line.trim() === 'Configuration') -const end = lines.findIndex((line, index) => index > start && line.trim() === 'PATH') +const endHeading = component ? 'IDE Setup' : 'PATH' +const end = lines.findIndex((line, index) => index > start && line.trim() === endHeading) console.log(lines.slice(start, end).filter(line => !line.includes('IDE integration')).join('\n').trimEnd()) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml index c5f0a277d4..7ad74cc8b8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml @@ -29,7 +29,7 @@ steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"doctor-system-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, - { argv = ["vp", "env", "doctor", "pnpm"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "doctor reports the system pnpm binary without resolving the declared range" }, + { argv = ["node", "print-doctor-configuration.cjs", "pnpm"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "doctor reports the system pnpm binary without resolving the declared range" }, { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "current reports the same system pnpm selection" }, { argv = ["vp", "env", "print", "pnpm"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "print exports the system pnpm directory" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md index f7bdc95793..ef7f36c6e8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md @@ -12,21 +12,11 @@ System-first inspection commands must use an available system manager without re ## `vp env off pnpm` -## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env doctor pnpm` +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 node print-doctor-configuration.cjs pnpm` doctor reports the system pnpm binary without resolving the declared range ``` -VITE+ - The Unified Toolchain for the Web - -Installation - ✓ Bin dir ~/.vite-plus/bin - ✓ Data dir ~/.vite-plus - ✓ Cache dir ~/.vite-plus/cache (not created yet) - ✓ Config dir ~/.vite-plus - ✓ State dir ~/.vite-plus - ✓ Shims pnpm, pnpx - Configuration ✓ Package manager system-first mode @@ -39,16 +29,6 @@ Package Manager Resolution Source system PATH Version pnpm@10.18.0 ✓ PM binary /system-bin/pnpm - -IDE Setup - ⚠ GUI applications may not see shell PATH changes. - - macOS: - Add to ~/.zshenv or ~/.profile: - . "$HOME/.vite-plus/env" - Then restart your IDE to apply changes. - -✓ All checks passed ``` ## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env current pnpm --json` From f5821da842d9d27509ac518d43ff3d976c92c990 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 30 Aug 2026 23:25:08 +0800 Subject: [PATCH 34/37] test(env): seed npm diagnostics per platform --- .../prepare-npm.cjs | 17 +++++++++++++++++ .../snapshots.toml | 5 +---- .../command_env_package_manager_diagnostics.md | 13 +------------ 3 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/prepare-npm.cjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/prepare-npm.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/prepare-npm.cjs new file mode 100644 index 0000000000..a8f2c4e4ce --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/prepare-npm.cjs @@ -0,0 +1,17 @@ +const { mkdirSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') + +const binRoot = join( + process.env.VP_HOME, + 'package_manager', + 'npm', + '10.9.4', + 'npm', + 'bin', +) +const extension = process.platform === 'win32' ? '.cmd' : '' +const contents = process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n' + +mkdirSync(binRoot, { recursive: true }) +for (const name of ['npm', 'npx']) + writeFileSync(join(binRoot, `${name}${extension}`), contents, { mode: 0o755 }) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index 390cb40045..baa479edc0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -2,10 +2,7 @@ name = "command_env_package_manager_diagnostics" vp = "global" steps = [ - { argv = ["vpt", "write-file", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npm", "#!/bin/sh\n"], snapshot = false }, - { argv = ["vpt", "write-file", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npx", "#!/bin/sh\n"], snapshot = false }, - { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npm"], snapshot = false }, - { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npx"], snapshot = false }, + { argv = ["node", "prepare-npm.cjs"], snapshot = false }, { argv = ["vp", "env", "current", "pm", "--json"], comment = "current reports the npm packageManager pin" }, { argv = ["vp", "env", "which", "npm"], comment = "which reports the npm packageManager pin" }, { argv = ["vp", "env", "which", "npx"], comment = "the npx alias reports the same npm packageManager pin" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md index 67ddcd0316..2ccfe00613 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md @@ -1,17 +1,6 @@ # command_env_package_manager_diagnostics -## `vpt write-file $VP_HOME/package_manager/npm/10.9.4/npm/bin/npm '#'\!'/bin/sh -'` - - -## `vpt write-file $VP_HOME/package_manager/npm/10.9.4/npm/bin/npx '#'\!'/bin/sh -'` - - -## `vpt chmod +x $VP_HOME/package_manager/npm/10.9.4/npm/bin/npm` - - -## `vpt chmod +x $VP_HOME/package_manager/npm/10.9.4/npm/bin/npx` +## `node prepare-npm.cjs` ## `vp env current pm --json` From d4bf2ea7d888374815ac232e64a4fe8a22be6c6f Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 31 Aug 2026 07:41:35 +0800 Subject: [PATCH 35/37] docs --- docs/guide/env.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/env.md b/docs/guide/env.md index 794a998e7e..d3ab26943d 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -183,7 +183,7 @@ vp node -e "console.log(1+1)" # Shorthand: forward any node flag or argument ## JSON output -This release intentionally changes the JSON contracts for `current`, `list`, and `list-remote`. `current --json` returns peer component objects: +The JSON output for `current`, `list`, and `list-remote` is organized by component. `current --json` returns sibling `node` and `package_manager` objects: ```json { From 2db7b09c8ad7c24f93dbaa8e611dc575f7902b6c Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 31 Aug 2026 07:44:55 +0800 Subject: [PATCH 36/37] docs --- docs/guide/env.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/guide/env.md b/docs/guide/env.md index d3ab26943d..4a7e5d6142 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -15,7 +15,8 @@ Unqualified versions remain Node.js versions for compatibility: ```bash vp env pin 22.0.0 # Node.js only vp env pin pnpm@10.18.0 # pnpm only -vp env pin 22.0.0 pnpm@10.18.0 # Both components +vp env pin node@24 pnpm@12 # Both components +vp env pin 22.0.0 pnpm@10.18.0 # Also both components ``` Vite+ checks the current directory first, then walks up through its parents. The nearest directory @@ -159,11 +160,11 @@ vp env print # Print PATH setup for both components # Manage vp env pin lts pnpm@10 # Pin both project components to exact versions vp env install # Install the complete resolved environment -vp env default lts # Set the global Node.js default +vp env default node@24 # Set the global Node.js default vp env default pnpm@10 # Set pnpm's global default version vp env use 20 pnpm@10 # Override both components for this shell vp env use --unset pm # Remove only the PM session override -vp env clean yarn # Remove unused managed Yarn versions +vp env clean # Remove unused managed Node.js and package manager versions # Inspect vp env current # Show current resolved environment From 880fea3c03b5cb94f7828a19bbd52fc1aaa449d1 Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 31 Aug 2026 08:38:58 +0800 Subject: [PATCH 37/37] push --- .../snapshots.toml | 1 + ...command_env_package_manager_diagnostics.md | 18 ++++++++++ .../vp_global_cli/src/commands/env/current.rs | 34 +++++++++++-------- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index baa479edc0..3db9ca3129 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -4,6 +4,7 @@ vp = "global" steps = [ { argv = ["node", "prepare-npm.cjs"], snapshot = false }, { argv = ["vp", "env", "current", "pm", "--json"], comment = "current reports the npm packageManager pin" }, + { argv = ["vp", "env", "current", "pm"], comment = "current lists every binary exposed by the selected package-manager family" }, { argv = ["vp", "env", "which", "npm"], comment = "which reports the npm packageManager pin" }, { argv = ["vp", "env", "which", "npx"], comment = "the npx alias reports the same npm packageManager pin" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md index 2ccfe00613..fceeb836a8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md @@ -25,6 +25,24 @@ current reports the npm packageManager pin } ``` +## `vp env current pm` + +current lists every binary exposed by the selected package-manager family + +``` +VITE+ - The Unified Toolchain for the Web + +Package Manager: + Name npm + Version 10.9.4 + Source packageManager + Bin Paths + npm /.vite-plus/package_manager/npm//npm/bin/npm + npx /.vite-plus/package_manager/npm//npm/bin/npx + Installed true + Mode managed +``` + ## `vp env which npm` which reports the npm packageManager pin diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 8466a864f0..10fc9875a5 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -46,7 +46,7 @@ struct PackageManagerInfo { mode: ShimMode, } -fn print_rows(title: &str, rows: &[(&str, String)]) { +fn print_rows(title: &str, rows: &[(String, String)]) { println!("{}", help::render_heading(title)); let label_width = rows.iter().map(|(label, _)| label.chars().count()).max().unwrap_or(0); for (label, value) in rows { @@ -116,11 +116,11 @@ pub async fn execute( print_rows( "Node.js", &[ - ("Version", node.version), - ("Source", node.source), - ("Bin Path", node.bin_path), - ("Installed", node.installed.to_string()), - ("Mode", mode_name(node.mode).into()), + ("Version".into(), node.version), + ("Source".into(), node.source), + ("Bin Path".into(), node.bin_path), + ("Installed".into(), node.installed.to_string()), + ("Mode".into(), mode_name(node.mode).into()), ], ); } @@ -128,16 +128,20 @@ pub async fn execute( if scope.includes_node() { println!(); } - print_rows( - "Package Manager", - &[ - ("Name", package_manager.name), - ("Version", package_manager.version), - ("Source", package_manager.source), - ("Installed", package_manager.installed.to_string()), - ("Mode", mode_name(package_manager.mode).into()), - ], + let mut rows = vec![ + ("Name".into(), package_manager.name), + ("Version".into(), package_manager.version), + ("Source".into(), package_manager.source), + ("Bin Paths".into(), String::new()), + ]; + rows.extend( + package_manager.bin_paths.into_iter().map(|(name, path)| (format!(" {name}"), path)), ); + rows.extend([ + ("Installed".into(), package_manager.installed.to_string()), + ("Mode".into(), mode_name(package_manager.mode).into()), + ]); + print_rows("Package Manager", &rows); } Ok(ExitStatus::default())