From c3b54c580592f0e6387e10d1838b0cf2851dcc81 Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 10 Aug 2026 18:51:35 +0800 Subject: [PATCH 01/22] feat(env)!: replace Corepack with package manager shims --- .github/workflows/e2e-test.yml | 5 - .../tests/cli_snapshots/README.md | 4 +- .../snapshots/cli_helper_message.md | 2 +- .../assert-shims.mjs | 21 +- .../fixtures/command_env_which/snapshots.toml | 2 - .../snapshots/command_env_which.md | 22 +- .../shim_corepack_bundled/snapshots.toml | 10 - .../snapshots/shim_corepack_bundled.md | 33 - .../fake-corepack.sh | 12 - .../snapshots.toml | 17 - .../shim_corepack_enable_install_directory.md | 69 -- .../shim_corepack_removed/snapshots.toml | 6 + .../snapshots/shim_corepack_removed.md | 13 + .../shim_pnpm12_native/snapshots.toml | 3 +- .../snapshots/shim_pnpm12_native.md | 12 +- .../.node-version | 2 +- .../snapshots.toml | 11 +- .../shim_pnpm_uses_project_node_version.md | 43 +- .../tests/cli_snapshots/main.rs | 4 +- .../tests/cli_snapshots/redact.rs | 2 +- crates/vp_global_cli/src/cli.rs | 2 - .../vp_global_cli/src/commands/env/clean.rs | 94 +-- crates/vp_global_cli/src/commands/env/exec.rs | 2 +- .../src/commands/env/package_metadata.rs | 6 - .../vp_global_cli/src/commands/env/setup.rs | 177 ++++- .../vp_global_cli/src/commands/env/which.rs | 49 +- .../src/commands/global/install.rs | 171 ++--- .../vp_global_cli/src/commands/global/mod.rs | 3 + crates/vp_global_cli/src/help.rs | 2 +- crates/vp_global_cli/src/shim/corepack.rs | 651 ------------------ crates/vp_global_cli/src/shim/dispatch.rs | 97 +-- crates/vp_global_cli/src/shim/mod.rs | 14 +- crates/vp_pm_cli/src/lib.rs | 2 +- crates/vp_pm_cli/src/package_manager.rs | 136 +++- docs/guide/env.md | 27 +- packages/cli/install.ps1 | 4 +- packages/cli/install.sh | 4 +- rfcs/docker-image.md | 2 +- rfcs/env-command.md | 105 +-- rfcs/trampoline-exe-for-shims.md | 6 +- rfcs/upgrade-command.md | 2 +- 41 files changed, 529 insertions(+), 1320 deletions(-) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots.toml delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots/shim_corepack_bundled.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots/shim_corepack_removed.md delete mode 100644 crates/vp_global_cli/src/shim/corepack.rs diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 9e143ff629..40e7665c5a 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -382,11 +382,6 @@ jobs: - name: varlet node-version: 22 command: | - # scripts/bootstrap.mjs spawns `pnpm build` via tinyexec and needs - # pnpm on PATH (not exposed by the vp install itself). corepack - # enable creates a pnpm launcher in the vp bin dir that resolves - # the project's pinned packageManager version (pnpm@9.15.9). - corepack enable node scripts/bootstrap.mjs # Report-only: oxlint 1.68.0 surfaces ts1038 ("A 'declare' modifier # cannot be used in an already ambient context.") on varlet's diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/README.md b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md index 0005c5c2e6..315ec507c0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md @@ -120,8 +120,8 @@ A step is a bare argv array or a table: `argv[0]` may be `vpt`, a runner-provisioned tool such as `nu`, or any executable exposed by the case's Vite+ installation, including default shims -such as `vp`, `node`, and `corepack` and globally installed package binaries. -There is no shell: no `&&`, no +such as `vp`, `node`, `npm`, and `pnpm` and globally installed package +binaries. There is no shell: no `&&`, no redirects, no globs. File setup and assertions go through `vpt` so behavior is identical on every platform: diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index d351576a27..1b463e087d 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 @@ -487,7 +487,7 @@ Inspect: Examples: Setup: - vp env setup # Create shims for node, npm, npx, corepack + 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 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs index 6a1cddda26..e86658fc35 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs @@ -2,8 +2,22 @@ import fs from 'node:fs'; import path from 'node:path'; const expected = path.resolve('external/vp'); +const shims = [ + 'vp', + 'node', + 'npm', + 'npx', + 'pnpm', + 'pnpx', + 'yarn', + 'yarnpkg', + 'bun', + 'bunx', + 'vpx', + 'vpr', +]; -for (const shim of ['vp', 'node', 'npm', 'npx', 'corepack', 'vpx', 'vpr']) { +for (const shim of shims) { const shimPath = path.join('home', 'bin', shim); const target = fs.readlinkSync(shimPath); if (target !== expected) { @@ -11,4 +25,9 @@ for (const shim of ['vp', 'node', 'npm', 'npx', 'corepack', 'vpx', 'vpr']) { } } +const actualShims = fs.readdirSync(path.join('home', 'bin')).sort(); +if (actualShims.join() !== [...shims].sort().join()) { + throw new Error(`unexpected shims: ${actualShims.join(', ')}`); +} + console.log('all shims point to external vp'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml index c212ec0abb..6f83226fca 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots.toml @@ -4,12 +4,10 @@ vp = "global" local-registry = true skip-platforms = ["windows"] steps = [ - { argv = ["vp", "remove", "-g", "corepack"], snapshot = false, continue-on-failure = true }, { argv = ["vp", "env", "exec", "node", "--version"], comment = "Ensure Node.js is installed first", continue-on-failure = true }, { argv = ["vp", "env", "which", "node"], comment = "Core tool - shows resolved Node.js binary path", continue-on-failure = true }, { argv = ["vp", "env", "which", "npm"], comment = "Core tool - shows resolved npm binary path", continue-on-failure = true }, { argv = ["vp", "env", "which", "npx"], comment = "Core tool - shows resolved npx binary path", continue-on-failure = true }, - { argv = ["vp", "env", "which", "corepack"], comment = "Core tool - corepack bundled with the resolved Node.js", continue-on-failure = true }, { argv = ["vp", "install", "-g", "cowsay@1.6.0"], comment = "Install a global package via vp", continue-on-failure = true }, { argv = ["vp", "env", "which", "cowsay"], comment = "Global package - shows binary path with metadata", continue-on-failure = true }, { argv = ["vp", "remove", "-g", "cowsay"], comment = "Cleanup", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md index e498576334..ff6f63c95b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_which/snapshots/command_env_which.md @@ -1,13 +1,5 @@ # command_env_which -## `vp remove -g corepack` - -**Exit code:** 1 - -``` -Failed to uninstall corepack: Package corepack is not installed -``` - ## `vp env exec node --version` Ensure Node.js is installed first @@ -52,18 +44,6 @@ VITE+ - The Unified Toolchain for the Web Source: /.node-version ``` -## `vp env which corepack` - -Core tool - corepack bundled with the resolved Node.js - -``` -VITE+ - The Unified Toolchain for the Web - -/.vite-plus/js_runtime/node//bin/corepack - Version: 20.18.0 - Source: /.node-version -``` - ## `vp install -g cowsay@1.6.0` Install a global package via vp @@ -108,6 +88,6 @@ Unknown tool - error message VITE+ - The Unified Toolchain for the Web error: tool 'unknown-tool' not found -Not a core tool (node, npm, npx, corepack) or installed global package. +Not a core tool (node, npm, npx) or installed global package. Run 'vp list -g' to see installed packages. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots.toml deleted file mode 100644 index a84d9285eb..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots.toml +++ /dev/null @@ -1,10 +0,0 @@ -[[case]] -name = "shim_corepack_bundled" -vp = "global" -skip-platforms = ["windows"] -steps = [ - { argv = ["vp", "remove", "-g", "corepack"], comment = "Isolate from a leftover managed corepack, which would win over the bundled one", snapshot = false, continue-on-failure = true }, - { argv = ["vpt", "write-file", ".node-version", "20.18.0\n"], comment = "Pin the project Node.js version", snapshot = false, continue-on-failure = true }, - { argv = ["vp", "env", "exec", "node", "--version"], comment = "Ensure Node.js is installed first", timeout = 120000 }, - { argv = ["corepack", "--version"], comment = "corepack shim runs the Node-bundled corepack" }, -] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots/shim_corepack_bundled.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots/shim_corepack_bundled.md deleted file mode 100644 index 541eaec651..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_bundled/snapshots/shim_corepack_bundled.md +++ /dev/null @@ -1,33 +0,0 @@ -# shim_corepack_bundled - -## `vp remove -g corepack` - -Isolate from a leftover managed corepack, which would win over the bundled one - -**Exit code:** 1 - -``` -Failed to uninstall corepack: Package corepack is not installed -``` - -## `vpt write-file .node-version '20.18.0 -'` - -Pin the project Node.js version - - -## `vp env exec node --version` - -Ensure Node.js is installed first - -``` - -``` - -## `corepack --version` - -corepack shim runs the Node-bundled corepack - -``` -0.29.3 -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh deleted file mode 100644 index 36f696d4ec..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -# Fake bundled corepack: echoes its invocation with the test root normalized -# for stable snapshots, and simulates corepack clobbering the npm shim on -# `enable` so the test can assert that Vite+ restores it. -if [ "$1" = "enable" ]; then - rm -f "$VP_HOME/bin/npm" -fi -out="corepack" -for arg in "$@"; do - out="$out $(printf '%s' "$arg" | sed "s#$PWD##g")" -done -printf '%s\n' "$out" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml deleted file mode 100644 index 0f31a1c51c..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml +++ /dev/null @@ -1,17 +0,0 @@ -[[case]] -name = "shim_corepack_enable_install_directory" -vp = "global" -skip-platforms = ["windows"] -steps = [ - { argv = ["vpt", "mkdir", "-p", "home/js_runtime/node/22.18.0/bin"], comment = "Isolated VP_HOME with a fake managed Node runtime layout", snapshot = false }, - { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], comment = "Project Node.js version", snapshot = false }, - { argv = ["vpt", "write-file", "home/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho fake-node\n"], comment = "Fake node binary", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/node"], snapshot = false }, - { argv = ["vpt", "cp", "fake-corepack.sh", "home/js_runtime/node/22.18.0/bin/corepack"], comment = "Fake bundled corepack that echoes its args", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/corepack"], snapshot = false }, - { argv = ["vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Create shims in the isolated home", snapshot = false }, - { argv = ["corepack", "use", "pnpm@10"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "Non-link commands run unchanged" }, - { argv = ["corepack", "enable", "--install-directory", "/tmp/custom-dir"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "Explicit --install-directory is respected, clobbered npm shim is restored" }, - { argv = ["corepack", "enable"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "--install-directory defaults to VP_HOME/bin" }, - { argv = ["vpt", "stat-file", "home/bin/npm", "--assert", "symlink"], comment = "Vite+ owns the npm shim" }, -] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md deleted file mode 100644 index d66cf17ca4..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md +++ /dev/null @@ -1,69 +0,0 @@ -# shim_corepack_enable_install_directory - -## `vpt mkdir -p home/js_runtime/node/22.18.0/bin` - -Isolated VP_HOME with a fake managed Node runtime layout - - -## `vpt write-file .node-version '22.18.0 -'` - -Project Node.js version - - -## `vpt write-file home/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh -echo fake-node -'` - -Fake node binary - - -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/node` - - -## `vpt cp fake-corepack.sh home/js_runtime/node/22.18.0/bin/corepack` - -Fake bundled corepack that echoes its args - - -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/corepack` - - -## `VP_HOME=${workspace}/home vp env setup` - -Create shims in the isolated home - - -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack use pnpm@10` - -Non-link commands run unchanged - -``` -corepack use pnpm@10 -``` - -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack enable --install-directory /tmp/custom-dir` - -Explicit --install-directory is respected, clobbered npm shim is restored - -``` -corepack enable --install-directory /tmp/custom-dir -warn: 'npm' is managed by Vite+ and was restored. Vite+ already resolves 'npm' per project, so corepack does not need to manage it. -``` - -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack enable` - ---install-directory defaults to VP_HOME/bin - -``` -corepack enable --install-directory /home/bin -warn: 'npm' is managed by Vite+ and was restored. Vite+ already resolves 'npm' per project, so corepack does not need to manage it. -``` - -## `vpt stat-file home/bin/npm --assert symlink` - -Vite+ owns the npm shim - -``` -home/bin/npm: symlink -``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots.toml new file mode 100644 index 0000000000..f034226f31 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots.toml @@ -0,0 +1,6 @@ +[[case]] +name = "shim_corepack_removed" +vp = "global" +steps = [ + { argv = ["vp", "install", "-g", "corepack"], comment = "Corepack is no longer a managed global package", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots/shim_corepack_removed.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots/shim_corepack_removed.md new file mode 100644 index 0000000000..e3fc335204 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_removed/snapshots/shim_corepack_removed.md @@ -0,0 +1,13 @@ +# shim_corepack_removed + +## `vp install -g corepack` + +Corepack is no longer a managed global package + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +error: Failed to install corepack: 'vp install -g corepack' is no longer supported. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml index b3564634ce..7201d6727e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml @@ -5,7 +5,8 @@ local-registry = true skip-platforms = ["windows"] comment = "pnpm 12 ships a native binary via @pnpm/exe.* platform packages; the pnpm shim runs it directly and the pnpx shim injects the dlx subcommand." steps = [ - { argv = ["vp", "install", "-g", "pnpm"], comment = "Expose the pnpm/pnpx shims", snapshot = false, continue-on-failure = true }, + { argv = ["vp", "install", "-g", "pnpm"], comment = "Reject the redundant managed global install", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "$VP_HOME/packages/pnpm.json", "--assert", "missing"], comment = "The redundant package should not be installed", snapshot = false, continue-on-failure = true }, { argv = ["vp", "env", "exec", "node", "--version"], comment = "Ensure Node.js is installed first", snapshot = false, continue-on-failure = true }, { argv = ["pnpm", "--version"], comment = "pnpm shim downloads the native binary and resolves the pinned packageManager version (12.0.0-beta.0)", continue-on-failure = true }, { argv = ["pnpx", "--silent", "cowsay", "hello"], comment = "pnpx shim injects dlx so the native binary runs the package", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md index da9b803c9a..a81701cc4b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md @@ -4,7 +4,17 @@ pnpm 12 ships a native binary via @pnpm/exe.* platform packages; the pnpm shim r ## `vp install -g pnpm` -Expose the pnpm/pnpx shims +Reject the redundant managed global install + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Vite+ already includes 'pnpm'; skipping. +``` + +## `vpt stat-file $VP_HOME/packages/pnpm.json --assert missing` + +The redundant package should not be installed ## `vp env exec node --version` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version index 1d9b7831ba..b009dfb9d9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version @@ -1 +1 @@ -22.12.0 +lts/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml index b39ea1c4ed..cc7ec5c2cc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml @@ -1,9 +1,14 @@ [[case]] name = "shim_pnpm_uses_project_node_version" vp = "global" +local-registry = true skip-platforms = ["windows"] steps = [ - { argv = ["vp", "install", "-g", "pnpm"], comment = "Ensure pnpm is globally installed", snapshot = false, continue-on-failure = true }, - { argv = ["vp", "env", "exec", "node", "-v"], comment = "Node version resolved from .node-version", continue-on-failure = true }, - { argv = ["vp", "env", "exec", "pnpm", "exec", "node", "-v"], comment = "pnpm should use same project Node version", continue-on-failure = true }, + { argv = ["vp", "env", "exec", "node", "-v"], comment = "Node version resolved from .node-version" }, + { argv = ["pnpm", "--version"], comment = "The unpinned pnpm shim resolves the latest version", snapshot = false }, + { argv = ["pnpm", "--version"], envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "The unpinned shim reuses its fresh latest-version cache without registry access", snapshot = false }, + { argv = ["pnpm", "exec", "node", "-v"], comment = "pnpm should use same project Node version" }, + { argv = ["vp", "env", "exec", "--node", "22.13", "pnpm", "exec", "node", "-e", "if(!process.version.startsWith('v22.13.'))process.exit(1);console.log('explicit Node reaches pnpm child')"], comment = "Explicit env exec version overrides the project version through the pnpm shim" }, + { argv = ["vpt", "write-file", ".node-version", ">=999.0.0\n"], snapshot = false }, + { argv = ["pnpm", "--version"], envs = [["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]], comment = "JS package-manager shims report project Node resolution failures", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_pnpm_uses_project_node_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_pnpm_uses_project_node_version.md index c249f0bb15..aa162d869e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_pnpm_uses_project_node_version.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_pnpm_uses_project_node_version.md @@ -1,10 +1,5 @@ # shim_pnpm_uses_project_node_version -## `vp install -g pnpm` - -Ensure pnpm is globally installed - - ## `vp env exec node -v` Node version resolved from .node-version @@ -13,10 +8,46 @@ Node version resolved from .node-version ``` -## `vp env exec pnpm exec node -v` +## `pnpm --version` + +The unpinned pnpm shim resolves the latest version + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 pnpm --version` + +The unpinned shim reuses its fresh latest-version cache without registry access + + +## `pnpm exec node -v` pnpm should use same project Node version ``` +Already up to date + +Done in using pnpm ``` + +## `vp env exec --node 22.13 pnpm exec node -e 'if('\!'process.version.startsWith('\''v22.13.'\''))process.exit(1);console.log('\''explicit Node reaches pnpm child'\'')'` + +Explicit env exec version overrides the project version through the pnpm shim + +``` +explicit Node reaches pnpm child +``` + +## `vpt write-file .node-version '>=999.0.0 +'` + + +## `VP_NODE_DIST_MIRROR=http://127.0.0.1:9 pnpm --version` + +JS package-manager shims report project Node resolution failures + +**Exit code:** 1 + +``` +vp: Failed to resolve Node version: Failed to download Node.js runtime: No version matching '>=999.0.0' found +vp: Run 'vp env doctor' for diagnostics +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index c0991f0014..d80c2fb1d6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -1364,8 +1364,8 @@ fn run_case( if step.snapshot || !succeeded { let mut redacted = redact_output(raw_output, &redactions, !step.formatted_snapshot); // A version-probe step's output is a bare semver that varies by - // environment (the managed Node's bundled npm or a - // corepack-resolved pin); mask it. Scoped by argv so + // environment (the managed Node's bundled npm or a package + // manager pin); mask it. Scoped by argv so // fixture-controlled bare versions elsewhere (a printed // `.node-version` file) stay assertable. let version_probe = matches!(argv.first().map(String::as_str), Some("npm" | "npx")) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index f8ddee50c9..b03c61869e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -259,7 +259,7 @@ static NODE_TRACE_WARNING_RE: LazyLock = LazyLock::new(|| { // A version-probe step (`npm --version` / `npx --version`) prints a lone bare // semver in its fenced code block (no `v` prefix, so the generic VERSION_RE // misses it). The value tracks the managed Node's bundled npm or a -// corepack-resolved packageManager pin, both of which vary by environment, so +// packageManager pin, both of which vary by environment, so // mask it. Applied via `redact_version_probe_output` ONLY to steps the runner // identifies as version probes: other steps' bare versions in a block (a // printed `.node-version` file) are fixture-controlled assertions that must diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 5337894919..60b7c50632 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -678,7 +678,6 @@ async fn managed_install( force, concurrency: concurrency.unwrap_or(DEFAULT_GLOBAL_INSTALL_CONCURRENCY), update: false, - only_bins: None, }, ) .await @@ -876,7 +875,6 @@ async fn managed_update( force: false, concurrency, update: true, - only_bins: None, }, ) .await diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index e1ca0f74cf..dd529c571e 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -1,11 +1,11 @@ //! Clean command for removing managed caches. //! -//! Handles `vp env clean` by removing unused Node.js runtimes, all managed -//! package manager installs, and the underlying Corepack cache. +//! Handles `vp env clean` by removing unused Node.js runtimes and all managed +//! package manager installs. use std::{path::Path, process::ExitStatus}; -use vp_shared::{env_vars, output}; +use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{config, list::list_installed_versions}; @@ -18,11 +18,6 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { let package_manager_dir = home_dir.join("package_manager"); let protected_versions = protected_node_versions(&cwd).await?; - let corepack_cleaned = run_corepack_cache_clean(&cwd).await?; - if corepack_cleaned { - output::success("Cleaned Corepack cache"); - } - let node_runtimes_removed = clean_node_runtimes(node_dir.as_path(), &protected_versions).await?; output::success(&format!( @@ -109,89 +104,6 @@ async fn remove_dir_all_if_exists(path: &Path) -> Result { } } -async fn run_corepack_cache_clean(cwd: &AbsolutePathBuf) -> Result { - let corepack_path = match resolve_corepack_from_path(cwd) { - Some(path) => path, - None => return Ok(false), - }; - - if corepack_cache_clean_would_auto_install(cwd, &corepack_path).await? { - return Ok(false); - } - - let result = tokio::process::Command::new(corepack_path.as_path()) - .args(["cache", "clean"]) - .current_dir(cwd.as_path()) - .env_remove(env_vars::VP_TOOL_RECURSION) - .output() - .await; - - match result { - Ok(command_output) if command_output.status.success() => Ok(true), - Ok(command_output) => Err(Error::Other(corepack_failure_message(&command_output).into())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(e) => Err(e.into()), - } -} - -async fn corepack_cache_clean_would_auto_install( - cwd: &AbsolutePathBuf, - corepack_path: &AbsolutePath, -) -> Result { - let bin_dir = config::get_bin_dir()?; - if corepack_path.parent() != Some(&bin_dir) { - return Ok(false); - } - - if config::load_config().await?.shim_mode == config::ShimMode::SystemFirst - && crate::shim::dispatch::find_system_tool("corepack").is_some() - { - return Ok(false); - } - - if has_usable_managed_corepack().await { - return Ok(false); - } - - let resolution = - crate::shim::dispatch::resolve_with_cache(cwd).await.map_err(|e| Error::Other(e.into()))?; - Ok(crate::shim::dispatch::locate_tool(&resolution.version, "corepack").is_err()) -} - -fn resolve_corepack_from_path(cwd: &AbsolutePathBuf) -> Option { - let path_var = std::env::var_os("PATH")?; - let paths = std::env::split_paths(&path_var).map(|path| { - if path.is_absolute() || path.starts_with("~") { - path - } else { - cwd.as_absolute_path().as_path().join(path) - } - }); - let search_path = std::env::join_paths(paths).ok()?; - vp_command::resolve_bin("corepack", Some(&search_path), cwd).ok() -} - -async fn has_usable_managed_corepack() -> bool { - let Ok(Some(metadata)) = crate::shim::dispatch::find_package_for_binary("corepack").await - else { - return false; - }; - crate::shim::dispatch::locate_package_binary(&metadata, "corepack").is_ok() - && crate::shim::dispatch::locate_tool(&metadata.platform.node, "node").is_ok() -} - -fn corepack_failure_message(command_output: &std::process::Output) -> String { - let stderr = String::from_utf8_lossy(&command_output.stderr); - let stdout = String::from_utf8_lossy(&command_output.stdout); - let stderr = stderr.trim(); - let stdout = stdout.trim(); - let details = if stderr.is_empty() { stdout } else { stderr }; - if details.is_empty() { - return "corepack cache clean failed".to_string(); - } - format!("corepack cache clean failed: {details}") -} - fn push_unique_version(versions: &mut Vec, version: String) { let normalized = version.strip_prefix('v').unwrap_or(&version).to_string(); if !versions.iter().any(|existing| existing == &normalized) { diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index a543c71340..8b7cea5d8e 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -147,7 +147,7 @@ async fn execute_with_version( let (cmd, args) = command.split_first().unwrap(); let mut child = tokio::process::Command::new(cmd); - child.args(args).env("PATH", new_path); + child.args(args).env("PATH", new_path).env(env_vars::VP_NODE_VERSION, &resolved_version); // 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() { diff --git a/crates/vp_global_cli/src/commands/env/package_metadata.rs b/crates/vp_global_cli/src/commands/env/package_metadata.rs index 21eadc1048..afd1441bec 100644 --- a/crates/vp_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vp_global_cli/src/commands/env/package_metadata.rs @@ -41,11 +41,6 @@ pub struct PackageMetadata { /// Binary names that are JavaScript files (need Node.js to run). #[serde(default)] pub js_bins: HashSet, - /// Whether `bins` was deliberately restricted to a subset of the bins the - /// package declares (e.g., the corepack shim auto-install links only - /// `corepack`). Updates keep the restriction; explicit installs reset it. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub bins_restricted: bool, /// Version spec the package was installed with (a dist-tag like /// `nightly`, a range, or an exact version), so `vp update -g` keeps /// resolving within it. `None` means the implicit `latest` tag. @@ -85,7 +80,6 @@ impl PackageMetadata { platform: Platform { node: node_version, npm: npm_version }, bins, js_bins, - bins_restricted: false, version_spec: None, manager, installed_at: Utc::now(), diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index b99249b124..41958fc750 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1,16 +1,16 @@ //! Setup command implementation for creating bin directory and shims. //! //! Creates the following structure: -//! - ~/.vite-plus/bin/ - Contains vp symlink and node/npm/npx/corepack shims +//! - ~/.vite-plus/bin/ - Contains vp symlink and default tool shims //! - ~/.vite-plus/current/ - Contains the actual vp CLI binary //! //! On Unix: //! - bin/vp is a symlink to the active vp binary -//! - bin/node, bin/npm, bin/npx, bin/corepack are symlinks to the active vp binary +//! - Default tool shims are symlinks to the active vp binary //! - Symlinks preserve argv[0], allowing tool detection via the symlink name //! //! On Windows: -//! - bin/vp.exe, bin/node.exe, bin/npm.exe, bin/npx.exe, bin/corepack.exe are trampoline executables +//! - bin/vp.exe and default tool shims are trampoline executables //! - Each trampoline detects its tool name from its own filename and spawns //! current\bin\vp.exe with VP_SHIM_TOOL env var set //! - This avoids the "Terminate batch job (Y/N)?" prompt from .cmd wrappers @@ -41,8 +41,9 @@ impl EnvShell { } } -/// Tools to create shims for (node, npm, npx, corepack, vpx, vpr) -pub(crate) const SHIM_TOOLS: &[&str] = &["node", "npm", "npx", "corepack", "vpx", "vpr"]; +/// Tools to create shims for during setup. +pub(crate) const SHIM_TOOLS: &[&str] = + &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vpx", "vpr"]; /// Execute the setup command. pub async fn execute(refresh: bool, env_only: bool) -> Result { @@ -70,6 +71,10 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result // Ensure bin directory exists tokio::fs::create_dir_all(&bin_dir).await?; + if refresh { + cleanup_legacy_package_manager_installs(&bin_dir).await; + } + #[cfg(windows)] tokio::fs::write(bin_dir.join("vp-use.cmd"), VP_USE_CMD_CONTENT).await?; @@ -79,7 +84,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result // Create wrapper script in bin/ setup_vp_wrapper(¤t_exe, &bin_dir, refresh).await?; - // Create shims for node, npm, npx, corepack + // Create default tool shims let mut created = Vec::new(); let mut skipped = Vec::new(); @@ -91,16 +96,15 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result skipped.push(*tool); } - // Remove corepack-written .cmd/.ps1/extensionless launchers that - // would shadow an existing trampoline .exe in PowerShell/Git Bash - // (create_shim skips existing shims without cleaning siblings). + // Remove legacy .cmd/.ps1/extensionless launchers that would shadow + // an existing trampoline .exe in PowerShell/Git Bash (create_shim + // skips existing shims without cleaning siblings). #[cfg(windows)] cleanup_legacy_windows_shim(&bin_dir, tool).await; - // Drop stale `npm install -g` link configs for default shim names - // (e.g. a pre-default-shim `npm i -g corepack`): the link itself is - // replaced by the shim above, and a leftover Npm-sourced BinConfig - // would let a later `npm uninstall -g` delete the default shim. + // Drop stale `npm install -g` link configs for default shim names. The + // link itself is replaced by the shim above, and a leftover Npm-sourced + // BinConfig would let a later `npm uninstall -g` delete the default shim. if let Ok(Some(config)) = super::bin_config::BinConfig::load(tool).await && config.source == super::bin_config::BinSource::Npm { @@ -149,6 +153,61 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result Ok(ExitStatus::default()) } +/// Remove legacy managed installs left by versions that did not expose package-manager shims. +async fn cleanup_legacy_package_manager_installs(bin_dir: &vt_path::AbsolutePath) { + use super::{bin_config::BinConfig, package_metadata::PackageMetadata}; + use crate::commands::global::{LEGACY_PACKAGE_MANAGER_PACKAGES, install::uninstall}; + + for package_name in LEGACY_PACKAGE_MANAGER_PACKAGES { + let has_metadata = match PackageMetadata::load(package_name).await { + Ok(metadata) => metadata.is_some(), + Err(error) => { + vp_shared::output::warn(&format!( + "Failed to inspect legacy global package '{package_name}': {error}" + )); + continue; + } + }; + let has_bin_config = match BinConfig::find_by_package(package_name).await { + Ok(bins) => !bins.is_empty(), + Err(error) => { + vp_shared::output::warn(&format!( + "Failed to inspect legacy shims for '{package_name}': {error}" + )); + continue; + } + }; + + if (has_metadata || has_bin_config) + && let Err(error) = uninstall(package_name, false).await + { + vp_shared::output::warn(&format!( + "Failed to remove legacy global package '{package_name}': {error}" + )); + } + } + + // Corepack is no longer exposed, so remove its old default shim even when no package metadata remains. + #[cfg(unix)] + { + let shim_path = bin_dir.join("corepack"); + match tokio::fs::remove_file(&shim_path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => vp_shared::output::warn(&format!( + "Failed to remove legacy Corepack shim at {}: {error}", + shim_path.as_path().display() + )), + } + } + + #[cfg(windows)] + { + remove_or_rename_to_old(&bin_dir.join("corepack.exe")).await; + cleanup_legacy_windows_shim(bin_dir, "corepack").await; + } +} + /// Create symlink in bin/ that points to the active vp binary. async fn setup_vp_wrapper( current_exe: &std::path::Path, @@ -233,7 +292,7 @@ pub(crate) async fn resolve_unix_vp_shim_target( Ok(current_exe.to_path_buf()) } -/// Create a single shim for a default shim tool (node/npm/npx/corepack/vpx/vpr). +/// Create a single default tool shim. /// /// Returns `true` if the shim was created, `false` if it already exists. pub(crate) async fn create_shim( @@ -888,15 +947,6 @@ mod tests { use super::*; - #[test] - fn test_shim_tools_contains_default_shims() { - // corepack is a default shim (#858, #1309). It must NOT be a core - // shim: `vp install -g corepack` stays allowed (CORE_SHIMS guard) and - // dispatch uses a dedicated resolution path instead of the core one. - assert!(SHIM_TOOLS.contains(&"corepack")); - assert!(!crate::commands::global::CORE_SHIMS.contains(&"corepack")); - } - /// Helper: create a test_guard with user_home set to the given path. fn home_guard(home: impl Into) -> vp_shared::TestEnvGuard { vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { @@ -1326,6 +1376,87 @@ mod tests { assert!(fresh_home.join("env.ps1").exists(), "env.ps1 file should be created"); } + #[tokio::test] + #[cfg_attr(windows, serial_test::serial)] + async fn test_execute_refresh_removes_legacy_package_manager_installs() { + use crate::commands::{ + env::{bin_config::BinConfig, package_metadata::PackageMetadata}, + global::LEGACY_PACKAGE_MANAGER_PACKAGES, + }; + + let temp_dir = TempDir::new().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); + let bin_dir = home.join("bin"); + let _env_guard = vp_shared::EnvConfig::test_guard( + vp_shared::EnvConfig::for_test_with_home(home.as_path()), + ); + #[cfg(windows)] + let _trampoline_guard = FakeTrampolineGuard::new(temp_dir.path()); + + tokio::fs::create_dir_all(&bin_dir).await.unwrap(); + let mut package_dirs = Vec::new(); + for package_name in LEGACY_PACKAGE_MANAGER_PACKAGES { + let mut metadata = PackageMetadata::new( + package_name.to_string(), + "1.0.0".to_string(), + "22.0.0".to_string(), + None, + vec![package_name.to_string()], + Default::default(), + "npm".to_string(), + ); + metadata.install_id = "123e4567-e89b-42d3-a456-426614174000".to_string(); + metadata.save().await.unwrap(); + let package_dir = metadata.installation_dir().unwrap(); + tokio::fs::create_dir_all(&package_dir).await.unwrap(); + package_dirs.push(package_dir); + + BinConfig::new( + package_name.to_string(), + package_name.to_string(), + "1.0.0".to_string(), + "22.0.0".to_string(), + ) + .save() + .await + .unwrap(); + } + + #[cfg(unix)] + tokio::fs::write(bin_dir.join("corepack"), "legacy corepack shim").await.unwrap(); + #[cfg(windows)] + for suffix in [".exe", ".cmd", ".ps1", ""] { + tokio::fs::write(bin_dir.join(format!("corepack{suffix}")), "legacy corepack shim") + .await + .unwrap(); + } + + let status = execute(true, false).await.unwrap(); + + assert!(status.success()); + for (package_name, package_dir) in LEGACY_PACKAGE_MANAGER_PACKAGES.iter().zip(package_dirs) + { + assert!(PackageMetadata::load(package_name).await.unwrap().is_none()); + assert!(BinConfig::load(package_name).await.unwrap().is_none()); + assert!(!package_dir.as_path().exists()); + } + for tool in ["pnpm", "yarn", "bun"] { + assert!( + std::fs::symlink_metadata(bin_dir.join(shim_filename(tool)).as_path()).is_ok(), + "{tool} should be recreated as a default shim" + ); + } + let corepack_suffixes: &[&str] = + if cfg!(windows) { &[".exe", ".cmd", ".ps1", ""] } else { &[""] }; + for suffix in corepack_suffixes { + assert!( + std::fs::symlink_metadata(bin_dir.join(format!("corepack{suffix}")).as_path()) + .is_err(), + "legacy corepack shim should be removed" + ); + } + } + #[tokio::test] #[cfg(unix)] async fn test_unix_vp_shim_target_prefers_standalone_layout_for_current_exe() { diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 3d5fbebb01..18544911c1 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -2,7 +2,7 @@ //! //! Shows the path to the tool binary that would be executed. //! -//! For core tools (node, npm, npx, corepack), shows the resolved Node.js +//! For core tools (node, npm, npx), shows the resolved Node.js //! binary path along with version and resolution source. //! For global packages, shows the binary path plus package metadata. @@ -24,8 +24,8 @@ use super::{ }; use crate::{cli::exit_status, error::Error}; -/// Core tools (node, npm, npx, corepack) -const CORE_TOOLS: &[&str] = &["node", "npm", "npx", "corepack"]; +/// Core tools (node, npm, npx) +const CORE_TOOLS: &[&str] = &["node", "npm", "npx"]; /// Column width for left-side labels in aligned metadata output const LABEL_WIDTH: usize = 10; @@ -38,20 +38,6 @@ pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result match locate_package_binary(&metadata, tool) { - Ok(_) => return execute_package_binary(tool, &metadata).await, - Err(e) => warn_unusable_managed_corepack(&e.to_string()), - }, - Ok(None) => {} - Err(e) => warn_unusable_managed_corepack(&e), - } - } return execute_core_tool(cwd, tool).await; } @@ -62,7 +48,7 @@ pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result Result { // Resolve version for current directory let resolution = resolve_version(&cwd).await?; @@ -218,20 +195,8 @@ async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result bool { let bin_name = if cfg!(target_os = "linux") || !ignore_case { bin_name } else { &bin_name.to_lowercase() }; CORE_SHIMS.contains(&bin_name) || crate::commands::env::setup::SHIM_TOOLS.contains(&bin_name) } -/// Whether a package may own a bin name. Protected shim names never belong -/// to packages, with one exception: the `corepack` package owning its own -/// `corepack` bin, so an explicit `vp install -g corepack` wins the shim's -/// resolution order. The exemption is scoped to the package name; any other -/// package declaring a `corepack` bin must not take BinConfig ownership. -pub(crate) fn package_may_own_bin(package_name: &str, bin_name: &str) -> bool { - !is_protected_shim(bin_name, true) || (bin_name == "corepack" && package_name == "corepack") -} - /// Options for [`install`]. pub struct InstallOptions<'a> { /// Node.js version to install with; resolved from the current directory @@ -96,12 +87,8 @@ pub struct InstallOptions<'a> { pub force: bool, /// Number of packages to install in parallel. pub concurrency: usize, - /// `vp update -g` semantics: carries a recorded bin restriction forward. + /// Whether this is a `vp update -g` operation. pub update: bool, - /// Only expose these binaries as shims; other bins the package declares - /// are ignored (used by the corepack shim auto-install, which must not - /// link corepack's pnpm/yarn launchers). - pub only_bins: Option<&'a [&'a str]>, } /// Install global packages parallelly. @@ -109,7 +96,7 @@ pub async fn install( package_specs: &[String], options: InstallOptions<'_>, ) -> Result<(), InstallError> { - let InstallOptions { node_version, force, concurrency, update, only_bins } = options; + let InstallOptions { node_version, force, concurrency, update } = options; if package_specs.is_empty() { return Ok(()); } @@ -117,7 +104,30 @@ pub async fn install( let operation_progress = if update { "Updating" } else { "Installing" }; let operation_past = if update { "Updated" } else { "Installed" }; - // 1. Resolve Node.js version + // 1. Parse package specs and skip legacy globals now provided by Vite+. + let mut packages = IndexMap::::new(); + for package_spec in package_specs { + let (package_name, _version_spec) = match parse_package_spec(package_spec) { + Ok(result) => result, + Err(error) => return Err((Some(package_spec.clone()), Box::new(error))), + }; + if package_name == "corepack" { + return Err(package_error( + &package_name, + Error::Other("'vp install -g corepack' is no longer supported.".into()), + )); + } + if LEGACY_PACKAGE_MANAGER_PACKAGES.contains(&package_name.as_str()) { + output::warn(&format!("Vite+ already includes '{package_name}'; skipping.")); + continue; + } + packages.insert(package_name, Package { spec: package_spec, install: None }); + } + if packages.is_empty() { + return Ok(()); + } + + // 2. Resolve Node.js version let node_version = if let Some(v) = node_version { let provider = NodeProvider::new(); match resolve_version_alias(v, &provider).await { @@ -137,7 +147,7 @@ pub async fn install( resolution.version }; - // 2. Ensure Node.js is installed + // 3. Ensure Node.js is installed let runtime = match vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &node_version) .await @@ -153,17 +163,7 @@ pub async fn install( let npm_path = if cfg!(windows) { node_bin_dir.join("npm.cmd") } else { node_bin_dir.join("npm") }; - // 3. Install packages in parallel - let mut packages = IndexMap::::new(); - for package_spec in package_specs { - // Parse package spec (e.g., "typescript", "typescript@5.0.0", "@scope/pkg") - - let (package_name, _version_spec) = match parse_package_spec(package_spec) { - Ok(result) => result, - Err(error) => return Err((Some(package_spec.clone()), Box::new(error))), - }; - packages.insert(package_name, Package { spec: package_spec, install: None }); - } + // 4. Install packages in parallel let packages_count = packages.len(); let concurrency = concurrency.max(1); @@ -229,7 +229,7 @@ pub async fn install( } progress.finish_and_clear(); - // 4. Finalize installed packages. + // 5. Finalize installed packages. let mut bin_owners = HashMap::::new(); for (index, (package_name, Package { spec, install })) in packages.into_iter().enumerate() { let lock_file = install_locks.remove(&package_name); @@ -244,8 +244,7 @@ pub async fn install( continue; }; - // Previous metadata drives both the inherited bin restriction and - // stale-bin detection below; load it once. + // Load previous metadata once for stale-bin detection below. let previous_metadata = match PackageMetadata::load(&package_name).await { Ok(metadata) => metadata, Err(error) => { @@ -257,29 +256,10 @@ pub async fn install( } }; - // Restrict exposed binaries when requested (e.g., the corepack shim - // auto-install only links `corepack`, not the pnpm/yarn launchers - // that `corepack enable` creates on demand). Updates carry a recorded - // restriction forward so `vp update -g` cannot re-expose the filtered - // bins; explicit installs (update=false) re-expose the full bin list. - let restriction: Option> = match only_bins { - Some(only) => Some(only.iter().map(ToString::to_string).collect()), - None if update => previous_metadata - .as_ref() - .filter(|previous| previous.bins_restricted) - .map(|previous| previous.bins.clone()), - None => None, - }; - let bins_restricted = restriction.is_some(); - if let Some(only) = &restriction { - bin_names.retain(|bin| only.contains(bin)); - js_bins.retain(|bin| only.contains(bin)); - } - // Drop bin names the package must not own before conflict detection, // shim creation, BinConfig ownership, and metadata recording. bin_names.retain(|bin| { - let allowed = package_may_own_bin(&package_name, bin); + let allowed = !is_protected_shim(bin, true); if !allowed { output::warn(&format!( "Package '{}' provides '{}' binary, but it conflicts with a built-in shim. \ @@ -289,7 +269,7 @@ pub async fn install( } allowed }); - js_bins.retain(|bin| package_may_own_bin(&package_name, bin)); + js_bins.retain(|bin| !is_protected_shim(bin, true)); let stale_bin_names = match stale_bin_names_for_package( previous_metadata.as_ref(), @@ -311,7 +291,7 @@ pub async fn install( let mut conflicts = Vec::<(String, String)>::new(); let mut finalize_blocked = false; - // 4.1 Detect binary ownership conflicts before writing metadata. + // 5.1 Detect binary ownership conflicts before writing metadata. for bin_name in &bin_names { if let Some(owner) = bin_owners.get(bin_name) && owner != &package_name @@ -341,7 +321,7 @@ pub async fn install( continue; } - // 4.2 Resolve conflicts, either by force-uninstalling owners or rolling back this install. + // 5.2 Resolve conflicts, either by force-uninstalling owners or rolling back this install. if !conflicts.is_empty() { if force { let packages_to_remove: HashSet<_> = @@ -380,7 +360,7 @@ pub async fn install( } } - // 4.3 Prepare metadata and remove binaries that the new install no longer provides. + // 5.3 Prepare metadata and remove binaries that the new install no longer provides. let bin_dir = match get_bin_dir().map_err(|error| package_error(&package_name, error)) { Ok(bin_dir) => bin_dir, Err(error) => { @@ -403,7 +383,6 @@ pub async fn install( "npm".to_string(), ); metadata.install_id = install_id.clone(); - metadata.bins_restricted = bins_restricted; metadata.version_spec = update_version_spec(spec); let mut finalized = true; @@ -436,7 +415,7 @@ pub async fn install( continue; } - // 4.4 Activate the new installation through metadata. + // 5.4 Activate the new installation through metadata. if let Err(error) = metadata.save().await.map_err(|error| package_error(&package_name, error)) { @@ -454,7 +433,7 @@ pub async fn install( continue; } - // 4.5 Expose each binary and record its ownership. + // 5.5 Expose each binary and record its ownership. for bin_name in &bin_names { let result = async { create_package_shim(&bin_dir, bin_name, &package_name).await?; @@ -494,11 +473,11 @@ pub async fn install( bin_owners.insert(bin_name.clone(), package_name.clone()); } - // 4.6 Remove stale installations for this package. + // 5.6 Remove stale installations for this package. cleanup_stale_installations(&package_name, &install_id).await; drop(lock_file); - // 4.7 Print success message + // 5.7 Print success message output::success(&format!( "{} {} {}{}", operation_past, @@ -1102,10 +1081,9 @@ pub(crate) async fn create_package_shim( bin_name: &str, package_name: &str, ) -> Result<(), Error> { - // Defense in depth: the finalize loop already filters bin names the - // package must not own (see package_may_own_bin); keep the guard here so - // no other caller can hand a protected shim to a package. - if !package_may_own_bin(package_name, bin_name) { + // Defense in depth: the finalize loop already filters protected bin + // names; keep the guard here so no other caller can hand one to a package. + if is_protected_shim(bin_name, true) { output::warn(&format!( "Package '{}' provides '{}' binary, but it conflicts with a built-in shim. Skipping.", package_name, bin_name @@ -1165,9 +1143,7 @@ pub(crate) async fn create_package_shim( /// Remove a shim for a package binary. async fn remove_package_shim(bin_dir: &vt_path::AbsolutePath, bin_name: &str) -> Result<(), Error> { - // Don't remove protected shims (e.g., `vp remove -g corepack` must keep - // the default corepack shim so it falls back to the Node-bundled or - // auto-installed corepack). + // Don't remove protected shims. if is_protected_shim(bin_name, false) { return Ok(()); } @@ -1229,6 +1205,13 @@ mod tests { } } + #[test] + fn test_default_shims_are_protected() { + for shim in CORE_SHIMS.iter().chain(crate::commands::env::setup::SHIM_TOOLS) { + assert!(is_protected_shim(shim, false), "{shim} should be protected"); + } + } + #[tokio::test] #[cfg_attr(windows, serial_test::serial)] async fn test_create_package_shim_creates_bin_dir() { @@ -1287,48 +1270,6 @@ mod tests { assert!(!shim_path.as_path().exists()); } - #[test] - fn test_package_may_own_bin_scopes_corepack_to_its_package() { - // Only the corepack package may own the corepack bin; any other - // package declaring a `corepack` bin must not take BinConfig - // ownership (it would win the corepack shim's resolution order). - assert!(package_may_own_bin("corepack", "corepack")); - assert!(!package_may_own_bin("some-package", "corepack")); - assert!(!package_may_own_bin("@scope/corepack", "corepack")); - - // Other protected shims never belong to packages - assert!(!package_may_own_bin("corepack", "npm")); - assert!(!package_may_own_bin("some-package", "vpx")); - assert!(!package_may_own_bin("some-package", "vpr")); - - // Regular bins are unrestricted - assert!(package_may_own_bin("typescript", "tsc")); - - #[cfg(any(windows, target_os = "macos"))] - assert!(!package_may_own_bin("some-package", "NPM")); - #[cfg(any(windows, target_os = "macos"))] - assert!(!package_may_own_bin("some-package", "Node")); - #[cfg(any(windows, target_os = "macos"))] - assert!(!package_may_own_bin("some-package", "VP")); - } - - #[tokio::test] - async fn test_create_package_shim_skips_corepack_bin_for_other_packages() { - use tempfile::TempDir; - use vt_path::AbsolutePathBuf; - - let temp_dir = TempDir::new().unwrap(); - let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - create_package_shim(&bin_dir, "corepack", "some-package").await.unwrap(); - - #[cfg(unix)] - let shim_path = bin_dir.join("corepack"); - #[cfg(windows)] - let shim_path = bin_dir.join("corepack.exe"); - assert!(!shim_path.as_path().exists()); - } - #[tokio::test] #[cfg_attr(windows, serial_test::serial)] async fn test_remove_package_shim_removes_shim() { diff --git a/crates/vp_global_cli/src/commands/global/mod.rs b/crates/vp_global_cli/src/commands/global/mod.rs index 91e4961485..457edb790c 100644 --- a/crates/vp_global_cli/src/commands/global/mod.rs +++ b/crates/vp_global_cli/src/commands/global/mod.rs @@ -19,6 +19,9 @@ pub mod packages; /// Core shims that should not be overwritten by package binaries. pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"]; +/// Legacy managed globals superseded by the default package-manager shims. +pub(crate) const LEGACY_PACKAGE_MANAGER_PACKAGES: &[&str] = &["yarn", "pnpm", "bun", "corepack"]; + #[derive(Debug)] struct PackageVersion { package_spec: String, diff --git a/crates/vp_global_cli/src/help.rs b/crates/vp_global_cli/src/help.rs index 919184211f..43416ad960 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_global_cli/src/help.rs @@ -513,7 +513,7 @@ fn env_help_doc() -> HelpDoc { "Examples", vec![ " Setup:", - " vp env setup # Create shims for node, npm, npx, corepack", + " 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", "", diff --git a/crates/vp_global_cli/src/shim/corepack.rs b/crates/vp_global_cli/src/shim/corepack.rs deleted file mode 100644 index 92c74c6bf5..0000000000 --- a/crates/vp_global_cli/src/shim/corepack.rs +++ /dev/null @@ -1,651 +0,0 @@ -//! Corepack shim dispatch. -//! -//! `corepack` is a default shim (created by `vp env setup`), but unlike the -//! core tools (node/npm/npx) it is not always available in the resolved -//! Node.js installation: Node.js 25 removed the bundled corepack. -//! -//! Resolution order: -//! 1. vp-managed global package (`vp install -g corepack`) — an explicit -//! install wins and provides a consistent corepack across Node.js versions -//! 2. corepack bundled with the project-resolved Node.js (Node.js <= 24) -//! 3. auto-install corepack as a vp-managed global package -//! -//! `corepack enable`/`corepack disable` create or remove package-manager -//! launchers next to the corepack binary found in PATH, which under the shim -//! would be the per-version Node.js bin directory (not on PATH). To keep the -//! launchers reachable, these commands get `--install-directory ~/.vite-plus/bin` -//! injected when not explicitly set, and Vite+-owned shims are restored -//! afterwards if corepack removed or replaced them. - -use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; -use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; - -use super::{ - dispatch::{ - create_bin_link, ensure_installed, find_package_for_binary, locate_tool, - package_binary_invocation, resolve_with_cache, - }, - exec, -}; -use crate::commands::env::{ - bin_config::{BinConfig, BinSource}, - config, setup, -}; - -/// Binary names corepack `enable`/`disable` may create or remove in the -/// install directory (npm/npx only when explicitly requested). -const COREPACK_MANAGED_BIN_NAMES: &[&str] = &["npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg"]; - -/// How to invoke the resolved corepack binary. -struct CorepackInvocation { - /// Program to execute (the corepack binary itself, or node for a JS entry) - program: AbsolutePathBuf, - /// Arguments to pass before the user-supplied args (e.g., the JS entry path) - pre_args: Vec, -} - -/// Dispatch a `corepack` shim invocation. -pub(crate) async fn dispatch_corepack(args: &[String]) -> i32 { - let invocation = match resolve_corepack_invocation().await { - Ok(invocation) => invocation, - Err(exit_code) => return exit_code, - }; - let CorepackInvocation { program, pre_args } = invocation; - let mut full_args = pre_args; - - // enable/disable create or remove launchers in the install directory. - // Inject the vp bin dir (so they land on PATH), run with spawn+wait, and - // restore any Vite+-owned shims corepack removed or replaced. The arg - // check runs first so the common path skips bin-dir resolution entirely. - if is_corepack_link_command(args) { - match config::get_bin_dir() { - Ok(bin_dir) => { - full_args.extend(inject_install_directory(args, &bin_dir)); - let owned_shims = snapshot_vp_owned_shims(&bin_dir).await; - let exit_code = exec::spawn_tool(&program, &full_args); - restore_vp_owned_shims(&bin_dir, &owned_shims).await; - return exit_code; - } - Err(e) => { - // Without a bin dir there is nothing to inject or restore; - // run corepack as-is, but say so instead of failing silently. - output::warn(&format!( - "Cannot resolve the Vite+ bin directory ({e}); running corepack without \ - an --install-directory default, created launchers may not be on PATH" - )); - } - } - } - - // The bundled corepack and native binaries have no leading args; exec - // with the caller's slice instead of cloning every argument. - if full_args.is_empty() { - return exec::exec_tool(&program, args); - } - full_args.extend(args.iter().cloned()); - exec::exec_tool(&program, &full_args) -} - -/// Resolve which corepack binary to execute. -/// -/// Returns an exit code on failure (errors are already printed). -async fn resolve_corepack_invocation() -> Result { - // 1. An explicit `vp install -g corepack` wins. Resolution errors (stale - // metadata, missing package files) fall through to the bundled copy - // instead of failing: broken managed state must not brick the shim. - match managed_corepack_invocation().await { - Ok(Some(invocation)) => return Ok(invocation), - Ok(None) => {} - Err(e) => { - output::warn(&format!( - "Ignoring unusable vp-managed corepack ({e}); falling back to the \ - Node-bundled corepack. Run `vp remove -g corepack` to clear it." - )); - } - } - - // 2. corepack bundled with the project-resolved Node.js (Node.js <= 24). - let cwd = match current_dir() { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Failed to get current directory: {e}"); - return Err(1); - } - }; - let resolution = match resolve_with_cache(&cwd).await { - Ok(resolution) => resolution, - Err(e) => { - eprintln!("vp: Failed to resolve Node version: {e}"); - eprintln!("vp: Run 'vp env doctor' for diagnostics"); - return Err(1); - } - }; - let corepack_path = match locate_tool(&resolution.version, "corepack") { - Ok(path) => Some(path), - Err(_) => { - // The runtime may not be installed yet; download it before - // concluding that corepack is not bundled. - if let Err(e) = ensure_installed(&resolution.version).await { - eprintln!("vp: Failed to install Node {}: {e}", resolution.version); - return Err(1); - } - locate_tool(&resolution.version, "corepack").ok() - } - }; - if let Some(corepack_path) = corepack_path { - // The bundled corepack sits in the same bin directory as node; - // prepend it so corepack's child processes see the same runtime. - if let Some(node_bin_dir) = corepack_path.parent() { - let _ = prepend_to_path_env(node_bin_dir, PrependOptions::default()); - } - // Match the core-tool dispatch: nested core-tool shims pass through. - // SAFETY: Setting env vars at this point before exec/spawn is safe - unsafe { - std::env::set_var(env_vars::VP_TOOL_RECURSION, "1"); - } - return Ok(CorepackInvocation { program: corepack_path, pre_args: Vec::new() }); - } - - // 3. No usable corepack in the resolved Node.js (Node.js 25+ no longer - // bundles it; a bundled copy may also have been removed, e.g. by - // `npm uninstall -g corepack`): install it as a vp-managed global - // package, then run that copy. Only the `corepack` bin is linked; the - // pnpm/yarn launchers the package also declares stay unexposed (that - // is `corepack enable`'s job) and must not conflict with vp-managed - // package managers. The notice goes to stderr so the wrapped - // corepack's stdout stays parseable. - eprintln!( - "vp: corepack is not available for Node.js {}; installing it as a managed global package", - resolution.version - ); - // Preserve the shape of a previous explicit (unrestricted) install: the - // reinstall must not silently drop launcher bins the user had exposed - // (the stale-bin cleanup would delete their pnpm/yarn shims). - let unrestricted = matches!( - crate::commands::env::package_metadata::PackageMetadata::load("corepack").await, - Ok(Some(previous)) if !previous.bins_restricted - ); - let only_bins: Option<&[&str]> = if unrestricted { None } else { Some(&["corepack"]) }; - if let Err((_, error)) = crate::commands::global::install::install( - &["corepack".to_string()], - crate::commands::global::install::InstallOptions { - node_version: None, - force: false, - concurrency: 1, - update: false, - only_bins, - }, - ) - .await - { - eprintln!("vp: Failed to install corepack: {error}"); - eprintln!("vp: Run 'vp install -g corepack' manually, then retry"); - return Err(1); - } - match managed_corepack_invocation().await { - Ok(Some(invocation)) => Ok(invocation), - Ok(None) => { - eprintln!("vp: corepack was installed but its binary could not be located"); - Err(1) - } - Err(e) => { - eprintln!("vp: corepack was installed but cannot be resolved: {e}"); - Err(1) - } - } -} - -/// Resolve a corepack installed via `vp install -g corepack`, if any. -/// -/// Uses the install-time Node.js version, like every other vp-managed -/// package binary. -async fn managed_corepack_invocation() -> Result, String> { - let Some(metadata) = find_package_for_binary("corepack").await? else { - return Ok(None); - }; - let (program, pre_args) = - package_binary_invocation(&metadata, "corepack", &metadata.platform.node).await?; - Ok(Some(CorepackInvocation { program, pre_args })) -} - -/// Check whether the args invoke `corepack enable`/`corepack disable` -/// (the commands that create or remove launchers in the install directory). -/// -/// Everything after a `--` separator is positional (package-manager names), -/// so the subcommand and help flags are only looked for before it. -fn is_corepack_link_command(args: &[String]) -> bool { - // Help output doesn't touch link files; run it as-is. - if crate::help::has_help_flag_before_terminator(args) { - return false; - } - let subcommand = args.iter().take_while(|arg| *arg != "--").find(|arg| !arg.starts_with('-')); - matches!(subcommand.map(String::as_str), Some("enable" | "disable")) -} - -/// Return the user args for an intercepted `corepack enable`/`disable` run, -/// injecting `--install-directory ` when not explicitly set so the -/// created launchers land on PATH. The flag is inserted before any `--` -/// separator; tokens after it are package-manager names. -fn inject_install_directory(args: &[String], bin_dir: &AbsolutePath) -> Vec { - let mut rewritten = args.to_vec(); - let has_install_directory = args - .iter() - .take_while(|arg| *arg != "--") - .any(|arg| arg == "--install-directory" || arg.starts_with("--install-directory=")); - if !has_install_directory { - let insert_at = args.iter().position(|arg| arg == "--").unwrap_or(args.len()); - rewritten.insert(insert_at, bin_dir.as_path().display().to_string()); - rewritten.insert(insert_at, "--install-directory".to_string()); - } - rewritten -} - -/// A Vite+-owned bin entry that was intact before corepack ran. -enum OwnedShim { - /// Default shim (npm/npx) — always belongs to Vite+. - Core { name: &'static str }, - /// Binary installed via `vp install -g` (BinConfig source `vp`). - Package { bin_config: BinConfig }, - /// Direct link created by the `npm install -g` interception - /// (BinConfig source `npm`). `source` is the link target captured at - /// snapshot time (Unix); when unavailable the restore falls back to the - /// managed Node.js layout via `locate_tool`. - NpmLink { bin_config: BinConfig, source: Option }, -} - -/// Snapshot which Vite+-owned shims among the corepack-managed launcher -/// names are intact before corepack runs. Only entries in this snapshot are -/// candidates for restoration, so shims the user removed on purpose are not -/// resurrected and untouched entries produce no spurious warnings. -/// -/// Default shims already replaced by a corepack launcher (e.g. a previous -/// interrupted run) are included too, so the restore self-heals them. -async fn snapshot_vp_owned_shims(bin_dir: &AbsolutePath) -> Vec { - let mut owned = Vec::new(); - for name in COREPACK_MANAGED_BIN_NAMES { - if setup::SHIM_TOOLS.contains(name) { - if core_shim_intact(bin_dir, name).await - || corepack_launcher_present(bin_dir, name).await - { - owned.push(OwnedShim::Core { name }); - } - continue; - } - let bin_config = match BinConfig::load(name).await { - Ok(Some(config)) => config, - Ok(None) => continue, - Err(e) => { - tracing::warn!("Skipping shim snapshot for '{}': {}", name, e); - continue; - } - }; - match bin_config.source { - BinSource::Vp => { - if is_vp_shim(bin_dir, name).await { - owned.push(OwnedShim::Package { bin_config }); - } - } - BinSource::Npm => { - if npm_link_intact(bin_dir, name).await { - let source = npm_link_source(bin_dir, name).await; - owned.push(OwnedShim::NpmLink { bin_config, source }); - } - } - } - } - owned -} - -/// Restore Vite+-owned shims that corepack `enable`/`disable` removed or -/// replaced, based on the pre-invocation snapshot. -async fn restore_vp_owned_shims(bin_dir: &AbsolutePath, owned_shims: &[OwnedShim]) { - // Resolved through the shim symlink chain so recreated shims point at the - // real vp binary, not at this process's `corepack` shim path. A failure - // only disables core-shim restores; package and npm-link restores below - // don't need the executable path. - let resolved_exe = if owned_shims.iter().any(|shim| matches!(shim, OwnedShim::Core { .. })) { - match std::env::current_exe() { - Ok(exe) => Some(tokio::fs::canonicalize(&exe).await.unwrap_or(exe)), - Err(e) => { - tracing::warn!("Cannot resolve the current executable for shim restore: {}", e); - None - } - } - } else { - None - }; - - for shim in owned_shims { - match shim { - OwnedShim::Core { name } => { - if core_shim_intact(bin_dir, name).await { - continue; - } - let Some(exe) = &resolved_exe else { continue }; - let _ = tokio::fs::remove_file(bin_dir.join(name)).await; - match setup::create_shim(exe, bin_dir, name, false).await { - Ok(_) => output::warn(&format!( - "'{name}' is managed by Vite+ and was restored. Vite+ already resolves \ - '{name}' per project, so corepack does not need to manage it." - )), - Err(e) => tracing::warn!("Failed to restore '{}' shim: {}", name, e), - } - // Remove corepack's extensionless/cmd/ps1 launchers that would - // shadow the trampoline .exe in Git Bash or PowerShell. - #[cfg(windows)] - setup::cleanup_legacy_windows_shim(bin_dir, name).await; - } - OwnedShim::Package { bin_config } => { - let name = bin_config.name.as_str(); - if is_vp_shim(bin_dir, name).await { - continue; - } - match crate::commands::global::install::create_package_shim( - bin_dir, - name, - &bin_config.package, - ) - .await - { - Ok(()) => output::warn(&format!( - "'{name}' is managed by `vp install -g {pkg}` and was restored. \ - Run `vp remove -g {pkg}` first to let corepack manage '{name}'.", - pkg = bin_config.package - )), - Err(e) => tracing::warn!("Failed to restore '{}' shim: {}", name, e), - } - } - OwnedShim::NpmLink { bin_config, source } => { - let name = bin_config.name.as_str(); - if npm_link_intact(bin_dir, name).await { - continue; - } - // Prefer the captured original target; fall back to the - // managed Node.js layout (validated per-OS by locate_tool). - let source_path = match source { - Some(path) => path.clone(), - None => match locate_tool(&bin_config.node_version, name) { - Ok(path) => path, - Err(e) => { - output::warn(&format!( - "'{name}' was linked by `npm install -g {pkg}` and removed by \ - corepack, but its source could not be located ({e}). \ - Run `npm install -g {pkg}` to recreate it.", - pkg = bin_config.package - )); - continue; - } - }, - }; - output::warn(&format!( - "'{name}' was linked by `npm install -g {pkg}`; restoring the link.", - pkg = bin_config.package - )); - let _ = tokio::fs::remove_file(bin_dir.join(name)).await; - // Remove corepack's launcher files (.cmd/.ps1/extensionless) - // so they cannot shadow the rewritten link. - #[cfg(windows)] - setup::cleanup_legacy_windows_shim(bin_dir, name).await; - create_bin_link( - bin_dir, - name, - &source_path, - &bin_config.package, - &bin_config.node_version, - ); - } - } - } -} - -/// Check whether a default shim (npm/npx) is still an intact Vite+ shim. -/// -/// Vite+ shims always link to the vp binary (relative `../current/bin/vp` or -/// an absolute path in dev layouts); corepack launchers link to corepack's -/// `dist/*.js` files. Broken symlinks count as not intact. -#[cfg(unix)] -async fn core_shim_intact(bin_dir: &AbsolutePath, name: &str) -> bool { - let shim_path = bin_dir.join(name); - match tokio::fs::read_link(&shim_path).await { - Ok(target) => { - target.file_name().is_some_and(|file_name| file_name == "vp") - && std::fs::exists(shim_path.as_path()).unwrap_or(false) - } - Err(_) => false, - } -} - -/// Check whether a default shim (npm/npx) is still an intact Vite+ shim. -#[cfg(windows)] -async fn core_shim_intact(bin_dir: &AbsolutePath, name: &str) -> bool { - is_vp_shim(bin_dir, name).await -} - -/// Check whether the bin entry currently holds a corepack launcher (the shape -/// corepack `enable` writes). Used to self-heal default shims clobbered by a -/// previous run that never reached its restore step: present launchers are -/// snapshotted as Vite+-owned, while an absent entry (deliberately removed by -/// the user) is not. -#[cfg(unix)] -async fn corepack_launcher_present(bin_dir: &AbsolutePath, name: &str) -> bool { - match tokio::fs::read_link(bin_dir.join(name)).await { - // corepack launchers are symlinks to corepack's dist/.js - Ok(target) => { - target.extension().is_some_and(|extension| extension == "js" || extension == "cjs") - } - Err(_) => false, - } -} - -/// Check whether the bin entry currently holds a corepack launcher. -/// -/// corepack's cmd-shim writes `.cmd`/`.ps1`/extensionless wrappers; Vite+ -/// never creates those for default shim names. -#[cfg(windows)] -async fn corepack_launcher_present(bin_dir: &AbsolutePath, name: &str) -> bool { - let cmd_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.cmd"))).await.unwrap_or(false); - let ps1_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.ps1"))).await.unwrap_or(false); - let sh_exists = tokio::fs::try_exists(&bin_dir.join(name)).await.unwrap_or(false); - cmd_exists || ps1_exists || sh_exists -} - -/// Capture the current target of an npm-interception link so the restore can -/// recreate it exactly (it may point at a custom npm prefix, not the managed -/// Node.js directory). -#[cfg(unix)] -async fn npm_link_source(bin_dir: &AbsolutePath, name: &str) -> Option { - let target = tokio::fs::read_link(bin_dir.join(name)).await.ok()?; - AbsolutePathBuf::new(target) -} - -/// Capture the current target of an npm-interception link. -/// -/// On Windows the link is a `.cmd` wrapper whose target is embedded in its -/// body; reconstructing it is not worth the parsing, so the restore falls -/// back to the managed Node.js layout via `locate_tool`. -#[cfg(windows)] -async fn npm_link_source(_bin_dir: &AbsolutePath, _name: &str) -> Option { - None -} - -/// Check whether the bin entry is an intact Vite+ package shim. -#[cfg(unix)] -async fn is_vp_shim(bin_dir: &AbsolutePath, name: &str) -> bool { - let shim_path = bin_dir.join(name); - match tokio::fs::read_link(&shim_path).await { - Ok(target) => crate::commands::global::install::is_vp_shim_target(&target, &shim_path), - Err(_) => false, - } -} - -/// Check whether the bin entry is an intact Vite+ package shim. -/// -/// Trampoline shims are `.exe` files; corepack's cmd-shim launchers are -/// `.cmd`/`.ps1`/extensionless files that shadow the trampoline in Git Bash -/// (extensionless) and PowerShell (`.ps1`), so any of them present means the -/// shim needs restoring. -#[cfg(windows)] -async fn is_vp_shim(bin_dir: &AbsolutePath, name: &str) -> bool { - let exe_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.exe"))).await.unwrap_or(false); - let cmd_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.cmd"))).await.unwrap_or(false); - let ps1_exists = - tokio::fs::try_exists(&bin_dir.join(format!("{name}.ps1"))).await.unwrap_or(false); - let sh_exists = tokio::fs::try_exists(&bin_dir.join(name)).await.unwrap_or(false); - exe_exists && !cmd_exists && !ps1_exists && !sh_exists -} - -/// Check whether a link created by the `npm install -g` interception is -/// still intact. -/// -/// On Unix these are symlinks pointing at the binary of the same name in a -/// managed Node.js bin directory; corepack launchers point at `dist/*.js` -/// files instead. -#[cfg(unix)] -async fn npm_link_intact(bin_dir: &AbsolutePath, name: &str) -> bool { - let link_path = bin_dir.join(name); - match tokio::fs::read_link(&link_path).await { - Ok(target) => { - target.file_name().is_some_and(|file_name| file_name == name) - && std::fs::exists(link_path.as_path()).unwrap_or(false) - } - Err(_) => false, - } -} - -/// Check whether a link created by the `npm install -g` interception is -/// still intact. -/// -/// On Windows npm links are `.cmd` wrappers with a fixed three-line shape -/// (see `create_bin_link`). corepack also writes `.cmd` launchers, so the -/// content is checked to detect an overwritten (not just deleted) link. -#[cfg(windows)] -async fn npm_link_intact(bin_dir: &AbsolutePath, name: &str) -> bool { - match tokio::fs::read_to_string(&bin_dir.join(format!("{name}.cmd"))).await { - Ok(content) => is_npm_link_wrapper(&content), - Err(_) => false, - } -} - -/// Whether `.cmd` content matches vp's npm-link wrapper shape written by -/// `create_bin_link`: `@echo off`, a quoted source invocation forwarding all -/// args, and the exit-code forward. corepack's cmd-shim launchers have a -/// different, multi-branch shape. -#[cfg(any(windows, test))] -fn is_npm_link_wrapper(content: &str) -> bool { - let mut lines = content.lines(); - lines.next() == Some("@echo off") - && lines.next().is_some_and(|line| line.starts_with('"') && line.ends_with("\" %*")) - && lines.next() == Some("exit /b %ERRORLEVEL%") - && lines.next().is_none() -} - -#[cfg(test)] -mod tests { - use vt_path::AbsolutePathBuf; - - use super::*; - - fn bin_dir() -> AbsolutePathBuf { - #[cfg(windows)] - { - AbsolutePathBuf::new(std::path::PathBuf::from("C:\\Users\\test\\.vite-plus\\bin")) - .unwrap() - } - #[cfg(not(windows))] - { - AbsolutePathBuf::new(std::path::PathBuf::from("/home/test/.vite-plus/bin")).unwrap() - } - } - - fn s(strs: &[&str]) -> Vec { - strs.iter().map(ToString::to_string).collect() - } - - #[test] - fn test_is_corepack_link_command() { - assert!(is_corepack_link_command(&s(&["enable"]))); - assert!(is_corepack_link_command(&s(&["disable", "yarn"]))); - assert!(is_corepack_link_command(&s(&["enable", "--install-directory", "/custom"]))); - assert!(is_corepack_link_command(&s(&["enable", "--", "pnpm"]))); - - assert!(!is_corepack_link_command(&s(&[]))); - assert!(!is_corepack_link_command(&s(&["--version"]))); - assert!(!is_corepack_link_command(&s(&["use", "pnpm@9"]))); - assert!(!is_corepack_link_command(&s(&["pnpm", "install"]))); - assert!(!is_corepack_link_command(&s(&["up"]))); - - // Everything after `--` is positional, not a subcommand - assert!(!is_corepack_link_command(&s(&["--", "enable"]))); - - // Help output doesn't touch link files - assert!(!is_corepack_link_command(&s(&["enable", "--help"]))); - assert!(!is_corepack_link_command(&s(&["enable", "-h"]))); - } - - #[test] - fn test_inject_install_directory_appends_when_missing() { - let bin_dir = bin_dir(); - let rewritten = inject_install_directory(&s(&["enable"]), &bin_dir); - assert_eq!( - rewritten, - s(&["enable", "--install-directory", &bin_dir.as_path().display().to_string()]) - ); - - let rewritten = inject_install_directory(&s(&["disable", "yarn"]), &bin_dir); - assert_eq!( - rewritten, - s(&[ - "disable", - "yarn", - "--install-directory", - &bin_dir.as_path().display().to_string() - ]) - ); - } - - #[test] - fn test_inject_install_directory_keeps_explicit_value() { - let bin_dir = bin_dir(); - let args = s(&["enable", "--install-directory", "/custom/dir"]); - assert_eq!(inject_install_directory(&args, &bin_dir), args); - - let args = s(&["enable", "--install-directory=/custom/dir"]); - assert_eq!(inject_install_directory(&args, &bin_dir), args); - } - - #[test] - fn test_is_npm_link_wrapper_shape() { - // vp's wrapper as written by create_bin_link - let wrapper = "@echo off\r\n\"C:\\vp\\node\\pnpm.cmd\" %*\r\nexit /b %ERRORLEVEL%\r\n"; - assert!(is_npm_link_wrapper(wrapper)); - - // corepack cmd-shim output is multi-branch and must not match - let corepack = "@SETLOCAL\r\n@IF EXIST \"%~dp0\\node.exe\" (\r\n ...\r\n)\r\n"; - assert!(!is_npm_link_wrapper(corepack)); - assert!(!is_npm_link_wrapper("")); - assert!(!is_npm_link_wrapper("@echo off\r\nsomething else\r\n")); - } - - #[test] - fn test_inject_install_directory_inserts_before_separator() { - let bin_dir = bin_dir(); - let dir = bin_dir.as_path().display().to_string(); - - // The injected flag must precede `--`; tokens after it are - // package-manager names. - let rewritten = inject_install_directory(&s(&["enable", "--", "pnpm"]), &bin_dir); - assert_eq!(rewritten, s(&["enable", "--install-directory", &dir, "--", "pnpm"])); - - // An --install-directory after `--` is a positional, not the flag - let rewritten = - inject_install_directory(&s(&["enable", "--", "--install-directory"]), &bin_dir); - assert_eq!( - rewritten, - s(&["enable", "--install-directory", &dir, "--", "--install-directory"]) - ); - } -} diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index f072073a74..dcd5aa6d4c 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -8,6 +8,7 @@ use vp_pm_cli::{ PackageManagerType, download_package_manager, package_manager_bin_path, package_manager_install_dir, resolve_package_manager_from_package_json, + resolve_package_manager_version, }; use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; @@ -262,18 +263,13 @@ fn check_npm_global_install_result( for bin_name in bin_names { // Skip protected shims (core shims and default env shims). Tell - // the user for the non-core names (e.g. `npm i -g corepack`): - // npm installed the package, but the binary stays unlinked. + // the user for non-core names: npm installed the package, but the + // binary stays unlinked. if is_protected_shim(&bin_name, false) { if !crate::commands::global::CORE_SHIMS.contains(&bin_name.as_str()) { - let hint = if bin_name == "corepack" { - " Use `vp install -g corepack` to manage its version." - } else { - "" - }; output::note(&vt_str::format!( "'{bin_name}' is a Vite+ default shim; the npm-installed copy is not \ - linked.{hint}" + linked." )); } continue; @@ -521,9 +517,8 @@ fn remove_npm_global_uninstall_links(bin_entries: &[(String, String)], npm_prefi let Ok(bin_dir) = config::get_bin_dir() else { return }; for (bin_name, package_name) in bin_entries { - // Skip protected shims: a stale Npm BinConfig (e.g. a pre-default-shim - // `npm install -g corepack`) must not let `npm uninstall -g` delete a - // default shim that `vp env setup` now owns. + // Skip protected shims: a stale Npm BinConfig must not let + // `npm uninstall -g` delete a default shim that `vp env setup` owns. if is_protected_shim(bin_name, false) { continue; } @@ -664,11 +659,10 @@ fn resolve_npm_prefix( get_npm_global_prefix(npm_path, node_dir) } -/// Resolve a matching package-manager binary from the current project's explicit -/// `packageManager` field. +/// Resolve a package-manager binary for the invoked shim. /// -/// The match is intentionally strict to avoid translating commands: `npm` only uses -/// `npm@...`, `pnpm` only uses `pnpm@...`, etc. +/// Project declarations only apply to a matching manager. Without one, named +/// package-manager shims use their latest release, while npm stays bundled with Node.js. async fn resolve_matching_package_manager_tool( cwd: &AbsolutePath, tool: &str, @@ -677,32 +671,29 @@ async fn resolve_matching_package_manager_tool( return Ok(None); }; - let Some(resolution) = resolve_package_manager_from_package_json(cwd)? else { - return Ok(None); + let (version, hash) = match resolve_package_manager_from_package_json(cwd)? { + Some(resolution) if resolution.package_manager_type == expected_type => { + (resolution.version, resolution.hash) + } + Some(_) => return Ok(None), + None if expected_type == PackageManagerType::Npm => return Ok(None), + None => (resolve_package_manager_version(expected_type, "latest").await?, None), }; - if resolution.package_manager_type != expected_type { - return Ok(None); - } - let bin_name = expected_type.bin_name_for_tool(tool); // Fast path: if the managed install already exists, skip download_package_manager // entirely. The slow path stats three files (`bin`, `.cmd`, `.ps1`) on every // invocation, which adds up on the shim hot path. - if let Some(install_dir) = package_manager_install_dir(expected_type, &resolution.version) { + if let Some(install_dir) = package_manager_install_dir(expected_type, &version) { let bin_path = package_manager_bin_path(&install_dir, bin_name); if bin_path.as_path().exists() { return Ok(Some(bin_path)); } } - let (install_dir, _, _) = download_package_manager( - resolution.package_manager_type, - &resolution.version, - resolution.hash.as_deref(), - ) - .await?; + let (install_dir, _, _) = + download_package_manager(expected_type, &version, hash.as_deref()).await?; Ok(Some(package_manager_bin_path(&install_dir, bin_name))) } @@ -725,7 +716,7 @@ async fn prepend_js_child_process_path_env( /// Main shim dispatch entry point. /// -/// Called when the binary is invoked as node, npm, npx, corepack, or a +/// Called when the binary is invoked as node, npm, npx, or a /// package binary. Returns an exit code to be used with std::process::exit. pub async fn dispatch(tool: &str, args: &[String]) -> i32 { tracing::debug!("dispatch: tool: {tool}, args: {:?}", args); @@ -796,15 +787,6 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Fall through to managed if system not found } - // corepack: dedicated resolution chain (vp-managed package → Node-bundled - // → auto-install), see shim::corepack. Intentionally placed after the - // bypass/system-first checks and outside the recursion passthrough so it - // always re-resolves (corepack may not exist on the prepended PATH at all - // with Node.js 25+). - if tool == "corepack" { - return super::corepack::dispatch_corepack(args).await; - } - // Check if this is a package binary (not node/npm/npx) if !is_core_shim_tool(tool) { return dispatch_package_binary(tool, args).await; @@ -959,32 +941,29 @@ async fn dispatch_package_binary(tool: &str, args: &[String]) -> i32 { Ok(Some(tool_path)) => { let node_version = match resolve_with_cache(&cwd).await { Ok(resolution) => resolution.version, - Err(_) => match find_package_for_binary(tool).await { - Ok(Some(metadata)) => metadata.platform.node, - _ => String::new(), - }, + Err(e) => { + eprintln!("vp: Failed to resolve Node version: {e}"); + eprintln!("vp: Run 'vp env doctor' for diagnostics"); + return 1; + } }; - if !node_version.is_empty() { - match ensure_installed(&node_version).await { - Ok(node_path) => { - if let Some(node_bin_dir) = node_path.parent() { - if let Err(e) = - prepend_js_child_process_path_env(&cwd, node_bin_dir).await - { - eprintln!( - "vp: Failed to resolve package manager for child \ - process PATH: {e}" - ); - return 1; - } - } - } - Err(e) => { - eprintln!("vp: Failed to install Node {}: {e}", node_version); + match ensure_installed(&node_version).await { + Ok(node_path) => { + if let Some(node_bin_dir) = node_path.parent() + && let Err(e) = + prepend_js_child_process_path_env(&cwd, node_bin_dir).await + { + eprintln!( + "vp: Failed to resolve package manager for child process PATH: {e}" + ); return 1; } } + Err(e) => { + eprintln!("vp: Failed to install Node {}: {e}", node_version); + return 1; + } } if let Some(pm_bin_dir) = tool_path.parent() { diff --git a/crates/vp_global_cli/src/shim/mod.rs b/crates/vp_global_cli/src/shim/mod.rs index c6f8a5a977..891595c8a0 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -1,7 +1,7 @@ -//! Shim module for intercepting node, npm, npx, corepack, and package binary commands. +//! Shim module for intercepting node, npm, npx, and package binary commands. //! //! This module provides the functionality for the vp binary to act as a shim -//! when invoked as `node`, `npm`, `npx`, `corepack`, or any globally installed +//! when invoked as `node`, `npm`, `npx`, or any globally installed //! package binary. //! //! Detection methods: @@ -10,7 +10,6 @@ //! - Legacy: `.cmd` wrappers call `vp env exec ` directly (deprecated) mod cache; -pub(crate) mod corepack; pub(crate) mod dispatch; pub(crate) mod exec; @@ -24,11 +23,6 @@ use vp_shared::env_vars; use crate::commands::env::config::get_bin_dir; /// Core shim tools (node, npm, npx). -/// -/// `corepack` is also a default shim (see `commands::env::setup::SHIM_TOOLS`) -/// but is intentionally not a core tool: it is not always bundled with the -/// resolved Node.js version (removed in Node.js 25+), so it has a dedicated -/// dispatch path with a managed fallback and never uses recursion passthrough. pub const CORE_SHIM_TOOLS: &[&str] = &["node", "npm", "npx"]; /// Extract the tool name from argv[0]. @@ -227,10 +221,6 @@ mod tests { assert!(!is_core_shim_tool("vp")); assert!(!is_core_shim_tool("cargo")); assert!(!is_core_shim_tool("tsc")); // Package binary, not core - // corepack is a default shim but intentionally not a core tool: - // it has a dedicated dispatch path and never uses recursion passthrough - assert!(!is_core_shim_tool("corepack")); - // is_shim_tool includes core tools assert!(is_shim_tool("node")); assert!(is_shim_tool("npm")); diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index de091c6f2a..94cb18cd29 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -25,7 +25,7 @@ pub use package_manager::{ PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, PackageManagerType, download_package_manager, get_package_manager_type_and_version, package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_from_package_json, + resolve_package_manager_from_package_json, resolve_package_manager_version, }; pub use request::HttpClient; pub use resolution::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index adc63f752d..ae36460cbc 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -6,6 +6,7 @@ use std::{ fs::{self, File}, io::{self, BufReader, Write}, path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, }; use crossterm::{ @@ -645,7 +646,47 @@ fn find_extracted_package_dir(target_dir: &Path) -> io::Result { )) } -async fn get_latest_version(package_manager_type: PackageManagerType) -> Result { +const LATEST_VERSION_CACHE_TTL_SECS: u64 = 3600; + +#[derive(Deserialize, Serialize)] +struct LatestVersionCache { + version: String, + expires_at: u64, +} + +fn latest_version_cache_path( + package_manager_type: PackageManagerType, +) -> Result { + Ok(vp_shared::get_vp_home()? + .join("cache") + .join("package_manager_latest") + .join(format!("{package_manager_type}.json"))) +} + +fn now_timestamp() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |duration| duration.as_secs()) +} + +async fn load_latest_version_cache(path: &AbsolutePath) -> Option { + let content = tokio::fs::read_to_string(path).await.ok()?; + let cache: LatestVersionCache = serde_json::from_str(&content).ok()?; + Version::parse(&cache.version).ok()?; + Some(cache) +} + +async fn save_latest_version_cache(path: &AbsolutePath, version: &str) { + let cache = LatestVersionCache { + version: version.to_string(), + expires_at: now_timestamp() + LATEST_VERSION_CACHE_TTL_SECS, + }; + let Ok(content) = serde_json::to_string(&cache) else { return }; + let Some(parent) = path.parent() else { return }; + if tokio::fs::create_dir_all(parent).await.is_ok() { + let _ = tokio::fs::write(path, content).await; + } +} + +async fn fetch_latest_version(package_manager_type: PackageManagerType) -> Result { let package_name = if matches!(package_manager_type, PackageManagerType::Yarn) { // yarn latest version should use `@yarnpkg/cli-dist` as package name "@yarnpkg/cli-dist".to_string() @@ -657,6 +698,31 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result< Ok(package_json.version) } +async fn get_latest_version(package_manager_type: PackageManagerType) -> Result { + let cache_path = latest_version_cache_path(package_manager_type)?; + let cached = load_latest_version_cache(&cache_path).await; + if let Some(cache) = &cached + && now_timestamp() < cache.expires_at + { + return Ok(cache.version.clone().into()); + } + + match fetch_latest_version(package_manager_type).await { + Ok(version) => { + save_latest_version_cache(&cache_path, &version).await; + Ok(version) + } + Err(error) => { + let Some(cache) = cached else { return Err(error) }; + tracing::warn!( + "Failed to refresh latest {package_manager_type} version: {error}; using cached {}", + cache.version + ); + Ok(cache.version.into()) + } + } +} + /// Abbreviated registry metadata: only the version list is needed. #[derive(Deserialize)] struct RegistryPackument { @@ -804,6 +870,20 @@ 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. `$VP_HOME/package_manager/pnpm/10.0.0/pnpm` pub async fn download_package_manager( @@ -811,15 +891,7 @@ pub async fn download_package_manager( version_or_latest: &str, expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { - let version: Str = if version_or_latest == "latest" { - get_latest_version(package_manager_type).await? - } else if Version::parse(version_or_latest).is_ok() { - version_or_latest.into() - } else { - // semver range (e.g. from devEngines.packageManager): prefer an already - // downloaded satisfying version, otherwise resolve from the registry - resolve_package_manager_range(package_manager_type, version_or_latest).await? - }; + let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; // Reject anything that is not strict semver `major.minor.patch[-prerelease][+build]`. // This prevents path traversal via the version being interpolated into @@ -1806,6 +1878,50 @@ mod tests { assert_eq!(find_cached_pnpm(&vp_home), None); } + #[tokio::test(flavor = "current_thread")] + async fn test_latest_version_cache_refreshes_after_expiry() { + use httpmock::prelude::*; + + let temp_dir = create_temp_dir(); + let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let server = MockServer::start(); + let mut first = server.mock(|when, then| { + when.method(GET).path("/bun/latest"); + then.status(200).json_body(serde_json::json!({ "version": "1.0.0" })); + }); + let _guard = EnvConfig::test_guard(EnvConfig { + npm_registry: server.base_url(), + vite_plus_home: Some(vp_home.as_path().to_path_buf()), + ..EnvConfig::for_test() + }); + + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "1.0.0" + ); + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "1.0.0" + ); + first.assert_hits(1); + + let cache_path = latest_version_cache_path(PackageManagerType::Bun).unwrap(); + let mut cache = load_latest_version_cache(&cache_path).await.unwrap(); + cache.expires_at = 0; + tokio::fs::write(&cache_path, serde_json::to_string(&cache).unwrap()).await.unwrap(); + first.delete(); + + let refreshed = server.mock(|when, then| { + when.method(GET).path("/bun/latest"); + then.status(200).json_body(serde_json::json!({ "version": "2.0.0" })); + }); + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "2.0.0" + ); + refreshed.assert_hits(1); + } + #[cfg(windows)] #[test] fn test_find_cached_package_manager_version_skips_missing_windows_shims() { diff --git a/docs/guide/env.md b/docs/guide/env.md index a1580348d5..a337339824 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -19,7 +19,7 @@ latest LTS. `devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. `vp env doctor` warns when declared sources conflict. -When a project declares `packageManager` (or `devEngines.packageManager`) in `package.json`, matching package-manager shims also use that package-manager version. For example, `packageManager: "npm@10.9.4"` makes both `npm` and `npx` run through npm 10.9.4. Alias pairs follow the installed package-manager shims: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Vite+ does not translate mismatched commands, so a project pinned to `pnpm` still lets `npm` fall back to the npm that comes with the resolved Node.js runtime. +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. By default, Vite+ stores its managed runtime and related files in `~/.vite-plus`. If needed, you can override that location with `VP_HOME`. @@ -43,7 +43,7 @@ This switches to system-first mode, where the shims prefer your system Node.js a ### Setup -- `vp env setup` creates or updates shims in `VP_HOME/bin` (and writes the per-shell setup scripts under `VP_HOME`) +- `vp env setup` creates or updates the `node`, `npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`, `bun`, `bunx`, `vpx`, and `vpr` shims in `VP_HOME/bin` (and writes the per-shell setup scripts under `VP_HOME`) - `vp env on` 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 @@ -88,7 +88,7 @@ In CI, `vp env use` can still run without shell initialization. It writes a temp - `vp env use` sets a Node.js version for the current shell session - `vp env install` installs a Node.js version - `vp env uninstall` removes an installed Node.js version -- `vp env clean` removes unused managed Node.js runtimes, all downloaded package managers, and the Corepack cache. +- `vp env 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` @@ -110,7 +110,7 @@ In CI, `vp env use` can still run without shell initialization. It writes a temp ```bash # Setup -vp env setup # Create shims for node, npm, npx, corepack +vp env 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 @@ -136,25 +136,6 @@ vp node script.js # Shorthand: run a Node.js script with the resolve vp node -e "console.log(1+1)" # Shorthand: forward any node flag or argument ``` -## Corepack - -Vite+ creates a `corepack` shim by default, so corepack works without a system Node.js installation: - -- On Node.js 24 and earlier, the shim runs the corepack bundled with the resolved Node.js version. -- On Node.js 25 and later, where corepack is no longer bundled, Vite+ installs corepack as a managed global package on first use. Only the `corepack` binary is linked; run `vp install -g corepack` yourself if you also want the package's pnpm/yarn launchers exposed directly. -- If you install corepack explicitly with `vp install -g corepack`, that installation is always preferred. - -`corepack enable` normally creates `pnpm`/`yarn` launchers next to the corepack binary, which under Vite+ would not be on `PATH`. The shim fixes this by defaulting `--install-directory` to `VP_HOME/bin`, so after `corepack enable` the launchers are available everywhere and still resolve the project's Node.js and package-manager versions: - -```bash -corepack enable # pnpm and yarn now resolve via corepack -corepack disable # Remove the pnpm/yarn launchers again -``` - -The launchers reference the corepack copy that created them. If that copy is later removed (for example by uninstalling the Node.js version it shipped with), rerun `corepack enable` to recreate them. - -Shims owned by Vite+ (`npm`, `npx`, and binaries installed with `vp install -g`) are protected: if corepack removes or replaces them, Vite+ restores them and prints a warning. - ## 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/packages/cli/install.ps1 b/packages/cli/install.ps1 index c37507a314..5a19ef2e65 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -671,7 +671,7 @@ function Refresh-Shims { } } -# Setup Node.js version manager (node/npm/npx/corepack shims) +# Setup Node.js version manager (node/npm/npx shims) # Returns: "true" = enabled, "false" = not enabled, "already" = already configured function Setup-NodeManager { param([string]$BinDir) @@ -717,7 +717,7 @@ function Setup-NodeManager { if ($isInteractive) { Write-Host "" Write-Host "Would you like Vite+ to manage your Node.js versions?" - Write-Host "It adds ``node``, ``npm``, ``npx``, and ``corepack`` shims to $NodeManagerBinDisplay and automatically uses the right version." + Write-Host "It adds ``node``, ``npm``, ``npx``, ``pnpm``, ``pnpx``, ``yarn``, ``yarnpkg``, ``bun``, and ``bunx`` shims to $NodeManagerBinDisplay." Write-Host "Opt out anytime with ``vp env off``." $response = Read-Host "Press Enter to accept (Y/n)" diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 5aa2244fab..c89d6435bf 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -878,7 +878,7 @@ refresh_shims() { fi } -# Setup Node.js version manager (node/npm/npx/corepack shims) +# Setup Node.js version manager (node/npm/npx shims) # Sets NODE_MANAGER_ENABLED global # Arguments: bin_dir - path to the version's bin directory containing vp setup_node_manager() { @@ -937,7 +937,7 @@ setup_node_manager() { if [ -e /dev/tty ] && [ -t 1 ]; then echo "" echo "Would you like Vite+ to manage your Node.js versions?" - echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/ and automatically uses the right version." + echo "It adds \`node\`, \`npm\`, \`npx\`, \`pnpm\`, \`pnpx\`, \`yarn\`, \`yarnpkg\`, \`bun\`, and \`bunx\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/." echo "Opt out anytime with \`vp env off\`." echo -n "Press Enter to accept (Y/n): " read -r response < /dev/tty diff --git a/rfcs/docker-image.md b/rfcs/docker-image.md index 661627dca3..dff0a0e841 100644 --- a/rfcs/docker-image.md +++ b/rfcs/docker-image.md @@ -189,7 +189,7 @@ deployed images small. - **Preinstalled:** `vp` (on `PATH`), `ca-certificates`, `curl`, `git`, and a build toolchain (`build-essential`, `python3`, `pkg-config`) for native addon compilation (for example `better-sqlite3`). Package managers are handled by - vp's managed corepack/runtime, so they are provisioned per-project rather than + vp's package-manager support, so they are provisioned per-project rather than baked to a fixed version. - **No baked default Node.js:** the installer pre-provisions a default Node.js (~190 MB); the image drops it (`rm -rf $VP_HOME/js_runtime`) because each diff --git a/rfcs/env-command.md b/rfcs/env-command.md index 96c6cf55be..d45af48db9 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -2,9 +2,7 @@ ## Summary -This RFC proposes adding a `vp env` command that provides system-wide, IDE-safe Node.js version management through a shim-based architecture. The shims intercept `node`, `npm`, `npx`, and `corepack` commands, automatically resolving and executing the correct Node.js version based on project configuration. - -> **Note**: The `corepack` shim was originally excluded because Vite+ has integrated package manager functionality. This was revisited in [#858](https://github.com/voidzero-dev/vite-plus/issues/858) and [#1309](https://github.com/voidzero-dev/vite-plus/issues/1309): users and scripts invoke `corepack`/`pnpm`/`yarn` directly, and without a system Node.js installation there is no reachable `corepack` at all. See [Corepack Shim](#corepack-shim). +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. ## Motivation @@ -23,7 +21,7 @@ This RFC proposes adding a `vp env` command that provides system-wide, IDE-safe A shim-based approach where: - `VP_HOME/bin/` directory is added to PATH (system-level for IDE reliability) -- Shims (`node`, `npm`, `npx`, `corepack`) are symlinks to the `vp` binary (Unix) or trampoline `.exe` files (Windows) +- Shims (`node`, `npm`, `npx`) are symlinks to the `vp` binary (Unix) or trampoline `.exe` files (Windows) - The `vp` CLI itself is also in `VP_HOME/bin/`, so users only need one PATH entry - The binary detects invocation via `argv[0]` and dispatches accordingly - Version resolution and installation leverage existing `vp_js_runtime` infrastructure @@ -184,7 +182,7 @@ vp env uninstall 20.18.0 vp env clean ``` -`vp env clean` removes all locally installed Node.js runtimes except the current resolved version and the configured default version. It also removes all downloaded Vite+ package-manager installs under `~/.vite-plus/package_manager` and runs `corepack cache clean` to clear Corepack-managed package-manager downloads. +`vp env clean` removes all locally installed Node.js runtimes except the current resolved version and the configured default version. It also removes all downloaded Vite+ package-manager installs under `~/.vite-plus/package_manager`. ### Global Package Commands @@ -226,7 +224,6 @@ vp update -g typescript # Update specific package node -v # Uses project-specific version npm install # Uses packageManager npm@ when explicitly configured, otherwise Node-bundled npm npx vitest # Uses packageManager npm@ when explicitly configured, otherwise Node-bundled npx -corepack enable # Uses Node-bundled or vp-managed corepack (see Corepack Shim) ``` Package-manager shims use `packageManager` only when the invoked command matches the configured manager or one of its generated aliases. For example, `packageManager: "npm@11.14.0"` makes the `npm` and `npx` shims run npm 11.14.0, while `packageManager: "pnpm@10.19.0"` does not turn `npm install` into `pnpm install`; `npm` falls back to the npm available through the resolved Node.js runtime. Alias pairs follow the package-manager download layout: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. @@ -242,7 +239,6 @@ argv[0] = "vp" → Normal CLI mode (vp env, vp build, etc.) argv[0] = "node" → Shim mode: resolve version, exec node argv[0] = "npm" → Shim mode: resolve version, exec npm argv[0] = "npx" → Shim mode: resolve version, exec npx -argv[0] = "corepack" → Shim mode: resolve version, exec corepack (managed fallback on Node 25+) ``` ### Architecture Diagram @@ -322,8 +318,7 @@ argv[0] = "corepack" → Shim mode: resolve version, exec corepack (managed fal │ │ ├── vp ────────────────────── Symlink to ../current/bin/vp │ │ │ ├── node ──────────────────────┐ │ │ │ ├── npm ──────────────────────┼──▶ Symlinks to ../current/bin/vp │ -│ │ ├── npx ──────────────────────┤ │ -│ │ └── corepack ──────────────────┘ │ +│ │ └── npx ──────────────────────┘ │ │ ├── current/bin/vp The actual vp CLI binary │ │ ├── js_runtime/node/ Node.js installations │ │ │ ├── 20.18.0/bin/node Installed Node.js versions │ @@ -370,13 +365,11 @@ VP_HOME/ # Default: ~/.vite-plus │ ├── node -> ../current/bin/vp # Symlink to vp binary (Unix) │ ├── npm -> ../current/bin/vp # Symlink to vp binary (Unix) │ ├── npx -> ../current/bin/vp # Symlink to vp binary (Unix) -│ ├── corepack -> ../current/bin/vp # Symlink to vp binary (Unix) │ ├── tsc -> ../current/bin/vp # Symlink for global package (Unix) │ ├── vp.exe # Trampoline forwarding to current\bin\vp.exe (Windows) │ ├── node.exe # Trampoline shim for node (Windows) │ ├── npm.exe # Trampoline shim for npm (Windows) │ ├── npx.exe # Trampoline shim for npx (Windows) -│ ├── corepack.exe # Trampoline shim for corepack (Windows) │ └── tsc.exe # Trampoline shim for global package (Windows) ├── current/ │ └── bin/ @@ -416,16 +409,16 @@ VP_HOME/ # Default: ~/.vite-plus **Key Directories:** -| Directory | Purpose | -| ------------------ | ---------------------------------------------------------------------------- | -| `bin/` | vp symlink and all shims (node, npm, npx, corepack, global package binaries) | -| `current/bin/` | The actual vp CLI binary (bin/ shims point here) | -| `js_runtime/node/` | Installed Node.js versions | -| `packages/` | Installed global packages with metadata | -| `bins/` | Per-binary config files (tracks which package owns each binary) | -| `shared/` | NODE_PATH symlinks for package require() resolution | -| `tmp/` | Staging area for atomic installations | -| `cache/` | Resolution cache | +| Directory | Purpose | +| ------------------ | ------------------------------------------------------------------ | +| `bin/` | vp symlink and all shims (node, npm, npx, global package binaries) | +| `current/bin/` | The actual vp CLI binary (bin/ shims point here) | +| `js_runtime/node/` | Installed Node.js versions | +| `packages/` | Installed global packages with metadata | +| `bins/` | Per-binary config files (tracks which package owns each binary) | +| `shared/` | NODE_PATH symlinks for package require() resolution | +| `tmp/` | Staging area for atomic installations | +| `cache/` | Resolution cache | ### config.json Format @@ -850,7 +843,7 @@ $ vp env doctor Installation ✓ VP_HOME ~/.vite-plus ✓ Bin directory exists - ✓ Shims node, npm, npx, corepack + ✓ Shims node, npm, npx Configuration ✓ Node.js mode managed @@ -928,7 +921,6 @@ Created shims: /Users/user/.vite-plus/bin/node /Users/user/.vite-plus/bin/npm /Users/user/.vite-plus/bin/npx - /Users/user/.vite-plus/bin/corepack Add to your shell profile (~/.zshrc, ~/.bashrc, etc.): @@ -949,7 +941,7 @@ $ vp env doctor Installation ✓ VP_HOME ~/.vite-plus ✓ Bin directory exists - ✓ Shims node, npm, npx, corepack + ✓ Shims node, npm, npx Configuration ✓ Node.js mode managed @@ -960,7 +952,6 @@ PATH ✓ node ~/.vite-plus/bin/node (vp shim) ✓ npm ~/.vite-plus/bin/npm (vp shim) ✓ npx ~/.vite-plus/bin/npx (vp shim) - ✓ corepack ~/.vite-plus/bin/corepack (vp shim) Version Resolution Directory /Users/user/projects/my-app @@ -1031,7 +1022,7 @@ $ vp env doctor Installation ✓ VP_HOME ~/.vite-plus ✗ Bin directory does not exist - ✗ Missing shims node, npm, npx, corepack + ✗ Missing shims node, npm, npx Run 'vp env setup' to create bin directory and shims. Configuration @@ -1054,7 +1045,6 @@ PATH node not found npm not found npx not found - corepack not found Version Resolution Directory /Users/user/projects/my-app @@ -1391,7 +1381,7 @@ $ vp env which eslint # Unknown tool (not core tool, not in any global package) $ vp env which unknown-tool error: tool 'unknown-tool' not found -Not a core tool (node, npm, npx, corepack) or installed global package. +Not a core tool (node, npm, npx) or installed global package. Run 'vp list -g' to see installed packages. # Node.js version not installed @@ -1894,46 +1884,6 @@ When `npm uninstall -g` is detected, the shim uses `spawn_tool()` (like install) On Unix, `exec_tool()` uses `exec()` which replaces the current process — no code runs after. For `npm install -g` and `npm uninstall -g` specifically, we use `spawn_tool()` (spawn + wait) to retain control after npm finishes, enabling the post-install hint and post-uninstall link cleanup. All other npm commands continue to use `exec_tool()` for zero overhead. -## Corepack Shim - -> Added in response to [#858](https://github.com/voidzero-dev/vite-plus/issues/858) and [#1309](https://github.com/voidzero-dev/vite-plus/issues/1309). - -`corepack` is part of the default shim tool list, so `vp env setup` (and the install scripts and `vp upgrade` shim refresh) create a `corepack` shim alongside `node`, `npm`, and `npx`. - -### Motivation - -- Without a system Node.js installation, corepack is unreachable even though Node.js ≤ 24 bundles it: `npm list -g` shows `corepack`, but no shim exists in `~/.vite-plus/bin`, so `corepack enable` fails with "command not found" (#1309). -- Many projects, scripts, and AI agents invoke `pnpm`/`yarn` directly instead of `vp` commands (#858). Corepack provides version-correct package manager executables based on `package.json#packageManager`, covering workflows `vp pm` does not (e.g., `yarn plugin ...`, see [#1539](https://github.com/voidzero-dev/vite-plus/issues/1539)). -- Node.js 25+ no longer bundles corepack, so the shim needs a managed fallback rather than relying on the bundled binary forever. - -### Resolution Order - -When the `corepack` shim is invoked: - -1. **vp-managed global package**: if corepack was installed via `vp install -g corepack`, that installation wins. Explicit user intent takes precedence, and the managed copy provides a consistent corepack version across Node.js versions (same philosophy as `packageManager` winning over the Node-bundled npm). -2. **Node-bundled corepack**: resolve the project Node.js version (same resolution chain as `node`/`npm`/`npx`) and use the `corepack` bundled with that installation (present in Node.js ≤ 24). -3. **Auto-install fallback**: on Node.js 25+ where corepack is not bundled, automatically install corepack as a vp-managed global package and execute it. Unlike an explicit `vp install -g corepack` (which exposes every binary the package declares, including its pnpm/yarn launchers), the auto-install links **only the `corepack` binary**: creating package-manager launchers stays `corepack enable`'s job, and the auto-install can never conflict with vp-managed package managers like an existing `vp install -g pnpm`. The restriction is recorded in the package metadata (`bins_restricted`) so `vp update -g` preserves it; an explicit `vp install -g corepack` resets it, and a shim-triggered reinstall over a previous unrestricted install keeps it unrestricted (it must not silently delete the user's exposed launcher bins). A one-line notice is printed to stderr when the install happens. - -### `corepack enable` / `corepack disable` - -Corepack's `enable` command creates package-manager launchers (`pnpm`, `yarn`, ...) **next to the corepack binary found in `PATH`**. Under the Vite+ shim that would be the per-version Node.js bin directory (`~/.vite-plus/js_runtime/node//bin/`), which is not on `PATH` — `corepack enable` would silently produce unreachable launchers. - -To fix this, the shim intercepts `corepack enable` and `corepack disable` invocations that do not pass an explicit `--install-directory` and injects `--install-directory ~/.vite-plus/bin` (the same spawn+wait pattern used for `npm install -g` interception): - -- `corepack enable` places `pnpm`/`yarn` launchers into `~/.vite-plus/bin`, which is on `PATH`. The launchers run via the shimmed `node`, so they still respect per-project Node.js version resolution, while corepack itself respects `package.json#packageManager`. -- `corepack disable` removes them from the same location. Vite+-owned entries among the corepack-managed launcher names (`npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`) are protected: default shims, `vp install -g` binaries, and `npm install -g` links tracked by a `BinConfig` are snapshotted before corepack runs and restored afterwards if corepack removed or replaced them. Entries that were already absent or not Vite+-owned before the run are left alone. - -### Interplay with `vp install -g corepack` - -- `corepack` is **not** added to `CORE_SHIMS` (the `vp install -g` conflict guard), so `vp install -g corepack` remains allowed — it is the explicit way to control the corepack version and the documented fallback for older Vite+ versions. -- `vp remove -g corepack` removes the package and its `BinConfig`, but keeps the default `corepack` shim in place (resolution falls back to the Node-bundled / auto-install path). -- `npm install -g corepack` installs the package but does not link the binary: the post-install check skips protected shim names (default shims are guarded by `is_protected_shim`) and prints a note pointing at `vp install -g corepack` instead. - -### Out of Scope - -- Default `pnpm`/`yarn` shims that route to `vp pm` equivalents — `vp pm` does not cover all package-manager subcommands yet ([#1539](https://github.com/voidzero-dev/vite-plus/issues/1539)). -- Replacing corepack with built-in Vite+ functionality. The shim is the compatibility bridge "until there is a proper alternative that everyone is happy to use". - ## Exec Command The `vp env exec` command executes a command with a specific Node.js version. It operates in two modes: @@ -2147,13 +2097,11 @@ The `vp env clean` command reclaims space used by managed runtime and package-ma - Preserves the configured global default Node.js version, when one is set. - Removes every other directory under `~/.vite-plus/js_runtime/node/`. - Removes all downloaded package-manager installs under `~/.vite-plus/package_manager/`. -- Runs `corepack cache clean` so Corepack-managed package-manager downloads are removed too. ### Example ```bash $ vp env clean -✓ Cleaned Corepack cache ✓ Removed 2 Node.js runtimes ✓ Removed 4 package manager installs ``` @@ -2206,7 +2154,6 @@ VP_HOME/ │ ├── node -> ../current/bin/vp # Symlink to same binary │ ├── npm -> ../current/bin/vp # Symlink to same binary │ ├── npx -> ../current/bin/vp # Symlink to same binary -│ ├── corepack -> ../current/bin/vp # Symlink to same binary │ └── tsc -> ../current/bin/vp # Symlink for global package └── current/ └── bin/ @@ -2235,7 +2182,6 @@ All shims use relative symlinks: ln -sf ../current/bin/vp ~/.vite-plus/bin/node ln -sf ../current/bin/vp ~/.vite-plus/bin/npm ln -sf ../current/bin/vp ~/.vite-plus/bin/npx -ln -sf ../current/bin/vp ~/.vite-plus/bin/corepack # Global package binaries ln -sf ../current/bin/vp ~/.vite-plus/bin/tsc @@ -2252,7 +2198,6 @@ VP_HOME\ │ ├── node.exe # Trampoline shim (sets VP_SHIM_TOOL=node) │ ├── npm.exe # Trampoline shim (sets VP_SHIM_TOOL=npm) │ ├── npx.exe # Trampoline shim (sets VP_SHIM_TOOL=npx) -│ ├── corepack.exe # Trampoline shim (sets VP_SHIM_TOOL=corepack) │ └── tsc.exe # Trampoline shim for global package └── current\ └── bin\ @@ -2294,7 +2239,7 @@ The Windows installer (`install.ps1`) follows this flow: 1. Download and install `vp.exe` and `vp-shim.exe` to `~/.vite-plus/current/bin/` 2. Create `~/.vite-plus/bin/vp.exe` trampoline (copy of `vp-shim.exe`) -3. Create shim trampolines: `node.exe`, `npm.exe`, `npx.exe`, `corepack.exe` (via `vp env setup`) +3. Create shim trampolines: `node.exe`, `npm.exe`, `npx.exe` (via `vp env setup`) 4. Configure User PATH to include `~/.vite-plus/bin` ## Testing Strategy @@ -2390,14 +2335,6 @@ env-doctor/ 1. NODE_PATH setup for shared package resolution -### Phase 5: Corepack Shim (P1) - -1. Add `corepack` to the default shim tool list created by `vp env setup` (Unix symlink, Windows trampoline) -2. Dispatch: vp-managed global corepack first, then Node-bundled corepack, then auto-install fallback (Node.js 25+) -3. Intercept `corepack enable`/`corepack disable` to default `--install-directory` to `~/.vite-plus/bin`, restoring Vite+-owned shims afterwards -4. Keep `vp install -g corepack` allowed; `vp remove -g corepack` keeps the default shim -5. Update `vp env doctor`, `vp env which`, install scripts, and docs; add snap tests - ## Backward Compatibility This is a new feature with no impact on existing functionality. The `vp` binary continues to work normally when invoked directly. @@ -2416,9 +2353,7 @@ The following decisions have been made: 2. **Windows Shim Strategy**: Trampoline `.exe` files that set `VP_SHIM_TOOL` and spawn `vp.exe` - Avoids "Terminate batch job?" prompt, works in all shells. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md). -3. **Corepack Handling**: Included as a default shim (revisited in [#1309](https://github.com/voidzero-dev/vite-plus/issues/1309), originally excluded). The shim prefers a vp-managed global corepack, falls back to the Node-bundled binary (Node.js ≤ 24), and auto-installs a managed copy on Node.js 25+ where corepack is no longer bundled. See [Corepack Shim](#corepack-shim). - -4. **Cache Persistence**: Persist across upgrades - Better performance, with cache format versioning for compatibility. +3. **Cache Persistence**: Persist across upgrades - Better performance, with cache format versioning for compatibility. ## Conclusion diff --git a/rfcs/trampoline-exe-for-shims.md b/rfcs/trampoline-exe-for-shims.md index c9f7c516a4..b97a47372e 100644 --- a/rfcs/trampoline-exe-for-shims.md +++ b/rfcs/trampoline-exe-for-shims.md @@ -6,7 +6,7 @@ Implemented ## Summary -Replace Windows `.cmd` wrapper scripts with lightweight trampoline `.exe` binaries for all shim tools (`vp`, `node`, `npm`, `npx`, `corepack`, `vpx`, `vpr`, and globally installed package binaries). This eliminates the `Terminate batch job (Y/N)?` prompt that appears when users press Ctrl+C, providing the same clean signal behavior as direct `.exe` invocation. +Replace Windows `.cmd` wrapper scripts with lightweight trampoline `.exe` binaries for all shim tools (`vp`, `node`, `npm`, `npx`, `vpx`, `vpr`, and globally installed package binaries). This eliminates the `Terminate batch job (Y/N)?` prompt that appears when users press Ctrl+C, providing the same clean signal behavior as direct `.exe` invocation. ## Motivation @@ -65,7 +65,6 @@ On Unix, shims are symlinks to the `vp` binary. The binary detects the tool name ├── node → ../current/bin/vp (symlink) ├── npm → ../current/bin/vp (symlink) ├── npx → ../current/bin/vp (symlink) -├── corepack → ../current/bin/vp (symlink) ├── vpx → ../current/bin/vp (symlink) └── vpr → ../current/bin/vp (symlink) ``` @@ -78,7 +77,6 @@ On Unix, shims are symlinks to the `vp` binary. The binary detects the tool name ├── node.exe # Trampoline → sets VP_SHIM_TOOL=node, spawns vp.exe ├── npm.exe # Trampoline → sets VP_SHIM_TOOL=npm, spawns vp.exe ├── npx.exe # Trampoline → sets VP_SHIM_TOOL=npx, spawns vp.exe -├── corepack.exe # Trampoline → sets VP_SHIM_TOOL=corepack, spawns vp.exe ├── vpx.exe # Trampoline → sets VP_SHIM_TOOL=vpx, spawns vp.exe ├── vpr.exe # Trampoline → sets VP_SHIM_TOOL=vpr, spawns vp.exe └── tsc.exe # Trampoline → sets VP_SHIM_TOOL=tsc, spawns vp.exe (package shim) @@ -213,7 +211,7 @@ When `vp env setup --refresh` is invoked through the trampoline (`~/.vite-plus/b During `vp upgrade`, after the `current` link is swapped to the new version, `vp env setup --refresh` is invoked to regenerate all trampoline `.exe` files. This ensures that when the trampoline binary (`vp-shim.exe`) changes between versions, all shims pick up the new version: -1. **Core shims** (`vp.exe`, `node.exe`, `npm.exe`, `npx.exe`, `corepack.exe`, `vpx.exe`, `vpr.exe`) are refreshed by the standard `--refresh` logic. +1. **Core shims** (`vp.exe`, `node.exe`, `npm.exe`, `npx.exe`, `vpx.exe`, `vpr.exe`) are refreshed by the standard `--refresh` logic. 2. **Package shims** (e.g., `tsc.exe`, `eslint.exe`, installed via `vp install -g`) are discovered by scanning `~/.vite-plus/bins/` for `BinConfig` entries with `source: Vp`, and each `.exe` is replaced with the new trampoline. Package shims installed via npm interception (`source: Npm`) use `.cmd` wrappers, not trampoline `.exe` files, and are not affected by this refresh. diff --git a/rfcs/upgrade-command.md b/rfcs/upgrade-command.md index 6894ae3e59..1160a4d621 100644 --- a/rfcs/upgrade-command.md +++ b/rfcs/upgrade-command.md @@ -302,7 +302,7 @@ Key differences on Windows: After the symlink swap (the **point of no return**), post-update operations are treated as non-fatal. Errors are printed to stderr as warnings but do not trigger the outer error handler (which would delete the now-active version directory). -1. **Refresh shims**: Run the equivalent of `vp env setup --refresh` to ensure node/npm/npx/corepack shims point to the new version. This also refreshes trampoline `.exe` files for globally installed package shims (e.g., `tsc.exe`) by scanning `BinConfig` entries. If this fails, the user can run it manually. +1. **Refresh shims**: Run the equivalent of `vp env setup --refresh` to ensure node/npm/npx shims point to the new version. This also refreshes trampoline `.exe` files for globally installed package shims (e.g., `tsc.exe`) by scanning `BinConfig` entries. If this fails, the user can run it manually. 2. **Cleanup old versions**: Remove old version directories, keeping the 3 most recent by **creation time** (matching `install.sh` behavior). The new version and the previous version are always protected from cleanup, even if they fall outside the top 3 (e.g., after a downgrade via `--rollback`). #### Step 7: Running Binary Consideration From 72c1dc5f17c0a5c75aa5f64014c2e690cf6df5aa Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 10 Aug 2026 19:20:42 +0800 Subject: [PATCH 02/22] refactor(pm): centralize managed binary resolution --- crates/vp_global_cli/src/shim/dispatch.rs | 21 +++------------------ crates/vp_pm_cli/src/lib.rs | 4 ++-- crates/vp_pm_cli/src/package_manager.rs | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index dcd5aa6d4c..8974b9bfb3 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -6,9 +6,7 @@ //! 3. Tool execution (core tools and package binaries) use vp_pm_cli::{ - PackageManagerType, download_package_manager, package_manager_bin_path, - package_manager_install_dir, resolve_package_manager_from_package_json, - resolve_package_manager_version, + PackageManagerType, ensure_package_manager_bin, resolve_package_manager_from_package_json, }; use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; @@ -677,24 +675,11 @@ async fn resolve_matching_package_manager_tool( } Some(_) => return Ok(None), None if expected_type == PackageManagerType::Npm => return Ok(None), - None => (resolve_package_manager_version(expected_type, "latest").await?, None), + None => ("latest".into(), None), }; let bin_name = expected_type.bin_name_for_tool(tool); - - // Fast path: if the managed install already exists, skip download_package_manager - // entirely. The slow path stats three files (`bin`, `.cmd`, `.ps1`) on every - // invocation, which adds up on the shim hot path. - if let Some(install_dir) = package_manager_install_dir(expected_type, &version) { - let bin_path = package_manager_bin_path(&install_dir, bin_name); - if bin_path.as_path().exists() { - return Ok(Some(bin_path)); - } - } - - let (install_dir, _, _) = - download_package_manager(expected_type, &version, hash.as_deref()).await?; - Ok(Some(package_manager_bin_path(&install_dir, bin_name))) + Ok(Some(ensure_package_manager_bin(expected_type, &version, hash.as_deref(), bin_name).await?)) } async fn prepend_js_child_process_path_env( diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index 94cb18cd29..9105a7e8ea 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -23,8 +23,8 @@ pub use dispatch::{DispatchResult, dispatch, dispatch_with_metadata}; pub use error::Error; pub use package_manager::{ PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, - PackageManagerType, download_package_manager, get_package_manager_type_and_version, - package_manager_bin_path, package_manager_install_dir, + PackageManagerType, download_package_manager, ensure_package_manager_bin, + get_package_manager_type_and_version, package_manager_bin_path, package_manager_install_dir, 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 ae36460cbc..f12bd5c58a 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -389,6 +389,27 @@ pub fn package_manager_bin_path(install_dir: &AbsolutePath, bin_name: &str) -> A if cfg!(windows) { bin_path.with_extension("cmd") } else { bin_path } } +/// Return a managed package-manager binary, downloading its release when needed. +pub async fn ensure_package_manager_bin( + package_manager_type: PackageManagerType, + version_or_latest: &str, + expected_hash: Option<&str>, + bin_name: &str, +) -> Result { + let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; + + if let Some(install_dir) = package_manager_install_dir(package_manager_type, &version) { + let bin_path = package_manager_bin_path(&install_dir, bin_name); + if bin_path.as_path().exists() { + return Ok(bin_path); + } + } + + let (install_dir, _, _) = + download_package_manager(package_manager_type, &version, expected_hash).await?; + Ok(package_manager_bin_path(&install_dir, bin_name)) +} + fn get_package_manager_from_package_json( workspace_root: &WorkspaceRoot, ) -> Result, Error> { From f2f38236e19dd8116604fa6ff788c0ba450d9132 Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 10 Aug 2026 20:27:52 +0800 Subject: [PATCH 03/22] refactor(pm): simplify latest version cache --- .../src/commands/global/install.rs | 4 +- crates/vp_pm_cli/src/error.rs | 3 - crates/vp_pm_cli/src/package_manager.rs | 104 +++++++++--------- 3 files changed, 52 insertions(+), 59 deletions(-) diff --git a/crates/vp_global_cli/src/commands/global/install.rs b/crates/vp_global_cli/src/commands/global/install.rs index b48c1b8562..c475a21527 100644 --- a/crates/vp_global_cli/src/commands/global/install.rs +++ b/crates/vp_global_cli/src/commands/global/install.rs @@ -107,8 +107,8 @@ pub async fn install( // 1. Parse package specs and skip legacy globals now provided by Vite+. let mut packages = IndexMap::::new(); for package_spec in package_specs { - let (package_name, _version_spec) = match parse_package_spec(package_spec) { - Ok(result) => result, + let package_name = match parse_package_spec(package_spec) { + Ok((package_name, _)) => package_name, Err(error) => return Err((Some(package_spec.clone()), Box::new(error))), }; if package_name == "corepack" { diff --git a/crates/vp_pm_cli/src/error.rs b/crates/vp_pm_cli/src/error.rs index eb8c6eec62..11de599488 100644 --- a/crates/vp_pm_cli/src/error.rs +++ b/crates/vp_pm_cli/src/error.rs @@ -30,7 +30,4 @@ pub enum Error { /// User-facing message printed without the "Error: " prefix. #[error("{0}")] UserMessage(Str), - - #[error("{0}")] - Other(Str), } diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index f12bd5c58a..063c400a31 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -6,7 +6,7 @@ use std::{ fs::{self, File}, io::{self, BufReader, Write}, path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, + time::Duration, }; use crossterm::{ @@ -667,47 +667,43 @@ fn find_extracted_package_dir(target_dir: &Path) -> io::Result { )) } -const LATEST_VERSION_CACHE_TTL_SECS: u64 = 3600; - -#[derive(Deserialize, Serialize)] -struct LatestVersionCache { - version: String, - expires_at: u64, -} +const LATEST_VERSION_CACHE_TTL: Duration = Duration::from_secs(3600); fn latest_version_cache_path( package_manager_type: PackageManagerType, -) -> Result { +) -> io::Result { Ok(vp_shared::get_vp_home()? .join("cache") .join("package_manager_latest") - .join(format!("{package_manager_type}.json"))) + .join(package_manager_type.to_string())) } -fn now_timestamp() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |duration| duration.as_secs()) +fn read_latest_version_cache(path: &AbsolutePath) -> Option<(Str, bool)> { + let version = fs::read_to_string(path).ok()?; + let version = version.trim(); + Version::parse(version).ok()?; + let is_fresh = fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < LATEST_VERSION_CACHE_TTL); + Some((version.into(), is_fresh)) } -async fn load_latest_version_cache(path: &AbsolutePath) -> Option { - let content = tokio::fs::read_to_string(path).await.ok()?; - let cache: LatestVersionCache = serde_json::from_str(&content).ok()?; - Version::parse(&cache.version).ok()?; - Some(cache) +fn write_latest_version_cache(path: &AbsolutePath, version: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, version) } -async fn save_latest_version_cache(path: &AbsolutePath, version: &str) { - let cache = LatestVersionCache { - version: version.to_string(), - expires_at: now_timestamp() + LATEST_VERSION_CACHE_TTL_SECS, - }; - let Ok(content) = serde_json::to_string(&cache) else { return }; - let Some(parent) = path.parent() else { return }; - if tokio::fs::create_dir_all(parent).await.is_ok() { - let _ = tokio::fs::write(path, content).await; +async fn get_latest_version(package_manager_type: PackageManagerType) -> Result { + let cache_path = latest_version_cache_path(package_manager_type)?; + let cached = read_latest_version_cache(&cache_path); + if let Some((version, true)) = &cached { + return Ok(version.clone()); } -} -async fn fetch_latest_version(package_manager_type: PackageManagerType) -> Result { let package_name = if matches!(package_manager_type, PackageManagerType::Yarn) { // yarn latest version should use `@yarnpkg/cli-dist` as package name "@yarnpkg/cli-dist".to_string() @@ -715,31 +711,18 @@ async fn fetch_latest_version(package_manager_type: PackageManagerType) -> Resul package_manager_type.to_string() }; let url = get_npm_package_version_url(&package_name, "latest"); - let package_json: PackageJson = HttpClient::new().get_json(&url).await?; - Ok(package_json.version) -} - -async fn get_latest_version(package_manager_type: PackageManagerType) -> Result { - let cache_path = latest_version_cache_path(package_manager_type)?; - let cached = load_latest_version_cache(&cache_path).await; - if let Some(cache) = &cached - && now_timestamp() < cache.expires_at - { - return Ok(cache.version.clone().into()); - } - - match fetch_latest_version(package_manager_type).await { - Ok(version) => { - save_latest_version_cache(&cache_path, &version).await; - Ok(version) + match HttpClient::new().get_json::(&url).await { + Ok(package_json) => { + let _ = write_latest_version_cache(&cache_path, &package_json.version); + Ok(package_json.version) } Err(error) => { - let Some(cache) = cached else { return Err(error) }; + let Some((version, _)) = cached else { return Err(error) }; tracing::warn!( "Failed to refresh latest {package_manager_type} version: {error}; using cached {}", - cache.version + version ); - Ok(cache.version.into()) + Ok(version) } } } @@ -1792,7 +1775,7 @@ fn simple_text_prompt() -> Result { #[cfg(test)] mod tests { - use std::fs; + use std::{fs, time::UNIX_EPOCH}; use tempfile::{TempDir, tempdir}; use vp_shared::EnvConfig; @@ -1900,7 +1883,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn test_latest_version_cache_refreshes_after_expiry() { + async fn test_latest_version_cache_refreshes_and_falls_back_after_expiry() { use httpmock::prelude::*; let temp_dir = create_temp_dir(); @@ -1927,12 +1910,18 @@ mod tests { first.assert_hits(1); let cache_path = latest_version_cache_path(PackageManagerType::Bun).unwrap(); - let mut cache = load_latest_version_cache(&cache_path).await.unwrap(); - cache.expires_at = 0; - tokio::fs::write(&cache_path, serde_json::to_string(&cache).unwrap()).await.unwrap(); + let expire_cache = || { + fs::File::options() + .write(true) + .open(&cache_path) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(UNIX_EPOCH)) + .unwrap(); + }; + expire_cache(); first.delete(); - let refreshed = server.mock(|when, then| { + let mut refreshed = server.mock(|when, then| { when.method(GET).path("/bun/latest"); then.status(200).json_body(serde_json::json!({ "version": "2.0.0" })); }); @@ -1941,6 +1930,13 @@ mod tests { "2.0.0" ); refreshed.assert_hits(1); + + expire_cache(); + refreshed.delete(); + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "2.0.0" + ); } #[cfg(windows)] From e1a69a5e719723146972d8256068f2d92474c2ee Mon Sep 17 00:00:00 2001 From: Liang Date: Tue, 11 Aug 2026 08:53:19 +0800 Subject: [PATCH 04/22] fix(env): only remove legacy package manager shims --- .../vp_global_cli/src/commands/env/setup.rs | 170 +++++++++--------- 1 file changed, 86 insertions(+), 84 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 41958fc750..d843230b8d 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -15,7 +15,7 @@ //! current\bin\vp.exe with VP_SHIM_TOOL env var set //! - This avoids the "Terminate batch job (Y/N)?" prompt from .cmd wrappers -use std::process::ExitStatus; +use std::{collections::HashSet, process::ExitStatus}; use super::config::{get_bin_dir, get_vp_home}; use crate::{error::Error, help}; @@ -72,7 +72,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result tokio::fs::create_dir_all(&bin_dir).await?; if refresh { - cleanup_legacy_package_manager_installs(&bin_dir).await; + cleanup_legacy_package_manager_shims(&bin_dir).await; } #[cfg(windows)] @@ -153,58 +153,58 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result Ok(ExitStatus::default()) } -/// Remove legacy managed installs left by versions that did not expose package-manager shims. -async fn cleanup_legacy_package_manager_installs(bin_dir: &vt_path::AbsolutePath) { +/// Remove shims from legacy managed package-manager installs. +async fn cleanup_legacy_package_manager_shims(bin_dir: &vt_path::AbsolutePath) { use super::{bin_config::BinConfig, package_metadata::PackageMetadata}; - use crate::commands::global::{LEGACY_PACKAGE_MANAGER_PACKAGES, install::uninstall}; + use crate::commands::global::LEGACY_PACKAGE_MANAGER_PACKAGES; + + let mut bin_names = HashSet::from(["corepack".to_string()]); for package_name in LEGACY_PACKAGE_MANAGER_PACKAGES { - let has_metadata = match PackageMetadata::load(package_name).await { - Ok(metadata) => metadata.is_some(), + match PackageMetadata::load(package_name).await { + Ok(Some(metadata)) => bin_names.extend(metadata.bins), + Ok(None) => {} Err(error) => { vp_shared::output::warn(&format!( "Failed to inspect legacy global package '{package_name}': {error}" )); - continue; } - }; - let has_bin_config = match BinConfig::find_by_package(package_name).await { - Ok(bins) => !bins.is_empty(), + } + match BinConfig::find_by_package(package_name).await { + Ok(bins) => bin_names.extend(bins), Err(error) => { vp_shared::output::warn(&format!( "Failed to inspect legacy shims for '{package_name}': {error}" )); - continue; } - }; + } + } - if (has_metadata || has_bin_config) - && let Err(error) = uninstall(package_name, false).await + for bin_name in bin_names { + #[cfg(unix)] { - vp_shared::output::warn(&format!( - "Failed to remove legacy global package '{package_name}': {error}" - )); + let shim_path = bin_dir.join(&bin_name); + match tokio::fs::remove_file(&shim_path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => vp_shared::output::warn(&format!( + "Failed to remove legacy package-manager shim at {}: {error}", + shim_path.as_path().display() + )), + } } - } - // Corepack is no longer exposed, so remove its old default shim even when no package metadata remains. - #[cfg(unix)] - { - let shim_path = bin_dir.join("corepack"); - match tokio::fs::remove_file(&shim_path).await { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => vp_shared::output::warn(&format!( - "Failed to remove legacy Corepack shim at {}: {error}", - shim_path.as_path().display() - )), + #[cfg(windows)] + { + remove_or_rename_to_old(&bin_dir.join(format!("{bin_name}.exe"))).await; + cleanup_legacy_windows_shim(bin_dir, &bin_name).await; } - } - #[cfg(windows)] - { - remove_or_rename_to_old(&bin_dir.join("corepack.exe")).await; - cleanup_legacy_windows_shim(bin_dir, "corepack").await; + if let Err(error) = BinConfig::delete(&bin_name).await { + vp_shared::output::warn(&format!( + "Failed to remove legacy shim config for '{bin_name}': {error}" + )); + } } } @@ -1378,11 +1378,8 @@ mod tests { #[tokio::test] #[cfg_attr(windows, serial_test::serial)] - async fn test_execute_refresh_removes_legacy_package_manager_installs() { - use crate::commands::{ - env::{bin_config::BinConfig, package_metadata::PackageMetadata}, - global::LEGACY_PACKAGE_MANAGER_PACKAGES, - }; + async fn test_execute_refresh_removes_only_legacy_package_manager_shims() { + use crate::commands::env::{bin_config::BinConfig, package_metadata::PackageMetadata}; let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); @@ -1394,26 +1391,24 @@ mod tests { let _trampoline_guard = FakeTrampolineGuard::new(temp_dir.path()); tokio::fs::create_dir_all(&bin_dir).await.unwrap(); - let mut package_dirs = Vec::new(); - for package_name in LEGACY_PACKAGE_MANAGER_PACKAGES { - let mut metadata = PackageMetadata::new( - package_name.to_string(), - "1.0.0".to_string(), - "22.0.0".to_string(), - None, - vec![package_name.to_string()], - Default::default(), - "npm".to_string(), - ); - metadata.install_id = "123e4567-e89b-42d3-a456-426614174000".to_string(); - metadata.save().await.unwrap(); - let package_dir = metadata.installation_dir().unwrap(); - tokio::fs::create_dir_all(&package_dir).await.unwrap(); - package_dirs.push(package_dir); + let mut metadata = PackageMetadata::new( + "pnpm".to_string(), + "1.0.0".to_string(), + "22.0.0".to_string(), + None, + vec!["pnpm".to_string(), "metadata-bin".to_string()], + Default::default(), + "npm".to_string(), + ); + metadata.install_id = "123e4567-e89b-42d3-a456-426614174000".to_string(); + metadata.save().await.unwrap(); + let package_dir = metadata.installation_dir().unwrap(); + tokio::fs::create_dir_all(&package_dir).await.unwrap(); + for bin_name in ["pnpm", "config-bin"] { BinConfig::new( - package_name.to_string(), - package_name.to_string(), + bin_name.to_string(), + "pnpm".to_string(), "1.0.0".to_string(), "22.0.0".to_string(), ) @@ -1422,38 +1417,45 @@ mod tests { .unwrap(); } - #[cfg(unix)] - tokio::fs::write(bin_dir.join("corepack"), "legacy corepack shim").await.unwrap(); - #[cfg(windows)] - for suffix in [".exe", ".cmd", ".ps1", ""] { - tokio::fs::write(bin_dir.join(format!("corepack{suffix}")), "legacy corepack shim") - .await - .unwrap(); + for bin_name in ["pnpm", "metadata-bin", "config-bin", "corepack"] { + #[cfg(unix)] + tokio::fs::write(bin_dir.join(bin_name), "legacy package-manager shim").await.unwrap(); + #[cfg(windows)] + for suffix in [".exe", ".cmd", ".ps1", ""] { + let content = if suffix.is_empty() { + "#!/bin/sh\nlegacy package-manager shim" + } else { + "legacy package-manager shim" + }; + tokio::fs::write(bin_dir.join(format!("{bin_name}{suffix}")), content) + .await + .unwrap(); + } } let status = execute(true, false).await.unwrap(); assert!(status.success()); - for (package_name, package_dir) in LEGACY_PACKAGE_MANAGER_PACKAGES.iter().zip(package_dirs) - { - assert!(PackageMetadata::load(package_name).await.unwrap().is_none()); - assert!(BinConfig::load(package_name).await.unwrap().is_none()); - assert!(!package_dir.as_path().exists()); - } - for tool in ["pnpm", "yarn", "bun"] { - assert!( - std::fs::symlink_metadata(bin_dir.join(shim_filename(tool)).as_path()).is_ok(), - "{tool} should be recreated as a default shim" - ); - } - let corepack_suffixes: &[&str] = - if cfg!(windows) { &[".exe", ".cmd", ".ps1", ""] } else { &[""] }; - for suffix in corepack_suffixes { - assert!( - std::fs::symlink_metadata(bin_dir.join(format!("corepack{suffix}")).as_path()) + assert!(PackageMetadata::load("pnpm").await.unwrap().is_some()); + assert!(package_dir.as_path().exists()); + assert!(BinConfig::load("pnpm").await.unwrap().is_none()); + assert!(BinConfig::load("config-bin").await.unwrap().is_none()); + assert!( + std::fs::symlink_metadata(bin_dir.join(shim_filename("pnpm")).as_path()).is_ok(), + "pnpm should be recreated as a default shim" + ); + for bin_name in ["metadata-bin", "config-bin", "corepack"] { + let suffixes: &[&str] = + if cfg!(windows) { &[".exe", ".cmd", ".ps1", ""] } else { &[""] }; + for suffix in suffixes { + assert!( + std::fs::symlink_metadata( + bin_dir.join(format!("{bin_name}{suffix}")).as_path() + ) .is_err(), - "legacy corepack shim should be removed" - ); + "legacy shim {bin_name}{suffix} should be removed" + ); + } } } From e15fc19ca8029ce7150033af5fa6b26ac6a41192 Mon Sep 17 00:00:00 2001 From: Liang Date: Tue, 11 Aug 2026 09:18:40 +0800 Subject: [PATCH 05/22] Revert "fix(env): only remove legacy package manager shims" This reverts commit fb6f8d94a8490e52d34133e62782448ad83690ad. --- .../vp_global_cli/src/commands/env/setup.rs | 170 +++++++++--------- 1 file changed, 84 insertions(+), 86 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index d843230b8d..41958fc750 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -15,7 +15,7 @@ //! current\bin\vp.exe with VP_SHIM_TOOL env var set //! - This avoids the "Terminate batch job (Y/N)?" prompt from .cmd wrappers -use std::{collections::HashSet, process::ExitStatus}; +use std::process::ExitStatus; use super::config::{get_bin_dir, get_vp_home}; use crate::{error::Error, help}; @@ -72,7 +72,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result tokio::fs::create_dir_all(&bin_dir).await?; if refresh { - cleanup_legacy_package_manager_shims(&bin_dir).await; + cleanup_legacy_package_manager_installs(&bin_dir).await; } #[cfg(windows)] @@ -153,58 +153,58 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result Ok(ExitStatus::default()) } -/// Remove shims from legacy managed package-manager installs. -async fn cleanup_legacy_package_manager_shims(bin_dir: &vt_path::AbsolutePath) { +/// Remove legacy managed installs left by versions that did not expose package-manager shims. +async fn cleanup_legacy_package_manager_installs(bin_dir: &vt_path::AbsolutePath) { use super::{bin_config::BinConfig, package_metadata::PackageMetadata}; - use crate::commands::global::LEGACY_PACKAGE_MANAGER_PACKAGES; - - let mut bin_names = HashSet::from(["corepack".to_string()]); + use crate::commands::global::{LEGACY_PACKAGE_MANAGER_PACKAGES, install::uninstall}; for package_name in LEGACY_PACKAGE_MANAGER_PACKAGES { - match PackageMetadata::load(package_name).await { - Ok(Some(metadata)) => bin_names.extend(metadata.bins), - Ok(None) => {} + let has_metadata = match PackageMetadata::load(package_name).await { + Ok(metadata) => metadata.is_some(), Err(error) => { vp_shared::output::warn(&format!( "Failed to inspect legacy global package '{package_name}': {error}" )); + continue; } - } - match BinConfig::find_by_package(package_name).await { - Ok(bins) => bin_names.extend(bins), + }; + let has_bin_config = match BinConfig::find_by_package(package_name).await { + Ok(bins) => !bins.is_empty(), Err(error) => { vp_shared::output::warn(&format!( "Failed to inspect legacy shims for '{package_name}': {error}" )); + continue; } - } - } + }; - for bin_name in bin_names { - #[cfg(unix)] + if (has_metadata || has_bin_config) + && let Err(error) = uninstall(package_name, false).await { - let shim_path = bin_dir.join(&bin_name); - match tokio::fs::remove_file(&shim_path).await { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => vp_shared::output::warn(&format!( - "Failed to remove legacy package-manager shim at {}: {error}", - shim_path.as_path().display() - )), - } + vp_shared::output::warn(&format!( + "Failed to remove legacy global package '{package_name}': {error}" + )); } + } - #[cfg(windows)] - { - remove_or_rename_to_old(&bin_dir.join(format!("{bin_name}.exe"))).await; - cleanup_legacy_windows_shim(bin_dir, &bin_name).await; + // Corepack is no longer exposed, so remove its old default shim even when no package metadata remains. + #[cfg(unix)] + { + let shim_path = bin_dir.join("corepack"); + match tokio::fs::remove_file(&shim_path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => vp_shared::output::warn(&format!( + "Failed to remove legacy Corepack shim at {}: {error}", + shim_path.as_path().display() + )), } + } - if let Err(error) = BinConfig::delete(&bin_name).await { - vp_shared::output::warn(&format!( - "Failed to remove legacy shim config for '{bin_name}': {error}" - )); - } + #[cfg(windows)] + { + remove_or_rename_to_old(&bin_dir.join("corepack.exe")).await; + cleanup_legacy_windows_shim(bin_dir, "corepack").await; } } @@ -1378,8 +1378,11 @@ mod tests { #[tokio::test] #[cfg_attr(windows, serial_test::serial)] - async fn test_execute_refresh_removes_only_legacy_package_manager_shims() { - use crate::commands::env::{bin_config::BinConfig, package_metadata::PackageMetadata}; + async fn test_execute_refresh_removes_legacy_package_manager_installs() { + use crate::commands::{ + env::{bin_config::BinConfig, package_metadata::PackageMetadata}, + global::LEGACY_PACKAGE_MANAGER_PACKAGES, + }; let temp_dir = TempDir::new().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); @@ -1391,24 +1394,26 @@ mod tests { let _trampoline_guard = FakeTrampolineGuard::new(temp_dir.path()); tokio::fs::create_dir_all(&bin_dir).await.unwrap(); - let mut metadata = PackageMetadata::new( - "pnpm".to_string(), - "1.0.0".to_string(), - "22.0.0".to_string(), - None, - vec!["pnpm".to_string(), "metadata-bin".to_string()], - Default::default(), - "npm".to_string(), - ); - metadata.install_id = "123e4567-e89b-42d3-a456-426614174000".to_string(); - metadata.save().await.unwrap(); - let package_dir = metadata.installation_dir().unwrap(); - tokio::fs::create_dir_all(&package_dir).await.unwrap(); + let mut package_dirs = Vec::new(); + for package_name in LEGACY_PACKAGE_MANAGER_PACKAGES { + let mut metadata = PackageMetadata::new( + package_name.to_string(), + "1.0.0".to_string(), + "22.0.0".to_string(), + None, + vec![package_name.to_string()], + Default::default(), + "npm".to_string(), + ); + metadata.install_id = "123e4567-e89b-42d3-a456-426614174000".to_string(); + metadata.save().await.unwrap(); + let package_dir = metadata.installation_dir().unwrap(); + tokio::fs::create_dir_all(&package_dir).await.unwrap(); + package_dirs.push(package_dir); - for bin_name in ["pnpm", "config-bin"] { BinConfig::new( - bin_name.to_string(), - "pnpm".to_string(), + package_name.to_string(), + package_name.to_string(), "1.0.0".to_string(), "22.0.0".to_string(), ) @@ -1417,45 +1422,38 @@ mod tests { .unwrap(); } - for bin_name in ["pnpm", "metadata-bin", "config-bin", "corepack"] { - #[cfg(unix)] - tokio::fs::write(bin_dir.join(bin_name), "legacy package-manager shim").await.unwrap(); - #[cfg(windows)] - for suffix in [".exe", ".cmd", ".ps1", ""] { - let content = if suffix.is_empty() { - "#!/bin/sh\nlegacy package-manager shim" - } else { - "legacy package-manager shim" - }; - tokio::fs::write(bin_dir.join(format!("{bin_name}{suffix}")), content) - .await - .unwrap(); - } + #[cfg(unix)] + tokio::fs::write(bin_dir.join("corepack"), "legacy corepack shim").await.unwrap(); + #[cfg(windows)] + for suffix in [".exe", ".cmd", ".ps1", ""] { + tokio::fs::write(bin_dir.join(format!("corepack{suffix}")), "legacy corepack shim") + .await + .unwrap(); } let status = execute(true, false).await.unwrap(); assert!(status.success()); - assert!(PackageMetadata::load("pnpm").await.unwrap().is_some()); - assert!(package_dir.as_path().exists()); - assert!(BinConfig::load("pnpm").await.unwrap().is_none()); - assert!(BinConfig::load("config-bin").await.unwrap().is_none()); - assert!( - std::fs::symlink_metadata(bin_dir.join(shim_filename("pnpm")).as_path()).is_ok(), - "pnpm should be recreated as a default shim" - ); - for bin_name in ["metadata-bin", "config-bin", "corepack"] { - let suffixes: &[&str] = - if cfg!(windows) { &[".exe", ".cmd", ".ps1", ""] } else { &[""] }; - for suffix in suffixes { - assert!( - std::fs::symlink_metadata( - bin_dir.join(format!("{bin_name}{suffix}")).as_path() - ) + for (package_name, package_dir) in LEGACY_PACKAGE_MANAGER_PACKAGES.iter().zip(package_dirs) + { + assert!(PackageMetadata::load(package_name).await.unwrap().is_none()); + assert!(BinConfig::load(package_name).await.unwrap().is_none()); + assert!(!package_dir.as_path().exists()); + } + for tool in ["pnpm", "yarn", "bun"] { + assert!( + std::fs::symlink_metadata(bin_dir.join(shim_filename(tool)).as_path()).is_ok(), + "{tool} should be recreated as a default shim" + ); + } + let corepack_suffixes: &[&str] = + if cfg!(windows) { &[".exe", ".cmd", ".ps1", ""] } else { &[""] }; + for suffix in corepack_suffixes { + assert!( + std::fs::symlink_metadata(bin_dir.join(format!("corepack{suffix}")).as_path()) .is_err(), - "legacy shim {bin_name}{suffix} should be removed" - ); - } + "legacy corepack shim should be removed" + ); } } From 68e61692f40cfa2bc84e29296a0b0e4dec44abc3 Mon Sep 17 00:00:00 2001 From: Liang Date: Tue, 11 Aug 2026 10:09:53 +0800 Subject: [PATCH 06/22] wip --- .../package.json | 5 + .../snapshots.toml | 7 + .../shim_mismatched_package_managers.md | 11 ++ .../vp_global_cli/src/commands/global/mod.rs | 3 +- crates/vp_global_cli/src/shim/dispatch.rs | 149 ++++-------------- crates/vp_global_cli/src/shim/mod.rs | 31 +--- 6 files changed, 61 insertions(+), 145 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/package.json new file mode 100644 index 0000000000..d4fc085cea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/package.json @@ -0,0 +1,5 @@ +{ + "name": "shim-mismatched-package-managers", + "private": true, + "packageManager": "pnpm@11.20.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml new file mode 100644 index 0000000000..88dbf0842e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml @@ -0,0 +1,7 @@ +[[case]] +name = "shim_mismatched_package_managers" +vp = "global" +steps = [ + { argv = ["yarn", "--version"], comment = "Yarn resolves independently of the project's pnpm declaration", snapshot = false }, + { argv = ["bun", "--version"], comment = "Bun resolves independently of the project's pnpm declaration", snapshot = false }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md new file mode 100644 index 0000000000..1dd8bb03d1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md @@ -0,0 +1,11 @@ +# shim_mismatched_package_managers + +## `yarn --version` + +Yarn resolves independently of the project's pnpm declaration + + +## `bun --version` + +Bun resolves independently of the project's pnpm declaration + diff --git a/crates/vp_global_cli/src/commands/global/mod.rs b/crates/vp_global_cli/src/commands/global/mod.rs index 457edb790c..c5e5a0de80 100644 --- a/crates/vp_global_cli/src/commands/global/mod.rs +++ b/crates/vp_global_cli/src/commands/global/mod.rs @@ -17,7 +17,8 @@ pub mod outdated; pub mod packages; /// Core shims that should not be overwritten by package binaries. -pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"]; +pub(crate) const CORE_SHIMS: &[&str] = + &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vp"]; /// Legacy managed globals superseded by the default package-manager shims. pub(crate) const LEGACY_PACKAGE_MANAGER_PACKAGES: &[&str] = &["yarn", "pnpm", "bun", "corepack"]; diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 8974b9bfb3..49d878ec0f 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -3,7 +3,7 @@ //! This module handles the core shim functionality: //! 1. Version resolution (with caching) //! 2. Node.js installation (if needed) -//! 3. Tool execution (core tools and package binaries) +//! 3. Tool execution (core shims and package binaries) use vp_pm_cli::{ PackageManagerType, ensure_package_manager_bin, resolve_package_manager_from_package_json, @@ -33,16 +33,6 @@ use crate::{ /// directly using the current PATH (passthrough mode). const RECURSION_ENV_VAR: &str = env_vars::VP_TOOL_RECURSION; -/// Package-manager tools whose Node.js runtime should be resolved from the -/// project context rather than the install-time version. -/// -/// Intentionally excludes `npm`/`npx`: those are core shims (see -/// `is_core_shim_tool`) and never reach `dispatch_package_binary`, so they are -/// handled by the main `dispatch` path instead. -fn is_package_manager_tool(tool: &str) -> bool { - matches!(PackageManagerType::from_tool(tool), Some(t) if t != PackageManagerType::Npm) -} - /// Parsed npm global command (install or uninstall). struct NpmGlobalCommand { /// Package names/specs extracted from args (e.g., ["codex", "typescript@5"]) @@ -657,11 +647,12 @@ fn resolve_npm_prefix( get_npm_global_prefix(npm_path, node_dir) } -/// Resolve a package-manager binary for the invoked shim. +/// Resolve the package-manager binary for a core shim. /// -/// Project declarations only apply to a matching manager. Without one, named -/// package-manager shims use their latest release, while npm stays bundled with Node.js. -async fn resolve_matching_package_manager_tool( +/// A project pin applies only to its matching manager. npm otherwise comes +/// from the selected Node.js installation; standalone managers use their +/// latest release when no matching pin exists. +async fn resolve_package_manager_tool( cwd: &AbsolutePath, tool: &str, ) -> Result, Error> { @@ -673,9 +664,8 @@ async fn resolve_matching_package_manager_tool( Some(resolution) if resolution.package_manager_type == expected_type => { (resolution.version, resolution.hash) } - Some(_) => return Ok(None), - None if expected_type == PackageManagerType::Npm => return Ok(None), - None => ("latest".into(), None), + Some(_) | None if expected_type == PackageManagerType::Npm => return Ok(None), + Some(_) | None => ("latest".into(), None), }; let bin_name = expected_type.bin_name_for_tool(tool); @@ -688,7 +678,7 @@ async fn prepend_js_child_process_path_env( ) -> Result<(), Error> { let _ = prepend_to_path_env(node_bin_dir, PrependOptions::default()); - let Some(npm_path) = resolve_matching_package_manager_tool(cwd, "npm").await? else { + let Some(npm_path) = resolve_package_manager_tool(cwd, "npm").await? else { return Ok(()); }; if let Some(pm_bin_dir) = npm_path.parent() @@ -701,8 +691,8 @@ async fn prepend_js_child_process_path_env( /// Main shim dispatch entry point. /// -/// Called when the binary is invoked as node, npm, npx, or a -/// package binary. Returns an exit code to be used with std::process::exit. +/// Called when the binary is invoked as a core shim or package binary. +/// Returns an exit code to be used with std::process::exit. pub async fn dispatch(tool: &str, args: &[String]) -> i32 { tracing::debug!("dispatch: tool: {tool}, args: {:?}", args); @@ -731,7 +721,7 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } // Check recursion prevention - if already in a shim context, passthrough directly - // Only applies to core tools (node/npm/npx) whose bin dir is prepended to PATH. + // Only applies to core tools whose bin dir is prepended to PATH. // Package binaries are always resolved via metadata lookup, so they can't loop. if std::env::var(RECURSION_ENV_VAR).is_ok() && is_core_shim_tool(tool) { tracing::debug!("recursion prevention enabled for core tool"); @@ -772,7 +762,8 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Fall through to managed if system not found } - // Check if this is a package binary (not node/npm/npx) + // Package binaries use their install-time Node.js version; core shims use + // the project-resolved runtime below. if !is_core_shim_tool(tool) { return dispatch_package_binary(tool, args).await; } @@ -807,25 +798,21 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } }; - // Locate tool binary. If the current project explicitly pins the invoked - // package manager in `packageManager`, prefer that managed package-manager - // binary over the tool bundled with Node.js. - let package_manager_tool_path = match resolve_matching_package_manager_tool(&cwd, tool).await { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); - return 1; - } - }; - let tool_path = match package_manager_tool_path { - Some(path) => path, - None => match locate_tool(&resolution.version, tool) { - Ok(p) => p, + // Package managers use a matching project pin or their family-specific + // fallback. Node and bundled npm tools come from the selected Node.js runtime. + let tool_path = match resolve_package_manager_tool(&cwd, tool).await { + Ok(Some(path)) => path, + Ok(None) => match locate_tool(&resolution.version, tool) { + Ok(path) => path, Err(e) => { eprintln!("vp: Tool '{tool}' not found: {e}"); return 1; } }, + Err(e) => { + eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); + return 1; + } }; // Save original PATH before we modify it - needed for npm global install check. @@ -833,9 +820,8 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { let original_path = if tool == "npm" { std::env::var_os("PATH") } else { None }; // Prepare environment for recursive invocations. Keep the project Node.js - // bin dir available for JS package-manager shims, and when a package-manager - // version was selected from `packageManager`, put that PM bin dir first so - // nested invocations see the same PM version while recursion prevention is set. + // bin dir available for JS package-manager shims, and put a separately + // installed PM bin dir first so nested invocations see the same PM version. let node_bin_dir = node_path.parent().expect("Node has no parent directory"); if let Err(e) = prepend_js_child_process_path_env(&cwd, node_bin_dir).await { eprintln!("vp: Failed to resolve package manager for child process PATH: {e}"); @@ -913,62 +899,6 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { /// Finds the package that provides this binary and executes it with the /// Node.js version that was used to install the package. async fn dispatch_package_binary(tool: &str, args: &[String]) -> i32 { - if PackageManagerType::from_tool(tool).is_some() { - let cwd = match current_dir() { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Failed to get current directory: {e}"); - return 1; - } - }; - - match resolve_matching_package_manager_tool(&cwd, tool).await { - Ok(Some(tool_path)) => { - let node_version = match resolve_with_cache(&cwd).await { - Ok(resolution) => resolution.version, - Err(e) => { - eprintln!("vp: Failed to resolve Node version: {e}"); - eprintln!("vp: Run 'vp env doctor' for diagnostics"); - return 1; - } - }; - - match ensure_installed(&node_version).await { - Ok(node_path) => { - if let Some(node_bin_dir) = node_path.parent() - && let Err(e) = - prepend_js_child_process_path_env(&cwd, node_bin_dir).await - { - eprintln!( - "vp: Failed to resolve package manager for child process PATH: {e}" - ); - return 1; - } - } - Err(e) => { - eprintln!("vp: Failed to install Node {}: {e}", node_version); - return 1; - } - } - - if let Some(pm_bin_dir) = tool_path.parent() { - let _ = prepend_to_path_env(pm_bin_dir, PrependOptions::default()); - } - - // SAFETY: Setting env vars at this point before exec is safe - unsafe { - std::env::set_var(RECURSION_ENV_VAR, "1"); - } - return exec::exec_tool(&tool_path, args); - } - Ok(None) => {} - Err(e) => { - eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); - return 1; - } - } - } - // Find which package provides this binary let package_metadata = match find_package_for_binary(tool).await { Ok(Some(metadata)) => metadata, @@ -983,31 +913,10 @@ async fn dispatch_package_binary(tool: &str, args: &[String]) -> i32 { } }; - // Determine Node.js version to use: - // - Package managers (pnpm, yarn): resolve from project context so they respect - // the project's engines.node / .node-version, falling back to install-time version - // - Other package binaries: use the install-time version (original behavior) - let node_version = if is_package_manager_tool(tool) { - let cwd = match current_dir() { - Ok(path) => path, - Err(e) => { - eprintln!("vp: Failed to get current directory: {e}"); - return 1; - } - }; - match resolve_with_cache(&cwd).await { - Ok(resolution) => resolution.version, - Err(_) => { - // Fall back to install-time version if project resolution fails - package_metadata.platform.node.clone() - } - } - } else { - package_metadata.platform.node.clone() - }; - let (program, mut full_args) = - match package_binary_invocation(&package_metadata, tool, &node_version).await { + match package_binary_invocation(&package_metadata, tool, &package_metadata.platform.node) + .await + { Ok(invocation) => invocation, Err(e) => { eprintln!("vp: {e}"); diff --git a/crates/vp_global_cli/src/shim/mod.rs b/crates/vp_global_cli/src/shim/mod.rs index 891595c8a0..10b58551be 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -1,8 +1,7 @@ -//! Shim module for intercepting node, npm, npx, and package binary commands. +//! Shim module for intercepting Node.js, package-manager, and package binary commands. //! //! This module provides the functionality for the vp binary to act as a shim -//! when invoked as `node`, `npm`, `npx`, or any globally installed -//! package binary. +//! when invoked as a managed tool or any globally installed package binary. //! //! Detection methods: //! - Unix: Symlinks to vp binary preserve argv[0], allowing tool detection @@ -22,8 +21,9 @@ use vp_shared::env_vars; use crate::commands::env::config::get_bin_dir; -/// Core shim tools (node, npm, npx). -pub const CORE_SHIM_TOOLS: &[&str] = &["node", "npm", "npx"]; +/// Core shim tools managed directly by the main dispatch path. +pub const CORE_SHIM_TOOLS: &[&str] = + &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx"]; /// Extract the tool name from argv[0]. /// We hope all bins should be put under $VP_HOME/bin @@ -64,7 +64,7 @@ pub fn extract_tool_name(argv0: &str) -> String { } } -/// Check if the given tool name is a core shim tool (node/npm/npx). +/// Check if the given tool name is managed directly by the core shim path. #[must_use] pub fn is_core_shim_tool(tool: &str) -> bool { CORE_SHIM_TOOLS.contains(&tool) @@ -73,7 +73,7 @@ pub fn is_core_shim_tool(tool: &str) -> bool { /// Check if the given tool name is a shim tool (core or package binary). /// /// This is a quick check that returns true if: -/// 1. The tool is a core shim (node/npm/npx), OR +/// 1. The tool is a core shim, OR /// 2. The tool name is not "vp" (package binaries are detected later via metadata) #[must_use] pub fn is_shim_tool(tool: &str) -> bool { @@ -211,23 +211,6 @@ mod tests { } } - #[test] - fn test_is_shim_tool() { - // Core shim tools are always recognized - assert!(is_core_shim_tool("node")); - assert!(is_core_shim_tool("npm")); - assert!(is_core_shim_tool("npx")); - assert!(!is_core_shim_tool("yarn")); // yarn is not a core shim tool - assert!(!is_core_shim_tool("vp")); - assert!(!is_core_shim_tool("cargo")); - assert!(!is_core_shim_tool("tsc")); // Package binary, not core - // is_shim_tool includes core tools - assert!(is_shim_tool("node")); - assert!(is_shim_tool("npm")); - assert!(is_shim_tool("npx")); - assert!(!is_shim_tool("vp")); // vp is never a shim - } - /// Test that is_potential_package_binary checks the configured bin directory. /// /// The function now checks if a shim exists in the configured bin directory From 525ef15b8aab0b6d317213c278c13dd51da26d3a Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 19:44:41 +0800 Subject: [PATCH 07/22] feat(env)!: manage package manager environments --- crates/vp_global_cli/src/cli.rs | 174 +++-- crates/vp_global_cli/src/command_picker.rs | 2 +- .../vp_global_cli/src/commands/env/clean.rs | 131 ++-- .../vp_global_cli/src/commands/env/config.rs | 92 +++ .../vp_global_cli/src/commands/env/current.rs | 289 +++++--- .../vp_global_cli/src/commands/env/default.rs | 175 ++--- .../vp_global_cli/src/commands/env/doctor.rs | 126 +++- crates/vp_global_cli/src/commands/env/exec.rs | 135 ++-- .../src/commands/env/lifecycle.rs | 131 ++++ crates/vp_global_cli/src/commands/env/list.rs | 260 ++++--- .../src/commands/env/list_remote.rs | 668 ++++++++---------- crates/vp_global_cli/src/commands/env/mod.rs | 195 ++--- crates/vp_global_cli/src/commands/env/off.rs | 38 +- crates/vp_global_cli/src/commands/env/on.rs | 36 +- .../src/commands/env/package_manager.rs | 57 ++ crates/vp_global_cli/src/commands/env/pin.rs | 382 +++++++++- crates/vp_global_cli/src/commands/env/spec.rs | 121 ++++ .../vp_global_cli/src/commands/env/unpin.rs | 9 +- crates/vp_global_cli/src/commands/env/use.rs | 243 +++++-- .../vp_global_cli/src/commands/env/which.rs | 47 +- crates/vp_global_cli/src/commands/mod.rs | 24 + crates/vp_global_cli/src/help.rs | 59 +- crates/vp_global_cli/src/shim/dispatch.rs | 49 +- crates/vp_pm_cli/src/dispatch.rs | 67 ++ crates/vp_pm_cli/src/lib.rs | 13 +- crates/vp_pm_cli/src/package_manager.rs | 204 +++++- crates/vp_shared/src/env_config.rs | 7 + crates/vp_shared/src/env_vars.rs | 3 + packages/cli/install.ps1 | 4 +- packages/cli/install.sh | 4 +- 30 files changed, 2610 insertions(+), 1135 deletions(-) create mode 100644 crates/vp_global_cli/src/commands/env/lifecycle.rs create mode 100644 crates/vp_global_cli/src/commands/env/package_manager.rs create mode 100644 crates/vp_global_cli/src/commands/env/spec.rs diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 60b7c50632..8dfb4e322e 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -217,7 +217,7 @@ pub enum Commands { global: bool, }, - /// Manage Node.js versions + /// Manage Node.js and package-manager environments Env(EnvArgs), // ========================================================================= @@ -291,30 +291,46 @@ pub struct EnvArgs { pub enum EnvSubcommands { /// Show current environment information Current { + /// Limit output to node, pm, or a package-manager family + scope: Option, + /// Output in JSON format #[arg(long)] json: bool, }, /// Print shell snippet to set environment for current session - Print, + Print { + /// Limit output to node, pm, or a package-manager family + scope: Option, + }, - /// Set or show the global default Node.js version + /// Set or show global Node.js and package-manager defaults #[command(after_long_help = "\ Examples: - vp env default # Show the current default - vp env default lts # Set the default")] + vp env default # Show both current defaults + vp env default lts # Set the Node.js default + vp env default pnpm@10 # Set the package-manager default")] Default { - /// Version to set as default (e.g., "20.18.0", "lts", "latest"). - /// If omitted, prints the current default. - version: Option, + /// Defaults or component selectors. Bare versions select Node.js. + values: Vec, + + /// Clear defaults instead of setting them + #[arg(long)] + unset: bool, }, - /// Enable managed mode - shims always use vite-plus managed Node.js - On, + /// Enable managed mode for Node.js and package managers + On { + /// Change only node or package-manager mode + scope: Option, + }, - /// Enable system-first mode - shims prefer system Node.js, fallback to managed - Off, + /// Enable system-first mode for Node.js and package managers + Off { + /// Change only node or package-manager mode + scope: Option, + }, /// Create or update shims in VP_HOME/bin Setup { @@ -327,20 +343,24 @@ Examples: }, /// Run diagnostics and show environment status - Doctor, + Doctor { + /// Limit diagnostics to node or package managers + scope: Option, + }, /// Show path to the tool that would be executed Which { - /// Tool name (node, npm, or npx) + /// Tool name resolved through the environment shims tool: String, }, - /// Pin a Node.js version in the current directory - /// (updates .node-version or package.json#devEngines.runtime) + /// Pin Node.js and package-manager versions in the current directory #[command(after_long_help = "\ Examples: - vp env pin lts # Pin to latest LTS - vp env pin --unpin # Remove the pin + vp env pin lts # Pin Node.js to latest LTS + vp env pin pnpm@10 # Pin the package manager + vp env pin 22 pnpm@10 # Pin both components + vp env pin --unpin # Remove both effective pins vp env pin \"^20.0.0\" --force # Overwrite existing pin vp env pin 24 --target node-version # Force the .node-version file @@ -348,9 +368,8 @@ The write target follows the compatibility-first rule: an existing .node-version keeps being updated; otherwise the pin is written to package.json#devEngines.runtime; .node-version is only created when the directory has no package.json.")] Pin { - /// Version to pin (e.g., "20.18.0", "lts", "latest", "^20.0.0"). - /// If omitted, prints the currently pinned version. - version: Option, + /// Versions to pin. Bare versions select Node.js; package managers use name@version. + specs: Vec, /// Remove the pin from the current directory #[arg(long)] @@ -369,26 +388,32 @@ keeps being updated; otherwise the pin is written to package.json#devEngines.run target: Option, }, - /// Remove the Node.js pin from current directory (alias for `pin --unpin`) + /// Remove environment pins from the current directory (alias for `pin --unpin`) Unpin { + /// Limit removal to node, pm, or a package-manager family + scope: Option, + /// Explicitly choose which pin source to remove #[arg(long, value_enum)] target: Option, }, - /// List locally installed Node.js versions + /// List locally installed Node.js and package-manager versions #[command(visible_alias = "ls")] List { + /// Limit output to node, pm, or a package-manager family + scope: Option, + /// Output as JSON #[arg(long)] json: bool, }, - /// List available Node.js versions from the registry + /// List available Node.js and package-manager versions from registries #[command(name = "list-remote", visible_alias = "ls-remote")] ListRemote { - /// Filter versions by pattern (e.g., "20" for 20.x versions) - pattern: Option, + /// Optional component selector followed by a version pattern + values: Vec, /// Show only LTS versions #[arg(long)] @@ -407,13 +432,14 @@ keeps being updated; otherwise the pin is written to package.json#devEngines.run sort: SortingMethod, }, - /// Execute a command with a specific Node.js version + /// Execute a command in a resolved or explicit environment #[command( visible_alias = "run", after_long_help = "\ Examples: - vp env exec --node lts npm install # Pin version for this invocation - vp env exec node -v # Shim mode: version auto-resolved" + vp env exec --node lts node -v # Override Node.js + vp env exec --package-manager pnpm@10 pnpm install # Override the package manager + vp env exec node -v # Resolve the full environment" )] Exec { /// Node.js version to use (e.g., "20.18.0", "lts", "^20.0.0"). @@ -422,43 +448,49 @@ Examples: #[arg(long)] node: Option, - /// npm version to use (optional, defaults to bundled) + /// npm version to use (alias for --package-manager npm@) #[arg(long)] npm: Option, + /// Package manager and version to use (for example, pnpm@10) + #[arg(long)] + package_manager: Option, + /// Command and arguments to run #[arg(trailing_var_arg = true, allow_hyphen_values = true)] command: Vec, }, - /// Uninstall a Node.js version + /// Uninstall explicit Node.js or package-manager versions #[command(visible_alias = "uni")] Uninstall { - /// Version to uninstall (e.g., "20.18.0") + /// Versions to uninstall. Bare versions select Node.js. #[arg(required = true)] - version: String, + specs: Vec, }, /// Remove unused managed runtimes and package manager caches - Clean, + Clean { + /// Limit cleanup to node, pm, or a package-manager family + scope: Option, + }, - /// Install a Node.js version + /// Install a resolved or explicit environment #[command(visible_alias = "i")] Install { - /// Version to install (e.g., "20", "20.18.0", "lts", "latest") - /// If not provided, installs the version from .node-version, package.json, or .nvmrc - version: Option, + /// Component selectors or explicit versions to install + requests: Vec, }, - /// Use a specific Node.js version for this shell session + /// Activate Node.js and package-manager versions for this shell session #[command(after_long_help = "\ Examples: - vp env use lts # Override session with latest LTS - vp env use --unset # Clear the session override")] + vp env use lts pnpm@10 # Override both session components + vp env use --unset # Clear both session overrides + vp env use --unset pm # Clear only the package-manager override")] Use { - /// Version to use (e.g., "20", "20.18.0", "lts", "latest"). - /// If omitted, reads from .node-version, package.json, or .nvmrc. - version: Option, + /// Component selectors or explicit versions to activate + requests: Vec, /// Remove session override (revert to file-based resolution) #[arg(long)] @@ -477,7 +509,9 @@ Examples: impl EnvSubcommands { fn is_quiet_or_machine_readable(&self) -> bool { match self { - Self::Current { json } | Self::List { json } | Self::ListRemote { json, .. } => *json, + Self::Current { json, .. } + | Self::List { json, .. } + | Self::ListRemote { json, .. } => *json, _ => false, } } @@ -490,6 +524,8 @@ pub enum PinTarget { NodeVersion, /// Pin via package.json#devEngines.runtime DevEngines, + /// Pin via the top-level packageManager field + PackageManager, } /// Version sorting order for list-remote command @@ -635,7 +671,38 @@ async fn run_package_manager_command( commands::prepend_js_runtime_to_path_env(&cwd).await?; let hint_command = command.clone(); - let result = vp_pm_cli::dispatch_with_metadata(&cwd, command).await?; + let selected = commands::env::package_manager::resolve_current(&cwd).await?; + let result = if let Some(selected) = selected.as_ref() + && commands::env::config::load_config().await?.package_manager_shim_mode() + == commands::env::config::ShimMode::SystemFirst + && let Some(system_path) = + crate::shim::dispatch::find_system_tool(&selected.package_manager_type.to_string()) + && let Some(manager) = + system_package_manager(selected.package_manager_type, &system_path).await + { + let package_manager = manager.client; + let status = + vp_pm_cli::dispatch_with_resolved_package_manager(&cwd, command, manager).await?; + vp_pm_cli::DispatchResult { status, package_manager } + } else { + match selected { + Some(selected) => { + let package_manager = selected.package_manager_type; + let status = vp_pm_cli::dispatch_with_package_manager( + &cwd, + command, + Some(( + selected.package_manager_type, + &selected.version, + selected.hash.as_deref(), + )), + ) + .await?; + vp_pm_cli::DispatchResult { status, package_manager } + } + None => vp_pm_cli::dispatch_with_metadata(&cwd, command).await?, + } + }; if result.status.success() && let Some(packages) = hint_command.why_hint_packages(result.package_manager) { @@ -665,6 +732,21 @@ fn active_toolchain_manifest(cwd: &vt_path::AbsolutePath) -> Option Option { + let output = + tokio::process::Command::new(executable.as_path()).arg("--version").output().await.ok()?; + if !output.status.success() { + return None; + } + let version = std::str::from_utf8(&output.stdout).ok()?.trim(); + node_semver::Version::parse(version).ok()?; + let install_dir = executable.parent()?.parent()?.to_absolute_path_buf(); + Some(vp_pm_cli::PackageManager::from_install_dir(kind, version, install_dir)) +} + async fn managed_install( packages: &[String], node: Option<&str>, 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 dd529c571e..180deebf8b 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -5,35 +5,74 @@ use std::{path::Path, process::ExitStatus}; +use vp_pm_cli::{PackageManagerType, resolve_package_manager_version}; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use super::{config, list::list_installed_versions}; +use super::{ + config, + list::list_installed_versions, + package_manager::{self, ALL_PACKAGE_MANAGERS}, + spec::{EnvScope, parse_package_manager_spec}, +}; use crate::error::Error; /// Execute the clean command. -pub async fn execute(cwd: AbsolutePathBuf) -> Result { +pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; let home_dir = vp_shared::get_vp_home()?; let node_dir = home_dir.join("js_runtime").join("node"); let package_manager_dir = home_dir.join("package_manager"); - let protected_versions = protected_node_versions(&cwd).await?; - - let node_runtimes_removed = - clean_node_runtimes(node_dir.as_path(), &protected_versions).await?; - output::success(&format!( - "Removed {node_runtimes_removed} Node.js runtime{}", - plural(node_runtimes_removed) - )); + if scope.includes_node() { + let protected_versions = protected_node_versions(&cwd).await?; + let removed = clean_node_runtimes(node_dir.as_path(), &protected_versions).await?; + output::success(&format!("Removed {removed} Node.js runtime{}", plural(removed))); + } - let package_managers_removed = clean_package_managers(package_manager_dir.as_path()).await?; - output::success(&format!( - "Removed {package_managers_removed} package manager install{}", - plural(package_managers_removed) - )); + if scope.includes_package_managers() { + let protected = match protected_package_manager(&cwd).await { + Ok(protected) => protected, + Err(error) => { + output::warn(&format!( + "Could not resolve the protected package manager; package-manager cleanup was skipped: {error}" + )); + return Ok(ExitStatus::default()); + } + }; + let selected = match scope { + EnvScope::PackageManager(kind) => vec![kind], + _ => ALL_PACKAGE_MANAGERS.to_vec(), + }; + let removed = + clean_package_managers(package_manager_dir.as_path(), &selected, &protected).await?; + output::success(&format!("Removed {removed} package manager install{}", plural(removed))); + } Ok(ExitStatus::default()) } +async fn protected_package_manager( + cwd: &AbsolutePath, +) -> Result)>, Error> { + let current = package_manager::resolve_current(cwd).await?; + let config = config::load_config().await?; + let default = + config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + let mut protected = Vec::new(); + if let Some(current) = current { + protected.push((current.package_manager_type, vec![current.version.to_string()])); + } + if let Some((kind, version)) = default { + let version = resolve_package_manager_version(kind, &version).await?.to_string(); + if let Some((_, versions)) = protected.iter_mut().find(|(current, _)| *current == kind) { + push_unique_version(versions, version); + } else { + protected.push((kind, vec![version])); + } + } + Ok(protected) +} + async fn protected_node_versions(cwd: &AbsolutePath) -> Result, Error> { let mut versions = Vec::new(); push_unique_version(&mut versions, config::resolve_version(cwd).await?.version); @@ -64,36 +103,29 @@ async fn clean_node_runtimes( Ok(removed) } -async fn clean_package_managers(package_manager_dir: &Path) -> Result { - let installs = count_package_manager_installs(package_manager_dir).await?; - if installs > 0 { - remove_dir_all_if_exists(package_manager_dir).await?; - } - Ok(installs) -} - -async fn count_package_manager_installs(package_manager_dir: &Path) -> Result { - let mut package_manager_entries = match tokio::fs::read_dir(package_manager_dir).await { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(e) => return Err(e.into()), - }; - - let mut count = 0; - while let Some(package_manager_entry) = package_manager_entries.next_entry().await? { - if !package_manager_entry.file_type().await?.is_dir() { - continue; - } - - let mut version_entries = tokio::fs::read_dir(package_manager_entry.path()).await?; - while let Some(version_entry) = version_entries.next_entry().await? { - if version_entry.file_type().await?.is_dir() { - count += 1; +async fn clean_package_managers( + package_manager_dir: &Path, + selected: &[PackageManagerType], + protected: &[(PackageManagerType, Vec)], +) -> Result { + let mut removed = 0; + for kind in selected { + let family = package_manager_dir.join(kind.to_string()); + let protected_versions = protected + .iter() + .find(|(protected_kind, _)| protected_kind == kind) + .map(|(_, versions)| versions.as_slice()) + .unwrap_or_default(); + for version in list_installed_versions(&family) { + if protected_versions.contains(&version) { + continue; + } + if remove_dir_all_if_exists(&family.join(version)).await? { + removed += 1; } } } - - Ok(count) + Ok(removed) } async fn remove_dir_all_if_exists(path: &Path) -> Result { @@ -143,16 +175,23 @@ mod tests { } #[tokio::test] - async fn clean_package_managers_removes_all_cached_installs() { + async fn clean_package_managers_preserves_selected_version() { let temp_dir = TempDir::new().unwrap(); let package_manager_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); tokio::fs::create_dir_all(package_manager_dir.join("pnpm").join("10.0.0")).await.unwrap(); tokio::fs::create_dir_all(package_manager_dir.join("npm").join("11.0.0")).await.unwrap(); tokio::fs::write(package_manager_dir.join("pnpm").join("10.0.0.lock"), "").await.unwrap(); - let removed = clean_package_managers(package_manager_dir.as_path()).await.unwrap(); + let removed = clean_package_managers( + package_manager_dir.as_path(), + &[PackageManagerType::Npm, PackageManagerType::Pnpm], + &[(PackageManagerType::Pnpm, vec!["10.0.0".into()])], + ) + .await + .unwrap(); - assert_eq!(removed, 2); - assert!(!package_manager_dir.as_path().exists()); + assert_eq!(removed, 1); + assert!(package_manager_dir.join("pnpm").join("10.0.0").as_path().exists()); + assert!(!package_manager_dir.join("npm").join("11.0.0").as_path().exists()); } } diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 38cd5805b9..28749a4082 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -35,9 +35,34 @@ pub struct Config { /// Default Node.js version when no project version file is found #[serde(default, skip_serializing_if = "Option::is_none")] pub default_node_version: Option, + /// Default package manager when the project does not select one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_package_manager: Option, /// Shim mode for tool resolution #[serde(default, skip_serializing_if = "is_default_shim_mode")] pub shim_mode: ShimMode, + /// Package-manager shim mode. Inherits `shim_mode` when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package_manager_shim_mode: Option, +} + +impl Config { + #[must_use] + pub fn package_manager_shim_mode(&self) -> ShimMode { + self.package_manager_shim_mode.unwrap_or(self.shim_mode) + } + + pub fn set_shim_modes(&mut self, node: bool, package_manager: bool, mode: ShimMode) { + if node && !package_manager && self.package_manager_shim_mode.is_none() { + self.package_manager_shim_mode = Some(self.package_manager_shim_mode()); + } + if node { + self.shim_mode = mode; + } + if package_manager { + self.package_manager_shim_mode = Some(mode); + } + } } /// Check if shim mode is the default (for skip_serializing_if) @@ -145,14 +170,24 @@ pub async fn save_config(config: &Config) -> Result<(), Error> { /// Set by `vp env use` command. pub const VERSION_ENV_VAR: &str = vp_shared::env_vars::VP_NODE_VERSION; +/// Environment variable for the per-shell package-manager override. +pub const PACKAGE_MANAGER_ENV_VAR: &str = vp_shared::env_vars::VP_PACKAGE_MANAGER; + /// Session version file name, written by `vp env use` so shims work without the shell eval wrapper. pub const SESSION_VERSION_FILE: &str = ".session-node-version"; +/// Package-manager session override file name. +pub const SESSION_PACKAGE_MANAGER_FILE: &str = ".session-package-manager"; + /// Get the path to the session version file (~/.vite-plus/.session-node-version). pub fn get_session_version_path() -> Result { Ok(get_vp_home()?.join(SESSION_VERSION_FILE)) } +pub fn get_session_package_manager_path() -> Result { + Ok(get_vp_home()?.join(SESSION_PACKAGE_MANAGER_FILE)) +} + /// Read the session version file. Returns `None` if the file is missing or empty. pub async fn read_session_version() -> Option { let path = get_session_version_path().ok()?; @@ -161,6 +196,13 @@ pub async fn read_session_version() -> Option { if trimmed.is_empty() { None } else { Some(trimmed) } } +pub async fn read_session_package_manager() -> Option { + let path = get_session_package_manager_path().ok()?; + let content = tokio::fs::read_to_string(path).await.ok()?; + let trimmed = content.trim().to_string(); + if trimmed.is_empty() { None } else { Some(trimmed) } +} + /// Read the session version file synchronously. Returns `None` if the file is missing or empty. pub fn read_session_version_sync() -> Option { let path = get_session_version_path().ok()?; @@ -180,6 +222,15 @@ pub async fn write_session_version(version: &str) -> Result<(), Error> { Ok(()) } +pub async fn write_session_package_manager(spec: &str) -> Result<(), Error> { + let path = get_session_package_manager_path()?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, spec).await?; + Ok(()) +} + /// Delete the session version file. Ignores "not found" errors. pub async fn delete_session_version() -> Result<(), Error> { let path = get_session_version_path()?; @@ -190,6 +241,15 @@ pub async fn delete_session_version() -> Result<(), Error> { } } +pub async fn delete_session_package_manager() -> Result<(), Error> { + let path = get_session_package_manager_path()?; + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + /// Resolve Node.js version for a directory. /// /// Resolution order: @@ -1375,4 +1435,36 @@ mod tests { assert_eq!(resolution_from_files.version, "20.18.0"); assert_eq!(resolution_from_files.source, ".node-version"); } + + #[test] + fn node_only_mode_change_preserves_inherited_package_manager_mode() { + let mut config = Config { + shim_mode: ShimMode::Managed, + package_manager_shim_mode: None, + ..Config::default() + }; + config.set_shim_modes(true, false, ShimMode::SystemFirst); + assert_eq!(config.shim_mode, ShimMode::SystemFirst); + assert_eq!(config.package_manager_shim_mode(), ShimMode::Managed); + } + + #[test] + fn package_manager_only_mode_change_preserves_node_mode() { + let mut config = Config { shim_mode: ShimMode::SystemFirst, ..Config::default() }; + config.set_shim_modes(false, true, ShimMode::Managed); + assert_eq!(config.shim_mode, ShimMode::SystemFirst); + assert_eq!(config.package_manager_shim_mode(), ShimMode::Managed); + } + + #[tokio::test] + async fn package_manager_session_file_round_trip() { + let temp_dir = TempDir::new().unwrap(); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_dir.path(), + )); + write_session_package_manager("pnpm@10.18.0").await.unwrap(); + assert_eq!(read_session_package_manager().await.as_deref(), Some("pnpm@10.18.0")); + delete_session_package_manager().await.unwrap(); + assert!(read_session_package_manager().await.is_none()); + } } diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index d712ea37c0..98d000a369 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -1,64 +1,49 @@ -//! Current environment information command. -//! -//! Shows information about the current Node.js environment. - -use std::process::ExitStatus; +use std::{collections::BTreeMap, process::ExitStatus}; use serde::Serialize; -use vp_pm_cli::{ - PackageManagerResolution, package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_from_package_json, -}; +use vp_pm_cli::{package_manager_bin_path, package_manager_install_dir}; use vt_path::AbsolutePathBuf; -use super::config::resolve_version; +use super::{ + config::{self, ShimMode, resolve_version}, + package_manager, + spec::EnvScope, +}; use crate::{error::Error, help}; -/// JSON output structure for `vp env current --json` #[derive(Serialize)] struct CurrentEnvInfo { - version: String, - source: String, #[serde(skip_serializing_if = "Option::is_none")] - project_root: Option, - node_path: String, - tool_paths: ToolPaths, + node: Option, #[serde(skip_serializing_if = "Option::is_none")] package_manager: Option, } #[derive(Serialize)] -struct ToolPaths { - node: String, - npm: String, - npx: String, +struct NodeInfo { + version: String, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + source_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + project_root: Option, + bin_path: String, + installed: bool, + mode: ShimMode, } -#[derive(Clone, Serialize)] +#[derive(Serialize)] struct PackageManagerInfo { name: String, version: String, source: String, - source_path: String, - project_root: String, - bin_path: String, -} - -impl PackageManagerInfo { - fn from_resolution(resolution: PackageManagerResolution) -> Option { - let install_dir = - package_manager_install_dir(resolution.package_manager_type, &resolution.version)?; - let name = resolution.package_manager_type.to_string(); - let bin_path = package_manager_bin_path(&install_dir, &name); - Some(Self { - name, - version: resolution.version.to_string(), - source: resolution.source.to_string(), - source_path: resolution.source_path.as_path().display().to_string(), - project_root: resolution.project_root.as_path().display().to_string(), - bin_path: bin_path.as_path().display().to_string(), - }) - } + #[serde(skip_serializing_if = "Option::is_none")] + source_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + project_root: Option, + bin_paths: BTreeMap, + installed: bool, + mode: ShimMode, } fn print_rows(title: &str, rows: &[(&str, String)]) { @@ -70,86 +55,176 @@ fn print_rows(title: &str, rows: &[(&str, String)]) { } } -/// Execute the current command. -pub async fn execute(cwd: AbsolutePathBuf, json: bool) -> Result { - let resolution = resolve_version(&cwd).await?; - let package_manager = resolve_package_manager_info(&cwd); - - // Get the home directory for this version - let home_dir = - vp_shared::get_vp_home()?.join("js_runtime").join("node").join(&resolution.version); - - #[cfg(windows)] - let (node_path, npm_path, npx_path) = - { (home_dir.join("node.exe"), home_dir.join("npm.cmd"), home_dir.join("npx.cmd")) }; +pub async fn execute( + cwd: AbsolutePathBuf, + scope: Option, + json: bool, +) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; + let config = config::load_config().await?; + + let node = if scope.includes_node() + && config.shim_mode == ShimMode::SystemFirst + && let Some(bin_path) = crate::shim::dispatch::find_system_tool("node") + { + Some(NodeInfo { + version: read_tool_version(&bin_path).await.unwrap_or_else(|| "unknown".into()), + source: "system PATH".into(), + source_path: None, + project_root: None, + bin_path: bin_path.as_path().display().to_string(), + installed: true, + mode: config.shim_mode, + }) + } else if scope.includes_node() { + let resolution = resolve_version(&cwd).await?; + let home = + vp_shared::get_vp_home()?.join("js_runtime").join("node").join(&resolution.version); + #[cfg(windows)] + let bin_path = home.join("node.exe"); + #[cfg(not(windows))] + let bin_path = home.join("bin").join("node"); + Some(NodeInfo { + version: resolution.version, + source: resolution.source, + source_path: resolution.source_path.map(|path| path.as_path().display().to_string()), + project_root: resolution.project_root.map(|path| path.as_path().display().to_string()), + installed: bin_path.as_path().exists(), + bin_path: bin_path.as_path().display().to_string(), + mode: config.shim_mode, + }) + } else { + None + }; - #[cfg(not(windows))] - let (node_path, npm_path, npx_path) = { - ( - home_dir.join("bin").join("node"), - home_dir.join("bin").join("npm"), - home_dir.join("bin").join("npx"), - ) + let package_manager = if scope.includes_package_managers() { + package_manager::resolve_current(&cwd).await?.and_then(|resolution| { + if let EnvScope::PackageManager(expected) = scope + && expected != resolution.package_manager_type + { + return None; + } + let system_paths = (config.package_manager_shim_mode() == ShimMode::SystemFirst) + .then(|| { + resolution + .package_manager_type + .bin_names() + .iter() + .filter_map(|name| { + crate::shim::dispatch::find_system_tool(name).map(|path| { + ((*name).to_string(), path.as_path().display().to_string()) + }) + }) + .collect::>() + }) + .filter(|paths| !paths.is_empty()); + let install_dir = + package_manager_install_dir(resolution.package_manager_type, &resolution.version)?; + let bin_paths = resolution + .package_manager_type + .bin_names() + .iter() + .map(|name| { + ( + (*name).to_string(), + package_manager_bin_path(&install_dir, name) + .as_path() + .display() + .to_string(), + ) + }) + .collect::>(); + let bin_paths = system_paths.unwrap_or(bin_paths); + let installed = bin_paths.values().all(|path| std::path::Path::new(path).exists()); + let system_version = bin_paths + .get(resolution.package_manager_type.to_string().as_str()) + .filter(|_| config.package_manager_shim_mode() == ShimMode::SystemFirst) + .and_then(|path| vt_path::AbsolutePathBuf::new(path.into())); + let is_system = system_version.is_some(); + Some(PackageManagerInfo { + name: resolution.package_manager_type.to_string(), + version: system_version + .as_ref() + .and_then(|path| read_tool_version_sync(path)) + .unwrap_or_else(|| resolution.version.to_string()), + source: if is_system { + "system PATH".into() + } else { + resolution.source.to_string() + }, + source_path: if is_system { + None + } else { + resolution.source_path.map(|path| path.as_path().display().to_string()) + }, + project_root: resolution + .project_root + .map(|path| path.as_path().display().to_string()), + bin_paths, + installed, + mode: config.package_manager_shim_mode(), + }) + }) + } else { + None }; if json { - let info = CurrentEnvInfo { - version: resolution.version.clone(), - source: resolution.source.clone(), - project_root: resolution - .project_root - .as_ref() - .map(|p| p.as_path().display().to_string()), - node_path: node_path.as_path().display().to_string(), - tool_paths: ToolPaths { - node: node_path.as_path().display().to_string(), - npm: npm_path.as_path().display().to_string(), - npx: npx_path.as_path().display().to_string(), - }, - package_manager: package_manager.clone(), - }; - - let json_str = serde_json::to_string_pretty(&info)?; - println!("{json_str}"); - } else { - let mut environment_rows = - vec![("Version", resolution.version.clone()), ("Source", resolution.source.clone())]; - if let Some(path) = &resolution.source_path { - environment_rows.push(("Source Path", path.as_path().display().to_string())); - } - if let Some(root) = &resolution.project_root { - environment_rows.push(("Project Root", root.as_path().display().to_string())); - } + println!("{}", serde_json::to_string_pretty(&CurrentEnvInfo { node, package_manager })?); + return Ok(ExitStatus::default()); + } - print_rows("Environment", &environment_rows); - println!(); + if let Some(node) = node { print_rows( - "Tool Paths", + "Node.js", &[ - ("node", node_path.as_path().display().to_string()), - ("npm", npm_path.as_path().display().to_string()), - ("npx", npx_path.as_path().display().to_string()), + ("Version", node.version), + ("Source", node.source), + ("Bin Path", node.bin_path), + ("Installed", node.installed.to_string()), + ("Mode", mode_name(node.mode).into()), ], ); - if let Some(package_manager) = package_manager { + } + if let Some(package_manager) = package_manager { + if scope.includes_node() { println!(); - print_rows( - "Package Manager", - &[ - ("Name", package_manager.name), - ("Version", package_manager.version), - ("Source", package_manager.source), - ("Source Path", package_manager.source_path), - ("Project Root", package_manager.project_root), - ("Bin Path", package_manager.bin_path), - ], - ); } + print_rows( + "Package Manager", + &[ + ("Name", package_manager.name), + ("Version", package_manager.version), + ("Source", package_manager.source), + ("Installed", package_manager.installed.to_string()), + ("Mode", mode_name(package_manager.mode).into()), + ], + ); } Ok(ExitStatus::default()) } -fn resolve_package_manager_info(cwd: &AbsolutePathBuf) -> Option { - PackageManagerInfo::from_resolution(resolve_package_manager_from_package_json(cwd).ok()??) +async fn read_tool_version(path: &vt_path::AbsolutePath) -> Option { + let output = + tokio::process::Command::new(path.as_path()).arg("--version").output().await.ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +} + +fn read_tool_version_sync(path: &vt_path::AbsolutePath) -> Option { + let output = std::process::Command::new(path.as_path()).arg("--version").output().ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +} + +fn mode_name(mode: ShimMode) -> &'static str { + match mode { + ShimMode::Managed => "managed", + ShimMode::SystemFirst => "system_first", + } } diff --git a/crates/vp_global_cli/src/commands/env/default.rs b/crates/vp_global_cli/src/commands/env/default.rs index 6638469eb7..e416edf658 100644 --- a/crates/vp_global_cli/src/commands/env/default.rs +++ b/crates/vp_global_cli/src/commands/env/default.rs @@ -1,108 +1,109 @@ -//! Default version management command. -//! -//! Handles `vp env default [VERSION]` to set or show the global default Node.js version. - use std::process::ExitStatus; +use vp_pm_cli::resolve_package_manager_version; use vt_path::AbsolutePathBuf; -use super::config::{get_config_path, load_config, save_config}; +use super::{ + config::{get_config_path, load_config, save_config}, + spec::{EnvScope, EnvSpecs}, +}; use crate::error::Error; -/// Execute the default command. -pub async fn execute(_cwd: AbsolutePathBuf, version: Option) -> Result { - match version { - Some(v) => set_default(&v).await, - None => show_default().await, - } -} - -/// Show the current default version. -async fn show_default() -> Result { - let config = load_config().await?; - - match config.default_node_version { - Some(version) => { - println!("Default Node.js version: {version}"); - let config_path = get_config_path()?; - println!(" Set via: {}", config_path.as_path().display()); - - // If it's an alias, also show the resolved version - if version == "lts" || version == "latest" { - let provider = vp_js_runtime::NodeProvider::new(); - match resolve_alias(&version, &provider).await { - Ok(resolved) => println!(" Currently resolves to: {resolved}"), - Err(_) => {} - } - } +pub async fn execute( + _cwd: AbsolutePathBuf, + values: Vec, + unset: bool, +) -> Result { + if unset { + let scope = match values.as_slice() { + [] => EnvScope::All, + [scope] => EnvScope::parse(Some(scope))?, + _ => return Err(Error::Other("default --unset accepts at most one scope".into())), + }; + let mut config = load_config().await?; + if scope.includes_node() { + config.default_node_version = None; } - None => { - // No default configured - show what would be used - let provider = vp_js_runtime::NodeProvider::new(); - match provider.resolve_latest_version().await { - Ok(lts_version) => { - println!("No default version configured. Using latest LTS ({lts_version})."); - println!(" Run 'vp env default ' to set a default."); - } - Err(_) => { - println!("No default version configured."); - println!(" Run 'vp env default ' to set a default."); - } + if scope.includes_package_managers() { + let should_clear = match scope { + EnvScope::PackageManager(expected) => config + .default_package_manager + .as_deref() + .and_then(|value| super::spec::parse_package_manager_spec(value).ok()) + .is_some_and(|(kind, _)| kind == expected), + _ => true, + }; + if should_clear { + config.default_package_manager = None; } } + save_config(&config).await?; + crate::shim::invalidate_cache(); + println!("Cleared selected environment defaults."); + return Ok(ExitStatus::default()); } - Ok(ExitStatus::default()) -} - -/// Set the default version. -async fn set_default(version: &str) -> Result { - let provider = vp_js_runtime::NodeProvider::new(); - - // Validate the version - let (display_version, store_version) = match version.to_lowercase().as_str() { - "lts" => { - // Resolve to show current value, but store "lts" as alias - let current_lts = provider.resolve_latest_version().await?; - (format!("lts (currently {})", current_lts), "lts".to_string()) - } - "latest" => { - // Resolve to show current value, but store "latest" as alias - let current_latest = provider.resolve_absolute_latest_version().await?; - (format!("latest (currently {})", current_latest), "latest".to_string()) - } - _ => { - // Validate version exists - let resolved = if vp_js_runtime::NodeProvider::is_exact_version(version) { - version.to_string() - } else { - provider.resolve_version(version).await?.to_string() - }; - (resolved.clone(), resolved) - } - }; + if values.is_empty() { + return show_default(EnvScope::All).await; + } + if values.len() == 1 + && let Ok(scope) = EnvScope::parse(values.first().map(String::as_str)) + { + return show_default(scope).await; + } - // Save to config + let specs = EnvSpecs::parse(&values)?; let mut config = load_config().await?; - config.default_node_version = Some(store_version); + if let Some(version) = specs.node { + config.default_node_version = Some(resolve_node_default(&version).await?); + } + if let Some((package_manager, version)) = specs.package_manager { + let stored = if version == "latest" { + version + } else { + resolve_package_manager_version(package_manager, &version).await?.to_string() + }; + config.default_package_manager = Some(format!("{package_manager}@{stored}")); + } save_config(&config).await?; - - // Invalidate resolve cache so the new default takes effect immediately crate::shim::invalidate_cache(); + println!("\u{2713} Environment defaults updated."); + Ok(ExitStatus::default()) +} - println!("\u{2713} Default Node.js version set to {display_version}"); - +async fn show_default(scope: EnvScope) -> Result { + let config = load_config().await?; + let config_path = get_config_path()?; + if scope.includes_node() { + match config.default_node_version { + Some(version) => println!("Default Node.js version: {version}"), + None => println!("Default Node.js version: latest LTS"), + } + } + if scope.includes_package_managers() { + let configured = config.default_package_manager.filter(|spec| match scope { + EnvScope::PackageManager(expected) => super::spec::parse_package_manager_spec(spec) + .is_ok_and(|(kind, _)| kind == expected), + _ => true, + }); + match configured { + Some(spec) => println!("Default package manager: {spec}"), + None => match scope { + EnvScope::PackageManager(kind) => { + println!("Default {kind} version: not configured") + } + _ => println!("Default package manager: not configured"), + }, + } + } + println!(" Set via: {}", config_path.as_path().display()); Ok(ExitStatus::default()) } -/// Resolve version alias to actual version. -async fn resolve_alias( - alias: &str, - provider: &vp_js_runtime::NodeProvider, -) -> Result { - match alias { - "lts" => Ok(provider.resolve_latest_version().await?.to_string()), - "latest" => Ok(provider.resolve_absolute_latest_version().await?.to_string()), - _ => Ok(alias.to_string()), +async fn resolve_node_default(version: &str) -> Result { + let provider = vp_js_runtime::NodeProvider::new(); + match version.to_lowercase().as_str() { + "lts" | "latest" => Ok(version.to_lowercase()), + _ => super::config::resolve_version_alias(version, &provider).await, } } diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 6ce79f0473..bf4c02e8d2 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -3,10 +3,15 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; +use vp_pm_cli::{package_manager_bin_path, package_manager_install_dir}; use vp_shared::{env_vars, output}; use vt_path::{AbsolutePathBuf, current_dir}; -use super::config::{self, ShimMode, get_bin_dir, get_vp_home, load_config, resolve_version}; +use super::{ + config::{self, ShimMode, get_bin_dir, get_vp_home, load_config, resolve_version}, + package_manager, + spec::EnvScope, +}; use crate::{ commands::shell::{ALL_SHELL_PROFILES, IDE_SHELL_PROFILES, ShellProfile, resolve_profile_path}, error::Error, @@ -74,7 +79,8 @@ fn abbreviate_home(path: &str) -> String { } /// Execute the doctor command. -pub async fn execute(cwd: AbsolutePathBuf) -> Result { +pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; let mut has_errors = false; // Section: Installation @@ -84,20 +90,33 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { // Section: Configuration print_section("Configuration"); - let (shim_mode, system_node_path) = check_shim_mode().await; + let (shim_mode, system_node_path) = check_shim_mode(scope).await; // Check env sourcing: IDE-relevant profiles first, then all shell profiles let env_status = cfg!(not(windows)).then(check_env_sourcing); - check_session_override(); + if scope.includes_node() { + check_session_override(); + } + if scope.includes_package_managers() { + check_package_manager_session_override().await; + } // Section: PATH print_section("PATH"); has_errors |= !check_path().await; // Section: Version Resolution - print_section("Version Resolution"); - let resolution = check_current_resolution(&cwd, shim_mode, system_node_path).await; + let resolution = if scope.includes_node() { + print_section("Node.js Resolution"); + check_current_resolution(&cwd, shim_mode, system_node_path).await + } else { + None + }; + if scope.includes_package_managers() { + print_section("Package Manager Resolution"); + check_package_manager_resolution(&cwd, scope).await; + } // Section: devEngines (conditional, see rfcs/dev-engines.md) check_dev_engines(&cwd, resolution.as_ref()).await; @@ -217,7 +236,7 @@ fn shim_filename(tool: &str) -> String { } /// Check and display shim mode. Returns the mode and any found system node path. -async fn check_shim_mode() -> (ShimMode, Option) { +async fn check_shim_mode(scope: EnvScope) -> (ShimMode, Option) { let config = match load_config().await { Ok(c) => c, Err(e) => { @@ -232,34 +251,89 @@ async fn check_shim_mode() -> (ShimMode, Option) { let mut system_node_path = None; - match config.shim_mode { - ShimMode::Managed => { - print_check(&output::CHECK.green().to_string(), "Node.js mode", "managed"); - } - ShimMode::SystemFirst => { - print_check( - &output::CHECK.green().to_string(), - "Node.js mode", - &"system-first".bright_blue().to_string(), - ); - - // Check if system Node.js is available - if let Some(system_node) = shim::find_system_tool("node") { - print_check(" ", "System Node.js", &system_node.as_path().display().to_string()); - system_node_path = Some(system_node); - } else { + if scope.includes_node() { + match config.shim_mode { + ShimMode::Managed => { + print_check(&output::CHECK.green().to_string(), "Node.js mode", "managed"); + } + ShimMode::SystemFirst => { print_check( - &output::WARN_SIGN.yellow().to_string(), - "System Node.js", - &"not found (will fall back to managed)".yellow().to_string(), + &output::CHECK.green().to_string(), + "Node.js mode", + &"system-first".bright_blue().to_string(), ); + + // Check if system Node.js is available + if let Some(system_node) = shim::find_system_tool("node") { + print_check( + " ", + "System Node.js", + &system_node.as_path().display().to_string(), + ); + system_node_path = Some(system_node); + } else { + print_check( + &output::WARN_SIGN.yellow().to_string(), + "System Node.js", + &"not found (will fall back to managed)".yellow().to_string(), + ); + } } } } + if scope.includes_package_managers() { + let mode = match config.package_manager_shim_mode() { + ShimMode::Managed => "managed", + ShimMode::SystemFirst => "system-first", + }; + print_check(&output::CHECK.green().to_string(), "Package manager mode", mode); + } (config.shim_mode, system_node_path) } +async fn check_package_manager_session_override() { + let environment = vp_shared::EnvConfig::get().package_manager; + let session = config::read_session_package_manager().await; + if let Some(value) = environment.or(session) { + print_check(" ", "PM session", &value); + } +} + +async fn check_package_manager_resolution(cwd: &AbsolutePathBuf, scope: EnvScope) { + match package_manager::resolve_current(cwd).await { + Ok(Some(resolution)) if !matches!(scope, EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) => + { + print_check(" ", "Source", &resolution.source); + print_check( + " ", + "Version", + &format!("{}@{}", resolution.package_manager_type, resolution.version) + .bright_green() + .to_string(), + ); + let installed = + package_manager_install_dir(resolution.package_manager_type, &resolution.version) + .is_some_and(|directory| { + resolution.package_manager_type.bin_names().iter().all(|name| { + package_manager_bin_path(&directory, name).as_path().exists() + }) + }); + let status = if installed { "installed" } else { "not installed" }; + let indicator = if installed { + output::CHECK.green().to_string() + } else { + output::WARN_SIGN.yellow().to_string() + }; + print_check(&indicator, "PM binaries", status); + } + Ok(_) => print_check(" ", "Package manager", "not selected"), + Err(error) => { + print_check(&output::CROSS.red().to_string(), "Package manager", &error.to_string()) + } + } +} + /// Check profile files for env sourcing and classify where it was found. /// /// Tries IDE-relevant profiles first, then falls back to all shell profiles. diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index 8b7cea5d8e..7e18cf77e2 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -10,8 +10,13 @@ use std::process::ExitStatus; use vp_js_runtime::NodeProvider; -use vp_shared::{env_vars, format_path_prepended}; +use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; +use vp_shared::env_vars; +use vt_path::AbsolutePath; +use super::{ + config, package_manager as package_manager_resolution, spec::parse_package_manager_spec, +}; use crate::{ cli::exit_status, error::Error, @@ -24,8 +29,10 @@ use crate::{ /// When `--node` is not provided and the command is a shim tool (node/npm/npx or global package), /// uses the same shim dispatch logic as Unix symlinks. pub async fn execute( + cwd: &AbsolutePath, node_version: Option<&str>, npm_version: Option<&str>, + package_manager: Option<&str>, command: &[String], ) -> Result { let command = normalize_wrapper_command(command); @@ -37,8 +44,15 @@ pub async fn execute( } // If --node is provided, use explicit version mode (existing behavior) - if let Some(version) = node_version { - return execute_with_version(version, npm_version, &command).await; + if npm_version.is_some() && package_manager.is_some() { + return Err(Error::Other("--npm and --package-manager cannot be used together".into())); + } + let package_manager = package_manager + .map(str::to_string) + .or_else(|| npm_version.map(|version| format!("npm@{version}"))); + + if node_version.is_some() || package_manager.is_some() { + return execute_with_version(cwd, node_version, package_manager.as_deref(), &command).await; } // No --node provided - check if first command is a shim tool @@ -71,15 +85,7 @@ pub async fn execute( return Ok(exit_status(exit_code)); } - // Not a shim tool and no --node - error - eprintln!("vp env exec: --node is required when running non-shim commands"); - eprintln!("Usage: vp env exec --node [args...]"); - eprintln!(); - eprintln!("For shim tools, --node is optional (version resolved automatically):"); - eprintln!(" vp env exec node script.js # Core tool"); - eprintln!(" vp env exec npm install # Core tool"); - eprintln!(" vp env exec tsc --version # Global package"); - Ok(exit_status(1)) + execute_with_version(cwd, None, None, &command).await } /// Normalize arguments when invoked via Windows shim wrappers. @@ -112,23 +118,61 @@ fn normalize_wrapper_command_inner(command: &[String], from_wrapper: bool) -> Ve /// Execute a command with an explicitly specified Node.js version. async fn execute_with_version( - node_version: &str, - npm_version: Option<&str>, + cwd: &AbsolutePath, + node_version: Option<&str>, + package_manager: Option<&str>, command: &[String], ) -> Result { - // Warn about unsupported --npm flag - if npm_version.is_some() { - eprintln!("Warning: --npm flag is not yet implemented, using bundled npm"); + let mut path_prefixes = Vec::new(); + let modes = config::load_config().await?; + let (resolved_node, system_node_bin) = if let Some(node_version) = node_version { + (resolve_version(node_version, &NodeProvider::new()).await?, None) + } else if modes.shim_mode == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool("node") + { + ( + read_tool_version(&path).await.unwrap_or_else(|| "unknown".into()), + path.parent().map(vt_path::AbsolutePath::to_absolute_path_buf), + ) + } else { + (config::resolve_version(cwd).await?.version, None) + }; + if let Some(bin_dir) = system_node_bin { + path_prefixes.push(bin_dir.into_path_buf()); + } else { + let runtime = + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolved_node) + .await?; + path_prefixes.push(runtime.get_bin_prefix().as_path().to_path_buf()); } - - // 1. Resolve version - let provider = NodeProvider::new(); - let resolved_version = resolve_version(node_version, &provider).await?; - - // 2. Ensure installed (download if needed) - let runtime = - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolved_version) - .await?; + let explicit_package_manager = package_manager.is_some(); + let selected_package_manager = if let Some(package_manager) = package_manager { + let (kind, selector) = parse_package_manager_spec(package_manager)?; + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + Some((kind, version, None)) + } else { + package_manager_resolution::resolve_current(cwd).await?.map(|resolution| { + (resolution.package_manager_type, resolution.version.to_string(), resolution.hash) + }) + }; + let resolved_package_manager = if let Some((kind, version, hash)) = selected_package_manager { + if !explicit_package_manager + && modes.package_manager_shim_mode() == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool(&kind.to_string()) + && let Some(bin_dir) = path.parent() + { + let system_version = read_tool_version(&path).await.unwrap_or(version); + path_prefixes.insert(0, bin_dir.as_path().to_path_buf()); + Some(format!("{kind}@{system_version}")) + } else { + let (install_dir, _, _) = + download_package_manager(kind, &version, hash.as_deref()).await?; + path_prefixes.insert(0, install_dir.join("bin").into_path_buf()); + Some(format!("{kind}@{version}")) + } + } else { + None + }; // 3. Clear recursion env var to force re-evaluation in child processes // SAFETY: This is safe because we're about to spawn a child process and we want @@ -138,16 +182,19 @@ async fn execute_with_version( std::env::remove_var(env_vars::VP_TOOL_RECURSION); } - // 4. Build PATH with node bin dir first (uses platform-specific separator) - // Always prepend to ensure the requested Node version is first in PATH - let node_bin_dir = runtime.get_bin_prefix(); - let new_path = format_path_prepended(node_bin_dir.as_path()); + let mut paths = path_prefixes; + paths.extend(std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())); + let new_path = std::env::join_paths(paths) + .map_err(|error| Error::Other(format!("failed to construct PATH: {error}").into()))?; // 5. Execute command let (cmd, args) = command.split_first().unwrap(); let mut child = tokio::process::Command::new(cmd); - child.args(args).env("PATH", new_path).env(env_vars::VP_NODE_VERSION, &resolved_version); + child.args(args).env("PATH", new_path).env(env_vars::VP_NODE_VERSION, &resolved_node); + if let Some(package_manager) = resolved_package_manager { + child.env(env_vars::VP_PACKAGE_MANAGER, package_manager); + } // The child runs in the inherited cwd, which a leading `-C ` changes // without touching our own environment; align its `PWD` accordingly. if let Ok(cwd) = vt_path::current_dir() { @@ -158,6 +205,15 @@ async fn execute_with_version( Ok(status) } +async fn read_tool_version(path: &AbsolutePath) -> Option { + let output = + tokio::process::Command::new(path.as_path()).arg("--version").output().await.ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().trim_start_matches('v').to_string()) +} + /// Resolve version to an exact version. /// /// Handles aliases (lts, latest) and version ranges. @@ -207,7 +263,8 @@ mod tests { #[tokio::test] async fn test_execute_missing_command() { - let result = execute(Some("20.18.0"), None, &[]).await; + let cwd = vt_path::current_dir().unwrap(); + let result = execute(&cwd, Some("20.18.0"), None, None, &[]).await; assert!(result.is_ok()); let status = result.unwrap(); assert!(!status.success()); @@ -218,7 +275,8 @@ mod tests { async fn test_execute_node_version() { // Run 'node --version' with a specific Node.js version let command = vec!["node".to_string(), "--version".to_string()]; - let result = execute(Some("20.18.0"), None, &command).await; + let cwd = vt_path::current_dir().unwrap(); + let result = execute(&cwd, Some("20.18.0"), None, None, &command).await; assert!(result.is_ok()); let status = result.unwrap(); assert!(status.success()); @@ -255,17 +313,6 @@ mod tests { assert_eq!(classify_version("latest"), VersionSelector::AbsoluteLatest); } - #[tokio::test] - async fn test_shim_mode_error_for_non_shim_command() { - // Running a non-shim command without --node should error - let command = vec!["python".to_string(), "--version".to_string()]; - let result = execute(None, None, &command).await; - assert!(result.is_ok()); - let status = result.unwrap(); - // Should fail because python is not a shim tool and --node was not provided - assert!(!status.success(), "Non-shim command without --node should fail"); - } - #[test] fn test_normalize_wrapper_command_strips_only_wrapper_separator() { let command = vec!["node".to_string(), "--".to_string(), "--version".to_string()]; diff --git a/crates/vp_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs new file mode 100644 index 0000000000..283595efed --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -0,0 +1,131 @@ +use std::process::ExitStatus; + +use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; +use vt_path::AbsolutePathBuf; + +use super::{ + config, package_manager, + spec::{EnvScope, EnvSpecs}, +}; +use crate::error::Error; + +pub(crate) async fn install( + cwd: AbsolutePathBuf, + requests: Vec, +) -> Result { + let (scope, specs) = parse_requests(&requests)?; + + if scope.includes_node() { + let version = match specs.node { + Some(version) => { + let provider = vp_js_runtime::NodeProvider::new(); + config::resolve_version_alias(&version, &provider).await? + } + None => config::resolve_version(&cwd).await?.version, + }; + println!("Installing Node.js v{version}..."); + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &version).await?; + println!("Installed Node.js v{version}"); + } + + if scope.includes_package_managers() { + let requested = if let Some(spec) = specs.package_manager { + Some(spec) + } else if let EnvScope::PackageManager(kind) = scope { + match package_manager::resolve_current(&cwd).await? { + Some(current) if current.package_manager_type == kind => { + Some((kind, current.version.to_string())) + } + _ => Some((kind, "latest".into())), + } + } else { + package_manager::resolve_current(&cwd) + .await? + .map(|current| (current.package_manager_type, current.version.to_string())) + }; + if let Some((kind, selector)) = requested { + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + println!("Installing {kind} v{version}..."); + download_package_manager(kind, &version, None).await?; + println!("Installed {kind} v{version}"); + } + } + + Ok(ExitStatus::default()) +} + +pub(crate) async fn uninstall(specs: Vec) -> Result { + let specs = EnvSpecs::parse(&specs)?; + let node = specs + .node + .map(|version| { + if vp_js_runtime::NodeProvider::is_exact_version(&version) { + Ok(version.strip_prefix('v').unwrap_or(&version).to_string()) + } else { + Err(Error::Other("uninstall requires exact Node.js versions".into())) + } + }) + .transpose()?; + let package_manager = specs + .package_manager + .map(|(kind, version)| { + node_semver::Version::parse(&version) + .map(|version| (kind, version.to_string())) + .map_err(|_| { + Error::Other("uninstall requires exact package-manager versions".into()) + }) + }) + .transpose()?; + + let home = vp_shared::get_vp_home()?; + let mut targets = Vec::new(); + if let Some(version) = node { + targets.push(( + format!("Node.js v{version}"), + home.join("js_runtime").join("node").join(version), + )); + } + if let Some((kind, version)) = package_manager { + targets.push(( + format!("{kind} v{version}"), + home.join("package_manager").join(kind.to_string()).join(version), + )); + } + for (label, target) in &targets { + if !target.as_path().exists() { + return Err(Error::Other(format!("{label} is not installed").into())); + } + } + for (label, target) in targets { + tokio::fs::remove_dir_all(target.as_path()).await?; + println!("Uninstalled {label}"); + } + Ok(ExitStatus::default()) +} + +fn parse_requests(requests: &[String]) -> Result<(EnvScope, EnvSpecs), Error> { + if requests.len() == 1 + && let Ok(scope) = EnvScope::parse(Some(&requests[0])) + { + return Ok((scope, EnvSpecs::default())); + } + let specs = EnvSpecs::parse(requests)?; + 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)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_bare_install_version_selects_node() { + let (scope, specs) = parse_requests(&["22.0.0".into()]).unwrap(); + assert_eq!(scope, EnvScope::Node); + assert_eq!(specs.node.as_deref(), Some("22.0.0")); + } +} diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 4ef758e70b..b78bda8194 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -1,17 +1,19 @@ -//! List command for displaying locally installed Node.js versions. -//! -//! Handles `vp env list` to show Node.js versions installed in VP_HOME/js_runtime/node/. +use std::{collections::BTreeMap, process::ExitStatus}; -use std::process::ExitStatus; - -use owo_colors::OwoColorize; use serde::Serialize; +use vp_pm_cli::{ + PackageManagerType, package_manager_bin_path, package_manager_install_dir, + resolve_package_manager_version, +}; use vt_path::AbsolutePathBuf; -use super::config; +use super::{ + config, + package_manager::{self, ALL_PACKAGE_MANAGERS}, + spec::{EnvScope, parse_package_manager_spec}, +}; use crate::error::Error; -/// JSON output format for a single installed version #[derive(Serialize)] struct InstalledVersionJson { version: String, @@ -19,136 +21,172 @@ struct InstalledVersionJson { default: bool, } -/// Scan the node versions directory and return sorted version strings. -pub(super) fn list_installed_versions(node_dir: &std::path::Path) -> Vec { - let entries = match std::fs::read_dir(node_dir) { +#[derive(Serialize)] +struct InstalledEnvironmentJson { + #[serde(skip_serializing_if = "Option::is_none")] + node: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + package_managers: Option>>, +} + +pub(super) fn list_installed_versions(directory: &std::path::Path) -> Vec { + let entries = match std::fs::read_dir(directory) { Ok(entries) => entries, Err(_) => return Vec::new(), }; - - let mut versions: Vec = entries + let mut versions = entries .filter_map(|entry| { let entry = entry.ok()?; let name = entry.file_name().into_string().ok()?; - // Skip hidden directories and non-directories - if name.starts_with('.') || !entry.path().is_dir() { - return None; - } - Some(name) + (!name.starts_with('.') && entry.path().is_dir()).then_some(name) }) - .collect(); - - versions.sort_by_cached_key(|v| node_semver::Version::parse(v).ok()); + .collect::>(); + versions.sort_by_cached_key(|version| node_semver::Version::parse(version).ok()); versions } -/// Execute the list command (local installed versions). -pub async fn execute(cwd: AbsolutePathBuf, json_output: bool) -> Result { - let home_dir = vp_shared::get_vp_home()?; - let node_dir = home_dir.join("js_runtime").join("node"); +pub async fn execute( + cwd: AbsolutePathBuf, + scope: Option, + json: bool, +) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; + let home = vp_shared::get_vp_home()?; + let config = config::load_config().await?; + let current_node = + config::resolve_version(&cwd).await.ok().map(|resolution| resolution.version); + let current_pm = package_manager::resolve_current(&cwd).await?; + let default_node = match config.default_node_version.as_deref() { + Some(selector) => Some( + config::resolve_version_alias(selector, &vp_js_runtime::NodeProvider::new()).await?, + ), + None => None, + }; + let default_pm = + config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + let default_pm = match default_pm { + Some((kind, selector)) => { + Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) + } + None => None, + }; - let versions = list_installed_versions(node_dir.as_path()); + let node = scope.includes_node().then(|| { + list_installed_versions(home.join("js_runtime").join("node").as_path()) + .into_iter() + .map(|version| InstalledVersionJson { + current: current_node.as_deref() == Some(version.as_str()), + default: default_node.as_deref() == Some(version.as_str()), + version, + }) + .collect::>() + }); + + let package_managers = if scope.includes_package_managers() { + let selected = selected_package_managers(scope); + Some( + selected + .into_iter() + .map(|kind| { + let versions = list_complete_package_manager_versions(&home, kind) + .into_iter() + .map(|version| InstalledVersionJson { + current: current_pm.as_ref().is_some_and(|current| { + current.package_manager_type == kind + && current.version.as_str() == version + }), + default: default_pm.as_ref().is_some_and(|(default, value)| { + *default == kind && value == &version + }), + version, + }) + .collect(); + (kind.to_string(), versions) + }) + .collect(), + ) + } else { + None + }; - if versions.is_empty() { - if json_output { - println!("[]"); - } else { - println!("No Node.js versions installed."); - println!(); - println!("Install a version with: vp env install "); - } + if json { + println!( + "{}", + serde_json::to_string_pretty(&InstalledEnvironmentJson { node, package_managers })? + ); return Ok(ExitStatus::default()); } - // Resolve current version (gracefully handle errors) - let current_version = config::resolve_version(&cwd).await.ok().map(|r| r.version); - - // Load default version - let default_version = config::load_config().await.ok().and_then(|c| c.default_node_version); - - if json_output { - print_json(&versions, current_version.as_deref(), default_version.as_deref()); - } else { - print_human(&versions, current_version.as_deref(), default_version.as_deref()); + if let Some(node) = node { + print_section("Node.js", &node); + } + if let Some(mut package_managers) = package_managers { + for kind in selected_package_managers(scope) { + let name = kind.to_string(); + if scope.includes_node() || kind != PackageManagerType::Npm { + println!(); + } + print_section( + package_manager_title(kind), + &package_managers.remove(&name).unwrap_or_default(), + ); + } } - Ok(ExitStatus::default()) } -/// Print installed versions as JSON. -fn print_json(versions: &[String], current: Option<&str>, default: Option<&str>) { - let entries: Vec = versions - .iter() - .map(|v| InstalledVersionJson { - version: v.clone(), - current: current.is_some_and(|c| c == v), - default: default.is_some_and(|d| d == v), - }) - .collect(); +fn package_manager_title(kind: PackageManagerType) -> &'static str { + match kind { + PackageManagerType::Npm => "npm", + PackageManagerType::Pnpm => "pnpm", + PackageManagerType::Yarn => "Yarn", + PackageManagerType::Bun => "Bun", + } +} - // unwrap is safe here since we're serializing simple structs - println!("{}", serde_json::to_string_pretty(&entries).unwrap()); +fn selected_package_managers(scope: EnvScope) -> Vec { + match scope { + EnvScope::PackageManager(package_manager) => vec![package_manager], + EnvScope::All | EnvScope::PackageManagers => ALL_PACKAGE_MANAGERS.to_vec(), + EnvScope::Node => Vec::new(), + } } -/// 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); +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() +} +fn print_section(title: &str, versions: &[InstalledVersionJson]) { + println!("{title}"); + if versions.is_empty() { + println!(" No versions installed."); + return; + } + for version in versions { let mut markers = Vec::new(); - if is_current { + if version.current { markers.push("current"); } - if is_default { + if version.default { markers.push("default"); } - - let marker_str = if markers.is_empty() { - String::new() - } else { - format!(" {}", markers.join(" ").dimmed()) - }; - - let line = format!("* v{v}{marker_str}"); - if is_current { - println!("{}", line.bright_blue()); - } else { - println!("{line}"); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_list_installed_versions_nonexistent_dir() { - let versions = list_installed_versions(std::path::Path::new("/nonexistent/path")); - assert!(versions.is_empty()); - } - - #[test] - fn test_list_installed_versions_empty_dir() { - let dir = tempfile::tempdir().unwrap(); - let versions = list_installed_versions(dir.path()); - assert!(versions.is_empty()); - } - - #[test] - fn test_list_installed_versions_with_versions() { - let dir = tempfile::tempdir().unwrap(); - // Create version directories - std::fs::create_dir(dir.path().join("20.18.0")).unwrap(); - std::fs::create_dir(dir.path().join("22.13.0")).unwrap(); - std::fs::create_dir(dir.path().join("18.20.0")).unwrap(); - // Create a hidden dir that should be skipped - std::fs::create_dir(dir.path().join(".tmp")).unwrap(); - // Create a file that should be skipped - std::fs::write(dir.path().join("some-file"), "").unwrap(); - - let versions = list_installed_versions(dir.path()); - assert_eq!(versions, vec!["18.20.0", "20.18.0", "22.13.0"]); + let suffix = + if markers.is_empty() { String::new() } else { format!(" ({})", markers.join(", ")) }; + println!(" * {}{suffix}", version.version); } } diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index 81b3317c8e..47a554ae96 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,33 @@ -//! List-remote command for displaying available Node.js versions from the registry. -//! -//! Handles `vp env list-remote` to show available Node.js versions from the Node.js distribution. +use std::{collections::BTreeMap, process::ExitStatus}; -use std::process::ExitStatus; - -use owo_colors::OwoColorize; +use futures::future::try_join_all; use serde::Serialize; use vp_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry}; +use vp_pm_cli::{ + PackageManagerType, fetch_package_manager_versions, resolve_package_manager_version, +}; use vt_path::AbsolutePathBuf; -use super::config; +use super::{ + config, + list::{list_complete_package_manager_versions, list_installed_versions}, + package_manager::{self, ALL_PACKAGE_MANAGERS}, + spec::{EnvScope, parse_package_manager_spec}, +}; use crate::{cli::SortingMethod, error::Error}; -/// Default number of major versions to show const DEFAULT_MAJOR_VERSIONS: usize = 10; -/// JSON output format for version list #[derive(Serialize)] -struct VersionListJson { - versions: Vec, +struct RemoteEnvironmentJson { + #[serde(skip_serializing_if = "Option::is_none")] + node: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + package_managers: Option>>, } -/// JSON format for a single version entry #[derive(Serialize)] -struct VersionJson { +struct NodeVersionJson { version: String, lts: Option, latest: bool, @@ -33,435 +37,315 @@ 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 = selected_package_managers(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 = + config::resolve_version(&cwd).await.ok().map(|resolution| resolution.version); + let current_pm = package_manager::resolve_current(&cwd).await?; + let default_node = match config.default_node_version.as_deref() { + Some(selector) => Some(config::resolve_version_alias(selector, &provider).await?), + None => None, + }; + let default_pm = + config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + let default_pm = match default_pm { + Some((kind, selector)) => { + Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) + } + None => None, + }; + let home = vp_shared::get_vp_home()?; + + let node = node_versions.map(|versions| { + build_node_versions( + &versions, + pattern.as_deref(), + lts_only, + show_all, + &sort, + current_node.as_deref(), + default_node.as_deref(), + &list_installed_versions(home.join("js_runtime").join("node").as_path()), + ) + }); + let package_managers = scope.includes_package_managers().then(|| { + package_manager_versions + .into_iter() + .map(|(kind, versions)| { + let installed = list_complete_package_manager_versions(&home, kind); + let entries = build_package_manager_versions( + versions, + pattern.as_deref(), + show_all, + &sort, + &installed, + current_pm.as_ref().and_then(|current| { + (current.package_manager_type == kind).then_some(current.version.as_str()) + }), + default_pm.as_ref().and_then(|(default, version)| { + (*default == kind).then_some(version.as_str()) + }), + ); + (kind.to_string(), entries) + }) + .collect() + }); + + if json { + println!( + "{}", + serde_json::to_string_pretty(&RemoteEnvironmentJson { node, package_managers })? + ); return Ok(ExitStatus::default()); } - - // Locally-derived markers (installed / current / default) used to annotate output. - let markers = local_markers(&cwd, &provider).await; - - // Filter versions based on options - let mut filtered = filter_versions(&versions, pattern.as_deref(), lts_only, show_all); - - // fetch_version_index() returns newest-first (desc). - // For asc (default), reverse to show oldest-first. - if matches!(sort, SortingMethod::Asc) { - filtered.reverse(); + if let Some(node) = node { + println!("Node.js"); + for entry in node { + println!( + " {}{}", + entry.version, + marker(entry.installed, entry.current, entry.default) + ); + } } - - if json_output { - print_json(&filtered, &versions, &markers)?; - } else { - print_human(&filtered, &markers); + if let Some(mut package_managers) = package_managers { + for kind in selected_package_managers(scope) { + let name = kind.to_string(); + let versions = package_managers.remove(&name).unwrap_or_default(); + println!(); + println!("{}", package_manager_title(kind)); + for entry in versions { + println!( + " {}{}", + entry.version, + marker(entry.installed, entry.current, entry.default) + ); + } + } } - Ok(ExitStatus::default()) } -/// Collect the locally-derived markers (installed / current / default). -/// -/// All lookups degrade gracefully: failures yield empty/none so the registry -/// listing still renders. -async fn local_markers(cwd: &AbsolutePathBuf, provider: &NodeProvider) -> LocalMarkers { - let installed = installed_versions(); - // Version resolved for the current project/cwd (same logic as `vp env current`); - // this is already a concrete version, never an alias. - let current = config::resolve_version(cwd).await.ok().map(|r| r.version); - // Global default may be stored as an alias (e.g. `lts`/`latest`) by - // `vp env default`, so resolve it to a concrete version before comparing - // against exact remote versions. - let default = match config::load_config().await.ok().and_then(|c| c.default_node_version) { - Some(alias) => config::resolve_version_alias(&alias, provider).await.ok(), - None => None, - }; - - LocalMarkers { installed, current, default } -} - -/// Collect the set of locally installed Node.js versions (without `v` prefix). -fn installed_versions() -> std::collections::HashSet { - let Ok(home_dir) = vp_shared::get_vp_home() else { - return std::collections::HashSet::new(); - }; - let node_dir = home_dir.join("js_runtime").join("node"); - super::list::list_installed_versions(node_dir.as_path()).into_iter().collect() +fn package_manager_title(kind: PackageManagerType) -> &'static str { + match kind { + PackageManagerType::Npm => "npm", + PackageManagerType::Pnpm => "pnpm", + PackageManagerType::Yarn => "Yarn", + PackageManagerType::Bun => "Bun", + } } -/// Strip a leading `v` from a version string, if present. -fn strip_v(version: &str) -> &str { - version.strip_prefix('v').unwrap_or(version) +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())), + } } -/// 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 selected_package_managers(scope: EnvScope) -> Vec { + match scope { + EnvScope::All | EnvScope::PackageManagers => ALL_PACKAGE_MANAGERS.to_vec(), + EnvScope::PackageManager(kind) => vec![kind], + EnvScope::Node => Vec::new(), + } } -/// Filter versions based on criteria. -fn filter_versions<'a>( - versions: &'a [NodeVersionEntry], +fn build_node_versions( + versions: &[NodeVersionEntry], pattern: Option<&str>, lts_only: bool, show_all: bool, -) -> Vec<&'a NodeVersionEntry> { - let mut filtered: Vec<&'a NodeVersionEntry> = versions.iter().collect(); - - // Filter by LTS if requested - if lts_only { - filtered.retain(|v| v.is_lts()); - } - - // Filter by pattern (major version) - if let Some(pattern) = pattern { - filtered.retain(|v| { - let version_str = v.version.strip_prefix('v').unwrap_or(&v.version); - version_str.starts_with(pattern) || version_str.starts_with(&format!("{pattern}.")) - }); - } - - // Limit to recent major versions unless --all is specified - if !show_all && pattern.is_none() { - filtered = limit_to_recent_majors(filtered, DEFAULT_MAJOR_VERSIONS); + sort: &SortingMethod, + current: Option<&str>, + default: Option<&str>, + installed: &[String], +) -> Vec { + let latest = versions.first().map(|entry| entry.version.as_str()); + let latest_lts = + versions.iter().find(|entry| entry.is_lts()).map(|entry| entry.version.as_str()); + let mut filtered = filter_recent( + versions.iter().filter(|entry| { + (!lts_only || entry.is_lts()) && matches_pattern(&entry.version, pattern) + }), + show_all || pattern.is_some(), + |entry| &entry.version, + ); + if matches!(sort, SortingMethod::Asc) { + filtered.reverse(); } - filtered -} - -/// Extract major version from a version string like "v20.18.0" or "20.18.0" -fn extract_major(version: &str) -> Option { - let version_str = version.strip_prefix('v').unwrap_or(version); - version_str.split('.').next()?.parse().ok() -} - -/// Limit versions to the N most recent major versions. -fn limit_to_recent_majors( - versions: Vec<&NodeVersionEntry>, - max_majors: usize, -) -> Vec<&NodeVersionEntry> { - // Get unique major versions - let mut majors: Vec = versions.iter().filter_map(|v| extract_major(&v.version)).collect(); - - majors.sort_unstable(); - majors.dedup(); - majors.reverse(); - - // Keep only the most recent N majors - let recent_majors: std::collections::HashSet = - majors.into_iter().take(max_majors).collect(); - - versions .into_iter() - .filter(|v| extract_major(&v.version).is_some_and(|m| recent_majors.contains(&m))) + .map(|entry| { + let version = entry.version.strip_prefix('v').unwrap_or(&entry.version).to_string(); + NodeVersionJson { + lts: match &entry.lts { + LtsInfo::Codename(name) => Some(name.to_string()), + _ => None, + }, + latest: latest == Some(entry.version.as_str()), + latest_lts: latest_lts == Some(entry.version.as_str()), + installed: installed.contains(&version), + current: current == Some(version.as_str()), + default: default == Some(version.as_str()), + version, + } + }) .collect() } -/// Build the JSON entries for the given versions. -fn build_json( - versions: &[&NodeVersionEntry], - all_versions: &[NodeVersionEntry], - markers: &LocalMarkers, -) -> Vec { - // Find the latest version and latest LTS - let latest_version = all_versions.first().map(|v| &v.version); - let latest_lts_version = all_versions.iter().find(|v| v.is_lts()).map(|v| &v.version); - +fn build_package_manager_versions( + mut versions: Vec, + pattern: Option<&str>, + show_all: bool, + sort: &SortingMethod, + installed: &[String], + current: Option<&str>, + default: Option<&str>, +) -> Vec { + let latest = + versions.iter().rev().find(|version| !version.is_prerelease()).map(ToString::to_string); + versions.retain(|version| { + !version.is_prerelease() && matches_pattern(&version.to_string(), pattern) + }); + if !show_all && pattern.is_none() { + let recent_majors = versions + .iter() + .rev() + .map(|version| version.major) + .collect::>() + .into_iter() + .rev() + .take(DEFAULT_MAJOR_VERSIONS) + .collect::>(); + versions.retain(|version| recent_majors.contains(&version.major)); + } + if matches!(sort, SortingMethod::Desc) { + versions.reverse(); + } versions - .iter() - .map(|v| { - let lts = match &v.lts { - LtsInfo::Codename(name) => Some(name.to_string()), - _ => None, - }; - let is_latest = latest_version.is_some_and(|lv| lv == &v.version); - let is_latest_lts = latest_lts_version.is_some_and(|llv| llv == &v.version); - let version = strip_v(&v.version).to_string(); - let is_installed = markers.installed.contains(&version); - let is_current = markers.current.as_deref() == Some(version.as_str()); - let is_default = markers.default.as_deref() == Some(version.as_str()); - - VersionJson { + .into_iter() + .map(|version| { + let version = version.to_string(); + PackageManagerVersionJson { + latest: latest.as_deref() == Some(version.as_str()), + installed: installed.contains(&version), + current: current == Some(version.as_str()), + default: default == Some(version.as_str()), version, - lts, - latest: is_latest, - latest_lts: is_latest_lts, - installed: is_installed, - current: is_current, - default: is_default, } }) .collect() } -/// Print versions as JSON. -fn print_json( - versions: &[&NodeVersionEntry], - all_versions: &[NodeVersionEntry], - markers: &LocalMarkers, -) -> Result<(), Error> { - let output = VersionListJson { versions: build_json(versions, all_versions, markers) }; - println!("{}", serde_json::to_string_pretty(&output)?); - - Ok(()) -} - -/// Print versions in human-readable format (fnm-style). -/// -/// Installed versions are highlighted (green, blue for the current project version) -/// when stdout supports color, and marked with a leading `*` otherwise so the -/// distinction survives piped output. The current/default versions are annotated -/// with trailing `current`/`default` labels. -fn print_human(versions: &[&NodeVersionEntry], markers: &LocalMarkers) { - if versions.is_empty() { - eprintln!("{}", "No versions were found!".red()); - return; +fn filter_recent<'a, T: 'a>( + values: impl Iterator, + show_all: bool, + version: impl Fn(&T) -> &str, +) -> Vec<&'a T> { + let values = values.collect::>(); + if show_all { + return values; } + let majors = values + .iter() + .filter_map(|value| major(version(value))) + .collect::>() + .into_iter() + .rev() + .take(DEFAULT_MAJOR_VERSIONS) + .collect::>(); + values + .into_iter() + .filter(|value| major(version(value)).is_some_and(|v| majors.contains(&v))) + .collect() +} - let colorize = use_color(); - - for version in versions { - let version_str = &version.version; - let stripped = strip_v(version_str); - // Ensure v prefix - let display = if version_str.starts_with('v') { - version_str.to_string() - } else { - format!("v{version_str}") - }; - let is_installed = markers.installed.contains(stripped); - let is_current = markers.current.as_deref() == Some(stripped); - let is_default = markers.default.as_deref() == Some(stripped); - - let lts_suffix = match &version.lts { - LtsInfo::Codename(name) => format!(" ({name})"), - _ => String::new(), - }; +fn major(version: &str) -> Option { + version.strip_prefix('v').unwrap_or(version).split('.').next()?.parse().ok() +} - let mut labels = Vec::new(); - if is_current { - labels.push("current"); - } - if is_default { - labels.push("default"); - } - let label_suffix = - if labels.is_empty() { String::new() } else { format!(" {}", labels.join(" ")) }; +fn matches_pattern(version: &str, pattern: Option<&str>) -> bool { + let Some(pattern) = pattern else { + return true; + }; + let version = version.strip_prefix('v').unwrap_or(version); + version.starts_with(pattern) || version.starts_with(&format!("{pattern}.")) +} - if colorize { - // Color each segment independently to avoid nested ANSI resets. - // Current project version takes precedence (blue), else installed (green). - let version_part = if is_current { - display.bright_blue().to_string() - } else if is_installed { - display.green().to_string() - } else { - display - }; - let lts_part = if lts_suffix.is_empty() { - String::new() - } else { - lts_suffix.bright_blue().to_string() - }; - let label_part = if label_suffix.is_empty() { - String::new() - } else { - label_suffix.dimmed().to_string() - }; - println!("{version_part}{lts_part}{label_part}"); - } else { - // No color: use a `*` marker with an aligned gutter for plain rows. - let marker = if is_installed { "* " } else { " " }; - println!("{marker}{display}{lts_suffix}{label_suffix}"); - } +fn marker(installed: bool, current: bool, default: bool) -> String { + let mut markers = Vec::new(); + if installed { + markers.push("installed"); + } + if current { + markers.push("current"); } + if default { + markers.push("default"); + } + if markers.is_empty() { String::new() } else { format!(" ({})", markers.join(", ")) } } #[cfg(test)] mod tests { use super::*; - fn make_version(version: &str, lts: Option<&str>) -> NodeVersionEntry { - NodeVersionEntry { - version: version.into(), - lts: match lts { - Some(name) => LtsInfo::Codename(name.into()), - None => LtsInfo::Boolean(false), - }, - } - } - - fn markers(installed: &[&str], current: Option<&str>, default: Option<&str>) -> LocalMarkers { - LocalMarkers { - installed: installed.iter().map(|s| (*s).to_string()).collect(), - current: current.map(str::to_string), - default: default.map(str::to_string), - } - } - - #[test] - fn test_filter_versions_lts_only() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - ]; - - let filtered = filter_versions(&versions, None, true, false); - assert_eq!(filtered.len(), 2); - assert!(filtered.iter().all(|v| v.is_lts())); - } - - #[test] - fn test_filter_versions_by_pattern() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v22.12.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - ]; - - let filtered = filter_versions(&versions, Some("22"), false, true); - assert_eq!(filtered.len(), 2); - assert!(filtered.iter().all(|v| v.version.starts_with("v22."))); - } - #[test] - fn test_limit_to_recent_majors() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v23.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v21.0.0", None), - make_version("v20.18.0", Some("Iron")), - ]; - - let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); - let limited = limit_to_recent_majors(refs, 2); - - // Should only have v24 and v23 - assert_eq!(limited.len(), 2); - assert!(limited.iter().any(|v| v.version.starts_with("v24."))); - assert!(limited.iter().any(|v| v.version.starts_with("v23."))); - } - - #[test] - fn test_filter_versions_show_all_returns_all_versions() { - // Create versions spanning many major versions (more than DEFAULT_MAJOR_VERSIONS) - let versions = vec![ - make_version("v25.0.0", None), - make_version("v24.0.0", None), - make_version("v23.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v21.0.0", None), - make_version("v20.18.0", Some("Iron")), - make_version("v19.0.0", None), - make_version("v18.20.0", Some("Hydrogen")), - make_version("v17.0.0", None), - make_version("v16.20.0", Some("Gallium")), - make_version("v15.0.0", None), - make_version("v14.0.0", None), - ]; - - // Without show_all, should be limited to DEFAULT_MAJOR_VERSIONS (10) - let filtered_limited = filter_versions(&versions, None, false, false); - assert_eq!(filtered_limited.len(), 10); - - // With show_all=true, should return all versions - let filtered_all = filter_versions(&versions, None, false, true); - assert_eq!(filtered_all.len(), 12); - } - - #[test] - fn test_build_json_marks_installed_versions() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - ]; - let all_versions = versions.clone(); - let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); - - // Installed dirs are stored without the leading `v`. - let json = build_json(&refs, &all_versions, &markers(&["22.13.0"], None, None)); - - let installed_entry = json.iter().find(|v| v.version == "22.13.0").unwrap(); - assert!(installed_entry.installed); - - let not_installed = json.iter().find(|v| v.version == "24.0.0").unwrap(); - assert!(!not_installed.installed); - } - - #[test] - fn test_build_json_empty_installed_set() { - let versions = vec![make_version("v24.0.0", None)]; - let all_versions = versions.clone(); - let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); - - let json = build_json(&refs, &all_versions, &markers(&[], None, None)); - assert!(json.iter().all(|v| !v.installed && !v.current && !v.default)); - } - - #[test] - fn test_build_json_marks_current_and_default() { - let versions = vec![ - make_version("v24.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - ]; - let all_versions = versions.clone(); - let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); - - // Current project resolves to 22.13.0; global default is 20.18.0. - let json = build_json( - &refs, - &all_versions, - &markers(&["22.13.0", "20.18.0"], Some("22.13.0"), Some("20.18.0")), + fn legacy_pattern_keeps_all_components_selected() { + assert_eq!( + parse_scope_and_pattern(&["20".into()]).unwrap(), + (EnvScope::All, Some("20".into())) ); - - let current = json.iter().find(|v| v.version == "22.13.0").unwrap(); - assert!(current.current && current.installed && !current.default); - - let default = json.iter().find(|v| v.version == "20.18.0").unwrap(); - assert!(default.default && default.installed && !default.current); - - let plain = json.iter().find(|v| v.version == "24.0.0").unwrap(); - assert!(!plain.current && !plain.default && !plain.installed); - } - - #[test] - fn test_filter_versions_show_all_with_lts_filter() { - let versions = vec![ - make_version("v25.0.0", None), - make_version("v22.13.0", Some("Jod")), - make_version("v20.18.0", Some("Iron")), - make_version("v18.20.0", Some("Hydrogen")), - ]; - - // With lts_only and show_all, should return all LTS versions - let filtered = filter_versions(&versions, None, true, true); - assert_eq!(filtered.len(), 3); - assert!(filtered.iter().all(|v| v.is_lts())); } } diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index bae8bccd8c..adf6dc95d3 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -10,13 +10,16 @@ mod current; mod default; mod doctor; mod exec; +mod lifecycle; mod list; mod list_remote; mod off; mod on; +pub(crate) mod package_manager; pub mod package_metadata; mod pin; pub(crate) mod setup; +mod spec; mod unpin; mod r#use; mod which; @@ -46,8 +49,8 @@ fn print_env_clean_tip() { fn should_print_env_header(subcommand: &EnvSubcommands) -> bool { match subcommand { - EnvSubcommands::Current { json } => !json, - EnvSubcommands::List { json } => !json, + EnvSubcommands::Current { json, .. } => !json, + EnvSubcommands::List { json, .. } => !json, EnvSubcommands::ListRemote { json, .. } => !json, // Keep these machine-consumable / passthrough commands header-free. EnvSubcommands::Use { .. } | EnvSubcommands::Exec { .. } => false, @@ -57,24 +60,12 @@ fn should_print_env_header(subcommand: &EnvSubcommands) -> bool { fn should_print_env_clean_tip(subcommand: &EnvSubcommands) -> bool { match subcommand { - EnvSubcommands::List { json } => !json, + EnvSubcommands::List { json, .. } => !json, EnvSubcommands::ListRemote { json, .. } => !json, _ => false, } } -fn is_installable_version_source(source: &str) -> bool { - matches!( - source, - ".node-version" - | ".nvmrc" - | "engines.node" - | "devEngines.runtime" - | config::VERSION_ENV_VAR - | config::SESSION_VERSION_FILE - ) -} - /// Execute the env command based on the provided arguments. pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result { // Handle subcommands first @@ -85,75 +76,52 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result current::execute(cwd, json).await, - crate::cli::EnvSubcommands::Print => print_env(cwd).await, - crate::cli::EnvSubcommands::Default { version } => default::execute(cwd, version).await, - crate::cli::EnvSubcommands::On => on::execute().await, - crate::cli::EnvSubcommands::Off => off::execute().await, + crate::cli::EnvSubcommands::Current { scope, json } => { + current::execute(cwd, scope, json).await + } + crate::cli::EnvSubcommands::Print { scope } => print_env(cwd, scope).await, + crate::cli::EnvSubcommands::Default { values, unset } => { + default::execute(cwd, values, unset).await + } + crate::cli::EnvSubcommands::On { scope } => on::execute(scope).await, + crate::cli::EnvSubcommands::Off { scope } => off::execute(scope).await, crate::cli::EnvSubcommands::Setup { refresh, env_only } => { setup::execute(refresh, env_only).await } - crate::cli::EnvSubcommands::Doctor => doctor::execute(cwd).await, + crate::cli::EnvSubcommands::Doctor { scope } => doctor::execute(cwd, scope).await, crate::cli::EnvSubcommands::Which { tool } => which::execute(cwd, &tool).await, - crate::cli::EnvSubcommands::Pin { version, unpin, no_install, force, target } => { - pin::execute(cwd, version, unpin, no_install, force, target).await + crate::cli::EnvSubcommands::Pin { specs, unpin, no_install, force, target } => { + pin::execute(cwd, specs, unpin, no_install, force, target).await } - crate::cli::EnvSubcommands::Unpin { target } => unpin::execute(cwd, target).await, - crate::cli::EnvSubcommands::List { json } => list::execute(cwd, json).await, - crate::cli::EnvSubcommands::ListRemote { pattern, lts, all, json, sort } => { - list_remote::execute(cwd, pattern, lts, all, json, sort).await + crate::cli::EnvSubcommands::Unpin { scope, target } => { + unpin::execute(cwd, scope, target).await } - crate::cli::EnvSubcommands::Exec { node, npm, command } => { - exec::execute(node.as_deref(), npm.as_deref(), &command).await + crate::cli::EnvSubcommands::List { scope, json } => { + list::execute(cwd, scope, json).await } - crate::cli::EnvSubcommands::Uninstall { version } => { - let provider = vp_js_runtime::NodeProvider::new(); - let resolved = config::resolve_version_alias(&version, &provider).await?; - let home_dir = vp_shared::get_vp_home()?; - let version_dir = home_dir.join("js_runtime").join("node").join(&resolved); - if !version_dir.as_path().exists() { - eprintln!("Node.js v{} is not installed", resolved); - return Ok(exit_status(1)); - } - tokio::fs::remove_dir_all(version_dir.as_path()).await.map_err(|e| { - crate::error::Error::Other( - format!("Failed to remove Node.js v{}: {}", resolved, e).into(), - ) - })?; - println!("Uninstalled Node.js v{}", resolved); - Ok(ExitStatus::default()) + crate::cli::EnvSubcommands::ListRemote { values, lts, all, json, sort } => { + list_remote::execute(cwd, values, lts, all, json, sort).await } - crate::cli::EnvSubcommands::Clean => clean::execute(cwd).await, - crate::cli::EnvSubcommands::Use { version, unset, no_install, silent_if_unchanged } => { - r#use::execute(cwd, version, unset, no_install, silent_if_unchanged).await + crate::cli::EnvSubcommands::Exec { node, npm, package_manager, command } => { + exec::execute( + &cwd, + node.as_deref(), + npm.as_deref(), + package_manager.as_deref(), + &command, + ) + .await } - crate::cli::EnvSubcommands::Install { version } => { - let (resolved, from_session_override) = if let Some(version) = version { - let provider = vp_js_runtime::NodeProvider::new(); - (config::resolve_version_alias(&version, &provider).await?, false) - } else { - let resolution = config::resolve_version(&cwd).await?; - let from_session_override = matches!( - resolution.source.as_str(), - config::VERSION_ENV_VAR | config::SESSION_VERSION_FILE - ); - if !is_installable_version_source(&resolution.source) { - eprintln!("No Node.js version found in current project."); - eprintln!("Specify a version: vp env install "); - eprintln!("Or pin one: vp env pin "); - return Ok(exit_status(1)); - } - (resolution.version, from_session_override) - }; - println!("Installing Node.js v{}...", resolved); - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolved) - .await?; - println!("Installed Node.js v{}", resolved); - if from_session_override { - eprintln!("Note: Installed from session override."); - eprintln!("Run `vp env use --unset` to revert to project version resolution."); - } - Ok(ExitStatus::default()) + crate::cli::EnvSubcommands::Uninstall { specs } => lifecycle::uninstall(specs).await, + crate::cli::EnvSubcommands::Clean { scope } => clean::execute(cwd, scope).await, + crate::cli::EnvSubcommands::Use { + requests, + unset, + no_install, + silent_if_unchanged, + } => r#use::execute(cwd, requests, unset, no_install, silent_if_unchanged).await, + crate::cli::EnvSubcommands::Install { requests } => { + lifecycle::install(cwd, requests).await } }; @@ -181,36 +149,71 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result Result { +async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result { + let scope = spec::EnvScope::parse(scope.as_deref())?; + let modes = config::load_config().await?; // Resolve the Node.js version for the current directory - let resolution = config::resolve_version(&cwd).await?; + let resolution = scope.includes_node().then(|| config::resolve_version(&cwd)); + let resolution = match resolution { + Some(resolution) => Some(resolution.await?), + None => None, + }; // Get the node bin directory - let runtime = - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolution.version) + let mut bin_dirs = Vec::new(); + if let Some(resolution) = resolution { + if modes.shim_mode == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool("node") + && let Some(bin_dir) = path.parent() + { + bin_dirs.push(bin_dir.as_path().display().to_string()); + } else { + let runtime = vp_js_runtime::download_runtime( + vp_js_runtime::JsRuntimeType::Node, + &resolution.version, + ) .await?; - - let bin_dir = runtime.get_bin_prefix(); - let snippet = match detect_shell() { - Shell::NuShell => { - format!("$env.PATH = ($env.PATH | prepend \"{}\")", bin_dir.as_path().display()) + bin_dirs.push(runtime.get_bin_prefix().as_path().display().to_string()); } - _ => format!("export PATH=\"{}:$PATH\"", bin_dir.as_path().display()), + } + if scope.includes_package_managers() + && let Some(resolution) = package_manager::resolve_current(&cwd).await? + && !matches!(scope, spec::EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) + { + if modes.package_manager_shim_mode() == config::ShimMode::SystemFirst + && let Some(path) = crate::shim::dispatch::find_system_tool( + &resolution.package_manager_type.to_string(), + ) + && let Some(bin_dir) = path.parent() + { + bin_dirs.insert(0, bin_dir.as_path().display().to_string()); + } else { + let (install_dir, _, _) = vp_pm_cli::download_package_manager( + resolution.package_manager_type, + &resolution.version, + resolution.hash.as_deref(), + ) + .await?; + bin_dirs.insert(0, install_dir.join("bin").as_path().display().to_string()); + } + } + if bin_dirs.is_empty() { + return Err(Error::Other("no selected environment component could be resolved".into())); + } + let snippet = match detect_shell() { + Shell::Posix => format!("export PATH=\"{}:$PATH\"", bin_dirs.join(":")), + Shell::Fish => format!("set -gx PATH {} $PATH", bin_dirs.join(" ")), + Shell::PowerShell => format!("$env:PATH = \"{};$env:PATH\"", bin_dirs.join(";")), + Shell::Cmd => format!("set PATH={};%PATH%", bin_dirs.join(";")), + Shell::NuShell => format!( + "$env.PATH = ($env.PATH | prepend [{}])", + bin_dirs.iter().map(|path| format!("\"{path}\"")).collect::>().join(", ") + ), }; // Print shell snippet - println!("# Add to your shell to use this Node.js version for this session:"); + println!("# Add to your shell to use this environment for this session:"); println!("{snippet}"); Ok(ExitStatus::default()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn nvmrc_is_an_installable_version_source() { - assert!(is_installable_version_source(".nvmrc")); - } -} diff --git a/crates/vp_global_cli/src/commands/env/off.rs b/crates/vp_global_cli/src/commands/env/off.rs index 461fdef0bf..099e31bf12 100644 --- a/crates/vp_global_cli/src/commands/env/off.rs +++ b/crates/vp_global_cli/src/commands/env/off.rs @@ -5,31 +5,39 @@ use std::process::ExitStatus; -use super::config::{ShimMode, load_config, save_config}; +use super::{ + config::{ShimMode, load_config, save_config}, + spec::EnvScope, +}; use crate::{error::Error, help}; /// Execute the `vp env off` command. -pub async fn execute() -> Result { - let mut config = load_config().await?; - - if config.shim_mode == ShimMode::SystemFirst { - println!("Node.js management is already set to system-first."); - println!( - "All vp commands and shims will prefer system Node.js, falling back to managed if not found." - ); - return Ok(ExitStatus::default()); +pub async fn execute(scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; + if matches!(scope, EnvScope::PackageManager(_)) { + return Err(Error::Other("off accepts only node or pm as a scope".into())); } - - config.shim_mode = ShimMode::SystemFirst; + let mut config = load_config().await?; + config.set_shim_modes( + scope.includes_node(), + scope.includes_package_managers(), + ShimMode::SystemFirst, + ); save_config(&config).await?; - println!("\u{2713} Node.js management set to system-first."); + let component = match scope { + EnvScope::All => "Node.js and package-manager management", + EnvScope::Node => "Node.js management", + EnvScope::PackageManagers => "Package-manager management", + EnvScope::PackageManager(_) => unreachable!(), + }; + println!("\u{2713} {component} set to system-first."); println!(); println!( - "All vp commands and shims will now prefer system Node.js, falling back to managed if not found." + "Selected commands and shims will now prefer system tools, falling back to managed tools." ); println!(); - println!("Run {} to always use Vite+ managed Node.js.", help::accent_command("vp env on")); + println!("Run {} to always use Vite+ managed tools.", help::accent_command("vp env on")); Ok(ExitStatus::default()) } diff --git a/crates/vp_global_cli/src/commands/env/on.rs b/crates/vp_global_cli/src/commands/env/on.rs index 6ded635215..a4f74afd76 100644 --- a/crates/vp_global_cli/src/commands/env/on.rs +++ b/crates/vp_global_cli/src/commands/env/on.rs @@ -4,27 +4,37 @@ use std::process::ExitStatus; -use super::config::{ShimMode, load_config, save_config}; +use super::{ + config::{ShimMode, load_config, save_config}, + spec::EnvScope, +}; use crate::{error::Error, help}; /// Execute the `vp env on` command. -pub async fn execute() -> Result { - let mut config = load_config().await?; - - if config.shim_mode == ShimMode::Managed { - println!("Node.js management is already set to managed."); - println!("All vp commands and shims will always use Vite+ managed Node.js."); - return Ok(ExitStatus::default()); +pub async fn execute(scope: Option) -> Result { + let scope = EnvScope::parse(scope.as_deref())?; + if matches!(scope, EnvScope::PackageManager(_)) { + return Err(Error::Other("on accepts only node or pm as a scope".into())); } - - config.shim_mode = ShimMode::Managed; + let mut config = load_config().await?; + config.set_shim_modes( + scope.includes_node(), + scope.includes_package_managers(), + ShimMode::Managed, + ); save_config(&config).await?; - println!("\u{2713} Node.js management set to managed."); + let component = match scope { + EnvScope::All => "Node.js and package-manager management", + EnvScope::Node => "Node.js management", + EnvScope::PackageManagers => "Package-manager management", + EnvScope::PackageManager(_) => unreachable!(), + }; + println!("\u{2713} {component} set to managed."); println!(); - println!("All vp commands and shims will now always use Vite+ managed Node.js."); + println!("Selected commands and shims will now use Vite+ managed tools."); println!(); - println!("Run {} to prefer system Node.js instead.", help::accent_command("vp env off")); + println!("Run {} to prefer system tools instead.", help::accent_command("vp env off")); Ok(ExitStatus::default()) } diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs new file mode 100644 index 0000000000..ef34d0db2f --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -0,0 +1,57 @@ +use vp_pm_cli::{ + EnvironmentPackageManagerResolution, PackageManagerType, resolve_environment_package_manager, +}; +use vt_path::AbsolutePath; + +use super::{config, spec::parse_package_manager_spec}; +use crate::error::Error; + +pub(crate) async fn resolve_current( + cwd: &AbsolutePath, +) -> Result, Error> { + let (session, session_source) = if let Some(spec) = vp_shared::EnvConfig::get().package_manager + { + (Some(parse_package_manager_spec(spec.trim())?), Some(config::PACKAGE_MANAGER_ENV_VAR)) + } else if let Some(spec) = config::read_session_package_manager().await { + (Some(parse_package_manager_spec(spec.trim())?), Some(config::SESSION_PACKAGE_MANAGER_FILE)) + } else { + (None, None) + }; + let config = config::load_config().await?; + let default = + config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + + let mut resolution = resolve_environment_package_manager( + cwd, + session.as_ref().map(|(kind, version)| (*kind, version.as_str())), + default.as_ref().map(|(kind, version)| (*kind, version.as_str())), + ) + .await + .map_err(Error::from)?; + if let (Some(resolution), Some(source)) = (&mut resolution, session_source) { + resolution.source = source.into(); + } + Ok(resolution) +} + +pub(crate) async fn resolve_from_files( + cwd: &AbsolutePath, +) -> Result, Error> { + let config = config::load_config().await?; + let default = + config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; + resolve_environment_package_manager( + cwd, + None, + default.as_ref().map(|(kind, version)| (*kind, version.as_str())), + ) + .await + .map_err(Error::from) +} + +pub(crate) const ALL_PACKAGE_MANAGERS: [PackageManagerType; 4] = [ + PackageManagerType::Npm, + PackageManagerType::Pnpm, + PackageManagerType::Yarn, + PackageManagerType::Bun, +]; diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 23b3468373..779f7150ce 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -10,10 +10,17 @@ 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}, + spec::{EnvScope, EnvSpecs}, +}; use crate::{cli::PinTarget, error::Error}; /// Node version file name @@ -25,7 +32,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 +40,61 @@ 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)?; + if specs.package_manager.is_some() + && !tokio::fs::try_exists(cwd.join(PACKAGE_JSON_FILE)).await.unwrap_or(false) + { + return Err(Error::Other("cannot pin a package manager without package.json".into())); + } + 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(), + )); + } + + if let Some(version) = specs.node { + do_pin(&cwd, &version, no_install, force, target).await?; + } + if let Some((package_manager, version)) = specs.package_manager { + pin_package_manager(&cwd, package_manager, &version, no_install, force, target).await?; } + Ok(ExitStatus::default()) +} - match version { - Some(v) => do_pin(&cwd, &v, no_install, force, target).await, - None => show_pinned(&cwd).await, +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 +227,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 +636,225 @@ pub async fn do_unpin( println!("No Node.js pin found in current directory."); } } + PinTarget::PackageManager => { + return Err(Error::Other( + "--target package-manager requires package-manager scope".into(), + )); + } } Ok(ExitStatus::default()) } +pub async fn do_unpin_scope( + cwd: &AbsolutePathBuf, + scope: EnvScope, + target: Option, +) -> Result { + if matches!(scope, EnvScope::Node) && matches!(target, Some(PinTarget::PackageManager)) { + return Err(Error::Other( + "--target package-manager is incompatible with node scope".into(), + )); + } + if scope.includes_package_managers() + && !scope.includes_node() + && matches!(target, Some(PinTarget::NodeVersion)) + { + return Err(Error::Other( + "--target node-version is incompatible with package-manager scope".into(), + )); + } + if scope.includes_node() && !matches!(target, Some(PinTarget::PackageManager)) { + do_unpin(cwd, target).await?; + } + if scope.includes_package_managers() { + unpin_package_manager(cwd, scope, target).await?; + } + Ok(ExitStatus::default()) +} + +async fn pin_package_manager( + cwd: &AbsolutePathBuf, + package_manager: PackageManagerType, + version: &str, + no_install: bool, + _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?; + let package_json_path = cwd.join(PACKAGE_JSON_FILE); + let content = tokio::fs::read_to_string(&package_json_path).await?; + let mut changed = false; + let updated = vp_shared::edit_json_object(&content, |obj| { + let use_top_level = matches!(target, Some(PinTarget::PackageManager)) + || (target.is_none() && obj.get("packageManager").is_some()); + if use_top_level { + let existing = obj.get("packageManager").and_then(serde_json::Value::as_str); + let prefix = format!("{package_manager}@{resolved}"); + let next = existing + .filter(|value| { + *value == prefix + || value.strip_prefix(&prefix).is_some_and(|suffix| suffix.starts_with('+')) + }) + .unwrap_or(&prefix) + .to_string(); + if obj.get("packageManager").and_then(serde_json::Value::as_str) != Some(&next) { + obj.insert("packageManager".into(), serde_json::Value::String(next)); + changed = true; + } + } else { + set_dev_engines_package_manager(obj, package_manager, &resolved); + changed = true; + } + }) + .map_err(|error| Error::Other(format!("failed to update package.json: {error}").into()))?; + if !changed { + println!("Already pinned to {package_manager}@{resolved}"); + return Ok(ExitStatus::default()); + } + tokio::fs::write(&package_json_path, updated).await?; + crate::shim::invalidate_cache(); + output::success(&format!("Pinned package manager to {package_manager}@{resolved}")); + if no_install { + output::note("Package manager will be downloaded on first use."); + } else if let Err(error) = download_package_manager(package_manager, &resolved, None).await { + output::warn(&format!("Failed to download {package_manager} {resolved}: {error}")); + } + Ok(ExitStatus::default()) +} + +fn set_dev_engines_package_manager( + obj: &mut serde_json::Map, + package_manager: PackageManagerType, + version: &str, +) { + use serde_json::Value; + + let entry = vp_shared::dev_engine_entry(&package_manager.to_string(), version); + let Some(dev_engines) = obj.get_mut("devEngines").and_then(Value::as_object_mut) else { + vp_shared::insert_after( + obj, + "engines", + "devEngines", + serde_json::json!({ "packageManager": entry }), + ); + return; + }; + let Some(field) = dev_engines.get_mut("packageManager") else { + dev_engines.insert("packageManager".into(), entry); + return; + }; + match field { + Value::Object(value) + if value.get("name").and_then(Value::as_str) + == Some(package_manager.to_string().as_str()) => + { + value.insert("version".into(), Value::String(version.into())); + } + Value::Object(_) => { + let previous = std::mem::take(field); + *field = Value::Array(vec![previous, entry]); + } + Value::Array(entries) => { + if let Some(value) = entries.iter_mut().filter_map(Value::as_object_mut).find(|value| { + value.get("name").and_then(Value::as_str) + == Some(package_manager.to_string().as_str()) + }) { + value.insert("version".into(), Value::String(version.into())); + } else { + entries.push(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 package_json_path = cwd.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(cwd)?; + let expected = match scope { + EnvScope::PackageManager(package_manager) => Some(package_manager), + _ => effective.as_ref().map(|resolution| resolution.package_manager_type), + }; + let mut changed = false; + let updated = vp_shared::edit_json_object(&content, |obj| { + let remove_top_level = matches!(target, Some(PinTarget::PackageManager)) + || (target.is_none() && obj.get("packageManager").is_some()); + let top_level_matches = expected.is_none_or(|expected| { + obj.get("packageManager") + .and_then(serde_json::Value::as_str) + .and_then(|value| value.split_once('@')) + .and_then(|(name, _)| PackageManagerType::from_name(name)) + == Some(expected) + }); + if remove_top_level && top_level_matches { + changed = obj.remove("packageManager").is_some(); + } else if let Some(expected) = expected { + changed = remove_dev_engines_package_manager(obj, expected); + } + }) + .map_err(|error| Error::Other(format!("failed to update package.json: {error}").into()))?; + if changed { + tokio::fs::write(&package_json_path, updated).await?; + crate::shim::invalidate_cache(); + output::success("Removed package-manager pin"); + } else { + println!("No package manager pin found in current directory."); + } + Ok(()) +} + +fn remove_dev_engines_package_manager( + obj: &mut serde_json::Map, + expected: PackageManagerType, +) -> bool { + let Some(dev_engines) = obj.get_mut("devEngines").and_then(serde_json::Value::as_object_mut) + else { + return false; + }; + let Some(field) = dev_engines.get_mut("packageManager") else { + return false; + }; + let expected = expected.to_string(); + let changed = match field { + serde_json::Value::Object(entry) => { + entry.get("name").and_then(serde_json::Value::as_str) == Some(expected.as_str()) + } + serde_json::Value::Array(entries) => { + let before = entries.len(); + entries.retain(|entry| { + entry.get("name").and_then(serde_json::Value::as_str) != Some(expected.as_str()) + }); + before != entries.len() + } + _ => false, + }; + let remove_field = changed + && match field { + serde_json::Value::Object(_) => true, + serde_json::Value::Array(entries) => entries.is_empty(), + _ => false, + }; + if remove_field { + dev_engines.remove("packageManager"); + } + changed +} + #[cfg(test)] mod tests { use serial_test::serial; @@ -589,6 +863,102 @@ mod tests { use super::*; + #[tokio::test] + async fn package_manager_pin_preserves_matching_integrity_suffix() { + let temp_dir = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write( + cwd.join("package.json"), + "{\n \"packageManager\": \"pnpm@10.18.0+sha512.keep\"\n}\n", + ) + .await + .unwrap(); + + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, false, None) + .await + .unwrap(); + + let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(content.contains("pnpm@10.18.0+sha512.keep")); + } + + #[tokio::test] + async fn package_manager_pin_drops_stale_integrity_suffix() { + let temp_dir = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write( + cwd.join("package.json"), + "{\n \"packageManager\": \"pnpm@10.17.0+sha512.stale\"\n}\n", + ) + .await + .unwrap(); + + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, false, None) + .await + .unwrap(); + + let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(content.contains("pnpm@10.18.0")); + assert!(!content.contains("sha512.stale")); + } + + #[test] + fn set_package_manager_updates_matching_array_entry_and_keeps_siblings() { + let mut manifest = serde_json::json!({ + "devEngines": { + "packageManager": [ + { "name": "npm", "version": "11.0.0", "onFail": "error" }, + { "name": "pnpm", "version": "10.17.0", "onFail": "download" } + ] + } + }); + set_dev_engines_package_manager( + manifest.as_object_mut().unwrap(), + PackageManagerType::Pnpm, + "10.18.0", + ); + let entries = manifest["devEngines"]["packageManager"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["version"], "11.0.0"); + assert_eq!(entries[1]["version"], "10.18.0"); + assert_eq!(entries[1]["onFail"], "download"); + } + + #[test] + fn remove_last_package_manager_array_entry_removes_field() { + let mut manifest = serde_json::json!({ + "devEngines": { + "packageManager": [{ "name": "pnpm", "version": "10.18.0" }], + "runtime": { "name": "node", "version": "22.0.0" } + } + }); + assert!(remove_dev_engines_package_manager( + manifest.as_object_mut().unwrap(), + PackageManagerType::Pnpm, + )); + assert!(manifest["devEngines"].get("packageManager").is_none()); + assert_eq!(manifest["devEngines"]["runtime"]["version"], "22.0.0"); + } + + #[tokio::test] + async fn package_manager_unpin_target_does_not_remove_node_pin() { + let temp_dir = TempDir::new().unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + tokio::fs::write(cwd.join(".node-version"), "22.0.0\n").await.unwrap(); + tokio::fs::write( + cwd.join("package.json"), + "{\n \"packageManager\": \"pnpm@10.18.0\"\n}\n", + ) + .await + .unwrap(); + + do_unpin_scope(&cwd, EnvScope::All, Some(PinTarget::PackageManager)).await.unwrap(); + + assert!(cwd.join(".node-version").as_path().exists()); + let manifest = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); + assert!(!manifest.contains("packageManager")); + } + #[tokio::test] async fn test_show_pinned_no_file() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vp_global_cli/src/commands/env/spec.rs b/crates/vp_global_cli/src/commands/env/spec.rs new file mode 100644 index 0000000000..1c3e3fb793 --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/spec.rs @@ -0,0 +1,121 @@ +use vp_pm_cli::PackageManagerType; + +use crate::error::Error; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EnvScope { + All, + Node, + PackageManagers, + PackageManager(PackageManagerType), +} + +impl EnvScope { + pub(crate) fn parse(value: Option<&str>) -> Result { + let Some(value) = value else { + return Ok(Self::All); + }; + match value { + "node" => Ok(Self::Node), + "pm" => Ok(Self::PackageManagers), + name => PackageManagerType::from_name(name) + .map(Self::PackageManager) + .ok_or_else(|| invalid_scope(name)), + } + } + + pub(crate) fn includes_node(self) -> bool { + matches!(self, Self::All | Self::Node) + } + + pub(crate) fn includes_package_managers(self) -> bool { + !matches!(self, Self::Node) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct EnvSpecs { + pub(crate) node: Option, + pub(crate) package_manager: Option<(PackageManagerType, String)>, +} + +impl EnvSpecs { + pub(crate) fn parse(values: &[String]) -> Result { + let mut parsed = Self::default(); + for value in values { + if let Some((name, version)) = value.split_once('@') { + if version.is_empty() { + return Err(invalid_spec(value)); + } + if name == "node" { + if parsed.node.replace(version.to_string()).is_some() { + return Err(duplicate("Node.js")); + } + } else { + let package_manager = + PackageManagerType::from_name(name).ok_or_else(|| invalid_spec(value))?; + if parsed + .package_manager + .replace((package_manager, version.to_string())) + .is_some() + { + return Err(duplicate("package manager")); + } + } + } else if EnvScope::parse(Some(value)).is_ok() { + return Err(Error::Other( + format!("{value:?} is a component selector, not a version specification") + .into(), + )); + } else if parsed.node.replace(value.clone()).is_some() { + return Err(duplicate("Node.js")); + } + } + Ok(parsed) + } +} + +pub(crate) fn parse_package_manager_spec( + value: &str, +) -> Result<(PackageManagerType, String), Error> { + let Some((name, version)) = value.split_once('@') else { + return Err(invalid_spec(value)); + }; + let package_manager = PackageManagerType::from_name(name).ok_or_else(|| invalid_spec(value))?; + if version.is_empty() { + return Err(invalid_spec(value)); + } + Ok((package_manager, version.to_string())) +} + +fn invalid_scope(value: &str) -> Error { + Error::Other( + format!("invalid environment scope {value:?}; expected node, pm, npm, pnpm, yarn, or bun") + .into(), + ) +} + +fn invalid_spec(value: &str) -> Error { + Error::Other( + format!( + "invalid environment specification {value:?}; expected a Node.js version or node|npm|pnpm|yarn|bun@" + ) + .into(), + ) +} + +fn duplicate(component: &str) -> Error { + Error::Other(format!("only one {component} specification may be supplied").into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_legacy_node_and_package_manager_specs() { + let parsed = EnvSpecs::parse(&["22.0.0".into(), "pnpm@10.18.0".into()]).unwrap(); + assert_eq!(parsed.node.as_deref(), Some("22.0.0")); + assert_eq!(parsed.package_manager, Some((PackageManagerType::Pnpm, "10.18.0".into()))); + } +} diff --git a/crates/vp_global_cli/src/commands/env/unpin.rs b/crates/vp_global_cli/src/commands/env/unpin.rs index 234bac5202..44a3b919a5 100644 --- a/crates/vp_global_cli/src/commands/env/unpin.rs +++ b/crates/vp_global_cli/src/commands/env/unpin.rs @@ -8,9 +8,14 @@ use std::process::ExitStatus; use vt_path::AbsolutePathBuf; +use super::spec::EnvScope; use crate::{cli::PinTarget, error::Error}; /// Execute the unpin command. -pub async fn execute(cwd: AbsolutePathBuf, target: Option) -> Result { - super::pin::do_unpin(&cwd, target).await +pub async fn execute( + cwd: AbsolutePathBuf, + scope: Option, + target: Option, +) -> Result { + super::pin::do_unpin_scope(&cwd, EnvScope::parse(scope.as_deref())?, target).await } diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 7b07cf94d8..52829cb242 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -1,7 +1,7 @@ //! Implementation of `vp env use` command. //! //! Outputs shell-appropriate commands to stdout that set (or unset) -//! the `VP_NODE_VERSION` environment variable. The shell function +//! the Node.js and package-manager environment variables. The shell function //! wrapper in `~/.vite-plus/env` evals this output to modify the current //! shell session. //! @@ -10,11 +10,13 @@ use std::process::ExitStatus; +use vp_pm_cli::{download_package_manager, resolve_package_manager_version}; use vt_path::AbsolutePathBuf; use super::{ - config::{self, VERSION_ENV_VAR}, - exit_status, + config::{self, PACKAGE_MANAGER_ENV_VAR, VERSION_ENV_VAR}, + exit_status, package_manager, + spec::{EnvScope, EnvSpecs}, }; use crate::{ commands::shell::{Shell, detect_shell}, @@ -22,26 +24,26 @@ use crate::{ }; /// Format a shell export command for the detected shell. -fn format_export(shell: &Shell, value: &str) -> String { +fn format_export(shell: &Shell, variable: &str, value: &str) -> String { match shell { - Shell::Posix => format!("export {VERSION_ENV_VAR}={value}"), - Shell::Fish => format!("set -gx {VERSION_ENV_VAR} {value}"), - Shell::PowerShell => format!("$env:{VERSION_ENV_VAR} = \"{value}\""), - Shell::Cmd => format!("set {VERSION_ENV_VAR}={value}"), - Shell::NuShell => format!("$env.{VERSION_ENV_VAR} = \"{value}\""), + Shell::Posix => format!("export {variable}={value}"), + Shell::Fish => format!("set -gx {variable} {value}"), + Shell::PowerShell => format!("$env:{variable} = \"{value}\""), + Shell::Cmd => format!("set {variable}={value}"), + Shell::NuShell => format!("$env.{variable} = \"{value}\""), } } /// Format a shell unset command for the detected shell. -fn format_unset(shell: &Shell) -> String { +fn format_unset(shell: &Shell, variable: &str) -> String { match shell { - Shell::Posix => format!("unset {VERSION_ENV_VAR}"), - Shell::Fish => format!("set -e {VERSION_ENV_VAR}"), + Shell::Posix => format!("unset {variable}"), + Shell::Fish => format!("set -e {variable}"), Shell::PowerShell => { - format!("Remove-Item Env:{VERSION_ENV_VAR} -ErrorAction SilentlyContinue") + format!("Remove-Item Env:{variable} -ErrorAction SilentlyContinue") } - Shell::Cmd => format!("set {VERSION_ENV_VAR}="), - Shell::NuShell => format!("hide-env {VERSION_ENV_VAR}"), + Shell::Cmd => format!("set {variable}="), + Shell::NuShell => format!("hide-env {variable}"), } } @@ -68,77 +70,131 @@ fn print_windows_eval_wrapper_required() { /// Execute the `vp env use` command. pub async fn execute( cwd: AbsolutePathBuf, - version: Option, + requests: Vec, unset: bool, no_install: bool, silent_if_unchanged: bool, ) -> Result { let shell = detect_shell(); + let (scope, specs) = parse_requests(&requests)?; // Handle --unset: remove session override. // Always delete the session file: on Windows it lives under VP_HOME and can // leak across shell windows, so even eval mode must clean it up. if unset { - config::delete_session_version().await?; + let unset_package_manager = match scope { + EnvScope::PackageManager(expected) => current_override( + config::read_session_package_manager().await, + vp_shared::EnvConfig::get().package_manager, + ) + .and_then(|value| super::spec::parse_package_manager_spec(&value).ok()) + .is_some_and(|(kind, _)| kind == expected), + _ => scope.includes_package_managers(), + }; + if scope.includes_node() { + config::delete_session_version().await?; + } + if unset_package_manager { + config::delete_session_package_manager().await?; + } if has_eval_wrapper() { - println!("{}", format_unset(&shell)); + if scope.includes_node() { + println!("{}", format_unset(&shell, VERSION_ENV_VAR)); + } + if unset_package_manager { + println!("{}", format_unset(&shell, PACKAGE_MANAGER_ENV_VAR)); + } } else if !can_use_session_file() { print_windows_eval_wrapper_required(); } - eprintln!("Reverted to file-based Node.js version resolution"); + eprintln!("Reverted selected components to project environment resolution"); return Ok(ExitStatus::default()); } let provider = vp_js_runtime::NodeProvider::new(); - - // Resolve version: explicit argument or from project files - // When no argument provided, unset session override and resolve from project files - let (resolved_version, source_desc) = if let Some(ref ver) = version { - let resolved = config::resolve_version_alias(ver, &provider).await?; - (resolved, format!("{ver}")) + let node = if scope.includes_node() { + let (version, source) = if let Some(selector) = specs.node.as_deref() { + (config::resolve_version_alias(selector, &provider).await?, selector.to_string()) + } else { + config::delete_session_version().await?; + if has_eval_wrapper() { + println!("{}", format_unset(&shell, VERSION_ENV_VAR)); + } else if !can_use_session_file() { + eprintln!("Reverted to file-based Node.js version resolution"); + print_windows_eval_wrapper_required(); + return Ok(ExitStatus::default()); + } + let resolution = config::resolve_version_from_files(&cwd).await?; + (resolution.version, resolution.source) + }; + Some((version, source)) } else { - // No version argument - unset session override first - config::delete_session_version().await?; - if has_eval_wrapper() { - println!("{}", format_unset(&shell)); - } else if !can_use_session_file() { - eprintln!("Reverted to file-based Node.js version resolution"); - print_windows_eval_wrapper_required(); - return Ok(ExitStatus::default()); + None + }; + + let package_manager = if scope.includes_package_managers() { + let resolved = if let Some((kind, selector)) = specs.package_manager { + let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + Some((kind, version, selector)) + } else { + config::delete_session_package_manager().await?; + package_manager::resolve_from_files(&cwd).await?.and_then(|resolution| { + if matches!(scope, EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) { + None + } else { + Some(( + resolution.package_manager_type, + resolution.version.to_string(), + resolution.source.to_string(), + )) + } + }) + }; + if let EnvScope::PackageManager(kind) = scope + && resolved.is_none() + { + let version = resolve_package_manager_version(kind, "latest").await?.to_string(); + Some((kind, version, "latest".into())) + } else { + resolved } - // Now resolve from project files (not from session override) - let resolution = config::resolve_version_from_files(&cwd).await?; - let source = resolution.source.clone(); - (resolution.version, source) + } else { + None }; // Check if already active and suppress output if requested if silent_if_unchanged { - let current_env = vp_shared::EnvConfig::get().node_version.map(|v| v.trim().to_string()); - let current = if !has_eval_wrapper() { - current_env.or(config::read_session_version().await) - } else { - current_env + let node_unchanged = match &node { + Some((version, _)) => { + current_override( + config::read_session_version().await, + vp_shared::EnvConfig::get().node_version, + ) + .as_deref() + == Some(version) + } + None => true, }; - if current.as_deref() == Some(&resolved_version) { - // Already active — idempotent, skip stderr status message - if has_eval_wrapper() { - config::delete_session_version().await?; - println!("{}", format_export(&shell, &resolved_version)); - } else if !can_use_session_file() { - print_windows_eval_wrapper_required(); - return Ok(exit_status(1)); - } else { - config::write_session_version(&resolved_version).await?; + let package_manager_unchanged = match &package_manager { + Some((kind, version, _)) => { + current_override( + config::read_session_package_manager().await, + vp_shared::EnvConfig::get().package_manager, + ) + .as_deref() + == Some(format!("{kind}@{version}").as_str()) } + None => true, + }; + if node_unchanged && package_manager_unchanged { return Ok(ExitStatus::default()); } } // Ensure version is installed (unless --no-install) - if !no_install { + if !no_install && let Some((resolved_version, _)) = &node { let home_dir = - vp_shared::get_vp_home()?.join("js_runtime").join("node").join(&resolved_version); + vp_shared::get_vp_home()?.join("js_runtime").join("node").join(resolved_version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); @@ -147,29 +203,68 @@ pub async fn execute( if !binary_path.as_path().exists() { eprintln!("Installing Node.js v{}...", resolved_version); - vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &resolved_version) + vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, resolved_version) .await?; } } + if !no_install && let Some((kind, version, _)) = &package_manager { + download_package_manager(*kind, version, None).await?; + } if has_eval_wrapper() { - config::delete_session_version().await?; - // Output the shell command to stdout (consumed by shell wrapper's eval) - println!("{}", format_export(&shell, &resolved_version)); + if let Some((version, _)) = &node { + config::delete_session_version().await?; + println!("{}", format_export(&shell, VERSION_ENV_VAR, version)); + } + if let Some((kind, version, _)) = &package_manager { + config::delete_session_package_manager().await?; + println!( + "{}", + format_export(&shell, PACKAGE_MANAGER_ENV_VAR, &format!("{kind}@{version}")) + ); + } } else if !can_use_session_file() { print_windows_eval_wrapper_required(); return Ok(exit_status(1)); } else { // No eval wrapper (CI or direct invocation) — write session file so shims can read it - config::write_session_version(&resolved_version).await?; + if let Some((version, _)) = &node { + config::write_session_version(version).await?; + } + if let Some((kind, version, _)) = &package_manager { + config::write_session_package_manager(&format!("{kind}@{version}")).await?; + } } - // Status message to stderr (visible to user) - eprintln!("Using Node.js v{} (resolved from {})", resolved_version, source_desc); + if let Some((version, source)) = node { + eprintln!("Using Node.js v{version} (resolved from {source})"); + } + if let Some((kind, version, source)) = package_manager { + eprintln!("Using {kind} v{version} (resolved from {source})"); + } Ok(ExitStatus::default()) } +fn current_override(session: Option, environment: Option) -> Option { + environment.map(|value| value.trim().to_string()).or(session) +} + +fn parse_requests(requests: &[String]) -> Result<(EnvScope, EnvSpecs), Error> { + if requests.len() == 1 + && let Ok(scope) = EnvScope::parse(Some(&requests[0])) + { + return Ok((scope, EnvSpecs::default())); + } + let specs = EnvSpecs::parse(requests)?; + 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)) +} + #[cfg(test)] mod tests { use super::*; @@ -216,60 +311,66 @@ 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_package_manager_export_posix() { + let result = format_export(&Shell::Posix, PACKAGE_MANAGER_ENV_VAR, "pnpm@10.18.0"); + assert_eq!(result, "export VP_PACKAGE_MANAGER=pnpm@10.18.0"); + } + #[test] fn test_format_export_fish() { - let result = format_export(&Shell::Fish, "20.18.0"); + let result = format_export(&Shell::Fish, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "set -gx VP_NODE_VERSION 20.18.0"); } #[test] fn test_format_export_powershell() { - let result = format_export(&Shell::PowerShell, "20.18.0"); + let result = format_export(&Shell::PowerShell, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "$env:VP_NODE_VERSION = \"20.18.0\""); } #[test] fn test_format_export_cmd() { - let result = format_export(&Shell::Cmd, "20.18.0"); + let result = format_export(&Shell::Cmd, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "set VP_NODE_VERSION=20.18.0"); } #[test] fn test_format_unset_posix() { - let result = format_unset(&Shell::Posix); + let result = format_unset(&Shell::Posix, VERSION_ENV_VAR); assert_eq!(result, "unset VP_NODE_VERSION"); } #[test] fn test_format_unset_fish() { - let result = format_unset(&Shell::Fish); + let result = format_unset(&Shell::Fish, VERSION_ENV_VAR); assert_eq!(result, "set -e VP_NODE_VERSION"); } #[test] fn test_format_unset_powershell() { - let result = format_unset(&Shell::PowerShell); + let result = format_unset(&Shell::PowerShell, VERSION_ENV_VAR); assert_eq!(result, "Remove-Item Env:VP_NODE_VERSION -ErrorAction SilentlyContinue"); } #[test] fn test_format_unset_cmd() { - let result = format_unset(&Shell::Cmd); + let result = format_unset(&Shell::Cmd, VERSION_ENV_VAR); assert_eq!(result, "set VP_NODE_VERSION="); } #[test] fn test_format_export_nushell() { - let result = format_export(&Shell::NuShell, "20.18.0"); + let result = format_export(&Shell::NuShell, VERSION_ENV_VAR, "20.18.0"); assert_eq!(result, "$env.VP_NODE_VERSION = \"20.18.0\""); } #[test] fn test_format_unset_nushell() { - let result = format_unset(&Shell::NuShell); + let result = format_unset(&Shell::NuShell, VERSION_ENV_VAR); assert_eq!(result, "hide-env VP_NODE_VERSION"); } diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 18544911c1..7f7b80d369 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -12,17 +12,18 @@ use chrono::Local; use owo_colors::OwoColorize; use vp_pm_cli::{ PackageManagerType, package_manager_bin_path, package_manager_install_dir, - resolve_package_manager_from_package_json, + resolve_package_manager_version, }; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{ bin_config::{BinConfig, BinSource}, - config::{VERSION_ENV_VAR, get_bin_dir, get_node_modules_dir, resolve_version}, + config::{ShimMode, VERSION_ENV_VAR, get_bin_dir, get_node_modules_dir, resolve_version}, + package_manager, package_metadata::PackageMetadata, }; -use crate::{cli::exit_status, error::Error}; +use crate::{cli::exit_status, error::Error, shim}; /// Core tools (node, npm, npx) const CORE_TOOLS: &[&str] = &["node", "npm", "npx"]; @@ -32,6 +33,18 @@ const LABEL_WIDTH: usize = 10; /// Execute the which command. pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result { + let config = super::config::load_config().await?; + let mode = if PackageManagerType::from_tool(tool).is_some() { + config.package_manager_shim_mode() + } else { + config.shim_mode + }; + if mode == ShimMode::SystemFirst + && let Some(path) = shim::dispatch::find_system_tool(tool) + { + println!("{}", path.as_path().display()); + return Ok(ExitStatus::default()); + } if let Some(status) = execute_package_manager_tool(&cwd, tool).await? { return Ok(status); } @@ -138,14 +151,18 @@ async fn execute_package_manager_tool( let Some(expected_type) = PackageManagerType::from_tool(tool) else { return Ok(None); }; - let Some(resolution) = resolve_package_manager_from_package_json(cwd)? else { - return Ok(None); + let resolution = package_manager::resolve_current(cwd).await?; + let version = match &resolution { + Some(resolution) if resolution.package_manager_type == expected_type => { + resolution.version.to_string() + } + Some(_) | None if expected_type == PackageManagerType::Npm => return Ok(None), + Some(_) | None => { + resolve_package_manager_version(expected_type, "latest").await?.to_string() + } }; - 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 +170,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,12 +179,18 @@ async fn execute_package_manager_tool( println!( " {: Resu if prepend_to_path_env(&node_bin_prefix, options) { tracing::debug!("Set PATH to include {:?}", node_bin_prefix); } + if let Some(package_manager) = env::package_manager::resolve_current(project_path).await? { + let config = env::config::load_config().await?; + if config.package_manager_shim_mode() == env::config::ShimMode::SystemFirst + && let Some(system_path) = crate::shim::dispatch::find_system_tool( + &package_manager.package_manager_type.to_string(), + ) + && let Some(bin_dir) = system_path.parent() + { + if prepend_to_path_env(bin_dir, PrependOptions { dedupe_anywhere: true }) { + tracing::debug!("Set PATH to include system package manager {:?}", bin_dir); + } + return Ok(()); + } + let (install_dir, _, _) = vp_pm_cli::download_package_manager( + package_manager.package_manager_type, + &package_manager.version, + package_manager.hash.as_deref(), + ) + .await?; + let bin_dir = install_dir.join("bin"); + if prepend_to_path_env(&bin_dir, PrependOptions { dedupe_anywhere: true }) { + tracing::debug!("Set PATH to include {:?}", bin_dir); + } + } Ok(()) } diff --git a/crates/vp_global_cli/src/help.rs b/crates/vp_global_cli/src/help.rs index 43416ad960..0901ec4993 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_global_cli/src/help.rs @@ -402,7 +402,7 @@ pub fn top_level_help_doc() -> HelpDoc { "install, i", "Install all dependencies, or add packages if package names are provided", ), - row("env", "Manage Node.js versions"), + row("env", "Manage Node.js and package managers"), ], ), section_rows( @@ -466,34 +466,28 @@ pub fn top_level_help_doc() -> HelpDoc { fn env_help_doc() -> HelpDoc { HelpDoc { usage: "vp env [COMMAND]".into(), - summary: vec!["Manage Node.js versions".into()], + summary: vec!["Manage Node.js and package-manager environments".into()], sections: vec![ section_rows( "Setup", vec![ row("setup", "Create or update shims in VP_HOME/bin"), - row("on", "Enable managed mode - shims always use vite-plus managed Node.js"), - row( - "off", - "Enable system-first mode - shims prefer system Node.js, fallback to managed", - ), - row("print", "Print shell snippet to set environment for current session"), + row("on", "Enable managed mode for Node.js and package managers"), + row("off", "Enable system-first mode for Node.js and package managers"), + row("print", "Print PATH setup for the resolved environment"), ], ), section_rows( "Manage", vec![ - row("default", "Set or show the global default Node.js version"), - row("pin", "Pin a Node.js version in the current directory"), - row( - "unpin", - "Remove the Node.js pin from the current directory (alias for `pin --unpin`)", - ), - row("use", "Use a specific Node.js version for this shell session"), - row("install, i", "Install a Node.js version"), - row("uninstall, uni", "Uninstall a Node.js version"), - row("clean", "Remove unused managed runtimes and package manager caches"), - row("exec, run", "Execute a command with a specific Node.js version"), + row("default", "Set or show global environment defaults"), + row("pin", "Pin Node.js and package-manager versions in the project"), + row("unpin", "Remove project environment pins (alias for `pin --unpin`)"), + row("use", "Activate an environment for this shell session"), + row("install, i", "Install a resolved or explicit environment"), + row("uninstall, uni", "Uninstall explicit component versions"), + row("clean", "Remove unused runtimes and package managers"), + row("exec, run", "Execute a command in a resolved or explicit environment"), ], ), section_rows( @@ -502,10 +496,10 @@ fn env_help_doc() -> HelpDoc { row("current", "Show current environment information"), row("doctor", "Run diagnostics and show environment status"), row("which", "Show path to the tool that would be executed"), - row("list, ls", "List locally installed Node.js versions"), + row("list, ls", "List locally installed environment components"), row( "list-remote, ls-remote", - "List available Node.js versions from the registry", + "List available versions from component registries", ), ], ), @@ -514,26 +508,29 @@ fn env_help_doc() -> HelpDoc { vec![ " Setup:", " vp env setup # Create Node.js and package-manager shims", - " vp env on # Use vite-plus managed Node.js", - " vp env print # Print shell snippet for this session", + " vp env on # Manage Node.js and package managers", + " vp env off pm # Prefer system package managers only", + " vp env print # Print PATH setup for both components", "", " Manage:", - " vp env pin lts # Pin to latest LTS version", - " vp env install # Install version from .node-version / package.json / .nvmrc", - " vp env use 20 # Use Node.js 20 for this shell session", - " vp env use --unset # Remove session override", - " vp env clean # Remove unused managed caches", + " vp env pin lts pnpm@10 # Pin both project components", + " vp env install # Install the resolved project environment", + " vp env use 20 pnpm@10 # Override both session components", + " vp env use --unset pm # Remove only the PM session override", + " vp env clean yarn # Clean one package-manager family", "", " 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@10 pnpm i # Override the package manager", + " vp env exec node -v # Resolve both components", ], ), section_lines( diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 49d878ec0f..f538d16d86 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -5,9 +5,7 @@ //! 2. Node.js installation (if needed) //! 3. Tool execution (core shims and package binaries) -use vp_pm_cli::{ - PackageManagerType, ensure_package_manager_bin, resolve_package_manager_from_package_json, -}; +use vp_pm_cli::{PackageManagerType, ensure_package_manager_bin}; use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; @@ -20,6 +18,7 @@ use crate::{ env::{ bin_config::{BinConfig, BinSource}, config::{self, ShimMode}, + package_manager, package_metadata::PackageMetadata, }, global::install::is_protected_shim, @@ -660,7 +659,8 @@ async fn resolve_package_manager_tool( return Ok(None); }; - let (version, hash) = match resolve_package_manager_from_package_json(cwd)? { + let resolution = package_manager::resolve_current(cwd).await?; + let (version, hash) = match resolution { Some(resolution) if resolution.package_manager_type == expected_type => { (resolution.version, resolution.hash) } @@ -735,11 +735,17 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } // Check shim mode from config - let shim_mode = load_shim_mode().await; + let shim_mode = load_shim_mode(tool).await; if shim_mode == ShimMode::SystemFirst { tracing::debug!("system-first mode enabled"); // In system-first mode, try to find system tool first if let Some(system_path) = find_system_tool(tool) { + if PackageManagerType::from_tool(tool).is_some() + && let Err(error) = prepare_node_path_for_system_package_manager().await + { + eprintln!("vp: Failed to prepare Node.js for system package manager: {error}"); + return 1; + } // Append current bin_dir to VP_BYPASS to prevent infinite loops // when multiple vite-plus installations exist in PATH. // The next installation will filter all accumulated paths. @@ -894,6 +900,26 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { exec::exec_tool(&tool_path, args) } +async fn prepare_node_path_for_system_package_manager() -> Result<(), Error> { + let config = config::load_config().await?; + if config.shim_mode == ShimMode::SystemFirst + && let Some(node) = find_system_tool("node") + && let Some(bin_dir) = node.parent() + { + let _ = prepend_to_path_env(bin_dir, PrependOptions::default()); + return Ok(()); + } + + let cwd = current_dir()?; + let resolution = resolve_with_cache(&cwd).await.map_err(|error| Error::Other(error.into()))?; + let node = + ensure_installed(&resolution.version).await.map_err(|error| Error::Other(error.into()))?; + let bin_dir = + node.parent().ok_or_else(|| Error::Other("Node.js has no bin directory".into()))?; + let _ = prepend_to_path_env(bin_dir, PrependOptions::default()); + Ok(()) +} + /// Dispatch a package binary shim. /// /// Finds the package that provides this binary and executes it with the @@ -1225,8 +1251,17 @@ pub(crate) fn locate_tool(version: &str, tool: &str) -> Result ShimMode { - config::load_config().await.map(|c| c.shim_mode).unwrap_or_default() +async fn load_shim_mode(tool: &str) -> ShimMode { + config::load_config() + .await + .map(|config| { + if PackageManagerType::from_tool(tool).is_some() { + config.package_manager_shim_mode() + } else { + config.shim_mode + } + }) + .unwrap_or_default() } /// Find a system tool in PATH, skipping the vite-plus bin directory and any diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index b38e19867b..772ad45d11 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -10,6 +10,7 @@ use vt_path::AbsolutePath; use crate::{ PackageManager, PackageManagerType, cli::{PackageManagerCommand, PmCommand}, + download_package_manager, error::Error, helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, resolution::{DlxArgs, StageCommand, run_resolution}, @@ -62,6 +63,72 @@ pub async fn dispatch_with_metadata( Ok(DispatchResult { status, package_manager }) } +pub async fn dispatch_with_package_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + package_manager: Option<(PackageManagerType, &str, Option<&str>)>, +) -> Result { + let render_diagnostics = command.should_render_diagnostics(); + let command = match command { + PackageManagerCommand::Dlx(args) => { + if let Some(package_manager) = package_manager { + let manager = build_selected_package_manager(package_manager).await?; + let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; + return run_resolution(cwd, resolution, render_diagnostics).await; + } + return Ok(dispatch_dlx(cwd, args, render_diagnostics).await?.status); + } + command => command, + }; + + let manager = if let Some(package_manager) = package_manager { + if manager_policy(&command) == ManagerPolicy::CreateIfMissing { + ensure_package_json(cwd).await?; + } + build_selected_package_manager(package_manager).await? + } else { + match manager_policy(&command) { + ManagerPolicy::CreateIfMissing => { + ensure_package_json(cwd).await?; + build_package_manager(cwd).await? + } + ManagerPolicy::RequireProject => build_package_manager(cwd).await?, + ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, + } + }; + + let resolution = command.resolve_for_manager(&manager)?; + run_resolution(cwd, resolution, render_diagnostics).await +} + +pub async fn dispatch_with_resolved_package_manager( + cwd: &AbsolutePath, + command: PackageManagerCommand, + manager: PackageManager, +) -> Result { + let render_diagnostics = command.should_render_diagnostics(); + let command = match command { + PackageManagerCommand::Dlx(args) => { + let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; + return run_resolution(cwd, resolution, render_diagnostics).await; + } + command => command, + }; + if manager_policy(&command) == ManagerPolicy::CreateIfMissing { + ensure_package_json(cwd).await?; + } + let resolution = command.resolve_for_manager(&manager)?; + run_resolution(cwd, resolution, render_diagnostics).await +} + +async fn build_selected_package_manager( + (kind, version, hash): (PackageManagerType, &str, Option<&str>), +) -> Result { + let (install_dir, _, version) = + download_package_manager(kind, version, hash).await.map_err(Error::Install)?; + Ok(PackageManager { client: kind, version, install_dir }) +} + async fn dispatch_dlx( cwd: &AbsolutePath, args: DlxArgs, diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index 9105a7e8ea..6570d0ecbb 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -19,13 +19,18 @@ mod shim; pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; pub use config::npm_registry; -pub use dispatch::{DispatchResult, dispatch, dispatch_with_metadata}; +pub use dispatch::{ + DispatchResult, dispatch, dispatch_with_metadata, dispatch_with_package_manager, + dispatch_with_resolved_package_manager, +}; pub use error::Error; pub use package_manager::{ - PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, - PackageManagerType, download_package_manager, 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_package_manager_from_package_json, resolve_package_manager_version, + resolve_environment_package_manager, resolve_package_manager_from_package_json, + resolve_package_manager_version, }; pub use request::HttpClient; pub use resolution::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 063c400a31..3283f54727 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -106,6 +106,16 @@ impl PackageManagerType { (_, Self::Bun) => "bun", } } + + #[must_use] + pub const fn bin_names(self) -> &'static [&'static str] { + match self { + Self::Npm => &["npm", "npx"], + Self::Pnpm => &["pnpm", "pnpx"], + Self::Yarn => &["yarn", "yarnpkg"], + Self::Bun => &["bun", "bunx"], + } + } } /// Package-manager resolution from an explicit project `packageManager` field. @@ -119,6 +129,16 @@ pub struct PackageManagerResolution { pub project_root: AbsolutePathBuf, } +#[derive(Debug, Clone)] +pub struct EnvironmentPackageManagerResolution { + pub package_manager_type: PackageManagerType, + pub version: Str, + pub hash: Option, + pub source: Str, + pub source_path: Option, + pub project_root: Option, +} + /// Where the package manager selection came from (see rfcs/dev-engines.md). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PackageManagerSource { @@ -211,6 +231,15 @@ impl PackageManager { PackageManagerBuilder::new(cwd) } + #[must_use] + pub fn from_install_dir( + client: PackageManagerType, + version: impl Into, + install_dir: AbsolutePathBuf, + ) -> Self { + Self { client, version: version.into(), install_dir } + } + #[must_use] pub fn get_bin_prefix(&self) -> AbsolutePathBuf { self.install_dir.join("bin") @@ -371,6 +400,100 @@ pub fn resolve_package_manager_from_package_json( })) } +/// Resolve the package manager selected by an explicit/session override, project files, or default. +/// This never downloads a package manager, writes package.json, or prompts. +pub async fn resolve_environment_package_manager( + cwd: impl AsRef, + override_spec: Option<(PackageManagerType, &str)>, + default_spec: Option<(PackageManagerType, &str)>, +) -> Result, Error> { + if let Some((package_manager_type, version)) = override_spec { + return Ok(Some(EnvironmentPackageManagerResolution { + package_manager_type, + version: resolve_package_manager_version(package_manager_type, version).await?, + hash: None, + source: "session".into(), + source_path: None, + project_root: None, + })); + } + + let (workspace_root, _) = match find_workspace_root(cwd.as_ref()) { + Ok(result) => result, + Err(vt_workspace::Error::PackageJsonNotFound(_)) => { + return resolve_default_package_manager(default_spec).await; + } + Err(error) => return Err(error.into()), + }; + + if let Some(project) = get_package_manager_from_package_json(&workspace_root)? { + let version = + resolve_package_manager_version(project.package_manager_type, &project.version).await?; + return Ok(Some(EnvironmentPackageManagerResolution { + package_manager_type: project.package_manager_type, + 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: resolve_package_manager_version(package_manager_type, &version_req).await?, + 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)) => { + let source = match source { + PackageManagerSource::LockfileOrConfig => "lockfile or config", + PackageManagerSource::Default => "default", + PackageManagerSource::PackageManagerField => "packageManager", + PackageManagerSource::DevEnginesPackageManager => "devEngines.packageManager", + }; + Ok(Some(EnvironmentPackageManagerResolution { + package_manager_type, + version: resolve_package_manager_version(package_manager_type, &version_req) + .await?, + hash, + source: source.into(), + source_path: None, + project_root: Some(workspace_root.path.to_absolute_path_buf()), + })) + } + Err(Error::UnrecognizedPackageManager) => { + resolve_default_package_manager(default_spec).await + } + Err(error) => Err(error), + } +} + +async fn resolve_default_package_manager( + default_spec: Option<(PackageManagerType, &str)>, +) -> Result, Error> { + let Some((package_manager_type, version)) = default_spec else { + return Ok(None); + }; + Ok(Some(EnvironmentPackageManagerResolution { + package_manager_type, + version: resolve_package_manager_version(package_manager_type, version).await?, + hash: None, + source: "default".into(), + source_path: None, + project_root: None, + })) +} + /// Return the managed install directory for a package manager version. #[must_use] pub fn package_manager_install_dir( @@ -727,6 +850,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 { @@ -752,6 +889,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 @@ -874,20 +1024,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. `$VP_HOME/package_manager/pnpm/10.0.0/pnpm` pub async fn download_package_manager( @@ -1824,6 +1960,46 @@ mod tests { assert_eq!(PackageManagerType::from_tool("tsc"), None); } + #[tokio::test] + async fn environment_resolution_prefers_override_to_manifest() { + let temp_dir = create_temp_dir(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + create_package_json(&cwd, r#"{"packageManager":"pnpm@10.18.0"}"#); + + let resolution = resolve_environment_package_manager( + &cwd, + Some((PackageManagerType::Yarn, "1.22.22")), + Some((PackageManagerType::Bun, "1.2.0")), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Yarn); + assert_eq!(resolution.version, "1.22.22"); + assert_eq!(resolution.source, "session"); + } + + #[tokio::test] + async fn environment_resolution_uses_default_without_project_selection() { + let temp_dir = create_temp_dir(); + let cwd = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + create_package_json(&cwd, r#"{"name":"example"}"#); + + let resolution = resolve_environment_package_manager( + &cwd, + None, + Some((PackageManagerType::Bun, "1.2.0")), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!(resolution.package_manager_type, PackageManagerType::Bun); + assert_eq!(resolution.version, "1.2.0"); + assert_eq!(resolution.source, "default"); + } + /// How fully a fake package manager install is written. enum InstallState { /// No shim files at all (`bin/` exists but is empty). diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index 6ff07cc398..b687409c7c 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -89,6 +89,11 @@ pub struct EnvConfig { /// Env: `VP_NODE_VERSION` pub node_version: Option, + /// Override package manager and version. + /// + /// Env: `VP_PACKAGE_MANAGER` + pub package_manager: Option, + /// User home directory. /// /// Env: `HOME` (Unix) / `USERPROFILE` (Windows) @@ -118,6 +123,7 @@ impl EnvConfig { is_ci: std::env::var("CI").is_ok(), env_use_eval_enable: std::env::var(env_vars::VP_ENV_USE_EVAL_ENABLE).is_ok(), node_version: std::env::var(env_vars::VP_NODE_VERSION).ok(), + package_manager: std::env::var(env_vars::VP_PACKAGE_MANAGER).ok(), user_home: std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .ok() @@ -200,6 +206,7 @@ impl EnvConfig { is_ci: false, env_use_eval_enable: false, node_version: None, + package_manager: None, user_home: None, vp_shell: None, } diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 0588b56322..ebe17a39d3 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -34,6 +34,9 @@ pub const VP_NODE_SKIP_SIGNATURE_VERIFY: &str = "VP_NODE_SKIP_SIGNATURE_VERIFY"; /// Override Node.js version (takes highest priority in version resolution). pub const VP_NODE_VERSION: &str = "VP_NODE_VERSION"; +/// Override package manager and version (for example, `pnpm@10.18.0`). +pub const VP_PACKAGE_MANAGER: &str = "VP_PACKAGE_MANAGER"; + /// Enable debug output for shim dispatch. pub const VP_DEBUG_SHIM: &str = "VP_DEBUG_SHIM"; diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 5a19ef2e65..e9e4a18c8e 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -993,14 +993,14 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" Write-Host "" Write-Host " ${BOLD}Get started:${NC}" Write-Host " ${BRIGHT_BLUE}vp create${NC} Create a new project" - Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" Write-Host " ${BRIGHT_BLUE}vp install${NC} Install dependencies" Write-Host " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" # Show Node.js manager status if ($nodeManagerResult -eq "true" -or $nodeManagerResult -eq "already") { Write-Host "" - Write-Host " Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}." + Write-Host " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." Write-Host " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." } diff --git a/packages/cli/install.sh b/packages/cli/install.sh index c89d6435bf..4799a163cc 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -1216,13 +1216,13 @@ WRAPPER_EOF echo "" echo -e " ${BOLD}Get started:${NC}" echo -e " ${BRIGHT_BLUE}vp create${NC} Create a new project" - echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" echo -e " ${BRIGHT_BLUE}vp install${NC} Install dependencies" echo -e " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" if [ "$NODE_MANAGER_ENABLED" = "true" ] || [ "$NODE_MANAGER_ENABLED" = "already" ]; then echo "" - echo -e " Vite+ is now managing Node.js via ${BRIGHT_BLUE}vp env${NC}." + echo -e " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." echo -e " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." fi From 5e83d23f66d185fc94a3c191e217d82c89e86a8c Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 19:44:50 +0800 Subject: [PATCH 08/22] docs(env): document unified environment management --- README.md | 4 +- docs/guide/env.md | 135 ++++++++++++++++++++++++------- docs/guide/index.md | 2 +- docs/guide/installer-env-vars.md | 10 +++ packages/cli/README.md | 4 +- rfcs/dev-engines.md | 2 +- rfcs/env-command.md | 22 ++++- 7 files changed, 141 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index d6ef9362ab..494beafef0 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ _runtime and package management, create, dev, check, test, build, pack, and mono Vite+ is the unified entry point for local web development. It combines [Vite](https://vite.dev/), [Vitest](https://vitest.dev/), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Rolldown](https://rolldown.rs/), [tsdown](https://tsdown.dev/), and [Vite Task](https://github.com/voidzero-dev/vite-task) into one zero-config toolchain that also manages runtime and package manager workflows: -- **`vp env`:** Manage Node.js globally and per project +- **`vp env`:** Manage Node.js and package managers globally and per project - **`vp install`:** Install dependencies with automatic package manager detection - **`vp dev`:** Run Vite's fast native ESM dev server with instant HMR - **`vp check`:** Run formatting, linting, and type checks in one command @@ -104,7 +104,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **config** - Configure hooks and agent integration - **staged** - Run linters on staged files - **install** (`i`) - Install dependencies -- **env** - Manage Node.js versions +- **env** - Manage Node.js and package managers #### Develop diff --git a/docs/guide/env.md b/docs/guide/env.md index a337339824..af4c0a3ab8 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -1,10 +1,20 @@ # Environment -`vp env` manages Node.js versions globally and per project. +`vp env` manages the complete JavaScript environment: one Node.js runtime and one selected package manager. npm, pnpm, Yarn, and Bun are peer package-manager families. ## Overview -Managed mode is on by default, so `node`, `npm`, and related shims resolve through Vite+ and pick the right Node.js version for the current project. +Managed mode is on by default, so Node.js and package-manager shims resolve through Vite+ and pick the right versions for the current project. + +Most commands operate on both components when no selector is given. Add `node`, `pm`, `npm`, `pnpm`, `yarn`, or `bun` to narrow the command. `pm` means all four families for listing and cleanup, but the single selected package manager for project operations. + +Unqualified versions remain Node.js versions for compatibility: + +```bash +vp env pin 22.0.0 # Node.js only +vp env pin pnpm@10.18.0 # pnpm only +vp env pin 22.0.0 pnpm@10.18.0 # Both components +``` Vite+ checks the current directory first, then walks up through its parents. The nearest directory with a supported declaration wins. Within each directory, sources are checked in this order: @@ -19,7 +29,17 @@ latest LTS. `devEngines.runtime` ranks above `engines.node` because it declares the development-environment requirement, while `engines.node` is a consumer-facing support range. `vp env doctor` warns when declared sources conflict. -When a project declares `packageManager` (or `devEngines.packageManager`) in `package.json`, matching package-manager shims also use that package-manager version. For example, `packageManager: "npm@10.9.4"` makes both `npm` and `npx` run through npm 10.9.4. Alias pairs follow the installed package-manager shims: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a package-manager declaration, invoking `pnpm`, `yarn`, or `bun` uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. Vite+ does not translate mismatched commands, so a project pinned to `pnpm` still lets `npm` fall back to the npm that comes with the resolved Node.js runtime. +Package-manager selection uses this priority: + +1. Explicit command override +2. `VP_PACKAGE_MANAGER` or the shell-session override +3. Top-level `packageManager` +4. `devEngines.packageManager` +5. Lockfile or manager-specific configuration +6. The global package-manager default +7. The named shim's latest release + +A selected manager controls only its named shims. For example, pnpm controls `pnpm` and `pnpx`; invoking `npm` still resolves npm independently. Alias pairs are `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Without a package-manager selection, invoking `pnpm`, `yarn`, or `bun` uses the latest release without prompting. The resolved version is cached for one hour and an expired cache remains available when the registry cannot be reached. npm instead falls back to the version bundled with the resolved Node.js runtime. By default, Vite+ stores its managed runtime and related files in `~/.vite-plus`. If needed, you can override that location with `VP_HOME`. @@ -29,7 +49,12 @@ If you want to keep that behavior, run: vp env on ``` -This enables managed mode, where the shims always use the Vite+-managed Node.js installation. +This enables managed mode for both components. Their modes can also be changed independently: + +```bash +vp env on node +vp env off pm +``` If you do not want Vite+ to manage Node.js first, run: @@ -37,16 +62,15 @@ If you do not want Vite+ to manage Node.js first, run: vp env off ``` -This switches to system-first mode, where the shims prefer your system Node.js and only fall back to the Vite+-managed runtime when needed. +This switches both components to system-first mode. Vite+ prefers system tools and falls back to managed installations. Mixed configurations compose: a system package-manager launcher receives the Node.js selected by the Node mode. ## Commands ### Setup - `vp env setup` creates or updates the `node`, `npm`, `npx`, `pnpm`, `pnpx`, `yarn`, `yarnpkg`, `bun`, `bunx`, `vpx`, and `vpr` shims in `VP_HOME/bin` (and writes the per-shell setup scripts under `VP_HOME`) -- `vp env on` enables managed mode so shims always use Vite+-managed Node.js -- `vp env off` enables system-first mode so shims prefer system Node.js first -- `vp env print` prints the shell snippet for the current session +- `vp env on` / `vp env off` changes both modes; append `node` or `pm` to change one +- `vp env print` prints PATH setup for both components; append a selector to print one PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: @@ -78,27 +102,27 @@ vp-use --unset Only `vp env use` needs this alternate command. Other `vp env` commands work normally in Command Prompt. `vp env setup` creates `vp-use.cmd` under `VP_HOME/bin` on Windows. -In CI, `vp env use` can still run without shell initialization. It writes a temporary session file under `VP_HOME` so later shim calls in the same job can resolve the selected Node.js version. +In CI, `vp env use` can still run without shell initialization. It writes temporary Node.js and package-manager session files under `VP_HOME` so later shim calls in the same job resolve the same environment. ### Manage -- `vp env default` sets or shows the global default Node.js version -- `vp env pin` pins a Node.js version in the current directory: an existing `.node-version` keeps being updated; otherwise the pin is written to `package.json#devEngines.runtime`; `.node-version` is only created when the directory has no `package.json`. Use `--target node-version` or `--target dev-engines` to choose explicitly. An existing `engines.node` is never modified. -- `vp env unpin` removes the pin from the same source `vp env pin` would write -- `vp env use` sets a Node.js version for the current shell session -- `vp env install` installs a Node.js version -- `vp env uninstall` removes an installed Node.js version -- `vp env clean` removes unused managed Node.js runtimes and all downloaded package managers. -- `vp env exec` runs a command with a specific Node.js version -- `vp node` runs a Node.js script — shorthand for `vp env exec node` +- `vp env default` shows both global defaults. Bare versions set Node.js; qualified specs such as `pnpm@10.18.0` set the PM fallback. `--unset` clears both unless scoped. +- `vp env pin` shows or writes project pins. Existing `.node-version` and top-level `packageManager` fields keep being updated for compatibility; otherwise Vite+ writes the matching `devEngines` entry. Use `--target node-version`, `--target dev-engines`, or `--target package-manager` to choose explicitly. +- `vp env unpin` removes both effective pins by default; append a selector to remove one. Lower-priority declarations are not deleted. +- `vp env use` activates the complete project environment. Explicit specs override selected components; `--unset` clears both unless scoped. +- `vp env install` installs the complete resolved environment, a selected component, or explicit specs. +- `vp env uninstall` removes explicit exact Node.js or qualified package-manager versions. +- `vp env clean` removes unused installs. Use `clean node`, `clean pm`, or a concrete manager. Current and configured-default versions are preserved. +- `vp env exec` runs a command in the resolved environment. Use `--node` and `--package-manager`; `--npm` is an alias for `--package-manager npm@…`. +- `vp node` uses the resolved Node.js runtime and exposes the selected package-manager path to child processes. ### Inspect - `vp env current` shows the current resolved environment - `vp env doctor` runs environment diagnostics - `vp env which` shows which tool path will be used -- `vp env list` shows locally installed Node.js versions -- `vp env list-remote` shows available Node.js versions from the registry +- `vp env list` shows separate Node.js, npm, pnpm, Yarn, and Bun sections; selectors narrow output +- `vp env list-remote` fetches Node.js and all four PM registries concurrently; selectors narrow network work. `--lts` implicitly selects Node.js. ## Project Setup @@ -111,31 +135,82 @@ In CI, `vp env use` can still run without shell initialization. It writes a temp ```bash # Setup vp env setup # Create Node.js and package-manager shims -vp env on # Use Vite+ managed Node.js -vp env print # Print shell snippet for this session +vp env on # Manage Node.js and package managers +vp env off pm # Prefer system package managers only +vp env print # Print PATH setup for both components # Manage -vp env pin lts # Pin the project to the latest LTS release -vp env install # Install the version from .node-version, package.json, or .nvmrc -vp env default lts # Set the global default version -vp env use 20 # Use Node.js 20 for the current shell session -vp env use --unset # Remove the session override -vp env clean # Remove unused managed caches +vp env pin lts pnpm@10 # Pin both project components to exact versions +vp env install # Install the complete resolved environment +vp env default lts # Set the global Node.js default +vp env default pnpm@10 # Set the global package-manager fallback +vp env use 20 pnpm@10 # Override both components for this shell +vp env use --unset pm # Remove only the PM session override +vp env clean yarn # Remove unused managed Yarn versions # Inspect vp env current # Show current resolved environment vp env current --json # JSON output for automation vp env which node # Show which node binary will be used vp env which npx # Show pinned package-manager alias when packageManager matches -vp env list-remote --lts # List only LTS versions +vp env list # Show every locally installed component +vp env list node # Show only Node.js installations +vp env list-remote --lts # List only Node.js LTS versions # Execute -vp env exec --node lts npm i # Execute npm with latest LTS +vp env exec --node lts --package-manager pnpm@10 pnpm install vp env exec node -v # Use shim mode with automatic version resolution vp node script.js # Shorthand: run a Node.js script with the resolved version vp node -e "console.log(1+1)" # Shorthand: forward any node flag or argument ``` +## JSON output + +This release intentionally changes the JSON contracts for `current`, `list`, and `list-remote`. `current --json` returns peer component objects: + +```json +{ + "node": { + "version": "22.0.0", + "source": "devEngines.runtime", + "source_path": "/project/package.json", + "project_root": "/project", + "bin_path": "/home/.vite-plus/js_runtime/node/22.0.0/bin/node", + "installed": true, + "mode": "managed" + }, + "package_manager": { + "name": "pnpm", + "version": "10.18.0", + "source": "packageManager", + "source_path": "/project/package.json", + "project_root": "/project", + "bin_paths": { + "pnpm": "/home/.vite-plus/package_manager/pnpm/10.18.0/pnpm/bin/pnpm", + "pnpx": "/home/.vite-plus/package_manager/pnpm/10.18.0/pnpm/bin/pnpx" + }, + "installed": true, + "mode": "managed" + } +} +``` + +`list --json` and `list-remote --json` group the component arrays: + +```json +{ + "node": [], + "package_managers": { + "npm": [], + "pnpm": [], + "yarn": [], + "bun": [] + } +} +``` + +Selectors omit unselected top-level fields or PM families. Registry listing is all-or-error: Vite+ prints no partial human or JSON result when any selected registry request fails. + ## Custom Node.js Mirror By default, Vite+ downloads Node.js from `https://nodejs.org/dist`. If you're behind a corporate proxy or need to use an internal mirror (e.g., Artifactory), set the `VP_NODE_DIST_MIRROR` environment variable: diff --git a/docs/guide/index.md b/docs/guide/index.md index 7cd00d7905..10f8bacec9 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -96,7 +96,7 @@ Vite+ can handle the entire local frontend development cycle from starting a pro - [`vp hooks`](/guide/commit-hooks) manages the Git hook dispatcher (`enable`, `disable`, `status`). - [`vp staged`](/guide/commit-hooks) runs checks on staged files. - [`vp install`](/guide/install) installs dependencies with the right package manager. -- [`vp env`](/guide/env) manages Node.js versions. +- [`vp env`](/guide/env) manages Node.js and package-manager environments. ### Develop diff --git a/docs/guide/installer-env-vars.md b/docs/guide/installer-env-vars.md index b4b087116b..c95167267b 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -93,6 +93,16 @@ These variables configure the installed Vite+ CLI. `VP_HOME` (above) also applie VP_NODE_VERSION=22 vp env exec node -v ``` +### `VP_PACKAGE_MANAGER` + +- **Purpose**: Override the selected package manager and version +- **Default**: None (resolved from the project or global default) +- **Format**: `npm|pnpm|yarn|bun@` +- **Example**: + ```bash + VP_PACKAGE_MANAGER=pnpm@10.18.0 vp install + ``` + ### `VP_NODE_SKIP_SIGNATURE_VERIFY` - **Purpose**: Skip PGP signature verification of Node.js downloads diff --git a/packages/cli/README.md b/packages/cli/README.md index 8d1f418426..994bac63a6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9,7 +9,7 @@ This package provides the project-local version of Vite+. The global `vp` comman Vite+ is the unified entry point for local web development. It combines [Vite](https://vite.dev/), [Vitest](https://vitest.dev/), [Oxlint](https://oxc.rs/docs/guide/usage/linter.html), [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html), [Rolldown](https://rolldown.rs/), [tsdown](https://tsdown.dev/), and [Vite Task](https://github.com/voidzero-dev/vite-task) into one zero-config toolchain that also manages runtime and package manager workflows: -- **`vp env`:** Manage Node.js globally and per project +- **`vp env`:** Manage Node.js and package managers globally and per project - **`vp install`:** Install dependencies with automatic package manager detection - **`vp dev`:** Run Vite's fast native ESM dev server with instant HMR - **`vp check`:** Run formatting, linting, and type checks in one command @@ -98,7 +98,7 @@ Use `vp migrate` to migrate to Vite+. It merges tool-specific config files such - **config** - Configure hooks and agent integration - **staged** - Run linters on staged files - **install** (`i`) - Install dependencies -- **env** - Manage Node.js versions +- **env** - Manage Node.js and package managers #### Develop diff --git a/rfcs/dev-engines.md b/rfcs/dev-engines.md index a2a8f85508..08a75f03b7 100644 --- a/rfcs/dev-engines.md +++ b/rfcs/dev-engines.md @@ -373,7 +373,7 @@ A small shared Rust helper (in `vp_shared`) will own "edit one field in package. - Managing non-Node runtimes (`deno`, `bun` as a runtime) via `devEngines.runtime`. - Validating `devEngines.os` / `cpu` / `libc`. - Acting as a general enforcement layer for arbitrary package manager names beyond pnpm / yarn / npm / bun. -- Changing session-override behavior (`vp env use`, `VP_NODE_VERSION`). +- Node session override behavior remains compatible; unified environments additionally support `VP_PACKAGE_MANAGER` and `.session-package-manager`. ## Deferred / Future Work diff --git a/rfcs/env-command.md b/rfcs/env-command.md index d45af48db9..be88e98ec0 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -1,8 +1,26 @@ -# RFC: `vp env` - Shim-Based Node Version Management +# RFC: `vp env` - Unified JavaScript Environment Management ## Summary -This RFC proposes adding a `vp env` command that provides system-wide, IDE-safe Node.js version management through a shim-based architecture. The shims intercept `node`, `npm`, and `npx` commands, automatically resolving and executing the correct Node.js version based on project configuration. +This RFC defines system-wide, IDE-safe Node.js and package-manager management through a shim-based architecture. The environment contains one Node.js runtime and one selected package manager; npm, pnpm, Yarn, and Bun remain independently callable families. + +## Breaking revision: unified environments + +The original Node.js-only command model was extended as a breaking change. Bare component-wide commands now operate on Node.js and package managers together, while unqualified version arguments remain Node.js for compatibility: + +```bash +vp env pin 22.0.0 # Node.js only (legacy-compatible) +vp env pin pnpm@10.18.0 # Package manager only +vp env pin 22.0.0 pnpm@10.18.0 # Both +``` + +Selectors are `node`, `pm`, `npm`, `pnpm`, `yarn`, and `bun`. `pm` selects every family for listing and cleanup, but the single project-selected manager for `current`, `pin`, `unpin`, `use`, and execution. + +Node and package-manager modes persist independently. Existing `shimMode` remains the Node mode; optional `packageManagerShimMode` inherits it when absent. A Node-only mode change materializes the inherited PM value before changing `shimMode`, so it cannot accidentally change PM behavior. + +Package-manager resolution priority is explicit override, `VP_PACKAGE_MANAGER` or `.session-package-manager`, top-level `packageManager`, `devEngines.packageManager`, lockfile/config detection, `defaultPackageManager`, then the existing fallback. The resolver is non-mutating and shared by env inspection, shims, `vp install`, `use`, and `exec`. + +The JSON contracts for `current`, `list`, and `list-remote` are intentionally breaking. `current` exposes `node` and `package_manager` objects. Local and remote lists expose `node` plus a `package_managers` object keyed by family. Scoped calls omit unselected fields, and multi-registry remote listing emits no partial output on failure. ## Motivation From 68eecbf37a3fca51035a5ffa89b9d2e540a1f6d7 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 19:45:07 +0800 Subject: [PATCH 09/22] test(env): update unified environment snapshots --- .../snapshots/cli_helper_message.md | 53 ++-- .../fixtures/command_env_clean/snapshots.toml | 3 + .../snapshots/command_env_clean.md | 30 +++ .../command_env_exec_shim_mode/snapshots.toml | 2 +- .../snapshots/command_env_exec_shim_mode.md | 10 +- .../snapshots.toml | 2 +- .../command_env_install_no_arg_fail.md | 9 +- .../command_env_list_remote/snapshots.toml | 2 +- .../snapshots/command_env_list_remote.md | 6 +- .../snapshots/command_env_off_on.md | 12 +- .../fixtures/command_env_unified/package.json | 11 + .../command_env_unified/snapshots.toml | 23 ++ .../snapshots/command_env_unified.md | 248 ++++++++++++++++++ .../snapshots/command_env_use.md | 14 +- .../command_env_use_shells/snapshots.toml | 18 +- .../snapshots/command_env_use_shells.md | 48 ++-- .../fixtures/vp_help/snapshots/help.global.md | 2 +- 17 files changed, 411 insertions(+), 82 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md 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..2a71df13d5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -16,7 +16,7 @@ Start: hooks Manage the Git hook dispatcher staged Run linters on staged files install, i Install all dependencies, or add packages if package names are provided - env Manage Node.js versions + env Manage Node.js and package managers Develop: dev Run the development server @@ -460,54 +460,57 @@ VITE+ - The Unified Toolchain for the Web Usage: vp env [COMMAND] -Manage Node.js versions +Manage Node.js and package-manager environments Setup: setup Create or update shims in VP_HOME/bin - on Enable managed mode - shims always use vite-plus managed Node.js - off Enable system-first mode - shims prefer system Node.js, fallback to managed - print Print shell snippet to set environment for current session + on Enable managed mode for Node.js and package managers + off Enable system-first mode for Node.js and package managers + print Print PATH setup for the resolved environment Manage: - default Set or show the global default Node.js version - pin Pin a Node.js version in the current directory - unpin Remove the Node.js pin from the current directory (alias for `pin --unpin`) - use Use a specific Node.js version for this shell session - install, i Install a Node.js version - uninstall, uni Uninstall a Node.js version - clean Remove unused managed runtimes and package manager caches - exec, run Execute a command with a specific Node.js version + default Set or show global environment defaults + pin Pin Node.js and package-manager versions in the project + unpin Remove project environment pins (alias for `pin --unpin`) + use Activate an environment for this shell session + install, i Install a resolved or explicit environment + uninstall, uni Uninstall explicit component versions + clean Remove unused runtimes and package managers + exec, run Execute a command in a resolved or explicit environment Inspect: current Show current environment information doctor Run diagnostics and show environment status which Show path to the tool that would be executed - list, ls List locally installed Node.js versions - list-remote, ls-remote List available Node.js versions from the registry + list, ls List locally installed environment components + list-remote, ls-remote List available versions from component registries Examples: Setup: vp env setup # Create Node.js and package-manager shims - vp env on # Use vite-plus managed Node.js - vp env print # Print shell snippet for this session + vp env on # Manage Node.js and package managers + vp env off pm # Prefer system package managers only + vp env print # Print PATH setup for both components Manage: - vp env pin lts # Pin to latest LTS version - vp env install # Install version from .node-version / package.json / .nvmrc - vp env use 20 # Use Node.js 20 for this shell session - vp env use --unset # Remove session override - vp env clean # Remove unused managed caches + vp env pin lts pnpm@10 # Pin both project components + vp env install # Install the resolved project environment + vp env use 20 pnpm@10 # Override both session components + vp env use --unset pm # Remove only the PM session override + vp env clean yarn # Clean one package-manager family 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@10 pnpm i # Override the package manager + vp env exec node -v # Resolve both components Related Commands: vp install -g # Install a package globally diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml index d3c0d5fe0e..fbc4aaf244 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots.toml @@ -4,4 +4,7 @@ vp = "global" seed-runtime = false steps = [ { argv = ["vp", "env", "clean"], comment = "Clean isolated Vite+ caches" }, + { argv = ["vp", "env", "clean", "node"], comment = "Clean only Node.js runtimes" }, + { argv = ["vp", "env", "clean", "pm"], comment = "Clean all package-manager families" }, + { argv = ["vp", "env", "clean", "pnpm"], comment = "Clean one package-manager family" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/command_env_clean.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/command_env_clean.md index 0f6d1c5db5..003946eeaa 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/command_env_clean.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_clean/snapshots/command_env_clean.md @@ -10,3 +10,33 @@ VITE+ - The Unified Toolchain for the Web ✓ Removed 0 Node.js runtimes ✓ Removed 0 package manager installs ``` + +## `vp env clean node` + +Clean only Node.js runtimes + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed 0 Node.js runtimes +``` + +## `vp env clean pm` + +Clean all package-manager families + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed 0 package manager installs +``` + +## `vp env clean pnpm` + +Clean one package-manager family + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Removed 0 package manager installs +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml index bd23501846..e76dd34273 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots.toml @@ -5,5 +5,5 @@ steps = [ { argv = ["vp", "env", "exec", "node", "-v"], comment = "Shim mode: version resolved from package.json engines.node", continue-on-failure = true }, { argv = ["vp", "env", "exec", "npm", "-v"], comment = "Shim mode: npm uses same version", continue-on-failure = true }, { argv = ["vp", "env", "exec", "node", "-e", "console.log('Hello from shim mode')"], comment = "Shim mode: run inline script", continue-on-failure = true }, - { argv = ["vp", "env", "exec", "nonexistent-tool", "--version"], comment = "expected error: non-shim command requires --node", continue-on-failure = true }, + { argv = ["vp", "env", "exec", "nonexistent-tool", "--version"], comment = "automatic mode resolves the environment before reporting a missing command", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md index e27e71449d..d1d1d93f52 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_exec_shim_mode/snapshots/command_env_exec_shim_mode.md @@ -26,16 +26,10 @@ Hello from shim mode ## `vp env exec nonexistent-tool --version` -expected error: non-shim command requires --node +automatic mode resolves the environment before reporting a missing command **Exit code:** 1 ``` -vp env exec: --node is required when running non-shim commands -Usage: vp env exec --node [args...] - -For shim tools, --node is optional (version resolved automatically): - vp env exec node script.js # Core tool - vp env exec npm install # Core tool - vp env exec tsc --version # Global package +error: Command execution failed: No such file or directory (os error 2) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml index 7cbe64e00f..195415b26c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots.toml @@ -4,5 +4,5 @@ vp = "global" skip-platforms = ["windows"] seed-runtime = false steps = [ - { argv = ["vp", "env", "install"], comment = "No version config - should error", continue-on-failure = true }, + { argv = ["vp", "env", "install"], comment = "No project declaration - install the resolved fallback environment", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md index 8a5a801a4f..09e3b01c3b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_install_no_arg_fail/snapshots/command_env_install_no_arg_fail.md @@ -2,14 +2,11 @@ ## `vp env install` -No version config - should error - -**Exit code:** 1 +No project declaration - install the resolved fallback environment ``` VITE+ - The Unified Toolchain for the Web -No Node.js version found in current project. -Specify a version: vp env install -Or pin one: vp env pin +Installing Node.js ... +Installed Node.js ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml index c0c6ddc924..b3a1f51d3e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots.toml @@ -6,5 +6,5 @@ seed-runtime = false steps = [ { argv = ["vp", "env", "install", "lts"], comment = "Install an LTS Node.js version locally", continue-on-failure = true }, { argv = ["vp", "env", "default", "lts"], comment = "Set it as the global default (stored as the `lts` alias)", continue-on-failure = true }, - { argv = ["node", "-e", "const {execFileSync}=require('node:child_process'); const {versions}=JSON.parse(execFileSync('vp',['env','list-remote','--lts','--json'],{encoding:'utf8'})); console.log('installed marked:', versions.some(v=>v.installed)); console.log('current marked:', versions.some(v=>v.current)); console.log('default marked:', versions.some(v=>v.default));"], comment = "installed/current/default flags should all resolve, including the `lts` default alias", continue-on-failure = true }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process'); const {node}=JSON.parse(execFileSync('vp',['env','list-remote','--lts','--json'],{encoding:'utf8'})); console.log('installed marked:', node.some(v=>v.installed)); console.log('current marked:', node.some(v=>v.current)); console.log('default marked:', node.some(v=>v.default));"], comment = "the unified JSON node entries resolve installed/current/default flags, including the `lts` default alias", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md index 5b3ceac068..593ef4bd1a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_list_remote/snapshots/command_env_list_remote.md @@ -18,12 +18,12 @@ Set it as the global default (stored as the `lts` alias) ``` VITE+ - The Unified Toolchain for the Web -✓ Default Node.js version set to lts (currently ) +✓ Environment defaults updated. ``` -## `node -e 'const {execFileSync}=require('\''node:child_process'\''); const {versions}=JSON.parse(execFileSync('\''vp'\'',['\''env'\'','\''list-remote'\'','\''--lts'\'','\''--json'\''],{encoding:'\''utf8'\''})); console.log('\''installed marked:'\'', versions.some(v=>v.installed)); console.log('\''current marked:'\'', versions.some(v=>v.current)); console.log('\''default marked:'\'', versions.some(v=>v.default));'` +## `node -e 'const {execFileSync}=require('\''node:child_process'\''); const {node}=JSON.parse(execFileSync('\''vp'\'',['\''env'\'','\''list-remote'\'','\''--lts'\'','\''--json'\''],{encoding:'\''utf8'\''})); console.log('\''installed marked:'\'', node.some(v=>v.installed)); console.log('\''current marked:'\'', node.some(v=>v.current)); console.log('\''default marked:'\'', node.some(v=>v.default));'` -installed/current/default flags should all resolve, including the `lts` default alias +the unified JSON node entries resolve installed/current/default flags, including the `lts` default alias ``` installed marked: true diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md index 3ad582ca09..6a701c5266 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_off_on/snapshots/command_env_off_on.md @@ -18,11 +18,11 @@ Switch to system-first mode ``` VITE+ - The Unified Toolchain for the Web -✓ Node.js management set to system-first. +✓ Node.js and package-manager management set to system-first. -All vp commands and shims will now prefer system Node.js, falling back to managed if not found. +Selected commands and shims will now prefer system tools, falling back to managed tools. -Run `vp env on` to always use Vite+ managed Node.js. +Run `vp env on` to always use Vite+ managed tools. ``` ## `vp run assert-not-managed` @@ -45,11 +45,11 @@ Switch back to managed mode ``` VITE+ - The Unified Toolchain for the Web -✓ Node.js management set to managed. +✓ Node.js and package-manager management set to managed. -All vp commands and shims will now always use Vite+ managed Node.js. +Selected commands and shims will now use Vite+ managed tools. -Run `vp env off` to prefer system Node.js instead. +Run `vp env off` to prefer system tools instead. ``` ## `vp run assert-managed` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_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..8fdab949fb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots.toml @@ -0,0 +1,23 @@ +[[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", "off", "node"], comment = "change only the Node.js mode" }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "the inherited PM mode remains managed" }, + { argv = ["vp", "env", "off", "pm"], comment = "change only the package-manager mode" }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "both modes can be system-first" }, + { argv = ["vp", "env", "on", "node"], comment = "restore only the Node.js mode" }, + { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "mixed mode configuration is persisted" }, + { argv = ["vp", "env", "on", "pm"], comment = "restore only the package-manager mode" }, + { 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..c5a7954bde --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots/command_env_unified.md @@ -0,0 +1,248 @@ +# 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 off node` + +change only the Node.js mode + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js 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. +``` + +## `vpt print-file $VP_HOME/config.json` + +the inherited PM mode remains managed + +``` +{ + "shimMode": "system_first", + "packageManagerShimMode": "managed" +} +``` + +## `vp env off pm` + +change only the package-manager mode + +``` +VITE+ - The Unified Toolchain for the Web + +✓ 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. +``` + +## `vpt print-file $VP_HOME/config.json` + +both modes can be system-first + +``` +{ + "shimMode": "system_first", + "packageManagerShimMode": "system_first" +} +``` + +## `vp env on node` + +restore only the Node.js mode + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js management set to managed. + +Selected commands and shims will now use Vite+ managed tools. + +Run `vp env off` to prefer system tools instead. +``` + +## `vpt print-file $VP_HOME/config.json` + +mixed mode configuration is persisted + +``` +{ + "packageManagerShimMode": "system_first" +} +``` + +## `vp env on pm` + +restore only the package-manager mode + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Package-manager management set to managed. + +Selected commands and shims will now use Vite+ managed tools. + +Run `vp env off` to prefer system tools instead. +``` + +## `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/snapshots/command_env_use.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md index 90d459cd38..8b1bd84718 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 lts pnpm@10 # Override both session components + vp env use --unset # Clear both session overrides + vp env use --unset pm # Clear only the package-manager override 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` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml index 2ba92803b3..52b8c20b4a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots.toml @@ -4,13 +4,13 @@ vp = "global" skip-platforms = ["windows"] env = { VP_ENV_USE_EVAL_ENABLE = "1" } steps = [ - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect bash and output posix export", envs = [["VP_SHELL", "bash"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect zsh and output posix export", envs = [["VP_SHELL", "zsh"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect fish and output fish export", envs = [["VP_SHELL", "fish"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect nushell and output nushell export", envs = [["VP_SHELL", "nu"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect powershell and output powershell export", envs = [["VP_SHELL", "pwsh"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect cmd and output cmd export", envs = [["VP_SHELL", "cmd"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect case-insensitive bash", envs = [["VP_SHELL", "BASH"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect case-insensitive fish", envs = [["VP_SHELL", "FISH"]], continue-on-failure = true }, - { argv = ["vp", "env", "use", "20.18.0", "--no-install"], comment = "should detect case-insensitive powershell", envs = [["VP_SHELL", "POWERSHELL"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect bash and output both posix exports", envs = [["VP_SHELL", "bash"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect zsh and output both posix exports", envs = [["VP_SHELL", "zsh"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect fish and output both fish exports", envs = [["VP_SHELL", "fish"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect nushell and output both nushell exports", envs = [["VP_SHELL", "nu"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect powershell and output both powershell exports", envs = [["VP_SHELL", "pwsh"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect cmd and output both cmd exports", envs = [["VP_SHELL", "cmd"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect case-insensitive bash", envs = [["VP_SHELL", "BASH"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect case-insensitive fish", envs = [["VP_SHELL", "FISH"]], continue-on-failure = true }, + { argv = ["vp", "env", "use", "20.18.0", "pnpm@10.18.0", "--no-install"], comment = "should detect case-insensitive powershell", envs = [["VP_SHELL", "POWERSHELL"]], continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/command_env_use_shells.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/command_env_use_shells.md index 6ff02cb5a1..f96a1999f0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/command_env_use_shells.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use_shells/snapshots/command_env_use_shells.md @@ -1,82 +1,100 @@ # command_env_use_shells -## `VP_SHELL=bash vp env use 20.18.0 --no-install` +## `VP_SHELL=bash vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect bash and output posix export +should detect bash and output both posix exports ``` export VP_NODE_VERSION=20.18.0 +export VP_PACKAGE_MANAGER=pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=zsh vp env use 20.18.0 --no-install` +## `VP_SHELL=zsh vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect zsh and output posix export +should detect zsh and output both posix exports ``` export VP_NODE_VERSION=20.18.0 +export VP_PACKAGE_MANAGER=pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=fish vp env use 20.18.0 --no-install` +## `VP_SHELL=fish vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect fish and output fish export +should detect fish and output both fish exports ``` set -gx VP_NODE_VERSION 20.18.0 +set -gx VP_PACKAGE_MANAGER pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=nu vp env use 20.18.0 --no-install` +## `VP_SHELL=nu vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect nushell and output nushell export +should detect nushell and output both nushell exports ``` $env.VP_NODE_VERSION = "20.18.0" +$env.VP_PACKAGE_MANAGER = "pnpm@10.18.0" Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=pwsh vp env use 20.18.0 --no-install` +## `VP_SHELL=pwsh vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect powershell and output powershell export +should detect powershell and output both powershell exports ``` $env:VP_NODE_VERSION = "20.18.0" +$env:VP_PACKAGE_MANAGER = "pnpm@10.18.0" Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=cmd vp env use 20.18.0 --no-install` +## `VP_SHELL=cmd vp env use 20.18.0 pnpm@10.18.0 --no-install` -should detect cmd and output cmd export +should detect cmd and output both cmd exports ``` set VP_NODE_VERSION=20.18.0 +set VP_PACKAGE_MANAGER=pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=BASH vp env use 20.18.0 --no-install` +## `VP_SHELL=BASH vp env use 20.18.0 pnpm@10.18.0 --no-install` should detect case-insensitive bash ``` export VP_NODE_VERSION=20.18.0 +export VP_PACKAGE_MANAGER=pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=FISH vp env use 20.18.0 --no-install` +## `VP_SHELL=FISH vp env use 20.18.0 pnpm@10.18.0 --no-install` should detect case-insensitive fish ``` set -gx VP_NODE_VERSION 20.18.0 +set -gx VP_PACKAGE_MANAGER pnpm@10.18.0 Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` -## `VP_SHELL=POWERSHELL vp env use 20.18.0 --no-install` +## `VP_SHELL=POWERSHELL vp env use 20.18.0 pnpm@10.18.0 --no-install` should detect case-insensitive powershell ``` $env:VP_NODE_VERSION = "20.18.0" +$env:VP_PACKAGE_MANAGER = "pnpm@10.18.0" Using Node.js (resolved from 20.18.0) +Using pnpm (resolved from 10.18.0) ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/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 From 46d5a7ea3b4001a0d4ce819bcfb5be8e1ac37a30 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 20:09:32 +0800 Subject: [PATCH 10/22] fix(env): simplify help examples --- .../snapshots/cli_helper_message.md | 16 ++++++++-------- .../command_env_use/snapshots/command_env_use.md | 6 +++--- crates/vp_global_cli/src/cli.rs | 16 ++++++++-------- crates/vp_global_cli/src/help.rs | 16 ++++++++-------- 4 files changed, 27 insertions(+), 27 deletions(-) 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 2a71df13d5..85c12b4bbf 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -493,11 +493,11 @@ Examples: vp env print # Print PATH setup for both components Manage: - vp env pin lts pnpm@10 # Pin both project components - vp env install # Install the resolved project environment - vp env use 20 pnpm@10 # Override both session components - vp env use --unset pm # Remove only the PM session override - vp env clean yarn # Clean one package-manager family + vp env default 22.19.0 # Set the Node.js default + vp env default pnpm@12 # Set the package-manager default + vp env pin 22.19.0 # Pin Node.js for this project + vp env use 22.19.0 # Use Node.js in this shell + vp env clean # Clean all unused managed versions Inspect: vp env current # Show current resolved environment @@ -508,9 +508,9 @@ Examples: vp env list-remote --lts # List only Node.js LTS versions Execute: - vp env exec --node lts node -v # Override Node.js - vp env exec --package-manager pnpm@10 pnpm i # Override the package manager - vp env exec node -v # Resolve both components + 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_use/snapshots/command_env_use.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md index 8b1bd84718..04f549bf96 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 @@ -21,9 +21,9 @@ Options: -h, --help Print help (see a summary with '-h') Examples: - vp env use lts pnpm@10 # Override both session components - vp env use --unset # Clear both session overrides - vp env use --unset pm # Clear only the package-manager 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 ``` diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 8dfb4e322e..684918e74c 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -309,8 +309,8 @@ pub enum EnvSubcommands { #[command(after_long_help = "\ Examples: vp env default # Show both current defaults - vp env default lts # Set the Node.js default - vp env default pnpm@10 # Set the package-manager default")] + vp env default 22.19.0 # Set the Node.js default + vp env default pnpm@12 # Set the package-manager default")] Default { /// Defaults or component selectors. Bare versions select Node.js. values: Vec, @@ -437,9 +437,9 @@ keeps being updated; otherwise the pin is written to package.json#devEngines.run visible_alias = "run", after_long_help = "\ Examples: - vp env exec --node lts node -v # Override Node.js - vp env exec --package-manager pnpm@10 pnpm install # Override the package manager - vp env exec node -v # Resolve the full environment" + 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"). @@ -485,9 +485,9 @@ Examples: /// Activate Node.js and package-manager versions for this shell session #[command(after_long_help = "\ Examples: - vp env use lts pnpm@10 # Override both session components - vp env use --unset # Clear both session overrides - vp env use --unset pm # Clear only the package-manager 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 { /// Component selectors or explicit versions to activate requests: Vec, diff --git a/crates/vp_global_cli/src/help.rs b/crates/vp_global_cli/src/help.rs index 0901ec4993..eb376dbf4a 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_global_cli/src/help.rs @@ -513,11 +513,11 @@ fn env_help_doc() -> HelpDoc { " vp env print # Print PATH setup for both components", "", " Manage:", - " vp env pin lts pnpm@10 # Pin both project components", - " vp env install # Install the resolved project environment", - " vp env use 20 pnpm@10 # Override both session components", - " vp env use --unset pm # Remove only the PM session override", - " vp env clean yarn # Clean one package-manager family", + " vp env default 22.19.0 # Set the Node.js default", + " vp env default pnpm@12 # Set the package-manager default", + " vp env pin 22.19.0 # Pin Node.js for this project", + " vp env use 22.19.0 # Use Node.js in this shell", + " vp env clean # Clean all unused managed versions", "", " Inspect:", " vp env current # Show current resolved environment", @@ -528,9 +528,9 @@ fn env_help_doc() -> HelpDoc { " vp env list-remote --lts # List only Node.js LTS versions", "", " Execute:", - " vp env exec --node lts node -v # Override Node.js", - " vp env exec --package-manager pnpm@10 pnpm i # Override the package manager", - " vp env exec node -v # Resolve both components", + " 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( From 74a1914138ff5a9b1a7e457c3b5ee587fdc4f2f4 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 21:55:02 +0800 Subject: [PATCH 11/22] feat(env): warn on package manager mismatch --- .../package.json | 5 ++++ .../snapshots.toml | 29 +++++++++++++++++++ ..._pin_warns_when_package_manager_differs.md | 13 +++++++++ ...use_does_not_warn_for_different_default.md | 17 +++++++++++ ..._use_warns_when_package_manager_differs.md | 11 +++++++ .../src/commands/env/package_manager.rs | 12 ++++++++ crates/vp_global_cli/src/commands/env/pin.rs | 2 ++ crates/vp_global_cli/src/commands/env/use.rs | 4 +++ crates/vp_pm_cli/src/package_manager.rs | 21 +++++++++++++- 9 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_does_not_warn_for_different_default.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_warns_when_package_manager_differs.md 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..fc53902c80 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -0,0 +1,29 @@ +[[case]] +name = "env_pin_warns_when_package_manager_differs" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "an explicit project manager warns before a different manager is pinned" }, +] + +[[case]] +name = "env_use_warns_when_package_manager_differs" +vp = "global" +seed-runtime = false +skip-platforms = ["windows"] +env = { VP_ENV_USE_EVAL_ENABLE = "1" } +steps = [ + { argv = ["vp", "env", "use", "yarn@4.12.0", "--no-install"], comment = "an explicit project manager warns before a different session manager is used" }, +] + +[[case]] +name = "env_use_does_not_warn_for_different_default" +vp = "global" +seed-runtime = false +skip-platforms = ["windows"] +env = { VP_ENV_USE_EVAL_ENABLE = "1" } +steps = [ + { argv = ["vp", "env", "default", "pnpm@10.18.0"], snapshot = false }, + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true}\n"], snapshot = false }, + { argv = ["vp", "env", "use", "yarn@4.12.0", "--no-install"], comment = "a different fallback manager does not warn" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md new file mode 100644 index 0000000000..bbb0438058 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_when_package_manager_differs.md @@ -0,0 +1,13 @@ +# env_pin_warns_when_package_manager_differs + +## `vp env pin yarn@4.12.0 --no-install` + +an explicit project manager warns before a different manager is pinned + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to pnpm from packageManager, but yarn was requested. +✓ Pinned package manager to yarn@4.12.0 +note: Package manager will be downloaded on first use. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_does_not_warn_for_different_default.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_does_not_warn_for_different_default.md new file mode 100644 index 0000000000..c1ff7e9dbf --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_does_not_warn_for_different_default.md @@ -0,0 +1,17 @@ +# env_use_does_not_warn_for_different_default + +## `vp env default pnpm@10.18.0` + + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true} +'` + + +## `vp env use yarn@4.12.0 --no-install` + +a different fallback manager does not warn + +``` +export VP_PACKAGE_MANAGER=yarn@4.12.0 +Using yarn (resolved from 4.12.0) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_warns_when_package_manager_differs.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_warns_when_package_manager_differs.md new file mode 100644 index 0000000000..5608d80719 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_use_warns_when_package_manager_differs.md @@ -0,0 +1,11 @@ +# env_use_warns_when_package_manager_differs + +## `vp env use yarn@4.12.0 --no-install` + +an explicit project manager warns before a different session manager is used + +``` +warn: Current environment resolves to pnpm from packageManager, but yarn was requested. +export VP_PACKAGE_MANAGER=yarn@4.12.0 +Using yarn (resolved from 4.12.0) +``` diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index ef34d0db2f..b2f50477b9 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -49,6 +49,18 @@ pub(crate) async fn resolve_from_files( .map_err(Error::from) } +pub(crate) async fn warn_if_target_differs(cwd: &AbsolutePath, target: PackageManagerType) { + let Ok(Some(resolution)) = resolve_current(cwd).await else { + return; + }; + if !resolution.source_kind.is_default() && resolution.package_manager_type != target { + vp_shared::output::warn(&format!( + "Current environment resolves to {} from {}, but {target} was requested.", + resolution.package_manager_type, resolution.source + )); + } +} + pub(crate) const ALL_PACKAGE_MANAGERS: [PackageManagerType; 4] = [ PackageManagerType::Npm, PackageManagerType::Pnpm, diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 779f7150ce..216b60a8c4 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -19,6 +19,7 @@ use vt_path::AbsolutePathBuf; use super::{ config::{get_config_path, load_config}, + package_manager, spec::{EnvScope, EnvSpecs}, }; use crate::{cli::PinTarget, error::Error}; @@ -685,6 +686,7 @@ async fn pin_package_manager( return Err(Error::Other("--target node-version cannot pin a package manager".into())); } let resolved = resolve_package_manager_version(package_manager, version).await?; + package_manager::warn_if_target_differs(cwd, package_manager).await; let package_json_path = cwd.join(PACKAGE_JSON_FILE); let content = tokio::fs::read_to_string(&package_json_path).await?; let mut changed = false; diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 52829cb242..0927abe71c 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -135,8 +135,12 @@ pub async fn execute( let package_manager = if scope.includes_package_managers() { let resolved = if let Some((kind, selector)) = specs.package_manager { let version = resolve_package_manager_version(kind, &selector).await?.to_string(); + package_manager::warn_if_target_differs(&cwd, kind).await; Some((kind, version, selector)) } else { + if let EnvScope::PackageManager(kind) = scope { + package_manager::warn_if_target_differs(&cwd, kind).await; + } config::delete_session_package_manager().await?; package_manager::resolve_from_files(&cwd).await?.and_then(|resolution| { if matches!(scope, EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) { diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 3283f54727..de320449a0 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -135,6 +135,7 @@ pub struct EnvironmentPackageManagerResolution { pub version: Str, pub hash: Option, pub source: Str, + pub source_kind: PackageManagerSource, pub source_path: Option, pub project_root: Option, } @@ -142,6 +143,8 @@ pub struct EnvironmentPackageManagerResolution { /// Where the package manager selection came from (see rfcs/dev-engines.md). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PackageManagerSource { + /// Explicit command or session override + ExplicitOverride, /// Top-level `packageManager` field in package.json PackageManagerField, /// `devEngines.packageManager` field in package.json @@ -152,6 +155,13 @@ pub enum PackageManagerSource { Default, } +impl PackageManagerSource { + #[must_use] + pub const fn is_default(self) -> bool { + matches!(self, Self::Default) + } +} + /// The package manager. /// Use `PackageManager::builder()` to create a package manager. /// Command argument resolution and execution live in `vp_pm_cli`. @@ -413,6 +423,7 @@ pub async fn resolve_environment_package_manager( version: resolve_package_manager_version(package_manager_type, version).await?, hash: None, source: "session".into(), + source_kind: PackageManagerSource::ExplicitOverride, source_path: None, project_root: None, })); @@ -434,6 +445,7 @@ pub async fn resolve_environment_package_manager( version, hash: project.hash, source: project.source, + source_kind: PackageManagerSource::PackageManagerField, source_path: Some(project.source_path), project_root: Some(project.project_root), })); @@ -448,6 +460,7 @@ pub async fn resolve_environment_package_manager( version: resolve_package_manager_version(package_manager_type, &version_req).await?, hash: None, source: "devEngines.packageManager".into(), + source_kind: PackageManagerSource::DevEnginesPackageManager, source_path: Some(workspace_root.path.join("package.json").to_absolute_path_buf()), project_root: Some(workspace_root.path.to_absolute_path_buf()), })); @@ -455,7 +468,9 @@ pub async fn resolve_environment_package_manager( match get_package_manager_type_and_version(&workspace_root, None) { Ok((package_manager_type, version_req, hash, source)) => { - let source = match source { + let source_kind = source; + let source = match source_kind { + PackageManagerSource::ExplicitOverride => "explicit override", PackageManagerSource::LockfileOrConfig => "lockfile or config", PackageManagerSource::Default => "default", PackageManagerSource::PackageManagerField => "packageManager", @@ -467,6 +482,7 @@ pub async fn resolve_environment_package_manager( .await?, hash, source: source.into(), + source_kind, source_path: None, project_root: Some(workspace_root.path.to_absolute_path_buf()), })) @@ -489,6 +505,7 @@ async fn resolve_default_package_manager( version: resolve_package_manager_version(package_manager_type, version).await?, hash: None, source: "default".into(), + source_kind: PackageManagerSource::Default, source_path: None, project_root: None, })) @@ -1978,6 +1995,7 @@ mod tests { assert_eq!(resolution.package_manager_type, PackageManagerType::Yarn); assert_eq!(resolution.version, "1.22.22"); assert_eq!(resolution.source, "session"); + assert_eq!(resolution.source_kind, PackageManagerSource::ExplicitOverride); } #[tokio::test] @@ -1998,6 +2016,7 @@ mod tests { assert_eq!(resolution.package_manager_type, PackageManagerType::Bun); assert_eq!(resolution.version, "1.2.0"); assert_eq!(resolution.source, "default"); + assert_eq!(resolution.source_kind, PackageManagerSource::Default); } /// How fully a fake package manager install is written. From f7d3a0bb19004bbf05fd6e7a74367e89d100388b Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 22:15:00 +0800 Subject: [PATCH 12/22] fix(env): align package manager environment state --- .../snapshots.toml | 12 ++ ..._env_package_manager_session_provenance.md | 44 +++++++ .../snapshots.toml | 20 ++++ ...list_ignores_package_manager_resolution.md | 35 ++++++ ...in_warns_for_lockfile_selection_offline.md | 20 ++++ .../package.json | 21 ++++ .../packages/app/package.json | 4 + .../snapshots.toml | 20 ++++ ...requested_dev_engines_manager_effective.md | 63 ++++++++++ ...n_package_manager_from_nested_workspace.md | 72 ++++++++++++ .../command_env_unified/snapshots.toml | 7 -- .../snapshots/command_env_unified.md | 88 -------------- .../fixtures/command_env_use/.node-version | 1 + .../fixtures/command_env_use/package.json | 3 +- .../fixtures/command_env_use/snapshots.toml | 1 + .../snapshots/command_env_use.md | 7 ++ .../src/commands/env/lifecycle.rs | 29 +---- crates/vp_global_cli/src/commands/env/list.rs | 73 ++++++------ .../src/commands/env/list_remote.rs | 71 ++++++------ .../src/commands/env/package_manager.rs | 109 +++++++++++++++--- crates/vp_global_cli/src/commands/env/pin.rs | 75 +++++------- crates/vp_global_cli/src/commands/env/spec.rs | 22 ++++ crates/vp_global_cli/src/commands/env/use.rs | 51 +++----- .../vp_global_cli/src/commands/env/which.rs | 31 +++-- 24 files changed, 574 insertions(+), 305 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_session_provenance.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/packages/app/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/.node-version diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml index 1a967c1d6e..35e45ba7ce 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots.toml @@ -6,3 +6,15 @@ steps = [ { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','which','npm'],{encoding:'utf8'});if(!text.includes('Package:')||!text.includes('npm@10.9.4')||!text.includes('package.json'))process.exit(1);console.log('which reports npm packageManager')"], comment = "which reports the npm packageManager pin" }, { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const text=execFileSync('vp',['env','which','npx'],{encoding:'utf8'});if(!text.includes('Package:')||!text.includes('npm@10.9.4')||!text.includes('package.json'))process.exit(1);console.log('which reports npx packageManager')"], comment = "which reports the npx packageManager pin" }, ] + +[[case]] +name = "command_env_package_manager_session_provenance" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "env", "use", "npm@10.9.4", "--no-install"], snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npm", "#!/bin/sh\n"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "$VP_HOME/package_manager/npm/10.9.4/npm/bin/npm"], snapshot = false }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "current reports the package-manager session file path" }, + { argv = ["vp", "env", "which", "npm"], comment = "which reports the package-manager session file as its source" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_session_provenance.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_session_provenance.md new file mode 100644 index 0000000000..534ce55cd2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_diagnostics/snapshots/command_env_package_manager_session_provenance.md @@ -0,0 +1,44 @@ +# command_env_package_manager_session_provenance + +## `vp env use npm@10.9.4 --no-install` + + +## `vpt write-file $VP_HOME/package_manager/npm/10.9.4/npm/bin/npm '#'\!'/bin/sh +'` + + +## `vpt chmod +x $VP_HOME/package_manager/npm/10.9.4/npm/bin/npm` + + +## `vp env current pm --json` + +current reports the package-manager session file path + +``` +{ + "package_manager": { + "name": "npm", + "version": "", + "source": ".session-package-manager", + "source_path": "/.vite-plus/.session-package-manager", + "bin_paths": { + "npm": "/.vite-plus/package_manager/npm//npm/bin/npm", + "npx": "/.vite-plus/package_manager/npm//npm/bin/npx" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vp env which npm` + +which reports the package-manager session file as its source + +``` +VITE+ - The Unified Toolchain for the Web + +/.vite-plus/package_manager/npm//npm/bin/npm + Package: npm@10.9.4 + Source: /.vite-plus/.session-package-manager +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml index fc53902c80..0c715e35e6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots.toml @@ -27,3 +27,23 @@ steps = [ { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true}\n"], snapshot = false }, { argv = ["vp", "env", "use", "yarn@4.12.0", "--no-install"], comment = "a different fallback manager does not warn" }, ] + +[[case]] +name = "env_pin_warns_for_lockfile_selection_offline" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true}\n"], snapshot = false }, + { argv = ["vpt", "touch-file", "pnpm-lock.yaml"], snapshot = false }, + { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "lockfile mismatch warning does not depend on registry resolution", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] + +[[case]] +name = "env_node_list_ignores_package_manager_resolution" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vpt", "write-file", "package.json", "{\"name\":\"command-env-package-manager-mismatch\",\"private\":true,\"devEngines\":{\"packageManager\":{\"name\":\"pnpm\",\"version\":\"^10.0.0\"}}}\n"], snapshot = false }, + { argv = ["vp", "env", "list", "node", "--json"], comment = "the node selector does not resolve an excluded package manager", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, + { argv = ["vp", "env", "list-remote", "20.18.0", "--lts", "--json"], comment = "the implicit node selector does not resolve an excluded package manager", envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md new file mode 100644 index 0000000000..f1a848d0dc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_node_list_ignores_package_manager_resolution.md @@ -0,0 +1,35 @@ +# env_node_list_ignores_package_manager_resolution + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true,"devEngines":{"packageManager":{"name":"pnpm","version":"^10.0.0"}}} +'` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list node --json` + +the node selector does not resolve an excluded package manager + +``` +{ + "node": [] +} +``` + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env list-remote 20.18.0 --lts --json` + +the implicit node selector does not resolve an excluded package manager + +``` +{ + "node": [ + { + "version": "20.18.0", + "lts": "Iron", + "latest": false, + "latest_lts": false, + "installed": false, + "current": false, + "default": false + } + ] +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md new file mode 100644 index 0000000000..e64b42fbfb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_mismatch/snapshots/env_pin_warns_for_lockfile_selection_offline.md @@ -0,0 +1,20 @@ +# env_pin_warns_for_lockfile_selection_offline + +## `vpt write-file package.json '{"name":"command-env-package-manager-mismatch","private":true} +'` + + +## `vpt touch-file pnpm-lock.yaml` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 vp env pin yarn@4.12.0 --no-install` + +lockfile mismatch warning does not depend on registry resolution + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to pnpm from lockfile or config, but yarn was requested. +✓ Pinned package manager to yarn@4.12.0 +note: Package manager will be downloaded on first use. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/package.json new file mode 100644 index 0000000000..cbf02f5c3c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/package.json @@ -0,0 +1,21 @@ +{ + "name": "command-env-pin-package-manager", + "private": true, + "workspaces": [ + "packages/*" + ], + "devEngines": { + "packageManager": [ + { + "name": "npm", + "version": "11.0.0", + "onFail": "error" + }, + { + "name": "pnpm", + "version": "10.17.0", + "onFail": "download" + } + ] + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/packages/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/packages/app/package.json new file mode 100644 index 0000000000..0a2d4152f2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/packages/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "app", + "private": true +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml new file mode 100644 index 0000000000..29a844fada --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots.toml @@ -0,0 +1,20 @@ +[[case]] +name = "env_pin_makes_requested_dev_engines_manager_effective" +vp = "global" +seed-runtime = false +steps = [ + { argv = ["vp", "env", "pin", "pnpm@10.18.0", "--no-install"], comment = "pinning an existing later option makes it the effective first supported entry" }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "current resolves the newly pinned manager" }, + { argv = ["vpt", "print-file", "package.json"], comment = "the pin preserves sibling options and their policy" }, +] + +[[case]] +name = "env_pin_package_manager_from_nested_workspace" +vp = "global" +seed-runtime = false +cwd = "packages/app" +steps = [ + { argv = ["vp", "env", "pin", "yarn@4.12.0", "--no-install"], comment = "a nested workspace pin updates the resolver-owned root manifest" }, + { argv = ["vp", "env", "current", "pm", "--json"], comment = "the nested project resolves the new root pin" }, + { argv = ["vpt", "print-file", "../../package.json", "package.json"], comment = "only the workspace manifest owns the package-manager pin" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md new file mode 100644 index 0000000000..7c0461090c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_makes_requested_dev_engines_manager_effective.md @@ -0,0 +1,63 @@ +# env_pin_makes_requested_dev_engines_manager_effective + +## `vp env pin pnpm@10.18.0 --no-install` + +pinning an existing later option makes it the effective first supported entry + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to npm from devEngines.packageManager, but pnpm was requested. +✓ Pinned package manager to pnpm@10.18.0 +note: Package manager will be downloaded on first use. +``` + +## `vp env current pm --json` + +current resolves the newly pinned manager + +``` +{ + "package_manager": { + "name": "pnpm", + "version": "", + "source": "devEngines.packageManager", + "source_path": "/package.json", + "project_root": "", + "bin_paths": { + "pnpm": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm", + "pnpx": "/.vite-plus/package_manager/pnpm//pnpm/bin/pnpx" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vpt print-file package.json` + +the pin preserves sibling options and their policy + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "workspaces": [ + "packages/*" + ], + "devEngines": { + "packageManager": [ + { + "name": "pnpm", + "version": "", + "onFail": "download" + }, + { + "name": "npm", + "version": "", + "onFail": "error" + } + ] + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md new file mode 100644 index 0000000000..5422195f05 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_pin_package_manager/snapshots/env_pin_package_manager_from_nested_workspace.md @@ -0,0 +1,72 @@ +# env_pin_package_manager_from_nested_workspace + +## `vp env pin yarn@4.12.0 --no-install` + +a nested workspace pin updates the resolver-owned root manifest + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Current environment resolves to npm from devEngines.packageManager, but yarn was requested. +✓ Pinned package manager to yarn@4.12.0 +note: Package manager will be downloaded on first use. +``` + +## `vp env current pm --json` + +the nested project resolves the new root pin + +``` +{ + "package_manager": { + "name": "yarn", + "version": "", + "source": "devEngines.packageManager", + "source_path": "/package.json", + "project_root": "", + "bin_paths": { + "yarn": "/.vite-plus/package_manager/yarn//yarn/bin/yarn", + "yarnpkg": "/.vite-plus/package_manager/yarn//yarn/bin/yarnpkg" + }, + "installed": false, + "mode": "managed" + } +} +``` + +## `vpt print-file ../../package.json package.json` + +only the workspace manifest owns the package-manager pin + +``` +{ + "name": "command-env-pin-package-manager", + "private": true, + "workspaces": [ + "packages/*" + ], + "devEngines": { + "packageManager": [ + { + "name": "yarn", + "version": "", + "onFail": "download" + }, + { + "name": "npm", + "version": "", + "onFail": "error" + }, + { + "name": "pnpm", + "version": "", + "onFail": "download" + } + ] + } +} +{ + "name": "app", + "private": true +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_unified/snapshots.toml index 8fdab949fb..4d3473010a 100644 --- 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 @@ -11,13 +11,6 @@ steps = [ { 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", "off", "node"], comment = "change only the Node.js mode" }, - { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "the inherited PM mode remains managed" }, - { argv = ["vp", "env", "off", "pm"], comment = "change only the package-manager mode" }, - { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "both modes can be system-first" }, - { argv = ["vp", "env", "on", "node"], comment = "restore only the Node.js mode" }, - { argv = ["vpt", "print-file", "$VP_HOME/config.json"], comment = "mixed mode configuration is persisted" }, - { argv = ["vp", "env", "on", "pm"], comment = "restore only the package-manager mode" }, { 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 index c5a7954bde..f9af7ba490 100644 --- 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 @@ -136,94 +136,6 @@ the pm selector omits Node.js } ``` -## `vp env off node` - -change only the Node.js mode - -``` -VITE+ - The Unified Toolchain for the Web - -✓ Node.js 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. -``` - -## `vpt print-file $VP_HOME/config.json` - -the inherited PM mode remains managed - -``` -{ - "shimMode": "system_first", - "packageManagerShimMode": "managed" -} -``` - -## `vp env off pm` - -change only the package-manager mode - -``` -VITE+ - The Unified Toolchain for the Web - -✓ 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. -``` - -## `vpt print-file $VP_HOME/config.json` - -both modes can be system-first - -``` -{ - "shimMode": "system_first", - "packageManagerShimMode": "system_first" -} -``` - -## `vp env on node` - -restore only the Node.js mode - -``` -VITE+ - The Unified Toolchain for the Web - -✓ Node.js management set to managed. - -Selected commands and shims will now use Vite+ managed tools. - -Run `vp env off` to prefer system tools instead. -``` - -## `vpt print-file $VP_HOME/config.json` - -mixed mode configuration is persisted - -``` -{ - "packageManagerShimMode": "system_first" -} -``` - -## `vp env on pm` - -restore only the package-manager mode - -``` -VITE+ - The Unified Toolchain for the Web - -✓ Package-manager management set to managed. - -Selected commands and shims will now use Vite+ managed tools. - -Run `vp env off` to prefer system tools instead. -``` - ## `vp env unpin` bare unpin removes both effective project pins diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/.node-version new file mode 100644 index 0000000000..2a393af592 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/.node-version @@ -0,0 +1 @@ +20.18.0 diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/package.json index 30895fc620..20211df413 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/package.json @@ -1,5 +1,6 @@ { "name": "command-env-use", "version": "1.0.0", - "private": true + "private": true, + "packageManager": "npm@10.9.4" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml index 546644b3b9..9fbd8b6be9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots.toml @@ -9,4 +9,5 @@ steps = [ { argv = ["vp", "env", "use", "--unset"], comment = "should output unset command to stdout", continue-on-failure = true }, { argv = ["vp", "env", "use", "d"], comment = "should show friendly error for invalid version", continue-on-failure = true }, { argv = ["vp", "env", "use", "abc"], comment = "should show friendly error for invalid version", continue-on-failure = true }, + { argv = ["vp", "env", "use", "--silent-if-unchanged", "--no-install"], comment = "an unchanged project environment emits no shell mutations", envs = [["VP_NODE_VERSION", "20.18.0"], ["VP_PACKAGE_MANAGER", "npm@10.9.4"]] }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_use/snapshots/command_env_use.md index 04f549bf96..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 @@ -78,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_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs index 283595efed..e76be99047 100644 --- a/crates/vp_global_cli/src/commands/env/lifecycle.rs +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -13,7 +13,7 @@ pub(crate) async fn install( cwd: AbsolutePathBuf, requests: Vec, ) -> Result { - let (scope, specs) = parse_requests(&requests)?; + let (scope, specs) = EnvSpecs::parse_requests(&requests)?; if scope.includes_node() { let version = match specs.node { @@ -102,30 +102,3 @@ pub(crate) async fn uninstall(specs: Vec) -> Result { } Ok(ExitStatus::default()) } - -fn parse_requests(requests: &[String]) -> Result<(EnvScope, EnvSpecs), Error> { - if requests.len() == 1 - && let Ok(scope) = EnvScope::parse(Some(&requests[0])) - { - return Ok((scope, EnvSpecs::default())); - } - let specs = EnvSpecs::parse(requests)?; - 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)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn legacy_bare_install_version_selects_node() { - let (scope, specs) = parse_requests(&["22.0.0".into()]).unwrap(); - assert_eq!(scope, EnvScope::Node); - assert_eq!(specs.node.as_deref(), Some("22.0.0")); - } -} diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index b78bda8194..4da12f95b3 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -8,8 +8,7 @@ use vp_pm_cli::{ use vt_path::AbsolutePathBuf; use super::{ - config, - package_manager::{self, ALL_PACKAGE_MANAGERS}, + config, package_manager, spec::{EnvScope, parse_package_manager_spec}, }; use crate::error::Error; @@ -53,22 +52,41 @@ pub async fn execute( let scope = EnvScope::parse(scope.as_deref())?; let home = vp_shared::get_vp_home()?; let config = config::load_config().await?; - let current_node = - config::resolve_version(&cwd).await.ok().map(|resolution| resolution.version); - let current_pm = package_manager::resolve_current(&cwd).await?; - let default_node = match config.default_node_version.as_deref() { - Some(selector) => Some( - config::resolve_version_alias(selector, &vp_js_runtime::NodeProvider::new()).await?, - ), - None => None, + let current_node = if scope.includes_node() { + config::resolve_version(&cwd).await.ok().map(|resolution| resolution.version) + } else { + None + }; + let current_pm = if scope.includes_package_managers() { + package_manager::resolve_current(&cwd).await? + } else { + None + }; + let default_node = if scope.includes_node() { + match config.default_node_version.as_deref() { + Some(selector) => Some( + config::resolve_version_alias(selector, &vp_js_runtime::NodeProvider::new()) + .await?, + ), + None => None, + } + } else { + None }; - let default_pm = - config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; - let default_pm = match default_pm { - Some((kind, selector)) => { - Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) + let default_pm = if scope.includes_package_managers() { + let default = config + .default_package_manager + .as_deref() + .map(parse_package_manager_spec) + .transpose()?; + match default { + Some((kind, selector)) => { + Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) + } + None => None, } - None => None, + } else { + None }; let node = scope.includes_node().then(|| { @@ -83,7 +101,7 @@ pub async fn execute( }); let package_managers = if scope.includes_package_managers() { - let selected = selected_package_managers(scope); + let selected = package_manager::selected(scope); Some( selected .into_iter() @@ -121,13 +139,13 @@ pub async fn execute( print_section("Node.js", &node); } if let Some(mut package_managers) = package_managers { - for kind in selected_package_managers(scope) { + 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_manager::title(kind), &package_managers.remove(&name).unwrap_or_default(), ); } @@ -135,23 +153,6 @@ pub async fn execute( Ok(ExitStatus::default()) } -fn package_manager_title(kind: PackageManagerType) -> &'static str { - match kind { - PackageManagerType::Npm => "npm", - PackageManagerType::Pnpm => "pnpm", - PackageManagerType::Yarn => "Yarn", - PackageManagerType::Bun => "Bun", - } -} - -fn selected_package_managers(scope: EnvScope) -> Vec { - match scope { - EnvScope::PackageManager(package_manager) => vec![package_manager], - EnvScope::All | EnvScope::PackageManagers => ALL_PACKAGE_MANAGERS.to_vec(), - EnvScope::Node => Vec::new(), - } -} - pub(super) fn list_complete_package_manager_versions( home: &AbsolutePathBuf, package_manager: PackageManagerType, 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 47a554ae96..f95115c3bf 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -3,15 +3,13 @@ use std::{collections::BTreeMap, process::ExitStatus}; use futures::future::try_join_all; use serde::Serialize; use vp_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry}; -use vp_pm_cli::{ - PackageManagerType, fetch_package_manager_versions, resolve_package_manager_version, -}; +use vp_pm_cli::{fetch_package_manager_versions, resolve_package_manager_version}; use vt_path::AbsolutePathBuf; use super::{ config, list::{list_complete_package_manager_versions, list_installed_versions}, - package_manager::{self, ALL_PACKAGE_MANAGERS}, + package_manager, spec::{EnvScope, parse_package_manager_spec}, }; use crate::{cli::SortingMethod, error::Error}; @@ -63,7 +61,7 @@ pub async fn execute( } let provider = NodeProvider::new(); - let package_manager_types = selected_package_managers(scope); + 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| { @@ -85,20 +83,38 @@ pub async fn execute( let package_manager_versions = package_manager_versions?; let config = config::load_config().await?; - let current_node = - config::resolve_version(&cwd).await.ok().map(|resolution| resolution.version); - let current_pm = package_manager::resolve_current(&cwd).await?; - let default_node = match config.default_node_version.as_deref() { - Some(selector) => Some(config::resolve_version_alias(selector, &provider).await?), - None => None, + let current_node = if scope.includes_node() { + config::resolve_version(&cwd).await.ok().map(|resolution| resolution.version) + } else { + None + }; + let current_pm = if scope.includes_package_managers() { + package_manager::resolve_current(&cwd).await? + } else { + None + }; + let default_node = if scope.includes_node() { + match config.default_node_version.as_deref() { + Some(selector) => Some(config::resolve_version_alias(selector, &provider).await?), + None => None, + } + } else { + None }; - let default_pm = - config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; - let default_pm = match default_pm { - Some((kind, selector)) => { - Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) + let default_pm = if scope.includes_package_managers() { + let default = config + .default_package_manager + .as_deref() + .map(parse_package_manager_spec) + .transpose()?; + match default { + Some((kind, selector)) => { + Some((kind, resolve_package_manager_version(kind, &selector).await?.to_string())) + } + None => None, } - None => None, + } else { + None }; let home = vp_shared::get_vp_home()?; @@ -155,11 +171,11 @@ pub async fn execute( } } if let Some(mut package_managers) = package_managers { - for kind in selected_package_managers(scope) { + 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)); + println!("{}", package_manager::title(kind)); for entry in versions { println!( " {}{}", @@ -172,15 +188,6 @@ pub async fn execute( Ok(ExitStatus::default()) } -fn package_manager_title(kind: PackageManagerType) -> &'static str { - match kind { - PackageManagerType::Npm => "npm", - PackageManagerType::Pnpm => "pnpm", - PackageManagerType::Yarn => "Yarn", - PackageManagerType::Bun => "Bun", - } -} - fn parse_scope_and_pattern(values: &[String]) -> Result<(EnvScope, Option), Error> { match values { [] => Ok((EnvScope::All, None)), @@ -193,14 +200,6 @@ fn parse_scope_and_pattern(values: &[String]) -> Result<(EnvScope, Option Vec { - match scope { - EnvScope::All | EnvScope::PackageManagers => ALL_PACKAGE_MANAGERS.to_vec(), - EnvScope::PackageManager(kind) => vec![kind], - EnvScope::Node => Vec::new(), - } -} - fn build_node_versions( versions: &[NodeVersionEntry], pattern: Option<&str>, diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index b2f50477b9..6de0d90abb 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -1,5 +1,7 @@ use vp_pm_cli::{ - EnvironmentPackageManagerResolution, PackageManagerType, resolve_environment_package_manager, + EnvironmentPackageManagerResolution, PackageManagerSource, PackageManagerType, + get_package_manager_type_and_version, resolve_environment_package_manager, + resolve_package_manager_from_package_json, }; use vt_path::AbsolutePath; @@ -9,14 +11,22 @@ use crate::error::Error; pub(crate) async fn resolve_current( cwd: &AbsolutePath, ) -> Result, Error> { - let (session, session_source) = if let Some(spec) = vp_shared::EnvConfig::get().package_manager - { - (Some(parse_package_manager_spec(spec.trim())?), Some(config::PACKAGE_MANAGER_ENV_VAR)) - } else if let Some(spec) = config::read_session_package_manager().await { - (Some(parse_package_manager_spec(spec.trim())?), Some(config::SESSION_PACKAGE_MANAGER_FILE)) - } else { - (None, None) - }; + let (session, session_source, session_source_path) = + if let Some(spec) = vp_shared::EnvConfig::get().package_manager { + ( + Some(parse_package_manager_spec(spec.trim())?), + Some(config::PACKAGE_MANAGER_ENV_VAR), + None, + ) + } else if let Some(spec) = config::read_session_package_manager().await { + ( + Some(parse_package_manager_spec(spec.trim())?), + Some(config::SESSION_PACKAGE_MANAGER_FILE), + config::get_session_package_manager_path().ok(), + ) + } else { + (None, None, None) + }; let config = config::load_config().await?; let default = config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; @@ -30,6 +40,7 @@ pub(crate) async fn resolve_current( .map_err(Error::from)?; if let (Some(resolution), Some(source)) = (&mut resolution, session_source) { resolution.source = source.into(); + resolution.source_path = session_source_path; } Ok(resolution) } @@ -50,20 +61,92 @@ pub(crate) async fn resolve_from_files( } pub(crate) async fn warn_if_target_differs(cwd: &AbsolutePath, target: PackageManagerType) { - let Ok(Some(resolution)) = resolve_current(cwd).await else { + let Ok(Some((current, source, source_kind))) = resolve_selection(cwd).await else { return; }; - if !resolution.source_kind.is_default() && resolution.package_manager_type != target { + if !source_kind.is_default() && current != target { vp_shared::output::warn(&format!( - "Current environment resolves to {} from {}, but {target} was requested.", - resolution.package_manager_type, resolution.source + "Current environment resolves to {current} from {source}, but {target} was requested." )); } } +async fn resolve_selection( + cwd: &AbsolutePath, +) -> Result, Error> { + // Mismatch warnings only need the selected family and source. Keeping this + // path free of exact-version resolution makes the warning reliable offline. + if let Some(spec) = vp_shared::EnvConfig::get().package_manager { + let (kind, _) = parse_package_manager_spec(spec.trim())?; + return Ok(Some(( + kind, + config::PACKAGE_MANAGER_ENV_VAR.into(), + PackageManagerSource::ExplicitOverride, + ))); + } + if let Some(spec) = config::read_session_package_manager().await { + let (kind, _) = parse_package_manager_spec(spec.trim())?; + return Ok(Some(( + kind, + config::SESSION_PACKAGE_MANAGER_FILE.into(), + PackageManagerSource::ExplicitOverride, + ))); + } + if let Some(resolution) = resolve_package_manager_from_package_json(cwd)? { + let source_kind = if resolution.source == "packageManager" { + PackageManagerSource::PackageManagerField + } else { + PackageManagerSource::DevEnginesPackageManager + }; + return Ok(Some(( + resolution.package_manager_type, + resolution.source.to_string(), + source_kind, + ))); + } + let (workspace_root, _) = match vt_workspace::find_workspace_root(cwd) { + Ok(result) => result, + Err(vt_workspace::Error::PackageJsonNotFound(_)) => return Ok(None), + Err(error) => return Err(error.into()), + }; + match get_package_manager_type_and_version(&workspace_root, None) { + Ok((kind, _, _, source_kind)) => { + let source = match source_kind { + PackageManagerSource::LockfileOrConfig => "lockfile or config", + PackageManagerSource::Default => "default", + PackageManagerSource::ExplicitOverride => "explicit override", + PackageManagerSource::PackageManagerField => "packageManager", + PackageManagerSource::DevEnginesPackageManager => "devEngines.packageManager", + }; + Ok(Some((kind, source.into(), source_kind))) + } + Err(vp_error::Error::UnrecognizedPackageManager) => Ok(None), + Err(error) => Err(error.into()), + } +} + 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 216b60a8c4..3fb4d15d10 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -56,11 +56,13 @@ pub async fn execute( } let specs = EnvSpecs::parse(&specs)?; - if specs.package_manager.is_some() - && !tokio::fs::try_exists(cwd.join(PACKAGE_JSON_FILE)).await.unwrap_or(false) - { - return Err(Error::Other("cannot pin a package manager without package.json".into())); - } + 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)) @@ -75,7 +77,8 @@ pub async fn execute( do_pin(&cwd, &version, no_install, force, target).await?; } if let Some((package_manager, version)) = specs.package_manager { - pin_package_manager(&cwd, package_manager, &version, no_install, force, target).await?; + pin_package_manager(&package_manager_root, package_manager, &version, no_install, target) + .await?; } Ok(ExitStatus::default()) } @@ -679,7 +682,6 @@ async fn pin_package_manager( package_manager: PackageManagerType, version: &str, no_install: bool, - _force: bool, target: Option, ) -> Result { if matches!(target, Some(PinTarget::NodeVersion)) { @@ -758,17 +760,19 @@ fn set_dev_engines_package_manager( } Value::Object(_) => { let previous = std::mem::take(field); - *field = Value::Array(vec![previous, entry]); + *field = Value::Array(vec![entry, previous]); } Value::Array(entries) => { - if let Some(value) = entries.iter_mut().filter_map(Value::as_object_mut).find(|value| { - value.get("name").and_then(Value::as_str) - == Some(package_manager.to_string().as_str()) - }) { + 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())); - } else { - entries.push(entry); } + entries.insert(0, entry); } _ => *field = entry, } @@ -782,12 +786,13 @@ async fn unpin_package_manager( if matches!(target, Some(PinTarget::NodeVersion)) { return Ok(()); } - let package_json_path = cwd.join(PACKAGE_JSON_FILE); + 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(cwd)?; + let effective = resolve_package_manager_from_package_json(&root)?; let expected = match scope { EnvScope::PackageManager(package_manager) => Some(package_manager), _ => effective.as_ref().map(|resolution| resolution.package_manager_type), @@ -820,6 +825,14 @@ async fn unpin_package_manager( 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, @@ -876,9 +889,7 @@ mod tests { .await .unwrap(); - pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, false, None) - .await - .unwrap(); + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, None).await.unwrap(); let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); assert!(content.contains("pnpm@10.18.0+sha512.keep")); @@ -895,37 +906,13 @@ mod tests { .await .unwrap(); - pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, false, None) - .await - .unwrap(); + pin_package_manager(&cwd, PackageManagerType::Pnpm, "10.18.0", true, None).await.unwrap(); let content = tokio::fs::read_to_string(cwd.join("package.json")).await.unwrap(); assert!(content.contains("pnpm@10.18.0")); assert!(!content.contains("sha512.stale")); } - #[test] - fn set_package_manager_updates_matching_array_entry_and_keeps_siblings() { - let mut manifest = serde_json::json!({ - "devEngines": { - "packageManager": [ - { "name": "npm", "version": "11.0.0", "onFail": "error" }, - { "name": "pnpm", "version": "10.17.0", "onFail": "download" } - ] - } - }); - set_dev_engines_package_manager( - manifest.as_object_mut().unwrap(), - PackageManagerType::Pnpm, - "10.18.0", - ); - let entries = manifest["devEngines"]["packageManager"].as_array().unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0]["version"], "11.0.0"); - assert_eq!(entries[1]["version"], "10.18.0"); - assert_eq!(entries[1]["onFail"], "download"); - } - #[test] fn remove_last_package_manager_array_entry_removes_field() { let mut manifest = serde_json::json!({ diff --git a/crates/vp_global_cli/src/commands/env/spec.rs b/crates/vp_global_cli/src/commands/env/spec.rs index 1c3e3fb793..b64563f294 100644 --- a/crates/vp_global_cli/src/commands/env/spec.rs +++ b/crates/vp_global_cli/src/commands/env/spec.rs @@ -73,6 +73,21 @@ impl EnvSpecs { } 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( @@ -118,4 +133,11 @@ mod tests { assert_eq!(parsed.node.as_deref(), Some("22.0.0")); assert_eq!(parsed.package_manager, Some((PackageManagerType::Pnpm, "10.18.0".into()))); } + + #[test] + fn bare_version_request_selects_node() { + let (scope, specs) = EnvSpecs::parse_requests(&["22.0.0".into()]).unwrap(); + assert_eq!(scope, EnvScope::Node); + assert_eq!(specs.node.as_deref(), Some("22.0.0")); + } } diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 0927abe71c..2cec92d916 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -76,7 +76,8 @@ pub async fn execute( silent_if_unchanged: bool, ) -> Result { let shell = detect_shell(); - let (scope, specs) = parse_requests(&requests)?; + 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 @@ -116,14 +117,6 @@ pub async fn execute( let (version, source) = if let Some(selector) = specs.node.as_deref() { (config::resolve_version_alias(selector, &provider).await?, selector.to_string()) } else { - config::delete_session_version().await?; - if has_eval_wrapper() { - println!("{}", format_unset(&shell, VERSION_ENV_VAR)); - } else if !can_use_session_file() { - eprintln!("Reverted to file-based Node.js version resolution"); - print_windows_eval_wrapper_required(); - return Ok(ExitStatus::default()); - } let resolution = config::resolve_version_from_files(&cwd).await?; (resolution.version, resolution.source) }; @@ -141,7 +134,6 @@ pub async fn execute( if let EnvScope::PackageManager(kind) = scope { package_manager::warn_if_target_differs(&cwd, kind).await; } - config::delete_session_package_manager().await?; package_manager::resolve_from_files(&cwd).await?.and_then(|resolution| { if matches!(scope, EnvScope::PackageManager(kind) if kind != resolution.package_manager_type) { None @@ -195,6 +187,18 @@ pub async fn execute( } } + 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 && let Some((resolved_version, _)) = &node { let home_dir = @@ -254,21 +258,6 @@ fn current_override(session: Option, environment: Option) -> Opt environment.map(|value| value.trim().to_string()).or(session) } -fn parse_requests(requests: &[String]) -> Result<(EnvScope, EnvSpecs), Error> { - if requests.len() == 1 - && let Ok(scope) = EnvScope::parse(Some(&requests[0])) - { - return Ok((scope, EnvSpecs::default())); - } - let specs = EnvSpecs::parse(requests)?; - 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)) -} - #[cfg(test)] mod tests { use super::*; @@ -319,12 +308,6 @@ mod tests { assert_eq!(result, "export VP_NODE_VERSION=20.18.0"); } - #[test] - fn test_format_package_manager_export_posix() { - let result = format_export(&Shell::Posix, PACKAGE_MANAGER_ENV_VAR, "pnpm@10.18.0"); - assert_eq!(result, "export VP_PACKAGE_MANAGER=pnpm@10.18.0"); - } - #[test] fn test_format_export_fish() { let result = format_export(&Shell::Fish, VERSION_ENV_VAR, "20.18.0"); @@ -387,7 +370,7 @@ mod tests { temp_dir.path(), )); - let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + let status = execute(cwd, vec!["20.18.0".into()], false, true, false).await.unwrap(); assert_eq!(status.code(), Some(1)); assert!(config::read_session_version().await.is_none()); @@ -403,7 +386,7 @@ mod tests { ..vp_shared::EnvConfig::for_test_with_home(temp_dir.path()) }); - let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + let status = execute(cwd, vec!["20.18.0".into()], false, true, false).await.unwrap(); assert!(status.success()); assert_eq!(config::read_session_version().await.as_deref(), Some("20.18.0")); @@ -422,7 +405,7 @@ mod tests { config::write_session_version("22.0.0").await.unwrap(); - let status = execute(cwd, Some("20.18.0".into()), false, true, false).await.unwrap(); + let status = execute(cwd, vec!["20.18.0".into()], false, true, false).await.unwrap(); assert!(status.success()); assert!(config::read_session_version().await.is_none()); diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 7f7b80d369..667ec52f48 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -152,14 +152,19 @@ async fn execute_package_manager_tool( return Ok(None); }; let resolution = package_manager::resolve_current(cwd).await?; - let version = match &resolution { - Some(resolution) if resolution.package_manager_type == expected_type => { - resolution.version.to_string() - } + let (version, source) = match &resolution { + Some(resolution) if resolution.package_manager_type == expected_type => ( + resolution.version.to_string(), + resolution.source_path.as_ref().map_or_else( + || resolution.source.to_string(), + |path| path.as_path().display().to_string(), + ), + ), Some(_) | None if expected_type == PackageManagerType::Npm => return Ok(None), - Some(_) | None => { - resolve_package_manager_version(expected_type, "latest").await?.to_string() - } + Some(_) | None => ( + resolve_package_manager_version(expected_type, "latest").await?.to_string(), + "registry fallback".into(), + ), }; let Some(install_dir) = package_manager_install_dir(expected_type, &version) else { @@ -181,17 +186,7 @@ async fn execute_package_manager_tool( "Package:".dimmed(), format!("{expected_type}@{version}").bright_blue() ); - println!( - " {: Date: Mon, 10 Aug 2026 18:19:39 +0800 Subject: [PATCH 13/22] test(env): cover floating Bun default fallback --- .../snapshots.toml | 11 +++++++++++ .../shim_bun_default_latest_matches_fallback.md | 15 +++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_bun_default_latest_matches_fallback.md 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 cc7ec5c2cc..ca0d114ff6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml @@ -12,3 +12,14 @@ steps = [ { argv = ["vpt", "write-file", ".node-version", ">=999.0.0\n"], snapshot = false }, { argv = ["pnpm", "--version"], envs = [["VP_NODE_DIST_MIRROR", "http://127.0.0.1:9"]], comment = "JS package-manager shims report project Node resolution failures", continue-on-failure = true }, ] + +[[case]] +name = "shim_bun_default_latest_matches_fallback" +vp = "global" +local-registry = true +skip-platforms = ["windows"] +steps = [ + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const {writeFileSync}=require('node:fs');writeFileSync(process.env.VP_HOME+'/bun-fallback-version',execFileSync('bun',['--version'],{encoding:'utf8'}))"], snapshot = false }, + { argv = ["vp", "env", "default", "bun@latest"], snapshot = false }, + { argv = ["node", "-e", "const {execFileSync}=require('node:child_process');const {readFileSync}=require('node:fs');const expected=readFileSync(process.env.VP_HOME+'/bun-fallback-version','utf8');const actual=execFileSync('bun',['--version'],{encoding:'utf8'});if(actual!==expected)throw new Error(`expected ${expected.trim()}, got ${actual.trim()}`);console.log('default bun@latest matches the unconfigured fallback')"], envs = [["NPM_CONFIG_REGISTRY", "http://127.0.0.1:9"]], comment = "the explicit floating default behaves like the unconfigured Bun fallback" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_bun_default_latest_matches_fallback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_bun_default_latest_matches_fallback.md new file mode 100644 index 0000000000..a25c2cbdfa --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots/shim_bun_default_latest_matches_fallback.md @@ -0,0 +1,15 @@ +# shim_bun_default_latest_matches_fallback + +## `node -e 'const {execFileSync}=require('\''node:child_process'\'');const {writeFileSync}=require('\''node:fs'\'');writeFileSync(process.env.VP_HOME+'\''/bun-fallback-version'\'',execFileSync('\''bun'\'',['\''--version'\''],{encoding:'\''utf8'\''}))'` + + +## `vp env default bun@latest` + + +## `NPM_CONFIG_REGISTRY=http://127.0.0.1:9 node -e 'const {execFileSync}=require('\''node:child_process'\'');const {readFileSync}=require('\''node:fs'\'');const expected=readFileSync(process.env.VP_HOME+'\''/bun-fallback-version'\'','\''utf8'\'');const actual=execFileSync('\''bun'\'',['\''--version'\''],{encoding:'\''utf8'\''});if(actual'\!'==expected)throw new Error(`expected ${expected.trim()}, got ${actual.trim()}`);console.log('\''default bun@latest matches the unconfigured fallback'\'')'` + +the explicit floating default behaves like the unconfigured Bun fallback + +``` +default bun@latest matches the unconfigured fallback +``` From 1fb27b3c1c0ad1637cf67e2eec031b67d2637f13 Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 10 Aug 2026 19:21:46 +0800 Subject: [PATCH 14/22] refactor(env): remove unused resolution source kind --- crates/vp_pm_cli/src/package_manager.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index de320449a0..28622a8be4 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -135,7 +135,6 @@ pub struct EnvironmentPackageManagerResolution { pub version: Str, pub hash: Option, pub source: Str, - pub source_kind: PackageManagerSource, pub source_path: Option, pub project_root: Option, } @@ -423,7 +422,6 @@ pub async fn resolve_environment_package_manager( version: resolve_package_manager_version(package_manager_type, version).await?, hash: None, source: "session".into(), - source_kind: PackageManagerSource::ExplicitOverride, source_path: None, project_root: None, })); @@ -445,7 +443,6 @@ pub async fn resolve_environment_package_manager( version, hash: project.hash, source: project.source, - source_kind: PackageManagerSource::PackageManagerField, source_path: Some(project.source_path), project_root: Some(project.project_root), })); @@ -460,7 +457,6 @@ pub async fn resolve_environment_package_manager( version: resolve_package_manager_version(package_manager_type, &version_req).await?, hash: None, source: "devEngines.packageManager".into(), - source_kind: PackageManagerSource::DevEnginesPackageManager, source_path: Some(workspace_root.path.join("package.json").to_absolute_path_buf()), project_root: Some(workspace_root.path.to_absolute_path_buf()), })); @@ -468,8 +464,7 @@ pub async fn resolve_environment_package_manager( match get_package_manager_type_and_version(&workspace_root, None) { Ok((package_manager_type, version_req, hash, source)) => { - let source_kind = source; - let source = match source_kind { + let source = match source { PackageManagerSource::ExplicitOverride => "explicit override", PackageManagerSource::LockfileOrConfig => "lockfile or config", PackageManagerSource::Default => "default", @@ -482,7 +477,6 @@ pub async fn resolve_environment_package_manager( .await?, hash, source: source.into(), - source_kind, source_path: None, project_root: Some(workspace_root.path.to_absolute_path_buf()), })) @@ -505,7 +499,6 @@ async fn resolve_default_package_manager( version: resolve_package_manager_version(package_manager_type, version).await?, hash: None, source: "default".into(), - source_kind: PackageManagerSource::Default, source_path: None, project_root: None, })) @@ -1995,7 +1988,6 @@ mod tests { assert_eq!(resolution.package_manager_type, PackageManagerType::Yarn); assert_eq!(resolution.version, "1.22.22"); assert_eq!(resolution.source, "session"); - assert_eq!(resolution.source_kind, PackageManagerSource::ExplicitOverride); } #[tokio::test] @@ -2016,7 +2008,6 @@ mod tests { assert_eq!(resolution.package_manager_type, PackageManagerType::Bun); assert_eq!(resolution.version, "1.2.0"); assert_eq!(resolution.source, "default"); - assert_eq!(resolution.source_kind, PackageManagerSource::Default); } /// How fully a fake package manager install is written. From 8cdb8e081259ba527cbe280e64e4e074d46fb632 Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 10 Aug 2026 20:31:19 +0800 Subject: [PATCH 15/22] refactor(env): share package manager spec resolution --- .../vp_global_cli/src/commands/env/default.rs | 7 +- crates/vp_global_cli/src/commands/env/mod.rs | 2 +- .../src/commands/env/package_manager.rs | 131 ++++++++---------- crates/vp_pm_cli/src/lib.rs | 4 +- crates/vp_pm_cli/src/package_manager.rs | 93 ++++++++----- 5 files changed, 121 insertions(+), 116 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/default.rs b/crates/vp_global_cli/src/commands/env/default.rs index e416edf658..38be85f675 100644 --- a/crates/vp_global_cli/src/commands/env/default.rs +++ b/crates/vp_global_cli/src/commands/env/default.rs @@ -1,7 +1,6 @@ use std::process::ExitStatus; use vp_pm_cli::resolve_package_manager_version; -use vt_path::AbsolutePathBuf; use super::{ config::{get_config_path, load_config, save_config}, @@ -9,11 +8,7 @@ use super::{ }; use crate::error::Error; -pub async fn execute( - _cwd: AbsolutePathBuf, - values: Vec, - unset: bool, -) -> Result { +pub async fn execute(values: Vec, unset: bool) -> Result { if unset { let scope = match values.as_slice() { [] => EnvScope::All, diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index adf6dc95d3..6ce3d6ec94 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -81,7 +81,7 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result print_env(cwd, scope).await, crate::cli::EnvSubcommands::Default { values, unset } => { - default::execute(cwd, values, unset).await + default::execute(values, unset).await } crate::cli::EnvSubcommands::On { scope } => on::execute(scope).await, crate::cli::EnvSubcommands::Off { scope } => off::execute(scope).await, diff --git a/crates/vp_global_cli/src/commands/env/package_manager.rs b/crates/vp_global_cli/src/commands/env/package_manager.rs index 6de0d90abb..8647d05660 100644 --- a/crates/vp_global_cli/src/commands/env/package_manager.rs +++ b/crates/vp_global_cli/src/commands/env/package_manager.rs @@ -1,9 +1,8 @@ use vp_pm_cli::{ - EnvironmentPackageManagerResolution, PackageManagerSource, PackageManagerType, - get_package_manager_type_and_version, resolve_environment_package_manager, - resolve_package_manager_from_package_json, + EnvironmentPackageManagerResolution, PackageManagerType, resolve_environment_package_manager, + resolve_environment_package_manager_spec, }; -use vt_path::AbsolutePath; +use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{config, spec::parse_package_manager_spec}; use crate::error::Error; @@ -11,6 +10,55 @@ use crate::error::Error; pub(crate) async fn resolve_current( cwd: &AbsolutePath, ) -> Result, Error> { + let specs = current_specs().await?; + + let mut resolution = + resolve_environment_package_manager(cwd, specs.session_spec(), specs.default_spec()) + .await + .map_err(Error::from)?; + specs.apply_session_source(&mut resolution); + Ok(resolution) +} + +pub(crate) async fn resolve_current_spec( + cwd: &AbsolutePath, +) -> Result, Error> { + let specs = current_specs().await?; + + let mut resolution = + resolve_environment_package_manager_spec(cwd, specs.session_spec(), specs.default_spec()) + .map_err(Error::from)?; + specs.apply_session_source(&mut resolution); + Ok(resolution) +} + +type PackageManagerSpec = (PackageManagerType, String); + +struct CurrentSpecs { + session: Option, + session_source: Option<&'static str>, + session_source_path: Option, + default: Option, +} + +impl CurrentSpecs { + fn session_spec(&self) -> Option<(PackageManagerType, &str)> { + self.session.as_ref().map(|(kind, version)| (*kind, version.as_str())) + } + + fn default_spec(&self) -> Option<(PackageManagerType, &str)> { + self.default.as_ref().map(|(kind, version)| (*kind, version.as_str())) + } + + fn apply_session_source(&self, resolution: &mut Option) { + if let (Some(resolution), Some(source)) = (resolution, self.session_source) { + resolution.source = source.into(); + resolution.source_path.clone_from(&self.session_source_path); + } + } +} + +async fn current_specs() -> Result { let (session, session_source, session_source_path) = if let Some(spec) = vp_shared::EnvConfig::get().package_manager { ( @@ -30,19 +78,7 @@ pub(crate) async fn resolve_current( let config = config::load_config().await?; let default = config.default_package_manager.as_deref().map(parse_package_manager_spec).transpose()?; - - let mut resolution = resolve_environment_package_manager( - cwd, - session.as_ref().map(|(kind, version)| (*kind, version.as_str())), - default.as_ref().map(|(kind, version)| (*kind, version.as_str())), - ) - .await - .map_err(Error::from)?; - if let (Some(resolution), Some(source)) = (&mut resolution, session_source) { - resolution.source = source.into(); - resolution.source_path = session_source_path; - } - Ok(resolution) + Ok(CurrentSpecs { session, session_source, session_source_path, default }) } pub(crate) async fn resolve_from_files( @@ -61,70 +97,17 @@ pub(crate) async fn resolve_from_files( } pub(crate) async fn warn_if_target_differs(cwd: &AbsolutePath, target: PackageManagerType) { - let Ok(Some((current, source, source_kind))) = resolve_selection(cwd).await else { + let Ok(Some(current)) = resolve_current_spec(cwd).await else { return; }; - if !source_kind.is_default() && current != target { + if current.source != "default" && current.package_manager_type != target { vp_shared::output::warn(&format!( - "Current environment resolves to {current} from {source}, but {target} was requested." + "Current environment resolves to {} from {}, but {target} was requested.", + current.package_manager_type, current.source )); } } -async fn resolve_selection( - cwd: &AbsolutePath, -) -> Result, Error> { - // Mismatch warnings only need the selected family and source. Keeping this - // path free of exact-version resolution makes the warning reliable offline. - if let Some(spec) = vp_shared::EnvConfig::get().package_manager { - let (kind, _) = parse_package_manager_spec(spec.trim())?; - return Ok(Some(( - kind, - config::PACKAGE_MANAGER_ENV_VAR.into(), - PackageManagerSource::ExplicitOverride, - ))); - } - if let Some(spec) = config::read_session_package_manager().await { - let (kind, _) = parse_package_manager_spec(spec.trim())?; - return Ok(Some(( - kind, - config::SESSION_PACKAGE_MANAGER_FILE.into(), - PackageManagerSource::ExplicitOverride, - ))); - } - if let Some(resolution) = resolve_package_manager_from_package_json(cwd)? { - let source_kind = if resolution.source == "packageManager" { - PackageManagerSource::PackageManagerField - } else { - PackageManagerSource::DevEnginesPackageManager - }; - return Ok(Some(( - resolution.package_manager_type, - resolution.source.to_string(), - source_kind, - ))); - } - let (workspace_root, _) = match vt_workspace::find_workspace_root(cwd) { - Ok(result) => result, - Err(vt_workspace::Error::PackageJsonNotFound(_)) => return Ok(None), - Err(error) => return Err(error.into()), - }; - match get_package_manager_type_and_version(&workspace_root, None) { - Ok((kind, _, _, source_kind)) => { - let source = match source_kind { - PackageManagerSource::LockfileOrConfig => "lockfile or config", - PackageManagerSource::Default => "default", - PackageManagerSource::ExplicitOverride => "explicit override", - PackageManagerSource::PackageManagerField => "packageManager", - PackageManagerSource::DevEnginesPackageManager => "devEngines.packageManager", - }; - Ok(Some((kind, source.into(), source_kind))) - } - Err(vp_error::Error::UnrecognizedPackageManager) => Ok(None), - Err(error) => Err(error.into()), - } -} - pub(crate) const ALL_PACKAGE_MANAGERS: [PackageManagerType; 4] = [ PackageManagerType::Npm, PackageManagerType::Pnpm, diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index 6570d0ecbb..fe0ec60df7 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -29,8 +29,8 @@ pub use package_manager::{ 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_package_manager_from_package_json, - resolve_package_manager_version, + resolve_environment_package_manager, resolve_environment_package_manager_spec, + resolve_package_manager_from_package_json, resolve_package_manager_version, }; pub use request::HttpClient; pub use resolution::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 28622a8be4..5e2c068209 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -142,8 +142,6 @@ pub struct EnvironmentPackageManagerResolution { /// Where the package manager selection came from (see rfcs/dev-engines.md). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PackageManagerSource { - /// Explicit command or session override - ExplicitOverride, /// Top-level `packageManager` field in package.json PackageManagerField, /// `devEngines.packageManager` field in package.json @@ -156,8 +154,13 @@ pub enum PackageManagerSource { impl PackageManagerSource { #[must_use] - pub const fn is_default(self) -> bool { - matches!(self, Self::Default) + pub const fn description(self) -> &'static str { + match self { + Self::PackageManagerField => "packageManager", + Self::DevEnginesPackageManager => "devEngines.packageManager", + Self::LockfileOrConfig => "lockfile or config", + Self::Default => "default", + } } } @@ -409,9 +412,11 @@ pub fn resolve_package_manager_from_package_json( })) } -/// Resolve the package manager selected by an explicit/session override, project files, or default. -/// This never downloads a package manager, writes package.json, or prompts. -pub async fn resolve_environment_package_manager( +/// Read the package manager selected by an explicit/session override, project files, or default. +/// +/// The returned version is the declared requirement. It is intentionally not resolved against the +/// registry or managed installs, so callers can inspect the selection without network access. +pub fn resolve_environment_package_manager_spec( cwd: impl AsRef, override_spec: Option<(PackageManagerType, &str)>, default_spec: Option<(PackageManagerType, &str)>, @@ -419,7 +424,7 @@ pub async fn resolve_environment_package_manager( if let Some((package_manager_type, version)) = override_spec { return Ok(Some(EnvironmentPackageManagerResolution { package_manager_type, - version: resolve_package_manager_version(package_manager_type, version).await?, + version: version.into(), hash: None, source: "session".into(), source_path: None, @@ -430,17 +435,15 @@ pub async fn resolve_environment_package_manager( let (workspace_root, _) = match find_workspace_root(cwd.as_ref()) { Ok(result) => result, Err(vt_workspace::Error::PackageJsonNotFound(_)) => { - return resolve_default_package_manager(default_spec).await; + 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)? { - let version = - resolve_package_manager_version(project.package_manager_type, &project.version).await?; return Ok(Some(EnvironmentPackageManagerResolution { package_manager_type: project.package_manager_type, - version, + version: project.version, hash: project.hash, source: project.source, source_path: Some(project.source_path), @@ -454,7 +457,7 @@ pub async fn resolve_environment_package_manager( let version_req = version_req.unwrap_or_else(|| "*".into()); return Ok(Some(EnvironmentPackageManagerResolution { package_manager_type, - version: resolve_package_manager_version(package_manager_type, &version_req).await?, + version: version_req, hash: None, source: "devEngines.packageManager".into(), source_path: Some(workspace_root.path.join("package.json").to_absolute_path_buf()), @@ -464,44 +467,51 @@ pub async fn resolve_environment_package_manager( match get_package_manager_type_and_version(&workspace_root, None) { Ok((package_manager_type, version_req, hash, source)) => { - let source = match source { - PackageManagerSource::ExplicitOverride => "explicit override", - PackageManagerSource::LockfileOrConfig => "lockfile or config", - PackageManagerSource::Default => "default", - PackageManagerSource::PackageManagerField => "packageManager", - PackageManagerSource::DevEnginesPackageManager => "devEngines.packageManager", - }; Ok(Some(EnvironmentPackageManagerResolution { package_manager_type, - version: resolve_package_manager_version(package_manager_type, &version_req) - .await?, + version: version_req, hash, - source: source.into(), + source: source.description().into(), source_path: None, project_root: Some(workspace_root.path.to_absolute_path_buf()), })) } Err(Error::UnrecognizedPackageManager) => { - resolve_default_package_manager(default_spec).await + Ok(default_spec.map(environment_package_manager_default)) } Err(error) => Err(error), } } -async fn resolve_default_package_manager( - default_spec: Option<(PackageManagerType, &str)>, -) -> Result, Error> { - let Some((package_manager_type, version)) = default_spec else { - return Ok(None); - }; - Ok(Some(EnvironmentPackageManagerResolution { +fn environment_package_manager_default( + (package_manager_type, version): (PackageManagerType, &str), +) -> EnvironmentPackageManagerResolution { + EnvironmentPackageManagerResolution { package_manager_type, - version: resolve_package_manager_version(package_manager_type, version).await?, + version: version.into(), hash: None, source: "default".into(), source_path: None, project_root: None, - })) + } +} + +/// Resolve an environment package-manager requirement to an exact version for managed-runtime +/// operations such as `vp env install` and package-manager shims. +pub async fn resolve_environment_package_manager( + cwd: impl AsRef, + override_spec: Option<(PackageManagerType, &str)>, + default_spec: Option<(PackageManagerType, &str)>, +) -> Result, Error> { + let Some(mut resolution) = + resolve_environment_package_manager_spec(cwd, override_spec, default_spec)? + else { + return Ok(None); + }; + resolution.version = + resolve_package_manager_version(resolution.package_manager_type, &resolution.version) + .await?; + Ok(Some(resolution)) } /// Return the managed install directory for a package manager version. @@ -2010,6 +2020,23 @@ mod tests { 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). From ad8ac8a01c74ab832ef79fce257fc8277ddccfc1 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 20:57:10 +0800 Subject: [PATCH 16/22] refactor(pm): execute package managers from PATH --- crates/vp_global_cli/src/cli.rs | 57 +-- crates/vp_global_cli/src/commands/mod.rs | 37 +- crates/vp_pm_cli/src/cli.rs | 7 +- crates/vp_pm_cli/src/dispatch.rs | 114 +++-- crates/vp_pm_cli/src/error.rs | 10 + crates/vp_pm_cli/src/helpers.rs | 4 +- crates/vp_pm_cli/src/lib.rs | 3 +- crates/vp_pm_cli/src/package_manager.rs | 570 ++++----------------- crates/vp_pm_cli/src/resolution/resolve.rs | 21 +- packages/cli/binding/src/cli/mod.rs | 226 +------- packages/cli/binding/src/exec/workspace.rs | 6 +- 11 files changed, 224 insertions(+), 831 deletions(-) diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index 684918e74c..5c0154b6af 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -671,37 +671,19 @@ async fn run_package_manager_command( commands::prepend_js_runtime_to_path_env(&cwd).await?; let hint_command = command.clone(); - let selected = commands::env::package_manager::resolve_current(&cwd).await?; - let result = if let Some(selected) = selected.as_ref() - && commands::env::config::load_config().await?.package_manager_shim_mode() - == commands::env::config::ShimMode::SystemFirst - && let Some(system_path) = - crate::shim::dispatch::find_system_tool(&selected.package_manager_type.to_string()) - && let Some(manager) = - system_package_manager(selected.package_manager_type, &system_path).await - { - let package_manager = manager.client; - let status = - vp_pm_cli::dispatch_with_resolved_package_manager(&cwd, command, manager).await?; - vp_pm_cli::DispatchResult { status, package_manager } - } else { - match selected { - Some(selected) => { - let package_manager = selected.package_manager_type; - let status = vp_pm_cli::dispatch_with_package_manager( - &cwd, - command, - Some(( - selected.package_manager_type, - &selected.version, - selected.hash.as_deref(), - )), - ) - .await?; - vp_pm_cli::DispatchResult { status, package_manager } - } - None => vp_pm_cli::dispatch_with_metadata(&cwd, command).await?, + let selected = commands::env::package_manager::resolve_current_spec(&cwd).await?; + let result = match selected { + Some(selected) => { + let package_manager = selected.package_manager_type; + let status = vp_pm_cli::dispatch_with_package_manager( + &cwd, + command, + Some((selected.package_manager_type, &selected.version, &selected.source)), + ) + .await?; + vp_pm_cli::DispatchResult { status, package_manager } } + None => vp_pm_cli::dispatch_with_metadata(&cwd, command).await?, }; if result.status.success() && let Some(packages) = hint_command.why_hint_packages(result.package_manager) @@ -732,21 +714,6 @@ fn active_toolchain_manifest(cwd: &vt_path::AbsolutePath) -> Option Option { - let output = - tokio::process::Command::new(executable.as_path()).arg("--version").output().await.ok()?; - if !output.status.success() { - return None; - } - let version = std::str::from_utf8(&output.stdout).ok()?.trim(); - node_semver::Version::parse(version).ok()?; - let install_dir = executable.parent()?.parent()?.to_absolute_path_buf(); - Some(vp_pm_cli::PackageManager::from_install_dir(kind, version, install_dir)) -} - async fn managed_install( packages: &[String], node: Option<&str>, diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index b5f3d9bfa3..62632155dd 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -108,15 +108,9 @@ pub(crate) fn warn_missing_local_cli_if_project(cwd: &AbsolutePath) { } } -/// Ensure the JS runtime is downloaded 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. +/// Ensure the project or CLI Node.js runtime is available to package-manager binaries on PATH. pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Result<(), Error> { 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? @@ -125,36 +119,9 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu }; 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) { + if prepend_to_path_env(&node_bin_prefix, PrependOptions { dedupe_anywhere: true }) { tracing::debug!("Set PATH to include {:?}", node_bin_prefix); } - if let Some(package_manager) = env::package_manager::resolve_current(project_path).await? { - let config = env::config::load_config().await?; - if config.package_manager_shim_mode() == env::config::ShimMode::SystemFirst - && let Some(system_path) = crate::shim::dispatch::find_system_tool( - &package_manager.package_manager_type.to_string(), - ) - && let Some(bin_dir) = system_path.parent() - { - if prepend_to_path_env(bin_dir, PrependOptions { dedupe_anywhere: true }) { - tracing::debug!("Set PATH to include system package manager {:?}", bin_dir); - } - return Ok(()); - } - let (install_dir, _, _) = vp_pm_cli::download_package_manager( - package_manager.package_manager_type, - &package_manager.version, - package_manager.hash.as_deref(), - ) - .await?; - let bin_dir = install_dir.join("bin"); - if prepend_to_path_env(&bin_dir, PrependOptions { dedupe_anywhere: true }) { - tracing::debug!("Set PATH to include {:?}", bin_dir); - } - } - Ok(()) } diff --git a/crates/vp_pm_cli/src/cli.rs b/crates/vp_pm_cli/src/cli.rs index 343b5e78ad..a2f6cb95bd 100644 --- a/crates/vp_pm_cli/src/cli.rs +++ b/crates/vp_pm_cli/src/cli.rs @@ -412,12 +412,7 @@ mod tests { } fn package_manager(client: PackageManagerType, version: &str) -> PackageManager { - let workspace_root = vt_path::current_dir().unwrap(); - PackageManager { - client, - version: version.into(), - install_dir: workspace_root.join(".test-package-manager"), - } + PackageManager { client, version: version.into(), source: "test".into() } } #[test] diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index 772ad45d11..c8dcdaf7b2 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -10,7 +10,6 @@ use vt_path::AbsolutePath; use crate::{ PackageManager, PackageManagerType, cli::{PackageManagerCommand, PmCommand}, - download_package_manager, error::Error, helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, resolution::{DlxArgs, StageCommand, run_resolution}, @@ -56,6 +55,7 @@ pub async fn dispatch_with_metadata( ManagerPolicy::RequireProject => build_package_manager(cwd).await?, ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, }; + let manager = probe_package_manager(cwd, manager).await?; let package_manager = manager.client; let resolution = command.resolve_for_manager(&manager)?; @@ -66,13 +66,15 @@ pub async fn dispatch_with_metadata( pub async fn dispatch_with_package_manager( cwd: &AbsolutePath, command: PackageManagerCommand, - package_manager: Option<(PackageManagerType, &str, Option<&str>)>, + package_manager: Option<(PackageManagerType, &str, &str)>, ) -> Result { let render_diagnostics = command.should_render_diagnostics(); let command = match command { PackageManagerCommand::Dlx(args) => { if let Some(package_manager) = package_manager { - let manager = build_selected_package_manager(package_manager).await?; + let manager = + probe_package_manager(cwd, build_selected_package_manager(package_manager)) + .await?; let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; return run_resolution(cwd, resolution, render_diagnostics).await; } @@ -85,15 +87,19 @@ pub async fn dispatch_with_package_manager( if manager_policy(&command) == ManagerPolicy::CreateIfMissing { ensure_package_json(cwd).await?; } - build_selected_package_manager(package_manager).await? + probe_package_manager(cwd, build_selected_package_manager(package_manager)).await? } else { match manager_policy(&command) { ManagerPolicy::CreateIfMissing => { ensure_package_json(cwd).await?; - build_package_manager(cwd).await? + probe_package_manager(cwd, build_package_manager(cwd).await?).await? + } + ManagerPolicy::RequireProject => { + probe_package_manager(cwd, build_package_manager(cwd).await?).await? + } + ManagerPolicy::AllowNpmFallback => { + probe_package_manager(cwd, build_package_manager_or_npm_default(cwd).await?).await? } - ManagerPolicy::RequireProject => build_package_manager(cwd).await?, - ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, } }; @@ -101,32 +107,63 @@ pub async fn dispatch_with_package_manager( run_resolution(cwd, resolution, render_diagnostics).await } -pub async fn dispatch_with_resolved_package_manager( +fn build_selected_package_manager( + (kind, version, source): (PackageManagerType, &str, &str), +) -> PackageManager { + PackageManager::from_version(kind, version, source) +} + +async fn probe_package_manager( cwd: &AbsolutePath, - command: PackageManagerCommand, - manager: PackageManager, -) -> Result { - let render_diagnostics = command.should_render_diagnostics(); - let command = match command { - PackageManagerCommand::Dlx(args) => { - let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; - return run_resolution(cwd, resolution, render_diagnostics).await; - } - command => command, - }; - if manager_policy(&command) == ManagerPolicy::CreateIfMissing { - ensure_package_json(cwd).await?; + mut manager: PackageManager, +) -> Result { + let command = manager.client.to_string(); + let output = tokio::process::Command::new(&command) + .arg("--version") + .current_dir(cwd) + .output() + .await + .map_err(|source| Error::PackageManagerUnavailable { manager: manager.client, source })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + format!("`{command} --version` exited with {}", output.status) + } else { + stderr + }; + return Err(Error::PackageManagerVersionProbeFailed { manager: manager.client, message }); } - let resolution = command.resolve_for_manager(&manager)?; - run_resolution(cwd, resolution, render_diagnostics).await + + let actual = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let actual_version = + semver::Version::parse(&actual).map_err(|source| Error::InvalidPackageManagerVersion { + manager: manager.client, + version: actual.clone(), + source, + })?; + if is_specific_version(&manager.version) && !version_matches(&manager.version, &actual_version) + { + vp_shared::output::warn(&format!( + "{} requests {}@{}, but `{}` on PATH reports {}; using {}", + manager.source, manager.client, manager.version, manager.client, actual, actual + )); + } + manager.version = actual.into(); + Ok(manager) } -async fn build_selected_package_manager( - (kind, version, hash): (PackageManagerType, &str, Option<&str>), -) -> Result { - let (install_dir, _, version) = - download_package_manager(kind, version, hash).await.map_err(Error::Install)?; - Ok(PackageManager { client: kind, version, install_dir }) +fn is_specific_version(version: &str) -> bool { + !matches!(version, "" | "*" | "latest") +} + +fn version_matches(expected: &str, actual: &semver::Version) -> bool { + if let Ok(expected) = semver::Version::parse(expected) { + return expected == *actual; + } + let Ok(expected) = node_semver::Range::parse(expected) else { + return false; + }; + node_semver::Version::parse(&actual.to_string()).is_ok_and(|actual| expected.satisfies(&actual)) } async fn dispatch_dlx( @@ -136,6 +173,7 @@ async fn dispatch_dlx( ) -> Result { match PackageManager::builder(cwd).build_with_default().await { Ok(manager) => { + let manager = probe_package_manager(cwd, manager).await?; let package_manager = manager.client; let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; let status = run_resolution(cwd, resolution, render_diagnostics).await?; @@ -244,4 +282,22 @@ mod tests { ManagerPolicy::AllowNpmFallback ); } + + #[test] + fn metadata_version_matching_supports_exact_versions_and_ranges() { + let actual = semver::Version::parse("10.18.2").unwrap(); + assert!(version_matches("10.18.2", &actual)); + assert!(!version_matches("10.18.1", &actual)); + assert!(version_matches("^10.0.0", &actual)); + assert!(!version_matches("^9.0.0", &actual)); + } + + #[test] + fn only_specific_metadata_versions_are_compared() { + assert!(!is_specific_version("")); + assert!(!is_specific_version("*")); + assert!(!is_specific_version("latest")); + assert!(is_specific_version("10.18.2")); + assert!(is_specific_version("^10.0.0")); + } } diff --git a/crates/vp_pm_cli/src/error.rs b/crates/vp_pm_cli/src/error.rs index 11de599488..08410ef530 100644 --- a/crates/vp_pm_cli/src/error.rs +++ b/crates/vp_pm_cli/src/error.rs @@ -27,6 +27,16 @@ pub enum Error { source: semver::Error, }, + #[error("Detected {manager}, but `{manager}` is not available on PATH: {source}")] + PackageManagerUnavailable { + manager: crate::PackageManagerType, + #[source] + source: io::Error, + }, + + #[error("Failed to query {manager} version: {message}")] + PackageManagerVersionProbeFailed { manager: crate::PackageManagerType, message: String }, + /// User-facing message printed without the "Error: " prefix. #[error("{0}")] UserMessage(Str), diff --git a/crates/vp_pm_cli/src/helpers.rs b/crates/vp_pm_cli/src/helpers.rs index ae47fb238d..0a701a21bc 100644 --- a/crates/vp_pm_cli/src/helpers.rs +++ b/crates/vp_pm_cli/src/helpers.rs @@ -34,11 +34,11 @@ pub async fn build_package_manager_or_npm_default( } } -pub(crate) fn default_npm_package_manager(cwd: &AbsolutePath) -> PackageManager { +pub(crate) fn default_npm_package_manager(_cwd: &AbsolutePath) -> PackageManager { PackageManager { client: PackageManagerType::Npm, version: "latest".into(), - install_dir: cwd.to_absolute_path_buf(), + source: "default".into(), } } diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index fe0ec60df7..b325cc81a6 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -1,6 +1,6 @@ //! Package-manager infrastructure for `vp`. //! -//! [`PackageManager`] detects and downloads the selected package manager. +//! [`PackageManager`] detects the selected package manager. //! [`PackageManagerCommand`] provides the shared clap surface, and [`dispatch`] //! resolves and executes it. Managed Node.js runtimes and managed global //! packages remain owned by the global CLI. @@ -21,7 +21,6 @@ pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; pub use config::npm_registry; 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::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 5e2c068209..f918689891 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -171,7 +171,7 @@ impl PackageManagerSource { pub struct PackageManager { pub(crate) client: PackageManagerType, pub(crate) version: Str, - pub(crate) install_dir: AbsolutePathBuf, + pub(crate) source: Str, } #[derive(Debug)] @@ -195,31 +195,13 @@ impl PackageManagerBuilder { /// Detect the package manager from the current working directory. pub async fn build(&self) -> Result { let (workspace_root, _cwd) = find_workspace_root(&self.cwd)?; - let (package_manager_type, version_or_req, hash, source) = + let (package_manager_type, version, _hash, source) = get_package_manager_type_and_version(&workspace_root, self.client_override)?; - - // only download the package manager if it's not already downloaded - let (install_dir, _package_name, version) = - download_package_manager(package_manager_type, &version_or_req, hash.as_deref()) - .await?; - - // Auto-pin the resolved version when detection had no explicit field - // (lockfiles, config files, or caller default). A devEngines range is - // the user's source of truth and is never frozen into an exact pin. - // See rfcs/dev-engines.md. - if matches!(source, PackageManagerSource::LockfileOrConfig | PackageManagerSource::Default) - && version_or_req != version - { - let package_json_path = workspace_root.path.join("package.json"); - set_dev_engines_package_manager_field( - &package_json_path, - package_manager_type, - &version, - ) - .await?; - } - - Ok(PackageManager { client: package_manager_type, version, install_dir }) + Ok(PackageManager { + client: package_manager_type, + version, + source: source.description().into(), + }) } /// Build the package manager with default package manager. @@ -244,17 +226,25 @@ impl PackageManager { } #[must_use] - pub fn from_install_dir( + pub fn from_version( client: PackageManagerType, version: impl Into, - install_dir: AbsolutePathBuf, + source: impl Into, ) -> Self { - Self { client, version: version.into(), install_dir } + Self { client, version: version.into(), source: source.into() } } +} +impl PackageManagerSource { #[must_use] - pub fn get_bin_prefix(&self) -> AbsolutePathBuf { - self.install_dir.join("bin") + pub const fn description(self) -> &'static str { + match self { + Self::ExplicitOverride => "explicit override", + Self::PackageManagerField => "packageManager", + Self::DevEnginesPackageManager => "devEngines.packageManager", + Self::LockfileOrConfig => "lockfile or config", + Self::Default => "default", + } } } @@ -1652,66 +1642,6 @@ async fn create_bun_shim_files(bin_prefix: &AbsolutePath) -> Result<(), Error> { Ok(()) } -/// Write the resolved package manager into `devEngines.packageManager`. -/// -/// Used by auto-pin when detection had no explicit field (rfcs/dev-engines.md): -/// 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( - package_json_path: impl AsRef, - package_manager_type: PackageManagerType, - version: &str, -) -> Result<(), Error> { - let package_json_path = package_json_path.as_ref(); - let content = if is_exists_file(package_json_path)? { - tokio::fs::read_to_string(&package_json_path).await? - } else { - "{}\n".to_string() - }; - let entry = vp_shared::dev_engine_entry(&package_manager_type.to_string(), version); - let updated = vp_shared::edit_json_object(&content, |obj| { - let Some(dev_engines) = obj.get_mut("devEngines").and_then(|v| v.as_object_mut()) else { - vp_shared::insert_after( - obj, - "engines", - "devEngines", - serde_json::json!({ "packageManager": entry }), - ); - return; - }; - // Auto-pin only fires when detection found no usable entry, but the field - // may still declare entries Vite+ does not act on (e.g. other package - // managers with onFail: ignore). Those are preserved, never replaced. - match dev_engines.get_mut("packageManager") { - // existing single entry: convert to array form, keeping it first - Some(existing @ serde_json::Value::Object(_)) => { - let existing = std::mem::take(existing); - dev_engines.insert( - "packageManager".into(), - serde_json::Value::Array(vec![existing, entry]), - ); - } - // existing array: append the resolved entry - Some(serde_json::Value::Array(entries)) => { - entries.push(entry); - } - // absent or malformed (spec-invalid) value: write a single entry - _ => { - dev_engines.insert("packageManager".into(), entry); - } - } - })?; - tokio::fs::write(&package_json_path, updated).await?; - tracing::debug!( - "set_dev_engines_package_manager_field: {:?} to {}@{}", - package_json_path, - package_manager_type, - version - ); - Ok(()) -} - use vp_shared::is_ci_environment; /// Interactive menu for selecting a package manager with keyboard navigation @@ -2561,8 +2491,10 @@ mod tests { let workspace_content = "packages:\n - 'packages/*'"; create_pnpm_workspace_yaml(&temp_dir_path, workspace_content); - let result = - PackageManager::builder(temp_dir_path).build().await.expect("Should detect pnpm"); + let result = PackageManager::builder(temp_dir_path.clone()) + .build() + .await + .expect("Should detect pnpm"); assert_eq!(result.client.to_string(), "pnpm"); } @@ -2577,24 +2509,16 @@ mod tests { fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'") .expect("Failed to write pnpm-lock.yaml"); - let result = - PackageManager::builder(temp_dir_path).build().await.expect("Should detect pnpm"); + let result = PackageManager::builder(temp_dir_path.clone()) + .build() + .await + .expect("Should detect pnpm"); assert_eq!(result.client.to_string(), "pnpm"); - - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir.path().join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - println!("package_json: {package_json:?}"); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "pnpm"); - assert!(Version::parse(entry["version"].as_str().unwrap()).is_ok()); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // the legacy field is not written - assert!(package_json.get("packageManager").is_none()); - // keep other fields - assert_eq!(package_json["version"].as_str().unwrap(), "1.0.0"); - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + assert_eq!(result.version, "latest"); + assert_eq!( + fs::read_to_string(temp_dir_path.join("package.json")).unwrap(), + package_content + ); } #[tokio::test] @@ -2613,28 +2537,15 @@ mod tests { .await .expect("Should detect yarn"); assert_eq!(result.client.to_string(), "yarn"); - assert!( - result.get_bin_prefix().ends_with("yarn/bin"), - "bin_prefix should end with yarn/bin, but got {:?}", - result.get_bin_prefix() + assert_eq!(result.version, "latest"); + assert_eq!( + fs::read_to_string(temp_dir_path.join("package.json")).unwrap(), + package_content ); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir_path.join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - println!("package_json: {package_json:?}"); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "yarn"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); } #[tokio::test] - #[cfg(not(windows))] // FIXME async fn test_detect_package_manager_with_package_lock_json() { - use std::process::Command; - let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -2647,42 +2558,15 @@ mod tests { let result = PackageManager::builder(temp_dir_path).build().await.expect("Should detect npm"); assert_eq!(result.client.to_string(), "npm"); - - // check shim files - let bin_prefix = result.get_bin_prefix(); - assert!(is_exists_file(bin_prefix.join("npm")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npm.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npm.ps1")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npx")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npx.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("npx.ps1")).unwrap()); - - // run npm --version - let mut paths = - env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); - paths.insert(0, bin_prefix.into_path_buf()); - let output = Command::new("npm") - .arg("--version") - .env("PATH", env::join_paths(&paths).unwrap()) - .output() - .expect("Failed to run npm"); - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); - // println!("npm --version: {:?}", String::from_utf8_lossy(&output.stdout)); - - // run npx --version - let output = Command::new("npx") - .arg("--version") - .env("PATH", env::join_paths(&paths).unwrap()) - .output() - .expect("Failed to run npx"); - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(result.version, "latest"); + assert_eq!( + fs::read_to_string(temp_dir.path().join("package.json")).unwrap(), + package_content + ); } #[tokio::test] - #[cfg(not(windows))] // FIXME async fn test_detect_package_manager_with_package_manager_field() { - use std::process::Command; - let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "pnpm@8.15.0"}"#; @@ -2693,28 +2577,11 @@ mod tests { .await .expect("Should detect pnpm with version"); assert_eq!(result.client.to_string(), "pnpm"); - - // check shim files - let bin_prefix = result.get_bin_prefix(); - assert!(is_exists_file(bin_prefix.join("pnpm.cjs")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpm.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpm.ps1")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpx.cjs")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpx.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("pnpx.ps1")).unwrap()); - - // run pnpm --version - let mut paths = - env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); - paths.insert(0, bin_prefix.into_path_buf()); - let output = Command::new("pnpm") - .arg("--version") - .env("PATH", env::join_paths(paths).unwrap()) - .output() - .expect("Failed to run pnpm"); - // println!("pnpm --version: {:?}", output); - assert!(output.status.success()); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "8.15.0"); + assert_eq!(result.version, "8.15.0"); + assert_eq!( + fs::read_to_string(temp_dir.path().join("package.json")).unwrap(), + package_content + ); } #[tokio::test] @@ -3146,143 +3013,6 @@ mod tests { assert_eq!(source, PackageManagerSource::PackageManagerField); } - #[tokio::test] - async fn test_set_dev_engines_package_manager_field_preserves_format_and_engines() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_json_path = temp_dir_path.join("package.json"); - // 4-space indentation and an existing engines.node that must stay unchanged - let package_content = "{\n \"name\": \"test-package\",\n \"engines\": {\n \"node\": \">=20.0.0\"\n },\n \"scripts\": {}\n}\n"; - fs::write(&package_json_path, package_content).unwrap(); - - set_dev_engines_package_manager_field( - &package_json_path, - PackageManagerType::Pnpm, - "9.15.0", - ) - .await - .unwrap(); - - let updated = fs::read_to_string(&package_json_path).unwrap(); - // engines.node is kept unchanged and devEngines is placed right after it - assert_eq!( - updated, - "{\n \"name\": \"test-package\",\n \"engines\": {\n \"node\": \">=20.0.0\"\n },\n \"devEngines\": {\n \"packageManager\": {\n \"name\": \"pnpm\",\n \"version\": \"9.15.0\",\n \"onFail\": \"download\"\n }\n },\n \"scripts\": {}\n}\n" - ); - } - - #[tokio::test] - async fn test_set_dev_engines_package_manager_field_appends_to_existing_array() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_json_path = temp_dir_path.join("package.json"); - // entries Vite+ does not act on (detection fell through to a lockfile) - // must be preserved, never replaced - let package_content = r#"{ - "name": "test-package", - "devEngines": { - "packageManager": [ - { - "name": "vlt", - "version": "^1.0.0", - "onFail": "ignore" - } - ] - } -} -"#; - fs::write(&package_json_path, package_content).unwrap(); - - set_dev_engines_package_manager_field( - &package_json_path, - PackageManagerType::Pnpm, - "9.15.0", - ) - .await - .unwrap(); - - let updated = fs::read_to_string(&package_json_path).unwrap(); - let package_json: serde_json::Value = serde_json::from_str(&updated).unwrap(); - let entries = package_json["devEngines"]["packageManager"].as_array().unwrap(); - assert_eq!(entries.len(), 2); - // the existing entry stays first with its onFail intact - assert_eq!(entries[0]["name"].as_str().unwrap(), "vlt"); - assert_eq!(entries[0]["onFail"].as_str().unwrap(), "ignore"); - assert_eq!(entries[1]["name"].as_str().unwrap(), "pnpm"); - assert_eq!(entries[1]["version"].as_str().unwrap(), "9.15.0"); - assert_eq!(entries[1]["onFail"].as_str().unwrap(), "download"); - } - - #[tokio::test] - async fn test_set_dev_engines_package_manager_field_converts_single_entry_to_array() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_json_path = temp_dir_path.join("package.json"); - let package_content = r#"{ - "devEngines": { - "packageManager": { - "name": "vlt", - "version": "^1.0.0", - "onFail": "warn" - } - } -} -"#; - fs::write(&package_json_path, package_content).unwrap(); - - set_dev_engines_package_manager_field( - &package_json_path, - PackageManagerType::Npm, - "11.4.0", - ) - .await - .unwrap(); - - let updated = fs::read_to_string(&package_json_path).unwrap(); - let package_json: serde_json::Value = serde_json::from_str(&updated).unwrap(); - let entries = package_json["devEngines"]["packageManager"].as_array().unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0]["name"].as_str().unwrap(), "vlt"); - assert_eq!(entries[0]["onFail"].as_str().unwrap(), "warn"); - assert_eq!(entries[1]["name"].as_str().unwrap(), "npm"); - assert_eq!(entries[1]["version"].as_str().unwrap(), "11.4.0"); - } - - #[tokio::test] - async fn test_set_dev_engines_package_manager_field_keeps_existing_runtime() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_json_path = temp_dir_path.join("package.json"); - let package_content = r#"{ - "name": "test-package", - "devEngines": { - "runtime": { - "name": "node", - "version": "^24.0.0" - } - } -} -"#; - fs::write(&package_json_path, package_content).unwrap(); - - set_dev_engines_package_manager_field( - &package_json_path, - PackageManagerType::Npm, - "11.4.0", - ) - .await - .unwrap(); - - let updated = fs::read_to_string(&package_json_path).unwrap(); - let package_json: serde_json::Value = serde_json::from_str(&updated).unwrap(); - // the existing runtime entry is preserved - assert_eq!(package_json["devEngines"]["runtime"]["version"].as_str().unwrap(), "^24.0.0"); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "npm"); - assert_eq!(entry["version"].as_str().unwrap(), "11.4.0"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - } - #[tokio::test] async fn test_parse_package_manager_with_sha1_hash() { let temp_dir = create_temp_dir(); @@ -3366,21 +3096,19 @@ mod tests { #[tokio::test] async fn test_download_success_package_manager_with_hash() { - use std::process::Command; - - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package", "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"}"#; - create_package_json(&temp_dir_path, package_content); - - let result = PackageManager::builder(temp_dir_path) - .build() + let result = download_package_manager( + PackageManagerType::Yarn, + "1.22.22", + Some("sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"), + ) .await - .expect("Should detect yarn with version and hash"); - assert_eq!(result.client.to_string(), "yarn"); + .expect("Should download yarn with the matching hash"); + let (target_dir, package_name, version) = result; + assert_eq!(package_name, "yarn"); + assert_eq!(version, "1.22.22"); // check shim files - let bin_prefix = result.get_bin_prefix(); + let bin_prefix = target_dir.join("bin"); assert!(is_exists_file(bin_prefix.join("yarn.js")).unwrap()); assert!(is_exists_file(bin_prefix.join("yarn")).unwrap()); assert!(is_exists_file(bin_prefix.join("yarn.cmd")).unwrap()); @@ -3388,33 +3116,17 @@ mod tests { assert!(is_exists_file(bin_prefix.join("yarnpkg")).unwrap()); assert!(is_exists_file(bin_prefix.join("yarnpkg.cmd")).unwrap()); assert!(is_exists_file(bin_prefix.join("yarnpkg.ps1")).unwrap()); - - // run pnpm --version - let mut paths = - env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); - paths.insert(0, bin_prefix.into_path_buf()); - let mut cmd = "yarn"; - if cfg!(windows) { - cmd = "yarn.cmd"; - } - let output = Command::new(cmd) - .arg("--version") - .env("PATH", env::join_paths(paths).unwrap()) - .output() - .expect("Failed to run yarn"); - // println!("pnpm --version: {:?}", output); - assert!(output.status.success()); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "1.22.22"); + remove_dir_all_force(target_dir).await.unwrap(); } #[tokio::test] async fn test_download_failed_package_manager_with_hash() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package", "packageManager": "yarn@1.22.21+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"}"#; - create_package_json(&temp_dir_path, package_content); - - let result = PackageManager::builder(temp_dir_path).build().await; + let result = download_package_manager( + PackageManagerType::Yarn, + "1.22.21", + Some("sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"), + ) + .await; assert!(result.is_err()); // Check if it's the expected error type if let Err(Error::HashMismatch { expected, actual }) = result { @@ -3433,33 +3145,26 @@ mod tests { #[tokio::test] async fn test_download_success_package_manager_with_sha1_and_sha224() { - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package", "packageManager": "yarn@1.22.20+sha1.167c8ab8d9c8c3826d3725d9579aaea8b47a2b18"}"#; - create_package_json(&temp_dir_path, package_content); - - let result = PackageManager::builder(temp_dir_path) - .build() - .await - .expect("Should detect yarn with version and hash"); - assert_eq!(result.client.to_string(), "yarn"); - - let temp_dir = create_temp_dir(); - let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let package_content = r#"{"name": "test-package", "packageManager": "pnpm@4.11.6+sha224.7783c4b01916b7a69e6ff05d328df6f83cb7f127e9c96be88739386d"}"#; - create_package_json(&temp_dir_path, package_content); - - let result = PackageManager::builder(temp_dir_path) - .build() - .await - .expect("Should detect pnpm with version and hash"); - assert_eq!(result.client.to_string(), "pnpm"); + let (yarn_dir, _, _) = download_package_manager( + PackageManagerType::Yarn, + "1.22.20", + Some("sha1.167c8ab8d9c8c3826d3725d9579aaea8b47a2b18"), + ) + .await + .expect("Should download yarn with a matching sha1 hash"); + let (pnpm_dir, _, _) = download_package_manager( + PackageManagerType::Pnpm, + "4.11.6", + Some("sha224.7783c4b01916b7a69e6ff05d328df6f83cb7f127e9c96be88739386d"), + ) + .await + .expect("Should download pnpm with a matching sha224 hash"); + remove_dir_all_force(yarn_dir).await.unwrap(); + remove_dir_all_force(pnpm_dir).await.unwrap(); } #[tokio::test] async fn test_detect_package_manager_with_yarn_package_manager_field() { - use std::process::Command; - let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "yarn@4.0.0"}"#; @@ -3470,39 +3175,12 @@ mod tests { .await .expect("Should detect yarn with version"); assert_eq!(result.client.to_string(), "yarn"); - assert_eq!(result.version, "4.0.0"); - assert!( - result.get_bin_prefix().ends_with("yarn/bin"), - "bin_prefix should end with yarn/bin, but got {:?}", - result.get_bin_prefix() + assert_eq!(result.source, "packageManager"); + assert_eq!( + fs::read_to_string(temp_dir_path.join("package.json")).unwrap(), + package_content ); - - // check shim files - let bin_prefix = result.get_bin_prefix(); - assert!(is_exists_file(bin_prefix.join("yarn.js")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarn")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarn.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarn.ps1")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarnpkg")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarnpkg.cmd")).unwrap()); - assert!(is_exists_file(bin_prefix.join("yarnpkg.ps1")).unwrap()); - - // run yarn --version - let mut cmd = "yarn"; - if cfg!(windows) { - cmd = "yarn.cmd"; - } - let mut paths = - env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); - paths.insert(0, bin_prefix.into_path_buf()); - let output = Command::new(cmd) - .arg("--version") - .env("PATH", env::join_paths(paths).unwrap()) - .output() - .expect("Failed to run yarn"); - assert!(output.status.success()); - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "4.0.0"); } #[tokio::test] @@ -3544,16 +3222,9 @@ mod tests { r#"{"name": "test-package", "packageManager": "yarn@10000000000.0.0"}"#; create_package_json(&temp_dir_path, package_content); - let result = PackageManager::builder(temp_dir_path).build().await; - assert!(result.is_err()); - println!("result: {result:?}"); - // Check if it's the expected error type - if let Err(Error::PackageManagerVersionNotFound { name, version, .. }) = result { - assert_eq!(name, "yarn"); - assert_eq!(version, "10000000000.0.0"); - } else { - panic!("Expected PackageManagerVersionNotFound error, got {result:?}"); - } + let result = PackageManager::builder(temp_dir_path).build().await.unwrap(); + assert_eq!(result.client, PackageManagerType::Yarn); + assert_eq!(result.version, "10000000000.0.0"); } #[tokio::test] @@ -3582,15 +3253,11 @@ mod tests { .await .expect("Should use default"); assert_eq!(result.client.to_string(), "yarn"); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir_path.join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "yarn"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); + assert_eq!(result.version, "latest"); + assert_eq!( + fs::read_to_string(temp_dir_path.join("package.json")).unwrap(), + package_content + ); } #[tokio::test] @@ -3665,7 +3332,7 @@ mod tests { .await .expect("Should detect pnpm from parent workspace"); assert_eq!(result.client.to_string(), "pnpm"); - assert!(result.get_bin_prefix().ends_with("pnpm/bin")); + assert_eq!(result.version, "latest"); } #[tokio::test] @@ -3763,20 +3430,11 @@ mod tests { .await .expect("Should detect yarn from .yarnrc.yml"); assert_eq!(result.client.to_string(), "yarn"); - assert!( - result.get_bin_prefix().ends_with("yarn/bin"), - "bin_prefix should end with yarn/bin, but got {:?}", - result.get_bin_prefix() + assert_eq!(result.version, "latest"); + assert_eq!( + fs::read_to_string(temp_dir_path.join("package.json")).unwrap(), + package_content ); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir.path().join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "yarn"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); } #[tokio::test] @@ -3795,20 +3453,11 @@ mod tests { .await .expect("Should detect pnpm from pnpmfile.cjs"); assert_eq!(result.client.to_string(), "pnpm"); - assert!( - result.get_bin_prefix().ends_with("pnpm/bin"), - "bin_prefix should end with pnpm/bin, but got {:?}", - result.get_bin_prefix() + assert_eq!(result.version, "latest"); + assert_eq!( + fs::read_to_string(temp_dir_path.join("package.json")).unwrap(), + package_content ); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir_path.join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "pnpm"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); } #[tokio::test] @@ -3830,20 +3479,11 @@ mod tests { .await .expect("Should detect yarn from yarn.config.cjs"); assert_eq!(result.client.to_string(), "yarn"); - assert!( - result.get_bin_prefix().ends_with("yarn/bin"), - "bin_prefix should end with yarn/bin, but got {:?}", - result.get_bin_prefix() + assert_eq!(result.version, "latest"); + assert_eq!( + fs::read_to_string(temp_dir_path.join("package.json")).unwrap(), + package_content ); - // auto-pin writes devEngines.packageManager (see rfcs/dev-engines.md) - let package_json_path = temp_dir_path.join("package.json"); - let package_json: serde_json::Value = - serde_json::from_slice(&fs::read(&package_json_path).unwrap()).unwrap(); - let entry = &package_json["devEngines"]["packageManager"]; - assert_eq!(entry["name"].as_str().unwrap(), "yarn"); - assert_eq!(entry["onFail"].as_str().unwrap(), "download"); - // keep other fields - assert_eq!(package_json["name"].as_str().unwrap(), "test-package"); } #[test] diff --git a/crates/vp_pm_cli/src/resolution/resolve.rs b/crates/vp_pm_cli/src/resolution/resolve.rs index 8a013e1c10..9cb18ead4b 100644 --- a/crates/vp_pm_cli/src/resolution/resolve.rs +++ b/crates/vp_pm_cli/src/resolution/resolve.rs @@ -36,7 +36,7 @@ where Yarn: Resolve, Bun: Resolve, { - let mut resolution = match manager.client { + let resolution = match manager.client { PackageManagerType::Npm => { let dialect = Version::parse(&manager.version).map_or_else(|_| Npm::unknown_version(), Npm::new); @@ -56,12 +56,6 @@ where } }; - if let CommandResolution::Run(command) = &mut resolution.outcome { - command - .env - .insert("PATH".to_string(), vp_shared::format_path_prepended(manager.get_bin_prefix())); - } - Ok(resolution) } @@ -79,12 +73,7 @@ mod tests { use crate::resolution::ApproveBuildsArgs; fn package_manager(client: PackageManagerType, version: &str) -> PackageManager { - let workspace_root = vt_path::current_dir().unwrap(); - PackageManager { - client, - version: version.into(), - install_dir: workspace_root.join(".test-package-manager"), - } + PackageManager { client, version: version.into(), source: "test".into() } } #[test] @@ -101,11 +90,7 @@ mod tests { assert_eq!(command.program, "npm"); assert_eq!(command.args, vec!["approve-scripts", "esbuild"]); - let path = command.env.get("PATH").expect("resolved command should bind PATH"); - assert_eq!( - std::env::split_paths(path).next().as_deref(), - Some(manager.get_bin_prefix().as_path()) - ); + assert!(!command.env.contains_key("PATH")); } #[test] diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index f5df698019..5e34d059ca 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -25,8 +25,8 @@ pub use types::{ ViteConfigResolverFn, }; use vp_error::Error; +use vp_shared::env_vars; pub use vp_shared::init_tracing; -use vp_shared::{PrependOptions, env_vars, prepend_to_path_env}; use vt::{ExitStatus, Session, SessionConfig}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_str::Str; @@ -87,8 +87,6 @@ async fn execute_direct_subcommand( } envs }); - let envs = envs_with_explicit_package_manager_path(cwd, envs).await?; - let status = match subcommand { SynthesizableSubcommand::Check { fix, @@ -141,94 +139,6 @@ async fn execute_direct_subcommand( Ok(status) } -fn is_path_env_key(key: &OsStr) -> bool { - if cfg!(windows) { key.eq_ignore_ascii_case("PATH") } else { key == "PATH" } -} - -fn try_prepend_to_env_path( - envs: &Arc, Arc>>, - bin_prefix: &AbsolutePath, -) -> Result, Arc>>, Error> { - let path_key = envs - .keys() - .find(|key| is_path_env_key(key.as_ref())) - .cloned() - .unwrap_or_else(|| Arc::from(OsStr::new("PATH"))); - let current_path = - envs.get(&path_key).map_or_else(Default::default, |path| path.to_os_string()); - let paths = if current_path.is_empty() { - Vec::new() - } else { - env::split_paths(¤t_path).collect::>() - }; - - if paths.first().is_some_and(|path| path == bin_prefix.as_path()) { - return Ok(Arc::clone(envs)); - } - - let new_path = env::join_paths( - std::iter::once(bin_prefix.as_path().to_path_buf()).chain(paths.into_iter()), - ) - .map_err(|error| Error::Anyhow(anyhow::Error::new(error)))?; - - let mut envs = FxHashMap::clone(envs); - envs.insert(path_key, Arc::from(new_path.as_os_str())); - Ok(Arc::new(envs)) -} - -fn prepend_to_env_path( - envs: &Arc, Arc>>, - bin_prefix: &AbsolutePath, -) -> Arc, Arc>> { - match try_prepend_to_env_path(envs, bin_prefix) { - Ok(updated_envs) => updated_envs, - Err(error) => { - tracing::debug!( - ?error, - "failed to prepend managed package manager bin to direct command PATH" - ); - Arc::clone(envs) - } - } -} - -async fn envs_with_explicit_package_manager_path( - cwd: &AbsolutePath, - envs: Arc, Arc>>, -) -> Result, Arc>>, Error> { - let Some(resolution) = (match vp_pm_cli::resolve_package_manager_from_package_json(cwd) { - Ok(resolution) => resolution, - Err(error) => { - tracing::debug!( - ?error, - "failed to resolve explicit packageManager for direct command PATH setup" - ); - return Ok(envs); - } - }) else { - return Ok(envs); - }; - - let (install_dir, _, _) = match vp_pm_cli::download_package_manager( - resolution.package_manager_type, - &resolution.version, - resolution.hash.as_deref(), - ) - .await - { - Ok(result) => result, - Err(error) => { - tracing::debug!( - ?error, - "failed to ensure managed package manager for direct command PATH setup" - ); - return Ok(envs); - } - }; - - Ok(prepend_to_env_path(&envs, &install_dir.join("bin"))) -} - /// Execute a vite-task command (run, cache) through Session. async fn execute_vite_task_command( command: vt::Command, @@ -256,12 +166,6 @@ async fn execute_vite_task_command( let mut command_handler = VitePlusCommandHandler::new(resolver); let mut config_loader = VitePlusConfigLoader::new(resolve_vite_config_fn); - // Update PATH to include package manager bin directory BEFORE session init - if let Ok(pm) = vp_pm_cli::PackageManager::builder(&cwd).build().await { - let bin_prefix = pm.get_bin_prefix(); - let _ = prepend_to_path_env(&bin_prefix, PrependOptions::default()); - } - let session = Session::init(SessionConfig { command_handler: &mut command_handler, user_config_loader: &mut config_loader, @@ -429,135 +333,9 @@ async fn execute_pm_command( #[cfg(test)] mod tests { - use std::{ - ffi::OsStr, - fs, - path::PathBuf, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, - }; + use std::path::PathBuf; - use rustc_hash::FxHashMap; use vt::config::UserRunConfig; - use vt_path::AbsolutePathBuf; - - use super::{envs_with_explicit_package_manager_path, prepend_to_env_path}; - - fn envs_with_path(path: &std::ffi::OsStr) -> Arc, Arc>> { - Arc::new(FxHashMap::from_iter([(Arc::from(OsStr::new("PATH")), Arc::from(path))])) - } - - #[test] - fn prepends_package_manager_bin_to_env_path() { - let cwd = std::env::current_dir().expect("current_dir should exist"); - let old_bin = cwd.join("old-bin"); - let pm_bin = AbsolutePathBuf::new(cwd.join("pm-bin")).expect("pm bin should be absolute"); - let original_path = std::env::join_paths([old_bin.as_path()]).expect("valid PATH"); - let envs = envs_with_path(original_path.as_os_str()); - - let updated = prepend_to_env_path(&envs, &pm_bin); - let path_value = updated.get(OsStr::new("PATH")).expect("PATH should exist"); - let paths = std::env::split_paths(path_value).collect::>(); - - assert_eq!(paths.first().map(std::path::PathBuf::as_path), Some(pm_bin.as_path())); - assert_eq!(paths.get(1).map(std::path::PathBuf::as_path), Some(old_bin.as_path())); - } - - #[test] - fn does_not_duplicate_package_manager_bin_when_already_first() { - let cwd = std::env::current_dir().expect("current_dir should exist"); - let pm_bin = AbsolutePathBuf::new(cwd.join("pm-bin")).expect("pm bin should be absolute"); - let original_path = std::env::join_paths([pm_bin.as_path()]).expect("valid PATH"); - let envs = envs_with_path(original_path.as_os_str()); - - let updated = prepend_to_env_path(&envs, &pm_bin); - let path_value = updated.get(OsStr::new("PATH")).expect("PATH should exist"); - let paths = std::env::split_paths(path_value).collect::>(); - - assert_eq!(paths, vec![pm_bin.as_path().to_path_buf()]); - } - - #[test] - fn creates_path_when_env_map_has_no_path() { - let cwd = std::env::current_dir().expect("current_dir should exist"); - let pm_bin = AbsolutePathBuf::new(cwd.join("pm-bin")).expect("pm bin should be absolute"); - let envs = Arc::new(FxHashMap::default()); - - let updated = prepend_to_env_path(&envs, &pm_bin); - let path_value = updated.get(OsStr::new("PATH")).expect("PATH should be created"); - let paths = std::env::split_paths(path_value).collect::>(); - - assert_eq!(paths, vec![pm_bin.as_path().to_path_buf()]); - } - - #[test] - fn preserves_path_key_casing_on_windows() { - let cwd = std::env::current_dir().expect("current_dir should exist"); - let old_bin = cwd.join("old-bin"); - let pm_bin = AbsolutePathBuf::new(cwd.join("pm-bin")).expect("pm bin should be absolute"); - let original_path = std::env::join_paths([old_bin.as_path()]).expect("valid PATH"); - let key = if cfg!(windows) { "Path" } else { "PATH" }; - let envs = Arc::new(FxHashMap::from_iter([( - Arc::from(OsStr::new(key)), - Arc::from(original_path.as_os_str()), - )])); - - let updated = prepend_to_env_path(&envs, &pm_bin); - let path_value = updated.get(OsStr::new(key)).expect("existing PATH key should be updated"); - let paths = std::env::split_paths(path_value).collect::>(); - - assert_eq!(paths.first().map(std::path::PathBuf::as_path), Some(pm_bin.as_path())); - assert_eq!(paths.get(1).map(std::path::PathBuf::as_path), Some(old_bin.as_path())); - } - - #[tokio::test] - async fn ignores_invalid_explicit_package_manager() { - let suffix = - SystemTime::now().duration_since(UNIX_EPOCH).expect("time should be valid").as_nanos(); - let temp_dir = std::env::temp_dir().join(format!("vite-plus-invalid-pm-{suffix}")); - fs::create_dir_all(&temp_dir).expect("temp dir should be created"); - fs::write( - temp_dir.join("package.json"), - r#"{"name":"fixture","packageManager":"unknown@1.0.0"}"#, - ) - .expect("package.json should be written"); - let cwd = AbsolutePathBuf::new(temp_dir.clone()).expect("temp dir should be absolute"); - let original_path = std::env::join_paths([temp_dir.join("old-bin")]).expect("valid PATH"); - let envs = envs_with_path(original_path.as_os_str()); - - let updated = envs_with_explicit_package_manager_path(&cwd, Arc::clone(&envs)) - .await - .expect("package manager preflight errors should not fail direct commands"); - - assert_eq!(updated.get(OsStr::new("PATH")), envs.get(OsStr::new("PATH"))); - fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); - } - - #[tokio::test] - async fn ignores_lockfile_without_explicit_package_manager() { - let suffix = - SystemTime::now().duration_since(UNIX_EPOCH).expect("time should be valid").as_nanos(); - let temp_dir = std::env::temp_dir().join(format!("vite-plus-no-pm-{suffix}")); - fs::create_dir_all(&temp_dir).expect("temp dir should be created"); - fs::write(temp_dir.join("package.json"), r#"{"name":"fixture"}"#) - .expect("package.json should be written"); - fs::write(temp_dir.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n") - .expect("lockfile should be written"); - let cwd = AbsolutePathBuf::new(temp_dir.clone()).expect("temp dir should be absolute"); - let original_path = std::env::join_paths([temp_dir.join("old-bin")]).expect("valid PATH"); - let envs = envs_with_path(original_path.as_os_str()); - - let updated = envs_with_explicit_package_manager_path(&cwd, Arc::clone(&envs)) - .await - .expect("missing packageManager should not error"); - - assert_eq!(updated.get(OsStr::new("PATH")), envs.get(OsStr::new("PATH"))); - assert_eq!( - fs::read_to_string(temp_dir.join("package.json")).expect("package.json should exist"), - r#"{"name":"fixture"}"# - ); - fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); - } #[test] fn run_config_types_in_sync() { diff --git a/packages/cli/binding/src/exec/workspace.rs b/packages/cli/binding/src/exec/workspace.rs index 3c65c59e9c..4541a581c1 100644 --- a/packages/cli/binding/src/exec/workspace.rs +++ b/packages/cli/binding/src/exec/workspace.rs @@ -105,13 +105,9 @@ pub(super) async fn execute_exec_workspace( // `pnpm exec` behaviour. let use_caller_cwd = is_cwd_only; - // Build base PATH: :: + // Build base PATH: : let base_path_dirs: Vec = { let mut dirs = Vec::new(); - // Include package manager bin dir - if let Ok(pm) = vp_pm_cli::PackageManager::builder(&*workspace_root.path).build().await { - dirs.push(pm.get_bin_prefix().as_path().to_path_buf()); - } // Include workspace root's node_modules/.bin let ws_bin = workspace_root.path.join("node_modules").join(".bin"); if ws_bin.as_path().is_dir() { From 4bcdf4187b21e02972f6902a34b642bfdc2a4b3e Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 21:09:19 +0800 Subject: [PATCH 17/22] refactor(cli): remove package manager download plumbing --- packages/cli/binding/index.cjs | 1 - packages/cli/binding/index.d.cts | 51 ----------- packages/cli/binding/src/package_manager.rs | 85 +------------------ .../src/create/__tests__/discovery.spec.ts | 16 ++-- packages/cli/src/create/bin.ts | 34 ++++---- packages/cli/src/create/command.ts | 14 --- packages/cli/src/create/discovery.ts | 5 +- .../bun-catalog-file-protocol.spec.ts | 5 +- .../src/migration/__tests__/migrator.spec.ts | 48 +++++------ packages/cli/src/migration/bin.ts | 72 +++++++--------- .../cli/src/migration/migrator/catalog.ts | 2 +- .../src/migration/migrator/orchestrators.ts | 21 ++--- packages/cli/src/migration/migrator/setup.ts | 11 +-- .../migration/migrator/vite-plus-bootstrap.ts | 8 +- packages/cli/src/types/workspace.ts | 10 ++- .../__tests__/package-manager-probe.spec.ts | 50 +++++++++++ .../cli/src/utils/__tests__/workspace.spec.ts | 9 +- packages/cli/src/utils/prompts.ts | 30 +++++-- 18 files changed, 178 insertions(+), 294 deletions(-) create mode 100644 packages/cli/src/utils/__tests__/package-manager-probe.spec.ts diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index b83ff07f47..86b82e069a 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -961,7 +961,6 @@ module.exports.resolveTsconfig = nativeBinding.resolveTsconfig; module.exports.shutdownAsyncRuntime = nativeBinding.shutdownAsyncRuntime; module.exports.startAsyncRuntime = nativeBinding.startAsyncRuntime; module.exports.detectWorkspace = nativeBinding.detectWorkspace; -module.exports.downloadPackageManager = nativeBinding.downloadPackageManager; module.exports.ensureBlockingStdio = nativeBinding.ensureBlockingStdio; module.exports.hasConfigKey = nativeBinding.hasConfigKey; module.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 54660f8ba8..50217d80ec 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3421,57 +3421,6 @@ export interface DetectWorkspaceResult { root?: string; } -/** - * Download a package manager - * - * ## Parameters - * - * - `options`: Configuration for the package manager to download, including: - * - `name`: The name of the package manager - * - `version`: The version of the package manager - * - `expected_hash`: The expected hash of the package manager - * - * ## Returns - * - * Returns a `DownloadPackageManagerResult` containing: - * - The name of the package manager - * - The install directory of the package manager - * - The binary prefix of the package manager - * - The package name of the package manager - * - The version of the package manager - * - * ## Example - * - * ```javascript - * const result = await downloadPackageManager({ - * name: "pnpm", - * version: "latest", - * }); - * console.log(`Package manager name: ${result.name}`); - * console.log(`Package manager install directory: ${result.installDir}`); - * console.log(`Package manager binary prefix: ${result.binPrefix}`); - * console.log(`Package manager package name: ${result.packageName}`); - * console.log(`Package manager version: ${result.version}`); - * ``` - */ -export declare function downloadPackageManager( - options: DownloadPackageManagerOptions, -): Promise; - -export interface DownloadPackageManagerOptions { - name: string; - version: string; - expectedHash?: string; -} - -export interface DownloadPackageManagerResult { - name: string; - installDir: string; - binPrefix: string; - packageName: string; - version: string; -} - /** Re-enable blocking stdio after Node.js has initialized its lazy standard streams. */ export declare function ensureBlockingStdio(): void; diff --git a/packages/cli/binding/src/package_manager.rs b/packages/cli/binding/src/package_manager.rs index 6278d5c007..a9d38c1303 100644 --- a/packages/cli/binding/src/package_manager.rs +++ b/packages/cli/binding/src/package_manager.rs @@ -1,93 +1,10 @@ use napi::{Error, anyhow, bindgen_prelude::*}; use napi_derive::napi; use vp_error::Error::{UnrecognizedPackageManager, UnsupportedPackageManager}; -use vp_pm_cli::{PackageManagerType, get_package_manager_type_and_version}; +use vp_pm_cli::get_package_manager_type_and_version; use vt_path::AbsolutePathBuf; use vt_workspace::{Error::PackageJsonNotFound, WorkspaceFile, find_workspace_root}; -#[napi(object)] -#[derive(Debug)] -pub struct DownloadPackageManagerOptions { - pub name: String, - pub version: String, - pub expected_hash: Option, -} - -#[napi(object)] -#[derive(Debug)] -pub struct DownloadPackageManagerResult { - pub name: String, - pub install_dir: String, - pub bin_prefix: String, - pub package_name: String, - pub version: String, -} - -/// Download a package manager -/// -/// ## Parameters -/// -/// - `options`: Configuration for the package manager to download, including: -/// - `name`: The name of the package manager -/// - `version`: The version of the package manager -/// - `expected_hash`: The expected hash of the package manager -/// -/// ## Returns -/// -/// Returns a `DownloadPackageManagerResult` containing: -/// - The name of the package manager -/// - The install directory of the package manager -/// - The binary prefix of the package manager -/// - The package name of the package manager -/// - The version of the package manager -/// -/// ## Example -/// -/// ```javascript -/// const result = await downloadPackageManager({ -/// name: "pnpm", -/// version: "latest", -/// }); -/// console.log(`Package manager name: ${result.name}`); -/// console.log(`Package manager install directory: ${result.installDir}`); -/// console.log(`Package manager binary prefix: ${result.binPrefix}`); -/// console.log(`Package manager package name: ${result.packageName}`); -/// console.log(`Package manager version: ${result.version}`); -/// ``` -#[napi] -pub async fn download_package_manager( - options: DownloadPackageManagerOptions, -) -> Result { - let package_manager_type = match options.name.as_str() { - "pnpm" => PackageManagerType::Pnpm, - "yarn" => PackageManagerType::Yarn, - "npm" => PackageManagerType::Npm, - "bun" => PackageManagerType::Bun, - _ => { - return Err(Error::from_reason(format!( - "Invalid package manager name: {}", - options.name - ))); - } - }; - - let (install_dir, package_name, version) = vp_pm_cli::download_package_manager( - package_manager_type, - &options.version, - options.expected_hash.as_deref(), - ) - .await - .map_err(anyhow::Error::from)?; - - Ok(DownloadPackageManagerResult { - name: options.name, - install_dir: install_dir.as_path().to_string_lossy().to_string(), - bin_prefix: install_dir.join("bin").as_path().to_string_lossy().to_string(), - package_name: package_name.to_string(), - version: version.to_string(), - }) -} - #[napi(object)] #[derive(Debug)] pub struct DetectWorkspaceResult { diff --git a/packages/cli/src/create/__tests__/discovery.spec.ts b/packages/cli/src/create/__tests__/discovery.spec.ts index a429766f7e..f3d1191800 100644 --- a/packages/cli/src/create/__tests__/discovery.spec.ts +++ b/packages/cli/src/create/__tests__/discovery.spec.ts @@ -4,7 +4,11 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { WorkspaceInfo, WorkspaceInfoOptional } from '../../types/index.js'; +import { + PackageManager, + type WorkspaceInfo, + type WorkspaceInfoOptional, +} from '../../types/index.js'; import { discoverTemplate, expandCreateShorthand, @@ -38,7 +42,7 @@ describe('discoverTemplate', () => { monorepoScope: '', workspacePatterns: ['tools/*'], parentDirs: ['tools'], - downloadPackageManager: { binPrefix: '' }, + packageManagerInfo: { name: PackageManager.pnpm, version: '10.0.0' }, packages: [{ name: 'my-template', path: 'tools/my-template' }], } as unknown as WorkspaceInfo; } @@ -285,10 +289,10 @@ describe('GitHub template helpers', () => { parentDirs: [], packageManager: 'pnpm', packageManagerVersion: 'latest', - downloadPackageManager: { - binPrefix: '/tmp/bin', + packageManagerInfo: { + name: PackageManager.pnpm, version: '10.0.0', - } as never, + }, packages: [], }); @@ -305,7 +309,7 @@ describe('GitHub template helpers', () => { parentDirs: [], packageManager: 'pnpm', packageManagerVersion: 'latest', - downloadPackageManager: { binPrefix: '/tmp/bin', version: '10.0.0' } as never, + packageManagerInfo: { name: PackageManager.pnpm, version: '10.0.0' }, packages: [], } as never; diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index c21f793146..82dff2565b 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -45,7 +45,7 @@ import { cancelAndExit, type CommandRunSummary, defaultInteractive, - downloadPackageManager, + probePackageManager, promptGitHooks, resolveGitInit, runViteFmt, @@ -820,23 +820,19 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h ? (detectedPackageManager ?? requestedPackageManager) : (requestedPackageManager ?? detectedPackageManager)) ?? (await selectPackageManager(options.interactive, compactOutput)); - const packageManagerVersion = - packageManager === detectedPackageManager - ? workspaceInfoOptional.packageManagerVersion - : 'latest'; - const shouldSilencePackageManagerInstallLog = + const shouldSilencePackageManagerProbe = compactOutput || (isMonorepo && workspaceInfoOptional.packageManager !== undefined); - // ensure the package manager is installed by vite-plus - const downloadResult = await downloadPackageManager( + const packageManagerInfo = await probePackageManager( packageManager, - packageManagerVersion, + workspaceInfoOptional.rootDir, options.interactive, - shouldSilencePackageManagerInstallLog, + shouldSilencePackageManagerProbe, ); const workspaceInfo: WorkspaceInfo = { ...workspaceInfoOptional, packageManager, - downloadPackageManager: downloadResult, + packageManagerVersion: packageManagerInfo.version, + packageManagerInfo, }; const existingAgentTargetPaths = @@ -960,7 +956,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h cwd: installCwd, projectDir: projectPath, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.downloadPackageManager.version, + packageManagerVersion: workspaceInfo.packageManagerInfo.version, targets, interactive: options.interactive, autoApprove: options.approveBuilds === true, @@ -1132,7 +1128,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h const installSummary = await runViteInstall(fullPath, options.interactive, installArgs, { silent: compactOutput, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.downloadPackageManager.version, + packageManagerVersion: workspaceInfo.packageManagerInfo.version, detectIgnoredBuilds: true, }); await handleIgnoredBuilds(fullPath, fullPath, installSummary); @@ -1144,7 +1140,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary, nextCommand: getNextCommand(projectDir, 'vp run'), packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.downloadPackageManager.version, + packageManagerVersion: workspaceInfo.packageManagerInfo.version, projectDir, }); return; @@ -1297,7 +1293,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h // signal is present) and force yarn's classic node_modules layout // (Plug'n'Play zip entries break @oxlint/migrate's fileURLToPath resolution). const installAndMigrate = async (installCwd: string) => { - setPackageManager(fullPath, workspaceInfo.downloadPackageManager); + setPackageManager(fullPath, workspaceInfo.packageManagerInfo); if (workspaceInfo.packageManager === PackageManager.yarn) { const yarnrcPath = path.join(fullPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcPath)) { @@ -1314,7 +1310,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary = await runViteInstall(installCwd, options.interactive, installArgs, { silent: compactOutput, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.downloadPackageManager.version, + packageManagerVersion: workspaceInfo.packageManagerInfo.version, detectIgnoredBuilds: true, }); if (installSummary.status !== 'installed') { @@ -1420,7 +1416,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary = await runViteInstall(workspaceInfo.rootDir, options.interactive, installArgs, { silent: compactOutput, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.downloadPackageManager.version, + packageManagerVersion: workspaceInfo.packageManagerInfo.version, detectIgnoredBuilds: true, }); await handleIgnoredBuilds(fullPath, workspaceInfo.rootDir, installSummary); @@ -1467,7 +1463,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary = await runViteInstall(fullPath, options.interactive, installArgs, { silent: compactOutput, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.downloadPackageManager.version, + packageManagerVersion: workspaceInfo.packageManagerInfo.version, detectIgnoredBuilds: true, }); await handleIgnoredBuilds(fullPath, fullPath, installSummary); @@ -1481,7 +1477,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary, nextCommand: getNextCommand(projectDir, 'vp run'), packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.downloadPackageManager.version, + packageManagerVersion: workspaceInfo.packageManagerInfo.version, projectDir, }); // #endregion diff --git a/packages/cli/src/create/command.ts b/packages/cli/src/create/command.ts index 778d84f2af..2fe11ef00c 100644 --- a/packages/cli/src/create/command.ts +++ b/packages/cli/src/create/command.ts @@ -120,17 +120,3 @@ export function formatDlxCommand( args: [...runner.args, packageName, ...args], }; } - -export function prependToPathToEnvs(extraPath: string, envs: NodeJS.ProcessEnv) { - const delimiter = path.delimiter; - const pathKey = Object.keys(envs).find((key) => key.toLowerCase() === 'path') ?? 'PATH'; - - const current = envs[pathKey] ?? ''; - - // avoid duplicate - const parts = current.split(delimiter).filter(Boolean); - if (!parts.includes(extraPath)) { - envs[pathKey] = extraPath + (current ? delimiter + current : ''); - } - return envs; -} diff --git a/packages/cli/src/create/discovery.ts b/packages/cli/src/create/discovery.ts index 0f2de3082f..4aa318f00e 100644 --- a/packages/cli/src/create/discovery.ts +++ b/packages/cli/src/create/discovery.ts @@ -4,7 +4,6 @@ import path from 'node:path'; import type { WorkspaceInfo, WorkspaceInfoOptional } from '../types/index.ts'; import { readJsonFile } from '../utils/json.ts'; import { isBingoTemplate } from '../utils/workspace.ts'; -import { prependToPathToEnvs } from './command.ts'; import { isRelativePath } from './org-manifest.ts'; import { BuiltinTemplate, type TemplateInfo, TemplateType } from './templates/types.ts'; @@ -103,9 +102,7 @@ export function discoverTemplate( // `bin` is an error rather than an npm fall-through). localTemplate?: boolean, ): TemplateInfo { - const envs = prependToPathToEnvs(workspaceInfo.downloadPackageManager.binPrefix, { - ...process.env, - }); + const envs = { ...process.env }; const parentDir = inferParentDir(templateName, workspaceInfo, localTemplate); if (bundledLocalPath) { return { diff --git a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts index 93cd6eae33..a2ede7fe30 100644 --- a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts +++ b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts @@ -30,11 +30,8 @@ function makeWorkspaceInfo(rootDir: string, packageManager: PackageManager): Wor parentDirs: [], packageManager, packageManagerVersion: '10.33.0', - downloadPackageManager: { + packageManagerInfo: { name: packageManager, - installDir: '/tmp', - binPrefix: '/tmp/bin', - packageName: packageManager, version: '1.0.0', }, packages: [], diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index f853aaa24c..48a628cded 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1368,11 +1368,8 @@ describe('parseNvmrcVersion', () => { describe('setPackageManager', () => { let tmpDir: string; - const downloadResult = { - name: 'pnpm', - installDir: '/tmp/install', - binPrefix: '/tmp/install/bin', - packageName: 'pnpm', + const packageManagerInfo = { + name: PackageManager.pnpm, version: '11.5.1', }; @@ -1392,7 +1389,7 @@ describe('setPackageManager', () => { it('writes devEngines.packageManager when neither field exists', () => { fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'x' }, null, 2)); - setPackageManager(tmpDir, downloadResult); + setPackageManager(tmpDir, packageManagerInfo); expect(readPkg().devEngines).toEqual({ packageManager: { name: 'pnpm', version: '11.5.1', onFail: 'download' }, }); @@ -1403,7 +1400,7 @@ describe('setPackageManager', () => { path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'x', packageManager: 'npm@10.5.0' }, null, 2), ); - setPackageManager(tmpDir, downloadResult); + setPackageManager(tmpDir, packageManagerInfo); const pkg = readPkg(); expect(pkg.packageManager).toBe('npm@10.5.0'); expect(pkg.devEngines).toBeUndefined(); @@ -1418,7 +1415,7 @@ describe('setPackageManager', () => { 2, ), ); - setPackageManager(tmpDir, downloadResult); + setPackageManager(tmpDir, packageManagerInfo); expect(readPkg().devEngines).toEqual({ runtime: { name: 'node', version: '^24.0.0' }, packageManager: { name: 'pnpm', version: '11.5.1', onFail: 'download' }, @@ -1431,7 +1428,7 @@ describe('setPackageManager', () => { path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'x', devEngines: 'oops' }, null, 2), ); - setPackageManager(tmpDir, downloadResult); + setPackageManager(tmpDir, packageManagerInfo); expect(readPkg().devEngines).toEqual({ packageManager: { name: 'pnpm', version: '11.5.1', onFail: 'download' }, }); @@ -1690,11 +1687,8 @@ function makeWorkspaceInfo( parentDirs: [], packageManager, packageManagerVersion: version, - downloadPackageManager: { + packageManagerInfo: { name: packageManager, - installDir: '/tmp', - binPrefix: '/tmp/bin', - packageName: packageManager, version, }, packages: [], @@ -2745,8 +2739,8 @@ describe('ensureVitePlusBootstrap', () => { workspacePatterns: ['packages/*'], packages: [{ name: 'import-resolver', path: 'packages/import-resolver' }], }; - workspaceInfo.downloadPackageManager = { - ...workspaceInfo.downloadPackageManager, + workspaceInfo.packageManagerInfo = { + ...workspaceInfo.packageManagerInfo, version: '9.15.9', }; ensureVitePlusBootstrap(workspaceInfo); @@ -2803,8 +2797,8 @@ describe('ensureVitePlusBootstrap', () => { workspacePatterns: ['packages/*'], packages: [{ name: 'import-resolver', path: 'packages/import-resolver' }], }; - workspaceInfo.downloadPackageManager = { - ...workspaceInfo.downloadPackageManager, + workspaceInfo.packageManagerInfo = { + ...workspaceInfo.packageManagerInfo, version: '9.4.0', }; ensureVitePlusBootstrap(workspaceInfo); @@ -2857,8 +2851,8 @@ describe('ensureVitePlusBootstrap', () => { workspacePatterns: ['packages/*'], packages: [{ name: 'app', path: 'packages/app' }], }; - workspaceInfo.downloadPackageManager = { - ...workspaceInfo.downloadPackageManager, + workspaceInfo.packageManagerInfo = { + ...workspaceInfo.packageManagerInfo, version: '9.15.9', }; ensureVitePlusBootstrap(workspaceInfo); @@ -3816,7 +3810,7 @@ describe('ensureVitePlusBootstrap', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '10.6.1'; - workspaceInfo.downloadPackageManager.version = '10.6.1'; + workspaceInfo.packageManagerInfo.version = '10.6.1'; ensureVitePlusBootstrap(workspaceInfo); @@ -4441,7 +4435,7 @@ describe('yarn catalog version gating', () => { function yarnWorkspaceInfo(version: string) { const info = makeWorkspaceInfo(tmpDir, PackageManager.yarn); info.packageManagerVersion = version; - info.downloadPackageManager = { ...info.downloadPackageManager, version }; + info.packageManagerInfo = { ...info.packageManagerInfo, version }; return info; } @@ -4637,7 +4631,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '9.15.9'; - workspaceInfo.downloadPackageManager.version = '9.15.9'; + workspaceInfo.packageManagerInfo.version = '9.15.9'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); // package.json emits catalog: specs (this part already works today). @@ -7250,7 +7244,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }), ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); - workspaceInfo.downloadPackageManager.version = '10.6.1'; + workspaceInfo.packageManagerInfo.version = '10.6.1'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { @@ -7274,7 +7268,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }), ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); - workspaceInfo.downloadPackageManager.version = '10.6.1'; + workspaceInfo.packageManagerInfo.version = '10.6.1'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { @@ -7294,7 +7288,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }), ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); - workspaceInfo.downloadPackageManager.version = '10.6.1'; + workspaceInfo.packageManagerInfo.version = '10.6.1'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); // No webdriverio -> nothing to manage -> no allowBuilds key added to the pnpm sink @@ -7315,7 +7309,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '9.15.0'; - workspaceInfo.downloadPackageManager.version = '9.15.0'; + workspaceInfo.packageManagerInfo.version = '9.15.0'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { @@ -7336,7 +7330,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '9.15.0'; - workspaceInfo.downloadPackageManager.version = '9.15.0'; + workspaceInfo.packageManagerInfo.version = '9.15.0'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index d856ef8d9d..5046724d9b 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -21,7 +21,7 @@ import { cancelAndExit, type CommandRunSummary, defaultInteractive, - downloadPackageManager, + probePackageManager, runViteInstall, selectPackageManager, upgradeYarn, @@ -725,58 +725,46 @@ function showMigrationSummary(options: { } } -async function downloadSupportedPackageManager(options: { +async function probeSupportedPackageManager(options: { rootDir: string; packageManager: PackageManager; - packageManagerVersion: string; interactive: boolean; updateMigrationProgress: (message: string) => void; failMigrationProgress: (message: string) => void; -}): Promise>> { - const { - rootDir, - packageManager, - packageManagerVersion, - interactive, - updateMigrationProgress, - failMigrationProgress, - } = options; +}): Promise>> { + const { rootDir, packageManager, interactive, updateMigrationProgress, failMigrationProgress } = + options; updateMigrationProgress('Preparing migration'); - const downloadResult = await downloadPackageManager( - packageManager, - packageManagerVersion, - interactive, - true, - ); + const packageManagerInfo = await probePackageManager(packageManager, rootDir, interactive, true); if ( packageManager === PackageManager.yarn && - semver.satisfies(downloadResult.version, '>=4.0.0 <4.10.0') + semver.satisfies(packageManagerInfo.version, '>=4.0.0 <4.10.0') ) { updateMigrationProgress('Upgrading Yarn'); await upgradeYarn(rootDir, interactive, true); } else if ( packageManager === PackageManager.pnpm && - semver.satisfies(downloadResult.version, '< 9.5.0') + semver.satisfies(packageManagerInfo.version, '< 9.5.0') ) { failMigrationProgress('Migration failed'); prompts.log.error( - `✘ pnpm@${downloadResult.version} is not supported by auto migration, please upgrade pnpm to >=9.5.0 first`, + `✘ pnpm@${packageManagerInfo.version} is not supported by auto migration, please upgrade pnpm to >=9.5.0 first`, ); cancelAndExit('Vite+ cannot automatically migrate this project yet.', 1); } else if ( packageManager === PackageManager.npm && - semver.satisfies(downloadResult.version, '< 8.3.0') + semver.satisfies(packageManagerInfo.version, '< 8.3.0') ) { failMigrationProgress('Migration failed'); prompts.log.error( - `✘ npm@${downloadResult.version} is not supported by auto migration, please upgrade npm to >=8.3.0 first`, + `✘ npm@${packageManagerInfo.version} is not supported by auto migration, please upgrade npm to >=8.3.0 first`, ); cancelAndExit('Vite+ cannot automatically migrate this project yet.', 1); } - return downloadResult; + return packageManagerInfo; } async function executeMigrationPlan( @@ -817,11 +805,10 @@ async function executeMigrationPlan( } }; - // 1. Download package manager + version validation - const downloadResult = await downloadSupportedPackageManager({ + // 1. Probe package manager + version validation + const packageManagerInfo = await probeSupportedPackageManager({ rootDir: workspaceInfoOptional.rootDir, packageManager: plan.packageManager, - packageManagerVersion: workspaceInfoOptional.packageManagerVersion, interactive, updateMigrationProgress, failMigrationProgress, @@ -829,7 +816,8 @@ async function executeMigrationPlan( const workspaceInfo: WorkspaceInfo = { ...workspaceInfoOptional, packageManager: plan.packageManager, - downloadPackageManager: downloadResult, + packageManagerVersion: packageManagerInfo.version, + packageManagerInfo, }; if (plan.convertYarnPnp) { @@ -855,7 +843,7 @@ async function executeMigrationPlan( { silent: true, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.downloadPackageManager.version, + packageManagerVersion: workspaceInfo.packageManagerInfo.version, }, ); @@ -991,7 +979,7 @@ async function executeMigrationPlan( { silent: true, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.downloadPackageManager.version, + packageManagerVersion: workspaceInfo.packageManagerInfo.version, }, ); if (finalInstallSummary.status === 'failed') { @@ -1027,7 +1015,7 @@ async function executeMigrationPlan( return { installDurationMs: initialInstallDurationMs + finalInstallDurationMs, finalInstallOk: finalInstallSummary.status === 'installed', - packageManagerVersion: downloadResult.version, + packageManagerVersion: packageManagerInfo.version, report, }; } @@ -1108,33 +1096,32 @@ async function main() { ? (workspaceInfoOptional.packageManager ?? (await selectPackageManager(options.interactive, true))) : workspaceInfoOptional.packageManager; - let downloadedPackageManager: Awaited> | undefined; + let packageManagerInfo: Awaited> | undefined; let packageManagerVersion = workspaceInfoOptional.packageManagerVersion; - const downloadExistingPackageManager = async () => { + const probeExistingPackageManager = async () => { if (!packageManager) { return undefined; } - downloadedPackageManager ??= await downloadSupportedPackageManager({ + packageManagerInfo ??= await probeSupportedPackageManager({ rootDir: workspaceInfoOptional.rootDir, packageManager, - packageManagerVersion, interactive: options.interactive, updateMigrationProgress, failMigrationProgress, }); - packageManagerVersion = downloadedPackageManager.version; - return downloadedPackageManager; + packageManagerVersion = packageManagerInfo.version; + return packageManagerInfo; }; const ensureExistingPackageManager = async () => { packageManager ??= await selectPackageManager(options.interactive, true); - return downloadExistingPackageManager(); + return probeExistingPackageManager(); }; if (vitePlusBootstrapPending) { await ensureExistingPackageManager(); } - // The package-manager download above starts the "Preparing migration" + // The package-manager probe above starts the "Preparing migration" // spinner. Stop it before gathering interactive decisions below: a live // spinner keeps re-rendering its timer line over the prompts and corrupts // them (the spinner is restarted for the bootstrap/install phase). @@ -1237,14 +1224,15 @@ async function main() { let needsInstall = false; if (vitePlusBootstrapPending) { - const downloadResult = await ensureExistingPackageManager(); - if (downloadResult && packageManager) { + const packageManagerInfo = await ensureExistingPackageManager(); + if (packageManagerInfo && packageManager) { updateMigrationProgress('Configuring package manager'); const bootstrapResult = ensureVitePlusBootstrap( { ...workspaceInfoOptional, packageManager, - downloadPackageManager: downloadResult, + packageManagerVersion: packageManagerInfo.version, + packageManagerInfo, }, report, ); diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 341b85847c..853d788335 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -112,7 +112,7 @@ const YARN_CATALOG_MIN_VERSION = '4.10.0'; // Yarn's `catalog:` protocol (and the `.yarnrc.yml` `catalog`/`catalogs` fields) // ships ENABLED BY DEFAULT only from Yarn 4.10.0. `vp migrate` auto-upgrades an // in-range Yarn (`>=4.0.0 <4.10.0`) to the latest stable, but the version this -// helper receives is the RECORDED (pre-upgrade) `downloadPackageManager.version`, +// helper receives is the RECORDED (pre-upgrade) `packageManagerInfo.version`, // and an older Yarn (1.x/2.x/3.x) is left untouched. Gating catalog emission on // the recorded version keeps the migration safe in every case: a project that is // not (yet) provably on a catalog-capable Yarn gets concrete specs instead of diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index af187c1d12..8520ac19b7 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -88,7 +88,7 @@ export function rewriteStandaloneProject( undefined, new Map([[projectPath, providerSourceModes]]), ); - const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.packageManagerInfo.version); let extractedStagedConfig: Record | null = null; let movedPnpmSettings: Record | undefined; let shouldAddPnpmWorkspaceVitePlusOverride = false; @@ -102,17 +102,14 @@ export function rewriteStandaloneProject( // and by the post-callback pnpm-workspace.yaml writer. const usePnpmWorkspaceYaml = packageManager === PackageManager.pnpm && - pnpmSupportsWorkspaceSettings(workspaceInfo.downloadPackageManager.version); + pnpmSupportsWorkspaceSettings(workspaceInfo.packageManagerInfo.version); // Whether the active toolchain edges use catalog references (`catalog:`) rather // than concrete aliases. Bun standalone projects never manage a catalog // (`rewriteBunCatalog` runs only on monorepo roots), so this stays false for // bun and the direct `vite` edge resolves to the concrete core alias. Yarn // catalogs require Yarn >= 4.10.0 (older Yarn cannot resolve `catalog:`), so a // project resolving to an older Yarn falls back to concrete specs. - const supportCatalog = supportsCatalog( - packageManager, - workspaceInfo.downloadPackageManager.version, - ); + const supportCatalog = supportsCatalog(packageManager, workspaceInfo.packageManagerInfo.version); editJsonFile<{ overrides?: Record; resolutions?: Record; @@ -336,7 +333,7 @@ export function rewriteStandaloneProject( rewriteAllImports(projectPath, silent, report, true); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager - setPackageManager(projectPath, workspaceInfo.downloadPackageManager); + setPackageManager(projectPath, workspaceInfo.packageManagerInfo); } /** @@ -353,9 +350,9 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceInfo.packageManager, ); - const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.packageManagerInfo.version); const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings( - workspaceInfo.downloadPackageManager.version, + workspaceInfo.packageManagerInfo.version, ); const workspaceShouldAllowBrowserBuilds = workspaceUsesWebdriverio( workspaceInfo.rootDir, @@ -380,7 +377,7 @@ export function rewriteMonorepo( // true here — this is the monorepo path). An older PM falls back to concrete. const supportCatalog = supportsCatalog( workspaceInfo.packageManager, - workspaceInfo.downloadPackageManager.version, + workspaceInfo.packageManagerInfo.version, true, ); // rewrite root workspace @@ -402,7 +399,7 @@ export function rewriteMonorepo( catalogDependencyResolver, workspaceInfo.packages, pnpmMajorVersion, - workspaceInfo.downloadPackageManager.version, + workspaceInfo.packageManagerInfo.version, workspaceShouldAllowBrowserBuilds, workspaceUsesVitest, supportCatalog, @@ -469,7 +466,7 @@ export function rewriteMonorepo( wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); } // set package manager - setPackageManager(workspaceInfo.rootDir, workspaceInfo.downloadPackageManager); + setPackageManager(workspaceInfo.rootDir, workspaceInfo.packageManagerInfo); } /** diff --git a/packages/cli/src/migration/migrator/setup.ts b/packages/cli/src/migration/migrator/setup.ts index c2fc455112..64b377c459 100644 --- a/packages/cli/src/migration/migrator/setup.ts +++ b/packages/cli/src/migration/migrator/setup.ts @@ -5,16 +5,13 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { globSync } from 'glob'; import semver from 'semver'; -import { type DownloadPackageManagerResult } from '../../../binding/index.js'; +import type { PackageManagerInfo } from '../../types/index.ts'; import { editJsonFile } from '../../utils/json.ts'; import { detectConfigs } from '../detector.ts'; import { type MigrationReport } from '../report.ts'; import { warnMigration } from './shared.ts'; -export function setPackageManager( - projectDir: string, - downloadPackageManager: DownloadPackageManagerResult, -) { +export function setPackageManager(projectDir: string, packageManagerInfo: PackageManagerInfo) { // Set the package manager pin. Compatibility-first rule (rfcs/dev-engines.md): // an existing `packageManager` field or `devEngines.packageManager` declaration // is the source of truth and is left as-is; otherwise the exact resolved version @@ -35,8 +32,8 @@ export function setPackageManager( pkg.devEngines = { ...devEngines, packageManager: { - name: downloadPackageManager.name, - version: downloadPackageManager.version, + name: packageManagerInfo.name, + version: packageManagerInfo.version, onFail: 'download', }, }; diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index ffdf0d776e..c8a3ad73a5 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -926,11 +926,11 @@ export function ensureVitePlusBootstrap( // when any package needs it. Each package's direct vitest dependency is // reconciled independently below. const usesVitest = workspaceUsesVitestDirectly(projectPath, workspaceInfo.packages, true); - const pnpmMajorVersion = pnpmMajor(workspaceInfo.downloadPackageManager.version); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.packageManagerInfo.version); const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); const usePnpmWorkspaceYaml = workspaceInfo.packageManager === PackageManager.pnpm && - pnpmSupportsWorkspaceSettings(workspaceInfo.downloadPackageManager.version); + pnpmSupportsWorkspaceSettings(workspaceInfo.packageManagerInfo.version); // Catalogs only resolve inside a bun WORKSPACE (a root package.json with a // non-empty `workspaces`). Gate both the catalog edges (`supportCatalog`) and // the `rewriteBunCatalog` sink below on this so a standalone bun project keeps @@ -945,7 +945,7 @@ export function ensureVitePlusBootstrap( !VITE_PLUS_VERSION.startsWith('file:') && supportsCatalog( workspaceInfo.packageManager, - workspaceInfo.downloadPackageManager.version, + workspaceInfo.packageManagerInfo.version, isBunWorkspace, ); const catalogDependencyResolver = createCatalogDependencyResolver( @@ -1206,7 +1206,7 @@ export function ensureVitePlusBootstrap( } const beforePackageManager = fs.readFileSync(packageJsonPath, 'utf-8'); - setPackageManager(projectPath, workspaceInfo.downloadPackageManager); + setPackageManager(projectPath, workspaceInfo.packageManagerInfo); const afterPackageManager = fs.readFileSync(packageJsonPath, 'utf-8'); result.packageManagerField = beforePackageManager !== afterPackageManager; result.changed = result.packageJson || result.packageManagerConfig || result.packageManagerField; diff --git a/packages/cli/src/types/workspace.ts b/packages/cli/src/types/workspace.ts index 97d1d2c8a1..d250de1fd9 100644 --- a/packages/cli/src/types/workspace.ts +++ b/packages/cli/src/types/workspace.ts @@ -1,6 +1,10 @@ -import type { DownloadPackageManagerResult } from '../../binding/index.js'; import type { PackageManager } from './package.ts'; +export interface PackageManagerInfo { + name: PackageManager; + version: string; +} + export interface WorkspacePackage { name: string; // The path of the package relative to the workspace root @@ -24,13 +28,13 @@ export interface WorkspaceInfo { parentDirs: string[]; packageManager: PackageManager; packageManagerVersion: string; - downloadPackageManager: DownloadPackageManagerResult; + packageManagerInfo: PackageManagerInfo; packages: WorkspacePackage[]; } export interface WorkspaceInfoOptional extends Omit< WorkspaceInfo, - 'packageManager' | 'downloadPackageManager' + 'packageManager' | 'packageManagerInfo' > { packageManager?: PackageManager; } diff --git a/packages/cli/src/utils/__tests__/package-manager-probe.spec.ts b/packages/cli/src/utils/__tests__/package-manager-probe.spec.ts new file mode 100644 index 0000000000..e8779bd7c0 --- /dev/null +++ b/packages/cli/src/utils/__tests__/package-manager-probe.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../command.ts', () => ({ + runCommandSilently: vi.fn(), +})); + +import { PackageManager } from '../../types/index.ts'; +import { runCommandSilently } from '../command.ts'; +import { probePackageManager } from '../prompts.ts'; + +const mockRunCommandSilently = vi.mocked(runCommandSilently); + +describe('probePackageManager', () => { + beforeEach(() => { + mockRunCommandSilently.mockReset(); + }); + + it('reads the version from the package manager on PATH', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 0, + stdout: Buffer.from('10.18.2\n'), + stderr: Buffer.alloc(0), + }); + + await expect( + probePackageManager(PackageManager.pnpm, '/workspace', false, true), + ).resolves.toEqual({ + name: PackageManager.pnpm, + version: '10.18.2', + }); + expect(mockRunCommandSilently).toHaveBeenCalledWith({ + command: PackageManager.pnpm, + args: ['--version'], + cwd: '/workspace', + envs: process.env, + }); + }); + + it('rejects an invalid version response', async () => { + mockRunCommandSilently.mockResolvedValue({ + exitCode: 0, + stdout: Buffer.from('latest\n'), + stderr: Buffer.alloc(0), + }); + + await expect( + probePackageManager(PackageManager.pnpm, '/workspace', false, true), + ).rejects.toThrow('pnpm --version returned an invalid version'); + }); +}); diff --git a/packages/cli/src/utils/__tests__/workspace.spec.ts b/packages/cli/src/utils/__tests__/workspace.spec.ts index a8b860c021..0a3f4ba280 100644 --- a/packages/cli/src/utils/__tests__/workspace.spec.ts +++ b/packages/cli/src/utils/__tests__/workspace.spec.ts @@ -38,12 +38,9 @@ function makeWorkspaceInfo(overrides: Partial = {}): WorkspaceInf workspacePatterns: [], parentDirs: [], packages: [], - downloadPackageManager: { - name: '', - installDir: '', - binPrefix: '', - packageName: '', - version: '', + packageManagerInfo: { + name: PackageManager.pnpm, + version: 'latest', }, ...overrides, }; diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 231aada721..4c7095d7f8 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -1,7 +1,6 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import semver from 'semver'; -import { downloadPackageManager as downloadPackageManagerBinding } from '../../binding/index.js'; import { PackageManager } from '../types/index.ts'; import { isPnpmIgnoredBuildsError, parseInstallGatedBuilds } from './approve-builds.ts'; import { runCommandSilently } from './command.ts'; @@ -75,20 +74,33 @@ export async function selectPackageManager(interactive?: boolean, silent = false } } -export async function downloadPackageManager( +export async function probePackageManager( packageManager: PackageManager, - version: string, + cwd: string, interactive?: boolean, silent = false, ) { const spinner = silent ? getSilentSpinner() : getSpinner(interactive); - spinner.start(`${packageManager}@${version} installing...`); - const downloadResult = await downloadPackageManagerBinding({ - name: packageManager, - version, + spinner.start(`Checking ${packageManager}...`); + const { exitCode, stderr, stdout } = await runCommandSilently({ + command: packageManager, + args: ['--version'], + cwd, + envs: process.env, }); - spinner.stop(`${packageManager}@${downloadResult.version} installed`); - return downloadResult; + const version = stdout.toString().trim(); + if (exitCode !== 0 || !semver.valid(version)) { + spinner.stop(`${packageManager} is unavailable`); + const detail = stderr.toString().trim(); + throw new Error( + detail || + (exitCode === 0 + ? `${packageManager} --version returned an invalid version: ${JSON.stringify(version)}` + : `${packageManager} --version exited with code ${exitCode}`), + ); + } + spinner.stop(`Using ${packageManager}@${version}`); + return { name: packageManager, version }; } export async function runViteInstall( From 0fe55b96cee93293e0ea54155fa659aa48a0585d Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 21:13:03 +0800 Subject: [PATCH 18/22] test(pm): cover direct binary dispatch --- .../command_package_manager_probe/bin/pnpm | 8 +++ .../package.json | 4 ++ .../pnpm-lock.yaml | 4 ++ .../snapshots.toml | 17 +++++++ ...manager_probe_warns_on_version_mismatch.md | 13 +++++ ...manager_probe_without_specific_metadata.md | 20 ++++++++ .../command_run_no_pm_mutation/package.json | 7 +++ .../command_run_no_pm_mutation/pnpm-lock.yaml | 4 ++ .../command_run_no_pm_mutation/snapshots.toml | 10 ++++ .../command_run_no_pm_mutation.global.md | 51 +++++++++++++++++++ .../command_run_no_pm_mutation.local.md | 49 ++++++++++++++++++ .../snapshots.toml | 14 ++--- .../test_managed_package_manager_path.md | 16 ++---- .../src/managed-pm-path.test.ts | 2 +- .../snapshots.toml | 1 + .../vite_task_path_env_include_pm.md | 2 + 16 files changed, 200 insertions(+), 22 deletions(-) create mode 100755 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/bin/pnpm create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/pnpm-lock.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots/package_manager_probe_warns_on_version_mismatch.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots/package_manager_probe_without_specific_metadata.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/pnpm-lock.yaml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots/command_run_no_pm_mutation.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots/command_run_no_pm_mutation.local.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/bin/pnpm b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/bin/pnpm new file mode 100755 index 0000000000..92033fa241 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/bin/pnpm @@ -0,0 +1,8 @@ +#!/bin/sh + +if [ "$1" = "--version" ]; then + echo "10.18.2" + exit 0 +fi + +echo "fake pnpm $*" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/package.json new file mode 100644 index 0000000000..1a9709f6d6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/package.json @@ -0,0 +1,4 @@ +{ + "name": "command-package-manager-probe", + "private": true +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/pnpm-lock.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/pnpm-lock.yaml new file mode 100644 index 0000000000..844f3bfacb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/pnpm-lock.yaml @@ -0,0 +1,4 @@ +lockfileVersion: '9.0' + +importers: + .: {} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots.toml new file mode 100644 index 0000000000..29a2dc61ed --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots.toml @@ -0,0 +1,17 @@ +[[case]] +name = "package_manager_probe_without_specific_metadata" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vp", "install", "--silent"], envs = [["PATH", "${workspace}/bin:${PATH}"]], comment = "lockfile detection uses the binary on PATH without a mismatch warning", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "probing and dispatch do not pin package.json", continue-on-failure = true }, +] + +[[case]] +name = "package_manager_probe_warns_on_version_mismatch" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "\"pnpm@9.0.0\""], snapshot = false }, + { argv = ["vp", "install", "--silent"], envs = [["PATH", "${workspace}/bin:${PATH}"]], comment = "specific metadata warns, then command translation and execution use the probed version", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots/package_manager_probe_warns_on_version_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots/package_manager_probe_warns_on_version_mismatch.md new file mode 100644 index 0000000000..3b779d0f06 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots/package_manager_probe_warns_on_version_mismatch.md @@ -0,0 +1,13 @@ +# package_manager_probe_warns_on_version_mismatch + +## `vpt json-edit package.json packageManager '"pnpm@9.0.0"'` + + +## `PATH=${workspace}/bin:${PATH} vp install --silent` + +specific metadata warns, then command translation and execution use the probed version + +``` +warn: packageManager requests pnpm@9.0.0, but `pnpm` on PATH reports 10.18.2; using 10.18.2 +fake pnpm install --silent +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots/package_manager_probe_without_specific_metadata.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots/package_manager_probe_without_specific_metadata.md new file mode 100644 index 0000000000..52df3ef3ba --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_package_manager_probe/snapshots/package_manager_probe_without_specific_metadata.md @@ -0,0 +1,20 @@ +# package_manager_probe_without_specific_metadata + +## `PATH=${workspace}/bin:${PATH} vp install --silent` + +lockfile detection uses the binary on PATH without a mismatch warning + +``` +fake pnpm install --silent +``` + +## `vpt print-file package.json` + +probing and dispatch do not pin package.json + +``` +{ + "name": "command-package-manager-probe", + "private": true +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/package.json new file mode 100644 index 0000000000..8a3f7f0465 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/package.json @@ -0,0 +1,7 @@ +{ + "name": "command-run-no-pm-mutation", + "private": true, + "scripts": { + "hello": "vpt print hello" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/pnpm-lock.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/pnpm-lock.yaml new file mode 100644 index 0000000000..844f3bfacb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/pnpm-lock.yaml @@ -0,0 +1,4 @@ +lockfileVersion: '9.0' + +importers: + .: {} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots.toml new file mode 100644 index 0000000000..3563455144 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots.toml @@ -0,0 +1,10 @@ +[[case]] +name = "command_run_no_pm_mutation" +vp = ["local", "global"] +comment = "A lockfile may select a package manager, but running tasks must not pin it or otherwise rewrite package.json." +steps = [ + { argv = ["vpt", "print-file", "package.json"], comment = "package.json before running tasks", continue-on-failure = true }, + { argv = ["vp", "run", "hello"], comment = "run through vp", continue-on-failure = true }, + { argv = ["vpr", "hello"], comment = "run through the vpr shorthand", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "package.json remains byte-for-byte unchanged", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots/command_run_no_pm_mutation.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots/command_run_no_pm_mutation.global.md new file mode 100644 index 0000000000..ae10763e8e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots/command_run_no_pm_mutation.global.md @@ -0,0 +1,51 @@ +# command_run_no_pm_mutation + +A lockfile may select a package manager, but running tasks must not pin it or otherwise rewrite package.json. + +## `vpt print-file package.json` + +package.json before running tasks + +``` +{ + "name": "command-run-no-pm-mutation", + "private": true, + "scripts": { + "hello": "vpt print hello" + } +} +``` + +## `vp run hello` + +run through vp + +``` +VITE+ - The Unified Toolchain for the Web + +$ vpt print hello ⊘ cache disabled +hello +``` + +## `vpr hello` + +run through the vpr shorthand + +``` +$ vpt print hello ⊘ cache disabled +hello +``` + +## `vpt print-file package.json` + +package.json remains byte-for-byte unchanged + +``` +{ + "name": "command-run-no-pm-mutation", + "private": true, + "scripts": { + "hello": "vpt print hello" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots/command_run_no_pm_mutation.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots/command_run_no_pm_mutation.local.md new file mode 100644 index 0000000000..2145b45161 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_run_no_pm_mutation/snapshots/command_run_no_pm_mutation.local.md @@ -0,0 +1,49 @@ +# command_run_no_pm_mutation + +A lockfile may select a package manager, but running tasks must not pin it or otherwise rewrite package.json. + +## `vpt print-file package.json` + +package.json before running tasks + +``` +{ + "name": "command-run-no-pm-mutation", + "private": true, + "scripts": { + "hello": "vpt print hello" + } +} +``` + +## `vp run hello` + +run through vp + +``` +$ vpt print hello ⊘ cache disabled +hello +``` + +## `vpr hello` + +run through the vpr shorthand + +``` +$ vpt print hello ⊘ cache disabled +hello +``` + +## `vpt print-file package.json` + +package.json remains byte-for-byte unchanged + +``` +{ + "name": "command-run-no-pm-mutation", + "private": true, + "scripts": { + "hello": "vpt print hello" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/snapshots.toml index 6c2728c3ae..916b0b8820 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/snapshots.toml @@ -3,16 +3,10 @@ name = "test_managed_package_manager_path" vp = "global" skip-platforms = ["windows"] comment = """ -The legacy case built a sanitized PATH (a mktemp dir holding only `node`, plus -/bin:/usr/bin) via `sh -c` to prove `vp test` exposes the managed package -manager to the test process even when no host pnpm is reachable -(src/managed-pm-path.test.ts runs `pnpm --version` and asserts the pinned -version). The runner's case environment is already hermetic: its PATH holds -only the case-owned Vite+ bins plus the system tail, so no host pnpm can -satisfy the lookup and the assertion exercises the managed path end to end. A -step-level PATH override would break `vp` resolution itself, which follows the -step PATH. +The runner's hermetic PATH contains the case-owned Vite+ shims and no host +pnpm. `vp test` leaves that PATH alone; the pnpm shim reached by the test owns +provisioning the pinned version. """ steps = [ - { argv = ["vp", "test", "--slowTestThreshold", "10000"], comment = "no host pnpm reachable; vp test still exposes the managed pnpm", continue-on-failure = true }, + { argv = ["vp", "test", "--slowTestThreshold", "10000"], comment = "the inherited pnpm shim provisions the configured version", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/snapshots/test_managed_package_manager_path.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/snapshots/test_managed_package_manager_path.md index a6794df8b2..a531060e93 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/snapshots/test_managed_package_manager_path.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/snapshots/test_managed_package_manager_path.md @@ -1,18 +1,12 @@ # test_managed_package_manager_path -The legacy case built a sanitized PATH (a mktemp dir holding only `node`, plus -/bin:/usr/bin) via `sh -c` to prove `vp test` exposes the managed package -manager to the test process even when no host pnpm is reachable -(src/managed-pm-path.test.ts runs `pnpm --version` and asserts the pinned -version). The runner's case environment is already hermetic: its PATH holds -only the case-owned Vite+ bins plus the system tail, so no host pnpm can -satisfy the lookup and the assertion exercises the managed path end to end. A -step-level PATH override would break `vp` resolution itself, which follows the -step PATH. +The runner's hermetic PATH contains the case-owned Vite+ shims and no host +pnpm. `vp test` leaves that PATH alone; the pnpm shim reached by the test owns +provisioning the pinned version. ## `vp test --slowTestThreshold 10000` -no host pnpm reachable; vp test still exposes the managed pnpm +the inherited pnpm shim provisions the configured version ``` VITE+ - The Unified Toolchain for the Web @@ -20,7 +14,7 @@ VITE+ - The Unified Toolchain for the Web RUN ✓ src/managed-pm-path.test.ts (1 test) - ✓ direct test command exposes the configured package manager on PATH + ✓ package manager shim on inherited PATH owns version provisioning Test Files 1 passed (1) Tests 1 passed (1) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/src/managed-pm-path.test.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/src/managed-pm-path.test.ts index cee9cc69d6..b539b1497f 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/src/managed-pm-path.test.ts +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/test_managed_package_manager_path/src/managed-pm-path.test.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process'; import { expect, test } from 'vite-plus/test'; -test('direct test command exposes the configured package manager on PATH', () => { +test('package manager shim on inherited PATH owns version provisioning', () => { const version = execFileSync('pnpm', ['--version'], { encoding: 'utf8' }).trim(); expect(version).toBe('11.2.2'); }); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_path_env_include_pm/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_path_env_include_pm/snapshots.toml index f91dcf31e7..04bfd15287 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_path_env_include_pm/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_path_env_include_pm/snapshots.toml @@ -1,6 +1,7 @@ [[case]] name = "vite_task_path_env_include_pm" vp = "local" +comment = "Vite Task inherits PATH unchanged; the Yarn shim already on PATH owns version provisioning." steps = [ { argv = ["vp", "install", "--no-frozen-lockfile"], continue-on-failure = true }, { argv = ["vp", "run", "check-yarn-version"], continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_path_env_include_pm/snapshots/vite_task_path_env_include_pm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_path_env_include_pm/snapshots/vite_task_path_env_include_pm.md index 868dda08ec..48b695ccb5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_path_env_include_pm/snapshots/vite_task_path_env_include_pm.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_path_env_include_pm/snapshots/vite_task_path_env_include_pm.md @@ -1,5 +1,7 @@ # vite_task_path_env_include_pm +Vite Task inherits PATH unchanged; the Yarn shim already on PATH owns version provisioning. + ## `vp install --no-frozen-lockfile` ``` From 8f9bd7a4c18d33b6be97c484292a9e1b5bfa7f1a Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 21:13:09 +0800 Subject: [PATCH 19/22] docs(pm): describe PATH-owned package managers --- docs/guide/install.md | 10 +++++---- rfcs/dev-engines.md | 2 ++ rfcs/package-manager-detection.md | 34 ++++++++++++------------------- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/docs/guide/install.md b/docs/guide/install.md index 8d1bb42523..458508bf0f 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -19,7 +19,9 @@ Vite+ detects the package manager from the workspace root in this order: 9. `bunfig.toml` 10. `yarn.config.cjs` -If none of those files are present, `vp` falls back to `pnpm` by default. Vite+ automatically downloads the matching package manager and uses it for the command you ran. When detection comes from lockfiles or config files, the resolved version is written to `devEngines.packageManager` so future runs are deterministic; projects that already declare `packageManager` or `devEngines.packageManager` are left as-is. +If none of those files are present, `vp` falls back to `pnpm` by default. After selecting the package manager, Vite+ invokes that manager's binary from the inherited `PATH`. Package-manager dispatch does not download a manager, prepend a managed directory to `PATH`, or write a version back to `package.json`. + +Before translating version-sensitive flags, Vite+ runs ` --version`. A specific version or range from `packageManager` or `devEngines.packageManager` is treated as the expected version. If the binary on `PATH` does not satisfy it, Vite+ warns and uses the binary's reported version for command translation. Detection without a specific version—such as a lockfile, an absent version, `*`, or `latest`—does not produce a mismatch warning. The [`devEngines.packageManager`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines) field accepts a single object or an array of objects, and its `version` may be a semver range: @@ -35,11 +37,11 @@ The [`devEngines.packageManager`](https://docs.npmjs.com/cli/v11/configuring-npm } ``` -A range resolves to an already-downloaded satisfying version when possible, otherwise to the latest satisfying version from the npm registry. The range itself stays the source of truth; Vite+ never freezes it into an exact `packageManager` pin. When both `packageManager` and `devEngines.packageManager` are declared, the `packageManager` field drives selection and Vite+ warns when it does not satisfy the devEngines constraint (`vp env doctor` shows details). +A range remains metadata for checking the selected binary; ordinary package-manager commands do not resolve it through the registry or freeze it into an exact `packageManager` pin. When both `packageManager` and `devEngines.packageManager` are declared, the `packageManager` field drives selection and Vite+ warns when it does not satisfy the devEngines constraint (`vp env doctor` shows details). -Vite+ currently downloads the declared package manager (the `onFail: "download"` behavior); the other `onFail` values are accepted but not yet differentiated. +Package-manager provisioning belongs to the invoked binary. The package-manager shims installed by Vite+ can provision their declared version, while a system binary is used as-is. Explicit environment commands such as `vp env install`, `vp env use`, and `vp env pin` continue to manage package-manager versions directly. -The explicit `packageManager` field (or the `devEngines.packageManager` declaration) also affects matching package-manager shims. If a project has `packageManager: "npm@10.9.4"`, `npm` and `npx` use npm 10.9.4. Other generated alias pairs behave the same way: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Mismatched tools are not translated; `npm` in a `pnpm` project still resolves as npm. +The explicit `packageManager` field (or the `devEngines.packageManager` declaration) also affects matching Vite+ package-manager shims. If a project has `packageManager: "npm@10.9.4"`, the Vite+ `npm` and `npx` shims provision and use npm 10.9.4. Other generated alias pairs behave the same way: `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Mismatched tools are not translated; `npm` in a `pnpm` project still resolves as npm. ## Usage diff --git a/rfcs/dev-engines.md b/rfcs/dev-engines.md index 08a75f03b7..f4b1ea5883 100644 --- a/rfcs/dev-engines.md +++ b/rfcs/dev-engines.md @@ -10,6 +10,8 @@ Make `package.json#devEngines` a first-class source for both Node.js runtime sel This RFC implements the plan agreed in [#864](https://github.com/voidzero-dev/vite-plus/issues/864), incorporating review notes from that thread (semver-aware conflict detection, range preservation for `devEngines.packageManager`, handling of all field shapes defined by the spec). +> **Current dispatch behavior:** a later package-manager refactor made ordinary command dispatch read-only. It reads this metadata, probes the matching binary on the inherited `PATH`, warns when a specific exact version or range is inconsistent, and uses the probed version. Dispatcher-side download, `PATH` injection, and auto-pin details later in this RFC are retained as historical design context; provisioning now belongs to package-manager shims and explicit `vp env` commands. + ## Motivation `devEngines` is the cross-tool standard for declaring development environment requirements, already supported by npm (v10.9+), pnpm (`devEngines.runtime` for Node management), and Corepack. Vite+ currently: diff --git a/rfcs/package-manager-detection.md b/rfcs/package-manager-detection.md index a524853579..bd5b9b57aa 100644 --- a/rfcs/package-manager-detection.md +++ b/rfcs/package-manager-detection.md @@ -152,36 +152,28 @@ vp create vite:monorepo --no-interactive --package-manager bun This ensures monorepo consistency while allowing standalone projects to override ambient detection explicitly. -## Auto-Update Behavior +## Dispatch Behavior -After detection and download, Vite+ writes the resolved version back to `package.json` so future runs are deterministic: +Detection is read-only. Ordinary package-manager commands do not download a manager, modify `PATH`, or write a resolved version to `package.json`. -- Detection from the `packageManager` field or an exact `devEngines.packageManager` version: already exact, no write needed. -- Detection from a `devEngines.packageManager` range: no write; the range is the user's source of truth and is never frozen into an exact pin. -- Detection from lockfiles, config files, or interactive selection: the exact resolved version is written to `devEngines.packageManager` with `onFail: "download"`. +After detection, Vite+ probes ` --version` through the inherited `PATH`. The reported version drives version-sensitive command translation. When an exact version or range was declared and the reported version does not satisfy it, Vite+ warns and continues with the reported version. No mismatch warning is emitted for an absent version, `*`, `latest`, or lockfile/config-only detection. -The write preserves existing entries Vite+ does not act on (e.g. another package manager declared with `onFail: "ignore"`): the resolved entry is appended to an existing array, an existing single entry is converted to array form with the original kept first, and a single entry is only written when the field is absent or malformed. - -This ensures: - -- Future runs use a deterministic version (Priority 1 or 2 match) -- Team members get consistent versions -- CI environments use deterministic versions +Provisioning is owned by the binary that was invoked. Vite+'s package-manager shims may download their selected version; system binaries do not. The explicit `vp env` commands retain their managed download and pinning behavior. ## Version Resolution -| Detection method | Version used | -| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `packageManager` field | Exact version from field (e.g., `10.19.0`) | -| `devEngines.packageManager` (exact version) | Exact version from field | -| `devEngines.packageManager` (range or absent) | Highest already-downloaded satisfying version, otherwise latest satisfying version from the npm registry | -| Lockfile/config detection | `"latest"`: resolved to latest stable version from npm registry | -| Interactive selection | `"latest"`: resolved to latest stable version from npm registry | +| Detection method | Metadata expectation | Version used for dispatch | +| -------------------------------------------- | ------------------------------- | ------------------------------ | +| `packageManager` field | Exact version from the field | Version reported by the binary | +| `devEngines.packageManager` (exact or range) | Declared exact version or range | Version reported by the binary | +| `devEngines.packageManager` (version absent) | Any version | Version reported by the binary | +| Lockfile/config detection | No specific version | Version reported by the binary | +| Interactive selection | No specific version | Version reported by the binary | **Special cases**: -- **yarn ≥ 2.0.0**: Downloads from `@yarnpkg/cli-dist` instead of `yarn` npm package -- **bun**: Downloads platform-specific native binary from `@oven/bun-{os}-{arch}` (including musl variants for Alpine Linux) +- **Vite+ Yarn shim, Yarn ≥ 2.0.0**: provisions from `@yarnpkg/cli-dist` instead of the `yarn` npm package +- **Vite+ Bun shim**: provisions the platform-specific native binary from `@oven/bun-{os}-{arch}` (including musl variants for Alpine Linux) ## Workspace and Monorepo Detection From 7f153334a3eb362e9963222b96d68ab6c1e6f658 Mon Sep 17 00:00:00 2001 From: Liang Date: Sun, 9 Aug 2026 21:14:57 +0800 Subject: [PATCH 20/22] style(pm): satisfy dispatcher lints --- crates/vp_pm_cli/src/dispatch.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index c8dcdaf7b2..4b06412b71 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -127,7 +127,7 @@ async fn probe_package_manager( if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let message = if stderr.is_empty() { - format!("`{command} --version` exited with {}", output.status) + vt_str::format!("`{command} --version` exited with {}", output.status).to_string() } else { stderr }; @@ -143,9 +143,14 @@ async fn probe_package_manager( })?; if is_specific_version(&manager.version) && !version_matches(&manager.version, &actual_version) { - vp_shared::output::warn(&format!( + vp_shared::output::warn(&vt_str::format!( "{} requests {}@{}, but `{}` on PATH reports {}; using {}", - manager.source, manager.client, manager.version, manager.client, actual, actual + manager.source, + manager.client, + manager.version, + manager.client, + actual, + actual )); } manager.version = actual.into(); @@ -163,7 +168,7 @@ fn version_matches(expected: &str, actual: &semver::Version) -> bool { let Ok(expected) = node_semver::Range::parse(expected) else { return false; }; - node_semver::Version::parse(&actual.to_string()).is_ok_and(|actual| expected.satisfies(&actual)) + node_semver::Version::parse(actual.to_string()).is_ok_and(|actual| expected.satisfies(&actual)) } async fn dispatch_dlx( From d6a4229d2f14f8aec302ee1a62687005f7977b09 Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 10 Aug 2026 19:25:14 +0800 Subject: [PATCH 21/22] refactor(cli): remove duplicate package manager state --- .../src/create/__tests__/discovery.spec.ts | 8 +--- packages/cli/src/create/bin.ts | 17 ++++---- .../bun-catalog-file-protocol.spec.ts | 4 -- .../src/migration/__tests__/migrator.spec.ts | 42 +++++++------------ packages/cli/src/migration/bin.ts | 6 +-- .../cli/src/migration/migrator/catalog.ts | 2 +- .../src/migration/migrator/orchestrators.ts | 22 ++++++---- packages/cli/src/migration/migrator/setup.ts | 12 ++++-- .../migration/migrator/vite-plus-bootstrap.ts | 8 ++-- packages/cli/src/types/workspace.ts | 11 +---- .../cli/src/utils/__tests__/workspace.spec.ts | 4 -- 11 files changed, 53 insertions(+), 83 deletions(-) diff --git a/packages/cli/src/create/__tests__/discovery.spec.ts b/packages/cli/src/create/__tests__/discovery.spec.ts index f3d1191800..dbca3bf642 100644 --- a/packages/cli/src/create/__tests__/discovery.spec.ts +++ b/packages/cli/src/create/__tests__/discovery.spec.ts @@ -42,7 +42,8 @@ describe('discoverTemplate', () => { monorepoScope: '', workspacePatterns: ['tools/*'], parentDirs: ['tools'], - packageManagerInfo: { name: PackageManager.pnpm, version: '10.0.0' }, + packageManager: PackageManager.pnpm, + packageManagerVersion: '10.0.0', packages: [{ name: 'my-template', path: 'tools/my-template' }], } as unknown as WorkspaceInfo; } @@ -289,10 +290,6 @@ describe('GitHub template helpers', () => { parentDirs: [], packageManager: 'pnpm', packageManagerVersion: 'latest', - packageManagerInfo: { - name: PackageManager.pnpm, - version: '10.0.0', - }, packages: [], }); @@ -309,7 +306,6 @@ describe('GitHub template helpers', () => { parentDirs: [], packageManager: 'pnpm', packageManagerVersion: 'latest', - packageManagerInfo: { name: PackageManager.pnpm, version: '10.0.0' }, packages: [], } as never; diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 82dff2565b..7025b70ae7 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -832,7 +832,6 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h ...workspaceInfoOptional, packageManager, packageManagerVersion: packageManagerInfo.version, - packageManagerInfo, }; const existingAgentTargetPaths = @@ -956,7 +955,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h cwd: installCwd, projectDir: projectPath, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.packageManagerInfo.version, + packageManagerVersion: workspaceInfo.packageManagerVersion, targets, interactive: options.interactive, autoApprove: options.approveBuilds === true, @@ -1128,7 +1127,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h const installSummary = await runViteInstall(fullPath, options.interactive, installArgs, { silent: compactOutput, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.packageManagerInfo.version, + packageManagerVersion: workspaceInfo.packageManagerVersion, detectIgnoredBuilds: true, }); await handleIgnoredBuilds(fullPath, fullPath, installSummary); @@ -1140,7 +1139,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary, nextCommand: getNextCommand(projectDir, 'vp run'), packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.packageManagerInfo.version, + packageManagerVersion: workspaceInfo.packageManagerVersion, projectDir, }); return; @@ -1293,7 +1292,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h // signal is present) and force yarn's classic node_modules layout // (Plug'n'Play zip entries break @oxlint/migrate's fileURLToPath resolution). const installAndMigrate = async (installCwd: string) => { - setPackageManager(fullPath, workspaceInfo.packageManagerInfo); + setPackageManager(fullPath, workspaceInfo.packageManager, workspaceInfo.packageManagerVersion); if (workspaceInfo.packageManager === PackageManager.yarn) { const yarnrcPath = path.join(fullPath, '.yarnrc.yml'); if (!fs.existsSync(yarnrcPath)) { @@ -1310,7 +1309,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary = await runViteInstall(installCwd, options.interactive, installArgs, { silent: compactOutput, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.packageManagerInfo.version, + packageManagerVersion: workspaceInfo.packageManagerVersion, detectIgnoredBuilds: true, }); if (installSummary.status !== 'installed') { @@ -1416,7 +1415,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary = await runViteInstall(workspaceInfo.rootDir, options.interactive, installArgs, { silent: compactOutput, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.packageManagerInfo.version, + packageManagerVersion: workspaceInfo.packageManagerVersion, detectIgnoredBuilds: true, }); await handleIgnoredBuilds(fullPath, workspaceInfo.rootDir, installSummary); @@ -1463,7 +1462,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary = await runViteInstall(fullPath, options.interactive, installArgs, { silent: compactOutput, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.packageManagerInfo.version, + packageManagerVersion: workspaceInfo.packageManagerVersion, detectIgnoredBuilds: true, }); await handleIgnoredBuilds(fullPath, fullPath, installSummary); @@ -1477,7 +1476,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h installSummary, nextCommand: getNextCommand(projectDir, 'vp run'), packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.packageManagerInfo.version, + packageManagerVersion: workspaceInfo.packageManagerVersion, projectDir, }); // #endregion diff --git a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts index a2ede7fe30..a1dc7e49d5 100644 --- a/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts +++ b/packages/cli/src/migration/__tests__/bun-catalog-file-protocol.spec.ts @@ -30,10 +30,6 @@ function makeWorkspaceInfo(rootDir: string, packageManager: PackageManager): Wor parentDirs: [], packageManager, packageManagerVersion: '10.33.0', - packageManagerInfo: { - name: packageManager, - version: '1.0.0', - }, packages: [], }; } diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 48a628cded..73e0c4fe2e 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -1389,7 +1389,7 @@ describe('setPackageManager', () => { it('writes devEngines.packageManager when neither field exists', () => { fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'x' }, null, 2)); - setPackageManager(tmpDir, packageManagerInfo); + setPackageManager(tmpDir, packageManagerInfo.name, packageManagerInfo.version); expect(readPkg().devEngines).toEqual({ packageManager: { name: 'pnpm', version: '11.5.1', onFail: 'download' }, }); @@ -1400,7 +1400,7 @@ describe('setPackageManager', () => { path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'x', packageManager: 'npm@10.5.0' }, null, 2), ); - setPackageManager(tmpDir, packageManagerInfo); + setPackageManager(tmpDir, packageManagerInfo.name, packageManagerInfo.version); const pkg = readPkg(); expect(pkg.packageManager).toBe('npm@10.5.0'); expect(pkg.devEngines).toBeUndefined(); @@ -1415,7 +1415,7 @@ describe('setPackageManager', () => { 2, ), ); - setPackageManager(tmpDir, packageManagerInfo); + setPackageManager(tmpDir, packageManagerInfo.name, packageManagerInfo.version); expect(readPkg().devEngines).toEqual({ runtime: { name: 'node', version: '^24.0.0' }, packageManager: { name: 'pnpm', version: '11.5.1', onFail: 'download' }, @@ -1428,7 +1428,7 @@ describe('setPackageManager', () => { path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'x', devEngines: 'oops' }, null, 2), ); - setPackageManager(tmpDir, packageManagerInfo); + setPackageManager(tmpDir, packageManagerInfo.name, packageManagerInfo.version); expect(readPkg().devEngines).toEqual({ packageManager: { name: 'pnpm', version: '11.5.1', onFail: 'download' }, }); @@ -1687,10 +1687,6 @@ function makeWorkspaceInfo( parentDirs: [], packageManager, packageManagerVersion: version, - packageManagerInfo: { - name: packageManager, - version, - }, packages: [], }; } @@ -2739,10 +2735,7 @@ describe('ensureVitePlusBootstrap', () => { workspacePatterns: ['packages/*'], packages: [{ name: 'import-resolver', path: 'packages/import-resolver' }], }; - workspaceInfo.packageManagerInfo = { - ...workspaceInfo.packageManagerInfo, - version: '9.15.9', - }; + workspaceInfo.packageManagerVersion = '9.15.9'; ensureVitePlusBootstrap(workspaceInfo); const pkg = readJson(path.join(pkgDir, 'package.json')) as { @@ -2797,10 +2790,7 @@ describe('ensureVitePlusBootstrap', () => { workspacePatterns: ['packages/*'], packages: [{ name: 'import-resolver', path: 'packages/import-resolver' }], }; - workspaceInfo.packageManagerInfo = { - ...workspaceInfo.packageManagerInfo, - version: '9.4.0', - }; + workspaceInfo.packageManagerVersion = '9.4.0'; ensureVitePlusBootstrap(workspaceInfo); const pkg = readJson(path.join(pkgDir, 'package.json')) as { @@ -2851,10 +2841,7 @@ describe('ensureVitePlusBootstrap', () => { workspacePatterns: ['packages/*'], packages: [{ name: 'app', path: 'packages/app' }], }; - workspaceInfo.packageManagerInfo = { - ...workspaceInfo.packageManagerInfo, - version: '9.15.9', - }; + workspaceInfo.packageManagerVersion = '9.15.9'; ensureVitePlusBootstrap(workspaceInfo); const rootPkg = readJson(path.join(tmpDir, 'package.json')) as { @@ -3810,7 +3797,7 @@ describe('ensureVitePlusBootstrap', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '10.6.1'; - workspaceInfo.packageManagerInfo.version = '10.6.1'; + workspaceInfo.packageManagerVersion = '10.6.1'; ensureVitePlusBootstrap(workspaceInfo); @@ -4435,7 +4422,6 @@ describe('yarn catalog version gating', () => { function yarnWorkspaceInfo(version: string) { const info = makeWorkspaceInfo(tmpDir, PackageManager.yarn); info.packageManagerVersion = version; - info.packageManagerInfo = { ...info.packageManagerInfo, version }; return info; } @@ -4631,7 +4617,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '9.15.9'; - workspaceInfo.packageManagerInfo.version = '9.15.9'; + workspaceInfo.packageManagerVersion = '9.15.9'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); // package.json emits catalog: specs (this part already works today). @@ -7244,7 +7230,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }), ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); - workspaceInfo.packageManagerInfo.version = '10.6.1'; + workspaceInfo.packageManagerVersion = '10.6.1'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { @@ -7268,7 +7254,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }), ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); - workspaceInfo.packageManagerInfo.version = '10.6.1'; + workspaceInfo.packageManagerVersion = '10.6.1'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { @@ -7288,7 +7274,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { }), ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); - workspaceInfo.packageManagerInfo.version = '10.6.1'; + workspaceInfo.packageManagerVersion = '10.6.1'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); // No webdriverio -> nothing to manage -> no allowBuilds key added to the pnpm sink @@ -7309,7 +7295,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '9.15.0'; - workspaceInfo.packageManagerInfo.version = '9.15.0'; + workspaceInfo.packageManagerVersion = '9.15.0'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { @@ -7330,7 +7316,7 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '9.15.0'; - workspaceInfo.packageManagerInfo.version = '9.15.0'; + workspaceInfo.packageManagerVersion = '9.15.0'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 5046724d9b..6646eea3d6 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -817,7 +817,6 @@ async function executeMigrationPlan( ...workspaceInfoOptional, packageManager: plan.packageManager, packageManagerVersion: packageManagerInfo.version, - packageManagerInfo, }; if (plan.convertYarnPnp) { @@ -843,7 +842,7 @@ async function executeMigrationPlan( { silent: true, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.packageManagerInfo.version, + packageManagerVersion: workspaceInfo.packageManagerVersion, }, ); @@ -979,7 +978,7 @@ async function executeMigrationPlan( { silent: true, packageManager: workspaceInfo.packageManager, - packageManagerVersion: workspaceInfo.packageManagerInfo.version, + packageManagerVersion: workspaceInfo.packageManagerVersion, }, ); if (finalInstallSummary.status === 'failed') { @@ -1232,7 +1231,6 @@ async function main() { ...workspaceInfoOptional, packageManager, packageManagerVersion: packageManagerInfo.version, - packageManagerInfo, }, report, ); diff --git a/packages/cli/src/migration/migrator/catalog.ts b/packages/cli/src/migration/migrator/catalog.ts index 853d788335..1568999a4b 100644 --- a/packages/cli/src/migration/migrator/catalog.ts +++ b/packages/cli/src/migration/migrator/catalog.ts @@ -112,7 +112,7 @@ const YARN_CATALOG_MIN_VERSION = '4.10.0'; // Yarn's `catalog:` protocol (and the `.yarnrc.yml` `catalog`/`catalogs` fields) // ships ENABLED BY DEFAULT only from Yarn 4.10.0. `vp migrate` auto-upgrades an // in-range Yarn (`>=4.0.0 <4.10.0`) to the latest stable, but the version this -// helper receives is the RECORDED (pre-upgrade) `packageManagerInfo.version`, +// helper receives is the RECORDED (pre-upgrade) `packageManagerVersion`, // and an older Yarn (1.x/2.x/3.x) is left untouched. Gating catalog emission on // the recorded version keeps the migration safe in every case: a project that is // not (yet) provably on a catalog-capable Yarn gets concrete specs instead of diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index 8520ac19b7..c12fc95d3b 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -88,7 +88,7 @@ export function rewriteStandaloneProject( undefined, new Map([[projectPath, providerSourceModes]]), ); - const pnpmMajorVersion = pnpmMajor(workspaceInfo.packageManagerInfo.version); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.packageManagerVersion); let extractedStagedConfig: Record | null = null; let movedPnpmSettings: Record | undefined; let shouldAddPnpmWorkspaceVitePlusOverride = false; @@ -102,14 +102,14 @@ export function rewriteStandaloneProject( // and by the post-callback pnpm-workspace.yaml writer. const usePnpmWorkspaceYaml = packageManager === PackageManager.pnpm && - pnpmSupportsWorkspaceSettings(workspaceInfo.packageManagerInfo.version); + pnpmSupportsWorkspaceSettings(workspaceInfo.packageManagerVersion); // Whether the active toolchain edges use catalog references (`catalog:`) rather // than concrete aliases. Bun standalone projects never manage a catalog // (`rewriteBunCatalog` runs only on monorepo roots), so this stays false for // bun and the direct `vite` edge resolves to the concrete core alias. Yarn // catalogs require Yarn >= 4.10.0 (older Yarn cannot resolve `catalog:`), so a // project resolving to an older Yarn falls back to concrete specs. - const supportCatalog = supportsCatalog(packageManager, workspaceInfo.packageManagerInfo.version); + const supportCatalog = supportsCatalog(packageManager, workspaceInfo.packageManagerVersion); editJsonFile<{ overrides?: Record; resolutions?: Record; @@ -333,7 +333,7 @@ export function rewriteStandaloneProject( rewriteAllImports(projectPath, silent, report, true); wrapLazyPluginsInViteConfig(projectPath, silent, report); // set package manager - setPackageManager(projectPath, workspaceInfo.packageManagerInfo); + setPackageManager(projectPath, workspaceInfo.packageManager, workspaceInfo.packageManagerVersion); } /** @@ -350,9 +350,9 @@ export function rewriteMonorepo( workspaceInfo.rootDir, workspaceInfo.packageManager, ); - const pnpmMajorVersion = pnpmMajor(workspaceInfo.packageManagerInfo.version); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.packageManagerVersion); const usePnpmWorkspaceSettings = pnpmSupportsWorkspaceSettings( - workspaceInfo.packageManagerInfo.version, + workspaceInfo.packageManagerVersion, ); const workspaceShouldAllowBrowserBuilds = workspaceUsesWebdriverio( workspaceInfo.rootDir, @@ -377,7 +377,7 @@ export function rewriteMonorepo( // true here — this is the monorepo path). An older PM falls back to concrete. const supportCatalog = supportsCatalog( workspaceInfo.packageManager, - workspaceInfo.packageManagerInfo.version, + workspaceInfo.packageManagerVersion, true, ); // rewrite root workspace @@ -399,7 +399,7 @@ export function rewriteMonorepo( catalogDependencyResolver, workspaceInfo.packages, pnpmMajorVersion, - workspaceInfo.packageManagerInfo.version, + workspaceInfo.packageManagerVersion, workspaceShouldAllowBrowserBuilds, workspaceUsesVitest, supportCatalog, @@ -466,7 +466,11 @@ export function rewriteMonorepo( wrapLazyPluginsInViteConfig(path.join(workspaceInfo.rootDir, pkg.path), silent, report); } // set package manager - setPackageManager(workspaceInfo.rootDir, workspaceInfo.packageManagerInfo); + setPackageManager( + workspaceInfo.rootDir, + workspaceInfo.packageManager, + workspaceInfo.packageManagerVersion, + ); } /** diff --git a/packages/cli/src/migration/migrator/setup.ts b/packages/cli/src/migration/migrator/setup.ts index 64b377c459..a6e7dd85ec 100644 --- a/packages/cli/src/migration/migrator/setup.ts +++ b/packages/cli/src/migration/migrator/setup.ts @@ -5,13 +5,17 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { globSync } from 'glob'; import semver from 'semver'; -import type { PackageManagerInfo } from '../../types/index.ts'; +import type { PackageManager } from '../../types/index.ts'; import { editJsonFile } from '../../utils/json.ts'; import { detectConfigs } from '../detector.ts'; import { type MigrationReport } from '../report.ts'; import { warnMigration } from './shared.ts'; -export function setPackageManager(projectDir: string, packageManagerInfo: PackageManagerInfo) { +export function setPackageManager( + projectDir: string, + packageManager: PackageManager, + packageManagerVersion: string, +) { // Set the package manager pin. Compatibility-first rule (rfcs/dev-engines.md): // an existing `packageManager` field or `devEngines.packageManager` declaration // is the source of truth and is left as-is; otherwise the exact resolved version @@ -32,8 +36,8 @@ export function setPackageManager(projectDir: string, packageManagerInfo: Packag pkg.devEngines = { ...devEngines, packageManager: { - name: packageManagerInfo.name, - version: packageManagerInfo.version, + name: packageManager, + version: packageManagerVersion, onFail: 'download', }, }; diff --git a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts index c8a3ad73a5..05d9b60ab0 100644 --- a/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts +++ b/packages/cli/src/migration/migrator/vite-plus-bootstrap.ts @@ -926,11 +926,11 @@ export function ensureVitePlusBootstrap( // when any package needs it. Each package's direct vitest dependency is // reconciled independently below. const usesVitest = workspaceUsesVitestDirectly(projectPath, workspaceInfo.packages, true); - const pnpmMajorVersion = pnpmMajor(workspaceInfo.packageManagerInfo.version); + const pnpmMajorVersion = pnpmMajor(workspaceInfo.packageManagerVersion); const shouldAllowBrowserBuilds = workspaceUsesWebdriverio(projectPath, workspaceInfo.packages); const usePnpmWorkspaceYaml = workspaceInfo.packageManager === PackageManager.pnpm && - pnpmSupportsWorkspaceSettings(workspaceInfo.packageManagerInfo.version); + pnpmSupportsWorkspaceSettings(workspaceInfo.packageManagerVersion); // Catalogs only resolve inside a bun WORKSPACE (a root package.json with a // non-empty `workspaces`). Gate both the catalog edges (`supportCatalog`) and // the `rewriteBunCatalog` sink below on this so a standalone bun project keeps @@ -945,7 +945,7 @@ export function ensureVitePlusBootstrap( !VITE_PLUS_VERSION.startsWith('file:') && supportsCatalog( workspaceInfo.packageManager, - workspaceInfo.packageManagerInfo.version, + workspaceInfo.packageManagerVersion, isBunWorkspace, ); const catalogDependencyResolver = createCatalogDependencyResolver( @@ -1206,7 +1206,7 @@ export function ensureVitePlusBootstrap( } const beforePackageManager = fs.readFileSync(packageJsonPath, 'utf-8'); - setPackageManager(projectPath, workspaceInfo.packageManagerInfo); + setPackageManager(projectPath, workspaceInfo.packageManager, workspaceInfo.packageManagerVersion); const afterPackageManager = fs.readFileSync(packageJsonPath, 'utf-8'); result.packageManagerField = beforePackageManager !== afterPackageManager; result.changed = result.packageJson || result.packageManagerConfig || result.packageManagerField; diff --git a/packages/cli/src/types/workspace.ts b/packages/cli/src/types/workspace.ts index d250de1fd9..55ac1fd38a 100644 --- a/packages/cli/src/types/workspace.ts +++ b/packages/cli/src/types/workspace.ts @@ -1,10 +1,5 @@ import type { PackageManager } from './package.ts'; -export interface PackageManagerInfo { - name: PackageManager; - version: string; -} - export interface WorkspacePackage { name: string; // The path of the package relative to the workspace root @@ -28,13 +23,9 @@ export interface WorkspaceInfo { parentDirs: string[]; packageManager: PackageManager; packageManagerVersion: string; - packageManagerInfo: PackageManagerInfo; packages: WorkspacePackage[]; } -export interface WorkspaceInfoOptional extends Omit< - WorkspaceInfo, - 'packageManager' | 'packageManagerInfo' -> { +export interface WorkspaceInfoOptional extends Omit { packageManager?: PackageManager; } diff --git a/packages/cli/src/utils/__tests__/workspace.spec.ts b/packages/cli/src/utils/__tests__/workspace.spec.ts index 0a3f4ba280..2369764a71 100644 --- a/packages/cli/src/utils/__tests__/workspace.spec.ts +++ b/packages/cli/src/utils/__tests__/workspace.spec.ts @@ -38,10 +38,6 @@ function makeWorkspaceInfo(overrides: Partial = {}): WorkspaceInf workspacePatterns: [], parentDirs: [], packages: [], - packageManagerInfo: { - name: PackageManager.pnpm, - version: 'latest', - }, ...overrides, }; } From 64cfc8beb8ad1da73625dde2f1b39b03614b2711 Mon Sep 17 00:00:00 2001 From: Liang Date: Mon, 10 Aug 2026 20:40:55 +0800 Subject: [PATCH 22/22] refactor(pm): remove obsolete package manager plumbing --- .../vp_global_cli/src/commands/env/doctor.rs | 4 +- crates/vp_global_cli/src/commands/env/exec.rs | 3 +- crates/vp_global_cli/src/commands/env/mod.rs | 2 +- crates/vp_global_cli/src/commands/env/pin.rs | 4 +- crates/vp_global_cli/src/error.rs | 4 - crates/vp_pm_cli/src/dispatch.rs | 23 +- crates/vp_pm_cli/src/helpers.rs | 24 +- crates/vp_pm_cli/src/lib.rs | 13 +- crates/vp_pm_cli/src/package_manager.rs | 264 ++++++++---------- crates/vp_setup/src/install.rs | 13 +- packages/cli/src/create/bin.ts | 4 +- .../src/migration/__tests__/migrator.spec.ts | 4 - packages/cli/src/migration/bin.ts | 43 +-- .../__tests__/package-manager-probe.spec.ts | 5 +- packages/cli/src/utils/prompts.ts | 2 +- 15 files changed, 176 insertions(+), 236 deletions(-) diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index bf4c02e8d2..dbdc188cd7 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -104,7 +104,7 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result bool { +fn check_path() -> bool { let bin_dir = match get_bin_dir() { Ok(d) => d, Err(_) => return false, diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index 7e18cf77e2..044aaf97b2 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -165,8 +165,7 @@ async fn execute_with_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?; + 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}")) } diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index 6ce3d6ec94..27c783c3a0 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -188,7 +188,7 @@ async fn print_env(cwd: AbsolutePathBuf, scope: Option) -> Result Result { +fn show_package_manager_pin(cwd: &AbsolutePathBuf) -> Result { match resolve_package_manager_from_package_json(cwd)? { Some(resolution) => { println!( diff --git a/crates/vp_global_cli/src/error.rs b/crates/vp_global_cli/src/error.rs index 1cd52d92aa..70b21f69f8 100644 --- a/crates/vp_global_cli/src/error.rs +++ b/crates/vp_global_cli/src/error.rs @@ -7,10 +7,6 @@ use vt_str::Str; /// Error type for the global CLI. #[derive(Debug, thiserror::Error)] pub enum Error { - #[allow(dead_code)] // Will be used for better error messages - #[error("No package manager detected. Please run in a project directory with a package.json.")] - NoPackageManager, - #[error("Failed to download Node.js runtime: {0}")] RuntimeDownload(#[from] vp_js_runtime::Error), diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index 4b06412b71..6cd58fdac8 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -50,10 +50,10 @@ pub async fn dispatch_with_metadata( let manager = match manager_policy(&command) { ManagerPolicy::CreateIfMissing => { ensure_package_json(cwd).await?; - build_package_manager(cwd).await? + build_package_manager(cwd)? } - ManagerPolicy::RequireProject => build_package_manager(cwd).await?, - ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, + ManagerPolicy::RequireProject => build_package_manager(cwd)?, + ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd)?, }; let manager = probe_package_manager(cwd, manager).await?; @@ -73,8 +73,7 @@ pub async fn dispatch_with_package_manager( PackageManagerCommand::Dlx(args) => { if let Some(package_manager) = package_manager { let manager = - probe_package_manager(cwd, build_selected_package_manager(package_manager)) - .await?; + probe_package_manager(cwd, selected_package_manager(package_manager)).await?; let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; return run_resolution(cwd, resolution, render_diagnostics).await; } @@ -87,18 +86,18 @@ pub async fn dispatch_with_package_manager( if manager_policy(&command) == ManagerPolicy::CreateIfMissing { ensure_package_json(cwd).await?; } - probe_package_manager(cwd, build_selected_package_manager(package_manager)).await? + probe_package_manager(cwd, selected_package_manager(package_manager)).await? } else { match manager_policy(&command) { ManagerPolicy::CreateIfMissing => { ensure_package_json(cwd).await?; - probe_package_manager(cwd, build_package_manager(cwd).await?).await? + probe_package_manager(cwd, build_package_manager(cwd)?).await? } ManagerPolicy::RequireProject => { - probe_package_manager(cwd, build_package_manager(cwd).await?).await? + probe_package_manager(cwd, build_package_manager(cwd)?).await? } ManagerPolicy::AllowNpmFallback => { - probe_package_manager(cwd, build_package_manager_or_npm_default(cwd).await?).await? + probe_package_manager(cwd, build_package_manager_or_npm_default(cwd)?).await? } } }; @@ -107,10 +106,10 @@ pub async fn dispatch_with_package_manager( run_resolution(cwd, resolution, render_diagnostics).await } -fn build_selected_package_manager( +fn selected_package_manager( (kind, version, source): (PackageManagerType, &str, &str), ) -> PackageManager { - PackageManager::from_version(kind, version, source) + PackageManager { client: kind, version: version.into(), source: source.into() } } async fn probe_package_manager( @@ -176,7 +175,7 @@ async fn dispatch_dlx( args: DlxArgs, render_diagnostics: bool, ) -> Result { - match PackageManager::builder(cwd).build_with_default().await { + match PackageManager::builder(cwd).build_with_default() { Ok(manager) => { let manager = probe_package_manager(cwd, manager).await?; let package_manager = manager.client; diff --git a/crates/vp_pm_cli/src/helpers.rs b/crates/vp_pm_cli/src/helpers.rs index 0a701a21bc..6ee87a38f6 100644 --- a/crates/vp_pm_cli/src/helpers.rs +++ b/crates/vp_pm_cli/src/helpers.rs @@ -6,8 +6,8 @@ use crate::{PackageManager, PackageManagerType, error::Error}; /// Build a `PackageManager`, converting `PackageJsonNotFound` into a /// friendly error message. -pub async fn build_package_manager(cwd: &AbsolutePath) -> Result { - match PackageManager::builder(cwd).build_with_default().await { +pub fn build_package_manager(cwd: &AbsolutePath) -> Result { + match PackageManager::builder(cwd).build_with_default() { Ok(pm) => Ok(pm), Err(vp_error::Error::WorkspaceError(vt_workspace::Error::PackageJsonNotFound(_))) => { Err(Error::UserMessage("No package.json found.".into())) @@ -23,25 +23,19 @@ pub async fn build_package_manager(cwd: &AbsolutePath) -> Result Result { - match PackageManager::builder(cwd).build().await { +pub fn build_package_manager_or_npm_default(cwd: &AbsolutePath) -> Result { + match PackageManager::builder(cwd).build() { Ok(pm) => Ok(pm), Err(vp_error::Error::WorkspaceError(vt_workspace::Error::PackageJsonNotFound(_))) - | Err(vp_error::Error::UnrecognizedPackageManager) => Ok(default_npm_package_manager(cwd)), + | Err(vp_error::Error::UnrecognizedPackageManager) => Ok(PackageManager { + client: PackageManagerType::Npm, + version: "latest".into(), + source: "default".into(), + }), Err(e) => Err(Error::Install(e)), } } -pub(crate) fn default_npm_package_manager(_cwd: &AbsolutePath) -> PackageManager { - PackageManager { - client: PackageManagerType::Npm, - version: "latest".into(), - source: "default".into(), - } -} - /// Ensure a package.json exists in the given directory. /// If it doesn't exist, create a minimal one with `{ "type": "module" }`. pub async fn ensure_package_json(project_path: &AbsolutePath) -> Result<(), Error> { diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index b325cc81a6..914e645fd8 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -23,13 +23,14 @@ pub use dispatch::{ DispatchResult, dispatch, dispatch_with_metadata, dispatch_with_package_manager, }; pub use error::Error; +pub(crate) use package_manager::PackageManager; pub use package_manager::{ - 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, + EnvironmentPackageManagerResolution, PackageManagerResolution, PackageManagerSource, + PackageManagerType, download_package_manager, ensure_package_manager_bin, + fetch_package_manager_versions, get_package_manager_type_and_version, package_manager_bin_path, + package_manager_install_dir, resolve_environment_package_manager, + resolve_environment_package_manager_spec, resolve_package_manager_from_package_json, + resolve_package_manager_version, }; pub use request::HttpClient; pub use resolution::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index f918689891..af22dadcf6 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -168,33 +168,33 @@ impl PackageManagerSource { /// Use `PackageManager::builder()` to create a package manager. /// Command argument resolution and execution live in `vp_pm_cli`. #[derive(Debug)] -pub struct PackageManager { +pub(crate) struct PackageManager { pub(crate) client: PackageManagerType, pub(crate) version: Str, pub(crate) source: Str, } #[derive(Debug)] -pub struct PackageManagerBuilder { +pub(crate) struct PackageManagerBuilder { client_override: Option, cwd: AbsolutePathBuf, } impl PackageManagerBuilder { - pub fn new(cwd: impl AsRef) -> Self { + fn new(cwd: impl AsRef) -> Self { Self { client_override: None, cwd: cwd.as_ref().to_absolute_path_buf() } } #[must_use] - pub const fn package_manager_type(mut self, package_manager_type: PackageManagerType) -> Self { + const fn package_manager_type(mut self, package_manager_type: PackageManagerType) -> Self { self.client_override = Some(package_manager_type); self } /// Build the package manager. /// Detect the package manager from the current working directory. - pub async fn build(&self) -> Result { - let (workspace_root, _cwd) = find_workspace_root(&self.cwd)?; + pub(crate) fn build(&self) -> Result { + let (workspace_root, _) = find_workspace_root(&self.cwd)?; let (package_manager_type, version, _hash, source) = get_package_manager_type_and_version(&workspace_root, self.client_override)?; Ok(PackageManager { @@ -206,13 +206,13 @@ impl PackageManagerBuilder { /// Build the package manager with default package manager. /// If the package manager is not specified, prompt the user to select a package manager. - pub async fn build_with_default(&self) -> Result { - let package_manager = match self.build().await { + pub(crate) fn build_with_default(&self) -> Result { + let package_manager = match self.build() { Ok(pm) => pm, Err(Error::UnrecognizedPackageManager) => { // Prompt user to select a package manager let selected_type = prompt_package_manager_selection()?; - Self::new(&self.cwd).package_manager_type(selected_type).build().await? + Self::new(&self.cwd).package_manager_type(selected_type).build()? } Err(e) => return Err(e), }; @@ -221,31 +221,9 @@ impl PackageManagerBuilder { } impl PackageManager { - pub fn builder(cwd: impl AsRef) -> PackageManagerBuilder { + pub(crate) fn builder(cwd: impl AsRef) -> PackageManagerBuilder { PackageManagerBuilder::new(cwd) } - - #[must_use] - pub fn from_version( - client: PackageManagerType, - version: impl Into, - source: impl Into, - ) -> Self { - Self { client, version: version.into(), source: source.into() } - } -} - -impl PackageManagerSource { - #[must_use] - pub const fn description(self) -> &'static str { - match self { - Self::ExplicitOverride => "explicit override", - Self::PackageManagerField => "packageManager", - Self::DevEnginesPackageManager => "devEngines.packageManager", - Self::LockfileOrConfig => "lockfile or config", - Self::Default => "default", - } - } } /// Get the package manager name, version, optional hash, and detection source @@ -538,7 +516,7 @@ pub async fn ensure_package_manager_bin( } } - let (install_dir, _, _) = + let install_dir = download_package_manager(package_manager_type, &version, expected_hash).await?; Ok(package_manager_bin_path(&install_dir, bin_name)) } @@ -1034,21 +1012,19 @@ async fn resolve_package_manager_range( resolve_latest_satisfying_version(package_manager_type, &range, version_req).await } -/// Download the package manager and extract it to the vite-plus home directory. -/// Return the install directory, e.g. `$VP_HOME/package_manager/pnpm/10.0.0/pnpm` +/// Download an exact package-manager version and extract it to the Vite+ home directory. +/// Returns the install directory, e.g. `$VP_HOME/package_manager/pnpm/10.0.0/pnpm`. pub async fn download_package_manager( package_manager_type: PackageManagerType, - version_or_latest: &str, + version: &str, expected_hash: Option<&str>, -) -> Result<(AbsolutePathBuf, Str, Str), Error> { - let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; - +) -> Result { // Reject anything that is not strict semver `major.minor.patch[-prerelease][+build]`. // This prevents path traversal via the version being interpolated into // `$VP_HOME/package_manager/{name}/{version}` below, since `AbsolutePath::join` // does not normalize `..` components. Also guards against registry-controlled // "latest" lookups returning a malicious value. - let parsed_version = Version::parse(&version).map_err(|_| { + let parsed_version = Version::parse(version).map_err(|_| { Error::InvalidArgument( format!( "invalid {package_manager_type} version {version:?}: expected semver 'major.minor.patch'" @@ -1056,6 +1032,7 @@ pub async fn download_package_manager( .into(), ) })?; + let version = Str::from(version); let mut package_name: Str = package_manager_type.to_string().into(); // handle yarn >= 2.0.0 to use `@yarnpkg/cli-dist` as package name @@ -1093,7 +1070,7 @@ pub async fn download_package_manager( // If all shims already exist, return the target directory // $VP_HOME/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) if is_package_manager_install_complete(&install_dir, &bin_name)? { - return Ok((install_dir, package_name, version)); + return Ok(install_dir); } // $VP_HOME/package_manager/pnpm/{tmp_name} @@ -1151,7 +1128,7 @@ pub async fn download_package_manager( // the install is all-or-nothing) if is_package_manager_install_complete(&install_dir, &bin_name)? { tracing::debug!("install already complete after lock acquisition, skip rename"); - return Ok((install_dir, package_name, version)); + return Ok(install_dir); } // rename $target_dir_tmp to $target_dir @@ -1163,7 +1140,7 @@ pub async fn download_package_manager( tracing::debug!("Create shim files for {}", bin_name); create_shim_files(package_manager_type, &install_dir.join("bin")).await?; - Ok((install_dir, package_name, version)) + Ok(install_dir) } /// Open a lock file without truncating it. This is required on Windows @@ -1219,9 +1196,7 @@ fn bun_requires_baseline() -> bool { async fn download_bun_package_manager( version: &Str, home_dir: &AbsolutePath, -) -> Result<(AbsolutePathBuf, Str, Str), Error> { - let package_name: Str = "bun".into(); - +) -> Result { // $VP_HOME/package_manager/bun/{version} let target_dir = home_dir.join("package_manager").join("bun").join(version.as_str()); let install_dir = target_dir.join("bun"); @@ -1229,7 +1204,7 @@ async fn download_bun_package_manager( // If shims already exist, return early (same completeness check as the cache // and the tgz download path) if is_package_manager_install_complete(&install_dir, "bun")? { - return Ok((install_dir, package_name, version.clone())); + return Ok(install_dir); } // Temporary by design: select a CPU-compatible package for new downloads without replacing existing installs. @@ -1309,7 +1284,7 @@ async fn download_bun_package_manager( if is_package_manager_install_complete(&install_dir, "bun")? { tracing::debug!("bun install already complete after lock acquisition, skip rename"); - return Ok((install_dir, package_name, version.clone())); + return Ok(install_dir); } // Rename temp dir to final location @@ -1321,7 +1296,7 @@ async fn download_bun_package_manager( tracing::debug!("Create shim files for bun"); create_shim_files(PackageManagerType::Bun, &install_dir.join("bin")).await?; - Ok((install_dir, package_name, version.clone())) + Ok(install_dir) } /// Platform-specific `@pnpm/exe.{os}-{arch}` package name for pnpm >= 12. @@ -1401,8 +1376,7 @@ async fn download_pnpm_native_package_manager( version: &Str, home_dir: &AbsolutePath, expected_hash: Option<&str>, -) -> Result<(AbsolutePathBuf, Str, Str), Error> { - let package_name: Str = "pnpm".into(); +) -> Result { let platform_package_name = get_pnpm_platform_package_name()?; // $VP_HOME/package_manager/pnpm/{version} @@ -1412,7 +1386,7 @@ async fn download_pnpm_native_package_manager( // If shims already exist, return early (same completeness check as the cache // and the tgz download path) if is_package_manager_install_complete(&install_dir, "pnpm")? { - return Ok((install_dir, package_name, version.clone())); + return Ok(install_dir); } // A `packageManager` hash describes the main `pnpm` tarball, not the @@ -1503,7 +1477,7 @@ async fn download_pnpm_native_package_manager( if is_package_manager_install_complete(&install_dir, "pnpm")? { tracing::debug!("pnpm install already complete after lock acquisition, skip rename"); - return Ok((install_dir, package_name, version.clone())); + return Ok(install_dir); } // Rename temp dir to final location @@ -1515,7 +1489,7 @@ async fn download_pnpm_native_package_manager( tracing::debug!("Create native shim files for pnpm"); create_pnpm_native_shim_files(&install_dir.join("bin")).await?; - Ok((install_dir, package_name, version.clone())) + Ok(install_dir) } /// Create shims for pnpm's native binary (pnpm >= 12): `pnpm` execs it @@ -2484,22 +2458,20 @@ mod tests { assert!(matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_))); } - #[tokio::test] - async fn test_detect_package_manager_with_pnpm_workspace_yaml() { + #[test] + fn test_detect_package_manager_with_pnpm_workspace_yaml() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let workspace_content = "packages:\n - 'packages/*'"; create_pnpm_workspace_yaml(&temp_dir_path, workspace_content); - let result = PackageManager::builder(temp_dir_path.clone()) - .build() - .await - .expect("Should detect pnpm"); + let result = + PackageManager::builder(temp_dir_path.clone()).build().expect("Should detect pnpm"); assert_eq!(result.client.to_string(), "pnpm"); } - #[tokio::test] - async fn test_detect_package_manager_with_pnpm_lock_yaml() { + #[test] + fn test_detect_package_manager_with_pnpm_lock_yaml() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "version": "1.0.0"}"#; @@ -2509,10 +2481,8 @@ mod tests { fs::write(temp_dir_path.join("pnpm-lock.yaml"), "lockfileVersion: '6.0'") .expect("Failed to write pnpm-lock.yaml"); - let result = PackageManager::builder(temp_dir_path.clone()) - .build() - .await - .expect("Should detect pnpm"); + let result = + PackageManager::builder(temp_dir_path.clone()).build().expect("Should detect pnpm"); assert_eq!(result.client.to_string(), "pnpm"); assert_eq!(result.version, "latest"); assert_eq!( @@ -2521,8 +2491,8 @@ mod tests { ); } - #[tokio::test] - async fn test_detect_package_manager_with_yarn_lock() { + #[test] + fn test_detect_package_manager_with_yarn_lock() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -2534,7 +2504,6 @@ mod tests { let result = PackageManager::builder(temp_dir_path.to_absolute_path_buf()) .build() - .await .expect("Should detect yarn"); assert_eq!(result.client.to_string(), "yarn"); assert_eq!(result.version, "latest"); @@ -2544,8 +2513,8 @@ mod tests { ); } - #[tokio::test] - async fn test_detect_package_manager_with_package_lock_json() { + #[test] + fn test_detect_package_manager_with_package_lock_json() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -2555,8 +2524,7 @@ mod tests { fs::write(temp_dir_path.join("package-lock.json"), r#"{"lockfileVersion": 2}"#) .expect("Failed to write package-lock.json"); - let result = - PackageManager::builder(temp_dir_path).build().await.expect("Should detect npm"); + let result = PackageManager::builder(temp_dir_path).build().expect("Should detect npm"); assert_eq!(result.client.to_string(), "npm"); assert_eq!(result.version, "latest"); assert_eq!( @@ -2565,8 +2533,8 @@ mod tests { ); } - #[tokio::test] - async fn test_detect_package_manager_with_package_manager_field() { + #[test] + fn test_detect_package_manager_with_package_manager_field() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "pnpm@8.15.0"}"#; @@ -2574,7 +2542,6 @@ mod tests { let result = PackageManager::builder(temp_dir_path) .build() - .await .expect("Should detect pnpm with version"); assert_eq!(result.client.to_string(), "pnpm"); assert_eq!(result.version, "8.15.0"); @@ -2990,8 +2957,8 @@ mod tests { assert!(dev_engines_package_manager_conflict_message(&field, &resolution).is_none()); } - #[tokio::test] - async fn test_detect_package_manager_field_priority_over_dev_engines() { + #[test] + fn test_detect_package_manager_field_priority_over_dev_engines() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{ @@ -3103,9 +3070,7 @@ mod tests { ) .await .expect("Should download yarn with the matching hash"); - let (target_dir, package_name, version) = result; - assert_eq!(package_name, "yarn"); - assert_eq!(version, "1.22.22"); + let target_dir = result; // check shim files let bin_prefix = target_dir.join("bin"); @@ -3145,14 +3110,14 @@ mod tests { #[tokio::test] async fn test_download_success_package_manager_with_sha1_and_sha224() { - let (yarn_dir, _, _) = download_package_manager( + let yarn_dir = download_package_manager( PackageManagerType::Yarn, "1.22.20", Some("sha1.167c8ab8d9c8c3826d3725d9579aaea8b47a2b18"), ) .await .expect("Should download yarn with a matching sha1 hash"); - let (pnpm_dir, _, _) = download_package_manager( + let pnpm_dir = download_package_manager( PackageManagerType::Pnpm, "4.11.6", Some("sha224.7783c4b01916b7a69e6ff05d328df6f83cb7f127e9c96be88739386d"), @@ -3163,8 +3128,8 @@ mod tests { remove_dir_all_force(pnpm_dir).await.unwrap(); } - #[tokio::test] - async fn test_detect_package_manager_with_yarn_package_manager_field() { + #[test] + fn test_detect_package_manager_with_yarn_package_manager_field() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "yarn@4.0.0"}"#; @@ -3172,7 +3137,6 @@ mod tests { let result = PackageManager::builder(temp_dir_path.clone()) .build() - .await .expect("Should detect yarn with version"); assert_eq!(result.client.to_string(), "yarn"); assert_eq!(result.version, "4.0.0"); @@ -3183,28 +3147,26 @@ mod tests { ); } - #[tokio::test] - async fn test_detect_package_manager_with_npm_package_manager_field() { + #[test] + fn test_detect_package_manager_with_npm_package_manager_field() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "npm@10.0.0"}"#; create_package_json(&temp_dir_path, package_content); - let result = PackageManager::builder(temp_dir_path) - .build() - .await - .expect("Should detect npm with version"); + let result = + PackageManager::builder(temp_dir_path).build().expect("Should detect npm with version"); assert_eq!(result.client.to_string(), "npm"); } - #[tokio::test] - async fn test_detect_package_manager_with_invalid_package_manager_field() { + #[test] + fn test_detect_package_manager_with_invalid_package_manager_field() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "invalid@1.0.0"}"#; create_package_json(&temp_dir_path, package_content); - let result = PackageManager::builder(temp_dir_path).build().await; + let result = PackageManager::builder(temp_dir_path).build(); assert!(result.is_err()); // Check if it's the expected error type if let Err(Error::UnsupportedPackageManager(name)) = result { @@ -3214,34 +3176,34 @@ mod tests { } } - #[tokio::test] - async fn test_detect_package_manager_with_not_exists_version_in_package_manager_field() { + #[test] + fn test_detect_package_manager_with_not_exists_version_in_package_manager_field() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "yarn@10000000000.0.0"}"#; create_package_json(&temp_dir_path, package_content); - let result = PackageManager::builder(temp_dir_path).build().await.unwrap(); + let result = PackageManager::builder(temp_dir_path).build().unwrap(); assert_eq!(result.client, PackageManagerType::Yarn); assert_eq!(result.version, "10000000000.0.0"); } - #[tokio::test] - async fn test_detect_package_manager_with_invalid_semver() { + #[test] + fn test_detect_package_manager_with_invalid_semver() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "pnpm@invalid-version"}"#; create_package_json(&temp_dir_path, package_content); - let result = PackageManager::builder(temp_dir_path).build().await; + let result = PackageManager::builder(temp_dir_path).build(); println!("result: {result:?}"); assert!(result.is_err()); } - #[tokio::test] - async fn test_detect_package_manager_with_default_fallback() { + #[test] + fn test_detect_package_manager_with_default_fallback() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -3250,7 +3212,6 @@ mod tests { let result = PackageManager::builder(temp_dir_path.clone()) .package_manager_type(PackageManagerType::Yarn) .build() - .await .expect("Should use default"); assert_eq!(result.client.to_string(), "yarn"); assert_eq!(result.version, "latest"); @@ -3260,14 +3221,14 @@ mod tests { ); } - #[tokio::test] - async fn test_detect_package_manager_without_any_indicators() { + #[test] + fn test_detect_package_manager_without_any_indicators() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; create_package_json(&temp_dir_path, package_content); - let result = PackageManager::builder(temp_dir_path).build().await; + let result = PackageManager::builder(temp_dir_path).build(); assert!(result.is_err()); // Check if it's the expected error type if matches!(result, Err(Error::UnrecognizedPackageManager)) { @@ -3277,8 +3238,8 @@ mod tests { } } - #[tokio::test] - async fn test_detect_package_manager_prioritizes_package_manager_field_over_lock_files() { + #[test] + fn test_detect_package_manager_prioritizes_package_manager_field_over_lock_files() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "yarn@4.0.0"}"#; @@ -3290,13 +3251,12 @@ mod tests { let result = PackageManager::builder(temp_dir_path) .build() - .await .expect("Should detect yarn from packageManager field"); assert_eq!(result.client.to_string(), "yarn"); } - #[tokio::test] - async fn test_detect_package_manager_prioritizes_pnpm_workspace_over_lock_files() { + #[test] + fn test_detect_package_manager_prioritizes_pnpm_workspace_over_lock_files() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -3312,13 +3272,12 @@ mod tests { let result = PackageManager::builder(temp_dir_path) .build() - .await .expect("Should detect pnpm from workspace file"); assert_eq!(result.client.to_string(), "pnpm"); } - #[tokio::test] - async fn test_detect_package_manager_from_subdirectory() { + #[test] + fn test_detect_package_manager_from_subdirectory() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let workspace_content = "packages:\n - 'packages/*'"; @@ -3329,18 +3288,25 @@ mod tests { let result = PackageManager::builder(sub_dir) .build() - .await .expect("Should detect pnpm from parent workspace"); assert_eq!(result.client.to_string(), "pnpm"); assert_eq!(result.version, "latest"); } #[tokio::test] - async fn test_download_package_manager_rejects_path_traversal_version() { - // Versions containing path separators or traversal components must be - // rejected before any filesystem operations: `AbsolutePath::join` does - // not normalize `..`, so a bad version would escape the home dir. - for bad in ["../../../escape", "..", "1.0.0/../../escape", "/foo/bar", "1.0.0\0", ""] { + async fn test_download_package_manager_requires_exact_safe_version() { + // Selectors and versions containing traversal components must be rejected + // before any filesystem operations. + for bad in [ + "latest", + "^10.0.0", + "../../../escape", + "..", + "1.0.0/../../escape", + "/foo/bar", + "1.0.0\0", + "", + ] { let result = download_package_manager(PackageManagerType::Pnpm, bad, None).await; match result { Err(Error::InvalidArgument(_)) => {} @@ -3357,21 +3323,17 @@ mod tests { async fn test_download_package_manager() { let result = download_package_manager(PackageManagerType::Yarn, "4.9.2", None).await; assert!(result.is_ok()); - let (target_dir, package_name, version) = result.unwrap(); + let target_dir = result.unwrap(); println!("result: {target_dir:?}"); assert!(is_exists_file(target_dir.join("bin/yarn")).unwrap()); assert!(is_exists_file(target_dir.join("bin/yarn.cmd")).unwrap()); - assert_eq!(package_name, "@yarnpkg/cli-dist"); - assert_eq!(version, "4.9.2"); // again should skip download let result = download_package_manager(PackageManagerType::Yarn, "4.9.2", None).await; assert!(result.is_ok()); - let (target_dir, package_name, version) = result.unwrap(); + let target_dir = result.unwrap(); assert!(is_exists_file(target_dir.join("bin/yarn")).unwrap()); assert!(is_exists_file(target_dir.join("bin/yarn.cmd")).unwrap()); - assert_eq!(package_name, "@yarnpkg/cli-dist"); - assert_eq!(version, "4.9.2"); remove_dir_all_force(target_dir).await.unwrap(); } @@ -3380,15 +3342,13 @@ mod tests { let result = download_package_manager(PackageManagerType::Pnpm, "12.0.0-beta.0", None).await; assert!(result.is_ok(), "{result:?}"); - let (target_dir, package_name, version) = result.unwrap(); + let target_dir = result.unwrap(); // native binary plus pnpm/pnpx shims, no JS entrypoint let native_name = if cfg!(windows) { "bin/pnpm.native.exe" } else { "bin/pnpm.native" }; assert!(is_exists_file(target_dir.join(native_name)).unwrap()); assert!(is_exists_file(target_dir.join("bin/pnpm")).unwrap()); assert!(is_exists_file(target_dir.join("bin/pnpm.cmd")).unwrap()); assert!(is_exists_file(target_dir.join("bin/pnpx")).unwrap()); - assert_eq!(package_name, "pnpm"); - assert_eq!(version, "12.0.0-beta.0"); // again should hit the completeness fast-path and skip download let result = @@ -3411,8 +3371,8 @@ mod tests { assert!(version_req.matches(&Version::parse(&version).unwrap())); } - #[tokio::test] - async fn test_detect_package_manager_with_yarnrc_yml() { + #[test] + fn test_detect_package_manager_with_yarnrc_yml() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -3427,7 +3387,6 @@ mod tests { let result = PackageManager::builder(temp_dir_path.clone()) .build() - .await .expect("Should detect yarn from .yarnrc.yml"); assert_eq!(result.client.to_string(), "yarn"); assert_eq!(result.version, "latest"); @@ -3437,8 +3396,8 @@ mod tests { ); } - #[tokio::test] - async fn test_detect_package_manager_with_pnpmfile_cjs() { + #[test] + fn test_detect_package_manager_with_pnpmfile_cjs() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -3450,7 +3409,6 @@ mod tests { let result = PackageManager::builder(temp_dir_path.clone()) .build() - .await .expect("Should detect pnpm from pnpmfile.cjs"); assert_eq!(result.client.to_string(), "pnpm"); assert_eq!(result.version, "latest"); @@ -3460,8 +3418,8 @@ mod tests { ); } - #[tokio::test] - async fn test_detect_package_manager_with_yarn_config_cjs() { + #[test] + fn test_detect_package_manager_with_yarn_config_cjs() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -3476,7 +3434,6 @@ mod tests { let result = PackageManager::builder(temp_dir_path.clone()) .build() - .await .expect("Should detect yarn from yarn.config.cjs"); assert_eq!(result.client.to_string(), "yarn"); assert_eq!(result.version, "latest"); @@ -3526,8 +3483,8 @@ mod tests { assert_eq!(source, PackageManagerSource::LockfileOrConfig); } - #[tokio::test] - async fn test_detect_package_manager_pnpmfile_over_yarn_config() { + #[test] + fn test_detect_package_manager_pnpmfile_over_yarn_config() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -3546,7 +3503,6 @@ mod tests { // pnpmfile.cjs should be detected first (before yarn.config.cjs) let result = PackageManager::builder(temp_dir_path) .build() - .await .expect("Should detect pnpm from pnpmfile.cjs"); assert_eq!( result.client.to_string(), @@ -3555,8 +3511,8 @@ mod tests { ); } // Tests for bun package manager detection - #[tokio::test] - async fn test_detect_package_manager_with_bun_lock() { + #[test] + fn test_detect_package_manager_with_bun_lock() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -3575,8 +3531,8 @@ mod tests { assert!(hash.is_none()); } - #[tokio::test] - async fn test_detect_package_manager_with_bun_lockb() { + #[test] + fn test_detect_package_manager_with_bun_lockb() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -3595,8 +3551,8 @@ mod tests { assert!(hash.is_none()); } - #[tokio::test] - async fn test_detect_package_manager_with_bunfig_toml() { + #[test] + fn test_detect_package_manager_with_bunfig_toml() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; @@ -3615,8 +3571,8 @@ mod tests { assert!(hash.is_none()); } - #[tokio::test] - async fn test_detect_package_manager_with_package_manager_field_bun() { + #[test] + fn test_detect_package_manager_with_package_manager_field_bun() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package", "packageManager": "bun@1.2.0"}"#; @@ -3632,8 +3588,8 @@ mod tests { assert!(hash.is_none()); } - #[tokio::test] - async fn test_detect_package_manager_with_package_manager_field_bun_with_hash() { + #[test] + fn test_detect_package_manager_with_package_manager_field_bun_with_hash() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = @@ -3650,8 +3606,8 @@ mod tests { assert_eq!(hash.unwrap().as_str(), "sha512.abc123"); } - #[tokio::test] - async fn test_detect_package_manager_bun_lock_priority_over_pnpmfile() { + #[test] + fn test_detect_package_manager_bun_lock_priority_over_pnpmfile() { let temp_dir = create_temp_dir(); let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let package_content = r#"{"name": "test-package"}"#; diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index af5aa48aa8..5386a8457d 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -270,14 +270,11 @@ pub async fn install_production_deps( download_runtime(JsRuntimeType::Node, &node_version).await.map_err(|error| { Error::Setup(format!("Failed to install Node.js {node_version}: {error}").into()) })?; - let (pnpm_dir, _, _) = - download_package_manager(PackageManagerType::Pnpm, PINNED_PNPM_VERSION, None) - .await - .map_err(|error| { - Error::Setup( - format!("Failed to install pnpm {PINNED_PNPM_VERSION}: {error}").into(), - ) - })?; + let pnpm_dir = download_package_manager(PackageManagerType::Pnpm, PINNED_PNPM_VERSION, None) + .await + .map_err(|error| { + Error::Setup(format!("Failed to install pnpm {PINNED_PNPM_VERSION}: {error}").into()) + })?; let pnpm_entry = pnpm_dir.join("bin").join("pnpm.cjs"); if !tokio::fs::try_exists(&pnpm_entry).await.unwrap_or(false) { return Err(Error::Setup( diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 7025b70ae7..34b1e9677e 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -822,7 +822,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h (await selectPackageManager(options.interactive, compactOutput)); const shouldSilencePackageManagerProbe = compactOutput || (isMonorepo && workspaceInfoOptional.packageManager !== undefined); - const packageManagerInfo = await probePackageManager( + const packageManagerVersion = await probePackageManager( packageManager, workspaceInfoOptional.rootDir, options.interactive, @@ -831,7 +831,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h const workspaceInfo: WorkspaceInfo = { ...workspaceInfoOptional, packageManager, - packageManagerVersion: packageManagerInfo.version, + packageManagerVersion, }; const existingAgentTargetPaths = diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 73e0c4fe2e..9452c497bb 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -3797,7 +3797,6 @@ describe('ensureVitePlusBootstrap', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '10.6.1'; - workspaceInfo.packageManagerVersion = '10.6.1'; ensureVitePlusBootstrap(workspaceInfo); @@ -4617,7 +4616,6 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '9.15.9'; - workspaceInfo.packageManagerVersion = '9.15.9'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); // package.json emits catalog: specs (this part already works today). @@ -7295,7 +7293,6 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '9.15.0'; - workspaceInfo.packageManagerVersion = '9.15.0'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { @@ -7316,7 +7313,6 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => { ); const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); workspaceInfo.packageManagerVersion = '9.15.0'; - workspaceInfo.packageManagerVersion = '9.15.0'; rewriteStandaloneProject(tmpDir, workspaceInfo, true, true); const pnpm = (readJson(path.join(tmpDir, 'package.json')).pnpm ?? {}) as { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 6646eea3d6..f4cb92aa9f 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -731,40 +731,45 @@ async function probeSupportedPackageManager(options: { interactive: boolean; updateMigrationProgress: (message: string) => void; failMigrationProgress: (message: string) => void; -}): Promise>> { +}): Promise { const { rootDir, packageManager, interactive, updateMigrationProgress, failMigrationProgress } = options; updateMigrationProgress('Preparing migration'); - const packageManagerInfo = await probePackageManager(packageManager, rootDir, interactive, true); + const packageManagerVersion = await probePackageManager( + packageManager, + rootDir, + interactive, + true, + ); if ( packageManager === PackageManager.yarn && - semver.satisfies(packageManagerInfo.version, '>=4.0.0 <4.10.0') + semver.satisfies(packageManagerVersion, '>=4.0.0 <4.10.0') ) { updateMigrationProgress('Upgrading Yarn'); await upgradeYarn(rootDir, interactive, true); } else if ( packageManager === PackageManager.pnpm && - semver.satisfies(packageManagerInfo.version, '< 9.5.0') + semver.satisfies(packageManagerVersion, '< 9.5.0') ) { failMigrationProgress('Migration failed'); prompts.log.error( - `✘ pnpm@${packageManagerInfo.version} is not supported by auto migration, please upgrade pnpm to >=9.5.0 first`, + `✘ pnpm@${packageManagerVersion} is not supported by auto migration, please upgrade pnpm to >=9.5.0 first`, ); cancelAndExit('Vite+ cannot automatically migrate this project yet.', 1); } else if ( packageManager === PackageManager.npm && - semver.satisfies(packageManagerInfo.version, '< 8.3.0') + semver.satisfies(packageManagerVersion, '< 8.3.0') ) { failMigrationProgress('Migration failed'); prompts.log.error( - `✘ npm@${packageManagerInfo.version} is not supported by auto migration, please upgrade npm to >=8.3.0 first`, + `✘ npm@${packageManagerVersion} is not supported by auto migration, please upgrade npm to >=8.3.0 first`, ); cancelAndExit('Vite+ cannot automatically migrate this project yet.', 1); } - return packageManagerInfo; + return packageManagerVersion; } async function executeMigrationPlan( @@ -806,7 +811,7 @@ async function executeMigrationPlan( }; // 1. Probe package manager + version validation - const packageManagerInfo = await probeSupportedPackageManager({ + const packageManagerVersion = await probeSupportedPackageManager({ rootDir: workspaceInfoOptional.rootDir, packageManager: plan.packageManager, interactive, @@ -816,7 +821,7 @@ async function executeMigrationPlan( const workspaceInfo: WorkspaceInfo = { ...workspaceInfoOptional, packageManager: plan.packageManager, - packageManagerVersion: packageManagerInfo.version, + packageManagerVersion, }; if (plan.convertYarnPnp) { @@ -1014,7 +1019,7 @@ async function executeMigrationPlan( return { installDurationMs: initialInstallDurationMs + finalInstallDurationMs, finalInstallOk: finalInstallSummary.status === 'installed', - packageManagerVersion: packageManagerInfo.version, + packageManagerVersion, report, }; } @@ -1095,21 +1100,21 @@ async function main() { ? (workspaceInfoOptional.packageManager ?? (await selectPackageManager(options.interactive, true))) : workspaceInfoOptional.packageManager; - let packageManagerInfo: Awaited> | undefined; + let probedPackageManagerVersion: string | undefined; let packageManagerVersion = workspaceInfoOptional.packageManagerVersion; const probeExistingPackageManager = async () => { if (!packageManager) { return undefined; } - packageManagerInfo ??= await probeSupportedPackageManager({ + probedPackageManagerVersion ??= await probeSupportedPackageManager({ rootDir: workspaceInfoOptional.rootDir, packageManager, interactive: options.interactive, updateMigrationProgress, failMigrationProgress, }); - packageManagerVersion = packageManagerInfo.version; - return packageManagerInfo; + packageManagerVersion = probedPackageManagerVersion; + return probedPackageManagerVersion; }; const ensureExistingPackageManager = async () => { packageManager ??= await selectPackageManager(options.interactive, true); @@ -1223,14 +1228,14 @@ async function main() { let needsInstall = false; if (vitePlusBootstrapPending) { - const packageManagerInfo = await ensureExistingPackageManager(); - if (packageManagerInfo && packageManager) { + const probedVersion = await ensureExistingPackageManager(); + if (probedVersion && packageManager) { updateMigrationProgress('Configuring package manager'); const bootstrapResult = ensureVitePlusBootstrap( { ...workspaceInfoOptional, packageManager, - packageManagerVersion: packageManagerInfo.version, + packageManagerVersion: probedVersion, }, report, ); @@ -1402,7 +1407,7 @@ async function main() { if (needsInstall) { const resolved = await ensureExistingPackageManager(); updateMigrationProgress('Installing dependencies'); - const resolvedVersion = resolved?.version ?? packageManagerVersion; + const resolvedVersion = resolved ?? packageManagerVersion; const npmReinstallPreparation = packageManager === PackageManager.npm ? prepareNpmViteAliasReinstall( diff --git a/packages/cli/src/utils/__tests__/package-manager-probe.spec.ts b/packages/cli/src/utils/__tests__/package-manager-probe.spec.ts index e8779bd7c0..192308d620 100644 --- a/packages/cli/src/utils/__tests__/package-manager-probe.spec.ts +++ b/packages/cli/src/utils/__tests__/package-manager-probe.spec.ts @@ -24,10 +24,7 @@ describe('probePackageManager', () => { await expect( probePackageManager(PackageManager.pnpm, '/workspace', false, true), - ).resolves.toEqual({ - name: PackageManager.pnpm, - version: '10.18.2', - }); + ).resolves.toBe('10.18.2'); expect(mockRunCommandSilently).toHaveBeenCalledWith({ command: PackageManager.pnpm, args: ['--version'], diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 4c7095d7f8..c41284d382 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -100,7 +100,7 @@ export async function probePackageManager( ); } spinner.stop(`Using ${packageManager}@${version}`); - return { name: packageManager, version }; + return version; } export async function runViteInstall(