diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 9e143ff629..40e7665c5a 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -382,11 +382,6 @@ jobs: - name: varlet node-version: 22 command: | - # scripts/bootstrap.mjs spawns `pnpm build` via tinyexec and needs - # pnpm on PATH (not exposed by the vp install itself). corepack - # enable creates a pnpm launcher in the vp bin dir that resolves - # the project's pinned packageManager version (pnpm@9.15.9). - corepack enable node scripts/bootstrap.mjs # Report-only: oxlint 1.68.0 surfaces ts1038 ("A 'declare' modifier # cannot be used in an already ambient context.") on varlet's diff --git a/README.md b/README.md index d6ef9362ab..494beafef0 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 @@ -104,7 +104,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **config** - Configure hooks and agent integration - **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/README.md b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md index 0005c5c2e6..315ec507c0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md @@ -120,8 +120,8 @@ A step is a bare argv array or a table: `argv[0]` may be `vpt`, a runner-provisioned tool such as `nu`, or any executable exposed by the case's Vite+ installation, including default shims -such as `vp`, `node`, and `corepack` and globally installed package binaries. -There is no shell: no `&&`, no +such as `vp`, `node`, `npm`, and `pnpm` and globally installed package +binaries. There is no shell: no `&&`, no redirects, no globs. File setup and assertions go through `vpt` so behavior is identical on every platform: 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 d351576a27..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 shims for node, npm, npx, corepack - vp env on # Use vite-plus managed Node.js - vp env print # Print shell snippet for this session + 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 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..d1d1d93f52 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: No such file or directory (os error 2) ``` 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_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_setup_external_vp/assert-shims.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs index 6a1cddda26..e86658fc35 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs @@ -2,8 +2,22 @@ import fs from 'node:fs'; import path from 'node:path'; const expected = path.resolve('external/vp'); +const shims = [ + 'vp', + 'node', + 'npm', + 'npx', + 'pnpm', + 'pnpx', + 'yarn', + 'yarnpkg', + 'bun', + 'bunx', + 'vpx', + 'vpr', +]; -for (const shim of ['vp', 'node', 'npm', 'npx', 'corepack', 'vpx', 'vpr']) { +for (const shim of shims) { const shimPath = path.join('home', 'bin', shim); const target = fs.readlinkSync(shimPath); if (target !== expected) { @@ -11,4 +25,9 @@ for (const shim of ['vp', 'node', 'npm', 'npx', 'corepack', 'vpx', 'vpr']) { } } +const actualShims = fs.readdirSync(path.join('home', 'bin')).sort(); +if (actualShims.join() !== [...shims].sort().join()) { + throw new Error(`unexpected shims: ${actualShims.join(', ')}`); +} + console.log('all shims point to external vp'); 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_env_which/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml index c212ec0abb..6f83226fca 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 @@ -4,12 +4,10 @@ vp = "global" local-registry = true skip-platforms = ["windows"] steps = [ - { argv = ["vp", "remove", "-g", "corepack"], snapshot = false, continue-on-failure = true }, { argv = ["vp", "env", "exec", "node", "--version"], comment = "Ensure Node.js is installed first", continue-on-failure = true }, { argv = ["vp", "env", "which", "node"], comment = "Core tool - shows resolved Node.js binary path", continue-on-failure = true }, { argv = ["vp", "env", "which", "npm"], comment = "Core tool - shows resolved npm binary path", continue-on-failure = true }, { argv = ["vp", "env", "which", "npx"], comment = "Core tool - shows resolved npx binary path", continue-on-failure = true }, - { argv = ["vp", "env", "which", "corepack"], comment = "Core tool - corepack bundled with the resolved Node.js", continue-on-failure = true }, { argv = ["vp", "install", "-g", "cowsay@1.6.0"], comment = "Install a global package via vp", continue-on-failure = true }, { argv = ["vp", "env", "which", "cowsay"], comment = "Global package - shows binary path with metadata", continue-on-failure = true }, { argv = ["vp", "remove", "-g", "cowsay"], comment = "Cleanup", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md index e498576334..ff6f63c95b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md @@ -1,13 +1,5 @@ # command_env_which -## `vp remove -g corepack` - -**Exit code:** 1 - -``` -Failed to uninstall corepack: Package corepack is not installed -``` - ## `vp env exec node --version` Ensure Node.js is installed first @@ -52,18 +44,6 @@ VITE+ - The Unified Toolchain for the Web Source: /.node-version ``` -## `vp env which corepack` - -Core tool - corepack bundled with the resolved Node.js - -``` -VITE+ - The Unified Toolchain for the Web - -/.vite-plus/js_runtime/node//bin/corepack - Version: 20.18.0 - Source: /.node-version -``` - ## `vp install -g cowsay@1.6.0` Install a global package via vp @@ -108,6 +88,6 @@ Unknown tool - error message VITE+ - The Unified Toolchain for the Web error: tool 'unknown-tool' not found -Not a core tool (node, npm, npx, corepack) or installed global package. +Not a core tool (node, npm, npx) or installed global package. Run 'vp list -g' to see installed packages. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots.toml deleted file mode 100644 index a84d9285eb..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots.toml +++ /dev/null @@ -1,10 +0,0 @@ -[[case]] -name = "shim_corepack_bundled" -vp = "global" -skip-platforms = ["windows"] -steps = [ - { argv = ["vp", "remove", "-g", "corepack"], comment = "Isolate from a leftover managed corepack, which would win over the bundled one", snapshot = false, continue-on-failure = true }, - { argv = ["vpt", "write-file", ".node-version", "20.18.0\n"], comment = "Pin the project Node.js version", snapshot = false, continue-on-failure = true }, - { argv = ["vp", "env", "exec", "node", "--version"], comment = "Ensure Node.js is installed first", timeout = 120000 }, - { argv = ["corepack", "--version"], comment = "corepack shim runs the Node-bundled corepack" }, -] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots/shim_corepack_bundled.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots/shim_corepack_bundled.md deleted file mode 100644 index 541eaec651..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots/shim_corepack_bundled.md +++ /dev/null @@ -1,33 +0,0 @@ -# shim_corepack_bundled - -## `vp remove -g corepack` - -Isolate from a leftover managed corepack, which would win over the bundled one - -**Exit code:** 1 - -``` -Failed to uninstall corepack: Package corepack is not installed -``` - -## `vpt write-file .node-version '20.18.0 -'` - -Pin the project Node.js version - - -## `vp env exec node --version` - -Ensure Node.js is installed first - -``` - -``` - -## `corepack --version` - -corepack shim runs the Node-bundled corepack - -``` -0.29.3 -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh deleted file mode 100644 index 36f696d4ec..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -# Fake bundled corepack: echoes its invocation with the test root normalized -# for stable snapshots, and simulates corepack clobbering the npm shim on -# `enable` so the test can assert that Vite+ restores it. -if [ "$1" = "enable" ]; then - rm -f "$VP_HOME/bin/npm" -fi -out="corepack" -for arg in "$@"; do - out="$out $(printf '%s' "$arg" | sed "s#$PWD##g")" -done -printf '%s\n' "$out" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml deleted file mode 100644 index 0f31a1c51c..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml +++ /dev/null @@ -1,17 +0,0 @@ -[[case]] -name = "shim_corepack_enable_install_directory" -vp = "global" -skip-platforms = ["windows"] -steps = [ - { argv = ["vpt", "mkdir", "-p", "home/js_runtime/node/22.18.0/bin"], comment = "Isolated VP_HOME with a fake managed Node runtime layout", snapshot = false }, - { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], comment = "Project Node.js version", snapshot = false }, - { argv = ["vpt", "write-file", "home/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho fake-node\n"], comment = "Fake node binary", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/node"], snapshot = false }, - { argv = ["vpt", "cp", "fake-corepack.sh", "home/js_runtime/node/22.18.0/bin/corepack"], comment = "Fake bundled corepack that echoes its args", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/corepack"], snapshot = false }, - { argv = ["vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Create shims in the isolated home", snapshot = false }, - { argv = ["corepack", "use", "pnpm@10"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "Non-link commands run unchanged" }, - { argv = ["corepack", "enable", "--install-directory", "/tmp/custom-dir"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "Explicit --install-directory is respected, clobbered npm shim is restored" }, - { argv = ["corepack", "enable"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "--install-directory defaults to VP_HOME/bin" }, - { argv = ["vpt", "stat-file", "home/bin/npm", "--assert", "symlink"], comment = "Vite+ owns the npm shim" }, -] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md deleted file mode 100644 index d66cf17ca4..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md +++ /dev/null @@ -1,69 +0,0 @@ -# shim_corepack_enable_install_directory - -## `vpt mkdir -p home/js_runtime/node/22.18.0/bin` - -Isolated VP_HOME with a fake managed Node runtime layout - - -## `vpt write-file .node-version '22.18.0 -'` - -Project Node.js version - - -## `vpt write-file home/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh -echo fake-node -'` - -Fake node binary - - -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/node` - - -## `vpt cp fake-corepack.sh home/js_runtime/node/22.18.0/bin/corepack` - -Fake bundled corepack that echoes its args - - -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/corepack` - - -## `VP_HOME=${workspace}/home vp env setup` - -Create shims in the isolated home - - -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack use pnpm@10` - -Non-link commands run unchanged - -``` -corepack use pnpm@10 -``` - -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack enable --install-directory /tmp/custom-dir` - -Explicit --install-directory is respected, clobbered npm shim is restored - -``` -corepack enable --install-directory /tmp/custom-dir -warn: 'npm' is managed by Vite+ and was restored. Vite+ already resolves 'npm' per project, so corepack does not need to manage it. -``` - -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack enable` - ---install-directory defaults to VP_HOME/bin - -``` -corepack enable --install-directory /home/bin -warn: 'npm' is managed by Vite+ and was restored. Vite+ already resolves 'npm' per project, so corepack does not need to manage it. -``` - -## `vpt stat-file home/bin/npm --assert symlink` - -Vite+ owns the npm shim - -``` -home/bin/npm: symlink -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots.toml new file mode 100644 index 0000000000..f034226f31 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots.toml @@ -0,0 +1,6 @@ +[[case]] +name = "shim_corepack_removed" +vp = "global" +steps = [ + { argv = ["vp", "install", "-g", "corepack"], comment = "Corepack is no longer a managed global package", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots/shim_corepack_removed.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots/shim_corepack_removed.md new file mode 100644 index 0000000000..e3fc335204 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots/shim_corepack_removed.md @@ -0,0 +1,13 @@ +# shim_corepack_removed + +## `vp install -g corepack` + +Corepack is no longer a managed global package + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +error: Failed to install corepack: 'vp install -g corepack' is no longer supported. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/package.json new file mode 100644 index 0000000000..d4fc085cea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/package.json @@ -0,0 +1,5 @@ +{ + "name": "shim-mismatched-package-managers", + "private": true, + "packageManager": "pnpm@11.20.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml new file mode 100644 index 0000000000..88dbf0842e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml @@ -0,0 +1,7 @@ +[[case]] +name = "shim_mismatched_package_managers" +vp = "global" +steps = [ + { argv = ["yarn", "--version"], comment = "Yarn resolves independently of the project's pnpm declaration", snapshot = false }, + { argv = ["bun", "--version"], comment = "Bun resolves independently of the project's pnpm declaration", snapshot = false }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md new file mode 100644 index 0000000000..1dd8bb03d1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md @@ -0,0 +1,11 @@ +# shim_mismatched_package_managers + +## `yarn --version` + +Yarn resolves independently of the project's pnpm declaration + + +## `bun --version` + +Bun resolves independently of the project's pnpm declaration + diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml index b3564634ce..7201d6727e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml @@ -5,7 +5,8 @@ local-registry = true skip-platforms = ["windows"] comment = "pnpm 12 ships a native binary via @pnpm/exe.* platform packages; the pnpm shim runs it directly and the pnpx shim injects the dlx subcommand." steps = [ - { argv = ["vp", "install", "-g", "pnpm"], comment = "Expose the pnpm/pnpx shims", snapshot = false, continue-on-failure = true }, + { argv = ["vp", "install", "-g", "pnpm"], comment = "Reject the redundant managed global install", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "$VP_HOME/packages/pnpm.json", "--assert", "missing"], comment = "The redundant package should not be installed", snapshot = false, continue-on-failure = true }, { argv = ["vp", "env", "exec", "node", "--version"], comment = "Ensure Node.js is installed first", snapshot = false, continue-on-failure = true }, { argv = ["pnpm", "--version"], comment = "pnpm shim downloads the native binary and resolves the pinned packageManager version (12.0.0-beta.0)", continue-on-failure = true }, { argv = ["pnpx", "--silent", "cowsay", "hello"], comment = "pnpx shim injects dlx so the native binary runs the package", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md index da9b803c9a..a81701cc4b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md @@ -4,7 +4,17 @@ pnpm 12 ships a native binary via @pnpm/exe.* platform packages; the pnpm shim r ## `vp install -g pnpm` -Expose the pnpm/pnpx shims +Reject the redundant managed global install + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Vite+ already includes 'pnpm'; skipping. +``` + +## `vpt stat-file $VP_HOME/packages/pnpm.json --assert missing` + +The redundant package should not be installed ## `vp env exec node --version` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version index 1d9b7831ba..b009dfb9d9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version @@ -1 +1 @@ -22.12.0 +lts/* 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 b39ea1c4ed..ca0d114ff6 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 @@ -1,9 +1,25 @@ [[case]] name = "shim_pnpm_uses_project_node_version" vp = "global" +local-registry = true skip-platforms = ["windows"] steps = [ - { argv = ["vp", "install", "-g", "pnpm"], comment = "Ensure pnpm is globally installed", snapshot = false, continue-on-failure = true }, - { argv = ["vp", "env", "exec", "node", "-v"], comment = "Node version resolved from .node-version", continue-on-failure = true }, - { argv = ["vp", "env", "exec", "pnpm", "exec", "node", "-v"], comment = "pnpm should use same project Node version", continue-on-failure = true }, + { argv = ["vp", "env", "exec", "node", "-v"], comment = "Node version resolved from .node-version" }, + { argv = ["pnpm", "--version"], comment = "The unpinned pnpm shim resolves the latest version", snapshot = false }, + { argv = ["pnpm", "--version"], envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "The unpinned shim reuses its fresh latest-version cache without registry access", snapshot = false }, + { argv = ["pnpm", "exec", "node", "-v"], comment = "pnpm should use same project Node version" }, + { argv = ["vp", "env", "exec", "--node", "22.13", "pnpm", "exec", "node", "-e", "if(!process.version.startsWith('v22.13.'))process.exit(1);console.log('explicit Node reaches pnpm child')"], comment = "Explicit env exec version overrides the project version through the pnpm shim" }, + { 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/shim_pnpm_uses_project_node_version/snapshots/shim_pnpm_uses_project_node_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_pnpm_uses_project_node_version.md index c249f0bb15..aa162d869e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_pnpm_uses_project_node_version.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_pnpm_uses_project_node_version.md @@ -1,10 +1,5 @@ # shim_pnpm_uses_project_node_version -## `vp install -g pnpm` - -Ensure pnpm is globally installed - - ## `vp env exec node -v` Node version resolved from .node-version @@ -13,10 +8,46 @@ Node version resolved from .node-version ``` -## `vp env exec pnpm exec node -v` +## `pnpm --version` + +The unpinned pnpm shim resolves the latest version + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 pnpm --version` + +The unpinned shim reuses its fresh latest-version cache without registry access + + +## `pnpm exec node -v` pnpm should use same project Node version ``` +Already up to date + +Done in using pnpm ``` + +## `vp env exec --node 22.13 pnpm exec node -e 'if('\!'process.version.startsWith('\''v22.13.'\''))process.exit(1);console.log('\''explicit Node reaches pnpm child'\'')'` + +Explicit env exec version overrides the project version through the pnpm shim + +``` +explicit Node reaches pnpm child +``` + +## `vpt write-file .node-version '>=999.0.0 +'` + + +## `VP_NODE_DIST_MIRROR=http://127.0.0.1:9 pnpm --version` + +JS package-manager shims report project Node resolution failures + +**Exit code:** 1 + +``` +vp: Failed to resolve Node version: Failed to download Node.js runtime: No version matching '>=999.0.0' found +vp: Run 'vp env doctor' for diagnostics +``` 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/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index c0991f0014..d80c2fb1d6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -1364,8 +1364,8 @@ fn run_case( if step.snapshot || !succeeded { let mut redacted = redact_output(raw_output, &redactions, !step.formatted_snapshot); // A version-probe step's output is a bare semver that varies by - // environment (the managed Node's bundled npm or a - // corepack-resolved pin); mask it. Scoped by argv so + // environment (the managed Node's bundled npm or a package + // manager pin); mask it. Scoped by argv so // fixture-controlled bare versions elsewhere (a printed // `.node-version` file) stay assertable. let version_probe = matches!(argv.first().map(String::as_str), Some("npm" | "npx")) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index f8ddee50c9..b03c61869e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -259,7 +259,7 @@ static NODE_TRACE_WARNING_RE: LazyLock = LazyLock::new(|| { // A version-probe step (`npm --version` / `npx --version`) prints a lone bare // semver in its fenced code block (no `v` prefix, so the generic VERSION_RE // misses it). The value tracks the managed Node's bundled npm or a -// corepack-resolved packageManager pin, both of which vary by environment, so +// packageManager pin, both of which vary by environment, so // mask it. Applied via `redact_version_probe_output` ONLY to steps the runner // identifies as version probes: other steps' bare versions in a block (a // printed `.node-version` file) are fixture-controlled assertions that must diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 5337894919..684918e74c 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), // ========================================================================= @@ -291,30 +291,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 { @@ -327,20 +343,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 @@ -348,9 +368,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)] @@ -369,26 +388,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)] @@ -407,13 +432,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"). @@ -422,43 +448,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)] @@ -477,7 +509,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, } } @@ -490,6 +524,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 @@ -635,7 +671,38 @@ async fn run_package_manager_command( commands::prepend_js_runtime_to_path_env(&cwd).await?; let hint_command = command.clone(); - 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 + { + let package_manager = manager.client; + let status = + vp_pm_cli::dispatch_with_resolved_package_manager(&cwd, command, manager).await?; + vp_pm_cli::DispatchResult { status, package_manager } + } else { + match selected { + Some(selected) => { + let package_manager = selected.package_manager_type; + let status = vp_pm_cli::dispatch_with_package_manager( + &cwd, + command, + Some(( + selected.package_manager_type, + &selected.version, + selected.hash.as_deref(), + )), + ) + .await?; + vp_pm_cli::DispatchResult { status, package_manager } + } + None => vp_pm_cli::dispatch_with_metadata(&cwd, command).await?, + } + }; if result.status.success() && let Some(packages) = hint_command.why_hint_packages(result.package_manager) { @@ -665,6 +732,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>, @@ -678,7 +760,6 @@ async fn managed_install( force, concurrency: concurrency.unwrap_or(DEFAULT_GLOBAL_INSTALL_CONCURRENCY), update: false, - only_bins: None, }, ) .await @@ -876,7 +957,6 @@ async fn managed_update( force: false, concurrency, update: true, - only_bins: None, }, ) .await 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 e1ca0f74cf..180deebf8b 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -1,44 +1,78 @@ //! Clean command for removing managed caches. //! -//! Handles `vp env clean` by removing unused Node.js runtimes, all managed -//! package manager installs, and the underlying Corepack cache. +//! Handles `vp env clean` by removing unused Node.js runtimes and all managed +//! package manager installs. use std::{path::Path, process::ExitStatus}; -use vp_shared::{env_vars, output}; +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 home_dir = vp_shared::get_vp_home()?; let node_dir = home_dir.join("js_runtime").join("node"); let package_manager_dir = home_dir.join("package_manager"); - let protected_versions = protected_node_versions(&cwd).await?; - - let corepack_cleaned = run_corepack_cache_clean(&cwd).await?; - if corepack_cleaned { - output::success("Cleaned Corepack cache"); + 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 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) - )); - - 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); @@ -69,36 +103,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 { @@ -109,89 +136,6 @@ async fn remove_dir_all_if_exists(path: &Path) -> Result { } } -async fn run_corepack_cache_clean(cwd: &AbsolutePathBuf) -> Result { - let corepack_path = match resolve_corepack_from_path(cwd) { - Some(path) => path, - None => return Ok(false), - }; - - if corepack_cache_clean_would_auto_install(cwd, &corepack_path).await? { - return Ok(false); - } - - let result = tokio::process::Command::new(corepack_path.as_path()) - .args(["cache", "clean"]) - .current_dir(cwd.as_path()) - .env_remove(env_vars::VP_TOOL_RECURSION) - .output() - .await; - - match result { - Ok(command_output) if command_output.status.success() => Ok(true), - Ok(command_output) => Err(Error::Other(corepack_failure_message(&command_output).into())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(e) => Err(e.into()), - } -} - -async fn corepack_cache_clean_would_auto_install( - cwd: &AbsolutePathBuf, - corepack_path: &AbsolutePath, -) -> Result { - let bin_dir = config::get_bin_dir()?; - if corepack_path.parent() != Some(&bin_dir) { - return Ok(false); - } - - if config::load_config().await?.shim_mode == config::ShimMode::SystemFirst - && crate::shim::dispatch::find_system_tool("corepack").is_some() - { - return Ok(false); - } - - if has_usable_managed_corepack().await { - return Ok(false); - } - - let resolution = - crate::shim::dispatch::resolve_with_cache(cwd).await.map_err(|e| Error::Other(e.into()))?; - Ok(crate::shim::dispatch::locate_tool(&resolution.version, "corepack").is_err()) -} - -fn resolve_corepack_from_path(cwd: &AbsolutePathBuf) -> Option { - let path_var = std::env::var_os("PATH")?; - let paths = std::env::split_paths(&path_var).map(|path| { - if path.is_absolute() || path.starts_with("~") { - path - } else { - cwd.as_absolute_path().as_path().join(path) - } - }); - let search_path = std::env::join_paths(paths).ok()?; - vp_command::resolve_bin("corepack", Some(&search_path), cwd).ok() -} - -async fn has_usable_managed_corepack() -> bool { - let Ok(Some(metadata)) = crate::shim::dispatch::find_package_for_binary("corepack").await - else { - return false; - }; - crate::shim::dispatch::locate_package_binary(&metadata, "corepack").is_ok() - && crate::shim::dispatch::locate_tool(&metadata.platform.node, "node").is_ok() -} - -fn corepack_failure_message(command_output: &std::process::Output) -> String { - let stderr = String::from_utf8_lossy(&command_output.stderr); - let stdout = String::from_utf8_lossy(&command_output.stdout); - let stderr = stderr.trim(); - let stdout = stdout.trim(); - let details = if stderr.is_empty() { stdout } else { stderr }; - if details.is_empty() { - return "corepack cache clean failed".to_string(); - } - format!("corepack cache clean failed: {details}") -} - fn push_unique_version(versions: &mut Vec, version: String) { let normalized = version.strip_prefix('v').unwrap_or(&version).to_string(); if !versions.iter().any(|existing| existing == &normalized) { @@ -231,16 +175,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 38cd5805b9..28749a4082 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) @@ -145,14 +170,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 (~/.vite-plus/.session-node-version). pub fn get_session_version_path() -> Result { Ok(get_vp_home()?.join(SESSION_VERSION_FILE)) } +pub fn get_session_package_manager_path() -> Result { + Ok(get_vp_home()?.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()?; @@ -161,6 +196,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()?; @@ -180,6 +222,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()?; @@ -190,6 +241,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: @@ -1375,4 +1435,36 @@ mod tests { assert_eq!(resolution_from_files.version, "20.18.0"); assert_eq!(resolution_from_files.source, ".node-version"); } + + #[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(); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_dir.path(), + )); + 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()); + } } diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index d712ea37c0..98d000a369 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,86 +55,176 @@ 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 home directory for this version - let home_dir = - vp_shared::get_vp_home()?.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::get_vp_home()?.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 6ce79f0473..bf4c02e8d2 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, get_vp_home, load_config, resolve_version}; +use super::{ + config::{self, ShimMode, get_bin_dir, get_vp_home, 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; @@ -217,7 +236,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) => { @@ -232,34 +251,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. diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index a543c71340..7e18cf77e2 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); + 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. @@ -207,7 +263,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()); @@ -218,7 +275,8 @@ mod tests { async fn test_execute_node_version() { // 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()); @@ -255,17 +313,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..e76be99047 --- /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::get_vp_home()?; + 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 4ef758e70b..4da12f95b3 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,136 +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 home_dir = vp_shared::get_vp_home()?; - let node_dir = home_dir.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::get_vp_home()?; + 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 81b3317c8e..f95115c3bf 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,435 +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::get_vp_home()?; + + 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 Ok(home_dir) = vp_shared::get_vp_home() else { - return std::collections::HashSet::new(); - }; - let node_dir = home_dir.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 bae8bccd8c..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,75 +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 home_dir = vp_shared::get_vp_home()?; - let version_dir = home_dir.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 } }; @@ -181,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/package_metadata.rs b/crates/vp_global_cli/src/commands/env/package_metadata.rs index 21eadc1048..afd1441bec 100644 --- a/crates/vp_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vp_global_cli/src/commands/env/package_metadata.rs @@ -41,11 +41,6 @@ pub struct PackageMetadata { /// Binary names that are JavaScript files (need Node.js to run). #[serde(default)] pub js_bins: HashSet, - /// Whether `bins` was deliberately restricted to a subset of the bins the - /// package declares (e.g., the corepack shim auto-install links only - /// `corepack`). Updates keep the restriction; explicit installs reset it. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub bins_restricted: bool, /// Version spec the package was installed with (a dist-tag like /// `nightly`, a range, or an exact version), so `vp update -g` keeps /// resolving within it. `None` means the implicit `latest` tag. @@ -85,7 +80,6 @@ impl PackageMetadata { platform: Platform { node: node_version, npm: npm_version }, bins, js_bins, - bins_restricted: false, version_spec: None, manager, installed_at: Utc::now(), diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 23b3468373..3fb4d15d10 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 serial_test::serial; @@ -589,6 +878,76 @@ mod tests { use super::*; + #[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/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index b99249b124..41958fc750 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1,16 +1,16 @@ //! Setup command implementation for creating bin directory and shims. //! //! Creates the following structure: -//! - ~/.vite-plus/bin/ - Contains vp symlink and node/npm/npx/corepack shims +//! - ~/.vite-plus/bin/ - Contains vp symlink and default tool shims //! - ~/.vite-plus/current/ - Contains the actual vp CLI binary //! //! On Unix: //! - bin/vp is a symlink to the active vp binary -//! - bin/node, bin/npm, bin/npx, bin/corepack are symlinks to the active vp binary +//! - Default tool shims are symlinks to the active vp binary //! - Symlinks preserve argv[0], allowing tool detection via the symlink name //! //! On Windows: -//! - bin/vp.exe, bin/node.exe, bin/npm.exe, bin/npx.exe, bin/corepack.exe are trampoline executables +//! - bin/vp.exe and default tool shims are trampoline executables //! - Each trampoline detects its tool name from its own filename and spawns //! current\bin\vp.exe with VP_SHIM_TOOL env var set //! - This avoids the "Terminate batch job (Y/N)?" prompt from .cmd wrappers @@ -41,8 +41,9 @@ impl EnvShell { } } -/// Tools to create shims for (node, npm, npx, corepack, vpx, vpr) -pub(crate) const SHIM_TOOLS: &[&str] = &["node", "npm", "npx", "corepack", "vpx", "vpr"]; +/// 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 { @@ -70,6 +71,10 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result // Ensure bin directory exists tokio::fs::create_dir_all(&bin_dir).await?; + if refresh { + cleanup_legacy_package_manager_installs(&bin_dir).await; + } + #[cfg(windows)] tokio::fs::write(bin_dir.join("vp-use.cmd"), VP_USE_CMD_CONTENT).await?; @@ -79,7 +84,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result // Create wrapper script in bin/ setup_vp_wrapper(¤t_exe, &bin_dir, refresh).await?; - // Create shims for node, npm, npx, corepack + // Create default tool shims let mut created = Vec::new(); let mut skipped = Vec::new(); @@ -91,16 +96,15 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result skipped.push(*tool); } - // Remove corepack-written .cmd/.ps1/extensionless launchers that - // would shadow an existing trampoline .exe in PowerShell/Git Bash - // (create_shim skips existing shims without cleaning siblings). + // Remove legacy .cmd/.ps1/extensionless launchers that would shadow + // an existing trampoline .exe in PowerShell/Git Bash (create_shim + // skips existing shims without cleaning siblings). #[cfg(windows)] cleanup_legacy_windows_shim(&bin_dir, tool).await; - // Drop stale `npm install -g` link configs for default shim names - // (e.g. a pre-default-shim `npm i -g corepack`): the link itself is - // replaced by the shim above, and a leftover Npm-sourced BinConfig - // would let a later `npm uninstall -g` delete the default shim. + // Drop stale `npm install -g` link configs for default shim names. The + // link itself is replaced by the shim above, and a leftover Npm-sourced + // BinConfig would let a later `npm uninstall -g` delete the default shim. if let Ok(Some(config)) = super::bin_config::BinConfig::load(tool).await && config.source == super::bin_config::BinSource::Npm { @@ -149,6 +153,61 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result Ok(ExitStatus::default()) } +/// Remove legacy managed installs left by versions that did not expose package-manager shims. +async fn cleanup_legacy_package_manager_installs(bin_dir: &vt_path::AbsolutePath) { + use super::{bin_config::BinConfig, package_metadata::PackageMetadata}; + use crate::commands::global::{LEGACY_PACKAGE_MANAGER_PACKAGES, install::uninstall}; + + for package_name in LEGACY_PACKAGE_MANAGER_PACKAGES { + let has_metadata = match PackageMetadata::load(package_name).await { + Ok(metadata) => metadata.is_some(), + Err(error) => { + vp_shared::output::warn(&format!( + "Failed to inspect legacy global package '{package_name}': {error}" + )); + continue; + } + }; + let has_bin_config = match BinConfig::find_by_package(package_name).await { + Ok(bins) => !bins.is_empty(), + Err(error) => { + vp_shared::output::warn(&format!( + "Failed to inspect legacy shims for '{package_name}': {error}" + )); + continue; + } + }; + + if (has_metadata || has_bin_config) + && let Err(error) = uninstall(package_name, false).await + { + vp_shared::output::warn(&format!( + "Failed to remove legacy global package '{package_name}': {error}" + )); + } + } + + // Corepack is no longer exposed, so remove its old default shim even when no package metadata remains. + #[cfg(unix)] + { + let shim_path = bin_dir.join("corepack"); + match tokio::fs::remove_file(&shim_path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => vp_shared::output::warn(&format!( + "Failed to remove legacy Corepack shim at {}: {error}", + shim_path.as_path().display() + )), + } + } + + #[cfg(windows)] + { + remove_or_rename_to_old(&bin_dir.join("corepack.exe")).await; + cleanup_legacy_windows_shim(bin_dir, "corepack").await; + } +} + /// Create symlink in bin/ that points to the active vp binary. async fn setup_vp_wrapper( current_exe: &std::path::Path, @@ -233,7 +292,7 @@ pub(crate) async fn resolve_unix_vp_shim_target( Ok(current_exe.to_path_buf()) } -/// Create a single shim for a default shim tool (node/npm/npx/corepack/vpx/vpr). +/// Create a single default tool shim. /// /// Returns `true` if the shim was created, `false` if it already exists. pub(crate) async fn create_shim( @@ -888,15 +947,6 @@ mod tests { use super::*; - #[test] - fn test_shim_tools_contains_default_shims() { - // corepack is a default shim (#858, #1309). It must NOT be a core - // shim: `vp install -g corepack` stays allowed (CORE_SHIMS guard) and - // dispatch uses a dedicated resolution path instead of the core one. - assert!(SHIM_TOOLS.contains(&"corepack")); - assert!(!crate::commands::global::CORE_SHIMS.contains(&"corepack")); - } - /// Helper: create a test_guard with user_home set to the given path. fn home_guard(home: impl Into) -> vp_shared::TestEnvGuard { vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { @@ -1326,6 +1376,87 @@ mod tests { assert!(fresh_home.join("env.ps1").exists(), "env.ps1 file should be created"); } + #[tokio::test] + #[cfg_attr(windows, serial_test::serial)] + async fn test_execute_refresh_removes_legacy_package_manager_installs() { + use crate::commands::{ + env::{bin_config::BinConfig, package_metadata::PackageMetadata}, + global::LEGACY_PACKAGE_MANAGER_PACKAGES, + }; + + let temp_dir = TempDir::new().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); + let bin_dir = home.join("bin"); + let _env_guard = vp_shared::EnvConfig::test_guard( + vp_shared::EnvConfig::for_test_with_home(home.as_path()), + ); + #[cfg(windows)] + let _trampoline_guard = FakeTrampolineGuard::new(temp_dir.path()); + + tokio::fs::create_dir_all(&bin_dir).await.unwrap(); + let mut package_dirs = Vec::new(); + for package_name in LEGACY_PACKAGE_MANAGER_PACKAGES { + let mut metadata = PackageMetadata::new( + package_name.to_string(), + "1.0.0".to_string(), + "22.0.0".to_string(), + None, + vec![package_name.to_string()], + Default::default(), + "npm".to_string(), + ); + metadata.install_id = "123e4567-e89b-42d3-a456-426614174000".to_string(); + metadata.save().await.unwrap(); + let package_dir = metadata.installation_dir().unwrap(); + tokio::fs::create_dir_all(&package_dir).await.unwrap(); + package_dirs.push(package_dir); + + BinConfig::new( + package_name.to_string(), + package_name.to_string(), + "1.0.0".to_string(), + "22.0.0".to_string(), + ) + .save() + .await + .unwrap(); + } + + #[cfg(unix)] + tokio::fs::write(bin_dir.join("corepack"), "legacy corepack shim").await.unwrap(); + #[cfg(windows)] + for suffix in [".exe", ".cmd", ".ps1", ""] { + tokio::fs::write(bin_dir.join(format!("corepack{suffix}")), "legacy corepack shim") + .await + .unwrap(); + } + + let status = execute(true, false).await.unwrap(); + + assert!(status.success()); + for (package_name, package_dir) in LEGACY_PACKAGE_MANAGER_PACKAGES.iter().zip(package_dirs) + { + assert!(PackageMetadata::load(package_name).await.unwrap().is_none()); + assert!(BinConfig::load(package_name).await.unwrap().is_none()); + assert!(!package_dir.as_path().exists()); + } + for tool in ["pnpm", "yarn", "bun"] { + assert!( + std::fs::symlink_metadata(bin_dir.join(shim_filename(tool)).as_path()).is_ok(), + "{tool} should be recreated as a default shim" + ); + } + let corepack_suffixes: &[&str] = + if cfg!(windows) { &[".exe", ".cmd", ".ps1", ""] } else { &[""] }; + for suffix in corepack_suffixes { + assert!( + std::fs::symlink_metadata(bin_dir.join(format!("corepack{suffix}")).as_path()) + .is_err(), + "legacy corepack shim should be removed" + ); + } + } + #[tokio::test] #[cfg(unix)] async fn test_unix_vp_shim_target_prefers_standalone_layout_for_current_exe() { 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 7b07cf94d8..2cec92d916 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 `~/.vite-plus/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,26 +24,26 @@ 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::Fish => format!("set -e {VERSION_ENV_VAR}"), + Shell::Posix => format!("unset {variable}"), + Shell::Fish => format!("set -e {variable}"), 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}"), } } @@ -68,77 +70,139 @@ 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.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::get_vp_home()?.join("js_runtime").join("node").join(&resolved_version); + vp_shared::get_vp_home()?.join("js_runtime").join("node").join(resolved_version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); @@ -147,29 +211,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 super::*; @@ -216,60 +304,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"); } #[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"); } @@ -282,7 +370,7 @@ mod tests { temp_dir.path(), )); - let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + let status = 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()); @@ -298,7 +386,7 @@ mod tests { ..vp_shared::EnvConfig::for_test_with_home(temp_dir.path()) }); - let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + let status = 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")); @@ -317,7 +405,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(); + let status = 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 3d5fbebb01..667ec52f48 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -2,7 +2,7 @@ //! //! Shows the path to the tool binary that would be executed. //! -//! For core tools (node, npm, npx, corepack), shows the resolved Node.js +//! For core tools (node, npm, npx), shows the resolved Node.js //! binary path along with version and resolution source. //! For global packages, shows the binary path plus package metadata. @@ -12,46 +12,45 @@ 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, corepack) -const CORE_TOOLS: &[&str] = &["node", "npm", "npx", "corepack"]; +/// Core tools (node, npm, npx) +const CORE_TOOLS: &[&str] = &["node", "npm", "npx"]; /// Column width for left-side labels in aligned metadata output 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); } // Check if this is a core tool if CORE_TOOLS.contains(&tool) { - // corepack: a vp-managed global install wins over the Node-bundled - // copy. Mirror the shim dispatch: BinConfig-based lookup, falling - // back to the bundled copy (with the same warning) when the managed - // state is unusable, so the diagnostic matches what actually runs. - if tool == "corepack" { - match crate::shim::dispatch::find_package_for_binary(tool).await { - Ok(Some(metadata)) => match locate_package_binary(&metadata, tool) { - Ok(_) => return execute_package_binary(tool, &metadata).await, - Err(e) => warn_unusable_managed_corepack(&e.to_string()), - }, - Ok(None) => {} - Err(e) => warn_unusable_managed_corepack(&e), - } - } return execute_core_tool(cwd, tool).await; } @@ -62,7 +61,7 @@ pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result ( + 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); @@ -167,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))); } @@ -176,27 +184,14 @@ async fn execute_package_manager_tool( println!( " {: Result { // Resolve version for current directory let resolution = resolve_version(&cwd).await?; @@ -218,20 +213,8 @@ async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result 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) } -/// Whether a package may own a bin name. Protected shim names never belong -/// to packages, with one exception: the `corepack` package owning its own -/// `corepack` bin, so an explicit `vp install -g corepack` wins the shim's -/// resolution order. The exemption is scoped to the package name; any other -/// package declaring a `corepack` bin must not take BinConfig ownership. -pub(crate) fn package_may_own_bin(package_name: &str, bin_name: &str) -> bool { - !is_protected_shim(bin_name, true) || (bin_name == "corepack" && package_name == "corepack") -} - /// Options for [`install`]. pub struct InstallOptions<'a> { /// Node.js version to install with; resolved from the current directory @@ -96,12 +87,8 @@ pub struct InstallOptions<'a> { pub force: bool, /// Number of packages to install in parallel. pub concurrency: usize, - /// `vp update -g` semantics: carries a recorded bin restriction forward. + /// Whether this is a `vp update -g` operation. pub update: bool, - /// Only expose these binaries as shims; other bins the package declares - /// are ignored (used by the corepack shim auto-install, which must not - /// link corepack's pnpm/yarn launchers). - pub only_bins: Option<&'a [&'a str]>, } /// Install global packages parallelly. @@ -109,7 +96,7 @@ pub async fn install( package_specs: &[String], options: InstallOptions<'_>, ) -> Result<(), InstallError> { - let InstallOptions { node_version, force, concurrency, update, only_bins } = options; + let InstallOptions { node_version, force, concurrency, update } = options; if package_specs.is_empty() { return Ok(()); } @@ -117,7 +104,30 @@ pub async fn install( let operation_progress = if update { "Updating" } else { "Installing" }; let operation_past = if update { "Updated" } else { "Installed" }; - // 1. Resolve Node.js version + // 1. Parse package specs and skip legacy globals now provided by Vite+. + let mut packages = IndexMap::::new(); + for package_spec in package_specs { + let package_name = match parse_package_spec(package_spec) { + Ok((package_name, _)) => package_name, + Err(error) => return Err((Some(package_spec.clone()), Box::new(error))), + }; + if package_name == "corepack" { + return Err(package_error( + &package_name, + Error::Other("'vp install -g corepack' is no longer supported.".into()), + )); + } + if LEGACY_PACKAGE_MANAGER_PACKAGES.contains(&package_name.as_str()) { + output::warn(&format!("Vite+ already includes '{package_name}'; skipping.")); + continue; + } + packages.insert(package_name, Package { spec: package_spec, install: None }); + } + if packages.is_empty() { + return Ok(()); + } + + // 2. Resolve Node.js version let node_version = if let Some(v) = node_version { let provider = NodeProvider::new(); match resolve_version_alias(v, &provider).await { @@ -137,7 +147,7 @@ pub async fn install( resolution.version }; - // 2. Ensure Node.js is installed + // 3. Ensure Node.js is installed let runtime = match vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &node_version) .await @@ -153,17 +163,7 @@ pub async fn install( let npm_path = if cfg!(windows) { node_bin_dir.join("npm.cmd") } else { node_bin_dir.join("npm") }; - // 3. Install packages in parallel - let mut packages = IndexMap::::new(); - for package_spec in package_specs { - // Parse package spec (e.g., "typescript", "typescript@5.0.0", "@scope/pkg") - - let (package_name, _version_spec) = match parse_package_spec(package_spec) { - Ok(result) => result, - Err(error) => return Err((Some(package_spec.clone()), Box::new(error))), - }; - packages.insert(package_name, Package { spec: package_spec, install: None }); - } + // 4. Install packages in parallel let packages_count = packages.len(); let concurrency = concurrency.max(1); @@ -229,7 +229,7 @@ pub async fn install( } progress.finish_and_clear(); - // 4. Finalize installed packages. + // 5. Finalize installed packages. let mut bin_owners = HashMap::::new(); for (index, (package_name, Package { spec, install })) in packages.into_iter().enumerate() { let lock_file = install_locks.remove(&package_name); @@ -244,8 +244,7 @@ pub async fn install( continue; }; - // Previous metadata drives both the inherited bin restriction and - // stale-bin detection below; load it once. + // Load previous metadata once for stale-bin detection below. let previous_metadata = match PackageMetadata::load(&package_name).await { Ok(metadata) => metadata, Err(error) => { @@ -257,29 +256,10 @@ pub async fn install( } }; - // Restrict exposed binaries when requested (e.g., the corepack shim - // auto-install only links `corepack`, not the pnpm/yarn launchers - // that `corepack enable` creates on demand). Updates carry a recorded - // restriction forward so `vp update -g` cannot re-expose the filtered - // bins; explicit installs (update=false) re-expose the full bin list. - let restriction: Option> = match only_bins { - Some(only) => Some(only.iter().map(ToString::to_string).collect()), - None if update => previous_metadata - .as_ref() - .filter(|previous| previous.bins_restricted) - .map(|previous| previous.bins.clone()), - None => None, - }; - let bins_restricted = restriction.is_some(); - if let Some(only) = &restriction { - bin_names.retain(|bin| only.contains(bin)); - js_bins.retain(|bin| only.contains(bin)); - } - // Drop bin names the package must not own before conflict detection, // shim creation, BinConfig ownership, and metadata recording. bin_names.retain(|bin| { - let allowed = package_may_own_bin(&package_name, bin); + let allowed = !is_protected_shim(bin, true); if !allowed { output::warn(&format!( "Package '{}' provides '{}' binary, but it conflicts with a built-in shim. \ @@ -289,7 +269,7 @@ pub async fn install( } allowed }); - js_bins.retain(|bin| package_may_own_bin(&package_name, bin)); + js_bins.retain(|bin| !is_protected_shim(bin, true)); let stale_bin_names = match stale_bin_names_for_package( previous_metadata.as_ref(), @@ -311,7 +291,7 @@ pub async fn install( let mut conflicts = Vec::<(String, String)>::new(); let mut finalize_blocked = false; - // 4.1 Detect binary ownership conflicts before writing metadata. + // 5.1 Detect binary ownership conflicts before writing metadata. for bin_name in &bin_names { if let Some(owner) = bin_owners.get(bin_name) && owner != &package_name @@ -341,7 +321,7 @@ pub async fn install( continue; } - // 4.2 Resolve conflicts, either by force-uninstalling owners or rolling back this install. + // 5.2 Resolve conflicts, either by force-uninstalling owners or rolling back this install. if !conflicts.is_empty() { if force { let packages_to_remove: HashSet<_> = @@ -380,7 +360,7 @@ pub async fn install( } } - // 4.3 Prepare metadata and remove binaries that the new install no longer provides. + // 5.3 Prepare metadata and remove binaries that the new install no longer provides. let bin_dir = match get_bin_dir().map_err(|error| package_error(&package_name, error)) { Ok(bin_dir) => bin_dir, Err(error) => { @@ -403,7 +383,6 @@ pub async fn install( "npm".to_string(), ); metadata.install_id = install_id.clone(); - metadata.bins_restricted = bins_restricted; metadata.version_spec = update_version_spec(spec); let mut finalized = true; @@ -436,7 +415,7 @@ pub async fn install( continue; } - // 4.4 Activate the new installation through metadata. + // 5.4 Activate the new installation through metadata. if let Err(error) = metadata.save().await.map_err(|error| package_error(&package_name, error)) { @@ -454,7 +433,7 @@ pub async fn install( continue; } - // 4.5 Expose each binary and record its ownership. + // 5.5 Expose each binary and record its ownership. for bin_name in &bin_names { let result = async { create_package_shim(&bin_dir, bin_name, &package_name).await?; @@ -494,11 +473,11 @@ pub async fn install( bin_owners.insert(bin_name.clone(), package_name.clone()); } - // 4.6 Remove stale installations for this package. + // 5.6 Remove stale installations for this package. cleanup_stale_installations(&package_name, &install_id).await; drop(lock_file); - // 4.7 Print success message + // 5.7 Print success message output::success(&format!( "{} {} {}{}", operation_past, @@ -1102,10 +1081,9 @@ pub(crate) async fn create_package_shim( bin_name: &str, package_name: &str, ) -> Result<(), Error> { - // Defense in depth: the finalize loop already filters bin names the - // package must not own (see package_may_own_bin); keep the guard here so - // no other caller can hand a protected shim to a package. - if !package_may_own_bin(package_name, bin_name) { + // Defense in depth: the finalize loop already filters protected bin + // names; keep the guard here so no other caller can hand one to a package. + if is_protected_shim(bin_name, true) { output::warn(&format!( "Package '{}' provides '{}' binary, but it conflicts with a built-in shim. Skipping.", package_name, bin_name @@ -1165,9 +1143,7 @@ pub(crate) async fn create_package_shim( /// Remove a shim for a package binary. async fn remove_package_shim(bin_dir: &vt_path::AbsolutePath, bin_name: &str) -> Result<(), Error> { - // Don't remove protected shims (e.g., `vp remove -g corepack` must keep - // the default corepack shim so it falls back to the Node-bundled or - // auto-installed corepack). + // Don't remove protected shims. if is_protected_shim(bin_name, false) { return Ok(()); } @@ -1229,6 +1205,13 @@ mod tests { } } + #[test] + fn test_default_shims_are_protected() { + for shim in CORE_SHIMS.iter().chain(crate::commands::env::setup::SHIM_TOOLS) { + assert!(is_protected_shim(shim, false), "{shim} should be protected"); + } + } + #[tokio::test] #[cfg_attr(windows, serial_test::serial)] async fn test_create_package_shim_creates_bin_dir() { @@ -1287,48 +1270,6 @@ mod tests { assert!(!shim_path.as_path().exists()); } - #[test] - fn test_package_may_own_bin_scopes_corepack_to_its_package() { - // Only the corepack package may own the corepack bin; any other - // package declaring a `corepack` bin must not take BinConfig - // ownership (it would win the corepack shim's resolution order). - assert!(package_may_own_bin("corepack", "corepack")); - assert!(!package_may_own_bin("some-package", "corepack")); - assert!(!package_may_own_bin("@scope/corepack", "corepack")); - - // Other protected shims never belong to packages - assert!(!package_may_own_bin("corepack", "npm")); - assert!(!package_may_own_bin("some-package", "vpx")); - assert!(!package_may_own_bin("some-package", "vpr")); - - // Regular bins are unrestricted - assert!(package_may_own_bin("typescript", "tsc")); - - #[cfg(any(windows, target_os = "macos"))] - assert!(!package_may_own_bin("some-package", "NPM")); - #[cfg(any(windows, target_os = "macos"))] - assert!(!package_may_own_bin("some-package", "Node")); - #[cfg(any(windows, target_os = "macos"))] - assert!(!package_may_own_bin("some-package", "VP")); - } - - #[tokio::test] - async fn test_create_package_shim_skips_corepack_bin_for_other_packages() { - use tempfile::TempDir; - use vt_path::AbsolutePathBuf; - - let temp_dir = TempDir::new().unwrap(); - let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - create_package_shim(&bin_dir, "corepack", "some-package").await.unwrap(); - - #[cfg(unix)] - let shim_path = bin_dir.join("corepack"); - #[cfg(windows)] - let shim_path = bin_dir.join("corepack.exe"); - assert!(!shim_path.as_path().exists()); - } - #[tokio::test] #[cfg_attr(windows, serial_test::serial)] async fn test_remove_package_shim_removes_shim() { diff --git a/crates/vp_global_cli/src/commands/global/mod.rs b/crates/vp_global_cli/src/commands/global/mod.rs index 91e4961485..c5e5a0de80 100644 --- a/crates/vp_global_cli/src/commands/global/mod.rs +++ b/crates/vp_global_cli/src/commands/global/mod.rs @@ -17,7 +17,11 @@ 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", "vp"]; +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"]; #[derive(Debug)] struct PackageVersion { diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index a07b462ca4..b5f3d9bfa3 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -130,6 +130,30 @@ 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? { + 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 919184211f..eb376dbf4a 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_global_cli/src/help.rs @@ -402,7 +402,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( @@ -466,34 +466,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( @@ -502,10 +496,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", ), ], ), @@ -513,27 +507,30 @@ fn env_help_doc() -> HelpDoc { "Examples", vec![ " Setup:", - " vp env setup # Create shims for node, npm, npx, corepack", - " vp env on # Use vite-plus managed Node.js", - " vp env print # Print shell snippet for this session", + " 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 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/corepack.rs b/crates/vp_global_cli/src/shim/corepack.rs deleted file mode 100644 index 92c74c6bf5..0000000000 --- a/crates/vp_global_cli/src/shim/corepack.rs +++ /dev/null @@ -1,651 +0,0 @@ -//! Corepack shim dispatch. -//! -//! `corepack` is a default shim (created by `vp env setup`), but unlike the -//! core tools (node/npm/npx) it is not always available in the resolved -//! Node.js installation: Node.js 25 removed the bundled corepack. -//! -//! Resolution order: -//! 1. vp-managed global package (`vp install -g corepack`) — an explicit -//! install wins and provides a consistent corepack across Node.js versions -//! 2. corepack bundled with the project-resolved Node.js (Node.js <= 24) -//! 3. auto-install corepack as a vp-managed global package -//! -//! `corepack enable`/`corepack disable` create or remove package-manager -//! launchers next to the corepack binary found in PATH, which under the shim -//! would be the per-version Node.js bin directory (not on PATH). To keep the -//! launchers reachable, these commands get `--install-directory ~/.vite-plus/bin` -//! injected when not explicitly set, and Vite+-owned shims are restored -//! afterwards if corepack removed or replaced them. - -use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; -use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; - -use super::{ - dispatch::{ - create_bin_link, ensure_installed, find_package_for_binary, locate_tool, - package_binary_invocation, resolve_with_cache, - }, - exec, -}; -use crate::commands::env::{ - bin_config::{BinConfig, BinSource}, - config, setup, -}; - -/// Binary names corepack `enable`/`disable` may create or remove in the -/// install directory (npm/npx only when explicitly requested). -const COREPACK_MANAGED_BIN_NAMES: &[&str] = &["npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg"]; - -/// How to invoke the resolved corepack binary. -struct CorepackInvocation { - /// Program to execute (the corepack binary itself, or node for a JS entry) - program: AbsolutePathBuf, - /// Arguments to pass before the user-supplied args (e.g., the JS entry path) - pre_args: Vec, -} - -/// Dispatch a `corepack` shim invocation. -pub(crate) async fn dispatch_corepack(args: &[String]) -> i32 { - let invocation = match resolve_corepack_invocation().await { - Ok(invocation) => invocation, - Err(exit_code) => return exit_code, - }; - let CorepackInvocation { program, pre_args } = invocation; - let mut full_args = pre_args; - - // enable/disable create or remove launchers in the install directory. - // Inject the vp bin dir (so they land on PATH), run with spawn+wait, and - // restore any Vite+-owned shims corepack removed or replaced. The arg - // check runs first so the common path skips bin-dir resolution entirely. - if is_corepack_link_command(args) { - match config::get_bin_dir() { - Ok(bin_dir) => { - full_args.extend(inject_install_directory(args, &bin_dir)); - let owned_shims = snapshot_vp_owned_shims(&bin_dir).await; - let exit_code = exec::spawn_tool(&program, &full_args); - restore_vp_owned_shims(&bin_dir, &owned_shims).await; - return exit_code; - } - Err(e) => { - // Without a bin dir there is nothing to inject or restore; - // run corepack as-is, but say so instead of failing silently. - output::warn(&format!( - "Cannot resolve the Vite+ bin directory ({e}); running corepack without \ - an --install-directory default, created launchers may not be on PATH" - )); - } - } - } - - // The bundled corepack and native binaries have no leading args; exec - // with the caller's slice instead of cloning every argument. - if full_args.is_empty() { - return exec::exec_tool(&program, args); - } - full_args.extend(args.iter().cloned()); - exec::exec_tool(&program, &full_args) -} - -/// Resolve which corepack binary to execute. -/// -/// Returns an exit code on failure (errors are already printed). -async fn resolve_corepack_invocation() -> Result { - // 1. An explicit `vp install -g corepack` wins. Resolution errors (stale - // metadata, missing package files) fall through to the bundled copy - // instead of failing: broken managed state must not brick the shim. - match managed_corepack_invocation().await { - Ok(Some(invocation)) => return Ok(invocation), - Ok(None) => {} - Err(e) => { - output::warn(&format!( - "Ignoring unusable vp-managed corepack ({e}); falling back to the \ - Node-bundled corepack. Run `vp remove -g corepack` to clear it." - )); - } - } - - // 2. corepack bundled with the project-resolved Node.js (Node.js <= 24). - let cwd = match current_dir() { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Failed to get current directory: {e}"); - return Err(1); - } - }; - let resolution = match resolve_with_cache(&cwd).await { - Ok(resolution) => resolution, - Err(e) => { - eprintln!("vp: Failed to resolve Node version: {e}"); - eprintln!("vp: Run 'vp env doctor' for diagnostics"); - return Err(1); - } - }; - let corepack_path = match locate_tool(&resolution.version, "corepack") { - Ok(path) => Some(path), - Err(_) => { - // The runtime may not be installed yet; download it before - // concluding that corepack is not bundled. - if let Err(e) = ensure_installed(&resolution.version).await { - eprintln!("vp: Failed to install Node {}: {e}", resolution.version); - return Err(1); - } - locate_tool(&resolution.version, "corepack").ok() - } - }; - if let Some(corepack_path) = corepack_path { - // The bundled corepack sits in the same bin directory as node; - // prepend it so corepack's child processes see the same runtime. - if let Some(node_bin_dir) = corepack_path.parent() { - let _ = prepend_to_path_env(node_bin_dir, PrependOptions::default()); - } - // Match the core-tool dispatch: nested core-tool shims pass through. - // SAFETY: Setting env vars at this point before exec/spawn is safe - unsafe { - std::env::set_var(env_vars::VP_TOOL_RECURSION, "1"); - } - return Ok(CorepackInvocation { program: corepack_path, pre_args: Vec::new() }); - } - - // 3. No usable corepack in the resolved Node.js (Node.js 25+ no longer - // bundles it; a bundled copy may also have been removed, e.g. by - // `npm uninstall -g corepack`): install it as a vp-managed global - // package, then run that copy. Only the `corepack` bin is linked; the - // pnpm/yarn launchers the package also declares stay unexposed (that - // is `corepack enable`'s job) and must not conflict with vp-managed - // package managers. The notice goes to stderr so the wrapped - // corepack's stdout stays parseable. - eprintln!( - "vp: corepack is not available for Node.js {}; installing it as a managed global package", - resolution.version - ); - // Preserve the shape of a previous explicit (unrestricted) install: the - // reinstall must not silently drop launcher bins the user had exposed - // (the stale-bin cleanup would delete their pnpm/yarn shims). - let unrestricted = matches!( - crate::commands::env::package_metadata::PackageMetadata::load("corepack").await, - Ok(Some(previous)) if !previous.bins_restricted - ); - let only_bins: Option<&[&str]> = if unrestricted { None } else { Some(&["corepack"]) }; - if let Err((_, error)) = crate::commands::global::install::install( - &["corepack".to_string()], - crate::commands::global::install::InstallOptions { - node_version: None, - force: false, - concurrency: 1, - update: false, - only_bins, - }, - ) - .await - { - eprintln!("vp: Failed to install corepack: {error}"); - eprintln!("vp: Run 'vp install -g corepack' manually, then retry"); - return Err(1); - } - match managed_corepack_invocation().await { - Ok(Some(invocation)) => Ok(invocation), - Ok(None) => { - eprintln!("vp: corepack was installed but its binary could not be located"); - Err(1) - } - Err(e) => { - eprintln!("vp: corepack was installed but cannot be resolved: {e}"); - Err(1) - } - } -} - -/// Resolve a corepack installed via `vp install -g corepack`, if any. -/// -/// Uses the install-time Node.js version, like every other vp-managed -/// package binary. -async fn managed_corepack_invocation() -> Result, String> { - let Some(metadata) = find_package_for_binary("corepack").await? else { - return Ok(None); - }; - let (program, pre_args) = - package_binary_invocation(&metadata, "corepack", &metadata.platform.node).await?; - Ok(Some(CorepackInvocation { program, pre_args })) -} - -/// Check whether the args invoke `corepack enable`/`corepack disable` -/// (the commands that create or remove launchers in the install directory). -/// -/// Everything after a `--` separator is positional (package-manager names), -/// so the subcommand and help flags are only looked for before it. -fn is_corepack_link_command(args: &[String]) -> bool { - // Help output doesn't touch link files; run it as-is. - if crate::help::has_help_flag_before_terminator(args) { - return false; - } - let subcommand = args.iter().take_while(|arg| *arg != "--").find(|arg| !arg.starts_with('-')); - matches!(subcommand.map(String::as_str), Some("enable" | "disable")) -} - -/// Return the user args for an intercepted `corepack enable`/`disable` run, -/// injecting `--install-directory ` when not explicitly set so the -/// created launchers land on PATH. The flag is inserted before any `--` -/// separator; tokens after it are package-manager names. -fn inject_install_directory(args: &[String], bin_dir: &AbsolutePath) -> Vec { - let mut rewritten = args.to_vec(); - let has_install_directory = args - .iter() - .take_while(|arg| *arg != "--") - .any(|arg| arg == "--install-directory" || arg.starts_with("--install-directory=")); - if !has_install_directory { - let insert_at = args.iter().position(|arg| arg == "--").unwrap_or(args.len()); - rewritten.insert(insert_at, bin_dir.as_path().display().to_string()); - rewritten.insert(insert_at, "--install-directory".to_string()); - } - rewritten -} - -/// A Vite+-owned bin entry that was intact before corepack ran. -enum OwnedShim { - /// Default shim (npm/npx) — always belongs to Vite+. - Core { name: &'static str }, - /// Binary installed via `vp install -g` (BinConfig source `vp`). - Package { bin_config: BinConfig }, - /// Direct link created by the `npm install -g` interception - /// (BinConfig source `npm`). `source` is the link target captured at - /// snapshot time (Unix); when unavailable the restore falls back to the - /// managed Node.js layout via `locate_tool`. - NpmLink { bin_config: BinConfig, source: Option }, -} - -/// Snapshot which Vite+-owned shims among the corepack-managed launcher -/// names are intact before corepack runs. Only entries in this snapshot are -/// candidates for restoration, so shims the user removed on purpose are not -/// resurrected and untouched entries produce no spurious warnings. -/// -/// Default shims already replaced by a corepack launcher (e.g. a previous -/// interrupted run) are included too, so the restore self-heals them. -async fn snapshot_vp_owned_shims(bin_dir: &AbsolutePath) -> Vec { - let mut owned = Vec::new(); - for name in COREPACK_MANAGED_BIN_NAMES { - if setup::SHIM_TOOLS.contains(name) { - if core_shim_intact(bin_dir, name).await - || corepack_launcher_present(bin_dir, name).await - { - owned.push(OwnedShim::Core { name }); - } - continue; - } - let bin_config = match BinConfig::load(name).await { - Ok(Some(config)) => config, - Ok(None) => continue, - Err(e) => { - tracing::warn!("Skipping shim snapshot for '{}': {}", name, e); - continue; - } - }; - match bin_config.source { - BinSource::Vp => { - if is_vp_shim(bin_dir, name).await { - owned.push(OwnedShim::Package { bin_config }); - } - } - BinSource::Npm => { - if npm_link_intact(bin_dir, name).await { - let source = npm_link_source(bin_dir, name).await; - owned.push(OwnedShim::NpmLink { bin_config, source }); - } - } - } - } - owned -} - -/// Restore Vite+-owned shims that corepack `enable`/`disable` removed or -/// replaced, based on the pre-invocation snapshot. -async fn restore_vp_owned_shims(bin_dir: &AbsolutePath, owned_shims: &[OwnedShim]) { - // Resolved through the shim symlink chain so recreated shims point at the - // real vp binary, not at this process's `corepack` shim path. A failure - // only disables core-shim restores; package and npm-link restores below - // don't need the executable path. - let resolved_exe = if owned_shims.iter().any(|shim| matches!(shim, OwnedShim::Core { .. })) { - match std::env::current_exe() { - Ok(exe) => Some(tokio::fs::canonicalize(&exe).await.unwrap_or(exe)), - Err(e) => { - tracing::warn!("Cannot resolve the current executable for shim restore: {}", e); - None - } - } - } else { - None - }; - - for shim in owned_shims { - match shim { - OwnedShim::Core { name } => { - if core_shim_intact(bin_dir, name).await { - continue; - } - let Some(exe) = &resolved_exe else { continue }; - let _ = tokio::fs::remove_file(bin_dir.join(name)).await; - match setup::create_shim(exe, bin_dir, name, false).await { - Ok(_) => output::warn(&format!( - "'{name}' is managed by Vite+ and was restored. Vite+ already resolves \ - '{name}' per project, so corepack does not need to manage it." - )), - Err(e) => tracing::warn!("Failed to restore '{}' shim: {}", name, e), - } - // Remove corepack's extensionless/cmd/ps1 launchers that would - // shadow the trampoline .exe in Git Bash or PowerShell. - #[cfg(windows)] - setup::cleanup_legacy_windows_shim(bin_dir, name).await; - } - OwnedShim::Package { bin_config } => { - let name = bin_config.name.as_str(); - if is_vp_shim(bin_dir, name).await { - continue; - } - match crate::commands::global::install::create_package_shim( - bin_dir, - name, - &bin_config.package, - ) - .await - { - Ok(()) => output::warn(&format!( - "'{name}' is managed by `vp install -g {pkg}` and was restored. \ - Run `vp remove -g {pkg}` first to let corepack manage '{name}'.", - pkg = bin_config.package - )), - Err(e) => tracing::warn!("Failed to restore '{}' shim: {}", name, e), - } - } - OwnedShim::NpmLink { bin_config, source } => { - let name = bin_config.name.as_str(); - if npm_link_intact(bin_dir, name).await { - continue; - } - // Prefer the captured original target; fall back to the - // managed Node.js layout (validated per-OS by locate_tool). - let source_path = match source { - Some(path) => path.clone(), - None => match locate_tool(&bin_config.node_version, name) { - Ok(path) => path, - Err(e) => { - output::warn(&format!( - "'{name}' was linked by `npm install -g {pkg}` and removed by \ - corepack, but its source could not be located ({e}). \ - Run `npm install -g {pkg}` to recreate it.", - pkg = bin_config.package - )); - continue; - } - }, - }; - output::warn(&format!( - "'{name}' was linked by `npm install -g {pkg}`; restoring the link.", - pkg = bin_config.package - )); - let _ = tokio::fs::remove_file(bin_dir.join(name)).await; - // Remove corepack's launcher files (.cmd/.ps1/extensionless) - // so they cannot shadow the rewritten link. - #[cfg(windows)] - setup::cleanup_legacy_windows_shim(bin_dir, name).await; - create_bin_link( - bin_dir, - name, - &source_path, - &bin_config.package, - &bin_config.node_version, - ); - } - } - } -} - -/// Check whether a default shim (npm/npx) is still an intact Vite+ shim. -/// -/// Vite+ shims always link to the vp binary (relative `../current/bin/vp` or -/// an absolute path in dev layouts); corepack launchers link to corepack's -/// `dist/*.js` files. Broken symlinks count as not intact. -#[cfg(unix)] -async fn core_shim_intact(bin_dir: &AbsolutePath, name: &str) -> bool { - let shim_path = bin_dir.join(name); - match tokio::fs::read_link(&shim_path).await { - Ok(target) => { - target.file_name().is_some_and(|file_name| file_name == "vp") - && std::fs::exists(shim_path.as_path()).unwrap_or(false) - } - Err(_) => false, - } -} - -/// Check whether a default shim (npm/npx) is still an intact Vite+ shim. -#[cfg(windows)] -async fn core_shim_intact(bin_dir: &AbsolutePath, name: &str) -> bool { - is_vp_shim(bin_dir, name).await -} - -/// Check whether the bin entry currently holds a corepack launcher (the shape -/// corepack `enable` writes). Used to self-heal default shims clobbered by a -/// previous run that never reached its restore step: present launchers are -/// snapshotted as Vite+-owned, while an absent entry (deliberately removed by -/// the user) is not. -#[cfg(unix)] -async fn corepack_launcher_present(bin_dir: &AbsolutePath, name: &str) -> bool { - match tokio::fs::read_link(bin_dir.join(name)).await { - // corepack launchers are symlinks to corepack's dist/.js - Ok(target) => { - target.extension().is_some_and(|extension| extension == "js" || extension == "cjs") - } - Err(_) => false, - } -} - -/// Check whether the bin entry currently holds a corepack launcher. -/// -/// corepack's cmd-shim writes `.cmd`/`.ps1`/extensionless wrappers; Vite+ -/// never creates those for default shim names. -#[cfg(windows)] -async fn corepack_launcher_present(bin_dir: &AbsolutePath, name: &str) -> bool { - let cmd_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.cmd"))).await.unwrap_or(false); - let ps1_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.ps1"))).await.unwrap_or(false); - let sh_exists = tokio::fs::try_exists(&bin_dir.join(name)).await.unwrap_or(false); - cmd_exists || ps1_exists || sh_exists -} - -/// Capture the current target of an npm-interception link so the restore can -/// recreate it exactly (it may point at a custom npm prefix, not the managed -/// Node.js directory). -#[cfg(unix)] -async fn npm_link_source(bin_dir: &AbsolutePath, name: &str) -> Option { - let target = tokio::fs::read_link(bin_dir.join(name)).await.ok()?; - AbsolutePathBuf::new(target) -} - -/// Capture the current target of an npm-interception link. -/// -/// On Windows the link is a `.cmd` wrapper whose target is embedded in its -/// body; reconstructing it is not worth the parsing, so the restore falls -/// back to the managed Node.js layout via `locate_tool`. -#[cfg(windows)] -async fn npm_link_source(_bin_dir: &AbsolutePath, _name: &str) -> Option { - None -} - -/// Check whether the bin entry is an intact Vite+ package shim. -#[cfg(unix)] -async fn is_vp_shim(bin_dir: &AbsolutePath, name: &str) -> bool { - let shim_path = bin_dir.join(name); - match tokio::fs::read_link(&shim_path).await { - Ok(target) => crate::commands::global::install::is_vp_shim_target(&target, &shim_path), - Err(_) => false, - } -} - -/// Check whether the bin entry is an intact Vite+ package shim. -/// -/// Trampoline shims are `.exe` files; corepack's cmd-shim launchers are -/// `.cmd`/`.ps1`/extensionless files that shadow the trampoline in Git Bash -/// (extensionless) and PowerShell (`.ps1`), so any of them present means the -/// shim needs restoring. -#[cfg(windows)] -async fn is_vp_shim(bin_dir: &AbsolutePath, name: &str) -> bool { - let exe_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.exe"))).await.unwrap_or(false); - let cmd_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.cmd"))).await.unwrap_or(false); - let ps1_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.ps1"))).await.unwrap_or(false); - let sh_exists = tokio::fs::try_exists(&bin_dir.join(name)).await.unwrap_or(false); - exe_exists && !cmd_exists && !ps1_exists && !sh_exists -} - -/// Check whether a link created by the `npm install -g` interception is -/// still intact. -/// -/// On Unix these are symlinks pointing at the binary of the same name in a -/// managed Node.js bin directory; corepack launchers point at `dist/*.js` -/// files instead. -#[cfg(unix)] -async fn npm_link_intact(bin_dir: &AbsolutePath, name: &str) -> bool { - let link_path = bin_dir.join(name); - match tokio::fs::read_link(&link_path).await { - Ok(target) => { - target.file_name().is_some_and(|file_name| file_name == name) - && std::fs::exists(link_path.as_path()).unwrap_or(false) - } - Err(_) => false, - } -} - -/// Check whether a link created by the `npm install -g` interception is -/// still intact. -/// -/// On Windows npm links are `.cmd` wrappers with a fixed three-line shape -/// (see `create_bin_link`). corepack also writes `.cmd` launchers, so the -/// content is checked to detect an overwritten (not just deleted) link. -#[cfg(windows)] -async fn npm_link_intact(bin_dir: &AbsolutePath, name: &str) -> bool { - match tokio::fs::read_to_string(&bin_dir.join(format!("{name}.cmd"))).await { - Ok(content) => is_npm_link_wrapper(&content), - Err(_) => false, - } -} - -/// Whether `.cmd` content matches vp's npm-link wrapper shape written by -/// `create_bin_link`: `@echo off`, a quoted source invocation forwarding all -/// args, and the exit-code forward. corepack's cmd-shim launchers have a -/// different, multi-branch shape. -#[cfg(any(windows, test))] -fn is_npm_link_wrapper(content: &str) -> bool { - let mut lines = content.lines(); - lines.next() == Some("@echo off") - && lines.next().is_some_and(|line| line.starts_with('"') && line.ends_with("\" %*")) - && lines.next() == Some("exit /b %ERRORLEVEL%") - && lines.next().is_none() -} - -#[cfg(test)] -mod tests { - use vt_path::AbsolutePathBuf; - - use super::*; - - fn bin_dir() -> AbsolutePathBuf { - #[cfg(windows)] - { - AbsolutePathBuf::new(std::path::PathBuf::from("C:\\Users\\test\\.vite-plus\\bin")) - .unwrap() - } - #[cfg(not(windows))] - { - AbsolutePathBuf::new(std::path::PathBuf::from("/home/test/.vite-plus/bin")).unwrap() - } - } - - fn s(strs: &[&str]) -> Vec { - strs.iter().map(ToString::to_string).collect() - } - - #[test] - fn test_is_corepack_link_command() { - assert!(is_corepack_link_command(&s(&["enable"]))); - assert!(is_corepack_link_command(&s(&["disable", "yarn"]))); - assert!(is_corepack_link_command(&s(&["enable", "--install-directory", "/custom"]))); - assert!(is_corepack_link_command(&s(&["enable", "--", "pnpm"]))); - - assert!(!is_corepack_link_command(&s(&[]))); - assert!(!is_corepack_link_command(&s(&["--version"]))); - assert!(!is_corepack_link_command(&s(&["use", "pnpm@9"]))); - assert!(!is_corepack_link_command(&s(&["pnpm", "install"]))); - assert!(!is_corepack_link_command(&s(&["up"]))); - - // Everything after `--` is positional, not a subcommand - assert!(!is_corepack_link_command(&s(&["--", "enable"]))); - - // Help output doesn't touch link files - assert!(!is_corepack_link_command(&s(&["enable", "--help"]))); - assert!(!is_corepack_link_command(&s(&["enable", "-h"]))); - } - - #[test] - fn test_inject_install_directory_appends_when_missing() { - let bin_dir = bin_dir(); - let rewritten = inject_install_directory(&s(&["enable"]), &bin_dir); - assert_eq!( - rewritten, - s(&["enable", "--install-directory", &bin_dir.as_path().display().to_string()]) - ); - - let rewritten = inject_install_directory(&s(&["disable", "yarn"]), &bin_dir); - assert_eq!( - rewritten, - s(&[ - "disable", - "yarn", - "--install-directory", - &bin_dir.as_path().display().to_string() - ]) - ); - } - - #[test] - fn test_inject_install_directory_keeps_explicit_value() { - let bin_dir = bin_dir(); - let args = s(&["enable", "--install-directory", "/custom/dir"]); - assert_eq!(inject_install_directory(&args, &bin_dir), args); - - let args = s(&["enable", "--install-directory=/custom/dir"]); - assert_eq!(inject_install_directory(&args, &bin_dir), args); - } - - #[test] - fn test_is_npm_link_wrapper_shape() { - // vp's wrapper as written by create_bin_link - let wrapper = "@echo off\r\n\"C:\\vp\\node\\pnpm.cmd\" %*\r\nexit /b %ERRORLEVEL%\r\n"; - assert!(is_npm_link_wrapper(wrapper)); - - // corepack cmd-shim output is multi-branch and must not match - let corepack = "@SETLOCAL\r\n@IF EXIST \"%~dp0\\node.exe\" (\r\n ...\r\n)\r\n"; - assert!(!is_npm_link_wrapper(corepack)); - assert!(!is_npm_link_wrapper("")); - assert!(!is_npm_link_wrapper("@echo off\r\nsomething else\r\n")); - } - - #[test] - fn test_inject_install_directory_inserts_before_separator() { - let bin_dir = bin_dir(); - let dir = bin_dir.as_path().display().to_string(); - - // The injected flag must precede `--`; tokens after it are - // package-manager names. - let rewritten = inject_install_directory(&s(&["enable", "--", "pnpm"]), &bin_dir); - assert_eq!(rewritten, s(&["enable", "--install-directory", &dir, "--", "pnpm"])); - - // An --install-directory after `--` is a positional, not the flag - let rewritten = - inject_install_directory(&s(&["enable", "--", "--install-directory"]), &bin_dir); - assert_eq!( - rewritten, - s(&["enable", "--install-directory", &dir, "--", "--install-directory"]) - ); - } -} diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index f072073a74..f538d16d86 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -3,12 +3,9 @@ //! This module handles the core shim functionality: //! 1. Version resolution (with caching) //! 2. Node.js installation (if needed) -//! 3. Tool execution (core tools and package binaries) +//! 3. Tool execution (core shims and package binaries) -use vp_pm_cli::{ - PackageManagerType, download_package_manager, package_manager_bin_path, - package_manager_install_dir, 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}; @@ -21,6 +18,7 @@ use crate::{ env::{ bin_config::{BinConfig, BinSource}, config::{self, ShimMode}, + package_manager, package_metadata::PackageMetadata, }, global::install::is_protected_shim, @@ -34,16 +32,6 @@ use crate::{ /// directly using the current PATH (passthrough mode). const RECURSION_ENV_VAR: &str = env_vars::VP_TOOL_RECURSION; -/// Package-manager tools whose Node.js runtime should be resolved from the -/// project context rather than the install-time version. -/// -/// Intentionally excludes `npm`/`npx`: those are core shims (see -/// `is_core_shim_tool`) and never reach `dispatch_package_binary`, so they are -/// handled by the main `dispatch` path instead. -fn is_package_manager_tool(tool: &str) -> bool { - matches!(PackageManagerType::from_tool(tool), Some(t) if t != PackageManagerType::Npm) -} - /// Parsed npm global command (install or uninstall). struct NpmGlobalCommand { /// Package names/specs extracted from args (e.g., ["codex", "typescript@5"]) @@ -262,18 +250,13 @@ fn check_npm_global_install_result( for bin_name in bin_names { // Skip protected shims (core shims and default env shims). Tell - // the user for the non-core names (e.g. `npm i -g corepack`): - // npm installed the package, but the binary stays unlinked. + // 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()) { - let hint = if bin_name == "corepack" { - " Use `vp install -g corepack` to manage its version." - } else { - "" - }; output::note(&vt_str::format!( "'{bin_name}' is a Vite+ default shim; the npm-installed copy is not \ - linked.{hint}" + linked." )); } continue; @@ -521,9 +504,8 @@ fn remove_npm_global_uninstall_links(bin_entries: &[(String, String)], npm_prefi let Ok(bin_dir) = config::get_bin_dir() else { return }; for (bin_name, package_name) in bin_entries { - // Skip protected shims: a stale Npm BinConfig (e.g. a pre-default-shim - // `npm install -g corepack`) must not let `npm uninstall -g` delete a - // default shim that `vp env setup` now owns. + // Skip protected shims: a stale Npm BinConfig must not let + // `npm uninstall -g` delete a default shim that `vp env setup` owns. if is_protected_shim(bin_name, false) { continue; } @@ -664,12 +646,12 @@ fn resolve_npm_prefix( get_npm_global_prefix(npm_path, node_dir) } -/// Resolve a matching package-manager binary from the current project's explicit -/// `packageManager` field. +/// Resolve the package-manager binary for a core shim. /// -/// The match is intentionally strict to avoid translating commands: `npm` only uses -/// `npm@...`, `pnpm` only uses `pnpm@...`, etc. -async fn resolve_matching_package_manager_tool( +/// A project pin applies only to its matching manager. npm otherwise comes +/// from the selected Node.js installation; standalone managers use their +/// latest release when no matching pin exists. +async fn resolve_package_manager_tool( cwd: &AbsolutePath, tool: &str, ) -> Result, Error> { @@ -677,33 +659,17 @@ async fn resolve_matching_package_manager_tool( 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, 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), }; - if resolution.package_manager_type != expected_type { - return Ok(None); - } - let bin_name = expected_type.bin_name_for_tool(tool); - - // Fast path: if the managed install already exists, skip download_package_manager - // entirely. The slow path stats three files (`bin`, `.cmd`, `.ps1`) on every - // invocation, which adds up on the shim hot path. - if let Some(install_dir) = package_manager_install_dir(expected_type, &resolution.version) { - let bin_path = package_manager_bin_path(&install_dir, bin_name); - if bin_path.as_path().exists() { - return Ok(Some(bin_path)); - } - } - - let (install_dir, _, _) = download_package_manager( - resolution.package_manager_type, - &resolution.version, - resolution.hash.as_deref(), - ) - .await?; - Ok(Some(package_manager_bin_path(&install_dir, bin_name))) + Ok(Some(ensure_package_manager_bin(expected_type, &version, hash.as_deref(), bin_name).await?)) } async fn prepend_js_child_process_path_env( @@ -712,7 +678,7 @@ async fn prepend_js_child_process_path_env( ) -> Result<(), Error> { let _ = prepend_to_path_env(node_bin_dir, PrependOptions::default()); - let Some(npm_path) = resolve_matching_package_manager_tool(cwd, "npm").await? else { + let Some(npm_path) = resolve_package_manager_tool(cwd, "npm").await? else { return Ok(()); }; if let Some(pm_bin_dir) = npm_path.parent() @@ -725,8 +691,8 @@ async fn prepend_js_child_process_path_env( /// Main shim dispatch entry point. /// -/// Called when the binary is invoked as node, npm, npx, corepack, or a -/// package binary. Returns an exit code to be used with std::process::exit. +/// Called when the binary is invoked as a core shim or package binary. +/// Returns an exit code to be used with std::process::exit. pub async fn dispatch(tool: &str, args: &[String]) -> i32 { tracing::debug!("dispatch: tool: {tool}, args: {:?}", args); @@ -755,7 +721,7 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } // Check recursion prevention - if already in a shim context, passthrough directly - // Only applies to core tools (node/npm/npx) whose bin dir is prepended to PATH. + // Only applies to core tools whose bin dir is prepended to PATH. // Package binaries are always resolved via metadata lookup, so they can't loop. if std::env::var(RECURSION_ENV_VAR).is_ok() && is_core_shim_tool(tool) { tracing::debug!("recursion prevention enabled for core tool"); @@ -769,11 +735,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. @@ -796,16 +768,8 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Fall through to managed if system not found } - // corepack: dedicated resolution chain (vp-managed package → Node-bundled - // → auto-install), see shim::corepack. Intentionally placed after the - // bypass/system-first checks and outside the recursion passthrough so it - // always re-resolves (corepack may not exist on the prepended PATH at all - // with Node.js 25+). - if tool == "corepack" { - return super::corepack::dispatch_corepack(args).await; - } - - // Check if this is a package binary (not node/npm/npx) + // Package binaries use their install-time Node.js version; core shims use + // the project-resolved runtime below. if !is_core_shim_tool(tool) { return dispatch_package_binary(tool, args).await; } @@ -840,25 +804,21 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } }; - // Locate tool binary. If the current project explicitly pins the invoked - // package manager in `packageManager`, prefer that managed package-manager - // binary over the tool bundled with Node.js. - let package_manager_tool_path = match resolve_matching_package_manager_tool(&cwd, tool).await { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); - return 1; - } - }; - let tool_path = match package_manager_tool_path { - Some(path) => path, - None => match locate_tool(&resolution.version, tool) { - Ok(p) => p, + // Package managers use a matching project pin or their family-specific + // 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; } }, + Err(e) => { + eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); + return 1; + } }; // Save original PATH before we modify it - needed for npm global install check. @@ -866,9 +826,8 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { let original_path = if tool == "npm" { std::env::var_os("PATH") } else { None }; // Prepare environment for recursive invocations. Keep the project Node.js - // bin dir available for JS package-manager shims, and when a package-manager - // version was selected from `packageManager`, put that PM bin dir first so - // nested invocations see the same PM version while recursion prevention is set. + // bin dir available for JS package-manager shims, and put a separately + // installed PM bin dir first so nested invocations see the same PM version. let node_bin_dir = node_path.parent().expect("Node has no parent directory"); if let Err(e) = prepend_js_child_process_path_env(&cwd, node_bin_dir).await { eprintln!("vp: Failed to resolve package manager for child process PATH: {e}"); @@ -941,70 +900,31 @@ 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 /// Node.js version that was used to install the package. async fn dispatch_package_binary(tool: &str, args: &[String]) -> i32 { - if PackageManagerType::from_tool(tool).is_some() { - let cwd = match current_dir() { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Failed to get current directory: {e}"); - return 1; - } - }; - - match resolve_matching_package_manager_tool(&cwd, tool).await { - Ok(Some(tool_path)) => { - let node_version = match resolve_with_cache(&cwd).await { - Ok(resolution) => resolution.version, - Err(_) => match find_package_for_binary(tool).await { - Ok(Some(metadata)) => metadata.platform.node, - _ => String::new(), - }, - }; - - if !node_version.is_empty() { - match ensure_installed(&node_version).await { - Ok(node_path) => { - if let Some(node_bin_dir) = node_path.parent() { - if let Err(e) = - prepend_js_child_process_path_env(&cwd, node_bin_dir).await - { - eprintln!( - "vp: Failed to resolve package manager for child \ - process PATH: {e}" - ); - return 1; - } - } - } - Err(e) => { - eprintln!("vp: Failed to install Node {}: {e}", node_version); - return 1; - } - } - } - - if let Some(pm_bin_dir) = tool_path.parent() { - let _ = prepend_to_path_env(pm_bin_dir, PrependOptions::default()); - } - - // SAFETY: Setting env vars at this point before exec is safe - unsafe { - std::env::set_var(RECURSION_ENV_VAR, "1"); - } - return exec::exec_tool(&tool_path, args); - } - Ok(None) => {} - Err(e) => { - eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); - return 1; - } - } - } - // Find which package provides this binary let package_metadata = match find_package_for_binary(tool).await { Ok(Some(metadata)) => metadata, @@ -1019,31 +939,10 @@ async fn dispatch_package_binary(tool: &str, args: &[String]) -> i32 { } }; - // Determine Node.js version to use: - // - Package managers (pnpm, yarn): resolve from project context so they respect - // the project's engines.node / .node-version, falling back to install-time version - // - Other package binaries: use the install-time version (original behavior) - let node_version = if is_package_manager_tool(tool) { - let cwd = match current_dir() { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Failed to get current directory: {e}"); - return 1; - } - }; - match resolve_with_cache(&cwd).await { - Ok(resolution) => resolution.version, - Err(_) => { - // Fall back to install-time version if project resolution fails - package_metadata.platform.node.clone() - } - } - } else { - package_metadata.platform.node.clone() - }; - let (program, mut full_args) = - match package_binary_invocation(&package_metadata, tool, &node_version).await { + match package_binary_invocation(&package_metadata, tool, &package_metadata.platform.node) + .await + { Ok(invocation) => invocation, Err(e) => { eprintln!("vp: {e}"); @@ -1352,8 +1251,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_global_cli/src/shim/mod.rs b/crates/vp_global_cli/src/shim/mod.rs index c6f8a5a977..10b58551be 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -1,8 +1,7 @@ -//! Shim module for intercepting node, npm, npx, corepack, and package binary commands. +//! Shim module for intercepting Node.js, package-manager, and package binary commands. //! //! This module provides the functionality for the vp binary to act as a shim -//! when invoked as `node`, `npm`, `npx`, `corepack`, or any globally installed -//! package binary. +//! when invoked as a managed tool or any globally installed package binary. //! //! Detection methods: //! - Unix: Symlinks to vp binary preserve argv[0], allowing tool detection @@ -10,7 +9,6 @@ //! - Legacy: `.cmd` wrappers call `vp env exec ` directly (deprecated) mod cache; -pub(crate) mod corepack; pub(crate) mod dispatch; pub(crate) mod exec; @@ -23,13 +21,9 @@ use vp_shared::env_vars; use crate::commands::env::config::get_bin_dir; -/// Core shim tools (node, npm, npx). -/// -/// `corepack` is also a default shim (see `commands::env::setup::SHIM_TOOLS`) -/// but is intentionally not a core tool: it is not always bundled with the -/// resolved Node.js version (removed in Node.js 25+), so it has a dedicated -/// dispatch path with a managed fallback and never uses recursion passthrough. -pub const CORE_SHIM_TOOLS: &[&str] = &["node", "npm", "npx"]; +/// 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"]; /// Extract the tool name from argv[0]. /// We hope all bins should be put under $VP_HOME/bin @@ -70,7 +64,7 @@ pub fn extract_tool_name(argv0: &str) -> String { } } -/// Check if the given tool name is a core shim tool (node/npm/npx). +/// 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) @@ -79,7 +73,7 @@ pub fn is_core_shim_tool(tool: &str) -> bool { /// Check if the given tool name is a shim tool (core or package binary). /// /// This is a quick check that returns true if: -/// 1. The tool is a core shim (node/npm/npx), OR +/// 1. The tool is a core shim, OR /// 2. The tool name is not "vp" (package binaries are detected later via metadata) #[must_use] pub fn is_shim_tool(tool: &str) -> bool { @@ -217,27 +211,6 @@ mod tests { } } - #[test] - fn test_is_shim_tool() { - // Core shim tools are always recognized - assert!(is_core_shim_tool("node")); - assert!(is_core_shim_tool("npm")); - assert!(is_core_shim_tool("npx")); - assert!(!is_core_shim_tool("yarn")); // yarn is not a core shim tool - assert!(!is_core_shim_tool("vp")); - assert!(!is_core_shim_tool("cargo")); - assert!(!is_core_shim_tool("tsc")); // Package binary, not core - // corepack is a default shim but intentionally not a core tool: - // it has a dedicated dispatch path and never uses recursion passthrough - assert!(!is_core_shim_tool("corepack")); - - // is_shim_tool includes core tools - assert!(is_shim_tool("node")); - assert!(is_shim_tool("npm")); - assert!(is_shim_tool("npx")); - assert!(!is_shim_tool("vp")); // vp is never a shim - } - /// Test that is_potential_package_binary checks the configured bin directory. /// /// The function now checks if a shim exists in the configured bin directory diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index b38e19867b..772ad45d11 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -10,6 +10,7 @@ use vt_path::AbsolutePath; use crate::{ PackageManager, PackageManagerType, cli::{PackageManagerCommand, PmCommand}, + download_package_manager, error::Error, helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, resolution::{DlxArgs, StageCommand, run_resolution}, @@ -62,6 +63,72 @@ pub async fn dispatch_with_metadata( Ok(DispatchResult { status, package_manager }) } +pub async fn dispatch_with_package_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + package_manager: Option<(PackageManagerType, &str, Option<&str>)>, +) -> Result { + let render_diagnostics = command.should_render_diagnostics(); + let command = match command { + PackageManagerCommand::Dlx(args) => { + if let Some(package_manager) = package_manager { + let manager = build_selected_package_manager(package_manager).await?; + let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; + return run_resolution(cwd, resolution, render_diagnostics).await; + } + return Ok(dispatch_dlx(cwd, args, render_diagnostics).await?.status); + } + command => command, + }; + + let manager = if let Some(package_manager) = package_manager { + if manager_policy(&command) == ManagerPolicy::CreateIfMissing { + ensure_package_json(cwd).await?; + } + build_selected_package_manager(package_manager).await? + } else { + 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 resolution = command.resolve_for_manager(&manager)?; + run_resolution(cwd, resolution, render_diagnostics).await +} + +pub async fn dispatch_with_resolved_package_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + manager: PackageManager, +) -> Result { + let render_diagnostics = command.should_render_diagnostics(); + let command = match command { + PackageManagerCommand::Dlx(args) => { + let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; + return run_resolution(cwd, resolution, render_diagnostics).await; + } + command => command, + }; + if manager_policy(&command) == ManagerPolicy::CreateIfMissing { + ensure_package_json(cwd).await?; + } + let resolution = command.resolve_for_manager(&manager)?; + run_resolution(cwd, resolution, render_diagnostics).await +} + +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/error.rs b/crates/vp_pm_cli/src/error.rs index eb8c6eec62..11de599488 100644 --- a/crates/vp_pm_cli/src/error.rs +++ b/crates/vp_pm_cli/src/error.rs @@ -30,7 +30,4 @@ pub enum Error { /// User-facing message printed without the "Error: " prefix. #[error("{0}")] UserMessage(Str), - - #[error("{0}")] - Other(Str), } diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index de091c6f2a..fe0ec60df7 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -19,13 +19,18 @@ 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, get_package_manager_type_and_version, - package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_from_package_json, + 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; pub use resolution::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index adc63f752d..5e2c068209 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -6,6 +6,7 @@ use std::{ fs::{self, File}, io::{self, BufReader, Write}, path::{Path, PathBuf}, + time::Duration, }; use crossterm::{ @@ -105,6 +106,16 @@ impl PackageManagerType { (_, Self::Bun) => "bun", } } + + #[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"], + } + } } /// Package-manager resolution from an explicit project `packageManager` field. @@ -118,6 +129,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 { @@ -131,6 +152,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`. @@ -210,6 +243,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") @@ -370,6 +412,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( @@ -388,6 +532,27 @@ pub fn package_manager_bin_path(install_dir: &AbsolutePath, bin_name: &str) -> A if cfg!(windows) { bin_path.with_extension("cmd") } else { bin_path } } +/// Return a managed package-manager binary, downloading its release when needed. +pub async fn ensure_package_manager_bin( + package_manager_type: PackageManagerType, + version_or_latest: &str, + expected_hash: Option<&str>, + bin_name: &str, +) -> Result { + let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; + + if let Some(install_dir) = package_manager_install_dir(package_manager_type, &version) { + let bin_path = package_manager_bin_path(&install_dir, bin_name); + if bin_path.as_path().exists() { + return Ok(bin_path); + } + } + + let (install_dir, _, _) = + download_package_manager(package_manager_type, &version, expected_hash).await?; + Ok(package_manager_bin_path(&install_dir, bin_name)) +} + fn get_package_manager_from_package_json( workspace_root: &WorkspaceRoot, ) -> Result, Error> { @@ -645,7 +810,43 @@ fn find_extracted_package_dir(target_dir: &Path) -> io::Result { )) } +const LATEST_VERSION_CACHE_TTL: Duration = Duration::from_secs(3600); + +fn latest_version_cache_path( + package_manager_type: PackageManagerType, +) -> io::Result { + Ok(vp_shared::get_vp_home()? + .join("cache") + .join("package_manager_latest") + .join(package_manager_type.to_string())) +} + +fn read_latest_version_cache(path: &AbsolutePath) -> Option<(Str, bool)> { + let version = fs::read_to_string(path).ok()?; + let version = version.trim(); + Version::parse(version).ok()?; + let is_fresh = fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < LATEST_VERSION_CACHE_TTL); + Some((version.into(), is_fresh)) +} + +fn write_latest_version_cache(path: &AbsolutePath, version: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, version) +} + async fn get_latest_version(package_manager_type: PackageManagerType) -> Result { + let cache_path = latest_version_cache_path(package_manager_type)?; + let cached = read_latest_version_cache(&cache_path); + if let Some((version, true)) = &cached { + return Ok(version.clone()); + } + let package_name = if matches!(package_manager_type, PackageManagerType::Yarn) { // yarn latest version should use `@yarnpkg/cli-dist` as package name "@yarnpkg/cli-dist".to_string() @@ -653,8 +854,34 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result< package_manager_type.to_string() }; let url = get_npm_package_version_url(&package_name, "latest"); - let package_json: PackageJson = HttpClient::new().get_json(&url).await?; - Ok(package_json.version) + match HttpClient::new().get_json::(&url).await { + Ok(package_json) => { + let _ = write_latest_version_cache(&cache_path, &package_json.version); + Ok(package_json.version) + } + Err(error) => { + let Some((version, _)) = cached else { return Err(error) }; + tracing::warn!( + "Failed to refresh latest {package_manager_type} version: {error}; using cached {}", + version + ); + Ok(version) + } + } +} + +/// 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. @@ -682,6 +909,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 @@ -811,15 +1051,7 @@ pub async fn download_package_manager( version_or_latest: &str, expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { - let version: Str = if version_or_latest == "latest" { - get_latest_version(package_manager_type).await? - } else if Version::parse(version_or_latest).is_ok() { - version_or_latest.into() - } else { - // semver range (e.g. from devEngines.packageManager): prefer an already - // downloaded satisfying version, otherwise resolve from the registry - resolve_package_manager_range(package_manager_type, version_or_latest).await? - }; + let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; // Reject anything that is not strict semver `major.minor.patch[-prerelease][+build]`. // This prevents path traversal via the version being interpolated into @@ -1699,7 +1931,7 @@ fn simple_text_prompt() -> Result { #[cfg(test)] mod tests { - use std::fs; + use std::{fs, time::UNIX_EPOCH}; use tempfile::{TempDir, tempdir}; use vp_shared::EnvConfig; @@ -1748,6 +1980,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). @@ -1806,6 +2095,63 @@ mod tests { assert_eq!(find_cached_pnpm(&vp_home), None); } + #[tokio::test(flavor = "current_thread")] + async fn test_latest_version_cache_refreshes_and_falls_back_after_expiry() { + use httpmock::prelude::*; + + let temp_dir = create_temp_dir(); + let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let server = MockServer::start(); + let mut first = server.mock(|when, then| { + when.method(GET).path("/bun/latest"); + then.status(200).json_body(serde_json::json!({ "version": "1.0.0" })); + }); + let _guard = EnvConfig::test_guard(EnvConfig { + npm_registry: server.base_url(), + vite_plus_home: Some(vp_home.as_path().to_path_buf()), + ..EnvConfig::for_test() + }); + + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "1.0.0" + ); + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "1.0.0" + ); + first.assert_hits(1); + + let cache_path = latest_version_cache_path(PackageManagerType::Bun).unwrap(); + let expire_cache = || { + fs::File::options() + .write(true) + .open(&cache_path) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(UNIX_EPOCH)) + .unwrap(); + }; + expire_cache(); + first.delete(); + + let mut refreshed = server.mock(|when, then| { + when.method(GET).path("/bun/latest"); + then.status(200).json_body(serde_json::json!({ "version": "2.0.0" })); + }); + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "2.0.0" + ); + refreshed.assert_hits(1); + + expire_cache(); + refreshed.delete(); + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "2.0.0" + ); + } + #[cfg(windows)] #[test] fn test_find_cached_package_manager_version_skips_missing_windows_shims() { diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index 6ff07cc398..b687409c7c 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -89,6 +89,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. /// /// Env: `HOME` (Unix) / `USERPROFILE` (Windows) @@ -118,6 +123,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: std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .ok() @@ -200,6 +206,7 @@ impl EnvConfig { is_ci: false, env_use_eval_enable: false, node_version: None, + package_manager: None, user_home: None, vp_shell: None, } diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 0588b56322..ebe17a39d3 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -34,6 +34,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 a1580348d5..af4c0a3ab8 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`. 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. By default, Vite+ stores its managed runtime and related files in `~/.vite-plus`. If needed, you can override that location with `VP_HOME`. @@ -29,7 +49,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: @@ -37,16 +62,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 shims in `VP_HOME/bin` (and writes the per-shell setup scripts under `VP_HOME`) -- `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 setup` creates or updates the `node`, `npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`, `bun`, `bunx`, `vpx`, and `vpr` shims in `VP_HOME/bin` (and writes the per-shell setup scripts under `VP_HOME`) +- `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: @@ -78,27 +102,27 @@ 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` under `VP_HOME/bin` on Windows. -In CI, `vp env use` can still run without shell initialization. It writes a temporary session file under `VP_HOME` so later shim calls in the same job can resolve the selected Node.js version. +In CI, `vp env use` can still run without shell initialization. It writes temporary Node.js and package-manager session files under `VP_HOME` so later shim calls in the same job 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, all downloaded package managers, and the Corepack cache. -- `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 @@ -110,50 +134,82 @@ In CI, `vp env use` can still run without shell initialization. It writes a temp ```bash # Setup -vp env setup # Create shims for node, npm, npx, corepack -vp env on # Use Vite+ managed Node.js -vp env print # Print shell snippet for this session +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 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 ``` -## Corepack - -Vite+ creates a `corepack` shim by default, so corepack works without a system Node.js installation: - -- On Node.js 24 and earlier, the shim runs the corepack bundled with the resolved Node.js version. -- On Node.js 25 and later, where corepack is no longer bundled, Vite+ installs corepack as a managed global package on first use. Only the `corepack` binary is linked; run `vp install -g corepack` yourself if you also want the package's pnpm/yarn launchers exposed directly. -- If you install corepack explicitly with `vp install -g corepack`, that installation is always preferred. - -`corepack enable` normally creates `pnpm`/`yarn` launchers next to the corepack binary, which under Vite+ would not be on `PATH`. The shim fixes this by defaulting `--install-directory` to `VP_HOME/bin`, so after `corepack enable` the launchers are available everywhere and still resolve the project's Node.js and package-manager versions: - -```bash -corepack enable # pnpm and yarn now resolve via corepack -corepack disable # Remove the pnpm/yarn launchers again +## 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" + } +} ``` -The launchers reference the corepack copy that created them. If that copy is later removed (for example by uninstalling the Node.js version it shipped with), rerun `corepack enable` to recreate them. +`list --json` and `list-remote --json` group the component arrays: + +```json +{ + "node": [], + "package_managers": { + "npm": [], + "pnpm": [], + "yarn": [], + "bun": [] + } +} +``` -Shims owned by Vite+ (`npm`, `npx`, and binaries installed with `vp install -g`) are protected: if corepack removes or replaces them, Vite+ restores them and prints a warning. +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 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 b4b087116b..c95167267b 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -93,6 +93,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 8d1f418426..994bac63a6 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 @@ -98,7 +98,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **config** - Configure hooks and agent integration - **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 c37507a314..e9e4a18c8e 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -671,7 +671,7 @@ function Refresh-Shims { } } -# Setup Node.js version manager (node/npm/npx/corepack shims) +# Setup Node.js version manager (node/npm/npx shims) # Returns: "true" = enabled, "false" = not enabled, "already" = already configured function Setup-NodeManager { param([string]$BinDir) @@ -717,7 +717,7 @@ function Setup-NodeManager { if ($isInteractive) { Write-Host "" Write-Host "Would you like Vite+ to manage your Node.js versions?" - Write-Host "It adds ``node``, ``npm``, ``npx``, and ``corepack`` shims to $NodeManagerBinDisplay and automatically uses the right version." + Write-Host "It adds ``node``, ``npm``, ``npx``, ``pnpm``, ``pnpx``, ``yarn``, ``yarnpkg``, ``bun``, and ``bunx`` shims to $NodeManagerBinDisplay." Write-Host "Opt out anytime with ``vp env off``." $response = Read-Host "Press Enter to accept (Y/n)" @@ -993,14 +993,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 5aa2244fab..4799a163cc 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -878,7 +878,7 @@ refresh_shims() { fi } -# Setup Node.js version manager (node/npm/npx/corepack shims) +# Setup Node.js version manager (node/npm/npx shims) # Sets NODE_MANAGER_ENABLED global # Arguments: bin_dir - path to the version's bin directory containing vp setup_node_manager() { @@ -937,7 +937,7 @@ setup_node_manager() { if [ -e /dev/tty ] && [ -t 1 ]; then echo "" echo "Would you like Vite+ to manage your Node.js versions?" - echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/ and automatically uses the right version." + echo "It adds \`node\`, \`npm\`, \`npx\`, \`pnpm\`, \`pnpx\`, \`yarn\`, \`yarnpkg\`, \`bun\`, and \`bunx\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/." echo "Opt out anytime with \`vp env off\`." echo -n "Press Enter to accept (Y/n): " read -r response < /dev/tty @@ -1216,13 +1216,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/docker-image.md b/rfcs/docker-image.md index 661627dca3..dff0a0e841 100644 --- a/rfcs/docker-image.md +++ b/rfcs/docker-image.md @@ -189,7 +189,7 @@ deployed images small. - **Preinstalled:** `vp` (on `PATH`), `ca-certificates`, `curl`, `git`, and a build toolchain (`build-essential`, `python3`, `pkg-config`) for native addon compilation (for example `better-sqlite3`). Package managers are handled by - vp's managed corepack/runtime, so they are provisioned per-project rather than + vp's package-manager support, so they are provisioned per-project rather than baked to a fixed version. - **No baked default Node.js:** the installer pre-provisions a default Node.js (~190 MB); the image drops it (`rm -rf $VP_HOME/js_runtime`) because each diff --git a/rfcs/env-command.md b/rfcs/env-command.md index 96c6cf55be..be88e98ec0 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -1,10 +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`, `npx`, and `corepack` 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. -> **Note**: The `corepack` shim was originally excluded because Vite+ has integrated package manager functionality. This was revisited in [#858](https://github.com/voidzero-dev/vite-plus/issues/858) and [#1309](https://github.com/voidzero-dev/vite-plus/issues/1309): users and scripts invoke `corepack`/`pnpm`/`yarn` directly, and without a system Node.js installation there is no reachable `corepack` at all. See [Corepack Shim](#corepack-shim). +## 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 @@ -23,7 +39,7 @@ This RFC proposes adding a `vp env` command that provides system-wide, IDE-safe A shim-based approach where: - `VP_HOME/bin/` directory is added to PATH (system-level for IDE reliability) -- Shims (`node`, `npm`, `npx`, `corepack`) are symlinks to the `vp` binary (Unix) or trampoline `.exe` files (Windows) +- Shims (`node`, `npm`, `npx`) are symlinks to the `vp` binary (Unix) or trampoline `.exe` files (Windows) - The `vp` CLI itself is also in `VP_HOME/bin/`, so users only need one PATH entry - The binary detects invocation via `argv[0]` and dispatches accordingly - Version resolution and installation leverage existing `vp_js_runtime` infrastructure @@ -184,7 +200,7 @@ vp env uninstall 20.18.0 vp env clean ``` -`vp env clean` removes all locally installed Node.js runtimes except the current resolved version and the configured default version. It also removes all downloaded Vite+ package-manager installs under `~/.vite-plus/package_manager` and runs `corepack cache clean` to clear Corepack-managed package-manager downloads. +`vp env clean` removes all locally installed Node.js runtimes except the current resolved version and the configured default version. It also removes all downloaded Vite+ package-manager installs under `~/.vite-plus/package_manager`. ### Global Package Commands @@ -226,7 +242,6 @@ vp update -g typescript # Update specific package node -v # Uses project-specific version npm install # Uses packageManager npm@ when explicitly configured, otherwise Node-bundled npm npx vitest # Uses packageManager npm@ when explicitly configured, otherwise Node-bundled npx -corepack enable # Uses Node-bundled or vp-managed corepack (see Corepack Shim) ``` Package-manager shims use `packageManager` only when the invoked command matches the configured manager or one of its generated aliases. For example, `packageManager: "npm@11.14.0"` makes the `npm` and `npx` shims run npm 11.14.0, while `packageManager: "pnpm@10.19.0"` does not turn `npm install` into `pnpm install`; `npm` falls back to the npm available through the resolved Node.js runtime. Alias pairs follow the package-manager download layout: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. @@ -242,7 +257,6 @@ argv[0] = "vp" → Normal CLI mode (vp env, vp build, etc.) argv[0] = "node" → Shim mode: resolve version, exec node argv[0] = "npm" → Shim mode: resolve version, exec npm argv[0] = "npx" → Shim mode: resolve version, exec npx -argv[0] = "corepack" → Shim mode: resolve version, exec corepack (managed fallback on Node 25+) ``` ### Architecture Diagram @@ -322,8 +336,7 @@ argv[0] = "corepack" → Shim mode: resolve version, exec corepack (managed fal │ │ ├── vp ────────────────────── Symlink to ../current/bin/vp │ │ │ ├── node ──────────────────────┐ │ │ │ ├── npm ──────────────────────┼──▶ Symlinks to ../current/bin/vp │ -│ │ ├── npx ──────────────────────┤ │ -│ │ └── corepack ──────────────────┘ │ +│ │ └── npx ──────────────────────┘ │ │ ├── current/bin/vp The actual vp CLI binary │ │ ├── js_runtime/node/ Node.js installations │ │ │ ├── 20.18.0/bin/node Installed Node.js versions │ @@ -370,13 +383,11 @@ VP_HOME/ # Default: ~/.vite-plus │ ├── node -> ../current/bin/vp # Symlink to vp binary (Unix) │ ├── npm -> ../current/bin/vp # Symlink to vp binary (Unix) │ ├── npx -> ../current/bin/vp # Symlink to vp binary (Unix) -│ ├── corepack -> ../current/bin/vp # Symlink to vp binary (Unix) │ ├── tsc -> ../current/bin/vp # Symlink for global package (Unix) │ ├── vp.exe # Trampoline forwarding to current\bin\vp.exe (Windows) │ ├── node.exe # Trampoline shim for node (Windows) │ ├── npm.exe # Trampoline shim for npm (Windows) │ ├── npx.exe # Trampoline shim for npx (Windows) -│ ├── corepack.exe # Trampoline shim for corepack (Windows) │ └── tsc.exe # Trampoline shim for global package (Windows) ├── current/ │ └── bin/ @@ -416,16 +427,16 @@ VP_HOME/ # Default: ~/.vite-plus **Key Directories:** -| Directory | Purpose | -| ------------------ | ---------------------------------------------------------------------------- | -| `bin/` | vp symlink and all shims (node, npm, npx, corepack, global package binaries) | -| `current/bin/` | The actual vp CLI binary (bin/ shims point here) | -| `js_runtime/node/` | Installed Node.js versions | -| `packages/` | Installed global packages with metadata | -| `bins/` | Per-binary config files (tracks which package owns each binary) | -| `shared/` | NODE_PATH symlinks for package require() resolution | -| `tmp/` | Staging area for atomic installations | -| `cache/` | Resolution cache | +| Directory | Purpose | +| ------------------ | ------------------------------------------------------------------ | +| `bin/` | vp symlink and all shims (node, npm, npx, global package binaries) | +| `current/bin/` | The actual vp CLI binary (bin/ shims point here) | +| `js_runtime/node/` | Installed Node.js versions | +| `packages/` | Installed global packages with metadata | +| `bins/` | Per-binary config files (tracks which package owns each binary) | +| `shared/` | NODE_PATH symlinks for package require() resolution | +| `tmp/` | Staging area for atomic installations | +| `cache/` | Resolution cache | ### config.json Format @@ -850,7 +861,7 @@ $ vp env doctor Installation ✓ VP_HOME ~/.vite-plus ✓ Bin directory exists - ✓ Shims node, npm, npx, corepack + ✓ Shims node, npm, npx Configuration ✓ Node.js mode managed @@ -928,7 +939,6 @@ Created shims: /Users/user/.vite-plus/bin/node /Users/user/.vite-plus/bin/npm /Users/user/.vite-plus/bin/npx - /Users/user/.vite-plus/bin/corepack Add to your shell profile (~/.zshrc, ~/.bashrc, etc.): @@ -949,7 +959,7 @@ $ vp env doctor Installation ✓ VP_HOME ~/.vite-plus ✓ Bin directory exists - ✓ Shims node, npm, npx, corepack + ✓ Shims node, npm, npx Configuration ✓ Node.js mode managed @@ -960,7 +970,6 @@ PATH ✓ node ~/.vite-plus/bin/node (vp shim) ✓ npm ~/.vite-plus/bin/npm (vp shim) ✓ npx ~/.vite-plus/bin/npx (vp shim) - ✓ corepack ~/.vite-plus/bin/corepack (vp shim) Version Resolution Directory /Users/user/projects/my-app @@ -1031,7 +1040,7 @@ $ vp env doctor Installation ✓ VP_HOME ~/.vite-plus ✗ Bin directory does not exist - ✗ Missing shims node, npm, npx, corepack + ✗ Missing shims node, npm, npx Run 'vp env setup' to create bin directory and shims. Configuration @@ -1054,7 +1063,6 @@ PATH node not found npm not found npx not found - corepack not found Version Resolution Directory /Users/user/projects/my-app @@ -1391,7 +1399,7 @@ $ vp env which eslint # Unknown tool (not core tool, not in any global package) $ vp env which unknown-tool error: tool 'unknown-tool' not found -Not a core tool (node, npm, npx, corepack) or installed global package. +Not a core tool (node, npm, npx) or installed global package. Run 'vp list -g' to see installed packages. # Node.js version not installed @@ -1894,46 +1902,6 @@ When `npm uninstall -g` is detected, the shim uses `spawn_tool()` (like install) On Unix, `exec_tool()` uses `exec()` which replaces the current process — no code runs after. For `npm install -g` and `npm uninstall -g` specifically, we use `spawn_tool()` (spawn + wait) to retain control after npm finishes, enabling the post-install hint and post-uninstall link cleanup. All other npm commands continue to use `exec_tool()` for zero overhead. -## Corepack Shim - -> Added in response to [#858](https://github.com/voidzero-dev/vite-plus/issues/858) and [#1309](https://github.com/voidzero-dev/vite-plus/issues/1309). - -`corepack` is part of the default shim tool list, so `vp env setup` (and the install scripts and `vp upgrade` shim refresh) create a `corepack` shim alongside `node`, `npm`, and `npx`. - -### Motivation - -- Without a system Node.js installation, corepack is unreachable even though Node.js ≤ 24 bundles it: `npm list -g` shows `corepack`, but no shim exists in `~/.vite-plus/bin`, so `corepack enable` fails with "command not found" (#1309). -- Many projects, scripts, and AI agents invoke `pnpm`/`yarn` directly instead of `vp` commands (#858). Corepack provides version-correct package manager executables based on `package.json#packageManager`, covering workflows `vp pm` does not (e.g., `yarn plugin ...`, see [#1539](https://github.com/voidzero-dev/vite-plus/issues/1539)). -- Node.js 25+ no longer bundles corepack, so the shim needs a managed fallback rather than relying on the bundled binary forever. - -### Resolution Order - -When the `corepack` shim is invoked: - -1. **vp-managed global package**: if corepack was installed via `vp install -g corepack`, that installation wins. Explicit user intent takes precedence, and the managed copy provides a consistent corepack version across Node.js versions (same philosophy as `packageManager` winning over the Node-bundled npm). -2. **Node-bundled corepack**: resolve the project Node.js version (same resolution chain as `node`/`npm`/`npx`) and use the `corepack` bundled with that installation (present in Node.js ≤ 24). -3. **Auto-install fallback**: on Node.js 25+ where corepack is not bundled, automatically install corepack as a vp-managed global package and execute it. Unlike an explicit `vp install -g corepack` (which exposes every binary the package declares, including its pnpm/yarn launchers), the auto-install links **only the `corepack` binary**: creating package-manager launchers stays `corepack enable`'s job, and the auto-install can never conflict with vp-managed package managers like an existing `vp install -g pnpm`. The restriction is recorded in the package metadata (`bins_restricted`) so `vp update -g` preserves it; an explicit `vp install -g corepack` resets it, and a shim-triggered reinstall over a previous unrestricted install keeps it unrestricted (it must not silently delete the user's exposed launcher bins). A one-line notice is printed to stderr when the install happens. - -### `corepack enable` / `corepack disable` - -Corepack's `enable` command creates package-manager launchers (`pnpm`, `yarn`, ...) **next to the corepack binary found in `PATH`**. Under the Vite+ shim that would be the per-version Node.js bin directory (`~/.vite-plus/js_runtime/node//bin/`), which is not on `PATH` — `corepack enable` would silently produce unreachable launchers. - -To fix this, the shim intercepts `corepack enable` and `corepack disable` invocations that do not pass an explicit `--install-directory` and injects `--install-directory ~/.vite-plus/bin` (the same spawn+wait pattern used for `npm install -g` interception): - -- `corepack enable` places `pnpm`/`yarn` launchers into `~/.vite-plus/bin`, which is on `PATH`. The launchers run via the shimmed `node`, so they still respect per-project Node.js version resolution, while corepack itself respects `package.json#packageManager`. -- `corepack disable` removes them from the same location. Vite+-owned entries among the corepack-managed launcher names (`npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`) are protected: default shims, `vp install -g` binaries, and `npm install -g` links tracked by a `BinConfig` are snapshotted before corepack runs and restored afterwards if corepack removed or replaced them. Entries that were already absent or not Vite+-owned before the run are left alone. - -### Interplay with `vp install -g corepack` - -- `corepack` is **not** added to `CORE_SHIMS` (the `vp install -g` conflict guard), so `vp install -g corepack` remains allowed — it is the explicit way to control the corepack version and the documented fallback for older Vite+ versions. -- `vp remove -g corepack` removes the package and its `BinConfig`, but keeps the default `corepack` shim in place (resolution falls back to the Node-bundled / auto-install path). -- `npm install -g corepack` installs the package but does not link the binary: the post-install check skips protected shim names (default shims are guarded by `is_protected_shim`) and prints a note pointing at `vp install -g corepack` instead. - -### Out of Scope - -- Default `pnpm`/`yarn` shims that route to `vp pm` equivalents — `vp pm` does not cover all package-manager subcommands yet ([#1539](https://github.com/voidzero-dev/vite-plus/issues/1539)). -- Replacing corepack with built-in Vite+ functionality. The shim is the compatibility bridge "until there is a proper alternative that everyone is happy to use". - ## Exec Command The `vp env exec` command executes a command with a specific Node.js version. It operates in two modes: @@ -2147,13 +2115,11 @@ The `vp env clean` command reclaims space used by managed runtime and package-ma - Preserves the configured global default Node.js version, when one is set. - Removes every other directory under `~/.vite-plus/js_runtime/node/`. - Removes all downloaded package-manager installs under `~/.vite-plus/package_manager/`. -- Runs `corepack cache clean` so Corepack-managed package-manager downloads are removed too. ### Example ```bash $ vp env clean -✓ Cleaned Corepack cache ✓ Removed 2 Node.js runtimes ✓ Removed 4 package manager installs ``` @@ -2206,7 +2172,6 @@ VP_HOME/ │ ├── node -> ../current/bin/vp # Symlink to same binary │ ├── npm -> ../current/bin/vp # Symlink to same binary │ ├── npx -> ../current/bin/vp # Symlink to same binary -│ ├── corepack -> ../current/bin/vp # Symlink to same binary │ └── tsc -> ../current/bin/vp # Symlink for global package └── current/ └── bin/ @@ -2235,7 +2200,6 @@ All shims use relative symlinks: ln -sf ../current/bin/vp ~/.vite-plus/bin/node ln -sf ../current/bin/vp ~/.vite-plus/bin/npm ln -sf ../current/bin/vp ~/.vite-plus/bin/npx -ln -sf ../current/bin/vp ~/.vite-plus/bin/corepack # Global package binaries ln -sf ../current/bin/vp ~/.vite-plus/bin/tsc @@ -2252,7 +2216,6 @@ VP_HOME\ │ ├── node.exe # Trampoline shim (sets VP_SHIM_TOOL=node) │ ├── npm.exe # Trampoline shim (sets VP_SHIM_TOOL=npm) │ ├── npx.exe # Trampoline shim (sets VP_SHIM_TOOL=npx) -│ ├── corepack.exe # Trampoline shim (sets VP_SHIM_TOOL=corepack) │ └── tsc.exe # Trampoline shim for global package └── current\ └── bin\ @@ -2294,7 +2257,7 @@ The Windows installer (`install.ps1`) follows this flow: 1. Download and install `vp.exe` and `vp-shim.exe` to `~/.vite-plus/current/bin/` 2. Create `~/.vite-plus/bin/vp.exe` trampoline (copy of `vp-shim.exe`) -3. Create shim trampolines: `node.exe`, `npm.exe`, `npx.exe`, `corepack.exe` (via `vp env setup`) +3. Create shim trampolines: `node.exe`, `npm.exe`, `npx.exe` (via `vp env setup`) 4. Configure User PATH to include `~/.vite-plus/bin` ## Testing Strategy @@ -2390,14 +2353,6 @@ env-doctor/ 1. NODE_PATH setup for shared package resolution -### Phase 5: Corepack Shim (P1) - -1. Add `corepack` to the default shim tool list created by `vp env setup` (Unix symlink, Windows trampoline) -2. Dispatch: vp-managed global corepack first, then Node-bundled corepack, then auto-install fallback (Node.js 25+) -3. Intercept `corepack enable`/`corepack disable` to default `--install-directory` to `~/.vite-plus/bin`, restoring Vite+-owned shims afterwards -4. Keep `vp install -g corepack` allowed; `vp remove -g corepack` keeps the default shim -5. Update `vp env doctor`, `vp env which`, install scripts, and docs; add snap tests - ## Backward Compatibility This is a new feature with no impact on existing functionality. The `vp` binary continues to work normally when invoked directly. @@ -2416,9 +2371,7 @@ The following decisions have been made: 2. **Windows Shim Strategy**: Trampoline `.exe` files that set `VP_SHIM_TOOL` and spawn `vp.exe` - Avoids "Terminate batch job?" prompt, works in all shells. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md). -3. **Corepack Handling**: Included as a default shim (revisited in [#1309](https://github.com/voidzero-dev/vite-plus/issues/1309), originally excluded). The shim prefers a vp-managed global corepack, falls back to the Node-bundled binary (Node.js ≤ 24), and auto-installs a managed copy on Node.js 25+ where corepack is no longer bundled. See [Corepack Shim](#corepack-shim). - -4. **Cache Persistence**: Persist across upgrades - Better performance, with cache format versioning for compatibility. +3. **Cache Persistence**: Persist across upgrades - Better performance, with cache format versioning for compatibility. ## Conclusion diff --git a/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index c9f7c516a4..b97a47372e 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -6,7 +6,7 @@ Implemented ## Summary -Replace Windows `.cmd` wrapper scripts with lightweight trampoline `.exe` binaries for all shim tools (`vp`, `node`, `npm`, `npx`, `corepack`, `vpx`, `vpr`, and globally installed package binaries). This eliminates the `Terminate batch job (Y/N)?` prompt that appears when users press Ctrl+C, providing the same clean signal behavior as direct `.exe` invocation. +Replace Windows `.cmd` wrapper scripts with lightweight trampoline `.exe` binaries for all shim tools (`vp`, `node`, `npm`, `npx`, `vpx`, `vpr`, and globally installed package binaries). This eliminates the `Terminate batch job (Y/N)?` prompt that appears when users press Ctrl+C, providing the same clean signal behavior as direct `.exe` invocation. ## Motivation @@ -65,7 +65,6 @@ On Unix, shims are symlinks to the `vp` binary. The binary detects the tool name ├── node → ../current/bin/vp (symlink) ├── npm → ../current/bin/vp (symlink) ├── npx → ../current/bin/vp (symlink) -├── corepack → ../current/bin/vp (symlink) ├── vpx → ../current/bin/vp (symlink) └── vpr → ../current/bin/vp (symlink) ``` @@ -78,7 +77,6 @@ On Unix, shims are symlinks to the `vp` binary. The binary detects the tool name ├── node.exe # Trampoline → sets VP_SHIM_TOOL=node, spawns vp.exe ├── npm.exe # Trampoline → sets VP_SHIM_TOOL=npm, spawns vp.exe ├── npx.exe # Trampoline → sets VP_SHIM_TOOL=npx, spawns vp.exe -├── corepack.exe # Trampoline → sets VP_SHIM_TOOL=corepack, spawns vp.exe ├── vpx.exe # Trampoline → sets VP_SHIM_TOOL=vpx, spawns vp.exe ├── vpr.exe # Trampoline → sets VP_SHIM_TOOL=vpr, spawns vp.exe └── tsc.exe # Trampoline → sets VP_SHIM_TOOL=tsc, spawns vp.exe (package shim) @@ -213,7 +211,7 @@ When `vp env setup --refresh` is invoked through the trampoline (`~/.vite-plus/b During `vp upgrade`, after the `current` link is swapped to the new version, `vp env setup --refresh` is invoked to regenerate all trampoline `.exe` files. This ensures that when the trampoline binary (`vp-shim.exe`) changes between versions, all shims pick up the new version: -1. **Core shims** (`vp.exe`, `node.exe`, `npm.exe`, `npx.exe`, `corepack.exe`, `vpx.exe`, `vpr.exe`) are refreshed by the standard `--refresh` logic. +1. **Core shims** (`vp.exe`, `node.exe`, `npm.exe`, `npx.exe`, `vpx.exe`, `vpr.exe`) are refreshed by the standard `--refresh` logic. 2. **Package shims** (e.g., `tsc.exe`, `eslint.exe`, installed via `vp install -g`) are discovered by scanning `~/.vite-plus/bins/` for `BinConfig` entries with `source: Vp`, and each `.exe` is replaced with the new trampoline. Package shims installed via npm interception (`source: Npm`) use `.cmd` wrappers, not trampoline `.exe` files, and are not affected by this refresh. diff --git a/rfcs/upgrade-command.md b/rfcs/upgrade-command.md index 6894ae3e59..1160a4d621 100644 --- a/rfcs/upgrade-command.md +++ b/rfcs/upgrade-command.md @@ -302,7 +302,7 @@ Key differences on Windows: After the symlink swap (the **point of no return**), post-update operations are treated as non-fatal. Errors are printed to stderr as warnings but do not trigger the outer error handler (which would delete the now-active version directory). -1. **Refresh shims**: Run the equivalent of `vp env setup --refresh` to ensure node/npm/npx/corepack shims point to the new version. This also refreshes trampoline `.exe` files for globally installed package shims (e.g., `tsc.exe`) by scanning `BinConfig` entries. If this fails, the user can run it manually. +1. **Refresh shims**: Run the equivalent of `vp env setup --refresh` to ensure node/npm/npx shims point to the new version. This also refreshes trampoline `.exe` files for globally installed package shims (e.g., `tsc.exe`) by scanning `BinConfig` entries. If this fails, the user can run it manually. 2. **Cleanup old versions**: Remove old version directories, keeping the 3 most recent by **creation time** (matching `install.sh` behavior). The new version and the previous version are always protected from cleanup, even if they fall outside the top 3 (e.g., after a downgrade via `--rollback`). #### Step 7: Running Binary Consideration