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_mismatched_package_managers/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/package.json new file mode 100644 index 0000000000..d4fc085cea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/package.json @@ -0,0 +1,5 @@ +{ + "name": "shim-mismatched-package-managers", + "private": true, + "packageManager": "pnpm@11.20.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml new file mode 100644 index 0000000000..88dbf0842e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots.toml @@ -0,0 +1,7 @@ +[[case]] +name = "shim_mismatched_package_managers" +vp = "global" +steps = [ + { argv = ["yarn", "--version"], comment = "Yarn resolves independently of the project's pnpm declaration", snapshot = false }, + { argv = ["bun", "--version"], comment = "Bun resolves independently of the project's pnpm declaration", snapshot = false }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md new file mode 100644 index 0000000000..1dd8bb03d1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_mismatched_package_managers/snapshots/shim_mismatched_package_managers.md @@ -0,0 +1,11 @@ +# shim_mismatched_package_managers + +## `yarn --version` + +Yarn resolves independently of the project's pnpm declaration + + +## `bun --version` + +Bun resolves independently of the project's pnpm declaration + diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml index b3564634ce..7201d6727e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots.toml @@ -5,7 +5,8 @@ local-registry = true skip-platforms = ["windows"] comment = "pnpm 12 ships a native binary via @pnpm/exe.* platform packages; the pnpm shim runs it directly and the pnpx shim injects the dlx subcommand." steps = [ - { argv = ["vp", "install", "-g", "pnpm"], comment = "Expose the pnpm/pnpx shims", snapshot = false, continue-on-failure = true }, + { argv = ["vp", "install", "-g", "pnpm"], comment = "Reject the redundant managed global install", continue-on-failure = true }, + { argv = ["vpt", "stat-file", "$VP_HOME/packages/pnpm.json", "--assert", "missing"], comment = "The redundant package should not be installed", snapshot = false, continue-on-failure = true }, { argv = ["vp", "env", "exec", "node", "--version"], comment = "Ensure Node.js is installed first", snapshot = false, continue-on-failure = true }, { argv = ["pnpm", "--version"], comment = "pnpm shim downloads the native binary and resolves the pinned packageManager version (12.0.0-beta.0)", continue-on-failure = true }, { argv = ["pnpx", "--silent", "cowsay", "hello"], comment = "pnpx shim injects dlx so the native binary runs the package", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md index da9b803c9a..a81701cc4b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm12_native/snapshots/shim_pnpm12_native.md @@ -4,7 +4,17 @@ pnpm 12 ships a native binary via @pnpm/exe.* platform packages; the pnpm shim r ## `vp install -g pnpm` -Expose the pnpm/pnpx shims +Reject the redundant managed global install + +``` +VITE+ - The Unified Toolchain for the Web + +warn: Vite+ already includes 'pnpm'; skipping. +``` + +## `vpt stat-file $VP_HOME/packages/pnpm.json --assert missing` + +The redundant package should not be installed ## `vp env exec node --version` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version index 1d9b7831ba..b009dfb9d9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/.node-version @@ -1 +1 @@ -22.12.0 +lts/* diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_pnpm_uses_project_node_version/snapshots.toml index b39ea1c4ed..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 = match parse_package_spec(package_spec) { + Ok((package_name, _)) => package_name, + Err(error) => return Err((Some(package_spec.clone()), Box::new(error))), + }; + if package_name == "corepack" { + return Err(package_error( + &package_name, + Error::Other("'vp install -g corepack' is no longer supported.".into()), + )); + } + if LEGACY_PACKAGE_MANAGER_PACKAGES.contains(&package_name.as_str()) { + output::warn(&format!("Vite+ already includes '{package_name}'; skipping.")); + continue; + } + packages.insert(package_name, Package { spec: package_spec, install: None }); + } + if packages.is_empty() { + return Ok(()); + } + + // 2. Resolve Node.js version let node_version = if let Some(v) = node_version { let provider = NodeProvider::new(); match resolve_version_alias(v, &provider).await { @@ -137,7 +147,7 @@ pub async fn install( resolution.version }; - // 2. Ensure Node.js is installed + // 3. Ensure Node.js is installed let runtime = match vp_js_runtime::download_runtime(vp_js_runtime::JsRuntimeType::Node, &node_version) .await @@ -153,17 +163,7 @@ pub async fn install( let npm_path = if cfg!(windows) { node_bin_dir.join("npm.cmd") } else { node_bin_dir.join("npm") }; - // 3. Install packages in parallel - let mut packages = IndexMap::::new(); - for package_spec in package_specs { - // Parse package spec (e.g., "typescript", "typescript@5.0.0", "@scope/pkg") - - let (package_name, _version_spec) = match parse_package_spec(package_spec) { - Ok(result) => result, - Err(error) => return Err((Some(package_spec.clone()), Box::new(error))), - }; - packages.insert(package_name, Package { spec: package_spec, install: None }); - } + // 4. Install packages in parallel let packages_count = packages.len(); let concurrency = concurrency.max(1); @@ -229,7 +229,7 @@ pub async fn install( } progress.finish_and_clear(); - // 4. Finalize installed packages. + // 5. Finalize installed packages. let mut bin_owners = HashMap::::new(); for (index, (package_name, Package { spec, install })) in packages.into_iter().enumerate() { let lock_file = install_locks.remove(&package_name); @@ -244,8 +244,7 @@ pub async fn install( continue; }; - // Previous metadata drives both the inherited bin restriction and - // stale-bin detection below; load it once. + // Load previous metadata once for stale-bin detection below. let previous_metadata = match PackageMetadata::load(&package_name).await { Ok(metadata) => metadata, Err(error) => { @@ -257,29 +256,10 @@ pub async fn install( } }; - // Restrict exposed binaries when requested (e.g., the corepack shim - // auto-install only links `corepack`, not the pnpm/yarn launchers - // that `corepack enable` creates on demand). Updates carry a recorded - // restriction forward so `vp update -g` cannot re-expose the filtered - // bins; explicit installs (update=false) re-expose the full bin list. - let restriction: Option> = match only_bins { - Some(only) => Some(only.iter().map(ToString::to_string).collect()), - None if update => previous_metadata - .as_ref() - .filter(|previous| previous.bins_restricted) - .map(|previous| previous.bins.clone()), - None => None, - }; - let bins_restricted = restriction.is_some(); - if let Some(only) = &restriction { - bin_names.retain(|bin| only.contains(bin)); - js_bins.retain(|bin| only.contains(bin)); - } - // Drop bin names the package must not own before conflict detection, // shim creation, BinConfig ownership, and metadata recording. bin_names.retain(|bin| { - let allowed = package_may_own_bin(&package_name, bin); + let allowed = !is_protected_shim(bin, true); if !allowed { output::warn(&format!( "Package '{}' provides '{}' binary, but it conflicts with a built-in shim. \ @@ -289,7 +269,7 @@ pub async fn install( } allowed }); - js_bins.retain(|bin| package_may_own_bin(&package_name, bin)); + js_bins.retain(|bin| !is_protected_shim(bin, true)); let stale_bin_names = match stale_bin_names_for_package( previous_metadata.as_ref(), @@ -311,7 +291,7 @@ pub async fn install( let mut conflicts = Vec::<(String, String)>::new(); let mut finalize_blocked = false; - // 4.1 Detect binary ownership conflicts before writing metadata. + // 5.1 Detect binary ownership conflicts before writing metadata. for bin_name in &bin_names { if let Some(owner) = bin_owners.get(bin_name) && owner != &package_name @@ -341,7 +321,7 @@ pub async fn install( continue; } - // 4.2 Resolve conflicts, either by force-uninstalling owners or rolling back this install. + // 5.2 Resolve conflicts, either by force-uninstalling owners or rolling back this install. if !conflicts.is_empty() { if force { let packages_to_remove: HashSet<_> = @@ -380,7 +360,7 @@ pub async fn install( } } - // 4.3 Prepare metadata and remove binaries that the new install no longer provides. + // 5.3 Prepare metadata and remove binaries that the new install no longer provides. let bin_dir = match get_bin_dir().map_err(|error| package_error(&package_name, error)) { Ok(bin_dir) => bin_dir, Err(error) => { @@ -403,7 +383,6 @@ pub async fn install( "npm".to_string(), ); metadata.install_id = install_id.clone(); - metadata.bins_restricted = bins_restricted; metadata.version_spec = update_version_spec(spec); let mut finalized = true; @@ -436,7 +415,7 @@ pub async fn install( continue; } - // 4.4 Activate the new installation through metadata. + // 5.4 Activate the new installation through metadata. if let Err(error) = metadata.save().await.map_err(|error| package_error(&package_name, error)) { @@ -454,7 +433,7 @@ pub async fn install( continue; } - // 4.5 Expose each binary and record its ownership. + // 5.5 Expose each binary and record its ownership. for bin_name in &bin_names { let result = async { create_package_shim(&bin_dir, bin_name, &package_name).await?; @@ -494,11 +473,11 @@ pub async fn install( bin_owners.insert(bin_name.clone(), package_name.clone()); } - // 4.6 Remove stale installations for this package. + // 5.6 Remove stale installations for this package. cleanup_stale_installations(&package_name, &install_id).await; drop(lock_file); - // 4.7 Print success message + // 5.7 Print success message output::success(&format!( "{} {} {}{}", operation_past, @@ -1102,10 +1081,9 @@ pub(crate) async fn create_package_shim( bin_name: &str, package_name: &str, ) -> Result<(), Error> { - // Defense in depth: the finalize loop already filters bin names the - // package must not own (see package_may_own_bin); keep the guard here so - // no other caller can hand a protected shim to a package. - if !package_may_own_bin(package_name, bin_name) { + // Defense in depth: the finalize loop already filters protected bin + // names; keep the guard here so no other caller can hand one to a package. + if is_protected_shim(bin_name, true) { output::warn(&format!( "Package '{}' provides '{}' binary, but it conflicts with a built-in shim. Skipping.", package_name, bin_name @@ -1165,9 +1143,7 @@ pub(crate) async fn create_package_shim( /// Remove a shim for a package binary. async fn remove_package_shim(bin_dir: &vt_path::AbsolutePath, bin_name: &str) -> Result<(), Error> { - // Don't remove protected shims (e.g., `vp remove -g corepack` must keep - // the default corepack shim so it falls back to the Node-bundled or - // auto-installed corepack). + // Don't remove protected shims. if is_protected_shim(bin_name, false) { return Ok(()); } @@ -1229,6 +1205,13 @@ mod tests { } } + #[test] + fn test_default_shims_are_protected() { + for shim in CORE_SHIMS.iter().chain(crate::commands::env::setup::SHIM_TOOLS) { + assert!(is_protected_shim(shim, false), "{shim} should be protected"); + } + } + #[tokio::test] #[cfg_attr(windows, serial_test::serial)] async fn test_create_package_shim_creates_bin_dir() { @@ -1287,48 +1270,6 @@ mod tests { assert!(!shim_path.as_path().exists()); } - #[test] - fn test_package_may_own_bin_scopes_corepack_to_its_package() { - // Only the corepack package may own the corepack bin; any other - // package declaring a `corepack` bin must not take BinConfig - // ownership (it would win the corepack shim's resolution order). - assert!(package_may_own_bin("corepack", "corepack")); - assert!(!package_may_own_bin("some-package", "corepack")); - assert!(!package_may_own_bin("@scope/corepack", "corepack")); - - // Other protected shims never belong to packages - assert!(!package_may_own_bin("corepack", "npm")); - assert!(!package_may_own_bin("some-package", "vpx")); - assert!(!package_may_own_bin("some-package", "vpr")); - - // Regular bins are unrestricted - assert!(package_may_own_bin("typescript", "tsc")); - - #[cfg(any(windows, target_os = "macos"))] - assert!(!package_may_own_bin("some-package", "NPM")); - #[cfg(any(windows, target_os = "macos"))] - assert!(!package_may_own_bin("some-package", "Node")); - #[cfg(any(windows, target_os = "macos"))] - assert!(!package_may_own_bin("some-package", "VP")); - } - - #[tokio::test] - async fn test_create_package_shim_skips_corepack_bin_for_other_packages() { - use tempfile::TempDir; - use vt_path::AbsolutePathBuf; - - let temp_dir = TempDir::new().unwrap(); - let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - - create_package_shim(&bin_dir, "corepack", "some-package").await.unwrap(); - - #[cfg(unix)] - let shim_path = bin_dir.join("corepack"); - #[cfg(windows)] - let shim_path = bin_dir.join("corepack.exe"); - assert!(!shim_path.as_path().exists()); - } - #[tokio::test] #[cfg_attr(windows, serial_test::serial)] async fn test_remove_package_shim_removes_shim() { diff --git a/crates/vp_global_cli/src/commands/global/mod.rs b/crates/vp_global_cli/src/commands/global/mod.rs index 91e4961485..c5e5a0de80 100644 --- a/crates/vp_global_cli/src/commands/global/mod.rs +++ b/crates/vp_global_cli/src/commands/global/mod.rs @@ -17,7 +17,11 @@ pub mod outdated; pub mod packages; /// Core shims that should not be overwritten by package binaries. -pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"]; +pub(crate) const CORE_SHIMS: &[&str] = + &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx", "vp"]; + +/// Legacy managed globals superseded by the default package-manager shims. +pub(crate) const LEGACY_PACKAGE_MANAGER_PACKAGES: &[&str] = &["yarn", "pnpm", "bun", "corepack"]; #[derive(Debug)] struct PackageVersion { diff --git a/crates/vp_global_cli/src/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..49d878ec0f 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -3,11 +3,10 @@ //! This module handles the core shim functionality: //! 1. Version resolution (with caching) //! 2. Node.js installation (if needed) -//! 3. Tool execution (core tools and package binaries) +//! 3. Tool execution (core shims and package binaries) use vp_pm_cli::{ - PackageManagerType, download_package_manager, package_manager_bin_path, - package_manager_install_dir, resolve_package_manager_from_package_json, + 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}; @@ -34,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"]) @@ -262,18 +251,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 +505,8 @@ fn remove_npm_global_uninstall_links(bin_entries: &[(String, String)], npm_prefi let Ok(bin_dir) = config::get_bin_dir() else { return }; for (bin_name, package_name) in bin_entries { - // Skip protected shims: a stale Npm BinConfig (e.g. a pre-default-shim - // `npm install -g corepack`) must not let `npm uninstall -g` delete a - // default shim that `vp env setup` now owns. + // Skip protected shims: a stale Npm BinConfig must not let + // `npm uninstall -g` delete a default shim that `vp env setup` owns. if is_protected_shim(bin_name, false) { continue; } @@ -664,12 +647,12 @@ fn resolve_npm_prefix( get_npm_global_prefix(npm_path, node_dir) } -/// Resolve a matching package-manager binary from the current project's explicit -/// `packageManager` field. +/// Resolve the package-manager binary for a core shim. /// -/// The match is intentionally strict to avoid translating commands: `npm` only uses -/// `npm@...`, `pnpm` only uses `pnpm@...`, etc. -async fn resolve_matching_package_manager_tool( +/// A project pin applies only to its matching manager. npm otherwise comes +/// from the selected Node.js installation; standalone managers use their +/// latest release when no matching pin exists. +async fn resolve_package_manager_tool( cwd: &AbsolutePath, tool: &str, ) -> Result, Error> { @@ -677,33 +660,16 @@ 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(_) | None if expected_type == PackageManagerType::Npm => return Ok(None), + Some(_) | None => ("latest".into(), None), }; - if resolution.package_manager_type != expected_type { - return Ok(None); - } - let bin_name = expected_type.bin_name_for_tool(tool); - - // Fast path: if the managed install already exists, skip download_package_manager - // entirely. The slow path stats three files (`bin`, `.cmd`, `.ps1`) on every - // invocation, which adds up on the shim hot path. - if let Some(install_dir) = package_manager_install_dir(expected_type, &resolution.version) { - let bin_path = package_manager_bin_path(&install_dir, bin_name); - if bin_path.as_path().exists() { - return Ok(Some(bin_path)); - } - } - - let (install_dir, _, _) = download_package_manager( - resolution.package_manager_type, - &resolution.version, - resolution.hash.as_deref(), - ) - .await?; - Ok(Some(package_manager_bin_path(&install_dir, bin_name))) + Ok(Some(ensure_package_manager_bin(expected_type, &version, hash.as_deref(), bin_name).await?)) } async fn prepend_js_child_process_path_env( @@ -712,7 +678,7 @@ async fn prepend_js_child_process_path_env( ) -> Result<(), Error> { let _ = prepend_to_path_env(node_bin_dir, PrependOptions::default()); - let Some(npm_path) = resolve_matching_package_manager_tool(cwd, "npm").await? else { + let Some(npm_path) = resolve_package_manager_tool(cwd, "npm").await? else { return Ok(()); }; if let Some(pm_bin_dir) = npm_path.parent() @@ -725,8 +691,8 @@ async fn prepend_js_child_process_path_env( /// Main shim dispatch entry point. /// -/// Called when the binary is invoked as node, npm, npx, corepack, or a -/// package binary. Returns an exit code to be used with std::process::exit. +/// Called when the binary is invoked as a core shim or package binary. +/// Returns an exit code to be used with std::process::exit. pub async fn dispatch(tool: &str, args: &[String]) -> i32 { tracing::debug!("dispatch: tool: {tool}, args: {:?}", args); @@ -755,7 +721,7 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { } // Check recursion prevention - if already in a shim context, passthrough directly - // Only applies to core tools (node/npm/npx) whose bin dir is prepended to PATH. + // Only applies to core tools whose bin dir is prepended to PATH. // Package binaries are always resolved via metadata lookup, so they can't loop. if std::env::var(RECURSION_ENV_VAR).is_ok() && is_core_shim_tool(tool) { tracing::debug!("recursion prevention enabled for core tool"); @@ -796,16 +762,8 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Fall through to managed if system not found } - // corepack: dedicated resolution chain (vp-managed package → Node-bundled - // → auto-install), see shim::corepack. Intentionally placed after the - // bypass/system-first checks and outside the recursion passthrough so it - // always re-resolves (corepack may not exist on the prepended PATH at all - // with Node.js 25+). - if tool == "corepack" { - return super::corepack::dispatch_corepack(args).await; - } - - // Check if this is a package binary (not node/npm/npx) + // Package binaries use their install-time Node.js version; core shims use + // the project-resolved runtime below. if !is_core_shim_tool(tool) { return dispatch_package_binary(tool, args).await; } @@ -840,25 +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. @@ -866,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}"); @@ -946,65 +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(_) => match find_package_for_binary(tool).await { - Ok(Some(metadata)) => metadata.platform.node, - _ => String::new(), - }, - }; - - if !node_version.is_empty() { - match ensure_installed(&node_version).await { - Ok(node_path) => { - if let Some(node_bin_dir) = node_path.parent() { - if let Err(e) = - prepend_js_child_process_path_env(&cwd, node_bin_dir).await - { - eprintln!( - "vp: Failed to resolve package manager for child \ - process PATH: {e}" - ); - return 1; - } - } - } - Err(e) => { - eprintln!("vp: Failed to install Node {}: {e}", node_version); - return 1; - } - } - } - - if let Some(pm_bin_dir) = tool_path.parent() { - let _ = prepend_to_path_env(pm_bin_dir, PrependOptions::default()); - } - - // SAFETY: Setting env vars at this point before exec is safe - unsafe { - std::env::set_var(RECURSION_ENV_VAR, "1"); - } - return exec::exec_tool(&tool_path, args); - } - Ok(None) => {} - Err(e) => { - eprintln!("vp: Failed to resolve package manager for '{tool}': {e}"); - return 1; - } - } - } - // Find which package provides this binary let package_metadata = match find_package_for_binary(tool).await { Ok(Some(metadata)) => metadata, @@ -1019,31 +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 c6f8a5a977..10b58551be 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -1,8 +1,7 @@ -//! Shim module for intercepting node, npm, npx, corepack, and package binary commands. +//! Shim module for intercepting Node.js, package-manager, and package binary commands. //! //! This module provides the functionality for the vp binary to act as a shim -//! when invoked as `node`, `npm`, `npx`, `corepack`, or any globally installed -//! package binary. +//! when invoked as a managed tool or any globally installed package binary. //! //! Detection methods: //! - Unix: Symlinks to vp binary preserve argv[0], allowing tool detection @@ -10,7 +9,6 @@ //! - Legacy: `.cmd` wrappers call `vp env exec ` directly (deprecated) mod cache; -pub(crate) mod corepack; pub(crate) mod dispatch; pub(crate) mod exec; @@ -23,13 +21,9 @@ use vp_shared::env_vars; use crate::commands::env::config::get_bin_dir; -/// Core shim tools (node, npm, npx). -/// -/// `corepack` is also a default shim (see `commands::env::setup::SHIM_TOOLS`) -/// but is intentionally not a core tool: it is not always bundled with the -/// resolved Node.js version (removed in Node.js 25+), so it has a dedicated -/// dispatch path with a managed fallback and never uses recursion passthrough. -pub const CORE_SHIM_TOOLS: &[&str] = &["node", "npm", "npx"]; +/// Core shim tools managed directly by the main dispatch path. +pub const CORE_SHIM_TOOLS: &[&str] = + &["node", "npm", "npx", "pnpm", "pnpx", "yarn", "yarnpkg", "bun", "bunx"]; /// Extract the tool name from argv[0]. /// We hope all bins should be put under $VP_HOME/bin @@ -70,7 +64,7 @@ pub fn extract_tool_name(argv0: &str) -> String { } } -/// Check if the given tool name is a core shim tool (node/npm/npx). +/// Check if the given tool name is managed directly by the core shim path. #[must_use] pub fn is_core_shim_tool(tool: &str) -> bool { CORE_SHIM_TOOLS.contains(&tool) @@ -79,7 +73,7 @@ pub fn is_core_shim_tool(tool: &str) -> bool { /// Check if the given tool name is a shim tool (core or package binary). /// /// This is a quick check that returns true if: -/// 1. The tool is a core shim (node/npm/npx), OR +/// 1. The tool is a core shim, OR /// 2. The tool name is not "vp" (package binaries are detected later via metadata) #[must_use] pub fn is_shim_tool(tool: &str) -> bool { @@ -217,27 +211,6 @@ mod tests { } } - #[test] - fn test_is_shim_tool() { - // Core shim tools are always recognized - assert!(is_core_shim_tool("node")); - assert!(is_core_shim_tool("npm")); - assert!(is_core_shim_tool("npx")); - assert!(!is_core_shim_tool("yarn")); // yarn is not a core shim tool - assert!(!is_core_shim_tool("vp")); - assert!(!is_core_shim_tool("cargo")); - assert!(!is_core_shim_tool("tsc")); // Package binary, not core - // corepack is a default shim but intentionally not a core tool: - // it has a dedicated dispatch path and never uses recursion passthrough - assert!(!is_core_shim_tool("corepack")); - - // is_shim_tool includes core tools - assert!(is_shim_tool("node")); - assert!(is_shim_tool("npm")); - assert!(is_shim_tool("npx")); - assert!(!is_shim_tool("vp")); // vp is never a shim - } - /// Test that is_potential_package_binary checks the configured bin directory. /// /// The function now checks if a shim exists in the configured bin directory diff --git a/crates/vp_pm_cli/src/error.rs b/crates/vp_pm_cli/src/error.rs index eb8c6eec62..11de599488 100644 --- a/crates/vp_pm_cli/src/error.rs +++ b/crates/vp_pm_cli/src/error.rs @@ -30,7 +30,4 @@ pub enum Error { /// User-facing message printed without the "Error: " prefix. #[error("{0}")] UserMessage(Str), - - #[error("{0}")] - Other(Str), } diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index de091c6f2a..9105a7e8ea 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -23,9 +23,9 @@ 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, - resolve_package_manager_from_package_json, + 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; 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..063c400a31 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -6,6 +6,7 @@ use std::{ fs::{self, File}, io::{self, BufReader, Write}, path::{Path, PathBuf}, + time::Duration, }; use crossterm::{ @@ -388,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> { @@ -645,7 +667,43 @@ fn find_extracted_package_dir(target_dir: &Path) -> io::Result { )) } +const LATEST_VERSION_CACHE_TTL: Duration = Duration::from_secs(3600); + +fn latest_version_cache_path( + package_manager_type: PackageManagerType, +) -> io::Result { + Ok(vp_shared::get_vp_home()? + .join("cache") + .join("package_manager_latest") + .join(package_manager_type.to_string())) +} + +fn read_latest_version_cache(path: &AbsolutePath) -> Option<(Str, bool)> { + let version = fs::read_to_string(path).ok()?; + let version = version.trim(); + Version::parse(version).ok()?; + let is_fresh = fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < LATEST_VERSION_CACHE_TTL); + Some((version.into(), is_fresh)) +} + +fn write_latest_version_cache(path: &AbsolutePath, version: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, version) +} + async fn get_latest_version(package_manager_type: PackageManagerType) -> Result { + let cache_path = latest_version_cache_path(package_manager_type)?; + let cached = read_latest_version_cache(&cache_path); + if let Some((version, true)) = &cached { + return Ok(version.clone()); + } + let package_name = if matches!(package_manager_type, PackageManagerType::Yarn) { // yarn latest version should use `@yarnpkg/cli-dist` as package name "@yarnpkg/cli-dist".to_string() @@ -653,8 +711,20 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result< package_manager_type.to_string() }; let url = get_npm_package_version_url(&package_name, "latest"); - let package_json: PackageJson = HttpClient::new().get_json(&url).await?; - Ok(package_json.version) + match HttpClient::new().get_json::(&url).await { + Ok(package_json) => { + let _ = write_latest_version_cache(&cache_path, &package_json.version); + Ok(package_json.version) + } + Err(error) => { + let Some((version, _)) = cached else { return Err(error) }; + tracing::warn!( + "Failed to refresh latest {package_manager_type} version: {error}; using cached {}", + version + ); + Ok(version) + } + } } /// Abbreviated registry metadata: only the version list is needed. @@ -804,6 +874,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 +895,7 @@ pub async fn download_package_manager( version_or_latest: &str, expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { - let version: Str = if version_or_latest == "latest" { - get_latest_version(package_manager_type).await? - } else if Version::parse(version_or_latest).is_ok() { - version_or_latest.into() - } else { - // semver range (e.g. from devEngines.packageManager): prefer an already - // downloaded satisfying version, otherwise resolve from the registry - resolve_package_manager_range(package_manager_type, version_or_latest).await? - }; + let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; // Reject anything that is not strict semver `major.minor.patch[-prerelease][+build]`. // This prevents path traversal via the version being interpolated into @@ -1699,7 +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; @@ -1806,6 +1882,63 @@ mod tests { assert_eq!(find_cached_pnpm(&vp_home), None); } + #[tokio::test(flavor = "current_thread")] + async fn test_latest_version_cache_refreshes_and_falls_back_after_expiry() { + use httpmock::prelude::*; + + let temp_dir = create_temp_dir(); + let vp_home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let server = MockServer::start(); + let mut first = server.mock(|when, then| { + when.method(GET).path("/bun/latest"); + then.status(200).json_body(serde_json::json!({ "version": "1.0.0" })); + }); + let _guard = EnvConfig::test_guard(EnvConfig { + npm_registry: server.base_url(), + vite_plus_home: Some(vp_home.as_path().to_path_buf()), + ..EnvConfig::for_test() + }); + + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "1.0.0" + ); + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "1.0.0" + ); + first.assert_hits(1); + + let cache_path = latest_version_cache_path(PackageManagerType::Bun).unwrap(); + let expire_cache = || { + fs::File::options() + .write(true) + .open(&cache_path) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(UNIX_EPOCH)) + .unwrap(); + }; + expire_cache(); + first.delete(); + + let mut refreshed = server.mock(|when, then| { + when.method(GET).path("/bun/latest"); + then.status(200).json_body(serde_json::json!({ "version": "2.0.0" })); + }); + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "2.0.0" + ); + refreshed.assert_hits(1); + + expire_cache(); + refreshed.delete(); + assert_eq!( + resolve_package_manager_version(PackageManagerType::Bun, "latest").await.unwrap(), + "2.0.0" + ); + } + #[cfg(windows)] #[test] fn test_find_cached_package_manager_version_skips_missing_windows_shims() { diff --git a/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