diff --git a/Cargo.lock b/Cargo.lock index 5826b19eb3..0b6c28f4b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8537,6 +8537,7 @@ dependencies = [ name = "vp_global_cli" version = "0.3.0" dependencies = [ + "base64-simd", "chrono", "clap", "clap_complete", diff --git a/README.md b/README.md index d94567baf8..c6ed460310 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ _runtime and package management, create, dev, check, test, build, pack, and mono Vite+ is the unified entry point for local web development. It combines [Vite](https://vite.dev/), [Vitest](https://vitest.dev/), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Rolldown](https://rolldown.rs/), [tsdown](https://tsdown.dev/), and [Vite Task](https://github.com/voidzero-dev/vite-task) into one zero-config toolchain that also manages runtime and package manager workflows: -- **`vp env`:** Manage Node.js globally and per project +- **`vp env`:** Manage Node.js and package managers globally and per project - **`vp install`:** Install dependencies with automatic package manager detection - **`vp dev`:** Run Vite's fast native ESM dev server with instant HMR - **`vp check`:** Run formatting, linting, and type checks in one command @@ -105,7 +105,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **hooks** - Manage the Git hook dispatcher - **staged** - Run linters on staged files - **install** (`i`) - Install dependencies -- **env** - Manage Node.js versions +- **env** - Manage Node.js and package managers #### Develop diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 1b463e087d..370f04858e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -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,58 @@ 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 selected environment scopes + off Enable system-first mode for selected environment scopes + print Print PATH setup for the resolved environment Manage: - default Set or show the global default Node.js version - pin Pin a Node.js version in the current directory - unpin Remove the Node.js pin from the current directory (alias for `pin --unpin`) - use Use a specific Node.js version for this shell session - install, i Install a Node.js version - uninstall, uni Uninstall a Node.js version - clean Remove unused managed runtimes and package manager caches - exec, run Execute a command with a specific Node.js version + default Set or show global environment defaults + pin Pin Node.js and package-manager versions in the project + unpin Remove project environment pins (alias for `pin --unpin`) + use Activate an environment for this shell session + install, i Install a resolved or explicit environment + uninstall, uni Uninstall explicit component versions + clean Remove unused runtimes and package managers + exec, run Execute a command in a resolved or explicit environment Inspect: current Show current environment information doctor Run diagnostics and show environment status which Show path to the tool that would be executed - list, ls List locally installed Node.js versions - list-remote, ls-remote List available Node.js versions from the registry + list, ls List locally installed environment components + list-remote, ls-remote List available versions from component registries Examples: Setup: vp env setup # Create Node.js and package-manager shims - vp env on # Use vite-plus managed Node.js - vp env print # Print shell snippet for this session + vp env on # Manage Node.js and package managers + vp env off pm # Prefer system package managers only + vp env off pnpm # Prefer system pnpm 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 pnpm's default version + vp env pin 22.19.0 # Pin Node.js for this project + vp env use 22.19.0 # Use Node.js in this shell + vp env clean # Clean all unused managed versions 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/assert-one-pnpm-version.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/assert-one-pnpm-version.cjs new file mode 100644 index 0000000000..c95e8d5d3b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/assert-one-pnpm-version.cjs @@ -0,0 +1,13 @@ +const { readdirSync } = require('node:fs') +const { join } = require('node:path') + +const versions = readdirSync(join(process.env.VP_HOME, 'package_manager', 'pnpm'), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + +if (versions.length !== 1 || versions[0] === '0.0.1') { + process.stderr.write(`unexpected pnpm installs: ${versions.join(', ')}\n`) + process.exit(1) +} + +console.log('kept one concrete pnpm fallback') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/prepare-pnpm-versions.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/prepare-pnpm-versions.cjs new file mode 100644 index 0000000000..f1229d22da --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/prepare-pnpm-versions.cjs @@ -0,0 +1,10 @@ +const { execFileSync } = require('node:child_process') +const { mkdirSync } = require('node:fs') +const { join } = require('node:path') + +const output = execFileSync('vp', ['env', 'current', 'pnpm', '--json'], { encoding: 'utf8' }) +const info = JSON.parse(output) +const pnpmRoot = join(process.env.VP_HOME, 'package_manager', 'pnpm') + +mkdirSync(join(pnpmRoot, info.package_manager.version), { recursive: true }) +mkdirSync(join(pnpmRoot, '0.0.1'), { recursive: true }) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml index d3c0d5fe0e..bd0f73a50c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml @@ -4,4 +4,20 @@ 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" }, +] + +[[case]] +name = "env_clean_preserves_concrete_package_manager_fallback" +vp = "global" +comment = "System-first changes dispatch preference, not cache ownership; clean must retain the managed fallback used when no system manager is available." +local-registry = true +seed-runtime = false +steps = [ + { argv = ["node", "prepare-pnpm-versions.cjs"], snapshot = false }, + { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, + { argv = ["vp", "env", "clean", "pnpm"], comment = "cleanup removes stale installs but preserves the concrete family's managed fallback even in system-first mode" }, + { argv = ["node", "assert-one-pnpm-version.cjs"], comment = "the cached registry fallback remains available" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/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_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md new file mode 100644 index 0000000000..e088c49d94 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/env_clean_preserves_concrete_package_manager_fallback.md @@ -0,0 +1,27 @@ +# env_clean_preserves_concrete_package_manager_fallback + +System-first changes dispatch preference, not cache ownership; clean must retain the managed fallback used when no system manager is available. + +## `node prepare-pnpm-versions.cjs` + + +## `vp env off pnpm` + + +## `vp env clean pnpm` + +cleanup removes stale installs but preserves the concrete family's managed fallback even in system-first mode + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed 1 package manager install +``` + +## `node assert-one-pnpm-version.cjs` + +the cached registry fallback remains available + +``` +kept one concrete pnpm fallback +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_cmd/snapshots/command_env_cmd.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_cmd/snapshots/command_env_cmd.md index 9cb89526b2..dcfa899c39 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_cmd/snapshots/command_env_cmd.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_cmd/snapshots/command_env_cmd.md @@ -6,7 +6,7 @@ verifies vp-use.cmd explicit, unset, file-based, failure status, and session-onl ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) Command Prompt environment use checks passed ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots.toml index 003f042bac..e8dc3ab0c6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots.toml @@ -4,4 +4,6 @@ vp = "global" skip-platforms = ["windows"] steps = [ { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','doctor'],{encoding:'utf8'}).replace(/\\u001b\\[[0-9;]*m/g,'');const lines=text.split('\\n');const start=lines.findIndex(l=>l.trim()==='devEngines');if(start===-1){console.error('devEngines section not found in doctor output');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()===''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\\n'));"], comment = "print only the deterministic devEngines section of vp env doctor (the other sections are environment-dependent)", continue-on-failure = true }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','doctor','node'],{encoding:'utf8'}).replace(/\\u001b\\[[0-9;]*m/g,'');const lines=text.split('\\n');const start=lines.findIndex(l=>l.trim()==='devEngines');if(start===-1){console.error('devEngines section not found in doctor output');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()===''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\\n'));"], comment = "node-scoped doctor output excludes package-manager findings", continue-on-failure = true }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','doctor','pm'],{encoding:'utf8'}).replace(/\\u001b\\[[0-9;]*m/g,'');const lines=text.split('\\n');const start=lines.findIndex(l=>l.trim()==='devEngines');if(start===-1){console.error('devEngines section not found in doctor output');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()===''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\\n'));"], comment = "package-manager-scoped doctor output excludes runtime findings", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots/command_env_doctor_dev_engines.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots/command_env_doctor_dev_engines.md index ccbfd823cd..600f83ee10 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots/command_env_doctor_dev_engines.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_doctor_dev_engines/snapshots/command_env_doctor_dev_engines.md @@ -10,3 +10,22 @@ devEngines ⚠ PackageManager packageManager is "npm@10.5.0" but devEngines.packageManager requires "pnpm" note: This will become an error in a future release. ``` + +## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const text=execFileSync('\''vp'\'',['\''env'\'','\''doctor'\'','\''node'\''],{encoding:'\''utf8'\''}).replace(/\u001b\[[0-9;]*m/g,'\'''\'');const lines=text.split('\''\n'\'');const start=lines.findIndex(l=>l.trim()==='\''devEngines'\'');if(start===-1){console.error('\''devEngines section not found in doctor output'\'');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()==='\'''\''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\''\n'\''));'` + +node-scoped doctor output excludes package-manager findings + +``` +devEngines + ⚠ Runtime .node-version (20.18.0) does not satisfy devEngines.runtime "^24.0.0" +``` + +## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const text=execFileSync('\''vp'\'',['\''env'\'','\''doctor'\'','\''pm'\''],{encoding:'\''utf8'\''}).replace(/\u001b\[[0-9;]*m/g,'\'''\'');const lines=text.split('\''\n'\'');const start=lines.findIndex(l=>l.trim()==='\''devEngines'\'');if(start===-1){console.error('\''devEngines section not found in doctor output'\'');process.exit(1);}const out=[];for(let i=start;istart&&lines[i].trim()==='\'''\''){break;}out.push(lines[i].trimEnd());}console.log(out.join('\''\n'\''));'` + +package-manager-scoped doctor output excludes runtime findings + +``` +devEngines + ⚠ PackageManager packageManager is "npm@10.5.0" but devEngines.packageManager requires "pnpm" + note: This will become an error in a future release. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_fish/snapshots/command_env_fish_use.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_fish/snapshots/command_env_fish_use.md index 5e2ae74cfc..79b34f6528 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_fish/snapshots/command_env_fish_use.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_fish/snapshots/command_env_fish_use.md @@ -15,7 +15,7 @@ verifies the Fish wrapper help, explicit use, unset, file-based use, and failure ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) error: Unexpected argument '--invalid-option' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml index bdfa290e49..8987a7ce34 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots.toml @@ -5,4 +5,22 @@ skip-platforms = ["windows"] seed-runtime = false steps = [ { argv = ["vp", "env", "install"], comment = "Install version from .node-version (22.x)", continue-on-failure = true }, + { argv = ["vp", "env", "use", "22", "--no-install"], snapshot = false }, + { argv = ["vp", "env", "install"], comment = "An implicit install identifies an active session override", continue-on-failure = true }, + { argv = ["vp", "env", "uninstall", "22"], comment = "Node.js uninstall accepts the same major-version selectors as install", continue-on-failure = true }, +] + +[[case]] +name = "command_env_install_standalone_npm_fallback" +vp = "global" +comment = "Explicit npm family scopes use standalone registry npm; only the directly invoked npm shim keeps Node.js' bundled npm fallback." +local-registry = true +skip-platforms = ["windows"] +seed-runtime = false +env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_SHELL = "bash" } +steps = [ + { argv = ["vp", "env", "use", "npm", "--no-install"], comment = "an explicit npm scope exports the standalone npm fallback" }, + { argv = ["vp", "env", "install", "npm"], comment = "an explicit npm scope installs the standalone registry fallback" }, + { argv = ["vp", "env", "current", "npm", "--json"], comment = "the standalone npm fallback is installed" }, + { argv = ["vpt", "stat-file", "$VP_HOME/js_runtime/node", "--assert", "missing"], comment = "installing standalone npm does not install Node.js" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_no_arg.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_no_arg.md index 371e8c018c..d109808977 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_no_arg.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_no_arg.md @@ -10,3 +10,29 @@ VITE+ - The Unified Toolchain for the Web Installing Node.js ... Installed Node.js ``` + +## `vp env use 22 --no-install` + + +## `vp env install` + +An implicit install identifies an active session override + +``` +VITE+ - The Unified Toolchain for the Web + +Installing Node.js ... +Installed Node.js +Note: Installed from session override. +Run `vp env use --unset` to revert to project version resolution. +``` + +## `vp env uninstall 22` + +Node.js uninstall accepts the same major-version selectors as install + +``` +VITE+ - The Unified Toolchain for the Web + +Uninstalled Node.js +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md new file mode 100644 index 0000000000..0f15d9154e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg/snapshots/command_env_install_standalone_npm_fallback.md @@ -0,0 +1,51 @@ +# command_env_install_standalone_npm_fallback + +Explicit npm family scopes use standalone registry npm; only the directly invoked npm shim keeps Node.js' bundled npm fallback. + +## `vp env use npm --no-install` + +an explicit npm scope exports the standalone npm fallback + +``` +export VP_PACKAGE_MANAGER=npm@12.0.2 +Using npm (resolved from registry fallback) +``` + +## `vp env install npm` + +an explicit npm scope installs the standalone registry fallback + +``` +VITE+ - The Unified Toolchain for the Web + +Installing npm ... +Installed npm +``` + +## `vp env current npm --json` + +the standalone npm fallback is installed + +``` +{ + "package_manager": { + "name": "npm", + "version": "", + "source": "registry fallback", + "bin_paths": { + "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", + "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" + }, + "installed": true, + "mode": "managed" + } +} +``` + +## `vpt stat-file $VP_HOME/js_runtime/node --assert missing` + +installing standalone npm does not install Node.js + +``` +/.vite-plus/js_runtime/node: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/assert-package-manager-installed.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/assert-package-manager-installed.cjs new file mode 100644 index 0000000000..a0adec8b2b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/assert-package-manager-installed.cjs @@ -0,0 +1,17 @@ +const { spawnSync } = require('node:child_process') +const { writeFileSync } = require('node:fs') + +writeFileSync('package.json', JSON.stringify({ + name: 'command-env-install-no-node-with-package-manager', + private: true, + packageManager: 'pnpm@10.18.0', +})) + +const result = spawnSync('vp', ['env', 'install'], { encoding: 'utf8' }) +const output = `${result.stdout}${result.stderr}` +if (result.status !== 1 || !output.includes('Installed pnpm v10.18.0')) { + process.stderr.write(output) + process.exit(1) +} + +console.log('installed the declared package manager after reporting the missing Node.js pin') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml index 7cbe64e00f..8b99a89c33 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml @@ -6,3 +6,15 @@ seed-runtime = false steps = [ { argv = ["vp", "env", "install"], comment = "No version config - should error", continue-on-failure = true }, ] + +[[case]] +name = "command_env_install_no_node_with_package_manager" +vp = "global" +comment = "Node.js and package-manager installation are independent components: a missing Node pin must not skip the declared package manager." +skip-platforms = ["windows"] +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\n"], snapshot = false }, + { argv = ["node", "assert-package-manager-installed.cjs"], comment = "a bare install still installs the declared package manager when Node.js is unpinned" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md new file mode 100644 index 0000000000..173688b3c6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_node_with_package_manager.md @@ -0,0 +1,19 @@ +# command_env_install_no_node_with_package_manager + +Node.js and package-manager installation are independent components: a missing Node pin must not skip the declared package manager. + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx '#'\!'/bin/sh +'` + + +## `node assert-package-manager-installed.cjs` + +a bare install still installs the declared package manager when Node.js is unpinned + +``` +installed the declared package manager after reporting the missing Node.js pin +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml index c0c6ddc924..ecbef22d4e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml @@ -4,7 +4,27 @@ vp = "global" skip-platforms = ["windows"] seed-runtime = false steps = [ + { argv = ["vp", "env", "default", "node"], comment = "An unconfigured Node.js default explains the effective fallback without claiming it was set in config", continue-on-failure = true }, { argv = ["vp", "env", "install", "lts"], comment = "Install an LTS Node.js version locally", continue-on-failure = true }, { argv = ["vp", "env", "default", "lts"], comment = "Set it as the global default (stored as the `lts` alias)", continue-on-failure = true }, - { argv = ["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 = ["vp", "env", "default", "node"], comment = "A configured Node.js alias shows its current resolution and config source", continue-on-failure = true }, + { argv = ["vp", "env", "default", "pnpm@10.18.0"], comment = "Package-manager default updates identify the selected family and version", continue-on-failure = true }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process'); const {node}=JSON.parse(execFileSync('vp',['env','list-remote','--lts','--json'],{encoding:'utf8'})); console.log('installed marked:', node.some(v=>v.installed)); console.log('current marked:', node.some(v=>v.current)); console.log('default marked:', node.some(v=>v.default));"], comment = "the unified JSON node entries resolve installed/current/default flags, including the `lts` default alias", continue-on-failure = true }, + { argv = ["vp", "env", "list-remote", "node", "22.11.0"], comment = "Human-readable Node.js results keep the v prefix, LTS codename, and interactive formatting", formatted-snapshot = true, continue-on-failure = true }, + { argv = ["vp", "env", "list-remote", "pnpm", "10.18.0"], comment = "Human-readable package-manager results retain current-version formatting", formatted-snapshot = true, continue-on-failure = true }, + { argv = ["vp", "env", "list-remote", "node", "999"], comment = "An empty Node.js result includes actionable feedback", continue-on-failure = true }, +] + +[[case]] +name = "command_env_list_format" +vp = "global" +skip-platforms = ["windows"] +seed-runtime = false +steps = [ + { argv = ["vpt", "mkdir", "-p", "$VP_HOME/js_runtime/node/22.11.0"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vp", "env", "default", "22.11.0", "pnpm@10.18.0"], snapshot = false }, + { argv = ["vp", "env", "list", "node"], comment = "Installed Node.js versions retain their interactive formatting", formatted-snapshot = true }, + { argv = ["vp", "env", "list", "pnpm"], comment = "Installed package-manager versions use the same interactive formatting", formatted-snapshot = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md new file mode 100644 index 0000000000..803486c6e8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_format.md @@ -0,0 +1,41 @@ +# command_env_list_format + +## `vpt mkdir -p $VP_HOME/js_runtime/node/22.11.0` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx '#'\!'/bin/sh +'` + + +## `vp env default 22.11.0 pnpm@10.18.0` + + +## `vp env list node` + +Installed Node.js versions retain their interactive formatting + +``` +VITE+ - The Unified Toolchain for the Web + +Node.js + \x1b[94m* \x1b[2mcurrent default + +\x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` + +## `vp env list pnpm` + +Installed package-manager versions use the same interactive formatting + +``` +VITE+ - The Unified Toolchain for the Web + +pnpm + \x1b[94m* 10.18.0 \x1b[2mcurrent default + +\x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` 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..772f8c81c5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md @@ -1,5 +1,16 @@ # command_env_list_remote +## `vp env default node` + +An unconfigured Node.js default explains the effective fallback without claiming it was set in config + +``` +VITE+ - The Unified Toolchain for the Web + +No default Node.js version configured. Using latest LTS (). + Run 'vp env default ' to set a default. +``` + ## `vp env install lts` Install an LTS Node.js version locally @@ -21,12 +32,73 @@ VITE+ - The Unified Toolchain for the Web ✓ Default Node.js version set to lts (currently ) ``` -## `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));'` +## `vp env default node` + +A configured Node.js alias shows its current resolution and config source -installed/current/default flags should all resolve, including the `lts` default alias +``` +VITE+ - The Unified Toolchain for the Web + +Default Node.js version: lts + Currently resolves to: + Set via: /.vite-plus/config.json +``` + +## `vp env default pnpm@10.18.0` + +Package-manager default updates identify the selected family and version + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Default pnpm version set to 10.18.0 +``` + +## `node -e 'const {execFileSync}=require('\''node:child_process'\''); const {node}=JSON.parse(execFileSync('\''vp'\'',['\''env'\'','\''list-remote'\'','\''--lts'\'','\''--json'\''],{encoding:'\''utf8'\''})); console.log('\''installed marked:'\'', node.some(v=>v.installed)); console.log('\''current marked:'\'', node.some(v=>v.current)); console.log('\''default marked:'\'', node.some(v=>v.default));'` + +the unified JSON node entries resolve installed/current/default flags, including the `lts` default alias ``` installed marked: true current marked: true default marked: true ``` + +## `vp env list-remote node 22.11.0` + +Human-readable Node.js results keep the v prefix, LTS codename, and interactive formatting + +``` +VITE+ - The Unified Toolchain for the Web + +Node.js + \x1b[94m (Jod) + +\x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` + +## `vp env list-remote pnpm 10.18.0` + +Human-readable package-manager results retain current-version formatting + +``` +VITE+ - The Unified Toolchain for the Web + +pnpm + \x1b[94m10.18.0\x1b[39;2m current default + +\x1b[2mnote: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` + +## `vp env list-remote node 999` + +An empty Node.js result includes actionable feedback + +``` +VITE+ - The Unified Toolchain for the Web + +Node.js + No versions were found! + +note: Run `vp env clean` to free disk space from unused managed runtimes and package manager caches. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md index 770e42262b..547e5d6bfe 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_nushell/snapshots/command_env_nushell.md @@ -16,7 +16,7 @@ loads the generated env.nu and verifies setup, explicit use, unset, and file-bas ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) Nushell environment checks passed ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md index 3ad582ca09..6a701c5266 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md @@ -18,11 +18,11 @@ Switch to system-first mode ``` VITE+ - The Unified Toolchain for the Web -✓ Node.js management set to system-first. +✓ Node.js and package-manager management set to system-first. -All vp commands and shims will now prefer system Node.js, falling back to managed if not found. +Selected commands and shims will now prefer system tools, falling back to managed tools. -Run `vp env on` to always use Vite+ managed Node.js. +Run `vp env on` to always use Vite+ managed tools. ``` ## `vp run assert-not-managed` @@ -45,11 +45,11 @@ Switch back to managed mode ``` VITE+ - The Unified Toolchain for the Web -✓ Node.js management set to managed. +✓ Node.js and package-manager management set to managed. -All vp commands and shims will now always use Vite+ managed Node.js. +Selected commands and shims will now use Vite+ managed tools. -Run `vp env off` to prefer system Node.js instead. +Run `vp env off` to prefer system tools instead. ``` ## `vp run assert-managed` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/assert-doctor-fails.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/assert-doctor-fails.cjs new file mode 100644 index 0000000000..5ff5ea222f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/assert-doctor-fails.cjs @@ -0,0 +1,13 @@ +const { spawnSync } = require('node:child_process') + +const result = spawnSync('vp', ['env', 'doctor', 'pm'], { + encoding: 'utf8', + env: { ...process.env, VP_PACKAGE_MANAGER: 'unknown@1.0.0' }, +}) +const output = result.stdout.replace(/\u001B\[[0-9;]*m/g, '') +if (result.status !== 1 || !output.includes('Package manager') || !output.includes('Some issues found')) { + process.stderr.write(`${result.stdout}${result.stderr}`) + process.exit(1) +} + +console.log('doctor returns a failing status for package-manager resolution errors') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/prepare-npm.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/prepare-npm.cjs new file mode 100644 index 0000000000..a8f2c4e4ce --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/prepare-npm.cjs @@ -0,0 +1,17 @@ +const { mkdirSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') + +const binRoot = join( + process.env.VP_HOME, + 'package_manager', + 'npm', + '10.9.4', + 'npm', + 'bin', +) +const extension = process.platform === 'win32' ? '.cmd' : '' +const contents = process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n' + +mkdirSync(binRoot, { recursive: true }) +for (const name of ['npm', 'npx']) + writeFileSync(join(binRoot, `${name}${extension}`), contents, { mode: 0o755 }) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index 1a967c1d6e..3db9ca3129 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -2,7 +2,46 @@ name = "command_env_package_manager_diagnostics" vp = "global" steps = [ - { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const info=JSON.parse(execFileSync('vp',['env','current','--json'],{encoding:'utf8'}));if(info.package_manager?.name!=='npm'||info.package_manager?.version!=='10.9.4'||info.package_manager?.source!=='packageManager')process.exit(1);console.log('current reports npm packageManager')"], comment = "current reports the npm packageManager pin" }, - { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','which','npm'],{encoding:'utf8'});if(!text.includes('Package:')||!text.includes('npm@10.9.4')||!text.includes('package.json'))process.exit(1);console.log('which reports npm packageManager')"], comment = "which reports the npm packageManager pin" }, - { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','which','npx'],{encoding:'utf8'});if(!text.includes('Package:')||!text.includes('npm@10.9.4')||!text.includes('package.json'))process.exit(1);console.log('which reports npx packageManager')"], comment = "which reports the npx packageManager pin" }, + { argv = ["node", "prepare-npm.cjs"], snapshot = false }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "current reports the npm packageManager pin" }, + { argv = ["vp", "env", "current", "pm"], comment = "current lists every binary exposed by the selected package-manager family" }, + { argv = ["vp", "env", "which", "npm"], comment = "which reports the npm packageManager pin" }, + { argv = ["vp", "env", "which", "npx"], comment = "the npx alias reports the same npm packageManager pin" }, +] + +[[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" }, +] + +[[case]] +name = "command_env_doctor_package_manager_resolution_failure" +vp = "global" +steps = [ + { argv = ["node", "assert-doctor-fails.cjs"], comment = "package-manager resolution errors make doctor fail" }, +] + +[[case]] +name = "command_env_empty_package_manager_override" +vp = "global" +steps = [ + { argv = ["vp", "env", "current", "pm", "--json"], envs = [["VP_PACKAGE_MANAGER", " "]], comment = "an empty package-manager environment override falls through to project resolution" }, +] + +[[case]] +name = "command_env_unset_session_independently_of_override" +vp = "global" +comment = "Scoped unset must inspect and clear the session file independently of any different environment override." +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "env", "use", "pnpm@10.18.0", "--no-install"], snapshot = false }, + { argv = ["vp", "env", "use", "--unset", "pnpm"], envs = [["VP_PACKAGE_MANAGER", "yarn@4.12.0"]], snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/.session-package-manager", "--assert", "missing"], comment = "a different environment override does not hide the matching session file from scoped cleanup" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_doctor_package_manager_resolution_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_doctor_package_manager_resolution_failure.md new file mode 100644 index 0000000000..03e5efceed --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_doctor_package_manager_resolution_failure.md @@ -0,0 +1,9 @@ +# command_env_doctor_package_manager_resolution_failure + +## `node assert-doctor-fails.cjs` + +package-manager resolution errors make doctor fail + +``` +doctor returns a failing status for package-manager resolution errors +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_empty_package_manager_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_empty_package_manager_override.md new file mode 100644 index 0000000000..adcc337af0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_empty_package_manager_override.md @@ -0,0 +1,23 @@ +# command_env_empty_package_manager_override + +## `VP_PACKAGE_MANAGER= vp env current pm --json` + +an empty package-manager environment override falls through to project resolution + +``` +{ + "package_manager": { + "name": "npm", + "version": "", + "source": "packageManager", + "source_path": "/package.json", + "project_root": "", + "bin_paths": { + "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", + "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" + }, + "installed": false, + "mode": "managed" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md index c40269096b..fceeb836a8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_diagnostics.md @@ -1,25 +1,68 @@ # command_env_package_manager_diagnostics -## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const info=JSON.parse(execFileSync('\''vp'\'',['\''env'\'','\''current'\'','\''--json'\''],{encoding:'\''utf8'\''}));if(info.package_manager?.name'\!'=='\''npm'\''||info.package_manager?.version'\!'=='\''10.9.4'\''||info.package_manager?.source'\!'=='\''packageManager'\'')process.exit(1);console.log('\''current reports npm packageManager'\'')'` +## `node prepare-npm.cjs` + + +## `vp env current pm --json` current reports the npm packageManager pin ``` -current reports npm packageManager +{ + "package_manager": { + "name": "npm", + "version": "", + "source": "packageManager", + "source_path": "/package.json", + "project_root": "", + "bin_paths": { + "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", + "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" + }, + "installed": true, + "mode": "managed" + } +} +``` + +## `vp env current pm` + +current lists every binary exposed by the selected package-manager family + +``` +VITE+ - The Unified Toolchain for the Web + +Package Manager: + Name npm + Version 10.9.4 + Source packageManager + Bin Paths + npm /.vite-plus/package_manager/npm//npm/bin/npm + npx /.vite-plus/package_manager/npm//npm/bin/npx + Installed true + Mode managed ``` -## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const text=execFileSync('\''vp'\'',['\''env'\'','\''which'\'','\''npm'\''],{encoding:'\''utf8'\''});if('\!'text.includes('\''Package:'\'')||'\!'text.includes('\''npm@10.9.4'\'')||'\!'text.includes('\''package.json'\''))process.exit(1);console.log('\''which reports npm packageManager'\'')'` +## `vp env which npm` which reports the npm packageManager pin ``` -which reports npm packageManager +VITE+ - The Unified Toolchain for the Web + +/.vite-plus/package_manager/npm//npm/bin/npm + Package: npm@10.9.4 + Source: /package.json ``` -## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const text=execFileSync('\''vp'\'',['\''env'\'','\''which'\'','\''npx'\''],{encoding:'\''utf8'\''});if('\!'text.includes('\''Package:'\'')||'\!'text.includes('\''npm@10.9.4'\'')||'\!'text.includes('\''package.json'\''))process.exit(1);console.log('\''which reports npx packageManager'\'')'` +## `vp env which npx` -which reports the npx packageManager pin +the npx alias reports the same npm packageManager pin ``` -which reports npx packageManager +VITE+ - The Unified Toolchain for the Web + +/.vite-plus/package_manager/npm//npm/bin/npx + Package: npm@10.9.4 + Source: /package.json ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_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_diagnostics/snapshots/command_env_unset_session_independently_of_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md new file mode 100644 index 0000000000..cc8ad9f29d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_unset_session_independently_of_override.md @@ -0,0 +1,17 @@ +# command_env_unset_session_independently_of_override + +Scoped unset must inspect and clear the session file independently of any different environment override. + +## `vp env use pnpm@10.18.0 --no-install` + + +## `VP_PACKAGE_MANAGER=yarn@4.12.0 vp env use --unset pnpm` + + +## `vpt stat-file $VP_HOME/.session-package-manager --assert missing` + +a different environment override does not hide the matching session file from scoped cleanup + +``` +/.vite-plus/.session-package-manager: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_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..a4342a3fa6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -0,0 +1,104 @@ +[[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", "--force"], 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" +comment = "A component selector must not resolve excluded components or turn an unrelated local listing into network work." +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "list", "node", "--json"], comment = "the node selector does not resolve an excluded package manager", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, + { argv = ["vp", "env", "list-remote", "20.18.0", "--lts", "--json"], comment = "the implicit node selector does not resolve an excluded package manager", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] + +[[case]] +name = "env_package_manager_list_ignores_other_family_resolution" +vp = "global" +comment = "A concrete family selector must not resolve the project-selected manager from another family." +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "list", "yarn", "--json"], comment = "a package-manager selector does not resolve an excluded package-manager family", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] + +[[case]] +name = "env_package_manager_list_stays_offline_for_selected_range" +vp = "global" +comment = "Local inventory must remain available offline even when marking the selected package-manager range requires best-effort resolution." +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "list", "pm", "--json"], comment = "local listing remains available when the selected range cannot reach the registry", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] + +[[case]] +name = "env_package_manager_list_stays_offline_for_floating_default" +vp = "global" +comment = "Local inventory reuses the cached concrete result of a floating default instead of requiring registry access." +local-registry = true +seed-runtime = false +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vp", "env", "default", "pnpm@latest"], snapshot = false }, + { argv = ["vp", "env", "list", "pnpm", "--json"], comment = "local listing remains available when a floating default cannot reach the registry", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] + +[[case]] +name = "env_node_list_stays_offline_for_floating_default" +vp = "global" +comment = "Local Node.js inventory must not require mirror access merely to mark a floating default." +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "$VP_HOME/js_runtime/node/20.18.0/bin/node", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/config.json", "{\"defaultNodeVersion\":\"latest\"}\n"], snapshot = false }, + { argv = ["vp", "env", "list", "node", "--json"], comment = "local Node.js listing remains available when a floating default cannot reach its mirror", envs = [["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]] }, +] + +[[case]] +name = "env_pin_package_manager_requires_confirmation" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "pipe-stdin", "n\n", "--", "vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "declining the overwrite prompt preserves the existing package-manager pin" }, + { argv = ["vpt", "print-file", "package.json"], comment = "package.json was not rewritten" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_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..9c39958567 --- /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,37 @@ +# env_node_list_ignores_package_manager_resolution + +A component selector must not resolve excluded components or turn an unrelated local listing into network work. + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `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_node_list_stays_offline_for_floating_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md new file mode 100644 index 0000000000..89544bf6b4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_stays_offline_for_floating_default.md @@ -0,0 +1,27 @@ +# env_node_list_stays_offline_for_floating_default + +Local Node.js inventory must not require mirror access merely to mark a floating default. + +## `vpt write-file $VP_HOME/js_runtime/node/20.18.0/bin/node '#'\!'/bin/sh +'` + + +## `vpt write-file $VP_HOME/config.json '{"defaultNodeVersion":"latest"} +'` + + +## `VP_NODE_DIST_MIRROR=http://127.0.0.1:9 vp env list node --json` + +local Node.js listing remains available when a floating default cannot reach its mirror + +``` +{ + "node": [ + { + "version": "20.18.0", + "current": false, + "default": false + } + ] +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md new file mode 100644 index 0000000000..f5f8364a4a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_ignores_other_family_resolution.md @@ -0,0 +1,19 @@ +# env_package_manager_list_ignores_other_family_resolution + +A concrete family selector must not resolve the project-selected manager from another family. + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list yarn --json` + +a package-manager selector does not resolve an excluded package-manager family + +``` +{ + "package_managers": { + "yarn": [] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md new file mode 100644 index 0000000000..52cc32ab21 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_floating_default.md @@ -0,0 +1,32 @@ +# env_package_manager_list_stays_offline_for_floating_default + +Local inventory reuses the cached concrete result of a floating default instead of requiring registry access. + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx '#'\!'/bin/sh +'` + + +## `vp env default pnpm@latest` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list pnpm --json` + +local listing remains available when a floating default cannot reach the registry + +``` +{ + "package_managers": { + "pnpm": [ + { + "version": "10.18.0", + "current": true, + "default": false + } + ] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md new file mode 100644 index 0000000000..2edb5fbe7c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_package_manager_list_stays_offline_for_selected_range.md @@ -0,0 +1,22 @@ +# env_package_manager_list_stays_offline_for_selected_range + +Local inventory must remain available offline even when marking the selected package-manager range requires best-effort resolution. + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list pm --json` + +local listing remains available when the selected range cannot reach the registry + +``` +{ + "package_managers": { + "bun": [], + "npm": [], + "pnpm": [], + "yarn": [] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md new file mode 100644 index 0000000000..5d80c7b545 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_package_manager_requires_confirmation.md @@ -0,0 +1,26 @@ +# env_pin_package_manager_requires_confirmation + +## `vpt pipe-stdin 'n +' -- vp env pin yarn@4.12.0 --no-install` + +declining the overwrite prompt preserves the existing package-manager pin + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to pnpm from packageManager, but yarn was requested. +Package manager already pinned to pnpm@10.18.0 +Overwrite with yarn@4.12.0? (Y/n): Cancelled. +``` + +## `vpt print-file package.json` + +package.json was not rewritten + +``` +{ + "name": "command-env-package-manager-mismatch", + "private": true, + "packageManager": "pnpm@10.18.0" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_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..feae6ff454 --- /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 --force` + +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_package_manager_modes/print-doctor-configuration.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs new file mode 100644 index 0000000000..eb729bfddc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/print-doctor-configuration.cjs @@ -0,0 +1,20 @@ +const { spawnSync } = require('node:child_process') + +const component = process.argv[2] +const args = ['env', 'doctor', ...(component ? [component] : [])] +const result = spawnSync('vp', args, { encoding: 'utf8' }) +if (result.error) + throw result.error +if (result.status !== 0) { + process.stderr.write(result.stdout) + process.stderr.write(result.stderr) + process.exit(result.status ?? 1) +} + +const output = result.stdout.replace(/\u001b\[[0-9;]*m/g, '') +const lines = output.split('\n') +const start = lines.findIndex(line => line.trim() === 'Configuration') +const endHeading = component ? 'IDE Setup' : 'PATH' +const end = lines.findIndex((line, index) => index > start && line.trim() === endHeading) + +console.log(lines.slice(start, end).filter(line => !line.includes('IDE integration')).join('\n').trimEnd()) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml new file mode 100644 index 0000000000..7ad74cc8b8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots.toml @@ -0,0 +1,35 @@ +[[case]] +name = "command_env_package_manager_modes" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vp", "env", "off", "pnpm"], comment = "switch only pnpm to system-first mode" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"], ["VP_BYPASS", "${PATH}"]], comment = "pnpm uses its individual mode" }, + { argv = ["vp", "env", "current", "bun", "--json"], envs = [["VP_PACKAGE_MANAGER", "bun@1.2.3"]], comment = "bun keeps the shared managed mode" }, + { argv = ["vp", "env", "on", "pnpm"], comment = "restore only pnpm to managed mode" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"]], comment = "pnpm returns to the shared managed mode" }, +] + +[[case]] +name = "command_env_doctor_package_manager_modes" +vp = "global" +comment = "Doctor may collapse identical family modes, but must expose per-family rows as soon as one package manager differs." +steps = [ + { argv = ["node", "print-doctor-configuration.cjs"], comment = "doctor keeps one package-manager row when every mode matches" }, + { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, + { argv = ["node", "print-doctor-configuration.cjs"], comment = "doctor prints each package manager when their modes differ" }, +] + +[[case]] +name = "command_env_doctor_system_package_manager" +vp = "global" +comment = "System-first inspection commands must use an available system manager without resolving its project range through the registry." +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"doctor-system-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, + { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, + { argv = ["node", "print-doctor-configuration.cjs", "pnpm"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "doctor reports the system pnpm binary without resolving the declared range" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "current reports the same system pnpm selection" }, + { argv = ["vp", "env", "print", "pnpm"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "print exports the system pnpm directory" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_package_manager_modes.md new file mode 100644 index 0000000000..84ee4bd073 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_package_manager_modes.md @@ -0,0 +1,29 @@ +# command_env_doctor_package_manager_modes + +Doctor may collapse identical family modes, but must expose per-family rows as soon as one package manager differs. + +## `node print-doctor-configuration.cjs` + +doctor keeps one package-manager row when every mode matches + +``` +Configuration + ✓ Node.js managed mode + ✓ Package manager managed mode +``` + +## `vp env off pnpm` + + +## `node print-doctor-configuration.cjs` + +doctor prints each package manager when their modes differ + +``` +Configuration + ✓ Node.js managed mode + ✓ npm managed mode + ✓ pnpm system-first mode + ✓ Yarn managed mode + ✓ Bun managed mode +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md new file mode 100644 index 0000000000..ef7f36c6e8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md @@ -0,0 +1,63 @@ +# command_env_doctor_system_package_manager + +System-first inspection commands must use an available system manager without resolving its project range through the registry. + +## `vpt write-file package.json '{"name":"doctor-system-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `vpt chmod +x system-bin/pnpm` + + +## `vp env off pnpm` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 node print-doctor-configuration.cjs pnpm` + +doctor reports the system pnpm binary without resolving the declared range + +``` +Configuration + ✓ Package manager system-first mode + +PATH + ✓ vp in PATH + ✓ pnpm ~/.vite-plus/bin/pnpm (vp shim) + ✓ pnpx ~/.vite-plus/bin/pnpx (vp shim) + +Package Manager Resolution + Source system PATH + Version pnpm@10.18.0 + ✓ PM binary /system-bin/pnpm +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env current pnpm --json` + +current reports the same system pnpm selection + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "system PATH", + "project_root": "", + "bin_paths": { + "pnpm": "/system-bin/pnpm" + }, + "installed": true, + "mode": "system_first" + } +} +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env print pnpm` + +print exports the system pnpm directory + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +export PATH="/system-bin:$PATH" +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md new file mode 100644 index 0000000000..c3fb82526c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_package_manager_modes.md @@ -0,0 +1,89 @@ +# command_env_package_manager_modes + +## `vp env off pnpm` + +switch only pnpm to system-first mode + +``` +VITE+ - The Unified Toolchain for the Web + +✓ pnpm management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +``` + +## `VP_PACKAGE_MANAGER=pnpm@10.18.0 VP_BYPASS=${PATH} vp env current pnpm --json` + +pnpm uses its individual mode + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "VP_PACKAGE_MANAGER", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": false, + "mode": "system_first" + } +} +``` + +## `VP_PACKAGE_MANAGER=bun@1.2.3 vp env current bun --json` + +bun keeps the shared managed mode + +``` +{ + "package_manager": { + "name": "bun", + "version": "", + "source": "VP_PACKAGE_MANAGER", + "bin_paths": { + "bun": "/.vite-plus/package_manager/bun//bun/bin/bun", + "bunx": "/.vite-plus/package_manager/bun//bun/bin/bunx" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vp env on pnpm` + +restore only pnpm to managed mode + +``` +VITE+ - The Unified Toolchain for the Web + +✓ pnpm management set to managed. + +Selected commands and shims will now use Vite+ managed tools. + +Run `vp env off` to prefer system tools instead. +``` + +## `VP_PACKAGE_MANAGER=pnpm@10.18.0 vp env current pnpm --json` + +pnpm returns to the shared managed mode + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "VP_PACKAGE_MANAGER", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": false, + "mode": "managed" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/system-bin/pnpm b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/system-bin/pnpm new file mode 100644 index 0000000000..1d0b624c16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/system-bin/pnpm @@ -0,0 +1,2 @@ +#!/bin/sh +printf '10.18.0\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/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..b12e93f5d7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml @@ -0,0 +1,50 @@ +[[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", "--force"], comment = "pinning an existing later option makes it the effective first supported entry" }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "current resolves the newly pinned manager" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the pin preserves sibling options and their policy" }, +] + +[[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", "--force"], comment = "a nested workspace pin updates the resolver-owned root manifest" }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "the nested project resolves the new root pin" }, + { argv = ["vpt", "print-file", "../../package.json", "package.json"], comment = "only the workspace manifest owns the package-manager pin" }, +] + +[[case]] +name = "env_unpin_targets_shadowed_dev_engines_manager" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-pin-package-manager\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\",\"devEngines\":{\"packageManager\":{\"name\":\"yarn\",\"version\":\"4.12.0\",\"onFail\":\"download\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "unpin", "pm", "--target", "dev-engines"], comment = "an explicit target removes the devEngines manager even when packageManager shadows it" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the effective top-level packageManager remains intact" }, +] + +[[case]] +name = "env_unpin_package_manager_target_preserves_dev_engines" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-pin-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"10.18.0\",\"onFail\":\"download\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "unpin", "pm", "--target", "package-manager"], comment = "an explicit top-level target does not remove a devEngines-only package-manager pin" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the devEngines package-manager pin remains unchanged" }, +] + +[[case]] +name = "env_pin_dev_engines_warns_when_top_level_version_shadows_it" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-pin-package-manager\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, + { argv = ["vp", "env", "pin", "pnpm@11.0.0", "--target", "dev-engines", "--no-install", "--force"], comment = "pinning a shadowed same-family devEngines version explains which declaration remains effective" }, + { argv = ["vpt", "print-file", "package.json"], comment = "both explicitly targeted declarations remain unchanged except for the new devEngines pin" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md new file mode 100644 index 0000000000..004acf8cb4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_dev_engines_warns_when_top_level_version_shadows_it.md @@ -0,0 +1,36 @@ +# env_pin_dev_engines_warns_when_top_level_version_shadows_it + +## `vpt write-file package.json '{"name":"command-env-pin-package-manager","private":true,"packageManager":"pnpm@10.18.0"} +'` + + +## `vp env pin pnpm@11.0.0 --target dev-engines --no-install --force` + +pinning a shadowed same-family devEngines version explains which declaration remains effective + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Pinned package manager to pnpm@11.0.0 +warn: Top-level packageManager pnpm@10.18.0 remains effective; remove or update it to use devEngines.packageManager pnpm@11.0.0. +note: Package manager will be downloaded on first use. +``` + +## `vpt print-file package.json` + +both explicitly targeted declarations remain unchanged except for the new devEngines pin + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "packageManager": "pnpm@10.18.0", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/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..ba3655a086 --- /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 --force` + +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..b5c1d47dc1 --- /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 --force` + +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_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md new file mode 100644 index 0000000000..5539bee4f0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_package_manager_target_preserves_dev_engines.md @@ -0,0 +1,23 @@ +# env_unpin_package_manager_target_preserves_dev_engines + +## `vpt write-file package.json '{"name":"command-env-pin-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"10.18.0","onFail":"download"}}} +'` + + +## `vp env unpin pm --target package-manager` + +an explicit top-level target does not remove a devEngines-only package-manager pin + +``` +VITE+ - The Unified Toolchain for the Web + +No package manager pin found in current directory. +``` + +## `vpt print-file package.json` + +the devEngines package-manager pin remains unchanged + +``` +{"name":"command-env-pin-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"","onFail":"download"}}} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md new file mode 100644 index 0000000000..eac0ebfc87 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_unpin_targets_shadowed_dev_engines_manager.md @@ -0,0 +1,28 @@ +# env_unpin_targets_shadowed_dev_engines_manager + +## `vpt write-file package.json '{"name":"command-env-pin-package-manager","private":true,"packageManager":"pnpm@10.18.0","devEngines":{"packageManager":{"name":"yarn","version":"4.12.0","onFail":"download"}}} +'` + + +## `vp env unpin pm --target dev-engines` + +an explicit target removes the devEngines manager even when packageManager shadows it + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed package-manager pin +``` + +## `vpt print-file package.json` + +the effective top-level packageManager remains intact + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "packageManager": "pnpm@10.18.0", + "devEngines": {} +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_bash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_bash.md index ca99219676..bd76e34400 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_bash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_bash.md @@ -16,7 +16,7 @@ loads the generated env file in Bash and verifies PATH, wrapper, completions, an ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) POSIX environment checks passed (bash) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_sh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_sh.md index a0b2947de9..d7b64f609d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_sh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_sh.md @@ -16,7 +16,7 @@ loads the generated env file in sh and verifies PATH, wrapper, and version switc ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) POSIX environment checks passed (sh) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_zsh.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_zsh.md index bd3c223b65..77ca5b05e9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_zsh.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_posix/snapshots/command_env_posix_zsh.md @@ -16,7 +16,7 @@ loads the generated env file in Zsh and verifies PATH, wrapper, completions, and ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) POSIX environment checks passed (zsh) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots/command_env_powershell.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots/command_env_powershell.md index a211b81d65..ca9210d7e9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots/command_env_powershell.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_powershell/snapshots/command_env_powershell.md @@ -13,7 +13,7 @@ dot-sources env.ps1 and verifies PowerShell environment setup and wrapper behavi ``` Using Node.js (resolved from 20.18.0) -Reverted to file-based Node.js version resolution +Reverted selected components to project environment resolution Using Node.js (resolved from .node-version) PowerShell environment checks passed ``` 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..f550341122 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml @@ -9,4 +9,16 @@ 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"]] }, +] + +[[case]] +name = "command_env_use_silent_unchanged_stays_noop" +vp = "global" +comment = "The unchanged guard must return before installation so --silent-if-unchanged remains free of downloads and filesystem side effects." +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-use\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, + { argv = ["vp", "env", "use", "pm", "--silent-if-unchanged"], envs = [["VP_PACKAGE_MANAGER", "pnpm@10.18.0"]], snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "--assert", "missing"], comment = "silent unchanged mode preserves the legacy no-op behavior" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.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/snapshots/command_env_use_silent_unchanged_stays_noop.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_stays_noop.md new file mode 100644 index 0000000000..ab71a2a075 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use_silent_unchanged_stays_noop.md @@ -0,0 +1,18 @@ +# command_env_use_silent_unchanged_stays_noop + +The unchanged guard must return before installation so --silent-if-unchanged remains free of downloads and filesystem side effects. + +## `vpt write-file package.json '{"name":"command-env-use","private":true,"packageManager":"pnpm@10.18.0"} +'` + + +## `VP_PACKAGE_MANAGER=pnpm@10.18.0 vp env use pm --silent-if-unchanged` + + +## `vpt stat-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm --assert missing` + +silent unchanged mode preserves the legacy no-op behavior + +``` +/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/.node-version new file mode 100644 index 0000000000..2a393af592 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/.node-version @@ -0,0 +1 @@ +20.18.0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml index 2ba92803b3..42d1d16309 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml @@ -4,13 +4,22 @@ 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 }, +] + +[[case]] +name = "env_use_clears_stale_package_manager_override" +vp = "global" +skip-platforms = ["windows"] +env = { VP_ENV_USE_EVAL_ENABLE = "1", VP_PACKAGE_MANAGER = "pnpm@10.18.0" } +steps = [ + { argv = ["vp", "env", "use", "--no-install"], comment = "activating a project with no package-manager selection clears the previous override", envs = [["VP_SHELL", "bash"]] }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/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_use_shells/snapshots/env_use_clears_stale_package_manager_override.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_clears_stale_package_manager_override.md new file mode 100644 index 0000000000..7ddcc11086 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/env_use_clears_stale_package_manager_override.md @@ -0,0 +1,11 @@ +# env_use_clears_stale_package_manager_override + +## `VP_SHELL=bash vp env use --no-install` + +activating a project with no package-manager selection clears the previous override + +``` +export VP_NODE_VERSION=20.18.0 +unset VP_PACKAGE_MANAGER +Using Node.js (resolved from .node-version) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml index 6f83226fca..f4e7ee037a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml @@ -13,3 +13,12 @@ steps = [ { argv = ["vp", "remove", "-g", "cowsay"], comment = "Cleanup", continue-on-failure = true }, { argv = ["vp", "env", "which", "unknown-tool"], comment = "Unknown tool - error message", continue-on-failure = true }, ] + +[[case]] +name = "command_env_current_concrete_package_manager_fallback" +vp = "global" +comment = "A named family has an effective registry fallback even when no project, session, or default selection exists." +local-registry = true +steps = [ + { argv = ["vp", "env", "current", "pnpm", "--json"], comment = "a concrete family reports the same registry fallback as its shim" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md new file mode 100644 index 0000000000..28c23a95cb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_current_concrete_package_manager_fallback.md @@ -0,0 +1,23 @@ +# command_env_current_concrete_package_manager_fallback + +A named family has an effective registry fallback even when no project, session, or default selection exists. + +## `vp env current pnpm --json` + +a concrete family reports the same registry fallback as its shim + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "registry fallback", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": false, + "mode": "managed" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml index c959e6c0d3..157dadd27b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots.toml @@ -10,3 +10,16 @@ steps = [ { tty = false, argv = ["vp", "add", "testnpm2", "-D"], comment = "should add package to auto-created package.json" }, { argv = ["vpt", "print-file", "package.json"], continue-on-failure = true }, ] + +[[case]] +name = "command_install_auto_pins_lockfile_manager" +vp = ["local", "global"] +comment = "A lockfile-inferred manager must become an exact project pin so later installs cannot drift to a newer release." +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"lockfile-project\",\"private\":true}\n"], snapshot = false }, + { argv = ["vpt", "touch-file", "pnpm-lock.yaml"], snapshot = false }, + { tty = false, argv = ["vp", "install", "--silent"], comment = "installing with a lockfile-inferred manager records the exact resolved version" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the inferred manager is pinned in devEngines" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md new file mode 100644 index 0000000000..79ece6ff71 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.global.md @@ -0,0 +1,35 @@ +# command_install_auto_pins_lockfile_manager + +A lockfile-inferred manager must become an exact project pin so later installs cannot drift to a newer release. + +## `vpt write-file package.json '{"name":"lockfile-project","private":true} +'` + + +## `vpt touch-file pnpm-lock.yaml` + + +## `vp install --silent` + +installing with a lockfile-inferred manager records the exact resolved version + +``` +``` + +## `vpt print-file package.json` + +the inferred manager is pinned in devEngines + +``` +{ + "name": "lockfile-project", + "private": true, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md new file mode 100644 index 0000000000..79ece6ff71 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_install_auto_create_package_json/snapshots/command_install_auto_pins_lockfile_manager.local.md @@ -0,0 +1,35 @@ +# command_install_auto_pins_lockfile_manager + +A lockfile-inferred manager must become an exact project pin so later installs cannot drift to a newer release. + +## `vpt write-file package.json '{"name":"lockfile-project","private":true} +'` + + +## `vpt touch-file pnpm-lock.yaml` + + +## `vp install --silent` + +installing with a lockfile-inferred manager records the exact resolved version + +``` +``` + +## `vpt print-file package.json` + +the inferred manager is pinned in devEngines + +``` +{ + "name": "lockfile-project", + "private": true, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "", + "onFail": "download" + } + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_no_package_json/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_no_package_json/snapshots.toml index c1d2526a3a..81e9b46c69 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_no_package_json/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_pm_no_package_json/snapshots.toml @@ -2,6 +2,7 @@ name = "command_pm_no_package_json" vp = ["local", "global"] skip-platforms = ["windows"] +env = { VP_PACKAGE_MANAGER = "npm@10.9.4" } steps = [ { argv = ["vp", "pm", "ls"], comment = "should show friendly error", continue-on-failure = true }, { argv = ["vp", "pm", "prune"], comment = "should show friendly error", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md index 84ab24569f..96ff3840a1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_upgrade_check/snapshots/command_upgrade_background_notice.md @@ -25,11 +25,11 @@ The next interactive command displays the cached update notice. ``` VITE+ - The Unified Toolchain for the Web -✓ Node.js management set to system-first. +✓ Node.js and package-manager management set to system-first. -All vp commands and shims will now prefer system Node.js, falling back to managed if not found. +Selected commands and shims will now prefer system tools, falling back to managed tools. -Run `vp env on` to always use Vite+ managed Node.js. +Run `vp env on` to always use Vite+ managed tools. vp update available: → 999.0.0, run vp upgrade ``` @@ -41,6 +41,9 @@ A subsequent command stays quiet after the notice timestamp is recorded. ``` VITE+ - The Unified Toolchain for the Web -Node.js management is already set to system-first. -All vp commands and shims will prefer system Node.js, falling back to managed if not found. +✓ Node.js and package-manager management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index aefcf8c16f..a29b06792f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -12,12 +12,17 @@ steps = [ [[case]] name = "install_yarn_corepack_hash_mismatch" vp = "global" +comment = "Every environment entry point must preserve and verify the same Corepack integrity suffix without repeating the expensive cache setup in separate cases." env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, + { argv = ["vp", "env", "install", "pm"], comment = "The explicit environment install accepts the verified project hash" }, { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, { argv = ["vp", "install"], comment = "The error names the artifact that the hash covers. vp does not download the CLI again", continue-on-failure = true }, + { argv = ["vp", "env", "install", "pm"], comment = "The explicit environment install rejects the mismatched project hash", continue-on-failure = true }, + { argv = ["vp", "env", "use", "pm", "--no-install"], envs = [["CI", "true"]], comment = "The session override preserves the project hash for first use" }, + { argv = ["yarn", "--version"], comment = "The package-manager shim enforces the hash retained by env use", continue-on-failure = true }, ] [[case]] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md index 6c1beb91ed..f9eab7a2bb 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md @@ -1,5 +1,7 @@ # install_yarn_corepack_hash_mismatch +Every environment entry point must preserve and verify the same Corepack integrity suffix without repeating the expensive cache setup in separate cases. + ## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` Ensure the Corepack-pinned Yarn version is not cached @@ -10,6 +12,17 @@ Ensure the Corepack-pinned Yarn version is not cached Cache the verified Yarn CLI +## `vp env install pm` + +The explicit environment install accepts the verified project hash + +``` +VITE+ - The Unified Toolchain for the Web + +Installing yarn ... +Installed yarn +``` + ## `vpt replace-file-content package.json b7ad4697 b7ad4698` Change the pin to a hash that the cached CLI does not match @@ -24,6 +37,39 @@ The error names the artifact that the hash covers. vp does not download the CLI ``` VITE+ - The Unified Toolchain for the Web -error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +error: Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. +``` + +## `vp env install pm` + +The explicit environment install rejects the mismatched project hash + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +Installing yarn ... +error: Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 +The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. +``` + +## `CI=true vp env use pm --no-install` + +The session override preserves the project hash for first use + +``` +Using yarn (resolved from packageManager) +``` + +## `yarn --version` + +The package-manager shim enforces the hash retained by env use + +**Exit code:** 1 + +``` +vp: Failed to resolve package manager for 'yarn': Install error: Hash mismatch for yarn@4.17.1: expected sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4698, got sha512.ccbfabf7d7b6b32075088be9386fb9a2e00bb6887ef07fa56effabc890a56d53da1ccc4128d62db245fcbd3961b236d75335bdf7d5320ed6eafb7588b7ad4697 The `packageManager` hash covers the extracted Yarn CLI (bin/yarn.js). Corepack hashes the same artifact. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/bin/pnpm b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/bin/pnpm new file mode 100644 index 0000000000..1dc0842dbb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/bin/pnpm @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'wrong-system-pnpm\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml new file mode 100644 index 0000000000..1f4bbee6f9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots.toml @@ -0,0 +1,71 @@ +[[case]] +name = "prefers_existing_family_and_records_choice" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-bin/yarn"], snapshot = false }, + { argv = ["pnpm", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ + { "expect-milestone" = "pm-shim-choice:pnpm" }, + { "write-key" = "down" }, + { "write-key" = "enter" }, + ] }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "the explicit system choice records only pnpm" }, + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "later pnpm invocations use the recorded choice without prompting" }, + { argv = ["yarn", "--version"], snapshot = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], interactions = [ + { "expect-milestone" = "pm-shim-choice:yarn" }, + { "write-key" = "down" }, + { "write-key" = "enter" }, + ] }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "Yarn records its own decision without changing pnpm" }, +] + +[[case]] +name = "system_package_manager_uses_system_first_node" +vp = "global" +comment = "Node and package-manager modes are independent: a system package manager must receive the Node.js selected by Node mode without forcing registry resolution." +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"system-package-manager\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/node"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/pnpm"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "bin/pnpm"], snapshot = false }, + { argv = ["vp", "env", "off", "node"], snapshot = false }, + { argv = ["vp", "env", "off", "pnpm"], snapshot = false }, + { argv = ["vp", "install"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "a system-first package manager resolves offline and receives the Node.js selected by system-first Node mode" }, + { argv = ["vp", "env", "exec", "--node", "20.18.0", "pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "explicit Node execution inspects the system manager before resolving its declared range" }, +] + +[[case]] +name = "managed_package_manager_uses_system_first_node" +vp = "global" +comment = "A managed package manager must still receive the system Node.js selected by Node mode." +seed-runtime = false +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"managed-package-manager\",\"private\":true,\"packageManager\":\"pnpm@10.18.0\"}\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", "#!/bin/sh\nnode --version\n"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx", "#!/bin/sh\nnode --version\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-dispatch-bin/node"], snapshot = false }, + { argv = ["vp", "env", "off", "node"], snapshot = false }, + { argv = ["vp", "env", "on", "pnpm"], snapshot = false }, + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]], comment = "a managed package manager receives system-first Node.js without resolving a managed runtime" }, + { argv = ["vp", "env", "print", "node"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH}"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]], comment = "Node environment printing uses the system runtime without resolving a managed runtime" }, +] + +[[case]] +name = "non_interactive_use_defaults_to_managed_without_deciding" +vp = "global" +comment = "Upgraded installations may have no recorded family mode; non-interactive use must stay deterministic without persisting consent on the user's behalf." +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "rm", "-f", "$VP_HOME/config.json"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system-bin/pnpm"], snapshot = false }, + { argv = ["pnpm", "--version"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "an undecided non-interactive shim uses managed pnpm without prompting" }, + { argv = ["vp", "env", "current", "pnpm", "--json"], tty = false, envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "environment inspection uses the same stable managed default" }, + { argv = ["vpt", "stat-file", "$VP_HOME/config.json", "--assert", "missing"], comment = "non-interactive use does not record a choice" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md new file mode 100644 index 0000000000..b87cf8a21f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/managed_package_manager_uses_system_first_node.md @@ -0,0 +1,51 @@ +# managed_package_manager_uses_system_first_node + +A managed package manager must still receive the system Node.js selected by Node mode. + +## `vpt write-file package.json '{"name":"managed-package-manager","private":true,"packageManager":"pnpm@10.18.0"} +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm '#'\!'/bin/sh +node --version +'` + + +## `vpt write-file $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx '#'\!'/bin/sh +node --version +'` + + +## `vpt chmod +x $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpm` + + +## `vpt chmod +x $VP_HOME/package_manager/pnpm/10.18.0/pnpm/bin/pnpx` + + +## `vpt chmod +x system-dispatch-bin/node` + + +## `vp env off node` + + +## `vp env on pnpm` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} VP_NODE_DIST_MIRROR=http://127.0.0.1:9 pnpm --version` + +a managed package manager receives system-first Node.js without resolving a managed runtime + +``` +system-node +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} VP_NODE_DIST_MIRROR=http://127.0.0.1:9 vp env print node` + +Node environment printing uses the system runtime without resolving a managed runtime + +``` +VITE+ - The Unified Toolchain for the Web + +# Add to your shell to use this environment for this session: +export PATH="/system-dispatch-bin:$PATH" +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md new file mode 100644 index 0000000000..98d0daba90 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/non_interactive_use_defaults_to_managed_without_deciding.md @@ -0,0 +1,45 @@ +# non_interactive_use_defaults_to_managed_without_deciding + +Upgraded installations may have no recorded family mode; non-interactive use must stay deterministic without persisting consent on the user's behalf. + +## `vpt rm -f $VP_HOME/config.json` + + +## `vpt chmod +x system-bin/pnpm` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` + +an undecided non-interactive shim uses managed pnpm without prompting + +``` +11.24.0 +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env current pnpm --json` + +environment inspection uses the same stable managed default + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "registry fallback", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": true, + "mode": "managed" + } +} +``` + +## `vpt stat-file $VP_HOME/config.json --assert missing` + +non-interactive use does not record a choice + +``` +/.vite-plus/config.json: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md new file mode 100644 index 0000000000..acdd1e77e2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/prefers_existing_family_and_records_choice.md @@ -0,0 +1,49 @@ +# prefers_existing_family_and_records_choice + +## `vpt rm -f $VP_HOME/config.json` + + +## `vpt chmod +x system-bin/pnpm` + + +## `vpt chmod +x system-bin/yarn` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` + + +## `vpt print-file $VP_HOME/config.json` + +the explicit system choice records only pnpm + +``` +{ + "packageManagerShimModes": { + "pnpm": "system_first" + } +} +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} pnpm --version` + +later pnpm invocations use the recorded choice without prompting + +``` +system-pnpm +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} yarn --version` + + +## `vpt print-file $VP_HOME/config.json` + +Yarn records its own decision without changing pnpm + +``` +{ + "packageManagerShimModes": { + "pnpm": "system_first", + "yarn": "system_first" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md new file mode 100644 index 0000000000..85cade8fb3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/snapshots/system_package_manager_uses_system_first_node.md @@ -0,0 +1,40 @@ +# system_package_manager_uses_system_first_node + +Node and package-manager modes are independent: a system package manager must receive the Node.js selected by Node mode without forcing registry resolution. + +## `vpt write-file package.json '{"name":"system-package-manager","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `vpt chmod +x system-dispatch-bin/node` + + +## `vpt chmod +x system-dispatch-bin/pnpm` + + +## `vpt chmod +x bin/pnpm` + + +## `vp env off node` + + +## `vp env off pnpm` + + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp install` + +a system-first package manager resolves offline and receives the Node.js selected by system-first Node mode + +``` +VITE+ - The Unified Toolchain for the Web + +system-node +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-dispatch-bin${PATH_SEPARATOR}${PATH} NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env exec --node 20.18.0 pnpm --version` + +explicit Node execution inspects the system manager before resolving its declared range + +``` +10.18.0 +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/pnpm b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/pnpm new file mode 100644 index 0000000000..a8b43d75ca --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/pnpm @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'system-pnpm\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/yarn b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/yarn new file mode 100644 index 0000000000..2af7f82425 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-bin/yarn @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'system-yarn\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/node b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/node new file mode 100644 index 0000000000..e5c7245721 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/node @@ -0,0 +1,2 @@ +#!/bin/sh +printf 'system-node\n' diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/pnpm b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/pnpm new file mode 100644 index 0000000000..a18e39da1b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_package_manager_first_use/system-dispatch-bin/pnpm @@ -0,0 +1,6 @@ +#!/bin/sh +if [ "${1-}" = "--version" ]; then + printf '10.18.0\n' +else + node --version +fi diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml index 4ef7aa3caa..b4a74e1431 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml @@ -12,3 +12,27 @@ steps = [ { argv = ["vpt", "write-file", ".node-version", ">=999.0.0\n"], snapshot = false }, { argv = ["pnpm", "--version"], envs = [["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]], comment = "JS package-manager shims report project Node resolution failures", continue-on-failure = true }, ] + +[[case]] +name = "shim_bun_default_latest_matches_fallback" +vp = "global" +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const {writeFileSync}=require('node:fs');writeFileSync(process.env.VP_HOME+'/bun-fallback-version',execFileSync('bun',['--version'],{encoding:'utf8'}))"], snapshot = false }, + { argv = ["vp", "env", "default", "bun@latest"], snapshot = false }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const {readFileSync}=require('node:fs');const expected=readFileSync(process.env.VP_HOME+'/bun-fallback-version','utf8');const actual=execFileSync('bun',['--version'],{encoding:'utf8'});if(actual!==expected)throw new Error(`expected ${expected.trim()}, got ${actual.trim()}`);console.log('default bun@latest matches the unconfigured fallback')"], envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "the explicit floating default behaves like the unconfigured Bun fallback" }, +] + +[[case]] +name = "shim_package_manager_defaults_are_independent" +vp = "global" +comment = "Package-manager defaults are per family so changing Bun cannot silently replace the pnpm shim's configured version." +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "env", "default", "pnpm@10.18.0"], snapshot = false }, + { argv = ["vp", "env", "default", "bun@1.2.0"], snapshot = false }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "pnpm and Bun defaults are persisted independently" }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const pnpm=execFileSync('pnpm',['--version'],{encoding:'utf8'}).trim();const bun=execFileSync('bun',['--version'],{encoding:'utf8'}).trim();if(pnpm!=='10.18.0'||bun!=='1.2.0')throw new Error(`expected pnpm 10.18.0 and bun 1.2.0, got pnpm ${pnpm} and bun ${bun}`);console.log('direct shims use independent defaults')"], comment = "direct package-manager shims use their own configured versions" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_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_package_manager_defaults_are_independent.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md new file mode 100644 index 0000000000..f27180a8e8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_package_manager_defaults_are_independent.md @@ -0,0 +1,36 @@ +# shim_package_manager_defaults_are_independent + +Package-manager defaults are per family so changing Bun cannot silently replace the pnpm shim's configured version. + +## `vp env default pnpm@10.18.0` + + +## `vp env default bun@1.2.0` + + +## `vpt print-file $VP_HOME/config.json` + +pnpm and Bun defaults are persisted independently + +``` +{ + "defaultPackageManagerVersions": { + "bun": "1.2.0", + "pnpm": "10.18.0" + }, + "packageManagerShimModes": { + "bun": "managed", + "npm": "managed", + "pnpm": "managed", + "yarn": "managed" + } +} +``` + +## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const pnpm=execFileSync('\''pnpm'\'',['\''--version'\''],{encoding:'\''utf8'\''}).trim();const bun=execFileSync('\''bun'\'',['\''--version'\''],{encoding:'\''utf8'\''}).trim();if(pnpm'\!'=='\''10.18.0'\''||bun'\!'=='\''1.2.0'\'')throw new Error(`expected pnpm 10.18.0 and bun 1.2.0, got pnpm ${pnpm} and bun ${bun}`);console.log('\''direct shims use independent defaults'\'')'` + +direct package-manager shims use their own configured versions + +``` +direct shims use independent defaults +``` diff --git a/crates/vp_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 cbe09b519d..f5e7d8ea6c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -693,12 +693,28 @@ impl CaseHome { .envs(&env) .output() .map_err(|e| format!("failed to run `vp env setup`: {e}"))?; + if !output.status.success() { + return Err(format!( + "`vp env setup` failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + + // Cases start from fresh-install consent. A dedicated first-use fixture + // removes this config before exercising upgrade compatibility. + let output = std::process::Command::new(vp) + .args(["env", "on", "pm"]) + .env_clear() + .envs(&env) + .output() + .map_err(|e| format!("failed to run `vp env on pm`: {e}"))?; if output.status.success() { return Ok(()); } - Err(format!( - "`vp env setup` failed with status {}\nstdout:\n{}\nstderr:\n{}", + "`vp env on pm` failed with status {}\nstdout:\n{}\nstderr:\n{}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index 70e661e3ad..1301e2dc33 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -57,6 +57,25 @@ static TOOL_VERSION_RE: LazyLock = LazyLock::new(|| { static BUN_BUILD_HASH_RE: LazyLock = LazyLock::new(|| { regex::Regex::new(r"(\bbun(?: [a-z-]+)* )\([0-9a-f]{6,12}\)").unwrap() }); +// Environment diagnostics report executable paths using the platform's +// distribution layout. Normalize Windows `.exe`/`.cmd` locations to the Unix +// spelling used by the shared snapshots. +static WINDOWS_MANAGED_NODE_BIN_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"(/.vite-plus/js_runtime/node/(?:|\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?))/node\.exe\b", + ) + .unwrap() +}); +static WINDOWS_MANAGED_PM_BIN_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r#"(/.vite-plus/package_manager/[^"\r\n]+/bin/[A-Za-z0-9._-]+)\.cmd\b"#) + .unwrap() +}); +static COMMAND_NOT_FOUND_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"(Command execution failed: )(?:No such file or directory \(os error 2\)|program not found)", + ) + .unwrap() +}); // The workspace's own vite-plus / @voidzero-dev/vite-plus-core version is // written verbatim into scaffolded catalogs and manifests (`vite-plus: 0.2.3`, // `"vite-plus": "0.2.3"`, `npm:@voidzero-dev/vite-plus-core@0.2.3`). Unlike @@ -135,13 +154,14 @@ static MANAGED_TEST_VERSION_RE: LazyLock = LazyLock::new(|| { }); // Environment-management output prints the resolving runtime as a labelled // `Node:` field, an installed-package table column, or the current `lts` -// target. The npm shim also records the node it ran under into a BinConfig's -// `"nodeVersion"` value. All track the environment's managed default (not a -// fixture pin), so they churn with runtime upgrades; mask by context so -// fixture-pinned versions elsewhere stay assertable. +// target. Default inspection also prints the current target in its fallback +// and alias-resolution messages. The npm shim records the node it ran under +// into a BinConfig's `"nodeVersion"` value. All track the environment's managed +// default (not a fixture pin), so they churn with runtime upgrades; mask by +// context so fixture-pinned versions elsewhere stay assertable. static WHICH_NODE_VERSION_RE: LazyLock = LazyLock::new(|| { regex::Regex::new( - r#"(?m)(Node:\s+|"nodeVersion":\s*"|Default Node\.js version set to [^()\n]+ \(currently |^\S+@\S+\s{2,})\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?"#, + r#"(?m)(Node:\s+|"nodeVersion":\s*"|Default Node\.js version set to [^()\n]+ \(currently |No default Node\.js version configured\. Using latest LTS \(|Currently resolves to:\s+|^\S+@\S+\s{2,})\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?"#, ) .unwrap() }); @@ -412,6 +432,12 @@ pub fn redact_output( redactions.iter().map(|(from, to)| (from.as_str(), *to)).collect(); redact_string(&mut output, &borrowed, normalize_separators); + // Normalize platform-specific managed executable paths and missing-command + // diagnostics before applying the general version redactions below. + output = WINDOWS_MANAGED_NODE_BIN_RE.replace_all(&output, "${1}/bin/node").into_owned(); + output = WINDOWS_MANAGED_PM_BIN_RE.replace_all(&output, "${1}").into_owned(); + output = COMMAND_NOT_FOUND_RE.replace_all(&output, "${1}program not found").into_owned(); + // Redact UUIDs to "" output = UUID_RE.replace_all(&output, "").into_owned(); diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index c67c707599..5c264e6756 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -87,12 +87,36 @@ fn masks_bun_build_hash_only_in_bun_banners() { assert_eq!(redact_output(unrelated.clone(), &[], true), unrelated); } +#[test] +fn normalizes_managed_executable_paths_and_missing_commands() { + let input = concat!( + r#""bin_path": "/.vite-plus/js_runtime/node/24.18.1/node.exe""#, + "\n", + r#""pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm.cmd""#, + "\n", + "error: Command execution failed: No such file or directory (os error 2)\n", + ) + .to_owned(); + assert_eq!( + redact_output(input, &[], true), + concat!( + r#""bin_path": "/.vite-plus/js_runtime/node//bin/node""#, + "\n", + r#""pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm""#, + "\n", + "error: Command execution failed: program not found\n", + ) + ); +} + #[test] fn masks_managed_node_versions_in_environment_output() { let input = concat!( "Node: 24.18.1\n", "\"nodeVersion\": \"24.18.1\"\n", "✓ Default Node.js version set to lts (currently 24.18.1)\n", + "No default Node.js version configured. Using latest LTS (24.18.1).\n", + " Currently resolves to: 24.18.1\n", "just-a-normal-package@0.0.0 24.18.1 just-a-normal-package\n", "fixture pin: 22.18.0\n", ) @@ -103,6 +127,8 @@ fn masks_managed_node_versions_in_environment_output() { "Node: \n", "\"nodeVersion\": \"\"\n", "✓ Default Node.js version set to lts (currently )\n", + "No default Node.js version configured. Using latest LTS ().\n", + " Currently resolves to: \n", "just-a-normal-package@0.0.0 just-a-normal-package\n", "fixture pin: 22.18.0\n", ) diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index bb411ba613..06f8b7acd8 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -12,6 +12,7 @@ name = "vp" path = "src/main.rs" [dependencies] +base64-simd = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true, features = ["unstable-dynamic"] } diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 00f28b5bc8..8c7fcab0cb 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -217,7 +217,7 @@ pub enum Commands { global: bool, }, - /// Manage Node.js versions + /// Manage Node.js and package-manager environments Env(EnvArgs), // ========================================================================= @@ -295,30 +295,46 @@ pub struct EnvArgs { pub enum EnvSubcommands { /// Show current environment information Current { + /// Limit output to node, pm, or a package-manager family + scope: Option, + /// Output in JSON format #[arg(long)] json: bool, }, /// Print shell snippet to set environment for current session - Print, + Print { + /// Limit output to node, pm, or a package-manager family + scope: Option, + }, - /// Set or show the global default Node.js version + /// Set or show global Node.js and per-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 pnpm's default version")] 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, package managers, or one package manager + On { + /// Change only node, pm, npm, pnpm, yarn, or bun mode + scope: Option, + }, - /// Enable system-first mode - shims prefer system Node.js, fallback to managed - Off, + /// Enable system-first mode for Node.js, package managers, or one package manager + Off { + /// Change only node, pm, npm, pnpm, yarn, or bun mode + scope: Option, + }, /// Create or update shims in VP_HOME/bin Setup { @@ -331,20 +347,24 @@ Examples: }, /// Run diagnostics and show environment status - Doctor, + Doctor { + /// Limit diagnostics to node or package managers + scope: Option, + }, /// Show path to the tool that would be executed Which { - /// Tool name (node, npm, or npx) + /// Tool name resolved through the environment shims tool: String, }, - /// Pin a Node.js version in the current directory - /// (updates .node-version or package.json#devEngines.runtime) + /// Pin Node.js and package-manager versions in the current directory #[command(after_long_help = "\ Examples: - vp env pin lts # Pin to latest LTS - vp env pin --unpin # Remove the pin + vp env pin lts # Pin Node.js to latest LTS + vp env pin pnpm@10 # Pin the package manager + vp env pin 22 pnpm@10 # Pin both components + vp env pin --unpin # Remove both effective pins vp env pin \"^20.0.0\" --force # Overwrite existing pin vp env pin 24 --target node-version # Force the .node-version file @@ -352,9 +372,8 @@ The write target follows the compatibility-first rule: an existing .node-version keeps being updated; otherwise the pin is written to package.json#devEngines.runtime; .node-version is only created when the directory has no package.json.")] Pin { - /// Version to pin (e.g., "20.18.0", "lts", "latest", "^20.0.0"). - /// If omitted, prints the currently pinned version. - version: Option, + /// Versions to pin. Bare versions select Node.js; package managers use name@version. + specs: Vec, /// Remove the pin from the current directory #[arg(long)] @@ -373,26 +392,32 @@ keeps being updated; otherwise the pin is written to package.json#devEngines.run target: Option, }, - /// Remove the Node.js pin from current directory (alias for `pin --unpin`) + /// Remove environment pins from the current directory (alias for `pin --unpin`) Unpin { + /// Limit removal to node, pm, or a package-manager family + scope: Option, + /// Explicitly choose which pin source to remove #[arg(long, value_enum)] target: Option, }, - /// List locally installed Node.js versions + /// List locally installed Node.js and package-manager versions #[command(visible_alias = "ls")] List { + /// Limit output to node, pm, or a package-manager family + scope: Option, + /// Output as JSON #[arg(long)] json: bool, }, - /// List available Node.js versions from the registry + /// List available Node.js and package-manager versions from registries #[command(name = "list-remote", visible_alias = "ls-remote")] ListRemote { - /// Filter versions by pattern (e.g., "20" for 20.x versions) - pattern: Option, + /// Optional component selector followed by a version pattern + values: Vec, /// Show only LTS versions #[arg(long)] @@ -411,13 +436,14 @@ keeps being updated; otherwise the pin is written to package.json#devEngines.run sort: SortingMethod, }, - /// Execute a command with a specific Node.js version + /// Execute a command in a resolved or explicit environment #[command( visible_alias = "run", after_long_help = "\ Examples: - vp env exec --node lts npm install # Pin version for this invocation - vp env exec node -v # Shim mode: version auto-resolved" + vp env exec --node lts node -v # Override Node.js + vp env exec --package-manager pnpm@12 pnpm install # Override the package manager + vp env exec node -v # Resolve the full environment" )] Exec { /// Node.js version to use (e.g., "20.18.0", "lts", "^20.0.0"). @@ -426,43 +452,49 @@ Examples: #[arg(long)] node: Option, - /// npm version to use (optional, defaults to bundled) + /// npm version to use (alias for --package-manager npm@) #[arg(long)] npm: Option, + /// Package manager and version to use (for example, pnpm@10) + #[arg(long)] + package_manager: Option, + /// Command and arguments to run #[arg(trailing_var_arg = true, allow_hyphen_values = true)] command: Vec, }, - /// Uninstall a Node.js version + /// Uninstall explicit Node.js or package-manager versions #[command(visible_alias = "uni")] Uninstall { - /// Version to uninstall (e.g., "20.18.0") + /// Versions to uninstall. Bare versions select Node.js. #[arg(required = true)] - version: String, + specs: Vec, }, /// Remove unused managed runtimes and package manager caches - Clean, + Clean { + /// Limit cleanup to node, pm, or a package-manager family + scope: Option, + }, - /// Install a Node.js version + /// Install a resolved or explicit environment #[command(visible_alias = "i")] Install { - /// Version to install (e.g., "20", "20.18.0", "lts", "latest") - /// If not provided, installs the version from .node-version, package.json, or .nvmrc - version: Option, + /// Component selectors or explicit versions to install + requests: Vec, }, - /// Use a specific Node.js version for this shell session + /// Activate Node.js and package-manager versions for this shell session #[command(after_long_help = "\ Examples: - vp env use lts # Override session with latest LTS - vp env use --unset # Clear the session override")] + vp env use 22.19.0 # Override Node.js for this session + vp env use pnpm@12 # Override the package manager + vp env use --unset # Clear both session overrides")] Use { - /// Version to use (e.g., "20", "20.18.0", "lts", "latest"). - /// If omitted, reads from .node-version, package.json, or .nvmrc. - version: Option, + /// Component selectors or explicit versions to activate + requests: Vec, /// Remove session override (revert to file-based resolution) #[arg(long)] @@ -481,7 +513,9 @@ Examples: impl EnvSubcommands { fn is_quiet_or_machine_readable(&self) -> bool { match self { - Self::Current { json } | Self::List { json } | Self::ListRemote { json, .. } => *json, + Self::Current { json, .. } + | Self::List { json, .. } + | Self::ListRemote { json, .. } => *json, _ => false, } } @@ -494,6 +528,8 @@ pub enum PinTarget { NodeVersion, /// Pin via package.json#devEngines.runtime DevEngines, + /// Pin via the top-level packageManager field + PackageManager, } /// Version sorting order for list-remote command @@ -638,7 +674,27 @@ async fn run_package_manager_command( } commands::prepend_js_runtime_to_path_env(&cwd).await?; - let result = vp_pm_cli::dispatch_with_metadata(&cwd, command).await?; + let selected = commands::env::package_manager::resolve_current_spec(&cwd).await?; + let result = if let Some(selected) = selected.as_ref() + && commands::env::config::load_config() + .await? + .package_manager_shim_mode_for(selected.package_manager_type) + == commands::env::config::ShimMode::SystemFirst + && let Some(system_path) = + crate::shim::dispatch::find_system_tool(&selected.package_manager_type.to_string()) + && let Some(manager) = + system_package_manager(selected.package_manager_type, &system_path).await + { + vp_pm_cli::dispatch_with_resolved_package_manager(&cwd, command, manager, selected).await? + } else { + let selected = commands::env::package_manager::resolve_current(&cwd).await?; + match selected { + Some(selected) => { + vp_pm_cli::dispatch_with_package_manager(&cwd, command, &selected).await? + } + None => vp_pm_cli::dispatch_with_metadata(&cwd, command).await?, + } + }; if result.status.success() && let Some(packages) = result.why_hint_packages.as_deref() { @@ -668,6 +724,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 bin_prefix = executable.parent()?.to_absolute_path_buf(); + Some(vp_pm_cli::PackageManager::from_bin_prefix(kind, version, bin_prefix)) +} + async fn managed_install( packages: &[String], node: Option<&str>, diff --git a/crates/vp_global_cli/src/command_picker.rs b/crates/vp_global_cli/src/command_picker.rs index 815fe9d88f..da3d0af487 100644 --- a/crates/vp_global_cli/src/command_picker.rs +++ b/crates/vp_global_cli/src/command_picker.rs @@ -109,7 +109,7 @@ const COMMANDS: &[CommandEntry] = &[ CommandEntry { label: "env", command: "env", - summary: "Manage Node.js versions.", + summary: "Manage Node.js and package managers.", append_help: false, }, CommandEntry { diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index 8faad4c0fc..bb79fdc6db 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -5,36 +5,65 @@ use std::{path::Path, process::ExitStatus}; +use vp_pm_cli::{PackageManagerType, resolve_package_manager_version}; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use super::{config, list::list_installed_versions}; +use super::{config, list::list_installed_versions, package_manager, spec::EnvScope}; use crate::error::Error; /// Execute the clean command. -pub async fn execute(cwd: AbsolutePathBuf) -> Result { +pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; let config = vp_shared::EnvConfig::get(); let data_dir = &config.dirs.data; let node_dir = data_dir.join("js_runtime").join("node"); let package_manager_dir = data_dir.join("package_manager"); - let protected_versions = protected_node_versions(&cwd).await?; - - let node_runtimes_removed = - clean_node_runtimes(node_dir.as_path(), &protected_versions).await?; - output::success(&format!( - "Removed {node_runtimes_removed} Node.js runtime{}", - plural(node_runtimes_removed) - )); + if scope.includes_node() { + let protected_versions = protected_node_versions(&cwd).await?; + let removed = clean_node_runtimes(node_dir.as_path(), &protected_versions).await?; + output::success(&format!("Removed {removed} Node.js runtime{}", plural(removed))); + } - let package_managers_removed = clean_package_managers(package_manager_dir.as_path()).await?; - output::success(&format!( - "Removed {package_managers_removed} package manager install{}", - plural(package_managers_removed) - )); + if scope.includes_package_managers() { + let mut removed = 0; + for kind in package_manager::selected(scope) { + let protected = match protected_package_manager(&cwd, kind).await { + Ok(protected) => protected, + Err(error) => { + output::warn(&format!( + "Could not resolve protected {kind} versions; {kind} cleanup was skipped: {error}" + )); + continue; + } + }; + removed += clean_package_managers( + package_manager_dir.as_path(), + &[kind], + &[(kind, protected)], + ) + .await?; + } + output::success(&format!("Removed {removed} package manager install{}", plural(removed))); + } Ok(ExitStatus::default()) } +async fn protected_package_manager( + cwd: &AbsolutePath, + kind: PackageManagerType, +) -> Result, Error> { + let current = package_manager::resolve_current_or_fallback_for(cwd, kind).await?; + let mut protected = vec![current.version.to_string()]; + let config = config::load_config().await?; + if let Some((_, selector, _)) = package_manager::configured_default_for(&config, kind)? { + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + push_unique_version(&mut protected, version); + } + Ok(protected) +} + async fn protected_node_versions(cwd: &AbsolutePath) -> Result, Error> { let mut versions = Vec::new(); push_unique_version(&mut versions, config::resolve_version(cwd).await?.version); @@ -65,36 +94,29 @@ async fn clean_node_runtimes( Ok(removed) } -async fn clean_package_managers(package_manager_dir: &Path) -> Result { - let installs = count_package_manager_installs(package_manager_dir).await?; - if installs > 0 { - remove_dir_all_if_exists(package_manager_dir).await?; - } - Ok(installs) -} - -async fn count_package_manager_installs(package_manager_dir: &Path) -> Result { - let mut package_manager_entries = match tokio::fs::read_dir(package_manager_dir).await { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(e) => return Err(e.into()), - }; - - let mut count = 0; - while let Some(package_manager_entry) = package_manager_entries.next_entry().await? { - if !package_manager_entry.file_type().await?.is_dir() { - continue; - } - - let mut version_entries = tokio::fs::read_dir(package_manager_entry.path()).await?; - while let Some(version_entry) = version_entries.next_entry().await? { - if version_entry.file_type().await?.is_dir() { - count += 1; +async fn clean_package_managers( + package_manager_dir: &Path, + selected: &[PackageManagerType], + protected: &[(PackageManagerType, Vec)], +) -> Result { + let mut removed = 0; + for kind in selected { + let family = package_manager_dir.join(kind.to_string()); + let protected_versions = protected + .iter() + .find(|(protected_kind, _)| protected_kind == kind) + .map(|(_, versions)| versions.as_slice()) + .unwrap_or_default(); + for version in list_installed_versions(&family) { + if protected_versions.contains(&version) { + continue; + } + if remove_dir_all_if_exists(&family.join(version)).await? { + removed += 1; } } } - - Ok(count) + Ok(removed) } async fn remove_dir_all_if_exists(path: &Path) -> Result { @@ -144,16 +166,23 @@ mod tests { } #[tokio::test] - async fn clean_package_managers_removes_all_cached_installs() { + async fn clean_package_managers_preserves_selected_version() { let temp_dir = TempDir::new().unwrap(); let package_manager_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); tokio::fs::create_dir_all(package_manager_dir.join("pnpm").join("10.0.0")).await.unwrap(); tokio::fs::create_dir_all(package_manager_dir.join("npm").join("11.0.0")).await.unwrap(); tokio::fs::write(package_manager_dir.join("pnpm").join("10.0.0.lock"), "").await.unwrap(); - let removed = clean_package_managers(package_manager_dir.as_path()).await.unwrap(); + let removed = clean_package_managers( + package_manager_dir.as_path(), + &[PackageManagerType::Npm, PackageManagerType::Pnpm], + &[(PackageManagerType::Pnpm, vec!["10.0.0".into()])], + ) + .await + .unwrap(); - assert_eq!(removed, 2); - assert!(!package_manager_dir.as_path().exists()); + assert_eq!(removed, 1); + assert!(package_manager_dir.join("pnpm").join("10.0.0").as_path().exists()); + assert!(!package_manager_dir.join("npm").join("11.0.0").as_path().exists()); } } diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 8cb1bffc10..7df905e760 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -5,13 +5,17 @@ //! - Version resolution with priority order //! - Config file management -use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use serde::{Deserialize, Deserializer, Serialize, de}; use vp_js_runtime::{ NodeProvider, VersionSource, is_valid_version, normalize_version, read_nvmrc_file, read_package_json, resolve_node_version, }; +use vp_pm_cli::PackageManagerType; use vt_path::{AbsolutePath, AbsolutePathBuf}; +use super::package_manager::ALL_PACKAGE_MANAGERS; use crate::error::Error; /// Config file name @@ -35,9 +39,106 @@ 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, - /// Shim mode for tool resolution - #[serde(default, skip_serializing_if = "is_default_shim_mode")] - pub shim_mode: ShimMode, + /// Default versions used by package-manager shims when the project does not select that family. + #[serde( + default, + alias = "defaultPackageManager", + deserialize_with = "deserialize_package_manager_default_versions", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub default_package_manager_versions: BTreeMap, + /// Node.js shim mode. `shimMode` is accepted as the legacy field name. + #[serde(default, alias = "shimMode", skip_serializing_if = "is_default_shim_mode")] + pub node_shim_mode: ShimMode, + /// Explicit per-package-manager shim modes. An absent family has not been configured yet. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub package_manager_shim_modes: BTreeMap, +} + +impl Config { + #[must_use] + pub fn default_package_manager_version_for( + &self, + package_manager: PackageManagerType, + ) -> Option<&str> { + self.default_package_manager_versions.get(&package_manager.to_string()).map(String::as_str) + } + + pub fn set_default_package_manager_version( + &mut self, + package_manager: PackageManagerType, + version: String, + ) { + self.default_package_manager_versions.insert(package_manager.to_string(), version); + } + + pub fn clear_default_package_manager_version(&mut self, package_manager: PackageManagerType) { + self.default_package_manager_versions.remove(&package_manager.to_string()); + } + + #[must_use] + pub fn configured_package_manager_shim_mode_for( + &self, + package_manager: PackageManagerType, + ) -> Option { + self.package_manager_shim_modes.get(&package_manager.to_string()).copied() + } + + #[must_use] + pub fn package_manager_shim_mode_for(&self, package_manager: PackageManagerType) -> ShimMode { + self.configured_package_manager_shim_mode_for(package_manager).unwrap_or_default() + } + + pub fn set_shim_modes(&mut self, node: bool, package_manager: bool, mode: ShimMode) { + if node { + self.node_shim_mode = mode; + } + if package_manager { + self.set_all_package_manager_shim_modes(mode); + } + } + + pub fn set_all_package_manager_shim_modes(&mut self, mode: ShimMode) { + self.package_manager_shim_modes.clear(); + for package_manager in ALL_PACKAGE_MANAGERS { + self.set_package_manager_shim_mode(package_manager, mode); + } + } + + pub fn set_package_manager_shim_mode( + &mut self, + package_manager: PackageManagerType, + mode: ShimMode, + ) { + self.package_manager_shim_modes.insert(package_manager.to_string(), mode); + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum PackageManagerDefaultVersions { + Versions(BTreeMap), + Legacy(String), +} + +fn deserialize_package_manager_default_versions<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + match PackageManagerDefaultVersions::deserialize(deserializer)? { + PackageManagerDefaultVersions::Versions(versions) => Ok(versions), + PackageManagerDefaultVersions::Legacy(spec) => { + let (name, version) = spec + .split_once('@') + .filter(|(name, version)| { + PackageManagerType::from_name(name).is_some() && !version.is_empty() + }) + .ok_or_else(|| de::Error::custom("invalid legacy package-manager default"))?; + Ok(BTreeMap::from([(name.to_string(), version.to_string())])) + } + } } /// Check if shim mode is the default (for skip_serializing_if) @@ -139,14 +240,24 @@ pub async fn save_config(config: &Config) -> Result<(), Error> { /// Set by `vp env use` command. pub const VERSION_ENV_VAR: &str = vp_shared::env_vars::VP_NODE_VERSION; +/// Environment variable for the per-shell package-manager override. +pub const PACKAGE_MANAGER_ENV_VAR: &str = vp_shared::env_vars::VP_PACKAGE_MANAGER; + /// Session version file name, written by `vp env use` so shims work without the shell eval wrapper. pub const SESSION_VERSION_FILE: &str = ".session-node-version"; +/// Package-manager session override file name. +pub const SESSION_PACKAGE_MANAGER_FILE: &str = ".session-package-manager"; + /// Get the path to the session version file (`/.session-node-version`). pub fn get_session_version_path() -> Result { Ok(vp_shared::EnvConfig::get().dirs.state.join(SESSION_VERSION_FILE)) } +pub fn get_session_package_manager_path() -> Result { + Ok(vp_shared::EnvConfig::get().dirs.state.join(SESSION_PACKAGE_MANAGER_FILE)) +} + /// Read the session version file. Returns `None` if the file is missing or empty. pub async fn read_session_version() -> Option { let path = get_session_version_path().ok()?; @@ -155,6 +266,13 @@ pub async fn read_session_version() -> Option { if trimmed.is_empty() { None } else { Some(trimmed) } } +pub async fn read_session_package_manager() -> Option { + let path = get_session_package_manager_path().ok()?; + let content = tokio::fs::read_to_string(path).await.ok()?; + let trimmed = content.trim().to_string(); + if trimmed.is_empty() { None } else { Some(trimmed) } +} + /// Read the session version file synchronously. Returns `None` if the file is missing or empty. pub fn read_session_version_sync() -> Option { let path = get_session_version_path().ok()?; @@ -174,6 +292,15 @@ pub async fn write_session_version(version: &str) -> Result<(), Error> { Ok(()) } +pub async fn write_session_package_manager(spec: &str) -> Result<(), Error> { + let path = get_session_package_manager_path()?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, spec).await?; + Ok(()) +} + /// Delete the session version file. Ignores "not found" errors. pub async fn delete_session_version() -> Result<(), Error> { let path = get_session_version_path()?; @@ -184,6 +311,15 @@ pub async fn delete_session_version() -> Result<(), Error> { } } +pub async fn delete_session_package_manager() -> Result<(), Error> { + let path = get_session_package_manager_path()?; + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + /// Resolve Node.js version for a directory. /// /// Resolution order: @@ -1396,4 +1532,88 @@ mod tests { ) .await; } + + #[test] + fn node_only_mode_change_leaves_package_managers_undecided() { + let mut config = Config::default(); + config.set_shim_modes(true, false, ShimMode::SystemFirst); + assert_eq!(config.node_shim_mode, ShimMode::SystemFirst); + assert_eq!(config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), None); + assert_eq!( + config.package_manager_shim_mode_for(PackageManagerType::Pnpm), + ShimMode::Managed + ); + } + + #[test] + fn package_manager_mode_change_preserves_other_package_manager_modes() { + let mut config = Config::default(); + config.set_package_manager_shim_mode(PackageManagerType::Pnpm, ShimMode::SystemFirst); + + assert_eq!( + config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), + Some(ShimMode::SystemFirst) + ); + assert_eq!(config.configured_package_manager_shim_mode_for(PackageManagerType::Bun), None); + + config.set_shim_modes(false, true, ShimMode::Managed); + assert_eq!( + config.configured_package_manager_shim_mode_for(PackageManagerType::Pnpm), + Some(ShimMode::Managed) + ); + assert_eq!( + config.configured_package_manager_shim_mode_for(PackageManagerType::Bun), + Some(ShimMode::Managed) + ); + assert_eq!(config.package_manager_shim_modes.len(), ALL_PACKAGE_MANAGERS.len()); + } + + #[test] + fn package_manager_only_mode_change_preserves_node_mode() { + let mut config = Config { node_shim_mode: ShimMode::SystemFirst, ..Config::default() }; + config.set_shim_modes(false, true, ShimMode::Managed); + assert_eq!(config.node_shim_mode, ShimMode::SystemFirst); + assert_eq!(config.package_manager_shim_modes.len(), ALL_PACKAGE_MANAGERS.len()); + } + + #[test] + fn legacy_shim_mode_loads_as_node_shim_mode() { + let config: Config = serde_json::from_str(r#"{"shimMode":"system_first"}"#).unwrap(); + + assert_eq!(config.node_shim_mode, ShimMode::SystemFirst); + assert_eq!( + serde_json::to_value(config).unwrap(), + serde_json::json!({ "nodeShimMode": "system_first" }) + ); + } + + #[test] + fn legacy_package_manager_default_migrates_to_version_map() { + let config: Config = + serde_json::from_str(r#"{"defaultPackageManager":"pnpm@10.18.0"}"#).unwrap(); + + assert_eq!( + config.default_package_manager_version_for(PackageManagerType::Pnpm), + Some("10.18.0") + ); + assert_eq!( + serde_json::to_value(config).unwrap()["defaultPackageManagerVersions"]["pnpm"], + "10.18.0" + ); + } + + #[tokio::test] + async fn package_manager_session_file_round_trip() { + let temp_dir = TempDir::new().unwrap(); + vp_shared::EnvConfig::with_vars_async( + [(vp_shared::env_vars::VP_HOME, temp_dir.path())], + |_| async { + write_session_package_manager("pnpm@10.18.0").await.unwrap(); + assert_eq!(read_session_package_manager().await.as_deref(), Some("pnpm@10.18.0")); + delete_session_package_manager().await.unwrap(); + assert!(read_session_package_manager().await.is_none()); + }, + ) + .await; + } } diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 94757c95c8..10fc9875a5 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -1,67 +1,52 @@ -//! 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)]) { +fn print_rows(title: &str, rows: &[(String, String)]) { println!("{}", help::render_heading(title)); let label_width = rows.iter().map(|(label, _)| label.chars().count()).max().unwrap_or(0); for (label, value) in rows { @@ -70,90 +55,196 @@ fn print_rows(title: &str, rows: &[(&str, String)]) { } } -/// Execute the current command. -pub async fn execute(cwd: AbsolutePathBuf, json: bool) -> Result { - let resolution = resolve_version(&cwd).await?; - let package_manager = resolve_package_manager_info(&cwd); - - // Get the install directory for this version - let home_dir = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(&resolution.version); - - #[cfg(windows)] - let (node_path, npm_path, npx_path) = - { (home_dir.join("node.exe"), home_dir.join("npm.cmd"), home_dir.join("npx.cmd")) }; - - #[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"), - ) +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.node_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.node_shim_mode, + }) + } else if scope.includes_node() { + let resolution = resolve_version(&cwd).await?; + let home = vp_shared::EnvConfig::get() + .dirs + .data + .join("js_runtime") + .join("node") + .join(&resolution.version); + #[cfg(windows)] + let bin_path = home.join("node.exe"); + #[cfg(not(windows))] + let bin_path = home.join("bin").join("node"); + Some(NodeInfo { + version: resolution.version, + source: resolution.source, + source_path: resolution.source_path.map(|path| path.as_path().display().to_string()), + project_root: resolution.project_root.map(|path| path.as_path().display().to_string()), + installed: bin_path.as_path().exists(), + bin_path: bin_path.as_path().display().to_string(), + mode: config.node_shim_mode, + }) + } else { + None + }; + + let package_manager = if scope.includes_package_managers() { + resolve_package_manager_info(&cwd, scope, &config).await? + } 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".into(), node.version), + ("Source".into(), node.source), + ("Bin Path".into(), node.bin_path), + ("Installed".into(), node.installed.to_string()), + ("Mode".into(), mode_name(node.mode).into()), ], ); - 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), - ], - ); } + let mut rows = vec![ + ("Name".into(), package_manager.name), + ("Version".into(), package_manager.version), + ("Source".into(), package_manager.source), + ("Bin Paths".into(), String::new()), + ]; + rows.extend( + package_manager.bin_paths.into_iter().map(|(name, path)| (format!(" {name}"), path)), + ); + rows.extend([ + ("Installed".into(), package_manager.installed.to_string()), + ("Mode".into(), mode_name(package_manager.mode).into()), + ]); + print_rows("Package Manager", &rows); } Ok(ExitStatus::default()) } -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()) +} + +async fn resolve_package_manager_info( + cwd: &vt_path::AbsolutePath, + scope: EnvScope, + config: &config::Config, +) -> Result, Error> { + let selected = package_manager::resolve_current_spec(cwd).await?.filter(|resolution| { + scope.package_manager().is_none_or(|expected| expected == resolution.package_manager_type) + }); + let selected_type = selected + .as_ref() + .map(|resolution| resolution.package_manager_type) + .or_else(|| scope.package_manager()); + let Some(selected_type) = selected_type else { + return Ok(None); + }; + let mode = config.package_manager_shim_mode_for(selected_type); + if mode == ShimMode::SystemFirst { + let bin_paths = selected_type + .bin_names() + .iter() + .filter_map(|name| { + crate::shim::dispatch::find_system_tool(name) + .map(|path| ((*name).to_string(), path.as_path().display().to_string())) + }) + .collect::>(); + if let Some(primary) = bin_paths.get(selected_type.to_string().as_str()) + && let Some(primary) = AbsolutePathBuf::new(primary.into()) + { + return Ok(Some(PackageManagerInfo { + name: selected_type.to_string(), + version: read_tool_version(&primary).await.unwrap_or_else(|| "unknown".into()), + source: "system PATH".into(), + source_path: None, + project_root: selected.as_ref().and_then(|resolution| { + resolution + .project_root + .as_ref() + .map(|path| path.as_path().display().to_string()) + }), + installed: true, + bin_paths, + mode, + })); + } + } + + let resolution = match scope.package_manager() { + Some(package_manager) => { + Some(package_manager::resolve_current_or_fallback_for(cwd, package_manager).await?) + } + None => package_manager::resolve_current_for(cwd, None).await?, + }; + let Some(resolution) = resolution else { + return Ok(None); + }; + let package_manager_type = resolution.package_manager_type; + let version = resolution.version.to_string(); + let source = resolution.source.to_string(); + let source_path = resolution.source_path.map(|path| path.as_path().display().to_string()); + let project_root = resolution.project_root.map(|path| path.as_path().display().to_string()); + let Some(install_dir) = package_manager_install_dir(package_manager_type, &version) else { + return Ok(None); + }; + let bin_paths = package_manager_type + .bin_names() + .iter() + .map(|name| { + ( + (*name).to_string(), + package_manager_bin_path(&install_dir, name).as_path().display().to_string(), + ) + }) + .collect::>(); + let installed = bin_paths.values().all(|path| std::path::Path::new(path).exists()); + Ok(Some(PackageManagerInfo { + name: package_manager_type.to_string(), + version, + source, + source_path, + project_root, + bin_paths, + installed, + mode, + })) +} + +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..74bbf31627 100644 --- a/crates/vp_global_cli/src/commands/env/default.rs +++ b/crates/vp_global_cli/src/commands/env/default.rs @@ -1,108 +1,152 @@ -//! 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, +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; + } + if scope.includes_package_managers() { + match scope { + EnvScope::PackageManager(package_manager) => { + config.clear_default_package_manager_version(package_manager); + } + _ => config.default_package_manager_versions.clear(), + } + } + save_config(&config).await?; + crate::shim::invalidate_cache(); + println!("Cleared selected environment defaults."); + return Ok(ExitStatus::default()); } -} -/// Show the current default version. -async fn show_default() -> Result { - let config = load_config().await?; + 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; + } - 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()); + let specs = EnvSpecs::parse(&values)?; + let mut config = load_config().await?; + let mut updates = Vec::new(); + if let Some(version) = specs.node { + let (stored, display) = resolve_node_default(&version).await?; + config.default_node_version = Some(stored); + updates.push(format!("Default Node.js version set to {display}")); + } + if let Some((package_manager, version, hash)) = specs.package_manager { + let stored = if version == "latest" { + version + } else { + resolve_package_manager_version(package_manager, &version).await?.to_string() + }; + let mut stored = stored; + if let Some(hash) = hash { + stored.push('+'); + stored.push_str(&hash); + } + config.set_default_package_manager_version(package_manager, stored.clone()); + updates.push(format!("Default {package_manager} version set to {stored}")); + } + save_config(&config).await?; + crate::shim::invalidate_cache(); + for update in updates { + println!("\u{2713} {update}"); + } + Ok(ExitStatus::default()) +} - // If it's an alias, also show the resolved version - if version == "lts" || version == "latest" { +async fn show_default(scope: EnvScope) -> Result { + let config = load_config().await?; + let mut has_configured_default = false; + if scope.includes_node() { + match config.default_node_version.as_deref() { + Some(version) => { + has_configured_default = true; + println!("Default Node.js version: {version}"); + if matches!(version, "lts" | "latest") { + let provider = vp_js_runtime::NodeProvider::new(); + if let Ok(resolved) = + super::config::resolve_version_alias(version, &provider).await + { + println!(" Currently resolves to: {resolved}"); + } + } + } + None => { let provider = vp_js_runtime::NodeProvider::new(); - match resolve_alias(&version, &provider).await { - Ok(resolved) => println!(" Currently resolves to: {resolved}"), - Err(_) => {} + match provider.resolve_latest_version().await { + Ok(version) => { + println!( + "No default Node.js version configured. Using latest LTS ({version})." + ); + } + Err(_) => println!("No default Node.js version configured."), } + println!(" Run 'vp env default ' to set a default."); } } - 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 selected = super::package_manager::selected(scope); + let configured = selected + .into_iter() + .filter_map(|package_manager| { + config + .default_package_manager_version_for(package_manager) + .map(|version| (package_manager, version)) + }) + .collect::>(); + if configured.is_empty() { + match scope { + EnvScope::PackageManager(kind) => { + println!("Default {kind} version: not configured") } + _ => println!("Package manager defaults: not configured"), + } + } else { + for (package_manager, version) in configured { + has_configured_default = true; + println!("Default {package_manager} version: {version}"); } } } - + if has_configured_default { + println!(" Set via: {}", get_config_path()?.as_path().display()); + } Ok(ExitStatus::default()) } -/// Set the default version. -async fn set_default(version: &str) -> Result { +async fn resolve_node_default(version: &str) -> Result<(String, String), Error> { let provider = vp_js_runtime::NodeProvider::new(); - - // Validate the version - let (display_version, store_version) = match version.to_lowercase().as_str() { + 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()) + let current = provider.resolve_latest_version().await?; + Ok(("lts".into(), format!("lts (currently {current})"))) } "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()) + let current = provider.resolve_absolute_latest_version().await?; + Ok(("latest".into(), format!("latest (currently {current})"))) } _ => { - // 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) + let resolved = super::config::resolve_version_alias(version, &provider).await?; + Ok((resolved.clone(), resolved)) } - }; - - // Save to config - let mut config = load_config().await?; - config.default_node_version = Some(store_version); - save_config(&config).await?; - - // Invalidate resolve cache so the new default takes effect immediately - crate::shim::invalidate_cache(); - - println!("\u{2713} Default Node.js version set to {display_version}"); - - 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()), } } diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 4ddcd6564f..a332b60058 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -3,10 +3,15 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; +use vp_pm_cli::{package_manager_bin_path, package_manager_install_dir}; use vp_shared::{env_vars, output}; use vt_path::{AbsolutePathBuf, current_dir}; -use super::config::{self, ShimMode, get_bin_dir, load_config, resolve_version}; +use super::{ + config::{self, ShimMode, get_bin_dir, load_config, resolve_version}, + package_manager, + spec::EnvScope, +}; use crate::{ commands::shell::{ALL_SHELL_PROFILES, IDE_SHELL_PROFILES, ShellProfile, resolve_profile_path}, error::Error, @@ -33,8 +38,6 @@ const KNOWN_VERSION_MANAGERS: &[(&str, &str)] = &[ ("n", "N_PREFIX"), ]; -use super::setup::SHIM_TOOLS; - /// Column width for left-side keys in aligned output const KEY_WIDTH: usize = 18; @@ -58,6 +61,14 @@ fn print_check(status: &str, key: &str, value: &str) { } } +fn print_package_manager_mode(key: &str, mode: ShimMode) { + let mode = match mode { + ShimMode::Managed => "managed mode", + ShimMode::SystemFirst => "system-first mode", + }; + print_check(&output::CHECK.green().to_string(), key, mode); +} + /// Print a continuation/hint line (dimmed). fn print_hint(text: &str) { println!(" {}", format!("note: {text}").dimmed()); @@ -74,33 +85,47 @@ 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 println!("{}", "Installation".bold()); has_errors |= !check_dirs().await; - has_errors |= !check_shims().await; + has_errors |= !check_shims(scope).await; // Section: Configuration print_section("Configuration"); - let (shim_mode, system_node_path) = check_shim_mode().await; + let (environment_config, 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; + has_errors |= !check_path(scope).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, environment_config.node_shim_mode, system_node_path).await + } else { + None + }; + if scope.includes_package_managers() { + print_section("Package Manager Resolution"); + has_errors |= !check_package_manager_resolution(&cwd, scope, &environment_config).await; + } // Section: devEngines (conditional, see rfcs/dev-engines.md) - check_dev_engines(&cwd, resolution.as_ref()).await; + check_dev_engines(&cwd, resolution.as_ref(), scope).await; // Section: Conflicts (conditional) check_conflicts(); @@ -169,7 +194,19 @@ async fn check_dirs() -> bool { /// Check shim files in the bin directory. A missing bin directory is /// already reported by [`check_dirs`]. -async fn check_shims() -> bool { +fn selected_shim_tools(scope: EnvScope) -> Vec<&'static str> { + match scope { + EnvScope::All => crate::shim::DEFAULT_SHIM_TOOLS.to_vec(), + EnvScope::Node => vec!["node"], + EnvScope::PackageManagers => package_manager::ALL_PACKAGE_MANAGERS + .into_iter() + .flat_map(|package_manager| package_manager.bin_names().iter().copied()) + .collect(), + EnvScope::PackageManager(package_manager) => package_manager.bin_names().to_vec(), + } +} + +async fn check_shims(scope: EnvScope) -> bool { let config = vp_shared::EnvConfig::get(); let bin_dir = &config.dirs.bin; @@ -179,7 +216,8 @@ async fn check_shims() -> bool { let mut missing = Vec::new(); - for tool in SHIM_TOOLS { + let tools = selected_shim_tools(scope); + for tool in &tools { let shim_path = bin_dir.join(shim_filename(tool)); if !tokio::fs::try_exists(&shim_path).await.unwrap_or(false) { missing.push(*tool); @@ -187,7 +225,7 @@ async fn check_shims() -> bool { } if missing.is_empty() { - print_check(&output::CHECK.green().to_string(), "Shims", &SHIM_TOOLS.join(", ")); + print_check(&output::CHECK.green().to_string(), "Shims", &tools.join(", ")); true } else { print_check( @@ -215,47 +253,168 @@ 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) -> (config::Config, Option) { let config = match load_config().await { Ok(c) => c, Err(e) => { print_check( &output::WARN_SIGN.yellow().to_string(), - "Node.js mode", + "Node.js", &format!("config error: {e}").yellow().to_string(), ); - return (ShimMode::default(), None); + return (config::Config::default(), None); } }; let mut system_node_path = None; - match config.shim_mode { - ShimMode::Managed => { - print_check(&output::CHECK.green().to_string(), "Node.js mode", "managed"); + if scope.includes_node() { + match config.node_shim_mode { + ShimMode::Managed => { + print_check(&output::CHECK.green().to_string(), "Node.js", "managed mode"); + } + ShimMode::SystemFirst => { + print_check( + &output::CHECK.green().to_string(), + "Node.js", + &"system-first mode".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(), + ); + } + } } - ShimMode::SystemFirst => { - print_check( - &output::CHECK.green().to_string(), - "Node.js mode", - &"system-first".bright_blue().to_string(), + } + if scope.includes_package_managers() { + if let Some(package_manager) = scope.package_manager() { + print_package_manager_mode( + "Package manager", + config.package_manager_shim_mode_for(package_manager), ); - - // 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 { + let modes = package_manager::ALL_PACKAGE_MANAGERS.map(|package_manager| { + (package_manager, config.package_manager_shim_mode_for(package_manager)) + }); + let shared_mode = modes[0].1; + if modes.iter().all(|(_, mode)| *mode == shared_mode) { + print_package_manager_mode("Package manager", shared_mode); } else { - print_check( - &output::WARN_SIGN.yellow().to_string(), - "System Node.js", - &"not found (will fall back to managed)".yellow().to_string(), - ); + for (package_manager, mode) in modes { + print_package_manager_mode(package_manager::title(package_manager), mode); + } } } } - (config.shim_mode, system_node_path) + (config, system_node_path) +} + +async fn check_package_manager_session_override() { + let environment = vp_shared::EnvConfig::get().package_manager.clone(); + let session = config::read_session_package_manager().await; + if let Some(value) = environment.or(session) { + print_check(" ", "PM session", &value); + } +} + +async fn check_package_manager_resolution( + cwd: &AbsolutePathBuf, + scope: EnvScope, + config: &config::Config, +) -> bool { + let selected = match package_manager::resolve_current_spec(cwd).await { + Ok(selected) => selected.filter(|resolution| { + scope + .package_manager() + .is_none_or(|expected| expected == resolution.package_manager_type) + }), + Err(error) => { + print_check(&output::CROSS.red().to_string(), "Package manager", &error.to_string()); + return false; + } + }; + let Some(selected) = selected else { + print_check(" ", "Package manager", "not selected"); + return true; + }; + + if config.package_manager_shim_mode_for(selected.package_manager_type) == ShimMode::SystemFirst + && let Some(system_binary) = + shim::find_system_tool(&selected.package_manager_type.to_string()) + { + let Some(version) = try_get_tool_version(&system_binary).await else { + print_check(" ", "Source", "system PATH"); + print_check( + &output::CROSS.red().to_string(), + "PM binary", + &format!("{} (could not execute)", system_binary.as_path().display()) + .red() + .to_string(), + ); + return false; + }; + print_check(" ", "Source", "system PATH"); + print_check( + " ", + "Version", + &format!("{}@{version}", selected.package_manager_type).bright_green().to_string(), + ); + print_check( + &output::CHECK.green().to_string(), + "PM binary", + &system_binary.as_path().display().to_string(), + ); + return true; + } + + match package_manager::resolve_current_for(cwd, scope.package_manager()).await { + Ok(Some(resolution)) => { + print_check(" ", "Source", &resolution.source); + 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); + true + } + Ok(_) => { + print_check(" ", "Package manager", "not selected"); + true + } + Err(error) => { + print_check(&output::CROSS.red().to_string(), "Package manager", &error.to_string()); + false + } + } } /// Check profile files for env sourcing and classify where it was found. @@ -329,7 +488,7 @@ fn check_session_override() { } /// Check PATH configuration. -async fn check_path() -> bool { +async fn check_path(scope: EnvScope) -> bool { let bin_dir = match get_bin_dir() { Ok(d) => d, Err(_) => return false, @@ -355,7 +514,7 @@ async fn check_path() -> bool { } // Show which tool would be executed for each shim - for tool in SHIM_TOOLS { + for tool in selected_shim_tools(scope) { if let Some(tool_path) = find_in_path(tool) { let expected = bin_dir.join(shim_filename(tool)); let display = abbreviate_home(&tool_path.display().to_string()); @@ -594,12 +753,17 @@ async fn check_current_resolution( /// Get the version string from a Node.js binary. async fn get_node_version(node_path: &vt_path::AbsolutePath) -> String { - match tokio::process::Command::new(node_path.as_path()).arg("--version").output().await { - Ok(output) if output.status.success() => { - String::from_utf8_lossy(&output.stdout).trim().to_string() - } - _ => "unknown".to_string(), - } + get_tool_version(node_path).await +} + +async fn get_tool_version(tool_path: &vt_path::AbsolutePath) -> String { + try_get_tool_version(tool_path).await.unwrap_or_else(|| "unknown".to_string()) +} + +async fn try_get_tool_version(tool_path: &vt_path::AbsolutePath) -> Option { + let output = + tokio::process::Command::new(tool_path.as_path()).arg("--version").output().await.ok()?; + output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) } /// One devEngines doctor finding. @@ -657,8 +821,12 @@ async fn find_nearest_dev_engines_node_version(cwd: &AbsolutePathBuf) -> Option< /// All checks are semver-aware: an exact version satisfying a declared range is /// not a conflict. Findings are warnings or notes; they never fail the doctor run /// and are never auto-fixed. -async fn check_dev_engines(cwd: &AbsolutePathBuf, resolution: Option<&config::VersionResolution>) { - let findings = collect_dev_engines_findings(cwd, resolution).await; +async fn check_dev_engines( + cwd: &AbsolutePathBuf, + resolution: Option<&config::VersionResolution>, + scope: EnvScope, +) { + let findings = collect_dev_engines_findings(cwd, resolution, scope).await; if findings.is_empty() { return; } @@ -728,9 +896,12 @@ async fn nvmrc_conflict_finding( async fn collect_dev_engines_findings( cwd: &AbsolutePathBuf, resolution: Option<&config::VersionResolution>, + scope: EnvScope, ) -> Vec { + let check_node = scope.includes_node(); + let check_package_managers = scope.includes_package_managers(); let mut findings = Vec::new(); - if let Some(finding) = nvmrc_conflict_finding(resolution).await { + if check_node && let Some(finding) = nvmrc_conflict_finding(resolution).await { findings.push(finding); } @@ -749,22 +920,29 @@ async fn collect_dev_engines_findings( // monorepo it can be a different (higher) file than the nearest package.json // used by the Node.js runtime checks above. let nearest_pkg_path = pkg_dir.join("package.json"); - let root_doc = read_workspace_root_doc(cwd, &nearest_pkg_path).await; + let root_doc = if check_package_managers { + read_workspace_root_doc(cwd, &nearest_pkg_path).await + } else { + None + }; let (pm_raw, pm_pkg): (&serde_json::Value, &vp_shared::PackageJson) = match &root_doc { Some((root_raw, root_pkg)) => (root_raw, root_pkg), None => (&raw, &pkg), }; - let runtime_field = pkg.dev_engines.as_ref().and_then(|de| de.runtime.as_ref()); - let package_manager_field = - pm_pkg.dev_engines.as_ref().and_then(|de| de.package_manager.as_ref()); + let runtime_field = + check_node.then(|| pkg.dev_engines.as_ref().and_then(|de| de.runtime.as_ref())).flatten(); + let package_manager_field = check_package_managers + .then(|| pm_pkg.dev_engines.as_ref().and_then(|de| de.package_manager.as_ref())) + .flatten(); // .node-version vs devEngines.runtime (semver-aware: only exact .node-version // values can conflict with a declared range). Both sides follow the resolution // walk: the check fires only when a .node-version actually wins resolution, and // the devEngines.runtime declaration may live in an ancestor manifest rather // than the nearest package.json. - if let Ok(Some(resolution)) = vp_js_runtime::resolve_node_version(cwd, true).await + if check_node + && let Ok(Some(resolution)) = vp_js_runtime::resolve_node_version(cwd, true).await && resolution.source == vp_js_runtime::VersionSource::NodeVersionFile && let Ok(version) = node_semver::Version::parse(&resolution.version) && let Some(declared) = find_nearest_dev_engines_node_version(cwd).await @@ -781,7 +959,8 @@ async fn collect_dev_engines_findings( } // Resolved Node.js version vs engines.node - if let Some(resolution) = resolution + if check_node + && let Some(resolution) = resolution && let Some(engines_node) = pkg.engines.as_ref().and_then(|e| e.node.as_ref()) && let Ok(version) = node_semver::Version::parse(&resolution.version) && let Ok(range) = node_semver::Range::parse(engines_node.as_str()) @@ -897,12 +1076,15 @@ async fn collect_dev_engines_findings( // Malformed entries that lenient parsing skipped (raw JSON inspection): // runtime entries come from the nearest package.json, packageManager entries // from the workspace root package.json - if let Some(raw_dev_engines) = raw.get("devEngines").and_then(serde_json::Value::as_object) + if check_node + && let Some(raw_dev_engines) = raw.get("devEngines").and_then(serde_json::Value::as_object) && let Some(value) = raw_dev_engines.get("runtime") { collect_malformed_entry_findings("runtime", value, &mut findings); } - if let Some(raw_dev_engines) = pm_raw.get("devEngines").and_then(serde_json::Value::as_object) + if check_package_managers + && let Some(raw_dev_engines) = + pm_raw.get("devEngines").and_then(serde_json::Value::as_object) && let Some(value) = raw_dev_engines.get("packageManager") { collect_malformed_entry_findings("packageManager", value, &mut findings); @@ -1006,16 +1188,39 @@ fn check_conflicts() { #[cfg(test)] mod tests { + use serial_test::serial; use tempfile::TempDir; use super::*; #[cfg(not(windows))] use crate::commands::shell::{ShellProfileKind, ShellProfileRoot}; + #[test] + fn test_selected_shim_tools_respect_scope() { + assert_eq!(selected_shim_tools(EnvScope::Node), vec!["node"]); + assert_eq!( + selected_shim_tools(EnvScope::PackageManager(vp_pm_cli::PackageManagerType::Pnpm)), + vec!["pnpm", "pnpx"] + ); + assert_eq!( + selected_shim_tools(EnvScope::PackageManagers), + vec!["npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx"] + ); + assert_eq!(selected_shim_tools(EnvScope::All), crate::shim::DEFAULT_SHIM_TOOLS); + } + /// Test helper: write `files` into a temp project and collect devEngines findings. async fn dev_engines_findings_for( files: &[(&str, &str)], resolved: Option<(&str, &str)>, + ) -> Vec { + dev_engines_findings_for_scope(files, resolved, EnvScope::All).await + } + + async fn dev_engines_findings_for_scope( + files: &[(&str, &str)], + resolved: Option<(&str, &str)>, + scope: EnvScope, ) -> Vec { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -1029,7 +1234,28 @@ mod tests { project_root: Some(temp_path.clone()), is_range: false, }); - collect_dev_engines_findings(&temp_path, resolution.as_ref()).await + collect_dev_engines_findings(&temp_path, resolution.as_ref(), scope).await + } + + #[tokio::test] + async fn test_dev_engines_findings_respect_scope() { + let package_json = r#"{ + "packageManager": "npm@10.5.0", + "devEngines": { + "runtime": {"name": "node", "version": "^24.0.0"}, + "packageManager": {"name": "pnpm", "version": "^11.0.0"} + } + }"#; + let files = [(".node-version", "20.18.0\n"), ("package.json", package_json)]; + + let node = dev_engines_findings_for_scope(&files, None, EnvScope::Node).await; + assert_eq!(node.len(), 1, "findings: {:?}", messages(&node)); + assert_eq!(node[0].key, "Runtime"); + + let package_managers = + dev_engines_findings_for_scope(&files, None, EnvScope::PackageManagers).await; + assert_eq!(package_managers.len(), 1, "findings: {:?}", messages(&package_managers)); + assert_eq!(package_managers[0].key, "PackageManager"); } // npm-install-checks: "semver version is not in range" (via .node-version) @@ -1336,7 +1562,7 @@ mod tests { tokio::fs::write(app_dir.join("package.json"), r#"{"name": "app"}"#).await.unwrap(); tokio::fs::write(app_dir.join(".node-version"), "20.18.0\n").await.unwrap(); - let findings = collect_dev_engines_findings(&app_dir, None).await; + let findings = collect_dev_engines_findings(&app_dir, None, EnvScope::All).await; assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); assert!(findings[0].warn); assert!( @@ -1363,7 +1589,7 @@ mod tests { .await .unwrap(); - let findings = collect_dev_engines_findings(&app_dir, None).await; + let findings = collect_dev_engines_findings(&app_dir, None, EnvScope::All).await; assert!(findings.is_empty(), "findings: {:?}", messages(&findings)); } @@ -1395,7 +1621,7 @@ mod tests { // running from the nested package still diagnoses the workspace root's // packageManager vs devEngines.packageManager conflict - let findings = collect_dev_engines_findings(&app_dir, None).await; + let findings = collect_dev_engines_findings(&app_dir, None, EnvScope::All).await; assert_eq!(findings.len(), 1, "findings: {:?}", messages(&findings)); assert!(findings[0].warn); assert_eq!(findings[0].key, "PackageManager"); @@ -1547,6 +1773,7 @@ mod tests { } #[test] + #[serial] fn test_abbreviate_home() { if let Ok(home) = std::env::var("HOME") { let path = format!("{home}/.vite-plus"); diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index 10a82a9d1e..087e34aef1 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -10,8 +10,14 @@ 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_with_hash, +}; use crate::{ cli::exit_status, error::Error, @@ -24,8 +30,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 +45,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,7 +86,6 @@ 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!(); @@ -112,23 +126,84 @@ 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.node_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 mut system_package_manager = None; + let selected_package_manager = if let Some(package_manager) = package_manager { + let (kind, selector, hash) = parse_package_manager_spec_with_hash(package_manager)?; + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + Some((kind, version, hash)) + } else { + let selected = package_manager_resolution::resolve_current_spec(cwd).await?; + if let Some(selected) = selected + && modes.package_manager_shim_mode_for(selected.package_manager_type) + == config::ShimMode::SystemFirst + && let Some(path) = + crate::shim::dispatch::find_system_tool(&selected.package_manager_type.to_string()) + && let Some(bin_dir) = path.parent() + { + let system_version = + read_tool_version(&path).await.unwrap_or_else(|| selected.version.to_string()); + path_prefixes.insert(0, bin_dir.as_path().to_path_buf()); + system_package_manager = + Some(format!("{}@{system_version}", selected.package_manager_type)); + None + } else { + package_manager_resolution::resolve_current(cwd).await?.map(|resolution| { + ( + resolution.package_manager_type, + resolution.version.to_string(), + resolution.hash.map(|hash| hash.to_string()), + ) + }) + } + }; + let resolved_package_manager = if system_package_manager.is_some() { + system_package_manager + } else if let Some((kind, version, hash)) = selected_package_manager { + if !explicit_package_manager + && modes.package_manager_shim_mode_for(kind) == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool(&kind.to_string()) + && let Some(bin_dir) = path.parent() + { + 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 +213,19 @@ async fn execute_with_version( std::env::remove_var(env_vars::VP_TOOL_RECURSION); } - // 4. Build PATH with node bin dir first (uses platform-specific separator) - // Always prepend to ensure the requested Node version is first in PATH - let node_bin_dir = runtime.get_bin_prefix(); - let new_path = format_path_prepended(node_bin_dir.as_path()); + let mut paths = path_prefixes; + paths.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())); + let new_path = std::env::join_paths(paths) + .map_err(|error| Error::Other(format!("failed to construct PATH: {error}").into()))?; // 5. Execute command let (cmd, args) = command.split_first().unwrap(); let mut child = tokio::process::Command::new(cmd); - child.args(args).env("PATH", new_path).env(env_vars::VP_NODE_VERSION, &resolved_version); + child.args(args).env("PATH", new_path).env(env_vars::VP_NODE_VERSION, &resolved_node); + if let Some(package_manager) = resolved_package_manager { + child.env(env_vars::VP_PACKAGE_MANAGER, package_manager); + } // The child runs in the inherited cwd, which a leading `-C ` changes // without touching our own environment; align its `PWD` accordingly. if let Ok(cwd) = vt_path::current_dir() { @@ -158,6 +236,15 @@ async fn execute_with_version( Ok(status) } +async fn read_tool_version(path: &AbsolutePath) -> Option { + let output = + tokio::process::Command::new(path.as_path()).arg("--version").output().await.ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +} + /// Resolve version to an exact version. /// /// Handles aliases (lts, latest) and version ranges. @@ -214,7 +301,8 @@ mod tests { #[tokio::test] async fn test_execute_missing_command() { - let result = execute(Some("20.18.0"), None, &[]).await; + let cwd = vt_path::current_dir().unwrap(); + let result = execute(&cwd, Some("20.18.0"), None, None, &[]).await; assert!(result.is_ok()); let status = result.unwrap(); assert!(!status.success()); @@ -229,7 +317,8 @@ mod tests { |_| async { // Run 'node --version' with a specific Node.js version let command = vec!["node".to_string(), "--version".to_string()]; - let result = execute(Some("20.18.0"), None, &command).await; + let cwd = vt_path::current_dir().unwrap(); + let result = execute(&cwd, Some("20.18.0"), None, None, &command).await; assert!(result.is_ok()); let status = result.unwrap(); assert!(status.success()); @@ -269,17 +358,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..7e30f020a0 --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -0,0 +1,139 @@ +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::{cli::exit_status, error::Error}; + +fn is_installable_node_source(source: &str) -> bool { + matches!( + source, + ".node-version" + | ".nvmrc" + | "engines.node" + | "devEngines.runtime" + | config::VERSION_ENV_VAR + | config::SESSION_VERSION_FILE + ) +} + +pub(crate) async fn install( + cwd: AbsolutePathBuf, + requests: Vec, +) -> Result { + let (scope, specs) = EnvSpecs::parse_requests(&requests)?; + let mut status = ExitStatus::default(); + + if scope.includes_node() { + let resolved = match specs.node { + Some(version) => { + let provider = vp_js_runtime::NodeProvider::new(); + Some((config::resolve_version_alias(&version, &provider).await?, false)) + } + None => { + let resolution = config::resolve_version(&cwd).await?; + if !is_installable_node_source(&resolution.source) { + eprintln!("No Node.js version found in current project."); + eprintln!("Specify a version: vp env install "); + eprintln!("Or pin one: vp env pin "); + status = exit_status(1); + None + } else { + let from_session_override = matches!( + resolution.source.as_str(), + config::VERSION_ENV_VAR | config::SESSION_VERSION_FILE + ); + Some((resolution.version, from_session_override)) + } + } + }; + if let Some((version, from_session_override)) = resolved { + println!("Installing Node.js v{version}..."); + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &version).await?; + println!("Installed Node.js v{version}"); + if from_session_override { + eprintln!("Note: Installed from session override."); + eprintln!("Run `vp env use --unset` to revert to project version resolution."); + } + } + } + + if scope.includes_package_managers() { + let requested = if let Some((kind, selector, hash)) = specs.package_manager { + let version = resolve_package_manager_version(kind, &selector).await?; + Some((kind, version, hash)) + } else if let EnvScope::PackageManager(kind) = scope { + let resolution = package_manager::resolve_current_or_fallback_for(&cwd, kind).await?; + Some(( + resolution.package_manager_type, + resolution.version, + resolution.hash.map(|hash| hash.to_string()), + )) + } else { + package_manager::resolve_current(&cwd).await?.map(|current| { + ( + current.package_manager_type, + current.version, + current.hash.map(|hash| hash.to_string()), + ) + }) + }; + if let Some((kind, version, hash)) = requested { + println!("Installing {kind} v{version}..."); + download_package_manager(kind, &version, hash.as_deref()).await?; + println!("Installed {kind} v{version}"); + } + } + + Ok(status) +} + +pub(crate) async fn uninstall(specs: Vec) -> Result { + let specs = EnvSpecs::parse(&specs)?; + 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 node = match specs.node { + Some(version) => { + let provider = vp_js_runtime::NodeProvider::new(); + Some(config::resolve_version_alias(&version, &provider).await?) + } + None => None, + }; + + let home = vp_shared::EnvConfig::get().dirs.data.clone(); + let mut targets = Vec::new(); + if let Some(version) = node { + targets.push(( + format!("Node.js v{version}"), + home.join("js_runtime").join("node").join(version), + )); + } + if let Some((kind, version)) = package_manager { + targets.push(( + format!("{kind} v{version}"), + home.join("package_manager").join(kind.to_string()).join(version), + )); + } + for (label, target) in &targets { + if !target.as_path().exists() { + return Err(Error::Other(format!("{label} is not installed").into())); + } + } + for (label, target) in targets { + tokio::fs::remove_dir_all(target.as_path()).await?; + println!("Uninstalled {label}"); + } + Ok(ExitStatus::default()) +} diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 27cf29b774..62624fa772 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -1,17 +1,13 @@ -//! 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::process::ExitStatus; +use std::{collections::BTreeMap, process::ExitStatus}; use owo_colors::OwoColorize; use serde::Serialize; +use vp_pm_cli::{PackageManagerType, package_manager_bin_path, package_manager_install_dir}; use vt_path::AbsolutePathBuf; -use super::config; +use super::{config, package_manager, spec::EnvScope}; use crate::error::Error; -/// JSON output format for a single installed version #[derive(Serialize)] struct InstalledVersionJson { version: String, @@ -19,135 +15,180 @@ struct InstalledVersionJson { default: bool, } -/// Scan the node versions directory and return sorted version strings. -pub(super) fn list_installed_versions(node_dir: &std::path::Path) -> Vec { - let entries = match std::fs::read_dir(node_dir) { +#[derive(Serialize)] +struct InstalledEnvironmentJson { + #[serde(skip_serializing_if = "Option::is_none")] + node: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + package_managers: Option>>, +} + +pub(super) fn list_installed_versions(directory: &std::path::Path) -> Vec { + let entries = match std::fs::read_dir(directory) { Ok(entries) => entries, Err(_) => return Vec::new(), }; - - let mut versions: Vec = entries + let mut versions = entries .filter_map(|entry| { let entry = entry.ok()?; let name = entry.file_name().into_string().ok()?; - // Skip hidden directories and non-directories - if name.starts_with('.') || !entry.path().is_dir() { - return None; - } - Some(name) + (!name.starts_with('.') && entry.path().is_dir()).then_some(name) }) - .collect(); - - versions.sort_by_cached_key(|v| node_semver::Version::parse(v).ok()); + .collect::>(); + versions.sort_by_cached_key(|version| node_semver::Version::parse(version).ok()); versions } -/// Execute the list command (local installed versions). -pub async fn execute(cwd: AbsolutePathBuf, json_output: bool) -> Result { - let node_dir = vp_shared::EnvConfig::get().dirs.data.join("js_runtime").join("node"); - - let versions = list_installed_versions(node_dir.as_path()); - - if versions.is_empty() { - if json_output { - println!("[]"); - } else { - println!("No Node.js versions installed."); - println!(); - println!("Install a version with: vp env install "); +pub async fn execute( + cwd: AbsolutePathBuf, + scope: Option, + json: bool, +) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; + let home = vp_shared::EnvConfig::get().dirs.data.clone(); + let config = config::load_config().await?; + let current_node = if scope.includes_node() { + config::resolve_version(&cwd).await.ok().map(|resolution| resolution.version) + } else { + None + }; + let current_pm = if scope.includes_package_managers() { + match scope.package_manager() { + Some(package_manager) => { + package_manager::resolve_current_or_fallback_for(&cwd, package_manager).await.ok() + } + None => package_manager::resolve_current_for(&cwd, None).await.ok().flatten(), + } + } else { + None + }; + let default_node = scope.includes_node().then(|| config.default_node_version.clone()).flatten(); + let mut default_package_manager_versions = BTreeMap::new(); + if scope.includes_package_managers() { + for kind in package_manager::selected(scope) { + let Some((_, selector, _)) = package_manager::configured_default_for(&config, kind)? + else { + continue; + }; + default_package_manager_versions.insert(kind.to_string(), selector); } - 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()); + 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_package_manager_versions.get(&kind.to_string()) + == Some(&version), + version, + }) + .collect(); + (kind.to_string(), versions) + }) + .collect(), + ) } else { - print_human(&versions, current_version.as_deref(), default_version.as_deref()); + None + }; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&InstalledEnvironmentJson { node, package_managers })? + ); + return Ok(ExitStatus::default()); } + if let Some(node) = node { + print_section("Node.js", &node, true); + } + 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(), + false, + ); + } + } 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], node: bool) { + println!("{title}"); + if versions.is_empty() { + println!(" No versions installed."); + return; + } + let colorize = use_color(); + for version in versions { let mut markers = Vec::new(); - if is_current { + if version.current { markers.push("current"); } - if is_default { + if version.default { markers.push("default"); } - - let marker_str = if markers.is_empty() { + let suffix = if markers.is_empty() { String::new() - } else { + } else if colorize { format!(" {}", markers.join(" ").dimmed()) + } else { + format!(" {}", markers.join(" ")) }; - - let line = format!("* v{v}{marker_str}"); - if is_current { - println!("{}", line.bright_blue()); + let display = if node { format!("v{}", version.version) } else { version.version.clone() }; + let line = format!("* {display}"); + if version.current && colorize { + println!(" {}{suffix}", line.bright_blue()); } else { - println!("{line}"); + println!(" {line}{suffix}"); } } } -#[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"]); - } +pub(super) fn use_color() -> bool { + vp_shared::is_stdout_terminal() && std::env::var_os("NO_COLOR").is_none() } diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index aacfc99797..2f0f0e693a 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,32 @@ -//! 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::process::ExitStatus; +use std::{collections::BTreeMap, process::ExitStatus}; +use futures::future::try_join_all; use owo_colors::OwoColorize; use serde::Serialize; use vp_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry}; +use vp_pm_cli::{fetch_package_manager_versions, resolve_package_manager_version}; use vt_path::AbsolutePathBuf; -use super::config; +use super::{ + config, + list::{list_complete_package_manager_versions, list_installed_versions, use_color}, + package_manager, + spec::EnvScope, +}; use crate::{cli::SortingMethod, error::Error}; -/// Default number of major versions to show const DEFAULT_MAJOR_VERSIONS: usize = 10; -/// JSON output format for version list #[derive(Serialize)] -struct VersionListJson { - versions: Vec, +struct RemoteEnvironmentJson { + #[serde(skip_serializing_if = "Option::is_none")] + node: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + package_managers: Option>>, } -/// JSON format for a single version entry #[derive(Serialize)] -struct VersionJson { +struct NodeVersionJson { version: String, lts: Option, latest: bool, @@ -33,432 +36,382 @@ 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_for(&cwd, scope.package_manager()).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 mut default_package_manager_versions = BTreeMap::new(); + if scope.includes_package_managers() { + for kind in package_manager::selected(scope) { + let Some((_, selector, _)) = package_manager::configured_default_for(&config, kind)? + else { + continue; + }; + let version = resolve_package_manager_version(kind, &selector).await?; + default_package_manager_versions.insert(kind.to_string(), version.to_string()); + } + } + let home = vp_shared::EnvConfig::get().dirs.data.clone(); + + let node = node_versions.map(|versions| { + build_node_versions( + &versions, + pattern.as_deref(), + lts_only, + show_all, + &sort, + current_node.as_deref(), + default_node.as_deref(), + &list_installed_versions(home.join("js_runtime").join("node").as_path()), + ) + }); + let package_managers = scope.includes_package_managers().then(|| { + package_manager_versions + .into_iter() + .map(|(kind, versions)| { + let installed = list_complete_package_manager_versions(&home, kind); + let entries = build_package_manager_versions( + versions, + pattern.as_deref(), + show_all, + &sort, + &installed, + current_pm.as_ref().and_then(|current| { + (current.package_manager_type == kind).then_some(current.version.as_str()) + }), + default_package_manager_versions.get(&kind.to_string()).map(String::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"); + print_node_versions(&node); } - - 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!(" {}", format_package_manager_version(&entry, use_color())); + } + } } - 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 } -} +fn print_node_versions(versions: &[NodeVersionJson]) { + if versions.is_empty() { + eprintln!(" {}", "No versions were found!".red()); + return; + } -/// Collect the set of locally installed Node.js versions (without `v` prefix). -fn installed_versions() -> std::collections::HashSet { - let node_dir = vp_shared::EnvConfig::get().dirs.data.join("js_runtime").join("node"); - super::list::list_installed_versions(node_dir.as_path()).into_iter().collect() + let colorize = use_color(); + for entry in versions { + println!(" {}", format_node_version(entry, colorize)); + } } -/// Strip a leading `v` from a version string, if present. -fn strip_v(version: &str) -> &str { - version.strip_prefix('v').unwrap_or(version) +fn format_package_manager_version(entry: &PackageManagerVersionJson, colorize: bool) -> String { + format_remote_version( + &entry.version, + "", + entry.installed, + entry.current, + entry.default, + colorize, + ) } -/// 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 format_node_version(entry: &NodeVersionJson, colorize: bool) -> String { + let display = format!("v{}", entry.version); + let lts = entry.lts.as_ref().map(|name| format!(" ({name})")).unwrap_or_default(); + format_remote_version(&display, <s, entry.installed, entry.current, entry.default, colorize) } -/// Filter versions based on criteria. -fn filter_versions<'a>( - versions: &'a [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()); +fn format_remote_version( + display: &str, + annotation: &str, + installed: bool, + current: bool, + default: bool, + colorize: bool, +) -> String { + let mut labels = Vec::new(); + if current { + labels.push("current"); } - - // 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}.")) - }); + if default { + labels.push("default"); } + let labels = if labels.is_empty() { String::new() } else { format!(" {}", labels.join(" ")) }; - // Limit to recent major versions unless --all is specified - if !show_all && pattern.is_none() { - filtered = limit_to_recent_majors(filtered, DEFAULT_MAJOR_VERSIONS); + if colorize { + let display = if current { + display.bright_blue().to_string() + } else if installed { + display.green().to_string() + } else { + display.to_string() + }; + let annotation = if annotation.is_empty() { + String::new() + } else { + annotation.bright_blue().to_string() + }; + let labels = if labels.is_empty() { labels } else { labels.dimmed().to_string() }; + format!("{display}{annotation}{labels}") + } else { + // Preserve installed state in redirected output, where color is unavailable. + let marker = if installed { "* " } else { " " }; + format!("{marker}{display}{annotation}{labels}") } - - 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() +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())), + } } -/// 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 +fn build_node_versions( + versions: &[NodeVersionEntry], + pattern: Option<&str>, + lts_only: bool, + show_all: bool, + 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 .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(), - }; - - 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 major(version: &str) -> Option { + version.strip_prefix('v').unwrap_or(version).split('.').next()?.parse().ok() +} - 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 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}.")) } #[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); + fn human_node_version_keeps_prefix_and_lts_codename() { + let entry = NodeVersionJson { + version: "22.11.0".into(), + lts: Some("Jod".into()), + latest: false, + latest_lts: false, + installed: false, + current: false, + default: false, + }; - let not_installed = json.iter().find(|v| v.version == "24.0.0").unwrap(); - assert!(!not_installed.installed); + assert_eq!(format_node_version(&entry, false), " v22.11.0 (Jod)"); } #[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(); + fn human_package_manager_version_keeps_plain_text_markers() { + let entry = PackageManagerVersionJson { + version: "10.18.0".into(), + latest: false, + installed: true, + current: true, + default: true, + }; - let json = build_json(&refs, &all_versions, &markers(&[], None, None)); - assert!(json.iter().all(|v| !v.installed && !v.current && !v.default)); + assert_eq!(format_package_manager_version(&entry, false), "* 10.18.0 current default"); } #[test] - fn test_build_json_marks_current_and_default() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - ]; - let all_versions = versions.clone(); - let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); - - // Current project resolves to 22.13.0; global default is 20.18.0. - let json = build_json( - &refs, - &all_versions, - &markers(&["22.13.0", "20.18.0"], Some("22.13.0"), Some("20.18.0")), + fn legacy_pattern_keeps_all_components_selected() { + assert_eq!( + parse_scope_and_pattern(&["20".into()]).unwrap(), + (EnvScope::All, Some("20".into())) ); - - let current = json.iter().find(|v| v.version == "22.13.0").unwrap(); - assert!(current.current && current.installed && !current.default); - - let default = json.iter().find(|v| v.version == "20.18.0").unwrap(); - assert!(default.default && default.installed && !default.current); - - let plain = json.iter().find(|v| v.version == "24.0.0").unwrap(); - assert!(!plain.current && !plain.default && !plain.installed); - } - - #[test] - fn test_filter_versions_show_all_with_lts_filter() { - let versions = vec![ - make_version("v25.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - make_version("v18.20.0", Some("Hydrogen")), - ]; - - // With lts_only and show_all, should return all LTS versions - let filtered = filter_versions(&versions, None, true, true); - assert_eq!(filtered.len(), 3); - assert!(filtered.iter().all(|v| v.is_lts())); } } diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index f68d8169e5..82778a3ed8 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -10,13 +10,16 @@ mod current; mod default; mod doctor; mod exec; +mod lifecycle; mod list; mod list_remote; mod off; mod on; +pub(crate) mod package_manager; pub mod package_metadata; mod pin; pub(crate) mod setup; +mod spec; mod unpin; mod r#use; mod which; @@ -46,8 +49,8 @@ fn print_env_clean_tip() { fn should_print_env_header(subcommand: &EnvSubcommands) -> bool { match subcommand { - EnvSubcommands::Current { json } => !json, - EnvSubcommands::List { json } => !json, + EnvSubcommands::Current { json, .. } => !json, + EnvSubcommands::List { json, .. } => !json, EnvSubcommands::ListRemote { json, .. } => !json, // Keep these machine-consumable / passthrough commands header-free. EnvSubcommands::Use { .. } | EnvSubcommands::Exec { .. } => false, @@ -57,24 +60,12 @@ fn should_print_env_header(subcommand: &EnvSubcommands) -> bool { fn should_print_env_clean_tip(subcommand: &EnvSubcommands) -> bool { match subcommand { - EnvSubcommands::List { json } => !json, + EnvSubcommands::List { json, .. } => !json, EnvSubcommands::ListRemote { json, .. } => !json, _ => false, } } -fn is_installable_version_source(source: &str) -> bool { - matches!( - source, - ".node-version" - | ".nvmrc" - | "engines.node" - | "devEngines.runtime" - | config::VERSION_ENV_VAR - | config::SESSION_VERSION_FILE - ) -} - /// Execute the env command based on the provided arguments. pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result { // Handle subcommands first @@ -85,79 +76,52 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result current::execute(cwd, json).await, - crate::cli::EnvSubcommands::Print => print_env(cwd).await, - crate::cli::EnvSubcommands::Default { version } => default::execute(cwd, version).await, - crate::cli::EnvSubcommands::On => on::execute().await, - crate::cli::EnvSubcommands::Off => off::execute().await, + crate::cli::EnvSubcommands::Current { scope, json } => { + current::execute(cwd, scope, json).await + } + crate::cli::EnvSubcommands::Print { scope } => print_env(cwd, scope).await, + crate::cli::EnvSubcommands::Default { values, unset } => { + default::execute(values, unset).await + } + crate::cli::EnvSubcommands::On { scope } => on::execute(scope).await, + crate::cli::EnvSubcommands::Off { scope } => off::execute(scope).await, crate::cli::EnvSubcommands::Setup { refresh, env_only } => { setup::execute(refresh, env_only).await } - crate::cli::EnvSubcommands::Doctor => doctor::execute(cwd).await, + crate::cli::EnvSubcommands::Doctor { scope } => doctor::execute(cwd, scope).await, crate::cli::EnvSubcommands::Which { tool } => which::execute(cwd, &tool).await, - crate::cli::EnvSubcommands::Pin { version, unpin, no_install, force, target } => { - pin::execute(cwd, version, unpin, no_install, force, target).await + crate::cli::EnvSubcommands::Pin { specs, unpin, no_install, force, target } => { + pin::execute(cwd, specs, unpin, no_install, force, target).await } - crate::cli::EnvSubcommands::Unpin { target } => unpin::execute(cwd, target).await, - crate::cli::EnvSubcommands::List { json } => list::execute(cwd, json).await, - crate::cli::EnvSubcommands::ListRemote { pattern, lts, all, json, sort } => { - list_remote::execute(cwd, pattern, lts, all, json, sort).await + crate::cli::EnvSubcommands::Unpin { scope, target } => { + unpin::execute(cwd, scope, target).await } - crate::cli::EnvSubcommands::Exec { node, npm, command } => { - exec::execute(node.as_deref(), npm.as_deref(), &command).await + crate::cli::EnvSubcommands::List { scope, json } => { + list::execute(cwd, scope, json).await } - crate::cli::EnvSubcommands::Uninstall { version } => { - let provider = vp_js_runtime::NodeProvider::new(); - let resolved = config::resolve_version_alias(&version, &provider).await?; - let version_dir = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(&resolved); - if !version_dir.as_path().exists() { - eprintln!("Node.js v{} is not installed", resolved); - return Ok(exit_status(1)); - } - tokio::fs::remove_dir_all(version_dir.as_path()).await.map_err(|e| { - crate::error::Error::Other( - format!("Failed to remove Node.js v{}: {}", resolved, e).into(), - ) - })?; - println!("Uninstalled Node.js v{}", resolved); - Ok(ExitStatus::default()) + crate::cli::EnvSubcommands::ListRemote { values, lts, all, json, sort } => { + list_remote::execute(cwd, values, lts, all, json, sort).await } - crate::cli::EnvSubcommands::Clean => clean::execute(cwd).await, - crate::cli::EnvSubcommands::Use { version, unset, no_install, silent_if_unchanged } => { - r#use::execute(cwd, version, unset, no_install, silent_if_unchanged).await + crate::cli::EnvSubcommands::Exec { node, npm, package_manager, command } => { + exec::execute( + &cwd, + node.as_deref(), + npm.as_deref(), + package_manager.as_deref(), + &command, + ) + .await } - crate::cli::EnvSubcommands::Install { version } => { - let (resolved, from_session_override) = if let Some(version) = version { - let provider = vp_js_runtime::NodeProvider::new(); - (config::resolve_version_alias(&version, &provider).await?, false) - } else { - let resolution = config::resolve_version(&cwd).await?; - let from_session_override = matches!( - resolution.source.as_str(), - config::VERSION_ENV_VAR | config::SESSION_VERSION_FILE - ); - if !is_installable_version_source(&resolution.source) { - eprintln!("No Node.js version found in current project."); - eprintln!("Specify a version: vp env install "); - eprintln!("Or pin one: vp env pin "); - return Ok(exit_status(1)); - } - (resolution.version, from_session_override) - }; - println!("Installing Node.js v{}...", resolved); - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolved) - .await?; - println!("Installed Node.js v{}", resolved); - if from_session_override { - eprintln!("Note: Installed from session override."); - eprintln!("Run `vp env use --unset` to revert to project version resolution."); - } - Ok(ExitStatus::default()) + crate::cli::EnvSubcommands::Uninstall { specs } => lifecycle::uninstall(specs).await, + crate::cli::EnvSubcommands::Clean { scope } => clean::execute(cwd, scope).await, + crate::cli::EnvSubcommands::Use { + requests, + unset, + no_install, + silent_if_unchanged, + } => r#use::execute(cwd, requests, unset, no_install, silent_if_unchanged).await, + crate::cli::EnvSubcommands::Install { requests } => { + lifecycle::install(cwd, requests).await } }; @@ -185,36 +149,154 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result Result { - // Resolve the Node.js version for the current directory - let resolution = config::resolve_version(&cwd).await?; - - // Get the node bin directory - 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()) +async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result { + let scope = spec::EnvScope::parse(scope.as_deref())?; + let modes = config::load_config().await?; + let mut bin_dirs = Vec::new(); + if scope.includes_node() { + bin_dirs.push(resolve_node_bin_dir(&cwd, &modes).await?.as_path().display().to_string()); + } + if scope.includes_package_managers() { + let selected = package_manager::resolve_current_spec(&cwd).await?.filter(|resolution| { + scope + .package_manager() + .is_none_or(|expected| expected == resolution.package_manager_type) + }); + let selected_type = selected + .as_ref() + .map(|resolution| resolution.package_manager_type) + .or_else(|| scope.package_manager()); + let system_bin_dir = selected_type.and_then(|package_manager| { + if modes.package_manager_shim_mode_for(package_manager) == config::ShimMode::SystemFirst + { + crate::shim::dispatch::find_system_tool(&package_manager.to_string()) + .and_then(|path| path.parent().map(vt_path::AbsolutePath::to_absolute_path_buf)) + } else { + None + } + }); + if let Some(bin_dir) = system_bin_dir { + bin_dirs.insert(0, bin_dir.as_path().display().to_string()); + } else { + let resolution = match scope.package_manager() { + Some(package_manager) => Some( + package_manager::resolve_current_or_fallback_for(&cwd, package_manager).await?, + ), + None => package_manager::resolve_current_for(&cwd, None).await?, + }; + if let Some(resolution) = resolution { + let (install_dir, _, _) = vp_pm_cli::download_package_manager( + resolution.package_manager_type, + &resolution.version, + resolution.hash.as_deref(), + ) + .await?; + bin_dirs.insert(0, install_dir.join("bin").as_path().display().to_string()); + } } - _ => format!("export PATH=\"{}:$PATH\"", bin_dir.as_path().display()), - }; + } + if bin_dirs.is_empty() { + return Err(Error::Other("no selected environment component could be resolved".into())); + } + let snippet = format_path_snippet(detect_shell(), &bin_dirs); // 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()) } +async fn resolve_node_bin_dir( + cwd: &vt_path::AbsolutePath, + config: &config::Config, +) -> Result { + if config.node_shim_mode == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool("node") + && let Some(bin_dir) = path.parent() + { + return Ok(bin_dir.to_absolute_path_buf()); + } + + let resolution = config::resolve_version(cwd).await?; + let runtime = + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolution.version) + .await?; + Ok(runtime.get_bin_prefix()) +} + +fn format_path_snippet(shell: Shell, bin_dirs: &[String]) -> String { + match shell { + Shell::Posix => format!( + "export PATH=\"{}:$PATH\"", + bin_dirs + .iter() + .map(|path| setup::escape_posix_double_quoted_string(path)) + .collect::>() + .join(":") + ), + Shell::Fish => format!( + "set -gx PATH {} $PATH", + bin_dirs + .iter() + .map(|path| format!("\"{}\"", setup::escape_fish_double_quoted_string(path))) + .collect::>() + .join(" ") + ), + Shell::PowerShell => format!( + "$env:PATH = '{};' + $env:PATH", + bin_dirs + .iter() + .map(|path| setup::escape_powershell_single_quoted_string(path)) + .collect::>() + .join(";") + ), + Shell::Cmd => format!( + "set \"PATH={};%PATH%\"", + bin_dirs.iter().map(|path| path.replace('%', "%%")).collect::>().join(";") + ), + Shell::NuShell => format!( + "$env.PATH = ($env.PATH | prepend [{}])", + bin_dirs + .iter() + .map(|path| format!("\"{}\"", setup::escape_nu_double_quoted_string(path))) + .collect::>() + .join(", ") + ), + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn nvmrc_is_an_installable_version_source() { - assert!(is_installable_version_source(".nvmrc")); + fn fish_path_snippet_quotes_each_directory() { + let snippet = format_path_snippet( + Shell::Fish, + &["/Users/Example User/node/bin".into(), "/tmp/pm/bin".into()], + ); + + assert_eq!(snippet, "set -gx PATH \"/Users/Example User/node/bin\" \"/tmp/pm/bin\" $PATH"); + } + + #[test] + fn path_snippets_escape_shell_metacharacters() { + assert_eq!( + format_path_snippet(Shell::Posix, &[r#"/tmp/$USER `tick` \ dir"#.into()]), + r#"export PATH="/tmp/\$USER \`tick\` \\ dir:$PATH""# + ); + assert_eq!( + format_path_snippet(Shell::PowerShell, &[r#"C:\A&B's"#.into()]), + r#"$env:PATH = 'C:\A&B''s;' + $env:PATH"# + ); + assert_eq!( + format_path_snippet(Shell::Cmd, &[r#"C:\%literal%\A&B"#.into()]), + r#"set "PATH=C:\%%literal%%\A&B;%PATH%""# + ); + assert_eq!( + format_path_snippet(Shell::NuShell, &[r#"C:\A "B""#.into()]), + r#"$env.PATH = ($env.PATH | prepend ["C:\\A \"B\""])"# + ); } } diff --git a/crates/vp_global_cli/src/commands/env/off.rs b/crates/vp_global_cli/src/commands/env/off.rs index 461fdef0bf..c528ed6ca0 100644 --- a/crates/vp_global_cli/src/commands/env/off.rs +++ b/crates/vp_global_cli/src/commands/env/off.rs @@ -5,31 +5,40 @@ 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 { +pub async fn execute(scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; 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." + if let EnvScope::PackageManager(package_manager) = scope { + config.set_package_manager_shim_mode(package_manager, ShimMode::SystemFirst); + } else { + config.set_shim_modes( + scope.includes_node(), + scope.includes_package_managers(), + ShimMode::SystemFirst, ); - return Ok(ExitStatus::default()); } - - config.shim_mode = 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".into(), + EnvScope::Node => "Node.js management".into(), + EnvScope::PackageManagers => "Package-manager management".into(), + EnvScope::PackageManager(package_manager) => format!("{package_manager} management"), + }; + println!("\u{2713} {component} set to system-first."); println!(); 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..b414c57185 100644 --- a/crates/vp_global_cli/src/commands/env/on.rs +++ b/crates/vp_global_cli/src/commands/env/on.rs @@ -4,27 +4,38 @@ 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 { +pub async fn execute(scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; 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()); + if let EnvScope::PackageManager(package_manager) = scope { + config.set_package_manager_shim_mode(package_manager, ShimMode::Managed); + } else { + config.set_shim_modes( + scope.includes_node(), + scope.includes_package_managers(), + ShimMode::Managed, + ); } - - config.shim_mode = 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".into(), + EnvScope::Node => "Node.js management".into(), + EnvScope::PackageManagers => "Package-manager management".into(), + EnvScope::PackageManager(package_manager) => format!("{package_manager} management"), + }; + println!("\u{2713} {component} set to managed."); println!(); - 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..0559ded0bf --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -0,0 +1,203 @@ +use vp_pm_cli::{ + EnvironmentPackageManagerResolution, PackageManagerType, resolve_environment_package_manager, + resolve_environment_package_manager_spec, resolve_package_manager_version, +}; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use super::{config, spec::parse_package_manager_spec_with_hash}; +use crate::error::Error; + +pub(crate) async fn resolve_current( + cwd: &AbsolutePath, +) -> Result, Error> { + resolve_current_for(cwd, None).await +} + +pub(crate) async fn resolve_current_for( + cwd: &AbsolutePath, + expected: Option, +) -> Result, Error> { + let specs = current_specs(expected).await?; + let mut resolution = resolve_environment_package_manager( + cwd, + specs.session_spec(), + specs.default_spec(), + expected, + ) + .await?; + specs.apply_session_source(&mut resolution); + Ok(resolution) +} + +pub(crate) async fn resolve_current_or_fallback_for( + cwd: &AbsolutePath, + package_manager: PackageManagerType, +) -> Result { + if let Some(resolution) = resolve_current_for(cwd, Some(package_manager)).await? { + return Ok(resolution); + } + + registry_fallback_for(package_manager).await +} + +pub(crate) async fn resolve_current_spec( + cwd: &AbsolutePath, +) -> Result, Error> { + let specs = current_specs(None).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) +} + +pub(crate) type PackageManagerSpec = (PackageManagerType, String, Option); + +struct CurrentSpecs { + session: Option, + session_source: Option<&'static str>, + session_source_path: Option, + default: Option, +} + +impl CurrentSpecs { + fn session_spec(&self) -> Option<(PackageManagerType, &str, Option<&str>)> { + self.session + .as_ref() + .map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())) + } + + fn default_spec(&self) -> Option<(PackageManagerType, &str, Option<&str>)> { + self.default + .as_ref() + .map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())) + } + + fn apply_session_source(&self, resolution: &mut Option) { + 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(expected: Option) -> Result { + let config = vp_shared::EnvConfig::get(); + let (session, session_source, session_source_path) = if let Some(spec) = + config.package_manager.as_deref().map(str::trim).filter(|spec| !spec.is_empty()) + { + ( + Some(parse_package_manager_spec_with_hash(spec)?), + Some(config::PACKAGE_MANAGER_ENV_VAR), + None, + ) + } else if let Some(spec) = config::read_session_package_manager().await { + ( + Some(parse_package_manager_spec_with_hash(spec.trim())?), + Some(config::SESSION_PACKAGE_MANAGER_FILE), + config::get_session_package_manager_path().ok(), + ) + } else { + (None, None, None) + }; + let config = config::load_config().await?; + let default = expected + .map(|package_manager| configured_default_for(&config, package_manager)) + .transpose()? + .flatten(); + Ok(CurrentSpecs { session, session_source, session_source_path, default }) +} + +pub(crate) fn configured_default_for( + config: &config::Config, + package_manager: PackageManagerType, +) -> Result, Error> { + config + .default_package_manager_version_for(package_manager) + .map(|version| { + parse_package_manager_spec_with_hash(&format!("{package_manager}@{version}")) + }) + .transpose() +} + +pub(crate) async fn resolve_from_files_for( + cwd: &AbsolutePath, + expected: Option, +) -> Result, Error> { + let config = config::load_config().await?; + let default = expected + .map(|package_manager| configured_default_for(&config, package_manager)) + .transpose()? + .flatten(); + resolve_environment_package_manager( + cwd, + None, + default.as_ref().map(|(kind, version, hash)| (*kind, version.as_str(), hash.as_deref())), + expected, + ) + .await + .map_err(Error::from) +} + +pub(crate) async fn resolve_from_files_or_fallback_for( + cwd: &AbsolutePath, + package_manager: PackageManagerType, +) -> Result { + if let Some(resolution) = resolve_from_files_for(cwd, Some(package_manager)).await? { + return Ok(resolution); + } + + registry_fallback_for(package_manager).await +} + +async fn registry_fallback_for( + package_manager: PackageManagerType, +) -> Result { + Ok(EnvironmentPackageManagerResolution { + package_manager_type: package_manager, + version: resolve_package_manager_version(package_manager, "latest").await?, + hash: None, + source: "registry fallback".into(), + source_path: None, + project_root: None, + }) +} + +pub(crate) async fn warn_if_target_differs(cwd: &AbsolutePath, target: PackageManagerType) { + let Ok(Some(current)) = resolve_current_spec(cwd).await else { + return; + }; + if current.source != "default" && current.package_manager_type != target { + vp_shared::output::warn(&format!( + "Current environment resolves to {} from {}, but {target} was requested.", + current.package_manager_type, current.source + )); + } +} + +pub(crate) const ALL_PACKAGE_MANAGERS: [PackageManagerType; 4] = [ + PackageManagerType::Npm, + PackageManagerType::Pnpm, + PackageManagerType::Yarn, + PackageManagerType::Bun, +]; + +pub(crate) fn selected(scope: super::spec::EnvScope) -> Vec { + match scope { + super::spec::EnvScope::All | super::spec::EnvScope::PackageManagers => { + ALL_PACKAGE_MANAGERS.to_vec() + } + super::spec::EnvScope::PackageManager(kind) => vec![kind], + super::spec::EnvScope::Node => Vec::new(), + } +} + +pub(crate) const fn title(kind: PackageManagerType) -> &'static str { + match kind { + PackageManagerType::Npm => "npm", + PackageManagerType::Pnpm => "pnpm", + PackageManagerType::Yarn => "Yarn", + PackageManagerType::Bun => "Bun", + } +} diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 84b42ceb0c..490c6cffbc 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,72 @@ 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, hash)) = specs.package_manager { + pin_package_manager( + &package_manager_root, + package_manager, + &version, + hash.as_deref(), + no_install, + force, + 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 +239,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 +648,296 @@ 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, + hash: Option<&str>, + no_install: bool, + force: 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 package_json: serde_json::Value = serde_json::from_str(&content)?; + let use_top_level = matches!(target, Some(PinTarget::PackageManager)) + || (target.is_none() && package_json.get("packageManager").is_some()); + let shadowing_top_level = + if use_top_level { None } else { existing_package_manager_pin(&content, true) }; + let shadowing_top_level = shadowing_top_level.filter(|(name, version)| { + PackageManagerType::from_name(name) == Some(package_manager) + && version.as_str() != resolved.as_str() + }); + if let Some((name, version)) = existing_package_manager_pin(&content, use_top_level) { + let existing = format!("{name}@{version}"); + let next = format!("{package_manager}@{resolved}"); + if existing != next + && !confirm_overwrite_pin("Package manager already pinned to", &existing, &next, force)? + { + return Ok(ExitStatus::default()); + } + } + let mut changed = false; + let updated = vp_shared::edit_json_object(&content, |obj| { + if use_top_level { + let prefix = format!("{package_manager}@{resolved}"); + let existing = obj.get("packageManager").and_then(serde_json::Value::as_str); + let next = hash.map_or_else( + || { + existing + .filter(|value| { + *value == prefix + || value + .strip_prefix(&prefix) + .is_some_and(|suffix| suffix.starts_with('+')) + }) + .unwrap_or(&prefix) + .to_string() + }, + |hash| format!("{prefix}+{hash}"), + ); + if obj.get("packageManager").and_then(serde_json::Value::as_str) != Some(&next) { + obj.insert("packageManager".into(), serde_json::Value::String(next)); + changed = true; + } + } 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 let Some((name, version)) = shadowing_top_level { + output::warn(&format!( + "Top-level packageManager {name}@{version} remains effective; remove or update it to use devEngines.packageManager {package_manager}@{resolved}." + )); + } + if no_install { + output::note("Package manager will be downloaded on first use."); + } else if let Err(error) = download_package_manager(package_manager, &resolved, hash).await { + output::warn(&format!("Failed to download {package_manager} {resolved}: {error}")); + } + Ok(ExitStatus::default()) +} + +fn existing_package_manager_pin(content: &str, top_level: bool) -> Option<(String, String)> { + if top_level { + let package_json: serde_json::Value = serde_json::from_str(content).ok()?; + let value = package_json.get("packageManager")?.as_str()?; + let (name, version) = value.split_once('@')?; + return Some(( + name.into(), + version.split_once('+').map_or(version, |(version, _)| version).into(), + )); + } + + let package_json: vp_shared::PackageJson = serde_json::from_str(content).ok()?; + let package_manager = package_json.dev_engines?.package_manager?; + let entry = package_manager.entries().first()?; + Some((entry.name.to_string(), entry.version.as_deref().unwrap_or("*").to_string())) +} + +fn set_dev_engines_package_manager( + obj: &mut serde_json::Map, + package_manager: PackageManagerType, + 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), + _ if matches!(target, Some(PinTarget::DevEngines)) => { + let package_json: vp_shared::PackageJson = serde_json::from_str(&content)?; + package_json.dev_engines.and_then(|dev_engines| dev_engines.package_manager).and_then( + |field| { + field + .entries() + .iter() + .find_map(|entry| PackageManagerType::from_name(&entry.name)) + }, + ) + } + _ => effective.as_ref().map(|resolution| resolution.package_manager_type), + }; + let mut changed = false; + 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 !matches!(target, Some(PinTarget::PackageManager)) + && let Some(expected) = expected + { + changed = remove_dev_engines_package_manager(obj, expected); + } + }) + .map_err(|error| Error::Other(format!("failed to update package.json: {error}").into()))?; + if changed { + tokio::fs::write(&package_json_path, updated).await?; + crate::shim::invalidate_cache(); + output::success("Removed package-manager pin"); + } else { + println!("No package manager pin found in current directory."); + } + Ok(()) +} + +fn workspace_root(cwd: &AbsolutePathBuf) -> Result, Error> { + match vt_workspace::find_workspace_root(cwd) { + Ok((workspace, _)) => Ok(Some(workspace.path.to_absolute_path_buf())), + Err(vt_workspace::Error::PackageJsonNotFound(_)) => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn remove_dev_engines_package_manager( + obj: &mut serde_json::Map, + expected: PackageManagerType, +) -> bool { + let Some(dev_engines) = obj.get_mut("devEngines").and_then(serde_json::Value::as_object_mut) + else { + return false; + }; + let Some(field) = dev_engines.get_mut("packageManager") else { + return false; + }; + let expected = expected.to_string(); + let changed = match field { + serde_json::Value::Object(entry) => { + entry.get("name").and_then(serde_json::Value::as_str) == Some(expected.as_str()) + } + serde_json::Value::Array(entries) => { + let before = entries.len(); + entries.retain(|entry| { + entry.get("name").and_then(serde_json::Value::as_str) != Some(expected.as_str()) + }); + before != entries.len() + } + _ => false, + }; + let remove_field = changed + && match field { + serde_json::Value::Object(_) => true, + serde_json::Value::Array(entries) => entries.is_empty(), + _ => false, + }; + if remove_field { + dev_engines.remove("packageManager"); + } + changed +} + #[cfg(test)] mod tests { use tempfile::TempDir; @@ -598,6 +955,107 @@ mod tests { dir } + #[tokio::test] + async fn package_manager_pin_preserves_matching_integrity_suffix() { + let temp_dir = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write( + cwd.join("package.json"), + "{\n \"packageManager\": \"pnpm@10.18.0+sha512.keep\"\n}\n", + ) + .await + .unwrap(); + + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", None, true, true, None) + .await + .unwrap(); + + let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(content.contains("pnpm@10.18.0+sha512.keep")); + } + + #[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", None, true, true, None) + .await + .unwrap(); + + let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(content.contains("pnpm@10.18.0")); + assert!(!content.contains("sha512.stale")); + } + + #[tokio::test] + async fn package_manager_pin_uses_explicit_integrity_suffix() { + let temp_dir = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write( + cwd.join("package.json"), + "{\n \"packageManager\": \"pnpm@10.17.0\"\n}\n", + ) + .await + .unwrap(); + + pin_package_manager( + &cwd, + PackageManagerType::Pnpm, + "10.18.0", + Some("sha512.explicit"), + true, + true, + None, + ) + .await + .unwrap(); + + let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(content.contains("pnpm@10.18.0+sha512.explicit")); + } + + #[test] + fn remove_last_package_manager_array_entry_removes_field() { + let mut manifest = serde_json::json!({ + "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 40d6e9d59d..5206c24fda 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -52,10 +52,6 @@ impl EnvShell { } } -/// Tools to create shims for during setup. -pub(crate) const SHIM_TOOLS: &[&str] = - &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vpx", "vpr"]; - /// Execute the setup command. pub async fn execute(refresh: bool, env_only: bool) -> Result { let config = vp_shared::EnvConfig::get(); @@ -100,7 +96,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result let mut created = Vec::new(); let mut skipped = Vec::new(); - for tool in SHIM_TOOLS { + for tool in crate::shim::DEFAULT_SHIM_TOOLS { let result = create_shim(¤t_exe, bin_dir, tool, refresh).await?; if result { created.push(*tool); @@ -433,8 +429,8 @@ async fn refresh_package_shims(bin_dir: &vt_path::AbsolutePath) -> Result<(), Er let trampoline_src = get_trampoline_path()?; for bin_name in &package_bins { - // Core shims (SHIM_TOOLS + vp) are already refreshed by the main loop. - if bin_name == "vp" || SHIM_TOOLS.contains(&bin_name.as_str()) { + // Default shims and vp are already refreshed by the main loop. + if bin_name == "vp" || crate::shim::DEFAULT_SHIM_TOOLS.contains(&bin_name.as_str()) { continue; } @@ -947,12 +943,12 @@ fn render_nu_path_ref(path_ref: &str) -> String { } /// Escape a value for a POSIX-shell double-quoted string. -fn escape_posix_double_quoted_string(value: &str) -> String { +pub(super) fn escape_posix_double_quoted_string(value: &str) -> String { value.replace('\\', "\\\\").replace('$', "\\$").replace('`', "\\`").replace('"', "\\\"") } /// Escape a value for a Fish double-quoted string. -fn escape_fish_double_quoted_string(value: &str) -> String { +pub(super) fn escape_fish_double_quoted_string(value: &str) -> String { value.replace('\\', "\\\\").replace('$', "\\$").replace('"', "\\\"") } @@ -968,13 +964,13 @@ fn escape_home_relative_double_quoted_path(path_ref: &str, escape: fn(&str) -> S /// /// Example: `vp "home\with spaces"` → `vp \"home\\with spaces\"` /// https://www.nushell.sh/book/working_with_strings.html#double-quoted-strings -fn escape_nu_double_quoted_string(value: &str) -> String { +pub(super) fn escape_nu_double_quoted_string(value: &str) -> String { // `vp "home\with spaces"` → `vp \"home\\with spaces\"` value.replace('\\', "\\\\").replace('"', "\\\"") } /// Escape a value for a PowerShell single-quoted string. -fn escape_powershell_single_quoted_string(value: &str) -> String { +pub(super) fn escape_powershell_single_quoted_string(value: &str) -> String { value.replace('\'', "''") } diff --git a/crates/vp_global_cli/src/commands/env/spec.rs b/crates/vp_global_cli/src/commands/env/spec.rs new file mode 100644 index 0000000000..6fe97f67f7 --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/spec.rs @@ -0,0 +1,171 @@ +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) + } + + pub(crate) fn package_manager(self) -> Option { + match self { + Self::PackageManager(kind) => Some(kind), + _ => None, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct EnvSpecs { + pub(crate) node: Option, + pub(crate) package_manager: Option<(PackageManagerType, String, Option)>, +} + +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 = parse_package_manager_spec_with_hash(value)?; + if parsed.package_manager.replace(package_manager).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 (package_manager, version, _) = parse_package_manager_spec_with_hash(value)?; + Ok((package_manager, version)) +} + +pub(crate) fn parse_package_manager_spec_with_hash( + value: &str, +) -> Result<(PackageManagerType, String, Option), Error> { + let Some((name, version)) = value.split_once('@') else { + return Err(invalid_spec(value)); + }; + let package_manager = PackageManagerType::from_name(name).ok_or_else(|| invalid_spec(value))?; + if version.is_empty() { + return Err(invalid_spec(value)); + } + let (version, hash) = version + .split_once('+') + .map_or((version, None), |(version, hash)| (version, Some(hash.to_string()))); + if version.is_empty() || hash.as_deref() == Some("") { + return Err(invalid_spec(value)); + } + Ok((package_manager, version.to_string(), hash)) +} + +fn invalid_scope(value: &str) -> Error { + 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(), None)) + ); + } + + #[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")); + } + + #[test] + fn package_manager_session_spec_preserves_hash() { + let parsed = + parse_package_manager_spec_with_hash("yarn@4.17.1+sha512.0123456789abcdef").unwrap(); + assert_eq!( + parsed, + (PackageManagerType::Yarn, "4.17.1".into(), Some("sha512.0123456789abcdef".into())) + ); + } +} diff --git a/crates/vp_global_cli/src/commands/env/unpin.rs b/crates/vp_global_cli/src/commands/env/unpin.rs index 234bac5202..44a3b919a5 100644 --- a/crates/vp_global_cli/src/commands/env/unpin.rs +++ b/crates/vp_global_cli/src/commands/env/unpin.rs @@ -8,9 +8,14 @@ use std::process::ExitStatus; use vt_path::AbsolutePathBuf; +use super::spec::EnvScope; use crate::{cli::PinTarget, error::Error}; /// Execute the unpin command. -pub async fn execute(cwd: AbsolutePathBuf, target: Option) -> Result { - super::pin::do_unpin(&cwd, target).await +pub async fn execute( + cwd: AbsolutePathBuf, + scope: Option, + target: Option, +) -> Result { + super::pin::do_unpin_scope(&cwd, EnvScope::parse(scope.as_deref())?, target).await } diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 405d322575..bf6d0aa565 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -1,7 +1,7 @@ //! Implementation of `vp env use` command. //! //! Outputs shell-appropriate commands to stdout that set (or unset) -//! the `VP_NODE_VERSION` environment variable. The shell function +//! the Node.js and package-manager environment variables. The shell function //! wrapper in `/env` evals this output to modify the current //! shell session. //! @@ -10,11 +10,13 @@ use std::process::ExitStatus; +use vp_pm_cli::{PackageManagerType, download_package_manager, resolve_package_manager_version}; use vt_path::AbsolutePathBuf; use super::{ - config::{self, VERSION_ENV_VAR}, - exit_status, + config::{self, PACKAGE_MANAGER_ENV_VAR, VERSION_ENV_VAR}, + exit_status, package_manager, + spec::{EnvScope, EnvSpecs}, }; use crate::{ commands::shell::{Shell, detect_shell}, @@ -22,29 +24,29 @@ use crate::{ }; /// Format a shell export command for the detected shell. -fn format_export(shell: &Shell, value: &str) -> String { +fn format_export(shell: &Shell, variable: &str, value: &str) -> String { match shell { - Shell::Posix => format!("export {VERSION_ENV_VAR}={value}"), - Shell::Fish => format!("set -gx {VERSION_ENV_VAR} {value}"), - Shell::PowerShell => format!("$env:{VERSION_ENV_VAR} = \"{value}\""), - Shell::Cmd => format!("set {VERSION_ENV_VAR}={value}"), - Shell::NuShell => format!("$env.{VERSION_ENV_VAR} = \"{value}\""), + Shell::Posix => format!("export {variable}={value}"), + Shell::Fish => format!("set -gx {variable} {value}"), + Shell::PowerShell => format!("$env:{variable} = \"{value}\""), + Shell::Cmd => format!("set {variable}={value}"), + Shell::NuShell => format!("$env.{variable} = \"{value}\""), } } /// Format a shell unset command for the detected shell. -fn format_unset(shell: &Shell) -> String { +fn format_unset(shell: &Shell, variable: &str) -> String { match shell { - Shell::Posix => format!("unset {VERSION_ENV_VAR}"), + Shell::Posix => format!("unset {variable}"), // Fish returns a nonzero status when the variable is already absent. // Keep the unset idempotent so wrappers can continue evaluating any // following command, such as the project-file export from `vp env use`. - Shell::Fish => format!("set -e {VERSION_ENV_VAR}; or true"), + Shell::Fish => format!("set -e {variable}; or true"), Shell::PowerShell => { - format!("Remove-Item Env:{VERSION_ENV_VAR} -ErrorAction SilentlyContinue") + format!("Remove-Item Env:{variable} -ErrorAction SilentlyContinue") } - Shell::Cmd => format!("set {VERSION_ENV_VAR}="), - Shell::NuShell => format!("hide-env {VERSION_ENV_VAR}"), + Shell::Cmd => format!("set {variable}="), + Shell::NuShell => format!("hide-env {variable}"), } } @@ -69,85 +71,242 @@ fn print_windows_eval_wrapper_required() { eprintln!("Then dot-source it now (or open a new PowerShell session) to load the wrapper."); } +fn package_manager_spec( + package_manager: PackageManagerType, + version: &str, + hash: Option<&str>, +) -> Result { + let mut spec = format!("{package_manager}@{version}"); + if let Some(hash) = hash { + if hash.is_empty() + || !hash.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'.' | b'-' | b'_' | b'/' | b'+' | b'=') + }) + { + return Err(Error::Other( + format!("invalid package-manager integrity suffix {hash:?}").into(), + )); + } + spec.push('+'); + spec.push_str(hash); + } + Ok(spec) +} + /// 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 session_package_manager = config::read_session_package_manager().await; + let environment_package_manager = vp_shared::EnvConfig::get().package_manager.clone(); + let (delete_session_package_manager, unset_environment_package_manager) = match scope { + EnvScope::PackageManager(expected) => ( + package_manager_matches(session_package_manager.as_deref(), expected), + package_manager_matches(environment_package_manager.as_deref(), expected), + ), + _ => (scope.includes_package_managers(), scope.includes_package_managers()), + }; + if scope.includes_node() { + config::delete_session_version().await?; + } + if delete_session_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_environment_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()); - } - // 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) + None }; - // Check if already active and suppress output if requested - if silent_if_unchanged { - let current_env = - vp_shared::EnvConfig::get().node_version.as_deref().map(|v| v.trim().to_string()); - let current = if !has_eval_wrapper() { - current_env.or(config::read_session_version().await) + let package_manager = if scope.includes_package_managers() { + if let Some((kind, selector, hash)) = specs.package_manager { + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + package_manager::warn_if_target_differs(&cwd, kind).await; + Some((kind, version, selector, hash)) + } else if let EnvScope::PackageManager(kind) = scope { + package_manager::warn_if_target_differs(&cwd, kind).await; + let resolution = + package_manager::resolve_from_files_or_fallback_for(&cwd, kind).await?; + Some(( + resolution.package_manager_type, + resolution.version.to_string(), + resolution.source.to_string(), + resolution.hash.map(|hash| hash.to_string()), + )) } else { - current_env + package_manager::resolve_from_files_for(&cwd, scope.package_manager()).await?.map( + |resolution| { + ( + resolution.package_manager_type, + resolution.version.to_string(), + resolution.source.to_string(), + resolution.hash.map(|hash| hash.to_string()), + ) + }, + ) + } + } else { + None + }; + + // Check if already active and suppress output if requested. + let unchanged = if silent_if_unchanged { + let node_unchanged = match &node { + Some((version, _)) => { + current_override( + config::read_session_version().await, + vp_shared::EnvConfig::get().node_version.clone(), + ) + .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, _, hash)) => { + let spec = package_manager_spec(*kind, version, hash.as_deref())?; + current_override( + config::read_session_package_manager().await, + vp_shared::EnvConfig::get().package_manager.clone(), + ) + .as_deref() + == Some(spec.as_str()) } - return Ok(ExitStatus::default()); + None if uses_project_environment && scope.includes_package_managers() => { + current_override( + config::read_session_package_manager().await, + vp_shared::EnvConfig::get().package_manager.clone(), + ) + .is_none() + } + None => true, + }; + node_unchanged && package_manager_unchanged + } else { + false + }; + if 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 { + ensure_components_installed(&node, &package_manager).await?; + } + + if has_eval_wrapper() { + if let Some((version, _)) = &node { + config::delete_session_version().await?; + println!("{}", format_export(&shell, VERSION_ENV_VAR, version)); + } + if let Some((kind, version, _, hash)) = &package_manager { + config::delete_session_package_manager().await?; + println!( + "{}", + format_export( + &shell, + PACKAGE_MANAGER_ENV_VAR, + &package_manager_spec(*kind, version, hash.as_deref())? + ) + ); + } else if uses_project_environment && scope.includes_package_managers() { + config::delete_session_package_manager().await?; + println!("{}", format_unset(&shell, PACKAGE_MANAGER_ENV_VAR)); + } + } else if !can_use_session_file() { + print_windows_eval_wrapper_required(); + return Ok(exit_status(1)); + } else { + // No eval wrapper (CI or direct invocation) — write session file so shims can read it + if let Some((version, _)) = &node { + config::write_session_version(version).await?; + } + if let Some((kind, version, _, hash)) = &package_manager { + config::write_session_package_manager(&package_manager_spec( + *kind, + version, + hash.as_deref(), + )?) + .await?; + } else if uses_project_environment && scope.includes_package_managers() { + config::delete_session_package_manager().await?; + } + } + + 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 package_manager_matches(value: Option<&str>, expected: PackageManagerType) -> bool { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| super::spec::parse_package_manager_spec(value).ok()) + .is_some_and(|(kind, _)| kind == expected) +} + +async fn ensure_components_installed( + node: &Option<(String, String)>, + package_manager: &Option<(PackageManagerType, String, String, Option)>, +) -> Result<(), Error> { + if let Some((resolved_version, _)) = node { let home_dir = vp_shared::EnvConfig::get() .dirs .data .join("js_runtime") .join("node") - .join(&resolved_version); + .join(resolved_version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); @@ -156,27 +315,18 @@ 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 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)); - } 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((kind, version, _, hash)) = package_manager { + download_package_manager(*kind, version, hash.as_deref()).await?; } + Ok(()) +} - // Status message to stderr (visible to user) - eprintln!("Using Node.js v{} (resolved from {})", resolved_version, source_desc); - - Ok(ExitStatus::default()) +fn current_override(session: Option, environment: Option) -> Option { + environment.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()).or(session) } #[cfg(test)] @@ -199,6 +349,14 @@ mod tests { ); } + #[test] + fn empty_environment_override_falls_back_to_session() { + assert_eq!( + current_override(Some("pnpm@10.18.0".into()), Some(" ".into())).as_deref(), + Some("pnpm@10.18.0") + ); + } + #[test] fn test_detect_shell_vp_shell_fish() { vp_shared::EnvConfig::with_vars( @@ -240,63 +398,75 @@ mod tests { #[test] fn test_format_export_posix() { - let result = format_export(&Shell::Posix, "20.18.0"); + let result = format_export(&Shell::Posix, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "export VP_NODE_VERSION=20.18.0"); } #[test] fn test_format_export_fish() { - let result = format_export(&Shell::Fish, "20.18.0"); + let result = format_export(&Shell::Fish, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "set -gx VP_NODE_VERSION 20.18.0"); } #[test] fn test_format_export_powershell() { - let result = format_export(&Shell::PowerShell, "20.18.0"); + let result = format_export(&Shell::PowerShell, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "$env:VP_NODE_VERSION = \"20.18.0\""); } #[test] fn test_format_export_cmd() { - let result = format_export(&Shell::Cmd, "20.18.0"); + let result = format_export(&Shell::Cmd, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "set VP_NODE_VERSION=20.18.0"); } #[test] fn test_format_unset_posix() { - let result = format_unset(&Shell::Posix); + let result = format_unset(&Shell::Posix, VERSION_ENV_VAR); assert_eq!(result, "unset VP_NODE_VERSION"); } #[test] fn test_format_unset_fish() { - let result = format_unset(&Shell::Fish); + let result = format_unset(&Shell::Fish, VERSION_ENV_VAR); assert_eq!(result, "set -e VP_NODE_VERSION; or true"); } #[test] fn test_format_unset_powershell() { - let result = format_unset(&Shell::PowerShell); + let result = format_unset(&Shell::PowerShell, VERSION_ENV_VAR); assert_eq!(result, "Remove-Item Env:VP_NODE_VERSION -ErrorAction SilentlyContinue"); } #[test] fn test_format_unset_cmd() { - let result = format_unset(&Shell::Cmd); + let result = format_unset(&Shell::Cmd, VERSION_ENV_VAR); assert_eq!(result, "set VP_NODE_VERSION="); } #[test] fn test_format_export_nushell() { - let result = format_export(&Shell::NuShell, "20.18.0"); + let result = format_export(&Shell::NuShell, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "$env.VP_NODE_VERSION = \"20.18.0\""); } #[test] fn test_format_unset_nushell() { - let result = format_unset(&Shell::NuShell); + let result = format_unset(&Shell::NuShell, VERSION_ENV_VAR); assert_eq!(result, "hide-env VP_NODE_VERSION"); } + #[test] + fn package_manager_spec_rejects_shell_metacharacters() { + let error = package_manager_spec( + PackageManagerType::Pnpm, + "10.18.0", + Some("sha512.valid; touch injected"), + ) + .unwrap_err(); + + assert!(error.to_string().contains("invalid package-manager integrity suffix")); + } + #[cfg(windows)] #[tokio::test] async fn test_windows_direct_use_without_eval_wrapper_does_not_write_session_file() { @@ -309,7 +479,7 @@ mod tests { [(env_vars::VP_HOME, Some(temp_dir.path())), ("CI", None)], |_| async move { let status = - execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + execute(cwd, vec!["20.18.0".into()], false, true, false).await.unwrap(); assert_eq!(status.code(), Some(1)); assert!(config::read_session_version().await.is_none()); @@ -327,7 +497,7 @@ mod tests { [(env_vars::VP_HOME, temp_dir.path().as_os_str()), ("CI", std::ffi::OsStr::new("1"))], |_| async { let status = - execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + execute(cwd, vec!["20.18.0".into()], false, true, false).await.unwrap(); assert!(status.success()); assert_eq!(config::read_session_version().await.as_deref(), Some("20.18.0")); @@ -351,7 +521,7 @@ mod tests { config::write_session_version("22.0.0").await.unwrap(); let status = - execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + execute(cwd, vec!["20.18.0".into()], false, true, false).await.unwrap(); assert!(status.success()); assert!(config::read_session_version().await.is_none()); diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 6b6a3a3091..9aa3c894dc 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -12,17 +12,18 @@ use chrono::Local; use owo_colors::OwoColorize; use vp_pm_cli::{ PackageManagerType, package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_from_package_json, + resolve_package_manager_version, }; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{ bin_config::{BinConfig, BinSource}, - config::{VERSION_ENV_VAR, get_bin_dir, get_node_modules_dir, resolve_version}, + config::{ShimMode, VERSION_ENV_VAR, get_bin_dir, get_node_modules_dir, resolve_version}, + package_manager, package_metadata::PackageMetadata, }; -use crate::{cli::exit_status, error::Error}; +use crate::{cli::exit_status, error::Error, shim}; /// Core tools (node, npm, npx) const CORE_TOOLS: &[&str] = &["node", "npm", "npx"]; @@ -32,6 +33,18 @@ const LABEL_WIDTH: usize = 10; /// Execute the which command. pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result { + let config = super::config::load_config().await?; + let mode = if let Some(package_manager) = PackageManagerType::from_tool(tool) { + config.package_manager_shim_mode_for(package_manager) + } else { + config.node_shim_mode + }; + if mode == ShimMode::SystemFirst + && let Some(path) = shim::dispatch::find_system_tool(tool) + { + println!("{}", path.as_path().display()); + return Ok(ExitStatus::default()); + } if let Some(status) = execute_package_manager_tool(&cwd, tool).await? { return Ok(status); } @@ -138,14 +151,23 @@ async fn execute_package_manager_tool( let Some(expected_type) = PackageManagerType::from_tool(tool) else { return Ok(None); }; - let Some(resolution) = resolve_package_manager_from_package_json(cwd)? else { - return Ok(None); + let resolution = package_manager::resolve_current_for(cwd, Some(expected_type)).await?; + let (version, source) = match &resolution { + Some(resolution) => ( + resolution.version.to_string(), + resolution.source_path.as_ref().map_or_else( + || resolution.source.to_string(), + |path| path.as_path().display().to_string(), + ), + ), + None if expected_type == PackageManagerType::Npm => return Ok(None), + None => ( + resolve_package_manager_version(expected_type, "latest").await?.to_string(), + "registry fallback".into(), + ), }; - if resolution.package_manager_type != expected_type { - return Ok(None); - } - let Some(install_dir) = package_manager_install_dir(expected_type, &resolution.version) else { + let Some(install_dir) = package_manager_install_dir(expected_type, &version) else { return Ok(None); }; let bin_name = expected_type.bin_name_for_tool(tool); @@ -153,7 +175,7 @@ async fn execute_package_manager_tool( if !tokio::fs::try_exists(&tool_path).await.unwrap_or(false) { output::error(&format!("{} not found", tool.bold())); - eprintln!("{expected_type} {} is not installed.", resolution.version); + eprintln!("{expected_type} {version} is not installed."); eprintln!("Run 'vp install' inside the project to download it."); return Ok(Some(exit_status(1))); } @@ -162,19 +184,23 @@ async fn execute_package_manager_tool( println!( " {: Result { + if matches!(tool, "npm" | "npx") + && super::config::load_config().await?.node_shim_mode == ShimMode::SystemFirst + && let Some(path) = shim::dispatch::find_system_tool(tool) + { + println!("{}", path.as_path().display()); + return Ok(ExitStatus::default()); + } + // Resolve version for current directory let resolution = resolve_version(&cwd).await?; diff --git a/crates/vp_global_cli/src/commands/global/install.rs b/crates/vp_global_cli/src/commands/global/install.rs index ccbaa443c1..e3c793d290 100644 --- a/crates/vp_global_cli/src/commands/global/install.rs +++ b/crates/vp_global_cli/src/commands/global/install.rs @@ -26,7 +26,7 @@ use crate::{ package_metadata::{PackageMetadata, is_legacy_install_id, is_nested_install_id}, }, global::{ - CORE_SHIMS, LEGACY_PACKAGE_MANAGER_PACKAGES, is_local_package_spec, parse_package_spec, + LEGACY_PACKAGE_MANAGER_PACKAGES, is_local_package_spec, parse_package_spec, update_version_spec, }, }, @@ -110,7 +110,7 @@ fn windows_regular_file_is_vp_shim(shim_path: &vt_path::AbsolutePath) -> bool { pub(crate) fn is_protected_shim(bin_name: &str, ignore_case: bool) -> bool { let bin_name = if cfg!(target_os = "linux") || !ignore_case { bin_name } else { &bin_name.to_lowercase() }; - CORE_SHIMS.contains(&bin_name) || crate::commands::env::setup::SHIM_TOOLS.contains(&bin_name) + bin_name == "vp" || crate::shim::DEFAULT_SHIM_TOOLS.contains(&bin_name) } /// Options for [`install`]. @@ -1229,7 +1229,7 @@ mod tests { #[test] fn test_default_shims_are_protected() { - for shim in CORE_SHIMS.iter().chain(crate::commands::env::setup::SHIM_TOOLS) { + for shim in crate::shim::DEFAULT_SHIM_TOOLS.iter().chain([&"vp"]) { assert!(is_protected_shim(shim, false), "{shim} should be protected"); } } diff --git a/crates/vp_global_cli/src/commands/global/mod.rs b/crates/vp_global_cli/src/commands/global/mod.rs index 6e9431fb96..e7b3a2de4a 100644 --- a/crates/vp_global_cli/src/commands/global/mod.rs +++ b/crates/vp_global_cli/src/commands/global/mod.rs @@ -16,10 +16,6 @@ pub mod install; pub mod outdated; pub mod packages; -/// Core shims that should not be overwritten by package binaries. -pub(crate) const CORE_SHIMS: &[&str] = - &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vp"]; - /// Legacy managed globals superseded by the default package-manager shims. pub(crate) const LEGACY_PACKAGE_MANAGER_PACKAGES: &[&str] = &["yarn", "pnpm", "bun", "corepack"]; diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 9ad0244734..9254010ca4 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -15,11 +15,12 @@ use vt_str::Str; use crate::{ cli::exit_status, commands::{ - env::setup::{SHIM_TOOLS, shim_filename}, + env::setup::shim_filename, global::install::is_vp_shim_target, shell::{ALL_SHELL_PROFILES, ShellProfileKind, abbreviate_home_path, resolve_profile_path}, }, error::Error, + shim::DEFAULT_SHIM_TOOLS, }; /// Comment marker written by the install script above the sourcing line. @@ -151,7 +152,7 @@ pub fn execute(yes: bool) -> Result { fn remove_shim_files(dirs: &vp_shared::VpDirs) { let mut names = recorded_bin_shim_names(dirs); names.insert(shim_filename("vp")); - names.extend(SHIM_TOOLS.iter().map(|tool| shim_filename(tool))); + names.extend(DEFAULT_SHIM_TOOLS.iter().map(|tool| shim_filename(tool))); #[cfg(windows)] names.insert("vp-use.cmd".to_string()); diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index a07b462ca4..007d351332 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -108,28 +108,62 @@ pub(crate) fn warn_missing_local_cli_if_project(cwd: &AbsolutePath) { } } -/// Ensure the JS runtime is downloaded and prepend its bin directory to PATH. +/// Select the configured JS runtime and prepend its bin directory to PATH. /// This should be called before executing any package manager command. /// /// If `project_path` contains a package.json, uses the project's runtime /// (based on devEngines.runtime). Otherwise, falls back to the CLI's runtime. pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Result<(), Error> { + let config = env::config::load_config().await?; let mut executor = JsExecutor::new(None); - // Use project runtime if package.json exists, otherwise use CLI runtime - let package_json_path = project_path.join("package.json"); - let runtime = if package_json_path.as_path().exists() { - executor.ensure_project_runtime(project_path).await? + let node_bin_prefix = if config.node_shim_mode == env::config::ShimMode::SystemFirst + && let Some(system_node) = crate::shim::dispatch::find_system_tool("node") + && let Some(bin_dir) = system_node.parent() + { + bin_dir.to_absolute_path_buf() } else { - executor.ensure_cli_runtime().await? + // Use project runtime if package.json exists, otherwise use CLI runtime + let package_json_path = project_path.join("package.json"); + let runtime = if package_json_path.as_path().exists() { + executor.ensure_project_runtime(project_path).await? + } else { + executor.ensure_cli_runtime().await? + }; + runtime.get_bin_prefix() }; - let node_bin_prefix = runtime.get_bin_prefix(); // Use dedupe_anywhere=true to check if node bin already exists anywhere in PATH let options = PrependOptions { dedupe_anywhere: true }; if prepend_to_path_env(&node_bin_prefix, options) { tracing::debug!("Set PATH to include {:?}", node_bin_prefix); } + if let Some(package_manager) = env::package_manager::resolve_current_spec(project_path).await? { + if config.package_manager_shim_mode_for(package_manager.package_manager_type) + == env::config::ShimMode::SystemFirst + && let Some(system_path) = crate::shim::dispatch::find_system_tool( + &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(()); + } + } + if let Some(package_manager) = env::package_manager::resolve_current(project_path).await? { + let (install_dir, _, _) = vp_pm_cli::download_package_manager( + package_manager.package_manager_type, + &package_manager.version, + package_manager.hash.as_deref(), + ) + .await?; + let bin_dir = install_dir.join("bin"); + if prepend_to_path_env(&bin_dir, PrependOptions { dedupe_anywhere: true }) { + tracing::debug!("Set PATH to include {:?}", bin_dir); + } + } Ok(()) } diff --git a/crates/vp_global_cli/src/help.rs b/crates/vp_global_cli/src/help.rs index 6445cc204b..faa9d26040 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_global_cli/src/help.rs @@ -266,7 +266,7 @@ pub fn top_level_help_doc() -> HelpDoc { "install, i", "Install all dependencies, or add packages if package names are provided", ), - row("env", "Manage Node.js versions"), + row("env", "Manage Node.js and package managers"), ], ), section_rows( @@ -330,34 +330,28 @@ pub fn top_level_help_doc() -> HelpDoc { fn env_help_doc() -> HelpDoc { HelpDoc { usage: "vp env [COMMAND]".into(), - summary: vec!["Manage Node.js versions".into()], + summary: vec!["Manage Node.js and package-manager environments".into()], sections: vec![ section_rows( "Setup", vec![ row("setup", "Create or update shims in VP_HOME/bin"), - row("on", "Enable managed mode - shims always use vite-plus managed Node.js"), - row( - "off", - "Enable system-first mode - shims prefer system Node.js, fallback to managed", - ), - row("print", "Print shell snippet to set environment for current session"), + row("on", "Enable managed mode for selected environment scopes"), + row("off", "Enable system-first mode for selected environment scopes"), + row("print", "Print PATH setup for the resolved environment"), ], ), section_rows( "Manage", vec![ - row("default", "Set or show the global default Node.js version"), - row("pin", "Pin a Node.js version in the current directory"), - row( - "unpin", - "Remove the Node.js pin from the current directory (alias for `pin --unpin`)", - ), - row("use", "Use a specific Node.js version for this shell session"), - row("install, i", "Install a Node.js version"), - row("uninstall, uni", "Uninstall a Node.js version"), - row("clean", "Remove unused managed runtimes and package manager caches"), - row("exec, run", "Execute a command with a specific Node.js version"), + row("default", "Set or show global environment defaults"), + row("pin", "Pin Node.js and package-manager versions in the project"), + row("unpin", "Remove project environment pins (alias for `pin --unpin`)"), + row("use", "Activate an environment for this shell session"), + row("install, i", "Install a resolved or explicit environment"), + row("uninstall, uni", "Uninstall explicit component versions"), + row("clean", "Remove unused runtimes and package managers"), + row("exec, run", "Execute a command in a resolved or explicit environment"), ], ), section_rows( @@ -366,10 +360,10 @@ fn env_help_doc() -> HelpDoc { row("current", "Show current environment information"), row("doctor", "Run diagnostics and show environment status"), row("which", "Show path to the tool that would be executed"), - row("list, ls", "List locally installed Node.js versions"), + row("list, ls", "List locally installed environment components"), row( "list-remote, ls-remote", - "List available Node.js versions from the registry", + "List available versions from component registries", ), ], ), @@ -378,26 +372,30 @@ fn env_help_doc() -> HelpDoc { vec![ " Setup:", " vp env setup # Create Node.js and package-manager shims", - " vp env on # Use vite-plus managed Node.js", - " vp env print # Print shell snippet for this session", + " vp env on # Manage Node.js and package managers", + " vp env off pm # Prefer system package managers only", + " vp env off pnpm # Prefer system pnpm 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 pnpm's default version", + " vp env pin 22.19.0 # Pin Node.js for this project", + " vp env use 22.19.0 # Use Node.js in this shell", + " vp env clean # Clean all unused managed versions", "", " 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/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 31459ed92b..2bdf387ebf 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -506,14 +506,14 @@ async fn has_valid_version_source(project_path: &AbsolutePath) -> Result Option { let config = config::load_config().await.ok()?; - if config.shim_mode != ShimMode::SystemFirst { + if config.node_shim_mode != ShimMode::SystemFirst { return None; } let system_node = shim::find_system_tool("node")?; diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 807e64af0d..0c67e85c1b 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -5,9 +5,8 @@ //! 2. Node.js installation (if needed) //! 3. Tool execution (core shims and package binaries) -use vp_pm_cli::{ - PackageManagerType, ensure_package_manager_bin, resolve_package_manager_from_package_json, -}; +use dialoguer::{Select, theme::ColorfulTheme}; +use vp_pm_cli::{PackageManagerType, ensure_package_manager_bin}; use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; @@ -20,6 +19,7 @@ use crate::{ env::{ bin_config::{BinConfig, BinSource}, config::{self, ShimMode}, + package_manager, package_metadata::PackageMetadata, }, global::install::is_protected_shim, @@ -254,7 +254,7 @@ fn check_npm_global_install_result( // the user for non-core names: npm installed the package, but the // binary stays unlinked. if is_protected_shim(&bin_name, false) { - if !crate::commands::global::CORE_SHIMS.contains(&bin_name.as_str()) { + if bin_name != "vp" && !crate::shim::is_core_shim_tool(&bin_name) { output::note(&vt_str::format!( "'{bin_name}' is a Vite+ default shim; the npm-installed copy is not \ linked." @@ -660,12 +660,11 @@ async fn resolve_package_manager_tool( return Ok(None); }; - let (version, hash) = match resolve_package_manager_from_package_json(cwd)? { - 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), + let resolution = package_manager::resolve_current_for(cwd, Some(expected_type)).await?; + let (version, hash) = match resolution { + Some(resolution) => (resolution.version, resolution.hash), + None if expected_type == PackageManagerType::Npm => return Ok(None), + None => ("latest".into(), None), }; let bin_name = expected_type.bin_name_for_tool(tool); @@ -737,11 +736,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. @@ -779,24 +784,45 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } }; - // Resolve version (with caching) - let resolution = match resolve_with_cache(&cwd).await { - Ok(r) => r, - Err(e) => { - eprintln!("vp: Failed to resolve Node version: {e}"); - eprintln!("vp: Run 'vp env doctor' for diagnostics"); - return 1; - } - }; - // Ensure Node.js is installed and locate its binary for PATH preparation. // Package-manager shims can use their own declared version, but JS-based - // package managers still need the project-resolved Node.js runtime. - let node_path = match ensure_installed(&resolution.version).await { - Ok(p) => p, - Err(e) => { - eprintln!("vp: Failed to install Node {}: {e}", resolution.version); - return 1; + // package managers still need the Node.js runtime selected by its mode. + let system_node = if PackageManagerType::from_tool(tool).is_some() { + match config::load_config().await { + Ok(config) if config.node_shim_mode == ShimMode::SystemFirst => { + find_system_tool("node") + } + Ok(_) => None, + Err(error) => { + eprintln!("vp: Failed to load Node.js shim mode: {error}"); + return 1; + } + } + } else { + None + }; + let resolution = if system_node.is_none() { + match resolve_with_cache(&cwd).await { + Ok(resolution) => Some(resolution), + Err(error) => { + eprintln!("vp: Failed to resolve Node version: {error}"); + eprintln!("vp: Run 'vp env doctor' for diagnostics"); + return 1; + } + } + } else { + None + }; + let node_path = if let Some(system_node) = system_node { + system_node + } else { + let resolution = resolution.as_ref().expect("managed Node.js has no resolution"); + match ensure_installed(&resolution.version).await { + Ok(path) => path, + Err(error) => { + eprintln!("vp: Failed to install Node {}: {error}", resolution.version); + return 1; + } } }; @@ -804,13 +830,19 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // fallback. Node and bundled npm tools come from the selected Node.js runtime. let tool_path = match resolve_package_manager_tool(&cwd, tool).await { Ok(Some(path)) => path, - Ok(None) => match locate_tool(&resolution.version, tool) { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Tool '{tool}' not found: {e}"); - return 1; + Ok(None) => { + let path = match resolution.as_ref() { + Some(resolution) => locate_tool(&resolution.version, tool), + None => find_system_tool(tool).ok_or_else(|| format!("system '{tool}' not found")), + }; + match path { + Ok(path) => path, + Err(error) => { + eprintln!("vp: Tool '{tool}' not found: {error}"); + return 1; + } } - }, + } Err(e) => { eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); return 1; @@ -837,10 +869,18 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Optional debug env vars if std::env::var(env_vars::VP_DEBUG_SHIM).is_ok() { - // SAFETY: Setting env vars at this point before exec is safe - unsafe { - std::env::set_var(env_vars::VP_ACTIVE_NODE, &resolution.version); - std::env::set_var(env_vars::VP_RESOLVE_SOURCE, &resolution.source); + if let Some(resolution) = resolution.as_ref() { + // SAFETY: Setting env vars at this point before exec is safe + unsafe { + std::env::set_var(env_vars::VP_ACTIVE_NODE, &resolution.version); + std::env::set_var(env_vars::VP_RESOLVE_SOURCE, &resolution.source); + } + } else if let Some(version) = read_node_version(&node_path) { + // SAFETY: Setting env vars at this point before exec is safe + unsafe { + std::env::set_var(env_vars::VP_ACTIVE_NODE, version); + std::env::set_var(env_vars::VP_RESOLVE_SOURCE, "system PATH"); + } } } @@ -859,19 +899,18 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { if let Some(parsed) = parse_npm_global_install(args) { let exit_code = exec::spawn_tool(&tool_path, args); if exit_code == 0 { - let node_dir = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(&*resolution.version); + let node_dir = node_prefix_from_binary(&node_path); + let node_version = resolution.as_ref().map_or_else( + || read_node_version(&node_path).unwrap_or_else(|| "unknown".into()), + |resolution| resolution.version.clone(), + ); let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); check_npm_global_install_result( &parsed.packages, original_path.as_deref(), &npm_prefix, &node_dir, - &resolution.version, + &node_version, ); } return exit_code; @@ -879,12 +918,7 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { if let Some(parsed) = parse_npm_global_uninstall(args) { // Collect bin names before uninstall (package.json will be gone after) - let node_dir = vp_shared::EnvConfig::get() - .dirs - .data - .join("js_runtime") - .join("node") - .join(&*resolution.version); + let node_dir = node_prefix_from_binary(&node_path); let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); let bin_names = collect_bin_names_from_npm(&parsed.packages, &npm_prefix, &node_dir); let exit_code = exec::spawn_tool(&tool_path, args); @@ -899,6 +933,42 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { exec::exec_tool(&tool_path, args) } +fn node_prefix_from_binary(node_path: &AbsolutePath) -> AbsolutePathBuf { + #[cfg(windows)] + let prefix = node_path.parent(); + #[cfg(not(windows))] + let prefix = node_path.parent().and_then(AbsolutePath::parent); + prefix.expect("Node.js has no installation prefix").to_absolute_path_buf() +} + +fn read_node_version(node_path: &AbsolutePath) -> Option { + let output = std::process::Command::new(node_path.as_path()).arg("--version").output().ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +} + +async fn prepare_node_path_for_system_package_manager() -> Result<(), Error> { + let config = config::load_config().await?; + if config.node_shim_mode == ShimMode::SystemFirst + && let Some(node) = find_system_tool("node") + && let Some(bin_dir) = node.parent() + { + let _ = prepend_to_path_env(bin_dir, PrependOptions::default()); + return Ok(()); + } + + let cwd = current_dir()?; + let resolution = resolve_with_cache(&cwd).await.map_err(|error| Error::Other(error.into()))?; + let node = + ensure_installed(&resolution.version).await.map_err(|error| Error::Other(error.into()))?; + let bin_dir = + node.parent().ok_or_else(|| Error::Other("Node.js has no bin directory".into()))?; + let _ = prepend_to_path_env(bin_dir, PrependOptions::default()); + Ok(()) +} + /// Dispatch a package binary shim. /// /// Finds the package that provides this binary and executes it with the @@ -1232,8 +1302,96 @@ 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 { + let Some(package_manager) = PackageManagerType::from_tool(tool) else { + return config::load_config().await.map(|config| config.node_shim_mode).unwrap_or_default(); + }; + resolve_package_manager_shim_mode(tool, package_manager).await +} + +async fn resolve_package_manager_shim_mode( + tool: &str, + package_manager: PackageManagerType, +) -> ShimMode { + let mut config = match config::load_config().await { + Ok(config) => config, + Err(error) => { + output::warn(&format!("Could not read package-manager shim preferences: {error}")); + return ShimMode::Managed; + } + }; + if let Some(mode) = config.configured_package_manager_shim_mode_for(package_manager) { + return mode; + } + + let Some(system_path) = find_system_tool(tool) else { + return ShimMode::Managed; + }; + + if !vp_shared::is_interactive_terminal() { + return ShimMode::Managed; + } + + let Some((mode, apply_to_all)) = + prompt_package_manager_shim_mode(package_manager, &system_path) + else { + output::note("Package-manager preference was not saved; using the system tool this time."); + return ShimMode::SystemFirst; + }; + if apply_to_all { + config.set_all_package_manager_shim_modes(mode); + } else { + config.set_package_manager_shim_mode(package_manager, mode); + } + if let Err(error) = config::save_config(&config).await { + output::warn(&format!("Could not save package-manager shim preferences: {error}")); + } + mode +} + +fn prompt_package_manager_shim_mode( + package_manager: PackageManagerType, + system_path: &AbsolutePath, +) -> Option<(ShimMode, bool)> { + let options = [ + "Use Vite+ for all package managers".to_string(), + format!("Use Vite+ for {package_manager}"), + format!("Use system {package_manager}"), + "Use system package managers".to_string(), + ]; + + output::raw_stderr("vp: Vite+ now can manage package-manager versions for each project."); + output::raw_stderr(&format!("Existing {package_manager}: {}", system_path.as_path().display())); + output::raw_stderr(""); + emit_prompt_milestone(&format!("pm-shim-choice:{package_manager}")); + let choice = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("How should {package_manager} run?")) + .items(&options) + .default(1) + .interact() + .ok()?; + + Some(match choice { + 0 => (ShimMode::Managed, true), + 1 => (ShimMode::Managed, false), + 2 => (ShimMode::SystemFirst, false), + _ => (ShimMode::SystemFirst, true), + }) +} + +/// Emit an invisible synchronization point for the PTY snapshot suite. +#[expect(clippy::disallowed_macros)] +fn emit_prompt_milestone(name: &str) { + use std::io::Write as _; + + if std::env::var_os(env_vars::VP_EMIT_MILESTONES).is_none_or(|value| value != "1") { + return; + } + let id = uuid::Uuid::new_v4(); + let encoded_name = base64_simd::URL_SAFE_NO_PAD.encode_to_string(name.as_bytes()); + let mut stderr = std::io::stderr().lock(); + let _ = write!(stderr, "\x1b]2;pty-terminal-test:{}:{encoded_name}\x1b\\", id.simple()); + let _ = stderr.flush(); } /// Find a system tool in PATH, skipping the vite-plus bin directory and any diff --git a/crates/vp_global_cli/src/shim/mod.rs b/crates/vp_global_cli/src/shim/mod.rs index 10b58551be..cb235dba3f 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -21,9 +21,9 @@ use vp_shared::env_vars; use crate::commands::env::config::get_bin_dir; -/// Core shim tools managed directly by the main dispatch path. -pub const CORE_SHIM_TOOLS: &[&str] = - &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx"]; +/// Default shims created by `vp env setup`. +pub const DEFAULT_SHIM_TOOLS: &[&str] = + &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vpx", "vpr"]; /// Extract the tool name from argv[0]. /// We hope all bins should be put under $VP_HOME/bin @@ -67,7 +67,7 @@ pub fn extract_tool_name(argv0: &str) -> String { /// Check if the given tool name is managed directly by the core shim path. #[must_use] pub fn is_core_shim_tool(tool: &str) -> bool { - CORE_SHIM_TOOLS.contains(&tool) + tool == "node" || vp_pm_cli::PackageManagerType::from_tool(tool).is_some() } /// Check if the given tool name is a shim tool (core or package binary). diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 22a15a5e04..05208888f2 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -221,10 +221,23 @@ async fn do_install(opts: &cli::Options, dirs: &VpDirs) -> Result<(), Box { + let vp_binary = dirs.data.join("current").join("bin").join(VP_BINARY_NAME); + let preference_result = tokio::process::Command::new(vp_binary.as_path()) + .args(["env", "on"]) + .output() + .await; + if !preference_result.is_ok_and(|output| output.status.success()) { + print_warn("Failed to record environment management preference."); + } + } + Ok(()) => {} + Err(e) => { + print_warn(&format!("Node.js and package-manager setup failed (non-fatal): {e}")) + } } } else if let Err(e) = install::create_env_files(&dirs.data).await { print_warn(&format!("Env file creation failed (non-fatal): {e}")); @@ -515,7 +528,7 @@ fn show_interactive_menu(opts: &mut cli::Options, data_dir: &str, bin_dir: &str) ); println!(" Version: {}", style(version).cyan()); println!( - " Node.js manager: {}", + " Node.js / package managers: {}", style(if opts.no_node_manager { "disabled" } else { "enabled" }).cyan() ); println!(); @@ -548,7 +561,7 @@ fn show_customize_menu(opts: &mut cli::Options) { println!(" 1) Version: [{}]", style(version_display).cyan()); println!(" 2) npm registry: [{}]", style(registry_display).cyan()); println!( - " 3) Node.js manager: [{}]", + " 3) Node.js / package managers: [{}]", style(if opts.no_node_manager { "disabled" } else { "enabled" }).cyan() ); println!( diff --git a/crates/vp_pm_cli/src/cli.rs b/crates/vp_pm_cli/src/cli.rs index a895edc99b..561fe77f88 100644 --- a/crates/vp_pm_cli/src/cli.rs +++ b/crates/vp_pm_cli/src/cli.rs @@ -416,7 +416,7 @@ mod tests { PackageManager { client, version: version.into(), - install_dir: workspace_root.join(".test-package-manager"), + bin_prefix: workspace_root.join(".test-package-manager").join("bin"), } } diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index 3e1413df6a..e2ea4d0e63 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -8,10 +8,14 @@ use std::process::ExitStatus; use vt_path::AbsolutePath; use crate::{ - PackageManager, + EnvironmentPackageManagerResolution, PackageManager, cli::{PackageManagerCommand, PmCommand}, + download_package_manager, error::Error, - helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, + helpers::{ + build_package_manager, build_package_manager_or_npm_default, ensure_package_json, + require_package_json, + }, resolution::{DlxArgs, StageCommand, run_resolution}, }; @@ -28,6 +32,12 @@ enum ManagerPolicy { AllowNpmFallback, } +enum ManagerSource<'a> { + Detect, + Environment(&'a EnvironmentPackageManagerResolution), + ResolvedEnvironment(PackageManager, &'a EnvironmentPackageManagerResolution), +} + pub async fn dispatch( cwd: &AbsolutePath, command: PackageManagerCommand, @@ -38,24 +48,67 @@ pub async fn dispatch( pub async fn dispatch_with_metadata( cwd: &AbsolutePath, command: PackageManagerCommand, +) -> Result { + dispatch_with_manager(cwd, command, ManagerSource::Detect).await +} + +pub async fn dispatch_with_package_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + package_manager: &EnvironmentPackageManagerResolution, +) -> Result { + dispatch_with_manager(cwd, command, ManagerSource::Environment(package_manager)).await +} + +pub async fn dispatch_with_resolved_package_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + manager: PackageManager, + package_manager: &EnvironmentPackageManagerResolution, +) -> Result { + dispatch_with_manager( + cwd, + command, + ManagerSource::ResolvedEnvironment(manager, package_manager), + ) + .await +} + +async fn dispatch_with_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + source: ManagerSource<'_>, ) -> Result { let render_diagnostics = command.should_render_diagnostics(); let command = match command { PackageManagerCommand::Dlx(args) => { - return dispatch_dlx(cwd, args, render_diagnostics).await; + let manager = match source { + ManagerSource::Detect => return dispatch_dlx(cwd, args, render_diagnostics).await, + source => resolve_manager(cwd, source).await?, + }; + let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; + let status = run_resolution(cwd, resolution, render_diagnostics).await?; + return Ok(DispatchResult { status, why_hint_packages: None }); } command => command, }; - let manager = match manager_policy(&command) { - ManagerPolicy::CreateIfMissing => { - ensure_package_json(cwd).await?; - build_package_manager(cwd).await? - } - ManagerPolicy::RequireProject => build_package_manager(cwd).await?, - ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, - }; + let policy = manager_policy(&command); + match policy { + ManagerPolicy::CreateIfMissing => ensure_package_json(cwd).await?, + ManagerPolicy::RequireProject => require_package_json(cwd)?, + ManagerPolicy::AllowNpmFallback => {} + } + let manager = match source { + ManagerSource::Detect => match policy { + ManagerPolicy::CreateIfMissing | ManagerPolicy::RequireProject => { + build_package_manager(cwd).await? + } + ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, + }, + source => resolve_manager(cwd, source).await?, + }; let package_manager = manager.client; let why_hint_packages = command.why_hint_packages(package_manager).map(<[String]>::to_vec); let resolution = command.resolve_for_manager(&manager)?; @@ -63,6 +116,65 @@ pub async fn dispatch_with_metadata( Ok(DispatchResult { status, why_hint_packages }) } +async fn resolve_manager( + cwd: &AbsolutePath, + source: ManagerSource<'_>, +) -> Result { + match source { + ManagerSource::Environment(package_manager) => { + let manager = build_selected_package_manager(package_manager).await?; + auto_pin_environment_package_manager(cwd, package_manager, &manager).await?; + Ok(manager) + } + ManagerSource::ResolvedEnvironment(manager, package_manager) => { + auto_pin_environment_package_manager(cwd, package_manager, &manager).await?; + Ok(manager) + } + ManagerSource::Detect => unreachable!("detected managers are resolved from the cwd"), + } +} + +async fn build_selected_package_manager( + package_manager: &EnvironmentPackageManagerResolution, +) -> Result { + let (install_dir, _, version) = download_package_manager( + package_manager.package_manager_type, + &package_manager.version, + package_manager.hash.as_deref(), + ) + .await + .map_err(Error::Install)?; + Ok(PackageManager { + client: package_manager.package_manager_type, + version, + bin_prefix: install_dir.join("bin"), + }) +} + +async fn auto_pin_environment_package_manager( + cwd: &AbsolutePath, + resolution: &EnvironmentPackageManagerResolution, + manager: &PackageManager, +) -> Result<(), Error> { + if matches!(resolution.source.as_str(), "lockfile or config" | "default") { + let project_root = resolution.project_root.clone().or_else(|| { + vt_workspace::find_workspace_root(cwd) + .ok() + .map(|(workspace, _)| workspace.path.to_absolute_path_buf()) + }); + let Some(project_root) = project_root else { + return Ok(()); + }; + super::package_manager::set_dev_engines_package_manager_field( + &project_root.join("package.json"), + resolution.package_manager_type, + &manager.version, + ) + .await?; + } + Ok(()) +} + async fn dispatch_dlx( cwd: &AbsolutePath, args: DlxArgs, diff --git a/crates/vp_pm_cli/src/helpers.rs b/crates/vp_pm_cli/src/helpers.rs index ae47fb238d..75c3ec229f 100644 --- a/crates/vp_pm_cli/src/helpers.rs +++ b/crates/vp_pm_cli/src/helpers.rs @@ -16,6 +16,17 @@ pub async fn build_package_manager(cwd: &AbsolutePath) -> Result Result<(), Error> { + match vt_workspace::find_workspace_root(cwd) { + Ok(_) => Ok(()), + Err(vt_workspace::Error::PackageJsonNotFound(_)) => { + Err(Error::UserMessage("No package.json found.".into())) + } + Err(error) => Err(Error::Install(error.into())), + } +} + /// Build a `PackageManager`, falling back to a default npm instance when no /// package.json is found. Uses `build()` instead of `build_with_default()` /// to skip the interactive package manager selection prompt on the fallback path. @@ -38,7 +49,7 @@ pub(crate) fn default_npm_package_manager(cwd: &AbsolutePath) -> PackageManager PackageManager { client: PackageManagerType::Npm, version: "latest".into(), - install_dir: cwd.to_absolute_path_buf(), + bin_prefix: cwd.join("bin"), } } diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index 9105a7e8ea..fe0ec60df7 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -19,12 +19,17 @@ mod shim; pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; pub use config::npm_registry; -pub use dispatch::{DispatchResult, dispatch, dispatch_with_metadata}; +pub use dispatch::{ + DispatchResult, dispatch, dispatch_with_metadata, dispatch_with_package_manager, + dispatch_with_resolved_package_manager, +}; pub use error::Error; pub use package_manager::{ - PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, - PackageManagerType, download_package_manager, ensure_package_manager_bin, + EnvironmentPackageManagerResolution, PackageManager, PackageManagerBuilder, + PackageManagerResolution, PackageManagerSource, PackageManagerType, download_package_manager, + ensure_package_manager_bin, fetch_package_manager_versions, get_package_manager_type_and_version, package_manager_bin_path, package_manager_install_dir, + resolve_environment_package_manager, resolve_environment_package_manager_spec, resolve_package_manager_from_package_json, resolve_package_manager_version, }; pub use request::HttpClient; diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index a6ca89d9f2..6f9c3de506 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -120,6 +120,16 @@ impl PackageManagerType { pub fn hashes_cli_binary_of(self, version: &Version) -> bool { matches!(self, Self::Yarn) && is_yarn_berry(version) } + + #[must_use] + pub const fn bin_names(self) -> &'static [&'static str] { + match self { + Self::Npm => &["npm", "npx"], + Self::Pnpm => &["pnpm", "pnpx"], + Self::Yarn => &["yarn", "yarnpkg"], + Self::Bun => &["bun", "bunx"], + } + } } /// Name of the file that records the pin vp verified when it installed a @@ -154,6 +164,16 @@ pub struct PackageManagerResolution { pub project_root: AbsolutePathBuf, } +#[derive(Debug, Clone)] +pub struct EnvironmentPackageManagerResolution { + pub package_manager_type: PackageManagerType, + pub version: Str, + pub hash: Option, + pub source: Str, + pub source_path: Option, + pub project_root: Option, +} + /// Where the package manager selection came from (see rfcs/dev-engines.md). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PackageManagerSource { @@ -167,6 +187,18 @@ pub enum PackageManagerSource { Default, } +impl PackageManagerSource { + #[must_use] + pub const fn description(self) -> &'static str { + match self { + Self::PackageManagerField => "packageManager", + Self::DevEnginesPackageManager => "devEngines.packageManager", + Self::LockfileOrConfig => "lockfile or config", + Self::Default => "default", + } + } +} + /// The package manager. /// Use `PackageManager::builder()` to create a package manager. /// Command argument resolution and execution live in `vp_pm_cli`. @@ -174,7 +206,7 @@ pub enum PackageManagerSource { pub struct PackageManager { pub(crate) client: PackageManagerType, pub(crate) version: Str, - pub(crate) install_dir: AbsolutePathBuf, + pub(crate) bin_prefix: AbsolutePathBuf, } #[derive(Debug)] @@ -222,7 +254,11 @@ impl PackageManagerBuilder { .await?; } - Ok(PackageManager { client: package_manager_type, version, install_dir }) + Ok(PackageManager { + client: package_manager_type, + version, + bin_prefix: install_dir.join("bin"), + }) } /// Build the package manager with default package manager. @@ -246,9 +282,27 @@ 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(), bin_prefix: install_dir.join("bin") } + } + + #[must_use] + pub fn from_bin_prefix( + client: PackageManagerType, + version: impl Into, + bin_prefix: AbsolutePathBuf, + ) -> Self { + Self { client, version: version.into(), bin_prefix } + } + #[must_use] pub fn get_bin_prefix(&self) -> AbsolutePathBuf { - self.install_dir.join("bin") + self.bin_prefix.clone() } } @@ -406,6 +460,117 @@ 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, Option<&str>)>, + default_spec: Option<(PackageManagerType, &str, Option<&str>)>, +) -> Result, Error> { + if let Some((package_manager_type, version, hash)) = override_spec { + return Ok(Some(EnvironmentPackageManagerResolution { + package_manager_type, + version: version.into(), + hash: hash.map(Str::from), + 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, hash): (PackageManagerType, &str, Option<&str>), +) -> EnvironmentPackageManagerResolution { + EnvironmentPackageManagerResolution { + package_manager_type, + version: version.into(), + hash: hash.map(Str::from), + 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. When `expected` is set, a +/// different selected family falls back to the matching configured default before registry lookup. +pub async fn resolve_environment_package_manager( + cwd: impl AsRef, + override_spec: Option<(PackageManagerType, &str, Option<&str>)>, + default_spec: Option<(PackageManagerType, &str, Option<&str>)>, + expected: Option, +) -> Result, Error> { + let mut resolution = + resolve_environment_package_manager_spec(cwd, override_spec, default_spec)?; + if let Some(expected) = expected + && resolution.as_ref().is_some_and(|resolution| resolution.package_manager_type != expected) + { + resolution = default_spec + .filter(|(package_manager, _, _)| *package_manager == expected) + .map(environment_package_manager_default); + } + let Some(mut resolution) = resolution else { + return Ok(None); + }; + resolution.version = + resolve_package_manager_version(resolution.package_manager_type, &resolution.version) + .await?; + Ok(Some(resolution)) +} + /// Return the managed install directory for a package manager version. #[must_use] pub fn package_manager_install_dir( @@ -773,6 +938,20 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result< } } +/// Resolve an exact, range, or `latest` package-manager version without downloading it. +pub async fn resolve_package_manager_version( + package_manager_type: PackageManagerType, + version: &str, +) -> Result { + if version == "latest" { + get_latest_version(package_manager_type).await + } else if Version::parse(version).is_ok() { + Ok(version.into()) + } else { + resolve_package_manager_range(package_manager_type, version).await + } +} + /// Abbreviated registry metadata: only the version list is needed. #[derive(Deserialize)] struct RegistryPackument { @@ -798,6 +977,19 @@ async fn fetch_registry_versions(package_name: &str) -> Result Result, Error> { + let mut versions = fetch_registry_versions(&package_manager_type.to_string()).await?; + if matches!(package_manager_type, PackageManagerType::Yarn) { + versions.extend(fetch_registry_versions("@yarnpkg/cli-dist").await?); + } + versions.sort(); + versions.dedup(); + Ok(versions) +} + /// Whether a version requirement explicitly asks for prereleases. /// /// A prerelease marker attaches the hyphen directly to a version @@ -920,20 +1112,6 @@ async fn resolve_package_manager_range( resolve_latest_satisfying_version(package_manager_type, &range, version_req).await } -/// Resolve an exact, range, or floating `latest` package-manager version. -pub async fn resolve_package_manager_version( - package_manager_type: PackageManagerType, - version: &str, -) -> Result { - if version == "latest" { - get_latest_version(package_manager_type).await - } else if Version::parse(version).is_ok() { - Ok(version.into()) - } else { - resolve_package_manager_range(package_manager_type, version).await - } -} - /// Download the package manager and extract it to the vite-plus home directory. /// Return the install directory, e.g. `/package_manager/pnpm/10.0.0/pnpm` pub async fn download_package_manager( @@ -1656,7 +1834,7 @@ async fn create_bun_shim_files(bin_prefix: &AbsolutePath) -> Result<(), Error> { /// the exact resolved version is recorded with `onFail: "download"` so future /// runs are deterministic. Preserves the file's key order and formatting style, /// placing `devEngines` next to `engines` when present. -async fn set_dev_engines_package_manager_field( +pub(crate) async fn set_dev_engines_package_manager_field( package_json_path: impl AsRef, package_manager_type: PackageManagerType, version: &str, @@ -2021,6 +2199,87 @@ 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("sha512.example"))), + Some((PackageManagerType::Bun, "1.2.0", None)), + None, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Yarn); + assert_eq!(resolution.version, "1.22.22"); + assert_eq!(resolution.hash.as_deref(), Some("sha512.example")); + assert_eq!(resolution.source, "session"); + } + + #[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", None)), + None, + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Bun); + assert_eq!(resolution.version, "1.2.0"); + assert_eq!(resolution.source, "default"); + } + + #[tokio::test] + async fn environment_resolution_uses_expected_default_for_different_project_manager() { + let temp_dir = create_temp_dir(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + create_package_json(&cwd, r#"{"packageManager":"bun@1.2.0"}"#); + + let resolution = resolve_environment_package_manager( + &cwd, + None, + Some((PackageManagerType::Pnpm, "10.18.0", None)), + Some(PackageManagerType::Pnpm), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Pnpm); + assert_eq!(resolution.version, "10.18.0"); + assert_eq!(resolution.source, "default"); + } + + #[test] + fn environment_spec_keeps_declared_version_range() { + let temp_dir = create_temp_dir(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + create_package_json( + &cwd, + r#"{"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}}"#, + ); + + let resolution = + resolve_environment_package_manager_spec(&cwd, None, None).unwrap().unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Pnpm); + assert_eq!(resolution.version, "^10.0.0"); + assert_eq!(resolution.source, "devEngines.packageManager"); + } + /// How fully a fake package manager install is written. enum InstallState { /// No shim files at all (`bin/` exists but is empty). diff --git a/crates/vp_pm_cli/src/resolution/resolve.rs b/crates/vp_pm_cli/src/resolution/resolve.rs index 8a013e1c10..896d1a461f 100644 --- a/crates/vp_pm_cli/src/resolution/resolve.rs +++ b/crates/vp_pm_cli/src/resolution/resolve.rs @@ -83,7 +83,7 @@ mod tests { PackageManager { client, version: version.into(), - install_dir: workspace_root.join(".test-package-manager"), + bin_prefix: workspace_root.join(".test-package-manager").join("bin"), } } diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index b9f460e66e..af2f23d434 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -157,6 +157,11 @@ pub struct EnvConfig { /// Env: `VP_NODE_VERSION` pub node_version: Option, + /// Override package manager and version. + /// + /// Env: `VP_PACKAGE_MANAGER` + pub package_manager: Option, + /// User home directory. /// /// Resolved once from `HOME` or `USERPROFILE` in platform order. See @@ -202,6 +207,7 @@ impl EnvConfig { is_ci: std::env::var("CI").is_ok(), env_use_eval_enable: std::env::var(env_vars::VP_ENV_USE_EVAL_ENABLE).is_ok(), node_version: std::env::var(env_vars::VP_NODE_VERSION).ok(), + package_manager: std::env::var(env_vars::VP_PACKAGE_MANAGER).ok(), user_home, vp_shell: std::env::var(env_vars::VP_SHELL).ok(), }) diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index ae8398eac1..5446d6b954 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -85,6 +85,9 @@ pub const VP_NODE_SKIP_SIGNATURE_VERIFY: &str = "VP_NODE_SKIP_SIGNATURE_VERIFY"; /// Override Node.js version (takes highest priority in version resolution). pub const VP_NODE_VERSION: &str = "VP_NODE_VERSION"; +/// Override package manager and version (for example, `pnpm@10.18.0`). +pub const VP_PACKAGE_MANAGER: &str = "VP_PACKAGE_MANAGER"; + /// Enable debug output for shim dispatch. pub const VP_DEBUG_SHIM: &str = "VP_DEBUG_SHIM"; diff --git a/docs/guide/env.md b/docs/guide/env.md index c7f29aa5f0..4a7e5d6142 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -1,10 +1,23 @@ # 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 configured package-manager shims resolve through Vite+ and pick the right versions for the current project. Fresh installers record managed mode for npm, pnpm, Yarn, and Bun after the user enables environment management. + +When an upgrade adds a package-manager shim that has no recorded mode, its first interactive invocation asks what to do only when the corresponding system binary is already on PATH. The current family defaults to managed mode; choosing a system tool or applying a choice to every family remains explicit. Non-interactive invocations use managed mode without recording a choice. + +Most commands operate on both components when no selector is given. Add `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the command. `pm` means all four families for listing and cleanup, but the single selected package manager for project operations. + +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 node@24 pnpm@12 # Both components +vp env pin 22.0.0 pnpm@10.18.0 # Also both components +``` Vite+ checks the current directory first, then walks up through its parents. The nearest directory with a supported declaration wins. Within each directory, sources are checked in this order: @@ -19,7 +32,17 @@ latest LTS. `devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. `vp env doctor` warns when declared sources conflict. -When a project declares `packageManager` (or `devEngines.packageManager`) in `package.json`, matching package-manager shims also use that package-manager version. For example, `packageManager: "npm@10.9.4"` makes both `npm` and `npx` run through npm 10.9.4. Alias pairs follow the installed package-manager shims: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a package-manager declaration, invoking `pnpm`, `yarn`, or `bun` uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. Vite+ does not translate mismatched commands, so a project pinned to `pnpm` still lets `npm` fall back to the npm that comes with the resolved Node.js runtime. +Package-manager selection uses this priority: + +1. Explicit command override +2. `VP_PACKAGE_MANAGER` or the shell-session override +3. Top-level `packageManager` +4. `devEngines.packageManager` +5. Lockfile or manager-specific configuration +6. The named package manager's global default version +7. The named shim's latest release + +A selected manager controls only its named shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Alias pairs are `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a matching project selection, a named shim uses its configured default version and otherwise uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. The directly invoked npm shim keeps its Node-bundled fallback, while an explicit `vp env ... npm` family scope uses standalone npm's latest release. A fresh install uses the split platform layout by default. On Unix, Vite+ stores managed runtimes and related files in `~/.local/share/vite-plus`. It @@ -34,7 +57,14 @@ 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, including one package-manager family: + +```bash +vp env on node +vp env off pm +vp env off pnpm +vp env on bun +``` If you do not want Vite+ to manage Node.js first, run: @@ -42,16 +72,17 @@ 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. + +Using `pm` records the selected mode for all currently supported package managers and replaces their individual choices. An unscoped `on` or `off` does the same while also changing Node.js. A family without a recorded mode remains undecided until its shim is first used or an `on` / `off` command configures it. ## Commands ### Setup - `vp env setup` creates or updates the `node`, `npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`, `bun`, `bunx`, `vpx`, and `vpr` shims in the resolved bin directory. It writes shell setup scripts in the config directory. -- `vp env on` enables managed mode so shims always use Vite+-managed Node.js -- `vp env off` enables system-first mode so shims prefer system Node.js first -- `vp env print` prints the shell snippet for the current session +- `vp env on` / `vp env off` changes both modes; append `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the change +- `vp env print` prints PATH setup for both components; append a selector to print one PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: @@ -87,28 +118,28 @@ vp-use --unset Only `vp env use` needs this alternate command. Other `vp env` commands work normally in Command Prompt. `vp env setup` creates `vp-use.cmd` in the bin directory on Windows. In CI, `vp env use` can run without shell initialization. It writes a temporary -session file in the resolved state directory. Later shim calls in the same job -use this file to select the Node.js version. +Node.js or package-manager session file in the resolved state directory. Later +shim calls in the same job use these files to resolve the same environment. ### Manage -- `vp env default` sets or shows the global default Node.js version -- `vp env pin` pins a Node.js version in the current directory: an existing `.node-version` keeps being updated; otherwise the pin is written to `package.json#devEngines.runtime`; `.node-version` is only created when the directory has no `package.json`. Use `--target node-version` or `--target dev-engines` to choose explicitly. An existing `engines.node` is never modified. -- `vp env unpin` removes the pin from the same source `vp env pin` would write -- `vp env use` sets a Node.js version for the current shell session -- `vp env install` installs a Node.js version -- `vp env uninstall` removes an installed Node.js version -- `vp env clean` removes unused managed Node.js runtimes and all downloaded package managers. -- `vp env exec` runs a command with a specific Node.js version -- `vp node` runs a Node.js script — shorthand for `vp env exec node` +- `vp env default` shows the global Node.js default and each configured package-manager version. Bare versions set Node.js; qualified specs such as `pnpm@10.18.0` set that package manager's shim default without replacing the defaults for Bun, Yarn, or npm. `--unset` clears all defaults unless scoped. +- `vp env pin` shows or writes project pins. Existing `.node-version` and top-level `packageManager` fields keep being updated for compatibility; otherwise Vite+ writes the matching `devEngines` entry. Use `--target node-version`, `--target dev-engines`, or `--target package-manager` to choose explicitly. +- `vp env unpin` removes both effective pins by default; append a selector to remove one. Lower-priority declarations are not deleted. +- `vp env use` activates the complete project environment. Explicit specs override selected components; `--unset` clears both unless scoped. +- `vp env install` installs the complete resolved environment, a selected component, or explicit specs. +- `vp env uninstall` removes explicit exact Node.js or qualified package-manager versions. +- `vp env clean` removes unused installs. Use `clean node`, `clean pm`, or a concrete manager. Current and configured-default versions are preserved. +- `vp env exec` runs a command in the resolved environment. Use `--node` and `--package-manager`; `--npm` is an alias for `--package-manager npm@…`. +- `vp node` uses the resolved Node.js runtime and exposes the selected package-manager path to child processes. ### Inspect - `vp env current` shows the current resolved environment - `vp env doctor` runs environment diagnostics - `vp env which` shows which tool path will be used -- `vp env list` shows locally installed Node.js versions -- `vp env list-remote` shows available Node.js versions from the registry +- `vp env list` shows separate Node.js, npm, pnpm, Yarn, and Bun sections; selectors narrow output +- `vp env list-remote` fetches Node.js and all four PM registries concurrently; selectors narrow network work. `--lts` implicitly selects Node.js. ## Project Setup @@ -121,31 +152,83 @@ use this file to select the Node.js version. ```bash # Setup vp env setup # Create Node.js and package-manager shims -vp env on # Use Vite+ managed Node.js -vp env print # Print shell snippet for this session +vp env on # Manage Node.js and package managers +vp env off pm # Prefer system package managers only +vp env off pnpm # Prefer system pnpm 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 node@24 # Set the global Node.js default +vp env default pnpm@10 # Set pnpm's global default version +vp env use 20 pnpm@10 # Override both components for this shell +vp env use --unset pm # Remove only the PM session override +vp env clean # Remove unused managed Node.js and package manager versions # Inspect vp env current # Show current resolved environment vp env current --json # JSON output for automation vp env which node # Show which node binary will be used vp env which npx # Show pinned package-manager alias when packageManager matches -vp env list-remote --lts # List only LTS versions +vp env list # Show every locally installed component +vp env list node # Show only Node.js installations +vp env list-remote --lts # List only Node.js LTS versions # Execute -vp env exec --node lts npm i # Execute npm with latest LTS +vp env exec --node lts --package-manager pnpm@10 pnpm install vp env exec node -v # Use shim mode with automatic version resolution vp node script.js # Shorthand: run a Node.js script with the resolved version vp node -e "console.log(1+1)" # Shorthand: forward any node flag or argument ``` +## JSON output + +The JSON output for `current`, `list`, and `list-remote` is organized by component. `current --json` returns sibling `node` and `package_manager` objects: + +```json +{ + "node": { + "version": "22.0.0", + "source": "devEngines.runtime", + "source_path": "/project/package.json", + "project_root": "/project", + "bin_path": "/home/.vite-plus/js_runtime/node/22.0.0/bin/node", + "installed": true, + "mode": "managed" + }, + "package_manager": { + "name": "pnpm", + "version": "10.18.0", + "source": "packageManager", + "source_path": "/project/package.json", + "project_root": "/project", + "bin_paths": { + "pnpm": "/home/.vite-plus/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", + "pnpx": "/home/.vite-plus/package_manager/pnpm/10.18.0/pnpm/bin/pnpx" + }, + "installed": true, + "mode": "managed" + } +} +``` + +`list --json` and `list-remote --json` group the component arrays: + +```json +{ + "node": [], + "package_managers": { + "npm": [], + "pnpm": [], + "yarn": [], + "bun": [] + } +} +``` + +Selectors omit unselected top-level fields or PM families. Registry listing is all-or-error: Vite+ prints no partial human or JSON result when any selected registry request fails. + ## Custom Node.js Mirror By default, Vite+ downloads Node.js from `https://nodejs.org/dist`. If you're behind a corporate proxy or need to use an internal mirror (e.g., Artifactory), set the `VP_NODE_DIST_MIRROR` environment variable: diff --git a/docs/guide/index.md b/docs/guide/index.md index 7cd00d7905..10f8bacec9 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -96,7 +96,7 @@ Vite+ can handle the entire local frontend development cycle from starting a pro - [`vp hooks`](/guide/commit-hooks) manages the Git hook dispatcher (`enable`, `disable`, `status`). - [`vp staged`](/guide/commit-hooks) runs checks on staged files. - [`vp install`](/guide/install) installs dependencies with the right package manager. -- [`vp env`](/guide/env) manages Node.js versions. +- [`vp env`](/guide/env) manages Node.js and package-manager environments. ### Develop diff --git a/docs/guide/installer-env-vars.md b/docs/guide/installer-env-vars.md index 253ba3b658..802af64636 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -127,6 +127,16 @@ These variables configure the installed Vite+ CLI. `VP_HOME` (above) also applie VP_NODE_VERSION=22 vp env exec node -v ``` +### `VP_PACKAGE_MANAGER` + +- **Purpose**: Override the selected package manager and version +- **Default**: None (resolved from the project or global default) +- **Format**: `npm|pnpm|yarn|bun@` +- **Example**: + ```bash + VP_PACKAGE_MANAGER=pnpm@10.18.0 vp install + ``` + ### `VP_NODE_SKIP_SIGNATURE_VERIFY` - **Purpose**: Skip PGP signature verification of Node.js downloads diff --git a/packages/cli/README.md b/packages/cli/README.md index 1c436fcb12..c4c12a6942 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9,7 +9,7 @@ This package provides the project-local version of Vite+. The global `vp` comman Vite+ is the unified entry point for local web development. It combines [Vite](https://vite.dev/), [Vitest](https://vitest.dev/), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Rolldown](https://rolldown.rs/), [tsdown](https://tsdown.dev/), and [Vite Task](https://github.com/voidzero-dev/vite-task) into one zero-config toolchain that also manages runtime and package manager workflows: -- **`vp env`:** Manage Node.js globally and per project +- **`vp env`:** Manage Node.js and package managers globally and per project - **`vp install`:** Install dependencies with automatic package manager detection - **`vp dev`:** Run Vite's fast native ESM dev server with instant HMR - **`vp check`:** Run formatting, linting, and type checks in one command @@ -99,7 +99,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **hooks** - Manage the Git hook dispatcher - **staged** - Run linters on staged files - **install** (`i`) - Install dependencies -- **env** - Manage Node.js versions +- **env** - Manage Node.js and package managers #### Develop diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index dfee1e7fd4..858ba4f9e6 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -879,7 +879,7 @@ function Setup-NodeManager { $isInteractive = [Environment]::UserInteractive -and -not $env:CI if ($isInteractive) { Write-Host "" - Write-Host "Would you like Vite+ to manage your Node.js versions?" + Write-Host "Would you like Vite+ to manage your Node.js and package-manager versions?" Write-Host "Vite+ adds ``node``, ``npm``, ``npx``, ``pnpm``, ``pnpx``, ``yarn``, ``yarnpkg``, ``bun``, and ``bunx`` shims to $NodeManagerBinDisplay." Write-Host "It selects the required version automatically." Write-Host "Opt out anytime with ``vp env off``." @@ -1169,6 +1169,20 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" # Setup Node.js version manager (shims) - separate component $nodeManagerResult = Setup-NodeManager -BinDir $BinDir + if ($nodeManagerResult -eq "true") { + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + & $vpBin env on *> $null + $preferenceExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($preferenceExitCode -ne 0) { + Write-Warn "Failed to record environment management preference." + } + $global:LASTEXITCODE = 0 + } Prompt-RemovePreviousInstallDir -PreviousInstallDir $previousInstallDir @@ -1201,14 +1215,14 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" Write-Host "" Write-Host " ${BOLD}Get started:${NC}" Write-Host " ${BRIGHT_BLUE}vp create${NC} Create a new project" - Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" Write-Host " ${BRIGHT_BLUE}vp install${NC} Install dependencies" Write-Host " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" # Show Node.js manager status if ($nodeManagerResult -eq "true" -or $nodeManagerResult -eq "already") { Write-Host "" - Write-Host " Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}." + Write-Host " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." Write-Host " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." } diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 7c5f1e29d9..7f0c88f5be 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -1130,7 +1130,7 @@ setup_node_manager() { # Prompt user in interactive mode if [ -e /dev/tty ] && [ -t 1 ]; then echo "" - echo "Would you like Vite+ to manage your Node.js versions?" + echo "Would you like Vite+ to manage your Node.js and package-manager versions?" echo "Vite+ adds \`node\`, \`npm\`, \`npx\`, \`pnpm\`, \`pnpx\`, \`yarn\`, \`yarnpkg\`, \`bun\`, and \`bunx\` shims to $(abbreviate_path "$SHIM_DIR")." echo "It selects the required version automatically." echo "Opt out anytime with \`vp env off\`." @@ -1417,6 +1417,11 @@ WRAPPER_EOF # Setup Node.js version manager (shims) - separate component setup_node_manager "$BIN_DIR" + if [ "$NODE_MANAGER_ENABLED" = "true" ]; then + if ! "$vp_bin" env on > /dev/null 2>&1; then + warn "Failed to record environment management preference." + fi + fi prompt_remove_previous_install_dir "$previous_install_dir" @@ -1436,13 +1441,13 @@ WRAPPER_EOF echo "" echo -e " ${BOLD}Get started:${NC}" echo -e " ${BRIGHT_BLUE}vp create${NC} Create a new project" - echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" echo -e " ${BRIGHT_BLUE}vp install${NC} Install dependencies" echo -e " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" if [ "$NODE_MANAGER_ENABLED" = "true" ] || [ "$NODE_MANAGER_ENABLED" = "already" ]; then echo "" - echo -e " Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}." + echo -e " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." echo -e " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." fi diff --git a/rfcs/dev-engines.md b/rfcs/dev-engines.md index a2a8f85508..08a75f03b7 100644 --- a/rfcs/dev-engines.md +++ b/rfcs/dev-engines.md @@ -373,7 +373,7 @@ A small shared Rust helper (in `vp_shared`) will own "edit one field in package. - Managing non-Node runtimes (`deno`, `bun` as a runtime) via `devEngines.runtime`. - Validating `devEngines.os` / `cpu` / `libc`. - Acting as a general enforcement layer for arbitrary package manager names beyond pnpm / yarn / npm / bun. -- Changing session-override behavior (`vp env use`, `VP_NODE_VERSION`). +- Node session override behavior remains compatible; unified environments additionally support `VP_PACKAGE_MANAGER` and `.session-package-manager`. ## Deferred / Future Work diff --git a/rfcs/env-command.md b/rfcs/env-command.md index d45af48db9..c8b8e0c10b 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -1,8 +1,26 @@ -# RFC: `vp env` - Shim-Based Node Version Management +# RFC: `vp env` - Unified JavaScript Environment Management ## Summary -This RFC proposes adding a `vp env` command that provides system-wide, IDE-safe Node.js version management through a shim-based architecture. The shims intercept `node`, `npm`, and `npx` commands, automatically resolving and executing the correct Node.js version based on project configuration. +This RFC defines system-wide, IDE-safe Node.js and package-manager management through a shim-based architecture. The environment contains one Node.js runtime and one selected package manager; npm, pnpm, Yarn, and Bun remain independently callable families. + +## Breaking revision: unified environments + +The original Node.js-only command model was extended as a breaking change. Bare component-wide commands now operate on Node.js and package managers together, while unqualified version arguments remain Node.js for compatibility: + +```bash +vp env pin 22.0.0 # Node.js only (legacy-compatible) +vp env pin pnpm@10.18.0 # Package manager only +vp env pin 22.0.0 pnpm@10.18.0 # Both +``` + +Selectors are `node`, `pm`, `npm`, `pnpm`, `yarn`, and `bun`. `pm` selects every family for listing and cleanup, but the single project-selected manager for `current`, `pin`, `unpin`, `use`, and execution. + +Node and package-manager modes persist independently. `nodeShimMode` stores the Node mode and accepts the legacy `shimMode` field while reading older configurations. Missing package-manager modes default to managed without inheriting Node state, and only package-manager mode commands or first-use choices persist them. + +Package-manager resolution priority is explicit override, `VP_PACKAGE_MANAGER` or `.session-package-manager`, top-level `packageManager`, `devEngines.packageManager`, lockfile/config detection, `defaultPackageManager`, then the existing fallback. The resolver is non-mutating and shared by env inspection, shims, `vp install`, `use`, and `exec`. + +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 @@ -438,7 +456,12 @@ VP_HOME/ # Default: ~/.vite-plus // Set via: vp env on (managed) or vp env off (system_first) // - "managed" (default): All vp commands and shims use vite-plus managed Node.js // - "system_first": All vp commands and shims prefer system Node.js, fallback to managed if not found - "shimMode": "managed" + "nodeShimMode": "managed", + + // `shimMode` is accepted as the legacy Node.js field name but is no longer written. + "packageManagerShimModes": { + "pnpm": "managed" + } } ``` @@ -846,7 +869,7 @@ Installation ✓ Shims node, npm, npx Configuration - ✓ Node.js mode managed + ✓ Node.js managed mode PATH ✗ vp not in PATH @@ -944,7 +967,7 @@ Installation ✓ Shims node, npm, npx Configuration - ✓ Node.js mode managed + ✓ Node.js managed mode ✓ IDE integration env sourced in ~/.zshenv PATH @@ -969,7 +992,7 @@ $ vp env doctor ... Configuration - ✓ Node.js mode managed + ✓ Node.js managed mode ✓ IDE integration env sourced in ~/.zshenv ⚠ Session override VP_NODE_VERSION=20.18.0 Overrides all file-based resolution. @@ -987,7 +1010,7 @@ $ vp env doctor ... Configuration - ✓ Node.js mode system-first + ✓ Node.js system-first mode System Node.js /usr/local/bin/node ✓ IDE integration env sourced in ~/.zshenv @@ -1009,7 +1032,7 @@ $ vp env doctor ... Configuration - ✓ Node.js mode system-first + ✓ Node.js system-first mode ⚠ System Node.js not found (will fall back to managed) ... @@ -1026,7 +1049,7 @@ Installation Run 'vp env setup' to create bin directory and shims. Configuration - ✓ Node.js mode managed + ✓ Node.js managed mode PATH ✗ vp not in PATH