diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3855fcfd22..24795c2b17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -435,6 +435,8 @@ jobs: - name: Install Global CLI vp run: | + # Grandfather legacy root until snapshot/CI paths fully use split layout (#2371). + mkdir -p "${USERPROFILE:-$HOME}/.vite-plus" pnpm bootstrap-cli:ci if [[ "$RUNNER_OS" == "Windows" ]]; then echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH @@ -846,7 +848,8 @@ jobs: echo "Error: $VP_HOME still exists after implode" exit 1 fi - # Reinstall + # Reinstall (re-seed legacy root for grandfather until #2371) + mkdir -p "${USERPROFILE:-$HOME}/.vite-plus" pnpm bootstrap-cli:ci vp --version @@ -875,6 +878,7 @@ jobs: Write-Error "$vpHome still exists after implode" exit 1 } + New-Item -ItemType Directory -Force -Path (Join-Path $HOME '.vite-plus') | Out-Null pnpm bootstrap-cli:ci vp --version @@ -898,6 +902,7 @@ jobs: echo Error: .vite-plus still exists after implode exit /b 1 ) + mkdir "%USERPROFILE%\.vite-plus" 2>NUL pnpm bootstrap-cli:ci vp --version @@ -943,7 +948,10 @@ jobs: target: ${{ matrix.target }} - name: Install Global CLI vp - run: pnpm bootstrap-cli:ci + run: | + # Grandfather legacy root until snapshot fixtures use split layout (#2371). + mkdir -p "$HOME/.vite-plus" + pnpm bootstrap-cli:ci # Provision the managed runtime once into the real home so cases can # seed from it (seed-runtime) instead of each downloading ~50MB. @@ -1030,7 +1038,10 @@ jobs: - name: Install Global CLI vp shell: bash - run: pnpm bootstrap-cli:ci + run: | + # Grandfather legacy root until snapshot fixtures use split layout (#2371). + mkdir -p "$USERPROFILE/.vite-plus" + pnpm bootstrap-cli:ci # Provision the managed runtime once into the real home so cases can # seed from it (seed-runtime) instead of each downloading ~50MB. @@ -1131,6 +1142,8 @@ jobs: cp ./package/rolldown-binding.linux-x64-musl.node ./rolldown/packages/rolldown/dist/shared/ rm -rf package *.tgz + # Grandfather legacy root until CI paths fully use split layout (#2371). + mkdir -p /root/.vite-plus pnpm bootstrap-cli:ci export PATH=\"/root/.vite-plus/bin:\$PATH\" @@ -1240,6 +1253,8 @@ jobs: - name: Build CLI run: | + # Grandfather legacy root until CI paths fully use split layout (#2371). + mkdir -p "$HOME/.vite-plus" pnpm bootstrap-cli:ci echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH @@ -1357,6 +1372,8 @@ jobs: - name: Build CLI run: | + # Grandfather legacy root until CI paths fully use split layout (#2371). + mkdir -p "${USERPROFILE:-$HOME}/.vite-plus" pnpm bootstrap-cli:ci # Mirror the per-OS path form used by the `test` job (ci.yml ~L266): # GITHUB_PATH on Windows needs a Windows-style path, otherwise child diff --git a/.github/workflows/publish-preview.yml b/.github/workflows/publish-preview.yml index b6bed535a6..69607b2bae 100644 --- a/.github/workflows/publish-preview.yml +++ b/.github/workflows/publish-preview.yml @@ -307,6 +307,8 @@ jobs: # Post (or update) a single sticky PR comment with the preview image tag after # it publishes. Re-runs reuse the same comment via the hidden marker instead of # creating a new one. + # Split / legacy-upgrade install coverage lives in test-standalone-install.yml + # (local branch build + install.sh, no bridge dependency). comment-docker-preview: if: >- github.repository == 'voidzero-dev/vite-plus' && diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index e6aab2c585..dd9a4047c7 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -5,12 +5,19 @@ permissions: {} on: workflow_dispatch: pull_request: + # Released-CLI jobs seed ~/.vite-plus so install.sh/ps1 take the Exist + # (grandfather) branch against npm latest. Layout jobs build the branch + # CLI and exercise fresh split install + legacy upgrade + implode. paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/tools/src/install-global-cli.ts' - 'crates/vp_installer/**' - 'crates/vp_pm_cli/**' - 'crates/vp_setup/**' + - 'crates/vp_shared/**' + - 'crates/vp_global_cli/**' + - 'crates/vp_trampoline/**' - '.github/workflows/test-standalone-install.yml' concurrency: @@ -40,6 +47,9 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - name: Seed legacy root (released CLI uses grandfather path) + run: mkdir -p "$HOME/.vite-plus" + - name: Run install.sh run: cat packages/cli/install.sh | bash @@ -130,6 +140,7 @@ jobs: - name: Run install.sh run: | + mkdir -p "$HOME/.vite-plus" output=$(cat packages/cli/install.sh | bash 2>&1) || { echo "$output" echo "Install script exited with non-zero status" @@ -169,13 +180,14 @@ jobs: ubuntu:20.04 bash -c " ls -al ~/ apt-get update && apt-get install -y curl ca-certificates - cat /workspace/packages/cli/install.sh | bash + # Escape \$HOME so the container expands it (not the GHA host). + mkdir -p \"\$HOME/.vite-plus\" && cat /workspace/packages/cli/install.sh | bash if [ -f ~/.profile ]; then source ~/.profile elif [ -f ~/.bashrc ]; then source ~/.bashrc else - export PATH="$HOME/.vite-plus/bin:$PATH" + export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" fi vp --version @@ -228,7 +240,8 @@ jobs: alpine:3.21 sh -c " # libstdc++: required by unofficial-builds Node.js musl binary apk add --no-cache bash curl ca-certificates libstdc++ - cat /workspace/packages/cli/install.sh | bash + # Escape \$HOME so the container expands it (not the GHA host). + mkdir -p \"\$HOME/.vite-plus\" && cat /workspace/packages/cli/install.sh | bash export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" vp --version @@ -285,7 +298,8 @@ jobs: alpine:3.21 sh -c " # libstdc++ is needed by unofficial-builds Node.js musl binary apk add --no-cache bash curl ca-certificates libstdc++ - cat /workspace/packages/cli/install.sh | bash + # Escape \$HOME so the container expands it (not the GHA host). + mkdir -p \"\$HOME/.vite-plus\" && cat /workspace/packages/cli/install.sh | bash export PATH=\"\$HOME/.vite-plus/bin:\$PATH\" vp --version @@ -330,13 +344,11 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - - name: Pin VP_HOME to USERPROFILE - # Namespace's Windows runners run jobs under a service account whose real - # profile (C:\Windows\system32\config\systemprofile) differs from - # %USERPROFILE%; vp resolves its home from the OS profile. Pin VP_HOME so - # the install, generated shims, and these assertions share one location. + - name: Seed legacy root under USERPROFILE + # Seed ~/.vite-plus so install takes the grandfather (Exist) branch + # under the runner profile (same as VpDirs Home rule). shell: bash - run: echo "VP_HOME=$USERPROFILE\.vite-plus" >> $GITHUB_ENV + run: mkdir -p "$USERPROFILE/.vite-plus" - name: Assert PowerShell 5.x shell: powershell @@ -420,6 +432,10 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - name: Seed legacy root (released CLI uses grandfather path) + shell: bash + run: mkdir -p "$USERPROFILE/.vite-plus" + - name: Run install.ps1 shell: pwsh run: | @@ -487,13 +503,11 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - - name: Pin VP_HOME to USERPROFILE - # Namespace's Windows runners run jobs under a service account whose real - # profile (C:\Windows\system32\config\systemprofile) differs from - # %USERPROFILE%; vp resolves its home from the OS profile. Pin VP_HOME so - # the install, generated shims, and these assertions share one location. + - name: Seed legacy root under USERPROFILE + # Seed ~/.vite-plus so install takes the grandfather (Exist) branch + # under the runner profile (same as VpDirs Home rule). shell: bash - run: echo "VP_HOME=$USERPROFILE\.vite-plus" >> $GITHUB_ENV + run: mkdir -p "$USERPROFILE/.vite-plus" - name: Install PowerShell 7.6 shell: pwsh @@ -584,6 +598,7 @@ jobs: $ErrorActionPreference = "Stop" $env:CI = "true" $env:VP_NODE_MANAGER = "no" + # Custom install root via deprecated VP_HOME (installer override / full legacy mapping) $env:VP_HOME = Join-Path $env:RUNNER_TEMP "vite-plus-release-age" $npmrc = Join-Path $env:USERPROFILE ".npmrc" @@ -655,7 +670,7 @@ jobs: # resolved home against PATH. Namespace's Windows runners execute jobs under a # Session-0 service account whose real profile is # C:\Windows\system32\config\systemprofile, not %USERPROFILE%, so even with - # VP_HOME set the installed vp resolves the systemprofile home and doctor + # Without a seeded ~/.vite-plus the installed vp may resolve the systemprofile home and doctor # fails. The other install jobs pass on Namespace because they don't run # `vp env doctor` under `set -e`. runs-on: windows-latest @@ -664,6 +679,10 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - name: Seed legacy root (released CLI uses grandfather path) + shell: bash + run: mkdir -p "$USERPROFILE/.vite-plus" + - name: Run install.ps1 shell: pwsh run: | @@ -818,13 +837,11 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - uses: ./.github/actions/clone - - name: Pin VP_HOME to USERPROFILE - # Namespace's Windows runners run jobs under a service account whose real - # profile (C:\Windows\system32\config\systemprofile) differs from - # %USERPROFILE%; vp-setup.exe installs into VP_HOME (falling back to the OS - # profile). Pin it so the install and these assertions share one location. + - name: Seed legacy root under USERPROFILE + # Seed ~/.vite-plus so install takes the grandfather (Exist) branch + # under the runner profile (same as VpDirs Home rule). shell: bash - run: echo "VP_HOME=$USERPROFILE\.vite-plus" >> $GITHUB_ENV + run: mkdir -p "$USERPROFILE/.vite-plus" - name: Setup Dev Drive uses: samypr100/setup-dev-drive@30f0f98ae5636b2b6501e181dfb3631b9974818d # v4.0.0 @@ -844,8 +861,12 @@ jobs: run: cargo build --release -p vp_installer - name: Install via vp-setup.exe (silent) + # Pin VP_HOME to USERPROFILE so the installer does not follow BaseDirs + # into the Session-0 systemprofile on Namespace Windows runners. shell: pwsh - run: ${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }} + run: | + $env:VP_HOME = Join-Path $env:USERPROFILE '.vite-plus' + & ${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }} - name: Set PATH shell: bash @@ -868,3 +889,224 @@ jobs: run: | vp --version vp --help + + # ── Branch CLI layout coverage (current install.sh + VpDirs) ──────────── + # Build the PR's vp binary and drive install.sh via install-global-cli so + # we exercise the same script users run, without the registry bridge. + + test-install-split: + name: Test install (split layout, local build, ${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: Linux + shell: bash + - os: macos-latest + name: macOS + shell: bash + # GitHub-hosted windows-latest (not Namespace): split defaults use + # %LOCALAPPDATA%/%APPDATA% under the runner profile, same reason + # test-install-ps1 stays off Namespace Session-0 systemprofile. + - os: windows-latest + name: Windows + shell: bash + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - uses: ./.github/actions/clone + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + package-manager-cache: false + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17 — Swatinem/rust-cache v2.9.1 for node24) + with: + save-cache: ${{ github.ref_name == 'main' }} + cache-key: install-layout-${{ matrix.os }} + + - name: Build vp CLI + run: cargo build -p vp_global_cli -p vp_trampoline --release + + - name: Clean install roots (fresh split install) + shell: bash + run: | + rm -rf "$HOME/.vite-plus" + if [ "${{ runner.os }}" = "Windows" ]; then + local_app="${LOCALAPPDATA:-$USERPROFILE/AppData/Local}" + roaming_app="${APPDATA:-$USERPROFILE/AppData/Roaming}" + rm -rf \ + "$local_app/vite-plus" \ + "$roaming_app/vite-plus" + else + rm -rf \ + "$HOME/.local/share/vite-plus" \ + "$HOME/.config/vite-plus" \ + "$HOME/.local/state/vite-plus" \ + "$HOME/.cache/vite-plus" + rm -f "$HOME/.local/bin/vp" + fi + + - name: Install via install script (local branch CLI) + run: pnpm install-global-cli + + - name: Verify split layout + shell: bash + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + local_app="${LOCALAPPDATA:-$USERPROFILE/AppData/Local}" + roaming_app="${APPDATA:-$USERPROFILE/AppData/Roaming}" + bin_dir="$local_app/vite-plus/bin" + data_dir="$local_app/vite-plus/data" + config_dir="$roaming_app/vite-plus" + vp_bin="$bin_dir/vp.exe" + [ -f "$vp_bin" ] || { echo "::error::vp missing at $vp_bin"; exit 1; } + [ -d "$data_dir/current" ] || { echo "::error::versions missing from the data dir"; exit 1; } + [ -f "$config_dir/env.ps1" ] || [ -f "$config_dir/env" ] || { + echo "::error::env script missing from the config dir ($config_dir)" + ls -la "$config_dir" 2>/dev/null || true + exit 1 + } + [ ! -e "$HOME/.vite-plus" ] || { echo "::error::legacy root must not be created by a split install"; exit 1; } + "$vp_bin" --version + else + [ -x "$HOME/.local/bin/vp" ] || { echo "::error::vp shim missing at ~/.local/bin/vp"; exit 1; } + [ -d "$HOME/.local/share/vite-plus/current" ] || { echo "::error::versions missing from the data dir"; exit 1; } + [ -f "$HOME/.config/vite-plus/env" ] || { echo "::error::env script missing from the config dir"; exit 1; } + [ ! -e "$HOME/.vite-plus" ] || { echo "::error::legacy root must not be created by a split install"; exit 1; } + "$HOME/.local/bin/vp" --version + fi + + - name: Implode split install + shell: bash + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + local_app="${LOCALAPPDATA:-$USERPROFILE/AppData/Local}" + roaming_app="${APPDATA:-$USERPROFILE/AppData/Roaming}" + bin_dir="$local_app/vite-plus/bin" + data_dir="$local_app/vite-plus/data" + config_dir="$roaming_app/vite-plus" + "$bin_dir/vp.exe" implode -y + # Windows deferred delete may leave a .removing-* rename briefly; + # data/config must not remain under their live paths. + [ ! -e "$data_dir" ] || { echo "::error::data dir remains after implode"; exit 1; } + [ ! -e "$config_dir" ] || { echo "::error::config dir remains after implode"; exit 1; } + [ ! -e "$bin_dir/vp.exe" ] || { echo "::error::vp shim remains after implode"; exit 1; } + else + "$HOME/.local/bin/vp" implode -y + [ ! -e "$HOME/.local/share/vite-plus" ] || { echo "::error::data dir remains after implode"; exit 1; } + [ ! -e "$HOME/.config/vite-plus" ] || { echo "::error::config dir remains after implode"; exit 1; } + # Shared ~/.local/bin must not be removed; only the vp shim. + [ ! -e "$HOME/.local/bin/vp" ] || { echo "::error::vp shim remains after implode"; exit 1; } + fi + + test-install-legacy-upgrade: + name: Test install (legacy upgrade, local build, ${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + name: Linux + - os: macos-latest + name: macOS + - os: windows-latest + name: Windows + runs-on: ${{ matrix.os }} + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - uses: ./.github/actions/clone + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .node-version + package-manager-cache: false + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17 — Swatinem/rust-cache v2.9.1 for node24) + with: + save-cache: ${{ github.ref_name == 'main' }} + cache-key: install-layout-${{ matrix.os }} + + - name: Build vp CLI + run: cargo build -p vp_global_cli -p vp_trampoline --release + + - name: Seed empty legacy root + shell: bash + run: | + # Exist gate: presence of ~/.vite-plus selects the grandfather layout + # (same as VpDirs Home rule and install.sh LEGACY_LAYOUT=true). + if [ "${{ runner.os }}" = "Windows" ]; then + local_app="${LOCALAPPDATA:-$USERPROFILE/AppData/Local}" + roaming_app="${APPDATA:-$USERPROFILE/AppData/Roaming}" + rm -rf \ + "$local_app/vite-plus" \ + "$roaming_app/vite-plus" + else + rm -rf \ + "$HOME/.local/share/vite-plus" \ + "$HOME/.config/vite-plus" \ + "$HOME/.local/state/vite-plus" \ + "$HOME/.cache/vite-plus" + rm -f "$HOME/.local/bin/vp" + fi + mkdir -p "$HOME/.vite-plus/bin" + + - name: Install via install script (local branch CLI) + run: pnpm install-global-cli + + - name: Verify legacy layout preserved + shell: bash + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + vp_bin="$HOME/.vite-plus/bin/vp.exe" + [ -f "$vp_bin" ] || { echo "::error::vp missing at $vp_bin"; exit 1; } + [ -d "$HOME/.vite-plus/current" ] || { echo "::error::current missing under legacy root"; exit 1; } + # Windows env scripts may be env.ps1 / env.cmd under the root. + [ -f "$HOME/.vite-plus/env" ] || [ -f "$HOME/.vite-plus/env.ps1" ] || { + echo "::error::env script should live under legacy root" + ls -la "$HOME/.vite-plus" 2>/dev/null || true + exit 1 + } + local_app="${LOCALAPPDATA:-$USERPROFILE/AppData/Local}" + [ ! -e "$local_app/vite-plus/data" ] || { + echo "::error::split data dir must not be created on legacy upgrade" + exit 1 + } + "$vp_bin" --version + else + [ -x "$HOME/.vite-plus/bin/vp" ] || { echo "::error::vp missing at ~/.vite-plus/bin/vp"; exit 1; } + [ -d "$HOME/.vite-plus/current" ] || { echo "::error::current missing under legacy root"; exit 1; } + [ -f "$HOME/.vite-plus/env" ] || { echo "::error::env script should live under legacy root"; exit 1; } + [ ! -e "$HOME/.local/share/vite-plus" ] || { echo "::error::split data dir must not be created on legacy upgrade"; exit 1; } + "$HOME/.vite-plus/bin/vp" --version + fi + + - name: Implode legacy install + shell: bash + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + "$HOME/.vite-plus/bin/vp.exe" implode -y + # Deferred delete renames away the live path immediately. + [ ! -e "$HOME/.vite-plus" ] || { echo "::error::legacy root remains after implode"; exit 1; } + else + "$HOME/.vite-plus/bin/vp" implode -y + [ ! -e "$HOME/.vite-plus" ] || { echo "::error::legacy root remains after implode"; exit 1; } + fi diff --git a/AGENTS.md b/AGENTS.md index 34a7d5d012..001516b821 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ vite-plus/ └── crates/vp_trampoline/ # Windows shim trampoline ``` +On-disk paths (bin, data, cache, and derived helpers) are resolved centrally via `vp_shared::VpDirs` (`crates/vp_shared/src/dirs.rs`, strategy chain in `dirs/resolution.rs`) — legacy monolithic `~/.vite-plus` root or split XDG/platform layout; no call site constructs `~/.vite-plus/...` or reads `XDG_*` itself. + `packages/test` is no longer tracked. The public test API is `vite-plus/test*`, generated by `packages/cli/build.ts` as shims over upstream `vitest` and `@vitest/browser*` exports. ## Where to Start diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3db7e9a7ab..e7e4979814 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ pnpm bootstrap-cli vp --version ``` -This builds all packages, compiles the Rust `vp` binary, and installs the CLI to `~/.vite-plus`. +This builds all packages, compiles the Rust `vp` binary, and installs the CLI to `~/.vite-plus` (the legacy monolithic layout; on-disk paths are resolved by `vp_shared::VpDirs` in `crates/vp_shared/src/dirs.rs`). To switch back to a release version, use `vp upgrade --force` (`current` points to `local-dev-*` but the binary version may still match the release, so `--force` is needed) diff --git a/Cargo.lock b/Cargo.lock index 85a5810fff..ded612b51e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7606,6 +7606,15 @@ dependencies = [ "xattr", ] +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -8608,6 +8617,8 @@ dependencies = [ "serde_json", "serial_test", "supports-color 3.0.2", + "temp-env", + "tempfile", "thiserror 2.0.19", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index ae926ac719..6a974f55f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -273,6 +273,7 @@ sugar_path = { version = "3", features = ["cached_current_dir"] } supports-color = "3" syn = { version = "2", default-features = false } tar = "0.4.43" +temp-env = "0.3.6" tempfile = "3.14.0" terminal_size = "0.4.2" test-log = { version = "0.2.18", features = ["trace"] } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs index 6a1cddda26..44f046e63e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs @@ -1,14 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; -const expected = path.resolve('external/vp'); +// Shims of a legacy install are relative links into its own current/bin/vp. +const expected = path.join('..', 'current', 'bin', 'vp'); for (const shim of ['vp', 'node', 'npm', 'npx', 'corepack', 'vpx', 'vpr']) { - const shimPath = path.join('home', 'bin', shim); + const shimPath = path.join('external', 'bin', shim); const target = fs.readlinkSync(shimPath); if (target !== expected) { throw new Error(`${shim} points to ${target}, expected ${expected}`); } } -console.log('all shims point to external vp'); +console.log('all shims point to the external install'); 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..424367b324 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 @@ -3,16 +3,20 @@ name = "command_env_setup_external_vp" vp = "global" skip-platforms = ["windows"] 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", "mkdir", "-p", "external/current/bin", "external/bin", "external/js_runtime/node/22.18.0/bin"], comment = "A second, complete legacy install outside the case home", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "external/current/bin/vp"], comment = "The external install's vp binary", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "external/bin/vp"], comment = "Marks the external layout as a legacy install for detection", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/current/bin/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/bin/vp"], 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 }, - { argv = ["./external/vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Setup shims from external vp", snapshot = false }, + { argv = ["vpt", "write-file", "external/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", "external/js_runtime/node/22.18.0/bin/node"], snapshot = false }, + # Pin VP_HOME to the external root: VpDirs no longer self-locates from the + # binary path / PATH, so env setup must target that install explicitly. + { argv = ["./external/current/bin/vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/external"]], comment = "env setup targets the external install via VP_HOME", snapshot = false }, # The legacy step set VP_BYPASS to reach a system node, which the hermetic # case PATH does not have; the node shim resolving the pinned 22.18.0 from # the seeded runtime serves the same purpose (any node can run the asserts). - { argv = ["node", "assert-shims.mjs"], comment = "Shims should point to external vp, not VP_HOME/current/bin/vp" }, - { argv = ["node", "-v"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "node shim uses the project version" }, + { argv = ["node", "assert-shims.mjs"], comment = "Shims point to the external install's vp, not the case home's" }, + { argv = ["node", "-v"], envs = [["VP_HOME", "${workspace}/external"], ["PATH", "${workspace}/external/bin:${PATH}"]], comment = "node shim uses the project version" }, ] 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..f9a6ddcd5c 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 @@ -1,16 +1,24 @@ # command_env_setup_external_vp -## `vpt mkdir -p external home` +## `vpt mkdir -p external/current/bin external/bin external/js_runtime/node/22.18.0/bin` -Prepare isolated external install and VP_HOME +A second, complete legacy install outside the case home -## `vpt cp $VP_HOME/bin/vp external/vp` +## `vpt cp $VP_HOME/current/bin/vp external/current/bin/vp` -Simulate a Homebrew-style vp outside VP_HOME +The external install's vp binary -## `vpt chmod +x external/vp` +## `vpt cp $VP_HOME/current/bin/vp external/bin/vp` + +Marks the external layout as a legacy install for detection + + +## `vpt chmod +x external/current/bin/vp` + + +## `vpt chmod +x external/bin/vp` ## `vpt write-file .node-version '22.18.0 @@ -19,30 +27,30 @@ Simulate a Homebrew-style vp outside VP_HOME Project Node.js version -## `vpt write-file home/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh +## `vpt write-file external/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh echo vp-managed-node-22.18.0 '` Preinstall managed Node runtime -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/node` +## `vpt chmod +x external/js_runtime/node/22.18.0/bin/node` -## `VP_HOME=${workspace}/home ./external/vp env setup` +## `VP_HOME=${workspace}/external ./external/current/bin/vp env setup` -Setup shims from external vp +env setup targets the external install via VP_HOME ## `node assert-shims.mjs` -Shims should point to external vp, not VP_HOME/current/bin/vp +Shims point to the external install's vp, not the case home's ``` -all shims point to external vp +all shims point to the external install ``` -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} node -v` +## `VP_HOME=${workspace}/external PATH=${workspace}/external/bin:${PATH} node -v` node shim uses the project version diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md index bdb8767398..7c97f2a0d2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md @@ -111,10 +111,14 @@ d="$(dirname "$(dirname "$(dirname "$0")")")" __vp_shell=/bin/sh [ -x "$__vp_shell" ] || __vp_shell=$(command -v sh) -if [ -n "${VP_HOME-}" ]; then +if [ -n "${VP_BIN_DIR-}" ]; then + __vp_bin="$VP_BIN_DIR" +elif [ -n "${VP_HOME-}" ]; then __vp_bin="$VP_HOME/bin" -elif [ -n "${HOME-}" ]; then +elif [ -n "${HOME-}" ] && [ -d "$HOME/.vite-plus/bin" ]; then __vp_bin="$HOME/.vite-plus/bin" +elif [ -n "${HOME-}" ]; then + __vp_bin="$HOME/.local/bin" else __vp_bin="" fi diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml index 0f31a1c51c..ace8822497 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml @@ -3,6 +3,9 @@ name = "shim_corepack_enable_install_directory" vp = "global" skip-platforms = ["windows"] steps = [ + # Pin an isolated legacy root at ${workspace}/home (the case HOME dir, not + # the provisioned ${workspace}/home/.vite-plus). Fakes live under that root + # so seed runtime under .vite-plus cannot shadow them. { argv = ["vpt", "mkdir", "-p", "home/js_runtime/node/22.18.0/bin"], comment = "Isolated VP_HOME with a fake managed Node runtime layout", snapshot = false }, { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], comment = "Project Node.js version", snapshot = false }, { argv = ["vpt", "write-file", "home/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho fake-node\n"], comment = "Fake node binary", snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index 78ee1c0ca1..23d7e4a7c2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -525,9 +525,27 @@ impl CaseHome { if flavor == Flavor::Local { self.write_local_package_cmd_shims(&package_dir, &local_bin_dir)?; } - self.run_env_setup(&vp)?; + // Complete the legacy install shape (`bin/vp` alongside + // `current/bin/vp`) before any case CLI runs: layout detection + // classifies `/current/bin/vp` as a split data dir unless + // `/bin/vp` exists, and `vp env setup` below would otherwise + // write shims into the split bin dir instead of `/bin`. let vp_bin_dir = self.vp_home().join("bin"); + std::fs::create_dir_all(&vp_bin_dir) + .map_err(|e| format!("failed to create bin dir: {e}"))?; + #[cfg(unix)] + { + let link = vp_bin_dir.join(VP_BINARY_NAME); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink("../current/bin/vp", &link) + .map_err(|e| format!("failed to link bin/vp: {e}"))?; + } + #[cfg(windows)] + flavor::install_file(&vp_bin_dir.join(VP_BINARY_NAME), &runtime.global_vp, "bin/vp.exe")?; + + self.run_env_setup(&vp)?; + let mut tool_dirs = match flavor { Flavor::Global => vec![vp_bin_dir], Flavor::Local => vec![local_bin_dir, vp_bin_dir], @@ -625,6 +643,11 @@ impl CaseHome { env.insert("TERM".into(), "xterm-256color".into()); env.insert("VP_CLI_TEST".into(), "1".into()); env.insert("NODE_NO_WARNINGS".into(), "1".into()); + // The CLI no longer reads VP_HOME (the provisioned + // `/.vite-plus/current/bin/vp` self-locates, and the on-disk + // `/.vite-plus` selects the legacy layout). Kept because + // fixture steps reference `$VP_HOME/...` in `vpt` argv (expanded + // from this env by vpt's `expand_env_arg`). env.insert("VP_HOME".into(), self.vp_home().into_os_string()); if cfg!(windows) { env.insert("USERPROFILE".into(), self.home.clone().into_os_string()); diff --git a/crates/vp_command/src/ps1_shim.rs b/crates/vp_command/src/ps1_shim.rs index f4665da6c0..9cee9b444a 100644 --- a/crates/vp_command/src/ps1_shim.rs +++ b/crates/vp_command/src/ps1_shim.rs @@ -7,9 +7,10 @@ //! `PowerShell` sidesteps the prompt and lets Ctrl+C propagate cleanly. //! //! The rewrite is scoped to two patterns: -//! - Inside `$VP_HOME` (`~/.vite-plus` by default) — vp's managed shims: -//! - `$VP_HOME/js_runtime/node//{npm,npx}.cmd`, -//! - `$VP_HOME/package_manager////bin/.cmd`. +//! - Inside vp's data directory (`~/.vite-plus` legacy root, or the split +//! data dir — see [`VpDirs::data_dir`]) — vp's managed shims: +//! - `/js_runtime/node//{npm,npx}.cmd`, +//! - `/package_manager////bin/.cmd`. //! - Any `<...>/node_modules/.bin/*.cmd` — the canonical layout for //! npm/pnpm/yarn-emitted shims (cmd-shim writes both `.cmd` and `.ps1` //! so the wrappers stay equivalent). @@ -31,6 +32,7 @@ use std::ffi::OsString; +use vp_shared::VpDirs; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_powershell::{POWERSHELL_PREFIX, find_ps1_sibling, is_stdin_terminal, powershell_host}; @@ -46,8 +48,8 @@ use vt_powershell::{POWERSHELL_PREFIX, find_ps1_sibling, is_stdin_terminal, powe /// - no `PowerShell` host (`pwsh.exe` or `powershell.exe`) is on PATH, /// - stdin is not a terminal (the `.ps1` wrappers hang on piped/null /// stdin and the Ctrl+C concern doesn't apply without a TTY), -/// - the resolved path is outside `$VP_HOME` (or `$VP_HOME` is -/// unresolvable) AND not under any `node_modules/.bin/`, +/// - the resolved path is outside the vite-plus install root +/// AND not under any `node_modules/.bin/`, /// - the resolved path is not a `.cmd` (case-insensitive), /// - the `.cmd` has no sibling `.ps1`. #[must_use] @@ -58,19 +60,24 @@ pub fn rewrite_cmd_to_powershell( // our stdin means a TTY in the child too. `is_stdin_terminal` is shared with // `vt_plan::ps1_shim` via the `vt_powershell` crate. let host = powershell_host()?; - rewrite_in_scope(resolved, vp_home().map(AsRef::as_ref), host, is_stdin_terminal()) + let install_root = vp_home(); + rewrite_in_scope( + resolved, + install_root.as_ref().map(AsRef::as_ref), + host, + is_stdin_terminal(), + ) } -/// Cached `$VP_HOME` (`~/.vite-plus` by default; overridable via env var). -/// Returns `None` if `vp_shared::get_vp_home()` failed; the rewrite still -/// applies to `node_modules/.bin/*.cmd` paths in that case (the two scopes -/// are independent). -fn vp_home() -> Option<&'static AbsolutePathBuf> { - use std::sync::LazyLock; - - static VP_HOME: LazyLock> = - LazyLock::new(|| vp_shared::get_vp_home().ok()); - VP_HOME.as_ref() +/// The vite-plus data directory (`~/.vite-plus` under the legacy layout; the +/// split data directory otherwise). Resolved per call so env/test overrides +/// are observed, matching the `VpDirs` recompute-on-every-call contract. +/// +/// The returned value is always `Some`; the `Option` only exists because the +/// rewrite scope check also applies to `node_modules/.bin/*.cmd` paths, which +/// are independent of the install root. +fn vp_home() -> Option { + Some(VpDirs::data_dir()) } /// Pure rewrite logic. Factored out so tests can drive it on any platform diff --git a/crates/vp_global_cli/src/commands/env/bin_config.rs b/crates/vp_global_cli/src/commands/env/bin_config.rs index a1959a22fe..8fcf27aa51 100644 --- a/crates/vp_global_cli/src/commands/env/bin_config.rs +++ b/crates/vp_global_cli/src/commands/env/bin_config.rs @@ -8,9 +8,9 @@ //! - Safe uninstall (only removes binaries owned by the package) use serde::{Deserialize, Serialize}; +use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; -use super::config::get_vp_home; use crate::error::Error; /// Source that installed a binary. @@ -52,9 +52,10 @@ impl BinConfig { Self { name, package, version: String::new(), node_version, source: BinSource::Npm } } - /// Get the bins directory path (~/.vite-plus/bins/). + /// Get the bins directory path (`/bins/`; `~/.vite-plus/bins/` under + /// the legacy layout — identical on disk). pub fn bins_dir() -> Result { - Ok(get_vp_home()?.join("bins")) + Ok(VpDirs::bins_dir()) } /// Get the path to a binary's config file. diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index e1ca0f74cf..b7b1514455 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -5,7 +5,7 @@ use std::{path::Path, process::ExitStatus}; -use vp_shared::{env_vars, output}; +use vp_shared::{VpDirs, env_vars, output}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{config, list::list_installed_versions}; @@ -13,9 +13,8 @@ use crate::error::Error; /// Execute the clean command. pub async fn execute(cwd: AbsolutePathBuf) -> Result { - let home_dir = vp_shared::get_vp_home()?; - let node_dir = home_dir.join("js_runtime").join("node"); - let package_manager_dir = home_dir.join("package_manager"); + let node_dir = VpDirs::js_runtime_dir().join("node"); + let package_manager_dir = VpDirs::package_manager_dir(); let protected_versions = protected_node_versions(&cwd).await?; let corepack_cleaned = run_corepack_cache_clean(&cwd).await?; @@ -138,7 +137,7 @@ async fn corepack_cache_clean_would_auto_install( cwd: &AbsolutePathBuf, corepack_path: &AbsolutePath, ) -> Result { - let bin_dir = config::get_bin_dir()?; + let bin_dir = VpDirs::bin_dir(); if corepack_path.parent() != Some(&bin_dir) { return Ok(false); } diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 38cd5805b9..e9b9c9426d 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -1,22 +1,21 @@ //! Configuration and version resolution for the env command. //! //! This module provides: -//! - VP_HOME path resolution //! - Version resolution with priority order //! - Config file management +//! +//! On-disk locations come from [`VpDirs`]. use serde::{Deserialize, Serialize}; use vp_js_runtime::{ NodeProvider, VersionSource, is_valid_version, normalize_version, read_nvmrc_file, read_package_json, resolve_node_version, }; +use vp_shared::VpDirs; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::error::Error; -/// Config file name -const CONFIG_FILE: &str = "config.json"; - /// Shim mode determines how shims resolve tools. #[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -61,23 +60,6 @@ pub struct VersionResolution { pub is_range: bool, } -/// Get the VP_HOME directory path. -/// -/// Uses `VP_HOME` environment variable if set, otherwise defaults to `~/.vite-plus`. -pub fn get_vp_home() -> Result { - Ok(vp_shared::get_vp_home()?) -} - -/// Get the bin directory path (~/.vite-plus/bin/). -pub fn get_bin_dir() -> Result { - Ok(get_vp_home()?.join("bin")) -} - -/// Get the packages directory path (~/.vite-plus/packages/). -pub fn get_packages_dir() -> Result { - Ok(get_vp_home()?.join("packages")) -} - /// Get the node_modules directory path for a package. /// /// npm uses different layouts on Unix vs Windows: @@ -110,14 +92,9 @@ pub fn get_node_modules_dir(prefix: &AbsolutePath, package_name: &str) -> Absolu } } -/// Get the config file path. -pub fn get_config_path() -> Result { - Ok(get_vp_home()?.join(CONFIG_FILE)) -} - /// Load configuration from disk. pub async fn load_config() -> Result { - let config_path = get_config_path()?; + let config_path = config_file_path(); if !tokio::fs::try_exists(&config_path).await.unwrap_or(false) { return Ok(Config::default()); @@ -130,11 +107,10 @@ pub async fn load_config() -> Result { /// Save configuration to disk. pub async fn save_config(config: &Config) -> Result<(), Error> { - let config_path = get_config_path()?; - let vite_plus_home = get_vp_home()?; + let config_path = config_file_path(); // Ensure directory exists - tokio::fs::create_dir_all(&vite_plus_home).await?; + tokio::fs::create_dir_all(&VpDirs::config_dir()).await?; let content = serde_json::to_string_pretty(config)?; tokio::fs::write(&config_path, content).await?; @@ -145,17 +121,23 @@ pub async fn save_config(config: &Config) -> Result<(), Error> { /// Set by `vp env use` command. pub const VERSION_ENV_VAR: &str = vp_shared::env_vars::VP_NODE_VERSION; +/// Main config file name under [`VpDirs::config_dir`]. +const CONFIG_FILE_NAME: &str = "config.json"; + /// Session version file name, written by `vp env use` so shims work without the shell eval wrapper. pub const SESSION_VERSION_FILE: &str = ".session-node-version"; -/// Get the path to the session version file (~/.vite-plus/.session-node-version). -pub fn get_session_version_path() -> Result { - Ok(get_vp_home()?.join(SESSION_VERSION_FILE)) +fn config_file_path() -> AbsolutePathBuf { + VpDirs::config_dir().join(CONFIG_FILE_NAME) +} + +fn session_version_file_path() -> AbsolutePathBuf { + VpDirs::state_dir().join(SESSION_VERSION_FILE) } /// Read the session version file. Returns `None` if the file is missing or empty. pub async fn read_session_version() -> Option { - let path = get_session_version_path().ok()?; + let path = session_version_file_path(); let content = tokio::fs::read_to_string(&path).await.ok()?; let trimmed = content.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } @@ -163,7 +145,7 @@ pub async fn read_session_version() -> Option { /// Read the session version file synchronously. Returns `None` if the file is missing or empty. pub fn read_session_version_sync() -> Option { - let path = get_session_version_path().ok()?; + let path = session_version_file_path(); let content = std::fs::read_to_string(path.as_path()).ok()?; let trimmed = content.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } @@ -171,7 +153,7 @@ pub fn read_session_version_sync() -> Option { /// Write the resolved version to the session version file. pub async fn write_session_version(version: &str) -> Result<(), Error> { - let path = get_session_version_path()?; + let path = session_version_file_path(); // Ensure parent directory exists if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; @@ -182,7 +164,7 @@ pub async fn write_session_version(version: &str) -> Result<(), Error> { /// Delete the session version file. Ignores "not found" errors. pub async fn delete_session_version() -> Result<(), Error> { - let path = get_session_version_path()?; + let path = session_version_file_path(); match tokio::fs::remove_file(&path).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -221,7 +203,7 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result Result Result Result { match config.default_node_version { Some(version) => { println!("Default Node.js version: {version}"); - let config_path = get_config_path()?; + let config_path = VpDirs::config_dir().join("config.json"); println!(" Set via: {}", config_path.as_path().display()); // If it's an alias, also show the resolved version diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 6ce79f0473..c7443b1468 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -3,10 +3,10 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; -use vp_shared::{env_vars, output}; +use vp_shared::{VpDirs, env_vars, output}; use vt_path::{AbsolutePathBuf, current_dir}; -use super::config::{self, ShimMode, get_bin_dir, get_vp_home, load_config, resolve_version}; +use super::config::{self, ShimMode, load_config, resolve_version}; use crate::{ commands::shell::{ALL_SHELL_PROFILES, IDE_SHELL_PROFILES, ShellProfile, resolve_profile_path}, error::Error, @@ -110,9 +110,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { Some(EnvSourcingStatus::IdeFound) | None => {} // All good, no guidance needed Some(EnvSourcingStatus::ShellOnly | EnvSourcingStatus::NotFound) => { // Show IDE setup guidance when env is not in IDE-relevant profiles - if let Ok(bin_dir) = get_bin_dir() { - print_ide_setup_guidance(&bin_dir); - } + print_ide_setup_guidance(&VpDirs::config_dir()); } } @@ -130,29 +128,21 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { } } -/// Check VP_HOME directory. +/// Check the vite-plus home directory (the legacy root under the `Home` +/// layout, the data directory under the split layout — same path on disk +/// under `Home`). async fn check_vite_plus_home() -> bool { - let home = match get_vp_home() { - Ok(h) => h, - Err(e) => { - print_check( - &output::CROSS.red().to_string(), - env_vars::VP_HOME, - &format!("{e}").red().to_string(), - ); - return false; - } - }; + let home = VpDirs::data_dir(); let display = abbreviate_home(&home.as_path().display().to_string()); if tokio::fs::try_exists(&home).await.unwrap_or(false) { - print_check(&output::CHECK.green().to_string(), env_vars::VP_HOME, &display); + print_check(&output::CHECK.green().to_string(), "Home directory", &display); true } else { print_check( &output::CROSS.red().to_string(), - env_vars::VP_HOME, + "Home directory", &"does not exist".red().to_string(), ); print_hint("Run 'vp env setup' to create it."); @@ -162,10 +152,7 @@ async fn check_vite_plus_home() -> bool { /// Check bin directory and shim files. async fn check_bin_dir() -> bool { - let bin_dir = match get_bin_dir() { - Ok(d) => d, - Err(_) => return false, - }; + let bin_dir = VpDirs::bin_dir(); if !tokio::fs::try_exists(&bin_dir).await.unwrap_or(false) { print_check( @@ -265,15 +252,9 @@ async fn check_shim_mode() -> (ShimMode, Option) { /// Tries IDE-relevant profiles first, then falls back to all shell profiles. /// Returns `EnvSourcingStatus` indicating where (if anywhere) the sourcing was found. fn check_env_sourcing() -> EnvSourcingStatus { - let bin_dir = match get_bin_dir() { - Ok(d) => d, - Err(_) => return EnvSourcingStatus::NotFound, - }; + let env_dir = VpDirs::config_dir(); - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); + let home_path = env_dir.as_path().display().to_string(); let home_path = if let Ok(home_dir) = std::env::var("HOME") { if let Some(suffix) = home_path.strip_prefix(&home_dir) { format!("$HOME{suffix}") @@ -339,10 +320,7 @@ fn check_session_override() { /// Check PATH configuration. async fn check_path() -> bool { - let bin_dir = match get_bin_dir() { - Ok(d) => d, - Err(_) => return false, - }; + let bin_dir = VpDirs::bin_dir(); let path_var = std::env::var_os("PATH").unwrap_or_default(); let paths: Vec<_> = std::env::split_paths(&path_var).collect(); @@ -359,7 +337,7 @@ async fn check_path() -> bool { print_check(&output::CROSS.red().to_string(), "vp", &"not in PATH".red().to_string()); print_hint(&format!("Expected: {bin_display}")); println!(); - print_path_fix(&bin_dir); + print_path_fix(&VpDirs::config_dir()); return false; } @@ -396,14 +374,11 @@ fn find_in_path(name: &str) -> Option { } /// Print PATH fix instructions for shell setup. -fn print_path_fix(bin_dir: &vt_path::AbsolutePath) { +fn print_path_fix(env_dir: &vt_path::AbsolutePath) { #[cfg(not(windows))] { - // Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); + // Use the $HOME prefix for readability when the env dir is under $HOME + let home_path = env_dir.as_path().display().to_string(); let home_path = if let Ok(home_dir) = std::env::var("HOME") { if let Some(suffix) = home_path.strip_prefix(&home_dir) { format!("$HOME{suffix}") @@ -431,7 +406,7 @@ fn print_path_fix(bin_dir: &vt_path::AbsolutePath) { #[cfg(windows)] { - let _ = bin_dir; + let _ = env_dir; println!(" {}", "Add the bin directory to your PATH via:".dimmed()); println!(" System Properties -> Environment Variables -> Path"); println!(); @@ -469,12 +444,9 @@ fn check_profile_files(vite_plus_home: &str, profile_files: &[ShellProfile]) -> } /// Print IDE setup guidance for GUI applications. -fn print_ide_setup_guidance(bin_dir: &vt_path::AbsolutePath) { - // Derive vite_plus_home display path from bin_dir.parent(), using $HOME prefix - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); +fn print_ide_setup_guidance(env_dir: &vt_path::AbsolutePath) { + // Use the $HOME prefix for readability when the env dir is under $HOME + let home_path = env_dir.as_path().display().to_string(); let home_path = if let Ok(home_dir) = std::env::var("HOME") { if let Some(suffix) = home_path.strip_prefix(&home_dir) { format!("$HOME{suffix}") @@ -571,10 +543,7 @@ async fn check_current_resolution( print_check(" ", "Version", &resolution.version.bright_green().to_string()); // Check if Node.js is installed - let home_dir = match vp_shared::get_vp_home() { - Ok(d) => d.join("js_runtime").join("node").join(&resolution.version), - Err(_) => return None, - }; + let home_dir = VpDirs::js_runtime_dir().join("node").join(&resolution.version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 2bd20a2a8b..848ffdb8a7 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -6,6 +6,7 @@ use std::{cmp::Ordering, process::ExitStatus}; use owo_colors::OwoColorize; use serde::Serialize; +use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; use super::config; @@ -52,8 +53,7 @@ fn compare_versions(a: &str, b: &str) -> Ordering { /// Execute the list command (local installed versions). pub async fn execute(cwd: AbsolutePathBuf, json_output: bool) -> Result { - let home_dir = vp_shared::get_vp_home()?; - let node_dir = home_dir.join("js_runtime").join("node"); + let node_dir = VpDirs::js_runtime_dir().join("node"); let versions = list_installed_versions(node_dir.as_path()); diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index 81b3317c8e..f51040a7ee 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -7,6 +7,7 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; use serde::Serialize; use vp_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry}; +use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; use super::config; @@ -103,10 +104,7 @@ async fn local_markers(cwd: &AbsolutePathBuf, provider: &NodeProvider) -> LocalM /// Collect the set of locally installed Node.js versions (without `v` prefix). fn installed_versions() -> std::collections::HashSet { - let Ok(home_dir) = vp_shared::get_vp_home() else { - return std::collections::HashSet::new(); - }; - let node_dir = home_dir.join("js_runtime").join("node"); + let node_dir = VpDirs::js_runtime_dir().join("node"); super::list::list_installed_versions(node_dir.as_path()).into_iter().collect() } diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index bae8bccd8c..d0fb31f5b3 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -3,6 +3,8 @@ //! This module provides the `vp env` command for managing Node.js environments //! through shim-based version management. +use vp_shared::VpDirs; + pub mod bin_config; mod clean; pub mod config; @@ -109,8 +111,7 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result { let provider = vp_js_runtime::NodeProvider::new(); let resolved = config::resolve_version_alias(&version, &provider).await?; - let home_dir = vp_shared::get_vp_home()?; - let version_dir = home_dir.join("js_runtime").join("node").join(&resolved); + let version_dir = VpDirs::js_runtime_dir().join("node").join(&resolved); if !version_dir.as_path().exists() { eprintln!("Node.js v{} is not installed", resolved); return Ok(exit_status(1)); diff --git a/crates/vp_global_cli/src/commands/env/package_metadata.rs b/crates/vp_global_cli/src/commands/env/package_metadata.rs index 21eadc1048..cbf30aa62d 100644 --- a/crates/vp_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vp_global_cli/src/commands/env/package_metadata.rs @@ -5,9 +5,9 @@ use std::collections::HashSet; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::{Uuid, Version}; +use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; -use super::config::get_packages_dir; use crate::error::Error; // This is legacy, for old Vite+ version's compatibility @@ -117,7 +117,7 @@ impl PackageMetadata { package_name: &str, install_id: &str, ) -> Result { - let packages_dir = get_packages_dir()?; + let packages_dir = VpDirs::packages_dir(); let package_dir = packages_dir.join(package_name); if install_id.is_empty() { Ok(package_dir) @@ -134,7 +134,7 @@ impl PackageMetadata { /// Get the metadata file path for a package. pub fn metadata_path(package_name: &str) -> Result { - let packages_dir = get_packages_dir()?; + let packages_dir = VpDirs::packages_dir(); Ok(packages_dir.join(format!("{package_name}.json"))) } @@ -173,7 +173,7 @@ impl PackageMetadata { /// List all installed packages. pub async fn list_all() -> Result, Error> { - let packages_dir = get_packages_dir()?; + let packages_dir = VpDirs::packages_dir(); if !tokio::fs::try_exists(&packages_dir).await.unwrap_or(false) { return Ok(Vec::new()); } @@ -358,9 +358,14 @@ mod tests { let result = metadata.save().await; assert!(result.is_ok(), "Failed to save scoped package metadata: {:?}", result.err()); - // Verify the file exists at the correct location - let expected_path = temp_path.join("packages").join("@scope").join("test-pkg.json"); - assert!(expected_path.exists(), "Metadata file not found at {:?}", expected_path); + // Verify the file exists at the correct location (under the resolved + // packages directory for the sandboxed home). + let expected_path = VpDirs::packages_dir().join("@scope").join("test-pkg.json"); + assert!( + expected_path.as_path().exists(), + "Metadata file not found at {:?}", + expected_path.as_path() + ); } #[tokio::test] diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 23b3468373..9c9880a4f1 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -10,10 +10,10 @@ use std::{io::Write, process::ExitStatus}; use vp_js_runtime::NodeProvider; -use vp_shared::output; +use vp_shared::{VpDirs, output}; use vt_path::AbsolutePathBuf; -use super::config::{get_config_path, load_config}; +use super::config::load_config; use crate::{cli::PinTarget, error::Error}; /// Node version file name @@ -76,7 +76,7 @@ async fn show_pinned(cwd: &AbsolutePathBuf) -> Result { let config = load_config().await?; match config.default_node_version { Some(version) => { - let config_path = get_config_path()?; + let config_path = VpDirs::config_dir().join("config.json"); println!("No version pinned."); println!(" Using default: {version} (from {})", config_path.as_path().display()); } @@ -583,7 +583,6 @@ pub async fn do_unpin( #[cfg(test)] mod tests { - use serial_test::serial; use tempfile::TempDir; use vt_path::AbsolutePathBuf; @@ -690,19 +689,14 @@ mod tests { } #[tokio::test] - // Run serially: mutates VP_HOME env var which affects invalidate_cache() - #[serial] async fn test_do_unpin_invalidates_cache() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Point VP_HOME to temp dir - unsafe { - std::env::set_var(vp_shared::env_vars::VP_HOME, temp_path.as_path()); - } - - // Create cache file manually - let cache_dir = temp_path.join("cache"); + // Sandboxed legacy layout: pin VP_HOME to the install root so cache + // is `/cache` (and stays isolated under async thread pools). + let install_root = temp_path.join(".vite-plus"); + let cache_dir = install_root.join("cache"); std::fs::create_dir_all(&cache_dir).unwrap(); let cache_file = cache_dir.join("resolve_cache.json"); std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); @@ -710,6 +704,9 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist before unpin" ); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + install_root.as_path(), + )); // Create .node-version and unpin let node_version_path = temp_path.join(".node-version"); @@ -722,27 +719,16 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_err(), "Cache file should be removed after unpin" ); - - // Cleanup - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } } - // Run serially: mutates VP_HOME env var which affects invalidate_cache() #[tokio::test] - #[serial] async fn test_do_pin_invalidates_cache() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Point VP_HOME to temp dir - unsafe { - std::env::set_var(vp_shared::env_vars::VP_HOME, temp_path.as_path()); - } - - // Create cache file manually - let cache_dir = temp_path.join("cache"); + // Sandboxed legacy layout (see test_do_unpin_invalidates_cache). + let install_root = temp_path.join(".vite-plus"); + let cache_dir = install_root.join("cache"); std::fs::create_dir_all(&cache_dir).unwrap(); let cache_file = cache_dir.join("resolve_cache.json"); std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); @@ -750,6 +736,9 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist before pin" ); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + install_root.as_path(), + )); // Pin an exact version (no_install=true to skip download, force=true to skip prompt) let result = do_pin(&temp_path, "20.18.0", true, true, None).await; @@ -766,11 +755,6 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_err(), "Cache file should be removed after pin" ); - - // Cleanup - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } } #[tokio::test] diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 7baee7c074..aa906ccf7a 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1,8 +1,9 @@ //! Setup command implementation for creating bin directory and shims. //! -//! Creates the following structure: -//! - ~/.vite-plus/bin/ - Contains vp symlink and node/npm/npx/corepack shims -//! - ~/.vite-plus/current/ - Contains the actual vp CLI binary +//! Creates the following structure (legacy layout shown; under the split +//! layout the bin dir and data dir are separate, see [`VpDirs`]): +//! - / - Contains vp symlink and node/npm/npx/corepack shims +//! - /current/ - Contains the actual vp CLI binary //! //! On Unix: //! - bin/vp is a symlink to the active vp binary @@ -17,7 +18,8 @@ use std::process::ExitStatus; -use super::config::{get_bin_dir, get_vp_home}; +use vp_shared::VpDirs; + use crate::{error::Error, help}; /// Shells that get a generated `~/.vite-plus/env.*` setup script. @@ -46,13 +48,11 @@ pub(crate) const SHIM_TOOLS: &[&str] = &["node", "npm", "npx", "corepack", "vpx" /// Execute the setup command. pub async fn execute(refresh: bool, env_only: bool) -> Result { - let vite_plus_home = get_vp_home()?; - - // Ensure home directory exists (env files are written here) - tokio::fs::create_dir_all(&vite_plus_home).await?; + // Ensure the env-scripts directory exists (env files are written here) + tokio::fs::create_dir_all(&VpDirs::config_dir()).await?; // Create env files with PATH guard (prevents duplicate PATH entries) - create_env_files(&vite_plus_home).await?; + create_env_files().await?; if env_only { println!("{}", help::render_heading("Setup")); @@ -61,7 +61,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result return Ok(ExitStatus::default()); } - let bin_dir = get_bin_dir()?; + let bin_dir = VpDirs::bin_dir(); println!("{}", help::render_heading("Setup")); println!(" Preparing vite-plus environment."); @@ -144,7 +144,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result } println!(); - print_path_instructions(&bin_dir); + print_path_instructions(&VpDirs::config_dir()); Ok(ExitStatus::default()) } @@ -219,14 +219,24 @@ pub(crate) async fn resolve_unix_vp_shim_target( current_exe: &std::path::Path, bin_dir: &vt_path::AbsolutePath, ) -> Result { - if let Some(vite_plus_home) = bin_dir.parent() { - let standalone_vp = vite_plus_home.join("current").join("bin").join("vp"); - if tokio::fs::try_exists(&standalone_vp).await.unwrap_or(false) { - let standalone_vp = tokio::fs::canonicalize(&standalone_vp).await.ok(); - let current_exe = tokio::fs::canonicalize(current_exe).await.ok(); - if standalone_vp.is_some() && standalone_vp == current_exe { - return Ok(std::path::PathBuf::from("../current/bin/vp")); + // `current/bin/vp` is the symlink-stable entry point: `current` is + // retargeted on upgrade, so shims pointing through it never dangle when + // old version directories are pruned. Resolve it via `VpDirs` so both the + // legacy and split layouts are covered (under the split layout `bin_dir` + // is not a child of the data dir). + let standalone_vp = VpDirs::current_dir().join("bin").join(vp_shared::VP_BINARY_NAME); + if tokio::fs::try_exists(&standalone_vp).await.unwrap_or(false) { + let standalone_canonical = tokio::fs::canonicalize(&standalone_vp).await.ok(); + let current_exe_canonical = tokio::fs::canonicalize(current_exe).await.ok(); + if standalone_canonical.is_some() && standalone_canonical == current_exe_canonical { + // Prefer a target relative to the bin dir so a relocated install + // root keeps working. + if let Some(root) = bin_dir.parent() + && let Ok(relative) = standalone_vp.as_path().strip_prefix(root.as_path()) + { + return Ok(std::path::Path::new("..").join(relative)); } + return Ok(standalone_vp.into_path_buf()); } } @@ -519,7 +529,6 @@ pub(crate) async fn cleanup_legacy_windows_shim(bin_dir: &vt_path::AbsolutePath, // Includes shell completion support const ENV_TEMPLATE_POSIX: &str = r#"#!/bin/sh # Vite+ environment setup (https://viteplus.dev) -export VP_HOME="__VP_HOME__" __vp_bin="__VP_BIN__" case ":${PATH}:" in *":${__vp_bin}:"*) @@ -567,7 +576,6 @@ fi "#; const ENV_TEMPLATE_FISH: &str = r#"# Vite+ environment setup (https://viteplus.dev) -set -gx VP_HOME "__VP_HOME__" set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) and set -e PATH[$__vp_idx] set -gx PATH __VP_BIN__ $PATH @@ -603,7 +611,6 @@ complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" // Completions delegate to Fish dynamically (VP_COMPLETE=fish) because clap_complete_nushell // generates multiple rest params (e.g. for `vp install`), which Nushell does not support. const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev) -$env.VP_HOME = ("__VP_HOME__" | path expand --no-symlink) $env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") # Shell function wrapper: intercepts `vp env use` to parse its stdout, @@ -664,7 +671,6 @@ export extern "vpr" [...args: string@"nu-complete vpr"] "#; const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) -$env:VP_HOME = "__VP_HOME_WIN__" $__vp_bin = "__VP_BIN_WIN__" if ($env:Path -split ';' -notcontains $__vp_bin) { $env:Path = "$__vp_bin;$env:Path" @@ -725,8 +731,10 @@ Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp // cmd.exe wrapper for `vp env use` (cmd.exe cannot define shell functions). // Users run `vp-use 24` in cmd.exe instead of `vp env use 24`. +// Locates the real vp.exe next to the bin dir: `\current\bin\vp.exe` +// (legacy layout) or `\data\current\bin\vp.exe` (split layout). #[cfg(windows)] -const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nset VP_HOME=%~dp0..\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; +const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nset \"VP_EXE=%~dp0..\\current\\bin\\vp.exe\"\r\nif not exist \"%VP_EXE%\" set \"VP_EXE=%~dp0..\\data\\current\\bin\\vp.exe\"\r\nfor /f \"delims=\" %%i in ('%VP_EXE% env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; fn render_home_relative_path(path: &std::path::Path, home_dir: Option<&std::path::Path>) -> String { // Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env). @@ -752,37 +760,26 @@ fn render_nu_path_ref(path_ref: &str) -> String { } } -/// Render the env-file content for `shell` against `vite_plus_home`. -fn render_env_content(shell: EnvShell, vite_plus_home: &vt_path::AbsolutePath) -> String { - let bin_path = vite_plus_home.join("bin"); +/// Render the env-file content for `shell` against the resolved [`VpDirs`]. +fn render_env_content(shell: EnvShell) -> String { + let bin_path = VpDirs::bin_dir(); let home_dir = vp_shared::EnvConfig::get().user_home; let home_dir = home_dir.as_deref(); - let home_path_ref = render_home_relative_path(vite_plus_home.as_path(), home_dir); let bin_path_ref = render_home_relative_path(bin_path.as_path(), home_dir); match shell { - EnvShell::Posix => ENV_TEMPLATE_POSIX - .replace("__VP_HOME__", &home_path_ref) - .replace("__VP_BIN__", &bin_path_ref), - EnvShell::Fish => ENV_TEMPLATE_FISH - .replace("__VP_HOME__", &home_path_ref) - .replace("__VP_BIN__", &bin_path_ref), + EnvShell::Posix => ENV_TEMPLATE_POSIX.replace("__VP_BIN__", &bin_path_ref), + EnvShell::Fish => ENV_TEMPLATE_FISH.replace("__VP_BIN__", &bin_path_ref), EnvShell::Nu => { // Nushell requires `~` instead of `$HOME` in string literals — `$HOME` is not // expanded at parse time, so PATH entries would contain a literal "$HOME/...". - let home_path_ref_nu = render_nu_path_ref(&home_path_ref); let bin_path_ref_nu = render_nu_path_ref(&bin_path_ref); - ENV_TEMPLATE_NU - .replace("__VP_HOME__", &home_path_ref_nu) - .replace("__VP_BIN__", &bin_path_ref_nu) + ENV_TEMPLATE_NU.replace("__VP_BIN__", &bin_path_ref_nu) } EnvShell::Powershell => { // PowerShell uses the actual absolute path (not $HOME-relative) - let home_path_win = vite_plus_home.as_path().display().to_string(); let bin_path_win = bin_path.as_path().display().to_string(); - ENV_TEMPLATE_PS1 - .replace("__VP_HOME_WIN__", &home_path_win) - .replace("__VP_BIN_WIN__", &bin_path_win) + ENV_TEMPLATE_PS1.replace("__VP_BIN_WIN__", &bin_path_win) } } } @@ -794,22 +791,20 @@ fn render_env_content(shell: EnvShell, vite_plus_home: &vt_path::AbsolutePath) - /// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function /// - `~/.vite-plus/env.nu` (Nushell) with `vp env use` wrapper function /// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function -async fn create_env_files(vite_plus_home: &vt_path::AbsolutePath) -> Result<(), Error> { +async fn create_env_files() -> Result<(), Error> { + let env_dir = VpDirs::config_dir(); for shell in [EnvShell::Posix, EnvShell::Fish, EnvShell::Nu, EnvShell::Powershell] { - let content = render_env_content(shell, vite_plus_home); - tokio::fs::write(vite_plus_home.join(shell.env_file_name()), content).await?; + let content = render_env_content(shell); + tokio::fs::write(env_dir.join(shell.env_file_name()), content).await?; } Ok(()) } -/// Print instructions for adding bin directory to PATH. -fn print_path_instructions(bin_dir: &vt_path::AbsolutePath) { - // Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); +/// Print instructions for sourcing the env files from `env_dir`. +fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { + // Use the $HOME prefix for readability when the env dir is under $HOME + let home_path = env_dir.as_path().display().to_string(); let (home_path, nu_home_path) = if let Ok(home_dir) = std::env::var("HOME") { if let Some(suffix) = home_path.strip_prefix(&home_dir) { // POSIX/Fish use $HOME; Nushell's `source` is a parse-time keyword @@ -879,12 +874,20 @@ mod tests { assert!(!crate::commands::global::CORE_SHIMS.contains(&"corepack")); } - /// Helper: create a test_guard with user_home set to the given path. - fn home_guard(home: impl Into) -> vp_shared::TestEnvGuard { - vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - user_home: Some(home.into()), + /// Set up a sandboxed legacy layout for env-file rendering tests. + /// + /// Pins `vite_plus_home` to `/.vite-plus` (reliable under async + /// thread pools) and `user_home` to `home` so env scripts can render + /// `$HOME/.vite-plus/...`. Returns the EnvConfig guard and the legacy root. + fn legacy_home(home: &std::path::Path) -> (vp_shared::TestEnvGuard, AbsolutePathBuf) { + let root = home.join(".vite-plus"); + std::fs::create_dir_all(&root).unwrap(); + let guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { + vite_plus_home: Some(root.clone()), + user_home: Some(home.to_path_buf()), ..vp_shared::EnvConfig::for_test() - }) + }); + (guard, AbsolutePathBuf::new(root).unwrap()) } #[cfg(windows)] @@ -921,10 +924,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_creates_all_files() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let env_path = home.join("env"); let env_fish_path = home.join("env.fish"); @@ -939,10 +941,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_nu_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); assert!( @@ -950,8 +951,8 @@ mod tests { "env.nu should not contain __VP_BIN__ placeholder" ); assert!( - nu_content.contains("~/bin"), - "env.nu should reference ~/bin (not $HOME/bin — Nushell does not expand $HOME in string literals)" + nu_content.contains("~/.vite-plus/bin"), + "env.nu should reference ~/.vite-plus/bin (not $HOME/bin — Nushell does not expand $HOME in string literals)" ); assert!( nu_content.contains("VP_ENV_USE_EVAL_ENABLE"), @@ -967,11 +968,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_replaces_placeholder_with_home_relative_path() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().join("vp_home")).unwrap(); - let _guard = home_guard(temp_dir.path()); - tokio::fs::create_dir_all(&home).await.unwrap(); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -987,64 +986,69 @@ mod tests { !fish_content.contains("__VP_BIN__"), "env.fish file should not contain __VP_BIN__ placeholder" ); - assert!( - !env_content.contains("__VP_HOME__") && !fish_content.contains("__VP_HOME__"), - "env files should not contain __VP_HOME__ placeholder" - ); - assert!( - !nu_content.contains("__VP_HOME__") && !ps1_content.contains("__VP_HOME_WIN__"), - "env files should not contain VP_HOME placeholders" - ); - // Should use $HOME-relative path since install dir is under HOME - assert!( - env_content.contains("$HOME/vp_home/bin"), - "env file should reference $HOME/vp_home/bin, got: {env_content}" - ); - assert!( - fish_content.contains("$HOME/vp_home/bin"), - "env.fish file should reference $HOME/vp_home/bin, got: {fish_content}" - ); - assert!( - env_content.contains("export VP_HOME=\"$HOME/vp_home\""), - "env file should export VP_HOME, got: {env_content}" - ); + // VP_HOME is gone: the CLI locates its install root from the + // executable path, so the env scripts must not set it. + for (name, content) in [ + ("env", &env_content), + ("env.fish", &fish_content), + ("env.nu", &nu_content), + ("env.ps1", &ps1_content), + ] { + assert!( + !content.contains("VP_HOME"), + "{name} should not reference VP_HOME, got: {content}" + ); + } + + // Should use $HOME-relative path since the bin dir is under HOME assert!( - fish_content.contains("set -gx VP_HOME \"$HOME/vp_home\""), - "env.fish file should export VP_HOME, got: {fish_content}" + env_content.contains("$HOME/.vite-plus/bin"), + "env file should reference $HOME/.vite-plus/bin, got: {env_content}" ); assert!( - nu_content.contains("$env.VP_HOME = (\"~/vp_home\" | path expand --no-symlink)"), - "env.nu file should set home-relative VP_HOME, got: {nu_content}" + fish_content.contains("$HOME/.vite-plus/bin"), + "env.fish file should reference $HOME/.vite-plus/bin, got: {fish_content}" ); assert!( - nu_content.contains("~/vp_home/bin"), - "env.nu file should reference ~/vp_home/bin, got: {nu_content}" + nu_content.contains("~/.vite-plus/bin"), + "env.nu file should reference ~/.vite-plus/bin, got: {nu_content}" ); - let expected_home = home.as_path().display().to_string(); + let expected_bin = home.join("bin").as_path().display().to_string(); assert!( - ps1_content.contains(&format!("$env:VP_HOME = \"{expected_home}\"")), - "env.ps1 file should set VP_HOME, got: {ps1_content}" + ps1_content.contains(&format!("$__vp_bin = \"{expected_bin}\"")), + "env.ps1 file should set the bin dir, got: {ps1_content}" ); } #[tokio::test] - async fn test_create_env_files_uses_absolute_path_when_not_under_home() { + async fn test_create_env_files_uses_absolute_path_when_bin_not_under_home() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Set user_home to a different path so install dir is NOT under HOME - let _guard = home_guard("/nonexistent-home-dir"); + let home = temp_dir.path().join("home"); + // Bin directory outside HOME via VP_BIN_DIR override (split layout). + let outside_bin = temp_dir.path().join("outside-bin"); + std::fs::create_dir_all(&outside_bin).unwrap(); + // Split layout: user home without a grandfathered ~/.vite-plus, plus an + // explicit bin override (VP_HOME must stay unset so VpEnvs can win). + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { + user_home: Some(home), + vp_bin_dir: Some(outside_bin.clone()), + ..vp_shared::EnvConfig::for_test() + }); - create_env_files(&home).await.unwrap(); + assert!(!VpDirs::is_legacy_layout(), "no .vite-plus under home → split layout"); + tokio::fs::create_dir_all(VpDirs::config_dir().as_path()).await.unwrap(); - let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); - let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + create_env_files().await.unwrap(); - // Should use absolute path since install dir is not under HOME - let expected_bin = home.join("bin"); - let expected_str = expected_bin.as_path().display().to_string().replace('\\', "/"); - let expected_home = home.as_path().display().to_string().replace('\\', "/"); + let env_content = + tokio::fs::read_to_string(VpDirs::config_dir().join("env")).await.unwrap(); + let fish_content = + tokio::fs::read_to_string(VpDirs::config_dir().join("env.fish")).await.unwrap(); + + // Should use the absolute path since the bin dir is not under HOME + let expected_str = outside_bin.display().to_string().replace('\\', "/"); assert!( env_content.contains(&expected_str), "env file should use absolute path {expected_str}, got: {env_content}" @@ -1053,26 +1057,32 @@ mod tests { fish_content.contains(&expected_str), "env.fish file should use absolute path {expected_str}, got: {fish_content}" ); + + // Should NOT use a $HOME-relative path for the bin dir assert!( - env_content.contains(&format!("export VP_HOME=\"{expected_home}\"")), - "env file should export absolute VP_HOME {expected_home}, got: {env_content}" - ); - assert!( - fish_content.contains(&format!("set -gx VP_HOME \"{expected_home}\"")), - "env.fish file should export absolute VP_HOME {expected_home}, got: {fish_content}" + !env_content.contains("export PATH=\"$HOME"), + "env file should not reference a $HOME-relative bin, got: {env_content}" ); + } - // Should NOT use $HOME-relative path - assert!(!env_content.contains("$HOME/bin"), "env file should not reference $HOME/bin"); + #[test] + fn test_render_home_relative_path_falls_back_to_absolute_outside_home() { + let (path, home) = if cfg!(windows) { + (r"C:\install\vp", r"C:\Users\vp") + } else { + ("/opt/vp", "/home/vp") + }; + let rendered = + render_home_relative_path(std::path::Path::new(path), Some(std::path::Path::new(home))); + assert_eq!(rendered, path.replace('\\', "/")); } #[tokio::test] async fn test_create_env_files_posix_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); @@ -1100,10 +1110,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_fish_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -1122,16 +1131,15 @@ mod tests { #[tokio::test] async fn test_create_env_files_is_idempotent() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); // Create env files twice - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let first_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let first_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); let first_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let second_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let second_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); let second_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); @@ -1144,10 +1152,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_posix_contains_vp_shell_function() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); @@ -1171,10 +1178,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_fish_contains_vp_function() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -1193,10 +1199,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_ps1_contains_vp_function() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); @@ -1215,27 +1220,30 @@ mod tests { #[serial_test::serial] async fn test_execute_creates_cmd_wrapper_in_fresh_home() { let temp_dir = TempDir::new().unwrap(); - let fresh_home = temp_dir.path().join("new-vite-plus"); let _trampoline_guard = FakeTrampolineGuard::new(temp_dir.path()); - let _env_guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vite_plus_home: Some(fresh_home.clone()), - user_home: Some(temp_dir.path().to_path_buf()), - ..vp_shared::EnvConfig::for_test() - }); + // Fresh home (no `.vite-plus` yet): the split layout is selected and + // setup creates the bin directory. + let _env_guard = vp_shared::EnvConfig::test_guard( + vp_shared::EnvConfig::for_test_with_home(temp_dir.path()), + ); - assert!(!fresh_home.exists(), "VP_HOME should not exist before initial setup"); + let bin_dir = VpDirs::bin_dir(); + assert!(!bin_dir.as_path().exists(), "bin dir should not exist before initial setup"); let status = execute(false, false).await.unwrap(); assert!(status.success(), "initial vp env setup should succeed"); - let bin_dir = AbsolutePathBuf::new(fresh_home.join("bin")).unwrap(); let cmd_content = tokio::fs::read_to_string(bin_dir.join("vp-use.cmd")).await.unwrap(); assert!( - cmd_content.contains("set VP_HOME=%~dp0..\r\nfor /f"), - "vp-use.cmd should set VP_HOME before invoking vp env use, got: {cmd_content}" + !cmd_content.contains("VP_HOME"), + "vp-use.cmd should not set VP_HOME, got: {cmd_content}" ); assert!( - cmd_content.contains("%~dp0..\\current\\bin\\vp.exe env use %*"), - "vp-use.cmd should invoke the install-local vp.exe" + cmd_content.contains("%~dp0..\\current\\bin\\vp.exe"), + "vp-use.cmd should try the legacy-layout vp.exe first, got: {cmd_content}" + ); + assert!( + cmd_content.contains("%~dp0..\\data\\current\\bin\\vp.exe"), + "vp-use.cmd should fall back to the split-layout vp.exe, got: {cmd_content}" ); } @@ -1243,12 +1251,11 @@ mod tests { #[cfg(unix)] async fn test_create_env_files_does_not_create_cmd_wrapper_on_unix() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); let bin_dir = home.join("bin"); tokio::fs::create_dir_all(&bin_dir).await.unwrap(); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); assert!( !bin_dir.join("vp-use.cmd").as_path().exists(), @@ -1259,31 +1266,36 @@ mod tests { #[tokio::test] async fn test_execute_env_only_creates_home_dir_and_env_files() { let temp_dir = TempDir::new().unwrap(); - let fresh_home = temp_dir.path().join("new-vite-plus"); - // Directory does NOT exist yet — execute should create it + // Fresh user home (no `.vite-plus`, no VP_HOME): split layout; execute + // creates the env-scripts config directory under that home. + // Use a nested home that does not yet exist so the assertion is + // meaningful (TempDir itself already exists on disk). + let home = temp_dir.path().join("user-home"); let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vite_plus_home: Some(fresh_home.clone()), - user_home: Some(temp_dir.path().to_path_buf()), + user_home: Some(home), ..vp_shared::EnvConfig::for_test() }); + let env_dir = VpDirs::config_dir(); + assert!(!env_dir.as_path().exists(), "env dir should not exist before initial setup"); + let status = execute(false, true).await.unwrap(); assert!(status.success(), "execute --env-only should succeed"); // Directory should now exist - assert!(fresh_home.exists(), "VP_HOME directory should be created"); + assert!(env_dir.as_path().exists(), "env directory should be created"); // Env files should be written - assert!(fresh_home.join("env").exists(), "env file should be created"); - assert!(fresh_home.join("env.fish").exists(), "env.fish file should be created"); - assert!(fresh_home.join("env.ps1").exists(), "env.ps1 file should be created"); + assert!(env_dir.join("env").as_path().exists(), "env file should be created"); + assert!(env_dir.join("env.fish").as_path().exists(), "env.fish file should be created"); + assert!(env_dir.join("env.ps1").as_path().exists(), "env.ps1 file should be created"); } #[tokio::test] #[cfg(unix)] async fn test_unix_vp_shim_target_prefers_standalone_layout_for_current_exe() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); + let (_guard, home) = legacy_home(temp_dir.path()); let bin_dir = home.join("bin"); let standalone_vp = home.join("current").join("bin").join("vp"); @@ -1296,11 +1308,37 @@ mod tests { assert_eq!(target, std::path::Path::new("../current/bin/vp")); } + #[tokio::test] + #[cfg(unix)] + async fn test_unix_vp_shim_target_prefers_standalone_layout_under_split_dirs() { + // Split layout: bin dir (`~/.local/bin`) is not the data dir's child, + // so the shim target must be derived from `VpDirs`, not `bin_dir/..`. + let temp_dir = TempDir::new().unwrap(); + let home = temp_dir.path().join("user-home"); + let bin_dir = AbsolutePathBuf::new(home.join(".local/bin")).unwrap(); + let data_dir = home.join(".local/share/vite-plus"); + let standalone_vp = data_dir.join("current").join("bin").join("vp"); + + tokio::fs::create_dir_all(standalone_vp.parent().unwrap()).await.unwrap(); + tokio::fs::create_dir_all(&bin_dir).await.unwrap(); + tokio::fs::write(&standalone_vp, b"vp").await.unwrap(); + + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { + vp_data_dir: Some(data_dir.clone()), + user_home: Some(home.clone()), + ..vp_shared::EnvConfig::for_test() + }); + + let target = resolve_unix_vp_shim_target(standalone_vp.as_path(), &bin_dir).await.unwrap(); + + assert_eq!(target, std::path::Path::new("../share/vite-plus/current/bin/vp")); + } + #[tokio::test] #[cfg(unix)] async fn test_unix_vp_shim_target_uses_current_exe_when_standalone_is_stale() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); + let (_guard, home) = legacy_home(temp_dir.path()); let bin_dir = home.join("bin"); let standalone_vp = home.join("current").join("bin").join("vp"); let external_vp = temp_dir.path().join("external-vp"); @@ -1319,7 +1357,7 @@ mod tests { #[cfg(unix)] async fn test_unix_vp_shim_target_uses_current_exe_without_standalone_layout() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); + let (_guard, home) = legacy_home(temp_dir.path()); let bin_dir = home.join("bin"); let external_vp = temp_dir.path().join("external-vp"); @@ -1335,7 +1373,7 @@ mod tests { #[cfg(unix)] async fn test_create_shim_replaces_stale_unix_symlink_without_refresh() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); + let (_guard, home) = legacy_home(temp_dir.path()); let bin_dir = home.join("bin"); let standalone_vp = home.join("current").join("bin").join("vp"); let external_vp = temp_dir.path().join("external-vp"); @@ -1358,7 +1396,7 @@ mod tests { #[cfg(unix)] async fn test_create_shim_replaces_broken_unix_symlink_without_refresh() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); + let (_guard, home) = legacy_home(temp_dir.path()); let bin_dir = home.join("bin"); let external_vp = temp_dir.path().join("external-vp"); let node_shim = bin_dir.join("node"); @@ -1378,7 +1416,7 @@ mod tests { #[cfg(unix)] async fn test_setup_vp_wrapper_replaces_stale_unix_symlink_without_refresh() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().join(".vite-plus")).unwrap(); + let (_guard, home) = legacy_home(temp_dir.path()); let bin_dir = home.join("bin"); let standalone_vp = home.join("current").join("bin").join("vp"); let external_vp = temp_dir.path().join("external-vp"); @@ -1418,10 +1456,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_contains_dynamic_completion() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files().await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 7b07cf94d8..eea52236c1 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -10,6 +10,7 @@ use std::process::ExitStatus; +use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; use super::{ @@ -137,8 +138,7 @@ pub async fn execute( // Ensure version is installed (unless --no-install) if !no_install { - let home_dir = - vp_shared::get_vp_home()?.join("js_runtime").join("node").join(&resolved_version); + let home_dir = VpDirs::js_runtime_dir().join("node").join(&resolved_version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 3d5fbebb01..f0127e6f88 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -14,12 +14,12 @@ use vp_pm_cli::{ PackageManagerType, package_manager_bin_path, package_manager_install_dir, resolve_package_manager_from_package_json, }; -use vp_shared::output; +use vp_shared::{VpDirs, output}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{ bin_config::{BinConfig, BinSource}, - config::{VERSION_ENV_VAR, get_bin_dir, get_node_modules_dir, resolve_version}, + config::{VERSION_ENV_VAR, get_node_modules_dir, resolve_version}, package_metadata::PackageMetadata, }; use crate::{cli::exit_status, error::Error}; @@ -110,7 +110,7 @@ async fn execute_npm_link_binary(tool: &str, bin_config: &BinConfig) -> Result Result { - let link_path = get_bin_dir()?.join(tool); + let link_path = VpDirs::bin_dir().join(tool); let target = tokio::fs::read_link(&link_path).await?; let binary_path = if target.is_absolute() { target @@ -127,7 +127,7 @@ async fn locate_npm_link_binary(tool: &str) -> Result { #[cfg(windows)] async fn locate_npm_link_binary(tool: &str) -> Result { - let cmd_path = get_bin_dir()?.join(format!("{tool}.cmd")); + let cmd_path = VpDirs::bin_dir().join(format!("{tool}.cmd")); let content = tokio::fs::read_to_string(&cmd_path).await?; let mut lines = content.lines(); let source = match (lines.next(), lines.next(), lines.next(), lines.next()) { @@ -202,8 +202,7 @@ async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result bin_dir, - Err(error) => { - let _ = cleanup_failed_install(&install_dir).await; - if first_error.is_none() { - first_error = Some(error); - } - continue; - } - }; + let bin_dir = VpDirs::bin_dir(); let metadata_version = installed_version.as_deref().unwrap_or("unknown"); let mut metadata = PackageMetadata::new( @@ -966,7 +957,7 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { }; if dry_run { - let bin_dir = get_bin_dir()?; + let bin_dir = VpDirs::bin_dir(); let package_dir = match &metadata { Some(metadata) => metadata.installation_dir()?, None => PackageMetadata::installation_dir_for(&package_name, "")?, @@ -991,7 +982,7 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { } // Remove shims and bin configs - let bin_dir = get_bin_dir()?; + let bin_dir = VpDirs::bin_dir(); for bin_name in &bins { remove_package_shim(&bin_dir, bin_name).await?; BinConfig::delete(bin_name).await?; @@ -1400,7 +1391,9 @@ mod tests { let _env_guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&temp_path)); - // Create bin directory + // `for_test_with_home` pins the legacy root at `temp_path` itself + // (VP_HOME Set mapping), so shims live at `/bin` — matching + // `VpDirs::bin_dir()` / uninstall, not `/.vite-plus/bin`. let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); tokio::fs::create_dir_all(&bin_dir).await.unwrap(); @@ -1535,7 +1528,10 @@ mod tests { let _trampoline_guard = FakeTrampolineGuard::new(&temp_path); let _env_guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(&temp_path)); + + // `for_test_with_home` pins the legacy root at `temp_path` itself. let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); + tokio::fs::create_dir_all(&bin_dir).await.unwrap(); let mut previous_metadata = PackageMetadata::new( "test-package".to_string(), diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 47b9a9d070..034bbde379 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -1,10 +1,14 @@ //! `vp implode` — completely remove vp and all its data from this system. -use std::{io::Write, process::ExitStatus}; +use std::{ + collections::HashSet, + io::Write, + process::ExitStatus, +}; use directories::BaseDirs; use owo_colors::OwoColorize; -use vp_shared::output; +use vp_shared::{VpDirs, output}; use vt_path::AbsolutePathBuf; use vt_str::Str; @@ -20,12 +24,9 @@ use crate::{ const VITE_PLUS_COMMENT: &str = "# Vite+ bin"; pub fn execute(yes: bool) -> Result { - let Ok(home_dir) = vp_shared::get_vp_home() else { - output::info("vite-plus is not installed (could not determine home directory)"); - return Ok(exit_status(0)); - }; + let plan = RemovalPlan::new(); - if !home_dir.as_path().exists() { + if !plan.anything_to_remove() { output::info("vite-plus is not installed (directory does not exist)"); return Ok(exit_status(0)); } @@ -35,13 +36,13 @@ pub fn execute(yes: bool) -> Result { .ok_or_else(|| Error::Other("Could not determine user home directory".into()))?; let user_home = AbsolutePathBuf::new(base_dirs.home_dir().to_path_buf()).unwrap(); - let source_matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let source_matcher = VitePlusSourceMatcher::new(&plan.profile_roots, &user_home); // Collect shell profiles that contain Vite+ lines (content cached for cleaning) let affected_profiles = collect_affected_profiles(&user_home, &source_matcher); // Confirmation - if !yes && !confirm_implode(&home_dir, &affected_profiles)? { + if !yes && !confirm_implode(&plan, &affected_profiles)? { return Ok(exit_status(0)); } @@ -51,7 +52,7 @@ pub fn execute(yes: bool) -> Result { // Remove Windows PATH entry #[cfg(windows)] { - let bin_path = home_dir.join("bin"); + let bin_path = VpDirs::bin_dir(); if let Err(e) = remove_windows_path_entry(&bin_path) { output::warn(&vt_str::format!("Failed to clean Windows PATH: {e}")); } else { @@ -59,8 +60,7 @@ pub fn execute(yes: bool) -> Result { } } - // Remove the directory - remove_vite_plus_dir(&home_dir)?; + plan.remove()?; output::raw(""); output::success("vite-plus has been removed from your system."); @@ -69,6 +69,218 @@ pub fn execute(yes: bool) -> Result { Ok(exit_status(0)) } +/// What `vp implode` removes, derived from the resolved [`VpDirs`] +/// layout. +struct RemovalPlan { + /// Directories removed wholesale. Legacy layout: just the install root. + /// Split layout: data, config, state, and cache dirs (deduplicated — + /// category overrides can make them coincide). + dirs: Vec, + /// Bin directory to clean of vp-owned shims (split layout only; under + /// the legacy layout it lives inside the removed root). The directory + /// itself is removed only when vp-dedicated; a shared dir like + /// `~/.local/bin` is never removed. + bin_dir: Option, + /// Directories shell-profile sourcing lines may reference. Legacy: the + /// install root (env scripts live there). Split: the env-scripts dir + /// (e.g. `. "$HOME/.config/vite-plus/env"`). + profile_roots: Vec, +} + +impl RemovalPlan { + fn new() -> Self { + if VpDirs::is_legacy_layout() { + let root = VpDirs::data_dir(); + return Self { dirs: vec![root.clone()], bin_dir: None, profile_roots: vec![root] }; + } + + let mut category_dirs = vec![ + VpDirs::data_dir(), + VpDirs::config_dir(), + VpDirs::state_dir(), + VpDirs::cache_dir(), + ]; + // Sort first: `dedup` only removes consecutive duplicates, but + // `VP_*_DIR` overrides can make non-adjacent categories coincide. + category_dirs.sort(); + category_dirs.dedup(); + Self { + dirs: category_dirs, + bin_dir: Some(VpDirs::bin_dir()), + profile_roots: vec![VpDirs::config_dir()], + } + } + + fn anything_to_remove(&self) -> bool { + self.dirs.iter().any(|dir| dir.as_path().exists()) + || self.bin_dir.as_ref().is_some_and(|bin_dir| { + // Package-shim names live under data/bins/*.json; if data is + // already gone, fall back to the core allowlist so leftover + // shims in a shared bin dir are still detected. + let owned = owned_bin_names_from_metadata(); + std::fs::read_dir(bin_dir) + .map(|entries| { + entries.filter_map(Result::ok).any(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| is_vp_owned_bin_name(name, &owned)) + }) + }) + .unwrap_or(false) + }) + } + + fn remove(&self) -> Result<(), Error> { + // Collect package-shim names from data/bins/*.json *before* wiping + // data, so a shared bin dir (e.g. ~/.local/bin) loses tsc etc. too. + let owned_bin_names = if self.bin_dir.is_some() { + owned_bin_names_from_metadata() + } else { + HashSet::new() + }; + + let mut failed = false; + for dir in &self.dirs { + if !dir.as_path().exists() { + continue; + } + if remove_vite_plus_dir(dir).is_err() { + failed = true; + } + } + if let Some(bin_dir) = &self.bin_dir { + clean_bin_dir(bin_dir, &owned_bin_names); + } + if failed { + Err(Error::Other("Failed to remove all vite-plus directories".into())) + } else { + Ok(()) + } + } +} + +/// Names of core shims vp owns in the bin directory, in both Unix and Windows +/// spellings: the `vp` wrapper, the tool shims, and the cmd.exe `vp env use` +/// wrapper. Package shims from `vp install -g` are discovered separately via +/// `data/bins/*.json` (see [`owned_bin_names_from_metadata`]). +const VP_OWNED_BIN_NAMES: &[&str] = &[ + "vp", + "node", + "npm", + "npx", + "corepack", + "vpx", + "vpr", + "vp.exe", + "node.exe", + "npm.exe", + "npx.exe", + "corepack.exe", + "vpx.exe", + "vpr.exe", + "vp.cmd", + "node.cmd", + "npm.cmd", + "npx.cmd", + "corepack.cmd", + "vpx.cmd", + "vpr.cmd", + "vp-use.cmd", +]; + +/// Collect every bin-dir file name vp may own: the core allowlist plus package +/// shims recorded under `VpDirs::bins_dir()` (`*.json` stems and Windows +/// `.exe` / `.cmd` variants). Must run before data is deleted on implode. +fn owned_bin_names_from_metadata() -> HashSet { + let mut owned: HashSet = VP_OWNED_BIN_NAMES.iter().map(|s| (*s).to_string()).collect(); + + let bins_dir = VpDirs::bins_dir(); + let Ok(entries) = std::fs::read_dir(bins_dir) else { + return owned; + }; + for entry in entries.filter_map(Result::ok) { + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + let Some(base) = name.strip_suffix(".json") else { + continue; + }; + if base.is_empty() { + continue; + } + // Unix package shim is the bare name; Windows trampoline is + // `.exe` (plus legacy `.cmd` / extensionless leftovers). + owned.insert(base.to_string()); + owned.insert(format!("{base}.exe")); + owned.insert(format!("{base}.cmd")); + } + owned +} + +/// Whether vp owns the bin-dir entry `name`: an exact owned shim name, or a +/// `..old` leftover from Windows rename-before-copy. +fn is_vp_owned_bin_name(name: &str, owned: &HashSet) -> bool { + if owned.contains(name) { + return true; + } + if let Some(stem) = name.strip_suffix(".old") + && let Some((base, timestamp)) = stem.rsplit_once('.') + { + // Rename-before-copy leftovers are `..old`. + return timestamp.bytes().all(|b| b.is_ascii_digit()) && owned.contains(base); + } + false +} + +/// Remove vp's shims from `bin_dir` (split layout). The directory itself is +/// removed only when it is vp-dedicated (contains nothing but vp-owned +/// files); otherwise only the owned shim names are deleted and a shared bin +/// dir like `~/.local/bin` is left in place. +fn clean_bin_dir(bin_dir: &AbsolutePathBuf, owned: &HashSet) { + let Ok(entries) = std::fs::read_dir(bin_dir) else { + return; + }; + let names: Vec = entries + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok().map(Str::from)) + .collect(); + if names.is_empty() { + return; + } + + if names.iter().all(|name| is_vp_owned_bin_name(name, owned)) { + // vp-dedicated bin dir: remove it wholesale. + match std::fs::remove_dir_all(bin_dir) { + Ok(()) => output::success(&vt_str::format!("Removed {}", bin_dir.as_path().display())), + Err(e) => { + output::warn(&vt_str::format!( + "Failed to remove {}: {e}", + bin_dir.as_path().display() + )); + } + } + return; + } + + // Shared bin dir: delete only the files vp owns (core + package shims). + for name in names.iter().filter(|name| is_vp_owned_bin_name(name, owned)) { + let path = bin_dir.join(name.as_str()); + match std::fs::remove_file(&path) { + Ok(()) => { + output::success(&vt_str::format!("Removed {}", path.as_path().display())); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + output::warn(&vt_str::format!( + "Failed to remove {}: {e}", + path.as_path().display() + )); + } + } + } +} + /// A shell profile that contains Vite+ sourcing lines. struct AffectedProfile { /// Display name (e.g. ".zshrc", ".config/fish/conf.d/vite-plus.fish"). @@ -126,7 +338,7 @@ fn collect_affected_profiles( /// Show confirmation prompt and require the user to type "uninstall". /// Returns `Ok(true)` if confirmed, `Ok(false)` if aborted. fn confirm_implode( - home_dir: &AbsolutePathBuf, + plan: &RemovalPlan, affected_profiles: &[AffectedProfile], ) -> Result { if !vp_shared::is_stdin_terminal() { @@ -138,7 +350,19 @@ fn confirm_implode( output::warn("This will completely remove vite-plus from your system!"); output::raw(""); - output::raw(&vt_str::format!(" Directory: {}", home_dir.as_path().display())); + if plan.dirs.len() == 1 { + output::raw(&vt_str::format!(" Directory: {}", plan.dirs[0].as_path().display())); + } else { + output::raw(" Directories:"); + for dir in &plan.dirs { + output::raw(&vt_str::format!(" - {}", dir.as_path().display())); + } + } + if let Some(bin_dir) = &plan.bin_dir + && bin_dir.as_path().exists() + { + output::raw(&vt_str::format!(" Shims to remove from: {}", bin_dir.as_path().display())); + } if !affected_profiles.is_empty() { output::raw(" Shell profiles to clean:"); for profile in affected_profiles { @@ -272,29 +496,37 @@ fn spawn_deferred_delete(trash_path: &std::path::Path) -> std::io::Result, } impl VitePlusSourceMatcher { - fn new(home_dir: &AbsolutePathBuf, user_home: &AbsolutePathBuf) -> Self { - let mut roots = vec![normalize_path_separators(&home_dir.as_path().display().to_string())]; - - if let Ok(Some(suffix)) = home_dir.strip_prefix(user_home) { - // `RelativePathBuf` guarantees forward-slash separators. - let suffix = vt_str::format!("{suffix}"); - if suffix.is_empty() { - roots.push(Str::from("$HOME")); - roots.push(Str::from("~")); - } else { - roots.push(vt_str::format!("$HOME/{suffix}")); - roots.push(vt_str::format!("~/{suffix}")); + fn new(reference_dirs: &[AbsolutePathBuf], user_home: &AbsolutePathBuf) -> Self { + let mut roots = Vec::new(); + + for dir in reference_dirs { + roots.push(normalize_path_separators(&dir.as_path().display().to_string())); + + if let Ok(Some(suffix)) = dir.strip_prefix(user_home) { + // `RelativePathBuf` guarantees forward-slash separators. + let suffix = vt_str::format!("{suffix}"); + if suffix.is_empty() { + roots.push(Str::from("$HOME")); + roots.push(Str::from("~")); + } else { + roots.push(vt_str::format!("$HOME/{suffix}")); + roots.push(vt_str::format!("~/{suffix}")); + } } } @@ -428,7 +660,7 @@ mod tests { fn default_source_matcher() -> VitePlusSourceMatcher { let user_home = default_user_home(); let home_dir = user_home.join(".vite-plus"); - VitePlusSourceMatcher::new(&home_dir, &user_home) + VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home) } #[test] @@ -451,7 +683,7 @@ mod tests { fn test_remove_vite_plus_lines_absolute_path() { let user_home = default_user_home(); let home_dir = user_home.join(".vite-plus"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let env_path = shell_path(&home_dir.join("env")); let content = vt_str::format!("# existing\n. \"{env_path}\"\n"); let result = remove_vite_plus_lines(&content, &matcher, "env"); @@ -462,7 +694,7 @@ mod tests { fn test_remove_vite_plus_lines_custom_absolute_path() { let user_home = custom_user_home(); let home_dir = user_home.join("tools").join("vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let env_path = shell_path(&home_dir.join("env")); let content = vt_str::format!("# existing\n. \"{env_path}\"\n"); let result = remove_vite_plus_lines(&content, &matcher, "env"); @@ -473,7 +705,7 @@ mod tests { fn test_remove_vite_plus_lines_custom_home_relative_path() { let user_home = custom_user_home(); let home_dir = user_home.join("tools").join("vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let content = "# existing\n. \"$HOME/tools/vp/env\"\n"; let result = remove_vite_plus_lines(content, &matcher, "env"); assert_eq!(&*result, "# existing\n"); @@ -483,7 +715,7 @@ mod tests { fn test_remove_vite_plus_lines_custom_tilde_path() { let user_home = custom_user_home(); let home_dir = user_home.join("tools").join("vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let content = "# existing\nsource '~/tools/vp/env.nu'\n"; let result = remove_vite_plus_lines(content, &matcher, "env.nu"); assert_eq!(&*result, "# existing\n"); @@ -542,7 +774,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let home_dir = temp_path.join(".vite-plus"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &temp_path); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &temp_path); let profile_path = temp_path.join(".zshrc"); let original = "# my config\nexport FOO=bar\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n"; std::fs::write(&profile_path, original).unwrap(); @@ -612,7 +844,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let home_dir = home.join(".vite-plus"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &home); // Clear env overrides so the test environment doesn't affect results let _guard = ProfileEnvGuard::new(None, None, None); @@ -639,7 +871,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let home_dir = home.join("tools/vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &home); let _guard = ProfileEnvGuard::new(None, None, None); @@ -725,7 +957,7 @@ mod tests { std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); let _guard = ProfileEnvGuard::new(Some(&zdotdir), None, None); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + let matcher = VitePlusSourceMatcher::new(&[home.join(".vite-plus")], &home); let profiles = collect_affected_profiles(&home, &matcher); let zdotdir_profiles: Vec<_> = @@ -749,7 +981,7 @@ mod tests { .unwrap(); let _guard = ProfileEnvGuard::new(None, Some(&xdg_config), None); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + let matcher = VitePlusSourceMatcher::new(&[home.join(".vite-plus")], &home); let profiles = collect_affected_profiles(&home, &matcher); let xdg_profiles: Vec<_> = @@ -772,7 +1004,7 @@ mod tests { std::fs::write(nushell_dir.join("vite-plus.nu"), "source '~/.vite-plus/env.nu'\n").unwrap(); let _guard = ProfileEnvGuard::new(None, None, Some(&xdg_data)); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + let matcher = VitePlusSourceMatcher::new(&[home.join(".vite-plus")], &home); let profiles = collect_affected_profiles(&home, &matcher); let xdg_profiles: Vec<_> = @@ -781,6 +1013,134 @@ mod tests { assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); } + #[test] + fn test_remove_vite_plus_lines_split_env_scripts_dir() { + // Split layout: profile lines reference the env-scripts dir + // (`. "$HOME/.config/vite-plus/env"`), not the data dir. + let user_home = default_user_home(); + let env_dir = user_home.join(".config").join("vite-plus"); + let data_dir = user_home.join(".local/share").join("vite-plus"); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&env_dir), &user_home); + let content = + "# existing\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.config/vite-plus/env\"\n"; + let result = remove_vite_plus_lines(content, &matcher, "env"); + assert_eq!(&*result, "# existing\n"); + + // Lines referencing the data dir are not env-script sourcing lines + // and stay untouched. + let env_path = shell_path(&data_dir.join("env")); + let content = vt_str::format!("# existing\n. \"{env_path}\"\n"); + let result = remove_vite_plus_lines(&content, &matcher, "env"); + assert_eq!(&*result, &*content); + } + + fn core_owned_names() -> HashSet { + VP_OWNED_BIN_NAMES.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + fn test_is_vp_owned_bin_name() { + let owned = core_owned_names(); + for name in [ + "vp", + "node", + "npm", + "npx", + "corepack", + "vpx", + "vpr", + "vp.exe", + "npm.cmd", + "vp-use.cmd", + ] { + assert!(is_vp_owned_bin_name(name, &owned), "{name} should be vp-owned"); + } + // Windows rename-before-copy leftovers. + assert!(is_vp_owned_bin_name("vp.exe.1700000000.old", &owned)); + // Not vp-owned: other tools, lookalikes, and bare .old files. + for foreign in ["git", "node.exe.old", "vpn", "vp.json", "vp.exe.old.bak", "tsc"] { + assert!(!is_vp_owned_bin_name(foreign, &owned), "{foreign} should not be vp-owned"); + } + // Package shims from bins/*.json expand to bare + Windows variants. + let mut with_package = owned; + with_package.insert("tsc".to_string()); + with_package.insert("tsc.exe".to_string()); + with_package.insert("tsc.cmd".to_string()); + assert!(is_vp_owned_bin_name("tsc", &with_package)); + assert!(is_vp_owned_bin_name("tsc.exe", &with_package)); + assert!(is_vp_owned_bin_name("tsc.exe.1700000000.old", &with_package)); + } + + #[test] + fn test_clean_bin_dir_removes_dedicated_dir() { + let temp_dir = tempfile::tempdir().unwrap(); + let bin_dir = AbsolutePathBuf::new(temp_dir.path().join("bin")).unwrap(); + std::fs::create_dir_all(&bin_dir).unwrap(); + for name in ["vp", "node", "npm", "vp-use.cmd", "vp.exe.1700000000.old"] { + std::fs::write(bin_dir.join(name), b"shim").unwrap(); + } + + clean_bin_dir(&bin_dir, &core_owned_names()); + + assert!(!bin_dir.as_path().exists(), "vp-dedicated bin dir should be removed wholesale"); + } + + #[test] + fn test_clean_bin_dir_keeps_shared_dir_and_foreign_files() { + let temp_dir = tempfile::tempdir().unwrap(); + let bin_dir = AbsolutePathBuf::new(temp_dir.path().join("bin")).unwrap(); + std::fs::create_dir_all(&bin_dir).unwrap(); + for name in ["vp", "node", "vpr"] { + std::fs::write(bin_dir.join(name), b"shim").unwrap(); + } + std::fs::write(bin_dir.join("git"), b"foreign").unwrap(); + + clean_bin_dir(&bin_dir, &core_owned_names()); + + assert!(bin_dir.as_path().exists(), "shared bin dir must not be removed"); + assert!(bin_dir.join("git").as_path().exists(), "foreign files must stay"); + for name in ["vp", "node", "vpr"] { + assert!(!bin_dir.join(name).as_path().exists(), "{name} should be removed"); + } + } + + #[test] + fn test_clean_bin_dir_removes_package_shims_from_metadata() { + // Shared ~/.local/bin-style dir: core shims + package shims (tsc) + foreign. + let temp_dir = tempfile::tempdir().unwrap(); + let root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let bin_dir = root.join("bin"); + let data_dir = root.join("data"); + let bins_meta = data_dir.join("bins"); + std::fs::create_dir_all(&bin_dir).unwrap(); + std::fs::create_dir_all(&bins_meta).unwrap(); + for name in ["vp", "node", "tsc", "tsc.exe", "git"] { + std::fs::write(bin_dir.join(name), b"shim").unwrap(); + } + std::fs::write(bins_meta.join("tsc.json"), r#"{"name":"tsc","package":"typescript"}"#) + .unwrap(); + + // Split layout: pin bin/data via VP_*_DIR (not VP_HOME / legacy mapping). + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { + vp_bin_dir: Some(bin_dir.as_path().to_path_buf()), + vp_data_dir: Some(data_dir.as_path().to_path_buf()), + user_home: Some(temp_dir.path().to_path_buf()), + ..vp_shared::EnvConfig::for_test() + }); + + let owned = owned_bin_names_from_metadata(); + assert!(owned.contains("tsc"), "metadata should expand bare package shim name"); + assert!(owned.contains("tsc.exe")); + + clean_bin_dir(&bin_dir, &owned); + + assert!(bin_dir.as_path().exists(), "shared bin dir must not be removed"); + assert!(bin_dir.join("git").as_path().exists(), "foreign files must stay"); + for name in ["vp", "node", "tsc", "tsc.exe"] { + assert!(!bin_dir.join(name).as_path().exists(), "{name} should be removed"); + } + } + #[test] fn test_execute_not_installed() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index c853e84881..2f8e00ae0d 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -8,10 +8,10 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; use vp_pm_cli::HttpClient; use vp_setup::{install, integrity, platform, registry}; -use vp_shared::output; +use vp_shared::{VpDirs, output}; use vt_path::AbsolutePathBuf; -use crate::{commands::env::config::get_vp_home, error::Error}; +use crate::error::Error; /// Options for the upgrade command. pub struct UpgradeOptions { @@ -34,7 +34,7 @@ pub struct UpgradeOptions { /// Execute the upgrade command. #[allow(clippy::print_stdout, clippy::print_stderr)] pub async fn execute(options: UpgradeOptions) -> Result { - let install_dir = get_vp_home()?; + let install_dir = VpDirs::data_dir(); // Handle --rollback if options.rollback { diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index 4413adfd0e..db294e9d72 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -231,7 +231,7 @@ mod tests { } // Run serially: the spawned `node` inherits this process's environment, and - // concurrent #[serial] tests mutate PATH/VP_HOME via std::env::set_var, + // concurrent #[serial] tests mutate PATH via std::env::set_var, // which can make a vp shim on PATH resolve incorrectly mid-test. #[test] #[serial] diff --git a/crates/vp_global_cli/src/commands/vpx.rs b/crates/vp_global_cli/src/commands/vpx.rs index c6d6fe8d36..5f0d4c779d 100644 --- a/crates/vp_global_cli/src/commands/vpx.rs +++ b/crates/vp_global_cli/src/commands/vpx.rs @@ -7,10 +7,10 @@ //! 3. System PATH (excluding vite-plus bin directory) //! 4. Remote download via `vp dlx` -use vp_shared::{PrependOptions, exit_code_from_status, output, prepend_to_path_env}; +use vp_shared::{PrependOptions, VpDirs, exit_code_from_status, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use crate::{commands::env::config, shim::dispatch}; +use crate::shim::dispatch; /// Parsed vpx flags. #[derive(Debug, Default)] @@ -184,20 +184,12 @@ async fn execute_global_binary(bin: GlobalBinary, args: &[String], cwd: &Absolut /// /// This prevents vpx from finding itself (or other vite-plus shims) on PATH. fn find_on_path(cmd: &str) -> Option { - let bin_dir = config::get_bin_dir().ok(); + let bin_dir = VpDirs::bin_dir(); let path_var = std::env::var_os("PATH")?; // Filter PATH to exclude vite-plus bin directory - let filtered_paths: Vec<_> = std::env::split_paths(&path_var) - .filter(|p| { - if let Some(ref bin) = bin_dir { - if p == bin.as_path() { - return false; - } - } - true - }) - .collect(); + let filtered_paths: Vec<_> = + std::env::split_paths(&path_var).filter(|p| p != bin_dir.as_path()).collect(); let filtered_path = std::env::join_paths(filtered_paths).ok()?; let cwd = vt_path::current_dir().ok()?; @@ -709,12 +701,12 @@ mod tests { #[serial] fn test_find_on_path_excludes_vp_bin_dir() { let original_path = std::env::var_os("PATH"); - let original_home = std::env::var_os("VP_HOME"); let temp = tempfile::tempdir().unwrap(); - // Set up a fake vite-plus home with bin dir - let fake_home = temp.path().join("vite-plus-home"); - let fake_bin = fake_home.join("bin"); + // Set up a fake vite-plus home with bin dir. The on-disk `.vite-plus` + // under the overridden user home selects the legacy layout, so the + // vp bin dir is `/.vite-plus/bin`. + let fake_bin = temp.path().join(".vite-plus").join("bin"); std::fs::create_dir_all(&fake_bin).unwrap(); create_fake_executable(&fake_bin, "vpx-excluded-tool"); @@ -723,13 +715,15 @@ mod tests { std::fs::create_dir_all(&other_dir).unwrap(); create_fake_executable(&other_dir, "vpx-excluded-tool"); - let path = std::env::join_paths([fake_bin.as_path(), other_dir.as_path()]).unwrap(); + let path = std::env::join_paths([fake_bin.as_os_str(), other_dir.as_os_str()]).unwrap(); // SAFETY: serial test unsafe { std::env::set_var("PATH", &path); - std::env::set_var("VP_HOME", fake_home.as_os_str()); } + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp.path().join(".vite-plus"), + )); let result = find_on_path("vpx-excluded-tool"); assert!(result.is_some()); @@ -744,10 +738,6 @@ mod tests { Some(v) => std::env::set_var("PATH", v), None => std::env::remove_var("PATH"), } - match &original_home { - Some(v) => std::env::set_var("VP_HOME", v), - None => std::env::remove_var("VP_HOME"), - } } } diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 66c263ec49..8e2399269c 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -7,7 +7,7 @@ use std::process::{ExitStatus, Output}; use tokio::process::Command; use vp_js_runtime::{JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project}; -use vp_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend}; +use vp_shared::{PrependOptions, PrependResult, VpDirs, env_vars, format_path_with_prepend}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::{ @@ -108,6 +108,27 @@ impl JsExecutor { cmd.env(env_vars::VP_CLI_BIN, bin_path.as_path()); } + // Split (XDG) layout: hand JS scripts the resolved dirs so TS code + // that reads paths directly (the create-org tarball cache, generated + // git hook scripts) agrees with the Rust side, and nested vp + // processes resolve the same layout. Legacy installs self-locate + // their root (executable path / `PATH` inference / the grandfathered + // `~/.vite-plus`), and the legacy layout intentionally ignores these + // vars, so only inject them for the split layout. Explicit user + // overrides always win. + if !VpDirs::is_legacy_layout() { + for (var, dir) in [ + (env_vars::VP_BIN_DIR, VpDirs::bin_dir()), + (env_vars::VP_DATA_DIR, VpDirs::data_dir()), + (env_vars::VP_CACHE_DIR, VpDirs::cache_dir()), + ] { + if std::env::var_os(var).is_none() { + tracing::debug!("Set {var} to {dir:?}"); + cmd.env(var, dir.as_path()); + } + } + } + // Prepend runtime bin to PATH so child processes can find the JS runtime let options = PrependOptions { dedupe_anywhere: true }; if let PrependResult::Prepended(new_path) = @@ -618,8 +639,9 @@ mod tests { use tempfile::TempDir; use vp_shared::EnvConfig; - // Isolate VP_HOME so config defaults to managed mode (no `vp env off`) - // and the runtime download cache stays inside the test sandbox. + // Isolate the user home so config defaults to managed mode (no + // `vp env off`) and the runtime download cache stays inside the test + // sandbox (split layout under the temp home). let vp_home = TempDir::new().unwrap(); let _guard = EnvConfig::test_guard(EnvConfig::for_test_with_home(vp_home.path().to_path_buf())); diff --git a/crates/vp_global_cli/src/shim/cache.rs b/crates/vp_global_cli/src/shim/cache.rs index 2f97fd4ee3..2b7c75c318 100644 --- a/crates/vp_global_cli/src/shim/cache.rs +++ b/crates/vp_global_cli/src/shim/cache.rs @@ -9,6 +9,7 @@ use std::{ }; use serde::{Deserialize, Serialize}; +use vp_shared::VpDirs; use vt_path::{AbsolutePath, AbsolutePathBuf}; /// Cache format version for upgrade compatibility @@ -39,7 +40,8 @@ pub struct ResolveCacheEntry { pub is_range: bool, } -/// Resolution cache stored in VP_HOME/cache/resolve_cache.json. +/// Resolution cache stored in `/resolve_cache.json` +/// (`~/.vite-plus/cache/resolve_cache.json` under the legacy layout). #[derive(Serialize, Deserialize, Debug)] pub struct ResolveCache { /// Cache format version for upgrade compatibility @@ -182,10 +184,12 @@ impl ResolveCache { } } +/// File name under [`VpDirs::cache_dir`]. +const RESOLVE_CACHE_FILE: &str = "resolve_cache.json"; + /// Get the cache file path. pub fn get_cache_path() -> Option { - let home = crate::commands::env::config::get_vp_home().ok()?; - Some(home.join("cache").join("resolve_cache.json")) + Some(VpDirs::cache_dir().join(RESOLVE_CACHE_FILE)) } /// Invalidate the entire resolve cache by deleting the cache file. @@ -344,15 +348,15 @@ mod tests { assert_eq!(cached_entry.unwrap().version, "20.20.0"); } - // Run serially: mutates VP_HOME env var which affects get_cache_path() #[test] - #[serial_test::serial] fn test_invalidate_cache_removes_file() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Set VP_HOME to temp dir so invalidate_cache() targets our test file - let cache_dir = temp_path.join("cache"); + // Sandboxed legacy layout: the on-disk `.vite-plus` under the + // overridden user home selects it, so the resolve cache lives at + // `/.vite-plus/cache/resolve_cache.json`. + let cache_dir = temp_path.join(".vite-plus").join("cache"); std::fs::create_dir_all(&cache_dir).unwrap(); let cache_file = cache_dir.join("resolve_cache.json"); @@ -373,14 +377,11 @@ mod tests { cache.save(&cache_file); assert!(std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist"); - // Point VP_HOME to our temp dir and call invalidate_cache - unsafe { - std::env::set_var(vp_shared::env_vars::VP_HOME, temp_path.as_path()); - } + // Pin VP_HOME to the legacy install root that holds the cache file. + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_path.join(".vite-plus").as_path(), + )); invalidate_cache(); - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } // Cache file should be removed assert!( diff --git a/crates/vp_global_cli/src/shim/corepack.rs b/crates/vp_global_cli/src/shim/corepack.rs index 92c74c6bf5..54679bcecc 100644 --- a/crates/vp_global_cli/src/shim/corepack.rs +++ b/crates/vp_global_cli/src/shim/corepack.rs @@ -17,7 +17,7 @@ //! injected when not explicitly set, and Vite+-owned shims are restored //! afterwards if corepack removed or replaced them. -use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; +use vp_shared::{PrependOptions, VpDirs, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use super::{ @@ -29,7 +29,7 @@ use super::{ }; use crate::commands::env::{ bin_config::{BinConfig, BinSource}, - config, setup, + setup, }; /// Binary names corepack `enable`/`disable` may create or remove in the @@ -58,23 +58,12 @@ pub(crate) async fn dispatch_corepack(args: &[String]) -> i32 { // restore any Vite+-owned shims corepack removed or replaced. The arg // check runs first so the common path skips bin-dir resolution entirely. if is_corepack_link_command(args) { - match config::get_bin_dir() { - Ok(bin_dir) => { - full_args.extend(inject_install_directory(args, &bin_dir)); - let owned_shims = snapshot_vp_owned_shims(&bin_dir).await; - let exit_code = exec::spawn_tool(&program, &full_args); - restore_vp_owned_shims(&bin_dir, &owned_shims).await; - return exit_code; - } - Err(e) => { - // Without a bin dir there is nothing to inject or restore; - // run corepack as-is, but say so instead of failing silently. - output::warn(&format!( - "Cannot resolve the Vite+ bin directory ({e}); running corepack without \ - an --install-directory default, created launchers may not be on PATH" - )); - } - } + let bin_dir = VpDirs::bin_dir(); + full_args.extend(inject_install_directory(args, &bin_dir)); + let owned_shims = snapshot_vp_owned_shims(&bin_dir).await; + let exit_code = exec::spawn_tool(&program, &full_args); + restore_vp_owned_shims(&bin_dir, &owned_shims).await; + return exit_code; } // The bundled corepack and native binaries have no leading args; exec diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index f072073a74..58e84f3eba 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -9,7 +9,7 @@ use vp_pm_cli::{ PackageManagerType, download_package_manager, package_manager_bin_path, package_manager_install_dir, resolve_package_manager_from_package_json, }; -use vp_shared::{PrependOptions, env_vars, output, prepend_to_path_env}; +use vp_shared::{PrependOptions, VpDirs, env_vars, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use super::{ @@ -229,7 +229,7 @@ fn check_npm_global_install_result( node_dir: &AbsolutePath, node_version: &str, ) { - let Ok(bin_dir) = config::get_bin_dir() else { return }; + let bin_dir = VpDirs::bin_dir(); // Derive bin dir from prefix (Unix: prefix/bin, Windows: prefix itself) #[cfg(unix)] @@ -364,7 +364,11 @@ fn check_npm_global_install_result( let bin_display = bin_list.join(", "); output::raw(&vt_str::format!("'{bin_display}' is not available on your PATH.")); - output::raw_inline("Create a link in ~/.vite-plus/bin/ to make it available? [Y/n] "); + let link_dir = VpDirs::bin_dir(); + output::raw_inline(&vt_str::format!( + "Create a link in {}/ to make it available? [Y/n] ", + link_dir.as_path().display() + )); let _ = std::io::Write::flush(&mut std::io::stdout()); let mut input = String::new(); @@ -518,7 +522,7 @@ fn dedup_missing_bins( /// still delete its binary from `npm_bin_dir`, leaving our symlink dangling. In that /// case we repair the link by pointing directly at the surviving package's binary. fn remove_npm_global_uninstall_links(bin_entries: &[(String, String)], npm_prefix: &AbsolutePath) { - let Ok(bin_dir) = config::get_bin_dir() else { return }; + let bin_dir = VpDirs::bin_dir(); for (bin_name, package_name) in bin_entries { // Skip protected shims: a stale Npm BinConfig (e.g. a pre-default-shim @@ -777,7 +781,8 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Append current bin_dir to VP_BYPASS to prevent infinite loops // when multiple vite-plus installations exist in PATH. // The next installation will filter all accumulated paths. - if let Ok(bin_dir) = config::get_bin_dir() { + { + let bin_dir = VpDirs::bin_dir(); let bypass_val = match std::env::var_os(env_vars::VP_BYPASS) { Some(existing) => { let mut paths: Vec<_> = std::env::split_paths(&existing).collect(); @@ -901,37 +906,30 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { if let Some(parsed) = parse_npm_global_install(args) { let exit_code = exec::spawn_tool(&tool_path, args); if exit_code == 0 { - if let Ok(home_dir) = vp_shared::get_vp_home() { - let node_dir = - home_dir.join("js_runtime").join("node").join(&*resolution.version); - let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); - check_npm_global_install_result( - &parsed.packages, - original_path.as_deref(), - &npm_prefix, - &node_dir, - &resolution.version, - ); - } + let node_dir = VpDirs::js_runtime_dir().join("node").join(&*resolution.version); + let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); + check_npm_global_install_result( + &parsed.packages, + original_path.as_deref(), + &npm_prefix, + &node_dir, + &resolution.version, + ); } return exit_code; } if let Some(parsed) = parse_npm_global_uninstall(args) { // Collect bin names before uninstall (package.json will be gone after) - let context = if let Ok(home_dir) = vp_shared::get_vp_home() { - let node_dir = home_dir.join("js_runtime").join("node").join(&*resolution.version); + let (bins, npm_prefix) = { + let node_dir = VpDirs::js_runtime_dir().join("node").join(&*resolution.version); let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); let bins = collect_bin_names_from_npm(&parsed.packages, &npm_prefix, &node_dir); - Some((bins, npm_prefix)) - } else { - None + (bins, npm_prefix) }; let exit_code = exec::spawn_tool(&tool_path, args); if exit_code == 0 { - if let Some((bin_names, npm_prefix)) = context { - remove_npm_global_uninstall_links(&bin_names, &npm_prefix); - } + remove_npm_global_uninstall_links(&bins, &npm_prefix); } return exit_code; } @@ -1296,16 +1294,12 @@ async fn cached_project_source_still_current( /// Ensure Node.js is installed. pub(crate) async fn ensure_installed(version: &str) -> Result { - let home_dir = vp_shared::get_vp_home() - .map_err(|e| format!("Failed to get vite-plus home dir: {e}"))? - .join("js_runtime") - .join("node") - .join(version); + let version_dir = VpDirs::js_runtime_dir().join("node").join(version); #[cfg(windows)] - let binary_path = home_dir.join("node.exe"); + let binary_path = version_dir.join("node.exe"); #[cfg(not(windows))] - let binary_path = home_dir.join("bin").join("node"); + let binary_path = version_dir.join("bin").join("node"); // Check if already installed if binary_path.as_path().exists() { @@ -1325,22 +1319,18 @@ pub(crate) async fn ensure_installed(version: &str) -> Result Result { - let home_dir = vp_shared::get_vp_home() - .map_err(|e| format!("Failed to get vite-plus home dir: {e}"))? - .join("js_runtime") - .join("node") - .join(version); + let version_dir = VpDirs::js_runtime_dir().join("node").join(version); #[cfg(windows)] let tool_path = if tool == "node" { - home_dir.join("node.exe") + version_dir.join("node.exe") } else { // npm and npx are .cmd scripts on Windows - home_dir.join(format!("{tool}.cmd")) + version_dir.join(format!("{tool}.cmd")) }; #[cfg(not(windows))] - let tool_path = home_dir.join("bin").join(tool); + let tool_path = version_dir.join("bin").join(tool); if !tool_path.as_path().exists() { return Err(format!("Tool '{}' not found at {}", tool, tool_path.as_path().display())); @@ -1367,7 +1357,7 @@ pub(crate) fn find_system_tool(tool: &str) -> Option { /// `cwd` only resolves relative PATH entries; it is a parameter so tests can /// exercise them without mutating the process-wide working directory. fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option { - let bin_dir = config::get_bin_dir().ok(); + let bin_dir = VpDirs::bin_dir(); let path_var = std::env::var_os("PATH")?; tracing::debug!("path_var: {:?}", path_var); @@ -1384,10 +1374,8 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option = std::env::split_paths(&path_var) .filter(|p| { - if let Some(ref bin) = bin_dir { - if p == bin.as_path() { - return false; - } + if p == bin_dir.as_path() { + return false; } !bypass_paths.iter().any(|bp| p == bp) }) @@ -1395,7 +1383,7 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option - // Installation B also needs to filter install_b_bin (via get_bin_dir), - // but get_bin_dir returns the real vite-plus home. So we test by putting - // install_b_bin in the bypass as well (simulating cumulative append). + // Installation B also needs to filter install_b_bin (via VpDirs::bin_dir), + // but VpDirs::bin_dir resolves this process's own bin dir, not B's. So we + // test by putting install_b_bin in the bypass as well (simulating + // cumulative append). let bypass = std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap(); diff --git a/crates/vp_global_cli/src/shim/mod.rs b/crates/vp_global_cli/src/shim/mod.rs index c6f8a5a977..62bc5c2dda 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -19,9 +19,7 @@ use std::fs; pub(crate) use cache::invalidate_cache; pub use dispatch::dispatch; pub(crate) use dispatch::find_system_tool; -use vp_shared::env_vars; - -use crate::commands::env::config::get_bin_dir; +use vp_shared::{VpDirs, env_vars}; /// Core shim tools (node, npm, npx). /// @@ -48,20 +46,18 @@ pub fn extract_tool_name(argv0: &str) -> String { if cfg!(target_os = "linux") { stem } else { - let bin_dir = get_bin_dir(); - if let Ok(bin_dir) = bin_dir { - if let Ok(read_dir) = fs::read_dir(&bin_dir) { - for bin in read_dir.flatten() { - if bin.path().file_stem().unwrap_or_default().to_string_lossy().to_lowercase() - == stem.to_lowercase() - { - return bin - .path() - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - } + let bin_dir = VpDirs::bin_dir(); + if let Ok(read_dir) = fs::read_dir(&bin_dir) { + for bin in read_dir.flatten() { + if bin.path().file_stem().unwrap_or_default().to_string_lossy().to_lowercase() + == stem.to_lowercase() + { + return bin + .path() + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); } } } @@ -106,12 +102,8 @@ pub fn is_shim_tool(tool: &str) -> bool { /// because when running through a wrapper script (e.g., current/bin/vp), the current_exe() /// returns the wrapper's location, not the original shim's location. fn is_potential_package_binary(tool: &str) -> bool { - use crate::commands::env::config; - - // Get the configured bin directory (respects VP_HOME env var) - let Ok(configured_bin) = config::get_bin_dir() else { - return false; - }; + // Get the configured bin directory + let configured_bin = VpDirs::bin_dir(); // Check if the shim exists in the configured bin directory. // Use symlink_metadata to detect symlinks (even broken ones). @@ -241,12 +233,11 @@ mod tests { /// Test that is_potential_package_binary checks the configured bin directory. /// /// The function now checks if a shim exists in the configured bin directory - /// (from VP_HOME/bin) instead of relying on current_exe(). + /// (`VpDirs::bin_dir()`) instead of relying on current_exe(). /// This allows it to work correctly with wrapper scripts. #[test] fn test_is_potential_package_binary_checks_configured_bin() { - // The function checks config::get_bin_dir() which respects VP_HOME. - // Without setting VP_HOME, it defaults to ~/.vite-plus/bin. + // The function checks VpDirs::bin_dir(). // // Since we can't easily create test shims in the actual bin directory, // we just verify the function doesn't panic and returns false for diff --git a/crates/vp_global_cli/src/upgrade_check.rs b/crates/vp_global_cli/src/upgrade_check.rs index 6cd8826d18..be957457d3 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -1,7 +1,7 @@ //! Background upgrade check for the vp CLI. //! //! Periodically queries the npm registry for the latest version and caches the -//! result to `~/.vite-plus/.upgrade-check.json`. Displays a one-line notice on +//! result to `/.upgrade-check.json`. Displays a one-line notice on //! stderr when a newer version is available, at most once per 24 hours. use std::time::{SystemTime, UNIX_EPOCH}; @@ -9,9 +9,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; use vp_setup::registry; +use vp_shared::VpDirs; const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; const PROMPT_INTERVAL_SECS: u64 = 24 * 60 * 60; +/// File name under [`VpDirs::state_dir`]. const CACHE_FILE_NAME: &str = ".upgrade-check.json"; #[expect(clippy::disallowed_types)] // String required for serde JSON round-trip @@ -22,15 +24,20 @@ struct UpgradeCheckCache { prompted_at: u64, } -fn read_cache(install_dir: &vt_path::AbsolutePath) -> Option { - let cache_path = install_dir.join(CACHE_FILE_NAME); +fn read_cache(cache_path: &vt_path::AbsolutePath) -> Option { let data = std::fs::read_to_string(cache_path.as_path()).ok()?; serde_json::from_str(&data).ok() } -fn write_cache(install_dir: &vt_path::AbsolutePath, cache: &UpgradeCheckCache) { - let cache_path = install_dir.join(CACHE_FILE_NAME); +fn write_cache(cache_path: &vt_path::AbsolutePath, cache: &UpgradeCheckCache) { if let Ok(data) = serde_json::to_string(cache) { + // Under the split layout the state dir may not exist yet (nothing + // else creates it on a fresh install), so create it first; without + // this the backoff cache never persists and every invocation + // re-queries the registry. + if let Some(parent) = cache_path.parent() { + let _ = std::fs::create_dir_all(parent.as_path()); + } let _ = std::fs::write(cache_path.as_path(), &data); } } @@ -72,17 +79,17 @@ async fn resolve_version_string() -> Option { } pub struct UpgradeCheckResult { - install_dir: vt_path::AbsolutePathBuf, + cache_path: vt_path::AbsolutePathBuf, cache: UpgradeCheckCache, } /// Returns an upgrade check result if a newer version is available and the user /// hasn't been prompted within the last 24 hours. Returns `None` otherwise. pub async fn check_for_update() -> Option { - let install_dir = vp_shared::get_vp_home().ok()?; + let cache_path = VpDirs::state_dir().join(CACHE_FILE_NAME); let current_version = env!("CARGO_PKG_VERSION"); let now = now_secs(); - let mut cache = read_cache(&install_dir); + let mut cache = read_cache(&cache_path); if should_check(cache.as_ref(), now) { let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); @@ -90,7 +97,7 @@ pub async fn check_for_update() -> Option { match resolve_version_string().await { Some(latest) => { let new_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &new_cache); + write_cache(&cache_path, &new_cache); cache = Some(new_cache); } None => { @@ -98,7 +105,7 @@ pub async fn check_for_update() -> Option { // retrying on every command when the registry is unreachable. let latest = cache.as_ref().map(|c| c.latest.clone()).unwrap_or_default(); let failed_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &failed_cache); + write_cache(&cache_path, &failed_cache); cache = Some(failed_cache); } } @@ -114,7 +121,7 @@ pub async fn check_for_update() -> Option { return None; } - Some(UpgradeCheckResult { install_dir, cache }) + Some(UpgradeCheckResult { cache_path, cache }) } /// Print a one-line upgrade notice to stderr and record the prompt time. @@ -133,7 +140,7 @@ pub fn display_upgrade_notice(result: &UpgradeCheckResult) { let mut cache = result.cache.clone(); cache.prompted_at = now_secs(); - write_cache(&result.install_dir, &cache); + write_cache(&result.cache_path, &cache); } /// Whether the upgrade check should run for the given command args. @@ -170,12 +177,13 @@ mod tests { fn cache_round_trip() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + let cache_file = dir_path.join(CACHE_FILE_NAME); let cache = UpgradeCheckCache { latest: "1.2.3".to_owned(), checked_at: 1000, prompted_at: 900 }; - write_cache(&dir_path, &cache); + write_cache(&cache_file, &cache); - let loaded = read_cache(&dir_path).expect("should read back cache"); + let loaded = read_cache(&cache_file).expect("should read back cache"); assert_eq!(loaded.latest, "1.2.3"); assert_eq!(loaded.checked_at, 1000); assert_eq!(loaded.prompted_at, 900); @@ -185,15 +193,16 @@ mod tests { fn read_cache_returns_none_for_missing_file() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - assert!(read_cache(&dir_path).is_none()); + assert!(read_cache(&dir_path.join(CACHE_FILE_NAME)).is_none()); } #[test] fn read_cache_returns_none_for_corrupt_file() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - std::fs::write(dir_path.join(CACHE_FILE_NAME).as_path(), "not json").unwrap(); - assert!(read_cache(&dir_path).is_none()); + let cache_file = dir_path.join(CACHE_FILE_NAME); + std::fs::write(cache_file.as_path(), "not json").unwrap(); + assert!(read_cache(&cache_file).is_none()); } fn with_env_vars_cleared(f: F) { diff --git a/crates/vp_installer/src/cli.rs b/crates/vp_installer/src/cli.rs index 61f7e343f7..c3fef12aab 100644 --- a/crates/vp_installer/src/cli.rs +++ b/crates/vp_installer/src/cli.rs @@ -22,7 +22,10 @@ pub struct Options { #[arg(long = "tag", default_value = "latest")] pub tag: String, - /// Custom installation directory (default: ~/.vite-plus) + /// Custom installation directory: selects the legacy monolithic layout + /// rooted at this directory (default: split platform layout, or the + /// legacy root when `~/.vite-plus` already exists). Equivalent to setting + /// the deprecated `VP_HOME` override for this process. #[arg(long = "install-dir")] pub install_dir: Option, @@ -49,7 +52,10 @@ pub fn parse() -> Options { opts.version = std::env::var("VP_VERSION").ok(); } if opts.install_dir.is_none() { - opts.install_dir = std::env::var("VP_HOME").ok(); + // Installer-only: honor deprecated `VP_HOME` as a custom legacy root + // (same as install.sh). Prefer `VP_*_DIR` / XDG for split overrides; + // those are read by VpDirs without going through this flag. + opts.install_dir = std::env::var(vp_shared::env_vars::DEPRECATED_VP_HOME).ok(); } if opts.registry.is_none() { opts.registry = std::env::var("NPM_CONFIG_REGISTRY").ok(); diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 28ce48bc35..670a51ccf8 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -28,6 +28,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; use vp_pm_cli::HttpClient; use vp_setup::{VP_BINARY_NAME, install, integrity, platform, registry}; +use vp_shared::VpDirs; use vt_path::AbsolutePathBuf; /// Restrict DLL search to system32 only to prevent DLL hijacking @@ -105,48 +106,61 @@ fn main() { let opts = cli::parse(); - // Resolve install dir and set VP_HOME before starting the tokio runtime, - // so the unsafe set_var runs while we're still single-threaded. - let install_dir = match resolve_install_dir(&opts) { - Ok(dir) => dir, - Err(e) => { - print_error(&format!("Failed to resolve install directory: {e}")); - std::process::exit(1); - } - }; - // Safety: called in main() before any threads are spawned. - unsafe { std::env::set_var("VP_HOME", install_dir.as_path()) }; + // Apply --install-dir before any VpDirs call so resolution matches the + // rest of the toolchain (same chain as install.sh / the vp CLI). + if let Some(ref dir) = opts.install_dir + && let Err(e) = apply_install_dir_override(dir) + { + print_error(&format!("Failed to resolve install directory: {e}")); + std::process::exit(1); + } let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap_or_else(|e| { print_error(&format!("Failed to create async runtime: {e}")); std::process::exit(1); }); - let code = rt.block_on(run(opts, install_dir)); + let code = rt.block_on(run(opts)); std::process::exit(code); } -#[allow(clippy::print_stdout, clippy::print_stderr)] -async fn run(mut opts: cli::Options, install_dir: AbsolutePathBuf) -> i32 { - let install_dir_display = install_dir.as_path().to_string_lossy().to_string(); +/// Pin layout for `--install-dir` / installer `VP_HOME` overrides. +/// +/// Custom roots use the legacy monolithic mapping. `VP_HOME` is the only env +/// that selects that full mapping (bin/data/cache/config/state under one root); +/// it is set only for this explicit override path, not for auto-detected +/// grandfathered installs. +fn apply_install_dir_override(dir: &str) -> Result<(), Box> { + let path = std::path::PathBuf::from(dir); + let abs = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; + let install_dir = AbsolutePathBuf::new(abs).ok_or("Invalid installation directory")?; + // Safety: called in main() before any threads are spawned. + unsafe { + std::env::set_var(vp_shared::env_vars::DEPRECATED_VP_HOME, install_dir.as_path()); + } + Ok(()) +} +#[allow(clippy::print_stdout, clippy::print_stderr)] +async fn run(mut opts: cli::Options) -> i32 { // Pre-compute Node.js manager default before showing the menu, // so the user sees the resolved value and can override it. if !opts.no_node_manager { - opts.no_node_manager = !auto_detect_node_manager(&install_dir, !opts.yes); + let bin_dir = VpDirs::bin_dir(); + opts.no_node_manager = !auto_detect_node_manager(&bin_dir, !opts.yes); } if !opts.yes { - let proceed = show_interactive_menu(&mut opts, &install_dir_display); + let proceed = show_interactive_menu(&mut opts); if !proceed { println!("Installation cancelled."); return 0; } } - let code = match do_install(&opts, &install_dir).await { + let code = match do_install(&opts).await { Ok(()) => { - print_success(&opts, &install_dir_display); + print_success(&opts); 0 } Err(e) => { @@ -165,18 +179,17 @@ async fn run(mut opts: cli::Options, install_dir: AbsolutePathBuf) -> i32 { } #[allow(clippy::print_stdout)] -async fn do_install( - opts: &cli::Options, - install_dir: &AbsolutePathBuf, -) -> Result<(), Box> { +async fn do_install(opts: &cli::Options) -> Result<(), Box> { + // Data dir holds CLI versions + `current` (legacy root, or split data dir). + let install_dir = VpDirs::data_dir(); let platform_suffix = platform::detect_platform_suffix()?; if !opts.quiet { print_info(&format!("detected platform: {platform_suffix}")); } // Check local version first to potentially skip HTTP requests - tokio::fs::create_dir_all(install_dir).await?; - let current_version = install::read_current_version(install_dir).await; + tokio::fs::create_dir_all(&install_dir).await?; + let current_version = install::read_current_version(&install_dir).await; let version_or_tag = opts.version.as_deref().unwrap_or(&opts.tag); @@ -237,7 +250,7 @@ async fn do_install( opts, &platform_data, &version_dir, - install_dir, + &install_dir, &target_version, current_version.is_some(), ) @@ -257,7 +270,7 @@ async fn do_install( if !opts.quiet { print_info("setting up shims..."); } - if let Err(e) = setup_bin_shims(install_dir).await { + if let Err(e) = setup_bin_shims(&install_dir).await { print_warn(&format!("Shim setup failed (non-fatal): {e}")); } @@ -265,15 +278,15 @@ async fn do_install( if !opts.quiet { print_info("setting up Node.js version manager..."); } - if let Err(e) = install::refresh_shims(install_dir).await { + if let Err(e) = install::refresh_shims(&install_dir).await { print_warn(&format!("Node.js manager setup failed (non-fatal): {e}")); } - } else if let Err(e) = install::create_env_files(install_dir).await { + } else if let Err(e) = install::create_env_files(&install_dir).await { print_warn(&format!("Env file creation failed (non-fatal): {e}")); } if !opts.no_modify_path { - let bin_dir_str = install_dir.join("bin").as_path().to_string_lossy().to_string(); + let bin_dir_str = VpDirs::bin_dir().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}")); } @@ -289,13 +302,13 @@ async fn do_install( /// /// Matches install.ps1/install.sh auto-detect logic: /// 1. VP_NODE_MANAGER=yes → enable; VP_NODE_MANAGER=no → disable -/// 2. Already managing Node (bin/node.exe exists) → enable (refresh) +/// 2. Already managing Node (`node` shim exists in the bin dir) → enable (refresh) /// 3. CI / Codespaces / DevContainer / DevPod → enable /// 4. No system `node` found → enable /// 5. System node present, interactive → enable (matching install.ps1's default-Y prompt; /// user can disable via customize menu before proceeding) /// 6. System node present, silent → disable (don't silently take over) -fn auto_detect_node_manager(install_dir: &vt_path::AbsolutePath, interactive: bool) -> bool { +fn auto_detect_node_manager(bin_dir: &vt_path::AbsolutePath, interactive: bool) -> bool { // VP_NODE_MANAGER env var: only "yes" and "no" are recognized; // unrecognized values fall through to normal auto-detection // (matching install.ps1/install.sh behavior). @@ -309,7 +322,7 @@ fn auto_detect_node_manager(install_dir: &vt_path::AbsolutePath, interactive: bo } // Already managing Node (shims exist from a previous install) - let node_shim = install_dir.join("bin").join(if cfg!(windows) { "node.exe" } else { "node" }); + let node_shim = bin_dir.join(if cfg!(windows) { "node.exe" } else { "node" }); if node_shim.as_path().exists() { return true; } @@ -399,11 +412,13 @@ async fn replace_windows_exe( Ok(()) } -/// Set up the `bin/vp` entry point (trampoline copy on Windows, symlink on Unix). -async fn setup_bin_shims( - install_dir: &vt_path::AbsolutePath, -) -> Result<(), Box> { - let bin_dir = install_dir.join("bin"); +/// Set up the `vp` entry point in the bin dir (trampoline copy on Windows, +/// symlink on Unix). +/// +/// Bin path comes from [`VpDirs::bin_dir`]: `/bin` under the legacy +/// layout, the separate split-layout bin dir (e.g. `~/.local/bin`) otherwise. +async fn setup_bin_shims(install_dir: &AbsolutePathBuf) -> Result<(), Box> { + let bin_dir = VpDirs::bin_dir(); tokio::fs::create_dir_all(&bin_dir).await?; #[cfg(windows)] @@ -434,7 +449,15 @@ async fn setup_bin_shims( #[cfg(unix)] { - let link_target = std::path::PathBuf::from("../current/bin/vp"); + // Legacy layout (bin dir is the data-local `bin`): keep the relative + // `../current/bin/vp` target. Split layout: the bin dir lives outside + // the data dir, so link absolutely. + let link_target = + if bin_dir.as_path().parent().is_some_and(|parent| parent == install_dir.as_path()) { + std::path::PathBuf::from("../current/bin/vp") + } else { + install_dir.join("current").join("bin").join("vp").as_path().to_path_buf() + }; let link_path = bin_dir.join("vp"); let _ = tokio::fs::remove_file(&link_path).await; tokio::fs::symlink(&link_target, &link_path).await?; @@ -466,16 +489,6 @@ async fn download_with_progress( Ok(data) } -fn resolve_install_dir(opts: &cli::Options) -> Result> { - if let Some(ref dir) = opts.install_dir { - let path = std::path::PathBuf::from(dir); - let abs = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; - AbsolutePathBuf::new(abs).ok_or_else(|| "Invalid installation directory".into()) - } else { - Ok(vp_shared::get_vp_home()?) - } -} - #[allow(clippy::print_stdout)] fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box> { #[cfg(windows)] @@ -497,10 +510,11 @@ fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box bool { +fn show_interactive_menu(opts: &mut cli::Options) -> bool { loop { let version = opts.version.as_deref().unwrap_or(&opts.tag); - let bin_dir = format!("{install_dir}{sep}bin", sep = std::path::MAIN_SEPARATOR); + let install_dir = VpDirs::data_dir().as_path().to_string_lossy().to_string(); + let bin_dir = VpDirs::bin_dir().as_path().to_string_lossy().to_string(); println!(); println!(" {}", "Welcome to Vite+ Installer!".bold()); @@ -594,11 +608,12 @@ fn read_input(prompt: &str) -> String { } #[allow(clippy::print_stdout)] -fn print_success(opts: &cli::Options, install_dir: &str) { +fn print_success(opts: &cli::Options) { if opts.quiet { return; } + let env_script = VpDirs::config_dir().join("env"); println!(); println!(" {} Vite+ has been installed successfully!", "\u{2714}".green().bold()); println!(); @@ -606,7 +621,9 @@ fn print_success(opts: &cli::Options, install_dir: &str) { println!(); println!(" {}", "vp --help".cyan()); println!(); - println!(" Install directory: {install_dir}"); + println!(" Install directory: {}", VpDirs::data_dir().as_path().display()); + println!(" Bin directory: {}", VpDirs::bin_dir().as_path().display()); + println!(" Shell setup: . \"{}\"", env_script.as_path().display()); println!(" Documentation: {}", "https://viteplus.dev/guide/"); println!(); } diff --git a/crates/vp_js_runtime/src/cache.rs b/crates/vp_js_runtime/src/cache.rs deleted file mode 100644 index 9308a83d1e..0000000000 --- a/crates/vp_js_runtime/src/cache.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Cache directory utilities for JavaScript runtimes. - -use vt_path::AbsolutePathBuf; - -use crate::Error; - -/// Get the cache directory for JavaScript runtimes. -/// -/// Returns `$VP_HOME/js_runtime`. -pub fn get_cache_dir() -> Result { - Ok(vp_shared::get_vp_home()?.join("js_runtime")) -} diff --git a/crates/vp_js_runtime/src/lib.rs b/crates/vp_js_runtime/src/lib.rs index 56a6e03189..efe136ff2e 100644 --- a/crates/vp_js_runtime/src/lib.rs +++ b/crates/vp_js_runtime/src/lib.rs @@ -43,7 +43,6 @@ clippy::print_stdout )] -mod cache; mod dev_engines; mod download; mod error; diff --git a/crates/vp_js_runtime/src/providers/node.rs b/crates/vp_js_runtime/src/providers/node.rs index 73b23daaeb..ae1fb91929 100644 --- a/crates/vp_js_runtime/src/providers/node.rs +++ b/crates/vp_js_runtime/src/providers/node.rs @@ -5,6 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use node_semver::{Range, Version}; use serde::{Deserialize, Serialize}; +use vp_shared::VpDirs; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_str::Str; @@ -31,6 +32,9 @@ const DEFAULT_NODE_DIST_URL: &str = "https://unofficial-builds.nodejs.org/downlo /// Default cache TTL in seconds (1 hour) const DEFAULT_CACHE_TTL_SECS: u64 = 3600; +/// Version-index cache file under `/node/`. +const INDEX_CACHE_FILE: &str = "index_cache.json"; + /// A single entry from the Node.js version index #[derive(Deserialize, Serialize, Debug, Clone)] pub struct NodeVersionEntry { @@ -102,7 +106,8 @@ impl NodeProvider { /// /// # Arguments /// * `version_req` - A semver range requirement (e.g., "^20.18.0") - /// * `cache_dir` - The cache directory path (e.g., `~/.cache/vite-plus/js_runtime`) + /// * `cache_dir` - The managed runtime install dir (i.e. `VpDirs::js_runtime_dir()`, + /// `/js_runtime` — `~/.vite-plus/js_runtime` under the legacy layout) /// /// # Returns /// The highest LTS cached version that satisfies the requirement, or the @@ -186,8 +191,7 @@ impl NodeProvider { /// /// Returns an error only if the download fails and no local cache exists. pub async fn fetch_version_index(&self) -> Result, Error> { - let cache_dir = crate::cache::get_cache_dir()?; - let cache_path = cache_dir.join("node/index_cache.json"); + let cache_path = VpDirs::js_runtime_dir().join("node").join(INDEX_CACHE_FILE); // Try to load from cache let Some(cache) = load_cache(&cache_path).await else { diff --git a/crates/vp_js_runtime/src/runtime.rs b/crates/vp_js_runtime/src/runtime.rs index da4a7bb387..f46d2f1b19 100644 --- a/crates/vp_js_runtime/src/runtime.rs +++ b/crates/vp_js_runtime/src/runtime.rs @@ -3,6 +3,7 @@ use std::time::Duration; use backon::{ExponentialBuilder, Retryable}; use node_semver::{Range, Version}; use tempfile::TempDir; +use vp_shared::VpDirs; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_str::Str; @@ -183,13 +184,13 @@ pub async fn download_runtime_with_provider( version: &str, ) -> Result { let platform = Platform::current(); - let cache_dir = crate::cache::get_cache_dir()?; + let cache_dir = VpDirs::js_runtime_dir(); // Get paths from provider let binary_relative_path = provider.binary_relative_path(platform); let bin_dir_relative_path = provider.bin_dir_relative_path(platform); - // Cache path: $CACHE_DIR/vite-plus/js_runtime/{runtime}/{version}/ + // Install path: /{runtime}/{version}/ let install_dir = cache_dir.join(provider.name()).join(version); // Check if already cached @@ -456,7 +457,7 @@ pub async fn resolve_node_version( /// Currently only supports Node.js runtime. pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result { let provider = NodeProvider::new(); - let cache_dir = crate::cache::get_cache_dir()?; + let cache_dir = VpDirs::js_runtime_dir(); // Resolve version from the project directory, walking up to inherit from ancestors let resolution = resolve_node_version(project_path, true).await?; @@ -1041,7 +1042,7 @@ mod tests { let version = "20.17.0"; // Clear any existing cache for this version - let cache_dir = crate::cache::get_cache_dir().unwrap(); + let cache_dir = VpDirs::js_runtime_dir(); let install_dir = cache_dir.join("node").join(version); if tokio::fs::try_exists(&install_dir).await.unwrap_or(false) { tokio::fs::remove_dir_all(&install_dir).await.unwrap(); diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 8460fa18b9..9016fbc7cc 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -19,7 +19,7 @@ use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; use tokio::fs::remove_dir_all; use vp_error::Error; -use vp_shared::OnFail; +use vp_shared::{OnFail, VpDirs}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_str::Str; #[cfg(test)] @@ -376,9 +376,9 @@ pub fn package_manager_install_dir( package_manager_type: PackageManagerType, version: &str, ) -> Option { - let home_dir = vp_shared::get_vp_home().ok()?; + let package_manager_dir = VpDirs::package_manager_dir(); let bin_name = package_manager_type.to_string(); - Some(home_dir.join("package_manager").join(&bin_name).join(version).join(&bin_name)) + Some(package_manager_dir.join(&bin_name).join(version).join(&bin_name)) } /// Return the executable shim path for a package manager binary inside an install directory. @@ -739,9 +739,8 @@ fn find_cached_package_manager_version( package_manager_type: PackageManagerType, range: &node_semver::Range, ) -> Result, Error> { - let home_dir = vp_shared::get_vp_home()?; let bin_name = package_manager_type.to_string(); - let versions_dir = home_dir.join("package_manager").join(&bin_name); + let versions_dir = VpDirs::package_manager_dir().join(&bin_name); let entries = match fs::read_dir(&versions_dir) { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -844,7 +843,7 @@ pub async fn download_package_manager( package_name = "@yarnpkg/cli-dist".into(); } - let home_dir = vp_shared::get_vp_home()?; + let package_manager_dir = VpDirs::package_manager_dir(); let bin_name = package_manager_type.to_string(); // For bun, use platform-specific download flow. @@ -852,7 +851,7 @@ pub async fn download_package_manager( // not the platform-specific binary, so we don't pass it through; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Bun) { - return download_bun_package_manager(&version, &home_dir).await; + return download_bun_package_manager(&version, &package_manager_dir).await; } // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package @@ -860,12 +859,13 @@ pub async fn download_package_manager( // A declared hash names the main tarball and is verified against it; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { - return download_pnpm_native_package_manager(&version, &home_dir, expected_hash).await; + return download_pnpm_native_package_manager(&version, &package_manager_dir, expected_hash) + .await; } let tgz_url = get_npm_package_tgz_url(&package_name, &version); - // $VP_HOME/package_manager/pnpm/10.0.0 - let target_dir = home_dir.join("package_manager").join(&bin_name).join(&version); + // /pnpm/10.0.0 + let target_dir = package_manager_dir.join(&bin_name).join(&version); let install_dir = target_dir.join(&bin_name); // If all shims already exist, return the target directory @@ -990,12 +990,12 @@ fn bun_requires_baseline() -> bool { /// Layout: `$VP_HOME/package_manager/bun/{version}/bun/bin/bun.native` async fn download_bun_package_manager( version: &Str, - home_dir: &AbsolutePath, + package_manager_dir: &AbsolutePath, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "bun".into(); - // $VP_HOME/package_manager/bun/{version} - let target_dir = home_dir.join("package_manager").join("bun").join(version.as_str()); + // /bun/{version} + let target_dir = package_manager_dir.join("bun").join(version.as_str()); let install_dir = target_dir.join("bun"); // If shims already exist, return early (same completeness check as the cache @@ -1169,14 +1169,14 @@ async fn fetch_platform_integrity( /// Layout: `$VP_HOME/package_manager/pnpm/{version}/pnpm/bin/pnpm.native` async fn download_pnpm_native_package_manager( version: &Str, - home_dir: &AbsolutePath, + package_manager_dir: &AbsolutePath, expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "pnpm".into(); let platform_package_name = get_pnpm_platform_package_name()?; - // $VP_HOME/package_manager/pnpm/{version} - let target_dir = home_dir.join("package_manager").join("pnpm").join(version.as_str()); + // /pnpm/{version} + let target_dir = package_manager_dir.join("pnpm").join(version.as_str()); let install_dir = target_dir.join("pnpm"); // If shims already exist, return early (same completeness check as the cache @@ -1742,8 +1742,12 @@ mod tests { Complete, } - /// Create a fake managed package manager install under + /// Create a fake managed package manager install under the data root: /// `/package_manager////bin/`. + /// + /// Callers pair this with `EnvConfig::for_test_with_home(vp_home)`, which + /// pins `VP_HOME` to `vp_home` so `VpDirs::package_manager_dir()` is + /// `/package_manager`. fn write_pm_install(vp_home: &AbsolutePath, name: &str, version: &str, state: InstallState) { let bin_dir = vp_home.join("package_manager").join(name).join(version).join(name).join("bin"); @@ -3823,17 +3827,21 @@ mod tests { .body("this is not a valid gzip archive"); }); + // The on-disk `.vite-plus` under the overridden user home selects + // the legacy layout, so package managers install under + // `/.vite-plus/package_manager/`. + let legacy_root = vp_home.path().join(".vite-plus"); + std::fs::create_dir_all(&legacy_root).unwrap(); let _guard = EnvConfig::test_guard(EnvConfig { npm_registry: server.base_url().into(), - vite_plus_home: Some(vp_home.path().to_path_buf()), - ..EnvConfig::for_test() + ..EnvConfig::for_test_with_home(vp_home.path().to_path_buf()) }); let result = download_package_manager(PackageManagerType::Pnpm, "10.0.0", None).await; assert!(result.is_err(), "corrupt tarball should fail the install, got {result:?}"); // The per-install temp dir must be gone after the failure. - let pnpm_dir = vp_home.path().join("package_manager").join("pnpm"); + let pnpm_dir = legacy_root.join("package_manager").join("pnpm"); let leftovers: Vec<_> = fs::read_dir(&pnpm_dir) .map(|rd| { rd.filter_map(Result::ok) diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index af5aa48aa8..1637b9d802 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -217,8 +217,10 @@ fn format_install_failure_message( /// Write stdout and stderr from a failed install to `upgrade.log`. /// -/// The log is written to the **parent** of `version_dir` (i.e. `~/.vite-plus/upgrade.log`) -/// so it survives the cleanup that removes `version_dir` on failure. +/// The log is written to the **parent** of `version_dir` (i.e. the data dir's +/// `upgrade.log` — `/upgrade.log`, `~/.vite-plus/upgrade.log` under the +/// legacy layout) so it survives the cleanup that removes `version_dir` on +/// failure. /// /// Returns the log file path on success, or `None` if writing failed. pub async fn write_upgrade_log( diff --git a/crates/vp_shared/Cargo.toml b/crates/vp_shared/Cargo.toml index 7a566fc0c7..20653e54cd 100644 --- a/crates/vp_shared/Cargo.toml +++ b/crates/vp_shared/Cargo.toml @@ -32,6 +32,8 @@ webpki-root-certs = { workspace = true } [dev-dependencies] serial_test = { workspace = true } +temp-env = { workspace = true } +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/vp_shared/src/dirs.rs b/crates/vp_shared/src/dirs.rs new file mode 100644 index 0000000000..cfd5ec1c61 --- /dev/null +++ b/crates/vp_shared/src/dirs.rs @@ -0,0 +1,202 @@ +//! On-disk path helpers for vite-plus. +//! +//! [`VpDirs`] owns only: +//! - **category roots** (`bin`, `data`, `cache`, `config`, `state`) from the +//! strategy chain in [`resolution`]; +//! - **first-level directories** under `data` (`current`, `js_runtime`, +//! `package_manager`, `packages`, `bins`). +//! +//! Files and deeper trees (e.g. `config.json`, `js_runtime/node/`) are +//! joined by the owning feature — not here. +//! +//! Resolution is recomputed on every call — cheap path joins plus at most a +//! few existence checks — so process env changes (and test `temp_env` +//! overrides) are observed without a separate cache. + +mod resolution; + +use vt_path::AbsolutePathBuf; + +/// Platform-specific binary name for the `vp` CLI. +pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; + +/// Directory name of the legacy monolithic install root (`~/.vite-plus`). +const LEGACY_HOME_DIR_NAME: &str = ".vite-plus"; + +/// Namespace for category roots and their first-level data subdirectories. +/// +/// # Panics +/// +/// Every accessor panics when no directory can be resolved at all — i.e. no +/// `VP_HOME`/`VP_*_DIR`/XDG override applies and no user home is resolvable +/// (`HOME`/`USERPROFILE` unset and the system base-dirs query failing). This +/// is treated as a process-level invariant: a CLI without a home directory +/// cannot function. +pub struct VpDirs; + +impl VpDirs { + // ── Category roots ──────────────────────────────────────────────────── + + /// Directory for executables and shims. + /// + /// Legacy: `/bin`. Split: `~/.local/bin` (or `VP_BIN_DIR` / XDG). + #[must_use] + pub fn bin_dir() -> AbsolutePathBuf { + resolution::bin_dir().expect("bin directory could not be resolved") + } + + /// Directory for payload data (CLI versions, runtimes, package managers). + /// + /// Legacy: ``. Split: `~/.local/share/vite-plus`. + #[must_use] + pub fn data_dir() -> AbsolutePathBuf { + resolution::data_dir().expect("data directory could not be resolved") + } + + /// Directory for disposable caches. + /// + /// Legacy: `/cache`. Split: `~/.cache/vite-plus`. + #[must_use] + pub fn cache_dir() -> AbsolutePathBuf { + resolution::cache_dir().expect("cache directory could not be resolved") + } + + /// Directory for user configuration (env scripts, `config.json`, …). + /// + /// Legacy: ``. Split: `~/.config/vite-plus`. + #[must_use] + pub fn config_dir() -> AbsolutePathBuf { + resolution::config_dir().expect("config directory could not be resolved") + } + + /// Directory for state files (session version, upgrade-check cache, …). + /// + /// Legacy: ``. Split: `~/.local/state/vite-plus`. + #[must_use] + pub fn state_dir() -> AbsolutePathBuf { + resolution::state_dir().expect("state directory could not be resolved") + } + + // ── First-level under `data_dir` ────────────────────────────────────── + + /// `current` symlink pointing at the active CLI version. + #[must_use] + pub fn current_dir() -> AbsolutePathBuf { + Self::data_dir().join("current") + } + + /// Managed JavaScript runtimes. + #[must_use] + pub fn js_runtime_dir() -> AbsolutePathBuf { + Self::data_dir().join("js_runtime") + } + + /// Managed package managers. + #[must_use] + pub fn package_manager_dir() -> AbsolutePathBuf { + Self::data_dir().join("package_manager") + } + + /// Globally installed packages. + #[must_use] + pub fn packages_dir() -> AbsolutePathBuf { + Self::data_dir().join("packages") + } + + /// Per-binary metadata for globally installed packages. + #[must_use] + pub fn bins_dir() -> AbsolutePathBuf { + Self::data_dir().join("bins") + } + + // ── Layout query ────────────────────────────────────────────────────── + + /// Whether the resolved layout is the legacy monolithic root. + /// + /// True when `data_dir` is a path named `.vite-plus` and `bin_dir` is + /// that root's `bin` child (the legacy on-disk mapping). + #[must_use] + pub fn is_legacy_layout() -> bool { + let data = Self::data_dir(); + data.as_path().file_name().is_some_and(|name| name == LEGACY_HOME_DIR_NAME) + && Self::bin_dir().as_path() == data.join("bin").as_path() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::env_vars; + + #[test] + #[serial_test::serial(vp_dirs_layout)] + fn is_legacy_layout_when_home_dot_vite_plus_exists() { + let home = tempfile::tempdir().unwrap(); + let legacy = home.path().join(LEGACY_HOME_DIR_NAME); + std::fs::create_dir_all(&legacy).unwrap(); + + temp_env::with_vars( + [ + ("HOME", Some(home.path().as_os_str())), + (env_vars::DEPRECATED_VP_HOME, None), + (env_vars::VP_BIN_DIR, None), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, None), + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, None), + (env_vars::XDG_CACHE_HOME, None), + (env_vars::XDG_CONFIG_HOME, None), + (env_vars::XDG_STATE_HOME, None), + ], + || { + assert!(VpDirs::is_legacy_layout()); + assert_eq!(VpDirs::data_dir().as_path(), legacy.as_path()); + assert_eq!(VpDirs::bin_dir().as_path(), legacy.join("bin").as_path()); + assert_eq!(VpDirs::cache_dir().as_path(), legacy.join("cache").as_path()); + assert_eq!(VpDirs::config_dir().as_path(), legacy.as_path()); + }, + ); + } + + #[cfg(not(target_os = "windows"))] + #[test] + #[serial_test::serial(vp_dirs_layout)] + fn fresh_home_uses_split_platform_defaults() { + let home = tempfile::tempdir().unwrap(); + + temp_env::with_vars( + [ + ("HOME", Some(home.path().as_os_str())), + (env_vars::DEPRECATED_VP_HOME, None), + (env_vars::VP_BIN_DIR, None), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, None), + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, None), + (env_vars::XDG_CACHE_HOME, None), + (env_vars::XDG_CONFIG_HOME, None), + (env_vars::XDG_STATE_HOME, None), + ], + || { + assert!(!VpDirs::is_legacy_layout()); + assert_eq!(VpDirs::bin_dir().as_path(), home.path().join(".local/bin").as_path()); + assert_eq!( + VpDirs::data_dir().as_path(), + home.path().join(".local/share/vite-plus").as_path() + ); + assert_eq!( + VpDirs::cache_dir().as_path(), + home.path().join(".cache/vite-plus").as_path() + ); + assert_eq!( + VpDirs::config_dir().as_path(), + home.path().join(".config/vite-plus").as_path() + ); + assert_eq!( + VpDirs::state_dir().as_path(), + home.path().join(".local/state/vite-plus").as_path() + ); + }, + ); + } +} diff --git a/crates/vp_shared/src/dirs/resolution.rs b/crates/vp_shared/src/dirs/resolution.rs new file mode 100644 index 0000000000..f1c6027261 --- /dev/null +++ b/crates/vp_shared/src/dirs/resolution.rs @@ -0,0 +1,796 @@ +//! Strategy-gated directory resolution. +//! +//! Each category is resolved by walking an ordered chain of *resolution +//! sources*. A source either proposes a candidate (`Some`) or abstains +//! (`None`). When it proposes, its [`FallthroughStrategy`] decides whether +//! that candidate wins: +//! +//! - [`FallthroughStrategy::Exist`] — accept only when the source's +//! existence gate passes (legacy roots: grandfather only if the root is +//! already on disk). +//! - [`FallthroughStrategy::Set`] — accept as soon as the source proposes +//! (env overrides and platform defaults, including first install). +//! +//! Source chain on Unix: +//! [`VpHome`] → [`Home`] → [`CurrentDir`] → [`VpEnvs`] → [`unix::Xdg`] → +//! [`unix::Unix`] +//! (Windows omits XDG; platform tail is [`windows::Windows`]): +//! +//! - [`VpHome`] — deprecated `VP_HOME` override: pins the legacy monolithic +//! mapping under that root (`Set`). +//! - [`Home`] — `~/.vite-plus` when that directory exists (`Exist`). +//! - [`CurrentDir`] — `./.vite-plus` when present (`Exist`). +//! - [`VpEnvs`] — `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR` (`Set`). +//! - XDG / platform defaults (`Set`). +//! +//! Legacy monolithic mapping (VpHome / Home / CurrentDir): +//! `bin` → `/bin`, `data`/`config`/`state` → ``, +//! `cache` → `/cache`. + +use std::path::{Path, PathBuf}; + +use directories::BaseDirs; +use vt_path::AbsolutePathBuf; + +use crate::{EnvConfig, env_vars}; + +/// Subdirectory name appended to XDG base directories and platform defaults. +const APP_DIR_NAME: &str = "vite-plus"; + +/// Directory name of the legacy monolithic install root (`~/.vite-plus`). +const LEGACY_HOME_DIR_NAME: &str = ".vite-plus"; + +/// When a source proposes a candidate, how the chain decides to stop or continue. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FallthroughStrategy { + /// Accept only when [`DirResolution::exist_gate`] (if any) or the + /// candidate path itself exists on disk. + Exist, + /// Accept as soon as the source proposes (`Some`). + Set, +} + +/// One layer in a resolution chain. +trait DirResolution { + const FALLTHROUGH: FallthroughStrategy; + + /// Optional path used for the Exist gate. Legacy roots gate on the + /// install root itself so `bin`/`cache` subdirs are accepted even when + /// not yet created under an existing root. + fn exist_gate(&self) -> Option<&Path> { + None + } + + fn bin_dir(&self) -> Option; + fn data_dir(&self) -> Option; + fn cache_dir(&self) -> Option; + fn config_dir(&self) -> Option; + fn state_dir(&self) -> Option; +} + +/// Absolute path from process env, or `None` if unset / relative. +fn process_env_var(name: &str) -> Option { + std::env::var_os(name).and_then(|path| AbsolutePathBuf::new(PathBuf::from(path))) +} + +/// Absolute path from [`EnvConfig`] first, then process env (production only). +/// +/// Tests isolate layouts via `EnvConfig::test_guard` / `for_test_with_home`. +/// While a test scope is active, unset fields stay unset — they must not leak +/// the process `VP_HOME` / `VP_*_DIR` into the sandbox. +fn config_or_env_path(from_config: Option, env_name: &str) -> Option { + if let Some(path) = from_config.and_then(AbsolutePathBuf::new) { + return Some(path); + } + if EnvConfig::is_test_scoped() { + return None; + } + process_env_var(env_name) +} + +/// User home for legacy `~/.vite-plus` and platform defaults. +/// +/// Prefers `EnvConfig::user_home` (tests). Outside a test scope, also consults +/// process `HOME`/`USERPROFILE` and [`BaseDirs`]. +fn user_home_path() -> Option { + if let Some(home) = EnvConfig::get().user_home { + return Some(home); + } + if EnvConfig::is_test_scoped() { + return None; + } + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .or_else(|| BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())) +} + +/// Explicit per-category overrides from the `VP_*_DIR` environment variables. +struct VpEnvs { + bin_dir: Option, + data_dir: Option, + cache_dir: Option, +} + +impl VpEnvs { + fn resolver() -> Self { + let config = EnvConfig::get(); + Self { + bin_dir: config_or_env_path(config.vp_bin_dir, env_vars::VP_BIN_DIR), + data_dir: config_or_env_path(config.vp_data_dir, env_vars::VP_DATA_DIR), + cache_dir: config_or_env_path(config.vp_cache_dir, env_vars::VP_CACHE_DIR), + } + } +} + +impl DirResolution for VpEnvs { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + self.bin_dir.clone() + } + + fn data_dir(&self) -> Option { + self.data_dir.clone() + } + + fn cache_dir(&self) -> Option { + self.cache_dir.clone() + } + + fn config_dir(&self) -> Option { + None + } + + fn state_dir(&self) -> Option { + None + } +} + +/// Legacy monolithic root: maps categories to the on-disk legacy layout. +/// +/// | Category | Path | +/// |----------|-----------------| +/// | bin | `/bin` | +/// | data | `` | +/// | cache | `/cache` | +/// | config | `` | +/// | state | `` | +struct LegacyRoot { + root: Option, +} + +impl LegacyRoot { + fn from_path(path: Option) -> Self { + Self { root: path.and_then(AbsolutePathBuf::new) } + } + + fn from_absolute(path: Option) -> Self { + Self { root: path } + } +} + +impl DirResolution for LegacyRoot { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Exist; + + fn exist_gate(&self) -> Option<&Path> { + // Gate on the root so bin/cache subdirs are accepted under an existing install. + self.root.as_ref().map(|p| p.as_path()) + } + + fn bin_dir(&self) -> Option { + self.root.clone().map(|root| root.join("bin")) + } + + fn data_dir(&self) -> Option { + self.root.clone() + } + + fn cache_dir(&self) -> Option { + self.root.clone().map(|root| root.join("cache")) + } + + fn config_dir(&self) -> Option { + self.root.clone() + } + + fn state_dir(&self) -> Option { + self.root.clone() + } +} + +// FALLTHROUGH is associated const and cannot depend on `self.strategy`. +// VpHome uses Set via a dedicated type; Home/CurrentDir use Exist via LegacyRoot +// with accepts() reading the const Exist. For VpHome we need Set — use a wrapper. + +/// Deprecated `VP_HOME` override: always pins the legacy mapping when set. +struct VpHome; + +impl VpHome { + fn resolver() -> VpHomeRoot { + let config = EnvConfig::get(); + VpHomeRoot(config_or_env_path(config.vite_plus_home, env_vars::DEPRECATED_VP_HOME)) + } +} + +struct VpHomeRoot(Option); + +impl DirResolution for VpHomeRoot { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + self.0.clone().map(|root| root.join("bin")) + } + + fn data_dir(&self) -> Option { + self.0.clone() + } + + fn cache_dir(&self) -> Option { + self.0.clone().map(|root| root.join("cache")) + } + + fn config_dir(&self) -> Option { + self.0.clone() + } + + fn state_dir(&self) -> Option { + self.0.clone() + } +} + +/// The legacy monolithic root, `~/.vite-plus`. +struct Home; + +/// A legacy-shaped root (`./.vite-plus`) inside the process working directory. +struct CurrentDir; + +impl Home { + fn resolver() -> LegacyRoot { + LegacyRoot::from_path(user_home_path().map(|home| home.join(LEGACY_HOME_DIR_NAME))) + } +} + +impl CurrentDir { + fn resolver() -> LegacyRoot { + LegacyRoot::from_absolute( + vt_path::current_dir().ok().map(|dir| dir.join(LEGACY_HOME_DIR_NAME)), + ) + } +} + +/// Whether `source`'s strategy accepts `dir` as a final answer. +fn accepts(source: &R, dir: &AbsolutePathBuf) -> bool { + match R::FALLTHROUGH { + FallthroughStrategy::Set => true, + FallthroughStrategy::Exist => { + if let Some(gate) = source.exist_gate() { + gate.exists() + } else { + dir.as_path().exists() + } + } + } +} + +macro_rules! resolutions { + ($method: ident, [$($resolution: ty),*]) => { + pub fn $method() -> Option { + $({ + let source = <$resolution>::resolver(); + if let Some(dir) = source.$method() + && accepts(&source, &dir) + { + return Some(dir); + } + })* + None + } + }; +} + +macro_rules! dir_methods { + ([$($method: ident),*], $resolutions:tt) => { + $( + resolutions!($method, $resolutions); + )* + }; +} + +/// Unix-only sources: XDG env vars and XDG-style platform defaults. +#[cfg(not(target_os = "windows"))] +mod unix { + use vt_path::AbsolutePathBuf; + + use super::{APP_DIR_NAME, DirResolution, FallthroughStrategy}; + use crate::env_vars; + + pub(super) struct Xdg; + + impl Xdg { + pub(super) fn resolver() -> Self { + Self + } + } + + impl DirResolution for Xdg { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_BIN_HOME).or_else(|| { + // uv-style `$XDG_DATA_HOME/../bin` fallback, lexically + // normalized so string-equality consumers (dedup, layout + // checks) see the canonical path. + super::process_env_var(env_vars::XDG_DATA_HOME).map(|dir| dir.join("../bin").clean()) + }) + } + + fn data_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_DATA_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + + fn cache_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_CACHE_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + + fn config_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_CONFIG_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + + fn state_dir(&self) -> Option { + super::process_env_var(env_vars::XDG_STATE_HOME).map(|dir| dir.join(APP_DIR_NAME)) + } + } + + /// Platform default under the real home directory. + pub(super) struct Unix(Option); + + impl Unix { + pub(super) fn resolver() -> Self { + Self(super::user_home_path().and_then(AbsolutePathBuf::new)) + } + } + + impl DirResolution for Unix { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(".local/bin")) + } + + fn data_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(format!(".local/share/{APP_DIR_NAME}"))) + } + + fn cache_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(format!(".cache/{APP_DIR_NAME}"))) + } + + fn config_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(format!(".config/{APP_DIR_NAME}"))) + } + + fn state_dir(&self) -> Option { + self.0.clone().map(|dir| dir.join(format!(".local/state/{APP_DIR_NAME}"))) + } + } +} + +/// Windows platform defaults under `%LOCALAPPDATA%` / `%APPDATA%`. +#[cfg(target_os = "windows")] +mod windows { + use directories::BaseDirs; + use vt_path::AbsolutePathBuf; + + use super::{APP_DIR_NAME, DirResolution, FallthroughStrategy}; + + pub(super) struct Windows { + local: Option, + roaming: Option, + } + + impl Windows { + pub(super) fn resolver() -> Self { + // Prefer EnvConfig user_home (test sandboxes) so platform defaults + // stay under the test root instead of the real LocalAppData. + if let Some(home) = crate::EnvConfig::get().user_home { + return Self { + local: AbsolutePathBuf::new( + home.join("AppData").join("Local").join(APP_DIR_NAME), + ), + roaming: AbsolutePathBuf::new( + home.join("AppData").join("Roaming").join(APP_DIR_NAME), + ), + }; + } + if crate::EnvConfig::is_test_scoped() { + return Self { local: None, roaming: None }; + } + let base = BaseDirs::new(); + Self { + local: base + .as_ref() + .map(|dirs| dirs.data_local_dir().join(APP_DIR_NAME)) + .and_then(AbsolutePathBuf::new), + roaming: base + .as_ref() + .map(|dirs| dirs.config_dir().join(APP_DIR_NAME)) + .and_then(AbsolutePathBuf::new), + } + } + } + + impl DirResolution for Windows { + const FALLTHROUGH: FallthroughStrategy = FallthroughStrategy::Set; + + fn bin_dir(&self) -> Option { + self.local.clone().map(|dir| dir.join("bin")) + } + + fn data_dir(&self) -> Option { + self.local.clone().map(|dir| dir.join("data")) + } + + fn cache_dir(&self) -> Option { + self.local.clone().map(|dir| dir.join("cache")) + } + + fn config_dir(&self) -> Option { + self.roaming.clone() + } + + fn state_dir(&self) -> Option { + self.local.clone().map(|dir| dir.join("state")) + } + } +} + +// VpHome → Home → CurrentDir → VpEnvs → (Xdg) → platform. +cfg_select! { + target_os = "windows" => { + dir_methods!( + [bin_dir, data_dir, cache_dir, config_dir, state_dir], + [VpHome, Home, CurrentDir, VpEnvs, windows::Windows] + ); + } + _ => { + dir_methods!( + [bin_dir, data_dir, cache_dir, config_dir, state_dir], + [VpHome, Home, CurrentDir, VpEnvs, unix::Xdg, unix::Unix] + ); + } +} + +#[cfg(test)] +mod tests { + use std::{ffi::OsStr, path::Path}; + + use super::*; + use crate::env_vars; + + fn assert_dir(got: Option, expected: &Path) { + let got = got.expect("resolution should yield a path"); + assert_eq!( + got.as_path(), + expected, + "resolved {} != expected {}", + got.as_path().display(), + expected.display() + ); + } + + #[test] + fn vp_envs_reads_absolute_category_paths() { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("bin"); + let data = root.path().join("data"); + let cache = root.path().join("cache"); + + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(bin.as_os_str())), + (env_vars::VP_DATA_DIR, Some(data.as_os_str())), + (env_vars::VP_CACHE_DIR, Some(cache.as_os_str())), + ], + || { + let envs = VpEnvs::resolver(); + assert_dir(envs.bin_dir(), &bin); + assert_dir(envs.data_dir(), &data); + assert_dir(envs.cache_dir(), &cache); + }, + ); + } + + #[test] + fn vp_envs_drops_relative_and_unset() { + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(OsStr::new("relative/bin"))), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, Some(OsStr::new("relative/cache"))), + ], + || { + let envs = VpEnvs::resolver(); + assert!(envs.bin_dir().is_none()); + assert!(envs.data_dir().is_none()); + assert!(envs.cache_dir().is_none()); + }, + ); + } + + #[test] + fn legacy_root_maps_categories_to_monolithic_layout() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(LEGACY_HOME_DIR_NAME); + + temp_env::with_var("HOME", Some(home.path().as_os_str()), || { + let place = Home::resolver(); + assert_dir(place.bin_dir(), &root.join("bin")); + assert_dir(place.data_dir(), &root); + assert_dir(place.cache_dir(), &root.join("cache")); + assert_dir(place.config_dir(), &root); + assert_dir(place.state_dir(), &root); + }); + } + + #[test] + fn vp_home_set_pins_legacy_mapping() { + let root = tempfile::tempdir().unwrap(); + temp_env::with_var(env_vars::DEPRECATED_VP_HOME, Some(root.path().as_os_str()), || { + let place = VpHome::resolver(); + assert_dir(place.bin_dir(), &root.path().join("bin")); + assert_dir(place.data_dir(), root.path()); + assert_dir(place.cache_dir(), &root.path().join("cache")); + }); + } + + #[test] + fn fallthrough_strategies_match_source_roles() { + assert_eq!(LegacyRoot::FALLTHROUGH, FallthroughStrategy::Exist); + assert_eq!(VpHomeRoot::FALLTHROUGH, FallthroughStrategy::Set); + assert_eq!(VpEnvs::FALLTHROUGH, FallthroughStrategy::Set); + } + + mod change_cwd { + use std::fs; + + use serial_test::serial; + use vt_path::AbsolutePathBuf; + + use super::{assert_dir, *}; + + struct RestoreCwd(AbsolutePathBuf); + + impl Drop for RestoreCwd { + fn drop(&mut self) { + let _ = std::env::set_current_dir(self.0.as_path()); + } + } + + pub(super) fn with_isolated_resolution(f: impl FnOnce(&Path, &Path)) { + let home = tempfile::tempdir().unwrap(); + let cwd = tempfile::tempdir().unwrap(); + let _restore_cwd = RestoreCwd(vt_path::current_dir().unwrap()); + std::env::set_current_dir(cwd.path()).unwrap(); + let cwd_abs = vt_path::current_dir().unwrap(); + + temp_env::with_vars( + [ + ("HOME", Some(home.path().as_os_str())), + (env_vars::DEPRECATED_VP_HOME, None), + (env_vars::VP_BIN_DIR, None), + (env_vars::VP_DATA_DIR, None), + (env_vars::VP_CACHE_DIR, None), + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, None), + (env_vars::XDG_CACHE_HOME, None), + (env_vars::XDG_CONFIG_HOME, None), + (env_vars::XDG_STATE_HOME, None), + ], + || f(home.path(), cwd_abs.as_path()), + ); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_prefers_existing_home_legacy_with_subdir_mapping() { + with_isolated_resolution(|home, _cwd| { + let legacy = home.join(LEGACY_HOME_DIR_NAME); + fs::create_dir_all(&legacy).unwrap(); + + let other = home.join("other-bin"); + fs::create_dir_all(&other).unwrap(); + temp_env::with_var(env_vars::VP_BIN_DIR, Some(other.as_os_str()), || { + assert_dir(bin_dir(), &legacy.join("bin")); + assert_dir(data_dir(), &legacy); + assert_dir(cache_dir(), &legacy.join("cache")); + assert_dir(config_dir(), &legacy); + assert_dir(state_dir(), &legacy); + }); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_legacy_accepts_bin_even_if_subdir_missing() { + // Root exists but bin/ not created yet — still legacy layout. + with_isolated_resolution(|home, _cwd| { + let legacy = home.join(LEGACY_HOME_DIR_NAME); + fs::create_dir_all(&legacy).unwrap(); + assert!(!legacy.join("bin").exists()); + assert_dir(bin_dir(), &legacy.join("bin")); + assert_dir(data_dir(), &legacy); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_vp_env_set_wins_when_legacy_missing() { + with_isolated_resolution(|home, _cwd| { + let bin = home.join("vp-bin"); + let data = home.join("vp-data"); + let cache = home.join("vp-cache"); + + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(bin.as_os_str())), + (env_vars::VP_DATA_DIR, Some(data.as_os_str())), + (env_vars::VP_CACHE_DIR, Some(cache.as_os_str())), + ], + || { + assert_dir(bin_dir(), &bin); + assert_dir(data_dir(), &data); + assert_dir(cache_dir(), &cache); + }, + ); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_vp_home_beats_existing_home_legacy() { + with_isolated_resolution(|home, _cwd| { + let grandfathered = home.join(LEGACY_HOME_DIR_NAME); + fs::create_dir_all(&grandfathered).unwrap(); + let custom = home.join("custom-vp"); + // Need not exist — Set strategy. + temp_env::with_var(env_vars::DEPRECATED_VP_HOME, Some(custom.as_os_str()), || { + assert_dir(data_dir(), &custom); + assert_dir(bin_dir(), &custom.join("bin")); + }); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_prefers_existing_cwd_legacy_root() { + with_isolated_resolution(|home, cwd| { + let local = cwd.join(LEGACY_HOME_DIR_NAME); + fs::create_dir_all(&local).unwrap(); + + // CurrentDir sits ahead of VpEnvs/XDG in the chain, so the + // cwd-local root wins over per-category overrides. + let other = home.join("other-bin"); + fs::create_dir_all(&other).unwrap(); + temp_env::with_var(env_vars::VP_BIN_DIR, Some(other.as_os_str()), || { + assert_dir(bin_dir(), &local.join("bin")); + assert_dir(data_dir(), &local); + assert_dir(cache_dir(), &local.join("cache")); + assert_dir(config_dir(), &local); + assert_dir(state_dir(), &local); + }); + }); + } + } + + #[cfg(not(target_os = "windows"))] + mod unix { + use super::*; + use crate::dirs::resolution::unix::{Unix, Xdg}; + + #[test] + fn xdg_resolves_all_categories() { + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("bin-home"); + let data = root.path().join("data-home"); + let cache = root.path().join("cache-home"); + let config = root.path().join("config-home"); + let state = root.path().join("state-home"); + + temp_env::with_vars( + [ + (env_vars::XDG_BIN_HOME, Some(bin.as_os_str())), + (env_vars::XDG_DATA_HOME, Some(data.as_os_str())), + (env_vars::XDG_CACHE_HOME, Some(cache.as_os_str())), + (env_vars::XDG_CONFIG_HOME, Some(config.as_os_str())), + (env_vars::XDG_STATE_HOME, Some(state.as_os_str())), + ], + || { + let xdg = Xdg::resolver(); + assert_dir(xdg.bin_dir(), &bin); + assert_dir(xdg.data_dir(), &data.join(APP_DIR_NAME)); + assert_dir(xdg.cache_dir(), &cache.join(APP_DIR_NAME)); + assert_dir(xdg.config_dir(), &config.join(APP_DIR_NAME)); + assert_dir(xdg.state_dir(), &state.join(APP_DIR_NAME)); + }, + ); + } + + #[test] + fn xdg_bin_falls_back_to_normalized_data_home_sibling() { + let root = tempfile::tempdir().unwrap(); + let data = root.path().join("data-home"); + + temp_env::with_vars( + [ + (env_vars::XDG_BIN_HOME, None), + (env_vars::XDG_DATA_HOME, Some(data.as_os_str())), + ], + || { + let xdg = Xdg::resolver(); + // uv-style `$XDG_DATA_HOME/../bin`, with `..` resolved lexically. + assert_dir(xdg.bin_dir(), &root.path().join("bin")); + }, + ); + } + + #[test] + fn platform_default_proposes_xdg_style_paths_under_home() { + let home = tempfile::tempdir().unwrap(); + + temp_env::with_var("HOME", Some(home.path().as_os_str()), || { + let unix = Unix::resolver(); + assert_dir(unix.bin_dir(), &home.path().join(".local/bin")); + assert_dir( + unix.data_dir(), + &home.path().join(format!(".local/share/{APP_DIR_NAME}")), + ); + assert_dir(unix.cache_dir(), &home.path().join(format!(".cache/{APP_DIR_NAME}"))); + assert_dir(unix.config_dir(), &home.path().join(format!(".config/{APP_DIR_NAME}"))); + assert_dir( + unix.state_dir(), + &home.path().join(format!(".local/state/{APP_DIR_NAME}")), + ); + }); + } + + mod change_cwd { + use serial_test::serial; + + use super::{super::change_cwd::with_isolated_resolution, *}; + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_falls_back_to_platform_when_no_source_proposes() { + with_isolated_resolution(|home, _cwd| { + assert_dir(bin_dir(), &home.join(".local/bin")); + assert_dir(data_dir(), &home.join(format!(".local/share/{APP_DIR_NAME}"))); + assert_dir(cache_dir(), &home.join(format!(".cache/{APP_DIR_NAME}"))); + assert_dir(config_dir(), &home.join(format!(".config/{APP_DIR_NAME}"))); + assert_dir(state_dir(), &home.join(format!(".local/state/{APP_DIR_NAME}"))); + }); + } + + #[test] + #[serial(resolution_cwd)] + fn dir_methods_resolves_categories_independently() { + with_isolated_resolution(|home, _cwd| { + let vp_bin = home.join("only-bin"); + let xdg_data = home.join("xdg-data"); + + temp_env::with_vars( + [ + (env_vars::VP_BIN_DIR, Some(vp_bin.as_os_str())), + (env_vars::XDG_DATA_HOME, Some(xdg_data.as_os_str())), + ], + || { + assert_dir(bin_dir(), &vp_bin); + assert_dir(data_dir(), &xdg_data.join(APP_DIR_NAME)); + assert_dir(cache_dir(), &home.join(format!(".cache/{APP_DIR_NAME}"))); + }, + ); + }); + } + } + } +} diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index 6ff07cc398..44fe198fa4 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -51,11 +51,38 @@ thread_local! { /// time. Use `EnvConfig::get()` to access the current config from anywhere. #[derive(Debug, Clone)] pub struct EnvConfig { - /// Override for the vite-plus home directory (`~/.vite-plus`). + /// Deprecated override for the vite-plus home directory (`~/.vite-plus`). /// - /// Env: `VP_HOME` + /// Still honored as the highest-priority layout rule (legacy monolithic + /// layout) for backward compatibility; no longer set by installers or + /// generated env scripts. + /// + /// Env: `VP_HOME` (deprecated) pub vite_plus_home: Option, + /// Override for the directory where executables and shims are installed. + /// + /// Only applies to the split XDG/platform layout (fresh installs); a + /// legacy `~/.vite-plus` layout is all-or-nothing. + /// + /// Env: `VP_BIN_DIR` + pub vp_bin_dir: Option, + + /// Override for the payload data directory (CLI versions, Node.js + /// runtimes, package managers). + /// + /// Only applies to the split XDG/platform layout (fresh installs). + /// + /// Env: `VP_DATA_DIR` + pub vp_data_dir: Option, + + /// Override for the disposable cache directory. + /// + /// Only applies to the split XDG/platform layout (fresh installs). + /// + /// Env: `VP_CACHE_DIR` + pub vp_cache_dir: Option, + /// NPM registry URL. /// /// Env: `npm_config_registry` or `NPM_CONFIG_REGISTRY` @@ -106,7 +133,10 @@ impl EnvConfig { /// Called once in `main()` via `EnvConfig::init()`. pub fn from_env() -> Self { Self { - vite_plus_home: std::env::var(env_vars::VP_HOME).ok().map(PathBuf::from), + vite_plus_home: std::env::var(env_vars::DEPRECATED_VP_HOME).ok().map(PathBuf::from), + vp_bin_dir: std::env::var(env_vars::VP_BIN_DIR).ok().map(PathBuf::from), + vp_data_dir: std::env::var(env_vars::VP_DATA_DIR).ok().map(PathBuf::from), + vp_cache_dir: std::env::var(env_vars::VP_CACHE_DIR).ok().map(PathBuf::from), npm_registry: std::env::var(env_vars::NPM_CONFIG_REGISTRY) .or_else(|_| std::env::var(env_vars::NPM_CONFIG_REGISTRY_UPPER)) .unwrap_or_else(|_| "https://registry.npmjs.org".into()) @@ -148,6 +178,15 @@ impl EnvConfig { }) } + /// Whether a thread-local test config is currently active. + /// + /// When true, path resolution must not fall back to the process environment + /// for unset layout fields — `for_test()` zeros them deliberately. + #[must_use] + pub fn is_test_scoped() -> bool { + TEST_CONFIG.with(|c| c.borrow().is_some()) + } + /// Run a closure with a test config override (thread-local, parallel-safe). /// /// The override only applies to the current thread. @@ -194,6 +233,9 @@ impl EnvConfig { pub fn for_test() -> Self { Self { vite_plus_home: None, + vp_bin_dir: None, + vp_data_dir: None, + vp_cache_dir: None, npm_registry: "https://registry.npmjs.org".into(), node_dist_mirror: None, node_skip_signature_verify: false, @@ -205,7 +247,12 @@ impl EnvConfig { } } - /// Create a test configuration with a custom home directory. + /// Create a test configuration that pins the install root via `VP_HOME`. + /// + /// Sets [`Self::vite_plus_home`] so [`crate::VpDirs`] resolves the legacy + /// monolithic mapping under `home` (same isolation pattern as production + /// `VP_HOME`). Use struct-update syntax to also set `user_home` / + /// `vp_*_dir` when a test needs split-layout fields. pub fn for_test_with_home(home: impl Into) -> Self { Self { vite_plus_home: Some(home.into()), ..Self::for_test() } } @@ -239,7 +286,7 @@ mod tests { #[test] fn test_for_test_returns_defaults() { let config = EnvConfig::for_test(); - assert!(config.vite_plus_home.is_none()); + assert!(config.user_home.is_none()); assert_eq!(config.npm_registry, "https://registry.npmjs.org"); assert!(!config.is_ci); assert!(!config.node_skip_signature_verify); @@ -260,7 +307,7 @@ mod tests { }; assert_eq!(config.npm_registry, "https://custom.registry"); assert!(config.is_ci); - assert!(config.vite_plus_home.is_none()); + assert!(config.user_home.is_none()); } #[test] diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 0588b56322..6b32e2ee03 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -9,11 +9,48 @@ //! //! Standard system variables (`PATH`, `HOME`, `CI`, etc.) are intentionally //! excluded — they're well-known and benefit less from constant definitions. +//! The `XDG_*_HOME` base-directory variables are the exception: they +//! participate in `VpDirs` path resolution, so they get constants too. // ── Config: read once at startup via EnvConfig ────────────────────────── -/// Override for the vite-plus home directory (default: `~/.vite-plus`). -pub const VP_HOME: &str = "VP_HOME"; +/// Deprecated override for the vite-plus home directory (`~/.vite-plus`). +/// +/// Still honored as the highest-priority layout rule (selects the legacy +/// monolithic layout) for backward compatibility — older env scripts and +/// custom-location installs export it — but no longer set by the installers +/// or the generated env scripts. Prefer `VP_*_DIR` / `XDG_*` variables. +pub const DEPRECATED_VP_HOME: &str = "VP_HOME"; + +/// Override directory for executables and shims. +/// +/// Only applies to the split XDG/platform layout (fresh installs); a legacy +/// `~/.vite-plus` layout is all-or-nothing. +pub const VP_BIN_DIR: &str = "VP_BIN_DIR"; + +/// Override directory for payload data: CLI versions, Node.js runtimes, and +/// package managers (the disk hogs). +pub const VP_DATA_DIR: &str = "VP_DATA_DIR"; + +/// Override directory for the disposable cache. +pub const VP_CACHE_DIR: &str = "VP_CACHE_DIR"; + +// ── XDG base directories: read by VpDirs resolution ──────────────────── + +/// XDG base directory for executables. +pub const XDG_BIN_HOME: &str = "XDG_BIN_HOME"; + +/// XDG base directory for user configuration. +pub const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME"; + +/// XDG base directory for user data. +pub const XDG_DATA_HOME: &str = "XDG_DATA_HOME"; + +/// XDG base directory for user state. +pub const XDG_STATE_HOME: &str = "XDG_STATE_HOME"; + +/// XDG base directory for disposable caches. +pub const XDG_CACHE_HOME: &str = "XDG_CACHE_HOME"; /// Log filter string for `tracing_subscriber` (e.g. `"debug"`, `"vt=trace"`). pub const VP_LOG: &str = "VP_LOG"; diff --git a/crates/vp_shared/src/home.rs b/crates/vp_shared/src/home.rs deleted file mode 100644 index c0004fdaf9..0000000000 --- a/crates/vp_shared/src/home.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::env; - -use directories::BaseDirs; -use vt_path::{AbsolutePathBuf, current_dir}; - -use crate::EnvConfig; - -/// Default `VP_HOME` directory name -const VITE_PLUS_HOME_DIR: &str = ".vite-plus"; - -/// Platform-specific binary name for the `vp` CLI. -pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; - -/// Get the vite-plus home directory. -/// -/// Uses `EnvConfig::get().vite_plus_home` if set, -/// or the `VP_HOME/bin` directory on `PATH`, -/// otherwise defaults to `~/.vite-plus`. -/// Falls back to `$CWD/.vite-plus` if the home directory cannot be determined. -pub fn get_vp_home() -> std::io::Result { - let config = EnvConfig::get(); - if let Some(ref home) = config.vite_plus_home - && let Some(path) = AbsolutePathBuf::new(home.clone()) - { - return Ok(path); - } - - // Project-local .bin wrappers can shadow Vite+ shims; only trust a full install layout. - if let Some(home) = infer_vp_home_from_path()? { - return Ok(home); - } - - // Default to ~/.vite-plus - match BaseDirs::new() { - Some(dirs) => { - let home = AbsolutePathBuf::new(dirs.home_dir().to_path_buf()).unwrap(); - Ok(home.join(VITE_PLUS_HOME_DIR)) - } - None => { - // Fallback to $CWD/.vite-plus - Ok(current_dir()?.join(VITE_PLUS_HOME_DIR)) - } - } -} - -fn infer_vp_home_from_path() -> std::io::Result> { - let Some(path_env) = env::var_os("PATH") else { - return Ok(None); - }; - - for path_entry in env::split_paths(&path_env) { - if path_entry.as_os_str().is_empty() { - continue; - } - - let bin_dir = if path_entry.is_absolute() { - AbsolutePathBuf::new(path_entry).unwrap() - } else { - current_dir()?.join(path_entry) - }; - if bin_dir.as_path().file_name().is_none_or(|name| name != "bin") { - continue; - } - let Some(home) = bin_dir.parent() else { - continue; - }; - if is_vp_home_layout(&bin_dir, home) { - return Ok(Some(home.to_absolute_path_buf())); - } - } - - Ok(None) -} - -fn is_vp_home_layout(bin_dir: &vt_path::AbsolutePath, home: &vt_path::AbsolutePath) -> bool { - bin_dir.join(VP_BINARY_NAME).as_path().is_file() - && home.join("current").join("bin").join(VP_BINARY_NAME).as_path().is_file() -} - -#[cfg(test)] -mod tests { - use std::ffi::{OsStr, OsString}; - - use super::*; - - struct EnvVarGuard { - name: &'static str, - original: Option, - } - - impl EnvVarGuard { - fn set(name: &'static str, value: impl AsRef) -> Self { - let guard = Self { name, original: std::env::var_os(name) }; - // SAFETY: these serial tests own process environment mutations and restore them on drop. - unsafe { std::env::set_var(name, value) }; - guard - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - // SAFETY: restore the environment snapshot captured by this serial test. - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.name, value), - None => std::env::remove_var(self.name), - } - } - } - } - - struct CurrentDirGuard { - original: AbsolutePathBuf, - } - - impl CurrentDirGuard { - fn set(path: impl AsRef) -> Self { - let guard = Self { original: current_dir().unwrap() }; - std::env::set_current_dir(path).unwrap(); - guard - } - } - - impl Drop for CurrentDirGuard { - fn drop(&mut self) { - std::env::set_current_dir(&self.original).unwrap(); - } - } - - fn write_executable(path: &std::path::Path) { - #[cfg(windows)] - std::fs::write(path, b"MZ").unwrap(); - #[cfg(not(windows))] - { - std::fs::write(path, "#!/bin/sh\necho 'fake vp'").unwrap(); - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(path, perms).unwrap(); - } - } - - #[test] - fn test_get_vp_home() { - let home = get_vp_home().unwrap(); - assert!(home.ends_with(".vite-plus")); - } - - #[test] - fn test_get_vp_home_with_custom_path() { - let temp_dir = std::env::temp_dir().join("vp-test-custom-home"); - EnvConfig::test_scope(EnvConfig::for_test_with_home(&temp_dir), || { - let home = get_vp_home().unwrap(); - assert_eq!(home.as_path(), temp_dir.as_path()); - }); - } - - #[test] - #[serial_test::serial] - fn test_get_vp_home_without_vp_home_infers_from_vp_on_path() { - let temp_dir = std::env::temp_dir().join(format!("vp-test-vp-path-{}", std::process::id())); - let vite_plus_home = temp_dir.join(".vite-plus"); - let bin_dir = vite_plus_home.join("bin"); - let current_bin_dir = vite_plus_home.join("current").join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - std::fs::create_dir_all(¤t_bin_dir).unwrap(); - - write_executable(&bin_dir.join(VP_BINARY_NAME)); - write_executable(¤t_bin_dir.join(VP_BINARY_NAME)); - - let path = std::env::join_paths([bin_dir.as_os_str()]).unwrap(); - let _path_guard = EnvVarGuard::set("PATH", path); - - // `EnvConfig::for_test()` leaves `vite_plus_home` unset, so `get_vp_home` - // ignores any real `VP_HOME` env var and exercises the PATH inference. - EnvConfig::test_scope(EnvConfig::for_test(), || { - let home = get_vp_home().unwrap(); - assert_eq!(home.as_path(), vite_plus_home.as_path()); - }); - - let _ = std::fs::remove_dir_all(&temp_dir); - } - - #[test] - #[serial_test::serial] - fn test_get_vp_home_without_vp_home_ignores_relative_bin_without_current_vp() { - let temp_dir = - std::env::temp_dir().join(format!("vp-test-relative-bin-{}", std::process::id())); - let project_dir = temp_dir.join("project"); - let bin_dir = project_dir.join("tools").join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - - write_executable(&bin_dir.join(VP_BINARY_NAME)); - - let _cwd_guard = CurrentDirGuard::set(&project_dir); - let path = std::env::join_paths([std::path::Path::new("tools/bin")]).unwrap(); - let _path_guard = EnvVarGuard::set("PATH", path); - - EnvConfig::test_scope(EnvConfig::for_test(), || { - let home = get_vp_home().unwrap(); - assert_ne!(home.as_path(), project_dir.join("tools").as_path()); - }); - - let _ = std::fs::remove_dir_all(&temp_dir); - } -} diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index bcac140c23..107f58381d 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -7,11 +7,11 @@ clippy::print_stdout )] +mod dirs; mod env_config; pub mod env_vars; mod error; pub mod header; -mod home; mod http; mod interactivity; mod json_edit; @@ -24,9 +24,9 @@ pub mod string_similarity; mod tls; mod tracing; +pub use dirs::{VP_BINARY_NAME, VpDirs}; pub use env_config::{EnvConfig, TestEnvGuard}; pub use error::format_error_chain; -pub use home::{VP_BINARY_NAME, get_vp_home}; pub use http::{HttpClientError, shared_http_client}; pub use interactivity::{ is_ci_environment, is_interactive_terminal, is_stderr_terminal, is_stdin_terminal, diff --git a/crates/vp_trampoline/src/main.rs b/crates/vp_trampoline/src/main.rs index b0f2aa639f..0c22627927 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -19,6 +19,22 @@ use std::{ process::{self, Command, ExitStatus}, }; +/// Locate the real `vp.exe` relative to the install base dir (the parent of +/// the bin dir the trampoline copy lives in). +/// +/// Legacy layout first (`/current/bin/vp.exe`, where the bin dir is +/// `/bin`), then the split layout (`/data/current/bin/vp.exe`, +/// where the bin dir is a separate `/bin`). Returns the path and +/// whether it is the legacy layout; `None` if neither exists. +fn locate_vp_exe(base: &std::path::Path) -> Option<(std::path::PathBuf, bool)> { + let legacy = base.join("current").join("bin").join("vp.exe"); + if legacy.is_file() { + return Some((legacy, true)); + } + let split = base.join("data").join("current").join("bin").join("vp.exe"); + split.is_file().then_some((split, false)) +} + /// Preserve Unix signal termination using the shell's `128 + signal` convention. fn exit_code_from_status(status: ExitStatus) -> i32 { #[cfg(unix)] @@ -37,10 +53,19 @@ fn main() { let tool_name = exe_path.file_stem().and_then(|s| s.to_str()).unwrap_or_else(|| process::exit(1)); - // 2. Locate vp.exe: /../current/bin/vp.exe + // 2. Locate vp.exe: legacy `/current/bin/vp.exe` first, then the + // split layout's `/data/current/bin/vp.exe`. let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1)); - let vp_home = bin_dir.parent().unwrap_or_else(|| process::exit(1)); - let vp_exe = vp_home.join("current").join("bin").join("vp.exe"); + let base = bin_dir.parent().unwrap_or_else(|| process::exit(1)); + let (vp_exe, is_legacy) = locate_vp_exe(base).unwrap_or_else(|| { + use std::io::Write; + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_all(b"vite-plus: could not locate vp.exe under "); + let _ = handle.write_all(base.as_os_str().as_encoded_bytes()); + let _ = handle.write_all(b" (tried current\\bin and data\\current\\bin)\n"); + process::exit(1); + }); // 3. Install Ctrl+C handler that ignores signals (child will handle them). // This prevents the "Terminate batch job (Y/N)?" prompt. @@ -48,13 +73,20 @@ fn main() { install_ctrl_handler(); // 4. Spawn vp.exe - // - Always set VP_HOME so vp.exe uses the correct home directory - // (matches what the old .cmd wrappers did with %~dp0..) + // - Legacy layout: pin the root via deprecated VP_HOME so vp.exe uses + // the full monolithic mapping (config/state live under the root; + // VP_*_DIR alone cannot express that). Only needed for custom / + // non-`~/.vite-plus` roots — standard Home Exist would already win, + // but pinning is cheap and keeps PATH-only installs correct. + // - Split layout: no override; platform defaults + self-location of + // `/data/current/bin/vp.exe` match. // - If tool is "vp", run in normal CLI mode (no VP_SHIM_TOOL) // - Otherwise, set VP_SHIM_TOOL so vp.exe enters shim dispatch let mut cmd = Command::new(&vp_exe); cmd.args(env::args_os().skip(1)); - cmd.env("VP_HOME", vp_home); + if is_legacy { + cmd.env("VP_HOME", base); + } if tool_name != "vp" { cmd.env("VP_SHIM_TOOL", tool_name); @@ -83,15 +115,54 @@ fn main() { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { use super::*; + #[cfg(unix)] #[test] fn preserves_signal_exit_code() { let status = Command::new("/bin/sh").arg("-c").arg("kill -ILL $$").status().unwrap(); assert_eq!(exit_code_from_status(status), 132); } + + fn test_base(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vp-trampoline-test-{name}-{}", process::id())) + } + + #[test] + fn locate_vp_exe_prefers_legacy_layout() { + let base = test_base("legacy"); + let legacy_dir = base.join("current").join("bin"); + std::fs::create_dir_all(&legacy_dir).unwrap(); + std::fs::write(legacy_dir.join("vp.exe"), b"MZ").unwrap(); + + assert_eq!(locate_vp_exe(&base).unwrap(), (legacy_dir.join("vp.exe"), true)); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn locate_vp_exe_falls_back_to_split_layout() { + let base = test_base("split"); + let split_dir = base.join("data").join("current").join("bin"); + std::fs::create_dir_all(&split_dir).unwrap(); + std::fs::write(split_dir.join("vp.exe"), b"MZ").unwrap(); + + assert_eq!(locate_vp_exe(&base).unwrap(), (split_dir.join("vp.exe"), false)); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn locate_vp_exe_returns_none_when_absent() { + let base = test_base("absent"); + std::fs::create_dir_all(&base).unwrap(); + + assert!(locate_vp_exe(&base).is_none()); + + let _ = std::fs::remove_dir_all(&base); + } } /// Install a console control handler that ignores Ctrl+C, Ctrl+Break, etc. diff --git a/docker/Dockerfile b/docker/Dockerfile index 100a601386..04211ead1b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -60,8 +60,12 @@ RUN apt-get update \ # root work those phases occasionally need. USER vp -ENV VP_HOME=/home/vp/.vite-plus \ - PATH=/home/vp/.vite-plus/bin:$PATH +# PATH carries both candidate bin dirs: the install script fetched from +# vite.plus picks the split XDG layout (~/.local/bin) when the CLI being +# installed supports it, and the legacy monolithic layout (~/.vite-plus/bin) +# otherwise — e.g. while the released install script still predates the +# split default. Whichever is unused is simply absent from PATH lookups. +ENV PATH=/home/vp/.local/bin:/home/vp/.vite-plus/bin:$PATH # Install the vp global CLI. The installer downloads the platform package from # npm (or from the registry bridge when VP_PR_VERSION is set). Node.js itself is @@ -71,9 +75,10 @@ ENV VP_HOME=/home/vp/.vite-plus \ # The installer pre-provisions a default Node.js (~190 MB). Drop it: each project # downloads its own pinned Node.js at build time, so the default is dead weight in a # builder image. The node/npm/npx shims remain and fetch the right version on -# first use. +# first use. The runtime lands in the data dir of whichever layout the +# installer selected (split: ~/.local/share/vite-plus, legacy: ~/.vite-plus). RUN curl -fsSL https://vite.plus | VP_VERSION="${VP_VERSION}" VP_PR_VERSION="${VP_PR_VERSION}" bash \ && vp --version \ - && rm -rf "$VP_HOME/js_runtime" + && rm -rf /home/vp/.local/share/vite-plus/js_runtime /home/vp/.vite-plus/js_runtime WORKDIR /app diff --git a/docs/guide/env.md b/docs/guide/env.md index a1580348d5..8c0eed0807 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -21,7 +21,7 @@ latest LTS. When a project declares `packageManager` (or `devEngines.packageManager`) in `package.json`, matching package-manager shims also use that package-manager version. For example, `packageManager: "npm@10.9.4"` makes both `npm` and `npx` run through npm 10.9.4. Alias pairs follow the installed package-manager shims: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Vite+ does not translate mismatched commands, so a project pinned to `pnpm` still lets `npm` fall back to the npm that comes with the resolved Node.js runtime. -By default, Vite+ stores its managed runtime and related files in `~/.vite-plus`. If needed, you can override that location with `VP_HOME`. +Fresh installs store the managed runtime and related files in a split XDG-style layout — resolved per category from `VP_BIN_DIR`/`VP_DATA_DIR`/`VP_CACHE_DIR`, the `XDG_*` base directories, and platform defaults. Installs that already have `~/.vite-plus` keep the legacy monolithic layout (grandfathered; nothing is moved). The `VP_HOME` variable is deprecated but still honored as the highest-priority layout rule, so older env scripts and custom-location installs that export it keep working; the installers also accept it as an override selecting the legacy layout. See [Directory Layout and XDG Variables](/guide/installer-env-vars#directory-layout-and-xdg-variables). References to `VP_HOME` paths below use the legacy layout; under the split layout, substitute the corresponding bin/config/data/state directory. If you want to keep that behavior, run: @@ -43,7 +43,7 @@ This switches to system-first mode, where the shims prefer your system Node.js a ### Setup -- `vp env setup` creates or updates shims in `VP_HOME/bin` (and writes the per-shell setup scripts under `VP_HOME`) +- `vp env setup` creates or updates shims in the Vite+ bin directory (`VP_HOME/bin` in the legacy layout) and writes the per-shell setup scripts to the config directory (`VP_HOME` in the legacy layout) - `vp env on` enables managed mode so shims always use Vite+-managed Node.js - `vp env off` enables system-first mode so shims prefer system Node.js first - `vp env print` prints the shell snippet for the current session @@ -51,6 +51,9 @@ This switches to system-first mode, where the shims prefer your system Node.js a PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: ```powershell +# Split layout (fresh installs) +. "$env:APPDATA\vite-plus\env.ps1" +# Legacy layout (existing ~/.vite-plus installs) . "$env:USERPROFILE\.vite-plus\env.ps1" ``` @@ -76,9 +79,9 @@ node --version vp-use --unset ``` -Only `vp env use` needs this alternate command. Other `vp env` commands work normally in Command Prompt. `vp env setup` creates `vp-use.cmd` under `VP_HOME/bin` on Windows. +Only `vp env use` needs this alternate command. Other `vp env` commands work normally in Command Prompt. `vp env setup` creates `vp-use.cmd` in the Vite+ bin directory (`VP_HOME/bin` in the legacy layout) on Windows. -In CI, `vp env use` can still run without shell initialization. It writes a temporary session file under `VP_HOME` so later shim calls in the same job can resolve the selected Node.js version. +In CI, `vp env use` can still run without shell initialization. It writes a temporary session file to the Vite+ state directory (`VP_HOME` in the legacy layout) so later shim calls in the same job can resolve the selected Node.js version. ### Manage @@ -144,7 +147,7 @@ Vite+ creates a `corepack` shim by default, so corepack works without a system N - On Node.js 25 and later, where corepack is no longer bundled, Vite+ installs corepack as a managed global package on first use. Only the `corepack` binary is linked; run `vp install -g corepack` yourself if you also want the package's pnpm/yarn launchers exposed directly. - If you install corepack explicitly with `vp install -g corepack`, that installation is always preferred. -`corepack enable` normally creates `pnpm`/`yarn` launchers next to the corepack binary, which under Vite+ would not be on `PATH`. The shim fixes this by defaulting `--install-directory` to `VP_HOME/bin`, so after `corepack enable` the launchers are available everywhere and still resolve the project's Node.js and package-manager versions: +`corepack enable` normally creates `pnpm`/`yarn` launchers next to the corepack binary, which under Vite+ would not be on `PATH`. The shim fixes this by defaulting `--install-directory` to the Vite+ bin directory (`VP_HOME/bin` in the legacy layout), so after `corepack enable` the launchers are available everywhere and still resolve the project's Node.js and package-manager versions: ```bash corepack enable # pnpm and yarn now resolve via corepack diff --git a/docs/guide/implode.md b/docs/guide/implode.md index 02a019f5f6..edda71cb96 100644 --- a/docs/guide/implode.md +++ b/docs/guide/implode.md @@ -6,6 +6,8 @@ Use `vp implode` to remove `vp` and all related Vite+ data from your machine. `vp implode` is the cleanup command for removing a Vite+ installation and its managed data. Use it if you no longer want Vite+ to manage your runtime, package manager, and related local tooling state. +It removes the Vite+ directories for the resolved layout — the legacy monolithic root (`~/.vite-plus`, or a custom root chosen at install time), or the split-layout data, config, state, and cache directories plus the vp-owned shims in the bin directory — and cleans the Vite+ lines from your shell profiles. + ::: info If you decide Vite+ is not for you, please [share your feedback with us](https://discord.gg/cAnsqHh5PX). ::: diff --git a/docs/guide/install.md b/docs/guide/install.md index 7eb21a015a..ab6645ffc8 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -74,7 +74,7 @@ Updates keep the version spec a package was installed with: a package installed ::: warning These commands do **NOT** interact with the underlying package manager's global installation directory. -Instead, Vite+ manages its own global packages under `VP_HOME/packages`, allowing them to remain available across different Node.js versions. +Instead, Vite+ manages its own global packages in the `packages` subdirectory of its data directory (`VP_HOME/packages` in the legacy `~/.vite-plus` layout; see [Directory Layout and XDG Variables](/guide/installer-env-vars#directory-layout-and-xdg-variables)), allowing them to remain available across different Node.js versions. As a result, commands such as `vp link` do not affect Vite+'s global packages and will not appear in `vp list -g`. ::: diff --git a/docs/guide/installer-env-vars.md b/docs/guide/installer-env-vars.md index b4b087116b..6807749ecf 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -23,11 +23,12 @@ These variables control the installer scripts and the standalone Windows install $env:VP_VERSION = "1.2.3"; irm https://vite.plus/ps1 | iex ``` -### `VP_HOME` +### `VP_HOME` (deprecated) -- **Purpose**: Installation directory; the installed CLI reads the same variable as the Vite+ home directory (see [Environment](/guide/env)) -- **Default**: `~/.vite-plus` (Unix) or `%USERPROFILE%\.vite-plus` (Windows) +- **Purpose**: Legacy override that selects the legacy monolithic layout, rooted at the given directory +- **Default**: None — fresh installs use the [split layout](#directory-layout-and-xdg-variables); `~/.vite-plus` is used only when it already exists (grandfathered installs) or when `VP_HOME`/`--install-dir` is set - **CLI equivalent**: `--install-dir` +- **Details**: Deprecated, but still honored by the installed `vp` CLI as the highest-priority layout rule (everything lives under this one root), so older env scripts and custom-location installs that export it keep working. The installers (`install.sh`, `install.ps1`, `vp-setup.exe`) also accept it as the install dir, and the installers and generated env scripts no longer set it for new installs. Prefer `VP_*_DIR` / `XDG_*` variables. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). - **Example**: ```bash @@ -75,7 +76,25 @@ When developing Vite+ itself, `VP_LOCAL_TGZ` (path to a local `vite-plus.tgz`) a ## Runtime Variables -These variables configure the installed Vite+ CLI. `VP_HOME` (above) also applies at runtime. +These variables configure the installed Vite+ CLI. + +### `VP_BIN_DIR` + +- **Purpose**: Directory for executables and shims (`node`, `npm`, `npx`, `corepack`, `vpx`, `vpr`, the `vp` wrapper) +- **Default**: `XDG_BIN_HOME` if set, then `XDG_DATA_HOME/../bin`, otherwise `~/.local/bin` (Unix) or `%LOCALAPPDATA%\vite-plus\bin` (Windows) +- **Details**: Only applies in the split layout; ignored when the legacy monolithic layout is selected. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). + +### `VP_DATA_DIR` + +- **Purpose**: Payload data directory (CLI versions, managed Node.js runtimes, package managers, global packages) +- **Default**: `XDG_DATA_HOME/vite-plus` if set, otherwise `~/.local/share/vite-plus` (Unix) or `%LOCALAPPDATA%\vite-plus\data` (Windows) +- **Details**: Only applies in the split layout; ignored when the legacy monolithic layout is selected. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). + +### `VP_CACHE_DIR` + +- **Purpose**: Disposable cache directory +- **Default**: `XDG_CACHE_HOME/vite-plus` if set, otherwise `~/.cache/vite-plus` (Unix) or `%LOCALAPPDATA%\vite-plus\cache` (Windows) +- **Details**: Only applies in the split layout; ignored when the legacy monolithic layout is selected. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). ### `VP_NODE_DIST_MIRROR` @@ -184,7 +203,36 @@ Vite+ also respects these standard environment variables: ### `HOME` / `USERPROFILE` - **Purpose**: User home directory -- **Effect**: Base for the default `~/.vite-plus` path +- **Effect**: Base for the legacy `~/.vite-plus` root and the Unix platform defaults (`~/.local/bin`, `~/.config`, ...) + +### `XDG_BIN_HOME` / `XDG_CONFIG_HOME` / `XDG_DATA_HOME` / `XDG_STATE_HOME` / `XDG_CACHE_HOME` + +- **Purpose**: XDG base directories honored when resolving the split layout +- **Details**: Read directly from the process environment during directory resolution. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). + +## Directory Layout and XDG Variables + +The installed CLI (`VpDirs` in `vp_shared`) resolves where its files live by walking an ordered chain; the first match wins. There is **no** executable self-location or `PATH`-based install discovery — only env overrides, on-disk grandfathering, and platform defaults. + +1. **`VP_HOME` is set** (deprecated) — the legacy monolithic layout rooted at its value; every category lives under this one root. +2. **`~/.vite-plus` exists** — the legacy monolithic layout, grandfathered: existing installs keep working untouched and nothing is moved. (A process-cwd `./.vite-plus` is also recognized for project-local / test fixtures.) +3. **Otherwise (fresh / split installs)** — each category resolves independently through its own override → XDG (Unix) → platform-default chain: + +| Category | Contents | Resolution (first match wins) | Unix default | Windows default | +| --------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | -------------------------- | -------------------------------- | +| Executables and shims | `node`, `npm`, `npx`, `corepack`, `vpx`, `vpr`, the `vp` wrapper | `VP_BIN_DIR` → `XDG_BIN_HOME` → `XDG_DATA_HOME/../bin` | `~/.local/bin` | `%LOCALAPPDATA%\vite-plus\bin` | +| Configuration | `config.json`, shell env scripts | `XDG_CONFIG_HOME/vite-plus` | `~/.config/vite-plus` | `%APPDATA%\vite-plus` | +| Data | CLI versions, managed Node.js runtimes, package managers, global packages, per-binary `bins/*.json` metadata | `VP_DATA_DIR` → `XDG_DATA_HOME/vite-plus` | `~/.local/share/vite-plus` | `%LOCALAPPDATA%\vite-plus\data` | +| State | Session and upgrade-check files | `XDG_STATE_HOME/vite-plus` | `~/.local/state/vite-plus` | `%LOCALAPPDATA%\vite-plus\state` | +| Cache | Disposable caches | `VP_CACHE_DIR` → `XDG_CACHE_HOME/vite-plus` | `~/.cache/vite-plus` | `%LOCALAPPDATA%\vite-plus\cache` | + +Notes: + +- Relative values in the `VP_*_DIR` and `XDG_*` variables are ignored, per the XDG Base Directory specification. +- `VP_BIN_DIR`, `VP_DATA_DIR`, and `VP_CACHE_DIR` only apply in the split layout; the legacy layout (rules 1–2) is all-or-nothing. +- `VP_HOME` is deprecated: still honored as rule 1 for backward compatibility, but no longer set by the installers or the generated env scripts. Prefer `VP_*_DIR` / `XDG_*` variables. +- Custom split roots require the corresponding `VP_*_DIR` (and/or `XDG_*`) variables to remain set in the environment for later CLI invocations — installers pin them only for the install session itself. Generated env scripts put the **bin** directory on `PATH`; they do not re-export category overrides. +- The installers (`install.sh`, `install.ps1`, `vp-setup.exe`) use the same precedence: `VP_HOME` / `--install-dir` → existing `~/.vite-plus` → split defaults (`VP_*_DIR` / `XDG_*` / platform). Fresh installs land on the split layout; an existing `~/.vite-plus` keeps the legacy layout. ## Precedence diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index c37507a314..d76b75bfb6 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -6,7 +6,10 @@ # # Environment variables: # VP_VERSION - Version to install (default: latest) -# VP_HOME - Installation directory (default: $env:USERPROFILE\.vite-plus) +# VP_HOME - Deprecated. When set, forces the legacy monolithic layout rooted +# at this directory (compat with older scripts). Prefer VP_*_DIR. +# VP_DATA_DIR / VP_BIN_DIR / VP_CACHE_DIR - Split-layout category overrides +# (same names the installed vp CLI reads via VpDirs). # 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 @@ -17,9 +20,40 @@ $ErrorActionPreference = "Stop" $ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } -$InstallDir = if ($env:VP_HOME) { $env:VP_HOME } else { "$env:USERPROFILE\.vite-plus" } -# Use ~ shorthand if install dir is under USERPROFILE, matching the final summary output -$NodeManagerBinDisplay = (Join-Path $InstallDir.TrimEnd('\', '/') "bin") -replace [regex]::Escape($env:USERPROFILE), '~' + +# Install layout — same strategy chain as vp_shared::dirs::resolution: +# VpHome (VP_HOME Set) → Home (%USERPROFILE%\.vite-plus Exist) → VpEnvs +# (VP_*_DIR Set) → platform defaults under LOCALAPPDATA/APPDATA. +# Fresh installs land on the split Windows layout; an existing +# %USERPROFILE%\.vite-plus keeps the legacy monolithic root (grandfathered). +$LegacyLayout = $false +if ($env:VP_HOME) { + $InstallDir = $env:VP_HOME + $LegacyLayout = $true +} elseif (Test-Path -LiteralPath "$env:USERPROFILE\.vite-plus" -PathType Container) { + $InstallDir = "$env:USERPROFILE\.vite-plus" + $LegacyLayout = $true +} else { + $localAppData = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { "$env:USERPROFILE\AppData\Local" } + $appData = if ($env:APPDATA) { $env:APPDATA } else { "$env:USERPROFILE\AppData\Roaming" } + if ($env:VP_DATA_DIR) { + $InstallDir = $env:VP_DATA_DIR + } else { + $InstallDir = "$localAppData\vite-plus\data" + } + if ($env:VP_BIN_DIR) { + $ShimBinDir = $env:VP_BIN_DIR + } else { + $ShimBinDir = "$localAppData\vite-plus\bin" + } + $EnvScriptsDir = "$appData\vite-plus" +} +if ($LegacyLayout) { + $ShimBinDir = Join-Path $InstallDir.TrimEnd('\', '/') "bin" + $EnvScriptsDir = $InstallDir +} +# Use ~ shorthand if the shim bin dir is under USERPROFILE, matching the final summary output +$NodeManagerBinDisplay = $ShimBinDir -replace [regex]::Escape($env:USERPROFILE), '~' # 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 @@ -329,7 +363,7 @@ function Prompt-RemovePreviousInstallDir { Write-Host "" Write-Warn "Found a previous Vite+ install at $PreviousInstallDir." - Write-Host "The new VP_HOME is $InstallDir." + Write-Host "The new install directory 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" @@ -338,8 +372,14 @@ function Prompt-RemovePreviousInstallDir { return } + # Pin the old root so implode targets it. Prefer VP_DATA_DIR for current + # CLIs; also set VP_HOME so pre-split CLIs still find the root. $previousVpHome = $env:VP_HOME + $previousVpDataDir = $env:VP_DATA_DIR + $previousVpBinDir = $env:VP_BIN_DIR try { + $env:VP_DATA_DIR = $PreviousInstallDir + $env:VP_BIN_DIR = Join-Path $PreviousInstallDir "bin" $env:VP_HOME = $PreviousInstallDir $output = & $vpBin implode --yes 2>&1 $exitCode = $LASTEXITCODE @@ -347,7 +387,9 @@ function Prompt-RemovePreviousInstallDir { $output = $_ $exitCode = 1 } finally { - $env:VP_HOME = $previousVpHome + if ($null -eq $previousVpHome) { Remove-Item Env:VP_HOME -ErrorAction SilentlyContinue } else { $env:VP_HOME = $previousVpHome } + if ($null -eq $previousVpDataDir) { Remove-Item Env:VP_DATA_DIR -ErrorAction SilentlyContinue } else { $env:VP_DATA_DIR = $previousVpDataDir } + if ($null -eq $previousVpBinDir) { Remove-Item Env:VP_BIN_DIR -ErrorAction SilentlyContinue } else { $env:VP_BIN_DIR = $previousVpBinDir } } if ($exitCode -eq 0) { @@ -573,10 +615,10 @@ function Remove-CurrentLink { } } -# Configure user PATH for ~/.vite-plus/bin +# Configure user PATH for the shim bin dir # Returns: "true" = added, "already" = already configured function Configure-UserPath { - $binPath = "$InstallDir\bin" + $binPath = $ShimBinDir $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($userPath -like "*$binPath*") { @@ -632,7 +674,7 @@ function Configure-Nushell { } $autoloadFile = Join-Path $autoloadDir "vite-plus.nu" - $nuEnvRef= (Join-Path $InstallDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' + $nuEnvRef = (Join-Path $EnvScriptsDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' $content = "# Vite+ bin (https://viteplus.dev)`n" + ("source '"+ $nuEnvRef +"'") + "`n" try { @@ -661,10 +703,42 @@ function Configure-Nushell { } } +# Run the installed vp with env that matches this install's layout. +# Released (pre-split) CLIs only honor VP_HOME; pin it so BaseDirs home +# mismatches (e.g. Namespace Windows service-account profile vs USERPROFILE) +# cannot redirect env setup / shims to a different tree. +function Invoke-InstalledVp { + param( + [string]$VpBin, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$VpArgs + ) + $prevHome = $env:VP_HOME + $prevData = $env:VP_DATA_DIR + $prevBin = $env:VP_BIN_DIR + try { + if ($LegacyLayout) { + $env:VP_HOME = $InstallDir + Remove-Item Env:VP_DATA_DIR -ErrorAction SilentlyContinue + Remove-Item Env:VP_BIN_DIR -ErrorAction SilentlyContinue + } else { + Remove-Item Env:VP_HOME -ErrorAction SilentlyContinue + $env:VP_DATA_DIR = $InstallDir + $env:VP_BIN_DIR = $ShimBinDir + } + & $VpBin @VpArgs + return $LASTEXITCODE + } finally { + if ($null -ne $prevHome) { $env:VP_HOME = $prevHome } else { Remove-Item Env:VP_HOME -ErrorAction SilentlyContinue } + if ($null -ne $prevData) { $env:VP_DATA_DIR = $prevData } else { Remove-Item Env:VP_DATA_DIR -ErrorAction SilentlyContinue } + if ($null -ne $prevBin) { $env:VP_BIN_DIR = $prevBin } else { Remove-Item Env:VP_BIN_DIR -ErrorAction SilentlyContinue } + } +} + # 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 + $setupOutput = Invoke-InstalledVp -VpBin "$BinDir\vp.exe" -VpArgs @("env", "setup", "--refresh") 2>&1 if ($LASTEXITCODE -ne 0) { Write-Warn "Failed to refresh shims:" Write-Host "$setupOutput" @@ -676,7 +750,7 @@ function Refresh-Shims { function Setup-NodeManager { param([string]$BinDir) - $binPath = "$InstallDir\bin" + $binPath = $ShimBinDir # Explicit override via environment variable if ($env:VP_NODE_MANAGER -eq "yes") { @@ -742,7 +816,7 @@ function Main { $previousInstallDir = Get-PreviousInstallDir if ($previousInstallDir -and (Test-NestedInstallDir -OldDir $previousInstallDir -NewDir $InstallDir)) { - Write-Error-Exit "Previous Vite+ install at $previousInstallDir overlaps with VP_HOME $InstallDir. Choose a separate VP_HOME or remove the previous install first." + Write-Error-Exit "Previous Vite+ install at $previousInstallDir overlaps with install directory $InstallDir. Choose a separate install location or remove the previous install first." } # Suppress progress bars for cleaner output @@ -765,7 +839,7 @@ function Main { # 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. + # out of Cleanup-OldVersions and makes the PR build obvious in the data dir. $PrCommitVersion = Resolve-BridgeCommitVersion -Ref $PrVersion if (-not $PrCommitVersion) { Write-Error-Exit "Could not resolve a registry bridge build for $PrVersion" @@ -919,13 +993,13 @@ function Main { cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null # Create bin directory and vp wrapper (always done) - New-Item -ItemType Directory -Force -Path "$InstallDir\bin" | Out-Null + New-Item -ItemType Directory -Force -Path $ShimBinDir | 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 "$InstallDir\bin\vp.exe" -Force + Copy-Item -Path $trampolineSrc -Destination "$ShimBinDir\vp.exe" -Force # Remove legacy .cmd and shell script wrappers from previous versions - foreach ($legacy in @("$InstallDir\bin\vp.cmd", "$InstallDir\bin\vp")) { + foreach ($legacy in @("$ShimBinDir\vp.cmd", "$ShimBinDir\vp")) { if (Test-Path $legacy) { Remove-Item -Path $legacy -Force -ErrorAction SilentlyContinue } @@ -935,28 +1009,37 @@ function Main { # 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 "$InstallDir\bin" $stale + $stalePath = Join-Path $ShimBinDir $stale if (Test-Path $stalePath) { Remove-Item -Path $stalePath -Force -ErrorAction SilentlyContinue } } - # Keep consistent with the original install.ps1 wrapper format + # VP_HOME points the pre-trampoline CLI at its install root: the + # wrapper's parent under the legacy layout; the data dir under the + # split layout (a data dir carries the same versions + `current` + # shape, and these old CLIs still read VP_HOME). + $wrapperHomeRef = if ($LegacyLayout) { '%~dp0..' } else { $InstallDir } $wrapperContent = @" @echo off -set VP_HOME=%~dp0.. +set VP_HOME=$wrapperHomeRef "%VP_HOME%\current\bin\vp.exe" %* exit /b %ERRORLEVEL% "@ - Set-Content -Path "$InstallDir\bin\vp.cmd" -Value $wrapperContent -NoNewline + Set-Content -Path "$ShimBinDir\vp.cmd" -Value $wrapperContent -NoNewline # Also create shell script wrapper for Git Bash/MSYS + $shHomeRef = if ($LegacyLayout) { + '"$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")"' + } else { + '"' + ($InstallDir -replace '\\', '/') + '"' + } $shContent = @" #!/bin/sh -VP_HOME="`$(dirname "`$(dirname "`$(readlink -f "`$0" 2>/dev/null || echo "`$0")")")" +VP_HOME=$shHomeRef export VP_HOME exec "`$VP_HOME/current/bin/vp.exe" "`$@" "@ - Set-Content -Path "$InstallDir\bin\vp" -Value $shContent -NoNewline + Set-Content -Path "$ShimBinDir\vp" -Value $shContent -NoNewline } # Cleanup old versions @@ -971,8 +1054,9 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" $pathResult = Configure-UserPath $nushellResult = Configure-Nushell - # Use ~ shorthand if install dir is under USERPROFILE, otherwise show full path - $displayDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~' + # Use ~ shorthand for paths under USERPROFILE, otherwise show full paths + $displayBinDir = $ShimBinDir -replace [regex]::Escape($env:USERPROFILE), '~' + $displayEnvScriptsDir = $EnvScriptsDir -replace [regex]::Escape($env:USERPROFILE), '~' # ANSI color codes for consistent output $e = [char]27 @@ -1030,23 +1114,23 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" Write-Host "" Write-Host " ${YELLOW}note${NC}: Some shells still need manual setup." Write-Host "" - Write-Host " vp was installed to: ${BOLD}${displayDir}\bin${NC}" + Write-Host " vp was installed to: ${BOLD}${displayBinDir}${NC}" 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', '$InstallDir\bin;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" + Write-Host " [Environment]::SetEnvironmentVariable('Path', '$ShimBinDir;' + [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 '$displayDir\env.nu'" + Write-Host " source '$displayEnvScriptsDir\env.nu'" Write-Host "" } Write-Host " Or run vp directly:" Write-Host "" - Write-Host " & `"$InstallDir\bin\vp.exe`"" + Write-Host " & `"$ShimBinDir\vp.exe`"" } Write-Host "" diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 5aa2244fab..67e5d5930c 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -7,7 +7,14 @@ # # Environment variables: # VP_VERSION - Version to install (default: latest) -# VP_HOME - Installation directory (default: ~/.vite-plus) +# VP_HOME - Deprecated. When set, forces the legacy monolithic layout rooted +# at this directory (compat with older scripts). Prefer VP_*_DIR. +# VP_DATA_DIR / VP_BIN_DIR / VP_CACHE_DIR - Split-layout category overrides +# (same names the installed vp CLI reads via VpDirs). +# XDG_CONFIG_HOME / XDG_DATA_HOME / XDG_BIN_HOME / XDG_CACHE_HOME / +# XDG_STATE_HOME - XDG base directories honored by the split layout +# (relative values are treated as unset). XDG_BIN_HOME is a +# uv-style convention, not part of the XDG Base Directory Spec. # 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) @@ -19,14 +26,66 @@ set -e VP_VERSION="${VP_VERSION:-latest}" -INSTALL_DIR="${VP_HOME:-$HOME/.vite-plus}" -# Use $HOME-relative path for shell config references (portable across sessions) -if case "$INSTALL_DIR" in "$HOME"/*) true;; *) false;; esac; then - INSTALL_DIR_REF_POSIX="\$HOME${INSTALL_DIR#"$HOME"}" - INSTALL_DIR_REF_NU="~${INSTALL_DIR#"$HOME"}" + +# Install layout — same strategy chain as vp_shared::dirs::resolution: +# VpHome (VP_HOME Set) → Home (~/.vite-plus Exist) → VpEnvs (VP_*_DIR Set) +# → XDG → platform defaults. +# Fresh installs land on the split XDG layout; an existing ~/.vite-plus keeps +# the legacy monolithic root (grandfathered). Git Bash/MSYS always uses the +# legacy root because install.ps1 owns the Windows split layout. +LEGACY_LAYOUT="false" +if [ -n "${VP_HOME:-}" ]; then + INSTALL_DIR="$VP_HOME" + LEGACY_LAYOUT="true" +elif [ -d "$HOME/.vite-plus" ]; then + INSTALL_DIR="$HOME/.vite-plus" + LEGACY_LAYOUT="true" +else + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + INSTALL_DIR="$HOME/.vite-plus" + LEGACY_LAYOUT="true" + ;; + esac +fi + +if [ "$LEGACY_LAYOUT" = "false" ]; then + # Relative VP_*_DIR/XDG_* values are treated as unset, per the XDG Base + # Directory Specification. + for dir_var in VP_BIN_DIR VP_DATA_DIR VP_CACHE_DIR XDG_BIN_HOME XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME; do + eval "dir_val=\${$dir_var:-}" + case "$dir_val" in + '' | /*) ;; + *) unset "$dir_var" ;; + esac + done + unset dir_var dir_val + + INSTALL_DIR="${VP_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/vite-plus}" + if [ -n "${VP_BIN_DIR:-}" ]; then + SHIM_BIN_DIR="$VP_BIN_DIR" + elif [ -n "${XDG_BIN_HOME:-}" ]; then + SHIM_BIN_DIR="$XDG_BIN_HOME" + elif [ -n "${XDG_DATA_HOME:-}" ] && [ "$XDG_DATA_HOME" != "/" ]; then + # uv's chain: $XDG_DATA_HOME/../bin (trailing slashes stripped so + # dirname resolves the same parent the CLI does) + SHIM_BIN_DIR="$(dirname "${XDG_DATA_HOME%/}")/bin" + else + SHIM_BIN_DIR="$HOME/.local/bin" + fi + ENV_SCRIPTS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vite-plus" else - INSTALL_DIR_REF_POSIX="$INSTALL_DIR" - INSTALL_DIR_REF_NU="$INSTALL_DIR" + SHIM_BIN_DIR="$INSTALL_DIR/bin" + ENV_SCRIPTS_DIR="$INSTALL_DIR" +fi + +# Use $HOME-relative paths for shell config references (portable across sessions) +if case "$ENV_SCRIPTS_DIR" in "$HOME"/*) true;; *) false;; esac; then + ENV_DIR_REF_POSIX="\$HOME${ENV_SCRIPTS_DIR#"$HOME"}" + ENV_DIR_REF_NU="~${ENV_SCRIPTS_DIR#"$HOME"}" +else + ENV_DIR_REF_POSIX="$ENV_SCRIPTS_DIR" + ENV_DIR_REF_NU="$ENV_SCRIPTS_DIR" fi # npm registry URL (strip trailing slash if present) NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" @@ -228,7 +287,7 @@ prompt_remove_previous_install_dir() { 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 + echo "The new install directory is $INSTALL_DIR." > /dev/tty printf "Remove the previous install directory? (y/N): " > /dev/tty local response @@ -244,8 +303,13 @@ prompt_remove_previous_install_dir() { return 0 fi + # Pin the old root so implode targets it. Prefer VP_DATA_DIR for current + # CLIs; also set VP_HOME so pre-split CLIs still find the root. local implode_output - if implode_output=$(VP_HOME="$old_dir" "$vp_bin" implode --yes 2>&1); then + if implode_output=$( + VP_DATA_DIR="$old_dir" VP_BIN_DIR="$old_dir/bin" 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." @@ -688,7 +752,7 @@ configure_zsh_path() { fi result=0 - append_source_to_file "$zshenv" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$zshenv" ". \"$ENV_DIR_REF_POSIX/env\"" "$ENV_SCRIPTS_DIR/env" "$ENV_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshenv")") ;; 2) already+=("$(abbreviate_path "$zshenv")") ;; @@ -697,7 +761,7 @@ configure_zsh_path() { if [ -f "$zshrc" ]; then result=0 - append_source_to_file "$zshrc" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$zshrc" ". \"$ENV_DIR_REF_POSIX/env\"" "$ENV_SCRIPTS_DIR/env" "$ENV_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshrc")") ;; 2) already+=("$(abbreviate_path "$zshrc")") ;; @@ -741,7 +805,7 @@ configure_bash_path() { fi existing=1 result=0 - append_source_to_file "$file" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$file" ". \"$ENV_DIR_REF_POSIX/env\"" "$ENV_SCRIPTS_DIR/env" "$ENV_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$file")") ;; 2) already+=("$(abbreviate_path "$file")") ;; @@ -776,7 +840,7 @@ configure_bash_path() { 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 \"$INSTALL_DIR_REF_POSIX/env.fish\" +source \"$ENV_DIR_REF_POSIX/env.fish\" " local result=0 @@ -811,7 +875,7 @@ configure_nushell_path() { local nushell_autoload="$nushell_dir/vite-plus.nu" local nushell_content="# Vite+ bin (https://viteplus.dev) -source '$INSTALL_DIR_REF_NU/env.nu' +source '$ENV_DIR_REF_NU/env.nu' " local result=0 @@ -867,12 +931,26 @@ configure_shell_path() { fi } +# Run the installed `vp` with env that matches this install's layout. +# Released (pre-split) CLIs only honor VP_HOME; current CLIs also read VP_*_DIR. +# Without this pin, BaseDirs home can disagree with the installer's USERPROFILE +# / HOME (Namespace Windows runners) and shims land in the wrong tree. +run_installed_vp() { + local vp_bin="$1" + shift + if [ "$LEGACY_LAYOUT" = "true" ]; then + VP_HOME="$INSTALL_DIR" "$vp_bin" "$@" + else + VP_DATA_DIR="$INSTALL_DIR" VP_BIN_DIR="$SHIM_BIN_DIR" "$vp_bin" "$@" + 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 + if ! setup_output=$(run_installed_vp "$vp_bin" env setup --refresh 2>&1); then warn "Failed to refresh shims:" echo "$setup_output" >&2 fi @@ -883,7 +961,7 @@ refresh_shims() { # Arguments: bin_dir - path to the version's bin directory containing vp setup_node_manager() { local bin_dir="$1" - local bin_path="$INSTALL_DIR/bin" + local bin_path="$SHIM_BIN_DIR" NODE_MANAGER_ENABLED="false" # Resolve vp binary name (vp on Unix, vp.exe on Windows) @@ -937,7 +1015,7 @@ setup_node_manager() { if [ -e /dev/tty ] && [ -t 1 ]; then echo "" echo "Would you like Vite+ to manage your Node.js versions?" - echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/ and automatically uses the right version." + echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$SHIM_BIN_DIR")/ and automatically uses the right version." echo "Opt out anytime with \`vp env off\`." echo -n "Press Enter to accept (Y/n): " read -r response < /dev/tty @@ -1008,7 +1086,7 @@ main() { local previous_install_dir previous_install_dir="$(detect_previous_install_dir || true)" if [ -n "$previous_install_dir" ] && is_nested_install_dir "$previous_install_dir" "$INSTALL_DIR"; then - error "Previous Vite+ install at $previous_install_dir overlaps with VP_HOME $INSTALL_DIR. Choose a separate VP_HOME or remove the previous install first." + error "Previous Vite+ install at $previous_install_dir overlaps with install directory $INSTALL_DIR. Choose a separate install location or remove the previous install first." fi local platform @@ -1028,7 +1106,7 @@ main() { # 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/`. + # of cleanup_old_versions and makes the PR build obvious in the data dir. # `|| 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. @@ -1173,15 +1251,19 @@ WRAPPER_EOF ln -sfn "$VP_VERSION" "$CURRENT_LINK" # Create bin directory and vp entrypoint (always done) - mkdir -p "$INSTALL_DIR/bin" + mkdir -p "$SHIM_BIN_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" "$INSTALL_DIR/bin/vp.exe" + cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$SHIM_BIN_DIR/vp.exe" fi + elif [ "$LEGACY_LAYOUT" = "true" ]; then + # Legacy layout: keep the relative symlink target (portable root). + ln -sf "../current/bin/vp" "$SHIM_BIN_DIR/vp" else - # Unix: symlink to current/bin/vp - ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" + # Split layout: the bin dir lives outside the data dir, so link + # absolutely to /current/bin/vp. + ln -sf "$INSTALL_DIR/current/bin/vp" "$SHIM_BIN_DIR/vp" fi # Cleanup old versions @@ -1194,7 +1276,7 @@ WRAPPER_EOF if [[ "$platform" == win32* ]]; then vp_bin="$INSTALL_DIR/current/bin/vp.exe" fi - "$vp_bin" env setup --env-only > /dev/null + run_installed_vp "$vp_bin" env setup --env-only > /dev/null # Setup Node.js version manager (shims) - separate component setup_node_manager "$BIN_DIR" @@ -1204,9 +1286,9 @@ WRAPPER_EOF # Configure shell PATH after the install is otherwise complete. configure_shell_path - # Use ~ shorthand if install dir is under HOME, otherwise show full path - local display_dir="${INSTALL_DIR/#$HOME/~}" - local display_location="${display_dir}/bin" + # Use ~ shorthand for the bin dir when it is under HOME + local display_location + display_location="$(abbreviate_path "$SHIM_BIN_DIR")" # Print success message echo "" @@ -1251,11 +1333,11 @@ WRAPPER_EOF echo "" echo " Manual setup instructions:" echo " - Bash/Zsh: add the following to your shell config (~/.bashrc, ~/.zshrc, etc.):" - echo " . \"$INSTALL_DIR_REF_POSIX/env\"" + echo " . \"$ENV_DIR_REF_POSIX/env\"" echo " - Fish: create ${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish with:" - echo " source \"$INSTALL_DIR_REF_POSIX/env.fish\"" + echo " source \"$ENV_DIR_REF_POSIX/env.fish\"" echo " - Nushell: create a vendor autoload file with:" - echo " source '$INSTALL_DIR_REF_NU/env.nu'" + echo " source '$ENV_DIR_REF_NU/env.nu'" echo "" echo " Or run vp directly:" echo "" diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 4dd43e59d7..118468700e 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -55,10 +55,14 @@ d=${rootExpr} __vp_shell=/bin/sh [ -x "$__vp_shell" ] || __vp_shell=$(command -v sh) -if [ -n "\${VP_HOME-}" ]; then +if [ -n "\${VP_BIN_DIR-}" ]; then + __vp_bin="$VP_BIN_DIR" +elif [ -n "\${VP_HOME-}" ]; then __vp_bin="$VP_HOME/bin" -elif [ -n "\${HOME-}" ]; then +elif [ -n "\${HOME-}" ] && [ -d "$HOME/.vite-plus/bin" ]; then __vp_bin="$HOME/.vite-plus/bin" +elif [ -n "\${HOME-}" ]; then + __vp_bin="$HOME/.local/bin" else __vp_bin="" fi diff --git a/packages/cli/src/create/__tests__/org-tarball.spec.ts b/packages/cli/src/create/__tests__/org-tarball.spec.ts index c97059f030..f496737139 100644 --- a/packages/cli/src/create/__tests__/org-tarball.spec.ts +++ b/packages/cli/src/create/__tests__/org-tarball.spec.ts @@ -2,16 +2,95 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanupStaleStagingDirs, + getCacheRoot, normalizeEntryName, parseEntryMode, resolveBundledPath, sanitizeHostForPath, } from '../org-tarball.js'; +describe('getCacheRoot', () => { + const scratchDirs: string[] = []; + + beforeEach(() => { + // Isolate from the developer/CI environment (empty string is falsy, so + // the code under test treats it as unset). + vi.stubEnv('VP_CACHE_DIR', ''); + vi.stubEnv('VP_HOME', ''); + vi.stubEnv('XDG_CACHE_HOME', ''); + vi.stubEnv('LOCALAPPDATA', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const dir of scratchDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function stubHome(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-cache-root-')); + scratchDirs.push(home); + // `os.homedir()` honors $HOME (POSIX) / %USERPROFILE% (Windows). + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + return home; + } + + it('prefers VP_CACHE_DIR when set', () => { + vi.stubEnv('VP_CACHE_DIR', '/vp/cache'); + expect(getCacheRoot()).toBe(path.join('/vp/cache', 'create-org')); + }); + + it('uses the legacy tmp dir when VP_HOME is set', () => { + vi.stubEnv('VP_HOME', '/vp/legacy'); + expect(getCacheRoot()).toBe(path.join('/vp/legacy', 'tmp', 'create-org')); + }); + + it('uses the legacy tmp dir when ~/.vite-plus already exists', () => { + const home = stubHome(); + fs.mkdirSync(path.join(home, '.vite-plus')); + expect(getCacheRoot()).toBe(path.join(home, '.vite-plus', 'tmp', 'create-org')); + }); + + it.skipIf(process.platform === 'win32')( + 'falls back to the split cache dir without touching ~/.vite-plus', + () => { + const home = stubHome(); + vi.stubEnv('XDG_CACHE_HOME', ''); + expect(getCacheRoot()).toBe(path.join(home, '.cache', 'vite-plus', 'create-org')); + // The fallback must not create the legacy root: its mere existence + // would flip future resolutions back to the legacy layout. + expect(fs.existsSync(path.join(home, '.vite-plus'))).toBe(false); + }, + ); + + it.skipIf(process.platform === 'win32')('honors an absolute XDG_CACHE_HOME', () => { + stubHome(); + vi.stubEnv('XDG_CACHE_HOME', '/xdg/cache'); + expect(getCacheRoot()).toBe(path.join('/xdg/cache', 'vite-plus', 'create-org')); + }); + + it.skipIf(process.platform === 'win32')('ignores a relative XDG_CACHE_HOME', () => { + const home = stubHome(); + vi.stubEnv('XDG_CACHE_HOME', 'relative/cache'); + expect(getCacheRoot()).toBe(path.join(home, '.cache', 'vite-plus', 'create-org')); + }); + + it.skipIf(process.platform !== 'win32')('uses %LOCALAPPDATA% on Windows', () => { + stubHome(); + vi.stubEnv('LOCALAPPDATA', 'C:\\Users\\test\\AppData\\Local'); + expect(getCacheRoot()).toBe( + path.join('C:\\Users\\test\\AppData\\Local', 'vite-plus', 'cache', 'create-org'), + ); + }); +}); + describe('resolveBundledPath', () => { const scratchDirs: string[] = []; diff --git a/packages/cli/src/create/org-tarball.ts b/packages/cli/src/create/org-tarball.ts index 66f15bd371..6d20927f94 100644 --- a/packages/cli/src/create/org-tarball.ts +++ b/packages/cli/src/create/org-tarball.ts @@ -8,9 +8,40 @@ import { parseTarGzip } from 'nanotar'; import { fetchNpmResource } from '../utils/npm-config.ts'; import type { OrgManifest } from './org-manifest.ts'; -function getCacheRoot(): string { - const home = process.env.VP_HOME || path.join(os.homedir(), '.vite-plus'); - return path.join(home, 'tmp', 'create-org'); +// Exported for tests. +export function getCacheRoot(): string { + // The global CLI injects VP_CACHE_DIR under the split (XDG) layout; this + // fallback only runs for out-of-band invocations. Never default to + // `~/.vite-plus` when it doesn't already exist — creating it would trip + // the CLI's legacy-layout detection and flip a split install back to + // legacy. + const cacheDir = process.env.VP_CACHE_DIR; + if (cacheDir) { + return path.join(cacheDir, 'create-org'); + } + const legacyHome = process.env.VP_HOME || path.join(os.homedir(), '.vite-plus'); + if (process.env.VP_HOME || fs.existsSync(legacyHome)) { + return path.join(legacyHome, 'tmp', 'create-org'); + } + // Split-layout platform cache default, mirroring VpDirs::cache_dir. + return path.join(getPlatformCacheDir(), 'create-org'); +} + +/** + * Platform cache directory for a fresh split-layout install + * (`$XDG_CACHE_HOME/vite-plus` or `~/.cache/vite-plus` on Unix, + * `%LOCALAPPDATA%\vite-plus\cache` on Windows). Kept in sync with + * `crates/vp_shared/src/dirs/resolution.rs`. + */ +function getPlatformCacheDir(): string { + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + return path.join(localAppData, 'vite-plus', 'cache'); + } + // Relative XDG values are treated as unset, per the XDG Base Directory Spec. + const xdgCache = process.env.XDG_CACHE_HOME; + const base = xdgCache && path.isAbsolute(xdgCache) ? xdgCache : path.join(os.homedir(), '.cache'); + return path.join(base, 'vite-plus'); } /** diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index 3e26c59b16..0038305736 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -82,9 +82,10 @@ export function installGlobalCli() { } try { - const installDir = process.env.VP_HOME - ? path.resolve(process.env.VP_HOME) - : path.join(os.homedir(), '.vite-plus'); + // Match install.sh / VpDirs resolution: do not force VP_HOME. Prefer + // explicit VP_*_DIR / deprecated VP_HOME from the environment; otherwise + // grandfather ~/.vite-plus when present, else the split platform data dir. + const installDir = resolveInstallDataDir(); // Locate the Rust vp binary (built by cargo or copied by CI) const binaryName = isWindows ? 'vp.exe' : 'vp'; @@ -134,7 +135,6 @@ export function installGlobalCli() { ...(process.env as Record), VP_LOCAL_TGZ: tgzPath, VP_LOCAL_BINARY: binaryPath, - VP_HOME: installDir, VP_VERSION: localDevVer, CI: 'true', // Skip vp install in install.sh — we handle deps ourselves: @@ -190,6 +190,36 @@ function getTargetDirs(): string[] { return dirs; } +/** + * Data dir for CLI versions + `current`, mirroring install.sh / VpDirs: + * VP_HOME (deprecated) → existing ~/.vite-plus → absolute VP_DATA_DIR → + * XDG_DATA_HOME / platform default. + * + * Relative VP_DATA_DIR is ignored (same as XDG / install.sh). + */ +function resolveInstallDataDir(): string { + if (process.env.VP_HOME) { + return path.resolve(process.env.VP_HOME); + } + const legacy = path.join(os.homedir(), '.vite-plus'); + if (existsSync(legacy)) { + return legacy; + } + const vpDataDir = process.env.VP_DATA_DIR; + if (vpDataDir && path.isAbsolute(vpDataDir)) { + return vpDataDir; + } + if (isWindows) { + const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'); + return path.join(localAppData, 'vite-plus', 'data'); + } + const xdgData = process.env.XDG_DATA_HOME; + if (xdgData && path.isAbsolute(xdgData)) { + return path.join(xdgData, 'vite-plus'); + } + return path.join(os.homedir(), '.local', 'share', 'vite-plus'); +} + function removeInstallPath(targetPath: string) { if (!isWindows) { rmSync(targetPath, { recursive: true, force: true }); diff --git a/rfcs/directory-layout.md b/rfcs/directory-layout.md new file mode 100644 index 0000000000..b58db06b17 --- /dev/null +++ b/rfcs/directory-layout.md @@ -0,0 +1,218 @@ +# RFC: Split Directory Layout via `VpDirs` + +## Status + +**Partially implemented** — fresh-install split layout + centralized resolution ship in [#2346](https://github.com/voidzero-dev/vite-plus/pull/2346) (closes [#827](https://github.com/voidzero-dev/vite-plus/issues/827)). Automatic on-disk migration and full `VP_HOME` cleanup are follow-ups ([#2371](https://github.com/voidzero-dev/vite-plus/issues/2371), [#2372](https://github.com/voidzero-dev/vite-plus/issues/2372)). + +## Background + +Vite+ historically stores the entire global install under a single monolithic root: + +```text +~/.vite-plus/ +├── bin/ # shims (vp, node, npm, …) +├── current → / +├── / # CLI payload + node_modules +├── js_runtime/ +├── package_manager/ +├── packages/ +├── bins/ +├── cache/ +├── env, env.fish, … # shell env scripts +├── config.json +└── … +``` + +That layout is simple to install and document, but it conflicts with platform conventions: + +1. **XDG / platform split** — binaries, data, cache, config, and state belong in different roots (`~/.local/bin`, `~/.local/share`, `~/.cache`, `~/.config`, `~/.local/state` on Unix; analogous Local/Roaming app dirs on Windows). +2. **PATH hygiene** — a dedicated `~/.local/bin` (or `%LOCALAPPDATA%\vite-plus\bin`) is the usual place for user tools; burying shims under a private tree forces a custom PATH entry forever. +3. **Scattered path construction** — call sites historically joined `~/.vite-plus/...` or read `VP_HOME` ad hoc, making layout changes error-prone. +4. **Testing friction** — snapshot and CI setups pin `VP_HOME` to force a single tree, which couples fixtures to the legacy shape. + +## Goals + +1. **Centralize** all on-disk category roots and first-level data subdirectories in `vp_shared::VpDirs` so no call site invents `~/.vite-plus/...` or reads `XDG_*` itself. +2. **Default fresh installs** to the split XDG / platform layout. +3. **Grandfather** existing default installs that still live at `~/.vite-plus` (or `./.vite-plus`) without moving files in this phase. +4. Keep **`VP_HOME` as a deprecated full-root pin** for custom roots and older scripts; prefer `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR`. +5. Align **installers** (`install.sh`, `install.ps1`, `vp-setup`, `install-global-cli`) with the same resolution strategy as the CLI. +6. Support **implode, env setup, trampoline, upgrade check**, and related flows on both layouts. + +## Non-Goals (this phase) + +1. **Automatic migration** of an existing `~/.vite-plus` tree into split roots (tracked in [#2372](https://github.com/voidzero-dev/vite-plus/issues/2372); see [Follow-up: layout migrate](#follow-up-layout-migrate-on-vp-upgrade)). +2. Removing the deprecated **read** of `VP_HOME` from the resolution chain (cleanup of _setters_ is [#2371](https://github.com/voidzero-dev/vite-plus/issues/2371)). +3. Introducing a new distribution channel or package format. +4. Changing the on-disk _payload_ shape under a version directory (`current`, version dirs, `node_modules`). + +## Design + +### Ownership: `VpDirs` + +`crates/vp_shared/src/dirs.rs` owns only: + +| Layer | Examples | +| -------------------------- | ---------------------------------------------------------------------------------- | +| **Category roots** | `bin_dir`, `data_dir`, `cache_dir`, `config_dir`, `state_dir` | +| **First-level under data** | `current_dir`, `js_runtime_dir`, `package_manager_dir`, `packages_dir`, `bins_dir` | + +Files and deeper trees (`config.json`, `js_runtime/node/`, `resolve_cache.json`, …) are joined by the owning feature, not by `VpDirs`. + +Resolution is **recomputed on every call** (cheap joins + at most a few existence checks) so process env changes and test `temp_env` overrides are observed without a process-wide path cache. + +`VpDirs::is_legacy_layout()` is true when `data_dir` is named `.vite-plus` and `bin_dir` is that root’s `bin` child (the legacy on-disk mapping). + +### Resolution chain + +Each category walks the following ordered sources. A source either proposes a path or is skipped. Acceptance is gated by a fallthrough strategy: + +| Strategy | Meaning | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Set** | Use the proposed path as soon as it is configured (env overrides, platform defaults, first install). | +| **Exist** | Use the path only when the install root already exists on disk (so grandfathering does not claim empty paths; bin/cache under an existing root are accepted even if those subdirs are not created yet). | + +**Unix:** + +```text +VP_HOME + → existing ~/.vite-plus + → existing ./.vite-plus + → VP_BIN_DIR / VP_DATA_DIR / VP_CACHE_DIR + → XDG_BIN_HOME / XDG_DATA_HOME / XDG_CACHE_HOME / XDG_CONFIG_HOME / XDG_STATE_HOME + → platform defaults +``` + +**Windows:** same head; no XDG step — after `VP_*_DIR`, fall through to Windows platform defaults (`%LOCALAPPDATA%` / `%APPDATA%`). + +| Source | Fallthrough | Behavior | +| ------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`VP_HOME`** (deprecated) | Set | When set, pins the **legacy monolithic mapping** under that root for all categories. | +| **`~/.vite-plus`** | Exist | When that directory exists, use the legacy mapping under it. | +| **`./.vite-plus`** | Exist | When present in the process cwd, same legacy mapping (project-local / tests). | +| **`VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR`** | Set | Absolute per-category overrides (relative values ignored). Only the categories with a corresponding variable are proposed here. | +| **`XDG_*`** (Unix) | Set | Absolute `XDG_BIN_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, with app name `vite-plus` on data/cache/config/state. Bin may follow uv-style `$XDG_DATA_HOME/../bin` when only data home is set. | +| **Platform defaults** | Set | See [category mapping](#category-mapping) (Unix XDG-style homes under `$HOME`, Windows Local/Roaming app dirs). | + +Relative `VP_*` / `XDG_*` values are treated as unset (per the XDG Base Directory Spec for the spec-defined variables). `XDG_BIN_HOME` is not part of the XDG spec; it is a uv-style convention, as is the `$XDG_DATA_HOME/../bin` bin fallback above. + +### Category mapping + +| Category | Split default (Unix) | Split default (Windows) | Legacy (`VP_HOME` / existing `~/.vite-plus`) | +| ---------- | -------------------------- | -------------------------------- | -------------------------------------------- | +| **bin** | `~/.local/bin` | `%LOCALAPPDATA%\vite-plus\bin` | `/bin` | +| **data** | `~/.local/share/vite-plus` | `%LOCALAPPDATA%\vite-plus\data` | `` | +| **cache** | `~/.cache/vite-plus` | `%LOCALAPPDATA%\vite-plus\cache` | `/cache` | +| **config** | `~/.config/vite-plus` | `%APPDATA%\vite-plus` | `` | +| **state** | `~/.local/state/vite-plus` | `%LOCALAPPDATA%\vite-plus\state` | `` | + +Under **data** (both layouts): version directories, `current`, `js_runtime`, `package_manager`, `packages`, `bins`. + +### Installers + +`install.sh` / `install.ps1` (and local `install-global-cli`) mirror the CLI chain: + +1. If `VP_HOME` is set → install into that root as **legacy**. +2. Else if default `~/.vite-plus` (or Windows equivalent) **exists** → **grandfather** legacy root. +3. Else → **split** data/bin/config (and related) using `VP_*_DIR` / `XDG_*` / platform defaults. + +Installers deliberately omit the `./.vite-plus` (cwd-local) step from the CLI chain; that step exists only for project-local/test resolution and must not influence where an installer puts files. + +There is a **single** install script per platform (no separate `legacy_install.*`). Local bootstrap does **not** force `VP_HOME`; it resolves the install data dir the same way. + +Env scripts are written under **config** (split: `~/.config/vite-plus/env*`; legacy: still under the monolithic root). PATH entries point at the resolved **bin** directory. + +### Global CLI → JS children + +Under the split layout, the global CLI injects `VP_BIN_DIR` / `VP_DATA_DIR` / `VP_CACHE_DIR` into JS child processes when those vars are unset, so the NAPI / local CLI and JS tools see the same category roots without re-implementing XDG logic. + +### User impact (this phase) + +| Install state | Behavior | +| ----------------------- | ------------------------------------------------------------------------------------- | +| Existing `~/.vite-plus` | Paths unchanged (grandfathered until migrate follow-up). | +| Custom `VP_HOME` | Still works as deprecated full-root pin. | +| Fresh install | Split layout; typically only `~/.local/bin` (or Windows bin dir) needs to be on PATH. | + +### Verified scenarios (manual) + +1. **Fresh split** — empty home, no `VP_HOME`: install lands on `~/.local/share/vite-plus`, shims in `~/.local/bin`, env under `~/.config/vite-plus`; `vp --version` works. +2. **Legacy reuse** — pre-seeded `~/.vite-plus` with markers: `install-global-cli` upgrades `current` in place, keeps prior version dirs and markers, does not create split roots; runtime writes `resolve_cache.json` under `~/.vite-plus/cache`; `vp env doctor` reports home `~/.vite-plus`. + +## Follow-up: `VP_HOME` cleanup + +**Issue:** [#2371](https://github.com/voidzero-dev/vite-plus/issues/2371) + +Much of the repo still **sets** or **assumes** `VP_HOME` as the primary install root (especially PTY snapshot tests). That fights the split layout. + +**Direction:** + +- Prefer `VP_*_DIR` / XDG / platform defaults in tests, CI, and docs. +- Keep **reading** `VP_HOME` in `VpDirs` as a deprecated custom-root pin until a later deprecation cut. +- Snapshot suite should not require a permanent `VP_HOME=~/.vite-plus` baseline for the happy path. + +## Follow-up: layout migrate on `vp upgrade` + +**Issue:** [#2372](https://github.com/voidzero-dev/vite-plus/issues/2372) + +After the split layout ships, stop grandfathering forever: on `vp upgrade` (and installer reinstall where appropriate), migrate a **default** legacy install into split roots and remove `~/.vite-plus`. + +### Mapping (Unix defaults; Windows analogous) + +| From under `~/.vite-plus` | To | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Version dirs, `current`, runtimes, package managers, packages, bins metadata | data dir (`~/.local/share/vite-plus`, …) | +| `config.json` (and durable user config) | config dir | +| Session / upgrade-check state | state dir | +| Shims / env scripts | **regenerate** into bin + config (do not copy relative links or stale env text) | +| Resolve cache | **drop** (rebuild on next use) | + +Custom `VP_HOME` roots are **out of auto-migrate** (remain a deprecated pin only). + +### Locked design constraints + +1. **Copy-first**, then delete the legacy root (no long-lived tombstone unless Windows file locks force a deferred cleanup). +2. **Never delete** the legacy root before split `data/current` (and critical shims) are verified. +3. **Conflict** if the split data root already holds a healthy unrelated install — abort with a clear message. +4. Shell profiles that source `~/.vite-plus/env*` must be **rewritten or cleaned** to the new config env path. +5. **N-1 path**: users on a pre-migrate CLI may re-exec after upgrade and/or re-run the install script as the guaranteed fallback. +6. **Immediate** removal of the default legacy root after a successful migrate (product choice: do not leave an empty grandfather forever). + +### Acceptance (migrate) + +- Machine with only default `~/.vite-plus` runs `vp upgrade` once → split roots populated, `~/.vite-plus` gone, shims/env work after shell restart. +- Fresh install never creates `~/.vite-plus`. +- CI covers legacy → split upgrade (in addition to released-CLI and fresh-split install paths). + +> Experimental migrate work was sketched on a side branch and **withdrawn** from the dirs PR because moving a live global install is high risk (Windows locks, PATH/profile cutover, concurrent shims). Re-land only behind careful staging and tests. + +## Testing strategy + +| Layer | What | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unit | `vp_shared` resolution / `is_legacy_layout` / fallthrough cases | +| Install CI | `test-standalone-install`: released CLI (often `VP_HOME`-pinned for pre-split packages) + local-build fresh split + grandfather / upgrade-adjacent jobs | +| Snapshots | Layout isolation without assuming a permanent monolithic home (improve further in #2371) | +| Manual | Fresh split install; existing legacy reuse with new CLI | + +## Alternatives considered + +1. **Always split; never grandfather** — breaks existing installs until migrate is perfect. Rejected for the first ship. +2. **Always migrate on first run of any command** — surprising and dangerous mid-script. Prefer explicit `vp upgrade` / installer. +3. **Keep monolithic forever; only document XDG as optional** — fails PATH and platform conventions for new users. +4. **Separate install scripts for legacy vs split** — duplicated drift; replaced by one script with resolution branching. + +## Open questions (post-migrate) + +1. Deprecation timeline for **reading** `VP_HOME` after most users are on split roots. +2. Whether cwd-local `./.vite-plus` remains useful for tests after snapshot fixtures stop relying on it. +3. Windows deferred delete / reboot policy when locked files block legacy root removal. + +## References + +- Issue: [#827](https://github.com/voidzero-dev/vite-plus/issues/827) +- Implementation PR: [#2346](https://github.com/voidzero-dev/vite-plus/pull/2346) +- Follow-ups: [#2371](https://github.com/voidzero-dev/vite-plus/issues/2371), [#2372](https://github.com/voidzero-dev/vite-plus/issues/2372) +- Code: `crates/vp_shared/src/dirs.rs`, `crates/vp_shared/src/dirs/resolution.rs` +- Installers: `packages/cli/install.sh`, `packages/cli/install.ps1`, `packages/tools/src/install-global-cli.ts` +- Related RFCs: [upgrade-command](./upgrade-command.md), [implode-command](./implode-command.md), [env-command](./env-command.md), [js-runtime](./js-runtime.md)