diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c704a1e2d..445eb9d72a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,7 +133,9 @@ jobs: target: x86_64-unknown-linux-gnu - name: Build CLI - run: pnpm bootstrap-cli:ci + run: | + pnpm bootstrap-cli:ci + echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH - name: Print help for built-in commands run: | @@ -195,6 +197,14 @@ jobs: - name: Build CLI run: | pnpm bootstrap-cli:ci + if [[ "$RUNNER_OS" == "Windows" ]]; then + echo "$USERPROFILE\.vite-plus-dev\bin" >> $GITHUB_PATH + else + echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + fi + + - name: Verify CLI installation + run: | which vp vp --version vp -h @@ -212,6 +222,137 @@ jobs: - name: Run CLI lint run: vp run lint + - name: Test global package install (powershell) + if: ${{ matrix.os == 'windows-latest' }} + shell: pwsh + run: | + echo "PATH: $env:Path" + where.exe node + where.exe npm + where.exe npx + where.exe vp + vp env doctor + + # Test 1: Install a JS-based CLI (typescript) + vp install -g typescript + tsc --version + where.exe tsc + + # Test 2: Verify the package was installed correctly + Get-ChildItem "$env:USERPROFILE\.vite-plus-dev\packages\typescript\" + Get-ChildItem "$env:USERPROFILE\.vite-plus-dev\bin\" + + # Test 3: Uninstall + vp uninstall -g typescript + + # Test 4: Verify uninstall removed shim + Write-Host "Checking bin dir after uninstall:" + Get-ChildItem "$env:USERPROFILE\.vite-plus-dev\bin\" + $shimPath = "$env:USERPROFILE\.vite-plus-dev\bin\tsc.cmd" + if (Test-Path $shimPath) { + Write-Error "tsc shim file still exists at $shimPath" + exit 1 + } + Write-Host "tsc shim removed successfully" + + # Test 5: use session + vp env use 18 + node --version + vp env doctor + vp env use --unset + node --version + + - name: Test global package install (cmd) + if: ${{ matrix.os == 'windows-latest' }} + shell: cmd + run: | + echo "PATH: %PATH%" + where.exe node + where.exe npm + where.exe npx + where.exe vp + + vp env use 18 + node --version + vp env use --unset + node --version + + vp env doctor + + REM Test 1: Install a JS-based CLI (typescript) + vp install -g typescript + tsc --version + where.exe tsc + + REM Test 2: Verify the package was installed correctly + dir "%USERPROFILE%\.vite-plus-dev\packages\typescript\" + dir "%USERPROFILE%\.vite-plus-dev\bin\" + + REM Test 3: Uninstall + vp uninstall -g typescript + + REM Test 4: Verify uninstall removed shim (.cmd wrapper) + echo Checking bin dir after uninstall: + dir "%USERPROFILE%\.vite-plus-dev\bin\" + if exist "%USERPROFILE%\.vite-plus-dev\bin\tsc.cmd" ( + echo Error: tsc.cmd shim file still exists + exit /b 1 + ) + echo tsc.cmd shim removed successfully + + REM Test 5: Verify shell script was also removed (for Git Bash) + if exist "%USERPROFILE%\.vite-plus-dev\bin\tsc" ( + echo Error: tsc shell script still exists + exit /b 1 + ) + echo tsc shell script removed successfully + + REM Test 6: use session + vp env use 18 + node --version + vp env doctor + vp env use --unset + node --version + + - name: Test global package install (bash) + run: | + echo "PATH: $PATH" + ls -la ~/.vite-plus-dev/ + ls -la ~/.vite-plus-dev/bin/ + which node + which npm + which npx + which vp + vp env doctor + + # Test 1: Install a JS-based CLI (typescript) + vp install -g typescript + tsc --version + which tsc + + # Test 2: Verify the package was installed correctly + ls -la ~/.vite-plus-dev/packages/typescript/ + ls -la ~/.vite-plus-dev/bin/ + + # Test 3: Uninstall + vp uninstall -g typescript + + # Test 4: Verify uninstall removed shim + echo "Checking bin dir after uninstall:" + ls -la ~/.vite-plus-dev/bin/ + if [ -f ~/.vite-plus-dev/bin/tsc ]; then + echo "Error: tsc shim file still exists at ~/.vite-plus-dev/bin/tsc" + exit 1 + fi + echo "tsc shim removed successfully" + + # Test 5: use session + vp env use 18 + node --version + vp env doctor + vp env use --unset + node --version + - name: Install Playwright browsers run: pnpx playwright install chromium @@ -255,7 +396,9 @@ jobs: target: x86_64-unknown-linux-gnu - name: Build CLI - run: pnpm bootstrap-cli:ci + run: | + pnpm bootstrap-cli:ci + echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH - name: Run local CLI `vite install` run: | diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 490f0a1778..30464c94d7 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -103,7 +103,7 @@ jobs: - name: Pack packages into tgz run: | - pnpm bootstrap-cli:ci + pnpm copy-cli-binding mkdir -p tmp/tgz cd packages/core && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. cd packages/test && pnpm pack --pack-destination ../../tmp/tgz && cd ../.. @@ -263,11 +263,15 @@ jobs: name: vite-plus-packages-${{ matrix.os }} path: tmp/tgz - - name: Install vite-plus from tgz in ${{ matrix.project.name }} + - name: Install vp CLI + shell: bash + run: | + node $GITHUB_WORKSPACE/packages/tools/src/install-global-cli.ts vp --tgz $GITHUB_WORKSPACE/tmp/tgz/vite-plus-cli-0.0.0.tgz + echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + + - name: Migrate in ${{ matrix.project.name }} working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }} run: | - # install global CLI first - npm install -g $GITHUB_WORKSPACE/tmp/tgz/vite-plus-cli-0.0.0.tgz node $GITHUB_WORKSPACE/ecosystem-ci/patch-project.ts ${{ matrix.project.name }} vp install --no-frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f2ae43b544..a06ffe0a84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,7 @@ jobs: run: | pnpm exec tool replace-file-content packages/cli/binding/Cargo.toml 'version = "0.0.0"' 'version = "${{ env.VERSION }}"' pnpm exec tool replace-file-content packages/global/binding/Cargo.toml 'version = "0.0.0"' 'version = "${{ env.VERSION }}"' + pnpm exec tool replace-file-content crates/vite_global_cli/Cargo.toml 'version = "0.0.0"' 'version = "${{ env.VERSION }}"' - name: Configure Git for access to vite-task run: git config --global url."https://x-access-token:${{ secrets.VITE_TASK_TOKEN }}@github.com/".insteadOf "ssh://git@github.com/" @@ -265,11 +266,11 @@ jobs: - name: Create release body run: | if [[ "${{ inputs.npm_tag }}" == "latest" ]]; then - INSTALL_BASH="curl -fsSL https://viteplus.dev/install.sh | bash" - INSTALL_PS1="irm https://viteplus.dev/install.ps1 | iex" + INSTALL_BASH="curl -fsSL https://staging.viteplus.dev/install.sh | bash" + INSTALL_PS1="irm https://staging.viteplus.dev/install.ps1 | iex" else - INSTALL_BASH="curl -fsSL https://viteplus.dev/install.sh | VITE_PLUS_VERSION=${{ env.VERSION }} bash" - INSTALL_PS1="\\\$env:VITE_PLUS_VERSION=\\\"${{ env.VERSION }}\\\"; irm https://viteplus.dev/install.ps1 | iex" + INSTALL_BASH="curl -fsSL https://staging.viteplus.dev/install.sh | VITE_PLUS_VERSION=${{ env.VERSION }} bash" + INSTALL_PS1="\\\$env:VITE_PLUS_VERSION=\\\"${{ env.VERSION }}\\\"; irm https://staging.viteplus.dev/install.ps1 | iex" fi cat > ./RELEASE_BODY.md <> $GITHUB_PATH + + - name: Verify bin setup + run: | + # Verify bin directory was created by vp env --setup + BIN_PATH="$HOME/.vite-plus/bin" + ls -al "$BIN_PATH" + if [ ! -d "$BIN_PATH" ]; then + echo "Error: Bin directory not found: $BIN_PATH" + exit 1 + fi + + # Verify shim executables exist + for shim in node npm npx; do + if [ ! -f "$BIN_PATH/$shim" ]; then + echo "Error: Shim not found: $BIN_PATH/$shim" + exit 1 + fi + echo "Found shim: $BIN_PATH/$shim" + done + + # Verify vp env doctor works + vp env doctor + vp env run --node 24 -- node -p "process.versions" + + which node + which npm + which npx + which vp + + test-install-sh-arm64: + name: Test install.sh (Linux ARM64 glibc via QEMU) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + with: + platforms: arm64 + + - name: Run install.sh in ARM64 container + run: | + docker run --rm --platform linux/arm64 \ + -v "${{ github.workspace }}:/workspace" \ + -e VITE_PLUS_VERSION=test \ + ubuntu:20.04 bash -c " + ls -al ~/ + apt-get update && apt-get install -y curl ca-certificates + cat /workspace/packages/global/install.sh | bash + if [ -f ~/.profile ]; then + source ~/.profile + elif [ -f ~/.bashrc ]; then + source ~/.bashrc + else + export PATH="$HOME/.vite-plus/bin:$PATH" + fi + + vp --version + vp --help + vp dlx print-current-version + + # Verify bin setup + BIN_PATH=\"\$HOME/.vite-plus/bin\" + if [ ! -d \"\$BIN_PATH\" ]; then + echo \"Error: Bin directory not found: \$BIN_PATH\" + exit 1 + fi + for shim in node npm npx; do + if [ ! -f \"\$BIN_PATH/\$shim\" ]; then + echo \"Error: Shim not found: \$BIN_PATH/\$shim\" + exit 1 + fi + echo \"Found shim: \$BIN_PATH/\$shim\" + done + vp env doctor + + export VITE_LOG=trace + vp env run --node 24 -- node -p \"process.versions\" + + # FIXME: qemu: uncaught target signal 11 (Segmentation fault) - core dumped + # vp new create-vite --no-interactive --no-agent -- hello --no-interactive -t vanilla + # cd hello && vp run build + " + + test-install-ps1: + name: Test install.ps1 (Windows x64) + runs-on: windows-latest + permissions: + contents: read + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Run install.ps1 + shell: pwsh + env: + VITE_PLUS_VERSION: test + run: | + & ./packages/global/install.ps1 + + - name: Set PATH + shell: bash + run: | + echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH + + - name: Verify installation on powershell + shell: pwsh + working-directory: ${{ runner.temp }} + run: | + # Print PATH from environment + echo "PATH: $env:Path" + vp --version + vp --help + # $env:VITE_LOG = "trace" + # test new command + vp new create-vite --no-interactive --no-agent -- hello --no-interactive -t vanilla + cd hello && vp run build + + - name: Verify bin setup on powershell + shell: pwsh + run: | + # Verify bin directory was created by vp env --setup + $binPath = "$env:USERPROFILE\.vite-plus\bin" + Get-ChildItem -Force $binPath + if (-not (Test-Path $binPath)) { + Write-Error "Bin directory not found: $binPath" + exit 1 + } + + # Verify shim executables exist (all use .cmd wrappers on Windows) + $expectedShims = @("node.cmd", "npm.cmd", "npx.cmd") + foreach ($shim in $expectedShims) { + $shimFile = Join-Path $binPath $shim + if (-not (Test-Path $shimFile)) { + Write-Error "Shim not found: $shimFile" + exit 1 + } + Write-Host "Found shim: $shimFile" + } + where.exe node + where.exe npm + where.exe npx + where.exe vp + + # Verify vp env doctor works + $env:Path = "$env:USERPROFILE\.vite-plus\bin;$env:Path" + vp env doctor + vp env run --node 24 -- node -p "process.versions" + + - name: Verify installation on cmd + shell: cmd + working-directory: ${{ runner.temp }} + run: | + echo PATH: %PATH% + dir "%USERPROFILE%\.vite-plus\bin" + + REM test new command + vp new create-vite --no-interactive --no-agent -- hello-cmd --no-interactive -t vanilla + cd hello-cmd && vp run build + + - name: Verify bin setup on cmd + shell: cmd + run: | + REM Verify bin directory was created by vp env --setup + set "BIN_PATH=%USERPROFILE%\.vite-plus\bin" + dir "%BIN_PATH%" + + REM Verify shim executables exist (Windows uses .cmd wrappers) + for %%s in (node.cmd npm.cmd npx.cmd vp.cmd) do ( + if not exist "%BIN_PATH%\%%s" ( + echo Error: Shim not found: %BIN_PATH%\%%s + exit /b 1 + ) + echo Found shim: %BIN_PATH%\%%s + ) + + where node + where npm + where npx + where vp + + REM Verify vp env doctor works + vp env doctor + vp env run --node 24 -- node -p "process.versions" + + - name: Verify installation on bash + shell: bash + working-directory: ${{ runner.temp }} + run: | + echo "PATH: $PATH" + ls -al ~/.vite-plus + ls -al ~/.vite-plus/bin + + vp --version + vp --help + # test new command + vp new create-vite --no-interactive --no-agent -- hello-bash --no-interactive -t vanilla + cd hello-bash && vp run build + + - name: Verify bin setup on bash + shell: bash + run: | + # Verify bin directory was created by vp env --setup + BIN_PATH="$HOME/.vite-plus/bin" + ls -al "$BIN_PATH" + if [ ! -d "$BIN_PATH" ]; then + echo "Error: Bin directory not found: $BIN_PATH" + exit 1 + fi + + # Verify .cmd wrappers exist (for cmd.exe/PowerShell) + for shim in node.cmd npm.cmd npx.cmd vp.cmd; do + if [ ! -f "$BIN_PATH/$shim" ]; then + echo "Error: .cmd wrapper not found: $BIN_PATH/$shim" + exit 1 + fi + echo "Found .cmd wrapper: $BIN_PATH/$shim" + done + + # Verify shell scripts exist (for Git Bash) + for shim in node npm npx vp; do + if [ ! -f "$BIN_PATH/$shim" ]; then + echo "Error: Shell script not found: $BIN_PATH/$shim" + exit 1 + fi + echo "Found shell script: $BIN_PATH/$shim" + done + + # Verify vp env doctor works + vp env doctor + vp env run --node 24 -- node -p "process.versions" + + which node + which npm + which npx + which vp diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59cd242647..46dfa799f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,9 @@ pnpm bootstrap-cli vp-dev --version ``` -Note: Local development installs the CLI as `vp-dev` (package name: `vite-plus-cli-dev`) to avoid overriding the published `vite-plus-cli` package and its `vp` bin name. In CI, `pnpm bootstrap-cli:ci` installs it as `vp`. +This installs the CLI to `~/.vite-plus-dev` (separate from the release version at `~/.vite-plus`) and creates a `vp-dev` wrapper script that sets the correct `VITE_PLUS_HOME` environment variable. + +Note: In CI, `pnpm bootstrap-cli:ci` installs `vp` (without the wrapper) to the same `~/.vite-plus-dev` directory. ## Workflow for build and test diff --git a/Cargo.lock b/Cargo.lock index e985ebe4e6..c7ba236162 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -762,6 +771,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -2316,6 +2339,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -5703,6 +5750,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.28" @@ -5743,6 +5799,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "seccompiler" version = "0.5.0" @@ -5922,6 +5984,32 @@ dependencies = [ "version_check", ] +[[package]] +name = "serial_test" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "sha1" version = "0.10.6" @@ -7041,7 +7129,12 @@ dependencies = [ name = "vite_global_cli" version = "0.0.0" dependencies = [ + "chrono", "clap", + "owo-colors", + "serde", + "serde_json", + "serial_test", "tempfile", "thiserror 2.0.17", "tokio", @@ -7053,6 +7146,7 @@ dependencies = [ "vite_shared", "vite_str", "vite_workspace", + "which", ] [[package]] @@ -7153,8 +7247,11 @@ name = "vite_shared" version = "0.0.0" dependencies = [ "directories", + "serde", + "serde_json", "tracing-subscriber", "vite_path", + "vite_str", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ba649d1737..479eff45aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ bincode = "2.0.1" bstr = { version = "1.12.0", default-features = false, features = ["alloc", "std"] } bitflags = "2.9.1" blake3 = "1.8.2" +chrono = { version = "0.4", features = ["serde"] } clap = "4.5.40" commondir = "1.0.0" cow-utils = "0.1.3" @@ -122,6 +123,7 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" serde_yaml = "0.9.34" serde_yml = "0.0.12" +serial_test = "3.2.0" sha1 = "0.10.6" sha2 = "0.10.9" simdutf8 = "0.1.5" diff --git a/crates/vite_command/src/lib.rs b/crates/vite_command/src/lib.rs index 1b2a0e5891..9b122cecaf 100644 --- a/crates/vite_command/src/lib.rs +++ b/crates/vite_command/src/lib.rs @@ -279,7 +279,8 @@ mod tests { .get(&RelativePathBuf::new("package.json").unwrap()) .expect("package.json should be in path accesses"); assert!(path_access.contains(AccessMode::WRITE)); - assert!(!path_access.contains(AccessMode::READ)); + // Note: We don't assert !READ because writeFileSync may trigger reads + // depending on Node.js internals and OS filesystem behavior } #[tokio::test] diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index 3c90f68b46..b652f4839c 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -12,10 +12,14 @@ name = "vp" path = "src/main.rs" [dependencies] +chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } +serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } +owo-colors = { workspace = true } vite_error = { workspace = true } vite_install = { workspace = true } vite_js_runtime = { workspace = true } @@ -23,8 +27,10 @@ vite_path = { workspace = true } vite_shared = { workspace = true } vite_str = { workspace = true } vite_workspace = { workspace = true } +which = { workspace = true } [dev-dependencies] +serial_test = { workspace = true } tempfile = { workspace = true } [lints] diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 7e0b9c385c..40d0bed8b5 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -45,7 +45,7 @@ pub enum Commands { // Category A: Package Manager Commands // ========================================================================= /// Install all dependencies, or add packages if package names are provided - #[command(alias = "i")] + #[command(visible_alias = "i")] Install { /// Do not install devDependencies #[arg(short = 'P', long)] @@ -135,6 +135,10 @@ pub enum Commands { #[arg(short = 'g', long)] global: bool, + /// Node.js version to use for global installation (only with -g) + #[arg(long, requires = "global")] + node: Option, + /// Packages to add (if provided, acts as `vp add`) #[arg(required = false)] packages: Option>, @@ -194,6 +198,10 @@ pub enum Commands { #[arg(short = 'g', long)] global: bool, + /// Node.js version to use for global installation (only with -g) + #[arg(long, requires = "global")] + node: Option, + /// Packages to add #[arg(required = true)] packages: Vec, @@ -204,7 +212,7 @@ pub enum Commands { }, /// Remove packages from dependencies - #[command(alias = "rm", alias = "un", alias = "uninstall")] + #[command(visible_alias = "rm", visible_alias = "un", visible_alias = "uninstall")] Remove { /// Only remove from `devDependencies` (pnpm-specific) #[arg(short = 'D', long)] @@ -234,6 +242,10 @@ pub enum Commands { #[arg(short = 'g', long)] global: bool, + /// Preview what would be removed without actually removing (only with -g) + #[arg(long, requires = "global")] + dry_run: bool, + /// Packages to remove #[arg(required = true)] packages: Vec, @@ -244,7 +256,7 @@ pub enum Commands { }, /// Update packages to their latest versions - #[command(alias = "up")] + #[command(visible_alias = "up")] Update { /// Update to latest version (ignore semver range) #[arg(short = 'L', long)] @@ -299,7 +311,7 @@ pub enum Commands { }, /// Deduplicate dependencies - #[command(alias = "ddp")] + #[command(visible_alias = "ddp")] Dedupe { /// Check if deduplication would make changes #[arg(long)] @@ -365,7 +377,7 @@ pub enum Commands { }, /// Show why a package is installed - #[command(alias = "explain")] + #[command(visible_alias = "explain")] Why { /// Package(s) to check #[arg(required = true)] @@ -429,7 +441,7 @@ pub enum Commands { }, /// View package information from the registry - #[command(alias = "view", alias = "show")] + #[command(visible_alias = "view", visible_alias = "show")] Info { /// Package name with optional version #[arg(required = true)] @@ -448,7 +460,7 @@ pub enum Commands { }, /// Link packages for local development - #[command(alias = "ln")] + #[command(visible_alias = "ln")] Link { /// Package name or directory to link #[arg(value_name = "PACKAGE|DIR")] @@ -573,6 +585,214 @@ pub enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + + /// Manage Node.js versions + Env(EnvArgs), +} + +/// Arguments for the `env` command +#[derive(clap::Args, Debug)] +#[command(after_help = "\ +Examples: + vp env setup # Create shims for node, npm, npx + vp env setup --refresh # Force refresh shims + vp env doctor # Check environment configuration + vp env default 20.18.0 # Set default Node.js version + vp env on # Use vite-plus managed Node.js + vp env off # Prefer system Node.js + vp env which node # Show which node binary will be used + vp env pin 20.18.0 # Pin Node.js version in current directory + vp env pin lts # Pin to latest LTS version + vp env unpin # Remove pinned version + vp env list # List locally installed Node.js versions + vp env list-remote # List available remote Node.js versions + vp env list-remote --lts # List only LTS versions + vp env list-remote 20 # List Node.js 20.x versions + vp env install 20.18.0 # Install Node.js 20.18.0 + vp env install # Install version from .node-version / package.json + vp env install lts # Install latest LTS version + vp env uninstall 20.18.0 # Uninstall Node.js 20.18.0 + vp env use 20 # Use Node.js 20 for this shell session + vp env use lts # Use latest LTS for this shell session + vp env use # Use project version for this shell session + vp env use --unset # Remove session override + vp env run --node 20 node -v # Run 'node -v' with Node.js 20 + vp env run --node lts npm i # Run 'npm i' with latest LTS + vp env run node -v # Shim mode (version auto-resolved) + vp env run npm install # Shim mode (version auto-resolved) + +Global Packages: + vp install -g # Install a global package + vp uninstall -g # Uninstall a global package + vp update -g [package] # Update global package(s) + vp list -g [package] # List installed global packages")] +pub struct EnvArgs { + /// Show current environment information + #[arg(long)] + pub current: bool, + + /// Output in JSON format + #[arg(long, requires = "current")] + pub json: bool, + + /// Print shell snippet to set environment for current session + #[arg(long)] + pub print: bool, + + /// Subcommand (e.g., 'default', 'setup', 'doctor', 'which') + #[command(subcommand)] + pub command: Option, +} + +/// Subcommands for the `env` command +#[derive(clap::Subcommand, Debug)] +pub enum EnvSubcommands { + /// Set or show the global default Node.js version + Default { + /// Version to set as default (e.g., "20.18.0", "lts", "latest") + /// If not provided, shows the current default + version: Option, + }, + + /// Enable managed mode - shims always use vite-plus managed Node.js + On, + + /// Enable system-first mode - shims prefer system Node.js, fallback to managed + Off, + + /// Create or update shims in VITE_PLUS_HOME/bin + Setup { + /// Force refresh shims even if they exist + #[arg(long)] + refresh: bool, + /// Only create env files (skip shims and instructions) + #[arg(long)] + env_only: bool, + }, + + /// Run diagnostics and show environment status + Doctor, + + /// Show path to the tool that would be executed + Which { + /// Tool name (node, npm, or npx) + tool: String, + }, + + /// Pin a Node.js version in the current directory (creates .node-version) + Pin { + /// Version to pin (e.g., "20.18.0", "lts", "latest", "^20.0.0") + /// If not provided, shows the current pinned version + version: Option, + + /// Remove the .node-version file from current directory + #[arg(long)] + unpin: bool, + + /// Skip pre-downloading the pinned version + #[arg(long)] + no_install: bool, + + /// Overwrite existing .node-version without confirmation + #[arg(long)] + force: bool, + }, + + /// Remove the .node-version file from current directory (alias for `pin --unpin`) + Unpin, + + /// List locally installed Node.js versions + #[command(visible_alias = "ls")] + List { + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// List available Node.js versions from the registry + #[command(name = "list-remote", visible_alias = "ls-remote")] + ListRemote { + /// Filter versions by pattern (e.g., "20" for 20.x versions) + pattern: Option, + + /// Show only LTS versions + #[arg(long)] + lts: bool, + + /// Show all versions (not just recent) + #[arg(long)] + all: bool, + + /// Output as JSON + #[arg(long)] + json: bool, + + /// Version sorting order + #[arg(long, value_enum, default_value_t = SortingMethod::Asc)] + sort: SortingMethod, + }, + + /// Run a command with a specific Node.js version + Run { + /// Node.js version to use (e.g., "20.18.0", "lts", "^20.0.0") + /// If not provided and command is node/npm/npx or a global package binary, + /// version is resolved automatically (same as shim behavior) + #[arg(long)] + node: Option, + + /// npm version to use (optional, defaults to bundled) + #[arg(long)] + npm: Option, + + /// Command and arguments to run + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, + }, + + /// Uninstall a Node.js version + #[command(visible_alias = "uni")] + Uninstall { + /// Version to uninstall (e.g., "20.18.0") + #[arg(required = true)] + version: String, + }, + + /// Install a Node.js version + #[command(visible_alias = "i")] + Install { + /// Version to install (e.g., "20", "20.18.0", "lts", "latest") + /// If not provided, installs the version from .node-version or package.json + version: Option, + }, + + /// Use a specific Node.js version for this shell session + Use { + /// Version to use (e.g., "20", "20.18.0", "lts", "latest") + /// If not provided, reads from .node-version or package.json + version: Option, + + /// Remove session override (revert to file-based resolution) + #[arg(long)] + unset: bool, + + /// Skip auto-installation if version not present + #[arg(long)] + no_install: bool, + + /// Suppress output if version is already active + #[arg(long)] + silent_if_unchanged: bool, + }, +} + +/// Version sorting order for list-remote command +#[derive(clap::ValueEnum, Clone, Debug, Default)] +pub enum SortingMethod { + /// Sort versions in ascending order (earliest to latest) + #[default] + Asc, + /// Sort versions in descending order (latest to earliest) + Desc, } /// Package manager subcommands @@ -625,7 +845,7 @@ pub enum PmCommands { }, /// List installed packages - #[command(alias = "ls")] + #[command(visible_alias = "ls")] List { /// Package pattern to filter pattern: Option, @@ -688,7 +908,7 @@ pub enum PmCommands { }, /// View package information from the registry - #[command(alias = "info", alias = "show")] + #[command(visible_alias = "info", visible_alias = "show")] View { /// Package name with optional version #[arg(required = true)] @@ -762,7 +982,7 @@ pub enum PmCommands { }, /// Manage package owners - #[command(subcommand, alias = "author")] + #[command(subcommand, visible_alias = "author")] Owner(OwnerCommands), /// Manage package cache @@ -777,7 +997,7 @@ pub enum PmCommands { }, /// Manage package manager configuration - #[command(subcommand, alias = "c")] + #[command(subcommand, visible_alias = "c")] Config(ConfigCommands), } @@ -857,7 +1077,7 @@ pub enum ConfigCommands { #[derive(Subcommand, Debug, Clone)] pub enum OwnerCommands { /// List package owners - #[command(alias = "ls")] + #[command(visible_alias = "ls")] List { /// Package name package: String, @@ -953,6 +1173,7 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result { @@ -960,6 +1181,20 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result Result { + // Handle global install via vite-plus managed global install + if global { + use crate::commands::env::global_install; + for package in &packages { + if let Err(e) = global_install::install(package, node.as_deref(), false).await { + eprintln!("Failed to install {}: {}", package, e); + return Ok(exit_status(1)); + } + } + return Ok(ExitStatus::default()); + } + let save_dependency_type = determine_save_dependency_type(save_dev, save_peer, save_optional, save_prod); @@ -1049,9 +1297,22 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result { + // Handle global uninstall via vite-plus managed global install + if global { + use crate::commands::env::global_install; + for package in &packages { + if let Err(e) = global_install::uninstall(package, dry_run).await { + eprintln!("Failed to uninstall {}: {}", package, e); + return Ok(exit_status(1)); + } + } + return Ok(ExitStatus::default()); + } + RemoveCommand::new(cwd) .execute( &packages, @@ -1082,6 +1343,29 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result { + // Handle global update via vite-plus managed global install + if global { + use crate::commands::env::{global_install, package_metadata::PackageMetadata}; + + let packages_to_update = if packages.is_empty() { + let all = PackageMetadata::list_all().await?; + if all.is_empty() { + println!("No global packages installed."); + return Ok(ExitStatus::default()); + } + all.iter().map(|p| p.name.clone()).collect::>() + } else { + packages.clone() + }; + for package in &packages_to_update { + if let Err(e) = global_install::install(package, None, false).await { + eprintln!("Failed to update {}: {}", package, e); + return Ok(exit_status(1)); + } + } + return Ok(ExitStatus::default()); + } + UpdateCommand::new(cwd) .execute( &packages, @@ -1225,6 +1509,22 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result commands::delegate::execute(cwd, "preview", &args).await, Commands::Cache { args } => commands::delegate::execute(cwd, "cache", &args).await, + + Commands::Env(args) => commands::env::execute(cwd, args).await, + } +} + +/// Create an exit status with the given code. +fn exit_status(code: i32) -> ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(code << 8) + } + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(code as u32) } } @@ -1252,20 +1552,22 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command { {bold}cache{reset} Manage the task cache {bold}new{reset} Generate a new project {bold}run{reset} Run tasks + {bold}env{reset} Manage Node.js versions {bold_underline}Package Manager Commands:{reset} - {bold}install{reset} Install all dependencies, or add packages if package names are provided - {bold}add{reset} Add packages to dependencies - {bold}remove{reset} Remove packages from dependencies - {bold}dedupe{reset} Deduplicate dependencies by removing older versions - {bold}dlx{reset} Execute a package binary without installing it as a dependency - {bold}info{reset} View package information from the registry - {bold}link{reset} Link packages for local development - {bold}outdated{reset} Check for outdated packages - {bold}pm{reset} Forward a command to the package manager - {bold}unlink{reset} Unlink packages - {bold}update{reset} Update packages to their latest versions - {bold}why{reset} Show why a package is installed + {bold}install, i{reset} Install all dependencies, or add packages if package names are provided + {bold}add{reset} Add packages to dependencies + {bold}remove, rm, un, uninstall{reset} Remove packages from dependencies + {bold}dedupe, ddp{reset} Deduplicate dependencies by removing older versions + {bold}dlx{reset} Execute a package binary without installing it as a dependency + {bold}info, view, show{reset} View package information from the registry + {bold}link, ln{reset} Link packages for local development + {bold}list, ls{reset} List installed packages + {bold}outdated{reset} Check for outdated packages + {bold}pm{reset} Forward a command to the package manager + {bold}unlink{reset} Unlink packages + {bold}update, up{reset} Update packages to their latest versions + {bold}why, explain{reset} Show why a package is installed " ); let help_template = format!( diff --git a/crates/vite_global_cli/src/commands/env/bin_config.rs b/crates/vite_global_cli/src/commands/env/bin_config.rs new file mode 100644 index 0000000000..80a02e3d8c --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/bin_config.rs @@ -0,0 +1,263 @@ +//! Per-binary configuration storage for global packages. +//! +//! Each binary installed via `vp install -g` gets a config file at +//! `~/.vite-plus/bins/{name}.json` that tracks which package owns it. +//! This enables: +//! - Deterministic binary-to-package resolution +//! - Conflict detection when installing packages with overlapping binaries +//! - Safe uninstall (only removes binaries owned by the package) + +use serde::{Deserialize, Serialize}; +use vite_path::AbsolutePathBuf; + +use super::config::get_vite_plus_home; +use crate::error::Error; + +/// Config for a single binary, stored at ~/.vite-plus/bins/{name}.json +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BinConfig { + /// Binary name + pub name: String, + /// Package that installed this binary + pub package: String, + /// Package version + pub version: String, + /// Node.js version used + pub node_version: String, +} + +impl BinConfig { + /// Create a new BinConfig. + pub fn new(name: String, package: String, version: String, node_version: String) -> Self { + Self { name, package, version, node_version } + } + + /// Get the bins directory path (~/.vite-plus/bins/). + pub fn bins_dir() -> Result { + Ok(get_vite_plus_home()?.join("bins")) + } + + /// Get the path to a binary's config file. + pub fn path(bin_name: &str) -> Result { + Ok(Self::bins_dir()?.join(format!("{bin_name}.json"))) + } + + /// Load config for a binary. + pub async fn load(bin_name: &str) -> Result, Error> { + let path = Self::path(bin_name)?; + if !tokio::fs::try_exists(&path).await.unwrap_or(false) { + return Ok(None); + } + let content = tokio::fs::read_to_string(&path).await?; + let config: Self = serde_json::from_str(&content) + .map_err(|e| Error::ConfigError(format!("Failed to parse bin config: {e}").into()))?; + Ok(Some(config)) + } + + /// Save config for a binary. + pub async fn save(&self) -> Result<(), Error> { + let path = Self::path(&self.name)?; + + // Ensure bins directory exists + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let content = serde_json::to_string_pretty(self).map_err(|e| { + Error::ConfigError(format!("Failed to serialize bin config: {e}").into()) + })?; + tokio::fs::write(&path, content).await?; + Ok(()) + } + + /// Delete config for a binary. + pub async fn delete(bin_name: &str) -> Result<(), Error> { + let path = Self::path(bin_name)?; + if tokio::fs::try_exists(&path).await.unwrap_or(false) { + tokio::fs::remove_file(&path).await?; + } + Ok(()) + } + + /// Find all binaries installed by a package. + /// + /// This is used as a fallback during uninstall when PackageMetadata is missing + /// (orphan recovery). + pub async fn find_by_package(package_name: &str) -> Result, Error> { + let bins_dir = Self::bins_dir()?; + if !tokio::fs::try_exists(&bins_dir).await.unwrap_or(false) { + return Ok(Vec::new()); + } + + let mut bins = Vec::new(); + let mut entries = tokio::fs::read_dir(&bins_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") { + if let Ok(content) = tokio::fs::read_to_string(&path).await { + if let Ok(config) = serde_json::from_str::(&content) { + if config.package == package_name { + bins.push(config.name); + } + } + } + } + } + + Ok(bins) + } +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + use tempfile::TempDir; + + use super::*; + + #[tokio::test] + #[serial] + async fn test_save_and_load() { + let temp_dir = TempDir::new().unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + let config = BinConfig::new( + "tsc".to_string(), + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + ); + config.save().await.unwrap(); + + let loaded = BinConfig::load("tsc").await.unwrap(); + assert!(loaded.is_some()); + let loaded = loaded.unwrap(); + assert_eq!(loaded.name, "tsc"); + assert_eq!(loaded.package, "typescript"); + assert_eq!(loaded.version, "5.0.0"); + assert_eq!(loaded.node_version, "20.18.0"); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_find_by_package() { + let temp_dir = TempDir::new().unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Create configs for typescript (tsc, tsserver) + let tsc = BinConfig::new( + "tsc".to_string(), + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + ); + tsc.save().await.unwrap(); + + let tsserver = BinConfig::new( + "tsserver".to_string(), + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + ); + tsserver.save().await.unwrap(); + + // Create config for eslint + let eslint = BinConfig::new( + "eslint".to_string(), + "eslint".to_string(), + "9.0.0".to_string(), + "22.0.0".to_string(), + ); + eslint.save().await.unwrap(); + + // Find by package + let ts_bins = BinConfig::find_by_package("typescript").await.unwrap(); + assert_eq!(ts_bins.len(), 2); + assert!(ts_bins.contains(&"tsc".to_string())); + assert!(ts_bins.contains(&"tsserver".to_string())); + + let eslint_bins = BinConfig::find_by_package("eslint").await.unwrap(); + assert_eq!(eslint_bins.len(), 1); + assert!(eslint_bins.contains(&"eslint".to_string())); + + let nonexistent_bins = BinConfig::find_by_package("nonexistent").await.unwrap(); + assert!(nonexistent_bins.is_empty()); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_delete() { + let temp_dir = TempDir::new().unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + let config = BinConfig::new( + "tsc".to_string(), + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + ); + config.save().await.unwrap(); + + // Verify it exists + let loaded = BinConfig::load("tsc").await.unwrap(); + assert!(loaded.is_some()); + + // Delete + BinConfig::delete("tsc").await.unwrap(); + + // Verify it's gone + let loaded = BinConfig::load("tsc").await.unwrap(); + assert!(loaded.is_none()); + + // Delete again should not error + BinConfig::delete("tsc").await.unwrap(); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_load_nonexistent() { + let temp_dir = TempDir::new().unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + let loaded = BinConfig::load("nonexistent").await.unwrap(); + assert!(loaded.is_none()); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } +} diff --git a/crates/vite_global_cli/src/commands/env/config.rs b/crates/vite_global_cli/src/commands/env/config.rs new file mode 100644 index 0000000000..097f99938b --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/config.rs @@ -0,0 +1,1216 @@ +//! Configuration and version resolution for the env command. +//! +//! This module provides: +//! - VITE_PLUS_HOME path resolution +//! - Version resolution with priority order +//! - Config file management + +use serde::{Deserialize, Serialize}; +use vite_js_runtime::{ + NodeProvider, VersionSource, normalize_version, read_package_json, resolve_node_version, +}; +use vite_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")] +pub enum ShimMode { + /// Shims always use vite-plus managed Node.js + #[default] + Managed, + /// Shims prefer system Node.js, fallback to managed if not found + SystemFirst, +} + +/// User configuration stored in VITE_PLUS_HOME/config.json +#[derive(Serialize, Deserialize, Default, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// Default Node.js version when no project version file is found + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_node_version: Option, + /// Shim mode for tool resolution + #[serde(default, skip_serializing_if = "is_default_shim_mode")] + pub shim_mode: ShimMode, +} + +/// Check if shim mode is the default (for skip_serializing_if) +fn is_default_shim_mode(mode: &ShimMode) -> bool { + *mode == ShimMode::Managed +} + +/// Version resolution result +#[derive(Debug)] +pub struct VersionResolution { + /// The resolved version string (e.g., "20.18.0") + pub version: String, + /// The source of the version (e.g., ".node-version", "engines.node", "default") + pub source: String, + /// Path to the source file (if applicable) + pub source_path: Option, + /// Project root directory (if version came from a project file) + pub project_root: Option, + /// Whether the original version spec was a range (e.g., "20", "^20.0.0", "lts/*") + /// Range versions should use time-based cache expiry instead of mtime-only validation + pub is_range: bool, +} + +/// Get the VITE_PLUS_HOME directory path. +/// +/// Uses `VITE_PLUS_HOME` environment variable if set, otherwise defaults to `~/.vite-plus`. +pub fn get_vite_plus_home() -> Result { + Ok(vite_shared::get_vite_plus_home()?) +} + +/// Get the bin directory path (~/.vite-plus/bin/). +pub fn get_bin_dir() -> Result { + Ok(get_vite_plus_home()?.join("bin")) +} + +/// Get the packages directory path (~/.vite-plus/packages/). +pub fn get_packages_dir() -> Result { + Ok(get_vite_plus_home()?.join("packages")) +} + +/// Get the tmp directory path for staging (~/.vite-plus/tmp/). +pub fn get_tmp_dir() -> Result { + Ok(get_vite_plus_home()?.join("tmp")) +} + +/// Get the node_modules directory path for a package. +/// +/// npm uses different layouts on Unix vs Windows: +/// - Unix: `/lib/node_modules/` +/// - Windows: `/node_modules/` +/// +/// This function probes both paths and returns the one that exists, +/// falling back to the platform default if neither exists. +pub fn get_node_modules_dir(prefix: &AbsolutePath, package_name: &str) -> AbsolutePathBuf { + // Try Unix layout first (lib/node_modules) + let unix_path = prefix.join("lib").join("node_modules").join(package_name); + if unix_path.as_path().exists() { + return unix_path; + } + + // Try Windows layout (node_modules) + let win_path = prefix.join("node_modules").join(package_name); + if win_path.as_path().exists() { + return win_path; + } + + // Neither exists - return platform default (for pre-creation checks) + #[cfg(windows)] + { + win_path + } + #[cfg(not(windows))] + { + unix_path + } +} + +/// Get the config file path. +pub fn get_config_path() -> Result { + Ok(get_vite_plus_home()?.join(CONFIG_FILE)) +} + +/// Load configuration from disk. +pub async fn load_config() -> Result { + let config_path = get_config_path()?; + + if !tokio::fs::try_exists(&config_path).await.unwrap_or(false) { + return Ok(Config::default()); + } + + let content = tokio::fs::read_to_string(&config_path).await?; + let config: Config = serde_json::from_str(&content)?; + Ok(config) +} + +/// Save configuration to disk. +pub async fn save_config(config: &Config) -> Result<(), Error> { + let config_path = get_config_path()?; + let vite_plus_home = get_vite_plus_home()?; + + // Ensure directory exists + tokio::fs::create_dir_all(&vite_plus_home).await?; + + let content = serde_json::to_string_pretty(config)?; + tokio::fs::write(&config_path, content).await?; + Ok(()) +} + +/// Environment variable for per-shell session Node.js version override. +/// Set by `vp env use` command. +pub const VERSION_ENV_VAR: &str = "VITE_PLUS_NODE_VERSION"; + +/// 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_vite_plus_home()?.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 content = tokio::fs::read_to_string(&path).await.ok()?; + let trimmed = content.trim().to_string(); + if trimmed.is_empty() { None } else { Some(trimmed) } +} + +/// 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 content = std::fs::read_to_string(path.as_path()).ok()?; + let trimmed = content.trim().to_string(); + if trimmed.is_empty() { None } else { Some(trimmed) } +} + +/// 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()?; + // Ensure parent directory exists + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&path, version).await?; + Ok(()) +} + +/// Delete the session version file. Ignores "not found" errors. +pub async fn delete_session_version() -> Result<(), Error> { + let path = get_session_version_path()?; + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + +/// Resolve Node.js version for a directory. +/// +/// Resolution order: +/// 0. `VITE_PLUS_NODE_VERSION` env var (session override from `vp env use`) +/// 1. `.session-node-version` file (session override written by `vp env use` for shell-wrapper-less environments) +/// 2. `.node-version` file in current or parent directories +/// 3. `package.json#engines.node` in current or parent directories +/// 4. `package.json#devEngines.runtime` in current or parent directories +/// 5. User default from config.json +/// 6. Latest LTS version +pub async fn resolve_version(cwd: &AbsolutePath) -> Result { + // Session override via environment variable (set by `vp env use`) + if let Ok(env_version) = std::env::var(VERSION_ENV_VAR) { + let env_version = env_version.trim(); + if !env_version.is_empty() { + return Ok(VersionResolution { + version: env_version.to_string(), + source: VERSION_ENV_VAR.into(), + source_path: None, + project_root: None, + is_range: false, + }); + } + } + + // Session override via file (written by `vp env use` for shell-wrapper-less environments) + if let Some(session_version) = read_session_version().await { + return Ok(VersionResolution { + version: session_version, + source: SESSION_VERSION_FILE.into(), + source_path: get_session_version_path().ok(), + project_root: None, + is_range: false, + }); + } + + let provider = NodeProvider::new(); + + // Use shared version resolution with directory walking + let resolution = resolve_node_version(cwd, true) + .await + .map_err(|e| Error::ConfigError(e.to_string().into()))?; + + if let Some(resolution) = resolution { + // Validate version before attempting resolution + // If invalid, warning is printed by normalize_version and we fall through to defaults + if let Some(validated) = + normalize_version(&resolution.version.clone().into(), &resolution.source.to_string()) + { + // Detect if the original version spec was a range (not exact) + // This includes partial versions (20, 20.18), semver ranges (^20.0.0), LTS aliases, and "latest" + let is_range = NodeProvider::is_version_alias(&validated) + || !NodeProvider::is_exact_version(&validated); + + let resolved = resolve_version_string(&validated, &provider).await?; + return Ok(VersionResolution { + version: resolved, + source: resolution.source.to_string(), + source_path: resolution.source_path, + project_root: resolution.project_root, + is_range, + }); + } + + // Invalid .node-version - check package.json sources in the same directory + // This mirrors the fallback logic in download_runtime_for_project() + if matches!(resolution.source, VersionSource::NodeVersionFile) { + if let Some(project_root) = &resolution.project_root { + let package_json_path = project_root.join("package.json"); + if let Ok(Some(pkg)) = read_package_json(&package_json_path).await { + // Try engines.node + if let Some(engines_node) = pkg + .engines + .as_ref() + .and_then(|e| e.node.clone()) + .and_then(|v| normalize_version(&v, "engines.node")) + { + let resolved = resolve_version_string(&engines_node, &provider).await?; + let is_range = NodeProvider::is_lts_alias(&engines_node) + || !NodeProvider::is_exact_version(&engines_node); + return Ok(VersionResolution { + version: resolved, + source: "engines.node".into(), + source_path: Some(package_json_path), + project_root: Some(project_root.clone()), + is_range, + }); + } + + // Try devEngines.runtime + if let Some(dev_engines) = pkg + .dev_engines + .as_ref() + .and_then(|de| de.runtime.as_ref()) + .and_then(|rt| rt.find_by_name("node")) + .map(|r| r.version.clone()) + .filter(|v| !v.is_empty()) + .and_then(|v| normalize_version(&v, "devEngines.runtime")) + { + let resolved = resolve_version_string(&dev_engines, &provider).await?; + let is_range = NodeProvider::is_lts_alias(&dev_engines) + || !NodeProvider::is_exact_version(&dev_engines); + return Ok(VersionResolution { + version: resolved, + source: "devEngines.runtime".into(), + source_path: Some(package_json_path), + project_root: Some(project_root.clone()), + is_range, + }); + } + } + } + } + // Invalid version and no valid package.json sources - fall through to user default or LTS + } + + // CLI-specific: Check user default from config + let config = load_config().await?; + if let Some(default_version) = config.default_node_version { + let resolved = resolve_version_alias(&default_version, &provider).await?; + // Check if default is an alias or range + let is_alias = matches!(default_version.to_lowercase().as_str(), "lts" | "latest"); + let is_range = is_alias + || NodeProvider::is_lts_alias(&default_version) + || !NodeProvider::is_exact_version(&default_version); + return Ok(VersionResolution { + version: resolved, + source: "default".into(), + // Don't set source_path for aliases (lts, latest) so cache can refresh + source_path: if is_alias { None } else { Some(get_config_path()?) }, + project_root: None, + is_range, + }); + } + + // CLI-specific: Fall back to latest LTS + let version = provider.resolve_latest_version().await?; + Ok(VersionResolution { + version: version.to_string(), + source: "lts".into(), + source_path: None, + project_root: None, + is_range: true, // LTS fallback is always a range (re-resolve periodically) + }) +} + +/// Resolve a version string to an exact version. +async fn resolve_version_string(version: &str, provider: &NodeProvider) -> Result { + // Check for LTS alias first (lts/*, lts/iron, lts/-1) + if NodeProvider::is_lts_alias(version) { + let resolved = provider.resolve_lts_alias(version).await?; + return Ok(resolved.to_string()); + } + + // Check for "latest" alias - resolves to absolute latest version (including non-LTS) + if NodeProvider::is_latest_alias(version) { + let resolved = provider.resolve_version("*").await?; + return Ok(resolved.to_string()); + } + + // If it's already an exact version, use it directly + if NodeProvider::is_exact_version(version) { + // Strip v prefix if present (e.g., "v20.18.0" -> "20.18.0") + let normalized = version.strip_prefix('v').unwrap_or(version); + return Ok(normalized.to_string()); + } + + // Resolve from network (semver ranges) + let resolved = provider.resolve_version(version).await?; + Ok(resolved.to_string()) +} + +/// Resolve version alias (lts, latest) to an exact version. +/// +/// Wraps resolution errors with a user-friendly message showing valid examples. +pub async fn resolve_version_alias( + version: &str, + provider: &NodeProvider, +) -> Result { + let result = match version.to_lowercase().as_str() { + "lts" => { + let resolved = provider.resolve_latest_version().await?; + Ok(resolved.to_string()) + } + "latest" => { + // Resolve * to get the absolute latest version + let resolved = provider.resolve_version("*").await?; + Ok(resolved.to_string()) + } + _ => resolve_version_string(version, provider).await, + }; + result.map_err(|e| match e { + Error::RuntimeDownload( + vite_js_runtime::Error::SemverRange(_) + | vite_js_runtime::Error::NoMatchingVersion { .. }, + ) => Error::Other( + format!( + "Invalid Node.js version: \"{version}\"\n\n\ + Valid examples:\n \ + vp env use 20 # Latest Node.js 20.x\n \ + vp env use 20.18.0 # Exact version\n \ + vp env use lts # Latest LTS version\n \ + vp env use latest # Latest version" + ) + .into(), + ), + other => other, + }) +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + use tempfile::TempDir; + use vite_js_runtime::VersionSource; + use vite_path::AbsolutePathBuf; + + use super::*; + + #[test] + fn test_get_node_modules_dir_probes_unix_layout() { + let temp_dir = TempDir::new().unwrap(); + let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create Unix layout + let unix_path = temp_dir.path().join("lib").join("node_modules").join("test-pkg"); + std::fs::create_dir_all(&unix_path).unwrap(); + + let result = get_node_modules_dir(&prefix, "test-pkg"); + assert!( + result.as_path().ends_with("lib/node_modules/test-pkg"), + "Should find Unix layout: {}", + result.as_path().display() + ); + } + + #[test] + fn test_get_node_modules_dir_probes_windows_layout() { + let temp_dir = TempDir::new().unwrap(); + let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create Windows layout (no lib/) + let win_path = temp_dir.path().join("node_modules").join("test-pkg"); + std::fs::create_dir_all(&win_path).unwrap(); + + let result = get_node_modules_dir(&prefix, "test-pkg"); + assert!( + result.as_path().ends_with("node_modules/test-pkg") + && !result.as_path().to_string_lossy().contains("lib/node_modules"), + "Should find Windows layout: {}", + result.as_path().display() + ); + } + + #[test] + fn test_get_node_modules_dir_prefers_unix_layout_when_both_exist() { + let temp_dir = TempDir::new().unwrap(); + let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create both layouts + let unix_path = temp_dir.path().join("lib").join("node_modules").join("test-pkg"); + let win_path = temp_dir.path().join("node_modules").join("test-pkg"); + std::fs::create_dir_all(&unix_path).unwrap(); + std::fs::create_dir_all(&win_path).unwrap(); + + let result = get_node_modules_dir(&prefix, "test-pkg"); + // Unix layout is checked first + assert!( + result.as_path().ends_with("lib/node_modules/test-pkg"), + "Should prefer Unix layout when both exist: {}", + result.as_path().display() + ); + } + + #[test] + fn test_get_node_modules_dir_returns_platform_default_when_neither_exists() { + let temp_dir = TempDir::new().unwrap(); + let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Don't create any directories + let result = get_node_modules_dir(&prefix, "test-pkg"); + + #[cfg(windows)] + assert!( + result.as_path().ends_with("node_modules/test-pkg") + && !result.as_path().to_string_lossy().contains("lib/node_modules"), + "Should return Windows default: {}", + result.as_path().display() + ); + + #[cfg(not(windows))] + assert!( + result.as_path().ends_with("lib/node_modules/test-pkg"), + "Should return Unix default: {}", + result.as_path().display() + ); + } + + #[test] + fn test_get_node_modules_dir_handles_scoped_packages() { + let temp_dir = TempDir::new().unwrap(); + let prefix = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create Unix layout for scoped package + let unix_path = temp_dir.path().join("lib").join("node_modules").join("@scope").join("pkg"); + std::fs::create_dir_all(&unix_path).unwrap(); + + let result = get_node_modules_dir(&prefix, "@scope/pkg"); + assert!( + result.as_path().ends_with("lib/node_modules/@scope/pkg"), + "Should find scoped package: {}", + result.as_path().display() + ); + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_from_node_version_file() { + // SAFETY: Clear session override so .node-version is used + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + } + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + assert!(resolution.source_path.is_some()); + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_walks_up_directory() { + // SAFETY: Clear session override so .node-version is used + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + } + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version in parent + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // Create subdirectory + let subdir = temp_path.join("subdir"); + tokio::fs::create_dir(&subdir).await.unwrap(); + + let resolution = resolve_version(&subdir).await.unwrap(); + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + } + + #[tokio::test] + async fn test_resolve_version_from_engines_node() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with engines.node + // Also create an empty .node-version to stop walk-up from finding parent project's version + let package_json = r#"{"engines":{"node":"20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Use resolve_node_version directly with walk_up=false to test engines.node specifically + let resolution = resolve_node_version(&temp_path, false) + .await + .map_err(|e| Error::ConfigError(e.to_string().into())) + .unwrap() + .unwrap(); + + assert_eq!(&*resolution.version, "20.18.0"); + assert_eq!(resolution.source, VersionSource::EnginesNode); + } + + #[tokio::test] + async fn test_resolve_version_from_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with devEngines.runtime + let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"20.18.0"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Use resolve_node_version directly with walk_up=false to test devEngines specifically + let resolution = resolve_node_version(&temp_path, false) + .await + .map_err(|e| Error::ConfigError(e.to_string().into())) + .unwrap() + .unwrap(); + + assert_eq!(&*resolution.version, "20.18.0"); + assert_eq!(resolution.source, VersionSource::DevEnginesRuntime); + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_node_version_takes_priority() { + // SAFETY: Clear session override so .node-version is used + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + } + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create both .node-version and package.json with engines.node + tokio::fs::write(temp_path.join(".node-version"), "22.0.0\n").await.unwrap(); + let package_json = r#"{"engines":{"node":"20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + // .node-version should take priority + assert_eq!(resolution.version, "22.0.0"); + assert_eq!(resolution.source, ".node-version"); + } + + #[tokio::test] + async fn test_resolve_version_string_strips_v_prefix() { + let provider = NodeProvider::new(); + // Test that v-prefixed exact versions are normalized + let result = resolve_version_string("v20.18.0", &provider).await.unwrap(); + assert_eq!(result, "20.18.0", "v prefix should be stripped from exact versions"); + } + + #[tokio::test] + #[ignore] // Requires running outside of any Node.js project (walk-up finds .node-version) + async fn test_resolve_version_alias_default_no_source_path() { + // Create config with lts as default + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // SAFETY: This test runs in isolation + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + let config = Config { default_node_version: Some("lts".to_string()), ..Default::default() }; + save_config(&config).await.unwrap(); + + // Create empty dir to resolve version in (no .node-version) + let test_dir = temp_path.join("test-project"); + tokio::fs::create_dir_all(&test_dir).await.unwrap(); + + let resolution = resolve_version(&test_dir).await.unwrap(); + assert_eq!(resolution.source, "default"); + assert!(resolution.source_path.is_none(), "Alias defaults should not have source_path"); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[ignore] // Requires running outside of any Node.js project (walk-up finds .node-version) + async fn test_resolve_version_exact_default_has_source_path() { + // Create config with exact version as default + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // SAFETY: This test runs in isolation + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + let config = + Config { default_node_version: Some("20.18.0".to_string()), ..Default::default() }; + save_config(&config).await.unwrap(); + + // Create empty dir to resolve version in (no .node-version) + let test_dir = temp_path.join("test-project"); + tokio::fs::create_dir_all(&test_dir).await.unwrap(); + + let resolution = resolve_version(&test_dir).await.unwrap(); + assert_eq!(resolution.source, "default"); + assert!(resolution.source_path.is_some(), "Exact version defaults should have source_path"); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_invalid_node_version_falls_through_to_lts() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid-version\n").await.unwrap(); + + // SAFETY: Set VITE_PLUS_HOME to temp dir to avoid using user's config + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // resolve_version should NOT fail - it should fall through to LTS + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Should fall through to LTS since the .node-version is invalid + // and no user default is configured + assert_eq!(resolution.source, "lts"); + assert!(resolution.source_path.is_none()); + assert!(resolution.is_range, "LTS fallback should be marked as range"); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_invalid_node_version_falls_through_to_default() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file with invalid version + tokio::fs::write(temp_path.join(".node-version"), "not-a-version\n").await.unwrap(); + + // SAFETY: Set VITE_PLUS_HOME to temp dir + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Create config with a default version + let config = + Config { default_node_version: Some("20.18.0".to_string()), ..Default::default() }; + save_config(&config).await.unwrap(); + + // resolve_version should NOT fail - it should fall through to user default + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Should fall through to user default since .node-version is invalid + assert_eq!(resolution.source, "default"); + assert_eq!(resolution.version, "20.18.0"); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_invalid_node_version_falls_through_to_engines_node() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file with invalid version (typo or unsupported alias) + tokio::fs::write(temp_path.join(".node-version"), "laetst\n").await.unwrap(); + + // Create package.json with valid engines.node + let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // SAFETY: Set VITE_PLUS_HOME to temp dir to avoid using user's config + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // resolve_version should NOT fail - it should fall through to engines.node + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Should fall through to engines.node since .node-version is invalid + assert_eq!(resolution.source, "engines.node"); + // Version should be resolved from ^20.18.0 (a 20.x version) + assert!( + resolution.version.starts_with("20."), + "Expected version to start with '20.', got: {}", + resolution.version + ); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_invalid_node_version_falls_through_to_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); + + // Create package.json with devEngines.runtime but no engines.node + let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"^20.18.0"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // SAFETY: Set VITE_PLUS_HOME to temp dir to avoid using user's config + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // resolve_version should NOT fail - it should fall through to devEngines.runtime + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Should fall through to devEngines.runtime since .node-version is invalid + assert_eq!(resolution.source, "devEngines.runtime"); + // Version should be resolved from ^20.18.0 (a 20.x version) + assert!( + resolution.version.starts_with("20."), + "Expected version to start with '20.', got: {}", + resolution.version + ); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_latest_alias_in_node_version() { + // SAFETY: Clear session override so .node-version is used + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + } + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file with "latest" alias + tokio::fs::write(temp_path.join(".node-version"), "latest\n").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Should resolve from .node-version + assert_eq!(resolution.source, ".node-version"); + // "latest" is a range (should be re-resolved periodically) + assert!(resolution.is_range, "'latest' should be marked as a range"); + // Version should be at least v20.x + assert!( + resolution.version.starts_with("2") || resolution.version.starts_with("3"), + "Expected version to be at least v20.x, got: {}", + resolution.version + ); + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_env_var_takes_priority() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var(VERSION_ENV_VAR, "22.0.0"); + } + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // VITE_PLUS_NODE_VERSION should take priority over .node-version + assert_eq!(resolution.version, "22.0.0"); + assert_eq!(resolution.source, VERSION_ENV_VAR); + assert!(resolution.source_path.is_none()); + assert!(!resolution.is_range); + + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + } + } + + /// Verify that the env var source is accepted by `vp env install` (no-arg) source validation. + /// This is a regression test for a bug where `vp env use 24` followed by `vp env install` + /// would fail with "No Node.js version found in current project." + #[tokio::test] + #[serial] + async fn test_env_var_source_accepted_by_install_validation() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var(VERSION_ENV_VAR, "22.0.0"); + } + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // The install command uses this match to validate sources. + // VERSION_ENV_VAR must be accepted alongside project-file sources. + let accepted = matches!( + resolution.source.as_str(), + ".node-version" | "engines.node" | "devEngines.runtime" | VERSION_ENV_VAR + ); + assert!( + accepted, + "Install source validation should accept '{}' but it was rejected", + resolution.source + ); + assert_eq!(resolution.version, "22.0.0"); + + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + } + } + + // ── Session version file tests ── + + #[tokio::test] + #[serial] + async fn test_write_and_read_session_version() { + let temp_dir = TempDir::new().unwrap(); + // SAFETY: Isolate VITE_PLUS_HOME to temp dir + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Write a session version + write_session_version("22.0.0").await.unwrap(); + + // Read it back (async) + let version = read_session_version().await; + assert_eq!(version.as_deref(), Some("22.0.0")); + + // Read it back (sync) + let version_sync = read_session_version_sync(); + assert_eq!(version_sync.as_deref(), Some("22.0.0")); + + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_read_session_version_returns_none_when_missing() { + let temp_dir = TempDir::new().unwrap(); + // SAFETY: Isolate VITE_PLUS_HOME to temp dir (no session file exists) + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + assert!(read_session_version().await.is_none()); + assert!(read_session_version_sync().is_none()); + + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_read_session_version_returns_none_for_empty_file() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Write empty content + let path = get_session_version_path().unwrap(); + tokio::fs::create_dir_all(path.parent().unwrap()).await.unwrap(); + tokio::fs::write(&path, "").await.unwrap(); + + assert!(read_session_version().await.is_none()); + assert!(read_session_version_sync().is_none()); + + // Also test whitespace-only content + tokio::fs::write(&path, " \n ").await.unwrap(); + assert!(read_session_version().await.is_none()); + assert!(read_session_version_sync().is_none()); + + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_read_session_version_trims_whitespace() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + write_session_version("20.18.0").await.unwrap(); + + // Overwrite with whitespace-padded content + let path = get_session_version_path().unwrap(); + tokio::fs::write(&path, " 20.18.0 \n").await.unwrap(); + + assert_eq!(read_session_version().await.as_deref(), Some("20.18.0")); + assert_eq!(read_session_version_sync().as_deref(), Some("20.18.0")); + + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_delete_session_version() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Write then delete + write_session_version("22.0.0").await.unwrap(); + assert!(read_session_version().await.is_some()); + + delete_session_version().await.unwrap(); + assert!(read_session_version().await.is_none()); + + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_delete_session_version_ignores_missing_file() { + let temp_dir = TempDir::new().unwrap(); + unsafe { + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Deleting a non-existent file should succeed + let result = delete_session_version().await; + assert!(result.is_ok()); + + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_session_file_takes_priority_over_node_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // SAFETY: Clear env var and set VITE_PLUS_HOME to temp dir + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // Write session version file + write_session_version("22.0.0").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Session file should take priority over .node-version + assert_eq!(resolution.version, "22.0.0"); + assert_eq!(resolution.source, SESSION_VERSION_FILE); + assert!(resolution.source_path.is_some()); + assert!(!resolution.is_range); + + // Clean up + delete_session_version().await.unwrap(); + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_env_var_takes_priority_over_session_file() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // SAFETY: Set env var and VITE_PLUS_HOME + unsafe { + std::env::set_var(VERSION_ENV_VAR, "24.0.0"); + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Write session version file with different version + write_session_version("22.0.0").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Env var should take priority over session file + assert_eq!(resolution.version, "24.0.0"); + assert_eq!(resolution.source, VERSION_ENV_VAR); + + // Clean up + delete_session_version().await.unwrap(); + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_falls_through_when_no_session_file() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // SAFETY: Clear env var, set VITE_PLUS_HOME (no session file written) + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Should fall through to .node-version since no session file exists + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + /// Verify that the session file source is accepted by `vp env install` (no-arg) source validation. + /// This is a regression test ensuring `vp env use 24` followed by `vp env install` + /// works when the session file is the resolution source. + #[tokio::test] + #[serial] + async fn test_session_file_source_accepted_by_install_validation() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // SAFETY: Clear env var, set VITE_PLUS_HOME + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + std::env::set_var("VITE_PLUS_HOME", temp_dir.path()); + } + + // Write session version file + write_session_version("22.0.0").await.unwrap(); + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // The install command uses this match to validate sources. + // SESSION_VERSION_FILE must be accepted alongside project-file sources. + let accepted = matches!( + resolution.source.as_str(), + ".node-version" + | "engines.node" + | "devEngines.runtime" + | VERSION_ENV_VAR + | SESSION_VERSION_FILE + ); + assert!( + accepted, + "Install source validation should accept '{}' but it was rejected", + resolution.source + ); + assert_eq!(resolution.version, "22.0.0"); + assert_eq!(resolution.source, SESSION_VERSION_FILE); + + // Clean up + delete_session_version().await.unwrap(); + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_empty_env_var_is_ignored() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // Set empty env var - should be ignored + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var(VERSION_ENV_VAR, ""); + } + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Empty env var should be ignored, should fall through to .node-version + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + } + } + + #[tokio::test] + #[serial] + async fn test_resolve_version_whitespace_env_var_is_ignored() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // Set whitespace-only env var - should be ignored + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var(VERSION_ENV_VAR, " "); + } + + let resolution = resolve_version(&temp_path).await.unwrap(); + + // Whitespace env var should be ignored, should fall through to .node-version + assert_eq!(resolution.version, "20.18.0"); + assert_eq!(resolution.source, ".node-version"); + + unsafe { + std::env::remove_var(VERSION_ENV_VAR); + } + } +} diff --git a/crates/vite_global_cli/src/commands/env/current.rs b/crates/vite_global_cli/src/commands/env/current.rs new file mode 100644 index 0000000000..969a1f921b --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/current.rs @@ -0,0 +1,92 @@ +//! Current environment information command. +//! +//! Shows information about the current Node.js environment. + +use std::process::ExitStatus; + +use serde::Serialize; +use vite_path::AbsolutePathBuf; + +use super::config::resolve_version; +use crate::error::Error; + +/// JSON output structure for --current --json +#[derive(Serialize)] +struct CurrentEnvInfo { + version: String, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + project_root: Option, + node_path: String, + tool_paths: ToolPaths, +} + +#[derive(Serialize)] +struct ToolPaths { + node: String, + npm: String, + npx: String, +} + +/// Execute the current command. +pub async fn execute(cwd: AbsolutePathBuf, json: bool) -> Result { + let resolution = resolve_version(&cwd).await?; + + // Get the home directory for this version + let home_dir = vite_shared::get_vite_plus_home()? + .join("js_runtime") + .join("node") + .join(&resolution.version); + + #[cfg(windows)] + let (node_path, npm_path, npx_path) = + { (home_dir.join("node.exe"), home_dir.join("npm.cmd"), home_dir.join("npx.cmd")) }; + + #[cfg(not(windows))] + let (node_path, npm_path, npx_path) = { + ( + home_dir.join("bin").join("node"), + home_dir.join("bin").join("npm"), + home_dir.join("bin").join("npx"), + ) + }; + + if json { + let info = CurrentEnvInfo { + version: resolution.version.clone(), + source: resolution.source.clone(), + project_root: resolution + .project_root + .as_ref() + .map(|p| p.as_path().display().to_string()), + node_path: node_path.as_path().display().to_string(), + tool_paths: ToolPaths { + node: node_path.as_path().display().to_string(), + npm: npm_path.as_path().display().to_string(), + npx: npx_path.as_path().display().to_string(), + }, + }; + + let json_str = serde_json::to_string_pretty(&info)?; + println!("{json_str}"); + } else { + println!("Node.js Environment"); + println!("==================="); + println!(); + println!("Version: {}", resolution.version); + println!("Source: {}", resolution.source); + if let Some(path) = &resolution.source_path { + println!("Source Path: {}", path.as_path().display()); + } + if let Some(root) = &resolution.project_root { + println!("Project Root: {}", root.as_path().display()); + } + println!(); + println!("Tool Paths:"); + println!(" node: {}", node_path.as_path().display()); + println!(" npm: {}", npm_path.as_path().display()); + println!(" npx: {}", npx_path.as_path().display()); + } + + Ok(ExitStatus::default()) +} diff --git a/crates/vite_global_cli/src/commands/env/default.rs b/crates/vite_global_cli/src/commands/env/default.rs new file mode 100644 index 0000000000..4692951d5d --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/default.rs @@ -0,0 +1,105 @@ +//! Default version management command. +//! +//! Handles `vp env default [VERSION]` to set or show the global default Node.js version. + +use std::process::ExitStatus; + +use vite_path::AbsolutePathBuf; + +use super::config::{get_config_path, load_config, save_config}; +use crate::error::Error; + +/// Execute the default command. +pub async fn execute(_cwd: AbsolutePathBuf, version: Option) -> Result { + match version { + Some(v) => set_default(&v).await, + None => show_default().await, + } +} + +/// Show the current default version. +async fn show_default() -> Result { + let config = load_config().await?; + + match config.default_node_version { + Some(version) => { + println!("Default Node.js version: {version}"); + let config_path = get_config_path()?; + println!(" Set via: {}", config_path.as_path().display()); + + // If it's an alias, also show the resolved version + if version == "lts" || version == "latest" { + let provider = vite_js_runtime::NodeProvider::new(); + match resolve_alias(&version, &provider).await { + Ok(resolved) => println!(" Currently resolves to: {resolved}"), + Err(_) => {} + } + } + } + None => { + // No default configured - show what would be used + let provider = vite_js_runtime::NodeProvider::new(); + match provider.resolve_latest_version().await { + Ok(lts_version) => { + println!("No default version configured. Using latest LTS ({lts_version})."); + println!(" Run 'vp env default ' to set a default."); + } + Err(_) => { + println!("No default version configured."); + println!(" Run 'vp env default ' to set a default."); + } + } + } + } + + Ok(ExitStatus::default()) +} + +/// Set the default version. +async fn set_default(version: &str) -> Result { + let provider = vite_js_runtime::NodeProvider::new(); + + // Validate the version + let (display_version, store_version) = match version.to_lowercase().as_str() { + "lts" => { + // Resolve to show current value, but store "lts" as alias + let current_lts = provider.resolve_latest_version().await?; + (format!("lts (currently {})", current_lts), "lts".to_string()) + } + "latest" => { + // Resolve to show current value, but store "latest" as alias + let current_latest = provider.resolve_version("*").await?; + (format!("latest (currently {})", current_latest), "latest".to_string()) + } + _ => { + // Validate version exists + let resolved = if vite_js_runtime::NodeProvider::is_exact_version(version) { + version.to_string() + } else { + provider.resolve_version(version).await?.to_string() + }; + (resolved.clone(), resolved) + } + }; + + // Save to config + let mut config = load_config().await?; + config.default_node_version = Some(store_version); + save_config(&config).await?; + + println!("\u{2713} Default Node.js version set to {display_version}"); + + Ok(ExitStatus::default()) +} + +/// Resolve version alias to actual version. +async fn resolve_alias( + alias: &str, + provider: &vite_js_runtime::NodeProvider, +) -> Result { + match alias { + "lts" => Ok(provider.resolve_latest_version().await?.to_string()), + "latest" => Ok(provider.resolve_version("*").await?.to_string()), + _ => Ok(alias.to_string()), + } +} diff --git a/crates/vite_global_cli/src/commands/env/doctor.rs b/crates/vite_global_cli/src/commands/env/doctor.rs new file mode 100644 index 0000000000..529186b830 --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/doctor.rs @@ -0,0 +1,761 @@ +//! Doctor command implementation for environment diagnostics. + +use std::process::ExitStatus; + +use owo_colors::OwoColorize; +use vite_path::{AbsolutePathBuf, current_dir}; + +use super::config::{ + self, ShimMode, get_bin_dir, get_vite_plus_home, load_config, resolve_version, +}; +use crate::error::Error; + +/// Known version managers that might conflict +const KNOWN_VERSION_MANAGERS: &[(&str, &str)] = &[ + ("nvm", "NVM_DIR"), + ("fnm", "FNM_DIR"), + ("volta", "VOLTA_HOME"), + ("asdf", "ASDF_DIR"), + ("mise", "MISE_DIR"), + ("n", "N_PREFIX"), +]; + +/// Tools that should have shims +const SHIM_TOOLS: &[&str] = &["node", "npm", "npx"]; + +/// Column width for left-side keys in aligned output +const KEY_WIDTH: usize = 18; + +/// Print a section header (bold, with blank line before). +fn print_section(name: &str) { + println!(); + println!("{}", name.bold()); +} + +/// Print an aligned key-value line with a status indicator. +/// +/// `status` should be a colored string like "✓".green(), "✗".red(), etc. +/// Use `" "` for informational lines with no status. +fn print_check(status: &str, key: &str, value: &str) { + println!(" {status} {key: String { + if let Ok(home) = std::env::var("HOME") { + if let Some(suffix) = path.strip_prefix(&home) { + return format!("~{suffix}"); + } + } + path.to_string() +} + +/// Execute the doctor command. +pub async fn execute(cwd: AbsolutePathBuf) -> Result { + let mut has_errors = false; + + // Section: Installation + println!("{}", "Installation".bold()); + has_errors |= !check_vite_plus_home().await; + has_errors |= !check_bin_dir().await; + + // Section: Configuration + print_section("Configuration"); + check_shim_mode().await; + let ide_env_found = check_ide_integration(); + check_session_override(); + + // Section: PATH + print_section("PATH"); + has_errors |= !check_path().await; + + // Section: Version Resolution + print_section("Version Resolution"); + check_current_resolution(&cwd).await; + + // Section: Conflicts (conditional) + check_conflicts(); + + // Section: IDE Setup (conditional - only when env sourcing NOT found) + if !ide_env_found { + if let Ok(bin_dir) = get_bin_dir() { + print_ide_setup_guidance(&bin_dir); + } + } + + // Summary + println!(); + if has_errors { + println!( + "{}", + "\u{2717} Some issues found. Run the suggested commands to fix them.".red().bold() + ); + Ok(super::exit_status(1)) + } else { + println!("{}", "\u{2713} All checks passed".green().bold()); + Ok(ExitStatus::default()) + } +} + +/// Check VITE_PLUS_HOME directory. +async fn check_vite_plus_home() -> bool { + let home = match get_vite_plus_home() { + Ok(h) => h, + Err(e) => { + print_check( + &"\u{2717}".red().to_string(), + "VITE_PLUS_HOME", + &format!("{e}").red().to_string(), + ); + return false; + } + }; + + let display = abbreviate_home(&home.as_path().display().to_string()); + + if tokio::fs::try_exists(&home).await.unwrap_or(false) { + print_check(&"\u{2713}".green().to_string(), "VITE_PLUS_HOME", &display); + true + } else { + print_check( + &"\u{2717}".red().to_string(), + "VITE_PLUS_HOME", + &"does not exist".red().to_string(), + ); + print_hint("Run 'vp env setup' to create it."); + false + } +} + +/// 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, + }; + + if !tokio::fs::try_exists(&bin_dir).await.unwrap_or(false) { + print_check( + &"\u{2717}".red().to_string(), + "Bin directory", + &"does not exist".red().to_string(), + ); + print_hint("Run 'vp env setup' to create bin directory and shims."); + return false; + } + + print_check(&"\u{2713}".green().to_string(), "Bin directory", "exists"); + + let mut missing = Vec::new(); + + for tool in SHIM_TOOLS { + let shim_path = bin_dir.join(shim_filename(tool)); + if !tokio::fs::try_exists(&shim_path).await.unwrap_or(false) { + missing.push(*tool); + } + } + + if missing.is_empty() { + print_check(&"\u{2713}".green().to_string(), "Shims", &SHIM_TOOLS.join(", ")); + true + } else { + print_check( + &"\u{2717}".red().to_string(), + "Missing shims", + &missing.join(", ").red().to_string(), + ); + print_hint("Run 'vp env setup' to create missing shims."); + false + } +} + +/// Get the filename for a shim (platform-specific). +fn shim_filename(tool: &str) -> String { + #[cfg(windows)] + { + // All tools use .cmd wrappers on Windows (including node) + format!("{tool}.cmd") + } + + #[cfg(not(windows))] + { + tool.to_string() + } +} + +/// Check and display shim mode. +async fn check_shim_mode() { + let config = match load_config().await { + Ok(c) => c, + Err(e) => { + print_check( + &"\u{26A0}".yellow().to_string(), + "Shim mode", + &format!("config error: {e}").yellow().to_string(), + ); + return; + } + }; + + match config.shim_mode { + ShimMode::Managed => { + print_check(&"\u{2713}".green().to_string(), "Shim mode", "managed"); + } + ShimMode::SystemFirst => { + print_check( + &"\u{2713}".green().to_string(), + "Shim mode", + &"system-first".cyan().to_string(), + ); + + // Check if system Node.js is available + if let Some(system_node) = find_system_node() { + print_check(" ", "System Node.js", &system_node.display().to_string()); + } else { + print_check( + &"\u{26A0}".yellow().to_string(), + "System Node.js", + &"not found (will use managed)".yellow().to_string(), + ); + } + } + } +} + +/// Check profile files for IDE integration and return whether env sourcing was found. +fn check_ide_integration() -> bool { + // On Windows, IDE PATH is handled by System Environment Variables + #[cfg(windows)] + { + return true; + } + + #[cfg(not(windows))] + { + let bin_dir = match get_bin_dir() { + Ok(d) => d, + Err(_) => return false, + }; + + 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 = if let Ok(home_dir) = std::env::var("HOME") { + if let Some(suffix) = home_path.strip_prefix(&home_dir) { + format!("$HOME{suffix}") + } else { + home_path + } + } else { + home_path + }; + + if let Some(file) = check_profile_files(&home_path) { + print_check( + &"\u{2713}".green().to_string(), + "IDE integration", + &format!("env sourced in {file}"), + ); + true + } else { + false + } + } +} + +/// Find system Node.js, skipping vite-plus bin directory and any +/// directories listed in `VITE_PLUS_BYPASS`. +fn find_system_node() -> Option { + let bin_dir = get_bin_dir().ok(); + let path_var = std::env::var_os("PATH")?; + + // Parse VITE_PLUS_BYPASS as a PATH-style list of additional directories to skip + let bypass_paths: Vec = std::env::var_os("VITE_PLUS_BYPASS") + .map(|v| std::env::split_paths(&v).collect()) + .unwrap_or_default(); + + // Filter PATH to exclude our bin directory and any bypass directories + 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; + } + } + !bypass_paths.iter().any(|bp| p == bp) + }) + .collect(); + + let filtered_path = std::env::join_paths(filtered_paths).ok()?; + + // Use which::which_in with filtered PATH - stops at first match + let cwd = current_dir().ok()?; + which::which_in("node", Some(filtered_path), cwd).ok() +} + +/// Check for active session override via VITE_PLUS_NODE_VERSION or session file. +fn check_session_override() { + if let Ok(version) = std::env::var(config::VERSION_ENV_VAR) { + let version = version.trim(); + if !version.is_empty() { + print_check( + &"\u{26A0}".yellow().to_string(), + "Session override", + &format!("VITE_PLUS_NODE_VERSION={version}").yellow().to_string(), + ); + print_hint("Overrides all file-based resolution."); + print_hint("Run 'vp env use --unset' to remove."); + } + } + + // Also check session version file + if let Some(version) = config::read_session_version_sync() { + print_check( + &"\u{26A0}".yellow().to_string(), + "Session override (file)", + &format!("{}={version}", config::SESSION_VERSION_FILE).yellow().to_string(), + ); + print_hint("Written by 'vp env use'. Run 'vp env use --unset' to remove."); + } +} + +/// Check PATH configuration. +async fn check_path() -> bool { + let bin_dir = match get_bin_dir() { + Ok(d) => d, + Err(_) => return false, + }; + + let path_var = std::env::var_os("PATH").unwrap_or_default(); + let paths: Vec<_> = std::env::split_paths(&path_var).collect(); + + // Check if bin directory is in PATH + let bin_path = bin_dir.as_path(); + let bin_position = paths.iter().position(|p| p == bin_path); + + let bin_display = abbreviate_home(&bin_dir.as_path().display().to_string()); + + match bin_position { + Some(0) => { + print_check(&"\u{2713}".green().to_string(), "vp", "first in PATH"); + } + Some(pos) => { + print_check( + &"\u{26A0}".yellow().to_string(), + "vp", + &format!("in PATH at position {pos}").yellow().to_string(), + ); + print_hint("For best results, bin should be first in PATH."); + } + None => { + print_check(&"\u{2717}".red().to_string(), "vp", &"not in PATH".red().to_string()); + print_hint(&format!("Expected: {bin_display}")); + println!(); + print_path_fix(&bin_dir); + return false; + } + } + + // Show which tool would be executed for each shim + for tool in SHIM_TOOLS { + if let Some(tool_path) = find_in_path(tool) { + let expected = bin_dir.join(shim_filename(tool)); + let display = abbreviate_home(&tool_path.display().to_string()); + if tool_path == expected.as_path() { + print_check( + &"\u{2713}".green().to_string(), + tool, + &format!("{display} {}", "(vp shim)".dimmed()), + ); + } else { + print_check( + &"\u{26A0}".yellow().to_string(), + tool, + &format!("{} {}", display.yellow(), "(not vp shim)".dimmed()), + ); + } + } else { + print_check(" ", tool, "not found"); + } + } + + true +} + +/// Find an executable in PATH. +fn find_in_path(name: &str) -> Option { + which::which(name).ok() +} + +/// Print PATH fix instructions for shell setup. +fn print_path_fix(bin_dir: &vite_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()); + 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}") + } else { + home_path + } + } else { + home_path + }; + + println!(" {}", "Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):".dimmed()); + println!(); + println!(" . \"{home_path}/env\""); + println!(); + println!(" {}", "For fish shell, add to ~/.config/fish/config.fish:".dimmed()); + println!(); + println!(" source \"{home_path}/env.fish\""); + println!(); + println!(" {}", "Then restart your terminal.".dimmed()); + } + + #[cfg(windows)] + { + let _ = bin_dir; + println!(" {}", "Add the bin directory to your PATH via:".dimmed()); + println!(" System Properties -> Environment Variables -> Path"); + println!(); + println!(" {}", "Then restart your terminal.".dimmed()); + } +} + +/// Check profile files for vite-plus env sourcing line. +/// +/// Returns `Some(display_path)` if any known profile file contains a reference +/// to the vite-plus env file, `None` otherwise. +#[cfg(not(windows))] +fn check_profile_files(vite_plus_home: &str) -> Option { + let home_dir = std::env::var("HOME").ok()?; + + // Build candidate strings to search for: both $HOME/... and /absolute/... + let env_suffix = "/env"; + let mut search_strings = vec![format!("{vite_plus_home}{env_suffix}")]; + // If vite_plus_home uses $HOME prefix, also check the expanded absolute form + if let Some(suffix) = vite_plus_home.strip_prefix("$HOME") { + search_strings.push(format!("{home_dir}{suffix}{env_suffix}")); + } + + #[cfg(target_os = "macos")] + let profile_files: &[&str] = &[".zshenv", ".profile"]; + + #[cfg(target_os = "linux")] + let profile_files: &[&str] = &[".profile"]; + + // Fallback for other Unix platforms + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + let profile_files: &[&str] = &[".profile"]; + + for file in profile_files { + let full_path = format!("{home_dir}/{file}"); + if let Ok(content) = std::fs::read_to_string(&full_path) { + if search_strings.iter().any(|s| content.contains(s)) { + return Some(format!("~/{file}")); + } + } + } + + None +} + +/// Print IDE setup guidance for GUI applications. +fn print_ide_setup_guidance(bin_dir: &vite_path::AbsolutePath) { + // On Windows, IDE PATH is handled by System Environment Variables (covered by check_path) + #[cfg(windows)] + { + let _ = bin_dir; + } + + #[cfg(not(windows))] + { + // 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()); + 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}") + } else { + home_path + } + } else { + home_path + }; + + print_section("IDE Setup"); + print_check( + &"\u{26A0}".yellow().to_string(), + "", + &"GUI applications may not see shell PATH changes.".yellow().to_string(), + ); + println!(); + + #[cfg(target_os = "macos")] + { + println!(" {}", "macOS:".dimmed()); + println!(" {}", "Add to ~/.zshenv or ~/.profile:".dimmed()); + println!(" . \"{home_path}/env\""); + println!(" {}", "Then restart your IDE to apply changes.".dimmed()); + } + + #[cfg(target_os = "linux")] + { + println!(" {}", "Linux:".dimmed()); + println!(" {}", "Add to ~/.profile:".dimmed()); + println!(" . \"{home_path}/env\""); + println!( + " {}", + "Then log out and log back in for changes to take effect.".dimmed() + ); + } + + // Fallback for other Unix platforms + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + println!(" {}", "Add to your shell profile:".dimmed()); + println!(" . \"{home_path}/env\""); + println!(" {}", "Then restart your IDE to apply changes.".dimmed()); + } + } +} + +/// Check current directory version resolution. +async fn check_current_resolution(cwd: &AbsolutePathBuf) { + print_check(" ", "Directory", &cwd.as_path().display().to_string()); + + match resolve_version(cwd).await { + Ok(resolution) => { + print_check(" ", "Source", &resolution.source); + print_check(" ", "Version", &resolution.version.bright_green().to_string()); + + // Check if Node.js is installed + let home_dir = match vite_shared::get_vite_plus_home() { + Ok(d) => d.join("js_runtime").join("node").join(&resolution.version), + Err(_) => return, + }; + + #[cfg(windows)] + let binary_path = home_dir.join("node.exe"); + #[cfg(not(windows))] + let binary_path = home_dir.join("bin").join("node"); + + if tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + print_check(&"\u{2713}".green().to_string(), "Node binary", "installed"); + } else { + print_check( + &"\u{26A0}".yellow().to_string(), + "Node binary", + &"not installed".yellow().to_string(), + ); + print_hint("Version will be downloaded on first use."); + } + } + Err(e) => { + print_check( + &"\u{2717}".red().to_string(), + "Resolution", + &format!("failed: {e}").red().to_string(), + ); + } + } +} + +/// Check for conflicts with other version managers. +fn check_conflicts() { + let mut conflicts = Vec::new(); + + for (name, env_var) in KNOWN_VERSION_MANAGERS { + if std::env::var(env_var).is_ok() { + conflicts.push(*name); + } + } + + // Also check for common shims in PATH + if let Some(node_path) = find_in_path("node") { + let path_str = node_path.to_string_lossy(); + if path_str.contains(".nvm") { + if !conflicts.contains(&"nvm") { + conflicts.push("nvm"); + } + } else if path_str.contains(".fnm") { + if !conflicts.contains(&"fnm") { + conflicts.push("fnm"); + } + } else if path_str.contains(".volta") { + if !conflicts.contains(&"volta") { + conflicts.push("volta"); + } + } + } + + if !conflicts.is_empty() { + print_section("Conflicts"); + for manager in &conflicts { + print_check( + &"\u{26A0}".yellow().to_string(), + manager, + &format!( + "detected ({} is set)", + KNOWN_VERSION_MANAGERS + .iter() + .find(|(n, _)| n == manager) + .map(|(_, e)| *e) + .unwrap_or("in PATH") + ) + .yellow() + .to_string(), + ); + } + print_hint("Consider removing other version managers from your PATH"); + print_hint("to avoid version conflicts."); + } +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_shim_filename_consistency() { + // All tools should use the same extension pattern + // On Windows: all .cmd, On Unix: all without extension + let node = shim_filename("node"); + let npm = shim_filename("npm"); + let npx = shim_filename("npx"); + + #[cfg(windows)] + { + // All shims should use .cmd on Windows (matching setup.rs) + assert_eq!(node, "node.cmd"); + assert_eq!(npm, "npm.cmd"); + assert_eq!(npx, "npx.cmd"); + } + + #[cfg(not(windows))] + { + assert_eq!(node, "node"); + assert_eq!(npm, "npm"); + assert_eq!(npx, "npx"); + } + } + + /// Create a fake executable file in the given directory. + #[cfg(unix)] + fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.join(name); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + #[cfg(windows)] + fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf { + let path = dir.join(format!("{name}.exe")); + std::fs::write(&path, "fake").unwrap(); + path + } + + /// Helper to save and restore PATH and VITE_PLUS_BYPASS around a test. + struct EnvGuard { + original_path: Option, + original_bypass: Option, + } + + impl EnvGuard { + fn new() -> Self { + Self { + original_path: std::env::var_os("PATH"), + original_bypass: std::env::var_os("VITE_PLUS_BYPASS"), + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + match &self.original_path { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + match &self.original_bypass { + Some(v) => std::env::set_var("VITE_PLUS_BYPASS", v), + None => std::env::remove_var("VITE_PLUS_BYPASS"), + } + } + } + } + + #[test] + #[serial] + fn test_find_system_node_skips_bypass_paths() { + let _guard = EnvGuard::new(); + let temp = TempDir::new().unwrap(); + let dir_a = temp.path().join("bin_a"); + let dir_b = temp.path().join("bin_b"); + std::fs::create_dir_all(&dir_a).unwrap(); + std::fs::create_dir_all(&dir_b).unwrap(); + create_fake_executable(&dir_a, "node"); + create_fake_executable(&dir_b, "node"); + + let path = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap(); + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("PATH", &path); + std::env::set_var("VITE_PLUS_BYPASS", dir_a.as_os_str()); + } + + let result = find_system_node(); + assert!(result.is_some(), "Should find node in non-bypassed directory"); + assert!(result.unwrap().starts_with(&dir_b), "Should find node in dir_b, not dir_a"); + } + + #[test] + #[serial] + fn test_find_system_node_returns_none_when_all_paths_bypassed() { + let _guard = EnvGuard::new(); + let temp = TempDir::new().unwrap(); + let dir_a = temp.path().join("bin_a"); + std::fs::create_dir_all(&dir_a).unwrap(); + create_fake_executable(&dir_a, "node"); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("PATH", dir_a.as_os_str()); + std::env::set_var("VITE_PLUS_BYPASS", dir_a.as_os_str()); + } + + let result = find_system_node(); + assert!(result.is_none(), "Should return None when all paths are bypassed"); + } + + #[test] + fn test_abbreviate_home() { + if let Ok(home) = std::env::var("HOME") { + let path = format!("{home}/.vite-plus"); + assert_eq!(abbreviate_home(&path), "~/.vite-plus"); + + // Non-home path should be unchanged + assert_eq!(abbreviate_home("/usr/local/bin"), "/usr/local/bin"); + } + } +} diff --git a/crates/vite_global_cli/src/commands/env/global_install.rs b/crates/vite_global_cli/src/commands/env/global_install.rs new file mode 100644 index 0000000000..9c4f73dc7d --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/global_install.rs @@ -0,0 +1,828 @@ +//! Global package installation handling. + +use std::{collections::HashSet, io::Read, process::Stdio}; + +use tokio::process::Command; +use vite_js_runtime::NodeProvider; +use vite_path::{AbsolutePath, current_dir}; +use vite_shared::format_path_prepended; + +use super::{ + bin_config::BinConfig, + config::{ + get_bin_dir, get_node_modules_dir, get_packages_dir, get_tmp_dir, resolve_version, + resolve_version_alias, + }, + package_metadata::PackageMetadata, +}; +use crate::error::Error; + +/// Install a global package. +/// +/// If `node_version` is provided, uses that version. Otherwise, resolves from current directory. +/// If `force` is true, auto-uninstalls conflicting packages. +pub async fn install( + package_spec: &str, + node_version: Option<&str>, + force: bool, +) -> Result<(), Error> { + // Parse package spec (e.g., "typescript", "typescript@5.0.0", "@scope/pkg") + let (package_name, _version_spec) = parse_package_spec(package_spec); + + println!(" Installing {} globally...", package_spec); + + // 1. Resolve Node.js version + let version = if let Some(v) = node_version { + let provider = NodeProvider::new(); + resolve_version_alias(v, &provider).await? + } else { + // Resolve from current directory + let cwd = current_dir().map_err(|e| { + Error::ConfigError(format!("Cannot get current directory: {}", e).into()) + })?; + let resolution = resolve_version(&cwd).await?; + resolution.version + }; + + // 2. Ensure Node.js is installed + let runtime = + vite_js_runtime::download_runtime(vite_js_runtime::JsRuntimeType::Node, &version).await?; + + let node_bin_dir = runtime.get_bin_prefix(); + let npm_path = + if cfg!(windows) { node_bin_dir.join("npm.cmd") } else { node_bin_dir.join("npm") }; + + // 3. Create staging directory + let tmp_dir = get_tmp_dir()?; + let staging_dir = tmp_dir.join("packages").join(&package_name); + + // Clean up any previous failed install + if tokio::fs::try_exists(&staging_dir).await.unwrap_or(false) { + tokio::fs::remove_dir_all(&staging_dir).await?; + } + tokio::fs::create_dir_all(&staging_dir).await?; + + // 4. Run npm install with prefix set to staging directory + println!(" Running npm install..."); + + let status = Command::new(npm_path.as_path()) + .args(["install", "-g", package_spec]) + .env("npm_config_prefix", staging_dir.as_path()) + .env("PATH", format_path_prepended(node_bin_dir.as_path())) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await?; + + if !status.success() { + // Clean up staging directory + let _ = tokio::fs::remove_dir_all(&staging_dir).await; + return Err(Error::ConfigError( + format!("npm install failed with exit code: {:?}", status.code()).into(), + )); + } + + // 5. Find installed package and extract metadata + let node_modules_dir = get_node_modules_dir(&staging_dir, &package_name); + let package_json_path = node_modules_dir.join("package.json"); + + if !tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) { + let _ = tokio::fs::remove_dir_all(&staging_dir).await; + return Err(Error::ConfigError( + format!( + "Package {} was not installed correctly, package.json not found at {}", + package_name, + package_json_path.as_path().display() + ) + .into(), + )); + } + + // Read package.json to get version and binaries + let package_json_content = tokio::fs::read_to_string(&package_json_path).await?; + let package_json: serde_json::Value = serde_json::from_str(&package_json_content) + .map_err(|e| Error::ConfigError(format!("Failed to parse package.json: {}", e).into()))?; + + let installed_version = package_json["version"].as_str().unwrap_or("unknown").to_string(); + + let binary_infos = extract_binaries(&package_json); + + // Detect which binaries are JavaScript files + let mut bin_names = Vec::new(); + let mut js_bins = HashSet::new(); + for info in &binary_infos { + bin_names.push(info.name.clone()); + let binary_path = node_modules_dir.join(&info.path); + if is_javascript_binary(&binary_path) { + js_bins.insert(info.name.clone()); + } + } + + // 5b. Check for binary conflicts (before moving staging to final location) + let mut conflicts: Vec<(String, String)> = Vec::new(); // (bin_name, existing_package) + + for bin_name in &bin_names { + if let Some(config) = BinConfig::load(bin_name).await? { + // Only conflict if owned by a different package + if config.package != package_name { + conflicts.push((bin_name.clone(), config.package.clone())); + } + } + } + + if !conflicts.is_empty() { + if force { + // Auto-uninstall conflicting packages + let packages_to_remove: HashSet<_> = + conflicts.iter().map(|(_, pkg)| pkg.clone()).collect(); + for pkg in packages_to_remove { + println!(" Uninstalling {} (conflicts with {})...", pkg, package_name); + // Use Box::pin to avoid recursive async type issues + Box::pin(uninstall(&pkg, false)).await?; + } + } else { + // Hard fail with clear error + // Clean up staging directory + let _ = tokio::fs::remove_dir_all(&staging_dir).await; + return Err(Error::BinaryConflict { + bin_name: conflicts[0].0.clone(), + existing_package: conflicts[0].1.clone(), + new_package: package_name.clone(), + }); + } + } + + // 6. Move staging to final location + let packages_dir = get_packages_dir()?; + let final_dir = packages_dir.join(&package_name); + + // Remove existing installation if present + if tokio::fs::try_exists(&final_dir).await.unwrap_or(false) { + tokio::fs::remove_dir_all(&final_dir).await?; + } + + // Create parent directory (handles scoped packages like @scope/pkg) + if let Some(parent) = final_dir.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::rename(&staging_dir, &final_dir).await?; + + // 7. Save package metadata + let metadata = PackageMetadata::new( + package_name.clone(), + installed_version.clone(), + version.clone(), + None, // npm version - could extract from runtime + bin_names.clone(), + js_bins, + "npm".to_string(), + ); + metadata.save().await?; + + // 8. Create shims for binaries and save per-binary configs + let bin_dir = get_bin_dir()?; + for bin_name in &bin_names { + create_package_shim(&bin_dir, bin_name, &package_name).await?; + + // Write per-binary config + let bin_config = BinConfig::new( + bin_name.clone(), + package_name.clone(), + installed_version.clone(), + version.clone(), + ); + bin_config.save().await?; + } + + println!(" Installed {} v{}", package_name, installed_version); + if !bin_names.is_empty() { + println!(" Binaries: {}", bin_names.join(", ")); + } + + Ok(()) +} + +/// Uninstall a global package. +/// +/// Uses two-phase uninstall: +/// 1. Try to use PackageMetadata for binary list +/// 2. Fallback to scanning BinConfig files for orphaned binaries +pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { + let (package_name, _) = parse_package_spec(package_name); + + // Phase 1: Try to use PackageMetadata for binary list + let bins = if let Some(metadata) = PackageMetadata::load(&package_name).await? { + metadata.bins.clone() + } else { + // Phase 2: Fallback - scan BinConfig files for orphaned binaries + let orphan_bins = BinConfig::find_by_package(&package_name).await?; + if orphan_bins.is_empty() { + return Err(Error::ConfigError( + format!("Package {} is not installed", package_name).into(), + )); + } + orphan_bins + }; + + if dry_run { + let bin_dir = get_bin_dir()?; + let packages_dir = get_packages_dir()?; + let package_dir = packages_dir.join(&package_name); + let metadata_path = PackageMetadata::metadata_path(&package_name)?; + + println!(" Would uninstall {}:", package_name); + for bin_name in &bins { + println!(" - shim: {}", bin_dir.join(bin_name).as_path().display()); + } + println!(" - package dir: {}", package_dir.as_path().display()); + println!(" - metadata: {}", metadata_path.as_path().display()); + return Ok(()); + } + + println!(" Uninstalling {}...", package_name); + + // Remove shims and bin configs + let bin_dir = get_bin_dir()?; + for bin_name in &bins { + remove_package_shim(&bin_dir, bin_name).await?; + BinConfig::delete(bin_name).await?; + } + + // Remove package directory + let packages_dir = get_packages_dir()?; + let package_dir = packages_dir.join(&package_name); + if tokio::fs::try_exists(&package_dir).await.unwrap_or(false) { + tokio::fs::remove_dir_all(&package_dir).await?; + } + + // Remove metadata file + PackageMetadata::delete(&package_name).await?; + + println!(" Uninstalled {}", package_name); + + Ok(()) +} + +/// Parse package spec into name and optional version. +fn parse_package_spec(spec: &str) -> (String, Option) { + // Handle scoped packages: @scope/name@version + if spec.starts_with('@') { + // Find the second @ for version + if let Some(idx) = spec[1..].find('@') { + let idx = idx + 1; // Adjust for the skipped first char + return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string())); + } + return (spec.to_string(), None); + } + + // Handle regular packages: name@version + if let Some(idx) = spec.find('@') { + return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string())); + } + + (spec.to_string(), None) +} + +/// Binary info extracted from package.json. +struct BinaryInfo { + /// Binary name (the command users will run) + name: String, + /// Relative path to the binary file from package root + path: String, +} + +/// Extract binary names and paths from package.json. +fn extract_binaries(package_json: &serde_json::Value) -> Vec { + let mut bins = Vec::new(); + + if let Some(bin) = package_json.get("bin") { + match bin { + serde_json::Value::String(path) => { + // Single binary with package name + if let Some(name) = package_json["name"].as_str() { + // Get just the package name without scope + let bin_name = name.split('/').last().unwrap_or(name); + bins.push(BinaryInfo { name: bin_name.to_string(), path: path.clone() }); + } + } + serde_json::Value::Object(map) => { + // Multiple binaries + for (name, path) in map { + if let serde_json::Value::String(path) = path { + bins.push(BinaryInfo { name: name.clone(), path: path.clone() }); + } + } + } + _ => {} + } + } + + bins +} + +/// Check if a file is a JavaScript file that should be run with Node. +/// +/// Returns true if: +/// - The file has a .js, .mjs, or .cjs extension +/// - The file has a shebang containing "node" +/// +/// This function safely reads only the first 256 bytes to check the shebang, +/// avoiding issues with binary files that may not have newlines. +fn is_javascript_binary(path: &AbsolutePath) -> bool { + // Check extension first (fast path, no file I/O) + if let Some(ext) = path.as_path().extension() { + let ext = ext.to_string_lossy().to_lowercase(); + if ext == "js" || ext == "mjs" || ext == "cjs" { + return true; + } + } + + // For extensionless files, read only first 256 bytes to check shebang + // This is safe even for binary files + if let Ok(mut file) = std::fs::File::open(path.as_path()) { + let mut buffer = [0u8; 256]; + if let Ok(n) = file.read(&mut buffer) { + if n >= 2 && buffer[0] == b'#' && buffer[1] == b'!' { + // Found shebang, check for "node" in the first line + // Find newline or use entire buffer + let end = buffer[..n].iter().position(|&b| b == b'\n').unwrap_or(n); + if let Ok(shebang) = std::str::from_utf8(&buffer[..end]) { + if shebang.contains("node") { + return true; + } + } + } + } + } + + false +} + +/// Core shims that should not be overwritten by package binaries. +const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"]; + +/// Create a shim for a package binary. +/// +/// On Unix: Creates a symlink to ../current/bin/vp +/// On Windows: Creates a .cmd wrapper that calls `vp env run ` +async fn create_package_shim( + bin_dir: &vite_path::AbsolutePath, + bin_name: &str, + package_name: &str, +) -> Result<(), Error> { + // Check for conflicts with core shims + if CORE_SHIMS.contains(&bin_name) { + println!( + " Warning: Package '{}' provides '{}' binary, but it conflicts with a core shim. Skipping.", + package_name, bin_name + ); + return Ok(()); + } + + // Ensure bin directory exists + tokio::fs::create_dir_all(bin_dir).await?; + + #[cfg(unix)] + { + let shim_path = bin_dir.join(bin_name); + + // Skip if already exists (e.g., re-installing the same package) + if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) { + return Ok(()); + } + + // Create symlink to ../current/bin/vp + tokio::fs::symlink("../current/bin/vp", &shim_path).await?; + tracing::debug!("Created package shim symlink {:?} -> ../current/bin/vp", shim_path); + } + + #[cfg(windows)] + { + let cmd_path = bin_dir.join(format!("{}.cmd", bin_name)); + + // Skip if already exists (e.g., re-installing the same package) + if tokio::fs::try_exists(&cmd_path).await.unwrap_or(false) { + return Ok(()); + } + + // Create .cmd wrapper that calls vp env run + // Set VITE_PLUS_HOME using %~dp0.. which resolves to the parent of bin/ + // This ensures the vp binary knows its home directory + let wrapper_content = format!( + "@echo off\r\nset VITE_PLUS_HOME=%~dp0..\r\n\"%VITE_PLUS_HOME%\\current\\bin\\vp.exe\" env run {} %*\r\nexit /b %ERRORLEVEL%\r\n", + bin_name + ); + tokio::fs::write(&cmd_path, wrapper_content).await?; + + // Also create shell script for Git Bash (bin_name without extension) + // Uses explicit "vp env run " instead of symlink+argv[0] because + // Windows symlinks require admin privileges + let sh_path = bin_dir.join(bin_name); + let sh_content = format!( + r#"#!/bin/sh +VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" +export VITE_PLUS_HOME +exec "$VITE_PLUS_HOME/current/bin/vp.exe" env run {} "$@" +"#, + bin_name + ); + tokio::fs::write(&sh_path, sh_content).await?; + + tracing::debug!("Created package shim wrappers for {} (.cmd and shell script)", bin_name); + } + + Ok(()) +} + +/// Remove a shim for a package binary. +async fn remove_package_shim( + bin_dir: &vite_path::AbsolutePath, + bin_name: &str, +) -> Result<(), Error> { + // Don't remove core shims + if CORE_SHIMS.contains(&bin_name) { + return Ok(()); + } + + #[cfg(unix)] + { + let shim_path = bin_dir.join(bin_name); + // Use symlink_metadata to detect symlinks (even broken ones) + if tokio::fs::symlink_metadata(&shim_path).await.is_ok() { + tokio::fs::remove_file(&shim_path).await?; + } + } + + #[cfg(windows)] + { + // Remove .cmd wrapper + let cmd_path = bin_dir.join(format!("{}.cmd", bin_name)); + if tokio::fs::try_exists(&cmd_path).await.unwrap_or(false) { + tokio::fs::remove_file(&cmd_path).await?; + } + + // Also remove shell script (for Git Bash) + let sh_path = bin_dir.join(bin_name); + if tokio::fs::try_exists(&sh_path).await.unwrap_or(false) { + tokio::fs::remove_file(&sh_path).await?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_create_package_shim_creates_bin_dir() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + // Create a temp directory but don't create the bin subdirectory + let temp_dir = TempDir::new().unwrap(); + let bin_dir = temp_dir.path().join("bin"); + let bin_dir = AbsolutePathBuf::new(bin_dir).unwrap(); + + // Verify bin directory doesn't exist + assert!(!bin_dir.as_path().exists()); + + // Create a shim - this should create the bin directory + create_package_shim(&bin_dir, "test-shim", "test-package").await.unwrap(); + + // Verify bin directory was created + assert!(bin_dir.as_path().exists()); + + // Verify shim file was created (on Windows, shims have .cmd extension) + // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata + #[cfg(unix)] + { + let shim_path = bin_dir.join("test-shim"); + assert!( + std::fs::symlink_metadata(shim_path.as_path()).is_ok(), + "Symlink shim should exist" + ); + } + #[cfg(windows)] + { + let shim_path = bin_dir.join("test-shim.cmd"); + assert!(shim_path.as_path().exists()); + } + } + + #[tokio::test] + async fn test_create_package_shim_skips_core_shims() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Try to create a shim for "node" which is a core shim + create_package_shim(&bin_dir, "node", "some-package").await.unwrap(); + + // Verify the shim was NOT created (core shims should be skipped) + #[cfg(unix)] + let shim_path = bin_dir.join("node"); + #[cfg(windows)] + let shim_path = bin_dir.join("node.cmd"); + assert!(!shim_path.as_path().exists()); + } + + #[tokio::test] + async fn test_remove_package_shim_removes_shim() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create a shim + create_package_shim(&bin_dir, "tsc", "typescript").await.unwrap(); + + // Verify the shim was created + // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata + #[cfg(unix)] + { + let shim_path = bin_dir.join("tsc"); + assert!( + std::fs::symlink_metadata(shim_path.as_path()).is_ok(), + "Shim should exist after creation" + ); + + // Remove the shim + remove_package_shim(&bin_dir, "tsc").await.unwrap(); + + // Verify the shim was removed + assert!( + std::fs::symlink_metadata(shim_path.as_path()).is_err(), + "Shim should be removed" + ); + } + #[cfg(windows)] + { + let shim_path = bin_dir.join("tsc.cmd"); + assert!(shim_path.as_path().exists(), "Shim should exist after creation"); + + // Remove the shim + remove_package_shim(&bin_dir, "tsc").await.unwrap(); + + // Verify the shim was removed + assert!(!shim_path.as_path().exists(), "Shim should be removed"); + } + } + + #[tokio::test] + async fn test_remove_package_shim_handles_missing_shim() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let bin_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Remove a shim that doesn't exist - should not error + remove_package_shim(&bin_dir, "nonexistent").await.unwrap(); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_uninstall_removes_shims_from_metadata() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path().to_path_buf(); + + // Set VITE_PLUS_HOME to temp directory + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("VITE_PLUS_HOME", &temp_path); + } + + // Create bin directory + let bin_dir = AbsolutePathBuf::new(temp_path.join("bin")).unwrap(); + tokio::fs::create_dir_all(&bin_dir).await.unwrap(); + + // Create shims for "tsc" and "tsserver" + create_package_shim(&bin_dir, "tsc", "typescript").await.unwrap(); + create_package_shim(&bin_dir, "tsserver", "typescript").await.unwrap(); + + // Verify shims exist + // On Unix, symlinks may be broken (target doesn't exist), so use symlink_metadata + #[cfg(unix)] + { + assert!( + std::fs::symlink_metadata(bin_dir.join("tsc").as_path()).is_ok(), + "tsc shim should exist" + ); + assert!( + std::fs::symlink_metadata(bin_dir.join("tsserver").as_path()).is_ok(), + "tsserver shim should exist" + ); + } + #[cfg(windows)] + { + assert!(bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should exist"); + assert!( + bin_dir.join("tsserver.cmd").as_path().exists(), + "tsserver.cmd shim should exist" + ); + } + + // Create metadata with bins + let metadata = PackageMetadata::new( + "typescript".to_string(), + "5.9.3".to_string(), + "20.18.0".to_string(), + None, + vec!["tsc".to_string(), "tsserver".to_string()], + HashSet::from(["tsc".to_string(), "tsserver".to_string()]), + "npm".to_string(), + ); + metadata.save().await.unwrap(); + + // Create package directory (needed for uninstall) + let packages_dir = AbsolutePathBuf::new(temp_path.join("packages")).unwrap(); + let package_dir = packages_dir.join("typescript"); + tokio::fs::create_dir_all(&package_dir).await.unwrap(); + + // Verify metadata was saved + let loaded = PackageMetadata::load("typescript").await.unwrap(); + assert!(loaded.is_some(), "Metadata should be loaded"); + let loaded = loaded.unwrap(); + assert_eq!(loaded.bins, vec!["tsc", "tsserver"], "bins should match"); + + // Run uninstall + uninstall("typescript", false).await.unwrap(); + + // Verify shims were removed + #[cfg(unix)] + { + assert!(!bin_dir.join("tsc").as_path().exists(), "tsc shim should be removed"); + assert!( + !bin_dir.join("tsserver").as_path().exists(), + "tsserver shim should be removed" + ); + } + #[cfg(windows)] + { + assert!(!bin_dir.join("tsc.cmd").as_path().exists(), "tsc.cmd shim should be removed"); + assert!( + !bin_dir.join("tsserver.cmd").as_path().exists(), + "tsserver.cmd shim should be removed" + ); + } + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[test] + fn test_parse_package_spec_simple() { + let (name, version) = parse_package_spec("typescript"); + assert_eq!(name, "typescript"); + assert_eq!(version, None); + } + + #[test] + fn test_parse_package_spec_with_version() { + let (name, version) = parse_package_spec("typescript@5.0.0"); + assert_eq!(name, "typescript"); + assert_eq!(version, Some("5.0.0".to_string())); + } + + #[test] + fn test_parse_package_spec_scoped() { + let (name, version) = parse_package_spec("@types/node"); + assert_eq!(name, "@types/node"); + assert_eq!(version, None); + } + + #[test] + fn test_parse_package_spec_scoped_with_version() { + let (name, version) = parse_package_spec("@types/node@20.0.0"); + assert_eq!(name, "@types/node"); + assert_eq!(version, Some("20.0.0".to_string())); + } + + #[test] + fn test_is_javascript_binary_with_js_extension() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let js_file = temp_dir.path().join("cli.js"); + std::fs::write(&js_file, "console.log('hello')").unwrap(); + + let path = AbsolutePathBuf::new(js_file).unwrap(); + assert!(is_javascript_binary(&path)); + } + + #[test] + fn test_is_javascript_binary_with_mjs_extension() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let mjs_file = temp_dir.path().join("cli.mjs"); + std::fs::write(&mjs_file, "export default 'hello'").unwrap(); + + let path = AbsolutePathBuf::new(mjs_file).unwrap(); + assert!(is_javascript_binary(&path)); + } + + #[test] + fn test_is_javascript_binary_with_cjs_extension() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let cjs_file = temp_dir.path().join("cli.cjs"); + std::fs::write(&cjs_file, "module.exports = 'hello'").unwrap(); + + let path = AbsolutePathBuf::new(cjs_file).unwrap(); + assert!(is_javascript_binary(&path)); + } + + #[test] + fn test_is_javascript_binary_with_node_shebang() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let cli_file = temp_dir.path().join("cli"); + std::fs::write(&cli_file, "#!/usr/bin/env node\nconsole.log('hello')").unwrap(); + + let path = AbsolutePathBuf::new(cli_file).unwrap(); + assert!(is_javascript_binary(&path)); + } + + #[test] + fn test_is_javascript_binary_with_direct_node_shebang() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let cli_file = temp_dir.path().join("cli"); + std::fs::write(&cli_file, "#!/usr/bin/node\nconsole.log('hello')").unwrap(); + + let path = AbsolutePathBuf::new(cli_file).unwrap(); + assert!(is_javascript_binary(&path)); + } + + #[test] + fn test_is_javascript_binary_native_executable() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + // Simulate a native binary (ELF header) + let native_file = temp_dir.path().join("native-cli"); + std::fs::write(&native_file, b"\x7fELF").unwrap(); + + let path = AbsolutePathBuf::new(native_file).unwrap(); + assert!(!is_javascript_binary(&path)); + } + + #[test] + fn test_is_javascript_binary_shell_script() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let shell_file = temp_dir.path().join("script.sh"); + std::fs::write(&shell_file, "#!/bin/bash\necho hello").unwrap(); + + let path = AbsolutePathBuf::new(shell_file).unwrap(); + assert!(!is_javascript_binary(&path)); + } + + #[test] + fn test_is_javascript_binary_python_script() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let python_file = temp_dir.path().join("script.py"); + std::fs::write(&python_file, "#!/usr/bin/env python3\nprint('hello')").unwrap(); + + let path = AbsolutePathBuf::new(python_file).unwrap(); + assert!(!is_javascript_binary(&path)); + } + + #[test] + fn test_is_javascript_binary_empty_file() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let empty_file = temp_dir.path().join("empty"); + std::fs::write(&empty_file, "").unwrap(); + + let path = AbsolutePathBuf::new(empty_file).unwrap(); + assert!(!is_javascript_binary(&path)); + } +} diff --git a/crates/vite_global_cli/src/commands/env/list.rs b/crates/vite_global_cli/src/commands/env/list.rs new file mode 100644 index 0000000000..bd0bbeca90 --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/list.rs @@ -0,0 +1,171 @@ +//! List command for displaying locally installed Node.js versions. +//! +//! Handles `vp env list` to show Node.js versions installed in VITE_PLUS_HOME/js_runtime/node/. + +use std::{cmp::Ordering, process::ExitStatus}; + +use owo_colors::OwoColorize; +use serde::Serialize; +use vite_path::AbsolutePathBuf; + +use super::config; +use crate::error::Error; + +/// JSON output format for a single installed version +#[derive(Serialize)] +struct InstalledVersionJson { + version: String, + current: bool, + default: bool, +} + +/// Scan the node versions directory and return sorted version strings. +fn list_installed_versions(node_dir: &std::path::Path) -> Vec { + let entries = match std::fs::read_dir(node_dir) { + Ok(entries) => entries, + Err(_) => return Vec::new(), + }; + + let mut versions: Vec = entries + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().into_string().ok()?; + // Skip hidden directories and non-directories + if name.starts_with('.') || !entry.path().is_dir() { + return None; + } + Some(name) + }) + .collect(); + + versions.sort_by(|a, b| compare_versions(a, b)); + versions +} + +/// Compare two version strings numerically (e.g., "20.18.0" vs "22.13.0"). +fn compare_versions(a: &str, b: &str) -> Ordering { + let parse = |v: &str| -> Vec { v.split('.').filter_map(|p| p.parse().ok()).collect() }; + let a_parts = parse(a); + let b_parts = parse(b); + a_parts.cmp(&b_parts) +} + +/// Execute the list command (local installed versions). +pub async fn execute(cwd: AbsolutePathBuf, json_output: bool) -> Result { + let home_dir = + vite_shared::get_vite_plus_home().map_err(|e| Error::ConfigError(format!("{e}").into()))?; + let node_dir = home_dir.join("js_runtime").join("node"); + + let versions = list_installed_versions(node_dir.as_path()); + + if versions.is_empty() { + if json_output { + println!("[]"); + } else { + println!("No Node.js versions installed."); + println!(); + println!("Install a version with: vp env install "); + } + return Ok(ExitStatus::default()); + } + + // Resolve current version (gracefully handle errors) + let current_version = config::resolve_version(&cwd).await.ok().map(|r| r.version); + + // Load default version + let default_version = config::load_config().await.ok().and_then(|c| c.default_node_version); + + if json_output { + print_json(&versions, current_version.as_deref(), default_version.as_deref()); + } else { + print_human(&versions, current_version.as_deref(), default_version.as_deref()); + } + + Ok(ExitStatus::default()) +} + +/// Print installed versions as JSON. +fn print_json(versions: &[String], current: Option<&str>, default: Option<&str>) { + let entries: Vec = versions + .iter() + .map(|v| InstalledVersionJson { + version: v.clone(), + current: current.is_some_and(|c| c == v), + default: default.is_some_and(|d| d == v), + }) + .collect(); + + // unwrap is safe here since we're serializing simple structs + println!("{}", serde_json::to_string_pretty(&entries).unwrap()); +} + +/// Print installed versions in human-readable format. +fn print_human(versions: &[String], current: Option<&str>, default: Option<&str>) { + for v in versions { + let is_current = current.is_some_and(|c| c == v); + let is_default = default.is_some_and(|d| d == v); + + let mut markers = Vec::new(); + if is_current { + markers.push("current"); + } + if is_default { + markers.push("default"); + } + + let marker_str = if markers.is_empty() { + String::new() + } else { + format!(" {}", markers.join(" ").dimmed()) + }; + + let line = format!("* v{v}{marker_str}"); + if is_current { + println!("{}", line.bright_blue()); + } else { + println!("{line}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_cmp() { + assert_eq!(compare_versions("18.20.0", "20.18.0"), Ordering::Less); + assert_eq!(compare_versions("22.13.0", "20.18.0"), Ordering::Greater); + assert_eq!(compare_versions("20.18.0", "20.18.0"), Ordering::Equal); + assert_eq!(compare_versions("20.9.0", "20.18.0"), Ordering::Less); + } + + #[test] + fn test_list_installed_versions_nonexistent_dir() { + let versions = list_installed_versions(std::path::Path::new("/nonexistent/path")); + assert!(versions.is_empty()); + } + + #[test] + fn test_list_installed_versions_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let versions = list_installed_versions(dir.path()); + assert!(versions.is_empty()); + } + + #[test] + fn test_list_installed_versions_with_versions() { + let dir = tempfile::tempdir().unwrap(); + // Create version directories + std::fs::create_dir(dir.path().join("20.18.0")).unwrap(); + std::fs::create_dir(dir.path().join("22.13.0")).unwrap(); + std::fs::create_dir(dir.path().join("18.20.0")).unwrap(); + // Create a hidden dir that should be skipped + std::fs::create_dir(dir.path().join(".tmp")).unwrap(); + // Create a file that should be skipped + std::fs::write(dir.path().join("some-file"), "").unwrap(); + + let versions = list_installed_versions(dir.path()); + assert_eq!(versions, vec!["18.20.0", "20.18.0", "22.13.0"]); + } +} diff --git a/crates/vite_global_cli/src/commands/env/list_remote.rs b/crates/vite_global_cli/src/commands/env/list_remote.rs new file mode 100644 index 0000000000..11ad831e56 --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/list_remote.rs @@ -0,0 +1,282 @@ +//! List-remote command for displaying available Node.js versions from the registry. +//! +//! Handles `vp env list-remote` to show available Node.js versions from the Node.js distribution. + +use std::process::ExitStatus; + +use owo_colors::OwoColorize; +use serde::Serialize; +use vite_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry}; + +use crate::{cli::SortingMethod, error::Error}; + +/// Default number of major versions to show +const DEFAULT_MAJOR_VERSIONS: usize = 10; + +/// JSON output format for version list +#[derive(Serialize)] +struct VersionListJson { + versions: Vec, +} + +/// JSON format for a single version entry +#[derive(Serialize)] +struct VersionJson { + version: String, + lts: Option, + latest: bool, + latest_lts: bool, +} + +/// Execute the list-remote command. +pub async fn execute( + pattern: Option, + lts_only: bool, + show_all: bool, + json_output: bool, + sort: SortingMethod, +) -> Result { + let provider = NodeProvider::new(); + let versions = provider.fetch_version_index().await?; + + if versions.is_empty() { + println!("No versions found."); + return Ok(ExitStatus::default()); + } + + // Filter versions based on options + let mut filtered = filter_versions(&versions, pattern.as_deref(), lts_only, show_all); + + // fetch_version_index() returns newest-first (desc). + // For asc (default), reverse to show oldest-first. + if matches!(sort, SortingMethod::Asc) { + filtered.reverse(); + } + + if json_output { + print_json(&filtered, &versions)?; + } else { + print_human(&filtered); + } + + Ok(ExitStatus::default()) +} + +/// Filter versions based on criteria. +fn filter_versions<'a>( + versions: &'a [NodeVersionEntry], + pattern: Option<&str>, + lts_only: bool, + show_all: bool, +) -> Vec<&'a NodeVersionEntry> { + let mut filtered: Vec<&'a NodeVersionEntry> = versions.iter().collect(); + + // Filter by LTS if requested + if lts_only { + filtered.retain(|v| v.is_lts()); + } + + // Filter by pattern (major version) + if let Some(pattern) = pattern { + filtered.retain(|v| { + let version_str = v.version.strip_prefix('v').unwrap_or(&v.version); + version_str.starts_with(pattern) || version_str.starts_with(&format!("{pattern}.")) + }); + } + + // Limit to recent major versions unless --all is specified + if !show_all && pattern.is_none() { + filtered = limit_to_recent_majors(filtered, DEFAULT_MAJOR_VERSIONS); + } + + filtered +} + +/// Extract major version from a version string like "v20.18.0" or "20.18.0" +fn extract_major(version: &str) -> Option { + let version_str = version.strip_prefix('v').unwrap_or(version); + version_str.split('.').next()?.parse().ok() +} + +/// Limit versions to the N most recent major versions. +fn limit_to_recent_majors( + versions: Vec<&NodeVersionEntry>, + max_majors: usize, +) -> Vec<&NodeVersionEntry> { + // Get unique major versions + let mut majors: Vec = versions.iter().filter_map(|v| extract_major(&v.version)).collect(); + + majors.sort_unstable(); + majors.dedup(); + majors.reverse(); + + // Keep only the most recent N majors + let recent_majors: std::collections::HashSet = + majors.into_iter().take(max_majors).collect(); + + versions + .into_iter() + .filter(|v| extract_major(&v.version).is_some_and(|m| recent_majors.contains(&m))) + .collect() +} + +/// Print versions as JSON. +fn print_json( + versions: &[&NodeVersionEntry], + all_versions: &[NodeVersionEntry], +) -> Result<(), Error> { + // Find the latest version and latest LTS + let latest_version = all_versions.first().map(|v| &v.version); + let latest_lts_version = all_versions.iter().find(|v| v.is_lts()).map(|v| &v.version); + + let version_list: Vec = versions + .iter() + .map(|v| { + let lts = match &v.lts { + LtsInfo::Codename(name) => Some(name.to_string()), + _ => None, + }; + let is_latest = latest_version.is_some_and(|lv| lv == &v.version); + let is_latest_lts = latest_lts_version.is_some_and(|llv| llv == &v.version); + + VersionJson { + version: v.version.strip_prefix('v').unwrap_or(&v.version).to_string(), + lts, + latest: is_latest, + latest_lts: is_latest_lts, + } + }) + .collect(); + + let output = VersionListJson { versions: version_list }; + println!("{}", serde_json::to_string_pretty(&output)?); + + Ok(()) +} + +/// Print versions in human-readable format (fnm-style). +fn print_human(versions: &[&NodeVersionEntry]) { + if versions.is_empty() { + eprintln!("{}", "No versions were found!".red()); + return; + } + + for version in versions { + let version_str = &version.version; + // Ensure v prefix + let display = if version_str.starts_with('v') { + version_str.to_string() + } else { + format!("v{version_str}") + }; + + if let LtsInfo::Codename(name) = &version.lts { + println!("{}{}", display, format!(" ({name})").bright_blue()); + } else { + println!("{display}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_version(version: &str, lts: Option<&str>) -> NodeVersionEntry { + NodeVersionEntry { + version: version.into(), + lts: match lts { + Some(name) => LtsInfo::Codename(name.into()), + None => LtsInfo::Boolean(false), + }, + } + } + + #[test] + fn test_filter_versions_lts_only() { + let versions = vec![ + make_version("v24.0.0", None), + make_version("v22.13.0", Some("Jod")), + make_version("v20.18.0", Some("Iron")), + ]; + + let filtered = filter_versions(&versions, None, true, false); + assert_eq!(filtered.len(), 2); + assert!(filtered.iter().all(|v| v.is_lts())); + } + + #[test] + fn test_filter_versions_by_pattern() { + let versions = vec![ + make_version("v24.0.0", None), + make_version("v22.13.0", Some("Jod")), + make_version("v22.12.0", Some("Jod")), + make_version("v20.18.0", Some("Iron")), + ]; + + let filtered = filter_versions(&versions, Some("22"), false, true); + assert_eq!(filtered.len(), 2); + assert!(filtered.iter().all(|v| v.version.starts_with("v22."))); + } + + #[test] + fn test_limit_to_recent_majors() { + let versions = vec![ + make_version("v24.0.0", None), + make_version("v23.0.0", None), + make_version("v22.13.0", Some("Jod")), + make_version("v21.0.0", None), + make_version("v20.18.0", Some("Iron")), + ]; + + let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); + let limited = limit_to_recent_majors(refs, 2); + + // Should only have v24 and v23 + assert_eq!(limited.len(), 2); + assert!(limited.iter().any(|v| v.version.starts_with("v24."))); + assert!(limited.iter().any(|v| v.version.starts_with("v23."))); + } + + #[test] + fn test_filter_versions_show_all_returns_all_versions() { + // Create versions spanning many major versions (more than DEFAULT_MAJOR_VERSIONS) + let versions = vec![ + make_version("v25.0.0", None), + make_version("v24.0.0", None), + make_version("v23.0.0", None), + make_version("v22.13.0", Some("Jod")), + make_version("v21.0.0", None), + make_version("v20.18.0", Some("Iron")), + make_version("v19.0.0", None), + make_version("v18.20.0", Some("Hydrogen")), + make_version("v17.0.0", None), + make_version("v16.20.0", Some("Gallium")), + make_version("v15.0.0", None), + make_version("v14.0.0", None), + ]; + + // Without show_all, should be limited to DEFAULT_MAJOR_VERSIONS (10) + let filtered_limited = filter_versions(&versions, None, false, false); + assert_eq!(filtered_limited.len(), 10); + + // With show_all=true, should return all versions + let filtered_all = filter_versions(&versions, None, false, true); + assert_eq!(filtered_all.len(), 12); + } + + #[test] + fn test_filter_versions_show_all_with_lts_filter() { + let versions = vec![ + make_version("v25.0.0", None), + make_version("v22.13.0", Some("Jod")), + make_version("v20.18.0", Some("Iron")), + make_version("v18.20.0", Some("Hydrogen")), + ]; + + // With lts_only and show_all, should return all LTS versions + let filtered = filter_versions(&versions, None, true, true); + assert_eq!(filtered.len(), 3); + assert!(filtered.iter().all(|v| v.is_lts())); + } +} diff --git a/crates/vite_global_cli/src/commands/env/mod.rs b/crates/vite_global_cli/src/commands/env/mod.rs new file mode 100644 index 0000000000..3c1cf35397 --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/mod.rs @@ -0,0 +1,171 @@ +//! Environment management commands. +//! +//! This module provides the `vp env` command for managing Node.js environments +//! through shim-based version management. + +pub mod bin_config; +pub mod config; +mod current; +mod default; +mod doctor; +pub mod global_install; +mod list; +mod list_remote; +mod off; +mod on; +pub mod package_metadata; +pub mod packages; +mod pin; +mod run; +mod setup; +mod unpin; +mod r#use; +mod which; + +use std::process::ExitStatus; + +use vite_path::AbsolutePathBuf; + +use crate::{cli::EnvArgs, error::Error}; + +/// Execute the env command based on the provided arguments. +pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result { + // Handle subcommands first + if let Some(subcommand) = args.command { + return match subcommand { + crate::cli::EnvSubcommands::Default { version } => default::execute(cwd, version).await, + crate::cli::EnvSubcommands::On => on::execute().await, + crate::cli::EnvSubcommands::Off => off::execute().await, + crate::cli::EnvSubcommands::Setup { refresh, env_only } => { + setup::execute(refresh, env_only).await + } + crate::cli::EnvSubcommands::Doctor => doctor::execute(cwd).await, + crate::cli::EnvSubcommands::Which { tool } => which::execute(cwd, &tool).await, + crate::cli::EnvSubcommands::Pin { version, unpin, no_install, force } => { + pin::execute(cwd, version, unpin, no_install, force).await + } + crate::cli::EnvSubcommands::Unpin => unpin::execute(cwd).await, + crate::cli::EnvSubcommands::List { json } => list::execute(cwd, json).await, + crate::cli::EnvSubcommands::ListRemote { pattern, lts, all, json, sort } => { + list_remote::execute(pattern, lts, all, json, sort).await + } + crate::cli::EnvSubcommands::Run { node, npm, command } => { + run::execute(node.as_deref(), npm.as_deref(), &command).await + } + crate::cli::EnvSubcommands::Uninstall { version } => { + let provider = vite_js_runtime::NodeProvider::new(); + let resolved = config::resolve_version_alias(&version, &provider).await?; + let home_dir = vite_shared::get_vite_plus_home() + .map_err(|e| crate::error::Error::ConfigError(format!("{e}").into()))?; + let version_dir = home_dir.join("js_runtime").join("node").join(&resolved); + if !version_dir.as_path().exists() { + eprintln!("Node.js v{} is not installed", resolved); + return Ok(exit_status(1)); + } + tokio::fs::remove_dir_all(version_dir.as_path()).await.map_err(|e| { + crate::error::Error::ConfigError( + format!("Failed to remove Node.js v{}: {}", resolved, e).into(), + ) + })?; + println!("Uninstalled Node.js v{}", resolved); + Ok(ExitStatus::default()) + } + crate::cli::EnvSubcommands::Use { version, unset, no_install, silent_if_unchanged } => { + r#use::execute(cwd, version, unset, no_install, silent_if_unchanged).await + } + crate::cli::EnvSubcommands::Install { version } => { + let (resolved, from_session_override) = if let Some(version) = version { + let provider = vite_js_runtime::NodeProvider::new(); + (config::resolve_version_alias(&version, &provider).await?, false) + } else { + let resolution = config::resolve_version(&cwd).await?; + let from_session_override = matches!( + resolution.source.as_str(), + config::VERSION_ENV_VAR | config::SESSION_VERSION_FILE + ); + match resolution.source.as_str() { + ".node-version" + | "engines.node" + | "devEngines.runtime" + | config::VERSION_ENV_VAR + | config::SESSION_VERSION_FILE => {} + _ => { + eprintln!("No Node.js version found in current project."); + eprintln!("Specify a version: vp env install "); + eprintln!("Or pin one: vp env pin "); + return Ok(exit_status(1)); + } + } + (resolution.version, from_session_override) + }; + println!("Installing Node.js v{}...", resolved); + vite_js_runtime::download_runtime(vite_js_runtime::JsRuntimeType::Node, &resolved) + .await?; + println!("Installed Node.js v{}", resolved); + if from_session_override { + eprintln!("Note: Installed from session override."); + eprintln!("Run `vp env use --unset` to revert to project version resolution."); + } + Ok(ExitStatus::default()) + } + }; + } + + // Handle flags + if args.current { + return current::execute(cwd, args.json).await; + } + + if args.print { + return print_env(cwd).await; + } + + // No flags provided - show help (use clap's built-in help printer) + use clap::CommandFactory; + let bin_name = crate::cli::Args::command().get_bin_name().unwrap_or("vp").to_string(); + let display_name: &'static str = Box::leak(format!("{bin_name} env").into_boxed_str()); + crate::cli::Args::command() + .find_subcommand("env") + .unwrap() + .clone() + .name(display_name) + .disable_help_subcommand(true) + .print_help() + .ok(); + Ok(ExitStatus::default()) +} + +/// Print shell snippet for setting environment (--print flag) +async fn print_env(cwd: AbsolutePathBuf) -> Result { + // Resolve the Node.js version for the current directory + let resolution = config::resolve_version(&cwd).await?; + + // Get the node bin directory + let runtime = vite_js_runtime::download_runtime( + vite_js_runtime::JsRuntimeType::Node, + &resolution.version, + ) + .await?; + + let bin_dir = runtime.get_bin_prefix(); + + // Print shell snippet + println!("# Add to your shell to use this Node.js version for this session:"); + println!("export PATH=\"{}:$PATH\"", bin_dir.as_path().display()); + + Ok(ExitStatus::default()) +} + +/// Create an exit status with the given code. +fn exit_status(code: i32) -> ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(code << 8) + } + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(code as u32) + } +} diff --git a/crates/vite_global_cli/src/commands/env/off.rs b/crates/vite_global_cli/src/commands/env/off.rs new file mode 100644 index 0000000000..8cda453f7b --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/off.rs @@ -0,0 +1,30 @@ +//! Enable system-first mode command. +//! +//! Handles `vp env off` to set shim mode to "system_first" - +//! shims prefer system Node.js, fallback to managed if not found. + +use std::process::ExitStatus; + +use super::config::{ShimMode, load_config, save_config}; +use crate::error::Error; + +/// Execute the `vp env off` command. +pub async fn execute() -> Result { + let mut config = load_config().await?; + + if config.shim_mode == ShimMode::SystemFirst { + println!("Shim mode is already set to system-first."); + println!("Shims will prefer system Node.js, falling back to managed if not found."); + return Ok(ExitStatus::default()); + } + + config.shim_mode = ShimMode::SystemFirst; + save_config(&config).await?; + + println!("\u{2713} Shim mode set to system-first."); + println!(); + println!("Shims will now prefer system Node.js, falling back to managed if not found."); + println!("Run 'vp env on' to always use vite-plus managed Node.js."); + + Ok(ExitStatus::default()) +} diff --git a/crates/vite_global_cli/src/commands/env/on.rs b/crates/vite_global_cli/src/commands/env/on.rs new file mode 100644 index 0000000000..1b6e7b3b9c --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/on.rs @@ -0,0 +1,29 @@ +//! Enable managed mode command. +//! +//! Handles `vp env on` to set shim mode to "managed" - shims always use vite-plus Node.js. + +use std::process::ExitStatus; + +use super::config::{ShimMode, load_config, save_config}; +use crate::error::Error; + +/// Execute the `vp env on` command. +pub async fn execute() -> Result { + let mut config = load_config().await?; + + if config.shim_mode == ShimMode::Managed { + println!("Shim mode is already set to managed."); + println!("Shims will always use vite-plus managed Node.js."); + return Ok(ExitStatus::default()); + } + + config.shim_mode = ShimMode::Managed; + save_config(&config).await?; + + println!("\u{2713} Shim mode set to managed."); + println!(); + println!("Shims will now always use vite-plus managed Node.js."); + println!("Run 'vp env off' to prefer system Node.js instead."); + + Ok(ExitStatus::default()) +} diff --git a/crates/vite_global_cli/src/commands/env/package_metadata.rs b/crates/vite_global_cli/src/commands/env/package_metadata.rs new file mode 100644 index 0000000000..95d56bc16d --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/package_metadata.rs @@ -0,0 +1,350 @@ +//! Package metadata storage for global packages. + +use std::collections::HashSet; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use vite_path::AbsolutePathBuf; + +use super::config::get_packages_dir; +use crate::error::Error; + +/// Metadata for a globally installed package. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageMetadata { + /// Package name + pub name: String, + /// Package version + pub version: String, + /// Platform versions used during installation + pub platform: Platform, + /// Binary names provided by this package + pub bins: Vec, + /// Binary names that are JavaScript files (need Node.js to run). + #[serde(default)] + pub js_bins: HashSet, + /// Package manager used for installation (npm, yarn, pnpm) + pub manager: String, + /// Installation timestamp + pub installed_at: DateTime, +} + +/// Platform versions pinned to this package. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Platform { + /// Node.js version + pub node: String, + /// npm version (if applicable) + #[serde(skip_serializing_if = "Option::is_none")] + pub npm: Option, +} + +impl PackageMetadata { + /// Create new package metadata. + pub fn new( + name: String, + version: String, + node_version: String, + npm_version: Option, + bins: Vec, + js_bins: HashSet, + manager: String, + ) -> Self { + Self { + name, + version, + platform: Platform { node: node_version, npm: npm_version }, + bins, + js_bins, + manager, + installed_at: Utc::now(), + } + } + + /// Check if a binary requires Node.js to run. + pub fn is_js_binary(&self, bin_name: &str) -> bool { + self.js_bins.contains(bin_name) + } + + /// Get the metadata file path for a package. + pub fn metadata_path(package_name: &str) -> Result { + let packages_dir = get_packages_dir()?; + Ok(packages_dir.join(format!("{package_name}.json"))) + } + + /// Load metadata for a package. + pub async fn load(package_name: &str) -> Result, Error> { + let path = Self::metadata_path(package_name)?; + if !tokio::fs::try_exists(&path).await.unwrap_or(false) { + return Ok(None); + } + let content = tokio::fs::read_to_string(&path).await?; + let metadata: Self = serde_json::from_str(&content).map_err(|e| { + Error::ConfigError(format!("Failed to parse package metadata: {e}").into()) + })?; + Ok(Some(metadata)) + } + + /// Save metadata for a package. + pub async fn save(&self) -> Result<(), Error> { + let path = Self::metadata_path(&self.name)?; + // Create parent directory (handles scoped packages like @scope/pkg.json) + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + let content = serde_json::to_string_pretty(self).map_err(|e| { + Error::ConfigError(format!("Failed to serialize package metadata: {e}").into()) + })?; + tokio::fs::write(&path, content).await?; + Ok(()) + } + + /// Delete metadata for a package. + pub async fn delete(package_name: &str) -> Result<(), Error> { + let path = Self::metadata_path(package_name)?; + if tokio::fs::try_exists(&path).await.unwrap_or(false) { + tokio::fs::remove_file(&path).await?; + } + Ok(()) + } + + /// List all installed packages. + pub async fn list_all() -> Result, Error> { + let packages_dir = get_packages_dir()?; + if !tokio::fs::try_exists(&packages_dir).await.unwrap_or(false) { + return Ok(Vec::new()); + } + + let mut packages = Vec::new(); + list_packages_recursive(&packages_dir, &mut packages).await?; + Ok(packages) + } + + /// Find the package that provides a given binary. + /// + /// Returns the package metadata if found, None otherwise. + pub async fn find_by_binary(binary_name: &str) -> Result, Error> { + let packages = Self::list_all().await?; + + for package in packages { + if package.bins.contains(&binary_name.to_string()) { + return Ok(Some(package)); + } + } + + Ok(None) + } +} + +/// Recursively list packages in a directory (handles scoped packages in subdirs). +async fn list_packages_recursive( + dir: &vite_path::AbsolutePath, + packages: &mut Vec, +) -> Result<(), Error> { + let mut entries = tokio::fs::read_dir(dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let file_type = entry.file_type().await?; + + if file_type.is_dir() { + // Only recurse into scoped package directories (@scope/) + // Skip package installation directories (typescript/, projj/) + if let Some(name) = entry.file_name().to_str() { + if name.starts_with('@') { + if let Some(abs_path) = AbsolutePathBuf::new(path) { + Box::pin(list_packages_recursive(&abs_path, packages)).await?; + } + } + } + } else if path.extension().is_some_and(|e| e == "json") { + // Read JSON metadata files + if let Ok(content) = tokio::fs::read_to_string(&path).await { + if let Ok(metadata) = serde_json::from_str::(&content) { + packages.push(metadata); + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + + use super::*; + + #[test] + fn test_metadata_path_regular_package() { + // Regular package: typescript.json + let path = PackageMetadata::metadata_path("typescript").unwrap(); + assert!(path.as_path().ends_with("typescript.json")); + } + + #[test] + fn test_metadata_path_scoped_package() { + // Scoped package: @types/node.json (inside @types directory) + let path = PackageMetadata::metadata_path("@types/node").unwrap(); + let path_str = path.as_path().to_string_lossy(); + assert!( + path_str.ends_with("@types/node.json"), + "Expected path ending with @types/node.json, got: {}", + path_str + ); + } + + #[tokio::test] + #[serial] + async fn test_save_scoped_package_metadata() { + use tempfile::TempDir; + + // Create temp directory and set VITE_PLUS_HOME + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path().to_path_buf(); + + // Temporarily override VITE_PLUS_HOME for this test + // SAFETY: This test runs in isolation + unsafe { + std::env::set_var("VITE_PLUS_HOME", &temp_path); + } + + let metadata = PackageMetadata::new( + "@scope/test-pkg".to_string(), + "1.0.0".to_string(), + "20.18.0".to_string(), + None, + vec!["test-bin".to_string()], + HashSet::from(["test-bin".to_string()]), + "npm".to_string(), + ); + + // This should not fail with "No such file or directory" + // because save() should create the @scope parent directory + 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); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_list_all_includes_scoped_packages() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path().to_path_buf(); + + // SAFETY: This test runs in isolation + unsafe { + std::env::set_var("VITE_PLUS_HOME", &temp_path); + } + + // Create regular package metadata + let regular = PackageMetadata::new( + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + None, + vec!["tsc".to_string()], + HashSet::from(["tsc".to_string()]), + "npm".to_string(), + ); + regular.save().await.unwrap(); + + // Create scoped package metadata + let scoped = PackageMetadata::new( + "@types/node".to_string(), + "20.0.0".to_string(), + "20.18.0".to_string(), + None, + vec![], + HashSet::new(), + "npm".to_string(), + ); + scoped.save().await.unwrap(); + + // list_all should find both + let all = PackageMetadata::list_all().await.unwrap(); + assert_eq!(all.len(), 2, "Expected 2 packages, got {}", all.len()); + + let names: Vec<_> = all.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"typescript"), "Missing typescript package"); + assert!(names.contains(&"@types/node"), "Missing @types/node package"); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_find_by_binary() { + use tempfile::TempDir; + + let temp_dir = TempDir::new().unwrap(); + let temp_path = temp_dir.path().to_path_buf(); + + // SAFETY: This test runs in isolation + unsafe { + std::env::set_var("VITE_PLUS_HOME", &temp_path); + } + + // Create typescript package with tsc and tsserver binaries + let typescript = PackageMetadata::new( + "typescript".to_string(), + "5.0.0".to_string(), + "20.18.0".to_string(), + None, + vec!["tsc".to_string(), "tsserver".to_string()], + HashSet::from(["tsc".to_string(), "tsserver".to_string()]), + "npm".to_string(), + ); + typescript.save().await.unwrap(); + + // Create eslint package with eslint binary + let eslint = PackageMetadata::new( + "eslint".to_string(), + "9.0.0".to_string(), + "22.13.0".to_string(), + None, + vec!["eslint".to_string()], + HashSet::from(["eslint".to_string()]), + "npm".to_string(), + ); + eslint.save().await.unwrap(); + + // Find by binary should return the correct package + let found = PackageMetadata::find_by_binary("tsc").await.unwrap(); + assert!(found.is_some(), "Should find package providing tsc"); + assert_eq!(found.unwrap().name, "typescript"); + + let found = PackageMetadata::find_by_binary("tsserver").await.unwrap(); + assert!(found.is_some(), "Should find package providing tsserver"); + assert_eq!(found.unwrap().name, "typescript"); + + let found = PackageMetadata::find_by_binary("eslint").await.unwrap(); + assert!(found.is_some(), "Should find package providing eslint"); + assert_eq!(found.unwrap().name, "eslint"); + + // Non-existent binary should return None + let found = PackageMetadata::find_by_binary("nonexistent").await.unwrap(); + assert!(found.is_none(), "Should not find package for nonexistent binary"); + + // Clean up env var + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + } + } +} diff --git a/crates/vite_global_cli/src/commands/env/packages.rs b/crates/vite_global_cli/src/commands/env/packages.rs new file mode 100644 index 0000000000..07ef5bda00 --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/packages.rs @@ -0,0 +1,73 @@ +//! List installed global packages. + +use std::process::ExitStatus; + +use owo_colors::OwoColorize; + +use super::package_metadata::PackageMetadata; +use crate::error::Error; + +/// Execute the packages command. +pub async fn execute(json: bool, pattern: Option<&str>) -> Result { + let all_packages = PackageMetadata::list_all().await?; + + let packages: Vec<_> = if let Some(pat) = pattern { + let pat_lower = pat.to_lowercase(); + all_packages.into_iter().filter(|p| p.name.to_lowercase().contains(&pat_lower)).collect() + } else { + all_packages + }; + + if packages.is_empty() { + if json { + println!("[]"); + } else if pattern.is_some() { + println!("No global packages matching '{}'.", pattern.unwrap()); + println!(); + println!("Run 'vp list -g' to see all installed global packages."); + } else { + println!("No global packages installed."); + println!(); + println!("Install packages with: vp install -g "); + } + return Ok(ExitStatus::default()); + } + + if json { + let json_output = serde_json::to_string_pretty(&packages) + .map_err(|e| Error::ConfigError(format!("Failed to serialize: {e}").into()))?; + println!("{json_output}"); + } else { + let col_pkg = "Package"; + let col_node = "Node version"; + let col_bins = "Binaries"; + + let mut w_pkg = col_pkg.len(); + let mut w_node = col_node.len(); + + for pkg in &packages { + let name = format!("{}@{}", pkg.name, pkg.version); + w_pkg = w_pkg.max(name.len()); + w_node = w_node.max(pkg.platform.node.len()); + } + + let gap = 3; + println!("{:gap$}{:gap$}{}", col_pkg, "", col_node, "", col_bins); + println!("{:gap$}{:gap$}{}", "---", "", "---", "", "---"); + + for pkg in &packages { + let name = format!("{:gap$}{:gap$}{}", + name.bright_blue(), + "", + pkg.platform.node, + "", + bins + ); + } + } + + Ok(ExitStatus::default()) +} diff --git a/crates/vite_global_cli/src/commands/env/pin.rs b/crates/vite_global_cli/src/commands/env/pin.rs new file mode 100644 index 0000000000..763c8fda3b --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/pin.rs @@ -0,0 +1,285 @@ +//! Pin command for per-directory Node.js version management. +//! +//! Handles `vp env pin [VERSION]` to pin a Node.js version in the current directory +//! by creating or updating a `.node-version` file. + +use std::{io::Write, process::ExitStatus}; + +use vite_js_runtime::NodeProvider; +use vite_path::AbsolutePathBuf; + +use super::config::{get_config_path, load_config}; +use crate::error::Error; + +/// Node version file name +const NODE_VERSION_FILE: &str = ".node-version"; + +/// Execute the pin command. +pub async fn execute( + cwd: AbsolutePathBuf, + version: Option, + unpin: bool, + no_install: bool, + force: bool, +) -> Result { + // Handle --unpin flag + if unpin { + return do_unpin(&cwd).await; + } + + match version { + Some(v) => do_pin(&cwd, &v, no_install, force).await, + None => show_pinned(&cwd).await, + } +} + +/// Show the current pinned version. +async fn show_pinned(cwd: &AbsolutePathBuf) -> Result { + let node_version_path = cwd.join(NODE_VERSION_FILE); + + // Check if .node-version exists in current directory + if tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) { + let content = tokio::fs::read_to_string(&node_version_path).await?; + let version = content.trim(); + println!("Pinned version: {version}"); + println!(" Source: {}", node_version_path.as_path().display()); + return Ok(ExitStatus::default()); + } + + // Check for inherited version from parent directories + if let Some((version, source_path)) = find_inherited_version(cwd).await? { + println!("No version pinned in current directory."); + println!(" Inherited: {version} from {}", source_path.as_path().display()); + return Ok(ExitStatus::default()); + } + + // No .node-version anywhere - show default + let config = load_config().await?; + match config.default_node_version { + Some(version) => { + let config_path = get_config_path()?; + println!("No version pinned."); + println!(" Using default: {version} (from {})", config_path.as_path().display()); + } + None => { + println!("No version pinned."); + println!(" Run 'vp env pin ' to pin a version."); + } + } + + Ok(ExitStatus::default()) +} + +/// Find .node-version in parent directories. +async fn find_inherited_version( + cwd: &AbsolutePathBuf, +) -> Result, Error> { + let mut current: Option = cwd.parent().map(|p| p.to_absolute_path_buf()); + + while let Some(dir) = current { + let node_version_path = dir.join(NODE_VERSION_FILE); + if tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) { + let content = tokio::fs::read_to_string(&node_version_path).await?; + return Ok(Some((content.trim().to_string(), node_version_path))); + } + current = dir.parent().map(|p| p.to_absolute_path_buf()); + } + + Ok(None) +} + +/// Pin a version to the current directory. +async fn do_pin( + cwd: &AbsolutePathBuf, + version: &str, + no_install: bool, + force: bool, +) -> Result { + let provider = NodeProvider::new(); + let node_version_path = cwd.join(NODE_VERSION_FILE); + + // Resolve the version (aliases like lts/latest are resolved to exact versions) + let (resolved_version, was_alias) = resolve_version_for_pin(version, &provider).await?; + + // Check if .node-version already exists + if !force && tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) { + let existing_content = tokio::fs::read_to_string(&node_version_path).await?; + let existing_version = existing_content.trim(); + + if existing_version == resolved_version { + println!("Already pinned to {resolved_version}"); + return Ok(ExitStatus::default()); + } + + // Prompt for confirmation + print!(".node-version already exists with version {existing_version}"); + println!(); + print!("Overwrite with {resolved_version}? (y/n): "); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + + if !input.trim().eq_ignore_ascii_case("y") { + println!("Cancelled."); + return Ok(ExitStatus::default()); + } + } + + // Write the version to .node-version + tokio::fs::write(&node_version_path, format!("{resolved_version}\n")).await?; + + // Print success message + if was_alias { + println!("\u{2713} Pinned Node.js version to {resolved_version} (resolved from {version})"); + } else { + println!("\u{2713} Pinned Node.js version to {resolved_version}"); + } + println!(" Created {} in {}", NODE_VERSION_FILE, cwd.as_path().display()); + + // Pre-download the version unless --no-install is specified + if no_install { + println!(" Note: Version will be downloaded on first use."); + } else { + // Download the runtime + match vite_js_runtime::download_runtime( + vite_js_runtime::JsRuntimeType::Node, + &resolved_version, + ) + .await + { + Ok(_) => { + println!("\u{2713} Node.js {resolved_version} installed"); + } + Err(e) => { + eprintln!("Warning: Failed to download Node.js {resolved_version}: {e}"); + eprintln!(" Version will be downloaded on first use."); + } + } + } + + Ok(ExitStatus::default()) +} + +/// Resolve version for pinning. +/// +/// Aliases (lts, latest) are resolved to exact versions. +/// Returns (resolved_version, was_alias). +async fn resolve_version_for_pin( + version: &str, + provider: &NodeProvider, +) -> Result<(String, bool), Error> { + match version.to_lowercase().as_str() { + "lts" => { + let resolved = provider.resolve_latest_version().await?; + Ok((resolved.to_string(), true)) + } + "latest" => { + let resolved = provider.resolve_version("*").await?; + Ok((resolved.to_string(), true)) + } + _ => { + // For exact versions, validate they exist + if NodeProvider::is_exact_version(version) { + // Validate the version exists by trying to resolve it + provider.resolve_version(version).await?; + Ok((version.to_string(), false)) + } else { + // For ranges/partial versions, keep as-is (resolved at runtime) + // But validate the range is parseable + provider.resolve_version(version).await?; + Ok((version.to_string(), false)) + } + } + } +} + +/// Remove the .node-version file from current directory. +pub async fn do_unpin(cwd: &AbsolutePathBuf) -> Result { + let node_version_path = cwd.join(NODE_VERSION_FILE); + + if !tokio::fs::try_exists(&node_version_path).await.unwrap_or(false) { + println!("No {} file in current directory.", NODE_VERSION_FILE); + return Ok(ExitStatus::default()); + } + + tokio::fs::remove_file(&node_version_path).await?; + println!("\u{2713} Removed {} from {}", NODE_VERSION_FILE, cwd.as_path().display()); + + Ok(ExitStatus::default()) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + use super::*; + + #[tokio::test] + async fn test_show_pinned_no_file() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Should not error when no .node-version exists + let result = show_pinned(&temp_path).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_show_pinned_with_file() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + let result = show_pinned(&temp_path).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_find_inherited_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version in parent + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // Create subdirectory + let subdir = temp_path.join("subdir"); + tokio::fs::create_dir(&subdir).await.unwrap(); + + let result = find_inherited_version(&subdir).await.unwrap(); + assert!(result.is_some()); + let (version, _) = result.unwrap(); + assert_eq!(version, "20.18.0"); + } + + #[tokio::test] + async fn test_do_unpin() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version + let node_version_path = temp_path.join(".node-version"); + tokio::fs::write(&node_version_path, "20.18.0\n").await.unwrap(); + + // Unpin + let result = do_unpin(&temp_path).await; + assert!(result.is_ok()); + + // File should be gone + assert!(!tokio::fs::try_exists(&node_version_path).await.unwrap()); + } + + #[tokio::test] + async fn test_do_unpin_no_file() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Should not error when no file exists + let result = do_unpin(&temp_path).await; + assert!(result.is_ok()); + } +} diff --git a/crates/vite_global_cli/src/commands/env/run.rs b/crates/vite_global_cli/src/commands/env/run.rs new file mode 100644 index 0000000000..80c40fa2bc --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/run.rs @@ -0,0 +1,241 @@ +//! Run command for executing commands with a specific Node.js version. +//! +//! Handles two modes: +//! 1. Explicit version: `vp env run --node [--npm ] ` +//! 2. Shim mode: `vp env run [args...]` where tool is node/npm/npx or a global package binary +//! +//! The shim mode uses the same dispatch logic as Unix symlinks, ensuring identical behavior +//! across platforms (used by Windows .cmd wrappers and Git Bash shell scripts). + +use std::process::ExitStatus; + +use vite_js_runtime::NodeProvider; +use vite_shared::format_path_prepended; + +use crate::{ + error::Error, + shim::{dispatch as shim_dispatch, is_shim_tool}, +}; + +/// Execute the run command. +/// +/// When `--node` is provided, runs a command with the specified Node.js version. +/// When `--node` is not provided and the command is a shim tool (node/npm/npx or global package), +/// uses the same shim dispatch logic as Unix symlinks. +pub async fn execute( + node_version: Option<&str>, + npm_version: Option<&str>, + command: &[String], +) -> Result { + if command.is_empty() { + eprintln!("vp env run: missing command to execute"); + eprintln!("Usage: vp env run [--node ] [args...]"); + return Ok(exit_status(1)); + } + + // If --node is provided, use explicit version mode (existing behavior) + if let Some(version) = node_version { + return execute_with_version(version, npm_version, command).await; + } + + // No --node provided - check if first command is a shim tool + // This includes: + // - Core tools (node, npm, npx) + // - Globally installed package binaries (tsc, eslint, etc.) + let tool = &command[0]; + if is_shim_tool(tool) { + // Clear recursion env var to force fresh version resolution. + // This is needed because `vp env run` may be invoked from within a context + // where VITE_PLUS_TOOL_RECURSION is already set (e.g., when pnpm runs through + // the vite-plus shim). Without clearing it, shim_dispatch would passthrough + // to the system node instead of resolving the version. + // SAFETY: This is safe because we're about to spawn a child process and we want + // fresh version resolution, not passthrough behavior. + unsafe { + std::env::remove_var("VITE_PLUS_TOOL_RECURSION"); + } + + // Use the SAME shim dispatch as Unix symlinks - this ensures: + // - Core tools: Version resolved from .node-version/package.json/default + // - Package binaries: Uses Node.js version from package metadata + // - Automatic Node.js download if needed + // - Recursion prevention via VITE_PLUS_TOOL_RECURSION + // - Shim mode checking (managed vs system-first) + let args: Vec = command[1..].to_vec(); + let exit_code = shim_dispatch(tool, &args).await; + return Ok(exit_status(exit_code)); + } + + // Not a shim tool and no --node - error + eprintln!("vp env run: --node is required when running non-shim commands"); + eprintln!("Usage: vp env run --node [args...]"); + eprintln!(); + eprintln!("For shim tools, --node is optional (version resolved automatically):"); + eprintln!(" vp env run node script.js # Core tool"); + eprintln!(" vp env run npm install # Core tool"); + eprintln!(" vp env run tsc --version # Global package"); + Ok(exit_status(1)) +} + +/// Execute a command with an explicitly specified Node.js version. +async fn execute_with_version( + node_version: &str, + npm_version: Option<&str>, + command: &[String], +) -> Result { + // Warn about unsupported --npm flag + if npm_version.is_some() { + eprintln!("Warning: --npm flag is not yet implemented, using bundled npm"); + } + + // 1. Resolve version + let provider = NodeProvider::new(); + let resolved_version = resolve_version(node_version, &provider).await?; + + // 2. Ensure installed (download if needed) + let runtime = + vite_js_runtime::download_runtime(vite_js_runtime::JsRuntimeType::Node, &resolved_version) + .await?; + + // 3. Clear recursion env var to force re-evaluation in child processes + // SAFETY: This is safe because we're about to spawn a child process and we want + // to ensure the env var is not inherited. We're not reading this env var in other + // threads at this point. + unsafe { + std::env::remove_var("VITE_PLUS_TOOL_RECURSION"); + } + + // 4. Build PATH with node bin dir first (uses platform-specific separator) + // Always prepend to ensure the requested Node version is first in PATH + let node_bin_dir = runtime.get_bin_prefix(); + let new_path = format_path_prepended(node_bin_dir.as_path()); + + // 5. Execute command + let (cmd, args) = command.split_first().unwrap(); + + let status = + tokio::process::Command::new(cmd).args(args).env("PATH", new_path).status().await?; + + Ok(status) +} + +/// Resolve version to an exact version. +/// +/// Handles aliases (lts, latest) and version ranges. +async fn resolve_version(version: &str, provider: &NodeProvider) -> Result { + match version.to_lowercase().as_str() { + "lts" => { + let resolved = provider.resolve_latest_version().await?; + Ok(resolved.to_string()) + } + "latest" => { + let resolved = provider.resolve_version("*").await?; + Ok(resolved.to_string()) + } + _ => { + // For exact versions, use directly + if NodeProvider::is_exact_version(version) { + // Strip v prefix if present + let normalized = version.strip_prefix('v').unwrap_or(version); + Ok(normalized.to_string()) + } else { + // For ranges/partial versions, resolve to exact + let resolved = provider.resolve_version(version).await?; + Ok(resolved.to_string()) + } + } + } +} + +/// Create an exit status with the given code. +fn exit_status(code: i32) -> ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(code << 8) + } + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(code as u32) + } +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + + use super::*; + + #[tokio::test] + async fn test_execute_missing_command() { + let result = execute(Some("20.18.0"), None, &[]).await; + assert!(result.is_ok()); + let status = result.unwrap(); + assert!(!status.success()); + } + + #[tokio::test] + #[serial] + async fn test_execute_node_version() { + // Run 'node --version' with a specific Node.js version + let command = vec!["node".to_string(), "--version".to_string()]; + let result = execute(Some("20.18.0"), None, &command).await; + assert!(result.is_ok()); + let status = result.unwrap(); + assert!(status.success()); + } + + #[tokio::test] + async fn test_resolve_version_exact() { + let provider = NodeProvider::new(); + let version = resolve_version("20.18.0", &provider).await.unwrap(); + assert_eq!(version, "20.18.0"); + } + + #[tokio::test] + async fn test_resolve_version_with_v_prefix() { + let provider = NodeProvider::new(); + let version = resolve_version("v20.18.0", &provider).await.unwrap(); + assert_eq!(version, "20.18.0"); + } + + #[tokio::test] + async fn test_resolve_version_partial() { + let provider = NodeProvider::new(); + let version = resolve_version("20", &provider).await.unwrap(); + // Should resolve to a 20.x.x version - check starts with "20." + assert!(version.starts_with("20."), "Expected version starting with '20.', got: {version}"); + } + + #[tokio::test] + async fn test_resolve_version_range() { + let provider = NodeProvider::new(); + let version = resolve_version("^20.0.0", &provider).await.unwrap(); + // Should resolve to a 20.x.x version - check starts with "20." + assert!(version.starts_with("20."), "Expected version starting with '20.', got: {version}"); + } + + #[tokio::test] + async fn test_resolve_version_lts() { + let provider = NodeProvider::new(); + let version = resolve_version("lts", &provider).await.unwrap(); + // Should resolve to a valid version (format: x.y.z) + let parts: Vec<&str> = version.split('.').collect(); + assert_eq!(parts.len(), 3, "Expected version format x.y.z, got: {version}"); + // Major version should be >= 20 (current LTS line) + let major: u32 = parts[0].parse().expect("Major version should be a number"); + assert!(major >= 20, "Expected major version >= 20, got: {major}"); + } + + #[tokio::test] + async fn test_shim_mode_error_for_non_shim_command() { + // Running a non-shim command without --node should error + let command = vec!["python".to_string(), "--version".to_string()]; + let result = execute(None, None, &command).await; + assert!(result.is_ok()); + let status = result.unwrap(); + // Should fail because python is not a shim tool and --node was not provided + assert!(!status.success(), "Non-shim command without --node should fail"); + } +} diff --git a/crates/vite_global_cli/src/commands/env/setup.rs b/crates/vite_global_cli/src/commands/env/setup.rs new file mode 100644 index 0000000000..da2734dae4 --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/setup.rs @@ -0,0 +1,795 @@ +//! Setup command implementation for creating bin directory and shims. +//! +//! Creates the following structure: +//! - ~/.vite-plus/bin/ - Contains vp symlink and node/npm/npx shims +//! - ~/.vite-plus/current/ - Contains the actual vp CLI binary +//! +//! On Unix: +//! - bin/vp is a symlink to ../current/bin/vp +//! - bin/node, bin/npm, bin/npx are symlinks to ../current/bin/vp +//! - Symlinks preserve argv[0], allowing tool detection via the symlink name +//! +//! On Windows: +//! - bin/vp.cmd is a wrapper script that calls ..\current\bin\vp.exe +//! - bin/node.cmd, bin/npm.cmd, bin/npx.cmd are wrappers calling `vp env run ` + +use std::process::ExitStatus; + +use super::config::{get_bin_dir, get_vite_plus_home}; +use crate::error::Error; + +/// Tools to create shims for (node, npm, npx) +const SHIM_TOOLS: &[&str] = &["node", "npm", "npx"]; + +/// Execute the setup command. +pub async fn execute(refresh: bool, env_only: bool) -> Result { + let vite_plus_home = get_vite_plus_home()?; + + // Ensure home directory exists (env files are written here) + tokio::fs::create_dir_all(&vite_plus_home).await?; + + // Create env files with PATH guard (prevents duplicate PATH entries) + create_env_files(&vite_plus_home).await?; + + if env_only { + return Ok(ExitStatus::default()); + } + + let bin_dir = get_bin_dir()?; + + println!("Setting up vite-plus environment..."); + println!(); + + // Ensure bin directory exists + tokio::fs::create_dir_all(&bin_dir).await?; + + // Get the current executable path (for shims) + let current_exe = std::env::current_exe() + .map_err(|e| Error::ConfigError(format!("Cannot find current executable: {e}").into()))?; + + // Create wrapper script in bin/ + setup_vp_wrapper(&bin_dir, refresh).await?; + + // Create shims for node, npm, npx + let mut created = Vec::new(); + let mut skipped = Vec::new(); + + for tool in SHIM_TOOLS { + let result = create_shim(¤t_exe, &bin_dir, tool, refresh).await?; + if result { + created.push(*tool); + } else { + skipped.push(*tool); + } + } + + // Print results + if !created.is_empty() { + println!("Created shims:"); + for tool in &created { + let shim_path = bin_dir.join(shim_filename(tool)); + println!(" {}", shim_path.as_path().display()); + } + } + + if !skipped.is_empty() && !refresh { + println!("Skipped existing shims:"); + for tool in &skipped { + let shim_path = bin_dir.join(shim_filename(tool)); + println!(" {}", shim_path.as_path().display()); + } + println!(); + println!("Use --refresh to update existing shims."); + } + + println!(); + print_path_instructions(&bin_dir); + + Ok(ExitStatus::default()) +} + +/// Create symlink in bin/ that points to current/bin/vp. +async fn setup_vp_wrapper(bin_dir: &vite_path::AbsolutePath, refresh: bool) -> Result<(), Error> { + #[cfg(unix)] + { + let bin_vp = bin_dir.join("vp"); + + // Create symlink bin/vp -> ../current/bin/vp + let should_create_symlink = refresh + || !tokio::fs::try_exists(&bin_vp).await.unwrap_or(false) + || !is_symlink(&bin_vp).await; // Replace non-symlink with symlink + + if should_create_symlink { + // Remove existing if present (could be old wrapper script or file) + if tokio::fs::try_exists(&bin_vp).await.unwrap_or(false) { + tokio::fs::remove_file(&bin_vp).await?; + } + // Create relative symlink + tokio::fs::symlink("../current/bin/vp", &bin_vp).await?; + tracing::debug!("Created symlink {:?} -> ../current/bin/vp", bin_vp); + } + } + + #[cfg(windows)] + { + let bin_vp_cmd = bin_dir.join("vp.cmd"); + + // Create wrapper script bin/vp.cmd that calls current\bin\vp.exe + let should_create_wrapper = + refresh || !tokio::fs::try_exists(&bin_vp_cmd).await.unwrap_or(false); + + if should_create_wrapper { + // Set VITE_PLUS_HOME using a for loop to canonicalize the path. + // %~dp0.. would produce paths like C:\Users\x\.vite-plus\bin\.. + // The for loop resolves this to a clean C:\Users\x\.vite-plus + let cmd_content = "@echo off\r\nfor %%I in (\"%~dp0..\") do set VITE_PLUS_HOME=%%~fI\r\n\"%VITE_PLUS_HOME%\\current\\bin\\vp.exe\" %*\r\nexit /b %ERRORLEVEL%\r\n"; + tokio::fs::write(&bin_vp_cmd, cmd_content).await?; + tracing::debug!("Created wrapper script {:?}", bin_vp_cmd); + } + + // Also create shell script for Git Bash (vp without extension) + // Note: We call vp.exe directly, not via symlink, because Windows + // symlinks require admin privileges and Git Bash support is unreliable + let bin_vp = bin_dir.join("vp"); + let should_create_sh = refresh || !tokio::fs::try_exists(&bin_vp).await.unwrap_or(false); + + if should_create_sh { + let sh_content = r#"#!/bin/sh +VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" +export VITE_PLUS_HOME +exec "$VITE_PLUS_HOME/current/bin/vp.exe" "$@" +"#; + tokio::fs::write(&bin_vp, sh_content).await?; + tracing::debug!("Created shell wrapper script {:?}", bin_vp); + } + } + + Ok(()) +} + +/// Check if a path is a symlink. +#[cfg(unix)] +async fn is_symlink(path: &vite_path::AbsolutePath) -> bool { + match tokio::fs::symlink_metadata(path).await { + Ok(m) => m.file_type().is_symlink(), + Err(_) => false, + } +} + +/// Create a single shim for node/npm/npx. +/// +/// Returns `true` if the shim was created, `false` if it already exists. +async fn create_shim( + source: &std::path::Path, + bin_dir: &vite_path::AbsolutePath, + tool: &str, + refresh: bool, +) -> Result { + let shim_path = bin_dir.join(shim_filename(tool)); + + // Check if shim already exists + if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) { + if !refresh { + return Ok(false); + } + // Remove existing shim for refresh + tokio::fs::remove_file(&shim_path).await?; + } + + #[cfg(unix)] + { + create_unix_shim(source, &shim_path, tool).await?; + } + + #[cfg(windows)] + { + create_windows_shim(source, bin_dir, tool).await?; + } + + Ok(true) +} + +/// Get the filename for a shim (platform-specific). +fn shim_filename(tool: &str) -> String { + #[cfg(windows)] + { + // All tools use .cmd wrappers on Windows (including node) + format!("{tool}.cmd") + } + + #[cfg(not(windows))] + { + tool.to_string() + } +} + +/// Create a Unix shim using symlink to ../current/bin/vp. +/// +/// Symlinks preserve argv[0], allowing the vp binary to detect which tool +/// was invoked. This is the same pattern used by Volta. +#[cfg(unix)] +async fn create_unix_shim( + _source: &std::path::Path, + shim_path: &vite_path::AbsolutePath, + _tool: &str, +) -> Result<(), Error> { + // Create symlink to ../current/bin/vp (relative path) + tokio::fs::symlink("../current/bin/vp", shim_path).await?; + tracing::debug!("Created symlink shim at {:?} -> ../current/bin/vp", shim_path); + + Ok(()) +} + +/// Create Windows shims using .cmd wrappers that call `vp env run `. +/// +/// All tools (node, npm, npx) get .cmd wrappers that invoke `vp env run`. +/// Also creates shell scripts (without extension) for Git Bash compatibility. +/// This is consistent with Volta's Windows approach. +#[cfg(windows)] +async fn create_windows_shim( + _source: &std::path::Path, + bin_dir: &vite_path::AbsolutePath, + tool: &str, +) -> Result<(), Error> { + let cmd_path = bin_dir.join(format!("{tool}.cmd")); + + // Create .cmd wrapper that calls vp env run + // Use a for loop to canonicalize VITE_PLUS_HOME path. + // %~dp0.. would produce paths like C:\Users\x\.vite-plus\bin\.. + // The for loop resolves this to a clean C:\Users\x\.vite-plus + let cmd_content = format!( + "@echo off\r\nfor %%I in (\"%~dp0..\") do set VITE_PLUS_HOME=%%~fI\r\n\"%VITE_PLUS_HOME%\\current\\bin\\vp.exe\" env run {} %*\r\nexit /b %ERRORLEVEL%\r\n", + tool + ); + + tokio::fs::write(&cmd_path, cmd_content).await?; + + // Also create shell script for Git Bash (tool without extension) + // Uses explicit "vp env run " instead of symlink+argv[0] because + // Windows symlinks require admin privileges + let sh_path = bin_dir.join(tool); + let sh_content = format!( + r#"#!/bin/sh +VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" +export VITE_PLUS_HOME +exec "$VITE_PLUS_HOME/current/bin/vp.exe" env run {} "$@" +"#, + tool + ); + tokio::fs::write(&sh_path, sh_content).await?; + + tracing::debug!("Created Windows wrappers for {} (.cmd and shell script)", tool); + + Ok(()) +} + +/// Create env files with PATH guard (prevents duplicate PATH entries). +/// +/// Creates: +/// - `~/.vite-plus/env` (POSIX shell — bash/zsh) with `vp()` wrapper function +/// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function +/// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function +/// - `~/.vite-plus/bin/vp-use.cmd` (cmd.exe wrapper for `vp env use`) +async fn create_env_files(vite_plus_home: &vite_path::AbsolutePath) -> Result<(), Error> { + let bin_path = vite_plus_home.join("bin"); + + // Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env) + // This makes the env file portable across sessions where HOME may differ + let bin_path_ref = if let Ok(home_dir) = std::env::var("HOME") { + let home = std::path::Path::new(&home_dir); + if let Ok(suffix) = bin_path.as_path().strip_prefix(home) { + format!("$HOME/{}", suffix.display()) + } else { + bin_path.as_path().display().to_string() + } + } else { + bin_path.as_path().display().to_string() + }; + + // POSIX env file (bash/zsh) + // When sourced multiple times, removes existing entry and re-prepends to front + // Uses parameter expansion to split PATH around the bin entry in O(1) operations + // Includes vp() shell function wrapper for `vp env use` (evals stdout) + let env_content = r#"#!/bin/sh +# Vite+ environment setup (https://viteplus.dev) +__vp_bin="__VP_BIN__" +case ":${PATH}:" in + *":${__vp_bin}:"*) + __vp_tmp=":${PATH}:" + __vp_before="${__vp_tmp%%":${__vp_bin}:"*}" + __vp_before="${__vp_before#:}" + __vp_after="${__vp_tmp#*":${__vp_bin}:"}" + __vp_after="${__vp_after%:}" + export PATH="${__vp_bin}${__vp_before:+:${__vp_before}}${__vp_after:+:${__vp_after}}" + unset __vp_tmp __vp_before __vp_after + ;; + *) + export PATH="$__vp_bin:$PATH" + ;; +esac +unset __vp_bin + +# Shell function wrapper: intercepts `vp env use` to eval its stdout, +# which sets/unsets VITE_PLUS_NODE_VERSION in the current shell session. +vp() { + if [ "$1" = "env" ] && [ "$2" = "use" ]; then + case " $* " in *" -h "*|*" --help "*) command vp "$@"; return; esac + __vp_out="$(VITE_PLUS_ENV_USE_EVAL_ENABLE=1 command vp "$@")" || return $? + eval "$__vp_out" + else + command vp "$@" + fi +} +"# + .replace("__VP_BIN__", &bin_path_ref); + let env_file = vite_plus_home.join("env"); + tokio::fs::write(&env_file, env_content).await?; + + // Fish env file with vp wrapper function + let env_fish_content = r#"# Vite+ environment setup (https://viteplus.dev) +set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) +and set -e PATH[$__vp_idx] +set -gx PATH __VP_BIN__ $PATH + +# Shell function wrapper: intercepts `vp env use` to eval its stdout, +# which sets/unsets VITE_PLUS_NODE_VERSION in the current shell session. +function vp + if test (count $argv) -ge 2; and test "$argv[1]" = "env"; and test "$argv[2]" = "use" + if contains -- -h $argv; or contains -- --help $argv + command vp $argv; return + end + set -lx VITE_PLUS_ENV_USE_EVAL_ENABLE 1 + set -l __vp_out (command vp $argv); or return $status + eval $__vp_out + else + command vp $argv + end +end +"# + .replace("__VP_BIN__", &bin_path_ref); + let env_fish_file = vite_plus_home.join("env.fish"); + tokio::fs::write(&env_fish_file, env_fish_content).await?; + + // PowerShell env file + let env_ps1_content = r#"# Vite+ environment setup (https://viteplus.dev) +$__vp_bin = "__VP_BIN_WIN__" +if ($env:Path -split ';' -notcontains $__vp_bin) { + $env:Path = "$__vp_bin;$env:Path" +} + +# Shell function wrapper: intercepts `vp env use` to eval its stdout, +# which sets/unsets VITE_PLUS_NODE_VERSION in the current shell session. +function vp { + if ($args.Count -ge 2 -and $args[0] -eq "env" -and $args[1] -eq "use") { + if ($args -contains "-h" -or $args -contains "--help") { + & (Join-Path $__vp_bin "vp.exe") @args; return + } + $env:VITE_PLUS_ENV_USE_EVAL_ENABLE = "1" + $output = & (Join-Path $__vp_bin "vp.exe") @args 2>&1 | ForEach-Object { + if ($_ -is [System.Management.Automation.ErrorRecord]) { + Write-Host $_.Exception.Message + } else { + $_ + } + } + Remove-Item Env:VITE_PLUS_ENV_USE_EVAL_ENABLE -ErrorAction SilentlyContinue + if ($LASTEXITCODE -eq 0 -and $output) { + Invoke-Expression ($output -join "`n") + } + } else { + & (Join-Path $__vp_bin "vp.exe") @args + } +} +"#; + + // For PowerShell, use the actual absolute path (not $HOME-relative) + let bin_path_win = bin_path.as_path().display().to_string(); + let env_ps1_content = env_ps1_content.replace("__VP_BIN_WIN__", &bin_path_win); + let env_ps1_file = vite_plus_home.join("env.ps1"); + tokio::fs::write(&env_ps1_file, env_ps1_content).await?; + + // 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` + let vp_use_cmd_content = "@echo off\r\nset VITE_PLUS_ENV_USE_EVAL_ENABLE=1\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VITE_PLUS_ENV_USE_EVAL_ENABLE=\r\n"; + // Only write if bin directory exists (it may not during --env-only) + if tokio::fs::try_exists(&bin_path).await.unwrap_or(false) { + let vp_use_cmd_file = bin_path.join("vp-use.cmd"); + tokio::fs::write(&vp_use_cmd_file, vp_use_cmd_content).await?; + } + + Ok(()) +} + +/// Print instructions for adding bin directory to PATH. +fn print_path_instructions(bin_dir: &vite_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()); + 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}") + } else { + home_path + } + } else { + home_path + }; + + println!("Add to your shell profile (~/.zshrc, ~/.bashrc, etc.):"); + println!(); + println!(" . \"{home_path}/env\""); + println!(); + println!("For fish shell, add to ~/.config/fish/config.fish:"); + println!(); + println!(" source \"{home_path}/env.fish\""); + println!(); + println!("For PowerShell, add to your $PROFILE:"); + println!(); + println!(" . \"{home_path}/env.ps1\""); + println!(); + println!("For IDE support (VS Code, Cursor), ensure bin directory is in system PATH:"); + + #[cfg(target_os = "macos")] + { + println!(" - macOS: Add to ~/.profile or use launchd"); + } + + #[cfg(target_os = "linux")] + { + println!(" - Linux: Add to ~/.profile for display manager integration"); + } + + #[cfg(target_os = "windows")] + { + println!(" - Windows: System Properties -> Environment Variables -> Path"); + } + + println!(); + println!("Restart your terminal and IDE, then run 'vp env doctor' to verify."); +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + use super::*; + + #[tokio::test] + #[serial] + 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(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("HOME", temp_dir.path()); + } + + create_env_files(&home).await.unwrap(); + + let env_path = home.join("env"); + let env_fish_path = home.join("env.fish"); + let env_ps1_path = home.join("env.ps1"); + assert!(env_path.as_path().exists(), "env file should be created"); + assert!(env_fish_path.as_path().exists(), "env.fish file should be created"); + assert!(env_ps1_path.as_path().exists(), "env.ps1 file should be created"); + + unsafe { + std::env::remove_var("HOME"); + } + } + + #[tokio::test] + #[serial] + 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().to_path_buf()).unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("HOME", temp_dir.path()); + } + + create_env_files(&home).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(); + + // Placeholder should be fully replaced + assert!( + !env_content.contains("__VP_BIN__"), + "env file should not contain __VP_BIN__ placeholder" + ); + assert!( + !fish_content.contains("__VP_BIN__"), + "env.fish file should not contain __VP_BIN__ placeholder" + ); + + // Should use $HOME-relative path since install dir is under HOME + assert!( + env_content.contains("$HOME/bin"), + "env file should reference $HOME/bin, got: {env_content}" + ); + assert!( + fish_content.contains("$HOME/bin"), + "env.fish file should reference $HOME/bin, got: {fish_content}" + ); + + unsafe { + std::env::remove_var("HOME"); + } + } + + #[tokio::test] + #[serial] + async fn test_create_env_files_uses_absolute_path_when_not_under_home() { + let temp_dir = TempDir::new().unwrap(); + let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Set HOME to a different path so install dir is NOT under HOME + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("HOME", "/nonexistent-home-dir"); + } + + create_env_files(&home).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(); + + // 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(); + assert!( + env_content.contains(&expected_str), + "env file should use absolute path {expected_str}, got: {env_content}" + ); + assert!( + fish_content.contains(&expected_str), + "env.fish file should use absolute path {expected_str}, got: {fish_content}" + ); + + // Should NOT use $HOME-relative path + assert!(!env_content.contains("$HOME/bin"), "env file should not reference $HOME/bin"); + + unsafe { + std::env::remove_var("HOME"); + } + } + + #[tokio::test] + #[serial] + 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(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("HOME", temp_dir.path()); + } + + create_env_files(&home).await.unwrap(); + + let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + + // Verify PATH guard structure: case statement checks for duplicate + assert!( + env_content.contains("case \":${PATH}:\" in"), + "env file should contain PATH guard case statement" + ); + assert!( + env_content.contains("*\":${__vp_bin}:\"*)"), + "env file should check for existing bin in PATH" + ); + // Verify it re-prepends to front when already present + assert!( + env_content.contains("export PATH=\"${__vp_bin}"), + "env file should re-prepend bin to front of PATH" + ); + // Verify simple prepend for new entry + assert!( + env_content.contains("export PATH=\"$__vp_bin:$PATH\""), + "env file should prepend bin to PATH for new entry" + ); + + unsafe { + std::env::remove_var("HOME"); + } + } + + #[tokio::test] + #[serial] + 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(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("HOME", temp_dir.path()); + } + + create_env_files(&home).await.unwrap(); + + let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + + // Verify fish PATH guard: remove existing entry before prepending + assert!( + fish_content.contains("contains -i --"), + "env.fish should check for existing bin in PATH" + ); + assert!( + fish_content.contains("set -e PATH[$__vp_idx]"), + "env.fish should remove existing entry" + ); + assert!(fish_content.contains("set -gx PATH"), "env.fish should set PATH globally"); + + unsafe { + std::env::remove_var("HOME"); + } + } + + #[tokio::test] + #[serial] + 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(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("HOME", temp_dir.path()); + } + + // Create env files twice + create_env_files(&home).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(); + 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(); + + assert_eq!(first_env, second_env, "env file should be identical after second write"); + assert_eq!(first_fish, second_fish, "env.fish file should be identical after second write"); + assert_eq!(first_ps1, second_ps1, "env.ps1 file should be identical after second write"); + + unsafe { + std::env::remove_var("HOME"); + } + } + + #[tokio::test] + #[serial] + 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(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("HOME", temp_dir.path()); + } + + create_env_files(&home).await.unwrap(); + + let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); + + // Verify vp() shell function wrapper is present + assert!(env_content.contains("vp() {"), "env file should contain vp() shell function"); + assert!( + env_content.contains("\"$1\" = \"env\""), + "env file should check for 'env' subcommand" + ); + assert!( + env_content.contains("\"$2\" = \"use\""), + "env file should check for 'use' subcommand" + ); + assert!(env_content.contains("eval \"$__vp_out\""), "env file should eval the output"); + assert!( + env_content.contains("command vp \"$@\""), + "env file should use 'command vp' for passthrough" + ); + + unsafe { + std::env::remove_var("HOME"); + } + } + + #[tokio::test] + #[serial] + 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(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("HOME", temp_dir.path()); + } + + create_env_files(&home).await.unwrap(); + + let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + + // Verify fish vp function wrapper is present + assert!(fish_content.contains("function vp"), "env.fish file should contain vp function"); + assert!( + fish_content.contains("\"$argv[1]\" = \"env\""), + "env.fish should check for 'env' subcommand" + ); + assert!( + fish_content.contains("\"$argv[2]\" = \"use\""), + "env.fish should check for 'use' subcommand" + ); + assert!( + fish_content.contains("command vp $argv"), + "env.fish should use 'command vp' for passthrough" + ); + + unsafe { + std::env::remove_var("HOME"); + } + } + + #[tokio::test] + #[serial] + 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(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("HOME", temp_dir.path()); + } + + create_env_files(&home).await.unwrap(); + + let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); + + // Verify PowerShell function is present + assert!(ps1_content.contains("function vp {"), "env.ps1 should contain vp function"); + assert!(ps1_content.contains("Invoke-Expression"), "env.ps1 should use Invoke-Expression"); + // Should not contain placeholders + assert!( + !ps1_content.contains("__VP_BIN_WIN__"), + "env.ps1 should not contain __VP_BIN_WIN__ placeholder" + ); + + unsafe { + std::env::remove_var("HOME"); + } + } + + #[tokio::test] + #[serial] + 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 + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("VITE_PLUS_HOME", &fresh_home); + std::env::set_var("HOME", temp_dir.path()); + } + + let status = execute(false, true).await.unwrap(); + assert!(status.success(), "execute --env-only should succeed"); + + // Directory should now exist + assert!(fresh_home.exists(), "VITE_PLUS_HOME 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"); + + unsafe { + std::env::remove_var("VITE_PLUS_HOME"); + std::env::remove_var("HOME"); + } + } +} diff --git a/crates/vite_global_cli/src/commands/env/unpin.rs b/crates/vite_global_cli/src/commands/env/unpin.rs new file mode 100644 index 0000000000..cb492467f6 --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/unpin.rs @@ -0,0 +1,14 @@ +//! Unpin command - alias for `pin --unpin`. +//! +//! Handles `vp env unpin` to remove the `.node-version` file from the current directory. + +use std::process::ExitStatus; + +use vite_path::AbsolutePathBuf; + +use crate::error::Error; + +/// Execute the unpin command. +pub async fn execute(cwd: AbsolutePathBuf) -> Result { + super::pin::do_unpin(&cwd).await +} diff --git a/crates/vite_global_cli/src/commands/env/use.rs b/crates/vite_global_cli/src/commands/env/use.rs new file mode 100644 index 0000000000..daa747c3fd --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/use.rs @@ -0,0 +1,265 @@ +//! Implementation of `vp env use` command. +//! +//! Outputs shell-appropriate commands to stdout that set (or unset) +//! the `VITE_PLUS_NODE_VERSION` environment variable. The shell function +//! wrapper in `~/.vite-plus/env` evals this output to modify the current +//! shell session. +//! +//! All user-facing status messages go to stderr so they don't interfere +//! with the eval'd output. + +use std::process::ExitStatus; + +use vite_path::AbsolutePathBuf; + +use super::config::{self, VERSION_ENV_VAR}; +use crate::error::Error; + +/// Detected shell type for output formatting. +enum Shell { + /// POSIX shell (bash, zsh, sh) + Posix, + /// Fish shell + Fish, + /// PowerShell + PowerShell, + /// Windows cmd.exe + Cmd, +} + +/// Detect the current shell from environment variables. +fn detect_shell() -> Shell { + if std::env::var("FISH_VERSION").is_ok() { + Shell::Fish + } else if cfg!(windows) && std::env::var("PSModulePath").is_ok() { + Shell::PowerShell + } else if cfg!(windows) { + Shell::Cmd + } else { + Shell::Posix + } +} + +/// Format a shell export command for the detected shell. +fn format_export(shell: &Shell, value: &str) -> String { + match shell { + Shell::Posix => format!("export {VERSION_ENV_VAR}={value}"), + Shell::Fish => format!("set -gx {VERSION_ENV_VAR} {value}"), + Shell::PowerShell => format!("$env:{VERSION_ENV_VAR} = \"{value}\""), + Shell::Cmd => format!("set {VERSION_ENV_VAR}={value}"), + } +} + +/// Format a shell unset command for the detected shell. +fn format_unset(shell: &Shell) -> String { + match shell { + Shell::Posix => format!("unset {VERSION_ENV_VAR}"), + Shell::Fish => format!("set -e {VERSION_ENV_VAR}"), + Shell::PowerShell => { + format!("Remove-Item Env:{VERSION_ENV_VAR} -ErrorAction SilentlyContinue") + } + Shell::Cmd => format!("set {VERSION_ENV_VAR}="), + } +} + +/// Whether the shell eval wrapper is active. +/// When true, the wrapper will eval our stdout to set env vars — no session file needed. +/// When false (CI, direct invocation), we write a session file so shims can read it. +fn has_eval_wrapper() -> bool { + std::env::var("VITE_PLUS_ENV_USE_EVAL_ENABLE").is_ok() +} + +/// Execute the `vp env use` command. +pub async fn execute( + cwd: AbsolutePathBuf, + version: Option, + unset: bool, + no_install: bool, + silent_if_unchanged: bool, +) -> Result { + let shell = detect_shell(); + + // Handle --unset: remove session override + if unset { + if has_eval_wrapper() { + println!("{}", format_unset(&shell)); + } else { + config::delete_session_version().await?; + } + eprintln!("Reverted to file-based Node.js version resolution"); + return Ok(ExitStatus::default()); + } + + let provider = vite_js_runtime::NodeProvider::new(); + + // Resolve version: explicit argument or from project files + let (resolved_version, source_desc) = if let Some(ref ver) = version { + let resolved = config::resolve_version_alias(ver, &provider).await?; + (resolved, format!("{ver}")) + } else { + let resolution = config::resolve_version(&cwd).await?; + let source = resolution.source.clone(); + (resolution.version, source) + }; + + // Check if already active and suppress output if requested + if silent_if_unchanged { + let current_env = std::env::var(VERSION_ENV_VAR).ok().map(|v| v.trim().to_string()); + let current = if !has_eval_wrapper() { + current_env.or(config::read_session_version().await) + } else { + current_env + }; + if current.as_deref() == Some(&resolved_version) { + // Already active — idempotent, skip stderr status message + if has_eval_wrapper() { + println!("{}", format_export(&shell, &resolved_version)); + } else { + config::write_session_version(&resolved_version).await?; + } + return Ok(ExitStatus::default()); + } + } + + // Ensure version is installed (unless --no-install) + if !no_install { + let home_dir = vite_shared::get_vite_plus_home() + .map_err(|e| Error::ConfigError(format!("{e}").into()))? + .join("js_runtime") + .join("node") + .join(&resolved_version); + + #[cfg(windows)] + let binary_path = home_dir.join("node.exe"); + #[cfg(not(windows))] + let binary_path = home_dir.join("bin").join("node"); + + if !binary_path.as_path().exists() { + eprintln!("Installing Node.js v{}...", resolved_version); + vite_js_runtime::download_runtime( + vite_js_runtime::JsRuntimeType::Node, + &resolved_version, + ) + .await?; + } + } + + if has_eval_wrapper() { + // Output the shell command to stdout (consumed by shell wrapper's eval) + println!("{}", format_export(&shell, &resolved_version)); + } else { + // No eval wrapper (CI or direct invocation) — write session file so shims can read it + config::write_session_version(&resolved_version).await?; + } + + // Status message to stderr (visible to user) + eprintln!("Using Node.js v{} (resolved from {})", resolved_version, source_desc); + + Ok(ExitStatus::default()) +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + + use super::*; + + #[test] + #[serial] + fn test_detect_shell_posix_even_with_psmodulepath() { + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::remove_var("FISH_VERSION"); + std::env::set_var("PSModulePath", "/some/path"); + } + let shell = detect_shell(); + #[cfg(not(windows))] + assert!(matches!(shell, Shell::Posix)); + #[cfg(windows)] + assert!(matches!(shell, Shell::PowerShell)); + // Cleanup + unsafe { + std::env::remove_var("PSModulePath"); + } + } + + #[test] + #[serial] + fn test_detect_shell_fish() { + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("FISH_VERSION", "3.7.0"); + std::env::remove_var("PSModulePath"); + } + let shell = detect_shell(); + assert!(matches!(shell, Shell::Fish)); + // Cleanup + unsafe { + std::env::remove_var("FISH_VERSION"); + } + } + + #[test] + #[serial] + fn test_detect_shell_posix_default() { + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::remove_var("FISH_VERSION"); + std::env::remove_var("PSModulePath"); + std::env::remove_var("COMSPEC"); + } + let shell = detect_shell(); + #[cfg(not(windows))] + assert!(matches!(shell, Shell::Posix)); + #[cfg(windows)] + assert!(matches!(shell, Shell::Cmd)); + } + + #[test] + fn test_format_export_posix() { + let result = format_export(&Shell::Posix, "20.18.0"); + assert_eq!(result, "export VITE_PLUS_NODE_VERSION=20.18.0"); + } + + #[test] + fn test_format_export_fish() { + let result = format_export(&Shell::Fish, "20.18.0"); + assert_eq!(result, "set -gx VITE_PLUS_NODE_VERSION 20.18.0"); + } + + #[test] + fn test_format_export_powershell() { + let result = format_export(&Shell::PowerShell, "20.18.0"); + assert_eq!(result, "$env:VITE_PLUS_NODE_VERSION = \"20.18.0\""); + } + + #[test] + fn test_format_export_cmd() { + let result = format_export(&Shell::Cmd, "20.18.0"); + assert_eq!(result, "set VITE_PLUS_NODE_VERSION=20.18.0"); + } + + #[test] + fn test_format_unset_posix() { + let result = format_unset(&Shell::Posix); + assert_eq!(result, "unset VITE_PLUS_NODE_VERSION"); + } + + #[test] + fn test_format_unset_fish() { + let result = format_unset(&Shell::Fish); + assert_eq!(result, "set -e VITE_PLUS_NODE_VERSION"); + } + + #[test] + fn test_format_unset_powershell() { + let result = format_unset(&Shell::PowerShell); + assert_eq!(result, "Remove-Item Env:VITE_PLUS_NODE_VERSION -ErrorAction SilentlyContinue"); + } + + #[test] + fn test_format_unset_cmd() { + let result = format_unset(&Shell::Cmd); + assert_eq!(result, "set VITE_PLUS_NODE_VERSION="); + } +} diff --git a/crates/vite_global_cli/src/commands/env/which.rs b/crates/vite_global_cli/src/commands/env/which.rs new file mode 100644 index 0000000000..4735853487 --- /dev/null +++ b/crates/vite_global_cli/src/commands/env/which.rs @@ -0,0 +1,195 @@ +//! Which command implementation. +//! +//! Shows the path to the tool binary that would be executed. +//! +//! For core tools (node, npm, npx), shows the resolved Node.js binary path +//! along with version and resolution source. +//! For global packages, shows the binary path plus package metadata. + +use std::process::ExitStatus; + +use chrono::Local; +use owo_colors::OwoColorize; +use vite_path::AbsolutePathBuf; + +use super::{ + config::{VERSION_ENV_VAR, get_node_modules_dir, get_packages_dir, resolve_version}, + package_metadata::PackageMetadata, +}; +use crate::error::Error; + +/// Core tools (node, npm, npx) +const CORE_TOOLS: &[&str] = &["node", "npm", "npx"]; + +/// Column width for left-side labels in aligned metadata output +const LABEL_WIDTH: usize = 10; + +/// Execute the which command. +pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result { + // Check if this is a core tool + if CORE_TOOLS.contains(&tool) { + return execute_core_tool(cwd, tool).await; + } + + // Check if this is a global package binary + if let Some(metadata) = PackageMetadata::find_by_binary(tool).await? { + return execute_package_binary(tool, &metadata).await; + } + + // Unknown tool + eprintln!("{} tool '{}' not found", "error:".red().bold(), tool.bold()); + eprintln!("Not a core tool (node, npm, npx) or installed global package."); + eprintln!("Run 'vp list -g' to see installed packages."); + Ok(exit_status(1)) +} + +/// Execute which for a core tool (node, npm, npx). +async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result { + // Resolve version for current directory + let resolution = resolve_version(&cwd).await?; + + // Get the tool path + let home_dir = vite_shared::get_vite_plus_home()? + .join("js_runtime") + .join("node") + .join(&resolution.version); + + #[cfg(windows)] + let tool_path = if tool == "node" { + home_dir.join("node.exe") + } else { + home_dir.join(format!("{tool}.cmd")) + }; + + #[cfg(not(windows))] + let tool_path = home_dir.join("bin").join(tool); + + // Check if the tool exists + if !tokio::fs::try_exists(&tool_path).await.unwrap_or(false) { + eprintln!("{} {} not found", "error:".red().bold(), tool.bold()); + eprintln!("Node.js {} is not installed.", resolution.version); + eprintln!("Run 'vp env install {}' to install it.", resolution.version); + return Ok(exit_status(1)); + } + + // Print binary path (first line, uncolored, pipe-friendly) + println!("{}", tool_path.as_path().display()); + + // Print metadata + let source_display = format_source(&resolution.source); + println!(" {: String { + match source { + s if s == VERSION_ENV_VAR => format!("{s} (session)"), + "lts" => "lts (fallback)".to_string(), + other => other.to_string(), + } +} + +/// Execute which for a global package binary. +async fn execute_package_binary( + tool: &str, + metadata: &PackageMetadata, +) -> Result { + // Locate the binary path + let binary_path = locate_package_binary(&metadata.name, tool)?; + + // Check if binary exists + if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + eprintln!("{} binary '{}' not found", "error:".red().bold(), tool.bold()); + eprintln!("Package {} may need to be reinstalled.", metadata.name); + eprintln!("Run 'vp install -g {}' to reinstall.", metadata.name); + return Ok(exit_status(1)); + } + + // Format installation timestamp (date only) + let installed_local = metadata.installed_at.with_timezone(&Local); + let installed_str = installed_local.format("%Y-%m-%d").to_string(); + + // Print binary path (first line, uncolored, pipe-friendly) + println!("{}", binary_path.as_path().display()); + + // Print metadata + println!( + " {: Result { + let packages_dir = get_packages_dir()?; + let package_dir = packages_dir.join(package_name); + + // The binary is referenced in package.json's bin field + // npm uses different layouts: Unix=lib/node_modules, Windows=node_modules + let node_modules_dir = get_node_modules_dir(&package_dir, package_name); + let package_json_path = node_modules_dir.join("package.json"); + + if !package_json_path.as_path().exists() { + return Err(Error::ConfigError(format!("Package {} not found", package_name).into())); + } + + // Read package.json to find the binary path + let content = std::fs::read_to_string(package_json_path.as_path())?; + let package_json: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| Error::ConfigError(format!("Failed to parse package.json: {e}").into()))?; + + let binary_path = match package_json.get("bin") { + Some(serde_json::Value::String(path)) => { + // Single binary - check if it matches the name + let pkg_name = package_json["name"].as_str().unwrap_or(""); + let expected_name = pkg_name.split('/').last().unwrap_or(pkg_name); + if expected_name == binary_name { + node_modules_dir.join(path) + } else { + return Err(Error::ConfigError( + format!("Binary {} not found in package", binary_name).into(), + )); + } + } + Some(serde_json::Value::Object(map)) => { + // Multiple binaries - find the one we need + if let Some(serde_json::Value::String(path)) = map.get(binary_name) { + node_modules_dir.join(path) + } else { + return Err(Error::ConfigError( + format!("Binary {} not found in package", binary_name).into(), + )); + } + } + _ => { + return Err(Error::ConfigError( + format!("No bin field in package.json for {}", package_name).into(), + )); + } + }; + + Ok(binary_path) +} + +/// Create an exit status with the given code. +fn exit_status(code: i32) -> ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(code << 8) + } + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(code as u32) + } +} diff --git a/crates/vite_global_cli/src/commands/install.rs b/crates/vite_global_cli/src/commands/install.rs index fb6e719b9c..02d80fd74f 100644 --- a/crates/vite_global_cli/src/commands/install.rs +++ b/crates/vite_global_cli/src/commands/install.rs @@ -64,6 +64,7 @@ mod tests { } #[tokio::test] + #[serial_test::serial] async fn test_install_command_with_package_json_with_package_manager() { let temp_dir = TempDir::new().unwrap(); let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 13f62d0c5e..f7a23a9ae2 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -24,6 +24,7 @@ //! - `delegate`: Local CLI delegation use vite_path::AbsolutePath; +use vite_shared::{PrependOptions, prepend_to_path_env}; use crate::{error::Error, js_executor::JsExecutor}; @@ -43,26 +44,13 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu executor.ensure_cli_runtime().await? }; - let node_bin_path = runtime.get_bin_prefix().as_path().to_path_buf(); - - // Check if node bin path already exists in PATH to avoid duplicates - let current_path = std::env::var_os("PATH").unwrap_or_default(); - let paths: Vec<_> = std::env::split_paths(¤t_path).collect(); - - if paths.iter().any(|p| p == &node_bin_path) { - return Ok(()); + let node_bin_prefix = runtime.get_bin_prefix(); + // Use dedupe_anywhere=true to check if node bin already exists anywhere in PATH + let options = PrependOptions { dedupe_anywhere: true }; + if prepend_to_path_env(&node_bin_prefix, options) { + tracing::debug!("Set PATH to include {:?}", node_bin_prefix); } - // Prepend node bin to PATH - let mut new_paths = vec![node_bin_path]; - new_paths.extend(paths); - let new_path = std::env::join_paths(new_paths).expect("Failed to join paths"); - tracing::debug!("Set PATH to {:?}", new_path); - // SAFETY: We're modifying PATH at the start of command execution before any - // parallel operations. This is safe because package manager commands run - // sequentially and child processes inherit the modified environment. - unsafe { std::env::set_var("PATH", new_path) }; - Ok(()) } @@ -84,6 +72,9 @@ pub mod migrate; pub mod new; pub mod version; +// Category D: Environment Management +pub mod env; + // Category C: Local CLI Delegation pub mod delegate; diff --git a/crates/vite_global_cli/src/commands/pm.rs b/crates/vite_global_cli/src/commands/pm.rs index e93a923b6b..7f7e69a9fb 100644 --- a/crates/vite_global_cli/src/commands/pm.rs +++ b/crates/vite_global_cli/src/commands/pm.rs @@ -44,9 +44,31 @@ pub async fn execute_pm_subcommand( cwd: AbsolutePathBuf, command: PmCommands, ) -> Result { + // Intercept `pm list -g` to use vite-plus managed global packages listing + if let PmCommands::List { global: true, json, ref pattern, .. } = command { + return crate::commands::env::packages::execute(json, pattern.as_deref()).await; + } + prepend_js_runtime_to_path_env(&cwd).await?; - let package_manager = PackageManager::builder(&cwd).build_with_default().await?; + let package_manager = match PackageManager::builder(&cwd).build_with_default().await { + Ok(pm) => pm, + Err(e) => { + // For `list` command, silently succeed when no workspace is found + // (matches `pnpm list` behavior in dirs without package.json) + if matches!(&command, PmCommands::List { .. }) + && matches!( + &e, + vite_error::Error::WorkspaceError(vite_workspace::Error::PackageJsonNotFound( + _ + )) + ) + { + return Ok(ExitStatus::default()); + } + return Err(e.into()); + } + }; match command { PmCommands::Prune { prod, no_optional, pass_through_args } => { diff --git a/crates/vite_global_cli/src/error.rs b/crates/vite_global_cli/src/error.rs index d12bfc5f5d..f9a1230c48 100644 --- a/crates/vite_global_cli/src/error.rs +++ b/crates/vite_global_cli/src/error.rs @@ -34,6 +34,17 @@ pub enum Error { #[error("Install error: {0}")] Install(#[from] vite_error::Error), + #[error("Configuration error: {0}")] + ConfigError(Str), + + #[error("JSON error: {0}")] + JsonError(#[from] serde_json::Error), + #[error("{0}")] Other(Str), + + #[error( + "Executable '{bin_name}' is already installed by {existing_package}\n\nPlease remove {existing_package} before installing {new_package}, or use --force to auto-replace" + )] + BinaryConflict { bin_name: String, existing_package: String, new_package: String }, } diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index b53786fd38..951ce311d1 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -8,6 +8,7 @@ use std::process::ExitStatus; use tokio::process::Command; use vite_js_runtime::{JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project}; use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_shared::{PrependOptions, PrependResult, format_path_with_prepend}; use crate::error::Error; @@ -58,7 +59,6 @@ impl JsExecutor { // e.g., packages/global/bin/vp -> packages/global/dist/ let exe_path = std::env::current_exe().map_err(|_| Error::JsScriptsDirNotFound)?; // Resolve symlinks to get the real binary path (Unix only) - // This is important when vp is symlinked from ~/.local/bin/vp // Skip on Windows to avoid path resolution issues #[cfg(unix)] let exe_path = std::fs::canonicalize(&exe_path).map_err(|_| Error::JsScriptsDirNotFound)?; @@ -94,17 +94,12 @@ impl JsExecutor { } // Prepend runtime bin to PATH so child processes can find the JS runtime - let runtime_bin_path = runtime_bin_prefix.as_path().to_path_buf(); - let current_path = std::env::var_os("PATH").unwrap_or_default(); - let paths: Vec<_> = std::env::split_paths(¤t_path).collect(); - - if !paths.iter().any(|p| p == &runtime_bin_path) { - let mut new_paths = vec![runtime_bin_path]; - new_paths.extend(paths); - if let Ok(new_path) = std::env::join_paths(new_paths) { - tracing::debug!("Set PATH to {:?}", new_path); - cmd.env("PATH", new_path); - } + let options = PrependOptions { dedupe_anywhere: true }; + if let PrependResult::Prepended(new_path) = + format_path_with_prepend(runtime_bin_prefix.as_path(), options) + { + tracing::debug!("Set PATH to {:?}", new_path); + cmd.env("PATH", new_path); } cmd @@ -243,6 +238,8 @@ impl JsExecutor { #[cfg(test)] mod tests { + use serial_test::serial; + use super::*; #[test] @@ -290,6 +287,7 @@ mod tests { } #[tokio::test] + #[serial] async fn test_execute_cli_script_prints_node_version() { use std::io::Write; diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index ec27a38597..85b706a3e9 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -11,28 +11,36 @@ mod cli; mod commands; mod error; mod js_executor; +mod shim; use std::process::ExitCode; -use vite_path::AbsolutePathBuf; - use crate::cli::{parse_args_from, run_command}; -/// Normalize help arguments: transform `help [command]` into `[command] --help` -fn normalize_help_args() -> Vec { - let args: Vec = std::env::args().collect(); - - // Skip the binary name (args[0]) +/// Normalize CLI arguments: +/// - `vp list ...` / `vp ls ...` → `vp pm list ...` +/// - `vp help [command]` → `vp [command] --help` +fn normalize_args(args: Vec) -> Vec { match args.get(1).map(String::as_str) { + // `vp list ...` → `vp pm list ...` + // `vp ls ...` → `vp pm list ...` + Some("list" | "ls") => { + let mut normalized = Vec::with_capacity(args.len() + 1); + normalized.push(args[0].clone()); + normalized.push("pm".to_string()); + normalized.push("list".to_string()); + normalized.extend(args[2..].iter().cloned()); + normalized + } // `vp help` alone -> show main help Some("help") if args.len() == 2 => vec![args[0].clone(), "--help".to_string()], // `vp help [command] [args...]` -> `vp [command] --help [args...]` Some("help") if args.len() > 2 => { let mut normalized = Vec::with_capacity(args.len()); - normalized.push(args[0].clone()); // binary name - normalized.push(args[2].clone()); // command + normalized.push(args[0].clone()); + normalized.push(args[2].clone()); normalized.push("--help".to_string()); - normalized.extend(args[3..].iter().cloned()); // remaining args + normalized.extend(args[3..].iter().cloned()); normalized } // No transformation needed @@ -40,39 +48,38 @@ fn normalize_help_args() -> Vec { } } -fn main() -> ExitCode { +#[tokio::main] +async fn main() -> ExitCode { // Initialize tracing vite_shared::init_tracing(); - // Get current working directory - let cwd = match std::env::current_dir() { - Ok(path) => { - if let Some(abs_path) = AbsolutePathBuf::new(path) { - abs_path - } else { - eprintln!("Error: Invalid current directory path"); - return ExitCode::FAILURE; - } - } + // Check for shim mode (invoked as node, npm, or npx) + let args: Vec = std::env::args().collect(); + let argv0 = args.first().map(|s| s.as_str()).unwrap_or("vp"); + tracing::debug!("argv0: {argv0}"); + + if let Some(tool) = shim::detect_shim_tool(argv0) { + // Shim mode - dispatch to the appropriate tool + let exit_code = shim::dispatch(&tool, &args[1..]).await; + return ExitCode::from(exit_code as u8); + } + + // Normal CLI mode - get current working directory + let cwd = match vite_path::current_dir() { + Ok(path) => path, Err(e) => { eprintln!("Error: Failed to get current directory: {e}"); return ExitCode::FAILURE; } }; - // Normalize help arguments: transform `help [command]` into `[command] --help` - let normalized_args = normalize_help_args(); + // Normalize arguments (list/ls aliases, help rewriting) + let normalized_args = normalize_args(args); // Parse CLI arguments (using custom help formatting) let args = parse_args_from(normalized_args); - // Run the async runtime - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("Failed to create Tokio runtime"); - - match runtime.block_on(run_command(cwd, args)) { + match run_command(cwd, args).await { Ok(exit_status) => { if exit_status.success() { ExitCode::SUCCESS diff --git a/crates/vite_global_cli/src/shim/cache.rs b/crates/vite_global_cli/src/shim/cache.rs new file mode 100644 index 0000000000..f9ad302c1f --- /dev/null +++ b/crates/vite_global_cli/src/shim/cache.rs @@ -0,0 +1,338 @@ +//! Resolution cache for shim operations. +//! +//! Caches version resolution results to avoid re-resolving on every invocation. +//! Uses mtime-based invalidation to detect changes in version source files. + +use std::{ + collections::HashMap, + time::{SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; +use vite_path::{AbsolutePath, AbsolutePathBuf}; + +/// Cache format version for upgrade compatibility +/// v2: Added `is_range` field to track range vs exact version for cache expiry +const CACHE_VERSION: u32 = 2; + +/// Default maximum cache entries (LRU eviction) +const DEFAULT_MAX_ENTRIES: usize = 4096; + +/// A single cache entry for a resolved version. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ResolveCacheEntry { + /// The resolved version string (e.g., "20.18.0") + pub version: String, + /// The source of the version (e.g., ".node-version", "engines.node") + pub source: String, + /// Project root directory (if applicable) + pub project_root: Option, + /// Unix timestamp when this entry was resolved + pub resolved_at: u64, + /// Mtime of the version source file (for invalidation) + pub version_file_mtime: u64, + /// Path to the version source file + pub source_path: Option, + /// Whether the original version spec was a range (e.g., "20", "^20.0.0", "lts/*") + /// Range versions use time-based expiry (1 hour) instead of mtime-only validation + #[serde(default)] + pub is_range: bool, +} + +/// Resolution cache stored in VITE_PLUS_HOME/cache/resolve_cache.json. +#[derive(Serialize, Deserialize, Debug)] +pub struct ResolveCache { + /// Cache format version for upgrade compatibility + version: u32, + /// Cache entries keyed by current working directory + entries: HashMap, +} + +impl Default for ResolveCache { + fn default() -> Self { + Self { version: CACHE_VERSION, entries: HashMap::new() } + } +} + +impl ResolveCache { + /// Load cache from disk. + pub fn load(cache_path: &AbsolutePath) -> Self { + match std::fs::read_to_string(cache_path) { + Ok(content) => { + match serde_json::from_str::(&content) { + Ok(cache) if cache.version == CACHE_VERSION => cache, + Ok(_) => { + // Version mismatch, reset cache + tracing::debug!("Cache version mismatch, resetting"); + Self::default() + } + Err(e) => { + tracing::debug!("Failed to parse cache: {e}"); + Self::default() + } + } + } + Err(_) => Self::default(), + } + } + + /// Save cache to disk. + pub fn save(&self, cache_path: &AbsolutePath) { + // Ensure parent directory exists + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + + if let Ok(content) = serde_json::to_string(self) { + std::fs::write(cache_path, content).ok(); + } + } + + /// Get a cache entry if valid. + pub fn get(&self, cwd: &AbsolutePath) -> Option<&ResolveCacheEntry> { + let key = cwd.as_path().to_string_lossy().to_string(); + let entry = self.entries.get(&key)?; + + // Validate mtime of source file + if !self.is_entry_valid(entry) { + return None; + } + + Some(entry) + } + + /// Insert a cache entry. + pub fn insert(&mut self, cwd: &AbsolutePath, entry: ResolveCacheEntry) { + let key = cwd.as_path().to_string_lossy().to_string(); + + // LRU eviction if needed + if self.entries.len() >= DEFAULT_MAX_ENTRIES { + self.evict_oldest(); + } + + self.entries.insert(key, entry); + } + + /// Check if an entry is still valid based on source file mtime and range status. + /// + /// For exact versions: Uses mtime-based validation only (cache valid until file changes) + /// For range versions: Uses both mtime AND time-based expiry (1 hour TTL) + /// + /// This ensures range versions like "20" or "^20.0.0" are periodically re-resolved + /// to pick up new releases, while exact versions like "20.18.0" only re-resolve + /// when the source file is modified. + fn is_entry_valid(&self, entry: &ResolveCacheEntry) -> bool { + // For range versions (including LTS aliases), always apply time-based expiry + // This ensures we periodically re-resolve to pick up new releases + if entry.is_range { + let now = + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + if now.saturating_sub(entry.resolved_at) >= 3600 { + // Range cache expired (> 1 hour) + return false; + } + // Range cache still within TTL, but also check mtime if source_path exists + if let Some(source_path) = &entry.source_path { + let path = std::path::Path::new(source_path); + if let Ok(metadata) = std::fs::metadata(path) { + if let Ok(mtime) = metadata.modified() { + let mtime_secs = + mtime.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + return mtime_secs == entry.version_file_mtime; + } + } + return false; // Source file missing or can't read mtime + } + return true; // No source file, within TTL + } + + // For exact versions, check source file + let Some(source_path) = &entry.source_path else { + // No source file to validate (e.g., "lts" default) + // Consider valid if resolved recently (within 1 hour) + let now = + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + return now.saturating_sub(entry.resolved_at) < 3600; + }; + + let path = std::path::Path::new(source_path); + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + + let Ok(mtime) = metadata.modified() else { + return false; + }; + + let mtime_secs = mtime.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + + mtime_secs == entry.version_file_mtime + } + + /// Evict the oldest entry (by resolved_at timestamp). + fn evict_oldest(&mut self) { + if let Some((oldest_key, _)) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.resolved_at) + .map(|(k, v)| (k.clone(), v.clone())) + { + self.entries.remove(&oldest_key); + } + } +} + +/// Get the cache file path. +pub fn get_cache_path() -> Option { + let home = crate::commands::env::config::get_vite_plus_home().ok()?; + Some(home.join("cache").join("resolve_cache.json")) +} + +/// Get the mtime of a file as Unix timestamp. +pub fn get_file_mtime(path: &AbsolutePath) -> Option { + let metadata = std::fs::metadata(path).ok()?; + let mtime = metadata.modified().ok()?; + mtime.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).ok() +} + +/// Get the current Unix timestamp. +pub fn now_timestamp() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_range_version_cache_should_expire_after_ttl() { + // BUG: Currently, range versions with source_path use mtime-only validation + // and never expire. They should use time-based expiry like aliases. + + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let cache_file = temp_path.join("cache.json"); + + // Create a .node-version file + let version_file = temp_path.join(".node-version"); + std::fs::write(&version_file, "20\n").unwrap(); + let mtime = + get_file_mtime(&version_file).expect("Should be able to get mtime of created file"); + + let mut cache = ResolveCache::default(); + + // Create an entry for a range version (e.g., "20" resolved to "20.20.0") + // with source_path set (from .node-version file) and resolved 2 hours ago + let entry = ResolveCacheEntry { + version: "20.20.0".to_string(), + source: ".node-version".to_string(), + project_root: None, + resolved_at: now_timestamp() - 7200, // 2 hours ago (> 1 hour TTL) + version_file_mtime: mtime, + source_path: Some(version_file.as_path().display().to_string()), + // BUG FIX: need to add is_range field + is_range: true, + }; + + // Save entry to cache + cache.insert(&temp_path, entry.clone()); + cache.save(&cache_file); + + // Reload cache + let loaded_cache = ResolveCache::load(&cache_file); + + // BUG: This entry is still considered valid because mtime hasn't changed + // but it SHOULD be invalid because it's a range and TTL has expired + // After fix: is_entry_valid should return false for expired range entries + let cached_entry = loaded_cache.get(&temp_path); + + // The cache entry should be INVALID (None) because: + // 1. is_range is true + // 2. resolved_at is > 1 hour ago + // Even though the mtime hasn't changed + assert!( + cached_entry.is_none(), + "Range version cache should expire after 1 hour TTL, \ + but mtime-only validation is returning the stale entry" + ); + } + + #[test] + fn test_exact_version_cache_uses_mtime_validation() { + // Exact versions should use mtime-based validation, not time-based expiry + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let cache_file = temp_path.join("cache.json"); + + // Create a .node-version file + let version_file = temp_path.join(".node-version"); + std::fs::write(&version_file, "20.18.0\n").unwrap(); + let mtime = get_file_mtime(&version_file).unwrap(); + + let mut cache = ResolveCache::default(); + + // Create an entry for an exact version resolved 2 hours ago + let entry = ResolveCacheEntry { + version: "20.18.0".to_string(), + source: ".node-version".to_string(), + project_root: None, + resolved_at: now_timestamp() - 7200, // 2 hours ago + version_file_mtime: mtime, + source_path: Some(version_file.as_path().display().to_string()), + is_range: false, // Exact version, not a range + }; + + cache.insert(&temp_path, entry); + cache.save(&cache_file); + + // Reload cache + let loaded_cache = ResolveCache::load(&cache_file); + let cached_entry = loaded_cache.get(&temp_path); + + // Exact version cache should still be valid as long as mtime hasn't changed + assert!( + cached_entry.is_some(), + "Exact version cache should use mtime validation, not time-based expiry" + ); + assert_eq!(cached_entry.unwrap().version, "20.18.0"); + } + + #[test] + fn test_range_cache_valid_within_ttl() { + // Range version cache should be valid within the 1 hour TTL + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let cache_file = temp_path.join("cache.json"); + + // Create a .node-version file + let version_file = temp_path.join(".node-version"); + std::fs::write(&version_file, "20\n").unwrap(); + let mtime = get_file_mtime(&version_file).unwrap(); + + let mut cache = ResolveCache::default(); + + // Create an entry for a range version resolved recently (30 minutes ago) + let entry = ResolveCacheEntry { + version: "20.20.0".to_string(), + source: ".node-version".to_string(), + project_root: None, + resolved_at: now_timestamp() - 1800, // 30 minutes ago (< 1 hour TTL) + version_file_mtime: mtime, + source_path: Some(version_file.as_path().display().to_string()), + is_range: true, + }; + + cache.insert(&temp_path, entry); + cache.save(&cache_file); + + // Reload cache + let loaded_cache = ResolveCache::load(&cache_file); + let cached_entry = loaded_cache.get(&temp_path); + + // Range version cache should still be valid within TTL + assert!(cached_entry.is_some(), "Range version cache should be valid within TTL"); + assert_eq!(cached_entry.unwrap().version, "20.20.0"); + } +} diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs new file mode 100644 index 0000000000..d1c7779528 --- /dev/null +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -0,0 +1,701 @@ +//! Main dispatch logic for shim operations. +//! +//! This module handles the core shim functionality: +//! 1. Version resolution (with caching) +//! 2. Node.js installation (if needed) +//! 3. Tool execution (core tools and package binaries) + +use vite_path::{AbsolutePathBuf, current_dir}; +use vite_shared::{PrependOptions, prepend_to_path_env}; + +use super::{ + cache::{self, ResolveCache, ResolveCacheEntry}, + exec, is_core_shim_tool, +}; +use crate::commands::env::{ + bin_config::BinConfig, + config::{self, ShimMode}, + package_metadata::PackageMetadata, +}; + +/// Environment variable used to prevent infinite recursion in shim dispatch. +/// +/// When set, the shim will skip version resolution and execute the tool +/// directly using the current PATH (passthrough mode). +const RECURSION_ENV_VAR: &str = "VITE_PLUS_TOOL_RECURSION"; + +/// Main shim dispatch entry point. +/// +/// Called when the binary is invoked as node, npm, npx, or a package binary. +/// Returns an exit code to be used with std::process::exit. +pub async fn dispatch(tool: &str, args: &[String]) -> i32 { + tracing::debug!("dispatch: tool: {tool}, args: {:?}", args); + // Check recursion prevention - if already in a shim context, passthrough directly + // Only applies to core tools (node/npm/npx) whose bin dir is prepended to PATH. + // Package binaries are always resolved via metadata lookup, so they can't loop. + if std::env::var(RECURSION_ENV_VAR).is_ok() && is_core_shim_tool(tool) { + tracing::debug!("recursion prevention enabled for core tool"); + return passthrough_to_system(tool, args); + } + + // Check bypass mode (explicit environment variable) + if std::env::var("VITE_PLUS_BYPASS").is_ok() { + tracing::debug!("bypass mode enabled"); + return bypass_to_system(tool, args); + } + + // Check shim mode from config + let shim_mode = load_shim_mode().await; + if shim_mode == ShimMode::SystemFirst { + tracing::debug!("system-first mode enabled"); + // In system-first mode, try to find system tool first + if let Some(system_path) = find_system_tool(tool) { + // Append current bin_dir to VITE_PLUS_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 bypass_val = match std::env::var_os("VITE_PLUS_BYPASS") { + Some(existing) => { + let mut paths: Vec<_> = std::env::split_paths(&existing).collect(); + paths.push(bin_dir.as_path().to_path_buf()); + std::env::join_paths(paths).unwrap_or(existing) + } + None => std::ffi::OsString::from(bin_dir.as_path()), + }; + // SAFETY: Setting env vars before exec (which replaces the process) is safe + unsafe { + std::env::set_var("VITE_PLUS_BYPASS", bypass_val); + } + } + return exec::exec_tool(&system_path, args); + } + // Fall through to managed if system not found + } + + // Check if this is a package binary (not node/npm/npx) + if !is_core_shim_tool(tool) { + return dispatch_package_binary(tool, args).await; + } + + // Get current working directory + let cwd = match current_dir() { + Ok(path) => path, + Err(e) => { + eprintln!("vp: Failed to get current directory: {e}"); + return 1; + } + }; + + // Resolve version (with caching) + let resolution = match resolve_with_cache(&cwd).await { + Ok(r) => r, + Err(e) => { + eprintln!("vp: Failed to resolve Node version: {e}"); + eprintln!("vp: Run 'vp env doctor' for diagnostics"); + return 1; + } + }; + + // Ensure Node.js is installed + if let Err(e) = ensure_installed(&resolution.version).await { + eprintln!("vp: Failed to install Node {}: {e}", resolution.version); + return 1; + } + + // Locate tool binary + let tool_path = match locate_tool(&resolution.version, tool) { + Ok(p) => p, + Err(e) => { + eprintln!("vp: Tool '{tool}' not found: {e}"); + return 1; + } + }; + + // Prepare environment for recursive invocations + // Prepend real node bin dir to PATH so child processes use the correct version + let node_bin_dir = tool_path.parent().expect("Tool has no parent directory"); + // Use dedupe_anywhere=false to only check if it's first in PATH (original behavior) + prepend_to_path_env(node_bin_dir, PrependOptions::default()); + + // Optional debug env vars + if std::env::var("VITE_PLUS_DEBUG_SHIM").is_ok() { + // SAFETY: Setting env vars at this point before exec is safe + unsafe { + std::env::set_var("VITE_PLUS_ACTIVE_NODE", &resolution.version); + std::env::set_var("VITE_PLUS_RESOLVE_SOURCE", &resolution.source); + } + } + + // Set recursion prevention marker before executing + // This prevents infinite loops when the executed tool invokes another shim + // SAFETY: Setting env vars at this point before exec is safe + unsafe { + std::env::set_var(RECURSION_ENV_VAR, "1"); + } + + // Execute the tool + exec::exec_tool(&tool_path, args) +} + +/// Dispatch a package binary shim. +/// +/// Finds the package that provides this binary and executes it with the +/// Node.js version that was used to install the package. +async fn dispatch_package_binary(tool: &str, args: &[String]) -> i32 { + // Find which package provides this binary + let package_metadata = match find_package_for_binary(tool).await { + Ok(Some(metadata)) => metadata, + Ok(None) => { + eprintln!("vp: Binary '{tool}' not found in any installed package"); + eprintln!("vp: Run 'vp install -g ' to install"); + return 1; + } + Err(e) => { + eprintln!("vp: Failed to find package for '{tool}': {e}"); + return 1; + } + }; + + // Get the Node.js version that was used to install this package + let node_version = &package_metadata.platform.node; + + // Ensure Node.js is installed + if let Err(e) = ensure_installed(node_version).await { + eprintln!("vp: Failed to install Node {}: {e}", node_version); + return 1; + } + + // Locate the actual binary in the package directory + let binary_path = match locate_package_binary(&package_metadata.name, tool) { + Ok(p) => p, + Err(e) => { + eprintln!("vp: Binary '{tool}' not found: {e}"); + return 1; + } + }; + + // Locate node binary for this version + let node_path = match locate_tool(node_version, "node") { + Ok(p) => p, + Err(e) => { + eprintln!("vp: Node not found: {e}"); + return 1; + } + }; + + // Prepare environment for recursive invocations + let node_bin_dir = node_path.parent().expect("Node has no parent directory"); + prepend_to_path_env(node_bin_dir, PrependOptions::default()); + + // Check if the binary is a JavaScript file that needs Node.js + // This info was determined at install time and stored in metadata + if package_metadata.is_js_binary(tool) { + // Execute: node + let mut full_args = vec![binary_path.as_path().display().to_string()]; + full_args.extend(args.iter().cloned()); + exec::exec_tool(&node_path, &full_args) + } else { + // Execute the binary directly (native executable or non-Node script) + exec::exec_tool(&binary_path, args) + } +} + +/// Find the package that provides a given binary. +/// +/// Uses BinConfig for deterministic O(1) lookup instead of scanning all packages. +async fn find_package_for_binary(binary_name: &str) -> Result, String> { + // Use BinConfig for deterministic lookup + if let Some(bin_config) = BinConfig::load(binary_name).await.map_err(|e| format!("{e}"))? { + return PackageMetadata::load(&bin_config.package).await.map_err(|e| format!("{e}")); + } + + // Binary not installed + Ok(None) +} + +/// Locate a binary within a package's installation directory. +fn locate_package_binary(package_name: &str, binary_name: &str) -> Result { + let packages_dir = config::get_packages_dir().map_err(|e| format!("{e}"))?; + let package_dir = packages_dir.join(package_name); + + // The binary is referenced in package.json's bin field + // npm uses different layouts: Unix=lib/node_modules, Windows=node_modules + let node_modules_dir = config::get_node_modules_dir(&package_dir, package_name); + let package_json_path = node_modules_dir.join("package.json"); + + if !package_json_path.as_path().exists() { + return Err(format!("Package {} not found", package_name)); + } + + // Read package.json to find the binary path + let content = std::fs::read_to_string(package_json_path.as_path()) + .map_err(|e| format!("Failed to read package.json: {e}"))?; + let package_json: serde_json::Value = + serde_json::from_str(&content).map_err(|e| format!("Failed to parse package.json: {e}"))?; + + let binary_path = match package_json.get("bin") { + Some(serde_json::Value::String(path)) => { + // Single binary - check if it matches the name + let pkg_name = package_json["name"].as_str().unwrap_or(""); + let expected_name = pkg_name.split('/').last().unwrap_or(pkg_name); + if expected_name == binary_name { + node_modules_dir.join(path) + } else { + return Err(format!("Binary {} not found in package", binary_name)); + } + } + Some(serde_json::Value::Object(map)) => { + // Multiple binaries - find the one we need + if let Some(serde_json::Value::String(path)) = map.get(binary_name) { + node_modules_dir.join(path) + } else { + return Err(format!("Binary {} not found in package", binary_name)); + } + } + _ => { + return Err(format!("No bin field in package.json for {}", package_name)); + } + }; + + if !binary_path.as_path().exists() { + return Err(format!( + "Binary {} not found at {}", + binary_name, + binary_path.as_path().display() + )); + } + + Ok(binary_path) +} + +/// Bypass shim and use system tool. +fn bypass_to_system(tool: &str, args: &[String]) -> i32 { + match find_system_tool(tool) { + Some(system_path) => exec::exec_tool(&system_path, args), + None => { + eprintln!("vp: VITE_PLUS_BYPASS is set but no system '{tool}' found in PATH"); + 1 + } + } +} + +/// Passthrough mode for recursion prevention. +/// +/// When VITE_PLUS_TOOL_RECURSION is set, we skip version resolution +/// and execute the tool directly using the current PATH. +/// This prevents infinite loops when a managed tool invokes another shim. +fn passthrough_to_system(tool: &str, args: &[String]) -> i32 { + match find_system_tool(tool) { + Some(system_path) => exec::exec_tool(&system_path, args), + None => { + eprintln!("vp: Recursion detected but no '{tool}' found in PATH (excluding shims)"); + 1 + } + } +} + +/// Resolve version with caching. +async fn resolve_with_cache(cwd: &AbsolutePathBuf) -> Result { + // Fast-path: VITE_PLUS_NODE_VERSION env var set by `vp env use` + // Skip all disk I/O for cache when session override is active + if let Ok(env_version) = std::env::var(config::VERSION_ENV_VAR) { + let env_version = env_version.trim().to_string(); + if !env_version.is_empty() { + return Ok(ResolveCacheEntry { + version: env_version, + source: config::VERSION_ENV_VAR.to_string(), + project_root: None, + resolved_at: cache::now_timestamp(), + version_file_mtime: 0, + source_path: None, + is_range: false, + }); + } + } + + // Fast-path: session version file written by `vp env use` + if let Some(session_version) = config::read_session_version().await { + return Ok(ResolveCacheEntry { + version: session_version, + source: config::SESSION_VERSION_FILE.to_string(), + project_root: None, + resolved_at: cache::now_timestamp(), + version_file_mtime: 0, + source_path: None, + is_range: false, + }); + } + + // Load cache + let cache_path = cache::get_cache_path(); + let mut cache = cache_path.as_ref().map(|p| ResolveCache::load(p)).unwrap_or_default(); + + // Check cache hit + if let Some(entry) = cache.get(cwd) { + tracing::debug!( + "Cache hit for {}: {} (from {})", + cwd.as_path().display(), + entry.version, + entry.source + ); + return Ok(entry.clone()); + } + + // Cache miss - resolve version + let resolution = config::resolve_version(cwd).await.map_err(|e| format!("{e}"))?; + + // Create cache entry + let mtime = resolution.source_path.as_ref().and_then(|p| cache::get_file_mtime(p)).unwrap_or(0); + + let entry = ResolveCacheEntry { + version: resolution.version.clone(), + source: resolution.source.clone(), + project_root: resolution + .project_root + .as_ref() + .map(|p: &AbsolutePathBuf| p.as_path().display().to_string()), + resolved_at: cache::now_timestamp(), + version_file_mtime: mtime, + source_path: resolution + .source_path + .as_ref() + .map(|p: &AbsolutePathBuf| p.as_path().display().to_string()), + is_range: resolution.is_range, + }; + + // Save to cache + cache.insert(cwd, entry.clone()); + if let Some(ref path) = cache_path { + cache.save(path); + } + + Ok(entry) +} + +/// Ensure Node.js is installed. +async fn ensure_installed(version: &str) -> Result<(), String> { + let home_dir = vite_shared::get_vite_plus_home() + .map_err(|e| format!("Failed to get vite-plus home dir: {e}"))? + .join("js_runtime") + .join("node") + .join(version); + + #[cfg(windows)] + let binary_path = home_dir.join("node.exe"); + #[cfg(not(windows))] + let binary_path = home_dir.join("bin").join("node"); + + // Check if already installed + if binary_path.as_path().exists() { + return Ok(()); + } + + // Download the runtime + vite_js_runtime::download_runtime(vite_js_runtime::JsRuntimeType::Node, version) + .await + .map_err(|e| format!("{e}"))?; + Ok(()) +} + +/// Locate a tool binary within the Node.js installation. +fn locate_tool(version: &str, tool: &str) -> Result { + let home_dir = vite_shared::get_vite_plus_home() + .map_err(|e| format!("Failed to get vite-plus home dir: {e}"))? + .join("js_runtime") + .join("node") + .join(version); + + #[cfg(windows)] + let tool_path = if tool == "node" { + home_dir.join("node.exe") + } else { + // npm and npx are .cmd scripts on Windows + home_dir.join(format!("{tool}.cmd")) + }; + + #[cfg(not(windows))] + let tool_path = home_dir.join("bin").join(tool); + + if !tool_path.as_path().exists() { + return Err(format!("Tool '{}' not found at {}", tool, tool_path.as_path().display())); + } + + Ok(tool_path) +} + +/// Load shim mode from config. +/// +/// Returns the default (Managed) if config cannot be read. +async fn load_shim_mode() -> ShimMode { + config::load_config().await.map(|c| c.shim_mode).unwrap_or_default() +} + +/// Find a system tool in PATH, skipping the vite-plus bin directory and any +/// directories listed in `VITE_PLUS_BYPASS`. +/// +/// Returns the absolute path to the tool if found, None otherwise. +fn find_system_tool(tool: &str) -> Option { + let bin_dir = config::get_bin_dir().ok(); + let path_var = std::env::var_os("PATH")?; + tracing::debug!("path_var: {:?}", path_var); + + // Parse VITE_PLUS_BYPASS as a PATH-style list of additional directories to skip. + // This prevents infinite loops when multiple vite-plus installations exist in PATH. + let bypass_paths: Vec = std::env::var_os("VITE_PLUS_BYPASS") + .map(|v| std::env::split_paths(&v).collect()) + .unwrap_or_default(); + tracing::debug!("bypass_paths: {:?}", bypass_paths); + + // Filter PATH to exclude our bin directory and any bypass directories + 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; + } + } + !bypass_paths.iter().any(|bp| p == bp) + }) + .collect(); + + let filtered_path = std::env::join_paths(filtered_paths).ok()?; + + // Use which::which_in with filtered PATH - stops at first match + let cwd = current_dir().ok()?; + let path = which::which_in(tool, Some(filtered_path), cwd).ok()?; + AbsolutePathBuf::new(path) +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + use tempfile::TempDir; + + use super::*; + + /// Create a fake executable file in the given directory. + #[cfg(unix)] + fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.join(name); + std::fs::write(&path, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + + #[cfg(windows)] + fn create_fake_executable(dir: &std::path::Path, name: &str) -> std::path::PathBuf { + let path = dir.join(format!("{name}.exe")); + std::fs::write(&path, "fake").unwrap(); + path + } + + /// Helper to save and restore PATH and VITE_PLUS_BYPASS around a test. + struct EnvGuard { + original_path: Option, + original_bypass: Option, + } + + impl EnvGuard { + fn new() -> Self { + Self { + original_path: std::env::var_os("PATH"), + original_bypass: std::env::var_os("VITE_PLUS_BYPASS"), + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + match &self.original_path { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + match &self.original_bypass { + Some(v) => std::env::set_var("VITE_PLUS_BYPASS", v), + None => std::env::remove_var("VITE_PLUS_BYPASS"), + } + } + } + } + + #[test] + #[serial] + fn test_find_system_tool_works_without_bypass() { + let _guard = EnvGuard::new(); + let temp = TempDir::new().unwrap(); + let dir = temp.path().join("bin_a"); + std::fs::create_dir_all(&dir).unwrap(); + create_fake_executable(&dir, "mytesttool"); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("PATH", &dir); + std::env::remove_var("VITE_PLUS_BYPASS"); + } + + let result = find_system_tool("mytesttool"); + assert!(result.is_some(), "Should find tool when no bypass is set"); + assert!(result.unwrap().as_path().starts_with(&dir)); + } + + #[test] + #[serial] + fn test_find_system_tool_skips_single_bypass_path() { + let _guard = EnvGuard::new(); + let temp = TempDir::new().unwrap(); + let dir_a = temp.path().join("bin_a"); + let dir_b = temp.path().join("bin_b"); + std::fs::create_dir_all(&dir_a).unwrap(); + std::fs::create_dir_all(&dir_b).unwrap(); + create_fake_executable(&dir_a, "mytesttool"); + create_fake_executable(&dir_b, "mytesttool"); + + let path = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap(); + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("PATH", &path); + // Bypass dir_a — should skip it and find dir_b's tool + std::env::set_var("VITE_PLUS_BYPASS", dir_a.as_os_str()); + } + + let result = find_system_tool("mytesttool"); + assert!(result.is_some(), "Should find tool in non-bypassed directory"); + assert!( + result.unwrap().as_path().starts_with(&dir_b), + "Should find tool in dir_b, not dir_a" + ); + } + + #[test] + #[serial] + fn test_find_system_tool_filters_multiple_bypass_paths() { + let _guard = EnvGuard::new(); + let temp = TempDir::new().unwrap(); + let dir_a = temp.path().join("bin_a"); + let dir_b = temp.path().join("bin_b"); + let dir_c = temp.path().join("bin_c"); + std::fs::create_dir_all(&dir_a).unwrap(); + std::fs::create_dir_all(&dir_b).unwrap(); + std::fs::create_dir_all(&dir_c).unwrap(); + create_fake_executable(&dir_a, "mytesttool"); + create_fake_executable(&dir_b, "mytesttool"); + create_fake_executable(&dir_c, "mytesttool"); + + let path = + std::env::join_paths([dir_a.as_path(), dir_b.as_path(), dir_c.as_path()]).unwrap(); + let bypass = std::env::join_paths([dir_a.as_path(), dir_b.as_path()]).unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("PATH", &path); + std::env::set_var("VITE_PLUS_BYPASS", &bypass); + } + + let result = find_system_tool("mytesttool"); + assert!(result.is_some(), "Should find tool in dir_c"); + assert!( + result.unwrap().as_path().starts_with(&dir_c), + "Should find tool in dir_c since dir_a and dir_b are bypassed" + ); + } + + #[test] + #[serial] + fn test_find_system_tool_returns_none_when_all_paths_bypassed() { + let _guard = EnvGuard::new(); + let temp = TempDir::new().unwrap(); + let dir_a = temp.path().join("bin_a"); + std::fs::create_dir_all(&dir_a).unwrap(); + create_fake_executable(&dir_a, "mytesttool"); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("PATH", dir_a.as_os_str()); + std::env::set_var("VITE_PLUS_BYPASS", dir_a.as_os_str()); + } + + let result = find_system_tool("mytesttool"); + assert!(result.is_none(), "Should return None when all paths are bypassed"); + } + + /// Simulates the SystemFirst loop prevention: Installation A sets VITE_PLUS_BYPASS + /// with its own bin dir, then Installation B (seeing VITE_PLUS_BYPASS) should filter + /// both A's dir (from bypass) and its own dir (from get_bin_dir), finding the real tool + /// in a third directory or returning None. + #[test] + #[serial] + fn test_find_system_tool_cumulative_bypass_prevents_loop() { + let _guard = EnvGuard::new(); + let temp = TempDir::new().unwrap(); + let install_a_bin = temp.path().join("install_a_bin"); + let install_b_bin = temp.path().join("install_b_bin"); + let real_system_bin = temp.path().join("real_system"); + std::fs::create_dir_all(&install_a_bin).unwrap(); + std::fs::create_dir_all(&install_b_bin).unwrap(); + std::fs::create_dir_all(&real_system_bin).unwrap(); + create_fake_executable(&install_a_bin, "mytesttool"); + create_fake_executable(&install_b_bin, "mytesttool"); + create_fake_executable(&real_system_bin, "mytesttool"); + + // PATH has all three dirs: install_a, install_b, real_system + let path = std::env::join_paths([ + install_a_bin.as_path(), + install_b_bin.as_path(), + real_system_bin.as_path(), + ]) + .unwrap(); + + // Simulate: Installation A already set VITE_PLUS_BYPASS= + // 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). + let bypass = + std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("PATH", &path); + std::env::set_var("VITE_PLUS_BYPASS", &bypass); + } + + let result = find_system_tool("mytesttool"); + assert!(result.is_some(), "Should find tool in real_system directory"); + assert!( + result.unwrap().as_path().starts_with(&real_system_bin), + "Should find the real system tool, not any vite-plus installation" + ); + } + + /// When both installations are bypassed and no real system tool exists, should return None. + #[test] + #[serial] + fn test_find_system_tool_returns_none_with_no_real_system_tool() { + let _guard = EnvGuard::new(); + let temp = TempDir::new().unwrap(); + let install_a_bin = temp.path().join("install_a_bin"); + let install_b_bin = temp.path().join("install_b_bin"); + std::fs::create_dir_all(&install_a_bin).unwrap(); + std::fs::create_dir_all(&install_b_bin).unwrap(); + create_fake_executable(&install_a_bin, "mytesttool"); + create_fake_executable(&install_b_bin, "mytesttool"); + + let path = + std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap(); + let bypass = + std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap(); + + // SAFETY: This test runs in isolation with serial_test + unsafe { + std::env::set_var("PATH", &path); + std::env::set_var("VITE_PLUS_BYPASS", &bypass); + } + + let result = find_system_tool("mytesttool"); + assert!( + result.is_none(), + "Should return None when all dirs are bypassed and no real system tool exists" + ); + } +} diff --git a/crates/vite_global_cli/src/shim/exec.rs b/crates/vite_global_cli/src/shim/exec.rs new file mode 100644 index 0000000000..d2df92333d --- /dev/null +++ b/crates/vite_global_cli/src/shim/exec.rs @@ -0,0 +1,49 @@ +//! Platform-specific execution for shim operations. +//! +//! On Unix, uses execve to replace the current process. +//! On Windows, spawns the process and waits for completion. + +use vite_path::AbsolutePath; + +/// Execute a tool, replacing the current process on Unix. +/// +/// Returns an exit code on Windows or if exec fails on Unix. +pub fn exec_tool(path: &AbsolutePath, args: &[String]) -> i32 { + #[cfg(unix)] + { + exec_unix(path, args) + } + + #[cfg(windows)] + { + exec_windows(path, args) + } +} + +/// Unix: Use exec to replace the current process. +#[cfg(unix)] +fn exec_unix(path: &AbsolutePath, args: &[String]) -> i32 { + use std::os::unix::process::CommandExt; + + let mut cmd = std::process::Command::new(path.as_path()); + cmd.args(args); + + // exec replaces the current process - this only returns on error + let err = cmd.exec(); + eprintln!("vp: Failed to exec {}: {}", path.as_path().display(), err); + 1 +} + +/// Windows: Spawn the process and wait for completion. +#[cfg(windows)] +fn exec_windows(path: &AbsolutePath, args: &[String]) -> i32 { + use std::process::Command; + + match Command::new(path.as_path()).args(args).status() { + Ok(status) => status.code().unwrap_or(1), + Err(e) => { + eprintln!("vp: Failed to execute {}: {}", path.as_path().display(), e); + 1 + } + } +} diff --git a/crates/vite_global_cli/src/shim/mod.rs b/crates/vite_global_cli/src/shim/mod.rs new file mode 100644 index 0000000000..3503d794fa --- /dev/null +++ b/crates/vite_global_cli/src/shim/mod.rs @@ -0,0 +1,188 @@ +//! Shim module for intercepting node, npm, npx, and package binary commands. +//! +//! This module provides the functionality for the vp binary to act as a shim +//! when invoked as `node`, `npm`, `npx`, or any globally installed package binary. +//! +//! Detection methods: +//! - Unix: Symlinks to vp binary preserve argv[0], allowing tool detection +//! - Windows: .cmd wrappers call `vp env run ` directly +//! - Legacy: VITE_PLUS_SHIM_TOOL env var (kept for backward compatibility) + +mod cache; +mod dispatch; +mod exec; + +pub use dispatch::dispatch; + +/// Core shim tools (node, npm, npx) +pub const CORE_SHIM_TOOLS: &[&str] = &["node", "npm", "npx"]; + +/// Extract the tool name from argv[0]. +/// +/// Handles various formats: +/// - `node` (Unix) +/// - `/usr/bin/node` (Unix full path) +/// - `node.exe` (Windows) +/// - `C:\path\node.exe` (Windows full path) +pub fn extract_tool_name(argv0: &str) -> String { + let path = std::path::Path::new(argv0); + let stem = path.file_stem().unwrap_or_default().to_string_lossy(); + + // Handle Windows: strip .exe, .cmd extensions if present in stem + // (file_stem already strips the extension) + stem.to_lowercase() +} + +/// Check if the given tool name is a core shim tool (node/npm/npx). +#[must_use] +pub fn is_core_shim_tool(tool: &str) -> bool { + CORE_SHIM_TOOLS.contains(&tool) +} + +/// Check if the given tool name is a shim tool (core or package binary). +/// +/// This is a quick check that returns true if: +/// 1. The tool is a core shim (node/npm/npx), OR +/// 2. The tool name is not "vp" (package binaries are detected later via metadata) +#[must_use] +pub fn is_shim_tool(tool: &str) -> bool { + // Core tools are always shims + if is_core_shim_tool(tool) { + return true; + } + // "vp" is not a shim - it's the main CLI + if tool == "vp" { + return false; + } + // For other tools, we need to check if they're package binaries + // This is a heuristic - we'll check metadata in dispatch + // We assume anything invoked from the bin directory is a shim + is_potential_package_binary(tool) +} + +/// Check if the tool could be a package binary shim. +/// +/// Returns true if a shim for the tool exists in the configured bin directory. +/// This check respects the VITE_PLUS_HOME environment variable for custom home directories. +/// +/// Note: We check the configured bin directory directly instead of using current_exe() +/// 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 VITE_PLUS_HOME env var) + let Ok(configured_bin) = config::get_bin_dir() else { + return false; + }; + + // Check if the shim exists in the configured bin directory + // Use symlink_metadata to detect symlinks (even broken ones) + let shim_path = configured_bin.join(tool); + std::fs::symlink_metadata(&shim_path).is_ok() +} + +/// Environment variable used for shim tool detection via shell wrapper scripts. +const SHIM_TOOL_ENV_VAR: &str = "VITE_PLUS_SHIM_TOOL"; + +/// Detect the shim tool from environment and argv. +/// +/// Detection priority: +/// 1. If argv[0] is "vp" or "vp.exe", this is a direct CLI invocation - NOT shim mode +/// 2. Check `VITE_PLUS_SHIM_TOOL` env var (for shell wrapper scripts) +/// 3. Fall back to argv[0] detection (primary method on Unix with symlinks) +/// +/// Note: Modern Windows wrappers use `vp env run ` instead of env vars. +/// +/// IMPORTANT: This function clears `VITE_PLUS_SHIM_TOOL` after reading it to +/// prevent the env var from leaking to child processes. +pub fn detect_shim_tool(argv0: &str) -> Option { + // Always clear the env var to prevent it from leaking to child processes. + // We read it first, then clear it immediately. + // SAFETY: We're at program startup before any threads are spawned. + let env_tool = std::env::var(SHIM_TOOL_ENV_VAR).ok(); + unsafe { + std::env::remove_var(SHIM_TOOL_ENV_VAR); + } + + // If argv[0] is explicitly "vp" or "vp.exe", this is a direct CLI invocation. + // Do NOT use the env var in this case - it may be stale from a parent process. + let argv0_tool = extract_tool_name(argv0); + if argv0_tool == "vp" { + return None; // Direct vp invocation, not shim mode + } + + // Check VITE_PLUS_SHIM_TOOL env var (set by shell wrapper scripts) + if let Some(tool) = env_tool { + if !tool.is_empty() { + let tool_lower = tool.to_lowercase(); + // Accept any tool from env var (could be core or package binary) + if tool_lower != "vp" { + return Some(tool_lower); + } + } + } + + // Fall back to argv[0] detection + if is_shim_tool(&argv0_tool) { Some(argv0_tool) } else { None } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_tool_name() { + assert_eq!(extract_tool_name("node"), "node"); + assert_eq!(extract_tool_name("/usr/bin/node"), "node"); + assert_eq!(extract_tool_name("/home/user/.vite-plus/bin/node"), "node"); + assert_eq!(extract_tool_name("npm"), "npm"); + assert_eq!(extract_tool_name("npx"), "npx"); + assert_eq!(extract_tool_name("vp"), "vp"); + + // Files with extensions (works on all platforms) + assert_eq!(extract_tool_name("node.exe"), "node"); + assert_eq!(extract_tool_name("npm.cmd"), "npm"); + + // Windows paths - only test on Windows + #[cfg(windows)] + { + assert_eq!(extract_tool_name("C:\\Users\\user\\.vite-plus\\bin\\node.exe"), "node"); + } + } + + #[test] + fn test_is_shim_tool() { + // Core shim tools are always recognized + assert!(is_core_shim_tool("node")); + assert!(is_core_shim_tool("npm")); + assert!(is_core_shim_tool("npx")); + assert!(!is_core_shim_tool("yarn")); // yarn is not a core shim tool + assert!(!is_core_shim_tool("vp")); + assert!(!is_core_shim_tool("cargo")); + assert!(!is_core_shim_tool("tsc")); // Package binary, not core + + // is_shim_tool includes core tools + assert!(is_shim_tool("node")); + assert!(is_shim_tool("npm")); + assert!(is_shim_tool("npx")); + assert!(!is_shim_tool("vp")); // vp is never a shim + } + + /// Test that is_potential_package_binary checks the configured bin directory. + /// + /// The function now checks if a shim exists in the configured bin directory + /// (from VITE_PLUS_HOME/bin) 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 VITE_PLUS_HOME. + // Without setting VITE_PLUS_HOME, it defaults to ~/.vite-plus/bin. + // + // 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 + // non-existent tools. + assert!(!is_potential_package_binary("nonexistent-tool-12345")); + assert!(!is_potential_package_binary("another-fake-tool")); + } +} diff --git a/crates/vite_install/src/config.rs b/crates/vite_install/src/config.rs index d4189854b5..6147e050ab 100644 --- a/crates/vite_install/src/config.rs +++ b/crates/vite_install/src/config.rs @@ -1,8 +1,5 @@ use std::{env, sync::LazyLock}; -use vite_error::Error; -use vite_path::AbsolutePathBuf; - pub static NPM_REGISTRY: LazyLock = LazyLock::new(|| { env::var("npm_config_registry") .or_else(|_| env::var("NPM_CONFIG_REGISTRY")) @@ -20,14 +17,6 @@ pub fn get_npm_package_version_url(name: &str, version_or_tag: &str) -> String { format!("{}/{}/{}", NPM_REGISTRY.clone(), name, version_or_tag) } -/// Cache directory -/// -/// It will use the cache directory of the operating system if available, -/// otherwise it will use the current directory. -pub fn get_cache_dir() -> Result { - Ok(vite_shared::get_cache_dir()?) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/vite_install/src/lib.rs b/crates/vite_install/src/lib.rs index 302d6ecf29..f6a233fba1 100644 --- a/crates/vite_install/src/lib.rs +++ b/crates/vite_install/src/lib.rs @@ -5,6 +5,6 @@ mod request; mod shim; pub use package_manager::{ - PackageManager, PackageManagerType, download_package_manager, format_path_env, + PackageManager, PackageManagerType, download_package_manager, get_package_manager_type_and_version, }; diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index 7e5dfbefad..256e5b935e 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -24,7 +24,7 @@ use vite_workspace::find_package_root; use vite_workspace::{WorkspaceFile, WorkspaceRoot, find_workspace_root, load_package_graph}; use crate::{ - config::{get_cache_dir, get_npm_package_tgz_url, get_npm_package_version_url}, + config::{get_npm_package_tgz_url, get_npm_package_version_url}, request::{HttpClient, download_and_extract_tgz_with_hash}, shim, }; @@ -349,8 +349,8 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result< Ok(package_json.version) } -/// Download the package manager and extract it to the cache directory. -/// Return the install directory, e.g. $`CACHE_DIR/vite/package_manager/pnpm/10.0.0/pnpm` +/// Download the package manager and extract it to the vite-plus home directory. +/// Return the install directory, e.g. `$VITE_PLUS_HOME/package_manager/pnpm/10.0.0/pnpm` pub async fn download_package_manager( package_manager_type: PackageManagerType, version_or_latest: &str, @@ -373,14 +373,14 @@ pub async fn download_package_manager( } let tgz_url = get_npm_package_tgz_url(&package_name, &version); - let cache_dir = get_cache_dir()?; + let home_dir = vite_shared::get_vite_plus_home()?; let bin_name = package_manager_type.to_string(); - // $CACHE_DIR/vite/package_manager/pnpm/10.0.0 - let target_dir = cache_dir.join("package_manager").join(&bin_name).join(&version); + // $VITE_PLUS_HOME/package_manager/pnpm/10.0.0 + let target_dir = home_dir.join("package_manager").join(&bin_name).join(&version); let install_dir = target_dir.join(&bin_name); - // If all shims are already exists, return the target directory - // $CACHE_DIR/vite/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) + // If all shims already exist, return the target directory + // $VITE_PLUS_HOME/package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) let bin_prefix = install_dir.join("bin"); let bin_file = bin_prefix.join(&bin_name); if is_exists_file(&bin_file)? @@ -390,7 +390,7 @@ pub async fn download_package_manager( return Ok((install_dir, package_name, version)); } - // $CACHE_DIR/vite/package_manager/pnpm/{tmp_name} + // $VITE_PLUS_HOME/package_manager/pnpm/{tmp_name} // Use tempfile::TempDir for robust temporary directory creation let parent_dir = target_dir.parent().unwrap(); tokio::fs::create_dir_all(parent_dir).await?; @@ -542,11 +542,7 @@ async fn set_package_manager_field( Ok(()) } -pub fn format_path_env(bin_prefix: impl AsRef) -> String { - let mut paths = env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); - paths.insert(0, bin_prefix.as_ref().to_path_buf()); - env::join_paths(paths).unwrap().to_string_lossy().to_string() -} +pub(crate) use vite_shared::format_path_prepended as format_path_env; /// Common CI environment variables const CI_ENV_VARS: &[&str] = &[ diff --git a/crates/vite_js_runtime/src/cache.rs b/crates/vite_js_runtime/src/cache.rs index 170fa4bd67..b8e79768c0 100644 --- a/crates/vite_js_runtime/src/cache.rs +++ b/crates/vite_js_runtime/src/cache.rs @@ -6,7 +6,7 @@ use crate::Error; /// Get the cache directory for JavaScript runtimes. /// -/// Returns `$CACHE_DIR/vite/js_runtime`. +/// Returns `$VITE_PLUS_HOME/js_runtime`. pub(crate) fn get_cache_dir() -> Result { - Ok(vite_shared::get_cache_dir()?.join("js_runtime")) + Ok(vite_shared::get_vite_plus_home()?.join("js_runtime")) } diff --git a/crates/vite_js_runtime/src/dev_engines.rs b/crates/vite_js_runtime/src/dev_engines.rs index 217824bb25..b8cf7c67e9 100644 --- a/crates/vite_js_runtime/src/dev_engines.rs +++ b/crates/vite_js_runtime/src/dev_engines.rs @@ -1,81 +1,17 @@ -//! Package.json devEngines.runtime and engines.node parsing. +//! `.node-version` file reading and writing utilities. //! -//! This module provides structs for parsing the `devEngines.runtime` and `engines.node` -//! fields from package.json. It also handles `.node-version` file reading and writing. +//! This module provides utilities for working with `.node-version` files, +//! which are used to specify Node.js versions for projects. +//! +//! For PackageJson types (devEngines, engines), see `vite_shared::package_json`. -use serde::Deserialize; use vite_path::AbsolutePath; +// Re-export shared types for internal use +pub(crate) use vite_shared::PackageJson; use vite_str::Str; use crate::Error; -/// A single runtime engine configuration. -#[derive(Deserialize, Default, Debug)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeEngine { - /// The name of the runtime (e.g., "node", "deno", "bun") - #[serde(default)] - pub name: Str, - /// The version requirement (e.g., "^24.4.0") - #[serde(default)] - pub version: Str, - /// Action to take on failure (e.g., "download", "error", "warn") - /// Currently not used but parsed for future use. - #[serde(default)] - #[allow(dead_code)] - pub on_fail: Str, -} - -/// Runtime field can be a single object or an array. -#[derive(Deserialize, Debug)] -#[serde(untagged)] -pub enum RuntimeEngineConfig { - /// A single runtime configuration - Single(RuntimeEngine), - /// Multiple runtime configurations - Multiple(Vec), -} - -impl RuntimeEngineConfig { - /// Find the first runtime with the given name. - #[must_use] - pub fn find_by_name(&self, name: &str) -> Option<&RuntimeEngine> { - match self { - Self::Single(engine) if engine.name == name => Some(engine), - Self::Single(_) => None, - Self::Multiple(engines) => engines.iter().find(|e| e.name == name), - } - } -} - -/// The devEngines section of package.json. -#[derive(Deserialize, Default, Debug)] -pub struct DevEngines { - /// Runtime configuration(s) - #[serde(default)] - pub runtime: Option, -} - -/// The engines section of package.json. -#[derive(Deserialize, Default, Debug)] -pub struct Engines { - /// Node.js version requirement (e.g., ">=20.0.0") - #[serde(default)] - pub node: Option, -} - -/// Partial package.json structure for reading devEngines and engines. -#[derive(Deserialize, Default, Debug)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PackageJson { - /// The devEngines configuration - #[serde(default)] - pub dev_engines: Option, - /// The engines configuration - #[serde(default)] - pub engines: Option, -} - /// Parse the content of a `.node-version` file. /// /// # Supported Formats @@ -84,10 +20,12 @@ pub(crate) struct PackageJson { /// - With `v` prefix: `v20.5.0` /// - Two-part version: `20.5` (treated as `^20.5.0` for resolution) /// - Single-part version: `20` (treated as `^20.0.0` for resolution) +/// - LTS aliases: `lts/*`, `lts/iron`, `lts/jod`, `lts/-1` /// /// # Returns /// -/// The version string with any leading `v` prefix stripped. +/// The version string with any leading `v` prefix stripped (for regular versions). +/// LTS aliases are preserved as-is (e.g., `lts/iron` stays `lts/iron`). /// Returns `None` if the content is empty or contains only whitespace. #[must_use] pub fn parse_node_version_content(content: &str) -> Option { @@ -95,7 +33,13 @@ pub fn parse_node_version_content(content: &str) -> Option { if version.is_empty() { return None; } - // Strip optional 'v' prefix + + // Preserve LTS aliases as-is (lts/*, lts/iron, lts/-1, etc.) + if version.starts_with("lts/") { + return Some(version.into()); + } + + // Strip optional 'v' prefix for regular versions let version = version.strip_prefix('v').unwrap_or(version); Some(version.into()) } @@ -135,102 +79,10 @@ pub async fn write_node_version_file( #[cfg(test)] mod tests { - use super::*; - - #[test] - fn test_parse_single_runtime() { - let json = r#"{ - "devEngines": { - "runtime": { - "name": "node", - "version": "^24.4.0", - "onFail": "download" - } - } - }"#; - - let pkg: PackageJson = serde_json::from_str(json).unwrap(); - let dev_engines = pkg.dev_engines.unwrap(); - let runtime = dev_engines.runtime.unwrap(); - - let node = runtime.find_by_name("node").unwrap(); - assert_eq!(node.name, "node"); - assert_eq!(node.version, "^24.4.0"); - assert_eq!(node.on_fail, "download"); - - assert!(runtime.find_by_name("deno").is_none()); - } + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; - #[test] - fn test_parse_multiple_runtimes() { - let json = r#"{ - "devEngines": { - "runtime": [ - { - "name": "node", - "version": "^24.4.0", - "onFail": "download" - }, - { - "name": "deno", - "version": "^2.4.3", - "onFail": "download" - } - ] - } - }"#; - - let pkg: PackageJson = serde_json::from_str(json).unwrap(); - let dev_engines = pkg.dev_engines.unwrap(); - let runtime = dev_engines.runtime.unwrap(); - - let node = runtime.find_by_name("node").unwrap(); - assert_eq!(node.name, "node"); - assert_eq!(node.version, "^24.4.0"); - - let deno = runtime.find_by_name("deno").unwrap(); - assert_eq!(deno.name, "deno"); - assert_eq!(deno.version, "^2.4.3"); - - assert!(runtime.find_by_name("bun").is_none()); - } - - #[test] - fn test_parse_no_dev_engines() { - let json = r#"{"name": "test"}"#; - - let pkg: PackageJson = serde_json::from_str(json).unwrap(); - assert!(pkg.dev_engines.is_none()); - } - - #[test] - fn test_parse_empty_dev_engines() { - let json = r#"{"devEngines": {}}"#; - - let pkg: PackageJson = serde_json::from_str(json).unwrap(); - let dev_engines = pkg.dev_engines.unwrap(); - assert!(dev_engines.runtime.is_none()); - } - - #[test] - fn test_parse_runtime_with_missing_fields() { - let json = r#"{ - "devEngines": { - "runtime": { - "name": "node" - } - } - }"#; - - let pkg: PackageJson = serde_json::from_str(json).unwrap(); - let dev_engines = pkg.dev_engines.unwrap(); - let runtime = dev_engines.runtime.unwrap(); - - let node = runtime.find_by_name("node").unwrap(); - assert_eq!(node.name, "node"); - assert!(node.version.is_empty()); - assert!(node.on_fail.is_empty()); - } + use super::*; #[test] fn test_parse_node_version_content_three_part() { @@ -273,9 +125,6 @@ mod tests { #[tokio::test] async fn test_read_node_version_file() { - use tempfile::TempDir; - use vite_path::AbsolutePathBuf; - let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -289,9 +138,6 @@ mod tests { #[tokio::test] async fn test_write_node_version_file() { - use tempfile::TempDir; - use vite_path::AbsolutePathBuf; - let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); @@ -304,31 +150,33 @@ mod tests { assert_eq!(read_node_version_file(&temp_path).await, Some("22.13.1".into())); } + // ======================================================================== + // LTS Alias Tests - These test support for lts/* syntax in .node-version + // ======================================================================== + #[test] - fn test_parse_engines_node() { - let json = r#"{"engines":{"node":">=20.0.0"}}"#; - let pkg: PackageJson = serde_json::from_str(json).unwrap(); - assert_eq!(pkg.engines.unwrap().node, Some(">=20.0.0".into())); + fn test_parse_node_version_content_lts_latest() { + // lts/* should be preserved as-is (not stripped of prefix) + assert_eq!(parse_node_version_content("lts/*\n"), Some("lts/*".into())); + assert_eq!(parse_node_version_content("lts/*"), Some("lts/*".into())); + assert_eq!(parse_node_version_content(" lts/* \n"), Some("lts/*".into())); } #[test] - fn test_parse_engines_node_empty() { - let json = r#"{"engines":{}}"#; - let pkg: PackageJson = serde_json::from_str(json).unwrap(); - assert!(pkg.engines.unwrap().node.is_none()); + fn test_parse_node_version_content_lts_codename() { + // lts/ should be preserved as-is + assert_eq!(parse_node_version_content("lts/iron\n"), Some("lts/iron".into())); + assert_eq!(parse_node_version_content("lts/jod\n"), Some("lts/jod".into())); + assert_eq!(parse_node_version_content("lts/hydrogen\n"), Some("lts/hydrogen".into())); + // Should preserve original case for codenames + assert_eq!(parse_node_version_content("lts/Iron\n"), Some("lts/Iron".into())); + assert_eq!(parse_node_version_content("lts/Jod\n"), Some("lts/Jod".into())); } #[test] - fn test_parse_both_engines_and_dev_engines() { - let json = r#"{ - "engines": {"node": ">=20.0.0"}, - "devEngines": {"runtime": {"name": "node", "version": "^24.4.0"}} - }"#; - let pkg: PackageJson = serde_json::from_str(json).unwrap(); - assert_eq!(pkg.engines.unwrap().node, Some(">=20.0.0".into())); - let dev_engines = pkg.dev_engines.unwrap(); - let runtime = dev_engines.runtime.unwrap(); - let node = runtime.find_by_name("node").unwrap(); - assert_eq!(node.version, "^24.4.0"); + fn test_parse_node_version_content_lts_offset() { + // lts/-n should be preserved as-is + assert_eq!(parse_node_version_content("lts/-1\n"), Some("lts/-1".into())); + assert_eq!(parse_node_version_content("lts/-2\n"), Some("lts/-2".into())); } } diff --git a/crates/vite_js_runtime/src/error.rs b/crates/vite_js_runtime/src/error.rs index 46230a9409..2f79c1f7d0 100644 --- a/crates/vite_js_runtime/src/error.rs +++ b/crates/vite_js_runtime/src/error.rs @@ -40,6 +40,20 @@ pub enum Error { #[error("No version matching '{version_req}' found")] NoMatchingVersion { version_req: Str }, + /// Invalid LTS alias format + #[error("Invalid LTS alias format: '{alias}'")] + InvalidLtsAlias { alias: Str }, + + /// Unknown LTS codename + #[error( + "Unknown LTS codename: '{codename}'. Valid codenames include: hydrogen (18.x), iron (20.x), jod (22.x)" + )] + UnknownLtsCodename { codename: Str }, + + /// Invalid LTS offset (too large) + #[error("Invalid LTS offset: {offset}. Only {available} LTS lines are available")] + InvalidLtsOffset { offset: i32, available: usize }, + /// IO error #[error(transparent)] Io(#[from] std::io::Error), diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index 6836b42f97..4460080f49 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -42,11 +42,15 @@ mod provider; mod providers; mod runtime; +pub use dev_engines::{ + parse_node_version_content, read_node_version_file, write_node_version_file, +}; pub use error::Error; pub use platform::{Arch, Os, Platform}; pub use provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider}; -pub use providers::NodeProvider; +pub use providers::{LtsInfo, NodeProvider, NodeVersionEntry}; pub use runtime::{ - JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, - download_runtime_with_provider, + JsRuntime, JsRuntimeType, VersionResolution, VersionSource, download_runtime, + download_runtime_for_project, download_runtime_with_provider, normalize_version, + read_package_json, resolve_node_version, }; diff --git a/crates/vite_js_runtime/src/providers/mod.rs b/crates/vite_js_runtime/src/providers/mod.rs index fda9fe1a38..96230597d7 100644 --- a/crates/vite_js_runtime/src/providers/mod.rs +++ b/crates/vite_js_runtime/src/providers/mod.rs @@ -5,4 +5,4 @@ mod node; -pub use node::NodeProvider; +pub use node::{LtsInfo, NodeProvider, NodeVersionEntry}; diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 21d65e4ce6..141d9c4fa5 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -311,6 +311,133 @@ impl NodeProvider { let versions = self.fetch_version_index().await?; find_latest_lts_version(&versions) } + + /// Check if a version string is an LTS alias (e.g., `lts/*`, `lts/iron`, `lts/-1`). + /// + /// Returns `true` for LTS alias formats: + /// - `lts/*` - Latest LTS version + /// - `lts/` - Specific LTS line (e.g., `lts/iron`, `lts/jod`) + /// - `lts/-n` - Nth-highest LTS line (e.g., `lts/-1` for second highest) + #[must_use] + pub fn is_lts_alias(version: &str) -> bool { + version.starts_with("lts/") + } + + /// Check if a version string is a "latest" alias. + /// + /// Returns `true` for: + /// - `latest` - The absolute latest Node.js version (including non-LTS) + #[must_use] + pub fn is_latest_alias(version: &str) -> bool { + version.eq_ignore_ascii_case("latest") + } + + /// Check if a version string is any kind of alias (lts/* or latest). + #[must_use] + pub fn is_version_alias(version: &str) -> bool { + Self::is_lts_alias(version) || Self::is_latest_alias(version) + } + + /// Resolve an LTS alias to an exact version. + /// + /// # Supported Formats + /// + /// - `lts/*` - Returns the latest LTS version + /// - `lts/` - Returns the highest version for that LTS line (e.g., `lts/iron` → 20.x) + /// - `lts/-n` - Returns the nth-highest LTS line (e.g., `lts/-1` → second highest) + /// + /// # Errors + /// + /// Returns an error if: + /// - The alias format is invalid + /// - The codename is not recognized + /// - The offset is too large (not enough LTS lines) + pub async fn resolve_lts_alias(&self, alias: &str) -> Result { + let suffix = alias + .strip_prefix("lts/") + .ok_or_else(|| Error::InvalidLtsAlias { alias: alias.into() })?; + + // lts/* - latest LTS + if suffix == "*" { + return self.resolve_latest_version().await; + } + + // lts/-n - nth-highest LTS (e.g., lts/-1 = second highest) + if suffix.starts_with('-') { + if let Ok(n) = suffix.parse::() { + if n < 0 { + return self.resolve_lts_by_offset(n).await; + } + } + } + + // lts/ - specific LTS line + self.resolve_lts_by_codename(suffix).await + } + + /// Resolve LTS by codename (e.g., "iron" → 20.x, "jod" → 22.x). + async fn resolve_lts_by_codename(&self, codename: &str) -> Result { + let versions = self.fetch_version_index().await?; + let target = codename.to_lowercase(); + + // Find all versions matching the codename + let matching: Vec<_> = versions + .iter() + .filter(|v| matches!(&v.lts, LtsInfo::Codename(name) if name.to_lowercase() == target)) + .collect(); + + if matching.is_empty() { + return Err(Error::UnknownLtsCodename { codename: codename.into() }); + } + + // Find the highest matching version + let highest = matching + .into_iter() + .filter_map(|entry| { + let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version); + Version::parse(version_str).ok().map(|v| (v, version_str)) + }) + .max_by(|(a, _), (b, _)| a.cmp(b)); + + highest + .map(|(_, version_str)| version_str.into()) + .ok_or_else(|| Error::UnknownLtsCodename { codename: codename.into() }) + } + + /// Resolve LTS by offset (e.g., -1 = second highest LTS line). + /// + /// The offset is negative: lts/-1 means "one below the latest LTS line". + async fn resolve_lts_by_offset(&self, offset: i32) -> Result { + let versions = self.fetch_version_index().await?; + + // Get unique LTS codenames ordered by highest version in each line + let mut lts_lines: Vec<(String, u64)> = Vec::new(); + + for entry in &versions { + if let LtsInfo::Codename(name) = &entry.lts { + let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version); + if let Ok(ver) = Version::parse(version_str) { + let key = name.to_lowercase(); + // Only add if we haven't seen this codename yet (keeping highest version) + if !lts_lines.iter().any(|(n, _)| n == &key) { + lts_lines.push((key, ver.major)); + } + } + } + } + + // Sort by major version descending (highest first) + lts_lines.sort_by(|a, b| b.1.cmp(&a.1)); + + // offset is negative, so lts/-1 = index 1 (second highest) + let index = (-offset) as usize; + + let (codename, _) = lts_lines + .get(index) + .ok_or_else(|| Error::InvalidLtsOffset { offset, available: lts_lines.len() })?; + + self.resolve_lts_by_codename(codename).await + } } /// Find the LTS version with the highest version number from a list of versions. @@ -1006,4 +1133,138 @@ fedcba987654 node-v22.13.1-win-x64.zip"; let result = resolve_version_from_list("^20.18.0", &versions).unwrap(); assert_eq!(result, "20.19.0"); } + + // ======================================================================== + // LTS Alias Tests + // ======================================================================== + + #[test] + fn test_is_lts_alias() { + // Valid LTS aliases + assert!(NodeProvider::is_lts_alias("lts/*")); + assert!(NodeProvider::is_lts_alias("lts/iron")); + assert!(NodeProvider::is_lts_alias("lts/jod")); + assert!(NodeProvider::is_lts_alias("lts/Iron")); // Case-insensitive for codename + assert!(NodeProvider::is_lts_alias("lts/Jod")); + assert!(NodeProvider::is_lts_alias("lts/hydrogen")); + assert!(NodeProvider::is_lts_alias("lts/-1")); // Offset format + assert!(NodeProvider::is_lts_alias("lts/-2")); + + // Not LTS aliases + assert!(!NodeProvider::is_lts_alias("20.18.0")); // Exact version + assert!(!NodeProvider::is_lts_alias("^20.0.0")); // Semver range + assert!(!NodeProvider::is_lts_alias("20")); // Partial version + assert!(!NodeProvider::is_lts_alias("iron")); // Codename without lts/ prefix + assert!(!NodeProvider::is_lts_alias("Lts/*")); // Wrong case for prefix + assert!(!NodeProvider::is_lts_alias("LTS/*")); // All caps prefix + assert!(!NodeProvider::is_lts_alias("")); // Empty + assert!(!NodeProvider::is_lts_alias("latest")); // Different alias + assert!(!NodeProvider::is_lts_alias("lts")); // No suffix + } + + #[test] + fn test_is_latest_alias() { + // Valid "latest" aliases (case-insensitive) + assert!(NodeProvider::is_latest_alias("latest")); + assert!(NodeProvider::is_latest_alias("Latest")); + assert!(NodeProvider::is_latest_alias("LATEST")); + + // Not "latest" aliases + assert!(!NodeProvider::is_latest_alias("lts/*")); + assert!(!NodeProvider::is_latest_alias("20.18.0")); + assert!(!NodeProvider::is_latest_alias("^20.0.0")); + assert!(!NodeProvider::is_latest_alias("")); + assert!(!NodeProvider::is_latest_alias("late")); + assert!(!NodeProvider::is_latest_alias("latestversion")); + } + + #[test] + fn test_is_version_alias() { + // LTS aliases + assert!(NodeProvider::is_version_alias("lts/*")); + assert!(NodeProvider::is_version_alias("lts/iron")); + + // "latest" alias + assert!(NodeProvider::is_version_alias("latest")); + assert!(NodeProvider::is_version_alias("LATEST")); + + // Not aliases + assert!(!NodeProvider::is_version_alias("20.18.0")); + assert!(!NodeProvider::is_version_alias("^20.0.0")); + assert!(!NodeProvider::is_version_alias("")); + } + + #[tokio::test] + async fn test_resolve_lts_alias_latest() { + let provider = NodeProvider::new(); + + // lts/* should resolve to the latest LTS version + let version = provider.resolve_lts_alias("lts/*").await.unwrap(); + + // Should be a valid semver version + let parsed = Version::parse(&version).expect("Should parse as semver"); + + // As of 2026, latest LTS is at least v24.x (Krypton) + assert!(parsed.major >= 24, "Latest LTS should be at least v24.x, got {}", version); + } + + #[tokio::test] + async fn test_resolve_lts_alias_codename_iron() { + let provider = NodeProvider::new(); + + // lts/iron should resolve to v20.x + let version = provider.resolve_lts_alias("lts/iron").await.unwrap(); + let parsed = Version::parse(&version).expect("Should parse as semver"); + assert_eq!(parsed.major, 20, "lts/iron should resolve to v20.x, got {}", version); + } + + #[tokio::test] + async fn test_resolve_lts_alias_codename_jod() { + let provider = NodeProvider::new(); + + // lts/jod should resolve to v22.x + let version = provider.resolve_lts_alias("lts/jod").await.unwrap(); + let parsed = Version::parse(&version).expect("Should parse as semver"); + assert_eq!(parsed.major, 22, "lts/jod should resolve to v22.x, got {}", version); + } + + #[tokio::test] + async fn test_resolve_lts_alias_codename_case_insensitive() { + let provider = NodeProvider::new(); + + // Should be case-insensitive for codenames + let version_lower = provider.resolve_lts_alias("lts/iron").await.unwrap(); + let version_mixed = provider.resolve_lts_alias("lts/Iron").await.unwrap(); + + assert_eq!(version_lower, version_mixed, "LTS codename should be case-insensitive"); + } + + #[tokio::test] + async fn test_resolve_lts_alias_offset() { + let provider = NodeProvider::new(); + + // lts/-1 should resolve to the second-highest LTS line + // As of 2026: lts/* = 24.x (Krypton), lts/-1 = 22.x (Jod) + let version = provider.resolve_lts_alias("lts/-1").await.unwrap(); + let parsed = Version::parse(&version).expect("Should parse as semver"); + assert_eq!(parsed.major, 22, "lts/-1 should resolve to v22.x (Jod), got {}", version); + } + + #[tokio::test] + async fn test_resolve_lts_alias_unknown_codename() { + let provider = NodeProvider::new(); + + // Unknown codename should error + let result = provider.resolve_lts_alias("lts/unknown").await; + assert!(result.is_err(), "Unknown LTS codename should return error"); + } + + #[tokio::test] + async fn test_resolve_lts_alias_invalid_offset() { + let provider = NodeProvider::new(); + + // Too large offset should error (there aren't 100 LTS lines) + let result = provider.resolve_lts_alias("lts/-100").await; + assert!(result.is_err(), "Invalid LTS offset should return error"); + } } diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index bc76da35ae..729843bd2e 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -198,8 +198,6 @@ pub enum VersionSource { EnginesNode, /// Version from `devEngines.runtime` in package.json (lowest priority) DevEnginesRuntime, - /// No version source specified, will use latest installed or LTS - None, } impl std::fmt::Display for VersionSource { @@ -208,11 +206,115 @@ impl std::fmt::Display for VersionSource { Self::NodeVersionFile => write!(f, ".node-version"), Self::EnginesNode => write!(f, "engines.node"), Self::DevEnginesRuntime => write!(f, "devEngines.runtime"), - Self::None => write!(f, "none"), } } } +/// Resolved version information with source tracking. +#[derive(Debug, Clone)] +pub struct VersionResolution { + /// The resolved version string (e.g., "20.18.0" or "^20.18.0") + pub version: Str, + /// The source type of the version + pub source: VersionSource, + /// Path to the source file (e.g., .node-version or package.json) + pub source_path: Option, + /// Project root directory (the directory containing the version source) + pub project_root: Option, +} + +/// Resolve Node.js version from project configuration. +/// +/// At each directory level, searches for version in the following priority order: +/// 1. `.node-version` file +/// 2. `package.json#engines.node` +/// 3. `package.json#devEngines.runtime[name="node"]` +/// +/// If `walk_up` is true, walks up the directory tree checking each level until +/// a version is found or the root is reached. +/// +/// # Arguments +/// * `start_dir` - The directory to start searching from +/// * `walk_up` - Whether to walk up the directory tree +/// +/// # Returns +/// `Some(VersionResolution)` if a version source is found, `None` otherwise. +/// +/// # Errors +/// Returns an error if file reading fails. +pub async fn resolve_node_version( + start_dir: &AbsolutePath, + walk_up: bool, +) -> Result, Error> { + let mut current = start_dir.to_owned(); + + loop { + // At each directory level, check both .node-version and package.json + // before moving to parent directory + + // 1. Check .node-version file + if let Some(version) = read_node_version_file(¤t).await { + let node_version_path = current.join(".node-version"); + return Ok(Some(VersionResolution { + version, + source: VersionSource::NodeVersionFile, + source_path: Some(node_version_path), + project_root: Some(current.to_absolute_path_buf()), + })); + } + + // 2-3. Check package.json (engines.node and devEngines.runtime) + let package_json_path = current.join("package.json"); + if tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) { + let content = tokio::fs::read_to_string(&package_json_path).await?; + if let Ok(pkg) = serde_json::from_str::(&content) { + // Check engines.node first + if let Some(engines) = &pkg.engines { + if let Some(node) = &engines.node { + if !node.is_empty() { + return Ok(Some(VersionResolution { + version: node.clone(), + source: VersionSource::EnginesNode, + source_path: Some(package_json_path), + project_root: Some(current.to_absolute_path_buf()), + })); + } + } + } + + // Check devEngines.runtime + if let Some(dev_engines) = &pkg.dev_engines { + if let Some(runtime) = &dev_engines.runtime { + if let Some(node_rt) = runtime.find_by_name("node") { + if !node_rt.version.is_empty() { + return Ok(Some(VersionResolution { + version: node_rt.version.clone(), + source: VersionSource::DevEnginesRuntime, + source_path: Some(package_json_path), + project_root: Some(current.to_absolute_path_buf()), + })); + } + } + } + } + } + } + + // Move to parent directory if walk_up is enabled + if !walk_up { + break; + } + + match current.parent() { + Some(parent) => current = parent.to_owned(), + None => break, + } + } + + // No version source found + Ok(None) +} + /// Download runtime based on project's version configuration. /// /// Reads Node.js version from multiple sources with the following priority: @@ -238,15 +340,19 @@ impl std::fmt::Display for VersionSource { /// # Note /// Currently only supports Node.js runtime. pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result { - let package_json_path = project_path.join("package.json"); - let pkg = read_package_json(&package_json_path).await?; let provider = NodeProvider::new(); let cache_dir = crate::cache::get_cache_dir()?; - // 1. Read all version sources (with validation) - let node_version_file = read_node_version_file(project_path) - .await - .and_then(|v| normalize_version(&v, ".node-version")); + // Use resolve_node_version to find the version (no directory walking for project downloads) + let resolution = resolve_node_version(project_path, false).await?; + + // Validate the version from the resolved source + let version_req = + resolution.as_ref().and_then(|r| normalize_version(&r.version, &r.source.to_string())); + + // For compatibility checking, we need to read all sources + let package_json_path = project_path.join("package.json"); + let pkg = read_package_json(&package_json_path).await?; let engines_node = pkg .as_ref() @@ -263,37 +369,31 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result .filter(|v| !v.is_empty()) .and_then(|v| normalize_version(&v, "devEngines.runtime")); - tracing::debug!( - "Version sources - .node-version: {:?}, engines.node: {:?}, devEngines.runtime: {:?}", - node_version_file, - engines_node, - dev_engines_runtime - ); - - // 2. Select version from highest priority source that exists - let (version_req, source) = if let Some(ref v) = node_version_file { - (v.clone(), VersionSource::NodeVersionFile) + // Determine the actual version requirement to use + let (version_req, source) = if let Some(ref v) = version_req { + (v.clone(), resolution.as_ref().map(|r| r.source)) } else if let Some(ref v) = engines_node { - (v.clone(), VersionSource::EnginesNode) + // Fall through if primary source was invalid + (v.clone(), Some(VersionSource::EnginesNode)) } else if let Some(ref v) = dev_engines_runtime { - (v.clone(), VersionSource::DevEnginesRuntime) + (v.clone(), Some(VersionSource::DevEnginesRuntime)) } else { - (Str::default(), VersionSource::None) + (Str::default(), None) }; - tracing::debug!("Selected version source: {source}, version_req: {version_req:?}"); + tracing::debug!("Selected version source: {source:?}, version_req: {version_req:?}"); - // 3. Resolve version (if range/partial → exact) + // Resolve version (if range/partial → exact) let (version, should_write_back) = resolve_version_for_project(&version_req, source, &provider, &cache_dir).await?; - // 4. Check compatibility with lower priority sources + // Check compatibility with lower priority sources check_version_compatibility(&version, source, &engines_node, &dev_engines_runtime); tracing::info!("Resolved Node.js version: {version}"); let runtime = download_runtime(JsRuntimeType::Node, &version).await?; - // 5. Write resolved version to .node-version (if resolution occurred) + // Write resolved version to .node-version (if resolution occurred) if should_write_back { if let Err(e) = write_node_version_file(project_path, &version).await { tracing::warn!("Failed to write .node-version: {e}"); @@ -310,7 +410,7 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result /// Returns (resolved_version, should_write_back). async fn resolve_version_for_project( version_req: &str, - _source: VersionSource, + _source: Option, provider: &NodeProvider, cache_dir: &AbsolutePath, ) -> Result<(Str, bool), Error> { @@ -321,6 +421,22 @@ async fn resolve_version_for_project( return Ok((version, true)); } + // Handle LTS aliases (lts/*, lts/iron, lts/-1) + if NodeProvider::is_lts_alias(version_req) { + tracing::debug!("Resolving LTS alias: {version_req}"); + let version = provider.resolve_lts_alias(version_req).await?; + // Don't write back - user explicitly specified an LTS alias + return Ok((version, false)); + } + + // Handle "latest" alias - resolves to absolute latest version (including non-LTS) + if NodeProvider::is_latest_alias(version_req) { + tracing::debug!("Resolving 'latest' alias"); + let version = provider.resolve_version("*").await?; + // Don't write back - user explicitly specified "latest" + return Ok((version, false)); + } + // Check if it's an exact version if NodeProvider::is_exact_version(version_req) { let normalized = version_req.strip_prefix('v').unwrap_or(version_req); @@ -348,7 +464,7 @@ async fn resolve_version_for_project( /// Emit warnings if incompatible. fn check_version_compatibility( resolved_version: &str, - source: VersionSource, + source: Option, engines_node: &Option, dev_engines_runtime: &Option, ) { @@ -358,14 +474,14 @@ fn check_version_compatibility( }; // Check engines.node if it's a lower priority source - if source != VersionSource::EnginesNode { + if source != Some(VersionSource::EnginesNode) { if let Some(req) = engines_node { check_constraint(&parsed, req, "engines.node", resolved_version, source); } } // Check devEngines.runtime if it's a lower priority source - if source != VersionSource::DevEnginesRuntime { + if source != Some(VersionSource::DevEnginesRuntime) { if let Some(req) = dev_engines_runtime { check_constraint(&parsed, req, "devEngines.runtime", resolved_version, source); } @@ -378,14 +494,15 @@ fn check_constraint( constraint: &str, constraint_source: &str, resolved_version: &str, - source: VersionSource, + source: Option, ) { match Range::parse(constraint) { Ok(range) => { if !range.satisfies(version) { + let source_str = source.map_or("none".to_string(), |s| s.to_string()); println!( - "warning: Node.js version {resolved_version} (from {source}) does not satisfy \ - {constraint_source} constraint '{constraint}'" + "warning: Node.js version {resolved_version} (from {source_str}) does not \ + satisfy {constraint_source} constraint '{constraint}'" ); } } @@ -395,9 +512,9 @@ fn check_constraint( } } -/// Normalize and validate a version string as semver (exact version or range). +/// Normalize and validate a version string as semver (exact version or range) or LTS alias. /// Trims whitespace and returns the normalized version, or None with a warning if invalid. -fn normalize_version(version: &Str, source: &str) -> Option { +pub fn normalize_version(version: &Str, source: &str) -> Option { // Trim leading/trailing whitespace let trimmed: Str = version.trim().into(); @@ -405,6 +522,11 @@ fn normalize_version(version: &Str, source: &str) -> Option { return None; } + // Accept version aliases (lts/*, lts/iron, lts/-1, latest) + if NodeProvider::is_version_alias(&trimmed) { + return Some(trimmed); + } + // Try parsing as exact version (strip 'v' prefix for exact version check) let without_v = trimmed.strip_prefix('v').unwrap_or(&trimmed); if Version::parse(without_v).is_ok() { @@ -422,7 +544,7 @@ fn normalize_version(version: &Str, source: &str) -> Option { } /// Read package.json contents. -async fn read_package_json( +pub async fn read_package_json( package_json_path: &AbsolutePathBuf, ) -> Result, Error> { if !tokio::fs::try_exists(package_json_path).await.unwrap_or(false) { @@ -911,7 +1033,6 @@ mod tests { assert_eq!(VersionSource::NodeVersionFile.to_string(), ".node-version"); assert_eq!(VersionSource::EnginesNode.to_string(), "engines.node"); assert_eq!(VersionSource::DevEnginesRuntime.to_string(), "devEngines.runtime"); - assert_eq!(VersionSource::None.to_string(), "none"); } // ========================================== @@ -1116,4 +1237,220 @@ mod tests { let version = Str::from(" "); assert_eq!(normalize_version(&version, "test"), None); } + + #[test] + fn test_normalize_version_lts_aliases() { + // LTS aliases should be accepted by normalize_version + assert_eq!(normalize_version(&"lts/*".into(), ".node-version"), Some("lts/*".into())); + assert_eq!(normalize_version(&"lts/iron".into(), ".node-version"), Some("lts/iron".into())); + assert_eq!(normalize_version(&"lts/jod".into(), ".node-version"), Some("lts/jod".into())); + assert_eq!(normalize_version(&"lts/-1".into(), ".node-version"), Some("lts/-1".into())); + assert_eq!(normalize_version(&"lts/-2".into(), ".node-version"), Some("lts/-2".into())); + } + + #[test] + fn test_normalize_version_latest_alias() { + // "latest" alias should be accepted by normalize_version (case-insensitive) + assert_eq!(normalize_version(&"latest".into(), ".node-version"), Some("latest".into())); + assert_eq!(normalize_version(&"Latest".into(), ".node-version"), Some("Latest".into())); + assert_eq!(normalize_version(&"LATEST".into(), ".node-version"), Some("LATEST".into())); + } + + #[tokio::test] + async fn test_download_runtime_for_project_with_lts_alias_in_node_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with LTS alias + tokio::fs::write(temp_path.join(".node-version"), "lts/iron\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // lts/iron should resolve to v20.x + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20, "lts/iron should resolve to v20.x, got {version}"); + + // Should NOT overwrite .node-version - user explicitly specified an LTS alias + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "lts/iron\n", ".node-version should remain unchanged"); + } + + #[tokio::test] + async fn test_download_runtime_for_project_with_lts_latest_alias() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with lts/* alias + tokio::fs::write(temp_path.join(".node-version"), "lts/*\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // lts/* should resolve to latest LTS (at least v22.x as of 2026) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 22, "lts/* should resolve to at least v22.x, got {version}"); + + // Should NOT overwrite .node-version + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "lts/*\n", ".node-version should remain unchanged"); + } + + #[tokio::test] + async fn test_download_runtime_for_project_with_latest_alias_in_node_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with "latest" alias + tokio::fs::write(temp_path.join(".node-version"), "latest\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // "latest" should resolve to the absolute latest version (including non-LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + // Latest version should be at least v20.x + assert!(parsed.major >= 20, "'latest' should resolve to at least v20.x, got {version}"); + + // Should NOT overwrite .node-version - user explicitly specified "latest" + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "latest\n", ".node-version should remain unchanged"); + } + + // ========================================== + // resolve_node_version tests + // ========================================== + + #[tokio::test] + async fn test_resolve_node_version_no_walk_up() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, "20.18.0"); + assert_eq!(resolution.source, VersionSource::NodeVersionFile); + assert!(resolution.source_path.is_some()); + assert!(resolution.project_root.is_some()); + } + + #[tokio::test] + async fn test_resolve_node_version_with_walk_up() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version in parent + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // Create subdirectory + let subdir = temp_path.join("subdir"); + tokio::fs::create_dir(&subdir).await.unwrap(); + + // With walk_up=true, should find version in parent + let resolution = resolve_node_version(&subdir, true).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, "20.18.0"); + assert_eq!(resolution.source, VersionSource::NodeVersionFile); + + // With walk_up=false, should not find version + let resolution = resolve_node_version(&subdir, false).await.unwrap(); + assert!(resolution.is_none()); + } + + #[tokio::test] + async fn test_resolve_node_version_engines_node() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with engines.node + let package_json = r#"{"engines":{"node":"20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, "20.18.0"); + assert_eq!(resolution.source, VersionSource::EnginesNode); + } + + #[tokio::test] + async fn test_resolve_node_version_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with devEngines.runtime + let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"20.18.0"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap(); + assert_eq!(&*resolution.version, "20.18.0"); + assert_eq!(resolution.source, VersionSource::DevEnginesRuntime); + } + + #[tokio::test] + async fn test_resolve_node_version_priority() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create both .node-version and package.json with different versions + tokio::fs::write(temp_path.join(".node-version"), "22.0.0\n").await.unwrap(); + let package_json = r#"{"engines":{"node":"20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let resolution = resolve_node_version(&temp_path, false).await.unwrap().unwrap(); + // .node-version should take priority + assert_eq!(&*resolution.version, "22.0.0"); + assert_eq!(resolution.source, VersionSource::NodeVersionFile); + } + + #[tokio::test] + async fn test_resolve_node_version_none_when_no_sources() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // No version sources at all + let resolution = resolve_node_version(&temp_path, false).await.unwrap(); + assert!(resolution.is_none()); + } + + /// Test that package.json in child directory takes priority over .node-version in parent. + /// + /// Directory structure: + /// ``` + /// parent/ + /// .node-version (22.0.0) + /// child/ + /// package.json (engines.node: "20.18.0") + /// ``` + /// + /// When resolving from `child/` with walk_up=true, it should find `package.json` in child + /// (20.18.0) instead of `.node-version` in parent (22.0.0). + #[tokio::test] + async fn test_resolve_node_version_child_package_json_over_parent_node_version() { + let temp_dir = TempDir::new().unwrap(); + let parent_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version in parent + tokio::fs::write(parent_path.join(".node-version"), "22.0.0\n").await.unwrap(); + + // Create child directory with package.json + let child_path = parent_path.join("child"); + tokio::fs::create_dir(&child_path).await.unwrap(); + let package_json = r#"{"engines":{"node":"20.18.0"}}"#; + tokio::fs::write(child_path.join("package.json"), package_json).await.unwrap(); + + // When resolving from child with walk_up=true, should find package.json in child + // NOT the .node-version in parent + let resolution = resolve_node_version(&child_path, true).await.unwrap().unwrap(); + assert_eq!( + &*resolution.version, "20.18.0", + "Should use child's package.json (20.18.0), not parent's .node-version (22.0.0)" + ); + assert_eq!(resolution.source, VersionSource::EnginesNode); + } } diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index 66727a6bd8..025e9a79f3 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -9,8 +9,11 @@ rust-version.workspace = true [dependencies] directories = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } tracing-subscriber = { workspace = true } vite_path = { workspace = true } +vite_str = { workspace = true } [lints] workspace = true diff --git a/crates/vite_shared/src/cache.rs b/crates/vite_shared/src/cache.rs deleted file mode 100644 index afa0fd1f04..0000000000 --- a/crates/vite_shared/src/cache.rs +++ /dev/null @@ -1,32 +0,0 @@ -use directories::BaseDirs; -use vite_path::{AbsolutePathBuf, current_dir}; - -/// Get the vite-plus cache directory. -/// -/// Uses the OS-specific cache directory, or falls back to `.cache` in the -/// current working directory if the home directory cannot be determined. -/// -/// # Platform-specific paths -/// -/// - **Linux**: `~/.cache/vite-plus` -/// - **macOS**: `~/Library/Caches/vite-plus` -/// - **Windows**: `C:\Users\\AppData\Local\vite-plus` -/// - **Fallback**: `$CWD/.cache/vite-plus` -pub fn get_cache_dir() -> std::io::Result { - let cache_dir = match BaseDirs::new() { - Some(dirs) => AbsolutePathBuf::new(dirs.cache_dir().to_path_buf()).unwrap(), - None => current_dir()?.join(".cache"), - }; - Ok(cache_dir.join("vite-plus")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_cache_dir() { - let cache_dir = get_cache_dir().unwrap(); - assert!(cache_dir.ends_with("vite-plus")); - } -} diff --git a/crates/vite_shared/src/home.rs b/crates/vite_shared/src/home.rs new file mode 100644 index 0000000000..3c1bccc5ab --- /dev/null +++ b/crates/vite_shared/src/home.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; + +use directories::BaseDirs; +use vite_path::{AbsolutePathBuf, current_dir}; + +/// Default VITE_PLUS_HOME directory name +const VITE_PLUS_HOME_DIR: &str = ".vite-plus"; + +/// Get the vite-plus home directory. +/// +/// Uses `VITE_PLUS_HOME` environment variable if set, otherwise defaults to `~/.vite-plus`. +/// Falls back to `$CWD/.vite-plus` if the home directory cannot be determined. +/// +/// # Platform-specific paths +/// +/// - **Default**: `~/.vite-plus` +/// - **Custom**: Value of `VITE_PLUS_HOME` environment variable +/// - **Fallback**: `$CWD/.vite-plus` +pub fn get_vite_plus_home() -> std::io::Result { + // Check VITE_PLUS_HOME env var first + if let Ok(home) = std::env::var("VITE_PLUS_HOME") { + if let Some(path) = AbsolutePathBuf::new(PathBuf::from(home)) { + return Ok(path); + } + } + + // 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)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_vite_plus_home() { + let home = get_vite_plus_home().unwrap(); + assert!(home.ends_with(".vite-plus")); + } + + #[test] + #[ignore] + fn test_get_vite_plus_home_with_env() { + // Save original value + let original = std::env::var("VITE_PLUS_HOME").ok(); + + // SAFETY: This test is single-threaded and we restore the env var after + unsafe { + // Set custom home + std::env::set_var("VITE_PLUS_HOME", "/custom/path"); + } + let home = get_vite_plus_home().unwrap(); + assert_eq!(home.as_path().to_str().unwrap(), "/custom/path"); + + // Restore original value + // SAFETY: This test is single-threaded and we're restoring the original value + unsafe { + match original { + Some(v) => std::env::set_var("VITE_PLUS_HOME", v), + None => std::env::remove_var("VITE_PLUS_HOME"), + } + } + } +} diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 3599de1a1b..74a099b455 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -1,7 +1,14 @@ //! Shared utilities for vite-plus crates -mod cache; +mod home; +mod package_json; +mod path_env; mod tracing; -pub use cache::get_cache_dir; +pub use home::get_vite_plus_home; +pub use package_json::{DevEngines, Engines, PackageJson, RuntimeEngine, RuntimeEngineConfig}; +pub use path_env::{ + PrependOptions, PrependResult, format_path_prepended, format_path_with_prepend, + prepend_to_path_env, +}; pub use tracing::init_tracing; diff --git a/crates/vite_shared/src/package_json.rs b/crates/vite_shared/src/package_json.rs new file mode 100644 index 0000000000..1e1cd56647 --- /dev/null +++ b/crates/vite_shared/src/package_json.rs @@ -0,0 +1,201 @@ +//! Package.json parsing utilities for Node.js version resolution. +//! +//! This module provides shared types for parsing `devEngines.runtime` and `engines.node` +//! fields from package.json, used across multiple crates for version resolution. + +use serde::Deserialize; +use vite_str::Str; + +/// A single runtime engine configuration. +#[derive(Deserialize, Default, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeEngine { + /// The name of the runtime (e.g., "node", "deno", "bun") + #[serde(default)] + pub name: Str, + /// The version requirement (e.g., "^24.4.0") + #[serde(default)] + pub version: Str, + /// Action to take on failure (e.g., "download", "error", "warn") + /// Currently not used but parsed for future use. + #[serde(default)] + pub on_fail: Str, +} + +/// Runtime field can be a single object or an array. +#[derive(Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum RuntimeEngineConfig { + /// A single runtime configuration + Single(RuntimeEngine), + /// Multiple runtime configurations + Multiple(Vec), +} + +impl RuntimeEngineConfig { + /// Find the first runtime with the given name. + #[must_use] + pub fn find_by_name(&self, name: &str) -> Option<&RuntimeEngine> { + match self { + Self::Single(engine) if engine.name == name => Some(engine), + Self::Single(_) => None, + Self::Multiple(engines) => engines.iter().find(|e| e.name == name), + } + } +} + +/// The devEngines section of package.json. +#[derive(Deserialize, Default, Debug, Clone)] +pub struct DevEngines { + /// Runtime configuration(s) + #[serde(default)] + pub runtime: Option, +} + +/// The engines section of package.json. +#[derive(Deserialize, Default, Debug, Clone)] +pub struct Engines { + /// Node.js version requirement (e.g., ">=20.0.0") + #[serde(default)] + pub node: Option, +} + +/// Partial package.json structure for reading devEngines and engines. +#[derive(Deserialize, Default, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PackageJson { + /// The devEngines configuration + #[serde(default)] + pub dev_engines: Option, + /// The engines configuration + #[serde(default)] + pub engines: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_single_runtime() { + let json = r#"{ + "devEngines": { + "runtime": { + "name": "node", + "version": "^24.4.0", + "onFail": "download" + } + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.name, "node"); + assert_eq!(node.version, "^24.4.0"); + assert_eq!(node.on_fail, "download"); + + assert!(runtime.find_by_name("deno").is_none()); + } + + #[test] + fn test_parse_multiple_runtimes() { + let json = r#"{ + "devEngines": { + "runtime": [ + { + "name": "node", + "version": "^24.4.0", + "onFail": "download" + }, + { + "name": "deno", + "version": "^2.4.3", + "onFail": "download" + } + ] + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.name, "node"); + assert_eq!(node.version, "^24.4.0"); + + let deno = runtime.find_by_name("deno").unwrap(); + assert_eq!(deno.name, "deno"); + assert_eq!(deno.version, "^2.4.3"); + + assert!(runtime.find_by_name("bun").is_none()); + } + + #[test] + fn test_parse_no_dev_engines() { + let json = r#"{"name": "test"}"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert!(pkg.dev_engines.is_none()); + } + + #[test] + fn test_parse_empty_dev_engines() { + let json = r#"{"devEngines": {}}"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + assert!(dev_engines.runtime.is_none()); + } + + #[test] + fn test_parse_runtime_with_missing_fields() { + let json = r#"{ + "devEngines": { + "runtime": { + "name": "node" + } + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.name, "node"); + assert!(node.version.is_empty()); + assert!(node.on_fail.is_empty()); + } + + #[test] + fn test_parse_engines_node() { + let json = r#"{"engines":{"node":">=20.0.0"}}"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert_eq!(pkg.engines.unwrap().node, Some(">=20.0.0".into())); + } + + #[test] + fn test_parse_engines_node_empty() { + let json = r#"{"engines":{}}"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert!(pkg.engines.unwrap().node.is_none()); + } + + #[test] + fn test_parse_both_engines_and_dev_engines() { + let json = r#"{ + "engines": {"node": ">=20.0.0"}, + "devEngines": {"runtime": {"name": "node", "version": "^24.4.0"}} + }"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert_eq!(pkg.engines.unwrap().node, Some(">=20.0.0".into())); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.version, "^24.4.0"); + } +} diff --git a/crates/vite_shared/src/path_env.rs b/crates/vite_shared/src/path_env.rs new file mode 100644 index 0000000000..3cd905b778 --- /dev/null +++ b/crates/vite_shared/src/path_env.rs @@ -0,0 +1,170 @@ +//! PATH environment variable manipulation utilities. +//! +//! This module provides functions for prepending directories to the PATH +//! environment variable with various deduplication strategies. + +use std::{env, ffi::OsString, path::Path}; + +use vite_path::AbsolutePath; + +/// Options for deduplication behavior when prepending to PATH. +#[derive(Debug, Clone, Copy, Default)] +pub struct PrependOptions { + /// If `false`, only check if the directory is first in PATH (faster). + /// If `true`, check if the directory exists anywhere in PATH. + pub dedupe_anywhere: bool, +} + +/// Result of a PATH prepend operation. +#[derive(Debug)] +pub enum PrependResult { + /// The directory was prepended successfully. + Prepended(OsString), + /// The directory is already present in PATH (based on dedup strategy). + AlreadyPresent, + /// Failed to join paths (invalid characters in path). + JoinError, +} + +/// Format PATH with the given directory prepended. +/// +/// This returns a new PATH value without modifying the environment. +/// Use this when you need to set PATH on a `Command` via `cmd.env()`. +/// +/// # Arguments +/// * `dir` - The directory to prepend to PATH +/// * `options` - Deduplication options +/// +/// # Returns +/// * `PrependResult::Prepended(new_path)` - The new PATH value with directory prepended +/// * `PrependResult::AlreadyPresent` - Directory already exists in PATH (based on options) +/// * `PrependResult::JoinError` - Failed to join paths +pub fn format_path_with_prepend(dir: impl AsRef, options: PrependOptions) -> PrependResult { + let dir = dir.as_ref(); + let current_path = env::var_os("PATH").unwrap_or_default(); + let paths: Vec<_> = env::split_paths(¤t_path).collect(); + + // Check for duplicates based on strategy + if options.dedupe_anywhere { + if paths.iter().any(|p| p == dir) { + return PrependResult::AlreadyPresent; + } + } else if let Some(first) = paths.first() { + if first == dir { + return PrependResult::AlreadyPresent; + } + } + + // Prepend the directory + let mut new_paths = vec![dir.to_path_buf()]; + new_paths.extend(paths); + + match env::join_paths(new_paths) { + Ok(new_path) => PrependResult::Prepended(new_path), + Err(_) => PrependResult::JoinError, + } +} + +/// Prepend a directory to the global PATH environment variable. +/// +/// This modifies the process environment using `std::env::set_var`. +/// +/// # Safety +/// This function uses `unsafe` to call `std::env::set_var`, which is unsafe +/// in multi-threaded contexts. Only call this before spawning threads or +/// when you're certain no other threads are reading environment variables. +/// +/// # Arguments +/// * `dir` - The directory to prepend to PATH +/// * `options` - Deduplication options +/// +/// # Returns +/// * `true` if PATH was modified +/// * `false` if the directory was already present or join failed +pub fn prepend_to_path_env(dir: &AbsolutePath, options: PrependOptions) -> bool { + match format_path_with_prepend(dir.as_path(), options) { + PrependResult::Prepended(new_path) => { + // SAFETY: Caller ensures this is safe (single-threaded or before exec) + unsafe { env::set_var("PATH", new_path) }; + true + } + PrependResult::AlreadyPresent | PrependResult::JoinError => false, + } +} + +/// Format PATH with the given directory prepended (simple version). +/// +/// This is a simpler version that always prepends without deduplication. +/// Use this for backward compatibility with `format_path_env`. +/// +/// # Arguments +/// * `bin_prefix` - The directory to prepend to PATH +/// +/// # Returns +/// The new PATH value as a String +pub fn format_path_prepended(bin_prefix: impl AsRef) -> String { + let mut paths = env::split_paths(&env::var_os("PATH").unwrap_or_default()).collect::>(); + paths.insert(0, bin_prefix.as_ref().to_path_buf()); + env::join_paths(paths).unwrap().to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn test_prepend_options_default() { + let options = PrependOptions::default(); + assert!(!options.dedupe_anywhere); + } + + #[test] + fn test_format_path_prepended() { + let result = format_path_prepended("/test/bin"); + assert!(result.starts_with("/test/bin")); + } + + #[test] + fn test_format_path_with_prepend_dedupe_first() { + // With dedupe_anywhere = false, should check first element only + let options = PrependOptions { dedupe_anywhere: false }; + let result = format_path_with_prepend(PathBuf::from("/new/path"), options); + assert!(matches!(result, PrependResult::Prepended(_))); + } + + #[test] + fn test_format_path_with_prepend_dedupe_anywhere() { + let options = PrependOptions { dedupe_anywhere: true }; + let result = format_path_with_prepend(PathBuf::from("/new/path"), options); + assert!(matches!(result, PrependResult::Prepended(_))); + } + + #[test] + #[ignore] + fn test_format_path_prepended_always_prepends() { + // Even if the directory exists somewhere in PATH, it should be prepended + let test_dir = "/test/node/bin"; + + // Set PATH to include test_dir in the middle + // SAFETY: This test runs in isolation + unsafe { + std::env::set_var("PATH", format!("/other/bin:{}:/another/bin", test_dir)); + } + + let result = format_path_prepended(test_dir); + + // Should start with test_dir regardless of existing PATH entries + assert!( + result.starts_with(test_dir), + "Directory should always be first in PATH, got: {}", + result + ); + + // Restore PATH + unsafe { + std::env::remove_var("PATH"); + } + } +} diff --git a/docs/vite/guide/cli.md b/docs/vite/guide/cli.md index 694115fcb6..d0100c1bb8 100644 --- a/docs/vite/guide/cli.md +++ b/docs/vite/guide/cli.md @@ -1,21 +1,21 @@ # Command Line Interface -## `vite` CLI +## `vp` CLI -The `vite` command is the main entry point for Vite+ (vite-plus), a monorepo task runner with intelligent caching and dependency resolution. +The `vp` command is the main entry point for Vite+ (vite-plus), a monorepo task runner with intelligent caching and dependency resolution. -**Type:** `vite [ARGS] [OPTIONS]` +**Type:** `vp [ARGS] [OPTIONS]` ## Dev Server -### `vite dev` +### `vp dev` -Start Vite dev server in the current directory. `vite serve` is an alias for `vite dev`. +Start Vite dev server in the current directory. `vp serve` is an alias for `vp dev`. #### Usage ```bash -vite dev [root] [OPTIONS] +vp dev [root] [OPTIONS] ``` #### Arguments @@ -48,21 +48,21 @@ vite dev [root] [OPTIONS] #### Examples ```bash -vite dev -vite dev ./apps/website -vite dev --port 3000 +vp dev +vp dev ./apps/website +vp dev --port 3000 ``` ## Build Application -### `vite build` +### `vp build` Build for production. #### Usage ```bash -vite build [root] [OPTIONS] +vp build [root] [OPTIONS] ``` #### Arguments @@ -100,130 +100,130 @@ vite build [root] [OPTIONS] ## Build Library -### `vite lib` +### `vp lib` Build a library using tsdown. #### Usage ```bash -vite lib [...] +vp lib [...] ``` #### Examples ```bash -vite lib -vite lib --watch -vite lib --outdir dist +vp lib +vp lib --watch +vp lib --outdir dist ``` ## Build Documentation -### `vite doc` +### `vp doc` Build documentation using VitePress. #### Usage ```bash -vite doc [...] +vp doc [...] ``` #### Examples ```bash -vite doc build -vite doc dev -vite doc dev --host 0.0.0.0 +vp doc build +vp doc dev +vp doc dev --host 0.0.0.0 ``` ## Lint -### `vite lint` +### `vp lint` Lint code using oxlint. #### Usage ```bash -vite lint [...] +vp lint [...] ``` #### Examples ```bash -vite lint -vite lint --fix -vite lint --quiet +vp lint +vp lint --fix +vp lint --quiet ``` ## Format -### `vite fmt` +### `vp fmt` Format code using oxfmt. #### Usage ```bash -vite fmt [...] +vp fmt [...] ``` #### Examples ```bash -vite fmt -vite fmt --check -vite fmt --ignore-path .gitignore +vp fmt +vp fmt --check +vp fmt --ignore-path .gitignore ``` ## Testing -### `vite test` +### `vp test` Run tests using Vitest. #### Usage ```bash -vite test [...] +vp test [...] ``` #### Examples ```bash -vite test -vite test --watch -vite test run --coverage +vp test +vp test --watch +vp test run --coverage ``` ## Task Runner -### `vite run` +### `vp run` Run tasks across monorepo packages with automatic dependency ordering. #### Usage ```bash -vite run ... [OPTIONS] [-- ...] +vp run ... [OPTIONS] [-- ...] ``` #### Examples ```bash # Run build in specific packages -vite run app#build web#build +vp run app#build web#build # Run build recursively across all packages -vite run build --recursive +vp run build --recursive # Run without topological ordering -vite run build --recursive --no-topological +vp run build --recursive --no-topological # Pass arguments to tasks -vite run test -- --watch --coverage +vp run test -- --watch --coverage ``` #### Options @@ -331,11 +331,11 @@ Vite+ uses intelligent caching to speed up task execution: ```bash # First run - executes task -vite run build +vp run build # → Cache miss: no previous cache entry found # Second run - replays from cache -vite run build +vp run build # → Cache hit - output replayed # Modify source file @@ -356,13 +356,13 @@ echo "modified" > node_modules/pkg/index.js ```bash # View cache entries -vite cache view +vp cache view # Enable cache debug output -vite run build --debug +vp run build --debug # Clean cache -vite cache clean +vp cache clean ``` ### Environment Variables @@ -381,7 +381,7 @@ vite cache clean Run specific tasks: ```bash -vite run app#build web#build +vp run app#build web#build ``` #### Recursive Mode @@ -389,8 +389,8 @@ vite run app#build web#build Run task in all packages: ```bash -vite run build --recursive -vite run build -r +vp run build --recursive +vp run build -r ``` **Behavior:** @@ -405,11 +405,11 @@ Controls implicit dependencies based on package relationships: ```bash # With topological (default for recursive) -vite run build -r +vp run build -r # → If A depends on B, A#build waits for B#build # Without topological -vite run build -r --no-topological +vp run build -r --no-topological # → Only explicit dependencies, no implicit ordering ``` @@ -419,11 +419,11 @@ Pass arguments to tasks using `--`: ```bash # Arguments go to all tasks -vite run build test -- --watch +vp run build test -- --watch # For built-in commands -vite test -- --coverage --run -vite lint -- --fix +vp test -- --coverage --run +vp lint -- --fix ``` ### Examples @@ -432,51 +432,51 @@ vite lint -- --fix ```bash # Install dependencies -vite install +vp install # Lint and fix issues -vite lint -- --fix +vp lint -- --fix # Format code -vite fmt +vp fmt # Run build recursively -vite run build -r +vp run build -r # Run tests in watch mode -vite test -- --watch +vp test -- --watch # Build for production -vite build +vp build # Start dev server -vite dev +vp dev # Build library -vite lib +vp lib # Build docs -vite doc build +vp doc build # Preview docs -vite doc dev +vp doc dev ``` #### Monorepo Workflows ```bash # Build all packages in dependency order -vite run build --recursive +vp run build --recursive # Build specific packages -vite run app#build utils#build +vp run app#build utils#build # Run tests without topological ordering -vite run test -r --no-topological +vp run test -r --no-topological # Clean cache and rebuild -vite cache clean -vite run build -r +vp cache clean +vp run build -r ``` ### Exit Codes @@ -492,34 +492,34 @@ Enable verbose logging: ```bash # Debug mode - shows cache operations -vite run build --debug +vp run build --debug # Trace logging -VITE_LOG=debug vite run build +VITE_LOG=debug vp run build # View cache contents -vite cache view +vp cache view ``` ## Package Management -### `vite install` +### `vp install` -Aliases: `vite i` +Aliases: `vp i` Install dependencies using the detected package manager. #### Usage ```bash -vite install [ARGS] [OPTIONS] +vp install [ARGS] [OPTIONS] ``` #### Examples ```bash -vite install -vite install --loglevel debug +vp install +vp install --loglevel debug ``` #### Note @@ -527,122 +527,122 @@ vite install --loglevel debug - Auto-detects package manager (pnpm/yarn/npm) - Prompts for selection if none package manager detected -### `vite update` +### `vp update` -Aliases: `vite up` +Aliases: `vp up` Updates packages to their latest version based on the specified range. #### Usage ```bash -vite update [-g] [...] +vp update [-g] [...] ``` #### Examples ```bash -vite update -vite update @types/node +vp update +vp update @types/node ``` -### `vite add` +### `vp add` Installs packages. #### Usage ```bash -vite add [OPTIONS] [@version]... +vp add [OPTIONS] [@version]... ``` #### Examples ```bash -vite add -D @types/node +vp add -D @types/node ``` -### `vite remove` +### `vp remove` -Aliases: `vite rm`, `vite uninstall`, `vite un` +Aliases: `vp rm`, `vp uninstall`, `vp un` Removes packages. #### Usage ```bash -vite remove [@version]... +vp remove [@version]... ``` ```bash -vite remove @types/node +vp remove @types/node ``` -### `vite link` +### `vp link` -Aliases: `vite ln` +Aliases: `vp ln` Makes the current local package accessible system-wide, or in another location. -### `vite unlink` +### `vp unlink` -Unlinks a system-wide package (inverse of `vite link`). +Unlinks a system-wide package (inverse of `vp link`). If called without arguments, all linked dependencies will be unlinked inside the current project. -### `vite dedupe` +### `vp dedupe` Perform an install removing older dependencies in the lockfile if a newer version can be used. -### `vite outdated` +### `vp outdated` Shows outdated packages. -### `vite why` +### `vp why` -Aliases: `vite explain` +Aliases: `vp explain` Shows all packages that depend on the specified package. -### `vite pm ` +### `vp pm ` -The `vite pm` command group provides a set of utilities for working with package manager. +The `vp pm` command group provides a set of utilities for working with package manager. > package manager commands with low usage frequency will under this command group. -#### `vite pm prune` +#### `vp pm prune` Removes unnecessary packages. -#### `vite pm pack` +#### `vp pm pack` Pack the current package into a tarball. -#### `vite pm list` +#### `vp pm list` -Aliases: `vite pm ls` +Aliases: `vp pm ls` List installed packages. -#### `vite pm view` +#### `vp pm view` View a package info from the registry. -#### `vite pm publish` +#### `vp pm publish` Publishes a package to the registry. -#### `vite pm owner` +#### `vp pm owner` Manage package owners. -#### `vite pm cache` +#### `vp pm cache` Manage the packages metadata cache. ## Others -### `vite optimize` +### `vp optimize` Pre-bundle dependencies. @@ -651,7 +651,7 @@ Pre-bundle dependencies. #### Usage ```bash -vite optimize [root] +vp optimize [root] ``` #### Options @@ -669,16 +669,16 @@ vite optimize [root] | `-m, --mode ` | Set env mode (`string`) | | `-h, --help` | Display available CLI options | -### `vite preview` +### `vp preview` Locally preview the production build. Do not use this as a production server as it's not designed for it. -This command starts a server in the build directory (by default `dist`). Run `vite build` beforehand to ensure that the build directory is up-to-date. Depending on the project's configured [`appType`](../../config/shared-options.md#apptype), it makes use of certain middleware. +This command starts a server in the build directory (by default `dist`). Run `vp build` beforehand to ensure that the build directory is up-to-date. Depending on the project's configured [`appType`](../../config/shared-options.md#apptype), it makes use of certain middleware. #### Usage ```bash -vite preview [root] +vp preview [root] ``` #### Options @@ -700,30 +700,30 @@ vite preview [root] | `-m, --mode ` | Set env mode (`string`) | | `-h, --help` | Display available CLI options | -### `vite cache` +### `vp cache` Manage the task cache. #### Usage ```bash -vite cache +vp cache ``` -#### `vite cache clean` +#### `vp cache clean` Clean up all cached task results. ```bash -vite cache clean +vp cache clean ``` -#### `vite cache view` +#### `vp cache view` View cache entries in JSON format for debugging. ```bash -vite cache view +vp cache view ``` ## See Also diff --git a/docs/vite/guide/index.md b/docs/vite/guide/index.md index 3093acf6af..1488cccf40 100644 --- a/docs/vite/guide/index.md +++ b/docs/vite/guide/index.md @@ -12,15 +12,36 @@ Vite+ is a unified toolchain for modern web development that extends Vite with p - **Formatting**: Integrated oxfmt for consistent code formatting - **Code Generation**: Scaffolding for new projects and monorepo workspaces - **Dependency Management**: Integrated dependency management with pnpm, yarn, npm and bun(coming soon) +- **Node.js Version Manager**: Built-in Node.js version management All in a single, cohesive tool designed for scale, speed, and developer sanity. ## Installation -### Global CLI +Install Vite+ globally as `vp`: + +For Linux or macOS: + +```bash +curl -fsSL https://viteplus.dev/install.sh | bash +``` + +For Windows: + +```bash +irm https://viteplus.dev/install.ps1 | iex +``` + +## Node.js Version Manager + +Vite+ includes a built-in Node.js version manager. During installation, you can opt-in to let Vite+ manage your Node.js versions. ```bash -npm install -g vite-plus-cli +vp env pin 22.12.0 # Pin version in .node-version +vp env default lts # Set global default +vp env list # Show available versions +vp env doctor # Diagnose issues +vp env help # Show all commands ``` ## Scaffolding Your First Vite+ Project @@ -28,7 +49,7 @@ npm install -g vite-plus-cli Create a Vite+ project: ```bash -vite new +vp new ``` Follow the prompts to select your preferred framework and configuration. @@ -39,16 +60,16 @@ Vite+ provides built-in commands that work seamlessly in both single-package and ```bash # Development -vite dev # Start dev server +vp dev # Start dev server # Build -vite build # Build for production +vp build # Build for production # Test -vite test # Run tests +vp test # Run tests # Lint -vite lint # Lint code with oxlint +vp lint # Lint code with oxlint ``` ## Monorepo Task Execution @@ -58,21 +79,21 @@ Vite+ includes a powerful task runner for managing tasks across monorepo package ### Run tasks recursively ```bash -vite run build -r # Build all packages with topological ordering -vite run test -r # Test all packages +vp run build -r # Build all packages with topological ordering +vp run test -r # Test all packages ``` ### Run tasks for specific packages ```bash -vite run app#build web#build # Build specific packages -vite run @scope/*#test # Test all packages matching pattern +vp run app#build web#build # Build specific packages +vp run @scope/*#test # Test all packages matching pattern ``` ### Current package ```bash -vite dev # Run dev script in current package +vp dev # Run dev script in current package ``` ## Task Dependencies @@ -98,7 +119,7 @@ Tasks automatically respect dependencies: Disable topological ordering: ```bash -vite run build -r --no-topological +vp run build -r --no-topological ``` ## Intelligent Caching @@ -112,7 +133,7 @@ Vite+ caches task outputs to speed up repeated builds: View cache operations: ```bash -vite run build -r --debug +vp run build -r --debug ``` ## Next Steps diff --git a/packages/cli/bin/vp b/packages/cli/bin/vp new file mode 120000 index 0000000000..3e0c3c76b5 --- /dev/null +++ b/packages/cli/bin/vp @@ -0,0 +1 @@ +vite \ No newline at end of file diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index b278f5c654..6011c1a99a 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use tokio::fs::write; use vite_error::Error; use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_shared::{PrependOptions, prepend_to_path_env}; use vite_str::Str; use vite_task::{ CLIArgs, LabeledReporter, Session, SessionCallbacks, TaskSynthesizer, @@ -748,9 +749,9 @@ pub async fn main( // Only update PATH if there's an explicit packageManager field in package.json. // Use build() instead of build_with_default() to avoid prompting or using defaults. if let Ok(pm) = vite_install::PackageManager::builder(&cwd).build().await { - let new_path = vite_install::format_path_env(&pm.get_bin_prefix()); - // SAFETY: Single-threaded context before session init - unsafe { env::set_var("PATH", new_path) }; + let bin_prefix = pm.get_bin_prefix(); + // Prepend package manager bin to PATH (skips if already first) + prepend_to_path_env(&bin_prefix, PrependOptions::default()); } // Create single Session (captures updated PATH) diff --git a/packages/cli/package.json b/packages/cli/package.json index dded0fc0a0..88a67c30d5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,8 @@ "name": "vite-plus", "version": "0.0.0", "bin": { - "vite": "./bin/vite" + "vite": "./bin/vite", + "vp": "./bin/vite" }, "files": [ "bin", diff --git a/packages/cli/snap-tests/command-vp-alias/package.json b/packages/cli/snap-tests/command-vp-alias/package.json new file mode 100644 index 0000000000..1e139caee7 --- /dev/null +++ b/packages/cli/snap-tests/command-vp-alias/package.json @@ -0,0 +1,4 @@ +{ + "name": "command-vp-alias", + "version": "0.0.0" +} diff --git a/packages/cli/snap-tests/command-vp-alias/snap.txt b/packages/cli/snap-tests/command-vp-alias/snap.txt new file mode 100644 index 0000000000..b95ba6312a --- /dev/null +++ b/packages/cli/snap-tests/command-vp-alias/snap.txt @@ -0,0 +1,36 @@ +> vp -h # vp should show help same as vite +Vite+/ + +Usage: vite + +Vite+ Commands: + dev Run the development server + build Build for production + preview Preview production build + lint Lint code + test Run tests + fmt Format code + lib Build library + run Run tasks + cache Manage the task cache + +Package Manager Commands: + install Install all dependencies + +Options: + -h, --help Print help + +> vp run -h # vp run should show help +Run tasks + +Usage: vite run [OPTIONS] [ADDITIONAL_ARGS]... + +Arguments: + `packageName#taskName` or `taskName` + [ADDITIONAL_ARGS]... Additional arguments to pass to the tasks + +Options: + -r, --recursive Run tasks found in all packages in the workspace, in topological order based on package dependencies + -t, --transitive Run tasks found in the current package and all its transitive dependencies, in topological order based on package dependencies + --ignore-depends-on Do not run dependencies specified in `dependsOn` fields + -h, --help Print help diff --git a/packages/cli/snap-tests/command-vp-alias/steps.json b/packages/cli/snap-tests/command-vp-alias/steps.json new file mode 100644 index 0000000000..bf362c8a16 --- /dev/null +++ b/packages/cli/snap-tests/command-vp-alias/steps.json @@ -0,0 +1,7 @@ +{ + "ignoredPlatforms": ["linux"], + "env": { + "VITE_DISABLE_AUTO_INSTALL": "1" + }, + "commands": ["vp -h # vp should show help same as vite", "vp run -h # vp run should show help"] +} diff --git a/packages/cli/snap-tests/yarn-install-with-options/steps.json b/packages/cli/snap-tests/yarn-install-with-options/steps.json index f187ce249c..82f48f3651 100644 --- a/packages/cli/snap-tests/yarn-install-with-options/steps.json +++ b/packages/cli/snap-tests/yarn-install-with-options/steps.json @@ -1,7 +1,8 @@ { "ignoredPlatforms": ["linux", "win32"], "env": { - "VITE_DISABLE_AUTO_INSTALL": "1" + "VITE_DISABLE_AUTO_INSTALL": "1", + "NODE_OPTIONS": "--no-deprecation" }, "commands": [ "vite install --help # print help message", diff --git a/packages/global/install.ps1 b/packages/global/install.ps1 index ef00afabea..0bdb084cd9 100644 --- a/packages/global/install.ps1 +++ b/packages/global/install.ps1 @@ -6,15 +6,18 @@ # # Environment variables: # VITE_PLUS_VERSION - Version to install (default: latest) -# VITE_PLUS_INSTALL_DIR - Installation directory (default: $env:USERPROFILE\.vite-plus) +# VITE_PLUS_HOME - Installation directory (default: $env:USERPROFILE\.vite-plus) # NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) +# VITE_PLUS_LOCAL_TGZ - Path to local vite-plus-cli.tgz (for development/testing) $ErrorActionPreference = "Stop" $ViteVersion = if ($env:VITE_PLUS_VERSION) { $env:VITE_PLUS_VERSION } else { "latest" } -$InstallDir = if ($env:VITE_PLUS_INSTALL_DIR) { $env:VITE_PLUS_INSTALL_DIR } else { "$env:USERPROFILE\.vite-plus" } +$InstallDir = if ($env:VITE_PLUS_HOME) { $env:VITE_PLUS_HOME } else { "$env:USERPROFILE\.vite-plus" } # 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 +$LocalTgz = $env:VITE_PLUS_LOCAL_TGZ function Write-Info { param([string]$Message) @@ -149,7 +152,7 @@ function Download-AndExtract { New-Item -ItemType Directory -Force -Path $tempExtract | Out-Null # Extract using tar (available in Windows 10+) - tar -xzf $tempFile -C $tempExtract + & "$env:SystemRoot\System32\tar.exe" -xzf $tempFile -C $tempExtract # Copy the specified file/directory $sourcePath = Join-Path $tempExtract "package" $Filter @@ -167,8 +170,11 @@ function Cleanup-OldVersions { param([string]$InstallDir) $maxVersions = 5 + # Only cleanup semver format directories (0.1.0, 1.2.3-beta.1, etc.) + # This excludes 'current' symlink and non-semver directories like 'local-dev' + $semverPattern = '^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$' $versions = Get-ChildItem -Path $InstallDir -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -ne "current" } + Where-Object { $_.Name -match $semverPattern } if ($null -eq $versions -or $versions.Count -le $maxVersions) { return @@ -185,10 +191,72 @@ function Cleanup-OldVersions { } } +# Configure user PATH for ~/.vite-plus/bin +# Returns: "true" = added, "already" = already configured +function Configure-UserPath { + $binPath = "$InstallDir\bin" + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + + if ($userPath -like "*$binPath*") { + return "already" + } + + $newPath = "$binPath;$userPath" + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + $env:Path = "$binPath;$env:Path" + return "true" +} + +# Setup Node.js version manager (node/npm/npx shims) +# Returns: "true" = enabled, "false" = not enabled, "already" = already configured +function Setup-NodeManager { + param([string]$BinDir) + + $binPath = "$InstallDir\bin" + + # Check if Vite+ is already managing Node.js (bin\node.exe exists) + if (Test-Path "$binPath\node.exe") { + # Already managing Node.js, just refresh shims + & "$BinDir\vp.exe" env setup --refresh | Out-Null + return "already" + } + + # Auto-enable on CI environment + if ($env:CI) { + & "$BinDir\vp.exe" env setup --refresh | Out-Null + return "true" + } + + # Check if node is available on the system + $nodeAvailable = $null -ne (Get-Command node -ErrorAction SilentlyContinue) + + # Auto-enable if no node available on system + if (-not $nodeAvailable) { + & "$BinDir\vp.exe" env setup --refresh | Out-Null + return "true" + } + + # Prompt user in interactive mode + $isInteractive = [Environment]::UserInteractive + if ($isInteractive) { + Write-Host "" + Write-Host "Would you want Vite+ to manage Node.js versions?" + $response = Read-Host "Press Enter to accept (Y/n)" + + if ($response -eq '' -or $response -eq 'y' -or $response -eq 'Y') { + & "$BinDir\vp.exe" env setup --refresh | Out-Null + return "true" + } + } + + return "false" +} + function Main { Write-Host "" - Write-Host "Setting up VITE+(⚡︎)..." - Write-Host "" + Write-Host "Setting up " -NoNewline + Write-Host "VITE+(⚡︎)" -ForegroundColor Cyan -NoNewline + Write-Host "..." # Suppress progress bars for cleaner output $ProgressPreference = 'SilentlyContinue' @@ -196,8 +264,20 @@ function Main { $arch = Get-Architecture $platform = "win32-$arch" - # Fetch package metadata and resolve version - $ViteVersion = Get-VersionFromMetadata + # Local development mode: use local tgz + if ($LocalTgz) { + # Validate local tgz + if (-not (Test-Path $LocalTgz)) { + Write-Error-Exit "Local tarball not found: $LocalTgz" + } + # Use version as-is (default to "local-dev") + if ($ViteVersion -eq "latest" -or $ViteVersion -eq "test") { + $ViteVersion = "local-dev" + } + } else { + # Fetch package metadata and resolve version from npm + $ViteVersion = Get-VersionFromMetadata + } # Set up version-specific directories $VersionDir = "$InstallDir\$ViteVersion" @@ -205,9 +285,6 @@ function Main { $DistDir = "$VersionDir\dist" $CurrentLink = "$InstallDir\current" - # Get package suffix from optionalDependencies (dynamic lookup) - $packageSuffix = Get-PackageSuffix -Platform $platform - $packageName = "@voidzero-dev/vite-plus-cli-$packageSuffix" $binaryName = "vp.exe" # Create directories @@ -215,27 +292,28 @@ function Main { New-Item -ItemType Directory -Force -Path $DistDir | Out-Null # Download and extract native binary and .node files from platform package - $platformUrl = "$NpmRegistry/$packageName/-/vite-plus-cli-$packageSuffix-$ViteVersion.tgz" + # Also copy JS bundle and assets + $itemsToCopy = @("dist", "templates", "rules", "AGENTS.md", "package.json") - $platformTempFile = New-TemporaryFile - try { - Invoke-WebRequest -Uri $platformUrl -OutFile $platformTempFile + if ($LocalTgz) { + # Use local tarball for development/testing + Write-Info "Using local tarball: $LocalTgz" # Create temp extraction directory - $platformTempExtract = Join-Path $env:TEMP "vite-platform-$(Get-Random)" - New-Item -ItemType Directory -Force -Path $platformTempExtract | Out-Null + $tempExtract = Join-Path $env:TEMP "vite-local-$(Get-Random)" + New-Item -ItemType Directory -Force -Path $tempExtract | Out-Null - # Extract the package - tar -xzf $platformTempFile -C $platformTempExtract + # Extract the tgz + & "$env:SystemRoot\System32\tar.exe" -xzf $LocalTgz -C $tempExtract - # Copy binary to BinDir - $binarySource = Join-Path $platformTempExtract "package" $binaryName + # Copy binary + $binarySource = Join-Path $tempExtract "package" "bin" $binaryName if (Test-Path $binarySource) { Copy-Item -Path $binarySource -Destination $BinDir -Force } - # Copy .node files to DistDir (delete existing first to avoid system cache issues) - $nodeFilesPath = Join-Path $platformTempExtract "package" + # Copy .node files if present + $nodeFilesPath = Join-Path $tempExtract "package" "dist" Get-ChildItem -Path $nodeFilesPath -Filter "*.node" -ErrorAction SilentlyContinue | ForEach-Object { $destFile = Join-Path $DistDir $_.Name if (Test-Path $destFile) { @@ -244,37 +322,80 @@ function Main { Copy-Item -Path $_.FullName -Destination $DistDir -Force } - Remove-Item -Recurse -Force $platformTempExtract - } finally { - Remove-Item $platformTempFile -ErrorAction SilentlyContinue - } + # Copy JS assets + foreach ($item in $itemsToCopy) { + $itemSource = Join-Path $tempExtract "package" $item + if (Test-Path $itemSource) { + Copy-Item -Path $itemSource -Destination $VersionDir -Recurse -Force + } + } - # Download and extract JS bundle - $mainUrl = "$NpmRegistry/vite-plus-cli/-/vite-plus-cli-$ViteVersion.tgz" + Remove-Item -Recurse -Force $tempExtract + } else { + # Download from npm registry + # Get package suffix from optionalDependencies (dynamic lookup) + $packageSuffix = Get-PackageSuffix -Platform $platform + $packageName = "@voidzero-dev/vite-plus-cli-$packageSuffix" + $platformUrl = "$NpmRegistry/$packageName/-/vite-plus-cli-$packageSuffix-$ViteVersion.tgz" - $mainTempFile = New-TemporaryFile - try { - Invoke-WebRequest -Uri $mainUrl -OutFile $mainTempFile + $platformTempFile = New-TemporaryFile + try { + Invoke-WebRequest -Uri $platformUrl -OutFile $platformTempFile - # Create temp extraction directory - $mainTempExtract = Join-Path $env:TEMP "vite-main-$(Get-Random)" - New-Item -ItemType Directory -Force -Path $mainTempExtract | Out-Null + # Create temp extraction directory + $platformTempExtract = Join-Path $env:TEMP "vite-platform-$(Get-Random)" + New-Item -ItemType Directory -Force -Path $platformTempExtract | Out-Null - # Extract the package - tar -xzf $mainTempFile -C $mainTempExtract + # Extract the package + & "$env:SystemRoot\System32\tar.exe" -xzf $platformTempFile -C $platformTempExtract - # Copy directories and files to VersionDir - $itemsToCopy = @("dist", "templates", "rules", "AGENTS.md", "package.json") - foreach ($item in $itemsToCopy) { - $itemSource = Join-Path $mainTempExtract "package" $item - if (Test-Path $itemSource) { - Copy-Item -Path $itemSource -Destination $VersionDir -Recurse -Force + # Copy binary to BinDir + $binarySource = Join-Path $platformTempExtract "package" $binaryName + if (Test-Path $binarySource) { + Copy-Item -Path $binarySource -Destination $BinDir -Force } + + # Copy .node files to DistDir (delete existing first to avoid system cache issues) + $nodeFilesPath = Join-Path $platformTempExtract "package" + Get-ChildItem -Path $nodeFilesPath -Filter "*.node" -ErrorAction SilentlyContinue | ForEach-Object { + $destFile = Join-Path $DistDir $_.Name + if (Test-Path $destFile) { + Remove-Item -Path $destFile -Force + } + Copy-Item -Path $_.FullName -Destination $DistDir -Force + } + + Remove-Item -Recurse -Force $platformTempExtract + } finally { + Remove-Item $platformTempFile -ErrorAction SilentlyContinue } - Remove-Item -Recurse -Force $mainTempExtract - } finally { - Remove-Item $mainTempFile -ErrorAction SilentlyContinue + # Download and extract JS bundle from npm + $mainUrl = "$NpmRegistry/vite-plus-cli/-/vite-plus-cli-$ViteVersion.tgz" + + $mainTempFile = New-TemporaryFile + try { + Invoke-WebRequest -Uri $mainUrl -OutFile $mainTempFile + + # Create temp extraction directory + $mainTempExtract = Join-Path $env:TEMP "vite-main-$(Get-Random)" + New-Item -ItemType Directory -Force -Path $mainTempExtract | Out-Null + + # Extract the package + & "$env:SystemRoot\System32\tar.exe" -xzf $mainTempFile -C $mainTempExtract + + # Copy directories and files to VersionDir + foreach ($item in $itemsToCopy) { + $itemSource = Join-Path $mainTempExtract "package" $item + if (Test-Path $itemSource) { + Copy-Item -Path $itemSource -Destination $VersionDir -Recurse -Force + } + } + + Remove-Item -Recurse -Force $mainTempExtract + } finally { + Remove-Item $mainTempFile -ErrorAction SilentlyContinue + } } # Remove devDependencies and optionalDependencies from package.json @@ -303,43 +424,75 @@ function Main { # Create new junction pointing to the version directory cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null + # Create bin directory and vp.cmd wrapper (always done) + # Set VITE_PLUS_HOME so the vp binary knows its home directory + New-Item -ItemType Directory -Force -Path "$InstallDir\bin" | Out-Null + $wrapperContent = @" +@echo off +set VITE_PLUS_HOME=%~dp0.. +"%VITE_PLUS_HOME%\current\bin\vp.exe" %* +exit /b %ERRORLEVEL% +"@ + Set-Content -Path "$InstallDir\bin\vp.cmd" -Value $wrapperContent -NoNewline + + # Create shell script wrapper for Git Bash (vp without extension) + # Note: We call vp.exe directly (not via symlink) because Windows symlinks + # require admin privileges and Git Bash symlink support is unreliable + $shContent = @" +#!/bin/sh +VITE_PLUS_HOME="`$(dirname "`$(dirname "`$(readlink -f "`$0" 2>/dev/null || echo "`$0")")")" +export VITE_PLUS_HOME +exec "`$VITE_PLUS_HOME/current/bin/vp.exe" "`$@" +"@ + Set-Content -Path "$InstallDir\bin\vp" -Value $shContent -NoNewline + # Cleanup old versions Cleanup-OldVersions -InstallDir $InstallDir - # Update PATH - $pathToAdd = "$InstallDir\current\bin" - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + # Configure user PATH (always attempted) + $pathResult = Configure-UserPath - # Check if we need to update PATH - $needsPathUpdate = $true - if ($userPath -like "*$pathToAdd*") { - $needsPathUpdate = $false - } + # Setup Node.js version manager (shims) - separate component + $nodeManagerResult = Setup-NodeManager -BinDir $BinDir - if ($needsPathUpdate) { - $newPath = "$pathToAdd;$userPath" - [Environment]::SetEnvironmentVariable("Path", $newPath, "User") - $env:Path = "$pathToAdd;$env:Path" - } + # Use ~ shorthand if install dir is under USERPROFILE, otherwise show full path + $displayDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~' + + # ANSI color codes for consistent output + $e = [char]27 + $GREEN = "$e[32m" + $BRIGHT_BLUE = "$e[94m" + $BOLD = "$e[1m" + $DIM = "$e[2m" + $BOLD_BRIGHT_BLUE = "$e[1;94m" + $NC = "$e[0m" # Print success message Write-Host "" - Write-Host "✔ " -ForegroundColor Green -NoNewline - Write-Host "VITE+(⚡︎) successfully installed!" + Write-Host "${GREEN}✔${NC} ${BOLD_BRIGHT_BLUE}VITE+(⚡︎)${NC} successfully installed!" Write-Host "" - Write-Host " Version: $ViteVersion" + Write-Host " The Unified Toolchain for the Web." Write-Host "" - # Use ~ shorthand if install dir is under USERPROFILE, otherwise show full path - $displayDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~' - Write-Host " Location: $displayDir\current\bin" + Write-Host " ${BOLD}Get started:${NC}" + Write-Host " ${BRIGHT_BLUE}vp new${NC} Create a new project" + Write-Host " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + Write-Host " ${BRIGHT_BLUE}vp install${NC} Install dependencies" + Write-Host " ${BRIGHT_BLUE}vp dev${NC} Start dev server" + + # Show Node.js manager status + if ($nodeManagerResult -eq "true" -or $nodeManagerResult -eq "already") { + Write-Host "" + Write-Host " Node.js is now managed by Vite+ (via ${BRIGHT_BLUE}vp env${NC})." + Write-Host " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup." + } + Write-Host "" - Write-Host " Next: Run vp --help to get started" + Write-Host " Run ${BRIGHT_BLUE}vp help${NC} for more information." # Show note if PATH was updated - if ($needsPathUpdate) { + if ($pathResult -eq "true") { Write-Host "" - Write-Host " Note: Restart your terminal or run:" - Write-Host " `$env:Path = `"$pathToAdd;`$env:Path`"" + Write-Host " Note: Restart your terminal and IDE for changes to take effect." } Write-Host "" diff --git a/packages/global/install.sh b/packages/global/install.sh index bb5494f83e..18de04b92b 100644 --- a/packages/global/install.sh +++ b/packages/global/install.sh @@ -7,22 +7,35 @@ # # Environment variables: # VITE_PLUS_VERSION - Version to install (default: latest) -# VITE_PLUS_INSTALL_DIR - Installation directory (default: ~/.vite-plus) +# VITE_PLUS_HOME - Installation directory (default: ~/.vite-plus) # NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) +# VITE_PLUS_LOCAL_TGZ - Path to local vite-plus-cli.tgz (for development/testing) set -e VITE_PLUS_VERSION="${VITE_PLUS_VERSION:-latest}" -INSTALL_DIR="${VITE_PLUS_INSTALL_DIR:-$HOME/.vite-plus}" +INSTALL_DIR="${VITE_PLUS_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="\$HOME${INSTALL_DIR#"$HOME"}" +else + INSTALL_DIR_REF="$INSTALL_DIR" +fi # npm registry URL (strip trailing slash if present) NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" NPM_REGISTRY="${NPM_REGISTRY%/}" +# Local tarball for development/testing +LOCAL_TGZ="${VITE_PLUS_LOCAL_TGZ:-}" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' +BRIGHT_BLUE='\033[0;94m' +BOLD='\033[1m' +DIM='\033[2m' +BOLD_BRIGHT_BLUE='\033[1;94m' NC='\033[0m' # No Color info() { @@ -296,36 +309,171 @@ download_and_extract() { rm -f "$temp_file" } -# Add to shell profile +# Add bin to shell profile by sourcing the env file # Returns: 0 = path added, 1 = file not found, 2 = path already exists -add_to_path() { +add_bin_to_path() { local shell_config="$1" - local path_to_add="$INSTALL_DIR/current/bin" - local path_line="export PATH=\"$path_to_add:\$PATH\"" + local env_file="$INSTALL_DIR_REF/env" + # Escape both absolute and $HOME-relative forms for grep (backward compat) + local abs_pattern ref_pattern + abs_pattern=$(printf '%s' "$INSTALL_DIR" | sed 's/[.[\*^$()+?{|]/\\&/g') + ref_pattern=$(printf '%s' "$INSTALL_DIR_REF" | sed 's/[.[\*^$()+?{|]/\\&/g') if [ -f "$shell_config" ]; then - # Check if already has the current/bin path - if grep -q "$path_to_add" "$shell_config" 2>/dev/null; then + if grep -q "${abs_pattern}/env" "$shell_config" 2>/dev/null || \ + grep -q "${ref_pattern}/env" "$shell_config" 2>/dev/null; then return 2 fi echo "" >> "$shell_config" - echo "# Added by vite-plus installer" >> "$shell_config" - echo "$path_line" >> "$shell_config" + echo "# Vite+ bin (https://viteplus.dev)" >> "$shell_config" + echo ". \"$env_file\"" >> "$shell_config" return 0 fi return 1 } +# Configure shell PATH for ~/.vite-plus/bin +# Sets PATH_CONFIGURED and SHELL_CONFIG_UPDATED globals +configure_shell_path() { + local bin_path="$INSTALL_DIR/bin" + PATH_CONFIGURED="false" + SHELL_CONFIG_UPDATED="" + + local result=1 # Default to failure - must explicitly set success + case "$SHELL" in + */zsh) + # Add to both .zshenv (for all shells including IDE) and .zshrc (to ensure PATH is at front) + # Create .zshenv if missing — it's the canonical place for PATH in zsh + # and is sourced by all session types (interactive, non-interactive, IDE) + [ -f "$HOME/.zshenv" ] || touch "$HOME/.zshenv" + local zshenv_result=0 zshrc_result=0 + add_bin_to_path "$HOME/.zshenv" || zshenv_result=$? + add_bin_to_path "$HOME/.zshrc" || zshrc_result=$? + # Prioritize .zshrc for user notification (easier to source) + if [ $zshrc_result -eq 0 ]; then + result=0 + SHELL_CONFIG_UPDATED=".zshrc" + elif [ $zshenv_result -eq 0 ]; then + result=0 + SHELL_CONFIG_UPDATED=".zshenv" + elif [ $zshenv_result -eq 2 ] || [ $zshrc_result -eq 2 ]; then + result=2 # already configured in at least one file + fi + ;; + */bash) + # Add to .bash_profile, .bashrc, AND .profile for maximum compatibility + # - .bash_profile: login shells (macOS default) + # - .bashrc: interactive non-login shells (Linux default) + # - .profile: fallback for systems without .bash_profile (Ubuntu minimal, etc.) + local bash_profile_result=0 bashrc_result=0 profile_result=0 + add_bin_to_path "$HOME/.bash_profile" || bash_profile_result=$? + add_bin_to_path "$HOME/.bashrc" || bashrc_result=$? + add_bin_to_path "$HOME/.profile" || profile_result=$? + # Prioritize .bashrc for user notification (most commonly edited) + if [ $bashrc_result -eq 0 ]; then + result=0 + SHELL_CONFIG_UPDATED=".bashrc" + elif [ $bash_profile_result -eq 0 ]; then + result=0 + SHELL_CONFIG_UPDATED=".bash_profile" + elif [ $profile_result -eq 0 ]; then + result=0 + SHELL_CONFIG_UPDATED=".profile" + elif [ $bash_profile_result -eq 2 ] || [ $bashrc_result -eq 2 ] || [ $profile_result -eq 2 ]; then + result=2 # already configured in at least one file + fi + ;; + */fish) + local fish_config="$HOME/.config/fish/config.fish" + # Escape both absolute and $HOME-relative forms for grep (backward compat) + local fish_abs_pattern fish_ref_pattern + fish_abs_pattern=$(printf '%s' "$INSTALL_DIR" | sed 's/[.[\*^$()+?{|]/\\&/g') + fish_ref_pattern=$(printf '%s' "$INSTALL_DIR_REF" | sed 's/[.[\*^$()+?{|]/\\&/g') + if [ -f "$fish_config" ]; then + if grep -q "${fish_abs_pattern}/env" "$fish_config" 2>/dev/null || \ + grep -q "${fish_ref_pattern}/env" "$fish_config" 2>/dev/null; then + result=2 + else + echo "" >> "$fish_config" + echo "# Vite+ bin (https://viteplus.dev)" >> "$fish_config" + echo "source \"$INSTALL_DIR_REF/env.fish\"" >> "$fish_config" + result=0 + SHELL_CONFIG_UPDATED="config.fish" + fi + fi + ;; + esac + + if [ $result -eq 0 ]; then + PATH_CONFIGURED="true" + elif [ $result -eq 2 ]; then + PATH_CONFIGURED="already" + fi + # If result is still 1, PATH_CONFIGURED remains "false" (set at function start) +} + +# Setup Node.js version manager (node/npm/npx shims) +# Sets NODE_MANAGER_ENABLED global +# Arguments: bin_dir - path to the version's bin directory containing vp +setup_node_manager() { + local bin_dir="$1" + local bin_path="$INSTALL_DIR/bin" + NODE_MANAGER_ENABLED="false" + + # Check if Vite+ is already managing Node.js (bin/node exists) + if [ -e "$bin_path/node" ]; then + # Already managing Node.js, just refresh shims + "$bin_dir/vp" env setup --refresh > /dev/null + NODE_MANAGER_ENABLED="already" + return 0 + fi + + # Auto-enable on CI environment + if [ -n "$CI" ]; then + "$bin_dir/vp" env setup --refresh > /dev/null + NODE_MANAGER_ENABLED="true" + return 0 + fi + + # Check if node is available on the system + local node_available="false" + if command -v node &> /dev/null; then + node_available="true" + fi + + # Auto-enable if no node available on system + if [ "$node_available" = "false" ]; then + "$bin_dir/vp" env setup --refresh > /dev/null + NODE_MANAGER_ENABLED="true" + return 0 + fi + + # Prompt user in interactive mode + if [ -e /dev/tty ] && [ -t 1 ]; then + echo "" + echo "Would you want Vite+ to manage Node.js versions?" + echo -n "Press Enter to accept (Y/n): " + read -r response < /dev/tty + + if [ -z "$response" ] || [ "$response" = "y" ] || [ "$response" = "Y" ]; then + "$bin_dir/vp" env setup --refresh > /dev/null + NODE_MANAGER_ENABLED="true" + fi + fi +} + # Cleanup old versions, keeping only the most recent ones cleanup_old_versions() { local max_versions=5 local versions=() - # List version directories (exclude 'current' symlink) + # List version directories (semver format like 0.1.0, 1.2.3-beta.1, 0.0.0-f48af939.20260205-0533) + # This excludes 'current' symlink and non-semver directories like 'local-dev' + local semver_regex='^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9._-]+)?$' for dir in "$INSTALL_DIR"/*/; do local name name=$(basename "$dir") - if [ "$name" != "current" ] && [ -d "$dir" ]; then + if [ -d "$dir" ] && [[ "$name" =~ $semver_regex ]]; then versions+=("$dir") fi done @@ -360,84 +508,30 @@ cleanup_old_versions() { done } -# Setup PATH - try ~/.local/bin symlink first, fallback to shell profile -# Returns via global variables: -# SYMLINK_CREATED - "true" if symlink was created, "false" otherwise -# SHELL_CONFIG_UPDATED - shell config file name if updated, empty otherwise -# PATH_ALREADY_CONFIGURED - "true" if PATH was already set up -setup_path() { - local local_bin="$HOME/.local/bin" - local path_to_add="$INSTALL_DIR/current/bin" - - SYMLINK_CREATED="false" - SHELL_CONFIG_UPDATED="" - PATH_ALREADY_CONFIGURED="false" - - # Check if ~/.local/bin is in PATH - if echo "$PATH" | tr ':' '\n' | grep -qx "$local_bin"; then - # Create ~/.local/bin if it doesn't exist - mkdir -p "$local_bin" - # Create symlink (force overwrite if exists) - ln -sf "$INSTALL_DIR/current/bin/vp" "$local_bin/vp" - SYMLINK_CREATED="true" - return 0 - fi - - # Fall back to adding to shell profile - local path_result=1 # 0=added, 1=failed, 2=already exists - - case "$SHELL" in - */zsh) - add_to_path "$HOME/.zshrc" - path_result=$? - [ $path_result -ne 1 ] && SHELL_CONFIG_UPDATED=".zshrc" - ;; - */bash) - add_to_path "$HOME/.bashrc" - path_result=$? - if [ $path_result -ne 1 ]; then - SHELL_CONFIG_UPDATED=".bashrc" - else - add_to_path "$HOME/.bash_profile" - path_result=$? - [ $path_result -ne 1 ] && SHELL_CONFIG_UPDATED=".bash_profile" - fi - ;; - */fish) - local fish_config="$HOME/.config/fish/config.fish" - if [ -f "$fish_config" ]; then - if grep -q "$path_to_add" "$fish_config" 2>/dev/null; then - path_result=2 - SHELL_CONFIG_UPDATED="config.fish" - else - echo "" >> "$fish_config" - echo "# Added by vite-plus installer" >> "$fish_config" - echo "set -gx PATH $path_to_add \$PATH" >> "$fish_config" - path_result=0 - SHELL_CONFIG_UPDATED="config.fish" - fi - fi - ;; - esac - - if [ $path_result -eq 2 ]; then - PATH_ALREADY_CONFIGURED="true" - fi -} - main() { echo "" - echo "Setting up VITE+(⚡︎)..." - echo "" + echo -e "Setting up VITE+(⚡︎)..." check_requirements local platform platform=$(detect_platform) - # Fetch package metadata and resolve version - get_version_from_metadata - VITE_PLUS_VERSION="$RESOLVED_VERSION" + # Local development mode: use local tgz + if [ -n "$LOCAL_TGZ" ]; then + # Validate local tgz + if [ ! -f "$LOCAL_TGZ" ]; then + error "Local tarball not found: $LOCAL_TGZ" + fi + # Use version as-is (default to "local-dev") + if [ "$VITE_PLUS_VERSION" = "latest" ] || [ "$VITE_PLUS_VERSION" = "test" ]; then + VITE_PLUS_VERSION="local-dev" + fi + else + # Fetch package metadata and resolve version from npm + get_version_from_metadata + VITE_PLUS_VERSION="$RESOLVED_VERSION" + fi # Set up version-specific directories VERSION_DIR="$INSTALL_DIR/$VITE_PLUS_VERSION" @@ -445,10 +539,6 @@ main() { DIST_DIR="$VERSION_DIR/dist" CURRENT_LINK="$INSTALL_DIR/current" - # Get package suffix from optionalDependencies (dynamic lookup) - get_package_suffix "$platform" - - local package_name="@voidzero-dev/vite-plus-cli-${PACKAGE_SUFFIX}" local binary_name="vp" if [[ "$platform" == win32* ]]; then binary_name="vp.exe" @@ -458,40 +548,76 @@ main() { mkdir -p "$BIN_DIR" "$DIST_DIR" # Download and extract native binary and .node files from platform package - local platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PACKAGE_SUFFIX}-${VITE_PLUS_VERSION}.tgz" + # Also copy JS bundle and assets + local items_to_copy=("dist" "templates" "rules" "AGENTS.md" "package.json") - # Create temp directory for extraction - local platform_temp_dir - platform_temp_dir=$(mktemp -d) - download_and_extract "$platform_url" "$platform_temp_dir" 1 + if [ -n "$LOCAL_TGZ" ]; then + # Use local tarball for development/testing + info "Using local tarball: $LOCAL_TGZ" - # Copy binary to BIN_DIR - cp "$platform_temp_dir/$binary_name" "$BIN_DIR/" - chmod +x "$BIN_DIR/$binary_name" + # Extract everything from tgz + local temp_dir + temp_dir=$(mktemp -d) + tar xzf "$LOCAL_TGZ" -C "$temp_dir" --strip-components=1 - # Copy .node files to DIST_DIR (delete existing first to avoid system cache issues) - for node_file in "$platform_temp_dir"/*.node; do - rm -f "$DIST_DIR/$(basename "$node_file")" - cp "$node_file" "$DIST_DIR/" - done - rm -rf "$platform_temp_dir" + # Copy binary + cp "$temp_dir/bin/$binary_name" "$BIN_DIR/" + chmod +x "$BIN_DIR/$binary_name" - # Download and extract JS bundle from main package - local main_url="${NPM_REGISTRY}/vite-plus-cli/-/vite-plus-cli-${VITE_PLUS_VERSION}.tgz" + # Copy .node files if present + for node_file in "$temp_dir"/dist/*.node; do + if [ -f "$node_file" ]; then + rm -f "$DIST_DIR/$(basename "$node_file")" + cp "$node_file" "$DIST_DIR/" + fi + done - # Create temp directory for extraction - local temp_dir - temp_dir=$(mktemp -d) - download_and_extract "$main_url" "$temp_dir" 1 + # Copy JS assets + for item in "${items_to_copy[@]}"; do + if [ -e "$temp_dir/$item" ]; then + cp -r "$temp_dir/$item" "$VERSION_DIR/" + fi + done - # Copy directories and files to VERSION_DIR - local items_to_copy=("dist" "templates" "rules" "AGENTS.md" "package.json") - for item in "${items_to_copy[@]}"; do - if [ -e "$temp_dir/$item" ]; then - cp -r "$temp_dir/$item" "$VERSION_DIR/" - fi - done - rm -rf "$temp_dir" + rm -rf "$temp_dir" + else + # Download from npm registry + # Get package suffix from optionalDependencies (dynamic lookup) + get_package_suffix "$platform" + local package_name="@voidzero-dev/vite-plus-cli-${PACKAGE_SUFFIX}" + local platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PACKAGE_SUFFIX}-${VITE_PLUS_VERSION}.tgz" + + # Create temp directory for extraction + local platform_temp_dir + platform_temp_dir=$(mktemp -d) + download_and_extract "$platform_url" "$platform_temp_dir" 1 + + # Copy binary to BIN_DIR + cp "$platform_temp_dir/$binary_name" "$BIN_DIR/" + chmod +x "$BIN_DIR/$binary_name" + + # Copy .node files to DIST_DIR (delete existing first to avoid system cache issues) + for node_file in "$platform_temp_dir"/*.node; do + rm -f "$DIST_DIR/$(basename "$node_file")" + cp "$node_file" "$DIST_DIR/" + done + rm -rf "$platform_temp_dir" + + # Download and extract JS bundle and assets from npm + local main_url="${NPM_REGISTRY}/vite-plus-cli/-/vite-plus-cli-${VITE_PLUS_VERSION}.tgz" + + # Create temp directory for extraction + local temp_dir + temp_dir=$(mktemp -d) + download_and_extract "$main_url" "$temp_dir" 1 + + for item in "${items_to_copy[@]}"; do + if [ -e "$temp_dir/$item" ]; then + cp -r "$temp_dir/$item" "$VERSION_DIR/" + fi + done + rm -rf "$temp_dir" + fi # Remove devDependencies and optionalDependencies from package.json # (temporary solution until deps are fully bundled) @@ -520,38 +646,68 @@ main() { # Create/update current symlink (use relative path for portability) ln -sfn "$VITE_PLUS_VERSION" "$CURRENT_LINK" + # Create bin directory and vp symlink (always done) + mkdir -p "$INSTALL_DIR/bin" + ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" + # Cleanup old versions cleanup_old_versions - # Setup PATH (sets SYMLINK_CREATED, SHELL_CONFIG_UPDATED, PATH_ALREADY_CONFIGURED) - setup_path + # Create env files with PATH guard (prevents duplicate PATH entries) + "$INSTALL_DIR/bin/vp" env setup --env-only > /dev/null - # Determine display location based on how PATH was configured - local display_location - if [ "$SYMLINK_CREATED" = "true" ]; then - display_location="~/.local/bin/vp" - else - # Use ~ shorthand if install dir is under HOME, otherwise show full path - local display_dir="${INSTALL_DIR/#$HOME/~}" - display_location="${display_dir}/current/bin" - fi + # Configure shell PATH (always attempted) + configure_shell_path + + # Setup Node.js version manager (shims) - separate component + setup_node_manager "$BIN_DIR" + + # Use ~ shorthand if install dir is under HOME, otherwise show full path + local display_dir="${INSTALL_DIR/#$HOME/~}" + local display_location="${display_dir}/bin" # Print success message echo "" - echo -e "${GREEN}✔${NC} VITE+(⚡︎) successfully installed!" + echo -e "${GREEN}✔${NC} ${BOLD_BRIGHT_BLUE}VITE+(⚡︎)${NC} successfully installed!" echo "" - echo " Version: ${VITE_PLUS_VERSION}" + echo " The Unified Toolchain for the Web." echo "" - echo " Location: ${display_location}" + echo -e " ${BOLD}Get started:${NC}" + echo -e " ${BRIGHT_BLUE}vp new${NC} Create a new project" + echo -e " ${BRIGHT_BLUE}vp env${NC} Manage Node.js versions" + echo -e " ${BRIGHT_BLUE}vp install${NC} Install dependencies" + echo -e " ${BRIGHT_BLUE}vp dev${NC} Start dev server" + + if [ "$NODE_MANAGER_ENABLED" = "true" ] || [ "$NODE_MANAGER_ENABLED" = "already" ]; then + echo "" + echo -e " Node.js is now managed by Vite+ (via ${BRIGHT_BLUE}vp env${NC})." + echo -e " Run ${BRIGHT_BLUE}vp env doctor${NC} to verify your setup." + fi + echo "" - echo " Next: Run vp --help to get started" + echo -e " Run ${BRIGHT_BLUE}vp help${NC} for more information." - # Show note if shell config was updated (not symlink, not already configured) - if [ "$SYMLINK_CREATED" = "false" ] && [ -n "$SHELL_CONFIG_UPDATED" ] && [ "$PATH_ALREADY_CONFIGURED" = "false" ]; then + # Show restart note if PATH was added to shell config + if [ "$PATH_CONFIGURED" = "true" ] && [ -n "$SHELL_CONFIG_UPDATED" ]; then echo "" echo " Note: Run \`source ~/$SHELL_CONFIG_UPDATED\` or restart your terminal." fi + # Show warning if PATH could not be automatically configured + if [ "$PATH_CONFIGURED" = "false" ]; then + echo "" + echo -e " ${YELLOW}note${NC}: Could not automatically add vp to your PATH." + echo "" + echo " To use vp, add this line to your shell config file:" + echo "" + echo " . \"$INSTALL_DIR_REF/env\"" + echo "" + echo " Common config files:" + echo " - Bash: ~/.bashrc or ~/.bash_profile" + echo " - Zsh: ~/.zshrc" + echo " - Fish: source \"$INSTALL_DIR_REF/env.fish\" in ~/.config/fish/config.fish" + fi + echo "" } diff --git a/packages/global/snap-tests-todo/command-env-use-shell-wrapper/package.json b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/package.json new file mode 100644 index 0000000000..331106a033 --- /dev/null +++ b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-env-use-shell-wrapper", + "version": "1.0.0", + "private": true +} diff --git a/packages/global/snap-tests-todo/command-env-use-shell-wrapper/snap.txt b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/snap.txt new file mode 100644 index 0000000000..f6b765416a --- /dev/null +++ b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/snap.txt @@ -0,0 +1,49 @@ +> bash -c '. $VITE_PLUS_HOME/env && type vp-dev' # should show vp-dev is a shell function +vp-dev is a function +vp-dev () +{ + if [ "$1" = "env" ] && [ "$2" = "use" ]; then + case " $* " in + *" -h "* | *" --help "*) + command vp-dev "$@"; + return + ;; + esac; + __vp_out="$(command vp-dev "$@")" || return $?; + eval "$__vp_out"; + else + command vp-dev "$@"; + fi +} + +> bash -c '. $VITE_PLUS_HOME/env && vp-dev env use -h' # should show help via shell wrapper +Use a specific Node.js version for this shell session + +Usage: vp env use [OPTIONS] [VERSION] + +Arguments: + [VERSION] Version to use (e.g., "20", "20.18.0", "lts", "latest") If not provided, reads from .node-version or package.json + +Options: + --unset Remove session override (revert to file-based resolution) + --no-install Skip auto-installation if version not present + --silent-if-unchanged Suppress output if version is already active + -h, --help Print help + +> bash -c '. $VITE_PLUS_HOME/env && vp-dev env use --help' # should show help via shell wrapper +Use a specific Node.js version for this shell session + +Usage: vp env use [OPTIONS] [VERSION] + +Arguments: + [VERSION] Version to use (e.g., "20", "20.18.0", "lts", "latest") If not provided, reads from .node-version or package.json + +Options: + --unset Remove session override (revert to file-based resolution) + --no-install Skip auto-installation if version not present + --silent-if-unchanged Suppress output if version is already active + -h, --help Print help + +> bash -c '. $VITE_PLUS_HOME/env && vp-dev env use 20.18.0 --no-install && echo VITE_PLUS_NODE_VERSION=$VITE_PLUS_NODE_VERSION' # should set env var via eval +Using Node.js v (resolved from ) +VITE_PLUS_NODE_VERSION=20.18.0 diff --git a/packages/global/snap-tests-todo/command-env-use-shell-wrapper/steps.json b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/steps.json new file mode 100644 index 0000000000..aa1d0e8b57 --- /dev/null +++ b/packages/global/snap-tests-todo/command-env-use-shell-wrapper/steps.json @@ -0,0 +1,10 @@ +{ + "env": {}, + "ignoredPlatforms": ["win32"], + "commands": [ + "bash -c '. $VITE_PLUS_HOME/env && type vp-dev' # should show vp-dev is a shell function", + "bash -c '. $VITE_PLUS_HOME/env && vp-dev env use -h' # should show help via shell wrapper", + "bash -c '. $VITE_PLUS_HOME/env && vp-dev env use --help' # should show help via shell wrapper", + "bash -c '. $VITE_PLUS_HOME/env && vp-dev env use 20.18.0 --no-install && echo VITE_PLUS_NODE_VERSION=$VITE_PLUS_NODE_VERSION' # should set env var via eval" + ] +} diff --git a/packages/global/snap-tests/new-create-tsdown/snap.txt b/packages/global/snap-tests-todo/new-create-tsdown/snap.txt similarity index 100% rename from packages/global/snap-tests/new-create-tsdown/snap.txt rename to packages/global/snap-tests-todo/new-create-tsdown/snap.txt diff --git a/packages/global/snap-tests/new-create-tsdown/steps.json b/packages/global/snap-tests-todo/new-create-tsdown/steps.json similarity index 100% rename from packages/global/snap-tests/new-create-tsdown/steps.json rename to packages/global/snap-tests-todo/new-create-tsdown/steps.json diff --git a/packages/global/snap-tests/cli-helper-message/snap.txt b/packages/global/snap-tests/cli-helper-message/snap.txt index 4172325a04..0e29fdbc79 100644 --- a/packages/global/snap-tests/cli-helper-message/snap.txt +++ b/packages/global/snap-tests/cli-helper-message/snap.txt @@ -14,20 +14,22 @@ Vite+ Commands: cache Manage the task cache new Generate a new project run Run tasks + env Manage Node.js versions Package Manager Commands: - install Install all dependencies, or add packages if package names are provided - add Add packages to dependencies - remove Remove packages from dependencies - dedupe Deduplicate dependencies by removing older versions - dlx Execute a package binary without installing it as a dependency - info View package information from the registry - link Link packages for local development - outdated Check for outdated packages - pm Forward a command to the package manager - unlink Unlink packages - update Update packages to their latest versions - why Show why a package is installed + install, i Install all dependencies, or add packages if package names are provided + add Add packages to dependencies + remove, rm, un, uninstall Remove packages from dependencies + dedupe, ddp Deduplicate dependencies by removing older versions + dlx Execute a package binary without installing it as a dependency + info, view, show View package information from the registry + link, ln Link packages for local development + list, ls List installed packages + outdated Check for outdated packages + pm Forward a command to the package manager + unlink Unlink packages + update, up Update packages to their latest versions + why, explain Show why a package is installed Options: -V, --version Print version @@ -72,6 +74,7 @@ Options: -O, --save-optional Save to optionalDependencies (only when adding packages) --save-catalog Save the new dependency to the default catalog (only when adding packages) -g, --global Install globally (only when adding packages) + --node Node.js version to use for global installation (only with -g) -h, --help Print help > vp add -h # show add help message @@ -84,19 +87,34 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - -P, --save-prod Save to `dependencies` (default) - -D, --save-dev Save to `devDependencies` - --save-peer Save to `peerDependencies` and `devDependencies` - -O, --save-optional Save to `optionalDependencies` - -E, --save-exact Save exact version rather than semver range - --save-catalog-name Save the new dependency to the specified catalog name - --save-catalog Save the new dependency to the default catalog - --allow-build A list of package names allowed to run postinstall - --filter Filter packages in monorepo (can be used multiple times) - -w, --workspace-root Add to workspace root - --workspace Only add if package exists in workspace (pnpm-specific) - -g, --global Install globally - -h, --help Print help + -P, --save-prod + Save to `dependencies` (default) + -D, --save-dev + Save to `devDependencies` + --save-peer + Save to `peerDependencies` and `devDependencies` + -O, --save-optional + Save to `optionalDependencies` + -E, --save-exact + Save exact version rather than semver range + --save-catalog-name + Save the new dependency to the specified catalog name + --save-catalog + Save the new dependency to the default catalog + --allow-build + A list of package names allowed to run postinstall + --filter + Filter packages in monorepo (can be used multiple times) + -w, --workspace-root + Add to workspace root + --workspace + Only add if package exists in workspace (pnpm-specific) + -g, --global + Install globally + --node + Node.js version to use for global installation (only with -g) + -h, --help + Print help > vp remove -h # show remove help message Remove packages from dependencies @@ -115,6 +133,7 @@ Options: -w, --workspace-root Remove from workspace root -r, --recursive Remove recursively from all workspace packages -g, --global Remove global packages + --dry-run Preview what would be removed without actually removing (only with -g) -h, --help Print help > vp update -h # show update help message @@ -225,6 +244,20 @@ Options: --find-by Use a finder function defined in .pnpmfile.cjs -h, --help Print help +> vp info -h # show info help message +View package information from the registry + +Usage: vp info [OPTIONS] [FIELD] [-- ...] + +Arguments: + Package name with optional version + [FIELD] Specific field to view + [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager + +Options: + --json Output in JSON format + -h, --help Print help + > vp pm -h # show pm help message Forward a command to the package manager @@ -233,12 +266,73 @@ Usage: vp pm Commands: prune Remove unnecessary packages pack Create a tarball of the package - list List installed packages - view View package information from the registry + list List installed packages [aliases: ls] + view View package information from the registry [aliases: info, show] publish Publish package to registry - owner Manage package owners + owner Manage package owners [aliases: author] cache Manage package cache - config Manage package manager configuration + config Manage package manager configuration [aliases: c] Options: -h, --help Print help + +> vp env # show env help message +Manage Node.js versions + +Usage: vp env [OPTIONS] [COMMAND] + +Commands: + default Set or show the global default Node.js version + on Enable managed mode - shims always use vite-plus managed Node.js + off Enable system-first mode - shims prefer system Node.js, fallback to managed + setup Create or update shims in VITE_PLUS_HOME/bin + doctor Run diagnostics and show environment status + which Show path to the tool that would be executed + pin Pin a Node.js version in the current directory (creates .node-version) + unpin Remove the .node-version file from current directory (alias for `pin --unpin`) + list List locally installed Node.js versions [aliases: ls] + list-remote List available Node.js versions from the registry [aliases: ls-remote] + run Run a command with a specific Node.js version + uninstall Uninstall a Node.js version [aliases: uni] + install Install a Node.js version [aliases: i] + use Use a specific Node.js version for this shell session + +Options: + --current Show current environment information + --json Output in JSON format + --print Print shell snippet to set environment for current session + -h, --help Print help + +Examples: + vp env setup # Create shims for node, npm, npx + vp env setup --refresh # Force refresh shims + vp env doctor # Check environment configuration + vp env default # Set default Node.js version + vp env on # Use vite-plus managed Node.js + vp env off # Prefer system Node.js + vp env which node # Show which node binary will be used + vp env pin # Pin Node.js version in current directory + vp env pin lts # Pin to latest LTS version + vp env unpin # Remove pinned version + vp env list # List locally installed Node.js versions + vp env list-remote # List available remote Node.js versions + vp env list-remote --lts # List only LTS versions + vp env list-remote 20 # List Node.js 20.x versions + vp env install # Install Node.js + vp env install # Install version from .node-version / package.json + vp env install lts # Install latest LTS version + vp env uninstall # Uninstall Node.js + vp env use 20 # Use Node.js 20 for this shell session + vp env use lts # Use latest LTS for this shell session + vp env use # Use project version for this shell session + vp env use --unset # Remove session override + vp env run --node 20 node -v # Run 'node -v' with Node.js 20 + vp env run --node lts npm i # Run 'npm i' with latest LTS + vp env run node -v # Shim mode (version auto-resolved) + vp env run npm install # Shim mode (version auto-resolved) + +Global Packages: + vp install -g # Install a global package + vp uninstall -g # Uninstall a global package + vp update -g [package] # Update global package(s) + vp list -g [package] # List installed global packages diff --git a/packages/global/snap-tests/cli-helper-message/steps.json b/packages/global/snap-tests/cli-helper-message/steps.json index e23370f240..9479bec876 100644 --- a/packages/global/snap-tests/cli-helper-message/steps.json +++ b/packages/global/snap-tests/cli-helper-message/steps.json @@ -11,6 +11,8 @@ "vp dedupe -h # show dedupe help message", "vp outdated -h # show outdated help message", "vp why -h # show why help message", - "vp pm -h # show pm help message" + "vp info -h # show info help message", + "vp pm -h # show pm help message", + "vp env # show env help message" ] } diff --git a/packages/global/snap-tests/command-add-npm10/snap.txt b/packages/global/snap-tests/command-add-npm10/snap.txt index 0d5606229e..aa10bccb23 100644 --- a/packages/global/snap-tests/command-add-npm10/snap.txt +++ b/packages/global/snap-tests/command-add-npm10/snap.txt @@ -8,19 +8,34 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - -P, --save-prod Save to `dependencies` (default) - -D, --save-dev Save to `devDependencies` - --save-peer Save to `peerDependencies` and `devDependencies` - -O, --save-optional Save to `optionalDependencies` - -E, --save-exact Save exact version rather than semver range - --save-catalog-name Save the new dependency to the specified catalog name - --save-catalog Save the new dependency to the default catalog - --allow-build A list of package names allowed to run postinstall - --filter Filter packages in monorepo (can be used multiple times) - -w, --workspace-root Add to workspace root - --workspace Only add if package exists in workspace (pnpm-specific) - -g, --global Install globally - -h, --help Print help + -P, --save-prod + Save to `dependencies` (default) + -D, --save-dev + Save to `devDependencies` + --save-peer + Save to `peerDependencies` and `devDependencies` + -O, --save-optional + Save to `optionalDependencies` + -E, --save-exact + Save exact version rather than semver range + --save-catalog-name + Save the new dependency to the specified catalog name + --save-catalog + Save the new dependency to the default catalog + --allow-build + A list of package names allowed to run postinstall + --filter + Filter packages in monorepo (can be used multiple times) + -w, --workspace-root + Add to workspace root + --workspace + Only add if package exists in workspace (pnpm-specific) + -g, --global + Install globally + --node + Node.js version to use for global installation (only with -g) + -h, --help + Print help > vp add testnpm2 -D -- --no-audit && cat package.json # should add package as dev dependencies diff --git a/packages/global/snap-tests/command-add-npm11/snap.txt b/packages/global/snap-tests/command-add-npm11/snap.txt index c6ac9aa028..a1a1ad75b6 100644 --- a/packages/global/snap-tests/command-add-npm11/snap.txt +++ b/packages/global/snap-tests/command-add-npm11/snap.txt @@ -8,19 +8,34 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - -P, --save-prod Save to `dependencies` (default) - -D, --save-dev Save to `devDependencies` - --save-peer Save to `peerDependencies` and `devDependencies` - -O, --save-optional Save to `optionalDependencies` - -E, --save-exact Save exact version rather than semver range - --save-catalog-name Save the new dependency to the specified catalog name - --save-catalog Save the new dependency to the default catalog - --allow-build A list of package names allowed to run postinstall - --filter Filter packages in monorepo (can be used multiple times) - -w, --workspace-root Add to workspace root - --workspace Only add if package exists in workspace (pnpm-specific) - -g, --global Install globally - -h, --help Print help + -P, --save-prod + Save to `dependencies` (default) + -D, --save-dev + Save to `devDependencies` + --save-peer + Save to `peerDependencies` and `devDependencies` + -O, --save-optional + Save to `optionalDependencies` + -E, --save-exact + Save exact version rather than semver range + --save-catalog-name + Save the new dependency to the specified catalog name + --save-catalog + Save the new dependency to the default catalog + --allow-build + A list of package names allowed to run postinstall + --filter + Filter packages in monorepo (can be used multiple times) + -w, --workspace-root + Add to workspace root + --workspace + Only add if package exists in workspace (pnpm-specific) + -g, --global + Install globally + --node + Node.js version to use for global installation (only with -g) + -h, --help + Print help > vp add testnpm2 -D -- --no-audit && cat package.json # should add package as dev dependencies diff --git a/packages/global/snap-tests/command-add-pnpm10/snap.txt b/packages/global/snap-tests/command-add-pnpm10/snap.txt index 52d2b4d517..22e07a27aa 100644 --- a/packages/global/snap-tests/command-add-pnpm10/snap.txt +++ b/packages/global/snap-tests/command-add-pnpm10/snap.txt @@ -8,19 +8,34 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - -P, --save-prod Save to `dependencies` (default) - -D, --save-dev Save to `devDependencies` - --save-peer Save to `peerDependencies` and `devDependencies` - -O, --save-optional Save to `optionalDependencies` - -E, --save-exact Save exact version rather than semver range - --save-catalog-name Save the new dependency to the specified catalog name - --save-catalog Save the new dependency to the default catalog - --allow-build A list of package names allowed to run postinstall - --filter Filter packages in monorepo (can be used multiple times) - -w, --workspace-root Add to workspace root - --workspace Only add if package exists in workspace (pnpm-specific) - -g, --global Install globally - -h, --help Print help + -P, --save-prod + Save to `dependencies` (default) + -D, --save-dev + Save to `devDependencies` + --save-peer + Save to `peerDependencies` and `devDependencies` + -O, --save-optional + Save to `optionalDependencies` + -E, --save-exact + Save exact version rather than semver range + --save-catalog-name + Save the new dependency to the specified catalog name + --save-catalog + Save the new dependency to the default catalog + --allow-build + A list of package names allowed to run postinstall + --filter + Filter packages in monorepo (can be used multiple times) + -w, --workspace-root + Add to workspace root + --workspace + Only add if package exists in workspace (pnpm-specific) + -g, --global + Install globally + --node + Node.js version to use for global installation (only with -g) + -h, --help + Print help [2]> vp add # should error because no packages specified error: the following required arguments were not provided: diff --git a/packages/global/snap-tests/command-add-pnpm9/snap.txt b/packages/global/snap-tests/command-add-pnpm9/snap.txt index af331c41c5..a87ed3119e 100644 --- a/packages/global/snap-tests/command-add-pnpm9/snap.txt +++ b/packages/global/snap-tests/command-add-pnpm9/snap.txt @@ -8,19 +8,34 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - -P, --save-prod Save to `dependencies` (default) - -D, --save-dev Save to `devDependencies` - --save-peer Save to `peerDependencies` and `devDependencies` - -O, --save-optional Save to `optionalDependencies` - -E, --save-exact Save exact version rather than semver range - --save-catalog-name Save the new dependency to the specified catalog name - --save-catalog Save the new dependency to the default catalog - --allow-build A list of package names allowed to run postinstall - --filter Filter packages in monorepo (can be used multiple times) - -w, --workspace-root Add to workspace root - --workspace Only add if package exists in workspace (pnpm-specific) - -g, --global Install globally - -h, --help Print help + -P, --save-prod + Save to `dependencies` (default) + -D, --save-dev + Save to `devDependencies` + --save-peer + Save to `peerDependencies` and `devDependencies` + -O, --save-optional + Save to `optionalDependencies` + -E, --save-exact + Save exact version rather than semver range + --save-catalog-name + Save the new dependency to the specified catalog name + --save-catalog + Save the new dependency to the default catalog + --allow-build + A list of package names allowed to run postinstall + --filter + Filter packages in monorepo (can be used multiple times) + -w, --workspace-root + Add to workspace root + --workspace + Only add if package exists in workspace (pnpm-specific) + -g, --global + Install globally + --node + Node.js version to use for global installation (only with -g) + -h, --help + Print help > vp add testnpm2 -D && cat package.json # should add package as dev dependencies Packages: + diff --git a/packages/global/snap-tests/command-add-yarn4/snap.txt b/packages/global/snap-tests/command-add-yarn4/snap.txt index 4eed4f55c0..906d58b25d 100644 --- a/packages/global/snap-tests/command-add-yarn4/snap.txt +++ b/packages/global/snap-tests/command-add-yarn4/snap.txt @@ -8,19 +8,34 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - -P, --save-prod Save to `dependencies` (default) - -D, --save-dev Save to `devDependencies` - --save-peer Save to `peerDependencies` and `devDependencies` - -O, --save-optional Save to `optionalDependencies` - -E, --save-exact Save exact version rather than semver range - --save-catalog-name Save the new dependency to the specified catalog name - --save-catalog Save the new dependency to the default catalog - --allow-build A list of package names allowed to run postinstall - --filter Filter packages in monorepo (can be used multiple times) - -w, --workspace-root Add to workspace root - --workspace Only add if package exists in workspace (pnpm-specific) - -g, --global Install globally - -h, --help Print help + -P, --save-prod + Save to `dependencies` (default) + -D, --save-dev + Save to `devDependencies` + --save-peer + Save to `peerDependencies` and `devDependencies` + -O, --save-optional + Save to `optionalDependencies` + -E, --save-exact + Save exact version rather than semver range + --save-catalog-name + Save the new dependency to the specified catalog name + --save-catalog + Save the new dependency to the default catalog + --allow-build + A list of package names allowed to run postinstall + --filter + Filter packages in monorepo (can be used multiple times) + -w, --workspace-root + Add to workspace root + --workspace + Only add if package exists in workspace (pnpm-specific) + -g, --global + Install globally + --node + Node.js version to use for global installation (only with -g) + -h, --help + Print help > vp add testnpm2 -D && cat package.json # should add package as dev dependencies ➤ YN0000: · Yarn diff --git a/packages/global/snap-tests/command-env-install-conflict/conflict-pkg/cli.js b/packages/global/snap-tests/command-env-install-conflict/conflict-pkg/cli.js new file mode 100644 index 0000000000..7db8c02570 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-conflict/conflict-pkg/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log('conflict-pkg cli'); diff --git a/packages/global/snap-tests/command-env-install-conflict/conflict-pkg/package.json b/packages/global/snap-tests/command-env-install-conflict/conflict-pkg/package.json new file mode 100644 index 0000000000..2e7570ba03 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-conflict/conflict-pkg/package.json @@ -0,0 +1,9 @@ +{ + "name": "conflict-pkg", + "version": "1.0.0", + "description": "Test package with conflicting binary names", + "bin": { + "conflict-cli": "./cli.js", + "node": "./cli.js" + } +} diff --git a/packages/global/snap-tests/command-env-install-conflict/snap.txt b/packages/global/snap-tests/command-env-install-conflict/snap.txt new file mode 100644 index 0000000000..1ce9f82153 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-conflict/snap.txt @@ -0,0 +1,25 @@ +> vp install -g ./conflict-pkg # Install package with conflicting binary name (uses cwd version) + Installing ./conflict-pkg globally... + Running npm install... + +added 1 package in ms + Warning: Package './conflict-pkg' provides 'node' binary, but it conflicts with a core shim. Skipping. + Installed ./conflict-pkg v + Binaries: conflict-cli, node + +> vp remove -g conflict-pkg # Cleanup + Uninstalling conflict-pkg... + Uninstalled conflict-pkg + +> vp install -g --node 20 ./conflict-pkg # Install with specific Node.js version + Installing ./conflict-pkg globally... + Running npm install... + +added 1 package in ms + Warning: Package './conflict-pkg' provides 'node' binary, but it conflicts with a core shim. Skipping. + Installed ./conflict-pkg v + Binaries: conflict-cli, node + +> vp remove -g conflict-pkg # Cleanup + Uninstalling conflict-pkg... + Uninstalled conflict-pkg diff --git a/packages/global/snap-tests/command-env-install-conflict/steps.json b/packages/global/snap-tests/command-env-install-conflict/steps.json new file mode 100644 index 0000000000..b1a13e2031 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-conflict/steps.json @@ -0,0 +1,10 @@ +{ + "env": {}, + "ignoredPlatforms": ["win32"], + "commands": [ + "vp install -g ./conflict-pkg # Install package with conflicting binary name (uses cwd version)", + "vp remove -g conflict-pkg # Cleanup", + "vp install -g --node 20 ./conflict-pkg # Install with specific Node.js version", + "vp remove -g conflict-pkg # Cleanup" + ] +} diff --git a/packages/global/snap-tests/command-env-install-fail/snap.txt b/packages/global/snap-tests/command-env-install-fail/snap.txt new file mode 100644 index 0000000000..9d8b0d1510 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-fail/snap.txt @@ -0,0 +1,12 @@ +[1]> vp install -g voidzero-nonexistent-pkg-xyz-12345 # Install non-existent package + Installing voidzero-nonexistent-pkg-xyz-12345 globally... + Running npm install... +npm error code E404 +npm error 404 Not Found - GET https://registry./voidzero-nonexistent-pkg-xyz-12345 - Not found +npm error 404 +npm error 404 The requested resource 'voidzero-nonexistent-pkg-xyz-12345@*' could not be found or you do not have permission to access it. +npm error 404 +npm error 404 Note that you can also install from a +npm error 404 tarball, folder, http url, or git url. +npm error A complete log of this run can be found in: /.npm/_logs/-debug.log +Failed to install voidzero-nonexistent-pkg-xyz-12345: Configuration error: npm install failed with exit code: Some(1) diff --git a/packages/global/snap-tests/command-env-install-fail/steps.json b/packages/global/snap-tests/command-env-install-fail/steps.json new file mode 100644 index 0000000000..08bb3771c3 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-fail/steps.json @@ -0,0 +1,5 @@ +{ + "env": {}, + "ignoredPlatforms": ["win32"], + "commands": ["vp install -g voidzero-nonexistent-pkg-xyz-12345 # Install non-existent package"] +} diff --git a/packages/global/snap-tests/command-env-install-no-arg-fail/package.json b/packages/global/snap-tests/command-env-install-no-arg-fail/package.json new file mode 100644 index 0000000000..28e07feda8 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-no-arg-fail/package.json @@ -0,0 +1,3 @@ +{ + "name": "command-env-install-no-arg-fail" +} diff --git a/packages/global/snap-tests/command-env-install-no-arg-fail/snap.txt b/packages/global/snap-tests/command-env-install-no-arg-fail/snap.txt new file mode 100644 index 0000000000..108172f3ad --- /dev/null +++ b/packages/global/snap-tests/command-env-install-no-arg-fail/snap.txt @@ -0,0 +1,4 @@ +[1]> vp env install # No version config - should error +No Node.js version found in current project. +Specify a version: vp env install +Or pin one: vp env pin diff --git a/packages/global/snap-tests/command-env-install-no-arg-fail/steps.json b/packages/global/snap-tests/command-env-install-no-arg-fail/steps.json new file mode 100644 index 0000000000..7db2536d81 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-no-arg-fail/steps.json @@ -0,0 +1,5 @@ +{ + "ignoredPlatforms": ["win32"], + "env": {}, + "commands": ["vp env install # No version config - should error"] +} diff --git a/packages/global/snap-tests/command-env-install-no-arg/.node-version b/packages/global/snap-tests/command-env-install-no-arg/.node-version new file mode 100644 index 0000000000..2bd5a0a98a --- /dev/null +++ b/packages/global/snap-tests/command-env-install-no-arg/.node-version @@ -0,0 +1 @@ +22 diff --git a/packages/global/snap-tests/command-env-install-no-arg/package.json b/packages/global/snap-tests/command-env-install-no-arg/package.json new file mode 100644 index 0000000000..f116ad9b2f --- /dev/null +++ b/packages/global/snap-tests/command-env-install-no-arg/package.json @@ -0,0 +1,3 @@ +{ + "name": "command-env-install-no-arg" +} diff --git a/packages/global/snap-tests/command-env-install-no-arg/snap.txt b/packages/global/snap-tests/command-env-install-no-arg/snap.txt new file mode 100644 index 0000000000..c4c6d5fff3 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-no-arg/snap.txt @@ -0,0 +1,3 @@ +> vp env install # Install version from .node-version (22.x) +Installing Node.js v... +Installed Node.js v diff --git a/packages/global/snap-tests/command-env-install-no-arg/steps.json b/packages/global/snap-tests/command-env-install-no-arg/steps.json new file mode 100644 index 0000000000..455df6cc3b --- /dev/null +++ b/packages/global/snap-tests/command-env-install-no-arg/steps.json @@ -0,0 +1,5 @@ +{ + "ignoredPlatforms": ["win32"], + "env": {}, + "commands": ["vp env install # Install version from .node-version (22.x)"] +} diff --git a/packages/global/snap-tests/command-env-install-node-version/command-env-install-node-version-pkg/cli.js b/packages/global/snap-tests/command-env-install-node-version/command-env-install-node-version-pkg/cli.js new file mode 100755 index 0000000000..d19700134f --- /dev/null +++ b/packages/global/snap-tests/command-env-install-node-version/command-env-install-node-version-pkg/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log('test-pkg cli'); diff --git a/packages/global/snap-tests/command-env-install-node-version/command-env-install-node-version-pkg/package.json b/packages/global/snap-tests/command-env-install-node-version/command-env-install-node-version-pkg/package.json new file mode 100644 index 0000000000..637d60fb4b --- /dev/null +++ b/packages/global/snap-tests/command-env-install-node-version/command-env-install-node-version-pkg/package.json @@ -0,0 +1,7 @@ +{ + "name": "command-env-install-node-version-pkg", + "version": "1.0.0", + "bin": { + "command-env-install-node-version-pkg-cli": "./cli.js" + } +} diff --git a/packages/global/snap-tests/command-env-install-node-version/snap.txt b/packages/global/snap-tests/command-env-install-node-version/snap.txt new file mode 100644 index 0000000000..3335c08594 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-node-version/snap.txt @@ -0,0 +1,29 @@ +> vp install -g --node 22 ./command-env-install-node-version-pkg # Install with Node.js 22 + Installing ./command-env-install-node-version-pkg globally... + Running npm install... + +added 1 package in ms + Installed ./command-env-install-node-version-pkg v + Binaries: command-env-install-node-version-pkg-cli + +> cat $VITE_PLUS_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])" # Verify Node 22 +Node major: 22 + +> vp remove -g command-env-install-node-version-pkg # Cleanup + Uninstalling command-env-install-node-version-pkg... + Uninstalled command-env-install-node-version-pkg + +> vp install -g --node 20 ./command-env-install-node-version-pkg # Install with Node.js 20 + Installing ./command-env-install-node-version-pkg globally... + Running npm install... + +added 1 package in ms + Installed ./command-env-install-node-version-pkg v + Binaries: command-env-install-node-version-pkg-cli + +> cat $VITE_PLUS_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])" # Verify Node 20 +Node major: 20 + +> vp remove -g command-env-install-node-version-pkg # Cleanup + Uninstalling command-env-install-node-version-pkg... + Uninstalled command-env-install-node-version-pkg diff --git a/packages/global/snap-tests/command-env-install-node-version/steps.json b/packages/global/snap-tests/command-env-install-node-version/steps.json new file mode 100644 index 0000000000..4956222fa4 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-node-version/steps.json @@ -0,0 +1,12 @@ +{ + "ignoredPlatforms": ["win32"], + "env": {}, + "commands": [ + "vp install -g --node 22 ./command-env-install-node-version-pkg # Install with Node.js 22", + "cat $VITE_PLUS_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e \"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])\" # Verify Node 22", + "vp remove -g command-env-install-node-version-pkg # Cleanup", + "vp install -g --node 20 ./command-env-install-node-version-pkg # Install with Node.js 20", + "cat $VITE_PLUS_HOME/bins/command-env-install-node-version-pkg-cli.json | node -e \"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('Node major:', d.nodeVersion.split('.')[0])\" # Verify Node 20", + "vp remove -g command-env-install-node-version-pkg # Cleanup" + ] +} diff --git a/packages/global/snap-tests/command-env-install-version-alias/command-env-install-version-alias-pkg/cli.js b/packages/global/snap-tests/command-env-install-version-alias/command-env-install-version-alias-pkg/cli.js new file mode 100644 index 0000000000..d19700134f --- /dev/null +++ b/packages/global/snap-tests/command-env-install-version-alias/command-env-install-version-alias-pkg/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log('test-pkg cli'); diff --git a/packages/global/snap-tests/command-env-install-version-alias/command-env-install-version-alias-pkg/package.json b/packages/global/snap-tests/command-env-install-version-alias/command-env-install-version-alias-pkg/package.json new file mode 100644 index 0000000000..82be99e07e --- /dev/null +++ b/packages/global/snap-tests/command-env-install-version-alias/command-env-install-version-alias-pkg/package.json @@ -0,0 +1,7 @@ +{ + "name": "command-env-install-version-alias-pkg", + "version": "1.0.0", + "bin": { + "command-env-install-version-alias-pkg-cli": "./cli.js" + } +} diff --git a/packages/global/snap-tests/command-env-install-version-alias/snap.txt b/packages/global/snap-tests/command-env-install-version-alias/snap.txt new file mode 100644 index 0000000000..1a908bbf7a --- /dev/null +++ b/packages/global/snap-tests/command-env-install-version-alias/snap.txt @@ -0,0 +1,29 @@ +> vp install -g --node lts ./command-env-install-version-alias-pkg # Install with LTS alias + Installing ./command-env-install-version-alias-pkg globally... + Running npm install... + +added 1 package in ms + Installed ./command-env-install-version-alias-pkg v + Binaries: command-env-install-version-alias-pkg-cli + +> cat $VITE_PLUS_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('LTS major >= 20:', v >= 20)" # Verify LTS version +LTS major >= 20: true + +> vp remove -g command-env-install-version-alias-pkg # Cleanup + Uninstalling command-env-install-version-alias-pkg... + Uninstalled command-env-install-version-alias-pkg + +> vp install -g --node latest ./command-env-install-version-alias-pkg # Install with latest alias + Installing ./command-env-install-version-alias-pkg globally... + Running npm install... + +added 1 package in ms + Installed ./command-env-install-version-alias-pkg v + Binaries: command-env-install-version-alias-pkg-cli + +> cat $VITE_PLUS_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('Latest major >= 20:', v >= 20)" # Verify latest version +Latest major >= 20: true + +> vp remove -g command-env-install-version-alias-pkg # Cleanup + Uninstalling command-env-install-version-alias-pkg... + Uninstalled command-env-install-version-alias-pkg diff --git a/packages/global/snap-tests/command-env-install-version-alias/steps.json b/packages/global/snap-tests/command-env-install-version-alias/steps.json new file mode 100644 index 0000000000..833b7cc710 --- /dev/null +++ b/packages/global/snap-tests/command-env-install-version-alias/steps.json @@ -0,0 +1,12 @@ +{ + "ignoredPlatforms": ["win32"], + "env": {}, + "commands": [ + "vp install -g --node lts ./command-env-install-version-alias-pkg # Install with LTS alias", + "cat $VITE_PLUS_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e \"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('LTS major >= 20:', v >= 20)\" # Verify LTS version", + "vp remove -g command-env-install-version-alias-pkg # Cleanup", + "vp install -g --node latest ./command-env-install-version-alias-pkg # Install with latest alias", + "cat $VITE_PLUS_HOME/bins/command-env-install-version-alias-pkg-cli.json | node -e \"const d=JSON.parse(require('fs').readFileSync(0,'utf8')); const v=parseInt(d.nodeVersion.split('.')[0]); console.log('Latest major >= 20:', v >= 20)\" # Verify latest version", + "vp remove -g command-env-install-version-alias-pkg # Cleanup" + ] +} diff --git a/packages/global/snap-tests/command-env-run-shim-mode/package.json b/packages/global/snap-tests/command-env-run-shim-mode/package.json new file mode 100644 index 0000000000..01daa978c1 --- /dev/null +++ b/packages/global/snap-tests/command-env-run-shim-mode/package.json @@ -0,0 +1,8 @@ +{ + "name": "command-env-run-shim-mode", + "version": "1.0.0", + "private": true, + "engines": { + "node": "20.18.0" + } +} diff --git a/packages/global/snap-tests/command-env-run-shim-mode/snap.txt b/packages/global/snap-tests/command-env-run-shim-mode/snap.txt new file mode 100644 index 0000000000..36e294d5b9 --- /dev/null +++ b/packages/global/snap-tests/command-env-run-shim-mode/snap.txt @@ -0,0 +1,18 @@ +> vp env run node -v # Shim mode: version resolved from package.json engines.node +v20.18.0 + +> vp env run npm -v # Shim mode: npm uses same version +10.8.2 + +> vp env run node -e "console.log('Hello from shim mode')" # Shim mode: run inline script +Hello from shim mode + +> vp env run nonexistent-tool --version || echo 'Expected error: non-shim command requires --node' # Error: non-shim tool +vp env run: --node is required when running non-shim commands +Usage: vp env run --node [args...] + +For shim tools, --node is optional (version resolved automatically): + vp env run node script.js # Core tool + vp env run npm install # Core tool + vp env run tsc --version # Global package +Expected error: non-shim command requires --node diff --git a/packages/global/snap-tests/command-env-run-shim-mode/steps.json b/packages/global/snap-tests/command-env-run-shim-mode/steps.json new file mode 100644 index 0000000000..a3ffec1485 --- /dev/null +++ b/packages/global/snap-tests/command-env-run-shim-mode/steps.json @@ -0,0 +1,10 @@ +{ + "env": {}, + "ignoredPlatforms": [], + "commands": [ + "vp env run node -v # Shim mode: version resolved from package.json engines.node", + "vp env run npm -v # Shim mode: npm uses same version", + "vp env run node -e \"console.log('Hello from shim mode')\" # Shim mode: run inline script", + "vp env run nonexistent-tool --version || echo 'Expected error: non-shim command requires --node' # Error: non-shim tool" + ] +} diff --git a/packages/global/snap-tests/command-env-run/package.json b/packages/global/snap-tests/command-env-run/package.json new file mode 100644 index 0000000000..1f55f590ed --- /dev/null +++ b/packages/global/snap-tests/command-env-run/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-env-run", + "version": "1.0.0", + "private": true +} diff --git a/packages/global/snap-tests/command-env-run/snap.txt b/packages/global/snap-tests/command-env-run/snap.txt new file mode 100644 index 0000000000..8ba2685fee --- /dev/null +++ b/packages/global/snap-tests/command-env-run/snap.txt @@ -0,0 +1,5 @@ +> vp env run --node 20.19 node -v # Run node with specific major version +v20.19.6 + +> vp env run --node 20.19 node -e "console.log('Hello from Node ' + process.version)" # Run inline script +Hello from Node v diff --git a/packages/global/snap-tests/command-env-run/steps.json b/packages/global/snap-tests/command-env-run/steps.json new file mode 100644 index 0000000000..418b31d7a2 --- /dev/null +++ b/packages/global/snap-tests/command-env-run/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "ignoredPlatforms": [], + "commands": [ + "vp env run --node 20.19 node -v # Run node with specific major version", + "vp env run --node 20.19 node -e \"console.log('Hello from Node ' + process.version)\" # Run inline script" + ] +} diff --git a/packages/global/snap-tests/command-env-use/package.json b/packages/global/snap-tests/command-env-use/package.json new file mode 100644 index 0000000000..30895fc620 --- /dev/null +++ b/packages/global/snap-tests/command-env-use/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-env-use", + "version": "1.0.0", + "private": true +} diff --git a/packages/global/snap-tests/command-env-use/snap.txt b/packages/global/snap-tests/command-env-use/snap.txt new file mode 100644 index 0000000000..498cdc5d54 --- /dev/null +++ b/packages/global/snap-tests/command-env-use/snap.txt @@ -0,0 +1,39 @@ +> vp env use --help # should show help +Use a specific Node.js version for this shell session + +Usage: vp env use [OPTIONS] [VERSION] + +Arguments: + [VERSION] Version to use (e.g., "20", "20.18.0", "lts", "latest") If not provided, reads from .node-version or package.json + +Options: + --unset Remove session override (revert to file-based resolution) + --no-install Skip auto-installation if version not present + --silent-if-unchanged Suppress output if version is already active + -h, --help Print help + +> vp env use 20.18.0 --no-install # should output export command to stdout +export VITE_PLUS_NODE_VERSION=20.18.0 +Using Node.js v (resolved from ) + +> vp env use --unset # should output unset command to stdout +unset VITE_PLUS_NODE_VERSION +Reverted to file-based Node.js version resolution + +[1]> vp env use d # should show friendly error for invalid version +Error: Invalid Node.js version: "d" + +Valid examples: + vp env use 20 # Latest Node.js 20.x + vp env use # Exact version + vp env use lts # Latest LTS version + vp env use latest # Latest version + +[1]> vp env use abc # should show friendly error for invalid version +Error: Invalid Node.js version: "abc" + +Valid examples: + vp env use 20 # Latest Node.js 20.x + vp env use # Exact version + vp env use lts # Latest LTS version + vp env use latest # Latest version diff --git a/packages/global/snap-tests/command-env-use/steps.json b/packages/global/snap-tests/command-env-use/steps.json new file mode 100644 index 0000000000..31599b0e25 --- /dev/null +++ b/packages/global/snap-tests/command-env-use/steps.json @@ -0,0 +1,13 @@ +{ + "env": { + "VITE_PLUS_ENV_USE_EVAL_ENABLE": "1" + }, + "ignoredPlatforms": ["win32"], + "commands": [ + "vp env use --help # should show help", + "vp env use 20.18.0 --no-install # should output export command to stdout", + "vp env use --unset # should output unset command to stdout", + "vp env use d # should show friendly error for invalid version", + "vp env use abc # should show friendly error for invalid version" + ] +} diff --git a/packages/global/snap-tests/command-env-which/.node-version b/packages/global/snap-tests/command-env-which/.node-version new file mode 100644 index 0000000000..2a393af592 --- /dev/null +++ b/packages/global/snap-tests/command-env-which/.node-version @@ -0,0 +1 @@ +20.18.0 diff --git a/packages/global/snap-tests/command-env-which/package.json b/packages/global/snap-tests/command-env-which/package.json new file mode 100644 index 0000000000..f1007b45cc --- /dev/null +++ b/packages/global/snap-tests/command-env-which/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-env-which", + "version": "1.0.0", + "private": true +} diff --git a/packages/global/snap-tests/command-env-which/snap.txt b/packages/global/snap-tests/command-env-which/snap.txt new file mode 100644 index 0000000000..ad7c991a88 --- /dev/null +++ b/packages/global/snap-tests/command-env-which/snap.txt @@ -0,0 +1,44 @@ +> vp env run node --version # Ensure Node.js is installed first +v20.18.0 + +> vp env which node # Core tool - shows resolved Node.js binary path +/.vite-plus-dev/js_runtime/node//bin/node + Version:  20.18.0 + Source:  .node-version + +> vp env which npm # Core tool - shows resolved npm binary path +/.vite-plus-dev/js_runtime/node//bin/npm + Version:  20.18.0 + Source:  .node-version + +> vp env which npx # Core tool - shows resolved npx binary path +/.vite-plus-dev/js_runtime/node//bin/npx + Version:  20.18.0 + Source:  .node-version + +> vp install -g cowsay@1.6.0 # Install a global package via vp + Installing cowsay@ globally... + Running npm install... + +added 41 packages in ms + +3 packages are looking for funding + run `npm fund` for details + Installed cowsay v + Binaries: cowsay, cowthink + +> vp env which cowsay # Global package - shows binary path with metadata +/.vite-plus-dev/packages/cowsay/lib/node_modules/cowsay/./cli.js + Package:  cowsay@ + Binaries:  cowsay, cowthink + Node:  20.18.0 + Installed:  + +> vp remove -g cowsay # Cleanup + Uninstalling cowsay... + Uninstalled cowsay + +[1]> vp env which unknown-tool # Unknown tool - error message +error: tool 'unknown-tool' not found +Not a core tool (node, npm, npx) or installed global package. +Run 'vp list -g' to see installed packages. diff --git a/packages/global/snap-tests/command-env-which/steps.json b/packages/global/snap-tests/command-env-which/steps.json new file mode 100644 index 0000000000..9a54a8eee9 --- /dev/null +++ b/packages/global/snap-tests/command-env-which/steps.json @@ -0,0 +1,14 @@ +{ + "env": {}, + "ignoredPlatforms": ["win32"], + "commands": [ + "vp env run node --version # Ensure Node.js is installed first", + "vp env which node # Core tool - shows resolved Node.js binary path", + "vp env which npm # Core tool - shows resolved npm binary path", + "vp env which npx # Core tool - shows resolved npx binary path", + "vp install -g cowsay@1.6.0 # Install a global package via vp", + "vp env which cowsay # Global package - shows binary path with metadata", + "vp remove -g cowsay # Cleanup", + "vp env which unknown-tool # Unknown tool - error message" + ] +} diff --git a/packages/global/snap-tests/command-link-pnpm10/steps.json b/packages/global/snap-tests/command-link-pnpm10/steps.json index 59de4421ab..99cbd04f02 100644 --- a/packages/global/snap-tests/command-link-pnpm10/steps.json +++ b/packages/global/snap-tests/command-link-pnpm10/steps.json @@ -1,5 +1,5 @@ { - "ignoredPlatforms": ["win32"], + "ignoredPlatforms": ["win32", "linux"], "env": { "VITE_DISABLE_AUTO_INSTALL": "1" }, diff --git a/packages/global/snap-tests/command-list-no-package-json/snap.txt b/packages/global/snap-tests/command-list-no-package-json/snap.txt new file mode 100644 index 0000000000..c6092decf4 --- /dev/null +++ b/packages/global/snap-tests/command-list-no-package-json/snap.txt @@ -0,0 +1,2 @@ +> vp ls # should output nothing without package.json +> vp pm list # should output nothing without package.json \ No newline at end of file diff --git a/packages/global/snap-tests/command-list-no-package-json/steps.json b/packages/global/snap-tests/command-list-no-package-json/steps.json new file mode 100644 index 0000000000..f3572793b0 --- /dev/null +++ b/packages/global/snap-tests/command-list-no-package-json/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp ls # should output nothing without package.json", + "vp pm list # should output nothing without package.json" + ] +} diff --git a/packages/global/snap-tests/command-owner-pnpm10/snap.txt b/packages/global/snap-tests/command-owner-pnpm10/snap.txt index 6d7a89aed8..dd5934aadb 100644 --- a/packages/global/snap-tests/command-owner-pnpm10/snap.txt +++ b/packages/global/snap-tests/command-owner-pnpm10/snap.txt @@ -4,7 +4,7 @@ Manage package owners Usage: vp pm owner Commands: - list List package owners + list List package owners [aliases: ls] add Add package owner rm Remove package owner diff --git a/packages/global/snap-tests/command-remove-npm10/snap.txt b/packages/global/snap-tests/command-remove-npm10/snap.txt index 6d228daabf..4d2499f9b3 100644 --- a/packages/global/snap-tests/command-remove-npm10/snap.txt +++ b/packages/global/snap-tests/command-remove-npm10/snap.txt @@ -50,11 +50,5 @@ removed 1 package in ms "packageManager": "npm@" } -> vp remove -g testnpm2 -- --dry-run --no-audit && cat package.json # support remove global package with dry-run - -up to date in ms -{ - "name": "command-remove-npm10", - "version": "1.0.0", - "packageManager": "npm@" -} +[1]> vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run +Failed to uninstall testnpm2: Configuration error: Package testnpm2 is not installed diff --git a/packages/global/snap-tests/command-remove-npm10/steps.json b/packages/global/snap-tests/command-remove-npm10/steps.json index dd708e924d..c0c59ee776 100644 --- a/packages/global/snap-tests/command-remove-npm10/steps.json +++ b/packages/global/snap-tests/command-remove-npm10/steps.json @@ -8,6 +8,6 @@ "vp add testnpm2 -- --no-audit && vp add -D test-vite-plus-install -- --no-audit && vp add -O test-vite-plus-package-optional -- --no-audit && cat package.json # should add packages to dependencies", "vp remove testnpm2 test-vite-plus-install -- --no-audit && cat package.json # should remove packages from dependencies", "vp remove -D test-vite-plus-package-optional -- --loglevel=warn --no-audit && cat package.json # support ignore -O flag and remove package from optional dependencies", - "vp remove -g testnpm2 -- --dry-run --no-audit && cat package.json # support remove global package with dry-run" + "vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run" ] } diff --git a/packages/global/snap-tests/command-remove-pnpm10/snap.txt b/packages/global/snap-tests/command-remove-pnpm10/snap.txt index 69f9f9a7de..6fe0b70458 100644 --- a/packages/global/snap-tests/command-remove-pnpm10/snap.txt +++ b/packages/global/snap-tests/command-remove-pnpm10/snap.txt @@ -15,6 +15,7 @@ Options: -w, --workspace-root Remove from workspace root -r, --recursive Remove recursively from all workspace packages -g, --global Remove global packages + --dry-run Preview what would be removed without actually removing (only with -g) -h, --help Print help [2]> vp remove # should error because no packages specified @@ -96,14 +97,8 @@ Done in ms using pnpm v "packageManager": "pnpm@" } -> vp remove -g testnpm2 -- --dry-run && cat package.json # support remove global package with dry-run - -up to date in ms -{ - "name": "command-remove-pnpm10", - "version": "1.0.0", - "packageManager": "pnpm@" -} +[1]> vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run +Failed to uninstall testnpm2: Configuration error: Package testnpm2 is not installed [2]> vp rm --stream foo && should show tips to use pass through arguments when options are not supported error: unexpected argument '--stream' found diff --git a/packages/global/snap-tests/command-remove-pnpm10/steps.json b/packages/global/snap-tests/command-remove-pnpm10/steps.json index 797fdc0b73..4a13862ca4 100644 --- a/packages/global/snap-tests/command-remove-pnpm10/steps.json +++ b/packages/global/snap-tests/command-remove-pnpm10/steps.json @@ -10,7 +10,7 @@ "vp add testnpm2 && vp add -D test-vite-plus-install && vp add -O test-vite-plus-package-optional && cat package.json # should add packages to dependencies", "vp remove testnpm2 test-vite-plus-install && cat package.json # should remove packages from dependencies", "vp remove -O test-vite-plus-package-optional -- --loglevel=warn && cat package.json # support remove package from optional dependencies and pass through arguments", - "vp remove -g testnpm2 -- --dry-run && cat package.json # support remove global package with dry-run", + "vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run", "vp rm --stream foo && should show tips to use pass through arguments when options are not supported" ] } diff --git a/packages/global/snap-tests/command-remove-yarn4/snap.txt b/packages/global/snap-tests/command-remove-yarn4/snap.txt index 62718ec278..1e9fb68b6f 100644 --- a/packages/global/snap-tests/command-remove-yarn4/snap.txt +++ b/packages/global/snap-tests/command-remove-yarn4/snap.txt @@ -81,11 +81,5 @@ $ yarn remove [-A,--all] [--mode #0] ... "packageManager": "yarn@" } -> vp remove -g testnpm2 -- --dry-run && cat package.json # support remove global package with dry-run - -up to date in ms -{ - "name": "command-remove-yarn4", - "version": "1.0.0", - "packageManager": "yarn@" -} +[1]> vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run +Failed to uninstall testnpm2: Configuration error: Package testnpm2 is not installed diff --git a/packages/global/snap-tests/command-remove-yarn4/steps.json b/packages/global/snap-tests/command-remove-yarn4/steps.json index 4393e176f6..b8fa417cad 100644 --- a/packages/global/snap-tests/command-remove-yarn4/steps.json +++ b/packages/global/snap-tests/command-remove-yarn4/steps.json @@ -8,6 +8,6 @@ "vp add testnpm2 && vp add -D test-vite-plus-install && vp add -O test-vite-plus-package-optional && cat package.json # should add packages to dependencies", "vp remove testnpm2 test-vite-plus-install && cat package.json # should remove packages from dependencies", "vp remove -D test-vite-plus-package-optional && cat package.json # support ignore -O flag and remove package from optional dependencies", - "vp remove -g testnpm2 -- --dry-run && cat package.json # support remove global package with dry-run" + "vp remove -g --dry-run testnpm2 && cat package.json # support remove global package with dry-run" ] } diff --git a/packages/global/snap-tests/env-install-binary-conflict/.node-version b/packages/global/snap-tests/env-install-binary-conflict/.node-version new file mode 100644 index 0000000000..54979ab5d9 --- /dev/null +++ b/packages/global/snap-tests/env-install-binary-conflict/.node-version @@ -0,0 +1 @@ +22.22.0 \ No newline at end of file diff --git a/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-a/cli.js b/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-a/cli.js new file mode 100755 index 0000000000..f6980db605 --- /dev/null +++ b/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-a/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log('Hello from pkg-a!'); diff --git a/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-a/package.json b/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-a/package.json new file mode 100644 index 0000000000..f8fe0eb00f --- /dev/null +++ b/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-a/package.json @@ -0,0 +1,8 @@ +{ + "name": "env-binary-conflict-pkg-a", + "version": "1.0.0", + "description": "Test package A that provides 'env-binary-conflict-cli' binary", + "bin": { + "env-binary-conflict-cli": "./cli.js" + } +} diff --git a/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-b/cli.js b/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-b/cli.js new file mode 100755 index 0000000000..3943e61da3 --- /dev/null +++ b/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-b/cli.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +console.log('Hello from pkg-b!'); diff --git a/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-b/package.json b/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-b/package.json new file mode 100644 index 0000000000..7a31a3e3b6 --- /dev/null +++ b/packages/global/snap-tests/env-install-binary-conflict/env-binary-conflict-pkg-b/package.json @@ -0,0 +1,8 @@ +{ + "name": "env-binary-conflict-pkg-b", + "version": "2.0.0", + "description": "Test package B that also provides 'env-binary-conflict-cli' binary", + "bin": { + "env-binary-conflict-cli": "./cli.js" + } +} diff --git a/packages/global/snap-tests/env-install-binary-conflict/snap.txt b/packages/global/snap-tests/env-install-binary-conflict/snap.txt new file mode 100644 index 0000000000..8014294338 --- /dev/null +++ b/packages/global/snap-tests/env-install-binary-conflict/snap.txt @@ -0,0 +1,55 @@ +> vp install -g ./env-binary-conflict-pkg-a # Install pkg-a which provides env-binary-conflict-cli binary + Installing ./env-binary-conflict-pkg-a globally... + Running npm install... + +added 1 package in ms + Installed ./env-binary-conflict-pkg-a v + Binaries: env-binary-conflict-cli + +> cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should point to pkg-a +{ + "name": "env-binary-conflict-cli", + "package": "./env-binary-conflict-pkg-a", + "version": "1.0.0", + "nodeVersion": "22.22.0" +} +[1]> vp install -g ./env-binary-conflict-pkg-b # Try to install pkg-b without force - should fail + Installing ./env-binary-conflict-pkg-b globally... + Running npm install... + +added 1 package in ms +Failed to install ./env-binary-conflict-pkg-b: Executable 'env-binary-conflict-cli' is already installed by ./env-binary-conflict-pkg-a + +Please remove ./env-binary-conflict-pkg-a before installing ./env-binary-conflict-pkg-b, or use --force to auto-replace + +> cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should still point to pkg-a +{ + "name": "env-binary-conflict-cli", + "package": "./env-binary-conflict-pkg-a", + "version": "1.0.0", + "nodeVersion": "22.22.0" +} +> vp install -g --force ./env-binary-conflict-pkg-b # Force install pkg-b - should auto-uninstall pkg-a + Installing ./env-binary-conflict-pkg-b globally... + Running npm install... + +added 1 package in ms + Uninstalling ./env-binary-conflict-pkg-a (conflicts with ./env-binary-conflict-pkg-b)... + Uninstalling ./env-binary-conflict-pkg-a... + Uninstalled ./env-binary-conflict-pkg-a + Installed ./env-binary-conflict-pkg-b v + Binaries: env-binary-conflict-cli + +> cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should now point to pkg-b +{ + "name": "env-binary-conflict-cli", + "package": "./env-binary-conflict-pkg-b", + "version": "2.0.0", + "nodeVersion": "22.22.0" +} +> vp remove -g env-binary-conflict-pkg-b # Cleanup + Uninstalling env-binary-conflict-pkg-b... + Uninstalled env-binary-conflict-pkg-b + +> test ! -f $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json && echo 'bin config removed' # Bin config should be deleted +bin config removed diff --git a/packages/global/snap-tests/env-install-binary-conflict/steps.json b/packages/global/snap-tests/env-install-binary-conflict/steps.json new file mode 100644 index 0000000000..ba5b0966f0 --- /dev/null +++ b/packages/global/snap-tests/env-install-binary-conflict/steps.json @@ -0,0 +1,14 @@ +{ + "env": {}, + "ignoredPlatforms": ["win32"], + "commands": [ + "vp install -g ./env-binary-conflict-pkg-a # Install pkg-a which provides env-binary-conflict-cli binary", + "cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should point to pkg-a", + "vp install -g ./env-binary-conflict-pkg-b # Try to install pkg-b without force - should fail", + "cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should still point to pkg-a", + "vp install -g --force ./env-binary-conflict-pkg-b # Force install pkg-b - should auto-uninstall pkg-a", + "cat $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json # Bin config should now point to pkg-b", + "vp remove -g env-binary-conflict-pkg-b # Cleanup", + "test ! -f $VITE_PLUS_HOME/bins/env-binary-conflict-cli.json && echo 'bin config removed' # Bin config should be deleted" + ] +} diff --git a/packages/global/snap-tests/shim-recursive-npm-run/package.json b/packages/global/snap-tests/shim-recursive-npm-run/package.json new file mode 100644 index 0000000000..e66dc70d03 --- /dev/null +++ b/packages/global/snap-tests/shim-recursive-npm-run/package.json @@ -0,0 +1,12 @@ +{ + "name": "shim-recursive-npm-run", + "version": "1.0.0", + "private": true, + "scripts": { + "outer": "npm run inner", + "inner": "echo hello from inner" + }, + "engines": { + "node": "22.12.0" + } +} diff --git a/packages/global/snap-tests/shim-recursive-npm-run/snap.txt b/packages/global/snap-tests/shim-recursive-npm-run/snap.txt new file mode 100644 index 0000000000..b63d9b17a2 --- /dev/null +++ b/packages/global/snap-tests/shim-recursive-npm-run/snap.txt @@ -0,0 +1,10 @@ +> npm run outer # Outer script calls npm run inner recursively + +> shim-recursive-npm-run@ outer +> npm run inner + + +> shim-recursive-npm-run@ inner +> echo hello from inner + +hello from inner diff --git a/packages/global/snap-tests/shim-recursive-npm-run/steps.json b/packages/global/snap-tests/shim-recursive-npm-run/steps.json new file mode 100644 index 0000000000..2fe4307d0b --- /dev/null +++ b/packages/global/snap-tests/shim-recursive-npm-run/steps.json @@ -0,0 +1,4 @@ +{ + "env": {}, + "commands": ["npm run outer # Outer script calls npm run inner recursively"] +} diff --git a/packages/global/snap-tests/shim-recursive-package-binary/.node-version b/packages/global/snap-tests/shim-recursive-package-binary/.node-version new file mode 100644 index 0000000000..1d9b7831ba --- /dev/null +++ b/packages/global/snap-tests/shim-recursive-package-binary/.node-version @@ -0,0 +1 @@ +22.12.0 diff --git a/packages/global/snap-tests/shim-recursive-package-binary/recursive-cli-pkg/cli.js b/packages/global/snap-tests/shim-recursive-package-binary/recursive-cli-pkg/cli.js new file mode 100755 index 0000000000..0e6064c42b --- /dev/null +++ b/packages/global/snap-tests/shim-recursive-package-binary/recursive-cli-pkg/cli.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === 'inner') { + console.log('inner call succeeded'); +} else { + console.log('outer call'); + const { execSync } = require('child_process'); + // This re-invokes the shim, testing recursion + const output = execSync('recursive-cli inner', { encoding: 'utf8' }); + process.stdout.write(output); +} diff --git a/packages/global/snap-tests/shim-recursive-package-binary/recursive-cli-pkg/package.json b/packages/global/snap-tests/shim-recursive-package-binary/recursive-cli-pkg/package.json new file mode 100644 index 0000000000..e7aa6d4517 --- /dev/null +++ b/packages/global/snap-tests/shim-recursive-package-binary/recursive-cli-pkg/package.json @@ -0,0 +1,7 @@ +{ + "name": "recursive-cli-pkg", + "version": "1.0.0", + "bin": { + "recursive-cli": "./cli.js" + } +} diff --git a/packages/global/snap-tests/shim-recursive-package-binary/snap.txt b/packages/global/snap-tests/shim-recursive-package-binary/snap.txt new file mode 100644 index 0000000000..b7d00fd67e --- /dev/null +++ b/packages/global/snap-tests/shim-recursive-package-binary/snap.txt @@ -0,0 +1,15 @@ +> vp install -g ./recursive-cli-pkg # Install test package + Installing ./recursive-cli-pkg globally... + Running npm install... + +added 1 package in ms + Installed ./recursive-cli-pkg v + Binaries: recursive-cli + +> recursive-cli # Outer call triggers recursive inner call through shim +outer call +inner call succeeded + +> vp remove -g recursive-cli-pkg # Cleanup + Uninstalling recursive-cli-pkg... + Uninstalled recursive-cli-pkg diff --git a/packages/global/snap-tests/shim-recursive-package-binary/steps.json b/packages/global/snap-tests/shim-recursive-package-binary/steps.json new file mode 100644 index 0000000000..51c76ca777 --- /dev/null +++ b/packages/global/snap-tests/shim-recursive-package-binary/steps.json @@ -0,0 +1,8 @@ +{ + "env": {}, + "commands": [ + "vp install -g ./recursive-cli-pkg # Install test package", + "recursive-cli # Outer call triggers recursive inner call through shim", + "vp remove -g recursive-cli-pkg # Cleanup" + ] +} diff --git a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap index 1e0fc3b9ea..77e00a1d57 100644 --- a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap +++ b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap @@ -5,6 +5,12 @@ exports[`replaceUnstableOutput() > replace date 1`] = ` " `; +exports[`replaceUnstableOutput() > replace full datetime (YYYY-MM-DD HH:MM:SS) 1`] = ` +"Installed: + Created: + Updated: " +`; + exports[`replaceUnstableOutput() > replace hash values 1`] = ` "npm notice shasum: npm notice integrity: sha512- diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index 662fdd8687..86cec5a334 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -33,6 +33,15 @@ Start at 15:01:23 expect(replaceUnstableOutput(output.trim())).toMatchSnapshot(); }); + test('replace full datetime (YYYY-MM-DD HH:MM:SS)', () => { + const output = ` + Installed: 2026-02-04 15:30:45 + Created: 2024-01-15 10:30:00 + Updated: 1999-12-31 23:59:59 + `; + expect(replaceUnstableOutput(output.trim())).toMatchSnapshot(); + }); + test('replace unstable pnpm install output', () => { const outputs = [ ` diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index d0a36b268a..b72130eec8 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -1,44 +1,219 @@ import { execSync } from 'node:child_process'; -import { readFileSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; +const isWindows = process.platform === 'win32'; + +// Get repo root from script location (packages/tools/src/install-global-cli.ts -> repo root) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '../../..'); + export function installGlobalCli() { - const { positionals } = parseArgs({ + // Detect if running directly or via tools dispatcher + const isDirectInvocation = process.argv[1]?.endsWith('install-global-cli.ts'); + const args = process.argv.slice(isDirectInvocation ? 2 : 3); + + const { positionals, values } = parseArgs({ allowPositionals: true, - args: process.argv.slice(3), + args, + options: { + tgz: { + type: 'string', + short: 't', + }, + }, }); const binName = positionals[0]; if (!binName || !['vp', 'vp-dev'].includes(binName)) { - console.error('Usage: tool install-global-cli '); + console.error('Usage: tool install-global-cli [--tgz ]'); process.exit(1); } console.log(`Installing global CLI with bin name: ${binName}`); - if (binName === 'vp') { - // CI: use original package.json settings - execSync('npm install -g ./packages/global --force', { + let tempDir: string | undefined; + let tgzPath: string; + + if (values.tgz) { + // Use provided tgz file directly + tgzPath = path.resolve(values.tgz); + if (!existsSync(tgzPath)) { + console.error(`Error: tgz file not found: ${tgzPath}`); + process.exit(1); + } + console.log(`Using provided tgz: ${tgzPath}`); + } else { + // Create temp directory for pnpm pack output + tempDir = mkdtempSync(path.join(os.tmpdir(), 'vite-plus-cli-')); + + // Use pnpm pack to create tarball + // - Auto-resolves catalog: dependencies + // - Includes binary (already in packages/global/bin/ after copy-vp-binary) + execSync(`pnpm pack --pack-destination "${tempDir}"`, { + cwd: path.join(repoRoot, 'packages/global'), stdio: 'inherit', }); - return; + + // Find the generated tgz file (name includes version) + const tgzFile = readdirSync(tempDir).find((f) => f.endsWith('.tgz')); + if (!tgzFile) { + throw new Error('pnpm pack did not create a .tgz file'); + } + tgzPath = path.join(tempDir, tgzFile); } - // Local development: temporarily modify package.json to avoid conflicts - const packageJsonPath = path.resolve('packages/global/package.json'); - const originalContent = readFileSync(packageJsonPath, 'utf-8'); - const packageJson = JSON.parse(originalContent); + try { + // Set up environment for install script + // Both vp and vp-dev use ~/.vite-plus-dev to avoid conflicting with release version + const installDir = path.join(os.homedir(), '.vite-plus-dev'); - packageJson.name = 'vite-plus-cli-dev'; - packageJson.bin = { 'vp-dev': './bin/wrapper.js' }; + const env: Record = { + ...(process.env as Record), + VITE_PLUS_LOCAL_TGZ: tgzPath, + VITE_PLUS_HOME: installDir, + VITE_PLUS_VERSION: 'local-dev', + CI: 'true', + }; - try { - writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); - execSync('npm install -g ./packages/global --force', { - stdio: 'inherit', - }); + // Run platform-specific install script (use absolute paths) + const installScriptDir = path.join(repoRoot, 'packages/global'); + if (isWindows) { + // Use pwsh (PowerShell Core) for better UTF-8 handling + const ps1Path = path.join(installScriptDir, 'install.ps1'); + execSync(`pwsh -ExecutionPolicy Bypass -File "${ps1Path}"`, { + stdio: 'inherit', + env, + }); + } else { + const shPath = path.join(installScriptDir, 'install.sh'); + execSync(`bash "${shPath}"`, { + stdio: 'inherit', + env, + }); + } + + // Create wrapper scripts + const binDir = path.join(installDir, 'bin'); + const currentBinDir = path.join(installDir, 'current', 'bin'); + + // Create wrapper scripts to ensure VITE_PLUS_HOME is always set + if (isWindows) { + // On Windows, install.ps1 already creates bin/vp.cmd with VITE_PLUS_HOME set. + // For 'vp-dev', we need to rename it to vp-dev.cmd. + if (binName === 'vp-dev') { + const vpCmd = path.join(binDir, 'vp.cmd'); + const vpDevCmd = path.join(binDir, 'vp-dev.cmd'); + if (existsSync(vpCmd)) { + renameSync(vpCmd, vpDevCmd); + console.log(`\nRenamed ${vpCmd} -> ${vpDevCmd}`); + } + } + // For 'vp', bin/vp.cmd is already correct from install.ps1 + } else { + // Unix: Rename vp -> vp-raw, then create a wrapper at vp + // The wrapper sets VITE_PLUS_HOME and VITE_PLUS_SHIM_TOOL for shim detection + const vpBinary = path.join(currentBinDir, 'vp'); + const vpRawBinary = path.join(currentBinDir, 'vp-raw'); + + // Rename vp -> vp-raw (always replace to ensure latest binary) + if (existsSync(vpBinary)) { + if (existsSync(vpRawBinary)) { + rmSync(vpRawBinary); + } + renameSync(vpBinary, vpRawBinary); + console.log(`Renamed ${vpBinary} -> ${vpRawBinary}`); + } + + // Create vp wrapper in current/bin/ that sets VITE_PLUS_HOME and calls vp-raw + // Uses VITE_PLUS_SHIM_TOOL env var for shim detection (more portable than exec -a) + const vpWrapperPath = path.join(currentBinDir, 'vp'); + const vpWrapperContent = `#!/bin/sh +VITE_PLUS_SHIM_TOOL="$(basename "$0")" +export VITE_PLUS_SHIM_TOOL +export VITE_PLUS_HOME="${installDir}" +exec "$VITE_PLUS_HOME/current/bin/vp-raw" "$@" +`; + writeFileSync(vpWrapperPath, vpWrapperContent); + chmodSync(vpWrapperPath, 0o755); + console.log(`Created wrapper: ${vpWrapperPath}`); + + // On Unix, create shell script wrappers + if (binName === 'vp-dev') { + // Remove the vp symlink to avoid confusion + rmSync(path.join(binDir, 'vp'), { force: true }); + + // Create vp-dev wrapper that points to current/bin/vp (the wrapper) + const wrapperPath = path.join(binDir, 'vp-dev'); + const wrapperContent = `#!/bin/sh +export VITE_PLUS_HOME="${installDir}" +exec "$VITE_PLUS_HOME/current/bin/vp" "$@" +`; + writeFileSync(wrapperPath, wrapperContent); + chmodSync(wrapperPath, 0o755); + console.log(`\nCreated wrapper script: ${wrapperPath}`); + } + // For 'vp' on Unix, install.sh already creates the symlink to ../current/bin/vp + // which now points to the wrapper script (which calls vp-raw) + } + + // Patch env files for vp-dev: the shell function wrappers created by `vp env setup` + // define vp() but in dev mode the binary is vp-dev, so we rename the functions + if (binName === 'vp-dev') { + const envPatches: Array<{ file: string; replacements: [string, string][] }> = [ + { + file: 'env', + replacements: [ + ['vp() {', 'vp-dev() {'], + ['command vp ', 'command vp-dev '], + ], + }, + { + file: 'env.fish', + replacements: [ + ['function vp\n', 'function vp-dev\n'], + ['command vp ', 'command vp-dev '], + ], + }, + { + file: 'env.ps1', + replacements: [['function vp {', 'function vp-dev {']], + }, + ]; + + for (const { file, replacements } of envPatches) { + const filePath = path.join(installDir, file); + if (existsSync(filePath)) { + let content = readFileSync(filePath, 'utf-8'); + for (const [from, to] of replacements) { + content = content.replaceAll(from, to); + } + writeFileSync(filePath, content); + console.log(`Patched ${filePath} for vp-dev`); + } + } + } } finally { - writeFileSync(packageJsonPath, originalContent); + // Cleanup temp dir only if we created it + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } } } + +// Allow running directly via: npx tsx install-global-cli.ts +if (import.meta.main) { + installGlobalCli(); +} diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index 465a3e2785..9ee3e36bab 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import fs, { readFileSync } from 'node:fs'; import fsPromises from 'node:fs/promises'; import { open } from 'node:fs/promises'; -import { cpus, tmpdir } from 'node:os'; +import { cpus, homedir, tmpdir } from 'node:os'; import path from 'node:path'; import { setTimeout } from 'node:timers/promises'; import { debuglog, parseArgs } from 'node:util'; @@ -71,9 +71,32 @@ export async function snapTest() { // Create a unique temporary directory for testing // On macOS, `tmpdir()` is a symlink. Resolve it so that we can replace the resolved cwd in outputs. - const tempTmpDir = `${fs.realpathSync(tmpdir())}/vite-plus-test-${randomUUID()}`; + const systemTmpDir = fs.realpathSync(tmpdir()); + const tempTmpDir = `${systemTmpDir}/vite-plus-test-${randomUUID()}`; fs.mkdirSync(tempTmpDir, { recursive: true }); + // Clean up stale .node-version and package.json in the system temp directory. + // vite-plus walks up the directory tree to resolve Node.js versions, so leftover + // files from previous runs can cause tests to pick up unexpected version configs. + for (const staleFile of ['.node-version', 'package.json']) { + const stalePath = path.join(systemTmpDir, staleFile); + if (fs.existsSync(stalePath)) { + fs.rmSync(stalePath); + } + } + + // Ensure shim mode is "managed" so snap tests use vite-plus managed Node.js + // instead of the system Node.js (equivalent to running `vp env on`). + const vitePlusHome = path.join(homedir(), '.vite-plus-dev'); + const configPath = path.join(vitePlusHome, 'config.json'); + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + if (config.shimMode && config.shimMode !== 'managed') { + delete config.shimMode; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + } + } + // Make dependencies available in the test cases fs.symlinkSync( path.resolve('node_modules'), @@ -158,12 +181,18 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string) { NO_COLOR: 'true', // set CI=true make sure snap-tests are stable on GitHub Actions CI: 'true', + // Use the dev installation, same as vp-dev + VITE_PLUS_HOME: path.join(homedir(), '.vite-plus-dev'), // A test case can override/unset environment variables above. // For example, VITE_PLUS_CLI_TEST/CI can be unset to test the real-world outputs. ...steps.env, }; + // Unset VITE_PLUS_NODE_VERSION to prevent `vp env use` session overrides + // from leaking into snap tests (it passes through via the VITE_* pattern). + delete env['VITE_PLUS_NODE_VERSION']; + // Sometimes on Windows, the PATH variable is named 'Path' if ('Path' in env && !('PATH' in env)) { env['PATH'] = env['Path']; diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 2c07b85184..fd44ba13c0 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -27,7 +27,11 @@ export function replaceUnstableOutput(output: string, cwd?: string) { // vite-plus hash version // e.g.: `vite-plus": "^0.0.0-aa9f90fe23216b8ad85b0ba4fc1bccb0614afaf0"` -> `vite-plus": "^0.0.0-` .replaceAll(/0\.0\.0-\w{40}/g, '0.0.0-') - // date + // date (YYYY-MM-DD HH:MM:SS) + .replaceAll(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/g, '') + // date only (YYYY-MM-DD) + .replaceAll(/\d{4}-\d{2}-\d{2}/g, '') + // time only (HH:MM:SS) .replaceAll(/\d{2}:\d{2}:\d{2}/g, '') // duration .replaceAll(/\d+(?:\.\d+)?(?:s|ms|µs|ns)/g, 'ms') @@ -104,6 +108,12 @@ export function replaceUnstableOutput(output: string, cwd?: string) { .replaceAll(/"integrity": "(\w+)-.+?"/g, '"integrity": "$1-"') // replace homedir; e.g.: /Users/foo/Library/pnpm/global/5/node_modules/testnpm2 => /Library/pnpm/global/5/node_modules/testnpm2 .replaceAll(homedir(), '') + // replace npm log file path with timestamp + // e.g.: /.npm/_logs/T07_38_18_387Z-debug-0.log => /.npm/_logs/-debug.log + .replaceAll( + /(\/\.npm\/_logs\/)T\d{2}_\d{2}_\d{2}_\d+Z-debug-\d+\.log/g, + '$1-debug.log', + ) // remove the newline after "Checking formatting..." .replaceAll(`Checking formatting...\n`, 'Checking formatting...') // remove warning @: No license field diff --git a/rfcs/env-command.md b/rfcs/env-command.md new file mode 100644 index 0000000000..f95b1d231e --- /dev/null +++ b/rfcs/env-command.md @@ -0,0 +1,2205 @@ +# RFC: `vp env` - Shim-Based Node Version Management + +## Summary + +This RFC proposes adding a `vp env` command that provides system-wide, IDE-safe Node.js version management through a shim-based architecture. The shims intercept `node`, `npm`, and `npx` commands, automatically resolving and executing the correct Node.js version based on project configuration. + +> **Note**: Corepack shim is not included as vite-plus has integrated package manager functionality. + +## Motivation + +### Current Pain Points + +1. **IDE Integration Issues**: GUI-launched IDEs (VS Code, Cursor) often don't see shell-configured Node versions because they inherit PATH from the system environment, not shell rc files. + +2. **Version Manager Fragmentation**: Users must choose between nvm, fnm, volta, asdf, or mise - each with different setup requirements and shell integrations. + +3. **Inconsistent Behavior**: Terminal-launched vs GUI-launched applications may use different Node versions, causing subtle bugs. + +4. **Manual Version Switching**: Users must remember to run `nvm use` or similar when entering projects. + +### Proposed Solution + +A shim-based approach where: + +- `VITE_PLUS_HOME/bin/` directory is added to PATH (system-level for IDE reliability) +- Shims (`node`, `npm`, `npx`) are symlinks to the `vp` binary (Unix) or `.cmd` wrappers (Windows) +- The `vp` CLI itself is also in `VITE_PLUS_HOME/bin/`, so users only need one PATH entry +- The binary detects invocation via `argv[0]` and dispatches accordingly +- Version resolution and installation leverage existing `vite_js_runtime` infrastructure + +## Command Usage + +### Setup Commands + +```bash +# Initial setup - creates shims and shows PATH configuration instructions +vp env setup + +# Force refresh shims (after vp binary upgrade) +vp env setup --refresh + +# Set the global default Node.js version (used when no project version file exists) +vp env default 20.18.0 +vp env default lts # Use latest LTS version +vp env default latest # Use latest version (not recommended for stability) + +# Show current default version +vp env default + +# Control shim mode +vp env on # Enable managed mode (shims always use vite-plus Node.js) +vp env off # Enable system-first mode (shims prefer system Node.js) +``` + +### Diagnostic Commands + +```bash +# Comprehensive system diagnostics +vp env doctor + +# Show which node binary would be executed in current directory +vp env which node +vp env which npm + +# Output current environment info as JSON +vp env --current --json +# Output: {"version":"20.18.0","source":".node-version","project_root":"/path/to/project","node_path":"/path/to/node"} + +# Print shell snippet for current session (fallback for special environments) +vp env --print +``` + +### Version Management Commands + +```bash +# Pin a specific version in current directory (creates .node-version) +vp env pin 20.18.0 + +# Pin using version aliases (resolved to exact version) +vp env pin lts # Resolves and pins current LTS (e.g., 22.13.0) +vp env pin latest # Resolves and pins latest version + +# Pin using semver ranges +vp env pin "^20.0.0" + +# Show current pinned version +vp env pin + +# Remove pin (delete .node-version file) +vp env pin --unpin +vp env unpin # Alternative syntax + +# Skip pre-downloading the pinned version +vp env pin 20.18.0 --no-install + +# List locally installed Node.js versions +vp env list +vp env ls # Alias + +# List available Node.js versions from the registry +vp env list-remote +vp env list-remote --lts # Show only LTS versions +vp env list-remote 20 # Show versions matching pattern +``` + +### Session Version Override + +```bash +# Use a specific Node.js version for this shell session +vp env use 24 # Switch to Node 24.x +vp env use lts # Switch to latest LTS +vp env use # Install & activate project's configured version +vp env use --unset # Remove session override + +# Options +vp env use --no-install # Skip auto-install if version not present +vp env use --silent-if-unchanged # Suppress output if version already active +``` + +**How it works:** + +1. `~/.vite-plus/env` includes a `vp()` shell function that intercepts `vp env use` calls +2. The wrapper sets `VITE_PLUS_ENV_USE_EVAL_ENABLE=1` before calling `command vp env use ...` +3. When the env var is present (wrapper active), `vp env use` outputs shell commands to stdout for eval +4. When the env var is absent (CI, direct invocation), `vp env use` writes a session file (`~/.vite-plus/.session-node-version`) instead +5. The shim dispatch checks `VITE_PLUS_NODE_VERSION` env var first, then the session file, in the resolution chain + +**Automatic session file (for CI / wrapper-less environments):** + +When `vp env use` detects that the shell eval wrapper is not active (i.e., `VITE_PLUS_ENV_USE_EVAL_ENABLE` is not set), it automatically writes the resolved version to `~/.vite-plus/.session-node-version`. Shims read this file directly from disk, so `vp env use` works without the shell wrapper — no extra flags needed. The env var still takes priority when set, so the shell wrapper experience is unchanged. + +```bash +# GitHub Actions example (no shell wrapper, session file written automatically) +- run: vp env use 20 +- run: node --version # v20.x via shim reading session file +- run: vp env use --unset # Clean up +``` + +**Shell-specific output:** + +| Shell | Set | Unset | +| ---------------- | ----------------------------------------- | -------------------------------------------- | +| POSIX (bash/zsh) | `export VITE_PLUS_NODE_VERSION=20.18.1` | `unset VITE_PLUS_NODE_VERSION` | +| Fish | `set -gx VITE_PLUS_NODE_VERSION 20.18.1` | `set -e VITE_PLUS_NODE_VERSION` | +| PowerShell | `$env:VITE_PLUS_NODE_VERSION = "20.18.1"` | `Remove-Item Env:VITE_PLUS_NODE_VERSION ...` | +| cmd.exe | `set VITE_PLUS_NODE_VERSION=20.18.1` | `set VITE_PLUS_NODE_VERSION=` | + +**Shell function wrappers** are included in env files created by `vp env setup`: + +- `~/.vite-plus/env` (POSIX - bash/zsh): `vp()` function +- `~/.vite-plus/env.fish` (fish): `function vp` +- `~/.vite-plus/env.ps1` (PowerShell): `function vp` +- `~/.vite-plus/bin/vp-use.cmd` (cmd.exe): dedicated wrapper since cmd.exe lacks shell functions + +### Node.js Version Management + +```bash +# Install a Node.js version +vp env install 20.18.0 +vp env install lts +vp env install latest + +# Uninstall a Node.js version +vp env uninstall 20.18.0 +``` + +### Global Package Commands + +```bash +# Install a global package +vp install -g typescript +vp install -g typescript@5.0.0 + +# Install with specific Node.js version +vp install -g --node 22 typescript +vp install -g --node lts typescript + +# Force install (auto-uninstalls conflicting packages) +vp install -g --force eslint-v9 # Removes 'eslint' if it provides same binary + +# List installed global packages +vp list -g +vp list -g --json + +# Example output (table format with colored package names): +# Package Node version Binaries +# --- --- --- +# pnpm@10.28.2 22.22.0 pnpm, pnpx +# serve@14.2.5 22.22.0 serve +# typescript@5.9.3 22.22.0 tsc, tsserver + +# Uninstall a global package +vp remove -g typescript + +# Update global packages +vp update -g # Update all global packages +vp update -g typescript # Update specific package +``` + +### Daily Usage (After Setup) + +```bash +# These commands are intercepted by shims automatically +node -v # Uses project-specific version +npm install # Uses correct npm for the resolved Node version +npx vitest # Uses correct npx +``` + +## Architecture Overview + +### Single-Binary Multi-Role Design + +The `vp` binary serves dual purposes based on `argv[0]`: + +``` +argv[0] = "vp" → Normal CLI mode (vp env, vp build, etc.) +argv[0] = "node" → Shim mode: resolve version, exec node +argv[0] = "npm" → Shim mode: resolve version, exec npm +argv[0] = "npx" → Shim mode: resolve version, exec npx +``` + +### Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PATH CONFIGURATION │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ User's PATH (after setup): │ +│ │ +│ PATH="~/.vite-plus/bin:/usr/local/bin:/usr/bin:..." │ +│ ▲ │ +│ │ │ +│ └── First in PATH = shims intercept node/npm/npx commands │ +│ │ +│ When user runs `node`: │ +│ │ +│ $ node app.js │ +│ │ │ +│ ▼ │ +│ Shell searches PATH left-to-right: │ +│ 1. ~/.vite-plus/bin/node ✓ Found! (shim) │ +│ 2. /usr/local/bin/node (skipped) │ +│ 3. /usr/bin/node (skipped) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SHIM DISPATCH FLOW │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ User runs: $ node app.js │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ ~/.vite-plus/bin/node │ ◄── Symlink to vp binary (via PATH) │ +│ │ (shim intercepts command) │ │ +│ └──────────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ argv[0] Detection │ │ +│ │ "node" → shim mode │ │ +│ └──────────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ Version Resolution │────▶│ Priority Order: │ │ +│ │ (walk up directory tree) │ │ 0. VITE_PLUS_NODE_VERSION │ │ +│ └──────────────┬───────────────┘ │ 1. .session-node-version │ │ +│ │ │ 2. .node-version │ │ +│ │ │ 3. package.json#engines │ │ +│ │ │ 4. package.json#devEngines │ │ +│ │ │ 5. User default (config) │ │ +│ │ │ 6. Latest LTS │ │ +│ ▼ └─────────────────────────────┘ │ +│ ┌──────────────────────────────┐ │ +│ │ Ensure Node.js installed │ │ +│ │ (download if needed) │ │ +│ └──────────────┬───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────┐ │ +│ │ execve() real node binary │ │ +│ │ ~/.vite-plus/.../node │ │ +│ └──────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ DIRECTORY STRUCTURE │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ~/.vite-plus/ (VITE_PLUS_HOME) │ +│ ├── bin/ │ +│ │ ├── vp ────────────────────── Symlink to ../current/bin/vp │ +│ │ ├── node ──────────────────────┐ │ +│ │ ├── npm ──────────────────────┼──▶ Symlinks to ../current/bin/vp │ +│ │ └── npx ──────────────────────┘ │ +│ ├── current/bin/vp The actual vp CLI binary │ +│ ├── js_runtime/node/ Node.js installations │ +│ │ ├── 20.18.0/bin/node Installed Node.js versions │ +│ │ ├── 22.13.0/bin/node │ +│ │ └── ... │ +│ ├── .session-node-version Session override (written by vp env use)│ +│ └── config.json User settings (default version, etc.) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ VERSION RESOLUTION (walk_up=true) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ /home/user/projects/app/src/ ◄── Current directory │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Check /home/user/projects/app/src/ │ │ +│ │ ├── .node-version? ✗ not found │ │ +│ │ └── package.json? ✗ not found │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ walk up │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Check /home/user/projects/app/ │ │ +│ │ ├── .node-version? ✗ not found │ │ +│ │ └── package.json? ✓ found! engines.node = "^20.0.0" │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Return: version="^20.0.0", source="engines.node", │ +│ project_root="/home/user/projects/app" │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### VITE_PLUS_HOME Directory Layout + +``` +VITE_PLUS_HOME/ # Default: ~/.vite-plus +├── bin/ +│ ├── vp -> ../current/bin/vp # Symlink to current vp binary (Unix) +│ ├── node -> ../current/bin/vp # Symlink to vp binary (Unix) +│ ├── npm -> ../current/bin/vp # Symlink to vp binary (Unix) +│ ├── npx -> ../current/bin/vp # Symlink to vp binary (Unix) +│ ├── tsc -> ../current/bin/vp # Symlink for global package (Unix) +│ ├── vp # Shell script for Git Bash (Windows) +│ ├── vp.cmd # Wrapper calling ..\current\bin\vp.exe (Windows) +│ ├── node # Shell script for Git Bash (Windows) +│ ├── node.cmd # Wrapper calling vp env run node (Windows) +│ ├── npm # Shell script for Git Bash (Windows) +│ ├── npm.cmd # Wrapper calling vp env run npm (Windows) +│ ├── npx # Shell script for Git Bash (Windows) +│ ├── npx.cmd # Wrapper calling vp env run npx (Windows) +│ ├── tsc # Shell script for global package Git Bash (Windows) +│ └── tsc.cmd # Wrapper for global package (Windows) +├── current/ +│ └── bin/ +│ ├── vp # The actual vp CLI binary (Unix) +│ └── vp.exe # The actual vp CLI binary (Windows) +├── js_runtime/ +│ └── node/ +│ ├── 20.18.0/ # Installed Node versions +│ │ └── bin/ +│ │ ├── node +│ │ ├── npm +│ │ └── npx +│ └── 22.13.0/ +├── packages/ # Global packages +│ ├── typescript/ +│ │ └── lib/ +│ │ └── node_modules/ +│ │ └── typescript/ +│ │ └── bin/ +│ ├── typescript.json # Package metadata +│ ├── eslint/ +│ └── eslint.json +├── bins/ # Per-binary config files (tracks ownership) +│ ├── tsc.json # { "package": "typescript", ... } +│ ├── tsserver.json +│ └── eslint.json +├── shared/ # NODE_PATH symlinks +│ ├── typescript -> ../packages/typescript/lib/node_modules/typescript +│ └── eslint -> ../packages/eslint/lib/node_modules/eslint +├── cache/ +│ └── resolve_cache.json # LRU cache for version resolution +├── tmp/ # Staging directory for installs +│ └── packages/ +├── .session-node-version # Session override (written by `vp env use`) +└── config.json # User configuration (default version, etc.) +``` + +**Key Directories:** + +| Directory | Purpose | +| ------------------ | ------------------------------------------------------------------ | +| `bin/` | vp symlink and all shims (node, npm, npx, global package binaries) | +| `current/bin/` | The actual vp CLI binary (bin/ shims point here) | +| `js_runtime/node/` | Installed Node.js versions | +| `packages/` | Installed global packages with metadata | +| `bins/` | Per-binary config files (tracks which package owns each binary) | +| `shared/` | NODE_PATH symlinks for package require() resolution | +| `tmp/` | Staging area for atomic installations | +| `cache/` | Resolution cache | + +### config.json Format + +```json +// ~/.vite-plus/config.json + +{ + // Default Node.js version when no project version file is found + // Set via: vp env default + "defaultNodeVersion": "20.18.0", + + // Alternatively, use aliases: + // "defaultNodeVersion": "lts" // Always use latest LTS + // "defaultNodeVersion": "latest" // Always use latest (not recommended) + + // Shim mode: controls how shims resolve tools + // Set via: vp env on (managed) or vp env off (system_first) + // - "managed" (default): Shims always use vite-plus managed Node.js + // - "system_first": Shims prefer system Node.js, fallback to managed if not found + "shimMode": "managed" +} +``` + +## Version Specification + +This section documents the supported version formats for `.node-version` files, `package.json` engines, and CLI commands. + +### Supported Version Formats + +vite-plus supports the following version specification formats, compatible with nvm, fnm, and actions/setup-node: + +| Format | Example | Resolution | Cache Expiry | +| ------------------- | --------------------------------- | ------------------------------ | ------------------- | +| **Exact version** | `20.18.0`, `v20.18.0` | Used directly | mtime-based | +| **Partial version** | `20`, `20.18` | Highest matching (prefers LTS) | time-based (1 hour) | +| **Semver range** | `^20.0.0`, `~20.18.0`, `>=20 <22` | Highest matching (prefers LTS) | time-based (1 hour) | +| **LTS latest** | `lts/*` | Highest LTS version | time-based (1 hour) | +| **LTS codename** | `lts/iron`, `lts/jod` | Highest version in LTS line | time-based (1 hour) | +| **LTS offset** | `lts/-1`, `lts/-2` | nth-highest LTS line | time-based (1 hour) | +| **Wildcard** | `*` | Latest version | time-based (1 hour) | + +### Exact Versions + +Exact three-part versions are used directly without network resolution: + +``` +20.18.0 → 20.18.0 +v20.18.0 → 20.18.0 (v prefix stripped) +22.13.1 → 22.13.1 +``` + +### Partial Versions + +Partial versions (major or major.minor) are resolved to the highest matching version at runtime. LTS versions are preferred over non-LTS versions: + +``` +20 → 20.19.0 (highest 20.x LTS) +20.18 → 20.18.3 (highest 20.18.x) +22 → 22.13.0 (highest 22.x LTS) +``` + +### Semver Ranges + +Standard npm/node-semver range syntax is supported. LTS versions are preferred within the matching range: + +``` +^20.0.0 → 20.19.0 (highest 20.x.x LTS) +~20.18.0 → 20.18.3 (highest 20.18.x) +>=20 <22 → 20.19.0 (highest in range, LTS preferred) +18 || 20 → 20.19.0 (highest LTS in either range) +18.x → 18.20.5 (highest 18.x) +``` + +### LTS Aliases + +LTS (Long Term Support) versions can be specified using special aliases, following the pattern established by nvm and actions/setup-node: + +**`lts/*`** - Resolves to the latest (highest version number) LTS version: + +``` +lts/* → 22.13.0 (latest LTS as of 2025) +``` + +**`lts/`** - Resolves to the highest version in a specific LTS line: + +``` +lts/iron → 20.19.0 (highest v20.x) +lts/jod → 22.13.0 (highest v22.x) +lts/hydrogen → 18.20.5 (highest v18.x) +lts/krypton → 24.x.x (when available) +``` + +Codenames are case-insensitive (`lts/Iron` and `lts/iron` both work). + +**`lts/-n`** - Resolves to the nth-highest LTS line (useful for testing against older supported versions): + +``` +lts/-1 → 20.19.0 (second-highest LTS, when latest is 22.x) +lts/-2 → 18.20.5 (third-highest LTS) +``` + +### LTS Codename Reference + +| Codename | Major Version | LTS Status | +| -------- | ------------- | ---------------------------- | +| Hydrogen | 18.x | Maintenance until 2025-04-30 | +| Iron | 20.x | Active LTS until 2026-04-30 | +| Jod | 22.x | Active LTS until 2027-04-30 | +| Krypton | 24.x | Will be LTS starting 2025-10 | + +New LTS codenames are added dynamically based on the Node.js release schedule. vite-plus fetches the version index from nodejs.org to resolve codenames, ensuring new LTS versions are supported automatically. + +### Version Resolution Priority + +When resolving which Node.js version to use, vite-plus checks the following sources in order: + +0. **`VITE_PLUS_NODE_VERSION` env var** (session override, highest priority) + - Set by `vp env use` via shell wrapper eval + - Overrides all file-based resolution + +1. **`.session-node-version`** file (session override) + - Written by `vp env use` to `~/.vite-plus/.session-node-version` + - Works without shell eval wrapper (CI environments) + - Deleted by `vp env use --unset` + +2. **`.node-version`** file + - Checked in current directory, then parent directories + - Simple format: one version per file + +3. **`package.json#engines.node`** + - Checked in current directory, then parent directories + - Standard npm constraint field + +4. **`package.json#devEngines.runtime`** + - Checked in current directory, then parent directories + - npm RFC-compliant development engines spec + +5. **User default** (`~/.vite-plus/config.json`) + - Set via `vp env default ` + +6. **System default** (latest LTS) + - Fallback when no version source is found + +### Cache Behavior + +Version resolution results are cached for performance: + +- **Exact versions**: Cached until the source file mtime changes +- **Range versions** (partial, semver, LTS aliases): Cached with 1-hour TTL, then re-resolved to pick up new releases + +This ensures that: + +- Exact version pins are fast and deterministic +- Range specifications can pick up new releases (e.g., `20` will use a newly released `20.20.0`) +- LTS aliases automatically use newer patch versions + +### File Format Compatibility + +The `.node-version` file format is intentionally simple and compatible with other tools: + +``` +# Supported content (one per file): +20.18.0 +v20.18.0 +20 +lts/* +lts/iron +^20.0.0 + +# Comments are NOT supported +# Leading/trailing whitespace is trimmed +# Only the first line is used +``` + +**Compatibility matrix:** + +| Tool | `.node-version` | `.nvmrc` | LTS aliases | Semver ranges | +| ------------------ | --------------- | -------- | ----------- | ------------- | +| vite-plus | ✅ | ✅ | ✅ | ✅ | +| nvm | ❌ | ✅ | ✅ | ✅ | +| fnm | ✅ | ✅ | ✅ | ✅ | +| volta | ✅ | ❌ | ❌ | ❌ | +| actions/setup-node | ✅ | ✅ | ✅ | ✅ | +| asdf | ✅ | ❌ | ❌ | ❌ | + +**Note**: Node.js binaries are stored in VITE_PLUS_HOME: + +- Linux/macOS: `~/.vite-plus/js_runtime/node/{version}/` +- Windows: `%USERPROFILE%\.vite-plus\js_runtime\node\{version}\` + +## Implementation Architecture + +### File Structure + +``` +crates/vite_global_cli/ +├── src/ +│ ├── main.rs # Entry point with shim detection +│ ├── cli.rs # Add Env command +│ ├── shim/ +│ │ ├── mod.rs # Shim module root +│ │ ├── dispatch.rs # Main shim dispatch logic +│ │ ├── exec.rs # Platform-specific execution +│ │ └── cache.rs # Resolution cache +│ └── commands/ +│ └── env/ +│ ├── mod.rs # Env command module +│ ├── config.rs # Configuration and version resolution +│ ├── setup.rs # setup subcommand implementation +│ ├── doctor.rs # doctor subcommand implementation +│ ├── which.rs # which subcommand implementation +│ ├── current.rs # --current implementation +│ ├── default.rs # default subcommand implementation +│ ├── on.rs # on subcommand implementation +│ ├── off.rs # off subcommand implementation +│ ├── pin.rs # pin subcommand implementation +│ ├── unpin.rs # unpin subcommand implementation +│ ├── list.rs # list subcommand implementation +│ └── use.rs # use subcommand implementation +``` + +### Shim Dispatch Flow + +1. Check `VITE_PLUS_BYPASS` environment variable → bypass to system tool (filters all listed directories from PATH) +2. Check `VITE_PLUS_TOOL_RECURSION` → if set, use passthrough mode +3. Check shim mode from config: + - If `system_first`: try system tool first, fallback to managed; appends own bin dir to `VITE_PLUS_BYPASS` before exec to prevent loops with multiple installations + - If `managed`: use vite-plus managed Node.js +4. Resolve version (with mtime-based caching) +5. Ensure Node.js is installed (download if needed) +6. Locate tool binary in the installed Node.js +7. Prepend real node bin dir to PATH for child processes +8. Set `VITE_PLUS_TOOL_RECURSION=1` to prevent recursion +9. Execute the tool (Unix: `execve`, Windows: spawn) + +### Shim Recursion Prevention + +To prevent infinite loops when shims invoke other shims, vite-plus uses environment variable markers: + +**Environment Variable**: `VITE_PLUS_TOOL_RECURSION` + +**Mechanism:** + +1. When a shim executes the real binary, it sets `VITE_PLUS_TOOL_RECURSION=1` +2. Subsequent shim invocations check this variable +3. If set, shims use **passthrough mode** (skip version resolution, use current PATH) +4. `vp env run` explicitly **removes** this variable to force re-evaluation + +**Environment Variable**: `VITE_PLUS_BYPASS` (PATH-style list) + +**SystemFirst Loop Prevention:** + +When multiple vite-plus installations exist in PATH and `system_first` mode is active, each installation could find the other's shim as the "system tool", causing an infinite exec loop. To prevent this: + +1. In `system_first` mode, before exec'ing the found system tool, the current installation appends its own bin directory to `VITE_PLUS_BYPASS` +2. The next installation sees `VITE_PLUS_BYPASS` is set and enters bypass mode via `find_system_tool()` +3. `find_system_tool()` filters all directories listed in `VITE_PLUS_BYPASS` (plus its own bin dir) from PATH +4. This ensures the search skips all known vite-plus bin directories and finds the real system binary (or errors cleanly) +5. `VITE_PLUS_BYPASS` is preserved through `vp env run` so loop protection remains active + +**Flow Diagram:** + +``` +User runs: node app.js + │ + ▼ +Shim checks VITE_PLUS_TOOL_RECURSION + │ + ├── Not set → Resolve version, set RECURSION=1, exec real node + │ + └── Set → Passthrough mode (use current PATH) +``` + +**Code Example:** + +```rust +const RECURSION_ENV_VAR: &str = "VITE_PLUS_TOOL_RECURSION"; + +fn execute_shim() { + if env::var(RECURSION_ENV_VAR).is_ok() { + // Passthrough: context already evaluated + execute_with_current_path(); + } else { + // First invocation: resolve version and set marker + let version = resolve_version(); + let path = build_path_for_version(version); + + env::set_var(RECURSION_ENV_VAR, "1"); + execute_with_path(path); + } +} + +fn execute_run_command() { + // Clear marker to force re-evaluation + env::remove_var(RECURSION_ENV_VAR); + + let version = parse_version_from_args(); + execute_with_version(version); +} +``` + +**Why This Matters:** + +- Prevents infinite loops when Node scripts spawn other Node processes +- Allows `vp env run` to override versions mid-execution +- Ensures consistent behavior in complex process trees + +## Design Decisions + +### 1. Single Binary with argv[0] Detection + +**Decision**: Use a single `vp` binary that detects shim mode from `argv[0]`. + +**Rationale**: + +- Simplifies upgrades (update one binary, refresh shims) +- Reduces disk usage vs separate binaries +- Consistent behavior across all tools +- Already proven pattern (used by fnm, volta) + +### 2. Symlinks for Shims (Unix) + +**Decision**: Use symlinks for all shims on Unix, pointing to the vp binary. + +**Rationale**: + +- Symlinks preserve argv[0] - executing a symlink sets argv[0] to the symlink path, not the target +- Proven pattern used by Volta successfully +- Single binary to maintain - update `current/bin/vp` and all shims work +- No binary accumulation issues (symlinks are just filesystem pointers) +- Relative symlinks (e.g., `../current/bin/vp`) work within the same directory tree + +### 3. Wrapper Scripts for Windows + +**Decision**: Use `.cmd` wrapper scripts on Windows that call `vp env run `. + +**Rationale**: + +- Windows PATH resolution prefers `.cmd` over `.exe` for extensionless commands +- Simple wrapper format: `vp env run npm %*` - no binary copies needed +- Same pattern as Volta (`volta run `) +- Single `vp.exe` binary to maintain in `current/bin/` +- No `VITE_PLUS_SHIM_TOOL` env var complexity - dispatch via `vp env run` command + +### 4. execve on Unix, spawn on Windows + +**Decision**: Use `execve` (process replacement) on Unix, `spawn` on Windows. + +**Rationale**: + +- `execve` preserves PID, signals, and process hierarchy on Unix +- Windows doesn't support `execve`-style process replacement +- `spawn` on Windows with proper exit code propagation is standard practice + +### 5. Separate VITE_PLUS_HOME from Cache + +**Decision**: Keep VITE_PLUS_HOME (bin, config) separate from cache (Node binaries). + +**Rationale**: + +- Cache uses XDG/platform-standard locations (already implemented) +- VITE_PLUS_HOME needs to be user-accessible for PATH configuration +- Allows clearing cache without breaking shim setup + +### 6. mtime-Based Cache Invalidation + +**Decision**: Invalidate resolution cache when version file mtime changes. + +**Rationale**: + +- Fast O(1) validation (stat call) +- No need to re-parse files on every invocation +- Content changes trigger mtime updates +- Simple and reliable + +## Error Handling + +### No Version File Found (Default Fallback) + +When no version file is found, vite-plus uses the configured default version: + +```bash +$ node -v +v20.18.0 # Uses user-configured default (set via 'vp env default 20.18.0') + +# If no default configured, uses latest LTS +$ node -v +v22.13.0 # Falls back to latest LTS +``` + +The resolution order is: + +1. `VITE_PLUS_NODE_VERSION` env var (session override) +2. `.session-node-version` file (session override) +3. `.node-version` in current or parent directories +4. `package.json#engines.node` in current or parent directories +5. `package.json#devEngines.runtime` in current or parent directories +6. **User Default**: Configured via `vp env default ` (stored in `~/.vite-plus/config.json`) +7. **System Default**: Latest LTS version + +### Installation Failure + +```bash +$ node -v +vp: Failed to install Node 20.18.0: Network error: connection refused +vp: Check your network connection and try again +vp: Or set VITE_PLUS_BYPASS=1 to use system node +``` + +### Tool Not Found + +```bash +$ npx vitest +vp: Tool 'npx' not found in Node 14.0.0 installation +vp: npx is available in Node 5.2.0+ +``` + +### PATH Misconfiguration + +```bash +$ vp env doctor +Installation + ✓ VITE_PLUS_HOME ~/.vite-plus + ✓ Bin directory exists + ✓ Shims node, npm, npx + +Configuration + ✓ Shim mode managed + +PATH + ✗ vp not in PATH + Expected: ~/.vite-plus/bin + + Add to your shell profile (~/.zshrc, ~/.bashrc, etc.): + + . "$HOME/.vite-plus/env" + + Then restart your terminal. + +... + +✗ Some issues found. Run the suggested commands to fix them. +``` + +## User Experience + +### First-Time Setup via Install Script + +**Note on Directory Structure:** + +- All binaries (vp CLI and shims): `~/.vite-plus/bin/` + +The global CLI installation script (`packages/global/install.sh`) will be updated to: + +1. Install the `vp` binary to `~/.vite-plus/current/bin/vp` +2. Create symlink `~/.vite-plus/bin/vp` → `../current/bin/vp` +3. Configure shell PATH to include `~/.vite-plus/bin` +4. Setup Node.js version manager based on environment: + - **CI environment**: Auto-enable (no prompt) + - **No system Node.js**: Auto-enable (no prompt) + - **Interactive with system Node.js**: Prompt user "Would you want Vite+ to manage Node.js versions?" +5. If already configured, skip silently + +```bash +$ curl -fsSL https://viteplus.dev/install.sh | sh + +Setting up VITE+(⚡︎)... + +Would you want Vite+ to manage Node.js versions? +Press Enter to accept (Y/n): + +✔ VITE+(⚡︎) successfully installed! + + The Unified Toolchain for the Web. + + Get started: + vp new Create a new project + vp env Manage Node.js versions + vp install Install dependencies + vp dev Start dev server + + Node.js is now managed by Vite+ (via vp env). + Run vp env doctor to verify your setup. + + Run vp help for more information. + + Note: Run `source ~/.zshrc` or restart your terminal. +``` + +### Manual Setup + +If user declines or needs to reconfigure: + +```bash +$ vp env setup + +Setting up vite-plus environment... + +Created shims: + /Users/user/.vite-plus/bin/node + /Users/user/.vite-plus/bin/npm + /Users/user/.vite-plus/bin/npx + +Add to your shell profile (~/.zshrc, ~/.bashrc, etc.): + + export PATH="/Users/user/.vite-plus/bin:$PATH" + +For IDE support (VS Code, Cursor), ensure bin directory is in system PATH: + - macOS: Add to ~/.profile or use launchd + - Linux: Add to ~/.profile for display manager integration + - Windows: System Properties → Environment Variables → Path + +Restart your terminal and IDE, then run 'vp env doctor' to verify. +``` + +### Doctor Output (Healthy) + +```bash +$ vp env doctor +Installation + ✓ VITE_PLUS_HOME ~/.vite-plus + ✓ Bin directory exists + ✓ Shims node, npm, npx + +Configuration + ✓ Shim mode managed + ✓ IDE integration env sourced in ~/.zshenv + +PATH + ✓ vp first in PATH + ✓ node ~/.vite-plus/bin/node (vp shim) + ✓ npm ~/.vite-plus/bin/npm (vp shim) + ✓ npx ~/.vite-plus/bin/npx (vp shim) + +Version Resolution + Directory /Users/user/projects/my-app + Source .node-version + Version 20.18.0 + ✓ Node binary installed + +✓ All checks passed +``` + +**Doctor Output with Session Override:** + +```bash +$ vp env doctor +... + +Configuration + ✓ Shim mode managed + ✓ IDE integration env sourced in ~/.zshenv + ⚠ Session override VITE_PLUS_NODE_VERSION=20.18.0 + Overrides all file-based resolution. + Run 'vp env use --unset' to remove. + ⚠ Session override (file) .session-node-version=20.18.0 + Written by 'vp env use'. Run 'vp env use --unset' to remove. + +... +``` + +**Doctor Output with System-First Mode:** + +```bash +$ vp env doctor +... + +Configuration + ✓ Shim mode system-first + System Node.js /usr/local/bin/node + ✓ IDE integration env sourced in ~/.zshenv + +... +``` + +**Doctor Output with System-First Mode (No System Node):** + +```bash +$ vp env doctor +... + +Configuration + ✓ Shim mode system-first + ⚠ System Node.js not found (will use managed) + +... +``` + +**Doctor Output (Unhealthy):** + +```bash +$ vp env doctor +Installation + ✓ VITE_PLUS_HOME ~/.vite-plus + ✗ Bin directory does not exist + ✗ Missing shims node, npm, npx + Run 'vp env setup' to create bin directory and shims. + +Configuration + ✓ Shim mode managed + +PATH + ✗ vp not in PATH + Expected: ~/.vite-plus/bin + + Add to your shell profile (~/.zshrc, ~/.bashrc, etc.): + + . "$HOME/.vite-plus/env" + + For fish shell, add to ~/.config/fish/config.fish: + + source "$HOME/.vite-plus/env.fish" + + Then restart your terminal. + + node not found + npm not found + npx not found + +Version Resolution + Directory /Users/user/projects/my-app + Source .node-version + Version 20.18.0 + ⚠ Node binary not installed + Version will be downloaded on first use. + +Conflicts + ⚠ nvm detected (NVM_DIR is set) + Consider removing other version managers from your PATH + to avoid version conflicts. + +IDE Setup + ⚠ GUI applications may not see shell PATH changes. + + macOS: + Add to ~/.zshenv or ~/.profile: + . "$HOME/.vite-plus/env" + Then restart your IDE to apply changes. + +✗ Some issues found. Run the suggested commands to fix them. +``` + +## Shell Configuration Reference + +This section documents shell configuration file behavior for PATH setup and troubleshooting. + +### Zsh Configuration Files + +| File | When Loaded | Use Case | +| ----------- | ------------------------------------------------------------------------ | ---------------------------------- | +| `.zshenv` | **Always** - every zsh instance (login, interactive, scripts, subshells) | PATH and environment variables | +| `.zprofile` | Login shells only | Login-time initialization | +| `.zshrc` | Interactive shells only | Aliases, functions, prompts | +| `.zlogin` | Login shells, after `.zshrc` | Commands after full initialization | + +**Loading Order (Login Interactive Shell):** + +``` +1. /etc/zshenv → System environment +2. ~/.zshenv → User environment (ALWAYS loaded) +3. /etc/zprofile → System login setup +4. ~/.zprofile → User login setup +5. /etc/zshrc → System interactive setup +6. ~/.zshrc → User interactive setup +7. /etc/zlogin → System login finalization +8. ~/.zlogin → User login finalization +``` + +**Key Point:** `.zshenv` is the **most reliable** location for PATH configuration because: + +- Loaded for ALL zsh instances including IDE-spawned processes +- Loaded even for non-interactive scripts and subshells + +### Bash Configuration Files + +| File | When Loaded | Use Case | +| --------------- | ---------------------------- | ----------------------------------------------- | +| `.bash_profile` | Login shells only | macOS Terminal, SSH sessions | +| `.bash_login` | Login shells only (fallback) | Used if `.bash_profile` absent | +| `.profile` | Login shells only (fallback) | Used if neither above exists; also read by `sh` | +| `.bashrc` | Interactive non-login shells | Linux terminal emulators, subshells | + +**Loading Order (Login Shell):** + +``` +1. /etc/profile → System profile +2. FIRST found of: → User profile (ONLY ONE is loaded) + - ~/.bash_profile + - ~/.bash_login + - ~/.profile +3. ~/.bashrc → ONLY if explicitly sourced by above +``` + +**Critical Behavior:** + +- Bash reads **only the first** profile file found (`.bash_profile` > `.bash_login` > `.profile`) +- `.bashrc` is **NOT automatically loaded** in login shells - the profile file must source it +- Standard pattern: `.bash_profile` should contain `source ~/.bashrc` + +### Fish Configuration Files + +Fish shell uses a simpler configuration model than bash/zsh. + +| File | When Loaded | Use Case | +| --------------------------------- | -------------------------------------------------------------- | -------------------------------- | +| `~/.config/fish/config.fish` | **Always** - every fish instance (login, interactive, scripts) | All configuration including PATH | +| `~/.config/fish/conf.d/*.fish` | **Always** - before config.fish | Modular configuration snippets | +| `~/.config/fish/functions/*.fish` | On-demand when function called | Autoloaded function definitions | + +**Key Points:** + +- Fish has **no distinction** between login and non-login shells for configuration +- `config.fish` is always loaded, similar to zsh's `.zshenv` +- This makes Fish more reliable for IDE integration than bash +- Universal variables (`set -U`) persist across sessions without config files + +**PATH Syntax:** + +```fish +# Fish uses different syntax than bash/zsh +set -gx PATH $HOME/.vite-plus/bin $PATH +``` + +### When Configuration Files May NOT Load + +| Scenario | Zsh Behavior | Bash Behavior | Fish Behavior | +| ------------------------ | --------------- | ----------------------------------- | -------------------- | +| Non-interactive scripts | Only `.zshenv` | **NOTHING** (unless `BASH_ENV` set) | `config.fish` loaded | +| IDE-launched processes | Only `.zshenv` | **NOTHING** (critical gap) | `config.fish` loaded | +| SSH sessions | All login files | `.bash_profile` only | `config.fish` loaded | +| Subshells | Only `.zshenv` | `.bashrc` (interactive) or nothing | `config.fish` loaded | +| macOS Terminal.app | All login files | `.bash_profile` → `.bashrc` | `config.fish` loaded | +| Linux terminal emulators | `.zshrc` | `.bashrc` only | `config.fish` loaded | + +### IDE Integration Challenges + +GUI-launched IDEs (VS Code, Cursor, JetBrains) have special PATH inheritance issues: + +**macOS:** + +- GUI apps inherit environment from `launchd`, not shell rc files +- IDE terminals may spawn login or non-login shells (varies by IDE settings) +- Solution: `.zshenv` for zsh; for bash, both `.bash_profile` and `.bashrc` needed + +**Linux:** + +- GUI apps inherit from display manager session +- `~/.profile` is often sourced by display managers (GDM, SDDM, etc.) +- Non-login terminals only read `.bashrc` + +**Windows:** + +- PATH is system/user environment variable +- No shell rc file complications + +### Install Script Shell Configuration + +The `install.sh` script configures PATH in multiple shell files for maximum compatibility: + +**For Zsh (`$SHELL` ends with `/zsh`):** + +- Adds to `~/.zshenv` - ensures all zsh instances see the PATH +- Adds to `~/.zshrc` - ensures PATH is at front for interactive shells + +**For Bash (`$SHELL` ends with `/bash`):** + +- Adds to `~/.bash_profile` - for login shells (macOS default) +- Adds to `~/.bashrc` - for interactive non-login shells (Linux default) +- Adds to `~/.profile` - fallback for systems without `.bash_profile` + +**For Fish (`$SHELL` ends with `/fish`):** + +- Adds to `~/.config/fish/config.fish` + +**Important Notes:** + +1. Only modifies files that **already exist** - does not create new rc files +2. Checks for existing PATH entry to avoid duplicates +3. Appends with comment marker: `# Vite+ bin (https://viteplus.dev)` + +### Troubleshooting PATH Issues + +**Symptom: `vp` not found after installation** + +1. Check which shell you're using: + + ```bash + echo $SHELL + ``` + +2. Verify the PATH entry was added: + + ```bash + # For zsh + grep "vite-plus" ~/.zshenv ~/.zshrc + + # For bash + grep "vite-plus" ~/.bash_profile ~/.bashrc ~/.profile + + # For fish + grep "vite-plus" ~/.config/fish/config.fish + ``` + +3. If no entry found, manually add to appropriate file: + + ```bash + # For zsh/bash - add this line: + export PATH="$HOME/.vite-plus/bin:$PATH" + + # For fish - add this line: + set -gx PATH $HOME/.vite-plus/bin $PATH + ``` + +4. Source the file or restart terminal: + ```bash + source ~/.zshrc # or ~/.bashrc + # For fish: source ~/.config/fish/config.fish + ``` + +**Symptom: IDE terminal doesn't see `vp` or `node`** + +1. For VS Code, check terminal profile settings (login shell recommended) +2. Ensure `~/.zshenv` contains the PATH entry (most reliable for zsh) +3. For bash users: may need to configure IDE to use login shell (`bash -l`) +4. Fish users: `config.fish` is always loaded, so PATH should work in IDEs +5. Run `vp env doctor` to diagnose PATH configuration + +**Symptom: Shell scripts can't find `node`** + +For bash scripts, non-interactive execution doesn't load rc files. Options: + +- Use `#!/usr/bin/env bash` with `BASH_ENV` set +- Source the rc file explicitly: `source ~/.bashrc` +- Use full path: `~/.vite-plus/bin/node` + +Note: Fish scripts (`#!/usr/bin/env fish`) always load `config.fish`, so this issue doesn't apply. + +### Default Version Command + +```bash +# Show current default version +$ vp env default +Default Node.js version: 20.18.0 + Set via: ~/.vite-plus/config.json + +# Set a specific version as default +$ vp env default 22.13.0 +✓ Default Node.js version set to 22.13.0 + +# Set to latest LTS +$ vp env default lts +✓ Default Node.js version set to lts (currently 22.13.0) + +# When no default is configured +$ vp env default +No default version configured. Using latest LTS (22.13.0). + Run 'vp env default ' to set a default. +``` + +### Shim Mode Commands + +The shim mode controls how shims resolve tools: + +| Mode | Description | +| ------------------- | ------------------------------------------------------------- | +| `managed` (default) | Shims always use vite-plus managed Node.js | +| `system_first` | Shims prefer system Node.js, fallback to managed if not found | + +```bash +# Enable managed mode (always use vite-plus Node.js) +$ vp env on +✓ Shim mode set to managed. + +Shims will now always use vite-plus managed Node.js. +Run 'vp env off' to prefer system Node.js instead. + +# Enable system-first mode (prefer system Node.js) +$ vp env off +✓ Shim mode set to system-first. + +Shims will now prefer system Node.js, falling back to managed if not found. +Run 'vp env on' to always use vite-plus managed Node.js. + +# If already in the requested mode +$ vp env on +Shim mode is already set to managed. +Shims will always use vite-plus managed Node.js. +``` + +**Use cases for system-first mode (`vp env off`)**: + +- When you have a system Node.js that you want to use by default +- When working on projects that don't need vite-plus version management +- When debugging version-related issues by comparing system vs managed Node.js + +### Which Command + +Shows the path to the tool binary that would be executed. The first line is always the bare path (pipe-friendly, copy-pastable). + +**Core tools** - shows the resolved Node.js binary path with version and resolution source: + +```bash +$ vp env which node +/Users/user/.vite-plus/js_runtime/node/20.18.0/bin/node + Version: 20.18.0 + Source: .node-version + +$ vp env which npm +/Users/user/.vite-plus/js_runtime/node/20.18.0/bin/npm + Version: 20.18.0 + Source: .node-version +``` + +When using session override: + +```bash +$ vp env which node +/Users/user/.vite-plus/js_runtime/node/18.20.0/bin/node + Version: 18.20.0 + Source: VITE_PLUS_NODE_VERSION (session) +``` + +**Global packages** - shows binary path plus package metadata: + +```bash +$ vp env which tsc +/Users/user/.vite-plus/packages/typescript/lib/node_modules/typescript/bin/tsc + Package: typescript@5.7.0 + Binaries: tsc, tsserver + Node: 20.18.0 + Installed: 2024-01-15 + +$ vp env which eslint +/Users/user/.vite-plus/packages/eslint/lib/node_modules/eslint/bin/eslint.js + Package: eslint@9.0.0 + Binaries: eslint + Node: 22.13.0 + Installed: 2024-02-20 +``` + +| Tool Type | Resolution | Output | +| --------------- | ----------------------------------- | -------------------------------------------------------------- | +| Core tools | Node.js version from project config | Binary path + Version + Source | +| Global packages | Package metadata lookup | Binary path + Package version + Node.js version + Install date | + +**Error cases:** + +```bash +# Unknown tool (not core tool, not in any global package) +$ vp env which unknown-tool +error: tool 'unknown-tool' not found +Not a core tool (node, npm, npx) or installed global package. +Run 'vp list -g' to see installed packages. + +# Node.js version not installed +$ vp env which node +error: node not found +Node.js 20.18.0 is not installed. +Run 'vp env install 20.18.0' to install it. + +# Global package binary missing +$ vp env which tsc +error: binary 'tsc' not found +Package typescript may need to be reinstalled. +Run 'vp install -g typescript' to reinstall. +``` + +## Pin Command + +The `vp env pin` command provides per-directory Node.js version pinning by managing `.node-version` files. + +### Behavior + +**Pinning a Version:** + +```bash +$ vp env pin 20.18.0 +✓ Pinned Node.js version to 20.18.0 + Created .node-version in /Users/user/projects/my-app +✓ Node.js 20.18.0 installed +``` + +**Pinning with Aliases:** + +Aliases (`lts`, `latest`) are resolved to exact versions at pin time for reproducibility: + +```bash +$ vp env pin lts +✓ Pinned Node.js version to 22.13.0 (resolved from lts) + Created .node-version in /Users/user/projects/my-app +✓ Node.js 22.13.0 installed +``` + +**Showing Current Pin:** + +```bash +$ vp env pin +Pinned version: 20.18.0 + Source: /Users/user/projects/my-app/.node-version + +# If no .node-version in current directory but found in parent +$ vp env pin +No version pinned in current directory. + Inherited: 22.13.0 from /Users/user/projects/.node-version + +# If no .node-version anywhere +$ vp env pin +No version pinned. + Using default: 20.18.0 (from ~/.vite-plus/config.json) +``` + +**Removing a Pin:** + +```bash +$ vp env pin --unpin +✓ Removed .node-version from /Users/user/projects/my-app + +# Alternative syntax +$ vp env unpin +✓ Removed .node-version from /Users/user/projects/my-app +``` + +### Version Format Support + +| Input | Written to File | Behavior | +| --------- | --------------- | -------------------------------- | +| `20.18.0` | `20.18.0` | Exact version | +| `20.18` | `20.18` | Latest 20.18.x at runtime | +| `20` | `20` | Latest 20.x.x at runtime | +| `lts` | `22.13.0` | Resolved at pin time | +| `latest` | `24.0.0` | Resolved at pin time | +| `^20.0.0` | `^20.0.0` | Semver range resolved at runtime | + +### Flags + +| Flag | Description | +| -------------- | ------------------------------------------------------- | +| `--unpin` | Remove the `.node-version` file | +| `--no-install` | Skip pre-downloading the pinned version | +| `--force` | Overwrite existing `.node-version` without confirmation | + +### Pre-download Behavior + +By default, `vp env pin` downloads the Node.js version immediately after pinning. Use `--no-install` to skip: + +```bash +$ vp env pin 20.18.0 --no-install +✓ Pinned Node.js version to 20.18.0 + Created .node-version in /Users/user/projects/my-app + Note: Version will be downloaded on first use. +``` + +### Overwrite Confirmation + +When a `.node-version` file already exists: + +```bash +$ vp env pin 22.13.0 +.node-version already exists with version 20.18.0 +Overwrite with 22.13.0? (y/n): y +✓ Pinned Node.js version to 22.13.0 +``` + +Use `--force` to skip confirmation: + +```bash +$ vp env pin 22.13.0 --force +✓ Pinned Node.js version to 22.13.0 +``` + +### Error Handling + +```bash +# Invalid version format +$ vp env pin invalid +Error: Invalid Node.js version: invalid + Use exact version (20.18.0), partial version (20), or semver range (^20.0.0) + +# Version doesn't exist +$ vp env pin 99.0.0 +Error: Node.js version 99.0.0 does not exist + Run 'vp env list-remote' to see available versions + +# Network error during alias resolution +$ vp env pin lts +Error: Failed to resolve 'lts': Network error + Check your network connection and try again +``` + +## Global Package Management + +vite-plus provides cross-Node-version global package management via `vp install -g`, `vp remove -g`, and `vp update -g`. Unlike `npm install -g` which installs into a Node-version-specific directory, vite-plus manages global packages independently so they persist across Node.js version changes. + +Note: `npm install -g` passes through to the real npm (Node-version-specific). Use `vp install -g` for vite-plus managed global packages. + +### How It Works + +When you run `vp install -g typescript`, vite-plus: + +1. Resolves the Node.js version (from `--node` flag or current directory) +2. Installs the package to `~/.vite-plus/packages/typescript/` +3. Records metadata (package version, Node version used, binaries) +4. Creates shims for each binary the package provides (`tsc`, `tsserver`) + +### Installation Flow + +``` +vp install -g typescript + │ + ▼ +Parse global flag → route to managed global install + │ + ▼ +Create staging: ~/.vite-plus/tmp/packages/typescript/ + │ + ▼ +Set npm_config_prefix → staging directory + │ + ▼ +Execute npm with modified environment + │ + ▼ +On success: +├── Move to: ~/.vite-plus/packages/typescript/ +├── Write config: ~/.vite-plus/packages/typescript.json +├── Create shims: ~/.vite-plus/bin/tsc, tsserver +└── Update shared NODE_PATH link +``` + +### Package Configuration File + +`~/.vite-plus/packages/typescript.json`: + +```json +{ + "name": "typescript", + "version": "5.7.0", + "platform": { + "node": "20.18.0", + "npm": "10.8.0" + }, + "bins": ["tsc", "tsserver"], + "manager": "npm", + "installedAt": "2024-01-15T10:30:00Z" +} +``` + +### Binary Execution + +When running `tsc`: + +1. Shim reads `~/.vite-plus/packages/typescript.json` +2. Loads the pinned platform (Node 20.18.0) +3. Constructs PATH with that Node version's bin directory +4. Sets NODE_PATH to include shared packages +5. Executes `~/.vite-plus/packages/typescript/lib/node_modules/.bin/tsc` + +### Installation with Specific Node.js Version + +```bash +# Install a global package (uses Node.js version from current directory) +vp install -g typescript + +# Install with a specific Node.js version +vp install -g --node 22 typescript +vp install -g --node 20.18.0 typescript +vp install -g --node lts typescript + +# Install multiple packages +vp install -g typescript eslint prettier +``` + +The `--node` flag allows you to specify which Node.js version to use for installation. If not provided, it resolves the version from the current directory (same as shim behavior). + +### Upgrade and Uninstall + +```bash +# Upgrade replaces the existing package +vp update -g typescript +vp install -g typescript@latest + +# Update all global packages +vp update -g + +# Uninstall removes package and shims +vp remove -g typescript +``` + +### Binary Conflict Handling + +When two packages provide the same binary name (e.g., both `eslint` and `eslint-v9` provide an `eslint` binary), vite-plus uses a **Volta-style hard fail** approach: + +#### Conflict Detection + +Each binary has a per-binary config file that tracks which package owns it: + +``` +~/.vite-plus/ + packages/ + typescript.json # Package metadata + eslint.json + bins/ # Per-binary config files + tsc.json # { "package": "typescript", ... } + tsserver.json + eslint.json # { "package": "eslint", ... } +``` + +**Binary config format** (`~/.vite-plus/bins/tsc.json`): + +```json +{ + "name": "tsc", + "package": "typescript", + "version": "5.7.0", + "nodeVersion": "20.18.0" +} +``` + +#### Default Behavior: Hard Fail + +When installing a package that provides a binary already owned by another package, the installation **fails with a clear error**: + +```bash +$ vp install -g eslint-v9 + Installing eslint-v9 globally... + +error: Executable 'eslint' is already installed by eslint + +Please remove eslint before installing eslint-v9, or use --force to auto-replace +``` + +This approach: + +- Prevents silent binary masking +- Makes conflicts explicit and visible +- Requires intentional user action to resolve + +#### Force Mode: Auto-Uninstall + +The `--force` flag automatically uninstalls the conflicting package before installing the new one: + +```bash +$ vp install -g --force eslint-v9 + Installing eslint-v9 globally... + Uninstalling eslint (conflicts with eslint-v9)... + Uninstalled eslint + Running npm install... + Installed eslint-v9 v9.0.0 + Binaries: eslint +``` + +**Important**: `--force` completely removes the conflicting package (not just the binary). This ensures a clean state without orphaned files. + +#### Two-Phase Uninstall + +Uninstall uses a resilient two-phase approach (inspired by Volta): + +1. **Phase 1**: Try to use `PackageMetadata` to get binary names +2. **Phase 2**: If metadata is missing, scan `bins/` directory for orphaned binary configs + +This allows recovery even if package metadata is corrupted or manually deleted. + +```bash +# Normal uninstall +$ vp remove -g typescript + Uninstalling typescript... + Uninstalled typescript + +# Recovery mode (if typescript.json is missing) +$ vp remove -g typescript + Uninstalling typescript... + Note: Package metadata not found, scanning for orphaned binaries... + Uninstalled typescript +``` + +#### Deterministic Binary Resolution + +Binary execution uses per-binary config for deterministic lookup: + +1. Check `~/.vite-plus/bins/{binary}.json` for owner package +2. Load package metadata to get Node.js version and binary path +3. If not found, the binary is not installed (no fallback scanning) + +This eliminates the non-deterministic behavior of filesystem iteration order. + +## Run Command + +The `vp env run` command executes a command with a specific Node.js version. It operates in two modes: + +1. **Explicit version mode**: When `--node` is provided, runs with the specified version +2. **Shim mode**: When `--node` is not provided and the command is a shim tool (node/npm/npx or global package), uses the same version resolution as Unix symlinks + +This is useful for: + +- Testing code against different Node versions +- Running one-off commands without changing project configuration +- CI/CD scripts that need explicit version control +- Windows shims (`.cmd` wrappers and Git Bash shell scripts call `vp env run `) + +### Usage + +```bash +# Shim mode: version resolved automatically (same as Unix symlinks) +vp env run node --version # Core tool - resolves from .node-version/package.json +vp env run npm install # Core tool +vp env run npx vitest # Core tool +vp env run tsc --version # Global package - uses Node.js from install time + +# Explicit version mode: run with specific Node version +vp env run --node 20.18.0 node app.js + +# Run with specific Node and npm versions +vp env run --node 22.13.0 --npm 10.8.0 npm install + +# Version can be semver range (resolved at runtime) +vp env run --node "^20.0.0" node -v + +# Run npm scripts +vp env run --node 18.20.0 npm test + +# Pass arguments to the command +vp env run --node 20 -- node --inspect app.js + +# Error: non-shim command without --node +vp env run python --version # Fails: --node required for non-shim tools +``` + +### Flags + +| Flag | Description | +| ------------------ | ----------------------------------------------------------------------------- | +| `--node ` | Node.js version to use (optional for shim tools, required for other commands) | +| `--npm ` | npm version to use (not yet implemented, uses bundled npm) | + +### Shim Mode Behavior + +When `--node` is **not provided** and the first command is a shim tool: + +- **Core tools (node, npm, npx)**: Version resolved from `.node-version`, `package.json#engines.node`, or default +- **Global packages (tsc, eslint, etc.)**: Uses the Node.js version that was used during `vp install -g` + +Both use the **exact same code path** as Unix symlinks (`shim::dispatch()`), ensuring identical behavior across platforms. This is how Windows `.cmd` wrappers and Git Bash shell scripts work. + +**Important**: The `VITE_PLUS_TOOL_RECURSION` environment variable is cleared before dispatch to ensure fresh version resolution, even when invoked from within a context where the variable is already set (e.g., when pnpm runs through the vite-plus shim). + +### Explicit Version Mode Behavior + +When `--node` **is provided**: + +1. **Version Resolution**: Specified versions are resolved to exact versions +2. **Auto-Install**: If the version isn't installed, it's downloaded automatically +3. **PATH Construction**: Constructs PATH with specified version's bin directory +4. **Recursion Reset**: Clears `VITE_PLUS_TOOL_RECURSION` to force context re-evaluation + +### Examples + +```bash +# Shim mode: same behavior as Unix symlinks +vp env run node -v # Uses version from project config +vp env run npm install # Uses same version +vp env run tsc --version # Global package + +# Test against multiple Node versions in CI +for version in 18 20 22; do + vp env run --node $version npm test +done + +# Run with exact version +vp env run --node 20.18.0 node -e "console.log(process.version)" +# Output: v20.18.0 + +# Debug with specific Node version +vp env run --node 22 -- node --inspect-brk app.js +``` + +### Use in Scripts + +```bash +#!/bin/bash +# test-matrix.sh + +VERSIONS="18.20.0 20.18.0 22.13.0" + +for v in $VERSIONS; do + echo "Testing with Node $v..." + vp env run --node "$v" npm test || exit 1 +done + +echo "All tests passed!" +``` + +## List Command (Local) + +The `vp env list` (alias `ls`) command displays locally installed Node.js versions. + +### Usage + +```bash +$ vp env list +* v18.20.0 +* v20.18.0 default +* v22.13.0 current +``` + +- Current version line is highlighted in cyan +- `current` and `default` markers are shown in dimmed text + +### Flags + +| Flag | Description | +| -------- | -------------- | +| `--json` | Output as JSON | + +### JSON Output + +```bash +$ vp env list --json +[ + {"version": "18.20.0", "current": false, "default": false}, + {"version": "20.18.0", "current": false, "default": true}, + {"version": "22.13.0", "current": true, "default": false} +] +``` + +### Empty State + +```bash +$ vp env list +No Node.js versions installed. + +Install a version with: vp env install +``` + +## List-Remote Command + +The `vp env list-remote` (alias `ls-remote`) command displays available Node.js versions from the registry. + +### Usage + +```bash +# List recent versions (default: last 10 major versions, ascending order) +$ vp env list-remote +v20.0.0 +v20.1.0 +... +v20.18.0 (Iron) +v22.0.0 +... +v22.13.0 (Jod) +v24.0.0 + +# List only LTS versions +$ vp env list-remote --lts + +# Filter by major version +$ vp env list-remote 20 + +# Show all versions +$ vp env list-remote --all + +# Sort newest first +$ vp env list-remote --sort desc +``` + +### Flags + +| Flag | Description | +| -------------------- | ----------------------------------- | +| `--lts` | Show only LTS versions | +| `--all` | Show all versions (not just recent) | +| `--json` | Output as JSON | +| `--sort ` | Sorting order (default: asc) | + +### JSON Output + +```bash +$ vp env list-remote --json +{ + "versions": [ + {"version": "24.0.0", "lts": false, "latest": true}, + {"version": "22.13.0", "lts": "Jod", "latest_lts": true}, + {"version": "22.12.0", "lts": "Jod", "latest_lts": false}, + ... + ] +} +``` + +### Current Command (JSON) + +```bash +$ vp env --current --json +{ + "version": "20.18.0", + "source": ".node-version", + "project_root": "/Users/user/projects/my-app", + "node_path": "/Users/user/.cache/vite-plus/js_runtime/node/20.18.0/bin/node", + "tool_paths": { + "node": "/Users/user/.cache/vite-plus/js_runtime/node/20.18.0/bin/node", + "npm": "/Users/user/.cache/vite-plus/js_runtime/node/20.18.0/bin/npm", + "npx": "/Users/user/.cache/vite-plus/js_runtime/node/20.18.0/bin/npx" + } +} +``` + +## Environment Variables + +| Variable | Description | Default | +| ------------------------------- | ----------------------------------------------------------------------------------------------- | -------------- | +| `VITE_PLUS_HOME` | Base directory for bin and config | `~/.vite-plus` | +| `VITE_PLUS_NODE_VERSION` | Session override for Node.js version (set by `vp env use`) | unset | +| `VITE_PLUS_LOG` | Log level: debug, info, warn, error | `warn` | +| `VITE_PLUS_DEBUG_SHIM` | Enable extra shim diagnostics | unset | +| `VITE_PLUS_BYPASS` | PATH-style list of bin dirs to skip when finding system tools; set `=1` to bypass shim entirely | unset | +| `VITE_PLUS_TOOL_RECURSION` | **Internal**: Prevents shim recursion | unset | +| `VITE_PLUS_ENV_USE_EVAL_ENABLE` | **Internal**: Set by shell wrappers to signal that `vp env use` output will be eval'd | unset | + +## Unix-Specific Considerations + +### Shim Structure + +``` +VITE_PLUS_HOME/ +├── bin/ +│ ├── vp -> ../current/bin/vp # Symlink to actual binary +│ ├── node -> ../current/bin/vp # Symlink to same binary +│ ├── npm -> ../current/bin/vp # Symlink to same binary +│ ├── npx -> ../current/bin/vp # Symlink to same binary +│ └── tsc -> ../current/bin/vp # Symlink for global package +└── current/ + └── bin/ + └── vp # The actual vp CLI binary +``` + +### How argv[0] Detection Works + +When a user runs `node`: + +1. Shell finds `~/.vite-plus/bin/node` in PATH +2. This is a symlink to `../current/bin/vp` +3. Kernel resolves symlink and executes `vp` binary +4. `argv[0]` is set to the invoking path: `node` (or full path) +5. `vp` binary extracts tool name from `argv[0]` (gets "node") +6. Dispatches to shim logic for node + +**Key Insight**: Symlinks preserve argv[0]. This is the same pattern Volta uses successfully. + +### Symlink Creation + +All shims use relative symlinks: + +```bash +# Core tools +ln -sf ../current/bin/vp ~/.vite-plus/bin/node +ln -sf ../current/bin/vp ~/.vite-plus/bin/npm +ln -sf ../current/bin/vp ~/.vite-plus/bin/npx + +# Global package binaries +ln -sf ../current/bin/vp ~/.vite-plus/bin/tsc +``` + +## Windows-Specific Considerations + +### Shim Structure + +``` +VITE_PLUS_HOME\ +├── bin\ +│ ├── vp # Shell script for Git Bash (calls vp.exe directly) +│ ├── vp.cmd # Wrapper for cmd.exe/PowerShell +│ ├── node # Shell script for Git Bash (calls vp env run node) +│ ├── node.cmd # Wrapper calling vp env run node +│ ├── npm # Shell script for Git Bash (calls vp env run npm) +│ ├── npm.cmd # Wrapper calling vp env run npm +│ ├── npx # Shell script for Git Bash (calls vp env run npx) +│ ├── npx.cmd # Wrapper calling vp env run npx +│ ├── tsc # Shell script for global package (Git Bash) +│ └── tsc.cmd # Wrapper for global package (cmd.exe/PowerShell) +└── current\ + └── bin\ + └── vp.exe # The actual vp CLI binary +``` + +### Shell Scripts for Git Bash + +Git Bash (MSYS2/MinGW) doesn't use Windows' PATHEXT mechanism, so it won't find `.cmd` files when you type a command without extension. Shell script wrappers (without extension) are created alongside all `.cmd` files. + +#### Why Not Symlinks? + +On Unix, shims are symlinks to the vp binary, which preserves argv[0] for tool detection. On Windows, we use explicit `vp env run ` calls instead of symlinks because: + +1. **Admin privileges required**: Windows symlinks need admin rights or Developer Mode +2. **Unreliable Git Bash support**: Symlink emulation varies by Git for Windows version +3. **Consistent with .cmd approach**: Both .cmd and shell scripts use the same dispatch pattern + +#### Wrapper Scripts + +**vp wrapper** (calls vp.exe directly): + +```sh +#!/bin/sh +VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" +export VITE_PLUS_HOME +exec "$VITE_PLUS_HOME/current/bin/vp.exe" "$@" +``` + +**Tool wrappers** (node, npm, npx - uses explicit dispatch): + +```sh +#!/bin/sh +VITE_PLUS_HOME="$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")" +export VITE_PLUS_HOME +exec "$VITE_PLUS_HOME/current/bin/vp.exe" env run node "$@" +``` + +This ensures all commands work in: + +- Git Bash +- WSL (if accessing Windows paths) +- Any POSIX-compatible shell on Windows + +### Wrapper Script Template (vp.cmd) + +```batch +@echo off +set VITE_PLUS_HOME=%~dp0.. +"%VITE_PLUS_HOME%\current\bin\vp.exe" %* +exit /b %ERRORLEVEL% +``` + +The `vp.cmd` wrapper forwards all arguments to the actual `vp.exe` binary. + +### Wrapper Script Template (node.cmd, npm.cmd, npx.cmd) + +```batch +@echo off +set VITE_PLUS_HOME=%~dp0.. +"%VITE_PLUS_HOME%\current\bin\vp.exe" env run node %* +exit /b %ERRORLEVEL% +``` + +For npm: + +```batch +@echo off +set VITE_PLUS_HOME=%~dp0.. +"%VITE_PLUS_HOME%\current\bin\vp.exe" env run npm %* +exit /b %ERRORLEVEL% +``` + +**How it works**: + +1. User runs `npm install` +2. Windows finds `~/.vite-plus/bin/npm.cmd` in PATH (cmd.exe/PowerShell) or `npm` (Git Bash) +3. Wrapper calls `vp.exe env run npm install` +4. `vp env run` command handles version resolution and execution + +**Benefits of this approach**: + +- Single `vp.exe` binary to update in `current\bin\` +- All shims are trivial `.cmd` text files and shell scripts (no binary copies) +- Consistent with Volta's Windows approach +- Clear, readable wrapper scripts +- Works in both cmd.exe/PowerShell and Git Bash + +### Windows Installation (install.ps1) + +The Windows installer (`install.ps1`) follows this flow: + +1. Download and install `vp.exe` to `~/.vite-plus/current/bin/` +2. Create `~/.vite-plus/bin/vp.cmd` wrapper script +3. Create `~/.vite-plus/bin/vp` shell script (for Git Bash) +4. Create shim wrappers: `node.cmd`, `npm.cmd`, `npx.cmd` (and corresponding shell scripts) +5. Configure User PATH to include `~/.vite-plus/bin` + +## Testing Strategy + +### Unit Tests + +- Tool name extraction from argv[0] +- Cache invalidation based on mtime +- PATH manipulation +- Shim mode loading + +### Integration Tests + +- Shim dispatch with version resolution +- Concurrent installation handling +- Doctor diagnostic output + +### Snap Tests + +Add snap tests in `packages/global/snap-tests/`: + +``` +env-setup/ +├── package.json +├── steps.json # [{"command": "vp env setup"}] +└── snap.txt + +env-doctor/ +├── package.json +├── .node-version # "20.18.0" +├── steps.json # [{"command": "vp env doctor"}] +└── snap.txt +``` + +### CI Matrix + +- ubuntu-latest: Full integration tests +- macos-latest: Full integration tests +- windows-latest: Full integration tests with .cmd wrapper validation + +## Security Considerations + +1. **Path Validation**: Verify executed binaries are under VITE_PLUS_HOME/cache paths +2. **No Path Traversal**: Sanitize version strings before path construction +3. **Atomic Installs**: Use temp directory + rename pattern (already implemented) +4. **Log Sanitization**: Don't log sensitive environment variables + +## Implementation Plan + +### Phase 1: Core Infrastructure (P0) + +1. Add `vp env` command structure to CLI +2. Implement argv[0] detection in main.rs +3. Implement shim dispatch logic for `node` +4. Implement `vp env setup` (Unix symlinks, Windows .cmd wrappers) +5. Implement `vp env doctor` basic diagnostics +6. Add resolution cache (persists across upgrades with version field) +7. Implement `vp env default [version]` to set/show global default Node.js version +8. Implement `vp env on` and `vp env off` for shim mode control +9. Implement `vp env pin [version]` for per-directory version pinning +10. Implement `vp env unpin` as alias for `pin --unpin` +11. Implement `vp env list` (local) and `vp env list-remote` (remote) to show versions +12. Implement recursion prevention (`VITE_PLUS_TOOL_RECURSION`) +13. Implement `vp env run --node ` command + +### Phase 2: Full Tool Support (P1) + +1. Add shims for `npm`, `npx` +2. Implement `vp env which` +3. Implement `vp env --current --json` +4. Enhanced doctor with conflict detection +5. Implement `vp install -g` / `vp remove -g` / `vp update -g` for managed global packages +6. Implement package metadata storage +7. Implement per-package binary shims +8. Implement `vp list -g` / `vp pm list -g` to list installed global packages +9. Implement `vp env install ` to install Node.js versions +10. Implement `vp env uninstall ` to uninstall Node.js versions +11. Implement per-binary config files (`bins/`) for conflict detection +12. Implement binary conflict detection (hard fail by default) +13. Implement `--force` flag for auto-uninstall on conflict +14. Implement two-phase uninstall with orphan recovery + +### Phase 3: Polish (P2) + +1. Implement `vp env --print` for session-only env +2. Add VITE_PLUS_BYPASS escape hatch +3. Improve error messages +4. Add IDE-specific setup guidance +5. Documentation + +### Phase 4: Future Enhancements (P3) + +1. NODE_PATH setup for shared package resolution + +## Backward Compatibility + +This is a new feature with no impact on existing functionality. The `vp` binary continues to work normally when invoked directly. + +## Future Enhancements + +1. **Multiple Runtime Support**: Extend shim architecture for other runtimes (Bun, Deno) +2. **SQLite Cache**: Replace JSON cache with SQLite for better performance at scale +3. **Shell Integration**: Provide shell hooks for prompt version display + +## Design Decisions Summary + +The following decisions have been made: + +1. **VITE_PLUS_HOME Default Location**: `~/.vite-plus` - Simple, memorable path that's easy for users to find and configure. + +2. **Windows Wrapper Strategy**: `.cmd` wrappers that call `vp env run ` - Consistent with Volta, no binary copies needed. + +3. **Corepack Handling**: Not included - vite-plus has integrated package manager functionality, making corepack shims unnecessary. + +4. **Cache Persistence**: Persist across upgrades - Better performance, with cache format versioning for compatibility. + +## Conclusion + +The `vp env` command provides: + +- ✅ System-wide Node version management via shims +- ✅ IDE-safe operation (works with GUI-launched apps) +- ✅ Zero daily friction (automatic version switching) +- ✅ Cross-platform support (Windows, macOS, Linux) +- ✅ Comprehensive diagnostics (`doctor`) +- ✅ Flexible shim mode control (`on`/`off` for managed vs system-first) +- ✅ Easy version pinning per project (`pin`/`unpin`) +- ✅ Version discovery with `list` command +- ✅ Leverages existing version resolution and installation infrastructure diff --git a/rfcs/global-cli-rust-binary.md b/rfcs/global-cli-rust-binary.md index eed638bd6e..c17d9d901f 100644 --- a/rfcs/global-cli-rust-binary.md +++ b/rfcs/global-cli-rust-binary.md @@ -236,7 +236,7 @@ Only these commands can run without any Node.js: │ 1. Read package.json from provided path │ │ 2. Extract devEngines.runtime.version │ │ 3. Resolve semver range if needed │ -│ 4. Check cache (~/.cache/vite/js_runtime/node/{version}/) │ +│ 4. Check cache (~/.vite-plus/js_runtime/node/{version}/) │ │ 5. Download Node.js if not cached │ │ 6. Return JsRuntime with binary path │ │ │ @@ -499,8 +499,8 @@ thiserror = "1" The global CLI will use the same configuration locations as the current CLI: -- **Cache directory**: `~/.cache/vite/` (via `vite_shared::cache_dir`) -- **Node.js runtime**: `~/.cache/vite/js_runtime/node/{version}/` +- **Home directory**: `~/.vite-plus/` (via `vite_shared::get_vite_plus_home`) +- **Node.js runtime**: `~/.vite-plus/js_runtime/node/{version}/` - **Package manager**: Auto-detected from lockfile or package.json ### JS Runtime Version Management diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index f6a3470e80..9506e93b0b 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -217,21 +217,21 @@ let runtime = download_runtime_for_project(&project_path).await?; Following the PackageManager pattern: ``` -$CACHE_DIR/vite/js_runtime/{runtime}/{version}/ +$VITE_PLUS_HOME/js_runtime/{runtime}/{version}/ ``` Examples: -- Linux x64: `~/.cache/vite/js_runtime/node/22.13.1/` -- macOS ARM: `~/Library/Caches/vite/js_runtime/node/22.13.1/` -- Windows x64: `%LOCALAPPDATA%\vite\js_runtime\node\22.13.1\` +- Linux x64: `~/.vite-plus/js_runtime/node/22.13.1/` +- macOS ARM: `~/.vite-plus/js_runtime/node/22.13.1/` +- Windows x64: `%USERPROFILE%\.vite-plus\js_runtime\node\22.13.1\` ### Version Index Cache The Node.js version index is cached locally to avoid repeated network requests: ``` -$CACHE_DIR/vite/js_runtime/node/index_cache.json +$VITE_PLUS_HOME/js_runtime/node/index_cache.json ``` Cache structure: