diff --git a/.github/scripts/test-install-bootstrap.ps1 b/.github/scripts/test-install-bootstrap.ps1 new file mode 100644 index 0000000000..4f8f601eaf --- /dev/null +++ b/.github/scripts/test-install-bootstrap.ps1 @@ -0,0 +1,131 @@ +# Run locally on Windows with ./.github/scripts/test-install-bootstrap.ps1; no registry or installed vp needed. +$ErrorActionPreference = 'Stop' +$source = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../../packages/cli/install.ps1') -Raw +. ([scriptblock]::Create(($source -replace '(?m)^ Main\r?$', ''))) +function Exit-Installer { param([int]$Code = 1); $script:ExitCode = $Code; throw $script:InstallStopSignal } +function Assert($Condition, [string]$Message) { + if (-not $Condition) { throw $Message } +} + +$testRoot = Join-Path $env:TEMP "vite-bootstrap-test-$(Get-Random)" +$originalTemp = $env:TEMP +$originalCheck = $env:VP_SELF_SETUP_SUPPORT_CHECK +$originalPath = $env:Path +$originalRegistry = $env:NPM_CONFIG_REGISTRY +$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' +@' +if ($args.Count -eq 0) { + if (Test-Path Env:VP_SELF_SETUP_SUPPORT_CHECK) { exit 99 } + New-Item -ItemType File -Path "$testRoot/binary-invoked" | Out-Null + if ($scenario -eq 'failure') { exit 42 } + if ($env:VP_SELF_SETUP_SHELL -ne 'powershell') { exit 98 } + if ($scenario -eq 'supported-pr' -and $env:NPM_CONFIG_REGISTRY -ne 'https://registry-bridge.viteplus.dev/') { exit 97 } + 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") + Write-Output ("`$script:ConfigDir = '{0}'" -f "$testRoot/config") + Write-Output ("`$script:StateDir = '{0}'" -f "$testRoot/state") + exit 0 +} +if ($env:VP_SELF_SETUP_SUPPORT_CHECK -ne '1') { exit 99 } +if ($scenario -in @('legacy', 'legacy-failure', 'pr')) { Write-Output 'Usage: vp [COMMAND]' } +else { Write-Output 'vite-plus-self-setup-v1' } +exit 0 +'@ | Set-Content -LiteralPath "$testRoot/package/binary.ps1" +@' +param($BinarySource, $ResolvedVersion, $PreviewRef) +$script:InstallDir = "$testRoot/data" +$script:ShimDir = "$testRoot/installed bin" +$script:CacheDir = "$testRoot/cache" +$script:ConfigDir = "$testRoot/config" +$script:StateDir = "$testRoot/state" +if (-not [System.IO.Path]::IsPathRooted($BinarySource) -or -not (Test-Path -LiteralPath $BinarySource)) { throw 'Invalid payload path' } +@($ResolvedVersion, $PreviewRef) | Set-Content -LiteralPath "$testRoot/legacy" +if ($scenario -eq 'legacy-failure') { exit 42 } +'@ | Set-Content -LiteralPath "$testRoot/scripts/install-legacy.ps1" +& "$env:SystemRoot\System32\tar.exe" -czf "$testRoot/payload.tgz" -C $testRoot package +Assert ($LASTEXITCODE -eq 0) 'Could not create fixture' +$env:TEMP = "$testRoot/tmp" + +function Invoke-RestMethod { + param($Uri) + $script:Requests.Add("GET $Uri") + return @{ version = '0.2.9' } +} +function Invoke-WebRequest { + param($Uri, $Method, $OutFile, [switch]$UseBasicParsing, $ErrorAction) + $script:Requests.Add("$Method $Uri") + if ($Method -eq 'Head') { + return @{ Headers = @{ 'x-commit-key' = "voidzero-dev:vite-plus:$fixtureSha" } } + } + Copy-Item -LiteralPath "$testRoot/payload.tgz" -Destination $OutFile +} + +# Use an executable script fixture so these checks need no native compiler. +$probe = ${function:Test-SelfSetupSupport} +function Test-SelfSetupSupport { + param($BinarySource) + & $probe -BinarySource (Join-Path (Split-Path $BinarySource) 'binary.ps1') +} +$handoff = ${function:Invoke-InstallHandoff} +function Invoke-InstallHandoff { + param($BinarySource) + & $handoff -BinarySource (Join-Path (Split-Path $BinarySource) 'binary.ps1') +} + +try { + foreach ($scenario in @('supported', 'legacy', 'legacy-failure', 'failure', 'pr', 'supported-pr')) { + $env:Path = $originalPath + $env:NPM_CONFIG_REGISTRY = 'https://custom.example' + $script:Requests = New-Object 'System.Collections.Generic.List[string]' + $script:ExitCode = 0 + $script:PackageMetadata = $null + Remove-Item -LiteralPath "$testRoot/legacy", "$testRoot/binary-invoked" -ErrorAction SilentlyContinue + $env:VP_SELF_SETUP_SUPPORT_CHECK = if ($scenario -eq 'supported') { 'original' } else { $null } + try { + & { + $ViteVersion = 'latest' + $LocalTgz = $LocalBinary = $PrVersion = $PrCommitVersion = $null + $NpmRegistry = 'https://custom.example' + $InstallerDirectory = "$testRoot/scripts" + if ($scenario -in @('pr', 'supported-pr')) { $PrVersion = '2406' } + Main + Assert ($env:NPM_CONFIG_REGISTRY -eq 'https://custom.example') 'Setup changed the caller registry' + Assert ($script:InstallDir -eq "$testRoot/data") 'InstallDir was lost' + Assert ($script:ShimDir -eq "$testRoot/installed bin") 'ShimDir was lost' + Assert ($script:CacheDir -eq "$testRoot/cache") 'CacheDir was lost' + Assert ($script:ConfigDir -eq "$testRoot/config") 'ConfigDir was lost' + Assert ($script:StateDir -eq "$testRoot/state") 'StateDir was lost' + } + } catch { + if ($scenario -notin @('failure', 'legacy-failure') -or -not (Test-IsInstallStopException $_)) { throw } + } + $expectedExit = if ($scenario -in @('failure', 'legacy-failure')) { 42 } else { 0 } + Assert ($script:ExitCode -eq $expectedExit) 'Binary exit code was lost' + 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') + 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') { + $record = @(Get-Content -LiteralPath "$testRoot/legacy") + Assert ($record[0] -eq "0.0.0-commit.$fixtureSha" -and $record[1] -eq '2406') 'Resolved preview identity was lost' + Assert ($script:Requests[-1].EndsWith("@$fixtureSha")) 'Payload used a mutable ref' + Assert ($script:Requests.Count -eq 2) 'Preview was resolved or downloaded more than once' + } + Assert (@(Get-ChildItem -LiteralPath "$testRoot/tmp" -Force).Count -eq 0) 'Temporary payload was not cleaned up' + Write-Host "PASS: $scenario" + } +} finally { + $env:VP_SELF_SETUP_SUPPORT_CHECK = $originalCheck + $env:Path = $originalPath + $env:NPM_CONFIG_REGISTRY = $originalRegistry + $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 new file mode 100644 index 0000000000..3ec14e6ee8 --- /dev/null +++ b/.github/scripts/test-install-bootstrap.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Run locally with bash .github/scripts/test-install-bootstrap.sh; no registry or installed vp needed. +set -eu +cd "$(dirname "$0")/../.." +eval "$(sed '/^main "[$]@"$/d' packages/cli/install.sh)" + +test_root=$(mktemp -d) +trap 'rm -rf "$test_root"' EXIT +export test_root +mkdir -p "$test_root/package" "$test_root/tmp" "$test_root/scripts" +touch "$test_root/scripts/install.sh" +cat > "$test_root/scripts/install-legacy.sh" <<'LEGACY' +#!/bin/bash +set -eu +INSTALL_DIR="$test_root/data" +SHIM_DIR="$test_root/installed bin" +CACHE_DIR="$test_root/cache" +CONFIG_DIR="$test_root/config" +STATE_DIR="$test_root/state" +case "$1" in /*) ;; *) exit 80 ;; esac +test -f "$1" +printf '%s\n' "$2" "$3" > "$test_root/legacy" +case "$scenario" in + legacy-failure|piped-legacy-failure) exit 43 ;; +esac +LEGACY +cat > "$test_root/package/vp" <<'BINARY' +#!/bin/bash +if [ "$#" -eq 0 ]; then + test -z "${VP_SELF_SETUP_SUPPORT_CHECK+x}" || exit 99 + touch "$test_root/binary-invoked" + if [ "$scenario" = failure ]; then exit 42; fi + test "${VP_SELF_SETUP_SHELL:-}" = sh || exit 98 + if [ "$scenario" = supported-pr ]; then + test "$NPM_CONFIG_REGISTRY" = https://registry-bridge.viteplus.dev/ || exit 97 + 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" + printf 'CONFIG_DIR=%q\n' "$test_root/config" + printf 'STATE_DIR=%q\n' "$test_root/state" + exit 0 +fi +test "${VP_SELF_SETUP_SUPPORT_CHECK:-}" = 1 || exit 99 +case "$scenario" in + legacy|legacy-failure|piped-legacy|piped-legacy-failure|pr) printf 'Usage: vp [COMMAND]\n' ;; + *) printf 'vite-plus-self-setup-v1\n' ;; +esac +BINARY +chmod +x "$test_root/package/vp" +tar czf "$test_root/payload.tgz" -C "$test_root" package +export TMPDIR="$test_root/tmp" +fixture_sha=0123456789012345678901234567890123456789 + +# Only transport is substituted; extraction, probing, and dispatch run normally. +curl() { + printf '%s\n' "$*" >> "$test_root/requests" + case "$*" in + *file://*) command curl "$@" ;; + *-fsSIL*) printf 'x-commit-key: voidzero-dev:vite-plus:%s\r\n' "$fixture_sha" ;; + *'https://custom.example/vite-plus/'*) printf '{"version":"0.2.9"}\n' ;; + *) cp "$test_root/payload.tgz" "${@: -1}" ;; + esac +} + +for scenario in supported legacy legacy-failure piped-legacy piped-legacy-failure failure pr supported-pr; do + export scenario + : > "$test_root/requests" + rm -f "$test_root/legacy" "$test_root/binary-invoked" + set +e + ( + set -e + VP_VERSION=latest + LOCAL_TGZ="" LOCAL_BINARY="" PR_VERSION="" PACKAGE_METADATA="" + NPM_REGISTRY=https://custom.example + export NPM_CONFIG_REGISTRY="$NPM_REGISTRY" + INSTALLER_PATH="$test_root/scripts/install.sh" + if [[ "$scenario" == piped-legacy* ]]; then + INSTALLER_PATH="" + LEGACY_INSTALLER_URL="file://$test_root/scripts/install-legacy.sh" + fi + if [[ "$scenario" == *pr ]]; then PR_VERSION=2406; fi + if [ "$scenario" = supported ]; then export VP_SELF_SETUP_SUPPORT_CHECK=original; fi + main + test "$NPM_CONFIG_REGISTRY" = https://custom.example + test "$INSTALL_DIR" = "$test_root/data" + test "$SHIM_DIR" = "$test_root/installed bin" + test "$CACHE_DIR" = "$test_root/cache" + test "$CONFIG_DIR" = "$test_root/config" + test "$STATE_DIR" = "$test_root/state" + ) > "$test_root/output" 2>&1 + status=$? + set -e + if [ "$scenario" = failure ]; then + test "$status" -eq 42 + elif [[ "$scenario" == *legacy-failure ]]; then + test "$status" -eq 43 + elif [ "$status" -ne 0 ]; then + cat "$test_root/output" + exit 1 + fi + case "$scenario" in + supported|supported-pr|failure) + 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" ;; + esac + if [ "$scenario" = pr ]; then + test "$(head -1 "$test_root/legacy")" = "0.0.0-commit.$fixture_sha" + test "$(tail -1 "$test_root/legacy")" = 2406 + grep -q "@$fixture_sha -o" "$test_root/requests" + test "$(wc -l < "$test_root/requests" | tr -d ' ')" = 2 + fi + test -z "$(ls -A "$test_root/tmp")" + echo "PASS: $scenario" +done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f05906583f..79676b99d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -760,6 +760,8 @@ jobs: $initialBinDir = Join-Path $initialVersionDir "bin" New-Item -ItemType Directory -Path $initialBinDir | Out-Null Copy-Item $currentVp (Join-Path $initialBinDir "vp.exe") + # Preserve installed state so the first invocation executes upgrade. + Copy-Item (Join-Path (Split-Path $currentVp) ".vp-setup-complete") $initialBinDir New-Item -ItemType Junction -Path (Join-Path $tempVpHome "current") -Target $initialVersionDir | Out-Null $env:VP_HOME = $tempVpHome @@ -815,6 +817,8 @@ jobs: initial_bin_dir="$temp_vp_home/$version/bin" mkdir -p "$initial_bin_dir" cp "$current_vp" "$initial_bin_dir/vp" + # Preserve installed state so the first invocation executes upgrade. + cp "$(dirname "$current_vp")/.vp-setup-complete" "$initial_bin_dir/" chmod +x "$initial_bin_dir/vp" # Relative symlink, matching swap_current_link's `current -> `. ln -s "$version" "$temp_vp_home/current" @@ -1255,10 +1259,13 @@ jobs: # The image must actually lack the CA bundle, or this job stops # guarding anything. test ! -e /etc/ssl/certs/ca-certificates.crt + # Exercise commands on the mounted build without installing a published package. + touch /usr/local/bin/.vp-setup-complete vp --version # HTTPS to nodejs.org through the shared client; exits nonzero # without the bundled-roots fallback. - vp env list-remote --lts + vp env list-remote --lts --json > /tmp/vp-versions.json + node -e "const versions = require(\"/tmp/vp-versions.json\"); if (!versions.node?.length) process.exit(1)" ' install-e2e-test: diff --git a/.github/workflows/deploy-docs-main.yml b/.github/workflows/deploy-docs-main.yml index 9b7a309d28..8d47efc213 100644 --- a/.github/workflows/deploy-docs-main.yml +++ b/.github/workflows/deploy-docs-main.yml @@ -13,6 +13,8 @@ on: - 'docs/**' - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/cli/install-legacy.sh' + - 'packages/cli/install-legacy.ps1' - '.github/workflows/deploy-docs-main.yml' - '.github/actions/deploy-docs/**' diff --git a/.github/workflows/deploy-docs-preview.yml b/.github/workflows/deploy-docs-preview.yml index 2579e08698..1659ee621d 100644 --- a/.github/workflows/deploy-docs-preview.yml +++ b/.github/workflows/deploy-docs-preview.yml @@ -8,6 +8,8 @@ on: - 'docs/**' - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/cli/install-legacy.sh' + - 'packages/cli/install-legacy.ps1' - '.github/workflows/deploy-docs-preview.yml' - '.github/actions/deploy-docs/**' diff --git a/.github/workflows/publish-preview-register.yml b/.github/workflows/publish-preview-register.yml index 2f294af714..330d6e7235 100644 --- a/.github/workflows/publish-preview-register.yml +++ b/.github/workflows/publish-preview-register.yml @@ -388,11 +388,11 @@ jobs: '', '```bash', '# macOS / Linux', - `curl -fsSL ${installerScripts}/install.sh | VP_PR_VERSION=${pr} bash`, + `curl -fsSL ${installerScripts}/install.sh | VP_PR_VERSION=${pr} VP_LEGACY_INSTALLER_URL=${installerScripts}/install-legacy.sh bash`, '```', '```powershell', '# Windows (PowerShell)', - `$env:VP_PR_VERSION="${pr}"; irm ${installerScripts}/install.ps1 | iex`, + `$env:VP_PR_VERSION="${pr}"; $env:VP_LEGACY_INSTALLER_URL="${installerScripts}/install-legacy.ps1"; irm ${installerScripts}/install.ps1 | iex`, '```', '', '**Or download the standalone Windows installer built from this commit:**', diff --git a/.github/workflows/reusable-release-build.yml b/.github/workflows/reusable-release-build.yml index 8a9a75448f..42af740973 100644 --- a/.github/workflows/reusable-release-build.yml +++ b/.github/workflows/reusable-release-build.yml @@ -153,6 +153,8 @@ jobs: } trap cleanup EXIT tar -xzf "vp-${{ matrix.settings.target }}.tar.gz" -C "${archive_dir}" + # Skip first-start setup because this build's preview packages are not published yet. + touch "${archive_dir}/.vp-setup-complete" node --input-type=module -e ' const before = JSON.stringify({ devDependencies: { "vite-plus": "0.0.0" } }) + "\n"; diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index c217953f98..c220470d68 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -8,6 +8,8 @@ on: paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/cli/install-legacy.sh' + - 'packages/cli/install-legacy.ps1' - 'packages/tools/src/install-global-cli.ts' - 'packages/tools/src/local-npm-registry.ts' - 'crates/vp_installer/**' @@ -51,8 +53,11 @@ jobs: # single-root tree. run: echo "VP_HOME=$HOME/.vite-plus" >> $GITHUB_ENV + - name: Check bootstrap handoff + run: bash .github/scripts/test-install-bootstrap.sh + - name: Run install.sh - run: cat packages/cli/install.sh | bash + run: cat packages/cli/install.sh | VP_LEGACY_INSTALLER_URL="file://$PWD/packages/cli/install-legacy.sh" bash - name: Verify installation working-directory: ${{ runner.temp }} @@ -149,7 +154,7 @@ jobs: sudo apt-get install --no-install-recommends -y fish zsh # Load installer helpers without invoking main. - eval "$(sed '/^main "[$]@"$/d' packages/cli/install.sh)" + eval "$(sed '/^main "[$]@"$/d' packages/cli/install-legacy.sh)" TEST_ROOT=$(mktemp -d) export HOME="$TEST_ROOT/home" @@ -221,7 +226,7 @@ jobs: - name: Shell installer rejects an unversioned Node sidecar run: | set -euo pipefail - eval "$(sed '/^main "[$]@"$/d' packages/cli/install.sh)" + eval "$(sed '/^main "[$]@"$/d' packages/cli/install-legacy.sh)" TEST_ROOT=$(mktemp -d) TEST_BIN="$TEST_ROOT/bin" @@ -238,7 +243,7 @@ jobs: - name: CI hides shell file warnings run: | set -euo pipefail - eval "$(sed '/^main "[$]@"$/d' packages/cli/install.sh)" + eval "$(sed '/^main "[$]@"$/d' packages/cli/install-legacy.sh)" SHELL_CONFIG=$(mktemp) chmod 444 "$SHELL_CONFIG" @@ -323,7 +328,8 @@ jobs: - name: Fresh home uses split layout run: | set -euo pipefail - FRESH=$(mktemp -d) + FRESH="$(mktemp -d)/home with 'quotes' and \$dollar" + mkdir -p "$FRESH" FAKE_TGZ=$(mktemp) export HOME="$FRESH" export USERPROFILE="$FRESH" @@ -331,12 +337,9 @@ jobs: export VP_VPDIRS_AWARE=1 unset VP_HOME VP_BIN_DIR VP_DATA_DIR VP_CACHE_DIR unset XDG_DATA_HOME XDG_CACHE_HOME XDG_CONFIG_HOME XDG_STATE_HOME - OUTPUT=$(mktemp) - VP_LOCAL_TGZ="$FAKE_TGZ" VP_VERSION=local-test bash packages/cli/install.sh | tee "$OUTPUT" - - grep -F "Install locations:" "$OUTPUT" - grep -F "Data directory: ~/.local/share/vite-plus" "$OUTPUT" - grep -F "Bin directory: ~/.local/share/vite-plus/bin" "$OUTPUT" + # setup-vp sources the installer and reads SHIM_DIR in its own shell. + VP_LOCAL_TGZ="$FAKE_TGZ" VP_VERSION=local-test source packages/cli/install.sh + test "$SHIM_DIR" = "$FRESH/.local/share/vite-plus/bin" test ! -d "$FRESH/.vite-plus" test -e "$FRESH/.local/share/vite-plus/current" @@ -352,6 +355,11 @@ jobs: test "$(dump_dir cache)" = "$FRESH/.cache/vite-plus" test "$(dump_dir config)" = "$FRESH/.config/vite-plus" test "$(dump_dir state)" = "$FRESH/.local/state/vite-plus" + test "$INSTALL_DIR" = "$(dump_dir data)" + test "$SHIM_DIR" = "$(dump_dir bin)" + test "$CACHE_DIR" = "$(dump_dir cache)" + test "$CONFIG_DIR" = "$(dump_dir config)" + test "$STATE_DIR" = "$(dump_dir state)" - name: Custom shared bin preserves an unrelated Node executable run: | @@ -458,7 +466,7 @@ jobs: # Do not set VP_HOME. A pre-VpDirs release must select the monolithic # ~/.vite-plus root. run: | - cat packages/cli/install.sh | bash | tee install-output.txt + cat packages/cli/install.sh | VP_LEGACY_INSTALLER_URL="file://$PWD/packages/cli/install-legacy.sh" bash 2>&1 | tee install-output.txt grep -F "does not support the split directory layout" install-output.txt - name: Verify monolithic layout @@ -565,7 +573,7 @@ jobs: - name: Run install.sh run: | - output=$(cat packages/cli/install.sh | bash 2>&1) || { + output=$(cat packages/cli/install.sh | VP_LEGACY_INSTALLER_URL="file://$PWD/packages/cli/install-legacy.sh" bash 2>&1) || { echo "$output" echo "Install script exited with non-zero status" exit 1 @@ -610,7 +618,7 @@ jobs: ls -al ~/ apt-get update && apt-get install -y curl ca-certificates export VP_HOME=\"\$HOME/.vite-plus\" - cat /workspace/packages/cli/install.sh | bash + cat /workspace/packages/cli/install.sh | VP_LEGACY_INSTALLER_URL=file:///workspace/packages/cli/install-legacy.sh bash if [ -f ~/.profile ]; then source ~/.profile elif [ -f ~/.bashrc ]; then @@ -670,7 +678,7 @@ jobs: # libstdc++: required by unofficial-builds Node.js musl binary apk add --no-cache bash curl ca-certificates libstdc++ export VP_HOME=\"\$HOME/.vite-plus\" - cat /workspace/packages/cli/install.sh | bash + cat /workspace/packages/cli/install.sh | VP_LEGACY_INSTALLER_URL=file:///workspace/packages/cli/install-legacy.sh bash export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" vp --version @@ -728,7 +736,7 @@ jobs: # libstdc++ is needed by unofficial-builds Node.js musl binary apk add --no-cache bash curl ca-certificates libstdc++ export VP_HOME=\"\$HOME/.vite-plus\" - cat /workspace/packages/cli/install.sh | bash + cat /workspace/packages/cli/install.sh | VP_LEGACY_INSTALLER_URL=file:///workspace/packages/cli/install-legacy.sh bash export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" vp --version @@ -790,6 +798,10 @@ jobs: exit 1 } + - name: Check bootstrap handoff + shell: powershell + run: ./.github/scripts/test-install-bootstrap.ps1 + - name: Run install.ps1 shell: powershell run: | @@ -797,6 +809,9 @@ jobs: - name: Run install.ps1 via irm simulation (catches BOM issues) shell: powershell + # Piped installers cannot locate a sibling script; use this PR's legacy implementation. + env: + VP_LEGACY_INSTALLER_URL: https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.full_name || github.repository }}/${{ github.event.pull_request.head.sha || github.sha }}/packages/cli/install-legacy.ps1 run: | $ErrorActionPreference = "Stop" Get-Content ./packages/cli/install.ps1 -Raw | Invoke-Expression @@ -960,6 +975,9 @@ jobs: - name: Run install.ps1 via iex under PowerShell 7.6 shell: pwsh + # Piped installers cannot locate a sibling script; use this PR's legacy implementation. + env: + VP_LEGACY_INSTALLER_URL: https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.full_name || github.repository }}/${{ github.event.pull_request.head.sha || github.sha }}/packages/cli/install-legacy.ps1 run: | & $env:PWSH76 -NoProfile -Command "Get-Content ./packages/cli/install.ps1 -Raw | Invoke-Expression" @@ -1331,6 +1349,8 @@ jobs: $payload, $true ) + # This fixture represents an installed payload, not a first-launch installer. + [System.IO.File]::WriteAllText((Join-Path $payloadBin ".vp-setup-complete"), "") $pointer = "vite-plus-shim-v1`nlayout=split`ndata=$data`ncache=$cache`n" [System.IO.File]::WriteAllText((Join-Path $bin "vp.shim"), $pointer) @@ -1543,7 +1563,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = "Stop" - $root = Join-Path $env:RUNNER_TEMP "vp-complete-overrides" + $root = Join-Path $env:RUNNER_TEMP "vp-complete-overrides 'quoted'" $env:USERPROFILE = Join-Path $root "profile" $env:LOCALAPPDATA = Join-Path $env:USERPROFILE "AppData\Local" $env:APPDATA = Join-Path $env:USERPROFILE "AppData\Roaming" @@ -1559,16 +1579,14 @@ jobs: New-Item -ItemType Directory -Force -Path $root | Out-Null New-Item -ItemType File -Force -Path $env:VP_LOCAL_TGZ | Out-Null - $output = (& ./packages/cli/install.ps1 *>&1) | Out-String + $output = (. ./packages/cli/install.ps1 *>&1) | Out-String Write-Host $output - if (-not $output.Contains("Install locations:")) { - throw "install.ps1 did not print install locations" - } - if (-not $output.Contains("Data directory: $($env:VP_DATA_DIR)")) { - throw "install.ps1 did not print the split data directory" + if (-not $output.Contains("Vite+ setup complete.")) { + throw "install.ps1 did not complete self-setup" } - if (-not $output.Contains("Bin directory: $($env:VP_BIN_DIR)")) { - throw "install.ps1 did not print the split bin directory" + + if ($script:ShimDir -ne $env:VP_BIN_DIR) { + throw "The sourced installer did not report its bin directory" } function Get-DirMap([string]$VpBinary) { @@ -1588,6 +1606,13 @@ jobs: $payload = Join-Path $env:VP_DATA_DIR "current\bin\vp.exe" $trampoline = Join-Path $env:VP_DATA_DIR "bin\vp.exe" $expected = Get-DirMap $payload + $reported = @{ + data = $script:InstallDir; bin = $script:ShimDir; cache = $script:CacheDir + config = $script:ConfigDir; state = $script:StateDir + } + foreach ($key in $reported.Keys) { + if ($reported[$key] -ne $expected[$key]) { throw "Installer reported an incorrect $key directory" } + } if ($expected['layout'] -ne 'split') { throw "payload did not select split layout" } if ($expected['bin'] -ne (Join-Path $env:VP_DATA_DIR 'bin')) { throw "unexpected bin root" } if ($expected['cache'] -ne $env:VP_CACHE_DIR) { throw "unexpected cache root" } @@ -1751,10 +1776,11 @@ jobs: } & $vp --version - - name: Start local preview registry for vp-setup.exe + - name: Start local registry for vp-setup.exe shell: bash run: | - test_version="0.0.0-commit.${GITHUB_SHA}" + # Self-setup installs the version compiled into the downloaded binary. + test_version=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/vp_global_cli/Cargo.toml | head -1) registry_root="$RUNNER_TEMP/vp-setup-local-registry" packages_dir="$registry_root/packages" main_dir="$registry_root/vite-plus" @@ -1817,45 +1843,68 @@ jobs: throw "the dangling current entry is not a reparse point" } - - name: Repair the dangling junction without ANSI output + - name: Repair the dangling junction and forward setup options shell: pwsh run: | $ErrorActionPreference = "Stop" $installer = Join-Path $env:DEV_DRIVE "target/release/vp-setup.exe" - $stdout = Join-Path $env:RUNNER_TEMP "vp-setup-no-color.stdout.txt" - $stderr = Join-Path $env:RUNNER_TEMP "vp-setup-no-color.stderr.txt" - $env:NO_COLOR = "1" - try { - $process = Start-Process -FilePath $installer ` - -ArgumentList @( - "--yes", - "--version", $env:VP_SETUP_TEST_VERSION, - "--registry", $env:VP_SETUP_TEST_REGISTRY, - "--no-node-manager", - "--no-modify-path" - ) ` - -RedirectStandardOutput $stdout -RedirectStandardError $stderr ` - -Wait -PassThru -NoNewWindow - } finally { - Remove-Item Env:NO_COLOR -ErrorAction SilentlyContinue - } + $stdout = Join-Path $env:RUNNER_TEMP "vp-setup.stdout.txt" + $stderr = Join-Path $env:RUNNER_TEMP "vp-setup.stderr.txt" + $pathBefore = [Environment]::GetEnvironmentVariable("Path", "User") + $process = Start-Process -FilePath $installer ` + -ArgumentList @( + "--yes", + "--version", $env:VP_SETUP_TEST_VERSION, + "--registry", $env:VP_SETUP_TEST_REGISTRY, + "--no-node-manager", + "--no-modify-path" + ) ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr ` + -Wait -PassThru -NoNewWindow Get-Content -Raw $stdout | Write-Host Get-Content -Raw $stderr | Write-Host if ($process.ExitCode -ne 0) { throw "vp-setup.exe exited with $($process.ExitCode)" } - foreach ($path in @($stdout, $stderr)) { - if ([System.IO.File]::ReadAllBytes($path) -contains [byte]0x1b) { - throw "$path contains an ANSI escape" - } - } + # NO_COLOR across the target CLI is deferred to a separate change. if (-not (Test-Path (Join-Path $env:VP_HOME "current/bin/vp.exe"))) { throw "vp-setup.exe did not repair the dangling current junction" } if (-not (Test-Path (Join-Path $env:VP_HOME "bin/vp.exe"))) { throw "vp-setup.exe did not create the global vp.exe" } + if (-not (Test-Path (Join-Path $env:VP_HOME "current/bin/.vp-setup-complete"))) { + throw "vp-setup.exe did not complete the binary's self-setup" + } + if ([Environment]::GetEnvironmentVariable("Path", "User") -cne $pathBefore) { + throw "--no-modify-path changed User PATH" + } + $config = Get-Content (Join-Path $env:VP_HOME "config.json") -Raw | ConvertFrom-Json + if ($config.nodeShimMode -ne "system_first") { + throw "--no-node-manager was not passed to self-setup" + } + + - name: Quiet same-version installation uses self-setup + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $installer = Join-Path $env:DEV_DRIVE "target/release/vp-setup.exe" + $previous = (Get-Item (Join-Path $env:VP_HOME "current")).Target + $output = & $installer --quiet --version $env:VP_SETUP_TEST_VERSION ` + --registry $env:VP_SETUP_TEST_REGISTRY --no-node-manager --no-modify-path 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Quiet setup failed: $output" + } + if ($output) { + throw "Quiet setup unexpectedly printed output: $output" + } + if (-not (Test-Path (Join-Path $env:VP_HOME "current/bin/.vp-setup-complete"))) { + throw "Quiet setup did not finish" + } + if ((Get-Item (Join-Path $env:VP_HOME "current")).Target -eq $previous) { + throw "Same-version setup did not deploy a fresh binary" + } - name: Set PATH shell: bash diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml index 280c43ede5..25a5e936d3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml @@ -6,6 +6,7 @@ steps = [ { argv = ["vpt", "mkdir", "-p", "external", "home"], comment = "Prepare isolated external install and VP_HOME", snapshot = false }, { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], comment = "Simulate a Homebrew-style vp outside VP_HOME", snapshot = false }, { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, + { argv = ["vpt", "touch-file", "external/.vp-setup-complete"], comment = "The external package manager has already set up this binary", snapshot = false }, { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], comment = "Project Node.js version", snapshot = false }, { argv = ["vpt", "write-file", "home/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho vp-managed-node-22.18.0\n"], comment = "Preinstall managed Node runtime", snapshot = false }, { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/node"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md index 3d2caaf3cd..915816c55c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md @@ -13,6 +13,11 @@ Simulate a Homebrew-style vp outside VP_HOME ## `vpt chmod +x external/vp` +## `vpt touch-file external/.vp-setup-complete` + +The external package manager has already set up this binary + + ## `vpt write-file .node-version '22.18.0 '` 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 new file mode 100644 index 0000000000..0e5a967dc0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots.toml @@ -0,0 +1,57 @@ +[[case]] +name = "command_self_setup" +vp = "global" +steps = [ + { argv = ["vpt", "rm", "$VP_HOME/current/bin/.vp-setup-complete"], snapshot = false }, + { argv = ["vp", "--help"], envs = [["VP_SELF_SETUP_SUPPORT_CHECK", "1"]], comment = "The capability probe does not perform setup" }, + ["vpt", "stat-file", "$VP_HOME/current/bin/.vp-setup-complete", "--assert", "missing"], + { argv = ["vp", "not-a-command"], comment = "An unmarked deployed binary consumes the invocation as setup, without parsing the command", snapshot = false }, + ["vpt", "stat-file", "$VP_HOME/current/bin/.vp-setup-complete", "--assert", "file"], + { argv = ["vp", "not-a-command"], comment = "Once marked, the binary dispatches commands normally", continue-on-failure = true }, +] + +[[case]] +name = "command_self_setup_retry" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "rm", "$VP_HOME/current/bin/.vp-setup-complete", "$VP_HOME/env"], snapshot = false }, + { argv = ["vpt", "mkdir", "$VP_HOME/env"], snapshot = false }, + { argv = ["vp", "env", "setup", "--refresh"], comment = "A failed setup does not mark the deployed binary as complete", continue-on-failure = true }, + ["vpt", "stat-file", "$VP_HOME/current/bin/.vp-setup-complete", "--assert", "missing"], + { argv = ["vpt", "rm", "-r", "$VP_HOME/env"], snapshot = false }, + { argv = ["vp", "env", "setup", "--refresh"], comment = "The same upgrade handoff retries setup after the failure is repaired", snapshot = false }, + ["vpt", "stat-file", "$VP_HOME/current/bin/.vp-setup-complete", "--assert", "file"], +] + +[[case]] +name = "command_self_setup_bootstrap_options" +vp = "global" +skip-platforms = ["windows"] +steps = [ + { argv = ["vpt", "mkdir", "-p", "external", "home", "user"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, + { argv = ["vpt", "write-file", "user/.bashrc", "# Existing shell configuration\n"], snapshot = false }, + { argv = ["./external/vp"], tty = false, envs = [["HOME", "${workspace}/user"], ["VP_HOME", "${workspace}/home"], ["VP_SKIP_DEPS_INSTALL", "1"], ["VP_VERSION", "bootstrap-test"], ["VP_NODE_MANAGER", "no"], ["VP_SELF_SETUP_NO_MODIFY_PATH", "1"]], comment = "A piped standalone binary installs with the bootstrap's choices", snapshot = false }, + ["vpt", "stat-file", "home/current/bin/.vp-setup-complete", "--assert", "file"], + ["vpt", "print-file", "home/config.json"], + ["vpt", "print-file", "user/.bashrc"], + ["vpt", "stat-file", "user/.zshenv", "--assert", "missing"], +] + +[[case]] +name = "command_self_setup_shell_warning" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [ + { argv = ["vpt", "mkdir", "-p", "external", "home", "user/.bashrc"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, + { argv = ["bash", "-c", "./external/vp > setup.log 2>&1"], envs = [["HOME", "${workspace}/user"], ["VP_HOME", "${workspace}/home"], ["VP_SKIP_DEPS_INSTALL", "1"], ["VP_VERSION", "bootstrap-test"], ["VP_NODE_MANAGER", "no"]], comment = "An unreadable shell profile warns without preventing installation", snapshot = false }, + ["vpt", "grep-file", "setup.log", "Could not configure shell profiles"], + ["vpt", "stat-file", "home/current/bin/.vp-setup-complete", "--assert", "file"], + ["vpt", "stat-file", "user/.bashrc", "--assert", "dir"], + { argv = ["./home/bin/vp", "--help"], envs = [["VP_HOME", "${workspace}/home"]], comment = "The installed CLI accepts commands after the warning", snapshot = false }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup.md new file mode 100644 index 0000000000..e00046f78c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup.md @@ -0,0 +1,41 @@ +# command_self_setup + +## `vpt rm $VP_HOME/current/bin/.vp-setup-complete` + + +## `VP_SELF_SETUP_SUPPORT_CHECK=1 vp --help` + +The capability probe does not perform setup + +``` +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 +``` + +## `vp not-a-command` + +An unmarked deployed binary consumes the invocation as setup, without parsing the command + + +## `vpt stat-file $VP_HOME/current/bin/.vp-setup-complete --assert file` + +``` +/.vite-plus/current/bin/.vp-setup-complete: file +``` + +## `vp not-a-command` + +Once marked, the binary dispatches commands normally + +**Exit code:** 2 + +``` +VITE+ - The Unified Toolchain for the Web + +error: Command 'not-a-command' not found +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_bootstrap_options.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_bootstrap_options.md new file mode 100644 index 0000000000..5995aeac43 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_bootstrap_options.md @@ -0,0 +1,51 @@ +# command_self_setup_bootstrap_options + +## `vpt mkdir -p external home user` + + +## `vpt cp $VP_HOME/bin/vp external/vp` + + +## `vpt chmod +x external/vp` + + +## `vpt write-file user/.bashrc '# Existing shell configuration +'` + + +## `HOME=${workspace}/user VP_HOME=${workspace}/home VP_SKIP_DEPS_INSTALL=1 VP_VERSION=bootstrap-test VP_NODE_MANAGER=no VP_SELF_SETUP_NO_MODIFY_PATH=1 ./external/vp` + +A piped standalone binary installs with the bootstrap's choices + + +## `vpt stat-file home/current/bin/.vp-setup-complete --assert file` + +``` +home/current/bin/.vp-setup-complete: file +``` + +## `vpt print-file home/config.json` + +``` +{ + "nodeShimMode": "system_first", + "packageManagerShimModes": { + "bun": "system_first", + "npm": "system_first", + "pnpm": "system_first", + "yarn": "system_first" + } +} +``` + +## `vpt print-file user/.bashrc` + +``` +# Existing shell configuration +``` + +## `vpt stat-file user/.zshenv --assert missing` + +``` +user/.zshenv: missing +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_retry.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_retry.md new file mode 100644 index 0000000000..db898a8042 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_retry.md @@ -0,0 +1,37 @@ +# command_self_setup_retry + +## `vpt rm $VP_HOME/current/bin/.vp-setup-complete $VP_HOME/env` + + +## `vpt mkdir $VP_HOME/env` + + +## `vp env setup --refresh` + +A failed setup does not mark the deployed binary as complete + +**Exit code:** 1 + +``` +error: Command execution failed: Is a directory (os error 21) +``` + +## `vpt stat-file $VP_HOME/current/bin/.vp-setup-complete --assert missing` + +``` +/.vite-plus/current/bin/.vp-setup-complete: missing +``` + +## `vpt rm -r $VP_HOME/env` + + +## `vp env setup --refresh` + +The same upgrade handoff retries setup after the failure is repaired + + +## `vpt stat-file $VP_HOME/current/bin/.vp-setup-complete --assert file` + +``` +/.vite-plus/current/bin/.vp-setup-complete: file +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_shell_warning.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_shell_warning.md new file mode 100644 index 0000000000..c1ea1a1c16 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup/snapshots/command_self_setup_shell_warning.md @@ -0,0 +1,38 @@ +# command_self_setup_shell_warning + +## `vpt mkdir -p external home user/.bashrc` + + +## `vpt cp $VP_HOME/bin/vp external/vp` + + +## `vpt chmod +x external/vp` + + +## `HOME=${workspace}/user VP_HOME=${workspace}/home VP_SKIP_DEPS_INSTALL=1 VP_VERSION=bootstrap-test VP_NODE_MANAGER=no bash -c './external/vp > setup.log 2>&1'` + +An unreadable shell profile warns without preventing installation + + +## `vpt grep-file setup.log 'Could not configure shell profiles'` + +``` +setup.log: found "Could not configure shell profiles" +``` + +## `vpt stat-file home/current/bin/.vp-setup-complete --assert file` + +``` +home/current/bin/.vp-setup-complete: file +``` + +## `vpt stat-file user/.bashrc --assert dir` + +``` +user/.bashrc: dir +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp --help` + +The installed CLI accepts commands after the warning + diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 5206c24fda..77add589f3 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -19,6 +19,7 @@ use std::process::ExitStatus; #[cfg(windows)] use indoc::formatdoc; +use vp_shared::output; use crate::{ commands::{ @@ -54,6 +55,16 @@ impl EnvShell { /// Execute the setup command. pub async fn execute(refresh: bool, env_only: bool) -> Result { + execute_for_binary(&std::env::current_exe()?, refresh, refresh, env_only).await +} + +// Self-setup must create shims for the deployed binary, not the temporary download. +pub(crate) async fn execute_for_binary( + current_exe: &std::path::Path, + refresh: bool, + refresh_entrypoints: bool, + env_only: bool, +) -> Result { let config = vp_shared::EnvConfig::get(); let dirs = &config.dirs; @@ -64,17 +75,17 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result create_env_files().await?; if env_only { - println!("{}", help::render_heading("Setup")); - println!(" Updated shell environment files."); - println!(" Run {} to verify setup.", help::accent_command("vp env doctor")); + output::raw(&help::render_heading("Setup")); + output::raw(" Updated shell environment files."); + output::raw(&format!(" Run {} to verify setup.", help::accent_command("vp env doctor"))); return Ok(ExitStatus::default()); } let bin_dir = &dirs.bin; - println!("{}", help::render_heading("Setup")); - println!(" Preparing vite-plus environment."); - println!(); + output::raw(&help::render_heading("Setup")); + output::raw(" Preparing vite-plus environment."); + output::raw(""); // Ensure bin directory exists tokio::fs::create_dir_all(bin_dir).await?; @@ -86,18 +97,17 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result #[cfg(windows)] tokio::fs::write(bin_dir.join("vp-use.cmd"), vp_use_cmd_content(&config)).await?; - // Get the current executable path (for shims) - let current_exe = std::env::current_exe()?; - // Create wrapper script in bin/ - setup_vp_wrapper(¤t_exe, bin_dir, refresh).await?; + setup_vp_wrapper(current_exe, bin_dir, refresh_entrypoints).await?; // Create default tool shims let mut created = Vec::new(); let mut skipped = Vec::new(); for tool in crate::shim::DEFAULT_SHIM_TOOLS { - let result = create_shim(¤t_exe, bin_dir, tool, refresh).await?; + let refresh_tool = + if matches!(*tool, "vpx" | "vpr") { refresh_entrypoints } else { refresh }; + let result = create_shim(current_exe, bin_dir, tool, refresh_tool).await?; if result { created.push(*tool); } else { @@ -122,40 +132,40 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result #[cfg(windows)] if refresh { - if let Err(e) = refresh_package_shims(bin_dir).await { + if let Err(e) = refresh_package_shims(current_exe, bin_dir).await { tracing::warn!("Failed to refresh package shims: {}", e); } } // Best-effort cleanup of .old files from rename-before-copy on Windows #[cfg(windows)] - if refresh { + if refresh || refresh_entrypoints { cleanup_old_files(bin_dir).await; } // Print results if !created.is_empty() { - println!("{}", help::render_heading("Created Shims")); + output::raw(&help::render_heading("Created Shims")); for tool in &created { let shim_path = bin_dir.join(shim_filename(tool)); - println!(" {}", shim_path.as_path().display()); + output::raw(&format!(" {}", shim_path.as_path().display())); } } if !skipped.is_empty() && !refresh { if !created.is_empty() { - println!(); + output::raw(""); } - println!("{}", help::render_heading("Skipped Shims")); + output::raw(&help::render_heading("Skipped Shims")); for tool in &skipped { let shim_path = bin_dir.join(shim_filename(tool)); - println!(" {}", shim_path.as_path().display()); + output::raw(&format!(" {}", shim_path.as_path().display())); } - println!(); - println!(" Use --refresh to update existing shims."); + output::raw(""); + output::raw(" Use --refresh to update existing shims."); } - println!(); + output::raw(""); print_path_instructions(&dirs.config); Ok(ExitStatus::default()) @@ -250,14 +260,13 @@ async fn setup_vp_wrapper( #[cfg(windows)] { - let _ = current_exe; let bin_vp_exe = bin_dir.join("vp.exe"); // Create trampoline bin/vp.exe that forwards to current\bin\vp.exe let should_create = refresh || !tokio::fs::try_exists(&bin_vp_exe).await.unwrap_or(false); if should_create { - let trampoline_src = get_trampoline_path()?; + let trampoline_src = trampoline_path_for_binary(current_exe)?; // On refresh, the existing vp.exe may still be running (the trampoline // that launched us). Windows prevents overwriting a running exe, so we // rename it to a timestamped .old file first, then copy the new one. @@ -395,11 +404,11 @@ async fn create_unix_shim( /// See: #[cfg(windows)] async fn create_windows_shim( - _source: &std::path::Path, + source: &std::path::Path, bin_dir: &vt_path::AbsolutePath, tool: &str, ) -> Result<(), Error> { - let trampoline_src = get_trampoline_path()?; + let trampoline_src = trampoline_path_for_binary(source)?; let shim_path = bin_dir.join(format!("{tool}.exe")); tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?; write_shim_pointer_beside(shim_path.as_path()); @@ -417,7 +426,10 @@ async fn create_windows_shim( /// Discovers all package binaries tracked by BinConfig with `source: Vp` /// and replaces their `.exe` with the current trampoline. #[cfg(windows)] -async fn refresh_package_shims(bin_dir: &vt_path::AbsolutePath) -> Result<(), Error> { +async fn refresh_package_shims( + current_exe: &std::path::Path, + bin_dir: &vt_path::AbsolutePath, +) -> Result<(), Error> { use super::bin_config::BinConfig; let package_bins = BinConfig::find_all_vp_source().await?; @@ -426,7 +438,7 @@ async fn refresh_package_shims(bin_dir: &vt_path::AbsolutePath) -> Result<(), Er return Ok(()); } - let trampoline_src = get_trampoline_path()?; + let trampoline_src = trampoline_path_for_binary(current_exe)?; for bin_name in &package_bins { // Default shims and vp are already refreshed by the main loop. @@ -467,6 +479,13 @@ fn write_shim_pointer_beside(exe_path: &std::path::Path) { /// In tests, `VP_TRAMPOLINE_PATH` can override the resolved path. #[cfg(windows)] pub(crate) fn get_trampoline_path() -> Result { + trampoline_path_for_binary(&std::env::current_exe()?) +} + +#[cfg(windows)] +fn trampoline_path_for_binary( + current_exe: &std::path::Path, +) -> Result { // Allow tests to override the trampoline path if let Ok(override_path) = std::env::var(vp_shared::env_vars::VP_TRAMPOLINE_PATH) { let path = std::path::PathBuf::from(override_path); @@ -476,7 +495,6 @@ pub(crate) fn get_trampoline_path() -> Result { } } - let current_exe = std::env::current_exe()?; let bin_dir = current_exe .parent() .ok_or_else(|| Error::Other("Cannot find parent directory of vp.exe".into()))?; @@ -943,12 +961,12 @@ fn render_nu_path_ref(path_ref: &str) -> String { } /// Escape a value for a POSIX-shell double-quoted string. -pub(super) fn escape_posix_double_quoted_string(value: &str) -> String { +pub(crate) fn escape_posix_double_quoted_string(value: &str) -> String { value.replace('\\', "\\\\").replace('$', "\\$").replace('`', "\\`").replace('"', "\\\"") } /// Escape a value for a Fish double-quoted string. -pub(super) fn escape_fish_double_quoted_string(value: &str) -> String { +pub(crate) fn escape_fish_double_quoted_string(value: &str) -> String { value.replace('\\', "\\\\").replace('$', "\\$").replace('"', "\\\"") } @@ -964,13 +982,13 @@ fn escape_home_relative_double_quoted_path(path_ref: &str, escape: fn(&str) -> S /// /// Example: `vp "home\with spaces"` → `vp \"home\\with spaces\"` /// https://www.nushell.sh/book/working_with_strings.html#double-quoted-strings -pub(super) fn escape_nu_double_quoted_string(value: &str) -> String { +pub(crate) fn escape_nu_double_quoted_string(value: &str) -> String { // `vp "home\with spaces"` → `vp \"home\\with spaces\"` value.replace('\\', "\\\\").replace('"', "\\\"") } /// Escape a value for a PowerShell single-quoted string. -pub(super) fn escape_powershell_single_quoted_string(value: &str) -> String { +pub(crate) fn escape_powershell_single_quoted_string(value: &str) -> String { value.replace('\'', "''") } @@ -1103,45 +1121,45 @@ fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { (env_path.clone(), env_path) }; - println!("{}", help::render_heading("Next Steps")); - println!(" Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):"); - println!(); - println!(" . \"{env_path}/env\""); - println!(); - println!(" For fish shell, add to ~/.config/fish/config.fish:"); - println!(); - println!(" source \"{env_path}/env.fish\""); - println!(); - println!(" For Nushell, add to ~/.config/nushell/config.nu:"); - println!(); - println!(" source '{nu_env_path}/env.nu'"); - println!(); - println!(" For PowerShell, add to your $PROFILE:"); - println!(); - println!(" . \"{env_path}/env.ps1\""); - println!(); - println!(" For IDE support (VS Code, Cursor), ensure bin directory is in system PATH:"); + output::raw(&help::render_heading("Next Steps")); + output::raw(" Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):"); + output::raw(""); + output::raw(&format!(" . \"{env_path}/env\"")); + output::raw(""); + output::raw(" For fish shell, add to ~/.config/fish/config.fish:"); + output::raw(""); + output::raw(&format!(" source \"{env_path}/env.fish\"")); + output::raw(""); + output::raw(" For Nushell, add to ~/.config/nushell/config.nu:"); + output::raw(""); + output::raw(&format!(" source '{nu_env_path}/env.nu'")); + output::raw(""); + output::raw(" For PowerShell, add to your $PROFILE:"); + output::raw(""); + output::raw(&format!(" . \"{env_path}/env.ps1\"")); + output::raw(""); + output::raw(" For IDE support (VS Code, Cursor), ensure bin directory is in system PATH:"); #[cfg(target_os = "macos")] { - println!(" - macOS: Add to ~/.profile or use launchd"); + output::raw(" - macOS: Add to ~/.profile or use launchd"); } #[cfg(target_os = "linux")] { - println!(" - Linux: Add to ~/.profile for display manager integration"); + output::raw(" - Linux: Add to ~/.profile for display manager integration"); } #[cfg(target_os = "windows")] { - println!(" - Windows: System Properties -> Environment Variables -> Path"); + output::raw(" - Windows: System Properties -> Environment Variables -> Path"); } - println!(); - println!( + output::raw(""); + output::raw(&format!( " Restart your terminal and IDE, then run {} to verify.", help::accent_command("vp env doctor") - ); + )); } #[cfg(test)] @@ -1788,6 +1806,27 @@ mod tests { !pointer.exists(), "setup without --refresh must not claim a skipped executable" ); + + // Reusing a bin directory must refresh Vite+ entrypoints without replacing Node. + for tool in ["vp", "vpx", "vpr"] { + tokio::fs::write(bin_dir.join(format!("{tool}.exe")), b"old-entrypoint") + .await + .unwrap(); + tokio::fs::write(bin_dir.join(format!("{tool}.shim")), b"old-data-root") + .await + .unwrap(); + } + execute_for_binary(&std::env::current_exe().unwrap(), false, true, false) + .await + .unwrap(); + let dirs = &vp_shared::EnvConfig::get().dirs; + for tool in ["vp", "vpx", "vpr"] { + let entrypoint = bin_dir.join(format!("{tool}.exe")); + assert_eq!(tokio::fs::read(&entrypoint).await.unwrap(), b"fake-trampoline"); + assert!(dirs.owns_windows_trampoline(&entrypoint)); + } + assert_eq!(tokio::fs::read(&node).await.unwrap(), b"foreign-node"); + assert!(!pointer.exists()); }, ) .await; diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index 11a43f7873..4748e62174 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -205,6 +205,8 @@ async fn install_platform_and_main( let previous_version = install::save_previous_version(install_dir).await?; tracing::debug!("Previous version: {:?}", previous_version); + install::clear_self_setup_marker(version_dir).await?; + // Swap current link — POINT OF NO RETURN install::swap_current_link(install_dir, install_dir_name).await?; diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index bad8e805a7..b5cf081f26 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -19,6 +19,7 @@ mod commands; mod error; mod help; mod js_executor; +mod self_setup; mod shim; mod upgrade_check; @@ -364,6 +365,12 @@ fn dump_dirs_from_env_config() -> bool { #[tokio::main] async fn main() -> ExitCode { + // Probe before tracing, directory resolution, or argument dispatch can emit output. + if env::var_os(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK).is_some() { + println!("vite-plus-self-setup-v1"); + return ExitCode::SUCCESS; + } + #[cfg(windows)] if let Some(code) = commands::implode::maybe_run_deferred_delete_helper(std::env::args_os()) { return ExitCode::from(code); @@ -374,10 +381,20 @@ async fn main() -> ExitCode { // Initialize tracing vp_shared::init_tracing(); + // Internal directory queries are also used by the local installer before deployment. if dump_dirs_from_env_config() { return ExitCode::SUCCESS; } + match self_setup::maybe_run().await { + Ok(true) => return ExitCode::SUCCESS, + Ok(false) => {} + Err(error) => { + output::error(&error.to_string()); + return ExitCode::FAILURE; + } + } + let mut args: Vec = std::env::args().collect(); // Replace bash completion script to fix completion for items containing ':' diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs new file mode 100644 index 0000000000..ed2d86b133 --- /dev/null +++ b/crates/vp_global_cli/src/self_setup.rs @@ -0,0 +1,375 @@ +//! First-start installation. A completed binary accepts commands; an unmarked one only sets up. + +mod shell; + +use std::path::Path; + +use dialoguer::{Confirm, theme::ColorfulTheme}; +use vp_setup::{SELF_SETUP_MARKER, VP_BINARY_NAME, install}; +use vp_shared::{EnvConfig, env_vars, output}; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use crate::{ + commands::env::{config, setup}, + error::Error, +}; + +pub(crate) async fn maybe_run() -> Result { + let shell = std::env::var(env_vars::VP_SELF_SETUP_SHELL).ok(); + if let Some(shell) = shell.as_deref() { + if !matches!(shell, "sh" | "powershell") { + return Err(Error::Other("VP_SELF_SETUP_SHELL must be sh or powershell".into())); + } + vp_shared::validate_vp_dir_env().map_err(|error| Error::Other(error.to_string().into()))?; + output::route_user_output_to_stderr(); + } + let binary = std::env::current_exe()?; + // macOS can return the invoking symlink; its parent is the shared shim directory. + #[cfg(target_os = "macos")] + let binary = std::fs::canonicalize(binary)?; + let bin = binary.parent().ok_or(Error::CliBinaryNotFound)?; + // Once the binary path is resolved, normal commands only check marker existence. + if bin.join(SELF_SETUP_MARKER).try_exists()? { + if let Some(shell) = shell.as_deref() { + print_shell_result(shell); + return Ok(true); + } + return Ok(false); + } + + vp_shared::validate_vp_dir_env().map_err(|error| Error::Other(error.to_string().into()))?; + run(&binary).await?; + if let Some(shell) = shell.as_deref() { + print_shell_result(shell); + } + Ok(true) +} + +// Only successful setup emits executable output; logs use stderr in this mode. +fn print_shell_result(shell: &str) { + let dirs = &EnvConfig::get().dirs; + for (sh_name, powershell_name, path) in [ + ("INSTALL_DIR", "InstallDir", &dirs.data), + ("SHIM_DIR", "ShimDir", &dirs.bin), + ("CACHE_DIR", "CacheDir", &dirs.cache), + ("CONFIG_DIR", "ConfigDir", &dirs.config), + ("STATE_DIR", "StateDir", &dirs.state), + ] { + let value = path.to_string(); + if shell == "powershell" { + println!( + "$script:{powershell_name} = '{}'", + setup::escape_powershell_single_quoted_string(&value) + ); + } else { + println!("{sh_name}=\"{}\"", setup::escape_posix_double_quoted_string(&value)); + } + } +} + +/// Setup Vite+ for the first run +async fn run(source: &Path) -> Result<(), Error> { + let env = EnvConfig::get(); + let dirs = &env.dirs; + let active_binary = dirs.data.join("current").join("bin").join(VP_BINARY_NAME); + let in_place = same_file::is_same_file(source, active_binary.as_path()).unwrap_or(false); + #[cfg(windows)] + if !in_place + && ["vp.exe", "vpx.exe", "vpr.exe"] + .iter() + .any(|name| std::fs::symlink_metadata(dirs.bin.join(name)).is_ok()) + && !env.is_ci + && !confirm( + &format!("Replace existing Vite+ commands in {}?", dirs.bin.as_path().display()), + false, + )? + { + return Err(Error::Other( + "Installation cancelled; existing Vite+ commands were kept.".into(), + )); + } + let previous_install = previous_install()?; + let node_manager = if in_place { NodeManager::Refresh } else { node_manager()? }; + let version = env!("CARGO_PKG_VERSION"); + let registry = std::env::var(env_vars::NPM_CONFIG_REGISTRY_UPPER) + .or_else(|_| std::env::var(env_vars::NPM_CONFIG_REGISTRY)) + .ok() + .filter(|value| !value.is_empty()) + .or_else(|| { + version + .starts_with("0.0.0-commit.") + .then(|| "https://registry-bridge.viteplus.dev/".to_string()) + }); + let registry = registry.as_deref(); + // The local bootstrap provisions JS dependencies itself after this invocation. + let skip_deps = std::env::var_os("VP_SKIP_DEPS_INSTALL").is_some_and(|value| !value.is_empty()); + let local_version = skip_deps.then(|| std::env::var("VP_VERSION").ok()).flatten(); + let install_version = local_version.as_deref().unwrap_or(version); + if !in_place + && (install_version.is_empty() + || Path::new(install_version).components().count() != 1 + || !matches!( + Path::new(install_version).components().next(), + Some(std::path::Component::Normal(_)) + )) + { + return Err(Error::Other("Invalid local installation version".into())); + } + + // 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 in_place { + AbsolutePathBuf::new( + source.parent().and_then(Path::parent).ok_or(Error::CliBinaryNotFound)?.to_path_buf(), + ) + .ok_or(Error::CliBinaryNotFound)? + } else { + let name = + install::target_install_dir_name(install_version, previous_version.as_deref(), true); + dirs.data.join(name) + }; + let binary = version_dir.join("bin").join(VP_BINARY_NAME); + if !in_place { + tokio::fs::create_dir_all(version_dir.join("bin")).await?; + install::clear_self_setup_marker(&version_dir).await?; + if !same_file::is_same_file(source, binary.as_path()).unwrap_or(false) { + tokio::fs::copy(source, &binary).await?; + } + } + if !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file() { + output::info(&format!("installing vite-plus@{version}...")); + install::generate_wrapper_package_json(&version_dir, version).await?; + if !skip_deps { + install::install_production_deps(&version_dir, registry, !interactive(), version) + .await?; + } + } + #[cfg(windows)] + if !version_dir.join("bin/vp-shim.exe").as_path().is_file() { + let sibling = source.with_file_name("vp-shim.exe"); + if sibling.is_file() { + tokio::fs::copy(sibling, version_dir.join("bin/vp-shim.exe")).await?; + } else { + // The standalone Windows executable needs its companion trampoline, which is not a JS dependency. + let suffix = vp_setup::platform::detect_platform_suffix()?; + let resolved = + vp_setup::registry::resolve_platform_package(version, &suffix, registry).await?; + let data = + vp_pm_cli::HttpClient::new().get_bytes(&resolved.platform_tarball_url).await?; + vp_setup::integrity::verify_integrity(&data, &resolved.platform_integrity)?; + let temporary = tempfile::tempdir()?; + let temporary_dir = AbsolutePathBuf::new(temporary.path().to_path_buf()) + .ok_or(Error::CliBinaryNotFound)?; + install::extract_platform_package(&data, &temporary_dir).await?; + tokio::fs::copy( + temporary_dir.join("bin/vp-shim.exe"), + version_dir.join("bin/vp-shim.exe"), + ) + .await?; + } + } + + if !in_place { + // Prepare the payload first, then let the old uninstaller clean its shell entries before writing ours. + remove_previous_install(previous_install.as_deref()).await?; + if std::env::var(env_vars::VP_SELF_SETUP_NO_MODIFY_PATH).as_deref() != Ok("1") { + if let Err(error) = shell::configure().await { + output::warn(&format!( + "Could not configure shell profiles: {error}. Add {} to PATH manually.", + dirs.bin.as_path().display() + )); + } + } + } + let mode = match node_manager { + NodeManager::Enable => Some(config::ShimMode::Managed), + NodeManager::SystemFirst => Some(config::ShimMode::SystemFirst), + NodeManager::Refresh => None, + }; + if let Some(mode) = mode { + let mut settings = config::load_config().await?; + settings.set_shim_modes(true, true, mode); + config::save_config(&settings).await?; + } + + // 2. Activate a standalone download; an upgrade hook must not overwrite rollback history. + if !in_place { + install::save_previous_version(&dirs.data).await?; + let name = version_dir + .as_path() + .file_name() + .and_then(|name| name.to_str()) + .ok_or(Error::CliBinaryNotFound)?; + install::swap_current_link(&dirs.data, name).await?; + } + + // 3. Run setup in this process. Spawning the unmarked binary here would reenter self-setup. + tokio::fs::create_dir_all(&dirs.bin).await?; + // Declining management must preserve foreign executables in a shared bin directory. + let refresh = node_manager != NodeManager::SystemFirst; + // Windows entrypoints must point at this installation even when Node management is declined. + setup::execute_for_binary(binary.as_path(), refresh, cfg!(windows) || refresh, false).await?; + if !in_place { + let name = version_dir + .as_path() + .file_name() + .and_then(|name| name.to_str()) + .ok_or(Error::CliBinaryNotFound)?; + let mut protected = vec![name]; + if let Some(previous) = previous_version.as_deref() { + protected.push(previous); + } + if let Err(error) = + install::cleanup_old_versions(&dirs.data, vp_setup::MAX_VERSIONS_KEEP, &protected).await + { + output::warn(&format!("Old version cleanup failed: {error}")); + } + } + + // A failure above leaves the marker absent so a later launch can retry. + tokio::fs::write(version_dir.join("bin").join(SELF_SETUP_MARKER), b"").await?; + output::success("Vite+ setup complete."); + Ok(()) +} + +fn interactive() -> bool { + std::env::var_os("CI").is_none() && vp_shared::is_stderr_terminal() +} + +fn find_on_path(name: &str) -> Option { + let cwd = vt_path::current_dir().ok()?; + vp_command::resolve_bin(name, None, &cwd).ok() +} + +fn confirm(prompt: &str, default: bool) -> Result { + if !interactive() { + return Ok(false); + } + Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .default(default) + .interact() + .map_err(|error| Error::Other(error.to_string().into())) +} + +#[derive(PartialEq, Eq)] +enum NodeManager { + SystemFirst, + Refresh, + Enable, +} + +fn node_manager() -> Result { + match std::env::var("VP_NODE_MANAGER").as_deref() { + Ok("yes") => return Ok(NodeManager::Enable), + Ok("no") => return Ok(NodeManager::SystemFirst), + _ => {} + } + let dirs = &EnvConfig::get().dirs; + let node = dirs.bin.join(setup::shim_filename("node")); + let exists = std::fs::symlink_metadata(&node).is_ok(); + #[cfg(unix)] + let owned = std::fs::symlink_metadata(&node) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + && same_file::is_same_file(&node, dirs.data.join("current/bin/vp")).unwrap_or(false); + #[cfg(windows)] + let owned = exists && dirs.owns_windows_trampoline(node.as_path()); + if owned { + // Refresh existing shims without undoing a user's `vp env off` preference. + return Ok(NodeManager::Refresh); + } + let automatic = ["CI", "CODESPACES", "REMOTE_CONTAINERS", "DEVPOD"] + .iter() + .any(|name| std::env::var_os(name).is_some()) + || find_on_path("node").is_none(); + if !exists && automatic { + return Ok(NodeManager::Enable); + } + let enable = + confirm("Would you like Vite+ to manage your Node.js and package-manager versions?", true)?; + Ok(if enable { NodeManager::Enable } else { NodeManager::SystemFirst }) +} + +// PATH discovery only offers cleanup after an explicit move; VpDirs remains the authority for the target. +fn previous_install() -> Result, Error> { + let env = EnvConfig::get(); + if !env.dir_envs.contains_key(env_vars::VP_HOME) { + return Ok(None); + } + let Some(vp) = find_on_path("vp") else { return Ok(None) }; + let Some(root) = vp.parent().and_then(|bin| bin.parent()) else { return Ok(None) }; + let root = + AbsolutePathBuf::new(std::fs::canonicalize(root)?).ok_or(Error::CliBinaryNotFound)?; + let target = normalize_target(&env.dirs.data)?; + if root.parent().is_none() + || root == target + || root == env.user_home + || ["/", "/bin", "/opt", "/usr", "/usr/bin", "/usr/local", "/usr/local/bin"] + .iter() + .any(|path| root.as_path() == Path::new(path)) + { + return Ok(None); + } + let has_entrypoint = ["vp", "vp.exe", "vp.cmd"] + .iter() + .any(|name| root.join("bin").join(name).as_path().is_file()); + if !root.join("current").as_path().exists() || !has_entrypoint { + return Ok(None); + } + if root.as_path().starts_with(target.as_path()) || target.as_path().starts_with(root.as_path()) + { + return Err(Error::Other(format!("The previous Vite+ install at {} overlaps with the new installation at {}. Choose a directory that does not overlap.", root.as_path().display(), target.as_path().display()).into())); + } + Ok(Some(root)) +} + +fn normalize_target(path: &AbsolutePath) -> Result { + if path.as_path().exists() { + return AbsolutePathBuf::new(std::fs::canonicalize(path)?).ok_or(Error::CliBinaryNotFound); + } + let parent = path.parent().ok_or(Error::CliBinaryNotFound)?; + let name = path.as_path().file_name().ok_or(Error::CliBinaryNotFound)?; + Ok(normalize_target(parent)?.join(name)) +} + +async fn remove_previous_install(previous: Option<&AbsolutePath>) -> Result<(), Error> { + let Some(previous) = previous else { return Ok(()) }; + if !confirm( + &format!( + "Found a previous Vite+ install at {previous}. Remove the previous install directory?" + ), + false, + )? { + return Ok(()); + } + let binary = previous.join("current/bin").join(VP_BINARY_NAME); + // An unmarked new-style installation may consume the first invocation as setup. + for _ in 0..2 { + let result = tokio::process::Command::new(binary.as_path()) + .args(["implode", "--yes"]) + .env(env_vars::VP_HOME, previous.as_path()) + .output() + .await; + match result { + Ok(result) if result.status.success() => { + if !binary.as_path().exists() { + output::success("Removed previous Vite+ install."); + return Ok(()); + } + } + Ok(result) => { + output::warn(&format!( + "Could not remove previous Vite+ install: {}", + String::from_utf8_lossy(&result.stderr) + )); + return Ok(()); + } + Err(error) => { + output::warn(&format!("Could not remove previous Vite+ install: {error}")); + return Ok(()); + } + } + } + output::warn("The previous Vite+ installation is still present."); + Ok(()) +} diff --git a/crates/vp_global_cli/src/self_setup/shell.rs b/crates/vp_global_cli/src/self_setup/shell.rs new file mode 100644 index 0000000000..9193ee921f --- /dev/null +++ b/crates/vp_global_cli/src/self_setup/shell.rs @@ -0,0 +1,114 @@ +//! Persist shell entrypoints using the same profiles that env doctor and implode inspect. + +use std::io::Write; + +use vp_shared::EnvConfig; + +use crate::{ + commands::{ + env::setup, + shell::{ALL_SHELL_PROFILES, ShellProfileKind, ShellProfileRoot, resolve_profile_path}, + }, + error::Error, +}; + +pub(super) async fn configure() -> Result<(), Error> { + let config = EnvConfig::get(); + for profile in ALL_SHELL_PROFILES { + let shell = match profile.root { + ShellProfileRoot::Zsh => "zsh", + ShellProfileRoot::Home => "bash", + ShellProfileRoot::Fish => "fish", + ShellProfileRoot::NushellConfig => continue, + ShellProfileRoot::NushellData => "nu", + }; + if super::find_on_path(shell).is_none() { + continue; + } + // Fish and Nushell use managed snippets; never rewrite the user's main config. + if shell == "fish" && matches!(profile.kind, ShellProfileKind::Main) { + continue; + } + let mut path = resolve_profile_path(profile, &config.user_home); + if shell == "nu" { + let result = tokio::process::Command::new("nu") + .args(["-c", "$nu.vendor-autoload-dirs | last"]) + .output() + .await?; + if !result.status.success() { + return Err(Error::Other( + "Could not determine Nushell vendor autoload directory".into(), + )); + } + let directory = String::from_utf8_lossy(&result.stdout).trim().to_string(); + path = vt_path::AbsolutePathBuf::new(directory.into()) + .ok_or_else(|| { + Error::Other("Nushell returned a non-absolute autoload directory".into()) + })? + .join("vite-plus.nu"); + } + let env = config.dirs.config.join(profile.env_file).to_string(); + let escaped = match shell { + "fish" => setup::escape_fish_double_quoted_string(&env), + "nu" => setup::escape_nu_double_quoted_string(&env), + _ => setup::escape_posix_double_quoted_string(&env), + }; + let source = if shell == "bash" || shell == "zsh" { "." } else { "source" }; + let line = format!("{source} \"{escaped}\""); + let content = format!("# Vite+ bin (https://viteplus.dev)\n{line}\n"); + match profile.kind { + ShellProfileKind::Snippet => { + let parent = path.parent().ok_or(Error::CliBinaryNotFound)?; + tokio::fs::create_dir_all(parent).await?; + tokio::fs::write(&path, content).await?; + } + ShellProfileKind::Main => { + if !path.as_path().exists() && profile.path != ".zshenv" { + continue; + } + let existing = match tokio::fs::read_to_string(&path).await { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.into()), + }; + let relative = config + .dirs + .config + .as_path() + .strip_prefix(config.user_home.as_path()) + .ok() + .map(|suffix| format!("$HOME/{}/{}", suffix.display(), profile.env_file)); + if existing.contains(&env) + || relative.is_some_and(|reference| existing.contains(&reference)) + { + continue; + } + let parent = path.parent().ok_or(Error::CliBinaryNotFound)?; + tokio::fs::create_dir_all(parent).await?; + let mut file = std::fs::OpenOptions::new().create(true).append(true).open(&path)?; + write!(file, "\n{content}")?; + } + } + } + #[cfg(windows)] + { + let bin = setup::escape_powershell_single_quoted_string(&config.dirs.bin.to_string()); + let script = format!( + "$bin = '{bin}'; $path = [Environment]::GetEnvironmentVariable('Path', 'User'); if (($path -split ';') -notcontains $bin) {{ [Environment]::SetEnvironmentVariable('Path', ($bin + ';' + $path), 'User') }}" + ); + let result = tokio::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .output() + .await?; + if !result.status.success() { + return Err(Error::Other( + format!( + "Could not configure user PATH: {}", + String::from_utf8_lossy(&result.stderr) + ) + .into(), + )); + } + } + Ok(()) +} diff --git a/crates/vp_installer/Cargo.toml b/crates/vp_installer/Cargo.toml index e4051506cc..09de1a1c9b 100644 --- a/crates/vp_installer/Cargo.toml +++ b/crates/vp_installer/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" clap = { workspace = true, features = ["derive"] } console = { workspace = true } indicatif = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } vp_pm_cli = { workspace = true } vt_path = { workspace = true } @@ -26,7 +27,6 @@ which = { workspace = true } winreg = { workspace = true } [dev-dependencies] -tempfile = { workspace = true } vp_shared = { workspace = true, features = ["test-utils"] } [lints] diff --git a/crates/vp_installer/src/legacy.rs b/crates/vp_installer/src/legacy.rs new file mode 100644 index 0000000000..408533ecbd --- /dev/null +++ b/crates/vp_installer/src/legacy.rs @@ -0,0 +1,232 @@ +//! Compatibility installation for binaries without first-start self-setup support. + +use vp_setup::{VP_BINARY_NAME, install}; +use vp_shared::VpDirs; +use vt_path::AbsolutePathBuf; + +#[cfg(windows)] +use super::windows_path; +use super::{cli, print_info, print_warn}; + +pub(super) async fn install( + opts: &cli::Options, + dirs: &VpDirs, + target_version: &str, + platform_data: &[u8], +) -> Result<(), Box> { + let current_version = install::read_current_version(&dirs.data).await; + + // Same version only if the binary is intact — a corrupted install needs a full reinstall. + // `is_install_dir_for_version` also matches `{version}+force.*` dirs left by a forced + // reinstall (`vp upgrade --force`), so re-running setup recognizes them as + // already installed instead of reinstalling. + let same_version = current_version + .as_deref() + .is_some_and(|current| install::is_install_dir_for_version(current, target_version)) + && tokio::fs::try_exists(dirs.data.join("current").join("bin").join(VP_BINARY_NAME)) + .await + .unwrap_or(false); + + if same_version { + if !opts.quiet { + print_info(&format!("version {target_version} already installed, verifying setup...")); + } + } else if let Some(ref current) = current_version { + if !opts.quiet { + print_info(&format!("upgrading from {current} to {target_version}")); + } + } + + if !same_version { + let install_dir = &dirs.data; + let version_dir = install_dir.join(target_version); + tokio::fs::create_dir_all(&version_dir).await?; + + let result = install_new_version( + opts, + platform_data, + &version_dir, + install_dir, + target_version, + current_version.is_some(), + ) + .await; + + // On failure, clean up the partial version directory (matches vp upgrade behavior) + if result.is_err() { + let _ = tokio::fs::remove_dir_all(&version_dir).await; + } + + result?; + } + + // --- Post-activation setup (always runs, even for same-version repair) --- + // All steps below are best-effort: the core install succeeded once `current` + // points at the right version. + + if !opts.quiet { + print_info("setting up shims..."); + } + if let Err(e) = setup_bin_shims(&dirs).await { + print_warn(&format!("Shim setup failed (non-fatal): {e}")); + } + + if !opts.no_node_manager { + if !opts.quiet { + print_info("setting up Node.js and package-manager version management..."); + } + match install::refresh_shims(&dirs.data).await { + Ok(()) if current_version.is_none() => { + let vp_binary = dirs.data.join("current").join("bin").join(VP_BINARY_NAME); + let preference_result = tokio::process::Command::new(vp_binary.as_path()) + .args(["env", "on"]) + .output() + .await; + if !preference_result.is_ok_and(|output| output.status.success()) { + print_warn("Failed to record environment management preference."); + } + } + Ok(()) => {} + Err(e) => { + print_warn(&format!("Node.js and package-manager setup failed (non-fatal): {e}")) + } + } + } else if let Err(e) = install::create_env_files(&dirs.data).await { + print_warn(&format!("Env file creation failed (non-fatal): {e}")); + } + + if !opts.no_modify_path { + let bin_dir_str = dirs.bin.as_path().to_string_lossy().to_string(); + if let Err(e) = modify_path(&bin_dir_str, opts.quiet) { + print_warn(&format!("PATH modification failed (non-fatal): {e}")); + } + } + + Ok(()) +} + +/// Extract, install deps, and activate a new version. Separated so the caller +/// can clean up the version directory on failure. +async fn install_new_version( + opts: &cli::Options, + platform_data: &[u8], + version_dir: &AbsolutePathBuf, + install_dir: &AbsolutePathBuf, + version: &str, + has_previous: bool, +) -> Result<(), Box> { + if !opts.quiet { + print_info("extracting binary..."); + } + install::extract_platform_package(platform_data, version_dir).await?; + + let binary_path = version_dir.join("bin").join(VP_BINARY_NAME); + if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + return Err("Binary not found after extraction. The download may be corrupted.".into()); + } + #[cfg(windows)] + if !tokio::fs::try_exists(version_dir.join("bin").join("vp-shim.exe")).await.unwrap_or(false) { + return Err( + "vp-setup did not find vp-shim.exe after extraction. The downloaded package can be corrupt." + .into(), + ); + } + + install::generate_wrapper_package_json(version_dir, version).await?; + + if !opts.quiet { + print_info("installing dependencies (this may take a moment)..."); + } + install::install_production_deps(version_dir, opts.registry.as_deref(), opts.yes, version) + .await?; + + let previous_version = + if has_previous { install::save_previous_version(install_dir).await? } else { None }; + install::swap_current_link(install_dir, version).await?; + + // Cleanup with both new and previous versions protected (matches vp upgrade) + let mut protected = vec![version]; + if let Some(ref prev) = previous_version { + protected.push(prev.as_str()); + } + if let Err(e) = + install::cleanup_old_versions(install_dir, vp_setup::MAX_VERSIONS_KEEP, &protected).await + { + print_warn(&format!("Old version cleanup failed (non-fatal): {e}")); + } + + Ok(()) +} + +/// Windows locks running `.exe` files — rename the old one out of the way before copying. +#[cfg(windows)] +async fn replace_windows_exe( + src: &vt_path::AbsolutePathBuf, + dst: &vt_path::AbsolutePathBuf, + bin_dir: &vt_path::AbsolutePathBuf, +) -> Result<(), Box> { + let old_name = format!( + "vp.exe.{}.old", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + ); + let _ = tokio::fs::rename(dst, &bin_dir.join(&old_name)).await; + tokio::fs::copy(src, dst).await?; + Ok(()) +} + +/// Set up the `/vp` entry point (trampoline copy on Windows, symlink on Unix). +async fn setup_bin_shims(dirs: &VpDirs) -> Result<(), Box> { + let bin_dir = &dirs.bin; + tokio::fs::create_dir_all(bin_dir).await?; + + #[cfg(windows)] + { + let shim_src = dirs.data.join("current").join("bin").join("vp-shim.exe"); + let shim_dst = bin_dir.join("vp.exe"); + + replace_windows_exe(&shim_src, &shim_dst, &bin_dir).await?; + dirs.write_shim_pointer("vp")?; + + // Best-effort cleanup of old shim files + if let Ok(mut entries) = tokio::fs::read_dir(&bin_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if entry.file_name().to_string_lossy().ends_with(".old") { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } + } + + #[cfg(unix)] + { + let current_vp = dirs.data.join("current").join("bin").join("vp"); + let link_path = bin_dir.join("vp"); + let _ = tokio::fs::remove_file(&link_path).await; + tokio::fs::symlink(current_vp.as_path(), &link_path).await?; + } + + Ok(()) +} + +#[allow(clippy::print_stdout)] +fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box> { + #[cfg(windows)] + { + windows_path::add_to_user_path(bin_dir)?; + if !quiet { + print_info("added to User PATH (restart your terminal to pick up changes)"); + } + } + + #[cfg(not(windows))] + { + if !quiet { + print_info(&format!("add {bin_dir} to your shell's PATH")); + } + } + + Ok(()) +} diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 05208888f2..31ca2445b2 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -2,6 +2,8 @@ //! //! This binary provides a download-and-run installation experience for Windows, //! complementing the existing PowerShell installer (`install.ps1`). +//! Like the scripts, it delegates installation to the downloaded binary's self-setup. +//! The `legacy` module installs older binaries that do not support that protocol. //! //! Modeled after `rustup-init.exe`: //! - Console-based (no GUI) @@ -18,6 +20,7 @@ )] mod cli; +mod legacy; #[cfg(windows)] mod windows_path; @@ -116,22 +119,11 @@ async fn run(mut opts: cli::Options, dirs: VpDirs) -> i32 { code } -/// Install the resolved version. -#[allow(clippy::print_stdout)] +/// Bootstrap the target binary; permanent installation belongs to its self-setup. async fn do_install(opts: &cli::Options, dirs: &VpDirs) -> Result<(), Box> { + // 1. Resolve and verify the download before touching the installation. let platform_suffix = platform::detect_platform_suffix()?; - if !opts.quiet { - print_info(&format!("detected platform: {platform_suffix}")); - } - - // Read the installed version first. This operation does not create a - // directory. Create the installation root after target-version validation. - let current_version = install::read_current_version(&dirs.data).await; - let version_or_tag = opts.version.as_deref().unwrap_or(&opts.tag); - - // Resolve the target version first. If it matches the installed version, - // skip the platform package request. if !opts.quiet { print_info(&format!("resolving version '{version_or_tag}'...")); } @@ -140,116 +132,82 @@ async fn do_install(opts: &cli::Options, dirs: &VpDirs) -> Result<(), Box --force`), so re-running setup recognizes them as - // already installed instead of re-downloading. - let same_version = current_version - .as_deref() - .is_some_and(|current| install::is_install_dir_for_version(current, &target_version)) - && tokio::fs::try_exists(dirs.data.join("current").join("bin").join(VP_BINARY_NAME)) - .await - .unwrap_or(false); - - if same_version { - if !opts.quiet { - print_info(&format!("version {target_version} already installed, verifying setup...")); - } - } else if let Some(ref current) = current_version { - if !opts.quiet { - print_info(&format!("upgrading from {current} to {target_version}")); - } + ).into()); } - - if !same_version { - // Only fetch platform metadata + download when we actually need to install - let resolved = registry::resolve_platform_package( - &target_version, - &platform_suffix, - opts.registry.as_deref(), - ) + let resolved = registry::resolve_platform_package( + &target_version, + &platform_suffix, + opts.registry.as_deref(), + ) + .await?; + let data = + download_with_progress(&HttpClient::new(), &resolved.platform_tarball_url, opts.quiet) + .await?; + integrity::verify_integrity(&data, &resolved.platform_integrity)?; + let temporary = tempfile::tempdir()?; + let directory = AbsolutePathBuf::new(temporary.path().to_path_buf()) + .ok_or("Installer temporary directory must be absolute")?; + install::extract_platform_package(&data, &directory).await?; + let binary = directory.join("bin").join(VP_BINARY_NAME); + + // 2. Match the script bootstrap protocol. Old binaries see --help instead of opening a picker. + let probe = tokio::process::Command::new(binary.as_path()) + .arg("--help") + .env(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK, "1") + .output() .await?; - - if !opts.quiet { - print_info(&format!("downloading vite-plus@{target_version} for {platform_suffix}...")); - } - let client = HttpClient::new(); - let platform_data = - download_with_progress(&client, &resolved.platform_tarball_url, opts.quiet).await?; - - if !opts.quiet { - print_info("verifying integrity..."); - } - integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; - - let install_dir = &dirs.data; - let version_dir = install_dir.join(&target_version); - tokio::fs::create_dir_all(&version_dir).await?; - - let result = install_new_version( - opts, - &platform_data, - &version_dir, - install_dir, - &target_version, - current_version.is_some(), + if !probe.status.success() || probe.stdout != b"vite-plus-self-setup-v1\n" { + return legacy::install(opts, dirs, &target_version, &data).await; + } + + // 3. The menu supplies setup choices; interactive runs may still need release-age consent. + let mut command = tokio::process::Command::new(binary.as_path()); + command + .env_remove(vp_shared::env_vars::VP_SELF_SETUP_SUPPORT_CHECK) + .env("VP_NODE_MANAGER", if opts.no_node_manager { "no" } else { "yes" }) + .env( + vp_shared::env_vars::VP_SELF_SETUP_NO_MODIFY_PATH, + if opts.no_modify_path { "1" } else { "0" }, ) - .await; - - // On failure, clean up the partial version directory (matches vp upgrade behavior) - if result.is_err() { - let _ = tokio::fs::remove_dir_all(&version_dir).await; - } - - result?; - } - - // --- Post-activation setup (always runs, even for same-version repair) --- - // All steps below are best-effort: the core install succeeded once `current` - // points at the right version. - - if !opts.quiet { - print_info("setting up shims..."); - } - if let Err(e) = setup_bin_shims(&dirs).await { - print_warn(&format!("Shim setup failed (non-fatal): {e}")); - } - - if !opts.no_node_manager { - if !opts.quiet { - print_info("setting up Node.js and package-manager version management..."); + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + if let Some(registry) = opts.registry.as_deref() { + command.env(vp_shared::env_vars::NPM_CONFIG_REGISTRY_UPPER, registry); + } + if !opts.yes && !opts.quiet { + let status = command + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .await?; + if !status.success() { + return Err(format!("Vite+ setup failed ({status})").into()); } - match install::refresh_shims(&dirs.data).await { - Ok(()) if current_version.is_none() => { - let vp_binary = dirs.data.join("current").join("bin").join(VP_BINARY_NAME); - let preference_result = tokio::process::Command::new(vp_binary.as_path()) - .args(["env", "on"]) - .output() - .await; - if !preference_result.is_ok_and(|output| output.status.success()) { - print_warn("Failed to record environment management preference."); - } - } - Ok(()) => {} - Err(e) => { - print_warn(&format!("Node.js and package-manager setup failed (non-fatal): {e}")) - } + } else if opts.quiet { + let output = command.output().await?; + if !output.status.success() { + io::stdout().write_all(&output.stdout)?; + io::stderr().write_all(&output.stderr)?; + return Err(format!("Vite+ setup failed ({})", output.status).into()); } - } else if let Err(e) = install::create_env_files(&dirs.data).await { - print_warn(&format!("Env file creation failed (non-fatal): {e}")); - } - - if !opts.no_modify_path { - let bin_dir_str = dirs.bin.as_path().to_string_lossy().to_string(); - if let Err(e) = modify_path(&bin_dir_str, opts.quiet) { - print_warn(&format!("PATH modification failed (non-fatal): {e}")); + } else { + let mut child = command.spawn()?; + let mut stdout = child.stdout.take().ok_or("Missing setup stdout pipe")?; + let mut stderr = child.stderr.take().ok_or("Missing setup stderr pipe")?; + // Drain both streams while the child runs, including output without line breaks. + let mut terminal_stdout = tokio::io::stdout(); + let mut terminal_stderr = tokio::io::stderr(); + let (status, _, _) = tokio::try_join!( + child.wait(), + tokio::io::copy(&mut stdout, &mut terminal_stdout), + tokio::io::copy(&mut stderr, &mut terminal_stderr), + )?; + if !status.success() { + return Err(format!("Vite+ setup failed ({status})").into()); } } - Ok(()) } @@ -349,112 +307,6 @@ fn auto_detect_node_manager_for_state(state: NodeShimState, interactive: bool) - interactive } -/// Extract, install deps, and activate a new version. Separated so the caller -/// can clean up the version directory on failure. -async fn install_new_version( - opts: &cli::Options, - platform_data: &[u8], - version_dir: &AbsolutePathBuf, - install_dir: &AbsolutePathBuf, - version: &str, - has_previous: bool, -) -> Result<(), Box> { - if !opts.quiet { - print_info("extracting binary..."); - } - install::extract_platform_package(platform_data, version_dir).await?; - - let binary_path = version_dir.join("bin").join(VP_BINARY_NAME); - if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { - return Err("Binary not found after extraction. The download may be corrupted.".into()); - } - #[cfg(windows)] - if !tokio::fs::try_exists(version_dir.join("bin").join("vp-shim.exe")).await.unwrap_or(false) { - return Err( - "vp-setup did not find vp-shim.exe after extraction. The downloaded package can be corrupt." - .into(), - ); - } - - install::generate_wrapper_package_json(version_dir, version).await?; - - if !opts.quiet { - print_info("installing dependencies (this may take a moment)..."); - } - install::install_production_deps(version_dir, opts.registry.as_deref(), opts.yes, version) - .await?; - - let previous_version = - if has_previous { install::save_previous_version(install_dir).await? } else { None }; - install::swap_current_link(install_dir, version).await?; - - // Cleanup with both new and previous versions protected (matches vp upgrade) - let mut protected = vec![version]; - if let Some(ref prev) = previous_version { - protected.push(prev.as_str()); - } - if let Err(e) = - install::cleanup_old_versions(install_dir, vp_setup::MAX_VERSIONS_KEEP, &protected).await - { - print_warn(&format!("Old version cleanup failed (non-fatal): {e}")); - } - - Ok(()) -} - -/// Windows locks running `.exe` files — rename the old one out of the way before copying. -#[cfg(windows)] -async fn replace_windows_exe( - src: &vt_path::AbsolutePathBuf, - dst: &vt_path::AbsolutePathBuf, - bin_dir: &vt_path::AbsolutePathBuf, -) -> Result<(), Box> { - let old_name = format!( - "vp.exe.{}.old", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - ); - let _ = tokio::fs::rename(dst, &bin_dir.join(&old_name)).await; - tokio::fs::copy(src, dst).await?; - Ok(()) -} - -/// Set up the `/vp` entry point (trampoline copy on Windows, symlink on Unix). -async fn setup_bin_shims(dirs: &VpDirs) -> Result<(), Box> { - let bin_dir = &dirs.bin; - tokio::fs::create_dir_all(bin_dir).await?; - - #[cfg(windows)] - { - let shim_src = dirs.data.join("current").join("bin").join("vp-shim.exe"); - let shim_dst = bin_dir.join("vp.exe"); - - replace_windows_exe(&shim_src, &shim_dst, &bin_dir).await?; - dirs.write_shim_pointer("vp")?; - - // Best-effort cleanup of old shim files - if let Ok(mut entries) = tokio::fs::read_dir(&bin_dir).await { - while let Ok(Some(entry)) = entries.next_entry().await { - if entry.file_name().to_string_lossy().ends_with(".old") { - let _ = tokio::fs::remove_file(entry.path()).await; - } - } - } - } - - #[cfg(unix)] - { - let current_vp = dirs.data.join("current").join("bin").join("vp"); - let link_path = bin_dir.join("vp"); - let _ = tokio::fs::remove_file(&link_path).await; - tokio::fs::symlink(current_vp.as_path(), &link_path).await?; - } - - Ok(()) -} - async fn download_with_progress( client: &HttpClient, url: &str, @@ -485,26 +337,6 @@ fn prepare_dirs() -> Result> { Ok(vp_shared::EnvConfig::get().dirs.clone()) } -#[allow(clippy::print_stdout)] -fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box> { - #[cfg(windows)] - { - windows_path::add_to_user_path(bin_dir)?; - if !quiet { - print_info("added to User PATH (restart your terminal to pick up changes)"); - } - } - - #[cfg(not(windows))] - { - if !quiet { - print_info(&format!("add {bin_dir} to your shell's PATH")); - } - } - - Ok(()) -} - #[allow(clippy::print_stdout)] fn show_interactive_menu(opts: &mut cli::Options, data_dir: &str, bin_dir: &str) -> bool { loop { diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index e73fc22771..fd6db41216 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -96,6 +96,15 @@ pub async fn extract_platform_package( /// This ensures consistent install behavior regardless of the user's global pnpm version. const PINNED_PNPM_VERSION: &str = "10.33.0"; +/// A reused target version must run its own setup again before accepting commands. +pub async fn clear_self_setup_marker(version_dir: &AbsolutePath) -> Result<(), Error> { + match tokio::fs::remove_file(version_dir.join("bin").join(crate::SELF_SETUP_MARKER)).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + /// Generate a wrapper `package.json` that declares `vite-plus` as a dependency. /// /// The `packageManager` field pins pnpm to a known-good version, ensuring @@ -479,7 +488,9 @@ fn remove_windows_current_link(current_link: &AbsolutePath) -> Result<(), Error> Ok(()) } -/// Refresh shims by running `vp env setup --refresh` with the new binary. +/// Hand off to the newly activated binary using the legacy refresh command. +/// New binaries without a setup marker intercept this invocation as self-setup; +/// legacy rollback targets still execute `env setup --refresh` normally. pub async fn refresh_shims(install_dir: &AbsolutePath) -> Result<(), Error> { let vp_binary = install_dir.join("current").join("bin").join(crate::VP_BINARY_NAME); @@ -657,6 +668,24 @@ pub async fn create_env_files(install_dir: &AbsolutePath) -> Result<(), Error> { mod tests { use super::*; + #[tokio::test] + async fn invalidate_only_the_target_versions_setup() { + let root = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(root.path().to_path_buf()).unwrap(); + let old = root.join("1.0.0"); + let target = root.join("1.1.0"); + for version in [&old, &target] { + tokio::fs::create_dir_all(version.join("bin")).await.unwrap(); + tokio::fs::write(version.join("bin").join(crate::SELF_SETUP_MARKER), b"") + .await + .unwrap(); + } + clear_self_setup_marker(&target).await.unwrap(); + assert!(!target.join("bin").join(crate::SELF_SETUP_MARKER).as_path().exists()); + assert!(old.join("bin").join(crate::SELF_SETUP_MARKER).as_path().is_file()); + clear_self_setup_marker(&target).await.unwrap(); + } + #[test] fn forced_active_version_installs_to_unique_semver_dir() { let dir = target_install_dir_name("0.1.23", Some("0.1.23"), true); diff --git a/crates/vp_setup/src/lib.rs b/crates/vp_setup/src/lib.rs index 6deee39349..e9fe1deffc 100644 --- a/crates/vp_setup/src/lib.rs +++ b/crates/vp_setup/src/lib.rs @@ -24,6 +24,9 @@ pub mod registry; /// Maximum number of old versions to keep. pub const MAX_VERSIONS_KEEP: usize = 3; +/// Stored beside a deployed binary after its first-start setup completes. +pub const SELF_SETUP_MARKER: &str = ".vp-setup-complete"; + pub use vp_shared::VP_BINARY_NAME; /// Return `true` if `version` supports the split directory layout. diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 5446d6b954..c258689dba 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -197,6 +197,12 @@ pub const VP_INSECURE_TLS: &str = "VP_INSECURE_TLS"; /// do not implement directory resolution again. pub const VP_DUMP_DIRS: &str = "VP_DUMP_DIRS"; +/// Bootstrap capability probe; presence requests only the self-setup contract. +pub const VP_SELF_SETUP_SUPPORT_CHECK: &str = "VP_SELF_SETUP_SUPPORT_CHECK"; + +/// Skip persistent shell/PATH changes during first-start installation. +pub const VP_SELF_SETUP_NO_MODIFY_PATH: &str = "VP_SELF_SETUP_NO_MODIFY_PATH"; + /// Keys in [`VP_DUMP_DIRS`] output. Each value uses one `\t` line. /// The `vp_global_cli` printer and `vp-setup` parser share these values. /// `install.sh` and `install.ps1` use the same keys. @@ -214,3 +220,6 @@ pub mod dump_dirs { /// When set, `get_trampoline_path()` uses this path instead of resolving /// relative to `current_exe()`. Only used in test environments. pub const VP_TRAMPOLINE_PATH: &str = "VP_TRAMPOLINE_PATH"; + +/// Emit shell assignments after self-setup (sh or powershell). +pub const VP_SELF_SETUP_SHELL: &str = "VP_SELF_SETUP_SHELL"; diff --git a/docs/.gitignore b/docs/.gitignore index 3180d7bb6f..2423900fe4 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -2,3 +2,6 @@ .wrangler public/install.sh public/install.ps1 + +public/install-legacy.sh +public/install-legacy.ps1 diff --git a/docs/.vitepress/scripts/copy-installers.mjs b/docs/.vitepress/scripts/copy-installers.mjs new file mode 100644 index 0000000000..aa390e5a5f --- /dev/null +++ b/docs/.vitepress/scripts/copy-installers.mjs @@ -0,0 +1,13 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +// Piped installers must fetch legacy from the same production or preview deploy. +const origin = (process.env.DOCS_SITE_ORIGIN || 'https://viteplus.dev').replace(/\/$/, ''); +for (const name of ['install.sh', 'install.ps1', 'install-legacy.sh', 'install-legacy.ps1']) { + const source = new URL(`../../../packages/cli/${name}`, import.meta.url); + const destination = new URL(`../../public/${name}`, import.meta.url); + const content = await readFile(source, 'utf8'); + await writeFile( + destination, + content.replace('https://viteplus.dev/install-legacy.', `${origin}/install-legacy.`), + ); +} diff --git a/docs/package.json b/docs/package.json index 818f947512..1dac4889f9 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,9 +4,9 @@ "type": "module", "scripts": { "dev": "vitepress dev", - "build": "cp ../packages/cli/install.sh ../packages/cli/install.ps1 public/ && vp run build:site", - "build:cloudflare": "cp ../packages/cli/install.sh ../packages/cli/install.ps1 public/ && vitepress build", - "build:netlify": "cp ../packages/cli/install.sh ../packages/cli/install.ps1 public/ && vitepress build", + "build": "node .vitepress/scripts/copy-installers.mjs && vp run build:site", + "build:cloudflare": "node .vitepress/scripts/copy-installers.mjs && vitepress build", + "build:netlify": "node .vitepress/scripts/copy-installers.mjs && vitepress build", "preview": "vitepress preview", "update-trusted-stack-stats": "node .vitepress/theme/data/fetch-trusted-stack-stats.ts" }, diff --git a/packages/cli/install-legacy.ps1 b/packages/cli/install-legacy.ps1 new file mode 100644 index 0000000000..eddc5375c5 --- /dev/null +++ b/packages/cli/install-legacy.ps1 @@ -0,0 +1,1113 @@ +# Vite+ CLI Installer for Windows +# https://vite.plus/ps1 +# +# Invoked by install.ps1 with an acquired binary, resolved version, and optional preview ref. +# Target resolution and platform downloads belong to the bootstrap. +# +# Environment variables: +# VP_HOME - Optional pin for the monolithic layout. If unset, Vite+ reuses an +# existing %USERPROFILE%\.vite-plus install. Otherwise, the complete +# VP_*_DIR group or Windows Local and Roaming folders select the roots. +# VP_BIN_DIR / VP_DATA_DIR / VP_CACHE_DIR - Complete group of absolute +# category overrides +# NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) +# VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) +# VP_PR_VERSION - PR number or commit SHA to install from the registry bridge +# (for temporary testing of unreleased builds, e.g. VP_PR_VERSION=1569). +# When set, overrides VP_VERSION and installs the clearly-defined +# 0.0.0-commit. build through the bridge instead of npm. + +param( + [Parameter(Mandatory = $true)][string]$BinarySource, + [Parameter(Mandatory = $true)][string]$ResolvedVersion, + [string]$PreviewRef +) + +$ErrorActionPreference = "Stop" + +$ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } +# After these helper definitions, the selected payload resolves category roots +# through VP_DUMP_DIRS. Pre-split payloads use the legacy layout. +# Local tarball for development/testing +$LocalTgz = $env:VP_LOCAL_TGZ +# PR number or commit SHA to install as a test build (registry bridge mode) +$PrVersion = $env:VP_PR_VERSION +$BridgeRegistry = "https://registry-bridge.viteplus.dev/" + +function Write-Info { + param([string]$Message) + Write-Host "info: " -ForegroundColor Blue -NoNewline + Write-Host $Message +} + +function Write-Success { + param([string]$Message) + Write-Host "success: " -ForegroundColor Green -NoNewline + Write-Host $Message +} + +function Write-Warn { + param([string]$Message) + Write-Host "warn: " -ForegroundColor Yellow -NoNewline + Write-Host $Message +} + +# Exit code when a Windows native binary cannot load required DLLs (STATUS_DLL_NOT_FOUND). +$script:DllNotFoundExitCode = -1073741515 + +function Test-IsDllNotFoundExitCode { + param([int]$ExitCode) + if ($ExitCode -eq $script:DllNotFoundExitCode) { + return $true + } + if ($ExitCode -eq 3221225781) { + return $true + } + if ($ExitCode -lt 0) { + $hex = '{0:X8}' -f ($ExitCode -band 0xFFFFFFFF) + return $hex -eq 'C0000135' + } + return $false +} + +function Get-DllNotFoundInstallMessage { + $arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } + $vcUrl = if ($arch -eq "arm64") { + "https://aka.ms/vs/17/release/vc_redist.arm64.exe" + } else { + "https://aka.ms/vs/17/release/vc_redist.x64.exe" + } + return @" +vp.exe could not start (exit code 0xC0000135). +This usually means Microsoft Visual C++ 2015-2022 Redistributable ($arch) is not installed. + +Install: $vcUrl +Then re-run: irm https://vite.plus/ps1 | iex +"@ +} + +# Internal stop signal: halts install without re-printing an error we already wrote. +$script:InstallStopSignal = 'VP_INSTALL_STOP' + +function Test-IsInstallStopException { + param( + [System.Management.Automation.ErrorRecord]$ErrorRecord + ) + return $ErrorRecord.Exception.Message -eq $script:InstallStopSignal +} + +function Test-ShouldKeepShellOpenAfterFailure { + # Only `irm ... | iex` typed in an already-open interactive shell should keep the + # session alive. CI, script files, and `powershell -Command "..."` must exit non-zero. + if ($env:CI -eq "true") { + return $false + } + if ($PSCommandPath) { + return $false + } + if (-not [Environment]::UserInteractive) { + return $false + } + try { + $commandLine = (Get-CimInstance Win32_Process -Filter "ProcessId=$PID").CommandLine + if ($commandLine -match '(^|\s)-Command(\s|$)') { + return $false + } + } catch { + return $false + } + return $true +} + +function Exit-Installer { + param([int]$Code = 1) + $global:LASTEXITCODE = $Code + if (-not (Test-ShouldKeepShellOpenAfterFailure)) { + exit $Code + } + throw $script:InstallStopSignal +} + +function Write-Error-Exit { + param([string]$Message) + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host $Message + Exit-Installer +} + +function Test-ReleaseAgeError { + param([string]$LogPath) + if (-not (Test-Path $LogPath)) { + return $false + } + + $content = Get-Content -Path $LogPath -Raw + # This wrapper install path is pinned to pnpm via packageManager, so this + # detection follows pnpm's resolver/reporter output rather than npm/yarn. + # + # pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so + # NO_MATURE_MATCHING_VERSION is normally printed as + # ERR_PNPM_NO_MATURE_MATCHING_VERSION. npm-resolver emits that code with the + # "does not meet the minimumReleaseAge constraint" message when + # publishedBy/minimumReleaseAge rejects a matching version. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 + # + # default-reporter may append guidance mentioning minimumReleaseAgeExclude + # when the error has an immatureVersion, so that token is also a useful + # release-age signal. minimum-release-age is pnpm's .npmrc key; npm's + # min-release-age is intentionally not treated as a pnpm signal here. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 + $hasReleaseAgeText = $content -match "does not meet the minimumReleaseAge constraint" ` + -or $content -match "minimumReleaseAge" ` + -or $content -match "minimumReleaseAgeExclude" ` + -or $content -match "minimum release age" ` + -or $content -match "minimum-release-age" + + # pnpm can also surface ERR_PNPM_NO_MATCHING_VERSION when minimumReleaseAge + # filters out all candidates. That code is also used for real missing + # versions, so require age-gate context before prompting for a bypass. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 + return $content -match "ERR_PNPM_NO_MATURE_MATCHING_VERSION" ` + -or $content -match "NO_MATURE_MATCHING_VERSION" ` + -or (($content -match "ERR_PNPM_NO_MATCHING_VERSION") -and $hasReleaseAgeText) ` + -or $hasReleaseAgeText +} + +function Confirm-ReleaseAgeOverride { + if ($env:CI -eq "true") { + return $false + } + if (-not [Environment]::UserInteractive) { + return $false + } + + Write-Host "" + Write-Warn "Your minimumReleaseAge setting prevented installing vite-plus@$ViteVersion." + Write-Host "This setting helps protect against newly published compromised packages." + Write-Host "Proceeding will disable this protection for this Vite+ install only." + $response = Read-Host "Do you want to proceed? (y/N)" + return $response -match "^(?i:y|yes)$" +} + +function Write-ReleaseAgeOverride { + # Append idempotently so a bridge registry line written for PR builds survives. + $npmrc = Join-Path $VersionDir ".npmrc" + if ((-not (Test-Path $npmrc)) -or (-not (Select-String -Path $npmrc -Pattern '^minimum-release-age=' -Quiet))) { + Add-Content -Path $npmrc -Value "minimum-release-age=0" + } +} + +function Test-AbsoluteOverridePath { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { + return $false + } + return [System.IO.Path]::IsPathRooted($Path) +} + +function Test-VpDirOverrides { + $values = @($env:VP_BIN_DIR, $env:VP_DATA_DIR, $env:VP_CACHE_DIR) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + if ($values.Count -ne 0 -and $values.Count -ne 3) { + Write-Error-Exit "Set VP_BIN_DIR, VP_DATA_DIR, and VP_CACHE_DIR together, or leave all three unset." + } + if ($values.Count -eq 3) { + foreach ($name in @("VP_BIN_DIR", "VP_DATA_DIR", "VP_CACHE_DIR")) { + $value = [Environment]::GetEnvironmentVariable($name) + if (-not (Test-AbsoluteOverridePath $value)) { + Write-Error-Exit "$name must be an absolute path." + } + } + } +} + +function Get-UserHomeDir { + if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + return $env:USERPROFILE + } + if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { + return $env:HOME + } + return [Environment]::GetFolderPath('UserProfile') +} + +# Released setup-vp versions add %USERPROFILE%\.vite-plus\bin to the GitHub +# Actions PATH. They do this after the installer exits. Use the monolithic +# layout until setup-vp declares support for VP_DUMP_DIRS. +function Enable-SetupVpLegacyCompatibility { + if ($env:GITHUB_ACTION_REPOSITORY -cne "voidzero-dev/setup-vp") { + return + } + if ($env:VP_VPDIRS_AWARE -eq "1") { + return + } + if ($env:VP_HOME -or $env:VP_BIN_DIR -or $env:VP_DATA_DIR -or $env:VP_CACHE_DIR) { + return + } + + $userHome = Get-UserHomeDir + if ([string]::IsNullOrWhiteSpace($userHome)) { + Write-Error-Exit "Vite+ could not resolve the user home directory." + } + $env:VP_HOME = Join-Path $userHome ".vite-plus" +} + +# Monolithic mapping: every category on one root. +function New-MonolithicLayout { + param([string]$Root) + return [pscustomobject]@{ + Kind = "single-root" + DataDir = $Root + ShimDir = Join-Path $Root "bin" + CacheDir = Join-Path $Root "cache" + ConfigDir = $Root + StateDir = $Root + } +} + +function Set-LayoutVars { + $script:InstallDir = $script:Layout.DataDir + $script:ShimDir = $script:Layout.ShimDir + $script:CacheDir = $script:Layout.CacheDir + $script:ConfigDir = $script:Layout.ConfigDir + $script:StateDir = $script:Layout.StateDir + $script:NodeManagerBinDisplay = $script:ShimDir -replace [regex]::Escape($env:USERPROFILE), '~' +} + +# Pre-split releases resolve all paths from VP_HOME, which defaults to +# %USERPROFILE%\.vite-plus. Install them in this monolithic root. This keeps +# environment setup, shims, trampolines, and installer paths consistent. +function Use-LegacyLayout { + $userHome = Get-UserHomeDir + if ([string]::IsNullOrWhiteSpace($userHome)) { + Write-Error-Exit "Vite+ could not resolve the user home directory." + } + + $root = if (Test-AbsoluteOverridePath $env:VP_HOME) { + $env:VP_HOME + } else { + Join-Path $userHome ".vite-plus" + } + $script:Layout = New-MonolithicLayout $root + Set-LayoutVars +} + +# Record the resolved layout next to each trampoline. +function Write-ShimPointer { + param( + [string]$BinDir, + [string]$DataDir, + [string]$CacheDir, + [string]$LayoutKind, + [string]$Name = "vp" + ) + $path = Join-Path $BinDir "$Name.shim" + $utf8 = New-Object System.Text.UTF8Encoding $false + $contents = "vite-plus-shim-v1`nlayout=$LayoutKind`ndata=$($DataDir.TrimEnd('\', '/'))`ncache=$($CacheDir.TrimEnd('\', '/'))`n" + [System.IO.File]::WriteAllText($path, $contents, $utf8) +} + +function Get-ShimPointerData { + param([string]$Path) + try { + $contents = [System.IO.File]::ReadAllText($Path).Trim() + } catch { + return $null + } + if ([string]::IsNullOrWhiteSpace($contents)) { + return $null + } + $lines = $contents -split "`r?`n" + if ($lines[0] -ne "vite-plus-shim-v1") { + return $null + } + foreach ($line in $lines) { + if ($line.StartsWith("data=")) { + return $line.Substring(5) + } + } + return $null +} + +function Normalize-InstallDir { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { + return $Path + } + + try { + if (Test-Path -LiteralPath $Path -PathType Container) { + return (Resolve-Path -LiteralPath $Path).ProviderPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + } + + return [System.IO.Path]::GetFullPath($Path).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + } catch { + return $Path.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + } +} + +function Test-SafeInstallDirToRemove { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { + return $false + } + + $normalized = Normalize-InstallDir $Path + $root = [System.IO.Path]::GetPathRoot($normalized) + # Do not use $home: PowerShell is case-insensitive and $HOME is read-only on 5.1. + $userHome = Normalize-InstallDir $env:USERPROFILE + $programFilesX86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)") + $unsafeDirs = @( + $root + $userHome + (Normalize-InstallDir $env:SystemRoot) + (Normalize-InstallDir $env:ProgramFiles) + (Normalize-InstallDir $programFilesX86) + ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + + return $unsafeDirs -notcontains $normalized +} + +function Test-VitePlusInstallDir { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + return $false + } + + $binDir = Join-Path $Path "bin" + if (-not (Test-Path -LiteralPath $binDir -PathType Container)) { + return $false + } + if (-not (Test-Path -LiteralPath (Join-Path $Path "current"))) { + return $false + } + + return (Test-Path -LiteralPath (Join-Path $binDir "vp.exe")) ` + -or (Test-Path -LiteralPath (Join-Path $binDir "vp.cmd")) ` + -or (Test-Path -LiteralPath (Join-Path $binDir "vp")) +} + +function Get-PreviousInstallDir { + if (-not $env:VP_HOME) { + return $null + } + + $vpCommand = Get-Command vp -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $vpCommand) { + return $null + } + + $vpPath = $vpCommand.Path + if (-not $vpPath) { + return $null + } + + $vpFileName = [System.IO.Path]::GetFileName($vpPath) + if ($vpFileName -notin @("vp", "vp.exe", "vp.cmd")) { + return $null + } + + $oldDir = Normalize-InstallDir (Split-Path -Parent (Split-Path -Parent $vpPath)) + $newDir = Normalize-InstallDir $InstallDir + if ($oldDir -eq $newDir) { + return $null + } + if (-not (Test-SafeInstallDirToRemove $oldDir)) { + return $null + } + if (-not (Test-VitePlusInstallDir $oldDir)) { + return $null + } + + return $oldDir +} + +function Test-NestedInstallDir { + param( + [string]$OldDir, + [string]$NewDir + ) + if ([string]::IsNullOrWhiteSpace($OldDir) -or [string]::IsNullOrWhiteSpace($NewDir)) { + return $false + } + + $oldDir = Normalize-InstallDir $OldDir + $newDir = Normalize-InstallDir $NewDir + if ([string]::IsNullOrWhiteSpace($oldDir) -or [string]::IsNullOrWhiteSpace($newDir) -or $oldDir -eq $newDir) { + return $false + } + + # Normalize-InstallDir already trimmed trailing separators + $oldPrefix = $oldDir + [System.IO.Path]::DirectorySeparatorChar + $newPrefix = $newDir + [System.IO.Path]::DirectorySeparatorChar + return $oldPrefix.StartsWith($newPrefix, [System.StringComparison]::OrdinalIgnoreCase) ` + -or $newPrefix.StartsWith($oldPrefix, [System.StringComparison]::OrdinalIgnoreCase) +} + +function Prompt-RemovePreviousInstallDir { + param([string]$PreviousInstallDir) + if (-not $PreviousInstallDir) { + return + } + if ($env:CI -eq "true") { + return + } + if (-not [Environment]::UserInteractive) { + return + } + + Write-Host "" + Write-Warn "Found a previous Vite+ install at $PreviousInstallDir." + Write-Host "The new VP_HOME is $InstallDir." + $response = Read-Host "Remove the previous install directory? (y/N)" + if ($response -match "^(?i:y|yes)$") { + $vpBin = Join-Path $PreviousInstallDir "current\bin\vp.exe" + if (-not (Test-Path -LiteralPath $vpBin)) { + Write-Warn "Could not remove previous Vite+ install at ${PreviousInstallDir}: vp binary not found." + return + } + + $previousVpHome = $env:VP_HOME + try { + $env:VP_HOME = $PreviousInstallDir + $output = & $vpBin implode --yes 2>&1 + $exitCode = $LASTEXITCODE + } catch { + $output = $_ + $exitCode = 1 + } finally { + $env:VP_HOME = $previousVpHome + } + + if ($exitCode -eq 0) { + Write-Success "Removed previous Vite+ install at $PreviousInstallDir." + } else { + Write-Warn "Could not remove previous Vite+ install at ${PreviousInstallDir}: $output" + } + } +} + +function Write-InstallFailure { + param( + [string]$LogPath, + [int]$ExitCode = 0 + ) + + if (Test-IsDllNotFoundExitCode $ExitCode) { + $message = Get-DllNotFoundInstallMessage + if ($env:CI -eq "true") { + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host $message + Exit-Installer + } + Write-Error-Exit $message + } + + if ($env:CI -eq "true") { + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host "Failed to install dependencies. Log output:" + Get-Content -Path $LogPath | ForEach-Object { Write-Host $_ } + Exit-Installer + } else { + Write-Error-Exit "Failed to install dependencies. See log for details: $LogPath" + } +} + +function Write-ReleaseAgeFailure { + param([string]$LogPath) + if ($env:CI -eq "true") { + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host "Install blocked by your minimumReleaseAge setting. Log output:" + Get-Content -Path $LogPath | ForEach-Object { Write-Host $_ } + } else { + Write-Error-Exit "Install blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly. See log for details: $LogPath" + } +} + +function Cleanup-OldVersions { + param([string]$InstallDir) + + $maxVersions = 3 + # Only cleanup semver format directories (0.1.0, 1.2.3-beta.1, etc.) + # This excludes 'current' symlink and non-semver directories like 'local-dev' + $semverPattern = '^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$' + $versions = Get-ChildItem -Path $InstallDir -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match $semverPattern } + + if ($null -eq $versions -or $versions.Count -le $maxVersions) { + return + } + + # Sort by creation time (oldest first) and select excess + $toDelete = $versions | + Sort-Object CreationTime | + Select-Object -First ($versions.Count - $maxVersions) + + foreach ($old in $toDelete) { + # Remove silently + Remove-Item -Path $old.FullName -Recurse -Force + } +} + +function Remove-CurrentLink { + param([string]$Path) + + try { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + } catch [System.Management.Automation.ItemNotFoundException] { + return + } + + $isReparsePoint = ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + + try { + if ($isReparsePoint) { + if ($item.PSIsContainer) { + [System.IO.Directory]::Delete($item.FullName) + } else { + [System.IO.File]::Delete($item.FullName) + } + return + } + + Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction Stop + } catch { + Write-Error-Exit "Failed to remove existing current link at ${Path}: $_" + } +} + +# Configure user PATH for the resolved shim directory +# Returns: "true" = added, "already" = already configured +function Configure-UserPath { + $binPath = $ShimDir + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + + if ($userPath -like "*$binPath*") { + return "already" + } + + $newPath = "$binPath;$userPath" + try { + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + $env:Path = "$binPath;$env:Path" + return "true" + } catch { + Write-Warn "Could not update user PATH automatically." + return "failed" + } +} + +function Get-NushellVendorAutoloadDir { + $nushellCommand = Get-Command nu -ErrorAction SilentlyContinue + if ($null -eq $nushellCommand) { + return $null + } + + try { + $dirsOutput = & $nushellCommand.Source -c '$nu.vendor-autoload-dirs | reverse | each {|dir| $dir } | str join (char nl)' 2>$null + } catch { + return $null + } + + foreach ($dir in ($dirsOutput -split "\r?\n")) { + if (-not [string]::IsNullOrWhiteSpace($dir)) { + return $dir + } + } + + return $null +} + +function Configure-Nushell { + $autoloadDir = Get-NushellVendorAutoloadDir + if ($null -eq $autoloadDir) { + if ($null -eq (Get-Command nu -ErrorAction SilentlyContinue)) { + return [pscustomobject]@{ + Status = "skipped" + Message = "skipped (not installed)" + } + } + + return [pscustomobject]@{ + Status = "failed" + Message = "failed (could not determine vendor autoload dir)" + } + } + + $autoloadFile = Join-Path $autoloadDir "vite-plus.nu" + $nuEnvRef= (Join-Path $ConfigDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' + $content = "# Vite+ bin (https://viteplus.dev)`n" + ("source '"+ $nuEnvRef +"'") + "`n" + + try { + New-Item -ItemType Directory -Force -Path $autoloadDir | Out-Null + if (Test-Path $autoloadFile) { + $existing = Get-Content -Path $autoloadFile -Raw + if ($existing -eq $content) { + return [pscustomobject]@{ + Status = "already" + Message = "already configured $autoloadFile" + } + } + } + + [System.IO.File]::WriteAllText($autoloadFile, $content) + return [pscustomobject]@{ + Status = "true" + Message = "updated $autoloadFile" + } + } catch { + Write-Warn "Could not configure Nushell automatically." + return [pscustomobject]@{ + Status = "failed" + Message = "failed $autoloadFile" + } + } +} + +# Run vp env setup --refresh, showing output only on failure +function Refresh-Shims { + param([string]$BinDir) + $setupOutput = & "$BinDir\vp.exe" env setup --refresh 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warn "Failed to refresh shims:" + Write-Host "$setupOutput" + } +} + +# Return true only if this Vite+ install owns the existing Node executable. +# $ShimDir can be shared. The existence of node.exe does not permit replacement. +function Test-VitePlusNodeShim { + $nodePath = Join-Path $ShimDir "node.exe" + $pointerPath = Join-Path $ShimDir "node.shim" + $hasNode = Test-Path -LiteralPath $nodePath -PathType Leaf + $hasPointer = Test-Path -LiteralPath $pointerPath -PathType Leaf + if (-not $hasNode -or -not $hasPointer) { + return $false + } + + $pointer = Get-ShimPointerData $pointerPath + if ([string]::IsNullOrWhiteSpace($pointer)) { + return $false + } + + return (Normalize-InstallDir $pointer) -eq (Normalize-InstallDir $InstallDir) +} + +# Setup Vite+ environment shims +# Returns: "true" = enabled, "false" = not enabled, "already" = already configured +function Setup-NodeManager { + param([string]$BinDir) + + $binPath = $ShimDir + + # Explicit override via environment variable + if ($env:VP_NODE_MANAGER -eq "yes") { + Refresh-Shims -BinDir $BinDir + return "true" + } elseif ($env:VP_NODE_MANAGER -eq "no") { + return "false" + } + + # A foreign Node executable in a custom bin directory prevents automatic + # enablement. The explicit setting or interactive prompt can permit + # replacement. + $foreignNodeInBin = $false + if (Test-Path -LiteralPath (Join-Path $binPath "node.exe")) { + if (Test-VitePlusNodeShim) { + Refresh-Shims -BinDir $BinDir + return "already" + } + $foreignNodeInBin = $true + } + + # Auto-enable on CI or devcontainer environments + # CI: standard CI environment variable (GitHub Actions, Travis, CircleCI, etc.) + # CODESPACES: set by GitHub Codespaces (https://docs.github.com/en/codespaces) + # REMOTE_CONTAINERS: set by VS Code Dev Containers extension + # DEVPOD: set by DevPod (https://devpod.sh) + $isAutomaticEnvironment = $env:CI -or $env:CODESPACES -or $env:REMOTE_CONTAINERS -or $env:DEVPOD + if (-not $foreignNodeInBin -and $isAutomaticEnvironment) { + Refresh-Shims -BinDir $BinDir + return "true" + } + + # Check if node is available on the system + $nodeAvailable = $null -ne (Get-Command node -ErrorAction SilentlyContinue) + + # Auto-enable if no node available on system + if (-not $nodeAvailable -and -not $foreignNodeInBin) { + Refresh-Shims -BinDir $BinDir + return "true" + } + + # Prompt user in interactive mode + # CI requires unattended setup. Some hosted PowerShell runners report an + # interactive host process, so do not use that report in CI. + $isInteractive = [Environment]::UserInteractive -and -not $env:CI + if ($isInteractive) { + Write-Host "" + Write-Host "Would you like Vite+ to manage your Node.js and package-manager versions?" + Write-Host "Vite+ adds ``node``, ``npm``, ``npx``, ``pnpm``, ``pnpx``, ``yarn``, ``yarnpkg``, ``bun``, and ``bunx`` shims to $NodeManagerBinDisplay." + Write-Host "It selects the required version automatically." + Write-Host "Opt out anytime with ``vp env off``." + $response = Read-Host "Press Enter to accept (Y/n)" + + if ($response -eq '' -or $response -eq 'y' -or $response -eq 'Y') { + Refresh-Shims -BinDir $BinDir + return "true" + } + } + + return "false" +} + +function Main { + Write-Host "" + Write-Host "Setting up " -NoNewline + Write-Host "VITE+" -ForegroundColor Blue -NoNewline + Write-Host "..." + + if ($PrVersion -and $LocalTgz) { + Write-Error-Exit "VP_PR_VERSION and VP_LOCAL_TGZ cannot be used together" + } + + Test-VpDirOverrides + Enable-SetupVpLegacyCompatibility + $ViteVersion = $ResolvedVersion + $PrVersion = $PreviewRef + if (-not (Test-Path -LiteralPath $BinarySource -PathType Leaf)) { + Write-Error-Exit "Run install.ps1 to resolve and download the installer payload." + } + if ($PrVersion) { + $PrCommitVersion = $ResolvedVersion + $ViteVersion = "pkg-pr-new-$PrVersion" + } + $binaryName = "vp.exe" + if (Apply-DirsFromVp $BinarySource) { + Set-LayoutVars + } else { + Use-LegacyLayout + Write-Info "vite-plus $ViteVersion does not support the split directory layout. Vite+ will install it in $InstallDir." + } + + # Run layout migration checks after the payload resolves the category roots. + # A pre-split payload selects the legacy layout first. + $previousInstallDir = Get-PreviousInstallDir + if ($previousInstallDir -and (Test-NestedInstallDir -OldDir $previousInstallDir -NewDir $InstallDir)) { + Write-Error-Exit "The previous Vite+ install at $previousInstallDir overlaps with VP_HOME $InstallDir. Set VP_HOME to a directory that does not overlap. Alternatively, remove the previous install." + } + + # Set up version-specific directories + $VersionDir = "$InstallDir\$ViteVersion" + $BinDir = "$VersionDir\bin" + $CurrentLink = "$InstallDir\current" + + # Create bin directory + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + + if ($LocalTgz) { + Write-Info "Vite+ uses the local tarball: $LocalTgz" + } + Copy-Item -LiteralPath $BinarySource -Destination (Join-Path $BinDir $binaryName) -Force + $shimSource = Join-Path (Split-Path $BinarySource) "vp-shim.exe" + if (Test-Path -LiteralPath $shimSource) { + Copy-Item -LiteralPath $shimSource -Destination (Join-Path $BinDir "vp-shim.exe") -Force + } + + # Remove Zone.Identifier (Mark of the Web) from downloaded binaries so + # Windows SmartScreen / Defender won't block execution. + Get-ChildItem -Path $BinDir -Filter "*.exe" | Unblock-File + + # Generate wrapper package.json that declares vite-plus as a dependency. + # pnpm will install vite-plus and all transitive deps via `vp install`. + # The packageManager field pins pnpm to a known-good version. + # In PR mode, pin vite-plus to the bridge's clearly-defined commit version and + # resolve it (plus its platform binaries and transitive deps) through the + # bridge registry written to .npmrc below. The bridge rewrites a preview + # tarball's transitive deps to versions, not self-contained URLs, so a full + # install must go through the registry rather than the bare download URL. + $vitePlusSpec = if ($PrVersion) { $PrCommitVersion } else { $ViteVersion } + if ($PrVersion) { + # Bridge registry; drop any stale wrapper lockfile (see install.sh for why): + # the reused pkg-pr-new- dir must re-resolve a lockfile matching the + # spec we just wrote, not fail under CI's frozen-lockfile default. + Set-Content -Path (Join-Path $VersionDir ".npmrc") -Value "registry=$BridgeRegistry" + Remove-Item -Path (Join-Path $VersionDir "pnpm-lock.yaml") -ErrorAction SilentlyContinue + } + $wrapperJson = @{ + name = "vp-global" + version = $ViteVersion + private = $true + packageManager = "pnpm@10.33.0" + dependencies = @{ + "vite-plus" = $vitePlusSpec + } + } | ConvertTo-Json -Depth 10 + Set-Content -Path (Join-Path $VersionDir "package.json") -Value $wrapperJson + + # Install production dependencies (skip if VP_SKIP_DEPS_INSTALL is set, + # e.g. during local dev where install-global-cli.ts handles deps separately) + if (-not $env:VP_SKIP_DEPS_INSTALL) { + $installLog = Join-Path $VersionDir "install.log" + Push-Location $VersionDir + try { + # Use cmd /c so CI=true is scoped to the child process only, + # avoiding leaking it into the user's shell session. + # Do not pass --silent to the inner install: pnpm suppresses the + # release-age error body in silent mode, which would leave + # install.log empty and make the release-age gate impossible to + # detect. Output is already captured to install.log here. + $output = cmd /c "set CI=true && `"$BinDir\vp.exe`" install" 2>&1 + $installExitCode = $LASTEXITCODE + $output | Out-File $installLog + if ($installExitCode -ne 0) { + if (Test-ReleaseAgeError $installLog) { + if (Confirm-ReleaseAgeOverride) { + # Write the override only after explicit consent, then retry once. + Write-ReleaseAgeOverride + $retryOutput = cmd /c "set CI=true && `"$BinDir\vp.exe`" install" 2>&1 + $retryExitCode = $LASTEXITCODE + $retryOutput | Out-File $installLog + if ($retryExitCode -ne 0) { + Write-InstallFailure -LogPath $installLog -ExitCode $retryExitCode + } + } else { + Write-ReleaseAgeFailure $installLog + Exit-Installer + } + } else { + Write-InstallFailure -LogPath $installLog -ExitCode $installExitCode + } + } + } finally { + Pop-Location + } + } + + # Create/update current junction (symlink) + Remove-CurrentLink $CurrentLink + # Create new junction pointing to the version directory + cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null + + # Create user bin directory and vp wrapper (always done) + New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null + $trampolineSrc = "$VersionDir\bin\vp-shim.exe" + if (Test-Path $trampolineSrc) { + # New versions: use trampoline exe to avoid "Terminate batch job (Y/N)?" on Ctrl+C + Copy-Item -Path $trampolineSrc -Destination (Join-Path $ShimDir "vp.exe") -Force + Write-ShimPointer -BinDir $ShimDir -DataDir $InstallDir -CacheDir $CacheDir -LayoutKind $Layout.Kind -Name "vp" + # Remove legacy .cmd and shell script wrappers from previous versions + foreach ($legacy in @((Join-Path $ShimDir "vp.cmd"), (Join-Path $ShimDir "vp"))) { + if (Test-Path $legacy) { + Remove-Item -Path $legacy -Force -ErrorAction SilentlyContinue + } + } + } else { + # Pre-trampoline versions: fall back to legacy .cmd and shell script wrappers. + # Remove any stale trampoline .exe shims left by a newer install — .exe wins + # over .cmd on Windows PATH, so leftover trampolines would bypass the wrappers. + foreach ($stale in @("vp.exe", "node.exe", "npm.exe", "npx.exe", "corepack.exe", "vpx.exe", "vpr.exe")) { + $stalePath = Join-Path $ShimDir $stale + if (Test-Path $stalePath) { + Remove-Item -Path $stalePath -Force -ErrorAction SilentlyContinue + } + } + # Pin VP_HOME to the data root. In a split install, $ShimDir is not + # `$InstallDir\bin`. Thus, `%~dp0..` would not find `\current`. + $wrapperContent = @" +@echo off +set VP_HOME=$InstallDir +"%VP_HOME%\current\bin\vp.exe" %* +exit /b %ERRORLEVEL% +"@ + Set-Content -Path (Join-Path $ShimDir "vp.cmd") -Value $wrapperContent -NoNewline + + # Also create shell script wrapper for Git Bash/MSYS + $installDirUnix = $InstallDir -replace '\\', '/' + $shContent = @" +#!/bin/sh +VP_HOME="$installDirUnix" +export VP_HOME +exec "`$VP_HOME/current/bin/vp.exe" "`$@" +"@ + Set-Content -Path (Join-Path $ShimDir "vp") -Value $shContent -NoNewline + } + + # Cleanup old versions + Cleanup-OldVersions -InstallDir $InstallDir + + # Create env files under the resolved config dir (matches install.sh). + # Use current\bin\vp.exe directly instead of the trampoline so a Windows + # refresh cannot overwrite the running wrapper. + $vpBin = Join-Path $InstallDir "current\bin\vp.exe" + if (Test-Path -LiteralPath $vpBin) { + & $vpBin env setup --env-only | Out-Null + } + + # Setup Node.js version manager (shims) - separate component + $nodeManagerResult = Setup-NodeManager -BinDir $BinDir + if ($nodeManagerResult -eq "true") { + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + & $vpBin env on *> $null + $preferenceExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($preferenceExitCode -ne 0) { + Write-Warn "Failed to record environment management preference." + } + $global:LASTEXITCODE = 0 + } + + Prompt-RemovePreviousInstallDir -PreviousInstallDir $previousInstallDir + + # Configure shell access after the install is otherwise complete. + $pathResult = Configure-UserPath + $nushellResult = Configure-Nushell + + # Use ~ when an install location is under USERPROFILE. Otherwise, show the + # full path. + $displayDataDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~' + $displayBinDir = $ShimDir -replace [regex]::Escape($env:USERPROFILE), '~' + $displayConfigDir = $ConfigDir -replace [regex]::Escape($env:USERPROFILE), '~' + + # ANSI color codes for consistent output + $e = [char]27 + $GREEN = "$e[32m" + $YELLOW = "$e[33m" + $BRIGHT_BLUE = "$e[94m" + $BOLD = "$e[1m" + $DIM = "$e[2m" + $BOLD_BRIGHT_BLUE = "$e[1;94m" + $NC = "$e[0m" + $CHECKMARK = [char]0x2714 + + # Print success message + Write-Host "" + Write-Host "${GREEN}${CHECKMARK}${NC} ${BOLD_BRIGHT_BLUE}VITE+${NC} successfully installed!" + Write-Host "" + Write-Host " The Unified Toolchain for the Web." + Write-Host "" + Write-Host " ${BOLD}Get started:${NC}" + Write-Host " ${BRIGHT_BLUE}vp create${NC} Create a new project" + Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" + Write-Host " ${BRIGHT_BLUE}vp install${NC} Install dependencies" + Write-Host " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" + + # Show Node.js manager status + if ($nodeManagerResult -eq "true" -or $nodeManagerResult -eq "already") { + Write-Host "" + Write-Host " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." + Write-Host " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." + } + + Write-Host "" + Write-Host " Run ${BRIGHT_BLUE}vp help${NC} to see available commands." + + Write-Host "" + Write-Host " ${BOLD}Install locations:${NC}" + Write-Host " Data directory: $displayDataDir" + Write-Host " Bin directory: $displayBinDir" + + Write-Host "" + Write-Host " Shell configuration:" + switch ($pathResult) { + "true" { Write-Host " - Windows PATH: updated" } + "already" { Write-Host " - Windows PATH: already configured" } + "failed" { Write-Host " - Windows PATH: failed" } + default { Write-Host " - Windows PATH: skipped" } + } + if ($nushellResult.Status -ne "skipped") { + Write-Host " - Nushell: $($nushellResult.Message)" + } + + # Show note if PATH or Nushell was updated + if ($pathResult -eq "true" -or $nushellResult.Status -eq "true") { + Write-Host "" + Write-Host " Note: Restart your terminal and IDE for changes to take effect." + } + + # Show manual PATH/Nushell instructions if anything still needs manual setup + if ($pathResult -eq "failed" -or $nushellResult.Status -eq "failed") { + Write-Host "" + Write-Host " ${YELLOW}note${NC}: Some shells still need manual setup." + Write-Host "" + if ($pathResult -eq "failed") { + Write-Host " To use vp in Powershell/cmd, manually add it to your PATH:" + Write-Host "" + Write-Host " [Environment]::SetEnvironmentVariable('Path', '$ShimDir;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" + Write-Host "" + } + if ($nushellResult.Status -eq "failed") { + Write-Host " To use vp in Nushell, create a vite-plus.nu file in your preferred vendor autoload directory with:" + Write-Host "" + Write-Host " source '$displayConfigDir\env.nu'" + Write-Host "" + } + Write-Host " Or run vp directly:" + Write-Host "" + Write-Host " & `"$(Join-Path $ShimDir 'vp.exe')`"" + } + + Write-Host "" +} + +function Apply-DirsFromVp { + param([string]$VpBinary) + $previous = $env:VP_DUMP_DIRS + $env:VP_DUMP_DIRS = "1" + try { + $out = & $VpBinary 2>$null + } finally { + if ($null -eq $previous) { + Remove-Item Env:VP_DUMP_DIRS -ErrorAction SilentlyContinue + } else { + $env:VP_DUMP_DIRS = $previous + } + } + $map = @{} + foreach ($line in @($out)) { + $text = "$line" + $sep = $text.IndexOf("`t") + if ($sep -lt 1) { + continue + } + $map[$text.Substring(0, $sep)] = $text.Substring($sep + 1) + } + if (-not $map['data'] -or -not $map['bin'] -or -not $map['cache'] -or -not $map['config'] -or -not $map['state']) { + return $false + } + $layoutKind = $map['layout'] + if ($layoutKind -ne 'single-root' -and $layoutKind -ne 'split') { + $isSingleRoot = (Normalize-InstallDir $map['bin']) -eq (Normalize-InstallDir (Join-Path $map['data'] 'bin')) ` + -and (Normalize-InstallDir $map['cache']) -eq (Normalize-InstallDir (Join-Path $map['data'] 'cache')) ` + -and (Normalize-InstallDir $map['config']) -eq (Normalize-InstallDir $map['data']) ` + -and (Normalize-InstallDir $map['state']) -eq (Normalize-InstallDir $map['data']) + $layoutKind = if ($isSingleRoot) { 'single-root' } else { 'split' } + } + $script:Layout = [pscustomobject]@{ + Kind = $layoutKind + DataDir = $map['data'] + ShimDir = $map['bin'] + CacheDir = $map['cache'] + ConfigDir = $map['config'] + StateDir = $map['state'] + } + return $true +} + +try { + Main +} catch { + if (Test-IsInstallStopException $_) { + if (Test-ShouldKeepShellOpenAfterFailure) { + return + } + exit $global:LASTEXITCODE + } + throw +} diff --git a/packages/cli/install-legacy.sh b/packages/cli/install-legacy.sh new file mode 100644 index 0000000000..d6b316c4f2 --- /dev/null +++ b/packages/cli/install-legacy.sh @@ -0,0 +1,1230 @@ +#!/bin/bash +# Vite+ CLI Installer +# https://vite.plus +# +# Invoked by install.sh with an acquired binary, resolved version, and optional preview ref. +# Target resolution and platform downloads belong to the bootstrap. +# +# Environment variables: +# VP_HOME - Optional pin for the monolithic layout. If unset, Vite+ reuses an +# existing ~/.vite-plus install. Otherwise, the complete VP_*_DIR +# group, XDG_*, or platform defaults select the split roots. +# VP_BIN_DIR / VP_DATA_DIR / VP_CACHE_DIR - Complete group of absolute +# category overrides +# XDG_DATA_HOME / XDG_CONFIG_HOME / … - Unix split defaults +# NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) +# VP_NODE_MANAGER - Set to "yes" or "no" to skip interactive prompt (for CI/devcontainers) +# VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) +# VP_PR_VERSION - PR number or commit SHA to install from the registry bridge +# (for temporary testing of unreleased builds, e.g. VP_PR_VERSION=1569). +# When set, overrides VP_VERSION and installs the clearly-defined +# 0.0.0-commit. build through the bridge instead of npm. + +set -e + +VP_VERSION="${VP_VERSION:-latest}" +# After these helper definitions, the selected payload resolves category roots +# through VP_DUMP_DIRS. Pre-split payloads use the legacy layout. +# Local tarball for development/testing +LOCAL_TGZ="${VP_LOCAL_TGZ:-}" +# PR number or commit SHA to install as a test build (registry bridge mode) +PR_VERSION="${VP_PR_VERSION:-}" +BRIDGE_REGISTRY="https://registry-bridge.viteplus.dev/" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +BRIGHT_BLUE='\033[0;94m' +BOLD='\033[1m' +DIM='\033[2m' +BOLD_BRIGHT_BLUE='\033[1;94m' +NC='\033[0m' # No Color + +info() { + echo -e "${BLUE}info${NC}: $1" +} + +success() { + echo -e "${GREEN}success${NC}: $1" +} + +warn() { + echo -e "${YELLOW}warn${NC}: $1" +} + +trace() { + [ "${VP_LOG:-}" = "trace" ] || return 0 + echo -e "${DIM}trace${NC}: $1" +} + +report_shell_config_error() { + if [ "${CI:-}" = "true" ]; then + trace "$1" + else + warn "$1" + fi +} + +error() { + echo -e "${RED}error${NC}: $1" + exit 1 +} + +is_release_age_error() { + local log_file="$1" + [ -f "$log_file" ] || return 1 + + # This wrapper install path is pinned to pnpm via packageManager, so this + # detection follows pnpm's resolver/reporter output rather than npm/yarn. + # + # pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so + # NO_MATURE_MATCHING_VERSION is normally printed as + # ERR_PNPM_NO_MATURE_MATCHING_VERSION. npm-resolver emits that code with the + # "does not meet the minimumReleaseAge constraint" message when + # publishedBy/minimumReleaseAge rejects a matching version. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 + # + # default-reporter may append guidance mentioning minimumReleaseAgeExclude + # when the error has an immatureVersion, so that token is also a useful + # release-age signal. minimum-release-age is pnpm's .npmrc key; npm's + # min-release-age is intentionally not treated as a pnpm signal here. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 + grep -Eqi 'ERR_PNPM_NO_MATURE_MATCHING_VERSION|NO_MATURE_MATCHING_VERSION|does not meet the minimumReleaseAge constraint|minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" && return 0 + + # pnpm can also surface ERR_PNPM_NO_MATCHING_VERSION when minimumReleaseAge + # filters out all candidates. That code is also used for real missing + # versions, so require age-gate context before prompting for a bypass. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 + if grep -Eq 'ERR_PNPM_NO_MATCHING_VERSION' "$log_file"; then + grep -Eqi 'minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" + return $? + fi + + return 1 +} + +confirm_release_age_override() { + [ -e /dev/tty ] && [ -t 1 ] || return 1 + + echo "" > /dev/tty + echo -e "${YELLOW}warn${NC}: Your minimumReleaseAge setting prevented installing vite-plus@${VP_VERSION}." > /dev/tty + echo "This setting helps protect against newly published compromised packages." > /dev/tty + echo "Proceeding will disable this protection for this Vite+ install only." > /dev/tty + printf "Do you want to proceed? (y/N): " > /dev/tty + + local response + read -r response < /dev/tty || return 1 + case "$response" in + y|Y|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +write_release_age_override() { + # Append idempotently so a bridge registry line written for PR builds survives. + if [ ! -f "$VERSION_DIR/.npmrc" ] || ! grep -q '^minimum-release-age=' "$VERSION_DIR/.npmrc" 2>/dev/null; then + printf 'minimum-release-age=0\n' >> "$VERSION_DIR/.npmrc" + fi +} + +is_absolute_path() { + case "$1" in + /*) return 0 ;; + [A-Za-z]:[\\/]*) return 0 ;; + *) return 1 ;; + esac +} + +# Print $1 when it is a non-empty absolute path; otherwise print nothing. +absolute_override() { + local val="$1" + if [ -n "$val" ] && is_absolute_path "$val"; then + printf '%s\n' "$val" + fi +} + +validate_vp_dir_overrides() { + local count=0 value + for value in "${VP_BIN_DIR:-}" "${VP_DATA_DIR:-}" "${VP_CACHE_DIR:-}"; do + [ -z "$value" ] || count=$((count + 1)) + done + if [ "$count" -ne 0 ] && [ "$count" -ne 3 ]; then + error "Set VP_BIN_DIR, VP_DATA_DIR, and VP_CACHE_DIR together, or leave all three unset." + fi + if [ "$count" -eq 3 ]; then + is_absolute_path "$VP_BIN_DIR" || error "VP_BIN_DIR must be an absolute path." + is_absolute_path "$VP_DATA_DIR" || error "VP_DATA_DIR must be an absolute path." + is_absolute_path "$VP_CACHE_DIR" || error "VP_CACHE_DIR must be an absolute path." + fi +} + +is_windows_uname() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + +resolution_home_dir() { + if is_windows_uname; then + printf '%s\n' "${USERPROFILE:-$HOME}" + else + printf '%s\n' "${HOME:-$USERPROFILE}" + fi +} + +# Released setup-vp versions add ~/.vite-plus/bin to the GitHub Actions or +# GitLab CI/CD PATH. They do this after the installer exits. Use the monolithic +# layout until setup-vp declares support for VP_DUMP_DIRS. +enable_setup_vp_legacy_compatibility() { + if [ "${GITHUB_ACTION_REPOSITORY:-}" != "voidzero-dev/setup-vp" ]; then + [ "${GITLAB_CI:-}" = "true" ] || return 0 + [ -n "${SETUP_VP_SETUP_REF:-}" ] || return 0 + fi + [ "${VP_VPDIRS_AWARE:-}" != "1" ] || return 0 + [ -z "${VP_HOME:-}" ] || return 0 + [ -z "${VP_BIN_DIR:-}" ] || return 0 + [ -z "${VP_DATA_DIR:-}" ] || return 0 + [ -z "${VP_CACHE_DIR:-}" ] || return 0 + + local resolution_home + resolution_home="$(resolution_home_dir)" + [ -n "$resolution_home" ] || error "Vite+ could not resolve the user home directory." + VP_HOME="$resolution_home/.vite-plus" + export VP_HOME +} + +# Escape a path fragment for a Bash/Zsh double-quoted string. `$HOME` is +# added separately when the config directory is under the user home. +escape_posix_double_quoted() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\$/\\\$}" + value="${value//\`/\\\`}" + value="${value//\"/\\\"}" + printf '%s' "$value" +} + +# Fish double-quoted strings do not evaluate backticks, but `$`, `"`, and +# backslashes still need escaping. +escape_fish_double_quoted() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\$/\\\$}" + value="${value//\"/\\\"}" + printf '%s' "$value" +} + +# Nushell expands values only in interpolated strings (`$"..."`). In a plain +# double-quoted string only backslashes and double quotes need escaping. +escape_nu_double_quoted() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf '%s' "$value" +} + +set_config_dir_refs() { + local dir="$1" + local shell_home="$2" + local suffix + if [ -n "$shell_home" ] && case "$dir" in "$shell_home"/*) true;; *) false;; esac; then + suffix="${dir#"$shell_home"}" + CONFIG_DIR_REF_POSIX="\$HOME$(escape_posix_double_quoted "$suffix")" + CONFIG_DIR_REF_FISH="\$HOME$(escape_fish_double_quoted "$suffix")" + CONFIG_DIR_REF_NU="~$(escape_nu_double_quoted "$suffix")" + else + CONFIG_DIR_REF_POSIX="$(escape_posix_double_quoted "$dir")" + CONFIG_DIR_REF_FISH="$(escape_fish_double_quoted "$dir")" + CONFIG_DIR_REF_NU="$(escape_nu_double_quoted "$dir")" + fi +} + +# Monolithic mapping: every category on one root. +set_monolithic_layout() { + LAYOUT_KIND="single-root" + INSTALL_DIR="$1" + SHIM_DIR="$1/bin" + CACHE_DIR="$1/cache" + CONFIG_DIR="$1" + STATE_DIR="$1" +} + +# Pre-split releases resolve all paths from VP_HOME, which defaults to +# ~/.vite-plus. Install them in this monolithic root. This keeps environment +# setup, shims, upgrades, and installer paths consistent. +use_legacy_layout() { + local resolution_home vp_home + resolution_home="$(resolution_home_dir)" + [ -n "$resolution_home" ] || error "Vite+ could not resolve the user home directory." + vp_home="$(absolute_override "${VP_HOME:-}")" + set_monolithic_layout "${vp_home:-$resolution_home/.vite-plus}" + set_config_dir_refs "$CONFIG_DIR" "${HOME:-}" +} + +normalize_existing_dir() { + local dir="${1%/}" + if [ -z "$dir" ]; then + dir="/" + fi + + if [ -d "$dir" ]; then + (cd "$dir" 2>/dev/null && pwd -P) || printf '%s\n' "$dir" + else + local base parent_dir + base="$(basename "$dir")" + parent_dir="$(cd "$(dirname "$dir")" 2>/dev/null && pwd -P)" || parent_dir="" + if [ -z "$parent_dir" ]; then + printf '%s\n' "$dir" + elif [ "$parent_dir" = "/" ]; then + printf '/%s\n' "$base" + else + printf '%s/%s\n' "$parent_dir" "$base" + fi + fi +} + +is_safe_install_dir_to_remove() { + local dir="$1" + [ -n "$dir" ] || return 1 + + case "$dir" in + "/" | "$HOME" | "/bin" | "/opt" | "/usr" | "/usr/bin" | "/usr/local" | "/usr/local/bin") + return 1 + ;; + esac + + return 0 +} + +is_vite_plus_install_dir() { + local dir="$1" + [ -d "$dir" ] || return 1 + [ -d "$dir/bin" ] || return 1 + [ -e "$dir/current" ] || return 1 + [ -e "$dir/bin/vp" ] || [ -e "$dir/bin/vp.exe" ] || [ -e "$dir/bin/vp.cmd" ] +} + +detect_previous_install_dir() { + [ -n "${VP_HOME:-}" ] || return 1 + + local vp_path + vp_path="$(command -v vp 2>/dev/null || true)" + [ -n "$vp_path" ] || return 1 + + case "$(basename "$vp_path")" in + vp | vp.exe | vp.cmd) ;; + *) return 1 ;; + esac + + local old_dir install_dir + old_dir="$(normalize_existing_dir "$(dirname "$(dirname "$vp_path")")")" + install_dir="$(normalize_existing_dir "$INSTALL_DIR")" + [ "$old_dir" != "$install_dir" ] || return 1 + + is_safe_install_dir_to_remove "$old_dir" || return 1 + is_vite_plus_install_dir "$old_dir" || return 1 + + printf '%s\n' "$old_dir" +} + +is_nested_install_dir() { + [ -n "$1" ] && [ -n "$2" ] || return 1 + + local old_dir install_dir + old_dir="$(normalize_existing_dir "$1")" + install_dir="$(normalize_existing_dir "$2")" + + [ "$old_dir" != "$install_dir" ] || return 1 + if [ "$old_dir" = "/" ] || [ "$install_dir" = "/" ]; then + return 0 + fi + + case "$old_dir" in + "$install_dir"/*) return 0 ;; + esac + case "$install_dir" in + "$old_dir"/*) return 0 ;; + esac + + return 1 +} + +prompt_remove_previous_install_dir() { + local old_dir="$1" + [ -n "$old_dir" ] || return 0 + [ -z "${CI:-}" ] || return 0 + [ -e /dev/tty ] && [ -t 1 ] || return 0 + + echo "" > /dev/tty + echo -e "${YELLOW}warn${NC}: Found a previous Vite+ install at $old_dir." > /dev/tty + echo "The new VP_HOME is $INSTALL_DIR." > /dev/tty + printf "Remove the previous install directory? (y/N): " > /dev/tty + + local response + read -r response < /dev/tty || return 0 + case "$response" in + y | Y | yes | YES) + local vp_bin="$old_dir/current/bin/vp" + if [ ! -f "$vp_bin" ]; then + vp_bin="$old_dir/current/bin/vp.exe" + fi + if [ ! -f "$vp_bin" ]; then + warn "Could not remove previous Vite+ install at $old_dir: vp binary not found." + return 0 + fi + + local implode_output + if implode_output=$(VP_HOME="$old_dir" "$vp_bin" implode --yes 2>&1); then + success "Removed previous Vite+ install at $old_dir." + else + warn "Could not remove previous Vite+ install at $old_dir." + if [ -n "$implode_output" ]; then + printf '%s\n' "$implode_output" >&2 + fi + fi + ;; + esac +} + +print_install_failure() { + local install_log="$1" + if [ "${CI:-}" = "true" ]; then + echo -e "${RED}error${NC}: Failed to install dependencies. Log output:" + cat "$install_log" + else + echo -e "${RED}error${NC}: Failed to install dependencies. See log for details: $install_log" + fi +} + +print_release_age_failure() { + local install_log="$1" + if [ "${CI:-}" = "true" ]; then + echo -e "${RED}error${NC}: Install blocked by your minimumReleaseAge setting. Log output:" + cat "$install_log" + else + echo -e "${RED}error${NC}: Install blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly. See log for details: $install_log" + fi +} + +# Detect libc type on Linux (gnu or musl) +detect_libc() { + # Prefer positive glibc detection first. + # This avoids false musl detection on systems where musl is installed + # but the distro itself is glibc-based (common on WSL/Ubuntu). + if command -v getconf &> /dev/null; then + if getconf GNU_LIBC_VERSION > /dev/null 2>&1; then + echo "gnu" + return + fi + fi + + # Check ldd output for musl/glibc + if command -v ldd &> /dev/null; then + ldd_out="$(ldd --version 2>&1 || true)" + if echo "$ldd_out" | grep -qi musl; then + echo "musl" + return + fi + if echo "$ldd_out" | grep -qi 'gnu libc'; then + echo "gnu" + return + fi + if echo "$ldd_out" | grep -qi 'glibc'; then + echo "gnu" + return + fi + fi + + # Final fallback: musl loader present usually indicates musl-based distro, + # but only check this after glibc detection to avoid false positives. + if [ -e /lib/ld-musl-x86_64.so.1 ] || [ -e /lib/ld-musl-aarch64.so.1 ]; then + echo "musl" + else + echo "gnu" + fi +} + +# Detect platform +detect_platform() { + local os arch + + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + MINGW*|MSYS*|CYGWIN*) os="win32" ;; + *) error "Unsupported operating system: $os" ;; + esac + + case "$arch" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) error "Unsupported architecture: $arch" ;; + esac + + # For Linux, append libc type to distinguish gnu vs musl + if [ "$os" = "linux" ]; then + local libc + libc=$(detect_libc) + echo "${os}-${arch}-${libc}" + else + echo "${os}-${arch}" + fi +} + +join_by() { + local separator="$1" + shift + local result="" + local item + + for item in "$@"; do + if [ -z "$result" ]; then + result="$item" + else + result="${result}${separator}${item}" + fi + done + + printf '%s\n' "$result" +} + +abbreviate_path() { + local path="$1" + if [ "${path#"$HOME"}" != "$path" ]; then + printf '~%s\n' "${path#"$HOME"}" + else + printf '%s\n' "$path" + fi +} + +record_shell_summary() { + local shell_name="$1" + local status="$2" + SHELL_CONFIG_SUMMARY+=(" - ${shell_name}: ${status}") +} + +# Add a sourcing line to an existing shell config file. +# Returns: 0 = line added, 1 = file missing, 2 = already configured, 3 = failed +append_source_to_file() { + local shell_config="$1" + local source_line="$2" + shift 2 + local search_patterns=("$@") + local pattern + + if [ ! -f "$shell_config" ]; then + return 1 + fi + + if [ ! -w "$shell_config" ]; then + report_shell_config_error "Cannot write to $shell_config (permission denied), skipping." + return 3 + fi + + for pattern in "${search_patterns[@]}"; do + if grep -Fq "$pattern" "$shell_config" 2>/dev/null; then + return 2 + fi + done + + { + printf '\n' + printf '%s\n' "# Vite+ bin (https://viteplus.dev)" + printf '%s\n' "$source_line" + } >> "$shell_config" + return 0 +} + +# Create or update an installer-managed snippet file. +# Returns: 0 = written, 2 = already configured, 3 = failed +write_managed_snippet() { + local snippet_file="$1" + local snippet_content="$2" + local snippet_dir + + snippet_dir=$(dirname "$snippet_file") + if ! mkdir -p "$snippet_dir" 2>/dev/null; then + report_shell_config_error "Cannot create $snippet_dir, skipping." + return 3 + fi + + if [ -f "$snippet_file" ] && [ ! -w "$snippet_file" ]; then + report_shell_config_error "Cannot write to $snippet_file (permission denied), skipping." + return 3 + fi + + if [ -f "$snippet_file" ] && printf '%s' "$snippet_content" | cmp -s - "$snippet_file"; then + return 2 + fi + + if ! printf '%s' "$snippet_content" > "$snippet_file"; then + report_shell_config_error "Cannot write to $snippet_file, skipping." + return 3 + fi + return 0 +} + +# Discover Nushell's preferred user-local vendor autoload directory. +# Nushell puts the user-local directory at the end of the list. +discover_nushell_vendor_autoload_dir() { + command -v nu > /dev/null 2>&1 || return 1 + + local nu_dirs_output + nu_dirs_output=$(nu -c '$nu.vendor-autoload-dirs | reverse | each {|dir| $dir } | str join (char nl)' 2>/dev/null) || return 1 + + while IFS= read -r dir; do + [ -n "$dir" ] || continue + printf '%s\n' "$dir" + return 0 + done </dev/null; then + report_shell_config_error "Cannot create $zsh_dir, skipping zsh." + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("zsh") + record_shell_summary "zsh" "failed (could not create $(abbreviate_path "$zsh_dir"))" + return + fi + + if [ ! -f "$zshenv" ] && ! touch "$zshenv" 2>/dev/null; then + report_shell_config_error "Cannot create $zshenv, skipping zsh." + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("zsh") + record_shell_summary "zsh" "failed (could not create $(abbreviate_path "$zshenv"))" + return + fi + + result=0 + append_source_to_file "$zshenv" ". \"$CONFIG_DIR_REF_POSIX/env\"" "$CONFIG_DIR/env" "$CONFIG_DIR_REF_POSIX/env" || result=$? + case "$result" in + 0) updated+=("$(abbreviate_path "$zshenv")") ;; + 2) already+=("$(abbreviate_path "$zshenv")") ;; + 3) failed+=("$(abbreviate_path "$zshenv")") ;; + esac + + if [ -f "$zshrc" ]; then + result=0 + append_source_to_file "$zshrc" ". \"$CONFIG_DIR_REF_POSIX/env\"" "$CONFIG_DIR/env" "$CONFIG_DIR_REF_POSIX/env" || result=$? + case "$result" in + 0) updated+=("$(abbreviate_path "$zshrc")") ;; + 2) already+=("$(abbreviate_path "$zshrc")") ;; + 3) failed+=("$(abbreviate_path "$zshrc")") ;; + esac + fi + + local details=() + if [ ${#updated[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_UPDATED="true" + SHELL_CONFIG_HAS_CONFIGURED="true" + details+=("updated $(join_by ', ' "${updated[@]}")") + fi + if [ ${#already[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_CONFIGURED="true" + details+=("already configured $(join_by ', ' "${already[@]}")") + fi + if [ ${#failed[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("zsh") + details+=("failed $(join_by ', ' "${failed[@]}")") + fi + + if [ ${#details[@]} -eq 0 ]; then + record_shell_summary "zsh" "skipped" + else + record_shell_summary "zsh" "$(join_by '; ' "${details[@]}")" + fi +} + +configure_bash_path() { + local updated=() + local already=() + local failed=() + local existing=0 + local file result + + for file in "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do + if [ ! -f "$file" ]; then + continue + fi + existing=1 + result=0 + append_source_to_file "$file" ". \"$CONFIG_DIR_REF_POSIX/env\"" "$CONFIG_DIR/env" "$CONFIG_DIR_REF_POSIX/env" || result=$? + case "$result" in + 0) updated+=("$(abbreviate_path "$file")") ;; + 2) already+=("$(abbreviate_path "$file")") ;; + 3) failed+=("$(abbreviate_path "$file")") ;; + esac + done + + if [ "$existing" -eq 0 ]; then + record_shell_summary "bash" "skipped (no existing rc files)" + return + fi + + local details=() + if [ ${#updated[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_UPDATED="true" + SHELL_CONFIG_HAS_CONFIGURED="true" + details+=("updated $(join_by ', ' "${updated[@]}")") + fi + if [ ${#already[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_CONFIGURED="true" + details+=("already configured $(join_by ', ' "${already[@]}")") + fi + if [ ${#failed[@]} -gt 0 ]; then + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("bash") + details+=("failed $(join_by ', ' "${failed[@]}")") + fi + + record_shell_summary "bash" "$(join_by '; ' "${details[@]}")" +} + +configure_fish_path() { + local fish_config="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish" + local fish_content="# Vite+ bin (https://viteplus.dev) +source \"$CONFIG_DIR_REF_FISH/env.fish\" +" + + local result=0 + write_managed_snippet "$fish_config" "$fish_content" || result=$? + case "$result" in + 0) + SHELL_CONFIG_HAS_UPDATED="true" + SHELL_CONFIG_HAS_CONFIGURED="true" + record_shell_summary "fish" "updated $(abbreviate_path "$fish_config")" + ;; + 2) + SHELL_CONFIG_HAS_CONFIGURED="true" + record_shell_summary "fish" "already configured $(abbreviate_path "$fish_config")" + ;; + *) + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("fish") + record_shell_summary "fish" "failed $(abbreviate_path "$fish_config")" + ;; + esac +} + +configure_nushell_path() { + local nushell_dir + nushell_dir=$(discover_nushell_vendor_autoload_dir 2>/dev/null) || true + if [ -z "$nushell_dir" ]; then + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("nushell") + record_shell_summary "nushell" "failed (could not determine vendor autoload dir)" + return + fi + + local nushell_autoload="$nushell_dir/vite-plus.nu" + local nushell_content="# Vite+ bin (https://viteplus.dev) +source \"$CONFIG_DIR_REF_NU/env.nu\" +" + + local result=0 + write_managed_snippet "$nushell_autoload" "$nushell_content" || result=$? + case "$result" in + 0) + SHELL_CONFIG_HAS_UPDATED="true" + SHELL_CONFIG_HAS_CONFIGURED="true" + record_shell_summary "nushell" "updated $(abbreviate_path "$nushell_autoload")" + ;; + 2) + SHELL_CONFIG_HAS_CONFIGURED="true" + record_shell_summary "nushell" "already configured $(abbreviate_path "$nushell_autoload")" + ;; + *) + SHELL_CONFIG_HAS_FAILURE="true" + SHELL_CONFIG_FAILED_SHELLS+=("nushell") + record_shell_summary "nushell" "failed $(abbreviate_path "$nushell_autoload")" + ;; + esac +} + +# Configure supported shell PATH integrations for all installed shells. +configure_shell_path() { + SHELL_CONFIG_SUMMARY=() + SHELL_CONFIG_FAILED_SHELLS=() + SHELL_CONFIG_HAS_UPDATED="false" + SHELL_CONFIG_HAS_CONFIGURED="false" + SHELL_CONFIG_HAS_FAILURE="false" + + if command -v zsh > /dev/null 2>&1; then + configure_zsh_path + else + record_shell_summary "zsh" "skipped (not installed)" + fi + + if command -v bash > /dev/null 2>&1; then + configure_bash_path + else + record_shell_summary "bash" "skipped (not installed)" + fi + + if command -v fish > /dev/null 2>&1; then + configure_fish_path + else + record_shell_summary "fish" "skipped (not installed)" + fi + + if command -v nu > /dev/null 2>&1; then + configure_nushell_path + else + record_shell_summary "nushell" "skipped (not installed)" + fi +} + +# Run vp env setup --refresh, showing output only on failure +# Arguments: vp_bin - path to the vp binary +refresh_shims() { + local vp_bin="$1" + local setup_output + if ! setup_output=$("$vp_bin" env setup --refresh 2>&1); then + warn "Failed to refresh shims:" + echo "$setup_output" >&2 + fi +} + +# Return success only if this Vite+ install owns the existing Node entry. A bin +# from an explicit override group can be shared. Entry existence does not permit +# replacement. +is_vite_plus_node_shim() { + local bin_path="$1" + local vp_bin="$2" + + # Unix shims are symlinks to the active vp binary. `-ef` follows the link. It + # accepts the old relative target and the absolute split-layout target. + if [ -L "$bin_path/node" ] && [ "$bin_path/node" -ef "$vp_bin" ]; then + return 0 + fi + + # install.sh can also run under Git Bash/MSYS. Windows trampolines carry a + # per-executable sidecar that records the owning data root. + if [ -f "$bin_path/node.exe" ] && [ -f "$bin_path/node.shim" ]; then + local pointer="" + pointer="$(shim_pointer_data "$bin_path/node.shim")" || return 1 + [ "$pointer" = "$INSTALL_DIR" ] && return 0 + fi + + return 1 +} + +shim_pointer_data() { + local file="$1" first="" line="" + IFS= read -r first < "$file" || [ -n "$first" ] || return 1 + first="${first%$'\r'}" + if [ "$first" != "vite-plus-shim-v1" ]; then + return 1 + fi + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in + data=*) printf '%s\n' "${line#data=}"; return 0 ;; + esac + done < "$file" + return 1 +} + +# Setup Vite+ environment shims +# Sets NODE_MANAGER_ENABLED global +# Arguments: bin_dir - path to the version's bin directory containing vp +setup_node_manager() { + local bin_dir="$1" + local bin_path="$SHIM_DIR" + NODE_MANAGER_ENABLED="false" + + # Resolve vp binary name (vp on Unix, vp.exe on Windows) + local vp_bin="$bin_dir/vp" + if [ -f "$bin_dir/vp.exe" ]; then + vp_bin="$bin_dir/vp.exe" + fi + + # Explicit override via environment variable + if [ "$VP_NODE_MANAGER" = "yes" ]; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="true" + return 0 + elif [ "$VP_NODE_MANAGER" = "no" ]; then + NODE_MANAGER_ENABLED="false" + return 0 + fi + + # Check if an existing Node entry is a Vite+ shim. A foreign entry in a custom + # bin directory prevents automatic enablement. The prompt below can get + # permission to replace the entry. + local unmanaged_node_in_bin="false" + if [ -e "$bin_path/node" ] || [ -L "$bin_path/node" ] || [ -e "$bin_path/node.exe" ]; then + if is_vite_plus_node_shim "$bin_path" "$vp_bin"; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="already" + return 0 + fi + unmanaged_node_in_bin="true" + fi + + # Auto-enable on CI or devcontainer environments + # CI: standard CI environment variable (GitHub Actions, Travis, CircleCI, etc.) + # CODESPACES: set by GitHub Codespaces (https://docs.github.com/en/codespaces) + # REMOTE_CONTAINERS: set by VS Code Dev Containers extension + # DEVPOD: set by DevPod (https://devpod.sh) + if [ "$unmanaged_node_in_bin" = "false" ] && { [ -n "$CI" ] || [ -n "$CODESPACES" ] || [ -n "$REMOTE_CONTAINERS" ] || [ -n "$DEVPOD" ]; }; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="true" + return 0 + fi + + # Check if node is available on the system + local node_available="false" + if command -v node &> /dev/null; then + node_available="true" + fi + + # Auto-enable if no node available on system + if [ "$node_available" = "false" ] && [ "$unmanaged_node_in_bin" = "false" ]; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="true" + return 0 + fi + + # Prompt user in interactive mode + if [ -e /dev/tty ] && [ -t 1 ]; then + echo "" + echo "Would you like Vite+ to manage your Node.js and package-manager versions?" + echo "Vite+ adds \`node\`, \`npm\`, \`npx\`, \`pnpm\`, \`pnpx\`, \`yarn\`, \`yarnpkg\`, \`bun\`, and \`bunx\` shims to $(abbreviate_path "$SHIM_DIR")." + echo "It selects the required version automatically." + echo "Opt out anytime with \`vp env off\`." + echo -n "Press Enter to accept (Y/n): " + read -r response < /dev/tty + + if [ -z "$response" ] || [ "$response" = "y" ] || [ "$response" = "Y" ]; then + refresh_shims "$vp_bin" + NODE_MANAGER_ENABLED="true" + fi + fi +} + +# Cleanup old versions, keeping only the most recent ones +cleanup_old_versions() { + local max_versions=3 + local versions=() + + # List version directories (semver format like 0.1.0, 1.2.3-beta.1, 0.0.0-f48af939.20260205-0533) + # This excludes 'current' symlink and non-semver directories like 'local-dev' + local semver_regex='^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$' + for dir in "$INSTALL_DIR"/*/; do + local name + name=$(basename "$dir") + if [ -d "$dir" ] && [[ "$name" =~ $semver_regex ]]; then + versions+=("$dir") + fi + done + + local count=${#versions[@]} + if [ "$count" -le "$max_versions" ]; then + return 0 + fi + + # Sort by creation time (oldest first) and delete excess + local sorted_versions + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS: use stat -f %B for birth time + sorted_versions=$(for v in "${versions[@]}"; do + echo "$(stat -f %B "$v") $v" + done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-) + else + # Linux: use stat -c %W for birth time, fallback to %Y (mtime) + sorted_versions=$(for v in "${versions[@]}"; do + local btime + btime=$(stat -c %W "$v" 2>/dev/null) + if [ "$btime" = "0" ] || [ -z "$btime" ]; then + btime=$(stat -c %Y "$v") + fi + echo "$btime $v" + done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-) + fi + + # Delete oldest versions (silently) + for old_version in $sorted_versions; do + rm -rf "$old_version" + done +} + +main() { + echo "" + echo -e "Setting up VITE+..." + + if [ -n "$PR_VERSION" ] && [ -n "$LOCAL_TGZ" ]; then + error "VP_PR_VERSION and VP_LOCAL_TGZ cannot be used together" + fi + + validate_vp_dir_overrides + enable_setup_vp_legacy_compatibility + local binary_source="${1:-}" + VP_VERSION="${2:-}" + PR_VERSION="${3:-}" + [ -f "$binary_source" ] && [ -n "$VP_VERSION" ] || error "Run install.sh to resolve and download the installer payload." + if [ -n "$PR_VERSION" ]; then + PR_COMMIT_VERSION="$VP_VERSION" + VP_VERSION="pkg-pr-new-$PR_VERSION" + fi + local platform binary_name previous_install_dir="" + platform=$(detect_platform) + binary_name="vp" + if [[ "$platform" == win32* ]]; then binary_name="vp.exe"; fi + + if ! apply_dirs_from_vp "$binary_source"; then + use_legacy_layout + info "vite-plus ${VP_VERSION} does not support the split directory layout. Vite+ will install it in $(abbreviate_path "$INSTALL_DIR")." + fi + + # Run layout migration checks after the payload resolves the category roots. + # A pre-split payload selects the legacy layout first. + previous_install_dir="$(detect_previous_install_dir || true)" + if [ -n "$previous_install_dir" ] && is_nested_install_dir "$previous_install_dir" "$INSTALL_DIR"; then + error "The previous Vite+ install at $previous_install_dir overlaps with VP_HOME $INSTALL_DIR. Set VP_HOME to a directory that does not overlap. Alternatively, remove the previous install." + fi + + # Set up version-specific directories + VERSION_DIR="$INSTALL_DIR/$VP_VERSION" + BIN_DIR="$VERSION_DIR/bin" + CURRENT_LINK="$INSTALL_DIR/current" + + # Create bin directory + mkdir -p "$BIN_DIR" + + if [ -n "$LOCAL_TGZ" ]; then + info "Vite+ uses the local tarball: $LOCAL_TGZ" + fi + cp "$binary_source" "$BIN_DIR/$binary_name" + chmod +x "$BIN_DIR/$binary_name" + local shim_src="$(dirname "$binary_source")/vp-shim.exe" + if [[ "$platform" == win32* ]] && [ -f "$shim_src" ]; then + cp "$shim_src" "$BIN_DIR/vp-shim.exe" + fi + + # Generate wrapper package.json that declares vite-plus as a dependency. + # pnpm will install vite-plus and all transitive deps via `vp install`. + # The packageManager field pins pnpm to a known-good version, ensuring + # consistent behavior regardless of the user's global pnpm version. + # In PR mode, pin vite-plus to the bridge's clearly-defined commit version and + # resolve it (plus its platform binaries and transitive deps) through the + # bridge registry written to .npmrc below. The bridge rewrites a preview + # tarball's transitive deps to versions, not self-contained URLs, so a full + # install must go through the registry rather than the bare download URL. + local vite_plus_spec="$VP_VERSION" + if [ -n "$PR_VERSION" ]; then + vite_plus_spec="$PR_COMMIT_VERSION" + # Resolve the commit version + platform binaries through the bridge. Drop any + # stale wrapper lockfile: the pkg-pr-new- dir is reused across a PR's + # commits and install.sh rewrites this package.json each run, so a leftover + # lockfile pinning a prior spec would fail `vp install` with + # ERR_PNPM_OUTDATED_LOCKFILE under CI's frozen-lockfile default. Removing it + # lets the install regenerate a lockfile matching the spec we just wrote. + printf 'registry=%s\n' "$BRIDGE_REGISTRY" > "$VERSION_DIR/.npmrc" + rm -f "$VERSION_DIR/pnpm-lock.yaml" + fi + cat > "$VERSION_DIR/package.json" < "$install_log" 2>&1); then + if is_release_age_error "$install_log"; then + if confirm_release_age_override; then + # Write the override only after explicit consent, then retry once. + write_release_age_override + if ! (cd "$VERSION_DIR" && CI=true "$vp_install_bin" install > "$install_log" 2>&1); then + print_install_failure "$install_log" + exit 1 + fi + else + print_release_age_failure "$install_log" + exit 1 + fi + else + print_install_failure "$install_log" + exit 1 + fi + fi + fi + + # Create/update current symlink (use relative path for portability) + ln -sfn "$VP_VERSION" "$CURRENT_LINK" + + # Create user bin directory and vp entrypoint (always done) + mkdir -p "$SHIM_DIR" + if [[ "$platform" == win32* ]]; then + # Windows: copy trampoline as vp.exe (matching install.ps1) + if [ -f "$INSTALL_DIR/current/bin/vp-shim.exe" ]; then + cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$SHIM_DIR/vp.exe" + # For a complete split override group, the trampoline reads .shim + # instead of inherited environment variables. + printf 'vite-plus-shim-v1\nlayout=%s\ndata=%s\ncache=%s\n' \ + "$LAYOUT_KIND" "$INSTALL_DIR" "$CACHE_DIR" >"$SHIM_DIR/vp.shim" + fi + else + ln -sfn "$INSTALL_DIR/current/bin/vp" "$SHIM_DIR/vp" + fi + + # Cleanup old versions + cleanup_old_versions + + # Create env files with PATH guard (prevents duplicate PATH entries) + # Use current/bin/vp directly (the real binary) instead of bin/vp (trampoline) + # to avoid the self-overwrite issue on Windows during --refresh + local vp_bin="$INSTALL_DIR/current/bin/vp" + if [[ "$platform" == win32* ]]; then + vp_bin="$INSTALL_DIR/current/bin/vp.exe" + fi + "$vp_bin" env setup --env-only > /dev/null + + # Setup Node.js version manager (shims) - separate component + setup_node_manager "$BIN_DIR" + if [ "$NODE_MANAGER_ENABLED" = "true" ]; then + if ! "$vp_bin" env on > /dev/null 2>&1; then + warn "Failed to record environment management preference." + fi + fi + + prompt_remove_previous_install_dir "$previous_install_dir" + + # Configure shell PATH after the install is otherwise complete. + configure_shell_path + + # Use ~ when an install location is under HOME. Otherwise, show the full path. + local display_data_dir display_bin_dir + display_data_dir="$(abbreviate_path "$INSTALL_DIR")" + display_bin_dir="$(abbreviate_path "$SHIM_DIR")" + + # Print success message + echo "" + echo -e "${GREEN}✔${NC} ${BOLD_BRIGHT_BLUE}VITE+${NC} successfully installed!" + echo "" + echo " The Unified Toolchain for the Web." + echo "" + echo -e " ${BOLD}Get started:${NC}" + echo -e " ${BRIGHT_BLUE}vp create${NC} Create a new project" + echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" + echo -e " ${BRIGHT_BLUE}vp install${NC} Install dependencies" + echo -e " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" + + if [ "$NODE_MANAGER_ENABLED" = "true" ] || [ "$NODE_MANAGER_ENABLED" = "already" ]; then + echo "" + echo -e " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." + echo -e " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." + fi + + echo "" + echo -e " Run ${BRIGHT_BLUE}vp help${NC} to see available commands." + + echo "" + echo -e " ${BOLD}Install locations:${NC}" + echo " Data directory: $display_data_dir" + echo " Bin directory: $display_bin_dir" + + # CI jobs configure PATH through the runner. + # Shell files do not change PATH for later steps. + # Do not print shell details in normal CI output. + if [ "${CI:-}" = "true" ]; then + echo "" + return + fi + + echo "" + echo " Shell configuration:" + local summary_line + for summary_line in "${SHELL_CONFIG_SUMMARY[@]}"; do + echo "$summary_line" + done + + # Show restart note if any shell config was updated + if [ "$SHELL_CONFIG_HAS_UPDATED" = "true" ]; then + echo "" + echo " Note: Restart your terminal to load updated shell configuration." + fi + + # Show manual PATH instructions if no shell was configured or any shell failed + if [ "$SHELL_CONFIG_HAS_CONFIGURED" = "false" ] || [ "$SHELL_CONFIG_HAS_FAILURE" = "true" ]; then + echo "" + echo -e " ${YELLOW}note${NC}: Some shells still need manual setup." + echo "" + echo " Manual setup instructions:" + echo " - Bash/Zsh: add the following to your shell config (~/.bashrc, ~/.zshrc, etc.):" + printf ' . "%s/env"\n' "$CONFIG_DIR_REF_POSIX" + echo " - Fish: create ${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish with:" + printf ' source "%s/env.fish"\n' "$CONFIG_DIR_REF_FISH" + echo " - Nushell: create a vendor autoload file with:" + printf ' source "%s/env.nu"\n' "$CONFIG_DIR_REF_NU" + echo "" + echo " Or run vp directly:" + echo "" + echo -e " ${display_bin_dir}/vp" + fi + + echo "" +} + +apply_dirs_from_vp() { + local vp="$1" + local out + out="$(VP_DUMP_DIRS=1 "$vp" 2>/dev/null)" || return 1 + INSTALL_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "data" { print $2; exit }')" + SHIM_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "bin" { print $2; exit }')" + CACHE_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "cache" { print $2; exit }')" + CONFIG_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "config" { print $2; exit }')" + STATE_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "state" { print $2; exit }')" + LAYOUT_KIND="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "layout" { print $2; exit }')" + [ -n "$INSTALL_DIR" ] && [ -n "$SHIM_DIR" ] && [ -n "$CACHE_DIR" ] && [ -n "$CONFIG_DIR" ] && [ -n "$STATE_DIR" ] || return 1 + if [ "$LAYOUT_KIND" != "single-root" ] && [ "$LAYOUT_KIND" != "split" ]; then + if [ "$SHIM_DIR" = "$INSTALL_DIR/bin" ] && [ "$CACHE_DIR" = "$INSTALL_DIR/cache" ] \ + && [ "$CONFIG_DIR" = "$INSTALL_DIR" ] && [ "$STATE_DIR" = "$INSTALL_DIR" ]; then + LAYOUT_KIND="single-root" + else + LAYOUT_KIND="split" + fi + fi + set_config_dir_refs "$CONFIG_DIR" "${HOME:-}" +} + +main "$@" diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 858ba4f9e6..12cbbcb9a2 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -18,11 +18,11 @@ # When set, overrides VP_VERSION and installs the clearly-defined # 0.0.0-commit. build through the bridge instead of npm. +# When dot-sourced, returns script-scoped InstallDir, ShimDir, CacheDir, ConfigDir, and StateDir. +# These are resolved paths, not VP_* overrides for subsequent commands. $ErrorActionPreference = "Stop" $ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } -# After these helper definitions, the selected payload resolves category roots -# through VP_DUMP_DIRS. Pre-split payloads use the legacy layout. # npm registry URL (strip trailing slash if present) $NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { "https://registry.npmjs.org" } # Local tarball for development/testing @@ -39,61 +39,18 @@ $PrVersion = $env:VP_PR_VERSION $BridgeDownloadBase = "https://registry-bridge.viteplus.dev/voidzero-dev/vite-plus" $BridgeRegistry = "https://registry-bridge.viteplus.dev/" +$script:InstallStopSignal = 'VP_INSTALL_STOP' +$script:PackageMetadata = $null +# Legacy is published beside this bootstrap; preview builds rewrite this origin. +$LegacyInstallerUrl = if ($env:VP_LEGACY_INSTALLER_URL) { $env:VP_LEGACY_INSTALLER_URL } else { 'https://viteplus.dev/install-legacy.ps1' } +$InstallerDirectory = $PSScriptRoot + function Write-Info { param([string]$Message) Write-Host "info: " -ForegroundColor Blue -NoNewline Write-Host $Message } -function Write-Success { - param([string]$Message) - Write-Host "success: " -ForegroundColor Green -NoNewline - Write-Host $Message -} - -function Write-Warn { - param([string]$Message) - Write-Host "warn: " -ForegroundColor Yellow -NoNewline - Write-Host $Message -} - -# Exit code when a Windows native binary cannot load required DLLs (STATUS_DLL_NOT_FOUND). -$script:DllNotFoundExitCode = -1073741515 - -function Test-IsDllNotFoundExitCode { - param([int]$ExitCode) - if ($ExitCode -eq $script:DllNotFoundExitCode) { - return $true - } - if ($ExitCode -eq 3221225781) { - return $true - } - if ($ExitCode -lt 0) { - $hex = '{0:X8}' -f ($ExitCode -band 0xFFFFFFFF) - return $hex -eq 'C0000135' - } - return $false -} - -function Get-DllNotFoundInstallMessage { - $arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" } - $vcUrl = if ($arch -eq "arm64") { - "https://aka.ms/vs/17/release/vc_redist.arm64.exe" - } else { - "https://aka.ms/vs/17/release/vc_redist.x64.exe" - } - return @" -vp.exe could not start (exit code 0xC0000135). -This usually means Microsoft Visual C++ 2015-2022 Redistributable ($arch) is not installed. - -Install: $vcUrl -Then re-run: irm https://vite.plus/ps1 | iex -"@ -} - -# Internal stop signal: halts install without re-printing an error we already wrote. -$script:InstallStopSignal = 'VP_INSTALL_STOP' - function Test-IsInstallStopException { param( [System.Management.Automation.ErrorRecord]$ErrorRecord @@ -140,364 +97,6 @@ function Write-Error-Exit { Exit-Installer } -function Test-ReleaseAgeError { - param([string]$LogPath) - if (-not (Test-Path $LogPath)) { - return $false - } - - $content = Get-Content -Path $LogPath -Raw - # This wrapper install path is pinned to pnpm via packageManager, so this - # detection follows pnpm's resolver/reporter output rather than npm/yarn. - # - # pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so - # NO_MATURE_MATCHING_VERSION is normally printed as - # ERR_PNPM_NO_MATURE_MATCHING_VERSION. npm-resolver emits that code with the - # "does not meet the minimumReleaseAge constraint" message when - # publishedBy/minimumReleaseAge rejects a matching version. - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 - # - # default-reporter may append guidance mentioning minimumReleaseAgeExclude - # when the error has an immatureVersion, so that token is also a useful - # release-age signal. minimum-release-age is pnpm's .npmrc key; npm's - # min-release-age is intentionally not treated as a pnpm signal here. - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 - $hasReleaseAgeText = $content -match "does not meet the minimumReleaseAge constraint" ` - -or $content -match "minimumReleaseAge" ` - -or $content -match "minimumReleaseAgeExclude" ` - -or $content -match "minimum release age" ` - -or $content -match "minimum-release-age" - - # pnpm can also surface ERR_PNPM_NO_MATCHING_VERSION when minimumReleaseAge - # filters out all candidates. That code is also used for real missing - # versions, so require age-gate context before prompting for a bypass. - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 - return $content -match "ERR_PNPM_NO_MATURE_MATCHING_VERSION" ` - -or $content -match "NO_MATURE_MATCHING_VERSION" ` - -or (($content -match "ERR_PNPM_NO_MATCHING_VERSION") -and $hasReleaseAgeText) ` - -or $hasReleaseAgeText -} - -function Confirm-ReleaseAgeOverride { - if ($env:CI -eq "true") { - return $false - } - if (-not [Environment]::UserInteractive) { - return $false - } - - Write-Host "" - Write-Warn "Your minimumReleaseAge setting prevented installing vite-plus@$ViteVersion." - Write-Host "This setting helps protect against newly published compromised packages." - Write-Host "Proceeding will disable this protection for this Vite+ install only." - $response = Read-Host "Do you want to proceed? (y/N)" - return $response -match "^(?i:y|yes)$" -} - -function Write-ReleaseAgeOverride { - # Append idempotently so a bridge registry line written for PR builds survives. - $npmrc = Join-Path $VersionDir ".npmrc" - if ((-not (Test-Path $npmrc)) -or (-not (Select-String -Path $npmrc -Pattern '^minimum-release-age=' -Quiet))) { - Add-Content -Path $npmrc -Value "minimum-release-age=0" - } -} - -function Test-AbsoluteOverridePath { - param([string]$Path) - if ([string]::IsNullOrWhiteSpace($Path)) { - return $false - } - return [System.IO.Path]::IsPathRooted($Path) -} - -function Test-VpDirOverrides { - $values = @($env:VP_BIN_DIR, $env:VP_DATA_DIR, $env:VP_CACHE_DIR) | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - if ($values.Count -ne 0 -and $values.Count -ne 3) { - Write-Error-Exit "Set VP_BIN_DIR, VP_DATA_DIR, and VP_CACHE_DIR together, or leave all three unset." - } - if ($values.Count -eq 3) { - foreach ($name in @("VP_BIN_DIR", "VP_DATA_DIR", "VP_CACHE_DIR")) { - $value = [Environment]::GetEnvironmentVariable($name) - if (-not (Test-AbsoluteOverridePath $value)) { - Write-Error-Exit "$name must be an absolute path." - } - } - } -} - -function Get-UserHomeDir { - if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { - return $env:USERPROFILE - } - if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { - return $env:HOME - } - return [Environment]::GetFolderPath('UserProfile') -} - -# Released setup-vp versions add %USERPROFILE%\.vite-plus\bin to the GitHub -# Actions PATH. They do this after the installer exits. Use the monolithic -# layout until setup-vp declares support for VP_DUMP_DIRS. -function Enable-SetupVpLegacyCompatibility { - if ($env:GITHUB_ACTION_REPOSITORY -cne "voidzero-dev/setup-vp") { - return - } - if ($env:VP_VPDIRS_AWARE -eq "1") { - return - } - if ($env:VP_HOME -or $env:VP_BIN_DIR -or $env:VP_DATA_DIR -or $env:VP_CACHE_DIR) { - return - } - - $userHome = Get-UserHomeDir - if ([string]::IsNullOrWhiteSpace($userHome)) { - Write-Error-Exit "Vite+ could not resolve the user home directory." - } - $env:VP_HOME = Join-Path $userHome ".vite-plus" -} - -# Monolithic mapping: every category on one root. -function New-MonolithicLayout { - param([string]$Root) - return [pscustomobject]@{ - Kind = "single-root" - DataDir = $Root - ShimDir = Join-Path $Root "bin" - CacheDir = Join-Path $Root "cache" - ConfigDir = $Root - StateDir = $Root - } -} - -function Set-LayoutVars { - $script:InstallDir = $script:Layout.DataDir - $script:ShimDir = $script:Layout.ShimDir - $script:CacheDir = $script:Layout.CacheDir - $script:ConfigDir = $script:Layout.ConfigDir - $script:StateDir = $script:Layout.StateDir - $script:NodeManagerBinDisplay = $script:ShimDir -replace [regex]::Escape($env:USERPROFILE), '~' -} - -# Pre-split releases resolve all paths from VP_HOME, which defaults to -# %USERPROFILE%\.vite-plus. Install them in this monolithic root. This keeps -# environment setup, shims, trampolines, and installer paths consistent. -function Use-LegacyLayout { - $userHome = Get-UserHomeDir - if ([string]::IsNullOrWhiteSpace($userHome)) { - Write-Error-Exit "Vite+ could not resolve the user home directory." - } - - $root = if (Test-AbsoluteOverridePath $env:VP_HOME) { - $env:VP_HOME - } else { - Join-Path $userHome ".vite-plus" - } - $script:Layout = New-MonolithicLayout $root - Set-LayoutVars -} - -# Record the resolved layout next to each trampoline. -function Write-ShimPointer { - param( - [string]$BinDir, - [string]$DataDir, - [string]$CacheDir, - [string]$LayoutKind, - [string]$Name = "vp" - ) - $path = Join-Path $BinDir "$Name.shim" - $utf8 = New-Object System.Text.UTF8Encoding $false - $contents = "vite-plus-shim-v1`nlayout=$LayoutKind`ndata=$($DataDir.TrimEnd('\', '/'))`ncache=$($CacheDir.TrimEnd('\', '/'))`n" - [System.IO.File]::WriteAllText($path, $contents, $utf8) -} - -function Get-ShimPointerData { - param([string]$Path) - try { - $contents = [System.IO.File]::ReadAllText($Path).Trim() - } catch { - return $null - } - if ([string]::IsNullOrWhiteSpace($contents)) { - return $null - } - $lines = $contents -split "`r?`n" - if ($lines[0] -ne "vite-plus-shim-v1") { - return $null - } - foreach ($line in $lines) { - if ($line.StartsWith("data=")) { - return $line.Substring(5) - } - } - return $null -} - -function Normalize-InstallDir { - param([string]$Path) - if ([string]::IsNullOrWhiteSpace($Path)) { - return $Path - } - - try { - if (Test-Path -LiteralPath $Path -PathType Container) { - return (Resolve-Path -LiteralPath $Path).ProviderPath.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) - } - - return [System.IO.Path]::GetFullPath($Path).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) - } catch { - return $Path.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) - } -} - -function Test-SafeInstallDirToRemove { - param([string]$Path) - if ([string]::IsNullOrWhiteSpace($Path)) { - return $false - } - - $normalized = Normalize-InstallDir $Path - $root = [System.IO.Path]::GetPathRoot($normalized) - # Do not use $home: PowerShell is case-insensitive and $HOME is read-only on 5.1. - $userHome = Normalize-InstallDir $env:USERPROFILE - $programFilesX86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)") - $unsafeDirs = @( - $root - $userHome - (Normalize-InstallDir $env:SystemRoot) - (Normalize-InstallDir $env:ProgramFiles) - (Normalize-InstallDir $programFilesX86) - ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - - return $unsafeDirs -notcontains $normalized -} - -function Test-VitePlusInstallDir { - param([string]$Path) - if (-not (Test-Path -LiteralPath $Path -PathType Container)) { - return $false - } - - $binDir = Join-Path $Path "bin" - if (-not (Test-Path -LiteralPath $binDir -PathType Container)) { - return $false - } - if (-not (Test-Path -LiteralPath (Join-Path $Path "current"))) { - return $false - } - - return (Test-Path -LiteralPath (Join-Path $binDir "vp.exe")) ` - -or (Test-Path -LiteralPath (Join-Path $binDir "vp.cmd")) ` - -or (Test-Path -LiteralPath (Join-Path $binDir "vp")) -} - -function Get-PreviousInstallDir { - if (-not $env:VP_HOME) { - return $null - } - - $vpCommand = Get-Command vp -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($null -eq $vpCommand) { - return $null - } - - $vpPath = $vpCommand.Path - if (-not $vpPath) { - return $null - } - - $vpFileName = [System.IO.Path]::GetFileName($vpPath) - if ($vpFileName -notin @("vp", "vp.exe", "vp.cmd")) { - return $null - } - - $oldDir = Normalize-InstallDir (Split-Path -Parent (Split-Path -Parent $vpPath)) - $newDir = Normalize-InstallDir $InstallDir - if ($oldDir -eq $newDir) { - return $null - } - if (-not (Test-SafeInstallDirToRemove $oldDir)) { - return $null - } - if (-not (Test-VitePlusInstallDir $oldDir)) { - return $null - } - - return $oldDir -} - -function Test-NestedInstallDir { - param( - [string]$OldDir, - [string]$NewDir - ) - if ([string]::IsNullOrWhiteSpace($OldDir) -or [string]::IsNullOrWhiteSpace($NewDir)) { - return $false - } - - $oldDir = Normalize-InstallDir $OldDir - $newDir = Normalize-InstallDir $NewDir - if ([string]::IsNullOrWhiteSpace($oldDir) -or [string]::IsNullOrWhiteSpace($newDir) -or $oldDir -eq $newDir) { - return $false - } - - # Normalize-InstallDir already trimmed trailing separators - $oldPrefix = $oldDir + [System.IO.Path]::DirectorySeparatorChar - $newPrefix = $newDir + [System.IO.Path]::DirectorySeparatorChar - return $oldPrefix.StartsWith($newPrefix, [System.StringComparison]::OrdinalIgnoreCase) ` - -or $newPrefix.StartsWith($oldPrefix, [System.StringComparison]::OrdinalIgnoreCase) -} - -function Prompt-RemovePreviousInstallDir { - param([string]$PreviousInstallDir) - if (-not $PreviousInstallDir) { - return - } - if ($env:CI -eq "true") { - return - } - if (-not [Environment]::UserInteractive) { - return - } - - Write-Host "" - Write-Warn "Found a previous Vite+ install at $PreviousInstallDir." - Write-Host "The new VP_HOME is $InstallDir." - $response = Read-Host "Remove the previous install directory? (y/N)" - if ($response -match "^(?i:y|yes)$") { - $vpBin = Join-Path $PreviousInstallDir "current\bin\vp.exe" - if (-not (Test-Path -LiteralPath $vpBin)) { - Write-Warn "Could not remove previous Vite+ install at ${PreviousInstallDir}: vp binary not found." - return - } - - $previousVpHome = $env:VP_HOME - try { - $env:VP_HOME = $PreviousInstallDir - $output = & $vpBin implode --yes 2>&1 - $exitCode = $LASTEXITCODE - } catch { - $output = $_ - $exitCode = 1 - } finally { - $env:VP_HOME = $previousVpHome - } - - if ($exitCode -eq 0) { - Write-Success "Removed previous Vite+ install at $PreviousInstallDir." - } else { - Write-Warn "Could not remove previous Vite+ install at ${PreviousInstallDir}: $output" - } - } -} - -# Resolve a PR number or commit SHA to the registry bridge's immutable commit -# version (0.0.0-commit.). A full commit SHA maps directly to the bridge's -# deterministic version; a PR number (or short ref) is resolved via the bridge -# download URL's `x-commit-key: ::` header (HEAD). function Resolve-BridgeCommitVersion { param([string]$Ref) $sha = $Ref @@ -515,43 +114,6 @@ function Resolve-BridgeCommitVersion { return "0.0.0-commit.$sha" } -function Write-InstallFailure { - param( - [string]$LogPath, - [int]$ExitCode = 0 - ) - - if (Test-IsDllNotFoundExitCode $ExitCode) { - $message = Get-DllNotFoundInstallMessage - if ($env:CI -eq "true") { - Write-Host "error: " -ForegroundColor Red -NoNewline - Write-Host $message - Exit-Installer - } - Write-Error-Exit $message - } - - if ($env:CI -eq "true") { - Write-Host "error: " -ForegroundColor Red -NoNewline - Write-Host "Failed to install dependencies. Log output:" - Get-Content -Path $LogPath | ForEach-Object { Write-Host $_ } - Exit-Installer - } else { - Write-Error-Exit "Failed to install dependencies. See log for details: $LogPath" - } -} - -function Write-ReleaseAgeFailure { - param([string]$LogPath) - if ($env:CI -eq "true") { - Write-Host "error: " -ForegroundColor Red -NoNewline - Write-Host "Install blocked by your minimumReleaseAge setting. Log output:" - Get-Content -Path $LogPath | ForEach-Object { Write-Host $_ } - } else { - Write-Error-Exit "Install blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly. See log for details: $LogPath" - } -} - function Get-Architecture { if ([Environment]::Is64BitOperatingSystem) { if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { @@ -564,12 +126,9 @@ function Get-Architecture { } } -# Cached package metadata -$script:PackageMetadata = $null - function Get-PackageMetadata { if ($null -eq $script:PackageMetadata) { - $versionPath = if ($ViteVersion -eq "latest") { "latest" } else { $ViteVersion } + $versionPath = $ViteVersion $metadataUrl = "$NpmRegistry/vite-plus/$versionPath" try { $script:PackageMetadata = Invoke-RestMethod $metadataUrl @@ -625,295 +184,47 @@ function Get-PlatformSuffix { return $Platform } -function Download-AndExtract { - param( - [string]$Url, - [string]$DestDir, - [string]$Filter - ) - - $tempFile = New-TemporaryFile - try { - # Suppress progress bar for cleaner output - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $Url -OutFile $tempFile - - # Create temp extraction directory - $tempExtract = Join-Path $env:TEMP "vite-install-$(Get-Random)" - New-Item -ItemType Directory -Force -Path $tempExtract | Out-Null - - # Extract using tar (available in Windows 10+) - & "$env:SystemRoot\System32\tar.exe" -xzf $tempFile -C $tempExtract - - # Copy the specified file/directory - $sourcePath = Join-Path (Join-Path $tempExtract "package") $Filter - if (Test-Path $sourcePath) { - Copy-Item -Path $sourcePath -Destination $DestDir -Recurse -Force - } - - Remove-Item -Recurse -Force $tempExtract - } finally { - Remove-Item $tempFile -ErrorAction SilentlyContinue - } -} - -function Cleanup-OldVersions { - param([string]$InstallDir) - - $maxVersions = 3 - # Only cleanup semver format directories (0.1.0, 1.2.3-beta.1, etc.) - # This excludes 'current' symlink and non-semver directories like 'local-dev' - $semverPattern = '^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$' - $versions = Get-ChildItem -Path $InstallDir -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match $semverPattern } - - if ($null -eq $versions -or $versions.Count -le $maxVersions) { - return +function Get-UserHomeDir { + if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + return $env:USERPROFILE } - - # Sort by creation time (oldest first) and select excess - $toDelete = $versions | - Sort-Object CreationTime | - Select-Object -First ($versions.Count - $maxVersions) - - foreach ($old in $toDelete) { - # Remove silently - Remove-Item -Path $old.FullName -Recurse -Force + if (-not [string]::IsNullOrWhiteSpace($env:HOME)) { + return $env:HOME } + return [Environment]::GetFolderPath('UserProfile') } -function Remove-CurrentLink { - param([string]$Path) - - try { - $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop - } catch [System.Management.Automation.ItemNotFoundException] { +# Released setup-vp versions add %USERPROFILE%\.vite-plus\bin to the GitHub +# Actions PATH. They do this after the installer exits. Use the monolithic +# layout until setup-vp declares support for VP_DUMP_DIRS. +function Enable-SetupVpLegacyCompatibility { + if ($env:GITHUB_ACTION_REPOSITORY -cne "voidzero-dev/setup-vp") { return } - - $isReparsePoint = ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 - - try { - if ($isReparsePoint) { - if ($item.PSIsContainer) { - [System.IO.Directory]::Delete($item.FullName) - } else { - [System.IO.File]::Delete($item.FullName) - } - return - } - - Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction Stop - } catch { - Write-Error-Exit "Failed to remove existing current link at ${Path}: $_" - } -} - -# Configure user PATH for the resolved shim directory -# Returns: "true" = added, "already" = already configured -function Configure-UserPath { - $binPath = $ShimDir - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - - if ($userPath -like "*$binPath*") { - return "already" - } - - $newPath = "$binPath;$userPath" - try { - [Environment]::SetEnvironmentVariable("Path", $newPath, "User") - $env:Path = "$binPath;$env:Path" - return "true" - } catch { - Write-Warn "Could not update user PATH automatically." - return "failed" - } -} - -function Get-NushellVendorAutoloadDir { - $nushellCommand = Get-Command nu -ErrorAction SilentlyContinue - if ($null -eq $nushellCommand) { - return $null - } - - try { - $dirsOutput = & $nushellCommand.Source -c '$nu.vendor-autoload-dirs | reverse | each {|dir| $dir } | str join (char nl)' 2>$null - } catch { - return $null - } - - foreach ($dir in ($dirsOutput -split "\r?\n")) { - if (-not [string]::IsNullOrWhiteSpace($dir)) { - return $dir - } - } - - return $null -} - -function Configure-Nushell { - $autoloadDir = Get-NushellVendorAutoloadDir - if ($null -eq $autoloadDir) { - if ($null -eq (Get-Command nu -ErrorAction SilentlyContinue)) { - return [pscustomobject]@{ - Status = "skipped" - Message = "skipped (not installed)" - } - } - - return [pscustomobject]@{ - Status = "failed" - Message = "failed (could not determine vendor autoload dir)" - } - } - - $autoloadFile = Join-Path $autoloadDir "vite-plus.nu" - $nuEnvRef= (Join-Path $ConfigDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' - $content = "# Vite+ bin (https://viteplus.dev)`n" + ("source '"+ $nuEnvRef +"'") + "`n" - - try { - New-Item -ItemType Directory -Force -Path $autoloadDir | Out-Null - if (Test-Path $autoloadFile) { - $existing = Get-Content -Path $autoloadFile -Raw - if ($existing -eq $content) { - return [pscustomobject]@{ - Status = "already" - Message = "already configured $autoloadFile" - } - } - } - - [System.IO.File]::WriteAllText($autoloadFile, $content) - return [pscustomobject]@{ - Status = "true" - Message = "updated $autoloadFile" - } - } catch { - Write-Warn "Could not configure Nushell automatically." - return [pscustomobject]@{ - Status = "failed" - Message = "failed $autoloadFile" - } - } -} - -# Run vp env setup --refresh, showing output only on failure -function Refresh-Shims { - param([string]$BinDir) - $setupOutput = & "$BinDir\vp.exe" env setup --refresh 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Warn "Failed to refresh shims:" - Write-Host "$setupOutput" - } -} - -# Return true only if this Vite+ install owns the existing Node executable. -# $ShimDir can be shared. The existence of node.exe does not permit replacement. -function Test-VitePlusNodeShim { - $nodePath = Join-Path $ShimDir "node.exe" - $pointerPath = Join-Path $ShimDir "node.shim" - $hasNode = Test-Path -LiteralPath $nodePath -PathType Leaf - $hasPointer = Test-Path -LiteralPath $pointerPath -PathType Leaf - if (-not $hasNode -or -not $hasPointer) { - return $false - } - - $pointer = Get-ShimPointerData $pointerPath - if ([string]::IsNullOrWhiteSpace($pointer)) { - return $false - } - - return (Normalize-InstallDir $pointer) -eq (Normalize-InstallDir $InstallDir) -} - -# Setup Vite+ environment shims -# Returns: "true" = enabled, "false" = not enabled, "already" = already configured -function Setup-NodeManager { - param([string]$BinDir) - - $binPath = $ShimDir - - # Explicit override via environment variable - if ($env:VP_NODE_MANAGER -eq "yes") { - Refresh-Shims -BinDir $BinDir - return "true" - } elseif ($env:VP_NODE_MANAGER -eq "no") { - return "false" - } - - # A foreign Node executable in a custom bin directory prevents automatic - # enablement. The explicit setting or interactive prompt can permit - # replacement. - $foreignNodeInBin = $false - if (Test-Path -LiteralPath (Join-Path $binPath "node.exe")) { - if (Test-VitePlusNodeShim) { - Refresh-Shims -BinDir $BinDir - return "already" - } - $foreignNodeInBin = $true - } - - # Auto-enable on CI or devcontainer environments - # CI: standard CI environment variable (GitHub Actions, Travis, CircleCI, etc.) - # CODESPACES: set by GitHub Codespaces (https://docs.github.com/en/codespaces) - # REMOTE_CONTAINERS: set by VS Code Dev Containers extension - # DEVPOD: set by DevPod (https://devpod.sh) - $isAutomaticEnvironment = $env:CI -or $env:CODESPACES -or $env:REMOTE_CONTAINERS -or $env:DEVPOD - if (-not $foreignNodeInBin -and $isAutomaticEnvironment) { - Refresh-Shims -BinDir $BinDir - return "true" + if ($env:VP_VPDIRS_AWARE -eq "1") { + return } - - # Check if node is available on the system - $nodeAvailable = $null -ne (Get-Command node -ErrorAction SilentlyContinue) - - # Auto-enable if no node available on system - if (-not $nodeAvailable -and -not $foreignNodeInBin) { - Refresh-Shims -BinDir $BinDir - return "true" + if ($env:VP_HOME -or $env:VP_BIN_DIR -or $env:VP_DATA_DIR -or $env:VP_CACHE_DIR) { + return } - # Prompt user in interactive mode - # CI requires unattended setup. Some hosted PowerShell runners report an - # interactive host process, so do not use that report in CI. - $isInteractive = [Environment]::UserInteractive -and -not $env:CI - if ($isInteractive) { - Write-Host "" - Write-Host "Would you like Vite+ to manage your Node.js and package-manager versions?" - Write-Host "Vite+ adds ``node``, ``npm``, ``npx``, ``pnpm``, ``pnpx``, ``yarn``, ``yarnpkg``, ``bun``, and ``bunx`` shims to $NodeManagerBinDisplay." - Write-Host "It selects the required version automatically." - Write-Host "Opt out anytime with ``vp env off``." - $response = Read-Host "Press Enter to accept (Y/n)" - - if ($response -eq '' -or $response -eq 'y' -or $response -eq 'Y') { - Refresh-Shims -BinDir $BinDir - return "true" - } + $userHome = Get-UserHomeDir + if ([string]::IsNullOrWhiteSpace($userHome)) { + Write-Error-Exit "Vite+ could not resolve the user home directory." } - - return "false" + $env:VP_HOME = Join-Path $userHome ".vite-plus" } function Main { - Write-Host "" - Write-Host "Setting up " -NoNewline - Write-Host "VITE+" -ForegroundColor Blue -NoNewline - Write-Host "..." + Enable-SetupVpLegacyCompatibility if ($PrVersion -and $LocalTgz) { Write-Error-Exit "VP_PR_VERSION and VP_LOCAL_TGZ cannot be used together" } - Test-VpDirOverrides - Enable-SetupVpLegacyCompatibility - $previousInstallDir = $null - # Suppress progress bars for cleaner output $ProgressPreference = 'SilentlyContinue' - $arch = Get-Architecture - $platform = "win32-$arch" - # Local development mode: use local tgz if ($LocalTgz) { # Validate local tgz @@ -927,398 +238,139 @@ function Main { if (-not $LocalBinary -or -not (Test-Path -LiteralPath $LocalBinary -PathType Leaf)) { Write-Error-Exit "Set VP_LOCAL_BINARY when you use VP_LOCAL_TGZ." } - if (Apply-DirsFromVp $LocalBinary) { - Set-LayoutVars - } else { - Use-LegacyLayout - Write-Info "The local vite-plus binary does not support the split directory layout. Vite+ will install it in $InstallDir." - } } elseif ($PrVersion) { # Registry bridge mode: resolve the requested PR/SHA to the bridge's # immutable commit version (0.0.0-commit.), the clearly-defined test - # version we install. The directory label stays non-semver so it keeps - # out of Cleanup-OldVersions and makes the PR build obvious in ~/.vite-plus. + # version we install. Legacy receives the full SHA as its preview ref. $PrCommitVersion = Resolve-BridgeCommitVersion -Ref $PrVersion if (-not $PrCommitVersion) { Write-Error-Exit "Could not resolve a registry bridge build for $PrVersion" } - $ViteVersion = "pkg-pr-new-$PrVersion" + $ViteVersion = $PrCommitVersion Write-Info "Using registry bridge build: $PrCommitVersion" } else { # Fetch package metadata and resolve version from npm $ViteVersion = Get-VersionFromMetadata } + Get-PayloadAndHandoff +} + +function Get-PayloadAndHandoff { + $arch = Get-Architecture + $platform = "win32-$arch" $binaryName = "vp.exe" - # Download the CLI platform tarball before Vite+ selects the final layout. - # The downloaded binary reports the layout that it supports. + # Keep acquisition separate from permanent installation. The bootstrap owns cleanup. $platformTempExtract = $null - if (-not $LocalTgz) { - # npm registry or registry bridge (when PrVersion is set) - $platformSuffix = Get-PlatformSuffix -Platform $platform - if ($PrVersion) { - # The registry bridge redirects this URL to the platform tarball for - # the matching commit build (0.0.0-commit.). - $platformUrl = "$BridgeDownloadBase/@voidzero-dev/vite-plus-cli-$platformSuffix@$PrVersion" - } else { - $packageName = "@voidzero-dev/vite-plus-cli-$platformSuffix" - $platformUrl = "$NpmRegistry/$packageName/-/vite-plus-cli-$platformSuffix-$ViteVersion.tgz" - } - - $platformTempFile = New-TemporaryFile - try { - Invoke-WebRequest -Uri $platformUrl -OutFile $platformTempFile - - # Create temp extraction directory - $platformTempExtract = Join-Path $env:TEMP "vite-platform-$(Get-Random)" - New-Item -ItemType Directory -Force -Path $platformTempExtract | Out-Null - - # Extract the package - & "$env:SystemRoot\System32\tar.exe" -xzf $platformTempFile -C $platformTempExtract - } finally { - Remove-Item $platformTempFile -ErrorAction SilentlyContinue - } - - # Ask the downloaded binary for its layout through VP_DUMP_DIRS. A - # pre-split release cannot report a layout. Give that release the - # monolithic root so the installed PATH commands work. - $packageDir = Join-Path $platformTempExtract "package" - $binarySource = Join-Path $packageDir $binaryName - if (Test-Path $binarySource) { - # Remove Zone.Identifier (Mark of the Web) so the probe can run. - Unblock-File -LiteralPath $binarySource - } - if ((Test-Path $binarySource) -and (Apply-DirsFromVp $binarySource)) { - Set-LayoutVars - } else { - Use-LegacyLayout - Write-Info "vite-plus $ViteVersion does not support the split directory layout. Vite+ will install it in $InstallDir." - } - } - - # Run layout migration checks after the payload resolves the category roots. - # A pre-split payload selects the legacy layout first. - $previousInstallDir = Get-PreviousInstallDir - if ($previousInstallDir -and (Test-NestedInstallDir -OldDir $previousInstallDir -NewDir $InstallDir)) { - Write-Error-Exit "The previous Vite+ install at $previousInstallDir overlaps with VP_HOME $InstallDir. Set VP_HOME to a directory that does not overlap. Alternatively, remove the previous install." - } - - # Set up version-specific directories - $VersionDir = "$InstallDir\$ViteVersion" - $BinDir = "$VersionDir\bin" - $CurrentLink = "$InstallDir\current" - - # Create bin directory - New-Item -ItemType Directory -Force -Path $BinDir | Out-Null - - if ($LocalTgz) { - # Local development mode: only need the binary - Write-Info "Vite+ uses the local tarball: $LocalTgz" - - # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts) - Copy-Item -Path $LocalBinary -Destination (Join-Path $BinDir $binaryName) -Force - # Also copy trampoline shim binary if available (sibling to vp.exe) - $shimSource = Join-Path (Split-Path $LocalBinary) "vp-shim.exe" - if (Test-Path $shimSource) { - Copy-Item -Path $shimSource -Destination (Join-Path $BinDir "vp-shim.exe") -Force - } - } else { - # Copy binary to BinDir - if (Test-Path $binarySource) { - Copy-Item -Path $binarySource -Destination $BinDir -Force - } - # Also copy trampoline shim binary if present in the package - $shimSource = Join-Path $packageDir "vp-shim.exe" - if (Test-Path $shimSource) { - Copy-Item -Path $shimSource -Destination $BinDir -Force - } - - Remove-Item -Recurse -Force $platformTempExtract - } + try { + if (-not $LocalTgz) { + # npm registry or registry bridge (when PrVersion is set) + $platformSuffix = Get-PlatformSuffix -Platform $platform + if ($PrVersion) { + # The registry bridge redirects this URL to the platform tarball for + # the matching commit build (0.0.0-commit.). + $platformUrl = "$BridgeDownloadBase/@voidzero-dev/vite-plus-cli-$platformSuffix@$($PrCommitVersion.Substring(13))" + } else { + $packageName = "@voidzero-dev/vite-plus-cli-$platformSuffix" + $platformUrl = "$NpmRegistry/$packageName/-/vite-plus-cli-$platformSuffix-$ViteVersion.tgz" + } - # Remove Zone.Identifier (Mark of the Web) from downloaded binaries so - # Windows SmartScreen / Defender won't block execution. - Get-ChildItem -Path $BinDir -Filter "*.exe" | Unblock-File + $platformTempFile = New-TemporaryFile + try { + Invoke-WebRequest -Uri $platformUrl -OutFile $platformTempFile - # Generate wrapper package.json that declares vite-plus as a dependency. - # pnpm will install vite-plus and all transitive deps via `vp install`. - # The packageManager field pins pnpm to a known-good version. - # In PR mode, pin vite-plus to the bridge's clearly-defined commit version and - # resolve it (plus its platform binaries and transitive deps) through the - # bridge registry written to .npmrc below. The bridge rewrites a preview - # tarball's transitive deps to versions, not self-contained URLs, so a full - # install must go through the registry rather than the bare download URL. - $vitePlusSpec = if ($PrVersion) { $PrCommitVersion } else { $ViteVersion } - if ($PrVersion) { - # Bridge registry; drop any stale wrapper lockfile (see install.sh for why): - # the reused pkg-pr-new- dir must re-resolve a lockfile matching the - # spec we just wrote, not fail under CI's frozen-lockfile default. - Set-Content -Path (Join-Path $VersionDir ".npmrc") -Value "registry=$BridgeRegistry" - Remove-Item -Path (Join-Path $VersionDir "pnpm-lock.yaml") -ErrorAction SilentlyContinue - } - $wrapperJson = @{ - name = "vp-global" - version = $ViteVersion - private = $true - packageManager = "pnpm@10.33.0" - dependencies = @{ - "vite-plus" = $vitePlusSpec - } - } | ConvertTo-Json -Depth 10 - Set-Content -Path (Join-Path $VersionDir "package.json") -Value $wrapperJson + # Create temp extraction directory + $platformTempExtract = Join-Path $env:TEMP "vite-platform-$(Get-Random)" + New-Item -ItemType Directory -Force -Path $platformTempExtract | Out-Null - # Install production dependencies (skip if VP_SKIP_DEPS_INSTALL is set, - # e.g. during local dev where install-global-cli.ts handles deps separately) - if (-not $env:VP_SKIP_DEPS_INSTALL) { - $installLog = Join-Path $VersionDir "install.log" - Push-Location $VersionDir - try { - # Use cmd /c so CI=true is scoped to the child process only, - # avoiding leaking it into the user's shell session. - # Do not pass --silent to the inner install: pnpm suppresses the - # release-age error body in silent mode, which would leave - # install.log empty and make the release-age gate impossible to - # detect. Output is already captured to install.log here. - $output = cmd /c "set CI=true && `"$BinDir\vp.exe`" install" 2>&1 - $installExitCode = $LASTEXITCODE - $output | Out-File $installLog - if ($installExitCode -ne 0) { - if (Test-ReleaseAgeError $installLog) { - if (Confirm-ReleaseAgeOverride) { - # Write the override only after explicit consent, then retry once. - Write-ReleaseAgeOverride - $retryOutput = cmd /c "set CI=true && `"$BinDir\vp.exe`" install" 2>&1 - $retryExitCode = $LASTEXITCODE - $retryOutput | Out-File $installLog - if ($retryExitCode -ne 0) { - Write-InstallFailure -LogPath $installLog -ExitCode $retryExitCode - } - } else { - Write-ReleaseAgeFailure $installLog - Exit-Installer - } - } else { - Write-InstallFailure -LogPath $installLog -ExitCode $installExitCode + # Extract the package + & "$env:SystemRoot\System32\tar.exe" -xzf $platformTempFile -C $platformTempExtract + if ($LASTEXITCODE -ne 0) { + Write-Error-Exit "Failed to extract platform package from: $platformUrl" } + } finally { + Remove-Item $platformTempFile -ErrorAction SilentlyContinue } - } finally { - Pop-Location - } - } - # Create/update current junction (symlink) - Remove-CurrentLink $CurrentLink - # Create new junction pointing to the version directory - cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null - - # Create user bin directory and vp wrapper (always done) - New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null - $trampolineSrc = "$VersionDir\bin\vp-shim.exe" - if (Test-Path $trampolineSrc) { - # New versions: use trampoline exe to avoid "Terminate batch job (Y/N)?" on Ctrl+C - Copy-Item -Path $trampolineSrc -Destination (Join-Path $ShimDir "vp.exe") -Force - Write-ShimPointer -BinDir $ShimDir -DataDir $InstallDir -CacheDir $CacheDir -LayoutKind $Layout.Kind -Name "vp" - # Remove legacy .cmd and shell script wrappers from previous versions - foreach ($legacy in @((Join-Path $ShimDir "vp.cmd"), (Join-Path $ShimDir "vp"))) { - if (Test-Path $legacy) { - Remove-Item -Path $legacy -Force -ErrorAction SilentlyContinue - } - } - } else { - # Pre-trampoline versions: fall back to legacy .cmd and shell script wrappers. - # Remove any stale trampoline .exe shims left by a newer install — .exe wins - # over .cmd on Windows PATH, so leftover trampolines would bypass the wrappers. - foreach ($stale in @("vp.exe", "node.exe", "npm.exe", "npx.exe", "corepack.exe", "vpx.exe", "vpr.exe")) { - $stalePath = Join-Path $ShimDir $stale - if (Test-Path $stalePath) { - Remove-Item -Path $stalePath -Force -ErrorAction SilentlyContinue + $binarySource = Join-Path (Join-Path $platformTempExtract "package") $binaryName + if (-not (Test-Path -LiteralPath $binarySource -PathType Leaf)) { + Write-Error-Exit "Downloaded package does not contain $binaryName" } + Unblock-File -LiteralPath $binarySource + } else { + $binarySource = $LocalBinary } - # Pin VP_HOME to the data root. In a split install, $ShimDir is not - # `$InstallDir\bin`. Thus, `%~dp0..` would not find `\current`. - $wrapperContent = @" -@echo off -set VP_HOME=$InstallDir -"%VP_HOME%\current\bin\vp.exe" %* -exit /b %ERRORLEVEL% -"@ - Set-Content -Path (Join-Path $ShimDir "vp.cmd") -Value $wrapperContent -NoNewline - - # Also create shell script wrapper for Git Bash/MSYS - $installDirUnix = $InstallDir -replace '\\', '/' - $shContent = @" -#!/bin/sh -VP_HOME="$installDirUnix" -export VP_HOME -exec "`$VP_HOME/current/bin/vp.exe" "`$@" -"@ - Set-Content -Path (Join-Path $ShimDir "vp") -Value $shContent -NoNewline - } - - # Cleanup old versions - Cleanup-OldVersions -InstallDir $InstallDir - - # Create env files under the resolved config dir (matches install.sh). - # Use current\bin\vp.exe directly instead of the trampoline so a Windows - # refresh cannot overwrite the running wrapper. - $vpBin = Join-Path $InstallDir "current\bin\vp.exe" - if (Test-Path -LiteralPath $vpBin) { - & $vpBin env setup --env-only | Out-Null - } - - # Setup Node.js version manager (shims) - separate component - $nodeManagerResult = Setup-NodeManager -BinDir $BinDir - if ($nodeManagerResult -eq "true") { - $previousErrorActionPreference = $ErrorActionPreference - try { - $ErrorActionPreference = "Continue" - & $vpBin env on *> $null - $preferenceExitCode = $LASTEXITCODE - } finally { - $ErrorActionPreference = $previousErrorActionPreference + $binarySource = (Resolve-Path -LiteralPath $binarySource).Path + if (Test-SelfSetupSupport -BinarySource $binarySource) { + Invoke-InstallHandoff -BinarySource $binarySource + } else { + Invoke-LegacyInstaller -BinarySource $binarySource } - if ($preferenceExitCode -ne 0) { - Write-Warn "Failed to record environment management preference." + } finally { + if ($platformTempExtract) { + Remove-Item -LiteralPath $platformTempExtract -Recurse -Force -ErrorAction SilentlyContinue } - $global:LASTEXITCODE = 0 - } - - Prompt-RemovePreviousInstallDir -PreviousInstallDir $previousInstallDir - - # Configure shell access after the install is otherwise complete. - $pathResult = Configure-UserPath - $nushellResult = Configure-Nushell - - # Use ~ when an install location is under USERPROFILE. Otherwise, show the - # full path. - $displayDataDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~' - $displayBinDir = $ShimDir -replace [regex]::Escape($env:USERPROFILE), '~' - $displayConfigDir = $ConfigDir -replace [regex]::Escape($env:USERPROFILE), '~' - - # ANSI color codes for consistent output - $e = [char]27 - $GREEN = "$e[32m" - $YELLOW = "$e[33m" - $BRIGHT_BLUE = "$e[94m" - $BOLD = "$e[1m" - $DIM = "$e[2m" - $BOLD_BRIGHT_BLUE = "$e[1;94m" - $NC = "$e[0m" - $CHECKMARK = [char]0x2714 - - # Print success message - Write-Host "" - Write-Host "${GREEN}${CHECKMARK}${NC} ${BOLD_BRIGHT_BLUE}VITE+${NC} successfully installed!" - Write-Host "" - Write-Host " The Unified Toolchain for the Web." - Write-Host "" - Write-Host " ${BOLD}Get started:${NC}" - Write-Host " ${BRIGHT_BLUE}vp create${NC} Create a new project" - Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" - Write-Host " ${BRIGHT_BLUE}vp install${NC} Install dependencies" - Write-Host " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" - - # Show Node.js manager status - if ($nodeManagerResult -eq "true" -or $nodeManagerResult -eq "already") { - Write-Host "" - Write-Host " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." - Write-Host " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." } +} - Write-Host "" - Write-Host " Run ${BRIGHT_BLUE}vp help${NC} to see available commands." - - Write-Host "" - Write-Host " ${BOLD}Install locations:${NC}" - Write-Host " Data directory: $displayDataDir" - Write-Host " Bin directory: $displayBinDir" - - Write-Host "" - Write-Host " Shell configuration:" - switch ($pathResult) { - "true" { Write-Host " - Windows PATH: updated" } - "already" { Write-Host " - Windows PATH: already configured" } - "failed" { Write-Host " - Windows PATH: failed" } - default { Write-Host " - Windows PATH: skipped" } - } - if ($nushellResult.Status -ne "skipped") { - Write-Host " - Nushell: $($nushellResult.Message)" +function Test-SelfSetupSupport { + param([string]$BinarySource) + $previous = $env:VP_SELF_SETUP_SUPPORT_CHECK + try { + $env:VP_SELF_SETUP_SUPPORT_CHECK = '1' + # Old binaries must exit with help rather than opening an interactive picker. + $response = & $BinarySource --help 2>$null + return $LASTEXITCODE -eq 0 -and @($response).Count -eq 1 -and $response -ceq 'vite-plus-self-setup-v1' + } catch { + return $false + } finally { + $env:VP_SELF_SETUP_SUPPORT_CHECK = $previous } +} - # Show note if PATH or Nushell was updated - if ($pathResult -eq "true" -or $nushellResult.Status -eq "true") { - Write-Host "" - Write-Host " Note: Restart your terminal and IDE for changes to take effect." +function Invoke-LegacyInstaller { + param([string]$BinarySource) + $global:LASTEXITCODE = 0 + $legacyScript = if ($InstallerDirectory) { Join-Path $InstallerDirectory 'install-legacy.ps1' } + if ($legacyScript -and (Test-Path -LiteralPath $legacyScript -PathType Leaf)) { + . $legacyScript -BinarySource $BinarySource -ResolvedVersion $ViteVersion -PreviewRef $PrVersion + } else { + $response = Invoke-WebRequest -Uri $LegacyInstallerUrl -UseBasicParsing + . ([scriptblock]::Create($response.Content)) -BinarySource $BinarySource -ResolvedVersion $ViteVersion -PreviewRef $PrVersion } - - # Show manual PATH/Nushell instructions if anything still needs manual setup - if ($pathResult -eq "failed" -or $nushellResult.Status -eq "failed") { - Write-Host "" - Write-Host " ${YELLOW}note${NC}: Some shells still need manual setup." - Write-Host "" - if ($pathResult -eq "failed") { - Write-Host " To use vp in Powershell/cmd, manually add it to your PATH:" - Write-Host "" - Write-Host " [Environment]::SetEnvironmentVariable('Path', '$ShimDir;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" - Write-Host "" - } - if ($nushellResult.Status -eq "failed") { - Write-Host " To use vp in Nushell, create a vite-plus.nu file in your preferred vendor autoload directory with:" - Write-Host "" - Write-Host " source '$displayConfigDir\env.nu'" - Write-Host "" - } - Write-Host " Or run vp directly:" - Write-Host "" - Write-Host " & `"$(Join-Path $ShimDir 'vp.exe')`"" + # A child script's exit only returns to this bootstrap, so forward its failure. + if ($LASTEXITCODE -ne 0) { + Exit-Installer -Code $LASTEXITCODE } - - Write-Host "" } -function Apply-DirsFromVp { - param([string]$VpBinary) - $previous = $env:VP_DUMP_DIRS - $env:VP_DUMP_DIRS = "1" +function Invoke-InstallHandoff { + param([string]$BinarySource) + $previous = $env:VP_SELF_SETUP_SUPPORT_CHECK + $previousShell = $env:VP_SELF_SETUP_SHELL + $previousRegistry = $env:NPM_CONFIG_REGISTRY try { - $out = & $VpBinary 2>$null - } finally { - if ($null -eq $previous) { - Remove-Item Env:VP_DUMP_DIRS -ErrorAction SilentlyContinue - } else { - $env:VP_DUMP_DIRS = $previous + Remove-Item Env:VP_SELF_SETUP_SUPPORT_CHECK -ErrorAction SilentlyContinue + $env:VP_SELF_SETUP_SHELL = 'powershell' + # Preview dependencies must use the same registry as the downloaded binary. + if ($PrVersion) { + $env:NPM_CONFIG_REGISTRY = $BridgeRegistry } - } - $map = @{} - foreach ($line in @($out)) { - $text = "$line" - $sep = $text.IndexOf("`t") - if ($sep -lt 1) { - continue + $result = & $BinarySource + if ($LASTEXITCODE -ne 0) { + Exit-Installer -Code $LASTEXITCODE } - $map[$text.Substring(0, $sep)] = $text.Substring($sep + 1) - } - if (-not $map['data'] -or -not $map['bin'] -or -not $map['cache'] -or -not $map['config'] -or -not $map['state']) { - return $false - } - $layoutKind = $map['layout'] - if ($layoutKind -ne 'single-root' -and $layoutKind -ne 'split') { - $isSingleRoot = (Normalize-InstallDir $map['bin']) -eq (Normalize-InstallDir (Join-Path $map['data'] 'bin')) ` - -and (Normalize-InstallDir $map['cache']) -eq (Normalize-InstallDir (Join-Path $map['data'] 'cache')) ` - -and (Normalize-InstallDir $map['config']) -eq (Normalize-InstallDir $map['data']) ` - -and (Normalize-InstallDir $map['state']) -eq (Normalize-InstallDir $map['data']) - $layoutKind = if ($isSingleRoot) { 'single-root' } else { 'split' } - } - $script:Layout = [pscustomobject]@{ - Kind = $layoutKind - DataDir = $map['data'] - ShimDir = $map['bin'] - CacheDir = $map['cache'] - ConfigDir = $map['config'] - StateDir = $map['state'] + Invoke-Expression ($result -join "`n") + # A child can update the user PATH, but this session needs the resolved bin directory too. + if (($env:Path -split ';') -notcontains $script:ShimDir) { + $env:Path = "$script:ShimDir;$env:Path" + } + } finally { + $env:VP_SELF_SETUP_SHELL = $previousShell + $env:NPM_CONFIG_REGISTRY = $previousRegistry + $env:VP_SELF_SETUP_SUPPORT_CHECK = $previous } - return $true } try { diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 7f0c88f5be..35694c76b1 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -21,11 +21,11 @@ # When set, overrides VP_VERSION and installs the clearly-defined # 0.0.0-commit. build through the bridge instead of npm. +# When sourced, returns INSTALL_DIR, SHIM_DIR, CACHE_DIR, CONFIG_DIR, and STATE_DIR. +# These are resolved paths, not VP_* overrides for subsequent commands. set -e VP_VERSION="${VP_VERSION:-latest}" -# After these helper definitions, the selected payload resolves category roots -# through VP_DUMP_DIRS. Pre-split payloads use the legacy layout. # npm registry URL (strip trailing slash if present) NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" NPM_REGISTRY="${NPM_REGISTRY%/}" @@ -43,406 +43,23 @@ PR_VERSION="${VP_PR_VERSION:-}" BRIDGE_DOWNLOAD_BASE="https://registry-bridge.viteplus.dev/voidzero-dev/vite-plus" BRIDGE_REGISTRY="https://registry-bridge.viteplus.dev/" -# Colors for output RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' BLUE='\033[0;34m' -BRIGHT_BLUE='\033[0;94m' -BOLD='\033[1m' -DIM='\033[2m' -BOLD_BRIGHT_BLUE='\033[1;94m' -NC='\033[0m' # No Color +NC='\033[0m' +PACKAGE_METADATA="" +# Legacy is published beside this bootstrap; preview builds rewrite this origin. +LEGACY_INSTALLER_URL="${VP_LEGACY_INSTALLER_URL:-https://viteplus.dev/install-legacy.sh}" +INSTALLER_PATH="${BASH_SOURCE[0]:-}" info() { - echo -e "${BLUE}info${NC}: $1" -} - -success() { - echo -e "${GREEN}success${NC}: $1" -} - -warn() { - echo -e "${YELLOW}warn${NC}: $1" -} - -trace() { - [ "${VP_LOG:-}" = "trace" ] || return 0 - echo -e "${DIM}trace${NC}: $1" -} - -report_shell_config_error() { - if [ "${CI:-}" = "true" ]; then - trace "$1" - else - warn "$1" - fi + echo -e "${BLUE}info${NC}: $1" >&2 } error() { - echo -e "${RED}error${NC}: $1" + echo -e "${RED}error${NC}: $1" >&2 exit 1 } -is_release_age_error() { - local log_file="$1" - [ -f "$log_file" ] || return 1 - - # This wrapper install path is pinned to pnpm via packageManager, so this - # detection follows pnpm's resolver/reporter output rather than npm/yarn. - # - # pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so - # NO_MATURE_MATCHING_VERSION is normally printed as - # ERR_PNPM_NO_MATURE_MATCHING_VERSION. npm-resolver emits that code with the - # "does not meet the minimumReleaseAge constraint" message when - # publishedBy/minimumReleaseAge rejects a matching version. - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 - # - # default-reporter may append guidance mentioning minimumReleaseAgeExclude - # when the error has an immatureVersion, so that token is also a useful - # release-age signal. minimum-release-age is pnpm's .npmrc key; npm's - # min-release-age is intentionally not treated as a pnpm signal here. - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 - grep -Eqi 'ERR_PNPM_NO_MATURE_MATCHING_VERSION|NO_MATURE_MATCHING_VERSION|does not meet the minimumReleaseAge constraint|minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" && return 0 - - # pnpm can also surface ERR_PNPM_NO_MATCHING_VERSION when minimumReleaseAge - # filters out all candidates. That code is also used for real missing - # versions, so require age-gate context before prompting for a bypass. - # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 - if grep -Eq 'ERR_PNPM_NO_MATCHING_VERSION' "$log_file"; then - grep -Eqi 'minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" - return $? - fi - - return 1 -} - -confirm_release_age_override() { - [ -e /dev/tty ] && [ -t 1 ] || return 1 - - echo "" > /dev/tty - echo -e "${YELLOW}warn${NC}: Your minimumReleaseAge setting prevented installing vite-plus@${VP_VERSION}." > /dev/tty - echo "This setting helps protect against newly published compromised packages." > /dev/tty - echo "Proceeding will disable this protection for this Vite+ install only." > /dev/tty - printf "Do you want to proceed? (y/N): " > /dev/tty - - local response - read -r response < /dev/tty || return 1 - case "$response" in - y|Y|yes|YES) return 0 ;; - *) return 1 ;; - esac -} - -write_release_age_override() { - # Append idempotently so a bridge registry line written for PR builds survives. - if [ ! -f "$VERSION_DIR/.npmrc" ] || ! grep -q '^minimum-release-age=' "$VERSION_DIR/.npmrc" 2>/dev/null; then - printf 'minimum-release-age=0\n' >> "$VERSION_DIR/.npmrc" - fi -} - -is_absolute_path() { - case "$1" in - /*) return 0 ;; - [A-Za-z]:[\\/]*) return 0 ;; - *) return 1 ;; - esac -} - -# Print $1 when it is a non-empty absolute path; otherwise print nothing. -absolute_override() { - local val="$1" - if [ -n "$val" ] && is_absolute_path "$val"; then - printf '%s\n' "$val" - fi -} - -validate_vp_dir_overrides() { - local count=0 value - for value in "${VP_BIN_DIR:-}" "${VP_DATA_DIR:-}" "${VP_CACHE_DIR:-}"; do - [ -z "$value" ] || count=$((count + 1)) - done - if [ "$count" -ne 0 ] && [ "$count" -ne 3 ]; then - error "Set VP_BIN_DIR, VP_DATA_DIR, and VP_CACHE_DIR together, or leave all three unset." - fi - if [ "$count" -eq 3 ]; then - is_absolute_path "$VP_BIN_DIR" || error "VP_BIN_DIR must be an absolute path." - is_absolute_path "$VP_DATA_DIR" || error "VP_DATA_DIR must be an absolute path." - is_absolute_path "$VP_CACHE_DIR" || error "VP_CACHE_DIR must be an absolute path." - fi -} - -is_windows_uname() { - case "$(uname -s)" in - MINGW*|MSYS*|CYGWIN*) return 0 ;; - *) return 1 ;; - esac -} - -resolution_home_dir() { - if is_windows_uname; then - printf '%s\n' "${USERPROFILE:-$HOME}" - else - printf '%s\n' "${HOME:-$USERPROFILE}" - fi -} - -# Released setup-vp versions add ~/.vite-plus/bin to the GitHub Actions or -# GitLab CI/CD PATH. They do this after the installer exits. Use the monolithic -# layout until setup-vp declares support for VP_DUMP_DIRS. -enable_setup_vp_legacy_compatibility() { - if [ "${GITHUB_ACTION_REPOSITORY:-}" != "voidzero-dev/setup-vp" ]; then - [ "${GITLAB_CI:-}" = "true" ] || return 0 - [ -n "${SETUP_VP_SETUP_REF:-}" ] || return 0 - fi - [ "${VP_VPDIRS_AWARE:-}" != "1" ] || return 0 - [ -z "${VP_HOME:-}" ] || return 0 - [ -z "${VP_BIN_DIR:-}" ] || return 0 - [ -z "${VP_DATA_DIR:-}" ] || return 0 - [ -z "${VP_CACHE_DIR:-}" ] || return 0 - - local resolution_home - resolution_home="$(resolution_home_dir)" - [ -n "$resolution_home" ] || error "Vite+ could not resolve the user home directory." - VP_HOME="$resolution_home/.vite-plus" - export VP_HOME -} - -# Escape a path fragment for a Bash/Zsh double-quoted string. `$HOME` is -# added separately when the config directory is under the user home. -escape_posix_double_quoted() { - local value="$1" - value="${value//\\/\\\\}" - value="${value//\$/\\\$}" - value="${value//\`/\\\`}" - value="${value//\"/\\\"}" - printf '%s' "$value" -} - -# Fish double-quoted strings do not evaluate backticks, but `$`, `"`, and -# backslashes still need escaping. -escape_fish_double_quoted() { - local value="$1" - value="${value//\\/\\\\}" - value="${value//\$/\\\$}" - value="${value//\"/\\\"}" - printf '%s' "$value" -} - -# Nushell expands values only in interpolated strings (`$"..."`). In a plain -# double-quoted string only backslashes and double quotes need escaping. -escape_nu_double_quoted() { - local value="$1" - value="${value//\\/\\\\}" - value="${value//\"/\\\"}" - printf '%s' "$value" -} - -set_config_dir_refs() { - local dir="$1" - local shell_home="$2" - local suffix - if [ -n "$shell_home" ] && case "$dir" in "$shell_home"/*) true;; *) false;; esac; then - suffix="${dir#"$shell_home"}" - CONFIG_DIR_REF_POSIX="\$HOME$(escape_posix_double_quoted "$suffix")" - CONFIG_DIR_REF_FISH="\$HOME$(escape_fish_double_quoted "$suffix")" - CONFIG_DIR_REF_NU="~$(escape_nu_double_quoted "$suffix")" - else - CONFIG_DIR_REF_POSIX="$(escape_posix_double_quoted "$dir")" - CONFIG_DIR_REF_FISH="$(escape_fish_double_quoted "$dir")" - CONFIG_DIR_REF_NU="$(escape_nu_double_quoted "$dir")" - fi -} - -# Monolithic mapping: every category on one root. -set_monolithic_layout() { - LAYOUT_KIND="single-root" - INSTALL_DIR="$1" - SHIM_DIR="$1/bin" - CACHE_DIR="$1/cache" - CONFIG_DIR="$1" - STATE_DIR="$1" -} - -# Pre-split releases resolve all paths from VP_HOME, which defaults to -# ~/.vite-plus. Install them in this monolithic root. This keeps environment -# setup, shims, upgrades, and installer paths consistent. -use_legacy_layout() { - local resolution_home vp_home - resolution_home="$(resolution_home_dir)" - [ -n "$resolution_home" ] || error "Vite+ could not resolve the user home directory." - vp_home="$(absolute_override "${VP_HOME:-}")" - set_monolithic_layout "${vp_home:-$resolution_home/.vite-plus}" - set_config_dir_refs "$CONFIG_DIR" "${HOME:-}" -} - -normalize_existing_dir() { - local dir="${1%/}" - if [ -z "$dir" ]; then - dir="/" - fi - - if [ -d "$dir" ]; then - (cd "$dir" 2>/dev/null && pwd -P) || printf '%s\n' "$dir" - else - local base parent_dir - base="$(basename "$dir")" - parent_dir="$(cd "$(dirname "$dir")" 2>/dev/null && pwd -P)" || parent_dir="" - if [ -z "$parent_dir" ]; then - printf '%s\n' "$dir" - elif [ "$parent_dir" = "/" ]; then - printf '/%s\n' "$base" - else - printf '%s/%s\n' "$parent_dir" "$base" - fi - fi -} - -is_safe_install_dir_to_remove() { - local dir="$1" - [ -n "$dir" ] || return 1 - - case "$dir" in - "/" | "$HOME" | "/bin" | "/opt" | "/usr" | "/usr/bin" | "/usr/local" | "/usr/local/bin") - return 1 - ;; - esac - - return 0 -} - -is_vite_plus_install_dir() { - local dir="$1" - [ -d "$dir" ] || return 1 - [ -d "$dir/bin" ] || return 1 - [ -e "$dir/current" ] || return 1 - [ -e "$dir/bin/vp" ] || [ -e "$dir/bin/vp.exe" ] || [ -e "$dir/bin/vp.cmd" ] -} - -detect_previous_install_dir() { - [ -n "${VP_HOME:-}" ] || return 1 - - local vp_path - vp_path="$(command -v vp 2>/dev/null || true)" - [ -n "$vp_path" ] || return 1 - - case "$(basename "$vp_path")" in - vp | vp.exe | vp.cmd) ;; - *) return 1 ;; - esac - - local old_dir install_dir - old_dir="$(normalize_existing_dir "$(dirname "$(dirname "$vp_path")")")" - install_dir="$(normalize_existing_dir "$INSTALL_DIR")" - [ "$old_dir" != "$install_dir" ] || return 1 - - is_safe_install_dir_to_remove "$old_dir" || return 1 - is_vite_plus_install_dir "$old_dir" || return 1 - - printf '%s\n' "$old_dir" -} - -is_nested_install_dir() { - [ -n "$1" ] && [ -n "$2" ] || return 1 - - local old_dir install_dir - old_dir="$(normalize_existing_dir "$1")" - install_dir="$(normalize_existing_dir "$2")" - - [ "$old_dir" != "$install_dir" ] || return 1 - if [ "$old_dir" = "/" ] || [ "$install_dir" = "/" ]; then - return 0 - fi - - case "$old_dir" in - "$install_dir"/*) return 0 ;; - esac - case "$install_dir" in - "$old_dir"/*) return 0 ;; - esac - - return 1 -} - -prompt_remove_previous_install_dir() { - local old_dir="$1" - [ -n "$old_dir" ] || return 0 - [ -z "${CI:-}" ] || return 0 - [ -e /dev/tty ] && [ -t 1 ] || return 0 - - echo "" > /dev/tty - echo -e "${YELLOW}warn${NC}: Found a previous Vite+ install at $old_dir." > /dev/tty - echo "The new VP_HOME is $INSTALL_DIR." > /dev/tty - printf "Remove the previous install directory? (y/N): " > /dev/tty - - local response - read -r response < /dev/tty || return 0 - case "$response" in - y | Y | yes | YES) - local vp_bin="$old_dir/current/bin/vp" - if [ ! -f "$vp_bin" ]; then - vp_bin="$old_dir/current/bin/vp.exe" - fi - if [ ! -f "$vp_bin" ]; then - warn "Could not remove previous Vite+ install at $old_dir: vp binary not found." - return 0 - fi - - local implode_output - if implode_output=$(VP_HOME="$old_dir" "$vp_bin" implode --yes 2>&1); then - success "Removed previous Vite+ install at $old_dir." - else - warn "Could not remove previous Vite+ install at $old_dir." - if [ -n "$implode_output" ]; then - printf '%s\n' "$implode_output" >&2 - fi - fi - ;; - esac -} - -# Resolve a PR number or commit SHA to the registry bridge's immutable commit -# version (0.0.0-commit.). A full commit SHA maps directly to the bridge's -# deterministic version; a PR number (or short ref) is resolved via the bridge -# download URL's `x-commit-key: ::` header (HEAD). -resolve_bridge_commit_version() { - local ref="$1" - local sha="$ref" - if [[ ! "$ref" =~ ^[0-9a-fA-F]{40}$ ]]; then - sha="$(curl -fsSIL "${BRIDGE_DOWNLOAD_BASE}@${ref}" 2>/dev/null | tr -d '\r' | awk -F ': ' ' - tolower($1) == "x-commit-key" { count = split($2, parts, ":"); print parts[count]; exit }')" - fi - case "$sha" in - '' | *[!0-9a-fA-F]*) return 1 ;; - esac - [ "${#sha}" -eq 40 ] || return 1 - printf '0.0.0-commit.%s' "$sha" -} - -print_install_failure() { - local install_log="$1" - if [ "${CI:-}" = "true" ]; then - echo -e "${RED}error${NC}: Failed to install dependencies. Log output:" - cat "$install_log" - else - echo -e "${RED}error${NC}: Failed to install dependencies. See log for details: $install_log" - fi -} - -print_release_age_failure() { - local install_log="$1" - if [ "${CI:-}" = "true" ]; then - echo -e "${RED}error${NC}: Install blocked by your minimumReleaseAge setting. Log output:" - cat "$install_log" - else - echo -e "${RED}error${NC}: Install blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly. See log for details: $install_log" - fi -} - -# Print user-friendly error message for curl failures -# Arguments: exit_code url print_curl_error() { local exit_code="$1" local url="$2" @@ -491,9 +108,6 @@ print_curl_error() { exit 1 } -# Wrapper for curl with user-friendly error messages -# Arguments: same as curl -# Returns: exits with error message on failure, otherwise returns curl output curl_with_error_handling() { local url="" local args=() @@ -523,7 +137,6 @@ curl_with_error_handling() { print_curl_error "$exit_code" "$url" } -# Detect libc type on Linux (gnu or musl) detect_libc() { # Prefer positive glibc detection first. # This avoids false musl detection on systems where musl is installed @@ -561,7 +174,6 @@ detect_libc() { fi } -# Detect platform detect_platform() { local os arch @@ -591,7 +203,6 @@ detect_platform() { fi } -# Check for required commands check_requirements() { local missing=() @@ -608,17 +219,10 @@ check_requirements() { fi } -# Fetch package metadata from npm registry (cached for reuse) -# Uses VP_VERSION to fetch the correct version's metadata -PACKAGE_METADATA="" fetch_package_metadata() { if [ -z "$PACKAGE_METADATA" ]; then local version_path metadata_url - if [ "$VP_VERSION" = "latest" ]; then - version_path="latest" - else - version_path="$VP_VERSION" - fi + version_path="$VP_VERSION" metadata_url="${NPM_REGISTRY}/vite-plus/${version_path}" PACKAGE_METADATA=$(curl_with_error_handling -s "$metadata_url") if [ -z "$PACKAGE_METADATA" ]; then @@ -643,8 +247,6 @@ fetch_package_metadata() { # PACKAGE_METADATA is set as a global variable, no need to echo } -# Get the version from package metadata -# Sets RESOLVED_VERSION global variable get_version_from_metadata() { # Call fetch_package_metadata to populate PACKAGE_METADATA global # Don't use command substitution as it would swallow the exit from error() @@ -655,10 +257,6 @@ get_version_from_metadata() { fi } -# Get platform suffix for CLI package download -# Sets PLATFORM_SUFFIX global variable -# Platform format from detect_platform(): darwin-arm64, darwin-x64, linux-x64-gnu, linux-arm64-gnu, win32-x64, etc. -# CLI package format: @voidzero-dev/vite-plus-cli-darwin-arm64, @voidzero-dev/vite-plus-cli-linux-x64-gnu, etc. get_platform_suffix() { local platform="$1" case "$platform" in @@ -667,16 +265,14 @@ get_platform_suffix() { esac } -# Download and extract file (silent mode - no progress bar) -download_and_extract() { +download_and_extract() ( local url="$1" local dest_dir="$2" - local strip_components="$3" - local filter="$4" # Download to temp file (silent mode) local temp_file temp_file=$(mktemp) + trap 'rm -f "$temp_file"' EXIT # Run curl and capture exit code for error handling set +e @@ -689,524 +285,69 @@ download_and_extract() { print_curl_error "$exit_code" "$url" fi - if [ -n "$filter" ]; then - tar xzf "$temp_file" -C "$dest_dir" --strip-components="$strip_components" "$filter" 2>/dev/null || \ - tar xzf "$temp_file" -C "$dest_dir" --strip-components="$strip_components" - else - tar xzf "$temp_file" -C "$dest_dir" --strip-components="$strip_components" - fi - rm -f "$temp_file" -} - -join_by() { - local separator="$1" - shift - local result="" - local item - - for item in "$@"; do - if [ -z "$result" ]; then - result="$item" - else - result="${result}${separator}${item}" - fi - done - - printf '%s\n' "$result" -} - -abbreviate_path() { - local path="$1" - if [ "${path#"$HOME"}" != "$path" ]; then - printf '~%s\n' "${path#"$HOME"}" - else - printf '%s\n' "$path" - fi -} - -record_shell_summary() { - local shell_name="$1" - local status="$2" - SHELL_CONFIG_SUMMARY+=(" - ${shell_name}: ${status}") -} - -# Add a sourcing line to an existing shell config file. -# Returns: 0 = line added, 1 = file missing, 2 = already configured, 3 = failed -append_source_to_file() { - local shell_config="$1" - local source_line="$2" - shift 2 - local search_patterns=("$@") - local pattern - - if [ ! -f "$shell_config" ]; then - return 1 - fi - - if [ ! -w "$shell_config" ]; then - report_shell_config_error "Cannot write to $shell_config (permission denied), skipping." - return 3 - fi - - for pattern in "${search_patterns[@]}"; do - if grep -Fq "$pattern" "$shell_config" 2>/dev/null; then - return 2 - fi - done - - { - printf '\n' - printf '%s\n' "# Vite+ bin (https://viteplus.dev)" - printf '%s\n' "$source_line" - } >> "$shell_config" - return 0 -} - -# Create or update an installer-managed snippet file. -# Returns: 0 = written, 2 = already configured, 3 = failed -write_managed_snippet() { - local snippet_file="$1" - local snippet_content="$2" - local snippet_dir - - snippet_dir=$(dirname "$snippet_file") - if ! mkdir -p "$snippet_dir" 2>/dev/null; then - report_shell_config_error "Cannot create $snippet_dir, skipping." - return 3 - fi - - if [ -f "$snippet_file" ] && [ ! -w "$snippet_file" ]; then - report_shell_config_error "Cannot write to $snippet_file (permission denied), skipping." - return 3 - fi - - if [ -f "$snippet_file" ] && printf '%s' "$snippet_content" | cmp -s - "$snippet_file"; then - return 2 - fi - - if ! printf '%s' "$snippet_content" > "$snippet_file"; then - report_shell_config_error "Cannot write to $snippet_file, skipping." - return 3 - fi - return 0 -} - -# Discover Nushell's preferred user-local vendor autoload directory. -# Nushell puts the user-local directory at the end of the list. -discover_nushell_vendor_autoload_dir() { - command -v nu > /dev/null 2>&1 || return 1 - - local nu_dirs_output - nu_dirs_output=$(nu -c '$nu.vendor-autoload-dirs | reverse | each {|dir| $dir } | str join (char nl)' 2>/dev/null) || return 1 - - while IFS= read -r dir; do - [ -n "$dir" ] || continue - printf '%s\n' "$dir" - return 0 - done </dev/null; then - report_shell_config_error "Cannot create $zsh_dir, skipping zsh." - SHELL_CONFIG_HAS_FAILURE="true" - SHELL_CONFIG_FAILED_SHELLS+=("zsh") - record_shell_summary "zsh" "failed (could not create $(abbreviate_path "$zsh_dir"))" - return - fi - - if [ ! -f "$zshenv" ] && ! touch "$zshenv" 2>/dev/null; then - report_shell_config_error "Cannot create $zshenv, skipping zsh." - SHELL_CONFIG_HAS_FAILURE="true" - SHELL_CONFIG_FAILED_SHELLS+=("zsh") - record_shell_summary "zsh" "failed (could not create $(abbreviate_path "$zshenv"))" - return - fi - - result=0 - append_source_to_file "$zshenv" ". \"$CONFIG_DIR_REF_POSIX/env\"" "$CONFIG_DIR/env" "$CONFIG_DIR_REF_POSIX/env" || result=$? - case "$result" in - 0) updated+=("$(abbreviate_path "$zshenv")") ;; - 2) already+=("$(abbreviate_path "$zshenv")") ;; - 3) failed+=("$(abbreviate_path "$zshenv")") ;; - esac - - if [ -f "$zshrc" ]; then - result=0 - append_source_to_file "$zshrc" ". \"$CONFIG_DIR_REF_POSIX/env\"" "$CONFIG_DIR/env" "$CONFIG_DIR_REF_POSIX/env" || result=$? - case "$result" in - 0) updated+=("$(abbreviate_path "$zshrc")") ;; - 2) already+=("$(abbreviate_path "$zshrc")") ;; - 3) failed+=("$(abbreviate_path "$zshrc")") ;; - esac - fi - - local details=() - if [ ${#updated[@]} -gt 0 ]; then - SHELL_CONFIG_HAS_UPDATED="true" - SHELL_CONFIG_HAS_CONFIGURED="true" - details+=("updated $(join_by ', ' "${updated[@]}")") - fi - if [ ${#already[@]} -gt 0 ]; then - SHELL_CONFIG_HAS_CONFIGURED="true" - details+=("already configured $(join_by ', ' "${already[@]}")") - fi - if [ ${#failed[@]} -gt 0 ]; then - SHELL_CONFIG_HAS_FAILURE="true" - SHELL_CONFIG_FAILED_SHELLS+=("zsh") - details+=("failed $(join_by ', ' "${failed[@]}")") - fi - - if [ ${#details[@]} -eq 0 ]; then - record_shell_summary "zsh" "skipped" - else - record_shell_summary "zsh" "$(join_by '; ' "${details[@]}")" - fi -} - -configure_bash_path() { - local updated=() - local already=() - local failed=() - local existing=0 - local file result + tar xzf "$temp_file" -C "$dest_dir" --strip-components=1 - for file in "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do - if [ ! -f "$file" ]; then - continue - fi - existing=1 - result=0 - append_source_to_file "$file" ". \"$CONFIG_DIR_REF_POSIX/env\"" "$CONFIG_DIR/env" "$CONFIG_DIR_REF_POSIX/env" || result=$? - case "$result" in - 0) updated+=("$(abbreviate_path "$file")") ;; - 2) already+=("$(abbreviate_path "$file")") ;; - 3) failed+=("$(abbreviate_path "$file")") ;; - esac - done +) - if [ "$existing" -eq 0 ]; then - record_shell_summary "bash" "skipped (no existing rc files)" - return - fi - - local details=() - if [ ${#updated[@]} -gt 0 ]; then - SHELL_CONFIG_HAS_UPDATED="true" - SHELL_CONFIG_HAS_CONFIGURED="true" - details+=("updated $(join_by ', ' "${updated[@]}")") - fi - if [ ${#already[@]} -gt 0 ]; then - SHELL_CONFIG_HAS_CONFIGURED="true" - details+=("already configured $(join_by ', ' "${already[@]}")") - fi - if [ ${#failed[@]} -gt 0 ]; then - SHELL_CONFIG_HAS_FAILURE="true" - SHELL_CONFIG_FAILED_SHELLS+=("bash") - details+=("failed $(join_by ', ' "${failed[@]}")") +resolve_bridge_commit_version() { + local ref="$1" + local sha="$ref" + if [[ ! "$ref" =~ ^[0-9a-fA-F]{40}$ ]]; then + sha="$(curl -fsSIL "${BRIDGE_DOWNLOAD_BASE}@${ref}" 2>/dev/null | tr -d '\r' | awk -F ': ' ' + tolower($1) == "x-commit-key" { count = split($2, parts, ":"); print parts[count]; exit }')" fi - - record_shell_summary "bash" "$(join_by '; ' "${details[@]}")" -} - -configure_fish_path() { - local fish_config="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish" - local fish_content="# Vite+ bin (https://viteplus.dev) -source \"$CONFIG_DIR_REF_FISH/env.fish\" -" - - local result=0 - write_managed_snippet "$fish_config" "$fish_content" || result=$? - case "$result" in - 0) - SHELL_CONFIG_HAS_UPDATED="true" - SHELL_CONFIG_HAS_CONFIGURED="true" - record_shell_summary "fish" "updated $(abbreviate_path "$fish_config")" - ;; - 2) - SHELL_CONFIG_HAS_CONFIGURED="true" - record_shell_summary "fish" "already configured $(abbreviate_path "$fish_config")" - ;; - *) - SHELL_CONFIG_HAS_FAILURE="true" - SHELL_CONFIG_FAILED_SHELLS+=("fish") - record_shell_summary "fish" "failed $(abbreviate_path "$fish_config")" - ;; + case "$sha" in + '' | *[!0-9a-fA-F]*) return 1 ;; esac + [ "${#sha}" -eq 40 ] || return 1 + printf '0.0.0-commit.%s' "$sha" } -configure_nushell_path() { - local nushell_dir - nushell_dir=$(discover_nushell_vendor_autoload_dir 2>/dev/null) || true - if [ -z "$nushell_dir" ]; then - SHELL_CONFIG_HAS_FAILURE="true" - SHELL_CONFIG_FAILED_SHELLS+=("nushell") - record_shell_summary "nushell" "failed (could not determine vendor autoload dir)" - return - fi - - local nushell_autoload="$nushell_dir/vite-plus.nu" - local nushell_content="# Vite+ bin (https://viteplus.dev) -source \"$CONFIG_DIR_REF_NU/env.nu\" -" - - local result=0 - write_managed_snippet "$nushell_autoload" "$nushell_content" || result=$? - case "$result" in - 0) - SHELL_CONFIG_HAS_UPDATED="true" - SHELL_CONFIG_HAS_CONFIGURED="true" - record_shell_summary "nushell" "updated $(abbreviate_path "$nushell_autoload")" - ;; - 2) - SHELL_CONFIG_HAS_CONFIGURED="true" - record_shell_summary "nushell" "already configured $(abbreviate_path "$nushell_autoload")" - ;; - *) - SHELL_CONFIG_HAS_FAILURE="true" - SHELL_CONFIG_FAILED_SHELLS+=("nushell") - record_shell_summary "nushell" "failed $(abbreviate_path "$nushell_autoload")" - ;; +is_windows_uname() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; esac } -# Configure supported shell PATH integrations for all installed shells. -configure_shell_path() { - SHELL_CONFIG_SUMMARY=() - SHELL_CONFIG_FAILED_SHELLS=() - SHELL_CONFIG_HAS_UPDATED="false" - SHELL_CONFIG_HAS_CONFIGURED="false" - SHELL_CONFIG_HAS_FAILURE="false" - - if command -v zsh > /dev/null 2>&1; then - configure_zsh_path - else - record_shell_summary "zsh" "skipped (not installed)" - fi - - if command -v bash > /dev/null 2>&1; then - configure_bash_path - else - record_shell_summary "bash" "skipped (not installed)" - fi - - if command -v fish > /dev/null 2>&1; then - configure_fish_path - else - record_shell_summary "fish" "skipped (not installed)" - fi - - if command -v nu > /dev/null 2>&1; then - configure_nushell_path +resolution_home_dir() { + if is_windows_uname; then + printf '%s\n' "${USERPROFILE:-$HOME}" else - record_shell_summary "nushell" "skipped (not installed)" - fi -} - -# Run vp env setup --refresh, showing output only on failure -# Arguments: vp_bin - path to the vp binary -refresh_shims() { - local vp_bin="$1" - local setup_output - if ! setup_output=$("$vp_bin" env setup --refresh 2>&1); then - warn "Failed to refresh shims:" - echo "$setup_output" >&2 - fi -} - -# Return success only if this Vite+ install owns the existing Node entry. A bin -# from an explicit override group can be shared. Entry existence does not permit -# replacement. -is_vite_plus_node_shim() { - local bin_path="$1" - local vp_bin="$2" - - # Unix shims are symlinks to the active vp binary. `-ef` follows the link. It - # accepts the old relative target and the absolute split-layout target. - if [ -L "$bin_path/node" ] && [ "$bin_path/node" -ef "$vp_bin" ]; then - return 0 - fi - - # install.sh can also run under Git Bash/MSYS. Windows trampolines carry a - # per-executable sidecar that records the owning data root. - if [ -f "$bin_path/node.exe" ] && [ -f "$bin_path/node.shim" ]; then - local pointer="" - pointer="$(shim_pointer_data "$bin_path/node.shim")" || return 1 - [ "$pointer" = "$INSTALL_DIR" ] && return 0 - fi - - return 1 -} - -shim_pointer_data() { - local file="$1" first="" line="" - IFS= read -r first < "$file" || [ -n "$first" ] || return 1 - first="${first%$'\r'}" - if [ "$first" != "vite-plus-shim-v1" ]; then - return 1 - fi - while IFS= read -r line || [ -n "$line" ]; do - line="${line%$'\r'}" - case "$line" in - data=*) printf '%s\n' "${line#data=}"; return 0 ;; - esac - done < "$file" - return 1 -} - -# Setup Vite+ environment shims -# Sets NODE_MANAGER_ENABLED global -# Arguments: bin_dir - path to the version's bin directory containing vp -setup_node_manager() { - local bin_dir="$1" - local bin_path="$SHIM_DIR" - NODE_MANAGER_ENABLED="false" - - # Resolve vp binary name (vp on Unix, vp.exe on Windows) - local vp_bin="$bin_dir/vp" - if [ -f "$bin_dir/vp.exe" ]; then - vp_bin="$bin_dir/vp.exe" - fi - - # Explicit override via environment variable - if [ "$VP_NODE_MANAGER" = "yes" ]; then - refresh_shims "$vp_bin" - NODE_MANAGER_ENABLED="true" - return 0 - elif [ "$VP_NODE_MANAGER" = "no" ]; then - NODE_MANAGER_ENABLED="false" - return 0 - fi - - # Check if an existing Node entry is a Vite+ shim. A foreign entry in a custom - # bin directory prevents automatic enablement. The prompt below can get - # permission to replace the entry. - local unmanaged_node_in_bin="false" - if [ -e "$bin_path/node" ] || [ -L "$bin_path/node" ] || [ -e "$bin_path/node.exe" ]; then - if is_vite_plus_node_shim "$bin_path" "$vp_bin"; then - refresh_shims "$vp_bin" - NODE_MANAGER_ENABLED="already" - return 0 - fi - unmanaged_node_in_bin="true" - fi - - # Auto-enable on CI or devcontainer environments - # CI: standard CI environment variable (GitHub Actions, Travis, CircleCI, etc.) - # CODESPACES: set by GitHub Codespaces (https://docs.github.com/en/codespaces) - # REMOTE_CONTAINERS: set by VS Code Dev Containers extension - # DEVPOD: set by DevPod (https://devpod.sh) - if [ "$unmanaged_node_in_bin" = "false" ] && { [ -n "$CI" ] || [ -n "$CODESPACES" ] || [ -n "$REMOTE_CONTAINERS" ] || [ -n "$DEVPOD" ]; }; then - refresh_shims "$vp_bin" - NODE_MANAGER_ENABLED="true" - return 0 - fi - - # Check if node is available on the system - local node_available="false" - if command -v node &> /dev/null; then - node_available="true" - fi - - # Auto-enable if no node available on system - if [ "$node_available" = "false" ] && [ "$unmanaged_node_in_bin" = "false" ]; then - refresh_shims "$vp_bin" - NODE_MANAGER_ENABLED="true" - return 0 - fi - - # Prompt user in interactive mode - if [ -e /dev/tty ] && [ -t 1 ]; then - echo "" - echo "Would you like Vite+ to manage your Node.js and package-manager versions?" - echo "Vite+ adds \`node\`, \`npm\`, \`npx\`, \`pnpm\`, \`pnpx\`, \`yarn\`, \`yarnpkg\`, \`bun\`, and \`bunx\` shims to $(abbreviate_path "$SHIM_DIR")." - echo "It selects the required version automatically." - echo "Opt out anytime with \`vp env off\`." - echo -n "Press Enter to accept (Y/n): " - read -r response < /dev/tty - - if [ -z "$response" ] || [ "$response" = "y" ] || [ "$response" = "Y" ]; then - refresh_shims "$vp_bin" - NODE_MANAGER_ENABLED="true" - fi + printf '%s\n' "${HOME:-$USERPROFILE}" fi } -# Cleanup old versions, keeping only the most recent ones -cleanup_old_versions() { - local max_versions=3 - local versions=() - - # List version directories (semver format like 0.1.0, 1.2.3-beta.1, 0.0.0-f48af939.20260205-0533) - # This excludes 'current' symlink and non-semver directories like 'local-dev' - local semver_regex='^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$' - for dir in "$INSTALL_DIR"/*/; do - local name - name=$(basename "$dir") - if [ -d "$dir" ] && [[ "$name" =~ $semver_regex ]]; then - versions+=("$dir") - fi - done - - local count=${#versions[@]} - if [ "$count" -le "$max_versions" ]; then - return 0 - fi - - # Sort by creation time (oldest first) and delete excess - local sorted_versions - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS: use stat -f %B for birth time - sorted_versions=$(for v in "${versions[@]}"; do - echo "$(stat -f %B "$v") $v" - done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-) - else - # Linux: use stat -c %W for birth time, fallback to %Y (mtime) - sorted_versions=$(for v in "${versions[@]}"; do - local btime - btime=$(stat -c %W "$v" 2>/dev/null) - if [ "$btime" = "0" ] || [ -z "$btime" ]; then - btime=$(stat -c %Y "$v") - fi - echo "$btime $v" - done | sort -n | head -n $((count - max_versions)) | cut -d' ' -f2-) +# Released setup-vp versions add ~/.vite-plus/bin to the GitHub Actions or +# GitLab CI/CD PATH. They do this after the installer exits. Use the monolithic +# layout until setup-vp declares support for VP_DUMP_DIRS. +enable_setup_vp_legacy_compatibility() { + if [ "${GITHUB_ACTION_REPOSITORY:-}" != "voidzero-dev/setup-vp" ]; then + [ "${GITLAB_CI:-}" = "true" ] || return 0 + [ -n "${SETUP_VP_SETUP_REF:-}" ] || return 0 fi + [ "${VP_VPDIRS_AWARE:-}" != "1" ] || return 0 + [ -z "${VP_HOME:-}" ] || return 0 + [ -z "${VP_BIN_DIR:-}" ] || return 0 + [ -z "${VP_DATA_DIR:-}" ] || return 0 + [ -z "${VP_CACHE_DIR:-}" ] || return 0 - # Delete oldest versions (silently) - for old_version in $sorted_versions; do - rm -rf "$old_version" - done + local resolution_home + resolution_home="$(resolution_home_dir)" + [ -n "$resolution_home" ] || error "Vite+ could not resolve the user home directory." + VP_HOME="$resolution_home/.vite-plus" + export VP_HOME } main() { - echo "" - echo -e "Setting up VITE+..." + enable_setup_vp_legacy_compatibility if [ -n "$PR_VERSION" ] && [ -n "$LOCAL_TGZ" ]; then error "VP_PR_VERSION and VP_LOCAL_TGZ cannot be used together" fi - validate_vp_dir_overrides - enable_setup_vp_legacy_compatibility check_requirements - local previous_install_dir="" - - local platform - platform=$(detect_platform) - # Local development mode: use local tgz if [ -n "$LOCAL_TGZ" ]; then # Validate local tgz @@ -1220,15 +361,10 @@ main() { if [ -z "$LOCAL_BINARY" ] || [ ! -f "$LOCAL_BINARY" ]; then error "Set VP_LOCAL_BINARY when you use VP_LOCAL_TGZ." fi - if ! apply_dirs_from_vp "$LOCAL_BINARY"; then - use_legacy_layout - info "The local vite-plus binary does not support the split directory layout. Vite+ will install it in $(abbreviate_path "$INSTALL_DIR")." - fi elif [ -n "$PR_VERSION" ]; then # Registry bridge mode: resolve the requested PR/SHA to the bridge's # immutable commit version (0.0.0-commit.), the clearly-defined test - # version we install. The directory label stays non-semver so it keeps out - # of cleanup_old_versions and makes the PR build obvious in `~/.vite-plus/`. + # version we install. Legacy receives the full SHA as its preview ref. # `|| true` keeps `set -e` from aborting this assignment when resolution # fails (unregistered ref / transient bridge error), so the actionable # error below is reachable instead of the installer exiting silently. @@ -1236,7 +372,7 @@ main() { if [ -z "$PR_COMMIT_VERSION" ]; then error "Could not resolve a registry bridge build for ${PR_VERSION}" fi - VP_VERSION="pkg-pr-new-${PR_VERSION}" + VP_VERSION="$PR_COMMIT_VERSION" info "Using registry bridge build: ${PR_COMMIT_VERSION}" else # Fetch package metadata and resolve version from npm @@ -1244,14 +380,23 @@ main() { VP_VERSION="$RESOLVED_VERSION" fi + local platform + platform=$(detect_platform) + local result + result="$(set -e; acquire_and_handoff "$platform")" || return $? + # setup-vp reads these assignments in the shell that sourced this installer. + eval "$result" +} + +acquire_and_handoff() ( + local platform="$1" local binary_name="vp" if [[ "$platform" == win32* ]]; then binary_name="vp.exe" fi - # Download the CLI platform tarball before Vite+ selects the final layout. - # The downloaded binary reports the layout that it supports. - local platform_temp_dir="" + # Keep acquisition separate from permanent installation. The bootstrap owns cleanup. + local binary_source platform_temp_dir="" if [ -z "$LOCAL_TGZ" ]; then # npm registry or registry bridge (when PR_VERSION is set) get_platform_suffix "$platform" @@ -1259,7 +404,7 @@ main() { if [ -n "$PR_VERSION" ]; then # The registry bridge redirects this URL to the platform tarball for the # matching commit build (0.0.0-commit.). - platform_url="${BRIDGE_DOWNLOAD_BASE}/@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}@${PR_VERSION}" + platform_url="${BRIDGE_DOWNLOAD_BASE}/@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}@${PR_COMMIT_VERSION#0.0.0-commit.}" else local package_name="@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}" platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PLATFORM_SUFFIX}-${VP_VERSION}.tgz" @@ -1267,260 +412,64 @@ main() { # Create temp directory for extraction platform_temp_dir=$(mktemp -d) - download_and_extract "$platform_url" "$platform_temp_dir" 1 - chmod +x "$platform_temp_dir/$binary_name" - - # Ask the downloaded binary for its layout through VP_DUMP_DIRS. A pre-split - # release cannot report a layout. Give that release the monolithic root so - # the installed PATH commands work. - if ! apply_dirs_from_vp "$platform_temp_dir/$binary_name"; then - use_legacy_layout - info "vite-plus ${VP_VERSION} does not support the split directory layout. Vite+ will install it in $(abbreviate_path "$INSTALL_DIR")." - fi - fi - - # Run layout migration checks after the payload resolves the category roots. - # A pre-split payload selects the legacy layout first. - previous_install_dir="$(detect_previous_install_dir || true)" - if [ -n "$previous_install_dir" ] && is_nested_install_dir "$previous_install_dir" "$INSTALL_DIR"; then - error "The previous Vite+ install at $previous_install_dir overlaps with VP_HOME $INSTALL_DIR. Set VP_HOME to a directory that does not overlap. Alternatively, remove the previous install." - fi - - # Set up version-specific directories - VERSION_DIR="$INSTALL_DIR/$VP_VERSION" - BIN_DIR="$VERSION_DIR/bin" - CURRENT_LINK="$INSTALL_DIR/current" - - # Create bin directory - mkdir -p "$BIN_DIR" - - if [ -n "$LOCAL_TGZ" ]; then - # Local development mode: only need the binary - info "Vite+ uses the local tarball: $LOCAL_TGZ" - - # Copy binary from LOCAL_BINARY env var (set by install-global-cli.ts) - cp "$LOCAL_BINARY" "$BIN_DIR/$binary_name" - # On Windows, also copy the trampoline shim binary if available - if [[ "$platform" == win32* ]]; then - local shim_src - shim_src="$(dirname "$LOCAL_BINARY")/vp-shim.exe" - if [ -f "$shim_src" ]; then - cp "$shim_src" "$BIN_DIR/vp-shim.exe" - fi - fi - chmod +x "$BIN_DIR/$binary_name" + platform_temp_dir=$(cd "$platform_temp_dir" && pwd -P) + trap "rm -rf -- $(printf '%q' "$platform_temp_dir")" EXIT + download_and_extract "$platform_url" "$platform_temp_dir" || exit $? + binary_source="$platform_temp_dir/$binary_name" + [ -f "$binary_source" ] || error "Downloaded package does not contain $binary_name" + chmod +x "$binary_source" else - # Copy binary to BIN_DIR - cp "$platform_temp_dir/$binary_name" "$BIN_DIR/" - chmod +x "$BIN_DIR/$binary_name" - # On Windows, also copy the trampoline shim binary if present in the package - if [[ "$platform" == win32* ]] && [ -f "$platform_temp_dir/vp-shim.exe" ]; then - cp "$platform_temp_dir/vp-shim.exe" "$BIN_DIR/" - fi - rm -rf "$platform_temp_dir" + binary_source="$(cd "$(dirname "$LOCAL_BINARY")" && pwd -P)/$(basename "$LOCAL_BINARY")" fi - # Generate wrapper package.json that declares vite-plus as a dependency. - # pnpm will install vite-plus and all transitive deps via `vp install`. - # The packageManager field pins pnpm to a known-good version, ensuring - # consistent behavior regardless of the user's global pnpm version. - # In PR mode, pin vite-plus to the bridge's clearly-defined commit version and - # resolve it (plus its platform binaries and transitive deps) through the - # bridge registry written to .npmrc below. The bridge rewrites a preview - # tarball's transitive deps to versions, not self-contained URLs, so a full - # install must go through the registry rather than the bare download URL. - local vite_plus_spec="$VP_VERSION" - if [ -n "$PR_VERSION" ]; then - vite_plus_spec="$PR_COMMIT_VERSION" - # Resolve the commit version + platform binaries through the bridge. Drop any - # stale wrapper lockfile: the pkg-pr-new- dir is reused across a PR's - # commits and install.sh rewrites this package.json each run, so a leftover - # lockfile pinning a prior spec would fail `vp install` with - # ERR_PNPM_OUTDATED_LOCKFILE under CI's frozen-lockfile default. Removing it - # lets the install regenerate a lockfile matching the spec we just wrote. - printf 'registry=%s\n' "$BRIDGE_REGISTRY" > "$VERSION_DIR/.npmrc" - rm -f "$VERSION_DIR/pnpm-lock.yaml" - fi - cat > "$VERSION_DIR/package.json" < "$install_log" 2>&1); then - if is_release_age_error "$install_log"; then - if confirm_release_age_override; then - # Write the override only after explicit consent, then retry once. - write_release_age_override - if ! (cd "$VERSION_DIR" && CI=true "$vp_install_bin" install > "$install_log" 2>&1); then - print_install_failure "$install_log" - exit 1 - fi - else - print_release_age_failure "$install_log" - exit 1 - fi - else - print_install_failure "$install_log" - exit 1 - fi - fi - fi - - # Create/update current symlink (use relative path for portability) - ln -sfn "$VP_VERSION" "$CURRENT_LINK" - - # Create user bin directory and vp entrypoint (always done) - mkdir -p "$SHIM_DIR" - if [[ "$platform" == win32* ]]; then - # Windows: copy trampoline as vp.exe (matching install.ps1) - if [ -f "$INSTALL_DIR/current/bin/vp-shim.exe" ]; then - cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$SHIM_DIR/vp.exe" - # For a complete split override group, the trampoline reads .shim - # instead of inherited environment variables. - printf 'vite-plus-shim-v1\nlayout=%s\ndata=%s\ncache=%s\n' \ - "$LAYOUT_KIND" "$INSTALL_DIR" "$CACHE_DIR" >"$SHIM_DIR/vp.shim" - fi + if supports_self_setup "$binary_source"; then + handoff_install "$binary_source" else - ln -sfn "$INSTALL_DIR/current/bin/vp" "$SHIM_DIR/vp" - fi - - # Cleanup old versions - cleanup_old_versions - - # Create env files with PATH guard (prevents duplicate PATH entries) - # Use current/bin/vp directly (the real binary) instead of bin/vp (trampoline) - # to avoid the self-overwrite issue on Windows during --refresh - local vp_bin="$INSTALL_DIR/current/bin/vp" - if [[ "$platform" == win32* ]]; then - vp_bin="$INSTALL_DIR/current/bin/vp.exe" - fi - "$vp_bin" env setup --env-only > /dev/null - - # Setup Node.js version manager (shims) - separate component - setup_node_manager "$BIN_DIR" - if [ "$NODE_MANAGER_ENABLED" = "true" ]; then - if ! "$vp_bin" env on > /dev/null 2>&1; then - warn "Failed to record environment management preference." - fi + run_legacy_installer "$binary_source" fi +) - prompt_remove_previous_install_dir "$previous_install_dir" - - # Configure shell PATH after the install is otherwise complete. - configure_shell_path - - # Use ~ when an install location is under HOME. Otherwise, show the full path. - local display_data_dir display_bin_dir - display_data_dir="$(abbreviate_path "$INSTALL_DIR")" - display_bin_dir="$(abbreviate_path "$SHIM_DIR")" - - # Print success message - echo "" - echo -e "${GREEN}✔${NC} ${BOLD_BRIGHT_BLUE}VITE+${NC} successfully installed!" - echo "" - echo " The Unified Toolchain for the Web." - echo "" - echo -e " ${BOLD}Get started:${NC}" - echo -e " ${BRIGHT_BLUE}vp create${NC} Create a new project" - echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js and package managers" - echo -e " ${BRIGHT_BLUE}vp install${NC} Install dependencies" - echo -e " ${BRIGHT_BLUE}vp migrate${NC} Migrate to Vite+" - - if [ "$NODE_MANAGER_ENABLED" = "true" ] || [ "$NODE_MANAGER_ENABLED" = "already" ]; then - echo "" - echo -e " Vite+ is now managing Node.js and package managers via ${BRIGHT_BLUE}vp env${NC}." - echo -e " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup, or ${BRIGHT_BLUE}vp env off${NC} to opt out." - fi - - echo "" - echo -e " Run ${BRIGHT_BLUE}vp help${NC} to see available commands." - - echo "" - echo -e " ${BOLD}Install locations:${NC}" - echo " Data directory: $display_data_dir" - echo " Bin directory: $display_bin_dir" - - # CI jobs configure PATH through the runner. - # Shell files do not change PATH for later steps. - # Do not print shell details in normal CI output. - if [ "${CI:-}" = "true" ]; then - echo "" - return - fi - - echo "" - echo " Shell configuration:" - local summary_line - for summary_line in "${SHELL_CONFIG_SUMMARY[@]}"; do - echo "$summary_line" +supports_self_setup() { + local response + # Old binaries must exit with help rather than opening an interactive picker. + response=$(VP_SELF_SETUP_SUPPORT_CHECK=1 "$1" --help 2>/dev/null && printf '.') || return 1 + [ "$response" = $'vite-plus-self-setup-v1\n.' ] +} + +run_legacy_installer() ( + local binary_source="$1" + local legacy_script="" + if [ -n "$INSTALLER_PATH" ] && [ -f "$INSTALLER_PATH" ]; then + legacy_script="$(dirname "$INSTALLER_PATH")/install-legacy.sh" + fi + if [ -z "$legacy_script" ] || [ ! -f "$legacy_script" ]; then + legacy_script=$(mktemp) + trap "rm -f -- $(printf '%q' "$legacy_script")" EXIT + curl_with_error_handling -fsSL "$LEGACY_INSTALLER_URL" -o "$legacy_script" >&2 + fi + # Preserve the child status explicitly, including when a caller disables errexit. + local status=0 + source "$legacy_script" "$binary_source" "$VP_VERSION" "$PR_VERSION" >&2 || status=$? + [ "$status" -eq 0 ] || exit "$status" + local name + for name in INSTALL_DIR SHIM_DIR CACHE_DIR CONFIG_DIR STATE_DIR; do + printf '%s=%q\n' "$name" "${!name}" done +) - # Show restart note if any shell config was updated - if [ "$SHELL_CONFIG_HAS_UPDATED" = "true" ]; then - echo "" - echo " Note: Restart your terminal to load updated shell configuration." - fi - - # Show manual PATH instructions if no shell was configured or any shell failed - if [ "$SHELL_CONFIG_HAS_CONFIGURED" = "false" ] || [ "$SHELL_CONFIG_HAS_FAILURE" = "true" ]; then - echo "" - echo -e " ${YELLOW}note${NC}: Some shells still need manual setup." - echo "" - echo " Manual setup instructions:" - echo " - Bash/Zsh: add the following to your shell config (~/.bashrc, ~/.zshrc, etc.):" - printf ' . "%s/env"\n' "$CONFIG_DIR_REF_POSIX" - echo " - Fish: create ${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish with:" - printf ' source "%s/env.fish"\n' "$CONFIG_DIR_REF_FISH" - echo " - Nushell: create a vendor autoload file with:" - printf ' source "%s/env.nu"\n' "$CONFIG_DIR_REF_NU" - echo "" - echo " Or run vp directly:" - echo "" - echo -e " ${display_bin_dir}/vp" +handoff_install() ( + unset VP_SELF_SETUP_SUPPORT_CHECK + # Preview dependencies must use the same registry as the downloaded binary. + if [ -n "$PR_VERSION" ]; then + export NPM_CONFIG_REGISTRY="$BRIDGE_REGISTRY" fi - - echo "" -} - -apply_dirs_from_vp() { - local vp="$1" - local out - out="$(VP_DUMP_DIRS=1 "$vp" 2>/dev/null)" || return 1 - INSTALL_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "data" { print $2; exit }')" - SHIM_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "bin" { print $2; exit }')" - CACHE_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "cache" { print $2; exit }')" - CONFIG_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "config" { print $2; exit }')" - STATE_DIR="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "state" { print $2; exit }')" - LAYOUT_KIND="$(printf '%s\n' "$out" | awk -F '\t' '$1 == "layout" { print $2; exit }')" - [ -n "$INSTALL_DIR" ] && [ -n "$SHIM_DIR" ] && [ -n "$CACHE_DIR" ] && [ -n "$CONFIG_DIR" ] && [ -n "$STATE_DIR" ] || return 1 - if [ "$LAYOUT_KIND" != "single-root" ] && [ "$LAYOUT_KIND" != "split" ]; then - if [ "$SHIM_DIR" = "$INSTALL_DIR/bin" ] && [ "$CACHE_DIR" = "$INSTALL_DIR/cache" ] \ - && [ "$CONFIG_DIR" = "$INSTALL_DIR" ] && [ "$STATE_DIR" = "$INSTALL_DIR" ]; then - LAYOUT_KIND="single-root" - else - LAYOUT_KIND="split" - fi + # curl | bash leaves stdin on the script pipe; setup consent must read from the terminal. + if [ -t 2 ] && [ -z "${CI+x}" ]; then + exec < /dev/tty fi - set_config_dir_refs "$CONFIG_DIR" "${HOME:-}" -} + local status=0 + VP_SELF_SETUP_SHELL=sh "$1" || status=$? + exit "$status" +) main "$@"