diff --git a/.github/scripts/test-install-bootstrap.ps1 b/.github/scripts/test-install-bootstrap.ps1 index ecf620a813..25662213c9 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 +$managementVariables = @('VP_ENV_FORBIDDEN', 'VP_NODE_MANAGER', 'VP_PM_MANAGER', 'VP_NPM_MANAGER', 'VP_PNPM_MANAGER', 'VP_YARN_MANAGER', 'VP_BUN_MANAGER') +$originalManagement = @{} +foreach ($name in $managementVariables) { $originalManagement[$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 'env-forbidden*' -and $env:VP_ENV_FORBIDDEN -cne '1') { 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 VP_ENV_FORBIDDEN=1 asks for the second response line. +$expectedCheck = if ($scenario -like 'env-forbidden*') { 'env-forbidden' } 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', 'env-forbidden-legacy')) { Write-Output 'Usage: vp [COMMAND]' } +else { + Write-Output 'vite-plus-self-setup-v1' + if ($expectedCheck -eq 'env-forbidden' -and $scenario -ne 'env-forbidden-v1') { Write-Output 'vite-plus-self-setup-env-forbidden' } +} 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') } +$envForbiddenProbe = ${function:Test-EnvForbiddenSupport} +function Test-EnvForbiddenSupport { + param($BinarySource) + & $envForbiddenProbe -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', + 'env-forbidden', 'env-forbidden-v1', 'env-forbidden-legacy', 'forbidden-value-true', '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,16 @@ 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 $managementVariables) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') } + switch ($scenario) { + { $_ -like 'env-forbidden*' } { $env:VP_ENV_FORBIDDEN = '1' } + # Only the exact value 1 stops management. + 'forbidden-value-true' { $env:VP_ENV_FORBIDDEN = 'true' } + } + $isEnvForbiddenFailure = $scenario -in @('env-forbidden-v1', 'env-forbidden-legacy') $isIntegrityFailure = $scenario -like 'integrity-*' $isMetadataFailure = $scenario -match '^(metadata-|platform-|provenance-|tarball-missing$)' - $expectedExit = if ($isIntegrityFailure -or $isMetadataFailure) { + $expectedExit = if ($isIntegrityFailure -or $isMetadataFailure -or $isEnvForbiddenFailure) { 1 } elseif ($scenario -in @('failure', 'legacy-failure')) { 42 @@ -260,13 +282,20 @@ try { Write-Host "PASS: $scenario" continue } + if ($isEnvForbiddenFailure) { + Assert (Test-Path -LiteralPath "$testRoot/binary-probed") 'VP_ENV_FORBIDDEN support was not probed' + Assert (-not (Test-Path -LiteralPath "$testRoot/binary-invoked")) 'An unsupported binary was invoked with VP_ENV_FORBIDDEN' + Assert (-not (Test-Path -LiteralPath "$testRoot/legacy")) 'VP_ENV_FORBIDDEN 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', 'env-forbidden', 'forbidden-value-true') 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 +354,7 @@ try { $env:Path = $originalPath $env:NPM_CONFIG_REGISTRY = $originalRegistry $env:VP_SHELL = $originalVpShell + foreach ($name in $managementVariables) { [Environment]::SetEnvironmentVariable($name, $originalManagement[$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..b190a43e3e 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" == env-forbidden* ]]; then + test "${VP_ENV_FORBIDDEN:-}" = 1 || 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 VP_ENV_FORBIDDEN=1 asks for the second response line. +case "$scenario:${VP_SELF_SETUP_SUPPORT_CHECK:-}" in + env-forbidden*:env-forbidden) ;; + *: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|env-forbidden-legacy) printf 'Usage: vp [COMMAND]\n' ;; + *) + printf 'vite-plus-self-setup-v1\n' + if [ "$VP_SELF_SETUP_SUPPORT_CHECK" = env-forbidden ] && [ "$scenario" != env-forbidden-v1 ]; then + printf 'vite-plus-self-setup-env-forbidden\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 \ + env-forbidden env-forbidden-v1 env-forbidden-legacy forbidden-value-true \ 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,12 @@ 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_ENV_FORBIDDEN VP_NODE_MANAGER VP_PM_MANAGER VP_NPM_MANAGER VP_PNPM_MANAGER VP_YARN_MANAGER VP_BUN_MANAGER + case "$scenario" in + env-forbidden*) export VP_ENV_FORBIDDEN=1 ;; + # Only the exact value 1 stops management. + forbidden-value-true) export VP_ENV_FORBIDDEN=true ;; + esac main test "$NPM_CONFIG_REGISTRY" = https://custom.example test "$INSTALL_DIR" = "$test_root/data" @@ -186,14 +205,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" = env-forbidden-v1 ] || [ "$scenario" = env-forbidden-legacy ]; then + test "$status" -ne 0 + grep -q 'does not support VP_ENV_FORBIDDEN' "$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*|env-forbidden|forbidden-value-true) test -f "$test_root/binary-invoked" test ! -f "$test_root/legacy" ;; + env-forbidden-v1|env-forbidden-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..d5c1497dec 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 VP_ENV_FORBIDDEN, so the installer must stop before legacy setup. + # Restore the workflow's value afterwards; the install below reproduces setup-vp with it. + $previousEnvForbidden = $env:VP_ENV_FORBIDDEN + $env:VP_ENV_FORBIDDEN = '1' + 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 VP_ENV_FORBIDDEN for a legacy release' } + if (-not $output.Contains('does not support VP_ENV_FORBIDDEN')) { throw "Unexpected VP_ENV_FORBIDDEN failure: $output" } + } finally { + $ErrorActionPreference = 'Stop' + $env:VP_ENV_FORBIDDEN = $previousEnvForbidden + } + if (Test-Path -LiteralPath (Join-Path $env:VP_HOME 'bin/vp.exe')) { throw 'The VP_ENV_FORBIDDEN attempt installed vp' } + $requests = @(Select-String -LiteralPath $stderr -Pattern 'GET /install-legacy.bin ' -SimpleMatch) + if ($requests.Count -ne 1) { throw 'The VP_ENV_FORBIDDEN 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/Cargo.lock b/Cargo.lock index 40759436a1..fa5ed63fae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8634,6 +8634,7 @@ dependencies = [ "thiserror 2.0.21", "tokio", "tracing", + "vp_command", "vp_js_runtime", "vp_pm_cli", "vp_shared", 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 89a5c272da..e8671696fc 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 @@ -13,6 +13,33 @@ steps = [ { argv = ["vp", "not-a-command"], comment = "Once marked, the binary dispatches commands normally", continue-on-failure = true }, ] +[[case]] +name = "command_self_setup_env_forbidden_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", "env-forbidden"]], comment = "The env-forbidden check value adds a second line after the unchanged v1 response" }, + { argv = ["vp", "--help"], envs = [["VP_SELF_SETUP_SUPPORT_CHECK", "Env-Forbidden"]], comment = "Only the exact lowercase value asks for VP_ENV_FORBIDDEN support" }, + ["vpt", "stat-file", "$VP_HOME/current/bin/.vp-setup-complete", "--assert", "missing"], +] + +[[case]] +name = "command_self_setup_env_forbidden_in_place" +vp = "global" +skip-platforms = ["windows"] +env = { VP_SELF_SETUP_NO_MODIFY_PATH = "1", NPM_CONFIG_REGISTRY = "http://127.0.0.1:9" } +steps = [ + { argv = ["vpt", "rm", "$VP_HOME/current/bin/.vp-setup-complete"], comment = "An interrupted upgrade left the active installation unmarked", snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/current/bin/vp-install-lock.yaml", "release lockfile\n"], comment = "The release lockfile is present, so setup would restore it into the installation", snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/current/node_modules/.vp-install-complete", "stale receipt\n"], comment = "A stale receipt makes setup reinstall the dependencies", snapshot = false }, + { argv = ["vpt", "write-file", "$VP_HOME/current/pnpm-lock.yaml", "existing lockfile\n"], snapshot = false }, + { argv = ["vp", "--help"], envs = [["VP_ENV_FORBIDDEN", "1"], ["PATH", "${VP_HOME}/bin"]], comment = "With only the Vite+ shims on PATH, setup fails before it prepares the lockfile", continue-on-failure = true }, + ["vpt", "print-file", "$VP_HOME/current/node_modules/.vp-install-complete"], + ["vpt", "print-file", "$VP_HOME/current/pnpm-lock.yaml"], + ["vpt", "stat-file", "$VP_HOME/current/bin/.vp-setup-complete", "--assert", "missing"], +] + [[case]] name = "command_self_setup_handoff" vp = "global" @@ -60,6 +87,20 @@ steps = [ ["vpt", "stat-file", "home/cache/.vp-owned", "--assert", "file"], ] +[[case]] +name = "command_self_setup_env_forbidden_config" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "mkdir", "-p", "external", "forbidden", "not-forbidden"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, + { argv = ["./external/vp"], tty = false, envs = [["VP_HOME", "${workspace}/forbidden"], ["VP_SKIP_DEPS_INSTALL", "1"], ["VP_VERSION", "forbidden-test"], ["VP_SELF_SETUP_NO_MODIFY_PATH", "1"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_MANAGER", "yes"], ["VP_PM_MANAGER", "yes"], ["VP_NPM_MANAGER", "yes"], ["VP_PNPM_MANAGER", "yes"], ["VP_YARN_MANAGER", "yes"], ["VP_BUN_MANAGER", "yes"]], comment = "VP_ENV_FORBIDDEN=1 saves every component as system_first, overriding explicit VP_*_MANAGER=yes", snapshot = false }, + ["vpt", "print-file", "forbidden/config.json"], + { argv = ["./external/vp"], tty = false, envs = [["VP_HOME", "${workspace}/not-forbidden"], ["VP_SKIP_DEPS_INSTALL", "1"], ["VP_VERSION", "forbidden-test"], ["VP_SELF_SETUP_NO_MODIFY_PATH", "1"], ["VP_ENV_FORBIDDEN", "true"], ["VP_NODE_MANAGER", "yes"], ["VP_PM_MANAGER", "yes"], ["VP_NPM_MANAGER", "yes"], ["VP_PNPM_MANAGER", "yes"], ["VP_YARN_MANAGER", "yes"], ["VP_BUN_MANAGER", "yes"]], comment = "Only the exact value 1 enables VP_ENV_FORBIDDEN; other values keep the VP_*_MANAGER choices", snapshot = false }, + ["vpt", "print-file", "not-forbidden/config.json"], +] + [[case]] name = "command_self_setup_combined_default" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_env_forbidden_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_env_forbidden_config.md new file mode 100644 index 0000000000..1fdbe38e50 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_env_forbidden_config.md @@ -0,0 +1,47 @@ +# command_self_setup_env_forbidden_config + +## `vpt mkdir -p external forbidden not-forbidden` + + +## `vpt cp $VP_HOME/bin/vp external/vp` + + +## `vpt chmod +x external/vp` + + +## `VP_HOME=${workspace}/forbidden VP_SKIP_DEPS_INSTALL=1 VP_VERSION=forbidden-test VP_SELF_SETUP_NO_MODIFY_PATH=1 VP_ENV_FORBIDDEN=1 VP_NODE_MANAGER=yes VP_PM_MANAGER=yes VP_NPM_MANAGER=yes VP_PNPM_MANAGER=yes VP_YARN_MANAGER=yes VP_BUN_MANAGER=yes ./external/vp` + +VP_ENV_FORBIDDEN=1 saves every component as system_first, overriding explicit VP_*_MANAGER=yes + + +## `vpt print-file forbidden/config.json` + +``` +{ + "nodeShimMode": "system_first", + "packageManagerShimModes": { + "bun": "system_first", + "npm": "system_first", + "pnpm": "system_first", + "yarn": "system_first" + } +} +``` + +## `VP_HOME=${workspace}/not-forbidden VP_SKIP_DEPS_INSTALL=1 VP_VERSION=forbidden-test VP_SELF_SETUP_NO_MODIFY_PATH=1 VP_ENV_FORBIDDEN=true VP_NODE_MANAGER=yes VP_PM_MANAGER=yes VP_NPM_MANAGER=yes VP_PNPM_MANAGER=yes VP_YARN_MANAGER=yes VP_BUN_MANAGER=yes ./external/vp` + +Only the exact value 1 enables VP_ENV_FORBIDDEN; other values keep the VP_*_MANAGER choices + + +## `vpt print-file not-forbidden/config.json` + +``` +{ + "packageManagerShimModes": { + "bun": "managed", + "npm": "managed", + "pnpm": "managed", + "yarn": "managed" + } +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_env_forbidden_in_place.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_env_forbidden_in_place.md new file mode 100644 index 0000000000..074ee82ace --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_env_forbidden_in_place.md @@ -0,0 +1,50 @@ +# command_self_setup_env_forbidden_in_place + +## `vpt rm $VP_HOME/current/bin/.vp-setup-complete` + +An interrupted upgrade left the active installation unmarked + + +## `vpt write-file $VP_HOME/current/bin/vp-install-lock.yaml 'release lockfile +'` + +The release lockfile is present, so setup would restore it into the installation + + +## `vpt write-file $VP_HOME/current/node_modules/.vp-install-complete 'stale receipt +'` + +A stale receipt makes setup reinstall the dependencies + + +## `vpt write-file $VP_HOME/current/pnpm-lock.yaml 'existing lockfile +'` + + +## `VP_ENV_FORBIDDEN=1 PATH=${VP_HOME}/bin vp --help` + +With only the Vite+ shims on PATH, setup fails before it prepares the lockfile + +**Exit code:** 1 + +``` +error: Setup error: VP_ENV_FORBIDDEN=1: Vite+ installs its dependencies with the system 'node', but the Vite+ shim at '/.vite-plus/bin/node' comes first on PATH; run `vp env off node` to move the shims after the system tools +``` + +## `vpt print-file $VP_HOME/current/node_modules/.vp-install-complete` + +``` +stale receipt +``` + +## `vpt print-file $VP_HOME/current/pnpm-lock.yaml` + +``` +existing lockfile +``` + +## `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/command_self_setup/snapshots/command_self_setup_env_forbidden_probe.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_env_forbidden_probe.md new file mode 100644 index 0000000000..2532338677 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_env_forbidden_probe.md @@ -0,0 +1,27 @@ +# command_self_setup_env_forbidden_probe + +## `vpt rm $VP_HOME/current/bin/.vp-setup-complete` + + +## `VP_SELF_SETUP_SUPPORT_CHECK=env-forbidden vp --help` + +The env-forbidden check value adds a second line after the unchanged v1 response + +``` +vite-plus-self-setup-v1 +vite-plus-self-setup-env-forbidden +``` + +## `VP_SELF_SETUP_SUPPORT_CHECK=Env-Forbidden vp --help` + +Only the exact lowercase value asks for VP_ENV_FORBIDDEN 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/command_self_setup_external/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml index dce8bb647f..e7b06e7a33 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml @@ -127,3 +127,18 @@ steps = [ ["vpt", "stat-file", "brew-prefix/INSTALL_RECEIPT.json", "--assert", "file"], ["vpt", "stat-file", "brew-prefix/node_modules/vite-plus/dist/bin.js", "--assert", "file"], ] + +[[case]] +name = "command_self_setup_external_homebrew_env_forbidden" +vp = "global" +skip-platforms = ["windows"] +env = { VP_SELF_SETUP_NO_MODIFY_PATH = "1", NPM_CONFIG_REGISTRY = "http://127.0.0.1:9" } +steps = [ + { argv = ["vpt", "mkdir", "-p", "brew-prefix/bin", "home", "empty"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "brew-prefix/bin/vp"], snapshot = false }, + { argv = ["vpt", "write-file", "brew-prefix/INSTALL_RECEIPT.json", '{"homebrew_version":"7.0.2","source":{"tap":"fengmk2/core"}}'], comment = "A Homebrew formula without bundled dependencies installs them into the user package directory", snapshot = false }, + { argv = ["vpt", "write-file", "home/config.json", '{"nodeShimMode":"managed","packageManagerShimModes":{"bun":"managed","npm":"managed","pnpm":"managed","yarn":"managed"}}'], snapshot = false }, + { argv = ["./brew-prefix/bin/vp", "--help"], envs = [["VP_HOME", "${workspace}/home"], ["VP_ENV_FORBIDDEN", "1"], ["PATH", "${workspace}/empty"]], comment = "Without a system node and pnpm, setup fails before it saves preferences or creates the package directory", continue-on-failure = true }, + ["vpt", "print-file", "home/config.json"], + ["vpt", "stat-file", "home/cli-packages", "--assert", "missing"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew_env_forbidden.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew_env_forbidden.md new file mode 100644 index 0000000000..4b861d7d5a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew_env_forbidden.md @@ -0,0 +1,37 @@ +# command_self_setup_external_homebrew_env_forbidden + +## `vpt mkdir -p brew-prefix/bin home empty` + + +## `vpt cp $VP_HOME/bin/vp brew-prefix/bin/vp` + + +## `vpt write-file brew-prefix/INSTALL_RECEIPT.json '{"homebrew_version":"7.0.2","source":{"tap":"fengmk2/core"}}'` + +A Homebrew formula without bundled dependencies installs them into the user package directory + + +## `vpt write-file home/config.json '{"nodeShimMode":"managed","packageManagerShimModes":{"bun":"managed","npm":"managed","pnpm":"managed","yarn":"managed"}}'` + + +## `VP_HOME=${workspace}/home VP_ENV_FORBIDDEN=1 PATH=${workspace}/empty ./brew-prefix/bin/vp --help` + +Without a system node and pnpm, setup fails before it saves preferences or creates the package directory + +**Exit code:** 1 + +``` +error: Setup error: VP_ENV_FORBIDDEN=1: Vite+ installs its dependencies with the system 'node', but no 'node' is on PATH; install it with your system package manager +``` + +## `vpt print-file home/config.json` + +``` +{"nodeShimMode":"managed","packageManagerShimModes":{"bun":"managed","npm":"managed","pnpm":"managed","yarn":"managed"}} +``` + +## `vpt stat-file home/cli-packages --assert missing` + +``` +home/cli-packages: missing +``` 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..7508fcc52e --- /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 VP_ENV_FORBIDDEN 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.20.2', '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/run-shim.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/run-shim.cjs new file mode 100644 index 0000000000..b72268e300 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/run-shim.cjs @@ -0,0 +1,8 @@ +// Run a Vite+ shim by path: with a managed runtime first on PATH, the runner +// would otherwise resolve the step's command to the managed tool itself. +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const [tool, ...args] = process.argv.slice(2); +const child = spawnSync(path.join(process.env.VP_HOME, 'bin', tool), args, { stdio: 'inherit' }); +process.exit(child.status ?? 1); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-child-tools.cjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-child-tools.cjs new file mode 100644 index 0000000000..83dd6a0fe2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/setup-child-tools.cjs @@ -0,0 +1,48 @@ +// A system npm that reports which Node.js runs it. `npm nested` runs +// `npm --version` again from PATH, as a lifecycle script would: the system npm +// answers with a marker, while the npm bundled with the seeded managed runtime +// prints its version. +const { chmodSync, mkdirSync, writeFileSync } = require('node:fs'); +const path = require('node:path'); + +const npm = path.join('system-bin', 'npm'); +writeFileSync( + npm, + `#!/usr/bin/env node +const node = process.execPath.includes('system-bin') + ? 'system' + : process.execPath.includes('js_runtime') + ? 'managed' + : 'other'; +if (process.argv[2] !== 'nested') { + console.log(\`system npm child on \${node} Node.js\`); + process.exit(0); +} +console.log(\`system npm on \${node} Node.js\`); +const child = require('node:child_process').spawnSync('npm', ['--version'], { stdio: 'inherit' }); +process.exit(child.status ?? 1); +`, +); +chmodSync(npm, 0o755); + +// A system pnpm, a project-local tool and a system path tool, all on `#!/usr/bin/env node`, that +// report which Node.js runs them. +const reporter = (name) => `#!/usr/bin/env node +const node = process.execPath.includes('system-bin') + ? 'system' + : process.execPath.includes('js_runtime') + ? 'managed' + : 'other'; +console.log(\`${name} on \${node} Node.js\`); +`; +const pnpm = path.join('system-bin', 'pnpm'); +writeFileSync(pnpm, reporter('system pnpm')); +chmodSync(pnpm, 0o755); +mkdirSync(path.join('node_modules', '.bin'), { recursive: true }); +const localTool = path.join('node_modules', '.bin', 'local-tool'); +writeFileSync(localTool, reporter('local tool')); +chmodSync(localTool, 0o755); +// A system tool that is not a core tool, which vpx finds through its own PATH search. +const pathTool = path.join('system-bin', 'path-tool'); +writeFileSync(pathTool, reporter('system path tool')); +chmodSync(pathTool, 0o755); 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..6f60a86dae 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 @@ -45,6 +45,8 @@ steps = [ { argv = ["node", "--version"], tty = false, envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "The external manager forwards to the managed fallback without re-entering itself." }, { argv = ["node", "--version"], tty = false, envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], ["VP_PATH_INJECTED_TOOLS", "node"]], comment = "An inherited injection marker must not cause recursion either." }, { argv = ["vp", "env", "exec", "node", "--version"], envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"]], comment = "env exec still runs managed Node when the inherited PATH contains the external manager." }, + # Under VP_ENV_FORBIDDEN=1 the external manager forwards to the fallback shim, which fails at once. + { argv = ["node", "--version"], tty = false, timeout = 20000, envs = [["PATH", "${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "With VP_ENV_FORBIDDEN=1, Node.js fails in the fallback shim instead of forwarding back to the external manager or using the managed runtime.", continue-on-failure = true }, ] [[case]] @@ -64,6 +66,7 @@ steps = [ { argv = ["node", "list-shims.cjs", "shared-bin", "data/fallback-bin"], comment = "Setup preserves the existing executable in the shared bin." }, { argv = ["sh", "split.sh"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"]], comment = "The generated shell script puts shared bin first and data-root fallback last." }, { argv = ["node", "-p", "process.execPath.includes('shared-bin')"], envs = [["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"]], comment = "The foreign Node remains executable." }, + { argv = ["vpx", "node", "-p", "process.execPath.includes('shared-bin')"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/shared-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "With VP_ENV_FORBIDDEN=1, the foreign Node first on PATH in the shared bin is selected." }, # System-first adds a fallback under data while keeping the foreign Node intact. { argv = ["vp", "env", "off", "node"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", ""], ["VP_BIN_DIR", "${workspace}/shared-bin"], ["VP_DATA_DIR", "${workspace}/data"], ["VP_CACHE_DIR", "${workspace}/cache"], ["XDG_CONFIG_HOME", "${workspace}/config"], ["PATH", "${workspace}/data/current/bin${PATH_SEPARATOR}${PATH}"]], snapshot = false }, @@ -96,7 +99,7 @@ steps = [ name = "vpx_script_follows_vp_node" vp = "global" cwd = "vpx-project" -comment = "vpx script mode runs on the Node.js that vp node selects, including system-first mode." +comment = "vpx script mode runs on the Node.js that vp node selects, including system-first mode and VP_ENV_FORBIDDEN=1." steps = [ { argv = ["node", "../setup-system-node.cjs"], snapshot = false }, { argv = ["vp", "env", "on"], snapshot = false }, @@ -105,4 +108,89 @@ steps = [ { argv = ["vpx", "--print", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/fallback-bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "A Vite+ shim before system Node.js selects managed Node.js." }, ["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." }, + { argv = ["vpx", "--print", "process.execPath.includes('system-bin')"], envs = [["PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "VP_ENV_FORBIDDEN=1: the system Node.js first on PATH, as vp node selects, even with managed config.json." }, + { argv = ["vpx", "--print", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "VP_ENV_FORBIDDEN=1 with the node shim first on PATH fails and suggests `vp env off node`, as vp node does.", continue-on-failure = true }, +] + +[[case]] +name = "env_forbidden_overrides_managed_shims" +vp = "global" +# The pnpm pin must not be downloaded, so prewarming it would be wasted. +prewarm-package-managers = false +comment = "VP_ENV_FORBIDDEN=1 restricts the Node.js and package-manager shims alike: they never search PATH or use managed installs. With managed config.json they fail and suggest `vp env off`; system tools still run when they come before the shims, as in a devShell, or after `vp env off`. Nothing is downloaded and the child PATH is unchanged." +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 VP_ENV_FORBIDDEN steps: the pinned runtime is cached, and the override is not." }, + + # Managed config.json puts the shims first; the unreachable mirror and registry fail any request. + { argv = ["node", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "The node shim fails without searching PATH for the system Node.js after it, and suggests `vp env off node`.", continue-on-failure = true }, + { argv = ["node", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_BYPASS", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "VP_BYPASS does not make it search PATH either.", continue-on-failure = true }, + { argv = ["pnpm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "The pnpm shim fails the same way, despite the packageManager pin, and suggests `vp env off pnpm`.", continue-on-failure = true }, + { argv = ["npm", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "So does the npm shim, instead of running the npm bundled with a runtime.", continue-on-failure = true }, + { argv = ["node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "true"]], comment = "Only the exact value 1 stops management; managed config.json stays in effect." }, + + # A devShell puts the system tools before the shims; VP_NODE_VERSION would select a managed version. + { argv = ["vp", "node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["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 = "vp node runs the system Node.js first on PATH, ignoring the session override." }, + { argv = ["vpx", "node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["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 = "vpx node does the same." }, + { argv = ["vp", "env", "exec", "npm", "--version"], envs = [["PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH}"], ["EXPECTED_PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["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 = "vp env exec runs the system npm first on PATH, and the child PATH is unchanged." }, + + # After `vp env off node`, the node shim moves after the system tools. + ["vp", "env", "off", "node"], + { argv = ["node", "--version"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "Without a system Node.js before the fallback shim, the shim fails instead of using the managed runtime.", continue-on-failure = true }, + # Without node in bin, the default PATH would resolve the host Node.js, which the runner rejects. + { argv = ["node", "list-managed.cjs"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"]], comment = "The VP_ENV_FORBIDDEN steps downloaded nothing." }, +] + +[[case]] +name = "env_forbidden_child_processes" +vp = "global" +comment = "VP_ENV_FORBIDDEN=1 holds for what the selected tools run: a `#!/usr/bin/env node` shebang and a nested `npm` resolve from the child PATH. The shims add no directory to it and leave directories inherited from a parent in place; where Vite+ selects a tool from PATH, a managed install there fails. A nested managed npm would print its version instead of the system marker." +# The fake npm is a shebang script, and run-shim.cjs starts shims without the `.exe` suffix. +skip-platforms = ["windows"] +steps = [ + { argv = ["node", "setup-system-node.cjs"], snapshot = false }, + { argv = ["node", "setup-child-tools.cjs"], snapshot = false }, + + # The seeded runtime for the .node-version pin plays the managed runtime. It is only read: + # it links the shared seed, so the case cannot plant fake managed tools there. + # Its npm prints a version where the system npm prints a marker. + # run-shim.cjs starts the shim by path while the managed runtime is first on PATH. + # The unreachable mirror and registry fail any request. + { argv = ["node", "run-shim.cjs", "npm", "nested"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_PATH_INJECTED_TOOLS", "node,npm,npx"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "The npm shim fails before it would reuse the managed npm that a parent selected, and suggests `vp env off npm`.", continue-on-failure = true }, + { argv = ["node", "run-shim.cjs", "npm", "--version"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_BYPASS", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "VP_BYPASS does not hand the npm shim to the first npm on PATH outside the shims either.", continue-on-failure = true }, + { argv = ["vpx", "node", "-p", "process.execPath.includes('system-bin')"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "vpx takes Node.js from the first PATH match, the inherited managed runtime, and fails instead of skipping it.", continue-on-failure = true }, + { argv = ["vpx", "npm", "nested"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "vpx fails the same way for npm when the first match is the npm bundled with the inherited managed runtime.", continue-on-failure = true }, + { argv = ["vpx", "npm", "--version"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "Without a system npm either, the first match is the npm bundled with the inherited managed runtime, so vpx fails with a pointer to `vp env doctor` instead of running it or downloading one through dlx.", continue-on-failure = true }, + + # run-shim.cjs starts shims from the bin directory, so the steps above come before the shims move. + ["vp", "env", "off", "npm"], + # The npm shim lives in fallback-bin from here on; the node shim stays in bin. + { argv = ["npm", "nested"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "The shell runs system npm, whose shebang reaches the node shim still first on PATH. The shim fails and suggests `vp env off node` instead of running the managed runtime.", continue-on-failure = true }, + # Both shims live in fallback-bin from here on, after the system tools. + ["vp", "env", "off", "node"], + { argv = ["npm", "nested"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "With the shims after the system tools, the shell runs system npm on system Node.js, and so does its nested npm, without Vite+." }, + { argv = ["vpx", "npm", "nested"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "vpx selects the same system npm as the first PATH match." }, + { argv = ["vp", "env", "exec", "npm", "nested"], envs = [["PATH", "${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "So does vp env exec." }, + { argv = ["vp", "env", "exec", "npm", "--version"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "vp env exec rejects the npm bundled with an inherited managed runtime, the first PATH match, instead of running it.", continue-on-failure = true }, + + # The inherited managed runtime has no pnpm, so the first pnpm on PATH is the system one. + # Its shebang would still run the managed Node.js first on PATH. + { argv = ["vp", "env", "exec", "pnpm", "--version"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "vp env exec rejects a system pnpm when the first Node.js on the child PATH is the inherited managed runtime, as vp node does.", continue-on-failure = true }, + { argv = ["vpx", "pnpm", "--version"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "So does vpx for a tool it finds on PATH.", continue-on-failure = true }, + { argv = ["vpx", "local-tool"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "And for a tool in node_modules/.bin.", continue-on-failure = true }, + { argv = ["vpx", "path-tool"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "And for a tool it finds through its own PATH search.", continue-on-failure = true }, + { argv = ["vp", "env", "exec", "pnpm", "--version"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "When the pnpm shim is the first pnpm on PATH, its error comes before the Node.js check.", continue-on-failure = true }, + { argv = ["vp", "env", "exec", "pnpm", "--version"], envs = [["PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "With the system Node.js first, the system pnpm runs on it." }, + { argv = ["vpx", "pnpm", "--version"], envs = [["PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "So does vpx." }, + { argv = ["vpx", "local-tool"], envs = [["PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "And the local tool." }, + { argv = ["vpx", "path-tool"], envs = [["PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "And the system path tool." }, + { argv = ["vpx", "corepack", "--version"], envs = [["PATH", "${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "With the system Node.js first, vpx still rejects the corepack bundled with the managed runtime, the first corepack on PATH, instead of running it.", continue-on-failure = true }, + + # With node system-first, as VP_ENV_FORBIDDEN=1 installs it, a managed runtime first on PATH + # is not reused as the system Node.js. + { argv = ["vp", "env", "which", "node"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "vp env which does not take the managed runtime first on PATH for the system Node.js. It still resolves the pinned managed version, which it does not run." }, + { argv = ["vp", "env", "current"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "Neither does vp env current." }, + { argv = ["vp", "install"], envs = [["PATH", "${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH}"], ["VP_ENV_FORBIDDEN", "1"], ["VP_NODE_DIST_MIRROR", "http://127.0.0.1:1"], ["NPM_CONFIG_REGISTRY", "http://127.0.0.1:1"]], comment = "A delegated command does not take the managed runtime for the system Node.js either. It stops at the safety net instead of running on it or downloading one.", continue-on-failure = true }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/env_forbidden_child_processes.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/env_forbidden_child_processes.md new file mode 100644 index 0000000000..7551da4e24 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/env_forbidden_child_processes.md @@ -0,0 +1,261 @@ +# env_forbidden_child_processes + +VP_ENV_FORBIDDEN=1 holds for what the selected tools run: a `#!/usr/bin/env node` shebang and a nested `npm` resolve from the child PATH. The shims add no directory to it and leave directories inherited from a parent in place; where Vite+ selects a tool from PATH, a managed install there fails. A nested managed npm would print its version instead of the system marker. + +## `node setup-system-node.cjs` + + +## `node setup-child-tools.cjs` + + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_PATH_INJECTED_TOOLS=node,npm,npx VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 node run-shim.cjs npm nested` + +The npm shim fails before it would reuse the managed npm that a parent selected, and suggests `vp env off npm`. + +**Exit code:** 1 + +``` +vp: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'npm', but the Vite+ shim comes first on PATH; run `vp env off npm` to move the shims after the system tools +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_BYPASS=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 node run-shim.cjs npm --version` + +VP_BYPASS does not hand the npm shim to the first npm on PATH outside the shims either. + +**Exit code:** 1 + +``` +vp: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'npm', but the Vite+ shim comes first on PATH; run `vp env off npm` to move the shims after the system tools +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx node -p process.execPath.includes('system-bin')` + +vpx takes Node.js from the first PATH match, the inherited managed runtime, and fails instead of skipping it. + +**Exit code:** 1 + +``` +error: vpx: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', but PATH selects the Vite+ managed install at '/.vite-plus/js_runtime/node//bin/node'; run `vp env doctor` to check PATH +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx npm nested` + +vpx fails the same way for npm when the first match is the npm bundled with the inherited managed runtime. + +**Exit code:** 1 + +``` +error: vpx: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'npm', but PATH selects the Vite+ managed install at '/.vite-plus/js_runtime/node//bin/npm'; run `vp env doctor` to check PATH +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx npm --version` + +Without a system npm either, the first match is the npm bundled with the inherited managed runtime, so vpx fails with a pointer to `vp env doctor` instead of running it or downloading one through dlx. + +**Exit code:** 1 + +``` +error: vpx: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'npm', but PATH selects the Vite+ managed install at '/.vite-plus/js_runtime/node//bin/npm'; run `vp env doctor` to check PATH +``` + +## `vp env off npm` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ npm management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 npm nested` + +The shell runs system npm, whose shebang reaches the node shim still first on PATH. The shim fails and suggests `vp env off node` instead of running the managed runtime. + +**Exit code:** 1 + +``` +vp: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', but the Vite+ shim comes first on PATH; run `vp env off node` to move the shims after the system tools +``` + +## `vp env off node` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 npm nested` + +With the shims after the system tools, the shell runs system npm on system Node.js, and so does its nested npm, without Vite+. + +``` +system npm on system Node.js +system npm child on system Node.js +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx npm nested` + +vpx selects the same system npm as the first PATH match. + +``` +system npm on system Node.js +system npm child on system Node.js +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vp env exec npm nested` + +So does vp env exec. + +``` +system npm on system Node.js +system npm child on system Node.js +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vp env exec npm --version` + +vp env exec rejects the npm bundled with an inherited managed runtime, the first PATH match, instead of running it. + +**Exit code:** 1 + +``` +error: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'npm', but PATH selects the Vite+ managed install at '/.vite-plus/js_runtime/node//bin/npm'; run `vp env doctor` to check PATH +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vp env exec pnpm --version` + +vp env exec rejects a system pnpm when the first Node.js on the child PATH is the inherited managed runtime, as vp node does. + +**Exit code:** 1 + +``` +error: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', but PATH selects the Vite+ managed install at '/.vite-plus/js_runtime/node//bin/node'; run `vp env doctor` to check PATH +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx pnpm --version` + +So does vpx for a tool it finds on PATH. + +**Exit code:** 1 + +``` +error: vpx: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', but PATH selects the Vite+ managed install at '/.vite-plus/js_runtime/node//bin/node'; run `vp env doctor` to check PATH +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx local-tool` + +And for a tool in node_modules/.bin. + +**Exit code:** 1 + +``` +error: vpx: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', but PATH selects the Vite+ managed install at '/.vite-plus/js_runtime/node//bin/node'; run `vp env doctor` to check PATH +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx path-tool` + +And for a tool it finds through its own PATH search. + +**Exit code:** 1 + +``` +error: vpx: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', but PATH selects the Vite+ managed install at '/.vite-plus/js_runtime/node//bin/node'; run `vp env doctor` to check PATH +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${VP_HOME}/bin VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vp env exec pnpm --version` + +When the pnpm shim is the first pnpm on PATH, its error comes before the Node.js check. + +**Exit code:** 1 + +``` +error: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'pnpm', but the Vite+ shim comes first on PATH; run `vp env off pnpm` to move the shims after the system tools +``` + +## `PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vp env exec pnpm --version` + +With the system Node.js first, the system pnpm runs on it. + +``` +system pnpm on system Node.js +``` + +## `PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx pnpm --version` + +So does vpx. + +``` +system pnpm on system Node.js +``` + +## `PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx local-tool` + +And the local tool. + +``` +local tool on system Node.js +``` + +## `PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx path-tool` + +And the system path tool. + +``` +system path tool on system Node.js +``` + +## `PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx corepack --version` + +With the system Node.js first, vpx still rejects the corepack bundled with the managed runtime, the first corepack on PATH, instead of running it. + +**Exit code:** 1 + +``` +error: vpx: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'corepack', but PATH selects the Vite+ managed install at '/.vite-plus/js_runtime/node//bin/corepack'; run `vp env doctor` to check PATH +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vp env which node` + +vp env which does not take the managed runtime first on PATH for the system Node.js. It still resolves the pinned managed version, which it does not run. + +``` +VITE+ - The Unified Toolchain for the Web + +/.vite-plus/js_runtime/node//bin/node + Version: 20.20.2 + Source: /.node-version +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vp env current` + +Neither does vp env current. + +``` +VITE+ - The Unified Toolchain for the Web + +Node.js: + Version 20.20.2 + Source .node-version + Bin Path /.vite-plus/js_runtime/node//bin/node + Installed true + Mode system_first +``` + +## `PATH=${VP_HOME}/js_runtime/node/20.20.2/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vp install` + +A delegated command does not take the managed runtime for the system Node.js either. It stops at the safety net instead of running on it or downloading one. + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +error: Failed to download Node.js runtime: VP_ENV_FORBIDDEN=1: Vite+ does not manage Node.js; it neither downloads nor uses a managed Node.js +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/env_forbidden_overrides_managed_shims.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/env_forbidden_overrides_managed_shims.md new file mode 100644 index 0000000000..0f77bab61b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/env_forbidden_overrides_managed_shims.md @@ -0,0 +1,127 @@ +# env_forbidden_overrides_managed_shims + +VP_ENV_FORBIDDEN=1 restricts the Node.js and package-manager shims alike: they never search PATH or use managed installs. With managed config.json they fail and suggest `vp env off`; system tools still run when they come before the shims, as in a devShell, or after `vp env off`. Nothing is downloaded and the child PATH is unchanged. + +## `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 VP_ENV_FORBIDDEN 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_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 node --version` + +The node shim fails without searching PATH for the system Node.js after it, and suggests `vp env off node`. + +**Exit code:** 1 + +``` +vp: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', but the Vite+ shim comes first on PATH; run `vp env off node` to move the shims after the system tools +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_BYPASS=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 node --version` + +VP_BYPASS does not make it search PATH either. + +**Exit code:** 1 + +``` +vp: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', but the Vite+ shim comes first on PATH; run `vp env off node` to move the shims after the system tools +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 pnpm --version` + +The pnpm shim fails the same way, despite the packageManager pin, and suggests `vp env off pnpm`. + +**Exit code:** 1 + +``` +vp: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'pnpm', but the Vite+ shim comes first on PATH; run `vp env off pnpm` to move the shims after the system tools +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 npm --version` + +So does the npm shim, instead of running the npm bundled with a runtime. + +**Exit code:** 1 + +``` +vp: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'npm', but the Vite+ shim comes first on PATH; run `vp env off npm` to move the shims after the system tools +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=true node -p process.execPath.includes('system-bin')` + +Only the exact value 1 stops management; managed config.json stays in effect. + +``` +false +``` + +## `PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 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 vp node -p process.execPath.includes('system-bin')` + +vp node runs the system Node.js first on PATH, ignoring the session override. + +``` +true +``` + +## `PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 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 vpx node -p process.execPath.includes('system-bin')` + +vpx node does the same. + +``` +true +``` + +## `PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH} EXPECTED_PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 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 vp env exec npm --version` + +vp env exec runs the system npm first on PATH, and the child PATH is unchanged. + +``` +system npm --version +same PATH +``` + +## `vp env off node` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 node --version` + +Without a system Node.js before the fallback shim, the shim fails instead of using the managed runtime. + +**Exit code:** 1 + +``` +vp: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', and no system 'node' comes before the Vite+ shims on PATH +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} node list-managed.cjs` + +The VP_ENV_FORBIDDEN steps downloaded nothing. + +``` +managed Node.js (.node-version pin): true +managed Node.js (VP_NODE_VERSION override): false +package_manager: (empty) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md index dc2cb316f3..d2c60b925d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/foreign_manager_falls_back_without_recursion.md @@ -38,3 +38,13 @@ env exec still runs managed Node when the inherited PATH contains the external m ``` ``` + +## `PATH=${workspace}/foreign-bin${PATH_SEPARATOR}${VP_HOME}/fallback-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 node --version` + +With VP_ENV_FORBIDDEN=1, Node.js fails in the fallback shim instead of forwarding back to the external manager or using the managed runtime. + +**Exit code:** 1 + +``` +vp: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', and no system 'node' comes before the Vite+ shims on PATH +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md index 18c87e8ac6..9513e52b6d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/split_layout_preserves_foreign_tools.md @@ -39,6 +39,14 @@ The foreign Node remains executable. true ``` +## `HOME=${workspace}/user VP_HOME= VP_BIN_DIR=${workspace}/shared-bin VP_DATA_DIR=${workspace}/data VP_CACHE_DIR=${workspace}/cache XDG_CONFIG_HOME=${workspace}/config PATH=${workspace}/shared-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx node -p process.execPath.includes('shared-bin')` + +With VP_ENV_FORBIDDEN=1, the foreign Node first on PATH in the shared bin is selected. + +``` +true +``` + ## `HOME=${workspace}/user VP_HOME= VP_BIN_DIR=${workspace}/shared-bin VP_DATA_DIR=${workspace}/data VP_CACHE_DIR=${workspace}/cache XDG_CONFIG_HOME=${workspace}/config PATH=${workspace}/data/current/bin${PATH_SEPARATOR}${PATH} vp env off node` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/vpx_script_follows_vp_node.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/vpx_script_follows_vp_node.md index 07da40c3d9..6dbd5e5ccf 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/vpx_script_follows_vp_node.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_system_first_fallback/snapshots/vpx_script_follows_vp_node.md @@ -1,6 +1,6 @@ # vpx_script_follows_vp_node -vpx script mode runs on the Node.js that vp node selects, including system-first mode. +vpx script mode runs on the Node.js that vp node selects, including system-first mode and VP_ENV_FORBIDDEN=1. ## `node ../setup-system-node.cjs` @@ -46,3 +46,21 @@ Managed mode ignores system Node.js. ``` false ``` + +## `PATH=${workspace}/system-bin${PATH_SEPARATOR}${VP_HOME}/bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx --print process.execPath.includes('system-bin')` + +VP_ENV_FORBIDDEN=1: the system Node.js first on PATH, as vp node selects, even with managed config.json. + +``` +true +``` + +## `PATH=${VP_HOME}/bin${PATH_SEPARATOR}${workspace}/system-bin${PATH_SEPARATOR}${PATH} VP_ENV_FORBIDDEN=1 VP_NODE_DIST_MIRROR=http://127.0.0.1:1 NPM_CONFIG_REGISTRY=http://127.0.0.1:1 vpx --print process.execPath.includes('system-bin')` + +VP_ENV_FORBIDDEN=1 with the node shim first on PATH fails and suggests `vp env off node`, as vp node does. + +**Exit code:** 1 + +``` +error: vpx: VP_ENV_FORBIDDEN=1: Vite+ does not manage 'node', but the Vite+ shim comes first on PATH; run `vp env off node` to move the shims after the system tools +``` 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..26f320b187 100644 --- a/crates/vp_command/src/external_tool.rs +++ b/crates/vp_command/src/external_tool.rs @@ -78,14 +78,234 @@ pub fn find_external_tool( } } +/// The first PATH match of a tool, classified by who owns it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FirstPathMatch { + /// A tool outside Vite+. + System(AbsolutePathBuf), + /// A Vite+ shim, from this or another installation. + ViteShim(AbsolutePathBuf), + /// A Vite+-owned install: a managed runtime or package manager (see + /// [`is_managed_install`]), or a global package. + VpOwned(AbsolutePathBuf), + /// No match on PATH. + NotFound, +} + +/// Classify the first match of `tool` on `path`. +/// +/// Only the first match counts: entries after it are never searched, so the +/// caller decides what a shim or a Vite+-owned install means instead of +/// skipping it. A shim is a Windows trampoline, a Unix link to `vp`, or +/// `self_exe` (canonicalized), as for [`find_external_tool`]. +/// `cwd` only resolves relative PATH entries. +pub fn classify_first_path_match( + tool: &str, + path: &OsStr, + self_exe: Option<&Path>, + cwd: &AbsolutePath, +) -> FirstPathMatch { + let paths = std::env::split_paths(path).map(|path| { + if path.is_absolute() || path.starts_with("~") { path } else { cwd.as_path().join(path) } + }); + let Ok(path) = std::env::join_paths(paths) else { + return FirstPathMatch::NotFound; + }; + let Ok(resolved) = resolve_bin(tool, Some(&path), cwd) else { + return FirstPathMatch::NotFound; + }; + let canonical = resolved.as_path().canonicalize().ok(); + let is_unix_shim = cfg!(unix) + && canonical + .as_ref() + .is_some_and(|target| target.file_name().is_some_and(|name| name == "vp")); + if vp_shared::is_windows_trampoline(resolved.as_path()) + || is_unix_shim + || (self_exe.is_some() && canonical.as_deref() == self_exe) + { + FirstPathMatch::ViteShim(resolved) + } else if is_managed_install(resolved.as_path()) || is_global_package(resolved.as_path()) { + FirstPathMatch::VpOwned(resolved) + } else { + FirstPathMatch::System(resolved) + } +} + /// 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, &[Path::new("js_runtime"), Path::new("package_manager")]) +} + +/// Whether `path` resolves into a `vp install -g` package, that is, under +/// `/packages` (the directory of `get_packages_dir` in +/// `vp_global_cli`). An old `corepack enable` can leave links from the bin +/// directory into a Corepack installed there. +fn is_global_package(path: &Path) -> bool { + resolves_under_data(path, &[Path::new("packages")]) +} + +fn resolves_under_data(path: &Path, directories: &[&Path]) -> 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 first(path: &[&Path], self_exe: Option<&Path>, cwd: &AbsolutePath) -> FirstPathMatch { + let path = std::env::join_paths(path).unwrap(); + classify_first_path_match("node", &path, self_exe, cwd) + } + + fn absolute(path: &Path) -> AbsolutePathBuf { + AbsolutePathBuf::new(path.to_path_buf()).unwrap() + } + + /// Make `dir/node` a shim: a link to `vp` on Unix, a trampoline on Windows. + fn make_shim(config: &EnvConfig, home: &Path, dir: &Path) -> PathBuf { + #[cfg(unix)] + { + let _ = config; + let vp = fake_executable(&home.join("current").join("bin"), "vp"); + std::fs::create_dir_all(dir).unwrap(); + let shim = dir.join("node"); + std::os::unix::fs::symlink(&vp, &shim).unwrap(); + shim + } + #[cfg(windows)] + { + let _ = home; + let shim = fake_executable(dir, "node"); + config.dirs.write_shim_pointer_beside(&shim).unwrap(); + shim + } + } + + #[test] + fn first_match_classifies_system_tools_and_managed_installs() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let bin = home.join("bin"); + let runtime = home.join("js_runtime").join("node").join("24.0.0").join("bin"); + let pm = home.join("package_manager").join("npm").join("10.0.0").join("bin"); + let system = temp.path().join("system"); + let managed_node = fake_executable(&runtime, "node"); + let pm_node = fake_executable(&pm, "node"); + let system_node = fake_executable(&system, "node"); + let foreign_node = fake_executable(&bin, "node"); + let cwd = absolute(temp.path()); + + EnvConfig::with_vars([(env_vars::VP_HOME, home.as_path())], |_| { + assert_eq!( + first(&[&system], None, &cwd), + FirstPathMatch::System(absolute(&system_node)) + ); + // A foreign tool in a shared bin directory is a system tool. + assert_eq!( + first(&[&bin, &system], None, &cwd), + FirstPathMatch::System(absolute(&foreign_node)) + ); + // Managed installs are reported, not skipped. + assert_eq!( + first(&[&runtime, &system], None, &cwd), + FirstPathMatch::VpOwned(absolute(&managed_node)) + ); + assert_eq!( + first(&[&pm, &system], None, &cwd), + FirstPathMatch::VpOwned(absolute(&pm_node)) + ); + assert_eq!(first(&[&temp.path().join("empty")], None, &cwd), FirstPathMatch::NotFound); + }); + } + + #[test] + fn first_match_stops_at_a_shim() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let bin = home.join("bin"); + let fallback = home.join("fallback-bin"); + let system = temp.path().join("system"); + fake_executable(&system, "node"); + let cwd = absolute(temp.path()); + + EnvConfig::with_vars([(env_vars::VP_HOME, home.as_path())], |config| { + // A system tool after the shim is never searched. + for dir in [&bin, &fallback] { + let shim = make_shim(&config, &home, dir); + assert_eq!( + first(&[dir, &system], None, &cwd), + FirstPathMatch::ViteShim(absolute(&shim)) + ); + } + }); + } + + #[cfg(unix)] + #[test] + fn first_match_reports_links_into_global_packages() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let bin = home.join("bin"); + let system = temp.path().join("system"); + fake_executable(&system, "node"); + let package_node = fake_executable( + &home.join("packages").join("corepack").join("lib").join("node_modules"), + "node", + ); + std::fs::create_dir_all(&bin).unwrap(); + std::os::unix::fs::symlink(&package_node, bin.join("node")).unwrap(); + let cwd = absolute(temp.path()); + + EnvConfig::with_vars([(env_vars::VP_HOME, home.as_path())], |_| { + assert_eq!( + first(&[&bin, &system], None, &cwd), + FirstPathMatch::VpOwned(absolute(&bin.join("node"))) + ); + }); + } + + #[test] + fn first_match_treats_self_exe_as_a_shim_and_resolves_relative_entries() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let relative = temp.path().join("relative"); + let node = fake_executable(&relative, "node"); + let cwd = absolute(temp.path()); + let canonical = node.canonicalize().unwrap(); + + EnvConfig::with_vars([(env_vars::VP_HOME, home.as_path())], |_| { + assert_eq!( + first(&[Path::new("relative")], None, &cwd), + FirstPathMatch::System(absolute(&node)) + ); + assert_eq!( + first(&[&relative], Some(&canonical), &cwd), + FirstPathMatch::ViteShim(absolute(&node)) + ); + }); + } +} diff --git a/crates/vp_command/src/lib.rs b/crates/vp_command/src/lib.rs index 69ebf982eb..ec145099ae 100644 --- a/crates/vp_command/src/lib.rs +++ b/crates/vp_command/src/lib.rs @@ -22,7 +22,10 @@ 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, FirstPathMatch, classify_first_path_match, find_external_tool, + is_managed_install, +}; 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..2cb0797970 100644 --- a/crates/vp_error/src/lib.rs +++ b/crates/vp_error/src/lib.rs @@ -96,6 +96,14 @@ pub enum Error { #[error("Invalid argument: {0}")] InvalidArgument(Str), + /// `VP_ENV_FORBIDDEN=1` forbids downloading or using a managed release of + /// `tool`, and resolving one through the registry. + #[error( + "{var}=1: Vite+ does not manage {tool}; it neither downloads nor uses a managed {tool}", + var = vp_shared::env_vars::VP_ENV_FORBIDDEN + )] + DisabledByEnvForbidden { tool: Str }, + #[cfg(feature = "migration")] #[error(transparent)] AstGrepConfigError(#[from] ast_grep_config::RuleConfigError), diff --git a/crates/vp_global_cli/src/commands/env/exec.rs b/crates/vp_global_cli/src/commands/env/exec.rs index c7e0d006be..236702a5e5 100644 --- a/crates/vp_global_cli/src/commands/env/exec.rs +++ b/crates/vp_global_cli/src/commands/env/exec.rs @@ -76,6 +76,17 @@ pub async fn execute( let args: Vec = command[1..].to_vec(); // stdout belongs to the dispatched tool; route vp's own output to stderr. vp_shared::output::route_user_output_to_stderr(); + // Under VP_ENV_FORBIDDEN=1 a core tool comes from PATH here, not from + // dispatch: its shim fails without searching PATH, even when a system + // tool comes before it. The Node.js it runs on comes from the child PATH. + if !from_wrapper + && is_core_shim_tool(tool) + && let Some(path) = crate::shim::dispatch::resolve_env_forbidden_tool(tool) + { + let path = path?; + crate::shim::dispatch::ensure_env_forbidden_child_node(&env)?; + return Ok(exit_status(crate::shim::exec::exec_tool(&path, &args, env))); + } // Dispatch assumes the shell's PATH lookup already preferred system-first tools, so repeat it here. if !from_wrapper && is_core_shim_tool(tool) diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 7d4b24aed2..219bbb4c78 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -225,6 +225,8 @@ pub(crate) async fn execute_for_binary( /// Complete preferences once, while the pre-upgrade shim layout is still available. /// Existing main-bin shims must stay there: a parent shell can have cached them. +/// `VP_ENV_FORBIDDEN=1` is the exception: every package manager becomes +/// system-first, and a cached main-bin shim fails without running anything. async fn initialize_package_manager_shim_modes( current_exe: &std::path::Path, settings: &mut super::config::Config, @@ -238,6 +240,12 @@ async fn initialize_package_manager_shim_modes( if settings.configured_package_manager_shim_mode_for(kind).is_some() { continue; } + // VP_ENV_FORBIDDEN=1 places every package manager after the system tools, as self-setup does. + if vp_shared::EnvConfig::get().env_forbidden { + settings.set_package_manager_shim_mode(kind, ShimMode::SystemFirst); + changed = true; + continue; + } let mut has_managed_tool = false; let mut has_system_first_tool = false; @@ -1693,6 +1701,43 @@ mod tests { ] } + /// Under `VP_ENV_FORBIDDEN=1` an unset package-manager mode becomes + /// system-first, as self-setup saves it, instead of following PATH. + #[tokio::test] + async fn test_initialize_package_manager_shim_modes_follows_env_forbidden() { + use super::super::{config::ShimMode, package_manager::ALL_PACKAGE_MANAGERS}; + + for (forbidden, expected) in + [(Some("1"), ShimMode::SystemFirst), (Some("true"), ShimMode::Managed)] + { + let temp_dir = TempDir::new().unwrap(); + let empty = temp_dir.path().join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + let vars = [ + (vp_shared::env_vars::VP_HOME, Some(temp_dir.path().as_os_str().to_owned())), + ("PATH", Some(empty.into_os_string())), + (vp_shared::env_vars::VP_ENV_FORBIDDEN, forbidden.map(Into::into)), + ]; + vp_shared::EnvConfig::with_vars_async(vars, |_| async { + let mut settings = super::super::config::Config::default(); + initialize_package_manager_shim_modes( + std::path::Path::new("/nonexistent/vp"), + &mut settings, + ) + .await + .unwrap(); + for kind in ALL_PACKAGE_MANAGERS { + assert_eq!( + settings.configured_package_manager_shim_mode_for(kind), + Some(expected), + "{forbidden:?} {kind}" + ); + } + }) + .await; + } + } + #[cfg(windows)] fn write_fake_trampoline(dir: &std::path::Path) -> std::path::PathBuf { let trampoline = dir.join("vp-shim.exe"); diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index bcc9056a14..0d2678bfd4 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; @@ -183,6 +183,39 @@ pub async fn execute(options: UpgradeOptions) -> Result { output::info("installing..."); } + install_version( + &platform_data, + install_dir, + &resolved, + current_version, + options.force, + options.silent, + options.registry.as_deref(), + ) + .await +} + +/// Install `resolved` from its platform tarball and switch to it. +async fn install_version( + platform_data: &[u8], + install_dir: &AbsolutePathBuf, + resolved: ®istry::ResolvedVersion, + current_version: &str, + force: bool, + silent: bool, + registry: Option<&str>, +) -> Result { + let version = &resolved.version; + // Refuse before touching the install directory: the target may be the + // retained previous version, which failed-install cleanup would delete. + ensure_staged_env_forbidden_support(platform_data, version).await?; + // Select the system node and pnpm before touching it for the same reason. + let system_tools = if vp_shared::EnvConfig::get().env_forbidden { + Some(install::system_install_tools(resolved.node_engines.as_deref()).await?) + } else { + None + }; + // Step 8: Create version directory. // // A forced reinstall of the active version cannot write into the active @@ -190,24 +223,22 @@ pub async fn execute(options: UpgradeOptions) -> Result { // Install into a unique semver build-metadata directory instead, then // repoint `current` after the install has completed. let active_install_dir = install::read_current_version(install_dir).await; - let install_dir_name = install::target_install_dir_name( - &resolved.version, - active_install_dir.as_deref(), - options.force, - ); + let install_dir_name = + install::target_install_dir_name(version, active_install_dir.as_deref(), force); let version_dir = install_dir.join(&install_dir_name); tokio::fs::create_dir_all(&version_dir).await?; // Step 9: Extract platform binary and install via npm let result = install_platform_and_main( - &platform_data, + platform_data, &version_dir, install_dir, &install_dir_name, - &resolved, + resolved, current_version, - options.silent, - options.registry.as_deref(), + silent, + registry, + system_tools.as_ref(), ) .await; @@ -231,25 +262,18 @@ async fn install_platform_and_main( current_version: &str, silent: bool, registry: Option<&str>, + system_tools: Option<&install::SystemInstallTools>, ) -> Result { let new_version = &resolved.version; - // Extract platform package (binary only; .node files installed via npm optionalDeps) - install::extract_platform_package(platform_data, version_dir).await?; - - // Verify binary was extracted - let binary_name = vp_setup::VP_BINARY_NAME; - let binary_path = version_dir.join("bin").join(binary_name); - if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { - return Err(Error::Upgrade( - "Binary not found after extraction. The download may be corrupted.".into(), - )); - } + extract_binary(platform_data, version_dir).await?; // Generate wrapper package.json that declares vite-plus as a dependency install::generate_wrapper_package_json(version_dir, new_version).await?; // Install production dependencies (pnpm installs vite-plus + all transitive deps) - let node_version = resolved.node_version.as_deref().map(install::NodeVersion::Required); + let node_version = system_tools + .map(install::NodeVersion::System) + .or_else(|| resolved.node_version.as_deref().map(install::NodeVersion::Required)); install::install_production_deps(version_dir, registry, !silent, node_version).await?; // Save previous version for rollback @@ -297,6 +321,74 @@ async fn install_platform_and_main( Ok(ExitStatus::default()) } +/// Extract the platform package into `dir` and return the path of its binary. +async fn extract_binary( + platform_data: &[u8], + dir: &AbsolutePath, +) -> Result { + // Extract platform package (binary only; .node files installed via npm optionalDeps) + install::extract_platform_package(platform_data, dir).await?; + + // Verify binary was extracted + let binary_path = dir.join("bin").join(vp_setup::VP_BINARY_NAME); + if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + return Err(Error::Upgrade( + "Binary not found after extraction. The download may be corrupted.".into(), + )); + } + Ok(binary_path) +} + +/// Refuse a target without `VP_ENV_FORBIDDEN` support while +/// `VP_ENV_FORBIDDEN=1` is set. The probe runs from a temporary +/// directory, so a refusal leaves every install directory unchanged. +async fn ensure_staged_env_forbidden_support( + platform_data: &[u8], + target: &str, +) -> Result<(), Error> { + if !vp_shared::EnvConfig::get().env_forbidden { + return Ok(()); + } + let staging = tempfile::tempdir().map_err(|e| { + Error::Upgrade(format!("Failed to create a temporary directory: {e}").into()) + })?; + let dir = AbsolutePathBuf::new(staging.path().to_path_buf()) + .ok_or_else(|| Error::Upgrade("Temporary directory must be absolute".into()))?; + let binary = extract_binary(platform_data, &dir).await?; + let support = vp_setup::support::probe_self_setup(&binary, true).await.map_err(|e| { + Error::Upgrade( + format!( + "Failed to run the downloaded vp binary from a temporary directory ({}): {e}. \ + Set TMPDIR (TMP or TEMP on Windows) to a directory that allows running programs.", + dir.as_path().display() + ) + .into(), + ) + })?; + require_env_forbidden_support(support, target) +} + +/// Refuse to switch to `binary` while `VP_ENV_FORBIDDEN=1` is set, +/// unless the binary supports `VP_ENV_FORBIDDEN`. +async fn ensure_env_forbidden_support(binary: &AbsolutePath, target: &str) -> Result<(), Error> { + if !vp_shared::EnvConfig::get().env_forbidden { + return Ok(()); + } + let support = vp_setup::support::probe_self_setup(binary, true).await?; + require_env_forbidden_support(support, target) +} + +/// A target without `VP_ENV_FORBIDDEN` support would download managed tools. +fn require_env_forbidden_support( + support: vp_setup::support::SelfSetupSupport, + target: &str, +) -> Result<(), Error> { + if support == vp_setup::support::SelfSetupSupport::EnvForbidden { + return Ok(()); + } + Err(Error::Upgrade(vp_setup::support::env_forbidden_unsupported_message(target).into())) +} + /// Execute rollback to the previous version. #[allow(clippy::print_stderr)] async fn execute_rollback( @@ -322,6 +414,9 @@ async fn execute_rollback( output::info(&format!("switching from {} {} {}", current_version, output::ARROW, previous)); } + ensure_env_forbidden_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?; @@ -341,3 +436,205 @@ 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 env_forbidden_requires_target_support() { + use std::os::unix::fs::PermissionsExt; + + use vp_setup::support::{ + ENV_FORBIDDEN_SUPPORT_CHECK, ENV_FORBIDDEN_SUPPORT_RESPONSE, + SELF_SETUP_SUPPORT_RESPONSE, + }; + + 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", + &format!( + r#"echo {SELF_SETUP_SUPPORT_RESPONSE}; [ "$VP_SELF_SETUP_SUPPORT_CHECK" = {ENV_FORBIDDEN_SUPPORT_CHECK} ] && echo {ENV_FORBIDDEN_SUPPORT_RESPONSE}; exit 0"# + ), + ); + let unsupported = script("unsupported", &format!("echo {SELF_SETUP_SUPPORT_RESPONSE}")); + + // Only the exact value `1` requires support. + for value in [None, Some("true")] { + EnvConfig::with_vars_async([(env_vars::VP_ENV_FORBIDDEN, value)], |_| async { + ensure_env_forbidden_support(&unsupported, "0.1.0").await.unwrap(); + }) + .await; + } + EnvConfig::with_vars_async(env_forbidden(), |_| async { + ensure_env_forbidden_support(&supported, "9.0.0").await.unwrap(); + let error = ensure_env_forbidden_support(&unsupported, "0.1.0").await.unwrap_err(); + assert!( + error + .to_string() + .contains(&vp_setup::support::env_forbidden_unsupported_message("0.1.0")), + "{error}" + ); + }) + .await; + } + + fn env_forbidden() -> [(&'static str, Option<&'static str>); 1] { + [(env_vars::VP_ENV_FORBIDDEN, Some("1"))] + } + + /// A registry resolution of `version` without a Node.js pin. + fn resolved(version: &str) -> registry::ResolvedVersion { + registry::ResolvedVersion { + version: version.into(), + platform_tarball_url: String::new(), + platform_integrity: String::new(), + has_install_lockfile: false, + node_version: None, + node_engines: None, + } + } + + /// A platform tarball whose `vp` binary is the shell script `vp_script`. + fn platform_tarball(vp_script: &str) -> Vec { + use flate2::{Compression, write::GzEncoder}; + + let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); + for (path, data, mode) in + [("package/package.json", "{}", 0o644), ("package/vp", vp_script, 0o755)] + { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(mode); + header.set_cksum(); + archive.append_data(&mut header, path, data.as_bytes()).unwrap(); + } + archive.into_inner().unwrap().finish().unwrap() + } + + #[tokio::test] + async fn env_forbidden_refusal_preserves_retained_version() { + let temp = tempfile::tempdir().unwrap(); + let install_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let retained = install_dir.join("0.1.0"); + std::fs::create_dir_all(&retained).unwrap(); + std::fs::write(retained.join("marker"), "").unwrap(); + std::fs::write(install_dir.join(".previous-version"), "0.1.0").unwrap(); + let tarball = platform_tarball(&format!( + "#!/bin/sh\necho {}\n", + vp_setup::support::SELF_SETUP_SUPPORT_RESPONSE + )); + + EnvConfig::with_vars_async(env_forbidden(), |_| async { + let error = Box::pin(install_version( + &tarball, + &install_dir, + &resolved("0.1.0"), + "9.9.9", + false, + true, + None, + )) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains(&vp_setup::support::env_forbidden_unsupported_message("0.1.0")), + "{error}" + ); + }) + .await; + + assert!(retained.join("marker").as_path().exists(), "retained version was removed"); + assert!(!retained.join("bin").as_path().exists(), "retained version was modified"); + assert_eq!( + std::fs::read_to_string(install_dir.join(".previous-version")).unwrap(), + "0.1.0" + ); + } + + // The probe must succeed, which needs the shell-script binary. + #[cfg(unix)] + #[tokio::test] + async fn env_forbidden_system_tool_refusal_preserves_retained_version() { + let temp = tempfile::tempdir().unwrap(); + let install_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let retained = install_dir.join("0.1.0"); + std::fs::create_dir_all(&retained).unwrap(); + std::fs::write(retained.join("marker"), "").unwrap(); + std::fs::write(install_dir.join(".previous-version"), "0.1.0").unwrap(); + let tarball = platform_tarball(&format!( + "#!/bin/sh\necho {}\necho {}\n", + vp_setup::support::SELF_SETUP_SUPPORT_RESPONSE, + vp_setup::support::ENV_FORBIDDEN_SUPPORT_RESPONSE + )); + // No Node.js satisfies this range, so the check fails whatever PATH holds. + let resolved = + registry::ResolvedVersion { node_engines: Some("<0.0.1".into()), ..resolved("0.1.0") }; + + EnvConfig::with_vars_async(env_forbidden(), |_| async { + let error = Box::pin(install_version( + &tarball, + &install_dir, + &resolved, + "9.9.9", + false, + true, + None, + )) + .await + .unwrap_err(); + assert!( + error.to_string().contains("Vite+ installs its dependencies with the system"), + "{error}" + ); + }) + .await; + + assert!(retained.join("marker").as_path().exists(), "retained version was removed"); + assert!(!retained.join("bin").as_path().exists(), "retained version was modified"); + assert_eq!( + std::fs::read_to_string(install_dir.join(".previous-version")).unwrap(), + "0.1.0" + ); + } + + #[tokio::test] + async fn env_forbidden_probe_launch_failure_explains_temporary_directory() { + let temp = tempfile::tempdir().unwrap(); + let install_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let tarball = platform_tarball("#!/nonexistent/sh\n"); + + EnvConfig::with_vars_async(env_forbidden(), |_| async { + let error = Box::pin(install_version( + &tarball, + &install_dir, + &resolved("9.0.0"), + "0.1.0", + false, + true, + None, + )) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("Failed to run the downloaded vp binary from a temporary directory"), + "{error}" + ); + }) + .await; + + assert!(!install_dir.join("9.0.0").as_path().exists(), "version directory was created"); + } +} diff --git a/crates/vp_global_cli/src/commands/vpx.rs b/crates/vp_global_cli/src/commands/vpx.rs index 6f9613c117..abca05660e 100644 --- a/crates/vp_global_cli/src/commands/vpx.rs +++ b/crates/vp_global_cli/src/commands/vpx.rs @@ -153,7 +153,7 @@ pub async fn execute_vpx(args: &[String], cwd: &AbsolutePath) -> i32 { // 1. Try local node_modules/.bin if let Some(local_bin) = find_local_binary(cwd, &cmd_name) { tracing::debug!("vpx: found local binary at {}", local_bin.as_path().display()); - let child_env = match local_bin_env(cwd) { + let child_env = match tool_env(cwd) { Ok(env) => env, Err(error) => { output::error(&format!("vpx: {error}")); @@ -170,10 +170,28 @@ pub async fn execute_vpx(args: &[String], cwd: &AbsolutePath) -> i32 { return execute_global_binary(global_bin, &positional[1..], cwd).await; } - // 3. Try system PATH (excluding vite-plus bin dir) - if let Some(path_bin) = find_on_path(&cmd_name) { + // 3. Try system PATH. Under VP_ENV_FORBIDDEN=1 a core tool is the first + // PATH match, shims included, and fails instead of falling back to + // `dlx`, which would download it. Other tools skip the vite-plus bin dir; + // under VP_ENV_FORBIDDEN=1 one in a managed install fails too. + // As in step 1, `tool_env` checks the Node.js the tool would run on. + let path_bin = match dispatch::resolve_env_forbidden_tool(&cmd_name) { + Some(Ok(path)) => Some(path), + Some(Err(e)) => { + output::error(&format!("vpx: {e}")); + return 1; + } + None => find_on_path(&cmd_name), + }; + if let Some(path_bin) = &path_bin + && let Err(e) = dispatch::ensure_env_forbidden_not_managed(&cmd_name, path_bin) + { + output::error(&format!("vpx: {e}")); + return 1; + } + if let Some(path_bin) = path_bin { tracing::debug!("vpx: found on PATH at {}", path_bin.as_path().display()); - let child_env = match local_bin_env(cwd) { + let child_env = match tool_env(cwd) { Ok(env) => env, Err(error) => { output::error(&format!("vpx: {error}")); @@ -229,23 +247,33 @@ 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. The Node.js +/// directory is not added under `VP_ENV_FORBIDDEN=1`, where Node.js comes from PATH. async fn execute_global_binary(bin: GlobalBinary, args: &[String], cwd: &AbsolutePath) -> i32 { // 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)); + let node_path = match dispatch::resolve_env_forbidden_tool("node") { + Some(Ok(path)) => path, + Some(Err(e)) => { + output::error(&format!("vpx: {e}")); return 1; } + None => 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 !vp_shared::EnvConfig::get().env_forbidden { + 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) { @@ -287,6 +315,16 @@ fn find_on_path(cmd: &str) -> Option { vp_command::resolve_bin(cmd, Some(&filtered_path), &cwd).ok() } +/// The child environment for a tool that vpx found locally or on PATH. +/// +/// Under `VP_ENV_FORBIDDEN=1` it fails when a managed Node.js comes first on +/// the child PATH, where the tool's shebang and scripts would find it. +fn tool_env(cwd: &AbsolutePath) -> Result { + let env = local_bin_env(cwd).map_err(|error| error.to_string())?; + dispatch::ensure_env_forbidden_child_node(&env).map_err(|error| error.to_string())?; + Ok(env) +} + /// Prepend all `node_modules/.bin` directories from cwd upward to PATH. /// /// Walks up from cwd and prepends each existing `node_modules/.bin` directory diff --git a/crates/vp_global_cli/src/commands/vpx_script.rs b/crates/vp_global_cli/src/commands/vpx_script.rs index 2a5f8d5187..fea578ac0e 100644 --- a/crates/vp_global_cli/src/commands/vpx_script.rs +++ b/crates/vp_global_cli/src/commands/vpx_script.rs @@ -491,8 +491,19 @@ async fn run_node(args: Vec, cwd: &AbsolutePath) -> i32 { // stdout belongs to the script; route vp's own output to stderr. output::route_user_output_to_stderr(); - // As `vp node` does: dispatch assumes the shell's PATH lookup already preferred a - // system-first Node.js, so repeat that lookup here. + // As `vp node` does: under VP_ENV_FORBIDDEN=1 Node.js comes from PATH here, + // not from dispatch, since its shim fails without searching PATH. + if let Some(node) = crate::shim::dispatch::resolve_env_forbidden_tool("node") { + return match node { + Ok(node) => crate::shim::exec::exec_tool(&node, &args, env), + Err(error) => { + output::error(&format!("vpx: {error}")); + 1 + } + }; + } + // Dispatch assumes the shell's PATH lookup already preferred a system-first Node.js, + // so repeat that lookup here. if let Ok(config) = super::env::config::load_config().await && config.shim_mode_for_tool("node") == super::env::config::ShimMode::SystemFirst && let Some(node) = crate::shim::dispatch::find_system_tool("node") diff --git a/crates/vp_global_cli/src/error.rs b/crates/vp_global_cli/src/error.rs index 1cd52d92aa..ea57386c13 100644 --- a/crates/vp_global_cli/src/error.rs +++ b/crates/vp_global_cli/src/error.rs @@ -52,6 +52,30 @@ pub enum Error { #[error("Upgrade error: {0}")] Upgrade(Str), + /// `VP_ENV_FORBIDDEN=1` requires a system tool that PATH lacks before the + /// Vite+ shims. + #[error( + "{var}=1: Vite+ does not manage '{tool}', and no system '{tool}' comes before the Vite+ shims on PATH", + var = vp_shared::env_vars::VP_ENV_FORBIDDEN + )] + SystemToolNotFound { tool: Str }, + + /// A tool under `VP_ENV_FORBIDDEN=1` reached the Vite+ shim at the front + /// of PATH, where managed `config.json` places it. + #[error( + "{var}=1: Vite+ does not manage '{tool}', but the Vite+ shim comes first on PATH; run `vp env off {component}` to move the shims after the system tools", + var = vp_shared::env_vars::VP_ENV_FORBIDDEN + )] + SystemToolBehindShim { tool: Str, component: Str }, + + /// A lookup under `VP_ENV_FORBIDDEN=1` reached a Vite+ managed install, + /// which a process with other settings left on PATH. + #[error( + "{var}=1: Vite+ does not manage '{tool}', but PATH selects the Vite+ managed install at '{path}'; run `vp env doctor` to check PATH", + var = vp_shared::env_vars::VP_ENV_FORBIDDEN + )] + SystemToolManagedInstall { tool: Str, path: 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 3e29141c0b..06f2d18108 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -332,8 +332,10 @@ fn is_homebrew_recovery_command(args: &[String]) -> 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 db330f9017..e75debcaa1 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -219,7 +219,9 @@ async fn run( )); } let previous_install = if deploy { previous_install()? } else { None }; - let node_override = shim_mode(env.node_manager); + // VP_ENV_FORBIDDEN=1 places every component after the system tools, whatever VP_*_MANAGER says. + let forbidden = env.env_forbidden.then_some(config::ShimMode::SystemFirst); + let node_override = forbidden.or_else(|| shim_mode(env.node_manager)); // A package upgrade can change the executable path or expire its receipt. // Preferences belong to the user, not to that particular binary. let configured = externally_owned && config::get_config_path()?.as_path().is_file(); @@ -254,6 +256,36 @@ async fn run( return Err(Error::Other("Invalid local installation version".into())); } + // VP_ENV_FORBIDDEN=1 installs with the system node and pnpm. Check them before + // changing anything: a same-version reinstall reuses the active directory, and + // Homebrew setup saves preferences and creates its package directory below. + let system_tools = if env.env_forbidden && !skip_deps && !bundled { + let source = AbsolutePathBuf::new(source.to_path_buf()).ok_or(Error::CliBinaryNotFound)?; + let installs = if let Some(package) = user_package { + !external::package_complete(&source, package).await? + } else if deploy { + true + } else { + // An in-place setup completes the version directory it runs from. Missing or + // stale installation receipts reinstall, as the lockfile preparation below decides. + let version_dir = source.parent().and_then(|bin| bin.parent()); + match version_dir { + Some(version_dir) => { + !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file() + || !vp_setup::lockfile::is_complete(&source, version_dir).await? + } + None => true, + } + }; + if installs { + Some(install::system_install_tools(Some(&runtime::cli_node_range())).await?) + } else { + None + } + } else { + None + }; + // 1. Prepare the payload before activating it. Upgrade has already done this in the in-place case. let previous_version = install::read_current_version(&dirs.data).await; let version_dir = if let Some(package) = user_package { @@ -307,7 +339,20 @@ async fn run( if needs_deps { output::info(&format!("installing vite-plus@{version}...")); install::generate_wrapper_package_json(&version_dir, version).await?; - if !skip_deps { + if !skip_deps && env.env_forbidden { + // Normally checked above, before any change; check now if that was skipped. + let tools = match system_tools { + Some(tools) => tools, + None => install::system_install_tools(Some(&runtime::cli_node_range())).await?, + }; + install::install_production_deps( + &version_dir, + registry, + true, + Some(install::NodeVersion::System(&tools)), + ) + .await?; + } else if !skip_deps { let preferred = node_version.as_deref().filter(|version| runtime::supports_cli(version)); if let Some(version) = node_version.as_deref().filter(|_| preferred.is_none()) { @@ -437,6 +482,8 @@ async fn save_management_preferences( if let Some(mode) = node_override.or(default_mode) { settings.node_shim_mode = mode; } + // VP_ENV_FORBIDDEN=1 places every package manager after the system tools too. + let forbidden = env.env_forbidden.then_some(config::ShimMode::SystemFirst); let pm_mode = shim_mode(env.pm_manager).or(default_mode); for (family, preference) in [ (PackageManagerType::Npm, env.npm_manager), @@ -444,7 +491,7 @@ async fn save_management_preferences( (PackageManagerType::Yarn, env.yarn_manager), (PackageManagerType::Bun, env.bun_manager), ] { - if let Some(mode) = shim_mode(preference).or(pm_mode) { + if let Some(mode) = forbidden.or_else(|| shim_mode(preference)).or(pm_mode) { settings.set_package_manager_shim_mode(family, mode); } } diff --git a/crates/vp_global_cli/src/self_setup/runtime.rs b/crates/vp_global_cli/src/self_setup/runtime.rs index ce2583ab3e..4939c06323 100644 --- a/crates/vp_global_cli/src/self_setup/runtime.rs +++ b/crates/vp_global_cli/src/self_setup/runtime.rs @@ -2,7 +2,7 @@ use js_semver::{Range, Version}; use vp_js_runtime::NodeProvider; -use vp_shared::EnvConfig; +use vp_shared::{EnvConfig, env_vars, output}; use crate::{commands::env::config, error::Error}; @@ -14,18 +14,32 @@ pub(super) async fn resolve() -> Result, Error> { // Project discovery and normal Node overrides do not apply here. return Ok(None); }; + if env.env_forbidden { + // The system node installs dependencies; resolving a selector would query the Node.js index. + output::warn(&format!( + "{}=1: ignoring {}; Vite+ installs its dependencies with the system Node.js.", + env_vars::VP_ENV_FORBIDDEN, + env_vars::VP_SETUP_NODE_VERSION + )); + return Ok(None); + } config::resolve_version_alias(selector, &NodeProvider::new()).await.map(Some) } -pub(super) fn supports_cli(version: &str) -> bool { - // This binary installs its own release. Read that release's requirement - // from the same package manifest used to publish it. +/// `engines.node` of the release this binary installs, read from the same +/// package manifest used to publish it. +pub(super) fn cli_node_range() -> String { let package: serde_json::Value = serde_json::from_str(include_str!("../../../../packages/cli/package.json")) .expect("CLI package.json must be valid JSON"); - let range = package["engines"]["node"].as_str().expect("CLI must declare engines.node"); + package["engines"]["node"].as_str().expect("CLI must declare engines.node").to_owned() +} + +pub(super) fn supports_cli(version: &str) -> bool { + // This binary installs its own release. + let range = cli_node_range(); Version::parse(version).is_ok_and(|version| { - Range::parse(range).expect("CLI Node range must be valid").satisfies(&version) + Range::parse(&range).expect("CLI Node range must be valid").satisfies(&version) }) } @@ -40,8 +54,6 @@ pub(super) async fn write_resolution(version: &str) -> Result<(), Error> { #[cfg(test)] mod tests { - use vp_shared::env_vars; - use super::*; async fn with_selector(selector: Option<&str>) -> Option { @@ -68,6 +80,22 @@ mod tests { assert_eq!(with_selector(Some(" v22.23.1 ")).await.as_deref(), Some("22.23.1")); } + #[tokio::test] + async fn env_forbidden_ignores_the_selector_without_resolving_it() { + // An alias needs the Node.js index; resolving it here would fail under VP_ENV_FORBIDDEN. + let root = tempfile::tempdir().unwrap(); + let resolved = EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, root.path().to_str()), + (env_vars::VP_SETUP_NODE_VERSION, Some("lts")), + (env_vars::VP_ENV_FORBIDDEN, Some("1")), + ], + |_| async { resolve().await.unwrap() }, + ) + .await; + assert_eq!(resolved, None); + } + #[tokio::test] async fn absent_or_empty_selector_keeps_the_pinned_default() { for selector in [None, Some(""), Some(" ")] { diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 97555c7642..1aa89fd059 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -5,6 +5,7 @@ //! 2. Node.js installation (if needed) //! 3. Tool execution (core shims and package binaries) +use vp_command::FirstPathMatch; use vp_pm_cli::{PackageManagerType, ensure_package_manager_bin}; use vp_shared::{PrependOptions, ToolPathEnv, env_vars, output}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; @@ -709,11 +710,21 @@ async fn resolve_package_manager_tool( Ok(Some(bin_path)) } +/// Prepare the child PATH for a JS tool running on the selected Node.js. +/// +/// `VP_ENV_FORBIDDEN=1` adds 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 system tools through the PATH entries +/// before the shims, where the shims fail under `VP_ENV_FORBIDDEN=1`. +/// Directories inherited from a parent stay as they are. async fn prepare_js_child_path( cwd: &AbsolutePath, node_bin_dir: &AbsolutePath, mut env: ToolPathEnv, ) -> Result { + if vp_shared::EnvConfig::get().env_forbidden { + return Ok(env); + } env.prepend(node_bin_dir, &["node"], PrependOptions::default())?; if let Some(npm_path) = resolve_package_manager_tool(cwd, "npm").await? { if let Some(bin_dir) = npm_path.parent() { @@ -735,6 +746,112 @@ async fn prepare_js_child_path( Ok(env) } +/// Whether `VP_ENV_FORBIDDEN=1` restricts `tool` to the system. +/// +/// It restricts every core tool: Node.js and every package-manager family. +/// Package binaries are not restricted. +pub(crate) fn is_env_forbidden_tool(tool: &str) -> bool { + vp_shared::EnvConfig::get().env_forbidden && is_core_shim_tool(tool) +} + +/// Whether a managed executable selected by a parent process may be reused. +/// Nothing managed is reused under `VP_ENV_FORBIDDEN=1`. +fn reuses_inherited_tool(path: &AbsolutePath) -> bool { + vp_command::is_managed_install(path.as_path()) && !vp_shared::EnvConfig::get().env_forbidden +} + +/// 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. + // Under VP_ENV_FORBIDDEN=1, dispatch fails before reaching this function. + 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() { + let config = config::load_config() + .await + .map_err(|error| vec![format!("Failed to load Node.js shim mode: {error}")])?; + (config.node_shim_mode == ShimMode::SystemFirst).then(|| find_system_tool("node")).flatten() + } 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 = match resolve_package_manager_tool(cwd, tool).await { + Ok(Some(path)) => path, + 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 + } + 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}")])? + }; + if let Some(kind) = PackageManagerType::from_tool(tool) + && 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. @@ -767,13 +884,20 @@ pub async fn dispatch(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { return crate::commands::vpr::execute_vpr(args, &cwd).await; } + // Under VP_ENV_FORBIDDEN=1, a core shim neither searches PATH nor forwards, + // even under VP_BYPASS, so a system tool that forwards back to it cannot loop. + if let Some(error) = env_forbidden_shim_error(tool).await { + eprintln!("vp: {error}"); + return 1; + } + // Preserve an already selected managed executable, including direct calls to a shim from its children. // External manager shims must not be re-entered here: they may have fallen back to us. let inherited_tool = 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(&path) { return exec::exec_tool(&path, args, env); } @@ -799,102 +923,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 +1062,12 @@ 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_path = match resolve_env_forbidden_tool("node") { + Some(node) => node.map_err(|e| e.to_string())?, + None => 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 +1079,11 @@ 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())?; + // As in `prepare_js_child_path`, VP_ENV_FORBIDDEN adds no directories. + if !vp_shared::EnvConfig::get().env_forbidden { + env.prepend(node_bin_dir, &["node"], PrependOptions::default()) + .map_err(|error| error.to_string())?; + } env }; @@ -1114,6 +1163,29 @@ fn bypass_to_system(tool: &str, args: &[String], env: ToolPathEnv) -> i32 { } } +/// The error for the shim of `tool` under `VP_ENV_FORBIDDEN=1`, which never +/// searches PATH. +/// +/// The shell's PATH lookup reached the shim, so no system tool comes before +/// it. Managed `config.json` places the shims first on PATH, so `vp env off` +/// moves them after the system tools. An unrestricted tool gets `None`. +async fn env_forbidden_shim_error(tool: &str) -> Option { + if !is_env_forbidden_tool(tool) { + return None; + } + let kind = PackageManagerType::from_tool(tool); + let bin = kind.map_or(tool, |kind| kind.bin_name_for_tool(tool)); + // An unreadable config.json cannot show that the shims come first. + let shims_first = config::load_config() + .await + .is_ok_and(|config| config.shim_mode_for_tool(tool) == config::ShimMode::Managed); + Some(if shims_first { + system_tool_behind_shim(bin) + } else { + Error::SystemToolNotFound { tool: bin.into() } + }) +} + /// 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 +1295,11 @@ fn node_install_dir(version: &str) -> AbsolutePathBuf { } /// Ensure Node.js is installed. +/// +/// Fails under `VP_ENV_FORBIDDEN=1`, even for a cached version. Callers that +/// support it 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)] @@ -1296,40 +1372,141 @@ pub(crate) fn resolve_external_node_executable( .ok_or_else(|| format!("Invalid Node executable path: {}", executable.trim())) } -/// Return the first PATH match only if it is external; a Vite+ shim selects managed resolution. +/// Locate `tool` among the system tools on PATH, or return `None` to select +/// managed resolution. /// -/// Returns the absolute path to the tool if found, None otherwise. +/// - Under `VP_ENV_FORBIDDEN=1`, a core tool is returned only when the first +/// PATH match is a system tool, even under `VP_BYPASS`. +/// - Otherwise `VP_BYPASS` searches past the Vite+ shims. +/// - Otherwise the first PATH match is returned unless it is a Vite+ shim; a +/// managed install there is a parent's selection, which system-first reuses. pub(crate) fn find_system_tool(tool: &str) -> Option { let cwd = current_dir().ok()?; - if std::env::var_os(env_vars::VP_BYPASS).is_some() { - return find_external_tool_in(tool, &cwd); - } find_system_tool_in(tool, &cwd) } /// `cwd` only resolves relative PATH entries; it is a parameter so tests can /// exercise them without mutating the process-wide working directory. fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option { - let path = std::env::var_os("PATH")?; - let paths = std::env::split_paths(&path).map(|path| { - if path.is_absolute() || path.starts_with("~") { path } else { cwd.as_path().join(path) } - }); - let path = std::env::join_paths(paths).ok()?; - let resolved = vp_command::resolve_bin(tool, Some(&path), cwd).ok()?; - let canonical = resolved.as_path().canonicalize().ok(); + // Under VP_ENV_FORBIDDEN=1 a core tool is the first PATH match, which must + // be a system tool. Nothing managed is reused, and VP_BYPASS does not + // search past the shims either. + if is_env_forbidden_tool(tool) { + return match first_path_match(tool, cwd) { + FirstPathMatch::System(path) => Some(path), + FirstPathMatch::VpOwned(_) | FirstPathMatch::ViteShim(_) | FirstPathMatch::NotFound => { + None + } + }; + } + if std::env::var_os(env_vars::VP_BYPASS).is_some() { + return find_external_tool_in(tool, cwd); + } + match first_path_match(tool, cwd) { + // A managed install first on PATH is a parent's selection, which system-first reuses. + FirstPathMatch::System(path) | FirstPathMatch::VpOwned(path) => Some(path), + FirstPathMatch::ViteShim(_) | FirstPathMatch::NotFound => None, + } +} + +/// Classify the first match of `tool` on the process PATH. +fn first_path_match(tool: &str, cwd: &AbsolutePath) -> FirstPathMatch { + let Some(path) = std::env::var_os("PATH") else { + return FirstPathMatch::NotFound; + }; + first_path_match_in(tool, &path, cwd) +} + +/// Classify the first match of `tool` on `path`. +fn first_path_match_in(tool: &str, path: &std::ffi::OsStr, cwd: &AbsolutePath) -> FirstPathMatch { let self_real = std::env::current_exe().ok().and_then(|exe| exe.canonicalize().ok()); - let is_unix_shim = cfg!(unix) - && canonical - .as_ref() - .is_some_and(|target| target.file_name().is_some_and(|name| name == "vp")); - if vp_shared::is_windows_trampoline(resolved.as_path()) - || is_unix_shim - || (self_real.is_some() && canonical == self_real) - { - None - } else { - Some(resolved) + vp_command::classify_first_path_match(tool, path, self_real.as_deref(), cwd) +} + +/// Under `VP_ENV_FORBIDDEN=1`, fail when the first `node` on the child PATH is +/// a Vite+ managed install. +/// +/// Checking the selected executable is not enough: a `#!/usr/bin/env node` +/// shebang and the scripts a tool runs resolve `node` from the child PATH, so a +/// system tool would still run on a managed runtime that a parent left first +/// there. The check applies to native tools too, since their scripts can run +/// `node`. PATH is not reordered. The shim of this installation there fails on +/// its own under `VP_ENV_FORBIDDEN=1`, and without `node` nothing managed runs. +/// Shims and managed installs of other installations are outside this guarantee. +pub(crate) fn ensure_env_forbidden_child_node(env: &ToolPathEnv) -> Result<(), Error> { + if !vp_shared::EnvConfig::get().env_forbidden { + return Ok(()); + } + let cwd = current_dir()?; + match first_path_match_in("node", env.path(), &cwd) { + FirstPathMatch::VpOwned(path) => Err(Error::SystemToolManagedInstall { + tool: "node".into(), + path: path.as_path().display().to_string().into(), + }), + FirstPathMatch::System(_) | FirstPathMatch::ViteShim(_) | FirstPathMatch::NotFound => { + Ok(()) + } + } +} + +/// Under `VP_ENV_FORBIDDEN=1`, fail when `path`, a tool found on PATH, lies in +/// a Vite+ managed runtime or package manager, such as the `corepack` bundled +/// with a managed Node.js that a parent left on PATH. +pub(crate) fn ensure_env_forbidden_not_managed( + tool: &str, + path: &AbsolutePath, +) -> Result<(), Error> { + if vp_shared::EnvConfig::get().env_forbidden && vp_command::is_managed_install(path.as_path()) { + return Err(Error::SystemToolManagedInstall { + tool: tool.into(), + path: path.as_path().display().to_string().into(), + }); } + Ok(()) +} + +/// Locate `tool` when `VP_ENV_FORBIDDEN=1` restricts it to the system, or +/// return `None` when it is not restricted. +/// +/// The tool is the first PATH match, which must be a system tool. A Vite+ +/// shim or managed install there fails instead of searching further. Short +/// aliases (`pn`, `pnx`) map to the binaries the family installs. +pub(crate) fn resolve_env_forbidden_tool(tool: &str) -> Option> { + if !is_env_forbidden_tool(tool) { + return None; + } + let tool = + PackageManagerType::from_tool(tool).map_or(tool, |kind| kind.bin_name_for_tool(tool)); + let cwd = match current_dir() { + Ok(cwd) => cwd, + Err(error) => return Some(Err(error.into())), + }; + Some(match first_path_match(tool, &cwd) { + FirstPathMatch::System(path) => Ok(path), + // The shims of this installation come first; `vp env off` moves them. + FirstPathMatch::ViteShim(shim) + if shim.parent().is_some_and(|dir| { + dir.as_path() == vp_shared::EnvConfig::get().dirs.bin.as_path() + }) => + { + Err(system_tool_behind_shim(tool)) + } + FirstPathMatch::ViteShim(_) | FirstPathMatch::NotFound => { + Err(Error::SystemToolNotFound { tool: tool.into() }) + } + FirstPathMatch::VpOwned(path) => Err(Error::SystemToolManagedInstall { + tool: tool.into(), + path: path.as_path().display().to_string().into(), + }), + }) +} + +/// The error for restricted `tool` behind the shims of this installation; +/// `tool` is the binary name, not an alias. +fn system_tool_behind_shim(tool: &str) -> Error { + let component = PackageManagerType::from_tool(tool) + .map_or_else(|| "node".into(), |kind| kind.to_string().into()); + Error::SystemToolBehindShim { tool: tool.into(), component } } /// Process-dependent inputs for [`vp_command::find_external_tool`]. @@ -1371,6 +1548,7 @@ fn find_external_tool_in(tool: &str, cwd: &AbsolutePath) -> Option 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") } + } + + /// Pins `VP_HOME`, PATH and `VP_ENV_FORBIDDEN`; everything else in `extra` + /// is set as given. + fn forbidden_vars( + layout: &ForbiddenLayout, + path: &std::ffi::OsStr, + forbidden: Option<&'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), + (env_vars::VP_ENV_FORBIDDEN, forbidden.map(Into::into)), + ]; + for (name, value) in extra { + vars.retain(|(var, _)| var != name); + vars.push((name, Some(value.clone()))); + } + vars + } + + /// Make `dir/` a Vite+ shim: a link to `vp` on Unix, a trampoline + /// with a shim pointer on Windows. + fn make_shim( + layout: &ForbiddenLayout, + dir: &std::path::Path, + tool: &str, + ) -> std::path::PathBuf { + std::fs::create_dir_all(dir).unwrap(); + #[cfg(unix)] + { + let vp_dir = layout.home.join("current"); + std::fs::create_dir_all(&vp_dir).unwrap(); + let vp = create_fake_executable(&vp_dir, "vp"); + let shim = dir.join(tool); + std::os::unix::fs::symlink(&vp, &shim).unwrap(); + shim + } + #[cfg(windows)] + { + let shim = create_fake_executable(dir, tool); + EnvConfig::with_vars(forbidden_vars(layout, &layout.path(), None, &[]), |config| { + config.dirs.write_shim_pointer_beside(&shim).unwrap(); + }); + shim + } + } + + fn exe(name: &str) -> String { + if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() } + } + + #[test] + fn test_find_system_tool_reuses_managed_installs_but_not_shims() { + let layout = ForbiddenLayout::new(); + let managed_node = layout.managed_bin.join(exe("node")); + let system_node = create_fake_executable(&layout.system_bin, "node"); + let empty = layout.home.join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + let shim_dir = layout.home.join("bin"); + make_shim(&layout, &shim_dir, "node"); + let cases = [ + (layout.path(), Some(managed_node)), + (std::env::join_paths([&layout.system_bin]).unwrap(), Some(system_node)), + (std::env::join_paths([&empty]).unwrap(), None), + (std::env::join_paths([&shim_dir, &layout.system_bin]).unwrap(), None), + ]; + for (path, expected) in cases { + EnvConfig::with_vars(forbidden_vars(&layout, &path, None, &[]), |_| { + let found = find_system_tool_in("node", &layout.cwd); + assert_eq!( + found.as_ref().map(|path| path.as_path()), + expected.as_deref(), + "{path:?}" + ); + }); + } + } + + /// Under `VP_ENV_FORBIDDEN=1`, a core shim fails without searching PATH. + /// The message follows where `config.json` places the shims. Other values + /// of `VP_ENV_FORBIDDEN` leave the shims to their own mode. + #[tokio::test(flavor = "current_thread")] + async fn test_env_forbidden_shim_error_follows_config_without_searching_path() { + let layout = ForbiddenLayout::new(); + for tool in ["node", "npm", "pnpm"] { + create_fake_executable(&layout.system_bin, tool); + } + let path = std::env::join_paths([&layout.system_bin]).unwrap(); + let shim_error = |forbidden: Option<&'static str>, tool: &'static str| { + let vars = forbidden_vars(&layout, &path, forbidden, &[]); + EnvConfig::with_vars_async(vars, move |_| async move { + env_forbidden_shim_error(tool).await.map(|error| error.to_string()) + }) + }; + let forbidden = Some("1"); + let behind_shim = |tool: &str, component: &str| { + Error::SystemToolBehindShim { tool: tool.into(), component: component.into() } + .to_string() + }; + + // Without config.json, the shims are managed and come first on PATH. + assert_eq!(shim_error(forbidden, "node").await, Some(behind_shim("node", "node"))); + assert_eq!(shim_error(forbidden, "npm").await, Some(behind_shim("npm", "npm"))); + assert_eq!(shim_error(forbidden, "npx").await, Some(behind_shim("npx", "npm"))); + assert_eq!(shim_error(forbidden, "pnx").await, Some(behind_shim("pnpx", "pnpm"))); + // Package binaries are not restricted. + assert_eq!(shim_error(forbidden, "tsc").await, None); + // Only the exact value `1` restricts the shims. + assert_eq!(shim_error(Some("true"), "pnpm").await, None); + assert_eq!(shim_error(None, "node").await, None); + + // System-first shims come last on PATH, so no system tool came before them. + let vars = forbidden_vars(&layout, &path, forbidden, &[]); + EnvConfig::with_vars_async(vars, |_| async { + let config = config::Config { + node_shim_mode: config::ShimMode::SystemFirst, + ..Default::default() + }; + config::save_config(&config).await.unwrap(); + }) + .await; + let not_found = Error::SystemToolNotFound { tool: "node".into() }; + assert_eq!(shim_error(forbidden, "node").await, Some(not_found.to_string())); + // The pnpm shims stay managed. + assert_eq!(shim_error(forbidden, "pnpm").await, Some(behind_shim("pnpm", "pnpm"))); + + // An unreadable config.json cannot show that the shims come first. + let vars = forbidden_vars(&layout, &path, forbidden, &[]); + EnvConfig::with_vars_async(vars, |_| async { + std::fs::write(config::get_config_path().unwrap(), "{").unwrap(); + }) + .await; + assert_eq!(shim_error(forbidden, "node").await, Some(not_found.to_string())); + } + + /// A lookup under `VP_ENV_FORBIDDEN=1` takes the first PATH match only: a + /// Vite+ shim or a managed install there fails instead of searching further. + #[test] + fn test_resolve_env_forbidden_tool_classifies_the_first_match() { + let layout = ForbiddenLayout::new(); + let system_node = create_fake_executable(&layout.system_bin, "node"); + let managed_node = layout.managed_bin.join(exe("node")); + let bin = layout.home.join("bin"); + let fallback_bin = layout.home.join("fallback-bin"); + make_shim(&layout, &bin, "node"); + make_shim(&layout, &bin, "pnpm"); + make_shim(&layout, &fallback_bin, "node"); + let empty = layout.home.join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + let resolve = |dirs: &[&std::path::Path], tool: &str| { + let path = std::env::join_paths(dirs).unwrap(); + EnvConfig::with_vars(forbidden_vars(&layout, &path, Some("1"), &[]), |_| { + resolve_env_forbidden_tool(tool) + .expect("the tool is restricted") + .map_err(|error| error.to_string()) + }) + }; + let not_found = + |tool: &str| Err(Error::SystemToolNotFound { tool: tool.into() }.to_string()); + + let system = resolve(&[&layout.system_bin, &layout.managed_bin], "node"); + assert_eq!(system.unwrap().as_path(), system_node); + // A managed runtime first on PATH is reported, not skipped. + assert_eq!( + resolve(&[&layout.managed_bin, &layout.system_bin], "node"), + Err(Error::SystemToolManagedInstall { + tool: "node".into(), + path: managed_node.display().to_string().into(), + } + .to_string()) + ); + // The shims of this installation come first, so `vp env off` moves them. + let behind_shim = |tool: &str, component: &str| { + Err(Error::SystemToolBehindShim { tool: tool.into(), component: component.into() } + .to_string()) + }; + assert_eq!(resolve(&[&bin, &layout.system_bin], "node"), behind_shim("node", "node")); + assert_eq!(resolve(&[&bin, &layout.system_bin], "pnpm"), behind_shim("pnpm", "pnpm")); + // A short alias resolves the binary the family installs. + assert_eq!(resolve(&[&bin, &layout.system_bin], "pn"), behind_shim("pnpm", "pnpm")); + // Fallback shims come last, so no system tool comes before them. + assert_eq!(resolve(&[&fallback_bin, &layout.system_bin], "node"), not_found("node")); + assert_eq!(resolve(&[&empty], "node"), not_found("node")); + + // Package binaries and other values of VP_ENV_FORBIDDEN are not restricted. + EnvConfig::with_vars(forbidden_vars(&layout, &layout.path(), Some("1"), &[]), |_| { + assert!(resolve_env_forbidden_tool("tsc").is_none()); + }); + EnvConfig::with_vars(forbidden_vars(&layout, &layout.path(), Some("true"), &[]), |_| { + assert!(resolve_env_forbidden_tool("node").is_none()); + assert!(resolve_env_forbidden_tool("pnpm").is_none()); + }); + } + + /// Under `VP_ENV_FORBIDDEN=1` a core tool is reused only when the first + /// PATH match is a system tool, even under `VP_BYPASS`. Package binaries + /// are not restricted, so `VP_BYPASS` still searches past their shims. + #[test] + fn test_find_system_tool_skips_managed_installs_when_env_forbidden() { + let layout = ForbiddenLayout::new(); + let system_node = create_fake_executable(&layout.system_bin, "node"); + let system_pnpm = create_fake_executable(&layout.system_bin, "pnpm"); + let system_tsc = create_fake_executable(&layout.system_bin, "tsc"); + let managed_pnpm_bin = layout.home.join("package_manager/pnpm/10.0.0/bin"); + std::fs::create_dir_all(&managed_pnpm_bin).unwrap(); + create_fake_executable(&managed_pnpm_bin, "pnpm"); + let shim_dir = layout.home.join("bin"); + make_shim(&layout, &shim_dir, "node"); + make_shim(&layout, &shim_dir, "tsc"); + let find = |dirs: &[&std::path::Path], tool: &str, bypass: bool| { + let path = std::env::join_paths(dirs).unwrap(); + let extra: &[(&'static str, std::ffi::OsString)] = + if bypass { &[(env_vars::VP_BYPASS, std::ffi::OsString::new())] } else { &[] }; + EnvConfig::with_vars(forbidden_vars(&layout, &path, Some("1"), extra), |_| { + find_system_tool_in(tool, &layout.cwd).map(|path| path.as_path().to_path_buf()) + }) + }; + + for bypass in [false, true] { + assert_eq!(find(&[&layout.managed_bin, &layout.system_bin], "node", bypass), None); + assert_eq!(find(&[&managed_pnpm_bin, &layout.system_bin], "pnpm", bypass), None); + assert_eq!(find(&[&shim_dir, &layout.system_bin], "node", bypass), None); + assert_eq!( + find(&[&layout.system_bin, &layout.managed_bin], "node", bypass), + Some(system_node.clone()) + ); + assert_eq!( + find(&[&layout.system_bin, &managed_pnpm_bin], "pnpm", bypass), + Some(system_pnpm.clone()) + ); + } + assert_eq!(find(&[&shim_dir, &layout.system_bin], "tsc", true), Some(system_tsc)); + } + + /// Under `VP_ENV_FORBIDDEN=1` a tool does not start when the first `node` + /// on the child PATH, not the process PATH, is a managed install. A shim + /// there fails on its own, and no `node` at all runs nothing managed. + #[test] + fn test_ensure_env_forbidden_child_node_rejects_a_managed_node_first_on_path() { + let layout = ForbiddenLayout::new(); + create_fake_executable(&layout.system_bin, "node"); + let managed_node = layout.managed_bin.join(exe("node")); + let shim_dir = layout.home.join("bin"); + make_shim(&layout, &shim_dir, "node"); + let empty = layout.home.join("empty"); + std::fs::create_dir_all(&empty).unwrap(); + // The process PATH has the system Node.js first; only the child PATH varies. + let process_path = std::env::join_paths([&layout.system_bin, &layout.managed_bin]).unwrap(); + let check = |dirs: &[&std::path::Path], forbidden: &'static str| { + let child = ToolPathEnv::new(std::env::join_paths(dirs).unwrap(), ""); + EnvConfig::with_vars( + forbidden_vars(&layout, &process_path, Some(forbidden), &[]), + |_| ensure_env_forbidden_child_node(&child).map_err(|error| error.to_string()), + ) + }; + + assert_eq!( + check(&[&layout.managed_bin, &layout.system_bin], "1"), + Err(Error::SystemToolManagedInstall { + tool: "node".into(), + path: managed_node.display().to_string().into(), + } + .to_string()) + ); + assert_eq!(check(&[&layout.system_bin, &layout.managed_bin], "1"), Ok(())); + assert_eq!(check(&[&shim_dir, &layout.managed_bin], "1"), Ok(())); + assert_eq!(check(&[&empty], "1"), Ok(())); + // Other values of VP_ENV_FORBIDDEN check nothing. + assert_eq!(check(&[&layout.managed_bin, &layout.system_bin], "true"), Ok(())); + } + + /// Under `VP_ENV_FORBIDDEN=1` a tool found on PATH fails when it lies in a + /// managed runtime, such as the corepack bundled with managed Node.js. + #[test] + fn test_ensure_env_forbidden_not_managed_rejects_managed_installs() { + let layout = ForbiddenLayout::new(); + let managed = + AbsolutePathBuf::new(create_fake_executable(&layout.managed_bin, "corepack")).unwrap(); + let system = + AbsolutePathBuf::new(create_fake_executable(&layout.system_bin, "corepack")).unwrap(); + let check = |path: &AbsolutePathBuf, forbidden: &'static str| { + EnvConfig::with_vars( + forbidden_vars(&layout, &layout.path(), Some(forbidden), &[]), + |_| { + ensure_env_forbidden_not_managed("corepack", path) + .map_err(|error| error.to_string()) + }, + ) + }; + + assert_eq!( + check(&managed, "1"), + Err(Error::SystemToolManagedInstall { + tool: "corepack".into(), + path: managed.as_path().display().to_string().into(), + } + .to_string()) + ); + assert_eq!(check(&system, "1"), Ok(())); + assert_eq!(check(&managed, "true"), Ok(())); + } + + #[tokio::test] + async fn test_ensure_installed_rejects_cached_node_when_env_forbidden() { + let layout = ForbiddenLayout::new(); + let path = layout.path(); + EnvConfig::with_vars_async(forbidden_vars(&layout, &path, None, &[]), |_| async { + assert!(ensure_installed("24.0.0").await.is_ok()); + }) + .await; + EnvConfig::with_vars_async(forbidden_vars(&layout, &path, Some("1"), &[]), |_| async { + let error = ensure_installed("24.0.0").await.unwrap_err(); + assert!(error.contains(&format!("{}=1", env_vars::VP_ENV_FORBIDDEN)), "{error}"); + }) + .await; + } + + /// A managed executable selected by a parent is reused, but never under + /// `VP_ENV_FORBIDDEN=1`: neither a managed package manager, nor the managed + /// runtime and its bundled npm, nor a package binary bundled with it. + #[test] + fn test_reuses_inherited_managed_tools_unless_env_forbidden() { + let layout = ForbiddenLayout::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 = create_fake_executable(&pnpm_bin, "pnpm"); + let npm = layout.managed_bin.join(exe("npm")); + let node = layout.managed_bin.join(exe("node")); + let corepack = create_fake_executable(&layout.managed_bin, "corepack"); + let tools = [pnpm, npm, node, corepack].map(|path| AbsolutePathBuf::new(path).unwrap()); + let path = layout.path(); + + for forbidden in [None, Some("true")] { + EnvConfig::with_vars(forbidden_vars(&layout, &path, forbidden, &[]), |_| { + for tool in &tools { + assert!(reuses_inherited_tool(tool), "{forbidden:?} {tool:?}"); + } + }); + } + EnvConfig::with_vars(forbidden_vars(&layout, &path, Some("1"), &[]), |_| { + for tool in &tools { + assert!(!reuses_inherited_tool(tool), "{tool:?}"); + } + }); + } + + /// Under `VP_ENV_FORBIDDEN=1` the child PATH gets no directory and records + /// no tool, so directories inherited from a parent stay in order. Otherwise + /// the runtime and its npm are added as before. + #[tokio::test] + async fn test_prepare_js_child_path_adds_nothing_when_env_forbidden() { + let layout = ForbiddenLayout::new(); + let node_dir = node_install_dir_in(&layout.home, "22.0.0"); + std::fs::create_dir_all(&node_dir).unwrap(); + create_fake_executable(&node_dir, "node"); + // The bundled npm is a `.cmd` script on Windows. + if cfg!(windows) { + std::fs::write(node_dir.join("npm.cmd"), "").unwrap(); + } else { + create_fake_executable(&node_dir, "npm"); + } + let node_dir = AbsolutePathBuf::new(node_dir).unwrap(); + let other = layout.cwd.as_path().join("other-bin"); + let path = std::env::join_paths([&layout.managed_bin, &other, &layout.system_bin]).unwrap(); + let prepare = |forbidden: Option<&'static str>| { + let vars = forbidden_vars(&layout, &path, forbidden, &[]); + let (cwd, node_dir) = (layout.cwd.clone(), node_dir.clone()); + EnvConfig::with_vars_async(vars, move |_| async move { + prepare_js_child_path(&cwd, &node_dir, ToolPathEnv::from_env()) + .await + .unwrap() + .into_envs() + }) + }; + + let [(_, child_path), (_, child_tools)] = prepare(Some("1")).await; + assert_eq!(child_path, path); + assert_eq!(child_tools, ""); + + // Other values of VP_ENV_FORBIDDEN do not keep the runtime out. + for forbidden in [None, Some("true")] { + let [(_, child_path), (_, child_tools)] = prepare(forbidden).await; + assert_eq!( + std::env::split_paths(&child_path).collect::>(), + [ + node_dir.as_path().to_path_buf(), + layout.managed_bin.clone(), + other.clone(), + layout.system_bin.clone() + ], + "{forbidden:?}" + ); + assert_eq!(child_tools, "node,npm", "{forbidden:?}"); + } + } } diff --git a/crates/vp_installer/src/cli.rs b/crates/vp_installer/src/cli.rs index db5f8a4eec..0936e5b75f 100644 --- a/crates/vp_installer/src/cli.rs +++ b/crates/vp_installer/src/cli.rs @@ -30,11 +30,54 @@ 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, + /// Vite+ manages no Node.js or package manager. Only `VP_ENV_FORBIDDEN=1` + /// selects it, and the interactive menu cannot change it. Self-setup + /// receives `VP_NODE_MANAGER=no`, which places the shims after the system + /// tools. + EnvForbidden, +} + +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 | Self::EnvForbidden => "no", + } + } + + /// The menu label for this choice. + #[must_use] + pub fn label(self) -> String { + match self { + Self::Enabled => "enabled".to_string(), + Self::Disabled => "disabled".to_string(), + Self::EnvForbidden => { + format!("disabled (set by {})", vp_shared::env_vars::VP_ENV_FORBIDDEN) + } + } + } +} + /// 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..74dbe3ee10 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().env_forbidden { + cli::NodeManager::EnvForbidden + } 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 (`yes` or `no`) is kept. Otherwise the +/// package managers follow the Node.js choice; under `VP_ENV_FORBIDDEN=1` both +/// are `no`, which places the shims after the system tools. Family-specific +/// variables and `VP_ENV_FORBIDDEN` 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")) + .unwrap_or_else(|| node_manager.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 +399,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 +431,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 +456,19 @@ 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::EnvForbidden => { + println!( + " Unset {} to change this option.", + vp_shared::env_vars::VP_ENV_FORBIDDEN + ); + cli::NodeManager::EnvForbidden + } + } + } "4" => opts.no_modify_path = !opts.no_modify_path, _ => println!(" Invalid option."), } @@ -490,6 +530,44 @@ mod tests { EnvConfig::with_vars(vars, |_| f()) } + #[test] + fn env_forbidden_overrides_flag_and_auto_detection() { + EnvConfig::with_vars([(env_vars::VP_ENV_FORBIDDEN, Some("1"))], |_| { + for flag in [false, true] { + assert_eq!( + resolve_node_manager(flag, || panic!("auto-detection must not run")), + cli::NodeManager::EnvForbidden + ); + } + }); + // Only the exact value `1` stops management. + EnvConfig::with_vars([(env_vars::VP_ENV_FORBIDDEN, Some("true"))], |_| { + 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_place_package_managers_after_system_tools_under_env_forbidden() { + 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::EnvForbidden, None), pair("no", "no")); + // An explicit choice is kept. + assert_eq!(envs(cli::NodeManager::EnvForbidden, Some("yes")), pair("no", "yes")); + // Only `yes` and `no` are explicit package-manager choices. + assert_eq!(envs(cli::NodeManager::Enabled, Some("Yes")), pair("yes", "yes")); + assert_eq!(envs(cli::NodeManager::Disabled, Some("")), 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..9af91d0dc9 100644 --- a/crates/vp_js_runtime/src/error.rs +++ b/crates/vp_js_runtime/src/error.rs @@ -9,6 +9,13 @@ pub enum Error { #[error("Version {version} not found for {runtime}")] VersionNotFound { runtime: Str, version: Str }, + /// `VP_ENV_FORBIDDEN=1` forbids downloading or using a managed runtime + #[error( + "{var}=1: Vite+ does not manage Node.js; it neither downloads nor uses a managed Node.js", + var = vp_shared::env_vars::VP_ENV_FORBIDDEN + )] + DisabledByEnvForbidden, + /// 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 5398b93447..098d15e4c2 100644 --- a/crates/vp_js_runtime/src/lib.rs +++ b/crates/vp_js_runtime/src/lib.rs @@ -67,6 +67,6 @@ pub use providers::{ }; 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 50bd8fd86a..cc084246d3 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> { + // VP_ENV_FORBIDDEN 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"); @@ -1588,4 +1590,46 @@ fedcba987654 node-v22.13.1-win-x64.zip"; }) .await; } + + /// `VP_ENV_FORBIDDEN=1` neither queries the mirror nor reads the index cache. + #[tokio::test] + async fn test_env_forbidden_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 = |forbidden: 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_ENV_FORBIDDEN, forbidden.map(std::ffi::OsStr::new)), + ] + }; + + // Without a cache, the lookup fails before any request. + vp_shared::EnvConfig::with_vars_async(vars(Some("1")), |_| async { + let error = NodeProvider::new().fetch_version_index().await.unwrap_err(); + assert!(matches!(error, Error::DisabledByEnvForbidden)); + }) + .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("1")), |_| 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 e9618cad49..b3c488b963 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_ENV_FORBIDDEN=1` stops Vite+ from managing Node.js. +/// +/// # Errors +/// +/// Returns [`Error::DisabledByEnvForbidden`] when `VP_ENV_FORBIDDEN=1`. +pub fn ensure_managed_node_allowed() -> Result<(), Error> { + if vp_shared::EnvConfig::get().env_forbidden { + return Err(Error::DisabledByEnvForbidden); + } + 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::DisabledByEnvForbidden`] for Node.js when `VP_ENV_FORBIDDEN=1`, 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: VP_ENV_FORBIDDEN 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()?; @@ -1812,4 +1830,36 @@ mod tests { assert_eq!(&*resolution.version, "20.18.0"); assert_eq!(resolution.source, VersionSource::NvmrcFile); } + + /// `VP_ENV_FORBIDDEN=1` rejects a managed Node.js before the cache lookup, + /// so an already installed version is not used either. + #[tokio::test] + async fn test_env_forbidden_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 (forbidden, rejected) in [(None, false), (Some("true"), false), (Some("1"), true)] { + vp_shared::EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, Some(vp_home.path().as_os_str())), + (env_vars::VP_ENV_FORBIDDEN, forbidden.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::DisabledByEnvForbidden))); + } else { + assert_eq!(result.unwrap().get_binary_path().as_path(), binary); + } + }, + ) + .await; + } + } } diff --git a/crates/vp_pm_cli/src/env_forbidden.rs b/crates/vp_pm_cli/src/env_forbidden.rs new file mode 100644 index 0000000000..4cea6d14c0 --- /dev/null +++ b/crates/vp_pm_cli/src/env_forbidden.rs @@ -0,0 +1,26 @@ +//! Run-time restriction of package managers by `VP_ENV_FORBIDDEN`. +//! +//! `VP_ENV_FORBIDDEN=1` stops Vite+ from managing Node.js and every +//! package-manager family. Vite+ then neither downloads nor uses a managed +//! package manager, nor asks the registry which release to use. + +use vp_error::Error; +use vp_shared::EnvConfig; + +use crate::PackageManagerType; + +/// Fail when `VP_ENV_FORBIDDEN=1` stops Vite+ from managing package managers. +/// +/// # Errors +/// +/// Returns [`Error::DisabledByEnvForbidden`] when `VP_ENV_FORBIDDEN=1`. +pub fn ensure_managed_package_manager_allowed( + package_manager_type: PackageManagerType, +) -> Result<(), Error> { + if EnvConfig::get().env_forbidden { + return Err(Error::DisabledByEnvForbidden { + tool: package_manager_type.to_string().into(), + }); + } + Ok(()) +} diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index e23c8711d4..2c6aba1b1a 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -10,6 +10,7 @@ mod cli; mod config; mod dispatch; +mod env_forbidden; mod error; mod lifecycle_env; mod package_manager; @@ -25,6 +26,7 @@ pub use dispatch::{ DispatchResult, dispatch, dispatch_with_metadata, dispatch_with_package_manager, dispatch_with_resolved_package_manager, }; +pub use env_forbidden::ensure_managed_package_manager_allowed; pub use error::Error; pub use lifecycle_env::LifecycleEnvContext; pub use package_manager::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index b01a6c5aa2..3abe687b21 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -22,6 +22,7 @@ use vt_workspace::{WorkspaceFile, WorkspaceRoot, find_workspace_root}; use crate::{ config::NpmConfig, + env_forbidden::ensure_managed_package_manager_allowed, request::{ HttpClient, download_and_extract_tgz_with_hash_and_config, is_not_found, verify_file_hash, }, @@ -694,6 +695,8 @@ pub async fn ensure_package_manager_bin( expected_hash: Option<&str>, bin_name: &str, ) -> Result { + // Checked before the version lookup and the cache: VP_ENV_FORBIDDEN 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 = @@ -1084,6 +1087,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)) @@ -1229,6 +1233,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) { @@ -1261,6 +1266,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 @@ -1341,6 +1347,8 @@ async fn resolve_package_manager_range( range: &js_semver::Range, npm_config: &NpmConfig, ) -> Result { + // Checked before the cached-version lookup: VP_ENV_FORBIDDEN 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); @@ -1398,6 +1406,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, @@ -2717,6 +2726,91 @@ mod tests { assert_eq!(find_cached_pnpm(&vp_home), None); } + /// `VP_ENV_FORBIDDEN=1` makes every package manager resolve, download and + /// reuse no managed release: every entry point fails before the cache and + /// before any registry request, even with `VP_PM_MANAGER=yes`. Other values + /// of `VP_ENV_FORBIDDEN` restrict nothing. + #[tokio::test(flavor = "current_thread")] + async fn env_forbidden_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 = |forbidden: 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_ENV_FORBIDDEN, forbidden.map(std::ffi::OsStr::new)), + (env_vars::VP_PM_MANAGER, pm.map(std::ffi::OsStr::new)), + ] + }; + let is_disabled = |error: &Error, family: &str| matches!(error, Error::DisabledByEnvForbidden { tool } if tool == family); + + // A cached exact release is still returned without VP_ENV_FORBIDDEN. + 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("1"), 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, "pnpm"), "{version}: {error}"); + assert!(!error.is_integrity_failure()); + let error = download_package_manager(PackageManagerType::Pnpm, version, None) + .await + .unwrap_err(); + assert!(is_disabled(&error, "pnpm"), "{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, "pnpm"), "{version}: {error}"); + } + let error = fetch_package_manager_versions(PackageManagerType::Pnpm).await.unwrap_err(); + assert!(is_disabled(&error, "pnpm"), "{error}"); + // Every family is restricted, not only the pinned one. + let error = resolve_package_manager_version(PackageManagerType::Yarn, "latest") + .await + .unwrap_err(); + assert!(is_disabled(&error, "yarn"), "{error}"); + }) + .await; + requests.assert_hits(0); + + // Only the exact value `1` restricts. + EnvConfig::with_vars_async(vars(Some("true"), None), |_| async { + let resolved = + ensure_package_manager_bin(PackageManagerType::Pnpm, "10.0.0", None, "pnpm") + .await + .unwrap(); + assert_eq!(resolved, cached); + ensure_managed_package_manager_allowed(PackageManagerType::Npm).unwrap(); + }) + .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/package_manager/bootstrap.rs b/crates/vp_pm_cli/src/package_manager/bootstrap.rs index 4ee9e6fc1b..cb399056ee 100644 --- a/crates/vp_pm_cli/src/package_manager/bootstrap.rs +++ b/crates/vp_pm_cli/src/package_manager/bootstrap.rs @@ -8,7 +8,9 @@ use super::{ PackageManagerType, download_package_manager_inner, is_package_manager_install_complete, package_manager_install_dir, }; -use crate::{config::NpmConfig, request::HttpClient}; +use crate::{ + config::NpmConfig, env_forbidden::ensure_managed_package_manager_allowed, request::HttpClient, +}; #[derive(Deserialize)] struct Metadata { @@ -36,6 +38,8 @@ pub async fn bootstrap_pnpm( "pnpm bootstrap requires a JavaScript pnpm release".into(), )); } + // Checked before the cache lookup: VP_ENV_FORBIDDEN also forbids cached installs. + ensure_managed_package_manager_allowed(PackageManagerType::Pnpm)?; let install = package_manager_install_dir(PackageManagerType::Pnpm, version) .expect("pnpm has an install directory"); if is_package_manager_install_complete(&install, "pnpm")? @@ -66,3 +70,62 @@ pub async fn bootstrap_pnpm( .await?; Ok(install) } + +#[cfg(test)] +mod tests { + use vp_shared::{EnvConfig, env_vars}; + + use super::*; + + /// `VP_ENV_FORBIDDEN=1` rejects the pinned pnpm before the cache and the + /// registry; other values still reuse the cached pin. + #[tokio::test(flavor = "current_thread")] + async fn env_forbidden_rejects_even_a_cached_pin() { + use httpmock::prelude::*; + + let home = tempfile::tempdir().unwrap(); + let server = MockServer::start(); + let requests = server.mock(|when, then| { + when.any_request(); + then.status(500); + }); + let registry = server.base_url(); + let vars = |forbidden: Option<&'static str>| { + [ + (env_vars::VP_HOME, Some(home.path().as_os_str())), + (env_vars::VP_ENV_FORBIDDEN, forbidden.map(std::ffi::OsStr::new)), + ] + }; + + // A complete cached install, as `is_package_manager_install_complete` and + // `bootstrap_pnpm` check it on every platform. + let cached = EnvConfig::with_vars_async(vars(None), |_| async { + let install = package_manager_install_dir(PackageManagerType::Pnpm, "10.0.0").unwrap(); + let bin = install.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + for name in ["pnpm", "pnpm.cmd", "pnpm.ps1", "pnpm.cjs"] { + std::fs::write(bin.join(name), "").unwrap(); + } + install + }) + .await; + + for value in [None, Some("true")] { + EnvConfig::with_vars_async(vars(value), |_| async { + let install = bootstrap_pnpm("10.0.0", Some(®istry)).await.unwrap(); + assert_eq!(install, cached, "{value:?}"); + }) + .await; + } + EnvConfig::with_vars_async(vars(Some("1")), |_| async { + let error = bootstrap_pnpm("10.0.0", Some(®istry)).await.unwrap_err(); + assert!( + matches!(&error, Error::DisabledByEnvForbidden { tool } if tool == "pnpm"), + "{error}" + ); + assert!(!error.is_integrity_failure()); + }) + .await; + requests.assert_hits(0); + } +} diff --git a/crates/vp_setup/Cargo.toml b/crates/vp_setup/Cargo.toml index 520960f800..afe5dc633f 100644 --- a/crates/vp_setup/Cargo.toml +++ b/crates/vp_setup/Cargo.toml @@ -19,6 +19,7 @@ tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } +vp_command = { workspace = true } vp_js_runtime = { workspace = true } vp_pm_cli = { workspace = true } vt_path = { workspace = true } diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 8c1fb3bc14..a5713dfb72 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -138,11 +138,13 @@ pub async fn clear_self_setup_marker(version_dir: &AbsolutePath) -> Result<(), E /// The `packageManager` field pins pnpm to a known-good version, ensuring /// consistent behavior regardless of the user's global pnpm version. /// pnpm will install `vite-plus` and all its transitive deps via `vp install`. +/// Under `VP_ENV_FORBIDDEN=1` the field is omitted: the system pnpm installs, +/// and a pin would make it download the pinned version. pub async fn generate_wrapper_package_json( version_dir: &AbsolutePath, version: &str, ) -> Result<(), Error> { - let json = serde_json::json!({ + let mut json = serde_json::json!({ "name": "vp-global", "version": version, "private": true, @@ -151,6 +153,12 @@ pub async fn generate_wrapper_package_json( "vite-plus": version } }); + // Remove after building: inserting would move the field in the default output. + if EnvConfig::get().env_forbidden + && let Some(fields) = json.as_object_mut() + { + fields.remove("packageManager"); + } let content = serde_json::to_string_pretty(&json)? + "\n"; tokio::fs::write(version_dir.join("package.json"), content).await?; Ok(()) @@ -192,7 +200,17 @@ const INSTALL_FAILURE_DIAGNOSTICS: &[(&str, &str)] = &[ ("EPERM", "Check permissions for the Vite+ data and cache directories."), ]; -fn install_failure_log(exit_code: i32, stdout: &[u8], stderr: &[u8]) -> String { +/// Lockfile errors from the system pnpm (`VP_ENV_FORBIDDEN=1`) are not fixed by a +/// newer Vite+ release: that pnpm may not read the pinned pnpm's lockfile. +const SYSTEM_PNPM_LOCKFILE_DIAGNOSTICS: &[&str] = + &["ERR_PNPM_OUTDATED_LOCKFILE", "ERR_PNPM_LOCKFILE_BREAKING_CHANGE"]; + +fn install_failure_log( + exit_code: i32, + lockfile_bypass: bool, + stdout: &[u8], + stderr: &[u8], +) -> String { let mut content = format!( "Stage: production dependency installation.\npnpm install failed with exit code {exit_code}.\n" ); @@ -205,7 +223,14 @@ fn install_failure_log(exit_code: i32, stdout: &[u8], stderr: &[u8]) -> String { }); if reported { recognized = true; - content.push_str(&format!("Reported error: {code}.\n{hint}\n")); + if lockfile_bypass && SYSTEM_PNPM_LOCKFILE_DIAGNOSTICS.contains(code) { + content.push_str(&format!( + "Reported error: {code}.\nThe system pnpm cannot use the installation lockfile. Retry with {}=1.\n", + vp_shared::env_vars::VP_SETUP_SKIP_INSTALL_LOCKFILE + )); + } else { + content.push_str(&format!("Reported error: {code}.\n{hint}\n")); + } } } if !recognized { @@ -221,18 +246,21 @@ fn install_failure_log(exit_code: i32, stdout: &[u8], stderr: &[u8]) -> String { /// /// The log is written to the **parent** of `version_dir` (i.e. `/upgrade.log`) /// so it survives the cleanup that removes `version_dir` on failure. +/// `lockfile_bypass` points lockfile errors to `VP_SETUP_SKIP_INSTALL_LOCKFILE=1`, +/// for the system pnpm with the installation lockfile. /// /// Returns the log file path on success, or `None` if writing failed. pub async fn write_upgrade_log( version_dir: &AbsolutePath, exit_code: i32, + lockfile_bypass: bool, stdout: &[u8], stderr: &[u8], ) -> Option { // Write to parent dir so the log survives version_dir cleanup on failure let parent = version_dir.as_path().parent()?; let log_path = AbsolutePathBuf::new(parent.join("upgrade.log"))?; - let content = install_failure_log(exit_code, stdout, stderr); + let content = install_failure_log(exit_code, lockfile_bypass, stdout, stderr); match tokio::fs::write(&log_path, &content).await { Ok(()) => Some(log_path), Err(e) => { @@ -248,12 +276,113 @@ pub enum NodeVersion<'a> { Preferred(&'a str), /// Use the target release's exact runtime or fail before activation. Required(&'a str), + /// Use the system `node` and `pnpm` that [`system_install_tools`] checked. + /// Required under `VP_ENV_FORBIDDEN=1`. + System(&'a SystemInstallTools), +} + +/// The system `node` and `pnpm` that install Vite+ dependencies under +/// `VP_ENV_FORBIDDEN=1`, and the PATH they were selected from. +#[derive(Debug)] +pub struct SystemInstallTools { + node: AbsolutePathBuf, + pnpm: AbsolutePathBuf, + /// The PATH used for the lookup, with relative entries made absolute. + /// pnpm runs with it, so its `#!/usr/bin/env node` finds the same `node`. + path: std::ffi::OsString, +} + +/// Select the system `node` and `pnpm` for dependency installation under +/// `VP_ENV_FORBIDDEN=1`. +/// +/// Call this before touching the installation: it fails unless the first +/// `node` and the first `pnpm` on PATH are system tools, or when `node` does +/// not satisfy `node_range` (the target release's `engines.node`). +/// +/// # Errors +/// +/// Returns an error if a tool is missing, is not a system tool, or `node` +/// is unsupported. +pub async fn system_install_tools(node_range: Option<&str>) -> Result { + let path = env::var_os("PATH").unwrap_or_default(); + let cwd = vt_path::current_dir()?; + let self_exe = env::current_exe().ok().and_then(|exe| exe.canonicalize().ok()); + find_system_install_tools(&path, &cwd, self_exe.as_deref(), node_range).await +} + +async fn find_system_install_tools( + path: &std::ffi::OsStr, + cwd: &AbsolutePath, + self_exe: Option<&Path>, + node_range: Option<&str>, +) -> Result { + // pnpm runs in the version directory, so resolve relative entries here. + let path = env::join_paths(env::split_paths(path).map(|entry| { + if entry.is_absolute() || entry.starts_with("~") { + entry + } else { + cwd.as_path().join(entry) + } + })) + .map_err(|error| Error::Setup(format!("Failed to read PATH: {error}").into()))?; + let node = system_tool("node", &path, self_exe, cwd)?; + let pnpm = system_tool("pnpm", &path, self_exe, cwd)?; + if let Some(range) = node_range { + let output = tokio::process::Command::new(node.as_path()).arg("--version").output().await?; + let version = String::from_utf8_lossy(&output.stdout); + let version = version.trim().trim_start_matches('v'); + let supported = output.status.success() + && js_semver::Range::parse(range).is_ok_and(|range| { + js_semver::Version::parse(version).is_ok_and(|version| range.satisfies(&version)) + }); + if !supported { + return Err(Error::Setup( + format!( + "{}=1: Vite+ installs its dependencies with the system Node.js, but {} reports '{version}', which does not satisfy '{range}'", + vp_shared::env_vars::VP_ENV_FORBIDDEN, + node.as_path().display(), + ) + .into(), + )); + } + } + Ok(SystemInstallTools { node, pnpm, path }) +} + +/// The first `tool` on `path` if it is a system tool. Later matches are never used. +fn system_tool( + tool: &str, + path: &std::ffi::OsStr, + self_exe: Option<&Path>, + cwd: &AbsolutePath, +) -> Result { + let var = vp_shared::env_vars::VP_ENV_FORBIDDEN; + let reason = match vp_command::classify_first_path_match(tool, path, self_exe, cwd) { + vp_command::FirstPathMatch::System(found) => return Ok(found), + vp_command::FirstPathMatch::ViteShim(found) => format!( + "the Vite+ shim at '{}' comes first on PATH; run `vp env off {tool}` to move the shims after the system tools", + found.as_path().display() + ), + vp_command::FirstPathMatch::VpOwned(found) => format!( + "PATH selects the Vite+ managed install at '{}'; put the system '{tool}' first on PATH", + found.as_path().display() + ), + vp_command::FirstPathMatch::NotFound => { + format!("no '{tool}' is on PATH; install it with your system package manager") + } + }; + Err(Error::Setup( + format!("{var}=1: Vite+ installs its dependencies with the system '{tool}', but {reason}") + .into(), + )) } /// Install production dependencies with a compatible managed Node.js and pinned pnpm. /// /// Spawns: `node /bin/pnpm.cjs install --ignore-workspace` -/// with `CI=true`. On failure, records safe diagnostics in the parent directory's `upgrade.log`. +/// with `CI=true`. With [`NodeVersion::System`], which `VP_ENV_FORBIDDEN=1` +/// requires, runs the system pnpm instead and downloads no Node.js or pnpm. +/// On failure, records safe diagnostics in the parent directory's `upgrade.log`. pub async fn install_production_deps( version_dir: &AbsolutePath, registry: Option<&str>, @@ -273,28 +402,58 @@ pub async fn install_production_deps( let args = dependency_install_args(version_dir, registry).await?; - let (node_runtime, pnpm_entry) = vp_shared::progress::with_spinner( - show_progress.then_some("Preparing Node.js and pnpm..."), - prepare_install_runtime(node_version, registry), - ) - .await?; - let output = vp_shared::progress::with_spinner( - show_progress.then_some("Installing dependencies..."), - run_pnpm_install(version_dir, &node_runtime, &pnpm_entry, &args, registry), - ) - .await?; + let system = match &node_version { + Some(NodeVersion::System(tools)) => Some(*tools), + _ if EnvConfig::get().env_forbidden => { + return Err(Error::Setup( + format!( + "{}=1: Vite+ installs its dependencies only with a checked system node and pnpm", + vp_shared::env_vars::VP_ENV_FORBIDDEN + ) + .into(), + )); + } + _ => None, + }; + let output = if let Some(tools) = system { + vp_shared::progress::with_spinner( + show_progress.then_some("Installing dependencies..."), + run_system_pnpm_install(version_dir, tools, &args, registry), + ) + .await? + } else { + let (node_runtime, pnpm_entry) = vp_shared::progress::with_spinner( + show_progress.then_some("Preparing Node.js and pnpm..."), + prepare_install_runtime(node_version, registry), + ) + .await?; + vp_shared::progress::with_spinner( + show_progress.then_some("Installing dependencies..."), + run_pnpm_install(version_dir, &node_runtime, &pnpm_entry, &args, registry), + ) + .await? + }; if !output.status.success() { let exit_code = vp_shared::exit_code_from_status(output.status); - let log_path = - write_upgrade_log(version_dir, exit_code, &output.stdout, &output.stderr).await; - return Err(Error::Setup( - format_install_failure_message( - vp_shared::exit_code_from_status(output.status), - log_path.as_ref(), - ) - .into(), - )); + // The system pnpm may not read the installation lockfile; the bypass resolves again. + let lockfile_bypass = system.is_some() && args.contains(&"--frozen-lockfile"); + let log_path = write_upgrade_log( + version_dir, + exit_code, + lockfile_bypass, + &output.stdout, + &output.stderr, + ) + .await; + let mut message = format_install_failure_message(exit_code, log_path.as_ref()); + if lockfile_bypass { + message.push_str(&format!( + ". If the system pnpm cannot use the installation lockfile, retry with {}=1", + vp_shared::env_vars::VP_SETUP_SKIP_INSTALL_LOCKFILE + )); + } + return Err(Error::Setup(message.into())); } lockfile::record_install(version_dir).await?; @@ -421,11 +580,41 @@ async fn run_pnpm_install( .map_err(|error| Error::Setup(format!("Failed to build PATH for pnpm: {error}").into()))?; let mut cmd = tokio::process::Command::new(node_runtime.get_binary_path().as_path()); - cmd.arg(pnpm_entry.as_path()) - .args(args) - .current_dir(version_dir) - .env("CI", "true") - .env("PATH", path); + cmd.arg(pnpm_entry.as_path()).args(args).env("PATH", path); + configure_pnpm_install(&mut cmd, version_dir, args, registry)?; + Ok(cmd.output().await?) +} + +/// Run the system pnpm that [`system_install_tools`] selected, on its PATH. +async fn run_system_pnpm_install( + version_dir: &AbsolutePath, + tools: &SystemInstallTools, + args: &[&str], + registry: Option<&str>, +) -> Result { + tracing::debug!( + "Installing with system node {} and pnpm {}", + tools.node.as_path().display(), + tools.pnpm.as_path().display() + ); + let mut cmd = tokio::process::Command::new(tools.pnpm.as_path()); + cmd.args(args) + // A different pnpm version must not download the version it would switch to. + .arg("--config.manage-package-manager-versions=false") + // Match the pinned pnpm, which does not run dependency build scripts. + .arg("--ignore-scripts") + .env("PATH", &tools.path); + configure_pnpm_install(&mut cmd, version_dir, args, registry)?; + Ok(cmd.output().await?) +} + +fn configure_pnpm_install( + cmd: &mut tokio::process::Command, + version_dir: &AbsolutePath, + args: &[&str], + registry: Option<&str>, +) -> Result<(), Error> { + cmd.current_dir(version_dir).env("CI", "true"); if args.contains(&"--frozen-lockfile") { // --config leaves numbers as strings. Environment values get pnpm's numeric parsing. @@ -434,10 +623,7 @@ async fn run_pnpm_install( .env("NPM_CONFIG_PEERS_SUFFIX_MAX_LENGTH", "1000"); } - configure_pnpm_command(&mut cmd, registry)?; - - let output = cmd.output().await?; - Ok(output) + configure_pnpm_command(cmd, registry) } /// Save the current version before swapping, for rollback support. @@ -1160,7 +1346,7 @@ mod tests { let version_dir = AbsolutePathBuf::new(temp.path().join("0.1.15").to_path_buf()).unwrap(); tokio::fs::create_dir(&version_dir).await.unwrap(); - let result = write_upgrade_log(&version_dir, 17, b"", b"ENOSPC: private path").await; + let result = write_upgrade_log(&version_dir, 17, false, b"", b"ENOSPC: private path").await; assert!(result.is_some(), "write_upgrade_log should return log path"); let log_path = result.unwrap(); @@ -1195,7 +1381,7 @@ mod tests { "{code}: https://private-user:private-password@registry.example.test/private-package?token=private-token\nAuthorization: Bearer private-bearer\n_authToken=private-config-token\n" ); for (stdout, stderr) in [(output.as_bytes(), &[][..]), (&[][..], output.as_bytes())] { - let content = install_failure_log(1, stdout, stderr); + let content = install_failure_log(1, false, stdout, stderr); assert!(content.contains("Stage: production dependency installation.")); assert!(content.contains("exit code 1")); assert!(content.contains(&format!("Reported error: {code}."))); @@ -1211,10 +1397,29 @@ mod tests { } } + #[test] + fn install_failure_log_points_the_system_pnpm_to_the_lockfile_bypass() { + for code in SYSTEM_PNPM_LOCKFILE_DIAGNOSTICS { + let output = format!("{code}: private detail"); + let managed = install_failure_log(1, false, output.as_bytes(), b""); + assert!(managed.contains("Retry with an updated Vite+ release."), "{managed}"); + assert!(!managed.contains("VP_SETUP_SKIP_INSTALL_LOCKFILE"), "{managed}"); + let system = install_failure_log(1, true, output.as_bytes(), b""); + assert!(system.contains(&format!("Reported error: {code}.")), "{system}"); + assert!(system.contains("Retry with VP_SETUP_SKIP_INSTALL_LOCKFILE=1."), "{system}"); + assert!(!system.contains("updated Vite+ release"), "{system}"); + assert!(!system.contains("private"), "{system}"); + } + // Other hints do not depend on the pnpm. + let system = install_failure_log(1, true, b"ENOSPC", b""); + assert!(system.contains("Free disk space before retrying the command."), "{system}"); + } + #[test] fn install_failure_log_deduplicates_known_codes_from_both_streams() { let content = install_failure_log( 1, + false, b"ERR_PNPM_FETCH_401 Unauthorized\nERR_PNPM_FETCH_401 Unauthorized", b"ENOSPC: no space\nERR_PNPM_FETCH_401 Unauthorized", ); @@ -1230,7 +1435,7 @@ mod tests { b"PREFIX_ERR_PNPM_FETCH_401 ERR_PNPM_FETCH_401_PRIVATE ENOSPC_PRIVATE", b"\xff\xfeprivate-token", ] { - let content = install_failure_log(1, output, output); + let content = install_failure_log(1, false, output, output); assert!(content.contains("No recognized error identifier was reported.")); assert!(!content.contains("Reported error:")); assert!(!content.contains("private")); @@ -1338,4 +1543,149 @@ mod tests { let registry = tokio::fs::read_to_string(version_dir.join("registry.txt")).await.unwrap(); assert_eq!(registry, registry_url); } + + /// A bin directory with a system `node` that reports `node_version` and a + /// `#!/usr/bin/env node` pnpm (see `tests/fixtures/fake-system-node.sh`). + #[cfg(unix)] + async fn fake_system_bin(dir: &AbsolutePath, node_version: &str) -> AbsolutePathBuf { + let bin = dir.join("system-bin"); + tokio::fs::create_dir_all(&bin).await.unwrap(); + let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + tokio::fs::symlink(fixtures.join("fake-system-node.sh"), bin.join("node")).await.unwrap(); + tokio::fs::symlink(fixtures.join("fake-system-pnpm"), bin.join("pnpm")).await.unwrap(); + tokio::fs::write(bin.join("node-version"), format!("v{node_version}\n")).await.unwrap(); + bin + } + + #[cfg(unix)] + fn join_path(entries: &[&AbsolutePath]) -> std::ffi::OsString { + env::join_paths(entries.iter().map(|entry| entry.as_path())).unwrap() + } + + fn env_forbidden(home: &Path) -> [(&'static str, Option<&str>); 2] { + [ + (vp_shared::env_vars::VP_HOME, home.to_str()), + (vp_shared::env_vars::VP_ENV_FORBIDDEN, Some("1")), + ] + } + + #[cfg(unix)] + #[tokio::test] + async fn env_forbidden_installs_with_the_system_node_and_pnpm() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let version_dir = root.join("version"); + tokio::fs::create_dir_all(version_dir.join("bin")).await.unwrap(); + let bin = fake_system_bin(&root, "24.22.1").await; + let home = root.join("home"); + + Box::pin(EnvConfig::with_vars_async(env_forbidden(home.as_path()), |env| async move { + let tools = + find_system_install_tools(&join_path(&[&bin]), &root, None, Some(">=22.18.0")) + .await + .unwrap(); + install_production_deps(&version_dir, None, false, Some(NodeVersion::System(&tools))) + .await + .unwrap(); + + // pnpm's shebang ran the checked system node. + let invocation = + tokio::fs::read_to_string(version_dir.join("invocation.txt")).await.unwrap(); + let lines = invocation.lines().collect::>(); + assert_eq!(lines[0], "SYSTEM_NODE"); + assert_eq!(lines[1], bin.join("pnpm").as_path().to_str().unwrap()); + assert_eq!(lines[2..4], ["install", "--ignore-workspace"]); + assert!(lines.contains(&"--config.manage-package-manager-versions=false"), "{lines:?}"); + assert!(lines.contains(&"--ignore-scripts"), "{lines:?}"); + let path = tokio::fs::read_to_string(version_dir.join("path.txt")).await.unwrap(); + assert_eq!(path, bin.as_path().to_str().unwrap()); + for managed in ["js_runtime", "package_manager"] { + assert!(!env.dirs.data.join(managed).as_path().exists(), "{managed} was created"); + } + })) + .await; + } + + #[cfg(unix)] + #[tokio::test] + async fn env_forbidden_rejects_tools_other_than_the_first_system_match() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let bin = fake_system_bin(&root, "24.22.1").await; + let home = root.join("home"); + let node_only = root.join("node-only"); + tokio::fs::create_dir_all(&node_only).await.unwrap(); + tokio::fs::symlink(bin.join("node"), node_only.join("node")).await.unwrap(); + tokio::fs::copy(bin.join("node-version"), node_only.join("node-version")).await.unwrap(); + // A Unix shim is a link to a file named `vp`. + let shims = root.join("shims"); + tokio::fs::create_dir_all(&shims).await.unwrap(); + tokio::fs::copy(bin.join("node"), shims.join("vp")).await.unwrap(); + tokio::fs::symlink(shims.join("vp"), shims.join("node")).await.unwrap(); + let managed = home.join("js_runtime/node/24.22.1/bin"); + tokio::fs::create_dir_all(&managed).await.unwrap(); + tokio::fs::copy(bin.join("node"), managed.join("node")).await.unwrap(); + + EnvConfig::with_vars_async(env_forbidden(home.as_path()), |_| async move { + let error = |path: std::ffi::OsString, range: Option<&'static str>| { + let root = root.clone(); + async move { + find_system_install_tools(&path, &root, None, range) + .await + .unwrap_err() + .to_string() + } + }; + let empty = root.join("empty"); + assert!(error(join_path(&[&empty]), None).await.contains("no 'node' is on PATH")); + assert!(error(join_path(&[&node_only]), None).await.contains("no 'pnpm' is on PATH")); + // A later system match is never used. + let shim = error(join_path(&[&shims, &bin]), None).await; + assert!(shim.contains("run `vp env off node`"), "{shim}"); + let owned = error(join_path(&[&managed, &bin]), None).await; + assert!(owned.contains("the Vite+ managed install"), "{owned}"); + let old = error(join_path(&[&bin]), Some(">=26.0.0")).await; + assert!(old.contains("reports '24.22.1', which does not satisfy '>=26.0.0'"), "{old}"); + }) + .await; + } + + #[tokio::test] + async fn env_forbidden_requires_checked_system_tools() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + let home = root.join("home"); + Box::pin(EnvConfig::with_vars_async(env_forbidden(home.as_path()), |env| async move { + for node_version in [None, Some(NodeVersion::Preferred("24.22.1"))] { + let error = install_production_deps(&root, None, false, node_version) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("only with a checked system node and pnpm"), "{error}"); + } + assert!(!env.dirs.data.join("package_manager").as_path().exists()); + })) + .await; + } + + #[tokio::test] + async fn wrapper_omits_the_pnpm_pin_only_under_env_forbidden() { + let temp = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + for (value, pinned) in [(Some("1"), false), (Some("true"), true), (None, true)] { + EnvConfig::with_vars_async( + [(vp_shared::env_vars::VP_ENV_FORBIDDEN, value)], + |_| async { + generate_wrapper_package_json(&root, "1.2.3").await.unwrap(); + }, + ) + .await; + let manifest: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(root.join("package.json")).await.unwrap()) + .unwrap(); + assert_eq!(manifest["dependencies"]["vite-plus"], "1.2.3"); + let expected = pinned.then(|| format!("pnpm@{PINNED_PNPM_VERSION}")); + assert_eq!(manifest["packageManager"].as_str(), expected.as_deref(), "{value:?}"); + } + } } diff --git a/crates/vp_setup/src/lib.rs b/crates/vp_setup/src/lib.rs index 64d2a16420..826da9205e 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; /// Node.js version used to install the global CLI's dependencies by default. pub const DEFAULT_INSTALL_NODE_VERSION: &str = include_str!("../../../.node-version").trim_ascii(); diff --git a/crates/vp_setup/src/registry.rs b/crates/vp_setup/src/registry.rs index 439e971d37..0a4a919db5 100644 --- a/crates/vp_setup/src/registry.rs +++ b/crates/vp_setup/src/registry.rs @@ -17,6 +17,14 @@ pub struct PackageVersionMetadata { pub has_install_lockfile: bool, #[serde(default, rename = "vpNodeVersion", deserialize_with = "deserialize_node_version")] pub node_version: Option, + #[serde(default)] + pub engines: Option, +} + +/// The `engines` field of a package version. +#[derive(Debug, Deserialize)] +pub struct Engines { + pub node: Option, } fn deserialize_node_version<'de, D: Deserializer<'de>>( @@ -61,6 +69,9 @@ pub struct ResolvedVersion { pub platform_integrity: String, pub has_install_lockfile: bool, pub node_version: Option, + /// `engines.node` of the main `vite-plus` package. Only [`resolve_version`] + /// sets it; the platform package declares a looser range. + pub node_engines: Option, } const MAIN_PACKAGE_NAME: &str = "vite-plus"; @@ -105,6 +116,13 @@ pub async fn resolve_version_string( version_or_tag: &str, registry_override: Option<&str>, ) -> Result { + Ok(fetch_main_metadata(version_or_tag, registry_override).await?.version) +} + +async fn fetch_main_metadata( + version_or_tag: &str, + registry_override: Option<&str>, +) -> Result { let default_registry = npm_registry(); let registry_raw = registry_override.unwrap_or(&default_registry); let registry = registry_raw.trim_end_matches('/'); @@ -117,7 +135,7 @@ pub async fn resolve_version_string( Error::Setup(format!("Failed to fetch package metadata from {main_url}: {e}").into()) })?; - Ok(main_meta.version) + Ok(main_meta) } /// Resolve the platform-specific package metadata for a given version. @@ -158,6 +176,7 @@ pub async fn resolve_platform_package( platform_integrity: cli_meta.dist.integrity, has_install_lockfile: cli_meta.has_install_lockfile, node_version: cli_meta.node_version, + node_engines: None, }) } @@ -171,8 +190,11 @@ pub async fn resolve_version( platform_suffix: &str, registry_override: Option<&str>, ) -> Result { - let version = resolve_version_string(version_or_tag, registry_override).await?; - resolve_platform_package(&version, platform_suffix, registry_override).await + let main = fetch_main_metadata(version_or_tag, registry_override).await?; + let mut resolved = + resolve_platform_package(&main.version, platform_suffix, registry_override).await?; + resolved.node_engines = main.engines.and_then(|engines| engines.node); + Ok(resolved) } #[cfg(test)] @@ -425,6 +447,39 @@ mod tests { assert_eq!(resolved.platform_tarball_url, format!("{}/platform.tgz", server.base_url())); assert_eq!(resolved.platform_integrity, "sha512-preview"); assert_eq!(resolved.node_version.as_deref(), Some("24.22.1")); + assert!(resolved.node_engines.is_none()); + } + + #[tokio::test] + async fn test_resolve_version_reads_node_engines_from_the_main_package() { + let version = "0.0.0-commit.0123456789abcdef0123456789abcdef01234567"; + let server = MockServer::start(); + let dist = serde_json::json!({ + "tarball": format!("{}/platform.tgz", server.base_url()), + "integrity": "sha512-preview", + }); + server.mock(|when, then| { + when.method(GET).path("/vite-plus/preview"); + then.status(200).json_body(serde_json::json!({ + "version": version, + "engines": { "node": "^22.18.0 || >=24.11.0" }, + "dist": dist, + })); + }); + // The platform package declares a looser range, which must not be used. + server.mock(|when, then| { + when.method(GET).path(format!("/{TEST_PACKAGE_NAME}/{version}")); + then.status(200).json_body(serde_json::json!({ + "version": version, + "engines": { "node": ">=20.0.0" }, + "dist": dist, + })); + }); + + let resolved = + resolve_version("preview", "darwin-arm64", Some(&server.base_url())).await.unwrap(); + + assert_eq!(resolved.node_engines.as_deref(), Some("^22.18.0 || >=24.11.0")); } #[tokio::test] diff --git a/crates/vp_setup/src/support.rs b/crates/vp_setup/src/support.rs new file mode 100644 index 0000000000..2d50d319d1 --- /dev/null +++ b/crates/vp_setup/src/support.rs @@ -0,0 +1,150 @@ +//! 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 [`ENV_FORBIDDEN_SUPPORT_CHECK`], a binary that also supports +//! `VP_ENV_FORBIDDEN` prints [`ENV_FORBIDDEN_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"; + +/// Check value that asks whether the binary supports `VP_ENV_FORBIDDEN`. +pub const ENV_FORBIDDEN_SUPPORT_CHECK: &str = "env-forbidden"; + +/// Second line of the probe response for [`ENV_FORBIDDEN_SUPPORT_CHECK`]. +pub const ENV_FORBIDDEN_SUPPORT_RESPONSE: &str = "vite-plus-self-setup-env-forbidden"; + +/// 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 `VP_ENV_FORBIDDEN` support. + SelfSetup, + /// First-start setup and `VP_ENV_FORBIDDEN`. + EnvForbidden, +} + +/// 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_FORBIDDEN_SUPPORT_CHECK { + &[SELF_SETUP_SUPPORT_RESPONSE, ENV_FORBIDDEN_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 env_forbidden = + format!("{SELF_SETUP_SUPPORT_RESPONSE}\n{ENV_FORBIDDEN_SUPPORT_RESPONSE}\n"); + if stdout == env_forbidden.as_bytes() { + SelfSetupSupport::EnvForbidden + } else if stdout == v1.as_bytes() { + SelfSetupSupport::SelfSetup + } else { + SelfSetupSupport::Legacy + } +} + +/// Run `binary --help` with the support check. With `env_forbidden`, the +/// check asks whether the binary also supports `VP_ENV_FORBIDDEN`. +/// +/// # Errors +/// +/// Returns an error if the binary cannot be started. +pub async fn probe_self_setup( + binary: &AbsolutePath, + env_forbidden: bool, +) -> std::io::Result { + let output = tokio::process::Command::new(binary.as_path()) + .arg("--help") + .env( + env_vars::VP_SELF_SETUP_SUPPORT_CHECK, + if env_forbidden { ENV_FORBIDDEN_SUPPORT_CHECK } 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 `VP_ENV_FORBIDDEN`. +#[must_use] +pub fn env_forbidden_unsupported_message(target: &str) -> String { + let variable = env_vars::VP_ENV_FORBIDDEN; + format!( + "vite-plus {target} does not support {variable}. Choose a version that supports it, or unset {variable}." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn support_response_adds_env_forbidden_line_only_for_its_check_value() { + assert_eq!(support_response("1".as_ref()), [SELF_SETUP_SUPPORT_RESPONSE]); + assert_eq!(support_response("Env-Forbidden".as_ref()), [SELF_SETUP_SUPPORT_RESPONSE]); + assert_eq!( + support_response(ENV_FORBIDDEN_SUPPORT_CHECK.as_ref()), + [SELF_SETUP_SUPPORT_RESPONSE, ENV_FORBIDDEN_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-env-forbidden\n" + ), + SelfSetupSupport::EnvForbidden + ); + 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", + &format!( + r#"echo {SELF_SETUP_SUPPORT_RESPONSE}; [ "$VP_SELF_SETUP_SUPPORT_CHECK" = {ENV_FORBIDDEN_SUPPORT_CHECK} ] && echo {ENV_FORBIDDEN_SUPPORT_RESPONSE}; exit 0"# + ), + ); + let v1 = script("v1", &format!("echo {SELF_SETUP_SUPPORT_RESPONSE}")); + let legacy = script("legacy", "echo 'Usage: vp [COMMAND]'"); + let failing = script("failing", &format!("echo {SELF_SETUP_SUPPORT_RESPONSE}; exit 1")); + + assert_eq!(probe_self_setup(&modern, true).await.unwrap(), SelfSetupSupport::EnvForbidden); + 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_setup/tests/fixtures/fake-system-node.sh b/crates/vp_setup/tests/fixtures/fake-system-node.sh new file mode 100755 index 0000000000..8823c46df3 --- /dev/null +++ b/crates/vp_setup/tests/fixtures/fake-system-node.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# A system node: `--version` prints the node-version file next to the link. +# Other runs record the marker, the arguments, and $PATH in the working directory. +if [ "$1" = "--version" ]; then + cat "$(dirname "$0")/node-version" + exit 0 +fi +echo SYSTEM_NODE > invocation.txt +printf '%s\n' "$@" >> invocation.txt +printf '%s' "$PATH" > path.txt diff --git a/crates/vp_setup/tests/fixtures/fake-system-pnpm b/crates/vp_setup/tests/fixtures/fake-system-pnpm new file mode 100755 index 0000000000..908ba8417a --- /dev/null +++ b/crates/vp_setup/tests/fixtures/fake-system-pnpm @@ -0,0 +1 @@ +#!/usr/bin/env node diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index e9c890e401..cf78e323e5 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -217,6 +217,13 @@ pub struct EnvConfig { pub yarn_manager: Option, pub bun_manager: Option, + /// Whether Vite+ manages no Node.js or package manager, enabled only by + /// `1`. At run time it takes precedence over `config.json` and every + /// `VP_*_MANAGER` variable. + /// + /// Env: `VP_ENV_FORBIDDEN` + pub env_forbidden: bool, + /// User home directory. /// /// Resolved once from `HOME` or `USERPROFILE` in platform order. See @@ -283,6 +290,7 @@ impl EnvConfig { pnpm_manager: ManagerPreference::from_var(env_vars::VP_PNPM_MANAGER), yarn_manager: ManagerPreference::from_var(env_vars::VP_YARN_MANAGER), bun_manager: ManagerPreference::from_var(env_vars::VP_BUN_MANAGER), + env_forbidden: std::env::var(env_vars::VP_ENV_FORBIDDEN).as_deref() == Ok("1"), user_home, vp_shell: std::env::var(env_vars::VP_SHELL).ok(), nu_version: std::env::var(env_vars::VP_NU_VERSION).ok(), @@ -465,6 +473,38 @@ impl EnvConfig { mod tests { use super::*; + #[test] + fn env_forbidden_requires_exact_one() { + for value in [None, Some(""), Some("0"), Some("true"), Some("yes"), Some(" 1"), Some("1")] { + EnvConfig::with_vars([(env_vars::VP_ENV_FORBIDDEN, value)], |config| { + assert_eq!(config.env_forbidden, value == Some("1"), "{value:?}"); + }); + } + } + + /// `system-only`, a removed run-time value, is unrecognized like any other + /// value and does not stop management either. + #[test] + fn manager_preference_accepts_only_yes_and_no() { + for (value, expected) in [ + ("yes", Some(ManagerPreference::Yes)), + ("no", Some(ManagerPreference::No)), + ("Yes", None), + ("system-only", None), + ] { + let vars = [ + (env_vars::VP_NODE_MANAGER, Some(value)), + (env_vars::VP_PM_MANAGER, Some(value)), + (env_vars::VP_ENV_FORBIDDEN, None), + ]; + EnvConfig::with_vars(vars, |config| { + assert_eq!(config.node_manager, expected, "{value}"); + assert_eq!(config.pm_manager, expected, "{value}"); + assert!(!config.env_forbidden, "{value}"); + }); + } + } + #[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 03c9f6bc43..6d2dd9d768 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -129,6 +129,11 @@ 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"; +/// `1` (exactly) stops Vite+ from managing Node.js and every package manager: +/// at run time it neither downloads nor uses a managed installation, +/// regardless of `config.json` and the `VP_*_MANAGER` variables. +pub const VP_ENV_FORBIDDEN: &str = "VP_ENV_FORBIDDEN"; + /// Enable debug output for shim dispatch. pub const VP_DEBUG_SHIM: &str = "VP_DEBUG_SHIM"; diff --git a/crates/vp_shared/src/path_env.rs b/crates/vp_shared/src/path_env.rs index b8c03c0580..477df1bd03 100644 --- a/crates/vp_shared/src/path_env.rs +++ b/crates/vp_shared/src/path_env.rs @@ -3,7 +3,13 @@ //! This module provides functions for prepending directories to the PATH //! environment variable with various deduplication strategies. -use std::{collections::BTreeSet, env, ffi::OsString, io, path::Path}; +use std::{ + collections::BTreeSet, + env, + ffi::{OsStr, OsString}, + io, + path::Path, +}; use vt_path::AbsolutePath; @@ -74,6 +80,11 @@ impl ToolPathEnv { self.tools.contains(tool) } + /// The PATH that a child process receives. + pub fn path(&self) -> &OsStr { + &self.path + } + /// Only pass tools supplied by this directory, never the names of vp shims. /// Use `dedupe_anywhere` to retain an existing directory's PATH precedence; /// explicit version selection instead moves the directory to the front. diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 64b835ed44..0348ae3b7d 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -420,7 +420,15 @@ function Get-PayloadAndHandoff { $binarySource = $LocalBinary } $binarySource = (Resolve-Path -LiteralPath $binarySource).Path - if (Test-SelfSetupSupport -BinarySource $binarySource) { + # VP_ENV_FORBIDDEN=1 (exact value only) stops Vite+ from managing Node.js + # and every package manager. + if ($env:VP_ENV_FORBIDDEN -ceq '1') { + # Binaries without VP_ENV_FORBIDDEN support would ignore it and download managed tools. + if (-not (Test-EnvForbiddenSupport -BinarySource $binarySource)) { + Write-Error-Exit "vite-plus $ViteVersion does not support VP_ENV_FORBIDDEN. Choose a version that supports it, or unset VP_ENV_FORBIDDEN." + } + Invoke-InstallHandoff -BinarySource $binarySource + } elseif (Test-SelfSetupSupport -BinarySource $binarySource) { Invoke-InstallHandoff -BinarySource $binarySource } else { Invoke-LegacyInstaller -BinarySource $binarySource @@ -447,6 +455,20 @@ function Test-SelfSetupSupport { } } +function Test-EnvForbiddenSupport { + param([string]$BinarySource) + $previous = $env:VP_SELF_SETUP_SUPPORT_CHECK + try { + $env:VP_SELF_SETUP_SUPPORT_CHECK = 'env-forbidden' + $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-env-forbidden' + } 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..c743234ee9 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 + # VP_ENV_FORBIDDEN=1 (exact value only) stops Vite+ from managing Node.js + # and every package manager. + if [ "${VP_ENV_FORBIDDEN-}" = 1 ]; then + # Binaries without VP_ENV_FORBIDDEN support would ignore it and download managed tools. + supports_env_forbidden "$binary_source" || + error "vite-plus $VP_VERSION does not support VP_ENV_FORBIDDEN. Choose a version that supports it, or unset VP_ENV_FORBIDDEN." + handoff_install "$binary_source" + elif supports_self_setup "$binary_source"; then handoff_install "$binary_source" else run_legacy_installer "$binary_source" @@ -750,6 +757,12 @@ supports_self_setup() { [ "$response" = $'vite-plus-self-setup-v1\n.' ] } +supports_env_forbidden() { + local response + response=$(VP_SELF_SETUP_SUPPORT_CHECK=env-forbidden "$1" --help 2>/dev/null && printf '.') || return 1 + [ "$response" = $'vite-plus-self-setup-v1\nvite-plus-self-setup-env-forbidden\n.' ] +} + run_legacy_installer() ( local binary_source="$1" local legacy_script=""