diff --git a/.github/scripts/test-install-bootstrap.ps1 b/.github/scripts/test-install-bootstrap.ps1 index ecf620a813..7012d11336 100644 --- a/.github/scripts/test-install-bootstrap.ps1 +++ b/.github/scripts/test-install-bootstrap.ps1 @@ -64,6 +64,9 @@ $originalCheck = $env:VP_SELF_SETUP_SUPPORT_CHECK $originalPath = $env:Path $originalRegistry = $env:NPM_CONFIG_REGISTRY $originalVpShell = $env:VP_SHELL +$managerVariables = @('VP_NODE_MANAGER', 'VP_PM_MANAGER', 'VP_NPM_MANAGER', 'VP_PNPM_MANAGER', 'VP_YARN_MANAGER', 'VP_BUN_MANAGER') +$originalManagers = @{} +foreach ($name in $managerVariables) { $originalManagers[$name] = [Environment]::GetEnvironmentVariable($name) } $fixtureSha = '0123456789012345678901234567890123456789' New-Item -ItemType Directory -Path "$testRoot/package", "$testRoot/tmp", "$testRoot/scripts" | Out-Null Set-Content -LiteralPath "$testRoot/package/vp.exe" -Value 'Payload fixture' @@ -78,6 +81,7 @@ if ($args.Count -eq 0) { $expectedVpShell = if ($scenario -eq 'supported-pr') { 'fish' } else { 'powershell' } if ($env:VP_SHELL -ne $expectedVpShell) { exit 96 } if ($scenario -eq 'supported-pr' -and $env:NPM_CONFIG_REGISTRY -ne 'https://registry-bridge.viteplus.dev/') { exit 97 } + if ($scenario -like 'system-only*' -and $env:VP_NODE_MANAGER -cne 'system-only') { exit 94 } Write-Output ("`$script:InstallDir = '{0}'" -f "$testRoot/data") Write-Output ("`$script:ShimDir = '{0}'" -f "$testRoot/installed bin") Write-Output ("`$script:CacheDir = '{0}'" -f "$testRoot/cache") @@ -85,10 +89,15 @@ if ($args.Count -eq 0) { Write-Output ("`$script:StateDir = '{0}'" -f "$testRoot/state") exit 0 } -if ($env:VP_SELF_SETUP_SUPPORT_CHECK -ne '1') { exit 99 } +# Only a system-only setting asks for the second response line. +$expectedCheck = if ($scenario -like 'system-only*' -or $scenario -eq 'pm-system-only') { 'system-only' } else { '1' } +if ($env:VP_SELF_SETUP_SUPPORT_CHECK -cne $expectedCheck) { exit 99 } New-Item -ItemType File -Path "$testRoot/binary-probed" | Out-Null -if ($scenario -in @('legacy', 'legacy-remote', 'legacy-failure', 'pr')) { Write-Output 'Usage: vp [COMMAND]' } -else { Write-Output 'vite-plus-self-setup-v1' } +if ($scenario -in @('legacy', 'legacy-remote', 'legacy-failure', 'pr', 'system-only-legacy')) { Write-Output 'Usage: vp [COMMAND]' } +else { + Write-Output 'vite-plus-self-setup-v1' + if ($expectedCheck -eq 'system-only' -and $scenario -ne 'system-only-v1') { Write-Output 'vite-plus-self-setup-system-only' } +} exit 0 '@ | Set-Content -LiteralPath "$testRoot/package/binary.ps1" @' @@ -196,6 +205,11 @@ function Test-SelfSetupSupport { param($BinarySource) & $probe -BinarySource (Join-Path (Split-Path $BinarySource) 'binary.ps1') } +$systemOnlyProbe = ${function:Test-SystemOnlySupport} +function Test-SystemOnlySupport { + param($BinarySource) + & $systemOnlyProbe -BinarySource (Join-Path (Split-Path $BinarySource) 'binary.ps1') +} $handoff = ${function:Invoke-InstallHandoff} function Invoke-InstallHandoff { param($BinarySource) @@ -205,6 +219,7 @@ function Invoke-InstallHandoff { try { foreach ($scenario in @( 'supported', 'legacy', 'legacy-remote', 'legacy-failure', 'failure', 'pr', 'supported-pr', + 'system-only', 'system-only-v1', 'system-only-legacy', 'pm-system-only', 'mixed-case-system-only', 'metadata-error', 'metadata-missing-version', 'metadata-network-error', 'platform-error', 'platform-network-error', 'provenance-missing', 'provenance-malformed', 'provenance-dotted-key', 'tarball-missing', 'integrity-missing', 'integrity-malformed', 'integrity-unsupported', 'integrity-noncanonical', @@ -220,9 +235,17 @@ try { $script:PackageMetadata = $null Remove-Item -LiteralPath "$testRoot/legacy", "$testRoot/binary-invoked", "$testRoot/binary-probed" -ErrorAction SilentlyContinue $env:VP_SELF_SETUP_SUPPORT_CHECK = if ($scenario -eq 'supported') { 'original' } else { $null } + foreach ($name in $managerVariables) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') } + switch ($scenario) { + { $_ -like 'system-only*' } { $env:VP_NODE_MANAGER = 'system-only' } + 'pm-system-only' { $env:VP_PNPM_MANAGER = 'system-only' } + # Only the exact lowercase value is system-only. + 'mixed-case-system-only' { $env:VP_NODE_MANAGER = 'System-Only' } + } + $isSystemOnlyFailure = $scenario -in @('system-only-v1', 'system-only-legacy') $isIntegrityFailure = $scenario -like 'integrity-*' $isMetadataFailure = $scenario -match '^(metadata-|platform-|provenance-|tarball-missing$)' - $expectedExit = if ($isIntegrityFailure -or $isMetadataFailure) { + $expectedExit = if ($isIntegrityFailure -or $isMetadataFailure -or $isSystemOnlyFailure) { 1 } elseif ($scenario -in @('failure', 'legacy-failure')) { 42 @@ -260,13 +283,20 @@ try { Write-Host "PASS: $scenario" continue } + if ($isSystemOnlyFailure) { + Assert (Test-Path -LiteralPath "$testRoot/binary-probed") 'System-only support was not probed' + Assert (-not (Test-Path -LiteralPath "$testRoot/binary-invoked")) 'An unsupported binary was invoked with system-only' + Assert (-not (Test-Path -LiteralPath "$testRoot/legacy")) 'System-only reached legacy setup' + Write-Host "PASS: $scenario" + continue + } Assert ($env:VP_SHELL -eq $initialVpShell) 'Setup changed the caller shell' if ($scenario -eq 'supported') { Assert (($env:Path -split ';')[0] -eq "$testRoot/installed bin") 'Installed bin directory was not added to the current PATH' } elseif ($scenario -eq 'failure') { Assert ($env:Path -eq $originalPath) 'Failed setup changed the current PATH' } - $usesBinary = $scenario -in @('supported', 'supported-pr', 'failure') + $usesBinary = $scenario -in @('supported', 'supported-pr', 'failure', 'system-only', 'pm-system-only', 'mixed-case-system-only') Assert ((Test-Path -LiteralPath "$testRoot/binary-invoked") -eq $usesBinary) 'Incorrect binary invocation' Assert ((Test-Path -LiteralPath "$testRoot/legacy") -eq (-not $usesBinary)) 'Incorrect legacy invocation' if ($scenario -eq 'pr') { @@ -325,6 +355,7 @@ try { $env:Path = $originalPath $env:NPM_CONFIG_REGISTRY = $originalRegistry $env:VP_SHELL = $originalVpShell + foreach ($name in $managerVariables) { [Environment]::SetEnvironmentVariable($name, $originalManagers[$name], 'Process') } $env:TEMP = $originalTemp Remove-Item -LiteralPath $testRoot -Recurse -Force $global:LASTEXITCODE = 0 diff --git a/.github/scripts/test-install-bootstrap.sh b/.github/scripts/test-install-bootstrap.sh index 3ca9bb1e49..f393f1b4c4 100644 --- a/.github/scripts/test-install-bootstrap.sh +++ b/.github/scripts/test-install-bootstrap.sh @@ -35,6 +35,9 @@ if [ "$#" -eq 0 ]; then if [[ "$scenario" == *pr ]]; then test "$NPM_CONFIG_REGISTRY" = https://registry-bridge.viteplus.dev/ || exit 97 fi + if [[ "$scenario" == system-only* ]]; then + test "${VP_NODE_MANAGER:-}" = system-only || exit 95 + fi printf 'INSTALL_DIR=%q\n' "$test_root/data" printf 'SHIM_DIR=%q\n' "$test_root/installed bin" printf 'CACHE_DIR=%q\n' "$test_root/cache" @@ -42,11 +45,20 @@ if [ "$#" -eq 0 ]; then printf 'STATE_DIR=%q\n' "$test_root/state" exit 0 fi -test "${VP_SELF_SETUP_SUPPORT_CHECK:-}" = 1 || exit 99 +# Only a system-only setting asks for the second response line. +case "$scenario:${VP_SELF_SETUP_SUPPORT_CHECK:-}" in + system-only*:system-only|pm-system-only:system-only) ;; + *:1) ;; + *) exit 99 ;; +esac touch "$test_root/binary-probed" case "$scenario" in - legacy|legacy-failure|piped-legacy|piped-legacy-failure|pr) printf 'Usage: vp [COMMAND]\n' ;; - *) printf 'vite-plus-self-setup-v1\n' ;; + legacy|legacy-failure|piped-legacy|piped-legacy-failure|pr|system-only-legacy) printf 'Usage: vp [COMMAND]\n' ;; + *) + printf 'vite-plus-self-setup-v1\n' + if [ "$VP_SELF_SETUP_SUPPORT_CHECK" = system-only ] && [ "$scenario" != system-only-v1 ]; then + printf 'vite-plus-self-setup-system-only\n' + fi ;; esac BINARY chmod +x "$test_root/package/vp" @@ -128,6 +140,7 @@ curl() { } for scenario in supported legacy legacy-failure piped-legacy piped-legacy-failure failure pr supported-pr \ + system-only system-only-v1 system-only-legacy pm-system-only mixed-case-system-only \ missing-hash-tools missing-hash-tools-pr \ escaped-integrity escaped-integrity-pr escaped-integrity-all escaped-integrity-lower escaped-integrity-key escaped-integrity-slash \ integrity-missing integrity-malformed integrity-unsupported integrity-noncanonical integrity-wrong-type \ @@ -156,6 +169,13 @@ for scenario in supported legacy legacy-failure piped-legacy piped-legacy-failur fi if [[ "$scenario" == *pr ]]; then PR_VERSION=2406; fi if [ "$scenario" = supported ]; then export VP_SELF_SETUP_SUPPORT_CHECK=original; fi + unset VP_NODE_MANAGER VP_PM_MANAGER VP_NPM_MANAGER VP_PNPM_MANAGER VP_YARN_MANAGER VP_BUN_MANAGER + case "$scenario" in + system-only*) export VP_NODE_MANAGER=system-only ;; + pm-system-only) export VP_PNPM_MANAGER=system-only ;; + # Only the exact lowercase value is system-only. + mixed-case-system-only) export VP_NODE_MANAGER=System-Only ;; + esac main test "$NPM_CONFIG_REGISTRY" = https://custom.example test "$INSTALL_DIR" = "$test_root/data" @@ -186,14 +206,21 @@ for scenario in supported legacy legacy-failure piped-legacy piped-legacy-failur test "$status" -eq 42 elif [[ "$scenario" == *legacy-failure ]]; then test "$status" -eq 43 + elif [ "$scenario" = system-only-v1 ] || [ "$scenario" = system-only-legacy ]; then + test "$status" -ne 0 + grep -q 'does not support VP_NODE_MANAGER=system-only' "$test_root/output" + test -f "$test_root/binary-probed" elif [ "$status" -ne 0 ]; then cat "$test_root/output" exit 1 fi case "$scenario" in - supported|supported-pr|failure|missing-hash-tools*|escaped-integrity*) + supported|supported-pr|failure|missing-hash-tools*|escaped-integrity*|system-only|pm-system-only|mixed-case-system-only) test -f "$test_root/binary-invoked" test ! -f "$test_root/legacy" ;; + system-only-v1|system-only-legacy) + test ! -f "$test_root/binary-invoked" + test ! -f "$test_root/legacy" ;; legacy|legacy-failure|piped-legacy|piped-legacy-failure|pr) test -f "$test_root/legacy" test ! -f "$test_root/binary-invoked" ;; diff --git a/.github/scripts/test-install-legacy-remote.ps1 b/.github/scripts/test-install-legacy-remote.ps1 index 01cd7a945b..dfeb750000 100644 --- a/.github/scripts/test-install-legacy-remote.ps1 +++ b/.github/scripts/test-install-legacy-remote.ps1 @@ -40,6 +40,27 @@ try { # setup-vp evaluates downloaded text in a script block, with no sibling legacy file. # A child process also keeps installer exit calls from bypassing server cleanup. $powershell = (Get-Process -Id $PID).Path + + # A legacy release cannot honor system-only, so the installer must stop before legacy setup. + # Restore the workflow's value afterwards; the install below reproduces setup-vp with it. + $previousNodeManager = $env:VP_NODE_MANAGER + $env:VP_NODE_MANAGER = 'system-only' + try { + # Windows PowerShell 5.1 turns redirected child stderr into errors, which 'Stop' would throw. + $ErrorActionPreference = 'Continue' + $output = & $powershell -NoProfile -Command '& ([scriptblock]::Create((irm -TimeoutSec 15 $env:VP_TEST_INSTALLER_URL)))' 2>&1 | Out-String + $exitCode = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + if ($exitCode -eq 0) { throw 'The installer accepted system-only for a legacy release' } + if (-not $output.Contains('does not support VP_NODE_MANAGER=system-only')) { throw "Unexpected system-only failure: $output" } + } finally { + $ErrorActionPreference = 'Stop' + $env:VP_NODE_MANAGER = $previousNodeManager + } + if (Test-Path -LiteralPath (Join-Path $env:VP_HOME 'bin/vp.exe')) { throw 'The system-only attempt installed vp' } + $requests = @(Select-String -LiteralPath $stderr -Pattern 'GET /install-legacy.bin ' -SimpleMatch) + if ($requests.Count -ne 1) { throw 'The system-only attempt fetched the legacy script' } + & $powershell -NoProfile -Command '& ([scriptblock]::Create((irm -TimeoutSec 15 $env:VP_TEST_INSTALLER_URL)))' if ($LASTEXITCODE -ne 0) { throw "Remote install failed with exit code $LASTEXITCODE" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml index 887e0cf973..73675d14a2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml @@ -12,6 +12,17 @@ steps = [ { argv = ["vp", "not-a-command"], comment = "Once marked, the binary dispatches commands normally", continue-on-failure = true }, ] +[[case]] +name = "command_self_setup_system_only_probe" +vp = "global" +env = { VP_SKIP_DEPS_INSTALL = "1" } +steps = [ + { argv = ["vpt", "rm", "$VP_HOME/current/bin/.vp-setup-complete"], snapshot = false }, + { argv = ["vp", "--help"], envs = [["VP_SELF_SETUP_SUPPORT_CHECK", "system-only"]], comment = "The system-only check value adds a second line after the unchanged v1 response" }, + { argv = ["vp", "--help"], envs = [["VP_SELF_SETUP_SUPPORT_CHECK", "System-Only"]], comment = "Only the exact lowercase value asks for system-only support" }, + ["vpt", "stat-file", "$VP_HOME/current/bin/.vp-setup-complete", "--assert", "missing"], +] + [[case]] name = "command_self_setup_handoff" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_system_only_probe.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_system_only_probe.md new file mode 100644 index 0000000000..b9462a0716 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_system_only_probe.md @@ -0,0 +1,27 @@ +# command_self_setup_system_only_probe + +## `vpt rm $VP_HOME/current/bin/.vp-setup-complete` + + +## `VP_SELF_SETUP_SUPPORT_CHECK=system-only vp --help` + +The system-only check value adds a second line after the unchanged v1 response + +``` +vite-plus-self-setup-v1 +vite-plus-self-setup-system-only +``` + +## `VP_SELF_SETUP_SUPPORT_CHECK=System-Only vp --help` + +Only the exact lowercase value asks for system-only support + +``` +vite-plus-self-setup-v1 +``` + +## `vpt stat-file $VP_HOME/current/bin/.vp-setup-complete --assert missing` + +``` +/.vite-plus/current/bin/.vp-setup-complete: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/check-managed-pnpm.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/check-managed-pnpm.cjs new file mode 100644 index 0000000000..620ef4f616 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/check-managed-pnpm.cjs @@ -0,0 +1,15 @@ +// Run the pnpm shim and report whether it selected the system pnpm or a managed +// release. The managed launcher's own output is platform-specific, so it is not +// printed. +const { spawnSync } = require('node:child_process'); +const { existsSync } = require('node:fs'); +const path = require('node:path'); + +const result = spawnSync('pnpm', ['--version'], { + encoding: 'utf8', + shell: process.platform === 'win32', + timeout: 120000, +}); +console.log(`system pnpm: ${String(result.stdout).includes('system pnpm')}`); +const managed = path.join(process.env.VP_HOME, 'package_manager', 'pnpm', '10.18.0'); +console.log(`managed pnpm 10.18.0 installed: ${existsSync(managed)}`); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/list-managed.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/list-managed.cjs new file mode 100644 index 0000000000..929b8f4c45 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/list-managed.cjs @@ -0,0 +1,18 @@ +// Report the managed installs that the system-only steps could use or create. +// The case's js_runtime links the runner's shared seed, whose versions differ +// between machines, so only the relevant versions are checked. +const { existsSync, readdirSync } = require('node:fs'); +const path = require('node:path'); + +const list = (directory) => (existsSync(directory) ? readdirSync(directory).sort() : []); +const runtimes = path.join(process.env.VP_HOME, 'js_runtime', 'node'); +// Snapshots redact a version after a tool name, so the lines name its source. +const versions = { '.node-version pin': '20.18.0', 'VP_NODE_VERSION override': '24.0.0' }; +for (const [source, version] of Object.entries(versions)) { + console.log(`managed Node.js (${source}): ${existsSync(path.join(runtimes, version))}`); +} +const managers = path.join(process.env.VP_HOME, 'package_manager'); +for (const family of list(managers)) { + console.log(`package_manager/${family}: ${list(path.join(managers, family)).join(', ')}`); +} +if (list(managers).length === 0) console.log('package_manager: (empty)'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-system-tools.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-system-tools.cjs new file mode 100644 index 0000000000..4478a42360 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-system-tools.cjs @@ -0,0 +1,30 @@ +// Fake system npm and pnpm beside system-bin/node. Each reports its name, its +// arguments, and whether the shim left PATH unchanged (EXPECTED_PATH). +const { chmodSync, writeFileSync } = require('node:fs'); +const path = require('node:path'); + +for (const name of ['npm', 'pnpm']) { + if (process.platform === 'win32') { + writeFileSync( + path.join('system-bin', `${name}.cmd`), + [ + '@echo off', + `echo system ${name} %*`, + 'if "%PATH%"=="%EXPECTED_PATH%" (echo same PATH) else (echo changed PATH)', + '', + ].join('\r\n'), + ); + } else { + const file = path.join('system-bin', name); + writeFileSync( + file, + [ + '#!/bin/sh', + `echo "system ${name} $*"`, + 'if [ "$PATH" = "$EXPECTED_PATH" ]; then echo "same PATH"; else echo "changed PATH"; fi', + '', + ].join('\n'), + ); + chmodSync(file, 0o755); + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml index e5e6cc407a..be9329154d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots.toml @@ -106,3 +106,30 @@ steps = [ ["vp", "env", "on", "node"], { argv = ["vpx", "--print", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "Managed mode ignores system Node.js." }, ] + +[[case]] +name = "system_only_overrides_managed_shims" +vp = "global" +comment = "With managed config.json, VP_*_MANAGER=system-only makes the shims use only system tools: no downloads, no network, no cached managed installs, and an unchanged child PATH." +# The pinned pnpm must not switch itself to another version. +env = { npm_config_manage_package_manager_versions = "false" } +steps = [ + { argv = ["node", "setup-system-node.cjs"], snapshot = false }, + { argv = ["node", "setup-system-tools.cjs"], snapshot = false }, + { argv = ["vpt", "write-file", "package.json", '{"packageManager":"pnpm@10.18.0"}'], snapshot = false }, + { argv = ["node", "list-managed.cjs"], comment = "Managed installs before the system-only steps: the pinned runtime is cached, and the override is not." }, + + # The unreachable mirror and registry fail any request, and VP_NODE_VERSION would select a managed version. + { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_NODE_MANAGER", "system-only"], ["VP_NODE_VERSION", "24.0.0"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "The node shim runs system Node.js, ignoring the session override and the cached managed runtime." }, + { argv = ["node", "-p", "process.env.PATH === process.env.EXPECTED_PATH"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["EXPECTED_PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_NODE_MANAGER", "system-only"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "The system Node.js directory is not moved to the front of the child PATH." }, + { argv = ["npm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["EXPECTED_PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_NODE_MANAGER", "system-only"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "npm follows its own (managed) mode: without an npm pin it is the npm beside system Node.js, and the child PATH is unchanged." }, + + # Family-specific values take precedence over VP_PM_MANAGER (ADR-2). + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_PNPM_MANAGER", "system-only"], ["VP_PM_MANAGER", "yes"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "VP_PNPM_MANAGER=system-only wins over VP_PM_MANAGER=yes and over the packageManager pin. Node.js stays managed, so only its directory is added to the child PATH." }, + { argv = ["npm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_PNPM_MANAGER", "yes"], ["VP_PM_MANAGER", "system-only"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "VP_PM_MANAGER=system-only applies to families without their own value." }, + { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_NODE_MANAGER", "System-Only"]], comment = "Only the exact lowercase value is system-only; managed config.json stays in effect." }, + { argv = ["node", "list-managed.cjs"], comment = "The system-only steps downloaded nothing." }, + + { argv = ["node", "check-managed-pnpm.cjs"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_PNPM_MANAGER", "yes"], ["VP_PM_MANAGER", "system-only"]], comment = "VP_PNPM_MANAGER=yes wins over VP_PM_MANAGER=system-only, so the shim selects the pinned managed pnpm instead of the system one." }, + { argv = ["node", "--version"], envs = [["PATH", "${VP_HOME}/bin"], ["VP_NODE_MANAGER", "system-only"]], comment = "Without a system Node.js on PATH, the shim fails instead of using the managed runtime.", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/system_only_overrides_managed_shims.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/system_only_overrides_managed_shims.md new file mode 100644 index 0000000000..d5c08a2c57 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/system_only_overrides_managed_shims.md @@ -0,0 +1,102 @@ +# system_only_overrides_managed_shims + +With managed config.json, VP_*_MANAGER=system-only makes the shims use only system tools: no downloads, no network, no cached managed installs, and an unchanged child PATH. + +## `node setup-system-node.cjs` + + +## `node setup-system-tools.cjs` + + +## `vpt write-file package.json '{"packageManager":"pnpm@10.18.0"}'` + + +## `node list-managed.cjs` + +Managed installs before the system-only steps: the pinned runtime is cached, and the override is not. + +``` +managed Node.js (.node-version pin): true +managed Node.js (VP_NODE_VERSION override): false +package_manager: (empty) +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_NODE_MANAGER=system-only VP_NODE_VERSION=24.0.0 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 node -p process.execPath.includes('system-bin')` + +The node shim runs system Node.js, ignoring the session override and the cached managed runtime. + +``` +true +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} EXPECTED_PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_NODE_MANAGER=system-only VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 node -p 'process.env.PATH === process.env.EXPECTED_PATH'` + +The system Node.js directory is not moved to the front of the child PATH. + +``` +true +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} EXPECTED_PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_NODE_MANAGER=system-only VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 npm --version` + +npm follows its own (managed) mode: without an npm pin it is the npm beside system Node.js, and the child PATH is unchanged. + +``` +system npm --version +same PATH +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_PNPM_MANAGER=system-only VP_PM_MANAGER=yes NPM_CONFIG_REGISTRY=http://127.0.0.1:1 pnpm --version` + +VP_PNPM_MANAGER=system-only wins over VP_PM_MANAGER=yes and over the packageManager pin. Node.js stays managed, so only its directory is added to the child PATH. + +``` +system pnpm --version +changed PATH +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_PNPM_MANAGER=yes VP_PM_MANAGER=system-only NPM_CONFIG_REGISTRY=http://127.0.0.1:1 npm --version` + +VP_PM_MANAGER=system-only applies to families without their own value. + +``` +system npm --version +changed PATH +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_NODE_MANAGER=System-Only node -p process.execPath.includes('system-bin')` + +Only the exact lowercase value is system-only; managed config.json stays in effect. + +``` +false +``` + +## `node list-managed.cjs` + +The system-only steps downloaded nothing. + +``` +managed Node.js (.node-version pin): true +managed Node.js (VP_NODE_VERSION override): false +package_manager: (empty) +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_PNPM_MANAGER=yes VP_PM_MANAGER=system-only node check-managed-pnpm.cjs` + +VP_PNPM_MANAGER=yes wins over VP_PM_MANAGER=system-only, so the shim selects the pinned managed pnpm instead of the system one. + +``` +system pnpm: false +managed pnpm installed: true +``` + +## `PATH=${VP_HOME}/bin VP_NODE_MANAGER=system-only node --version` + +Without a system Node.js on PATH, the shim fails instead of using the managed runtime. + +**Exit code:** 1 + +``` +vp: VP_NODE_MANAGER=system-only requires a system 'node' on PATH, but none was found +``` diff --git a/crates/vp_command/Cargo.toml b/crates/vp_command/Cargo.toml index 4b9f9a91f0..1abaa9e617 100644 --- a/crates/vp_command/Cargo.toml +++ b/crates/vp_command/Cargo.toml @@ -24,6 +24,7 @@ nix = { workspace = true, features = ["signal", "term"] } [dev-dependencies] tempfile = { workspace = true } +vp_shared = { workspace = true, features = ["test-utils"] } tokio = { workspace = true, features = ["macros", "test-util"] } [lints] diff --git a/crates/vp_command/src/external_tool.rs b/crates/vp_command/src/external_tool.rs index 5b7bdc9017..fe04ed8e4e 100644 --- a/crates/vp_command/src/external_tool.rs +++ b/crates/vp_command/src/external_tool.rs @@ -20,12 +20,17 @@ pub struct ExternalToolQuery<'a> { pub bypass: &'a [PathBuf], /// Canonicalized executable to exclude, or `None` to exclude no executable. pub self_exe: Option<&'a Path>, + /// Also skip Vite+-managed runtimes and package managers (see + /// [`is_managed_install`]) and the Vite+ fallback-bin directory, so that a + /// managed install left on PATH never counts as the system tool. + pub exclude_managed: bool, } /// Find `tool` on PATH outside Vite+ installations. /// /// Skips the Vite+ bin directory, the bypass directories, shims from other /// installations, and `self_exe`. Those would otherwise dispatch back into vp. +/// With [`ExternalToolQuery::exclude_managed`], also skips managed installs. /// `cwd` only resolves relative PATH entries. pub fn find_external_tool( tool: &str, @@ -34,6 +39,7 @@ pub fn find_external_tool( ) -> Option { let env = vp_shared::EnvConfig::get(); let bin_dir = env.dirs.bin.as_path(); + let fallback_bin = query.exclude_managed.then(|| env.dirs.fallback_bin()); tracing::debug!("path_var: {:?}", query.path); tracing::debug!("bypass_paths: {:?}", query.bypass); @@ -42,7 +48,11 @@ pub fn find_external_tool( // matches from them as relative paths, which `AbsolutePathBuf` rejects; // `~`-prefixed entries are left to `which`'s own tilde expansion. let mut filtered_paths: Vec<_> = std::env::split_paths(query.path) - .filter(|p| p != bin_dir && !query.bypass.iter().any(|bp| p == bp)) + .filter(|p| { + p != bin_dir + && !query.bypass.iter().any(|bp| p == bp) + && fallback_bin.as_ref().is_none_or(|fallback| p != fallback.as_path()) + }) .map(|p| if p.is_absolute() || p.starts_with("~") { p } else { cwd.as_path().join(p) }) .collect(); @@ -59,6 +69,7 @@ pub fn find_external_tool( if !vp_shared::is_windows_trampoline(resolved.as_path()) && !is_unix_shim && (query.self_exe.is_none() || canonical.as_deref() != query.self_exe) + && !(query.exclude_managed && is_managed_install(resolved.as_path())) { return Some(resolved); } @@ -81,11 +92,90 @@ pub fn find_external_tool( /// Whether `path` resolves into a Vite+-managed Node.js runtime or package /// manager, that is, under `/js_runtime` or `/package_manager`. pub fn is_managed_install(path: &Path) -> bool { + resolves_under_data(path, &["js_runtime", "package_manager"]) +} + +/// Whether `path` resolves into a Vite+-managed Node.js runtime, that is, +/// under `/js_runtime`. This includes the npm bundled with it. +pub fn is_managed_runtime(path: &Path) -> bool { + resolves_under_data(path, &["js_runtime"]) +} + +fn resolves_under_data(path: &Path, directories: &[&str]) -> bool { let Ok(target) = path.canonicalize() else { return false; }; let data = &vp_shared::EnvConfig::get().dirs.data; - ["js_runtime", "package_manager"].iter().any(|directory| { + directories.iter().any(|directory| { data.join(directory).as_path().canonicalize().is_ok_and(|root| target.starts_with(root)) }) } + +#[cfg(test)] +mod tests { + use vp_shared::{EnvConfig, env_vars}; + + use super::*; + + fn fake_executable(dir: &Path, name: &str) -> PathBuf { + std::fs::create_dir_all(dir).unwrap(); + #[cfg(windows)] + let path = dir.join(format!("{name}.exe")); + #[cfg(not(windows))] + let path = dir.join(name); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path + } + + fn find(path: &[&Path], exclude_managed: bool, cwd: &AbsolutePath) -> Option { + let path = std::env::join_paths(path).unwrap(); + let query = ExternalToolQuery { path: &path, bypass: &[], self_exe: None, exclude_managed }; + find_external_tool("node", &query, cwd).map(|path| path.as_path().to_path_buf()) + } + + #[test] + fn exclude_managed_skips_managed_installs_and_fallback_bin() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let runtime = home.join("js_runtime").join("node").join("24.0.0").join("bin"); + let fallback = home.join("fallback-bin"); + let system = temp.path().join("system"); + let managed_node = fake_executable(&runtime, "node"); + let fallback_node = fake_executable(&fallback, "node"); + let system_node = fake_executable(&system, "node"); + let cwd = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + + EnvConfig::with_vars([(env_vars::VP_HOME, home.as_path())], |_| { + assert!(is_managed_install(&managed_node)); + assert!(is_managed_runtime(&managed_node)); + assert!(!is_managed_install(&system_node)); + + // Without the option, managed installs on PATH are reported as before. + assert_eq!(find(&[&runtime, &system], false, &cwd), Some(managed_node.clone())); + assert_eq!(find(&[&fallback, &system], false, &cwd), Some(fallback_node.clone())); + + assert_eq!(find(&[&runtime, &system], true, &cwd), Some(system_node.clone())); + assert_eq!(find(&[&fallback, &system], true, &cwd), Some(system_node.clone())); + assert_eq!(find(&[&runtime, &fallback], true, &cwd), None); + }); + } + + #[test] + fn managed_runtime_excludes_package_managers() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let pnpm = fake_executable( + &home.join("package_manager").join("pnpm").join("10.0.0").join("pnpm").join("bin"), + "pnpm", + ); + EnvConfig::with_vars([(env_vars::VP_HOME, home.as_path())], |_| { + assert!(is_managed_install(&pnpm)); + assert!(!is_managed_runtime(&pnpm)); + }); + } +} diff --git a/crates/vp_command/src/lib.rs b/crates/vp_command/src/lib.rs index 69ebf982eb..b87383bcf4 100644 --- a/crates/vp_command/src/lib.rs +++ b/crates/vp_command/src/lib.rs @@ -22,7 +22,9 @@ use vt_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; mod external_tool; mod ps1_shim; -pub use external_tool::{ExternalToolQuery, find_external_tool, is_managed_install}; +pub use external_tool::{ + ExternalToolQuery, find_external_tool, is_managed_install, is_managed_runtime, +}; pub use ps1_shim::rewrite_cmd_to_powershell; bitflags! { diff --git a/crates/vp_error/src/lib.rs b/crates/vp_error/src/lib.rs index 5fb2b7eebd..0a5a2e2ea8 100644 --- a/crates/vp_error/src/lib.rs +++ b/crates/vp_error/src/lib.rs @@ -96,6 +96,13 @@ pub enum Error { #[error("Invalid argument: {0}")] InvalidArgument(Str), + /// A `VP_*_MANAGER=system-only` variable forbids downloading or using a + /// managed release of `tool`, and resolving one through the registry. + #[error( + "{variable}=system-only: Vite+ uses only the system {tool} and does not download or use a managed {tool}" + )] + DisabledBySystemOnly { tool: Str, variable: &'static str }, + #[cfg(feature = "migration")] #[error(transparent)] AstGrepConfigError(#[from] ast_grep_config::RuleConfigError), diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 136b4cbaa4..e75842649b 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -32,6 +32,41 @@ pub enum ShimMode { SystemFirst, } +/// Run-time mode of a tool: the `config.json` shim mode, unless a +/// `VP_*_MANAGER=system-only` variable restricts the tool to the system. +/// +/// `SystemOnly` comes only from the environment. Other `VP_*_MANAGER` values +/// apply only at installation, so the configured mode stays in effect for them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EffectiveMode { + /// Vite+ manages the tool. + Managed, + /// Prefer the system tool and fall back to the managed one. + SystemFirst, + /// Use only the system tool; never download or use a managed one. + SystemOnly, +} + +impl EffectiveMode { + fn from_config(mode: ShimMode, system_only: bool) -> Self { + match (system_only, mode) { + (true, _) => Self::SystemOnly, + (false, ShimMode::Managed) => Self::Managed, + (false, ShimMode::SystemFirst) => Self::SystemFirst, + } + } +} + +/// Effective Node.js mode: `SystemOnly` under `VP_NODE_MANAGER=system-only`, +/// otherwise the configured shim mode. +#[must_use] +pub fn effective_node_mode(config: &Config) -> EffectiveMode { + EffectiveMode::from_config( + config.node_shim_mode, + vp_shared::EnvConfig::get().node_system_only(), + ) +} + /// User configuration stored in `/config.json` #[derive(Serialize, Deserialize, Default, Debug)] #[serde(rename_all = "camelCase")] @@ -638,6 +673,28 @@ mod tests { use super::*; + #[test] + fn effective_node_mode_follows_config_unless_system_only() { + for (shim_mode, configured) in [ + (ShimMode::Managed, EffectiveMode::Managed), + (ShimMode::SystemFirst, EffectiveMode::SystemFirst), + ] { + let config = Config { node_shim_mode: shim_mode, ..Config::default() }; + // Unset, `yes`, `no` and unrecognized values keep the configured mode. + for (value, expected) in [ + (None, configured), + (Some("yes"), configured), + (Some("no"), configured), + (Some("System-Only"), configured), + (Some("system-only"), EffectiveMode::SystemOnly), + ] { + vp_shared::EnvConfig::with_vars([(env_vars::VP_NODE_MANAGER, value)], |_| { + assert_eq!(effective_node_mode(&config), expected, "{shim_mode:?} {value:?}"); + }); + } + } + } + #[test] fn test_get_node_modules_dir_probes_unix_layout() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index f2b2540090..dd91904f72 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -9,7 +9,7 @@ use console::style; use vp_pm_cli::HttpClient; use vp_setup::{install, integrity, platform, registry}; use vp_shared::output; -use vt_path::AbsolutePathBuf; +use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::error::Error; @@ -227,6 +227,9 @@ async fn install_platform_and_main( )); } + // A target without system-only support would download managed tools. + ensure_system_only_support(&binary_path, new_version).await?; + // Generate wrapper package.json that declares vite-plus as a dependency install::generate_wrapper_package_json(version_dir, new_version).await?; @@ -278,6 +281,22 @@ async fn install_platform_and_main( Ok(ExitStatus::default()) } +/// Refuse to switch to `binary` while a `VP_*_MANAGER=system-only` variable is +/// set, unless the binary supports system-only mode. +async fn ensure_system_only_support(binary: &AbsolutePath, target: &str) -> Result<(), Error> { + let variables = vp_shared::EnvConfig::get().system_only_variables(); + if variables.is_empty() { + return Ok(()); + } + let support = vp_setup::support::probe_self_setup(binary, true).await?; + if support == vp_setup::support::SelfSetupSupport::SystemOnly { + return Ok(()); + } + Err(Error::Upgrade( + vp_setup::support::system_only_unsupported_message(target, &variables).into(), + )) +} + /// Execute rollback to the previous version. #[allow(clippy::print_stderr)] async fn execute_rollback( @@ -303,6 +322,9 @@ async fn execute_rollback( output::info(&format!("switching from {} {} {}", current_version, output::ARROW, previous)); } + ensure_system_only_support(&prev_dir.join("bin").join(vp_setup::VP_BINARY_NAME), &previous) + .await?; + // Save the current version as the new "previous" before swapping install::save_previous_version(install_dir).await?; @@ -322,3 +344,54 @@ async fn execute_rollback( Ok(ExitStatus::default()) } + +#[cfg(all(test, unix))] +mod tests { + use vp_shared::{EnvConfig, env_vars}; + + use super::*; + + #[tokio::test] + async fn system_only_requires_target_support() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let script = |name: &str, body: &str| { + let path = temp.path().join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + AbsolutePathBuf::new(path).unwrap() + }; + let supported = script( + "supported", + r#"echo vite-plus-self-setup-v1; [ "$VP_SELF_SETUP_SUPPORT_CHECK" = system-only ] && echo vite-plus-self-setup-system-only; exit 0"#, + ); + let unsupported = script("unsupported", "echo vite-plus-self-setup-v1"); + + let managers = |node: Option<&'static str>| { + [ + (env_vars::VP_NODE_MANAGER, node), + (env_vars::VP_PM_MANAGER, None), + (env_vars::VP_NPM_MANAGER, None), + (env_vars::VP_PNPM_MANAGER, None), + (env_vars::VP_YARN_MANAGER, None), + (env_vars::VP_BUN_MANAGER, None), + ] + }; + EnvConfig::with_vars_async(managers(None), |_| async { + ensure_system_only_support(&unsupported, "0.1.0").await.unwrap(); + }) + .await; + EnvConfig::with_vars_async(managers(Some("system-only")), |_| async { + ensure_system_only_support(&supported, "9.0.0").await.unwrap(); + let error = ensure_system_only_support(&unsupported, "0.1.0").await.unwrap_err(); + assert!( + error + .to_string() + .contains("vite-plus 0.1.0 does not support VP_NODE_MANAGER=system-only"), + "{error}" + ); + }) + .await; + } +} diff --git a/crates/vp_global_cli/src/commands/vpx.rs b/crates/vp_global_cli/src/commands/vpx.rs index 6f9613c117..c2c7274cb9 100644 --- a/crates/vp_global_cli/src/commands/vpx.rs +++ b/crates/vp_global_cli/src/commands/vpx.rs @@ -14,7 +14,10 @@ use vp_shared::{PrependOptions, ToolPathEnv, exit_code_from_status, output}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::vpx_script; -use crate::{commands::env::config, shim::dispatch}; +use crate::{ + commands::env::config::{self, EffectiveMode}, + shim::dispatch, +}; /// Parsed vpx flags. #[derive(Debug, Default)] @@ -229,23 +232,37 @@ async fn find_global_binary(cmd: &str) -> Option { /// Execute a globally installed binary. /// /// Ensures the required Node.js version is installed, prepends its bin dir -/// and local node_modules/.bin dirs to PATH, then executes. +/// and local node_modules/.bin dirs to PATH, then executes. System-only +/// Node.js comes from PATH, and its directory is not added again. async fn execute_global_binary(bin: GlobalBinary, args: &[String], cwd: &AbsolutePath) -> i32 { + let node_system_only = vp_shared::EnvConfig::get().node_system_only(); // Ensure Node.js is installed and locate its binary - let node_path = match dispatch::ensure_installed(&bin.node_version).await { - Ok(p) => p, - Err(e) => { - output::error(&format!("vpx: Failed to install Node {}: {e}", bin.node_version)); - return 1; + let node_path = if node_system_only { + match dispatch::resolve_effective_tool("node", EffectiveMode::SystemOnly) { + Ok(path) => path.expect("system-only resolution returns a path or an error"), + Err(e) => { + output::error(&format!("vpx: {e}")); + return 1; + } + } + } else { + match dispatch::ensure_installed(&bin.node_version).await { + Ok(p) => p, + Err(e) => { + output::error(&format!("vpx: Failed to install Node {}: {e}", bin.node_version)); + return 1; + } } }; // Prepend Node.js bin dir to PATH - let node_bin_dir = node_path.parent().expect("Node has no parent directory"); let mut child_env = ToolPathEnv::from_env(); - if let Err(error) = child_env.prepend(node_bin_dir, &["node"], PrependOptions::default()) { - output::error(&format!("vpx: {error}")); - return 1; + if !node_system_only { + let node_bin_dir = node_path.parent().expect("Node has no parent directory"); + if let Err(error) = child_env.prepend(node_bin_dir, &["node"], PrependOptions::default()) { + output::error(&format!("vpx: {error}")); + return 1; + } } if let Err(error) = prepend_node_modules_bin_to_path(cwd, &mut child_env) { diff --git a/crates/vp_global_cli/src/error.rs b/crates/vp_global_cli/src/error.rs index 1cd52d92aa..ce94ddbfde 100644 --- a/crates/vp_global_cli/src/error.rs +++ b/crates/vp_global_cli/src/error.rs @@ -52,6 +52,10 @@ pub enum Error { #[error("Upgrade error: {0}")] Upgrade(Str), + /// A `VP_*_MANAGER=system-only` variable requires a system tool that PATH lacks. + #[error("{variable}=system-only requires a system '{tool}' on PATH, but none was found")] + SystemToolNotFound { tool: Str, variable: &'static str }, + #[error("{0}")] Setup(#[from] vp_setup::error::Error), diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index 5259962b08..5e063b2c6f 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -313,8 +313,10 @@ fn dump_dirs_from_env_config() -> bool { #[tokio::main] async fn main() -> ExitCode { // Probe before tracing, directory resolution, or argument dispatch can emit output. - if env::var_os(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK).is_some() { - vp_shared::output::print_stdout_line(format_args!("vite-plus-self-setup-v1")); + if let Some(check) = env::var_os(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK) { + for line in vp_setup::support::support_response(&check) { + vp_shared::output::print_stdout_line(format_args!("{line}")); + } return ExitCode::SUCCESS; } diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index b220b5d88b..ddbd8aa5b2 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -393,6 +393,9 @@ fn shim_mode(preference: Option) -> Option match preference? { ManagerPreference::Yes => Some(config::ShimMode::Managed), ManagerPreference::No => Some(config::ShimMode::SystemFirst), + // Installation does not accept `system-only` yet; like an unrecognized + // value, it falls through to the next preference. + ManagerPreference::SystemOnly => None, } } diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 97555c7642..56f5bba558 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -17,7 +17,7 @@ use crate::{ commands::{ env::{ bin_config::{BinConfig, BinSource, validate_bin_name}, - config::{self, ShimMode}, + config::{self, EffectiveMode}, package_manager, package_metadata::PackageMetadata, }, @@ -687,7 +687,8 @@ fn resolve_npm_prefix( /// /// 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. +/// latest release when no matching pin exists. A system-only family always +/// comes from PATH, without consulting pins or the registry. async fn resolve_package_manager_tool( cwd: &AbsolutePath, tool: &str, @@ -695,6 +696,10 @@ async fn resolve_package_manager_tool( let Some(expected_type) = PackageManagerType::from_tool(tool) else { return Ok(None); }; + let bin_name = expected_type.bin_name_for_tool(tool); + if vp_pm_cli::system_only_variable(expected_type).is_some() { + return resolve_effective_tool(bin_name, EffectiveMode::SystemOnly); + } let resolution = package_manager::resolve_shim_for(cwd, expected_type).await?; let (version, hash) = match resolution { @@ -703,23 +708,33 @@ async fn resolve_package_manager_tool( None => ("latest".into(), None), }; - let bin_name = expected_type.bin_name_for_tool(tool); let bin_path = ensure_package_manager_bin(expected_type, &version, hash.as_deref(), bin_name).await?; Ok(Some(bin_path)) } +/// Prepare the child PATH for a JS tool running on the selected Node.js. +/// +/// System-only tools add no directories: a resolved system directory may be a +/// canonical path missing from PATH, and adding it would reorder the user's +/// PATH. Children reach the same system tools through PATH or the shims. async fn prepare_js_child_path( cwd: &AbsolutePath, node_bin_dir: &AbsolutePath, mut env: ToolPathEnv, ) -> Result { - env.prepend(node_bin_dir, &["node"], PrependOptions::default())?; + let node_system_only = vp_shared::EnvConfig::get().node_system_only(); + if !node_system_only { + env.prepend(node_bin_dir, &["node"], PrependOptions::default())?; + } + if vp_pm_cli::system_only_variable(PackageManagerType::Npm).is_some() { + return Ok(env); + } if let Some(npm_path) = resolve_package_manager_tool(cwd, "npm").await? { if let Some(bin_dir) = npm_path.parent() { env.prepend(bin_dir, PackageManagerType::Npm.bin_names(), PrependOptions::default())?; } - } else { + } else if !node_system_only { // Bundled npm belongs to the selected runtime, not an ancestor's runtime. let tools: Vec<_> = PackageManagerType::Npm .bin_names() @@ -735,6 +750,130 @@ async fn prepare_js_child_path( Ok(env) } +/// The variable that restricts `tool` (Node.js or a package-manager family) +/// to the system, if any. Package binaries have no such variable. +fn system_only_variable_for_tool(tool: &str) -> Option<&'static str> { + if tool == "node" { + return vp_shared::EnvConfig::get().node_system_only().then_some(env_vars::VP_NODE_MANAGER); + } + PackageManagerType::from_tool(tool).and_then(vp_pm_cli::system_only_variable) +} + +/// Whether a managed executable selected by a parent process may be reused. +/// +/// System-only tools never reuse one. When Node.js is system-only, an +/// inherited managed runtime (including its bundled npm) is not reused either; +/// managed package managers still are, since they run on the PATH `node`. +fn reuses_inherited_tool(tool: &str, path: &AbsolutePath) -> bool { + vp_command::is_managed_install(path.as_path()) + && system_only_variable_for_tool(tool).is_none() + && !(vp_shared::EnvConfig::get().node_system_only() + && vp_command::is_managed_runtime(path.as_path())) +} + +/// Node.js, the tool, and the child PATH selected for a core shim. +struct CoreToolSelection { + node_path: AbsolutePathBuf, + tool_path: AbsolutePathBuf, + /// Managed version resolution; `None` when Node.js comes from the system. + resolution: Option, + child_env: ToolPathEnv, +} + +/// Select Node.js and `tool` for a core shim and prepare the child PATH. +/// +/// On failure, returns the user-facing messages, each printed with a `vp: ` +/// prefix. This function never executes the tool, so it can be tested directly. +async fn select_core_tool( + tool: &str, + cwd: &AbsolutePathBuf, + env: ToolPathEnv, +) -> Result> { + // Ensure Node.js is installed and locate its binary for PATH preparation. + // Package-manager shims can use their own declared version, but JS-based + // package managers still need the Node.js runtime selected by its mode. + // System-only Node.js ignores inherited and session selections. + let node_system_only = vp_shared::EnvConfig::get().node_system_only(); + let inherited_node = (!node_system_only + && PackageManagerType::from_tool(tool).is_some() + && env.contains("node")) + .then(|| find_system_tool("node")) + .flatten(); + let node_is_inherited = inherited_node.is_some(); + let system_node = if node_system_only { + resolve_effective_tool("node", EffectiveMode::SystemOnly) + .map_err(|e| vec![e.to_string()])? + } else if node_is_inherited { + inherited_node + } else if PackageManagerType::from_tool(tool).is_some() { + let config = config::load_config() + .await + .map_err(|error| vec![format!("Failed to load Node.js shim mode: {error}")])?; + resolve_effective_tool("node", config::effective_node_mode(&config)) + .map_err(|e| vec![e.to_string()])? + } else { + None + }; + let resolution = if system_node.is_none() { + Some(resolve_with_cache(cwd).await.map_err(|error| { + vec![ + format!("Failed to resolve Node version: {error}"), + "Run 'vp env doctor' for diagnostics".to_string(), + ] + })?) + } else { + None + }; + let mut node_path = if let Some(system_node) = system_node { + system_node + } else { + let resolution = resolution.as_ref().expect("managed Node.js has no resolution"); + ensure_installed(&resolution.version).await.map_err(|error| { + vec![format!("Failed to install Node {}: {error}", resolution.version)] + })? + }; + + // 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, bundled) = match resolve_package_manager_tool(cwd, tool).await { + Ok(Some(path)) => (path, false), + Ok(None) => { + let (runtime_path, tool_path) = + resolve_bundled_tool(node_path, tool, resolution.is_none()) + .map_err(|error| vec![format!("Tool '{tool}' not found: {error}")])?; + node_path = runtime_path; + (tool_path, true) + } + Err(e) => return Err(vec![format!("Failed to resolve package manager for '{tool}': {e}")]), + }; + + // Prepare environment for recursive invocations. Keep the project Node.js + // 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"); + let mut child_env = if node_is_inherited { + // Adding a missing tool must retain the parent's Node and npm precedence. + env + } else { + prepare_js_child_path(cwd, node_bin_dir, env) + .await + .map_err(|error| vec![format!("Failed to prepare child process PATH: {error}")])? + }; + // A system-only family, or npm bundled with system-only Node.js, lives in + // a system directory, which stays out of the child PATH. + if let Some(kind) = PackageManagerType::from_tool(tool) + && vp_pm_cli::system_only_variable(kind).is_none() + && !(bundled && node_system_only) + && let Some(bin_dir) = tool_path.parent() + { + child_env + .prepend(bin_dir, kind.bin_names(), PrependOptions::default()) + .map_err(|error| vec![format!("Failed to prepare package manager PATH: {error}")])?; + } + + Ok(CoreToolSelection { node_path, tool_path, resolution, child_env }) +} + /// Main shim dispatch entry point. /// /// Called when the binary is invoked as a core shim or package binary. @@ -773,7 +912,7 @@ pub async fn dispatch(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { PackageManagerType::from_tool(tool).map_or(tool, |kind| kind.bin_name_for_tool(tool)); if env.contains(inherited_tool) && let Some(path) = find_system_tool(inherited_tool) - && vp_command::is_managed_install(path.as_path()) + && reuses_inherited_tool(tool, &path) { return exec::exec_tool(&path, args, env); } @@ -799,102 +938,21 @@ pub async fn dispatch(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { } }; - // Ensure Node.js is installed and locate its binary for PATH preparation. - // Package-manager shims can use their own declared version, but JS-based - // package managers still need the Node.js runtime selected by its mode. - let inherited_node = (PackageManagerType::from_tool(tool).is_some() && env.contains("node")) - .then(|| find_system_tool("node")) - .flatten(); - let node_is_inherited = inherited_node.is_some(); - let system_node = if node_is_inherited { - inherited_node - } else if PackageManagerType::from_tool(tool).is_some() { - match config::load_config().await { - Ok(config) if config.node_shim_mode == ShimMode::SystemFirst => { - find_system_tool("node") - } - Ok(_) => None, - Err(error) => { - eprintln!("vp: Failed to load Node.js shim mode: {error}"); - return 1; - } - } - } else { - None - }; - let resolution = if system_node.is_none() { - match resolve_with_cache(&cwd).await { - Ok(resolution) => Some(resolution), - Err(error) => { - eprintln!("vp: Failed to resolve Node version: {error}"); - eprintln!("vp: Run 'vp env doctor' for diagnostics"); - return 1; - } - } - } else { - None - }; - let mut node_path = if let Some(system_node) = system_node { - system_node - } else { - let resolution = resolution.as_ref().expect("managed Node.js has no resolution"); - match ensure_installed(&resolution.version).await { - Ok(path) => path, - Err(error) => { - eprintln!("vp: Failed to install Node {}: {error}", resolution.version); - return 1; - } - } - }; - - // 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 resolve_bundled_tool(node_path, tool, resolution.is_none()) { - Ok((runtime_path, tool_path)) => { - node_path = runtime_path; - tool_path - } - Err(error) => { - eprintln!("vp: Tool '{tool}' not found: {error}"); + let CoreToolSelection { node_path, tool_path, resolution, child_env } = + match select_core_tool(tool, &cwd, env).await { + Ok(selection) => selection, + Err(messages) => { + for message in messages { + eprintln!("vp: {message}"); + } 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. // Only captured for npm to avoid unnecessary work on node/npx hot path. 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 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"); - let mut child_env = if node_is_inherited { - // Adding a missing tool must retain the parent's Node and npm precedence. - env - } else { - match prepare_js_child_path(&cwd, node_bin_dir, env).await { - Ok(env) => env, - Err(error) => { - eprintln!("vp: Failed to prepare child process PATH: {error}"); - return 1; - } - } - }; - if let Some(kind) = PackageManagerType::from_tool(tool) - && let Some(bin_dir) = tool_path.parent() - && let Err(error) = child_env.prepend(bin_dir, kind.bin_names(), PrependOptions::default()) - { - eprintln!("vp: Failed to prepare package manager PATH: {error}"); - return 1; - } - // Optional debug env vars if std::env::var(env_vars::VP_DEBUG_SHIM).is_ok() { if let Some(resolution) = resolution.as_ref() { @@ -1019,9 +1077,16 @@ pub(crate) async fn package_binary_invocation( node_version: &str, mut env: ToolPathEnv, ) -> Result<(AbsolutePathBuf, Vec, ToolPathEnv), String> { - let node_path = ensure_installed(node_version) - .await - .map_err(|e| format!("Failed to install Node {node_version}: {e}"))?; + let node_system_only = vp_shared::EnvConfig::get().node_system_only(); + let node_path = if node_system_only { + resolve_effective_tool("node", EffectiveMode::SystemOnly) + .map_err(|e| e.to_string())? + .expect("system-only resolution returns a path or an error") + } else { + ensure_installed(node_version) + .await + .map_err(|e| format!("Failed to install Node {node_version}: {e}"))? + }; // Locate the actual binary in the package directory let binary_path = locate_package_binary(metadata, tool) @@ -1033,8 +1098,10 @@ pub(crate) async fn package_binary_invocation( let child_env = if let Ok(cwd) = current_dir() { prepare_js_child_path(&cwd, node_bin_dir, env).await.map_err(|error| error.to_string())? } else { - env.prepend(node_bin_dir, &["node"], PrependOptions::default()) - .map_err(|error| error.to_string())?; + if !node_system_only { + env.prepend(node_bin_dir, &["node"], PrependOptions::default()) + .map_err(|error| error.to_string())?; + } env }; @@ -1105,7 +1172,7 @@ pub(crate) fn locate_package_binary( /// Bypass shim and use system tool. fn bypass_to_system(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { - match find_system_tool(tool) { + match find_bypass_tool(tool) { Some(system_path) => exec::exec_tool(&system_path, args, env), None => { eprintln!("vp: VP_BYPASS is set but no system '{tool}' found in PATH"); @@ -1114,6 +1181,21 @@ fn bypass_to_system(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { } } +/// The system tool for `VP_BYPASS`. +/// +/// A system-only tool skips managed installs left on PATH. So does npm when +/// Node.js is system-only, because the npm bundled with a managed runtime +/// would run that runtime. +fn find_bypass_tool(tool: &str) -> Option { + let npm_on_system_only_node = vp_shared::EnvConfig::get().node_system_only() + && PackageManagerType::from_tool(tool) == Some(PackageManagerType::Npm); + if system_only_variable_for_tool(tool).is_some() || npm_on_system_only_node { + current_dir().ok().and_then(|cwd| find_system_only_tool(tool, &cwd)) + } else { + find_system_tool(tool) + } +} + /// Resolve version with caching. pub(crate) async fn resolve_with_cache(cwd: &AbsolutePathBuf) -> Result { // Fast-path: VP_NODE_VERSION env var set by `vp env use` @@ -1223,7 +1305,11 @@ fn node_install_dir(version: &str) -> AbsolutePathBuf { } /// Ensure Node.js is installed. +/// +/// Fails in system-only mode, even for a cached version. Callers that support +/// system-only mode select the system Node.js before reaching this function. pub(crate) async fn ensure_installed(version: &str) -> Result { + vp_js_runtime::ensure_managed_node_allowed().map_err(|error| error.to_string())?; let home_dir = node_install_dir(version); #[cfg(windows)] @@ -1332,6 +1418,36 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option Result, Error> { + match mode { + EffectiveMode::Managed => Ok(None), + EffectiveMode::SystemFirst => Ok(find_system_tool(tool)), + EffectiveMode::SystemOnly => { + let cwd = current_dir()?; + find_system_only_tool(tool, &cwd).map(Some).ok_or_else(|| Error::SystemToolNotFound { + tool: tool.into(), + variable: system_only_variable_for_tool(tool).unwrap_or(env_vars::VP_NODE_MANAGER), + }) + } + } +} + +/// System-only lookup: like [`find_external_tool_in`], but managed installs +/// and the fallback-bin directory never count as the system tool. +fn find_system_only_tool(tool: &str, cwd: &AbsolutePath) -> Option { + let inputs = ExternalToolInputs::from_process_env()?; + vp_command::find_external_tool(tool, &inputs.query(true), cwd) +} + /// Process-dependent inputs for [`vp_command::find_external_tool`]. struct ExternalToolInputs { path: std::ffi::OsString, @@ -1353,11 +1469,12 @@ impl ExternalToolInputs { Some(Self { path, bypass, self_exe }) } - fn query(&self) -> vp_command::ExternalToolQuery<'_> { + fn query(&self, exclude_managed: bool) -> vp_command::ExternalToolQuery<'_> { vp_command::ExternalToolQuery { path: &self.path, bypass: &self.bypass, self_exe: self.self_exe.as_deref(), + exclude_managed, } } } @@ -1365,12 +1482,13 @@ impl ExternalToolInputs { /// Explicit bypass skips Vite+ installations and the directories listed by the caller. fn find_external_tool_in(tool: &str, cwd: &AbsolutePath) -> Option { let inputs = ExternalToolInputs::from_process_env()?; - vp_command::find_external_tool(tool, &inputs.query(), cwd) + vp_command::find_external_tool(tool, &inputs.query(false), cwd) } #[cfg(test)] mod tests { use tempfile::TempDir; + use vp_shared::EnvConfig; use super::*; @@ -2431,4 +2549,315 @@ mod tests { let result = resolve_npm_prefix(&parsed, &dummy_dir, &dummy_dir, &ToolPathEnv::from_env()); assert!(!result.as_path().as_os_str().is_empty()); } + + /// Directories for system-only shim tests: a managed Node.js cached under + /// `VP_HOME` and a separate system directory. + struct SystemOnlyLayout { + _temp: TempDir, + home: std::path::PathBuf, + managed_bin: std::path::PathBuf, + system_bin: std::path::PathBuf, + cwd: AbsolutePathBuf, + } + + impl SystemOnlyLayout { + fn new() -> Self { + let temp = TempDir::new().unwrap(); + let home = temp.path().join("home"); + let managed_bin = node_install_dir_in(&home, "24.0.0"); + let system_bin = temp.path().join("system-bin"); + let cwd = AbsolutePathBuf::new(temp.path().join("project")).unwrap(); + for dir in [&managed_bin, &system_bin, &cwd.as_path().to_path_buf()] { + std::fs::create_dir_all(dir).unwrap(); + } + create_fake_executable(&managed_bin, "node"); + create_fake_executable(&managed_bin, "npm"); + Self { _temp: temp, home, managed_bin, system_bin, cwd } + } + + /// PATH with the managed runtime first, as a parent shim leaves it. + fn path(&self) -> std::ffi::OsString { + std::env::join_paths([&self.managed_bin, &self.system_bin]).unwrap() + } + } + + fn node_install_dir_in(home: &std::path::Path, version: &str) -> std::path::PathBuf { + let dir = home.join("js_runtime").join("node").join(version); + if cfg!(windows) { dir } else { dir.join("bin") } + } + + fn child_path(env: ToolPathEnv) -> std::ffi::OsString { + let [(_, path), _] = env.into_envs(); + path + } + + const MANAGER_VARS: [&str; 6] = [ + env_vars::VP_NODE_MANAGER, + env_vars::VP_PM_MANAGER, + env_vars::VP_NPM_MANAGER, + env_vars::VP_PNPM_MANAGER, + env_vars::VP_YARN_MANAGER, + env_vars::VP_BUN_MANAGER, + ]; + + /// Pins `VP_HOME`, PATH and the manager variables; everything else in + /// `extra` is set as given. + fn system_only_vars( + layout: &SystemOnlyLayout, + path: &std::ffi::OsStr, + managers: &[(&'static str, &'static str)], + extra: &[(&'static str, std::ffi::OsString)], + ) -> Vec<(&'static str, Option)> { + let mut vars = vec![ + (env_vars::VP_HOME, Some(layout.home.clone().into_os_string())), + ("PATH", Some(path.to_os_string())), + (env_vars::VP_BYPASS, None), + (env_vars::VP_PATH_INJECTED_TOOLS, None), + ]; + vars.extend(MANAGER_VARS.into_iter().map(|name| { + (name, managers.iter().find(|(var, _)| *var == name).map(|(_, value)| value.into())) + })); + for (name, value) in extra { + vars.retain(|(var, _)| var != name); + vars.push((name, Some(value.clone()))); + } + vars + } + + #[test] + fn test_resolve_effective_tool_system_only_skips_managed_installs() { + let layout = SystemOnlyLayout::new(); + let system_node = create_fake_executable(&layout.system_bin, "node"); + let only_managed = std::env::join_paths([&layout.managed_bin]).unwrap(); + + let managers = [(env_vars::VP_NODE_MANAGER, "system-only")]; + EnvConfig::with_vars(system_only_vars(&layout, &layout.path(), &managers, &[]), |_| { + let resolved = resolve_effective_tool("node", EffectiveMode::SystemOnly).unwrap(); + assert_eq!(resolved.unwrap().as_path(), system_node); + assert!(resolve_effective_tool("node", EffectiveMode::Managed).unwrap().is_none()); + }); + EnvConfig::with_vars(system_only_vars(&layout, &only_managed, &managers, &[]), |_| { + let error = resolve_effective_tool("node", EffectiveMode::SystemOnly).unwrap_err(); + assert!( + matches!( + &error, + Error::SystemToolNotFound { tool, variable: env_vars::VP_NODE_MANAGER } + if tool == "node" + ), + "{error}" + ); + }); + // A package manager names the variable that made it system-only. + let managers = [(env_vars::VP_PM_MANAGER, "system-only")]; + EnvConfig::with_vars(system_only_vars(&layout, &only_managed, &managers, &[]), |_| { + let error = resolve_effective_tool("pnpm", EffectiveMode::SystemOnly).unwrap_err(); + assert!(matches!( + error, + Error::SystemToolNotFound { variable: env_vars::VP_PM_MANAGER, .. } + )); + }); + } + + #[tokio::test] + async fn test_ensure_installed_rejects_cached_node_in_system_only_mode() { + let layout = SystemOnlyLayout::new(); + let path = layout.path(); + EnvConfig::with_vars_async(system_only_vars(&layout, &path, &[], &[]), |_| async { + assert!(ensure_installed("24.0.0").await.is_ok()); + }) + .await; + let managers = [(env_vars::VP_NODE_MANAGER, "system-only")]; + EnvConfig::with_vars_async(system_only_vars(&layout, &path, &managers, &[]), |_| async { + let error = ensure_installed("24.0.0").await.unwrap_err(); + assert!(error.contains("VP_NODE_MANAGER=system-only"), "{error}"); + }) + .await; + } + + /// System-only Node.js ignores session overrides, the configured default, + /// and a managed runtime inherited from a parent, and adds nothing to PATH. + #[tokio::test] + async fn test_select_core_tool_system_only_node_ignores_managed_selections() { + let layout = SystemOnlyLayout::new(); + let system_node = create_fake_executable(&layout.system_bin, "node"); + std::fs::write(layout.home.join(config::SESSION_VERSION_FILE), "24.0.0").unwrap(); + std::fs::write(layout.home.join("config.json"), r#"{"defaultNodeVersion":"24.0.0"}"#) + .unwrap(); + let path = layout.path(); + let managers = [(env_vars::VP_NODE_MANAGER, "system-only")]; + let extra = [ + (env_vars::VP_NODE_VERSION, "24.0.0".into()), + (env_vars::VP_PATH_INJECTED_TOOLS, "node,npm".into()), + ]; + EnvConfig::with_vars_async( + system_only_vars(&layout, &path, &managers, &extra), + |_| async { + for tool in ["node", "pnpm"] { + // The inherited shortcut must not reuse the managed runtime. + let managed = AbsolutePathBuf::new(layout.managed_bin.join(if cfg!(windows) { + "node.exe" + } else { + "node" + })) + .unwrap(); + assert!(!reuses_inherited_tool(tool, &managed)); + } + let selection = + select_core_tool("node", &layout.cwd, ToolPathEnv::from_env()).await.unwrap(); + assert_eq!(selection.node_path.as_path(), system_node); + assert_eq!(selection.tool_path.as_path(), system_node); + assert!(selection.resolution.is_none()); + assert_eq!(child_path(selection.child_env), path); + }, + ) + .await; + } + + /// A system-only package manager comes from PATH without registry requests, + /// and its directory is not added to the child PATH. + #[tokio::test(flavor = "current_thread")] + async fn test_select_core_tool_system_only_package_manager_skips_registry() { + use httpmock::prelude::*; + + let layout = SystemOnlyLayout::new(); + let system_node = create_fake_executable(&layout.system_bin, "node"); + let system_pnpm = create_fake_executable(&layout.system_bin, "pnpm"); + std::fs::write(layout.cwd.join("package.json"), r#"{"packageManager":"pnpm@10.0.0"}"#) + .unwrap(); + let server = MockServer::start(); + let requests = server.mock(|when, then| { + when.any_request(); + then.status(500); + }); + let path = layout.path(); + let managers = [ + (env_vars::VP_NODE_MANAGER, "system-only"), + (env_vars::VP_PNPM_MANAGER, "system-only"), + (env_vars::VP_PM_MANAGER, "yes"), + ]; + let extra = [(env_vars::NPM_CONFIG_REGISTRY, server.base_url().into())]; + EnvConfig::with_vars_async( + system_only_vars(&layout, &path, &managers, &extra), + |_| async { + let selection = + select_core_tool("pnpm", &layout.cwd, ToolPathEnv::from_env()).await.unwrap(); + assert_eq!(selection.node_path.as_path(), system_node); + assert_eq!(selection.tool_path.as_path(), system_pnpm); + assert_eq!(child_path(selection.child_env), path); + }, + ) + .await; + requests.assert_hits(0); + } + + /// Under `VP_BYPASS`, system-only Node.js also skips the npm bundled with a + /// managed runtime; otherwise bypass keeps reporting the first PATH match. + #[test] + fn test_bypass_skips_managed_runtime_npm_with_system_only_node() { + let layout = SystemOnlyLayout::new(); + let system_npm = create_fake_executable(&layout.system_bin, "npm"); + let managed_npm = layout.managed_bin.join(if cfg!(windows) { "npm.exe" } else { "npm" }); + let path = layout.path(); + let extra = [(env_vars::VP_BYPASS, std::ffi::OsString::new())]; + + EnvConfig::with_vars(system_only_vars(&layout, &path, &[], &extra), |_| { + assert_eq!(find_bypass_tool("npm").unwrap().as_path(), managed_npm); + }); + let managers = [(env_vars::VP_NODE_MANAGER, "system-only")]; + EnvConfig::with_vars(system_only_vars(&layout, &path, &managers, &extra), |_| { + assert_eq!(find_bypass_tool("npm").unwrap().as_path(), system_npm); + assert_eq!(find_bypass_tool("npx"), None); + }); + } + + /// With managed Node.js and a system-only package manager, only the managed + /// runtime directory enters the child PATH, never the system directory. + #[tokio::test(flavor = "current_thread")] + async fn test_select_core_tool_system_only_package_manager_with_managed_node() { + let layout = SystemOnlyLayout::new(); + let system_pnpm = create_fake_executable(&layout.system_bin, "pnpm"); + let path = std::env::join_paths([&layout.system_bin]).unwrap(); + let managers = [(env_vars::VP_PNPM_MANAGER, "system-only")]; + let extra = [(env_vars::VP_NODE_VERSION, "24.0.0".into())]; + EnvConfig::with_vars_async( + system_only_vars(&layout, &path, &managers, &extra), + |_| async { + let selection = + select_core_tool("pnpm", &layout.cwd, ToolPathEnv::from_env()).await.unwrap(); + assert!(selection.node_path.as_path().starts_with(&layout.managed_bin)); + assert_eq!(selection.tool_path.as_path(), system_pnpm); + let child_path = child_path(selection.child_env); + let entries: Vec<_> = std::env::split_paths(&child_path).collect(); + assert_eq!(entries, [layout.managed_bin.clone(), layout.system_bin.clone()]); + }, + ) + .await; + } + + /// With system-only Node.js, a managed package manager selected by a parent + /// is still reused, unless its family is system-only too. + #[test] + fn test_reuses_inherited_managed_package_manager_with_system_only_node() { + let layout = SystemOnlyLayout::new(); + let pnpm_bin = layout + .home + .join("package_manager") + .join("pnpm") + .join("10.0.0") + .join("pnpm") + .join("bin"); + std::fs::create_dir_all(&pnpm_bin).unwrap(); + let pnpm = AbsolutePathBuf::new(create_fake_executable(&pnpm_bin, "pnpm")).unwrap(); + let npm = AbsolutePathBuf::new(layout.managed_bin.join(if cfg!(windows) { + "npm.exe" + } else { + "npm" + })) + .unwrap(); + let path = layout.path(); + + EnvConfig::with_vars(system_only_vars(&layout, &path, &[], &[]), |_| { + assert!(reuses_inherited_tool("pnpm", &pnpm)); + assert!(reuses_inherited_tool("npm", &npm)); + }); + let managers = [(env_vars::VP_NODE_MANAGER, "system-only")]; + EnvConfig::with_vars(system_only_vars(&layout, &path, &managers, &[]), |_| { + assert!(reuses_inherited_tool("pnpm", &pnpm)); + assert!(!reuses_inherited_tool("npm", &npm)); + }); + let managers = [ + (env_vars::VP_NODE_MANAGER, "system-only"), + (env_vars::VP_PNPM_MANAGER, "system-only"), + ]; + EnvConfig::with_vars(system_only_vars(&layout, &path, &managers, &[]), |_| { + assert!(!reuses_inherited_tool("pnpm", &pnpm)); + }); + } + + /// Bundled npm of a system-only Node.js reached through a symlinked PATH + /// entry: the canonical runtime directory is not added to the child PATH. + #[cfg(unix)] + #[tokio::test] + async fn test_select_core_tool_system_only_bundled_npm_keeps_child_path() { + use std::os::unix::fs::PermissionsExt; + + let layout = SystemOnlyLayout::new(); + let real_bin = layout.home.parent().unwrap().join("nodejs").join("bin"); + std::fs::create_dir_all(&real_bin).unwrap(); + let real_node = real_bin.join("node"); + // The probe `node -p process.execPath` reports the real executable. + std::fs::write(&real_node, format!("#!/bin/sh\necho '{}'\n", real_node.display())).unwrap(); + std::fs::set_permissions(&real_node, std::fs::Permissions::from_mode(0o755)).unwrap(); + let real_npm = create_fake_executable(&real_bin, "npm"); + std::os::unix::fs::symlink(&real_node, layout.system_bin.join("node")).unwrap(); + let path = std::env::join_paths([&layout.system_bin]).unwrap(); + let managers = [(env_vars::VP_NODE_MANAGER, "system-only")]; + EnvConfig::with_vars_async(system_only_vars(&layout, &path, &managers, &[]), |_| async { + let selection = + select_core_tool("npm", &layout.cwd, ToolPathEnv::from_env()).await.unwrap(); + assert_eq!(selection.tool_path.as_path(), real_npm.canonicalize().unwrap()); + assert_eq!(child_path(selection.child_env), path); + }) + .await; + } } diff --git a/crates/vp_installer/src/cli.rs b/crates/vp_installer/src/cli.rs index db5f8a4eec..dc395cca09 100644 --- a/crates/vp_installer/src/cli.rs +++ b/crates/vp_installer/src/cli.rs @@ -30,11 +30,51 @@ pub struct Options { #[arg(long = "no-node-manager")] pub no_node_manager: bool, + /// Resolved Node.js and package-manager management choice. `run` derives + /// it from `--no-node-manager`, `VP_NODE_MANAGER` and auto-detection. + #[arg(skip)] + pub node_manager: NodeManager, + /// Do not modify the User PATH #[arg(long = "no-modify-path")] pub no_modify_path: bool, } +/// Node.js management choice passed to the installed binary's self-setup. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum NodeManager { + /// Vite+ manages Node.js (`VP_NODE_MANAGER=yes`). + #[default] + Enabled, + /// Prefer the system Node.js (`VP_NODE_MANAGER=no`). + Disabled, + /// Use only the system Node.js. Only `VP_NODE_MANAGER=system-only` selects + /// it, and the interactive menu cannot change it. + SystemOnly, +} + +impl NodeManager { + /// The `VP_NODE_MANAGER` value for this choice. + #[must_use] + pub const fn env_value(self) -> &'static str { + match self { + Self::Enabled => "yes", + Self::Disabled => "no", + Self::SystemOnly => vp_shared::env_vars::SYSTEM_ONLY, + } + } + + /// The menu label for this choice. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Enabled => "enabled", + Self::Disabled => "disabled", + Self::SystemOnly => "system-only (set by VP_NODE_MANAGER)", + } + } +} + /// Parse CLI arguments, merging with environment variables. /// CLI flags take precedence over environment variables. pub fn parse() -> Options { diff --git a/crates/vp_installer/src/legacy.rs b/crates/vp_installer/src/legacy.rs index 215a1b8f98..6e293a7b63 100644 --- a/crates/vp_installer/src/legacy.rs +++ b/crates/vp_installer/src/legacy.rs @@ -71,7 +71,7 @@ pub(super) async fn install( print_warn(&format!("Shim setup failed (non-fatal): {e}")); } - if !opts.no_node_manager { + if opts.node_manager == cli::NodeManager::Enabled { if !opts.quiet { print_info("setting up Node.js and package-manager version management..."); } diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 5dd276138a..3c8461a810 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -30,7 +30,10 @@ use std::io::{self, Write}; use console::style; use indicatif::{ProgressBar, ProgressStyle}; use vp_pm_cli::HttpClient; -use vp_setup::{VP_BINARY_NAME, install, integrity, platform, registry}; +use vp_setup::{ + VP_BINARY_NAME, install, integrity, platform, registry, + support::{self, SelfSetupSupport}, +}; use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; @@ -87,9 +90,8 @@ async fn run(mut opts: cli::Options, dirs: VpDirs) -> i32 { // Pre-compute Node.js manager default before showing the menu, // so the user sees the resolved value and can override it. - if !opts.no_node_manager { - opts.no_node_manager = !auto_detect_node_manager(&dirs, !opts.yes); - } + opts.node_manager = + resolve_node_manager(opts.no_node_manager, || auto_detect_node_manager(&dirs, !opts.yes)); if !opts.yes { let proceed = show_interactive_menu(&mut opts, &data_dir_display, &bin_dir_display); @@ -151,29 +153,22 @@ async fn do_install(opts: &cli::Options, dirs: &VpDirs) -> Result<(), Box Result<(), Box bool, +) -> cli::NodeManager { + if vp_shared::EnvConfig::get().node_system_only() { + cli::NodeManager::SystemOnly + } else if no_node_manager || !auto_detect() { + cli::NodeManager::Disabled + } else { + cli::NodeManager::Enabled + } +} + +/// `VP_NODE_MANAGER` and `VP_PM_MANAGER` for the installed binary's self-setup. +/// +/// An explicit `VP_PM_MANAGER` value is kept. Otherwise the package managers +/// follow the Node.js choice, except that `system-only` becomes `no`: only an +/// explicit variable makes package managers system-only. Family-specific +/// variables reach the child unchanged through the inherited environment. +fn manager_envs(node_manager: cli::NodeManager) -> [(&'static str, String); 2] { + let package_managers = std::env::var(vp_shared::env_vars::VP_PM_MANAGER) + .ok() + .filter(|value| matches!(value.as_str(), "yes" | "no" | vp_shared::env_vars::SYSTEM_ONLY)) + .unwrap_or_else(|| { + match node_manager { + cli::NodeManager::SystemOnly => cli::NodeManager::Disabled, + other => other, + } + .env_value() + .to_string() + }); + [ + (vp_shared::env_vars::VP_NODE_MANAGER, node_manager.env_value().to_string()), + (vp_shared::env_vars::VP_PM_MANAGER, package_managers), + ] +} + /// Auto-detect whether the Node.js version manager should be enabled. /// /// Pure logic — no user prompts. Called once before the interactive menu @@ -368,10 +406,7 @@ fn show_interactive_menu(opts: &mut cli::Options, data_dir: &str, bin_dir: &str) .cyan() ); println!(" Version: {}", style(version).cyan()); - println!( - " Node.js / package managers: {}", - style(if opts.no_node_manager { "disabled" } else { "enabled" }).cyan() - ); + println!(" Node.js / package managers: {}", style(opts.node_manager.label()).cyan()); println!(); println!(" 1) {} (default)", style("Proceed with installation").bold()); println!(" 2) Customize installation"); @@ -403,7 +438,7 @@ fn show_customize_menu(opts: &mut cli::Options) { println!(" 2) npm registry: [{}]", style(registry_display).cyan()); println!( " 3) Node.js / package managers: [{}]", - style(if opts.no_node_manager { "disabled" } else { "enabled" }).cyan() + style(opts.node_manager.label()).cyan() ); println!( " 4) Modify PATH: [{}]", @@ -428,7 +463,16 @@ fn show_customize_menu(opts: &mut cli::Options) { let r = read_input(" npm registry URL (or empty for default): "); opts.registry = if r.is_empty() { None } else { Some(r) }; } - "3" => opts.no_node_manager = !opts.no_node_manager, + "3" => { + opts.node_manager = match opts.node_manager { + cli::NodeManager::Enabled => cli::NodeManager::Disabled, + cli::NodeManager::Disabled => cli::NodeManager::Enabled, + cli::NodeManager::SystemOnly => { + println!(" Unset VP_NODE_MANAGER=system-only to change this option."); + cli::NodeManager::SystemOnly + } + } + } "4" => opts.no_modify_path = !opts.no_modify_path, _ => println!(" Invalid option."), } @@ -490,6 +534,45 @@ mod tests { EnvConfig::with_vars(vars, |_| f()) } + #[test] + fn system_only_node_manager_overrides_flag_and_auto_detection() { + EnvConfig::with_vars([(env_vars::VP_NODE_MANAGER, Some("system-only"))], |_| { + for flag in [false, true] { + assert_eq!( + resolve_node_manager(flag, || panic!("auto-detection must not run")), + cli::NodeManager::SystemOnly + ); + } + }); + // Only the exact lowercase value selects system-only. + EnvConfig::with_vars([(env_vars::VP_NODE_MANAGER, Some("System-Only"))], |_| { + assert_eq!(resolve_node_manager(false, || true), cli::NodeManager::Enabled); + assert_eq!(resolve_node_manager(false, || false), cli::NodeManager::Disabled); + assert_eq!(resolve_node_manager(true, || true), cli::NodeManager::Disabled); + }); + } + + #[test] + fn manager_envs_pass_system_only_without_implying_it_for_package_managers() { + let envs = |node, pm: Option<&str>| { + EnvConfig::with_vars([(env_vars::VP_PM_MANAGER, pm)], |_| manager_envs(node)) + }; + let pair = |node: &str, pm: &str| { + [ + (env_vars::VP_NODE_MANAGER, node.to_string()), + (env_vars::VP_PM_MANAGER, pm.to_string()), + ] + }; + assert_eq!(envs(cli::NodeManager::SystemOnly, None), pair("system-only", "no")); + assert_eq!(envs(cli::NodeManager::SystemOnly, Some("yes")), pair("system-only", "yes")); + assert_eq!( + envs(cli::NodeManager::Enabled, Some("system-only")), + pair("yes", "system-only") + ); + assert_eq!(envs(cli::NodeManager::Disabled, Some("System-Only")), pair("no", "no")); + assert_eq!(envs(cli::NodeManager::Enabled, None), pair("yes", "yes")); + } + #[test] fn foreign_node_requires_interactive_or_explicit_consent() { EnvConfig::with_vars([(env_vars::VP_NODE_MANAGER, None), ("CI", Some("true"))], |_| { diff --git a/crates/vp_js_runtime/src/error.rs b/crates/vp_js_runtime/src/error.rs index cf56762115..f394e0a558 100644 --- a/crates/vp_js_runtime/src/error.rs +++ b/crates/vp_js_runtime/src/error.rs @@ -9,6 +9,12 @@ pub enum Error { #[error("Version {version} not found for {runtime}")] VersionNotFound { runtime: Str, version: Str }, + /// `VP_NODE_MANAGER=system-only` forbids downloading or using a managed runtime + #[error( + "{variable}=system-only: Vite+ uses only the system Node.js and does not download or use a managed Node.js" + )] + DisabledBySystemOnly { variable: &'static str }, + /// Platform not supported for this runtime #[error("Platform {platform} is not supported for {runtime}")] UnsupportedPlatform { platform: Str, runtime: Str }, diff --git a/crates/vp_js_runtime/src/lib.rs b/crates/vp_js_runtime/src/lib.rs index f7f75d092d..71cb99474a 100644 --- a/crates/vp_js_runtime/src/lib.rs +++ b/crates/vp_js_runtime/src/lib.rs @@ -64,6 +64,6 @@ pub use provider::{ pub use providers::{LtsInfo, NodeProvider, NodeVersionEntry, resolve_version_from_list}; pub use runtime::{ JsRuntime, JsRuntimeType, VersionResolution, VersionSource, download_runtime, - download_runtime_for_project, download_runtime_with_provider, is_valid_version, - normalize_version, read_package_json, resolve_node_version, + download_runtime_for_project, download_runtime_with_provider, ensure_managed_node_allowed, + is_valid_version, normalize_version, read_package_json, resolve_node_version, }; diff --git a/crates/vp_js_runtime/src/providers/node.rs b/crates/vp_js_runtime/src/providers/node.rs index 29688dc70f..9432dfb0b8 100644 --- a/crates/vp_js_runtime/src/providers/node.rs +++ b/crates/vp_js_runtime/src/providers/node.rs @@ -185,6 +185,8 @@ impl NodeProvider { /// /// Returns an error only if the download fails and no local cache exists. pub async fn fetch_version_index(&self) -> Result, Error> { + // System-only mode resolves no managed versions, even from the cache. + crate::runtime::ensure_managed_node_allowed()?; let cache_dir = crate::cache::get_cache_dir()?; let cache_path = cache_dir.join("node/index_cache.json"); @@ -1538,4 +1540,49 @@ fedcba987654 node-v22.13.1-win-x64.zip"; }) .await; } + + /// System-only mode neither queries the mirror nor reads the index cache. + #[tokio::test] + async fn test_system_only_rejects_version_index_without_network() { + use httpmock::prelude::*; + + let vp_home = tempfile::TempDir::new().unwrap(); + let server = MockServer::start_async().await; + let index = server + .mock_async(|when, then| { + when.method(GET).path("/index.json"); + then.status(200).body(NODE_INDEX); + }) + .await; + let mirror = server.base_url(); + let vars = |manager: Option<&'static str>| { + [ + (env_vars::VP_HOME, Some(vp_home.path().as_os_str())), + (env_vars::VP_NODE_DIST_MIRROR, Some(std::ffi::OsStr::new(mirror.as_str()))), + (env_vars::VP_NODE_MANAGER, manager.map(std::ffi::OsStr::new)), + ] + }; + + // Without a cache, the lookup fails before any request. + vp_shared::EnvConfig::with_vars_async(vars(Some("system-only")), |_| async { + let error = NodeProvider::new().fetch_version_index().await.unwrap_err(); + assert!(matches!( + error, + Error::DisabledBySystemOnly { variable: env_vars::VP_NODE_MANAGER } + )); + }) + .await; + index.assert_hits_async(0).await; + + // A fresh cache is not used either. + vp_shared::EnvConfig::with_vars_async(vars(None), |_| async { + NodeProvider::new().fetch_version_index().await.unwrap(); + }) + .await; + vp_shared::EnvConfig::with_vars_async(vars(Some("system-only")), |_| async { + assert!(NodeProvider::new().fetch_version_index().await.is_err()); + }) + .await; + index.assert_hits_async(1).await; + } } diff --git a/crates/vp_js_runtime/src/runtime.rs b/crates/vp_js_runtime/src/runtime.rs index 95de008c9f..dcff137e19 100644 --- a/crates/vp_js_runtime/src/runtime.rs +++ b/crates/vp_js_runtime/src/runtime.rs @@ -169,13 +169,27 @@ fn log_checksum_only(archive_filename: &str) { ); } +/// Fail when `VP_NODE_MANAGER=system-only` restricts Node.js to the system. +/// +/// # Errors +/// +/// Returns [`Error::DisabledBySystemOnly`] in system-only mode. +pub fn ensure_managed_node_allowed() -> Result<(), Error> { + if vp_shared::EnvConfig::get().node_system_only() { + return Err(Error::DisabledBySystemOnly { variable: vp_shared::env_vars::VP_NODE_MANAGER }); + } + Ok(()) +} + /// Download and cache a JavaScript runtime using a provider /// /// This is the generic download function that works with any `JsRuntimeProvider`. /// /// # Errors /// -/// Returns an error if download, verification, or extraction fails. +/// Returns an error if download, verification, or extraction fails, or +/// [`Error::DisabledBySystemOnly`] for Node.js in system-only mode, even when the +/// version is already cached. /// /// # Panics /// @@ -185,6 +199,10 @@ pub async fn download_runtime_with_provider( runtime_type: JsRuntimeType, version: &str, ) -> Result { + // Checked before the cache lookup: system-only also forbids cached installs. + match runtime_type { + JsRuntimeType::Node => ensure_managed_node_allowed()?, + } let platform = Platform::current(); let cache_dir = crate::cache::get_cache_dir()?; @@ -1813,4 +1831,42 @@ mod tests { assert_eq!(&*resolution.version, "20.18.0"); assert_eq!(resolution.source, VersionSource::NvmrcFile); } + + /// System-only mode rejects a managed Node.js before the cache lookup, so + /// an already installed version is not used either. + #[tokio::test] + async fn test_system_only_rejects_cached_runtime() { + let vp_home = TempDir::new().unwrap(); + let provider = NodeProvider::new(); + let install_dir = vp_home.path().join("js_runtime").join("node").join("24.0.0"); + let binary = install_dir.join(provider.binary_relative_path(Platform::current()).as_str()); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::write(&binary, "").unwrap(); + + for (manager, rejected) in [(None, false), (Some("no"), false), (Some("system-only"), true)] + { + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, Some(vp_home.path().as_os_str())), + (env_vars::VP_NODE_MANAGER, manager.map(std::ffi::OsStr::new)), + ], + |_| async { + let result = + download_runtime_with_provider(&provider, JsRuntimeType::Node, "24.0.0") + .await; + if rejected { + assert!(matches!( + result, + Err(Error::DisabledBySystemOnly { + variable: env_vars::VP_NODE_MANAGER + }) + )); + } else { + assert_eq!(result.unwrap().get_binary_path().as_path(), binary); + } + }, + ) + .await; + } + } } diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index 12ad5fa286..3611552b1a 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -16,6 +16,7 @@ mod package_manager; mod request; pub(crate) mod resolution; mod shim; +mod system_only; pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; pub use config::{npm_authorization_for_url, npm_registry, npm_registry_for_scope}; @@ -43,3 +44,6 @@ pub use resolution::{ PublishArgs, RebuildArgs, RemoveArgs, SearchArgs, StageCommand, TeamCommand, TokenCommand, UnlinkArgs, UpdateArgs, VersionArgs, ViewArgs, WhoamiArgs, WhyArgs, }; +pub use system_only::{ + ensure_managed_package_manager_allowed, package_manager_preference, system_only_variable, +}; diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 4880a9c83d..2a34de7075 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -26,6 +26,7 @@ use crate::{ HttpClient, download_and_extract_tgz_with_hash_and_config, is_not_found, verify_file_hash, }, shim, + system_only::ensure_managed_package_manager_allowed, }; #[derive(Serialize, Deserialize, Clone, Default)] @@ -649,6 +650,8 @@ pub async fn ensure_package_manager_bin( expected_hash: Option<&str>, bin_name: &str, ) -> Result { + // Checked before the version lookup and the cache: system-only forbids both. + ensure_managed_package_manager_allowed(package_manager_type)?; let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; let verifies_cached_cli = @@ -1039,6 +1042,7 @@ async fn resolve_dist_tag( tag: &str, npm_config: &NpmConfig, ) -> Result { + ensure_managed_package_manager_allowed(package_manager_type)?; let urls = dist_tag_packages(package_manager_type) .iter() .map(|package_name| npm_config.package_version_url(package_name, tag)) @@ -1184,6 +1188,7 @@ async fn fetch_package_manager_versions_with_config( package_manager_type: PackageManagerType, npm_config: &NpmConfig, ) -> Result, Error> { + ensure_managed_package_manager_allowed(package_manager_type)?; let mut versions = fetch_registry_versions(&package_manager_type.to_string(), npm_config).await?; if matches!(package_manager_type, PackageManagerType::Yarn) { @@ -1216,6 +1221,7 @@ async fn resolve_latest_satisfying_version( version_req: &str, npm_config: &NpmConfig, ) -> Result { + ensure_managed_package_manager_allowed(package_manager_type)?; let package_name = package_manager_type.to_string(); let mut versions = fetch_registry_versions(&package_name, npm_config).await?; // yarn >= 2.0.0 is published as `@yarnpkg/cli-dist`; merge both version lists @@ -1296,6 +1302,8 @@ async fn resolve_package_manager_range( range: &js_semver::Range, npm_config: &NpmConfig, ) -> Result { + // Checked before the cached-version lookup: system-only uses no managed release. + ensure_managed_package_manager_allowed(package_manager_type)?; if let Some(cached) = find_cached_package_manager_version(package_manager_type, range)? { tracing::debug!("Found cached {package_manager_type} {cached} satisfying {version_req}"); return Ok(cached); @@ -1353,6 +1361,7 @@ async fn download_package_manager_with_config( expected_hash: Option<&str>, npm_config: &NpmConfig, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { + ensure_managed_package_manager_allowed(package_manager_type)?; let installed = download_package_manager_inner( cwd, package_manager_type, @@ -2584,6 +2593,92 @@ mod tests { assert_eq!(find_cached_pnpm(&vp_home), None); } + /// A system-only family resolves, downloads and reuses no managed release: + /// every entry point fails before the cache and before any registry request. + #[tokio::test(flavor = "current_thread")] + async fn system_only_package_manager_uses_no_managed_release_or_registry() { + use httpmock::prelude::*; + + let temp_dir = create_temp_dir(); + let server = MockServer::start(); + let requests = server.mock(|when, then| { + when.any_request(); + then.status(500); + }); + let registry = server.base_url(); + let vars = |pnpm: Option<&'static str>, pm: Option<&'static str>| { + [ + (env_vars::VP_HOME, Some(temp_dir.path().as_os_str())), + (env_vars::NPM_CONFIG_REGISTRY, Some(std::ffi::OsStr::new(registry.as_str()))), + (env_vars::VP_PNPM_MANAGER, pnpm.map(std::ffi::OsStr::new)), + (env_vars::VP_PM_MANAGER, pm.map(std::ffi::OsStr::new)), + (env_vars::VP_NPM_MANAGER, None), + (env_vars::VP_YARN_MANAGER, None), + (env_vars::VP_BUN_MANAGER, None), + ] + }; + let is_disabled = |error: &Error| matches!(error, Error::DisabledBySystemOnly { tool, variable: env_vars::VP_PNPM_MANAGER } if tool == "pnpm"); + + // A cached exact release is still returned without system-only. + let cached = EnvConfig::with_vars_async(vars(None, None), |_| async { + let install_dir = + package_manager_install_dir(PackageManagerType::Pnpm, "10.0.0").unwrap(); + let bin = package_manager_bin_path(&install_dir, "pnpm"); + fs::create_dir_all(bin.parent().unwrap()).unwrap(); + fs::write(&bin, "").unwrap(); + let resolved = + ensure_package_manager_bin(PackageManagerType::Pnpm, "10.0.0", None, "pnpm") + .await + .unwrap(); + assert_eq!(resolved, bin); + bin + }) + .await; + assert!(cached.as_path().exists()); + + EnvConfig::with_vars_async(vars(Some("system-only"), Some("yes")), |_| async { + for version in ["10.0.0", "^10.0.0", "latest"] { + let error = + ensure_package_manager_bin(PackageManagerType::Pnpm, version, None, "pnpm") + .await + .unwrap_err(); + assert!(is_disabled(&error), "{version}: {error}"); + assert!(!error.is_integrity_failure()); + let error = download_package_manager(PackageManagerType::Pnpm, version, None) + .await + .unwrap_err(); + assert!(is_disabled(&error), "{version}: {error}"); + } + for version in ["^10.0.0", "latest"] { + let error = resolve_package_manager_version(PackageManagerType::Pnpm, version) + .await + .unwrap_err(); + assert!(is_disabled(&error), "{version}: {error}"); + } + let error = fetch_package_manager_versions(PackageManagerType::Pnpm).await.unwrap_err(); + assert!(is_disabled(&error), "{error}"); + }) + .await; + requests.assert_hits(0); + + // The family-specific value wins over VP_PM_MANAGER, and other families + // follow VP_PM_MANAGER. + EnvConfig::with_vars_async(vars(Some("yes"), Some("system-only")), |_| async { + ensure_package_manager_bin(PackageManagerType::Pnpm, "10.0.0", None, "pnpm") + .await + .unwrap(); + let error = resolve_package_manager_version(PackageManagerType::Yarn, "latest") + .await + .unwrap_err(); + assert!(matches!( + error, + Error::DisabledBySystemOnly { variable: env_vars::VP_PM_MANAGER, .. } + )); + }) + .await; + requests.assert_hits(0); + } + #[tokio::test(flavor = "current_thread")] async fn test_latest_version_cache_refreshes_and_falls_back_after_expiry() { use httpmock::prelude::*; diff --git a/crates/vp_pm_cli/src/system_only.rs b/crates/vp_pm_cli/src/system_only.rs new file mode 100644 index 0000000000..323da45e9f --- /dev/null +++ b/crates/vp_pm_cli/src/system_only.rs @@ -0,0 +1,46 @@ +//! Run-time `system-only` package-manager preference. +//! +//! `VP__MANAGER=system-only` (or `VP_PM_MANAGER=system-only` without a +//! family-specific value) restricts a family to its system installation. Vite+ +//! then neither downloads nor uses a managed release of that family, nor asks +//! the registry which release to use. + +use vp_error::Error; +use vp_shared::{EnvConfig, ManagerPreference}; + +use crate::PackageManagerType; + +/// The explicit management preference for `package_manager_type`, with the +/// variable that supplied it. See [`EnvConfig::package_manager_preference`]. +#[must_use] +pub fn package_manager_preference( + package_manager_type: PackageManagerType, +) -> Option<(ManagerPreference, &'static str)> { + EnvConfig::get().package_manager_preference(&package_manager_type.to_string()) +} + +/// The variable that restricts `package_manager_type` to the system, if any. +#[must_use] +pub fn system_only_variable(package_manager_type: PackageManagerType) -> Option<&'static str> { + match package_manager_preference(package_manager_type)? { + (ManagerPreference::SystemOnly, variable) => Some(variable), + _ => None, + } +} + +/// Fail when `package_manager_type` is restricted to the system. +/// +/// # Errors +/// +/// Returns [`Error::DisabledBySystemOnly`] in system-only mode. +pub fn ensure_managed_package_manager_allowed( + package_manager_type: PackageManagerType, +) -> Result<(), Error> { + match system_only_variable(package_manager_type) { + Some(variable) => Err(Error::DisabledBySystemOnly { + tool: package_manager_type.to_string().into(), + variable, + }), + None => Ok(()), + } +} diff --git a/crates/vp_setup/src/lib.rs b/crates/vp_setup/src/lib.rs index d81316381f..787d6b4b1e 100644 --- a/crates/vp_setup/src/lib.rs +++ b/crates/vp_setup/src/lib.rs @@ -21,6 +21,7 @@ pub mod integrity; pub mod lockfile; pub mod platform; pub mod registry; +pub mod support; /// Maximum number of old versions to keep. pub const MAX_VERSIONS_KEEP: usize = 3; diff --git a/crates/vp_setup/src/support.rs b/crates/vp_setup/src/support.rs new file mode 100644 index 0000000000..1bf6575cb2 --- /dev/null +++ b/crates/vp_setup/src/support.rs @@ -0,0 +1,147 @@ +//! Capability probe of a downloaded `vp` binary. +//! +//! Installers and `vp upgrade` run the binary with +//! `VP_SELF_SETUP_SUPPORT_CHECK` set before handing over to it. A binary that +//! supports first-start setup prints [`SELF_SETUP_SUPPORT_RESPONSE`]. That line +//! never changes, because released installers compare it exactly. When the +//! check value is `system-only`, a binary that also supports +//! `VP_*_MANAGER=system-only` prints [`SYSTEM_ONLY_SUPPORT_RESPONSE`] on a +//! second line. + +use vp_shared::env_vars; +use vt_path::AbsolutePath; + +/// First line of the probe response: first-start setup is supported. +pub const SELF_SETUP_SUPPORT_RESPONSE: &str = "vite-plus-self-setup-v1"; + +/// Second line of the probe response for the `system-only` check value. +pub const SYSTEM_ONLY_SUPPORT_RESPONSE: &str = "vite-plus-self-setup-system-only"; + +/// What a probed binary supports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelfSetupSupport { + /// No first-start setup: an older binary, or the probe failed. + Legacy, + /// First-start setup without `system-only` support. + SelfSetup, + /// First-start setup and `VP_*_MANAGER=system-only`. + SystemOnly, +} + +/// Lines a binary prints for the support check value `check`. +#[must_use] +pub fn support_response(check: &std::ffi::OsStr) -> &'static [&'static str] { + if check == env_vars::SYSTEM_ONLY { + &[SELF_SETUP_SUPPORT_RESPONSE, SYSTEM_ONLY_SUPPORT_RESPONSE] + } else { + &[SELF_SETUP_SUPPORT_RESPONSE] + } +} + +/// Classify the probe output of a successful run. +#[must_use] +pub fn parse_support_response(stdout: &[u8]) -> SelfSetupSupport { + let v1 = format!("{SELF_SETUP_SUPPORT_RESPONSE}\n"); + let system_only = format!("{SELF_SETUP_SUPPORT_RESPONSE}\n{SYSTEM_ONLY_SUPPORT_RESPONSE}\n"); + if stdout == system_only.as_bytes() { + SelfSetupSupport::SystemOnly + } else if stdout == v1.as_bytes() { + SelfSetupSupport::SelfSetup + } else { + SelfSetupSupport::Legacy + } +} + +/// Run `binary --help` with the support check. With `system_only`, the check +/// asks whether the binary also supports `system-only`. +/// +/// # Errors +/// +/// Returns an error if the binary cannot be started. +pub async fn probe_self_setup( + binary: &AbsolutePath, + system_only: bool, +) -> std::io::Result { + let output = tokio::process::Command::new(binary.as_path()) + .arg("--help") + .env( + env_vars::VP_SELF_SETUP_SUPPORT_CHECK, + if system_only { env_vars::SYSTEM_ONLY } else { "1" }, + ) + .output() + .await?; + if !output.status.success() { + return Ok(SelfSetupSupport::Legacy); + } + Ok(parse_support_response(&output.stdout)) +} + +/// Error message for a target that cannot honor the `system-only` variables. +#[must_use] +pub fn system_only_unsupported_message(target: &str, variables: &[&str]) -> String { + let settings = variables + .iter() + .map(|name| format!("{name}={}", env_vars::SYSTEM_ONLY)) + .collect::>(); + format!( + "vite-plus {target} does not support {}. Choose a version that supports it, or unset {}.", + settings.join(", "), + variables.join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn support_response_adds_system_only_line_only_for_its_check_value() { + assert_eq!(support_response("1".as_ref()), [SELF_SETUP_SUPPORT_RESPONSE]); + assert_eq!(support_response("System-Only".as_ref()), [SELF_SETUP_SUPPORT_RESPONSE]); + assert_eq!( + support_response("system-only".as_ref()), + [SELF_SETUP_SUPPORT_RESPONSE, SYSTEM_ONLY_SUPPORT_RESPONSE] + ); + } + + #[test] + fn parse_support_response_requires_exact_lines() { + assert_eq!( + parse_support_response(b"vite-plus-self-setup-v1\nvite-plus-self-setup-system-only\n"), + SelfSetupSupport::SystemOnly + ); + assert_eq!( + parse_support_response(b"vite-plus-self-setup-v1\n"), + SelfSetupSupport::SelfSetup + ); + assert_eq!(parse_support_response(b"Usage: vp [COMMAND]\n"), SelfSetupSupport::Legacy); + assert_eq!(parse_support_response(b""), SelfSetupSupport::Legacy); + } + + #[cfg(unix)] + #[tokio::test] + async fn probe_self_setup_passes_the_check_value() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let script = |name: &str, body: &str| { + let path = temp.path().join(name); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + vt_path::AbsolutePathBuf::new(path).unwrap() + }; + let modern = script( + "modern", + r#"echo vite-plus-self-setup-v1; [ "$VP_SELF_SETUP_SUPPORT_CHECK" = system-only ] && echo vite-plus-self-setup-system-only; exit 0"#, + ); + let v1 = script("v1", "echo vite-plus-self-setup-v1"); + let legacy = script("legacy", "echo 'Usage: vp [COMMAND]'"); + let failing = script("failing", "echo vite-plus-self-setup-v1; exit 1"); + + assert_eq!(probe_self_setup(&modern, true).await.unwrap(), SelfSetupSupport::SystemOnly); + assert_eq!(probe_self_setup(&modern, false).await.unwrap(), SelfSetupSupport::SelfSetup); + assert_eq!(probe_self_setup(&v1, true).await.unwrap(), SelfSetupSupport::SelfSetup); + assert_eq!(probe_self_setup(&legacy, true).await.unwrap(), SelfSetupSupport::Legacy); + assert_eq!(probe_self_setup(&failing, false).await.unwrap(), SelfSetupSupport::Legacy); + } +} diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index e9c890e401..f9acdc26c3 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -103,14 +103,18 @@ fn persisted_dir_envs() -> HashMap<&'static str, String> { /// Management preference from a `VP_*_MANAGER` variable. /// -/// Only the exact values `yes` and `no` are recognized. Any other value, -/// including a non-UTF-8 one, counts as unset. +/// Only the exact values `yes`, `no`, and `system-only` are recognized. Any +/// other value, including a differently cased or non-UTF-8 one, counts as unset. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ManagerPreference { /// `yes`: Vite+ manages the tool. Yes, /// `no`: prefer the system tool. No, + /// `system-only`: use only the system tool. Unlike the other values, this + /// one overrides `config.json` at run time, and Vite+ neither downloads nor + /// uses a managed installation of the tool. + SystemOnly, } impl ManagerPreference { @@ -118,6 +122,7 @@ impl ManagerPreference { match std::env::var(name).as_deref() { Ok("yes") => Some(Self::Yes), Ok("no") => Some(Self::No), + Ok(env_vars::SYSTEM_ONLY) => Some(Self::SystemOnly), _ => None, } } @@ -237,6 +242,51 @@ pub struct EnvConfig { } impl EnvConfig { + /// Explicit management preference for a package-manager family (`npm`, + /// `pnpm`, `yarn`, or `bun`), with the variable that supplied it. + /// + /// The family-specific variable takes precedence over `VP_PM_MANAGER`. An + /// unrecognized value counts as unset and falls through to the next + /// variable. An unknown family consults only `VP_PM_MANAGER`. + #[must_use] + pub fn package_manager_preference( + &self, + family: &str, + ) -> Option<(ManagerPreference, &'static str)> { + let family_specific = match family { + "npm" => self.npm_manager.map(|preference| (preference, env_vars::VP_NPM_MANAGER)), + "pnpm" => self.pnpm_manager.map(|preference| (preference, env_vars::VP_PNPM_MANAGER)), + "yarn" => self.yarn_manager.map(|preference| (preference, env_vars::VP_YARN_MANAGER)), + "bun" => self.bun_manager.map(|preference| (preference, env_vars::VP_BUN_MANAGER)), + _ => None, + }; + family_specific + .or_else(|| self.pm_manager.map(|preference| (preference, env_vars::VP_PM_MANAGER))) + } + + /// Whether `VP_NODE_MANAGER=system-only` restricts Node.js to the system. + #[must_use] + pub fn node_system_only(&self) -> bool { + self.node_manager == Some(ManagerPreference::SystemOnly) + } + + /// The `VP_*_MANAGER` variables currently set to `system-only`. + #[must_use] + pub fn system_only_variables(&self) -> Vec<&'static str> { + [ + (env_vars::VP_NODE_MANAGER, self.node_manager), + (env_vars::VP_PM_MANAGER, self.pm_manager), + (env_vars::VP_NPM_MANAGER, self.npm_manager), + (env_vars::VP_PNPM_MANAGER, self.pnpm_manager), + (env_vars::VP_YARN_MANAGER, self.yarn_manager), + (env_vars::VP_BUN_MANAGER, self.bun_manager), + ] + .into_iter() + .filter(|(_, preference)| *preference == Some(ManagerPreference::SystemOnly)) + .map(|(name, _)| name) + .collect() + } + /// Read configuration from the real process environment. /// /// Non-test builds call this function on the first [`EnvConfig::get`] and @@ -465,6 +515,114 @@ impl EnvConfig { mod tests { use super::*; + const MANAGER_VARS: [&str; 6] = [ + env_vars::VP_NODE_MANAGER, + env_vars::VP_PM_MANAGER, + env_vars::VP_NPM_MANAGER, + env_vars::VP_PNPM_MANAGER, + env_vars::VP_YARN_MANAGER, + env_vars::VP_BUN_MANAGER, + ]; + + /// Unset every `VP_*_MANAGER` variable, then apply `vars`. + fn with_manager_vars( + vars: &[(&'static str, &str)], + f: impl FnOnce(Arc) -> R, + ) -> R { + let pins = MANAGER_VARS + .into_iter() + .map(|name| (name, vars.iter().find(|(var, _)| *var == name).map(|(_, value)| *value))); + EnvConfig::with_vars(pins, f) + } + + #[test] + fn system_only_requires_exact_lowercase_value() { + for (value, expected) in [ + ("system-only", Some(ManagerPreference::SystemOnly)), + ("System-Only", None), + ("SYSTEM-ONLY", None), + ("system_only", None), + ("yes", Some(ManagerPreference::Yes)), + ("no", Some(ManagerPreference::No)), + ] { + with_manager_vars(&[(env_vars::VP_NODE_MANAGER, value)], |config| { + assert_eq!(config.node_manager, expected, "{value}"); + assert_eq!( + config.node_system_only(), + expected == Some(ManagerPreference::SystemOnly) + ); + }); + } + } + + #[test] + fn family_preference_takes_precedence_over_shared_preference() { + with_manager_vars( + &[(env_vars::VP_PNPM_MANAGER, "system-only"), (env_vars::VP_PM_MANAGER, "yes")], + |config| { + assert_eq!( + config.package_manager_preference("pnpm"), + Some((ManagerPreference::SystemOnly, env_vars::VP_PNPM_MANAGER)) + ); + assert_eq!( + config.package_manager_preference("npm"), + Some((ManagerPreference::Yes, env_vars::VP_PM_MANAGER)) + ); + }, + ); + with_manager_vars( + &[(env_vars::VP_PNPM_MANAGER, "yes"), (env_vars::VP_PM_MANAGER, "system-only")], + |config| { + assert_eq!( + config.package_manager_preference("pnpm"), + Some((ManagerPreference::Yes, env_vars::VP_PNPM_MANAGER)) + ); + for family in ["npm", "yarn", "bun", "unknown"] { + assert_eq!( + config.package_manager_preference(family), + Some((ManagerPreference::SystemOnly, env_vars::VP_PM_MANAGER)), + "{family}" + ); + } + }, + ); + } + + #[test] + fn unrecognized_family_preference_falls_through() { + with_manager_vars( + &[(env_vars::VP_YARN_MANAGER, "System-Only"), (env_vars::VP_PM_MANAGER, "system-only")], + |config| { + assert_eq!( + config.package_manager_preference("yarn"), + Some((ManagerPreference::SystemOnly, env_vars::VP_PM_MANAGER)) + ); + }, + ); + with_manager_vars(&[], |config| { + assert_eq!(config.package_manager_preference("npm"), None); + assert!(config.system_only_variables().is_empty()); + }); + } + + #[test] + fn system_only_variables_lists_each_system_only_variable() { + with_manager_vars( + &[ + (env_vars::VP_NODE_MANAGER, "system-only"), + (env_vars::VP_PM_MANAGER, "no"), + (env_vars::VP_BUN_MANAGER, "system-only"), + (env_vars::VP_YARN_MANAGER, "System-Only"), + ], + |config| { + assert_eq!( + config.system_only_variables(), + [env_vars::VP_NODE_MANAGER, env_vars::VP_BUN_MANAGER] + ); + }, + ); + } + #[test] fn installation_lockfile_bypass_requires_explicit_opt_in() { for value in [None, Some(""), Some("0"), Some("true"), Some("1")] { diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 43cc73c9e2..880e2c97b1 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -109,7 +109,7 @@ pub const VP_YARN_VERSION: &str = "VP_YARN_VERSION"; /// Override the bun and bunx shim version. pub const VP_BUN_VERSION: &str = "VP_BUN_VERSION"; -/// Node.js management preference (`yes` or `no`). +/// Node.js management preference (`yes`, `no`, or [`SYSTEM_ONLY`]). pub const VP_NODE_MANAGER: &str = "VP_NODE_MANAGER"; /// Package-manager management preference shared by every family. @@ -128,6 +128,10 @@ pub const VP_YARN_MANAGER: &str = "VP_YARN_MANAGER"; /// Bun management preference; takes precedence over `VP_PM_MANAGER`. pub const VP_BUN_MANAGER: &str = "VP_BUN_MANAGER"; +/// `VP_*_MANAGER` value that restricts a tool to its system installation at +/// run time. Matched exactly (lowercase only). +pub const SYSTEM_ONLY: &str = "system-only"; + /// Enable debug output for shim dispatch. pub const VP_DEBUG_SHIM: &str = "VP_DEBUG_SHIM"; diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 64b835ed44..2571304b0f 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -420,7 +420,14 @@ function Get-PayloadAndHandoff { $binarySource = $LocalBinary } $binarySource = (Resolve-Path -LiteralPath $binarySource).Path - if (Test-SelfSetupSupport -BinarySource $binarySource) { + $systemOnly = @(Get-SystemOnlyVariables) + if ($systemOnly.Count -gt 0) { + # Binaries without system-only support would ignore it and download managed tools. + if (-not (Test-SystemOnlySupport -BinarySource $binarySource)) { + Write-Error-Exit "vite-plus $ViteVersion does not support $($systemOnly -join ', '). Choose a version that supports it, or unset those variables." + } + Invoke-InstallHandoff -BinarySource $binarySource + } elseif (Test-SelfSetupSupport -BinarySource $binarySource) { Invoke-InstallHandoff -BinarySource $binarySource } else { Invoke-LegacyInstaller -BinarySource $binarySource @@ -447,6 +454,29 @@ function Test-SelfSetupSupport { } } +# The VP_*_MANAGER=system-only settings (exact, lowercase value only). +function Get-SystemOnlyVariables { + foreach ($name in 'VP_NODE_MANAGER', 'VP_PM_MANAGER', 'VP_NPM_MANAGER', 'VP_PNPM_MANAGER', 'VP_YARN_MANAGER', 'VP_BUN_MANAGER') { + if ([Environment]::GetEnvironmentVariable($name) -ceq 'system-only') { + "$name=system-only" + } + } +} + +function Test-SystemOnlySupport { + param([string]$BinarySource) + $previous = $env:VP_SELF_SETUP_SUPPORT_CHECK + try { + $env:VP_SELF_SETUP_SUPPORT_CHECK = 'system-only' + $response = @(& $BinarySource --help 2>$null) + return $LASTEXITCODE -eq 0 -and $response.Count -eq 2 -and $response[0] -ceq 'vite-plus-self-setup-v1' -and $response[1] -ceq 'vite-plus-self-setup-system-only' + } catch { + return $false + } finally { + $env:VP_SELF_SETUP_SUPPORT_CHECK = $previous + } +} + function Invoke-LegacyInstaller { param([string]$BinarySource) $global:LASTEXITCODE = 0 diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 0200b13018..b37120cbdc 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -736,7 +736,14 @@ acquire_and_handoff() ( binary_source="$(cd "$(dirname "$LOCAL_BINARY")" && pwd -P)/$(basename "$LOCAL_BINARY")" fi - if supports_self_setup "$binary_source"; then + local system_only + system_only=$(system_only_variables) + if [ -n "$system_only" ]; then + # Binaries without system-only support would ignore it and download managed tools. + supports_system_only "$binary_source" || + error "vite-plus $VP_VERSION does not support $system_only. Choose a version that supports it, or unset those variables." + handoff_install "$binary_source" + elif supports_self_setup "$binary_source"; then handoff_install "$binary_source" else run_legacy_installer "$binary_source" @@ -750,6 +757,23 @@ supports_self_setup() { [ "$response" = $'vite-plus-self-setup-v1\n.' ] } +# Print the VP_*_MANAGER=system-only settings (exact, lowercase value only). +system_only_variables() { + local name settings="" + for name in VP_NODE_MANAGER VP_PM_MANAGER VP_NPM_MANAGER VP_PNPM_MANAGER VP_YARN_MANAGER VP_BUN_MANAGER; do + if [ "${!name-}" = "system-only" ]; then + settings="${settings:+$settings, }$name=system-only" + fi + done + printf '%s' "$settings" +} + +supports_system_only() { + local response + response=$(VP_SELF_SETUP_SUPPORT_CHECK=system-only "$1" --help 2>/dev/null && printf '.') || return 1 + [ "$response" = $'vite-plus-self-setup-v1\nvite-plus-self-setup-system-only\n.' ] +} + run_legacy_installer() ( local binary_source="$1" local legacy_script=""