diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 445eb9d72a..0559954d02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,7 +135,7 @@ jobs: - name: Build CLI run: | pnpm bootstrap-cli:ci - echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH - name: Print help for built-in commands run: | @@ -198,9 +198,9 @@ jobs: run: | pnpm bootstrap-cli:ci if [[ "$RUNNER_OS" == "Windows" ]]; then - echo "$USERPROFILE\.vite-plus-dev\bin" >> $GITHUB_PATH + echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH else - echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH fi - name: Verify CLI installation @@ -239,16 +239,16 @@ jobs: 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\" + Get-ChildItem "$env:USERPROFILE\.vite-plus\packages\typescript\" + Get-ChildItem "$env:USERPROFILE\.vite-plus\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" + Get-ChildItem "$env:USERPROFILE\.vite-plus\bin\" + $shimPath = "$env:USERPROFILE\.vite-plus\bin\tsc.cmd" if (Test-Path $shimPath) { Write-Error "tsc shim file still exists at $shimPath" exit 1 @@ -285,23 +285,23 @@ jobs: 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\" + dir "%USERPROFILE%\.vite-plus\packages\typescript\" + dir "%USERPROFILE%\.vite-plus\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" ( + dir "%USERPROFILE%\.vite-plus\bin\" + if exist "%USERPROFILE%\.vite-plus\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" ( + if exist "%USERPROFILE%\.vite-plus\bin\tsc" ( echo Error: tsc shell script still exists exit /b 1 ) @@ -317,8 +317,8 @@ jobs: - name: Test global package install (bash) run: | echo "PATH: $PATH" - ls -la ~/.vite-plus-dev/ - ls -la ~/.vite-plus-dev/bin/ + ls -la ~/.vite-plus/ + ls -la ~/.vite-plus/bin/ which node which npm which npx @@ -331,17 +331,17 @@ jobs: which tsc # Test 2: Verify the package was installed correctly - ls -la ~/.vite-plus-dev/packages/typescript/ - ls -la ~/.vite-plus-dev/bin/ + ls -la ~/.vite-plus/packages/typescript/ + ls -la ~/.vite-plus/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" + ls -la ~/.vite-plus/bin/ + if [ -f ~/.vite-plus/bin/tsc ]; then + echo "Error: tsc shim file still exists at ~/.vite-plus/bin/tsc" exit 1 fi echo "tsc shim removed successfully" @@ -361,6 +361,197 @@ jobs: RUST_BACKTRACE=1 pnpm test git diff --exit-code + cli-self-update: + name: CLI self-update test + needs: + - download-previous-rolldown-binaries + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + - os: namespace-profile-mac-default + - os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: ./.github/actions/clone + + - 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/" + + - run: | + brew install rustup + rustup install stable + echo "PATH=/opt/homebrew/opt/rustup/bin:$PATH" >> $GITHUB_ENV + if: ${{ matrix.os == 'namespace-profile-mac-default' }} + + - uses: oxc-project/setup-rust@d286d43bc1f606abbd98096666ff8be68c8d5f57 # v1.0.0 + with: + save-cache: ${{ github.ref_name == 'main' }} + cache-key: cli-self-update + + - uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4 + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: rolldown-binaries + path: ./rolldown/packages/rolldown/src + merge-multiple: true + + - name: Build with upstream + uses: ./.github/actions/build-upstream + with: + target: ${{ matrix.os == 'ubuntu-latest' && 'x86_64-unknown-linux-gnu' || matrix.os == 'windows-latest' && 'x86_64-pc-windows-msvc' || 'aarch64-apple-darwin' }} + + - name: Build CLI + run: | + pnpm bootstrap-cli:ci + if [[ "$RUNNER_OS" == "Windows" ]]; then + echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH + else + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH + fi + + - name: Verify CLI installation + run: | + which vp + vp --version + + - name: Test self-update (bash) + shell: bash + run: | + # Helper to read the installed CLI version from package.json + get_cli_version() { + node -p "require(require('path').resolve(process.env.USERPROFILE || process.env.HOME, '.vite-plus', 'current', 'package.json')).version" + } + + # Save initial (dev build) version + INITIAL_VERSION=$(get_cli_version) + echo "Initial version: $INITIAL_VERSION" + + # --check queries npm registry and prints update status + vp self-update --check + # upgrade alias should also work + vp upgrade --check + + # full self-update: download, extract, swap + vp self-update --tag test --force + vp --version + vp env doctor + + ls -la ~/.vite-plus/ + + # Verify version changed after update + UPDATED_VERSION=$(get_cli_version) + echo "Updated version: $UPDATED_VERSION" + if [ "$UPDATED_VERSION" == "$INITIAL_VERSION" ]; then + echo "Error: version should have changed after self-update (still $INITIAL_VERSION)" + exit 1 + fi + + # rollback to the previous version + vp self-update --rollback + vp --version + vp env doctor + + # Verify version restored after rollback + ROLLBACK_VERSION=$(get_cli_version) + echo "Rollback version: $ROLLBACK_VERSION" + if [ "$ROLLBACK_VERSION" != "$INITIAL_VERSION" ]; then + echo "Error: version should have been restored after rollback (expected $INITIAL_VERSION, got $ROLLBACK_VERSION)" + exit 1 + fi + + - name: Test self-update (powershell) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + Get-ChildItem "$env:USERPROFILE\.vite-plus\" + + # Helper to read the installed CLI version from package.json + function Get-CliVersion { + node -p "require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'package.json')).version" + } + + # Save initial (dev build) version + $initialVersion = Get-CliVersion + Write-Host "Initial version: $initialVersion" + + # --check queries npm registry and prints update status + vp self-update --check + # upgrade alias should also work + vp upgrade --check + + # full self-update: download, extract, swap + vp self-update --tag test --force + vp --version + vp env doctor + + Get-ChildItem "$env:USERPROFILE\.vite-plus\" + + # Verify version changed after update + $updatedVersion = Get-CliVersion + Write-Host "Updated version: $updatedVersion" + if ($updatedVersion -eq $initialVersion) { + Write-Error "Error: version should have changed after self-update (still $initialVersion)" + exit 1 + } + + # rollback to the previous version + vp self-update --rollback + vp --version + vp env doctor + + # Verify version restored after rollback + $rollbackVersion = Get-CliVersion + Write-Host "Rollback version: $rollbackVersion" + if ($rollbackVersion -ne $initialVersion) { + Write-Error "Error: version should have been restored after rollback (expected $initialVersion, got $rollbackVersion)" + exit 1 + } + + - name: Test self-update (cmd) + if: matrix.os == 'windows-latest' + shell: cmd + run: | + REM Save initial (dev build) version + for /f "usebackq delims=" %%v in (`node -p "require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'package.json')).version"`) do set INITIAL_VERSION=%%v + echo Initial version: %INITIAL_VERSION% + + REM --check queries npm registry and prints update status + vp self-update --check + REM upgrade alias should also work + vp upgrade --check + + REM full self-update: download, extract, swap + vp self-update --tag test --force + vp --version + vp env doctor + + dir "%USERPROFILE%\.vite-plus\" + + REM Verify version changed after update + for /f "usebackq delims=" %%v in (`node -p "require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'package.json')).version"`) do set UPDATED_VERSION=%%v + echo Updated version: %UPDATED_VERSION% + if "%UPDATED_VERSION%"=="%INITIAL_VERSION%" ( + echo Error: version should have changed after self-update, still %INITIAL_VERSION% + exit /b 1 + ) + + REM rollback to the previous version + vp self-update --rollback + vp --version + vp env doctor + + REM Verify version restored after rollback + for /f "usebackq delims=" %%v in (`node -p "require(require('path').resolve(process.env.USERPROFILE, '.vite-plus', 'current', 'package.json')).version"`) do set ROLLBACK_VERSION=%%v + echo Rollback version: %ROLLBACK_VERSION% + if not "%ROLLBACK_VERSION%"=="%INITIAL_VERSION%" ( + echo Error: version should have been restored after rollback, expected %INITIAL_VERSION%, got %ROLLBACK_VERSION% + exit /b 1 + ) + install-e2e-test: name: Local CLI `vite install` E2E test needs: @@ -398,7 +589,7 @@ jobs: - name: Build CLI run: | pnpm bootstrap-cli:ci - echo "$HOME/.vite-plus-dev/bin" >> $GITHUB_PATH + echo "$HOME/.vite-plus/bin" >> $GITHUB_PATH - name: Run local CLI `vite install` run: | @@ -440,6 +631,7 @@ jobs: - lint - run - cli-e2e-test + - cli-self-update steps: - run: exit 1 # Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379 diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 30464c94d7..4cdb3310df 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -266,8 +266,8 @@ jobs: - 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 + node $GITHUB_WORKSPACE/packages/tools/src/install-global-cli.ts --tgz $GITHUB_WORKSPACE/tmp/tgz/vite-plus-cli-0.0.0.tgz + echo "$HOME/.vite-plus/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) || '' }} diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 5e7d8047b4..7cf3e6ba99 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -103,6 +103,16 @@ jobs: which npx which vp + - name: Verify self-update + run: | + # --check queries npm registry and prints update status + vp self-update --check + vp self-update 0.0.0-b356849c.20260207-0631 + vp --version + # rollback to the previous version (should succeed after a real update) + vp self-update --rollback + vp --version + test-install-sh-arm64: name: Test install.sh (Linux ARM64 glibc via QEMU) runs-on: ubuntu-latest @@ -155,6 +165,13 @@ jobs: export VITE_LOG=trace vp env run --node 24 -- node -p \"process.versions\" + # Verify self-update + vp self-update --check + vp self-update 0.0.0-b356849c.20260207-0631 + vp --version + vp self-update --rollback + vp --version + # 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 @@ -180,6 +197,17 @@ jobs: run: | echo "$USERPROFILE\.vite-plus\bin" >> $GITHUB_PATH + - name: Verify self-update + shell: pwsh + run: | + # --check queries npm registry and prints update status + vp self-update --check + vp self-update 0.0.0-b356849c.20260207-0631 + vp --version + # rollback to the previous version (should succeed after a real update) + vp self-update --rollback + vp --version + - name: Verify installation on powershell shell: pwsh working-directory: ${{ runner.temp }} @@ -229,6 +257,7 @@ jobs: working-directory: ${{ runner.temp }} run: | echo PATH: %PATH% + dir "%USERPROFILE%\.vite-plus" dir "%USERPROFILE%\.vite-plus\bin" REM test new command diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46dfa799f2..329bc6f8e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,12 +33,10 @@ just build ``` pnpm bootstrap-cli -vp-dev --version +vp --version ``` -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. +This installs the CLI to `~/.vite-plus` and creates the `vp` binary. ## Workflow for build and test diff --git a/Cargo.lock b/Cargo.lock index c7ba236162..5009180072 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2713,6 +2713,16 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "junction" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642883fdc81cf2da15ee8183fa1d2c7da452414dd41541a0f3e1428069345447" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + [[package]] name = "konst" version = "0.2.19" @@ -7129,12 +7139,18 @@ dependencies = [ name = "vite_global_cli" version = "0.0.0" dependencies = [ + "base64-simd", "chrono", "clap", + "flate2", + "junction", + "node-semver", "owo-colors", "serde", "serde_json", "serial_test", + "sha2", + "tar", "tempfile", "thiserror 2.0.17", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 479eff45aa..440930b763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ itoa = "1.0.15" json-escape-simd = "3" json-strip-comments = "3" jsonschema = { version = "0.38.0", default-features = false } +junction = "1.4.1" memchr = "2.7.4" mimalloc-safe = "0.1.52" mime = "0.3.17" diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index b652f4839c..35d6a3a48a 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -12,10 +12,15 @@ name = "vp" path = "src/main.rs" [dependencies] +base64-simd = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } +flate2 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +node-semver = { workspace = true } +sha2 = { workspace = true } +tar = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } @@ -29,6 +34,9 @@ vite_str = { workspace = true } vite_workspace = { workspace = true } which = { workspace = true } +[target.'cfg(windows)'.dependencies] +junction = { workspace = true } + [dev-dependencies] serial_test = { workspace = true } tempfile = { workspace = true } diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 40d0bed8b5..9e1eea0d22 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -588,6 +588,40 @@ pub enum Commands { /// Manage Node.js versions Env(EnvArgs), + + // ========================================================================= + // Self-Management + // ========================================================================= + /// Update vp itself to the latest version + #[command(name = "self-update", visible_alias = "upgrade")] + SelfUpdate { + /// Target version (e.g., "0.2.0"). Defaults to latest. + version: Option, + + /// npm dist-tag to install (default: "latest", also: "test") + #[arg(long, default_value = "latest")] + tag: String, + + /// Check for updates without installing + #[arg(long)] + check: bool, + + /// Revert to the previously active version + #[arg(long)] + rollback: bool, + + /// Force reinstall even if already on the target version + #[arg(long)] + force: bool, + + /// Suppress output + #[arg(long)] + silent: bool, + + /// Custom npm registry URL + #[arg(long)] + registry: Option, + }, } /// Arguments for the `env` command @@ -1511,6 +1545,20 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result commands::delegate::execute(cwd, "cache", &args).await, Commands::Env(args) => commands::env::execute(cwd, args).await, + + // Self-Management + Commands::SelfUpdate { version, tag, check, rollback, force, silent, registry } => { + commands::self_update::execute(commands::self_update::SelfUpdateOptions { + version, + tag, + check, + rollback, + force, + silent, + registry, + }) + .await + } } } @@ -1568,6 +1616,9 @@ fn apply_custom_help(cmd: clap::Command) -> clap::Command { {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 + +{bold_underline}Maintenance Commands:{reset} + {bold}self-update, upgrade{reset} Update vp itself to the latest version " ); let help_template = format!( diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 734ae6438c..6d6bf0ad46 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -101,6 +101,9 @@ pub mod version; // Category D: Environment Management pub mod env; +// Self-Management +pub mod self_update; + // Category C: Local CLI Delegation pub mod delegate; diff --git a/crates/vite_global_cli/src/commands/self_update/install.rs b/crates/vite_global_cli/src/commands/self_update/install.rs new file mode 100644 index 0000000000..f6bdea5fa5 --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/install.rs @@ -0,0 +1,504 @@ +//! Installation logic for self-update. +//! +//! Handles tarball extraction, dependency installation, symlink swapping, +//! and version cleanup. + +use std::{ + io::{Cursor, Read as _}, + path::Path, +}; + +use flate2::read::GzDecoder; +use tar::Archive; +use vite_path::{AbsolutePath, AbsolutePathBuf}; + +use crate::error::Error; + +/// Validate that a path from a tarball entry is safe (no path traversal). +/// +/// Returns `false` if the path contains `..` components or is absolute. +fn is_safe_tar_path(path: &Path) -> bool { + // Also check for Unix-style absolute paths, since tar archives always use forward + // slashes and `Path::is_absolute()` on Windows only recognizes `C:\...` style paths. + let starts_with_slash = path.to_string_lossy().starts_with('/'); + !path.is_absolute() + && !starts_with_slash + && !path.components().any(|c| matches!(c, std::path::Component::ParentDir)) +} + +/// Files/directories to extract from the main package tarball. +const MAIN_PACKAGE_ENTRIES: &[&str] = + &["dist/", "templates/", "rules/", "AGENTS.md", "package.json"]; + +/// Extract the platform-specific package (binary + .node files). +/// +/// From the platform tarball, extracts: +/// - The `vp` binary → `{version_dir}/bin/vp` +/// - Any `.node` files → `{version_dir}/dist/` +pub async fn extract_platform_package( + tgz_data: &[u8], + version_dir: &AbsolutePath, +) -> Result<(), Error> { + let bin_dir = version_dir.join("bin"); + let dist_dir = version_dir.join("dist"); + tokio::fs::create_dir_all(&bin_dir).await?; + tokio::fs::create_dir_all(&dist_dir).await?; + + let data = tgz_data.to_vec(); + let bin_dir_clone = bin_dir.clone(); + let dist_dir_clone = dist_dir.clone(); + + tokio::task::spawn_blocking(move || { + let cursor = Cursor::new(data); + let decoder = GzDecoder::new(cursor); + let mut archive = Archive::new(decoder); + + for entry_result in archive.entries()? { + let mut entry = entry_result?; + let path = entry.path()?.to_path_buf(); + + // Strip the leading `package/` prefix that npm tarballs have + let relative = path.strip_prefix("package").unwrap_or(&path).to_path_buf(); + + // Reject paths with traversal components (security) + if !is_safe_tar_path(&relative) { + continue; + } + + let file_name = relative.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if file_name == "vp" || file_name == "vp.exe" { + // Binary goes to bin/ + let target = bin_dir_clone.join(file_name); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + std::fs::write(&target, &buf)?; + + // Set executable permission on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))?; + } + } else if file_name.ends_with(".node") { + // .node NAPI files go to dist/ + let target = dist_dir_clone.join(file_name); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + std::fs::write(&target, &buf)?; + } + } + + Ok::<(), Error>(()) + }) + .await + .map_err(|e| Error::SelfUpdate(format!("Task join error: {e}").into()))??; + + Ok(()) +} + +/// Extract the main package (JS bundles, templates, rules, package.json). +/// +/// Copies specific directories and files from the tarball to the version directory. +pub async fn extract_main_package( + tgz_data: &[u8], + version_dir: &AbsolutePath, +) -> Result<(), Error> { + let version_dir_owned = version_dir.as_path().to_path_buf(); + let data = tgz_data.to_vec(); + + tokio::task::spawn_blocking(move || { + let cursor = Cursor::new(data); + let decoder = GzDecoder::new(cursor); + let mut archive = Archive::new(decoder); + + for entry_result in archive.entries()? { + let mut entry = entry_result?; + let path = entry.path()?.to_path_buf(); + + // Strip the leading `package/` prefix + let relative = path.strip_prefix("package").unwrap_or(&path).to_path_buf(); + + // Reject paths with traversal components (security) + if !is_safe_tar_path(&relative) { + continue; + } + + let relative_str = relative.to_string_lossy(); + + // Check if this entry matches our allowed list + let should_extract = MAIN_PACKAGE_ENTRIES.iter().any(|allowed| { + if allowed.ends_with('/') { + // Directory prefix match + relative_str.starts_with(allowed) + } else { + // Exact file match + relative_str == *allowed + } + }); + + if !should_extract { + continue; + } + + let target = version_dir_owned.join(&*relative_str); + + if entry.header().entry_type().is_dir() { + std::fs::create_dir_all(&target)?; + } else { + // Ensure parent directory exists + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + std::fs::write(&target, &buf)?; + } + } + + Ok::<(), Error>(()) + }) + .await + .map_err(|e| Error::SelfUpdate(format!("Task join error: {e}").into()))??; + + Ok(()) +} + +/// Strip devDependencies and optionalDependencies from package.json. +pub async fn strip_dev_dependencies(version_dir: &AbsolutePath) -> Result<(), Error> { + let package_json_path = version_dir.join("package.json"); + + if !tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) { + return Ok(()); + } + + let content = tokio::fs::read_to_string(&package_json_path).await?; + let mut json: serde_json::Value = serde_json::from_str(&content)?; + + if let Some(obj) = json.as_object_mut() { + obj.remove("devDependencies"); + obj.remove("optionalDependencies"); + } + + let updated = serde_json::to_string_pretty(&json)?; + tokio::fs::write(&package_json_path, format!("{updated}\n")).await?; + + Ok(()) +} + +/// Install production dependencies using the new version's binary. +/// +/// Spawns: `{version_dir}/bin/vp install --silent` with `CI=true`. +pub async fn install_production_deps(version_dir: &AbsolutePath) -> Result<(), Error> { + let vp_binary = version_dir.join("bin").join(if cfg!(windows) { "vp.exe" } else { "vp" }); + + if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + format!("New binary not found at {}", vp_binary.as_path().display()).into(), + )); + } + + tracing::debug!("Running vp install in {}", version_dir.as_path().display()); + + let output = tokio::process::Command::new(vp_binary.as_path()) + .args(["install", "--silent"]) + .current_dir(version_dir) + .env("CI", "true") + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::SelfUpdate( + format!( + "Failed to install production dependencies (exit code: {})\n{}", + output.status.code().unwrap_or(-1), + stderr.trim() + ) + .into(), + )); + } + + Ok(()) +} + +/// Save the current version before swapping, for rollback support. +/// +/// Reads the `current` symlink target and writes the version to `.previous-version`. +pub async fn save_previous_version(install_dir: &AbsolutePath) -> Result, Error> { + let current_link = install_dir.join("current"); + + if !tokio::fs::try_exists(¤t_link).await.unwrap_or(false) { + return Ok(None); + } + + let target = tokio::fs::read_link(¤t_link).await?; + let version = target.file_name().and_then(|n| n.to_str()).map(String::from); + + if let Some(ref v) = version { + let prev_file = install_dir.join(".previous-version"); + tokio::fs::write(&prev_file, v).await?; + tracing::debug!("Saved previous version: {}", v); + } + + Ok(version) +} + +/// Atomically swap the `current` symlink to point to a new version. +/// +/// On Unix: creates a temp symlink then renames (atomic). +/// On Windows: removes junction and creates a new one. +pub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Result<(), Error> { + let current_link = install_dir.join("current"); + let version_dir = install_dir.join(version); + + // Verify the version directory exists + if !tokio::fs::try_exists(&version_dir).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + format!("Version directory does not exist: {}", version_dir.as_path().display()).into(), + )); + } + + #[cfg(unix)] + { + // Atomic symlink swap: create temp link, then rename over current + let temp_link = install_dir.join("current.new"); + + // Remove temp link if it exists from a previous failed attempt + let _ = tokio::fs::remove_file(&temp_link).await; + + tokio::fs::symlink(version, &temp_link).await?; + tokio::fs::rename(&temp_link, ¤t_link).await?; + } + + #[cfg(windows)] + { + // Windows: junction swap (not atomic) + // Remove whatever exists at current_link — could be a junction, symlink, or directory. + // We don't rely on junction::exists() since it may not detect junctions created by + // cmd /c mklink /J (used by install.ps1). + if current_link.as_path().exists() { + // std::fs::remove_dir works on junctions/symlinks without removing target contents + if let Err(e) = std::fs::remove_dir(¤t_link) { + tracing::debug!("remove_dir failed ({}), trying junction::delete", e); + junction::delete(¤t_link).map_err(|e| { + Error::SelfUpdate( + format!( + "Failed to remove existing junction at {}: {e}", + current_link.as_path().display() + ) + .into(), + ) + })?; + } + } + + junction::create(&version_dir, ¤t_link).map_err(|e| { + Error::SelfUpdate( + format!( + "Failed to create junction at {}: {e}\nTry removing it manually and run again.", + current_link.as_path().display() + ) + .into(), + ) + })?; + } + + tracing::debug!("Swapped current → {}", version); + Ok(()) +} + +/// Refresh shims by running `vp env setup --refresh` with the new binary. +pub async fn refresh_shims(install_dir: &AbsolutePath) -> Result<(), Error> { + let vp_binary = + install_dir.join("current").join("bin").join(if cfg!(windows) { "vp.exe" } else { "vp" }); + + if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { + tracing::warn!( + "New binary not found at {}, skipping shim refresh", + vp_binary.as_path().display() + ); + return Ok(()); + } + + tracing::debug!("Refreshing shims..."); + + let output = tokio::process::Command::new(vp_binary.as_path()) + .args(["env", "setup", "--refresh"]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Shim refresh exited with code {}, continuing anyway\n{}", + output.status.code().unwrap_or(-1), + stderr.trim() + ); + } + + Ok(()) +} + +/// Clean up old version directories, keeping at most `max_keep` versions. +/// +/// Sorts by creation time (newest first, matching install.sh behavior) and removes +/// the oldest beyond the limit. Protected versions are never removed, even if they +/// fall outside the keep limit (e.g., the active version after a downgrade). +pub async fn cleanup_old_versions( + install_dir: &AbsolutePath, + max_keep: usize, + protected_versions: &[&str], +) -> Result<(), Error> { + let mut versions: Vec<(std::time::SystemTime, AbsolutePathBuf)> = Vec::new(); + + let mut entries = tokio::fs::read_dir(install_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Only consider entries that parse as semver + if node_semver::Version::parse(&name_str).is_ok() { + let metadata = entry.metadata().await?; + // Use creation time (birth time), fallback to modified time + let time = metadata.created().unwrap_or_else(|_| { + metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + let path = AbsolutePathBuf::new(entry.path()).ok_or_else(|| { + Error::SelfUpdate( + format!("Invalid absolute path: {}", entry.path().display()).into(), + ) + })?; + versions.push((time, path)); + } + } + + // Sort newest first (by creation time, matching install.sh) + versions.sort_by(|a, b| b.0.cmp(&a.0)); + + // Remove versions beyond the keep limit, but never remove protected versions + for (_time, path) in versions.into_iter().skip(max_keep) { + let name = path.as_path().file_name().and_then(|n| n.to_str()).unwrap_or(""); + if protected_versions.contains(&name) { + tracing::debug!("Skipping protected version: {}", name); + continue; + } + tracing::debug!("Cleaning up old version: {}", path.as_path().display()); + if let Err(e) = tokio::fs::remove_dir_all(&path).await { + tracing::warn!("Failed to remove {}: {}", path.as_path().display(), e); + } + } + + Ok(()) +} + +/// Read the previous version from `.previous-version` file. +pub async fn read_previous_version(install_dir: &AbsolutePath) -> Result, Error> { + let prev_file = install_dir.join(".previous-version"); + + if !tokio::fs::try_exists(&prev_file).await.unwrap_or(false) { + return Ok(None); + } + + let content = tokio::fs::read_to_string(&prev_file).await?; + let version = content.trim().to_string(); + + if version.is_empty() { Ok(None) } else { Ok(Some(version)) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_safe_tar_path_normal() { + assert!(is_safe_tar_path(Path::new("dist/index.js"))); + assert!(is_safe_tar_path(Path::new("bin/vp"))); + assert!(is_safe_tar_path(Path::new("package.json"))); + assert!(is_safe_tar_path(Path::new("templates/react/index.ts"))); + } + + #[test] + fn test_is_safe_tar_path_traversal() { + assert!(!is_safe_tar_path(Path::new("../etc/passwd"))); + assert!(!is_safe_tar_path(Path::new("dist/../../etc/passwd"))); + assert!(!is_safe_tar_path(Path::new(".."))); + } + + #[test] + fn test_is_safe_tar_path_absolute() { + assert!(!is_safe_tar_path(Path::new("/etc/passwd"))); + assert!(!is_safe_tar_path(Path::new("/usr/bin/vp"))); + } + + #[tokio::test] + async fn test_cleanup_preserves_active_downgraded_version() { + let temp = tempfile::tempdir().unwrap(); + let install_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + + // Create 7 version directories with staggered creation times. + // Simulate: installed 0.1-0.7 in order, then rolled back to 0.2.0 + for v in ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0"] { + tokio::fs::create_dir(install_dir.join(v)).await.unwrap(); + // Small delay to ensure distinct creation times + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // Simulate rollback: current points to 0.2.0 (low semver rank) + #[cfg(unix)] + tokio::fs::symlink("0.2.0", install_dir.join("current")).await.unwrap(); + + // Cleanup keeping top 5, with 0.2.0 protected (the active version) + cleanup_old_versions(&install_dir, 5, &["0.2.0"]).await.unwrap(); + + // 0.2.0 is the active version — it MUST survive cleanup + assert!( + tokio::fs::try_exists(install_dir.join("0.2.0")).await.unwrap(), + "Active version 0.2.0 was deleted by cleanup" + ); + } + + #[tokio::test] + async fn test_cleanup_sorts_by_creation_time_not_semver() { + let temp = tempfile::tempdir().unwrap(); + let install_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap(); + + // Create versions in non-semver order with creation times: + // 0.5.0 (oldest), 0.1.0, 0.3.0, 0.7.0, 0.2.0, 0.6.0 (newest) + for v in ["0.5.0", "0.1.0", "0.3.0", "0.7.0", "0.2.0", "0.6.0"] { + tokio::fs::create_dir(install_dir.join(v)).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // Keep top 4 by creation time → keep 0.6.0, 0.2.0, 0.7.0, 0.3.0 + // Remove 0.1.0 and 0.5.0 (oldest by creation time) + cleanup_old_versions(&install_dir, 4, &[]).await.unwrap(); + + // The 4 newest by creation time should survive + assert!(tokio::fs::try_exists(install_dir.join("0.6.0")).await.unwrap()); + assert!(tokio::fs::try_exists(install_dir.join("0.2.0")).await.unwrap()); + assert!(tokio::fs::try_exists(install_dir.join("0.7.0")).await.unwrap()); + assert!(tokio::fs::try_exists(install_dir.join("0.3.0")).await.unwrap()); + + // The 2 oldest by creation time should be removed + assert!( + !tokio::fs::try_exists(install_dir.join("0.5.0")).await.unwrap(), + "0.5.0 (oldest by creation time) should have been removed" + ); + assert!( + !tokio::fs::try_exists(install_dir.join("0.1.0")).await.unwrap(), + "0.1.0 (second oldest by creation time) should have been removed" + ); + } + + #[tokio::test] + async fn test_cleanup_old_versions_with_nonexistent_dir() { + // Verifies that cleanup_old_versions propagates errors on non-existent dir. + // In the real flow, such errors from post-swap operations should be non-fatal. + let non_existent = + AbsolutePathBuf::new(std::env::temp_dir().join("non-existent-self-update-test-dir")) + .unwrap(); + let result = cleanup_old_versions(&non_existent, 5, &[]).await; + assert!(result.is_err(), "cleanup_old_versions should error on non-existent dir"); + } +} diff --git a/crates/vite_global_cli/src/commands/self_update/integrity.rs b/crates/vite_global_cli/src/commands/self_update/integrity.rs new file mode 100644 index 0000000000..0f31021848 --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/integrity.rs @@ -0,0 +1,75 @@ +//! Integrity verification for downloaded tarballs. +//! +//! Verifies SHA-512 integrity using the Subresource Integrity (SRI) format +//! that npm registries provide: `sha512-{base64}`. + +use sha2::{Digest, Sha512}; + +use crate::error::Error; + +/// Verify the integrity of data against an SRI hash. +/// +/// Parses the SRI format `sha512-{base64}`, computes the SHA-512 hash +/// of the data, base64-encodes it, and compares. +pub fn verify_integrity(data: &[u8], expected_sri: &str) -> Result<(), Error> { + let expected_b64 = expected_sri + .strip_prefix("sha512-") + .ok_or_else(|| Error::UnsupportedIntegrity(expected_sri.into()))?; + + let mut hasher = Sha512::new(); + hasher.update(data); + let actual_b64 = base64_simd::STANDARD.encode_to_string(hasher.finalize()); + + if actual_b64 != expected_b64 { + return Err(Error::IntegrityMismatch { + expected: expected_sri.into(), + actual: format!("sha512-{actual_b64}").into(), + }); + } + + tracing::debug!("Integrity verification successful"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verify_integrity_valid() { + let data = b"Hello, World!"; + let mut hasher = Sha512::new(); + hasher.update(data); + let hash = base64_simd::STANDARD.encode_to_string(hasher.finalize()); + let sri = format!("sha512-{hash}"); + + assert!(verify_integrity(data, &sri).is_ok()); + } + + #[test] + fn test_verify_integrity_mismatch() { + let data = b"Hello, World!"; + let sri = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + + let err = verify_integrity(data, sri).unwrap_err(); + assert!(matches!(err, Error::IntegrityMismatch { .. })); + } + + #[test] + fn test_verify_integrity_unsupported_format() { + let data = b"Hello, World!"; + let sri = "sha256-abc123"; + + let err = verify_integrity(data, sri).unwrap_err(); + assert!(matches!(err, Error::UnsupportedIntegrity(_))); + } + + #[test] + fn test_verify_integrity_no_prefix() { + let data = b"Hello, World!"; + let sri = "not-a-valid-sri"; + + let err = verify_integrity(data, sri).unwrap_err(); + assert!(matches!(err, Error::UnsupportedIntegrity(_))); + } +} diff --git a/crates/vite_global_cli/src/commands/self_update/mod.rs b/crates/vite_global_cli/src/commands/self_update/mod.rs new file mode 100644 index 0000000000..670d5f0a17 --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/mod.rs @@ -0,0 +1,261 @@ +//! Self-update command for the vp CLI. +//! +//! Downloads and installs a new version of the CLI from the npm registry +//! with SHA-512 integrity verification. + +mod install; +mod integrity; +mod platform; +mod registry; + +use std::process::ExitStatus; + +use owo_colors::OwoColorize; +use vite_install::request::HttpClient; +use vite_path::AbsolutePathBuf; + +use crate::{commands::env::config::get_vite_plus_home, error::Error}; + +/// Options for the self-update command. +pub struct SelfUpdateOptions { + /// Target version (e.g., "0.2.0"). None means use the tag. + pub version: Option, + /// npm dist-tag (default: "latest") + pub tag: String, + /// Check for updates without installing + pub check: bool, + /// Revert to previous version + pub rollback: bool, + /// Force reinstall even if already on the target version + pub force: bool, + /// Suppress output + pub silent: bool, + /// Custom npm registry URL + pub registry: Option, +} + +/// Maximum number of old versions to keep. +const MAX_VERSIONS_KEEP: usize = 5; + +/// Execute the self-update command. +#[allow(clippy::print_stdout, clippy::print_stderr)] +pub async fn execute(options: SelfUpdateOptions) -> Result { + let install_dir = get_vite_plus_home()?; + + // Handle --rollback + if options.rollback { + return execute_rollback(&install_dir, options.silent).await; + } + + // Step 1: Detect platform + let platform_suffix = platform::detect_platform_suffix()?; + tracing::debug!("Platform: {}", platform_suffix); + + // Step 2: Determine version to resolve + let version_or_tag = options.version.as_deref().unwrap_or(&options.tag); + + if !options.silent { + eprintln!("info: checking for updates..."); + } + + // Step 3: Resolve version from npm registry + let resolved = + registry::resolve_version(version_or_tag, &platform_suffix, options.registry.as_deref()) + .await?; + + let current_version = env!("CARGO_PKG_VERSION"); + + if !options.silent { + eprintln!("info: found vite-plus-cli@{} (current: {})", resolved.version, current_version); + } + + // Step 4: Handle --check (report and exit) + if options.check { + if resolved.version == current_version { + println!("\n{} Already up to date ({})", "\u{2714}".green(), current_version); + } else { + println!("Update available: {} \u{2192} {}", current_version, resolved.version); + println!("Run `vp self-update` to update."); + } + return Ok(ExitStatus::default()); + } + + // Step 5: Handle already up-to-date + if resolved.version == current_version && !options.force { + if !options.silent { + println!("\n{} Already up to date ({})", "\u{2714}".green(), current_version); + } + return Ok(ExitStatus::default()); + } + + if !options.silent { + eprintln!( + "info: downloading vite-plus-cli@{} for {}...", + resolved.version, platform_suffix + ); + } + + // Step 6: Download both tarballs + let client = HttpClient::new(); + + let (platform_data, main_data) = tokio::try_join!( + async { + client.get_bytes(&resolved.platform_tarball_url).await.map_err(|e| { + Error::SelfUpdate(format!("Failed to download platform package: {e}").into()) + }) + }, + async { + client.get_bytes(&resolved.main_tarball_url).await.map_err(|e| { + Error::SelfUpdate(format!("Failed to download main package: {e}").into()) + }) + }, + )?; + + // Step 7: Verify integrity + integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; + integrity::verify_integrity(&main_data, &resolved.main_integrity)?; + + if !options.silent { + eprintln!("info: installing..."); + } + + // Step 8: Create version directory + let version_dir = install_dir.join(&resolved.version); + tokio::fs::create_dir_all(&version_dir).await?; + + // Step 9: Extract platform package (binary + .node files) + let result = install_platform_and_main( + &platform_data, + &main_data, + &version_dir, + &install_dir, + &resolved.version, + current_version, + options.silent, + ) + .await; + + // On failure, clean up the version directory + if result.is_err() { + tracing::debug!("Cleaning up failed install at {}", version_dir.as_path().display()); + let _ = tokio::fs::remove_dir_all(&version_dir).await; + } + + result +} + +/// Core installation logic, separated for error cleanup. +#[allow(clippy::print_stdout, clippy::print_stderr)] +async fn install_platform_and_main( + platform_data: &[u8], + main_data: &[u8], + version_dir: &AbsolutePathBuf, + install_dir: &AbsolutePathBuf, + new_version: &str, + current_version: &str, + silent: bool, +) -> Result { + // Extract platform package + install::extract_platform_package(platform_data, version_dir).await?; + + // Extract main package + install::extract_main_package(main_data, version_dir).await?; + + // Verify critical files were extracted + let binary_name = if cfg!(windows) { "vp.exe" } else { "vp" }; + let binary_path = version_dir.join("bin").join(binary_name); + if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + "Binary not found after extraction. The download may be corrupted.".into(), + )); + } + let package_json_path = version_dir.join("package.json"); + if !tokio::fs::try_exists(&package_json_path).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + "package.json not found after extraction. The download may be corrupted.".into(), + )); + } + + // Strip dev dependencies from package.json + install::strip_dev_dependencies(version_dir).await?; + + // Install production dependencies + install::install_production_deps(version_dir).await?; + + // Save previous version for rollback + let previous_version = install::save_previous_version(install_dir).await?; + tracing::debug!("Previous version: {:?}", previous_version); + + // Swap current link — POINT OF NO RETURN + install::swap_current_link(install_dir, new_version).await?; + + // Post-swap operations: non-fatal (the update already succeeded) + if let Err(e) = install::refresh_shims(install_dir).await { + eprintln!("warn: Shim refresh failed (non-fatal): {e}"); + } + + let mut protected = vec![new_version]; + if let Some(ref prev) = previous_version { + protected.push(prev.as_str()); + } + if let Err(e) = install::cleanup_old_versions(install_dir, MAX_VERSIONS_KEEP, &protected).await + { + eprintln!("warn: Old version cleanup failed (non-fatal): {e}"); + } + + if !silent { + println!( + "\n{} Updated vite-plus from {} \u{2192} {}", + "\u{2714}".green(), + current_version, + new_version + ); + println!( + "\n Release notes: https://github.com/voidzero-dev/vite-plus/releases/tag/v{}", + new_version + ); + } + + Ok(ExitStatus::default()) +} + +/// Execute rollback to the previous version. +#[allow(clippy::print_stdout, clippy::print_stderr)] +async fn execute_rollback( + install_dir: &AbsolutePathBuf, + silent: bool, +) -> Result { + let previous = install::read_previous_version(install_dir) + .await? + .ok_or_else(|| Error::SelfUpdate("No previous version found. Cannot rollback.".into()))?; + + // Verify the version directory still exists + let prev_dir = install_dir.join(&previous); + if !tokio::fs::try_exists(&prev_dir).await.unwrap_or(false) { + return Err(Error::SelfUpdate( + format!("Previous version directory ({}) no longer exists. Cannot rollback.", previous) + .into(), + )); + } + + if !silent { + let current_version = env!("CARGO_PKG_VERSION"); + eprintln!("info: rolling back to previous version..."); + eprintln!("info: switching from {} \u{2192} {}", current_version, previous); + } + + // Save the current version as the new "previous" before swapping + install::save_previous_version(install_dir).await?; + + // Swap to the previous version + install::swap_current_link(install_dir, &previous).await?; + + // Refresh shims + install::refresh_shims(install_dir).await?; + + if !silent { + println!("\n{} Rolled back to {}", "\u{2714}".green(), previous); + } + + Ok(ExitStatus::default()) +} diff --git a/crates/vite_global_cli/src/commands/self_update/platform.rs b/crates/vite_global_cli/src/commands/self_update/platform.rs new file mode 100644 index 0000000000..39a4b24d57 --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/platform.rs @@ -0,0 +1,75 @@ +//! Platform detection for self-update. +//! +//! Detects the current platform and returns the npm package suffix +//! used to find the correct platform-specific binary package. + +use crate::error::Error; + +/// Detect the current platform suffix for npm package naming. +/// +/// Returns strings like `darwin-arm64`, `linux-x64-gnu`, `linux-arm64-musl`, `win32-x64-msvc`. +pub fn detect_platform_suffix() -> Result { + let os_name = if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "win32" + } else { + return Err(Error::SelfUpdate( + format!("Unsupported operating system: {}", std::env::consts::OS).into(), + )); + }; + + let arch_name = if cfg!(target_arch = "x86_64") { + "x64" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + return Err(Error::SelfUpdate( + format!("Unsupported architecture: {}", std::env::consts::ARCH).into(), + )); + }; + + if os_name == "linux" { + let libc = if cfg!(target_env = "musl") { "musl" } else { "gnu" }; + Ok(format!("{os_name}-{arch_name}-{libc}")) + } else if os_name == "win32" { + Ok(format!("{os_name}-{arch_name}-msvc")) + } else { + Ok(format!("{os_name}-{arch_name}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_platform_suffix() { + let suffix = detect_platform_suffix().unwrap(); + + // Should be non-empty and contain a dash + assert!(!suffix.is_empty()); + assert!(suffix.contains('-')); + + // Should match the current platform + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + assert_eq!(suffix, "darwin-arm64"); + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + assert_eq!(suffix, "darwin-x64"); + + #[cfg(all(target_os = "linux", target_arch = "x86_64", not(target_env = "musl")))] + assert_eq!(suffix, "linux-x64-gnu"); + + #[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "musl"))] + assert_eq!(suffix, "linux-x64-musl"); + + #[cfg(all(target_os = "linux", target_arch = "aarch64", not(target_env = "musl")))] + assert_eq!(suffix, "linux-arm64-gnu"); + + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + assert_eq!(suffix, "win32-x64-msvc"); + } +} diff --git a/crates/vite_global_cli/src/commands/self_update/registry.rs b/crates/vite_global_cli/src/commands/self_update/registry.rs new file mode 100644 index 0000000000..6e4ec1c1b9 --- /dev/null +++ b/crates/vite_global_cli/src/commands/self_update/registry.rs @@ -0,0 +1,155 @@ +//! npm registry client for version resolution. +//! +//! Queries the npm registry to resolve versions and get tarball URLs +//! with integrity hashes for both the main package and platform-specific package. + +use std::collections::HashMap; + +use serde::Deserialize; +use vite_install::{config::NPM_REGISTRY, request::HttpClient}; + +use crate::error::Error; + +/// npm package version metadata (subset of fields we need). +#[derive(Debug, Deserialize)] +pub struct PackageVersionMetadata { + pub version: String, + pub dist: DistInfo, + #[serde(default, rename = "optionalDependencies")] + pub optional_dependencies: HashMap, +} + +/// Distribution info from npm registry. +#[derive(Debug, Deserialize)] +pub struct DistInfo { + pub tarball: String, + pub integrity: String, +} + +/// Resolved version info with URLs and integrity for both packages. +#[derive(Debug)] +pub struct ResolvedVersion { + pub version: String, + pub main_tarball_url: String, + pub main_integrity: String, + pub platform_tarball_url: String, + pub platform_integrity: String, +} + +const MAIN_PACKAGE_NAME: &str = "vite-plus-cli"; +const PLATFORM_PACKAGE_SCOPE: &str = "@voidzero-dev"; + +/// Resolve a version from the npm registry. +/// +/// Makes two HTTP calls: +/// 1. Main package metadata to get version, tarball URL, integrity, and optional deps +/// 2. Platform package metadata to get platform-specific tarball URL and integrity +pub async fn resolve_version( + version_or_tag: &str, + platform_suffix: &str, + registry_override: Option<&str>, +) -> Result { + let registry = registry_override.unwrap_or_else(|| &NPM_REGISTRY); + let client = HttpClient::new(); + + // Step 1: Fetch main package metadata + let main_url = format!("{registry}/{MAIN_PACKAGE_NAME}/{version_or_tag}"); + tracing::debug!("Fetching main package metadata: {}", main_url); + + let main_meta: PackageVersionMetadata = client.get_json(&main_url).await.map_err(|e| { + Error::SelfUpdate(format!("Failed to fetch package metadata from {main_url}: {e}").into()) + })?; + + // Step 2: Determine platform package name from optionalDependencies + let platform_package_name = + format!("{PLATFORM_PACKAGE_SCOPE}/{MAIN_PACKAGE_NAME}-{platform_suffix}"); + + if !main_meta.optional_dependencies.contains_key(&platform_package_name) { + return Err(Error::SelfUpdate( + format!( + "Platform package '{platform_package_name}' not found in optionalDependencies of {MAIN_PACKAGE_NAME}@{}. \ + Your platform ({platform_suffix}) may not be supported.", + main_meta.version + ) + .into(), + )); + } + + // Step 3: Fetch platform package metadata + let platform_url = format!("{registry}/{platform_package_name}/{}", main_meta.version); + tracing::debug!("Fetching platform package metadata: {}", platform_url); + + let platform_meta: PackageVersionMetadata = + client.get_json(&platform_url).await.map_err(|e| { + Error::SelfUpdate( + format!("Failed to fetch platform package metadata from {platform_url}: {e}") + .into(), + ) + })?; + + Ok(ResolvedVersion { + version: main_meta.version, + main_tarball_url: main_meta.dist.tarball, + main_integrity: main_meta.dist.integrity, + platform_tarball_url: platform_meta.dist.tarball, + platform_integrity: platform_meta.dist.integrity, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_package_name_construction() { + let suffix = "darwin-arm64"; + let name = format!("{PLATFORM_PACKAGE_SCOPE}/{MAIN_PACKAGE_NAME}-{suffix}"); + assert_eq!(name, "@voidzero-dev/vite-plus-cli-darwin-arm64"); + } + + #[test] + fn test_all_platform_suffixes_match_published_packages() { + // These are the actual published optionalDependencies keys + // (from packages/global/publish-native-addons.ts RUST_TARGETS keys) + let published_suffixes = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64-gnu", + "linux-x64-gnu", + "win32-arm64-msvc", + "win32-x64-msvc", + ]; + + let published_deps: HashMap = published_suffixes + .iter() + .map(|s| { + (format!("{PLATFORM_PACKAGE_SCOPE}/{MAIN_PACKAGE_NAME}-{s}"), "0.1.0".to_string()) + }) + .collect(); + + // All known platform suffixes that detect_platform_suffix() can return + let detection_suffixes = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64-gnu", + "linux-x64-gnu", + "linux-arm64-musl", + "linux-x64-musl", + "win32-arm64-msvc", + "win32-x64-msvc", + ]; + + for suffix in &detection_suffixes { + let package_name = format!("{PLATFORM_PACKAGE_SCOPE}/{MAIN_PACKAGE_NAME}-{suffix}"); + // musl variants are not published, so skip them + if suffix.contains("musl") { + continue; + } + assert!( + published_deps.contains_key(&package_name), + "Platform suffix '{suffix}' produces package name '{package_name}' \ + which does not match any published package" + ); + } + } +} diff --git a/crates/vite_global_cli/src/error.rs b/crates/vite_global_cli/src/error.rs index 13f41d3a4a..3d335cc8b4 100644 --- a/crates/vite_global_cli/src/error.rs +++ b/crates/vite_global_cli/src/error.rs @@ -51,4 +51,13 @@ pub enum 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 }, + + #[error("Self-update error: {0}")] + SelfUpdate(Str), + + #[error("Integrity mismatch: expected {expected}, got {actual}")] + IntegrityMismatch { expected: Str, actual: Str }, + + #[error("Unsupported integrity format: {0} (only sha512 is supported)")] + UnsupportedIntegrity(Str), } diff --git a/crates/vite_install/src/lib.rs b/crates/vite_install/src/lib.rs index f6a233fba1..84d871f27a 100644 --- a/crates/vite_install/src/lib.rs +++ b/crates/vite_install/src/lib.rs @@ -1,7 +1,7 @@ pub mod commands; -mod config; +pub mod config; pub mod package_manager; -mod request; +pub mod request; mod shim; pub use package_manager::{ diff --git a/crates/vite_install/src/request.rs b/crates/vite_install/src/request.rs index 650490b45b..b2cc23657c 100644 --- a/crates/vite_install/src/request.rs +++ b/crates/vite_install/src/request.rs @@ -40,6 +40,22 @@ impl HttpClient { Self { max_times, min_delay } } + /// Get raw bytes from a URL + /// + /// # Arguments + /// + /// * `url` - The URL to fetch bytes from + /// + /// # Returns + /// + /// * `Ok(Vec)` - The raw bytes from the response + /// * `Err(e)` - If the request fails + pub async fn get_bytes(&self, url: &str) -> Result, Error> { + tracing::debug!("Fetching bytes from: {}", url); + let response = self.get(url).await?; + Ok(response.bytes().await?.to_vec()) + } + async fn get(&self, url: &str) -> Result { let response = (|| async { reqwest::get(url).await?.error_for_status() }) .retry( diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index caeb1eb3e7..a43a84b7b0 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -19,7 +19,7 @@ async function migrateProject(project: string) { const directory = 'directory' in repoConfig ? repoConfig.directory : undefined; const cwd = directory ? join(repoRoot, directory) : repoRoot; // run vp migrate - const cli = process.env.VITE_PLUS_CLI_BIN ?? (process.env.CI ? 'vp' : 'vp-dev'); + const cli = process.env.VITE_PLUS_CLI_BIN ?? 'vp'; execSync(`${cli} migrate --no-agent --no-interactive`, { cwd, stdio: 'inherit', diff --git a/package.json b/package.json index e8897a2183..5c0fbbd1fe 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,10 @@ "scripts": { "build": "pnpm -F @voidzero-dev/* -F vite-plus build && pnpm -F vite-plus-cli build", "bootstrap-cli": "pnpm build && cargo build -p vite_global_cli --release && pnpm copy-vp-binary && pnpm install-global-cli", - "bootstrap-cli:ci": "pnpm install-global-cli:ci", + "bootstrap-cli:ci": "pnpm install-global-cli", "copy-vp-binary": "rm -f packages/global/bin/vp packages/global/bin/vp.exe && (cp target/release/vp packages/global/bin/vp || cp target/release/vp.exe packages/global/bin/vp.exe)", "copy-cli-binding": "pnpm --filter=vite-plus-cli copy-binding", - "install-global-cli": "pnpm copy-cli-binding && tool install-global-cli vp-dev && vp-dev --version", - "install-global-cli:ci": "pnpm copy-cli-binding && tool install-global-cli vp", + "install-global-cli": "pnpm copy-cli-binding && tool install-global-cli", "tsgo": "tsgo -b tsconfig.json", "lint": "vite lint --type-aware --threads 4", "test": "vite test run && pnpm -r snap-test", 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 index f6b765416a..ce16411e96 100644 --- 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 @@ -1,22 +1,22 @@ -> bash -c '. $VITE_PLUS_HOME/env && type vp-dev' # should show vp-dev is a shell function -vp-dev is a function -vp-dev () +> bash -c '. $VITE_PLUS_HOME/env && type vp' # should show vp is a shell function +vp is a function +vp () { if [ "$1" = "env" ] && [ "$2" = "use" ]; then case " $* " in *" -h "* | *" --help "*) - command vp-dev "$@"; + command vp "$@"; return ;; esac; - __vp_out="$(command vp-dev "$@")" || return $?; + __vp_out="$(command vp "$@")" || return $?; eval "$__vp_out"; else - command vp-dev "$@"; + command vp "$@"; fi } -> bash -c '. $VITE_PLUS_HOME/env && vp-dev env use -h' # should show help via shell wrapper +> bash -c '. $VITE_PLUS_HOME/env && vp 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] @@ -30,7 +30,7 @@ Options: --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 +> bash -c '. $VITE_PLUS_HOME/env && vp 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] @@ -44,6 +44,6 @@ Options: --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 +> bash -c '. $VITE_PLUS_HOME/env && vp 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 index aa1d0e8b57..4035b61ce4 100644 --- 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 @@ -2,9 +2,9 @@ "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" + "bash -c '. $VITE_PLUS_HOME/env && type vp' # should show vp is a shell function", + "bash -c '. $VITE_PLUS_HOME/env && vp env use -h' # should show help via shell wrapper", + "bash -c '. $VITE_PLUS_HOME/env && vp env use --help' # should show help via shell wrapper", + "bash -c '. $VITE_PLUS_HOME/env && vp 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/cli-helper-message/snap.txt b/packages/global/snap-tests/cli-helper-message/snap.txt index 0e29fdbc79..24a76a1530 100644 --- a/packages/global/snap-tests/cli-helper-message/snap.txt +++ b/packages/global/snap-tests/cli-helper-message/snap.txt @@ -31,6 +31,9 @@ Package Manager Commands: update, up Update packages to their latest versions why, explain Show why a package is installed +Maintenance Commands: + self-update, upgrade Update vp itself to the latest version + Options: -V, --version Print version -h, --help Print help @@ -336,3 +339,20 @@ Global Packages: vp uninstall -g # Uninstall a global package vp update -g [package] # Update global package(s) vp list -g [package] # List installed global packages + +> vp self-update -h # show self-update help message +Update vp itself to the latest version + +Usage: vp self-update [OPTIONS] [VERSION] + +Arguments: + [VERSION] Target version (e.g., "0.2.0"). Defaults to latest + +Options: + --tag npm dist-tag to install (default: "latest", also: "test") [default: latest] + --check Check for updates without installing + --rollback Revert to the previously active version + --force Force reinstall even if already on the target version + --silent Suppress output + --registry Custom npm registry URL + -h, --help Print help diff --git a/packages/global/snap-tests/cli-helper-message/steps.json b/packages/global/snap-tests/cli-helper-message/steps.json index 9479bec876..763f692eff 100644 --- a/packages/global/snap-tests/cli-helper-message/steps.json +++ b/packages/global/snap-tests/cli-helper-message/steps.json @@ -13,6 +13,7 @@ "vp why -h # show why help message", "vp info -h # show info help message", "vp pm -h # show pm help message", - "vp env # show env help message" + "vp env # show env help message", + "vp self-update -h # show self-update help message" ] } diff --git a/packages/global/snap-tests/command-env-which/snap.txt b/packages/global/snap-tests/command-env-which/snap.txt index ad7c991a88..4e79846d11 100644 --- a/packages/global/snap-tests/command-env-which/snap.txt +++ b/packages/global/snap-tests/command-env-which/snap.txt @@ -2,17 +2,17 @@ v20.18.0 > vp env which node # Core tool - shows resolved Node.js binary path -/.vite-plus-dev/js_runtime/node//bin/node +/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 +/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 +/js_runtime/node//bin/npx Version:  20.18.0 Source:  .node-version @@ -28,7 +28,7 @@ added 41 packages in ms 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 +/packages/cowsay/lib/node_modules/cowsay/./cli.js Package:  cowsay@ Binaries:  cowsay, cowthink Node:  20.18.0 diff --git a/packages/global/snap-tests/command-self-update-check/snap.txt b/packages/global/snap-tests/command-self-update-check/snap.txt new file mode 100644 index 0000000000..729c3d0573 --- /dev/null +++ b/packages/global/snap-tests/command-self-update-check/snap.txt @@ -0,0 +1,11 @@ +> vp self-update --check # check for updates without installing +info: checking for updates... +info: found vite-plus-cli@ +Update available: +Run `vp self-update` to update. + +> vp upgrade --check # check using upgrade alias +info: checking for updates... +info: found vite-plus-cli@ +Update available: +Run `vp self-update` to update. diff --git a/packages/global/snap-tests/command-self-update-check/steps.json b/packages/global/snap-tests/command-self-update-check/steps.json new file mode 100644 index 0000000000..22fcef559e --- /dev/null +++ b/packages/global/snap-tests/command-self-update-check/steps.json @@ -0,0 +1,7 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": [ + "vp self-update --check # check for updates without installing", + "vp upgrade --check # check using upgrade alias" + ] +} diff --git a/packages/global/snap-tests/command-self-update-rollback/snap.txt b/packages/global/snap-tests/command-self-update-rollback/snap.txt new file mode 100644 index 0000000000..3283d2e45e --- /dev/null +++ b/packages/global/snap-tests/command-self-update-rollback/snap.txt @@ -0,0 +1,2 @@ +[1]> vp self-update --rollback # should fail with no previous version +Error: Self-update error: No previous version found. Cannot rollback. diff --git a/packages/global/snap-tests/command-self-update-rollback/steps.json b/packages/global/snap-tests/command-self-update-rollback/steps.json new file mode 100644 index 0000000000..a7f812cb48 --- /dev/null +++ b/packages/global/snap-tests/command-self-update-rollback/steps.json @@ -0,0 +1,4 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": ["vp self-update --rollback # should fail with no previous version"] +} diff --git a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap index 77e00a1d57..82766eba15 100644 --- a/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap +++ b/packages/tools/src/__tests__/__snapshots__/utils.spec.ts.snap @@ -115,6 +115,13 @@ exports[`replaceUnstableOutput() > replace unstable vite-plus hash version 1`] = "vite-plus-core": "^0.0.0-"" `; +exports[`replaceUnstableOutput() > replace vite-plus home paths 1`] = ` +"/js_runtime/node/v/bin/node +/packages/cowsay/lib/node_modules/cowsay/./cli.js + +/bin" +`; + exports[`replaceUnstableOutput() > replace yarn YN0000: └ Completed with duration to empty string 1`] = ` "➤ YN0000: └ Completed ➤ YN0000: └ Completed diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index 86cec5a334..6b7a41faa3 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, test } from '@voidzero-dev/vite-plus-test'; @@ -200,6 +200,17 @@ line 3 expect(replaceUnstableOutput(output.trim())).toMatchSnapshot(); }); + test.skipIf(process.platform === 'win32')('replace vite-plus home paths', () => { + const home = homedir(); + const output = [ + `${home}/.vite-plus/js_runtime/node/v20.18.0/bin/node`, + `${home}/.vite-plus/packages/cowsay/lib/node_modules/cowsay/./cli.js`, + `${home}/.vite-plus`, + `${home}/.vite-plus/bin`, + ].join('\n'); + expect(replaceUnstableOutput(output)).toMatchSnapshot(); + }); + test('replace ignore npm warn exec The following package was not found and will be installed: cowsay@ warning log', () => { const output = ` npm warn exec The following package was not found and will be installed: cowsay@ diff --git a/packages/tools/src/install-global-cli.ts b/packages/tools/src/install-global-cli.ts index b72130eec8..1d431a75a7 100644 --- a/packages/tools/src/install-global-cli.ts +++ b/packages/tools/src/install-global-cli.ts @@ -1,14 +1,5 @@ import { execSync } from 'node:child_process'; -import { - chmodSync, - existsSync, - mkdtempSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -25,8 +16,8 @@ export function installGlobalCli() { const isDirectInvocation = process.argv[1]?.endsWith('install-global-cli.ts'); const args = process.argv.slice(isDirectInvocation ? 2 : 3); - const { positionals, values } = parseArgs({ - allowPositionals: true, + const { values } = parseArgs({ + allowPositionals: false, args, options: { tgz: { @@ -36,13 +27,7 @@ export function installGlobalCli() { }, }); - const binName = positionals[0]; - if (!binName || !['vp', 'vp-dev'].includes(binName)) { - console.error('Usage: tool install-global-cli [--tgz ]'); - process.exit(1); - } - - console.log(`Installing global CLI with bin name: ${binName}`); + console.log('Installing global CLI: vp'); let tempDir: string | undefined; let tgzPath: string; @@ -76,9 +61,7 @@ export function installGlobalCli() { } 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'); + const installDir = path.join(os.homedir(), '.vite-plus'); const env: Record = { ...(process.env as Record), @@ -104,107 +87,7 @@ export function installGlobalCli() { 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`); - } - } - } + // install.sh/install.ps1 already creates the correct symlinks and wrappers for vp } finally { // Cleanup temp dir only if we created it if (tempDir) { diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index 9ee3e36bab..62c55c02c1 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -85,9 +85,16 @@ export async function snapTest() { } } + const vitePlusHome = path.join(homedir(), '.vite-plus'); + + // Remove .previous-version so command-self-update-rollback snap test is stable + const previousVersionPath = path.join(vitePlusHome, '.previous-version'); + if (fs.existsSync(previousVersionPath)) { + fs.rmSync(previousVersionPath); + } + // 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')); @@ -181,8 +188,7 @@ 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'), + VITE_PLUS_HOME: path.join(homedir(), '.vite-plus'), // 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. diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index fd44ba13c0..4fb8a9832a 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -108,6 +108,7 @@ 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(), '') + .replaceAll(/\/\.vite-plus/g, '') // replace npm log file path with timestamp // e.g.: /.npm/_logs/T07_38_18_387Z-debug-0.log => /.npm/_logs/-debug.log .replaceAll( diff --git a/rfcs/self-update-command.md b/rfcs/self-update-command.md new file mode 100644 index 0000000000..597acddfb5 --- /dev/null +++ b/rfcs/self-update-command.md @@ -0,0 +1,629 @@ +# RFC: Self-Update Command + +## Status + +Draft + +## Background + +Vite+ is distributed as a standalone Rust binary via bash installation (`curl -fsSL https://viteplus.dev/install.sh | bash`). Currently, users must re-run the full install script to update to a new version. This is friction-heavy and unfamiliar to users who expect a built-in update mechanism (like `rustup update`, `volta fetch`, or `brew upgrade`). + +A native `vp self-update` command would allow users to update the CLI in-place with a single command, improving the upgrade experience significantly. + +### Current Installation Structure + +``` +~/.vite-plus/ +├── bin/ +│ ├── vp → ../current/bin/vp # Stable symlink (in PATH) +│ ├── node → ../current/bin/node # Shim symlinks +│ ├── npm → ../current/bin/npm +│ └── npx → ../current/bin/npx +├── current → 0.1.0/ # Symlink to active version +├── 0.1.0/ # Version directory +│ ├── bin/vp # Actual binary +│ ├── dist/ # JS bundles + .node files +│ ├── package.json +│ └── node_modules/ +├── 0.0.9/ # Previous version (kept for rollback) +├── env # POSIX shell env (sourced by shell config) +├── env.fish # Fish shell env +└── env.ps1 # PowerShell env +``` + +Key invariant: `~/.vite-plus/bin/vp` is a symlink to `../current/bin/vp` (Unix) or a `.cmd` wrapper calling `current\bin\vp.exe` (Windows), and `current` is a symlink (Unix) or junction (Windows) to the active version directory. Upgrading swaps the `current` link — atomic on Unix, near-instant on Windows. + +## Goals + +1. Provide a fast, reliable `vp self-update` command that upgrades the CLI to the latest (or specified) version +2. Reuse the same npm-based distribution channel (no new infrastructure) +3. Support atomic upgrades with automatic rollback on failure +4. Keep the last 5 versions for manual rollback +5. Support version pinning and channel selection (latest, test) + +## Non-Goals + +1. Auto-update on every command invocation (may be a future enhancement) +2. Windows PowerShell install path (covered by `install.ps1`) +3. Migrating away from npm as the distribution channel +4. Updating Node.js versions (already handled by `vp env`) + +## User Stories + +### Story 1: Quick Update to Latest + +A developer sees that a new version of Vite+ is available and wants to update. + +```bash +$ vp self-update +info: checking for updates... +info: found vite-plus-cli@0.2.0 (current: 0.1.0) +info: downloading vite-plus-cli@0.2.0 for darwin-arm64... +info: installing... + +✔ Updated vite-plus from 0.1.0 → 0.2.0 + + Release notes: https://github.com/voidzero-dev/vite-plus/releases/tag/v0.2.0 +``` + +### Story 2: Already Up to Date + +```bash +$ vp self-update +info: checking for updates... + +✔ Already up to date (0.2.0) +``` + +### Story 3: Update to a Specific Version + +```bash +$ vp self-update 0.1.5 +info: checking for updates... +info: found vite-plus-cli@0.1.5 (current: 0.2.0) +info: downloading vite-plus-cli@0.1.5 for darwin-arm64... +info: installing... + +✔ Updated vite-plus from 0.2.0 → 0.1.5 +``` + +### Story 4: Install a Test Channel Build + +```bash +$ vp self-update --tag test +info: checking for updates... +info: found vite-plus-cli@0.3.0-beta.1 (current: 0.2.0) +info: downloading vite-plus-cli@0.3.0-beta.1 for darwin-arm64... +info: installing... + +✔ Updated vite-plus from 0.2.0 → 0.3.0-beta.1 +``` + +### Story 5: Rollback to Previous Version + +```bash +$ vp self-update --rollback +info: rolling back to previous version... +info: switching from 0.2.0 → 0.1.0 + +✔ Rolled back to 0.1.0 +``` + +### Story 6: Check for Updates Without Installing + +```bash +$ vp self-update --check +info: checking for updates... +Update available: 0.2.0 → 0.3.0 +Run `vp self-update` to update. +``` + +### Story 7: CI Environment — Non-interactive + +```bash +# In CI, just update silently +$ vp self-update --silent +``` + +## Technical Design + +### Command Interface + +``` +vp self-update [VERSION] [OPTIONS] +vp upgrade [VERSION] [OPTIONS] # alias + +Arguments: + [VERSION] Target version (e.g., "0.2.0"). Defaults to "latest" + +Options: + --tag npm dist-tag to install (default: "latest", also: "test") + --check Check for updates without installing + --rollback Revert to the previously active version + --force Force reinstall even if already on the target version + --silent Suppress output (useful in CI) + --registry Custom npm registry URL (overrides NPM_CONFIG_REGISTRY) +``` + +### Architecture + +The self-update command is implemented entirely in Rust within the `vite_global_cli` crate, mirroring the logic of `install.sh` but running as a native subprocess workflow. + +``` +┌─────────────────────────────────────────────────┐ +│ vp self-update │ +├─────────────────────────────────────────────────┤ +│ 1. Resolve version (npm registry query) │ +│ 2. Check if already installed │ +│ 3. Download platform binary (.tgz) │ +│ 4. Download main JS bundle (.tgz) │ +│ 5. Extract to ~/.vite-plus/{version}/ │ +│ 6. Install production dependencies │ +│ 7. Atomic swap: current → {version} │ +│ 8. Refresh shims (non-fatal) │ +│ 9. Cleanup old versions (non-fatal, keep 5) │ +└─────────────────────────────────────────────────┘ +``` + +### Implementation Flow + +#### Step 1: Version Resolution + +Query the npm registry for the target version: + +``` +GET {registry}/vite-plus-cli/{version_or_tag} +``` + +- If `VERSION` arg is provided, use it directly +- If `--tag` is provided, resolve that dist-tag (e.g., `latest`, `test`) +- Default to `latest` + +Parse the JSON response to extract: + +- `version`: the resolved semver version +- `optionalDependencies`: to find the platform-specific package name + +#### Step 2: Version Comparison + +Compare the resolved version against the currently running binary's version (`env!("CARGO_PKG_VERSION")`). + +- If same version and `--force` is not set: print "already up to date" and exit +- If target is older: proceed (allows deliberate downgrade) + +#### Step 3: Download and Verify + +Download two tarballs from the npm registry: + +1. **Platform binary**: `{registry}/@voidzero-dev/vite-plus-cli-{platform_suffix}/-/vite-plus-cli-{suffix}-{version}.tgz` + - Contains: `vp` binary + `.node` NAPI files +2. **Main package**: `{registry}/vite-plus-cli/-/vite-plus-cli-{version}.tgz` + - Contains: `dist/` (JS bundles), `package.json`, `templates/`, `rules/`, `AGENTS.md` + +**Integrity verification**: Each tarball is verified against the `integrity` field from the npm registry metadata. The npm registry provides SHA-512 hashes in the [Subresource Integrity](https://w3c.github.io/webappsec-subresource-integrity/) format: + +```json +{ + "dist": { + "tarball": "https://registry.npmjs.org/vite-plus-cli/-/vite-plus-cli-0.0.0-xxx.tgz", + "integrity": "sha512-Z3se9k/NTRf8s5eSmuSoMOFFB/TUGBHIoeWDU5VoHV...", + "shasum": "3399579218148ae410011bde8934e12209743ef3" + } +} +``` + +Verification flow: + +1. Download tarball to temp file +2. Compute SHA-512 hash of the downloaded file +3. Base64-encode and compare against `integrity` field (format: `sha512-{base64}`) +4. If mismatch: delete temp file, report error, abort update + +```rust +use sha2::{Sha512, Digest}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; + +fn verify_integrity(data: &[u8], expected: &str) -> Result<(), Error> { + // Parse "sha512-{base64}" format + let expected_hash = expected.strip_prefix("sha512-") + .ok_or(Error::UnsupportedIntegrity(expected.into()))?; + + let mut hasher = Sha512::new(); + hasher.update(data); + let actual_hash = STANDARD.encode(hasher.finalize()); + + if actual_hash != expected_hash { + return Err(Error::IntegrityMismatch { + expected: expected.into(), + actual: format!("sha512-{}", actual_hash), + }); + } + Ok(()) +} +``` + +To get the `integrity` field for the platform package, we need to query its metadata separately: + +- Main package metadata: `{registry}/vite-plus-cli/{version}` → contains `dist.integrity` +- Platform package metadata: `{registry}/@voidzero-dev/vite-plus-cli-{suffix}/{version}` → contains `dist.integrity` + +Platform detection reuses existing logic from `vite_js_runtime` or mirrors the bash script's approach: + +- `uname -s` → os (darwin, linux) +- `uname -m` → arch (x64, arm64) +- Linux: detect gnu vs musl libc + +#### Step 4: Extract and Install + +1. Create `~/.vite-plus/{version}/` with `bin/` and `dist/` subdirectories +2. Extract platform binary to `{version}/bin/vp`, set executable permissions +3. Extract `.node` files to `{version}/dist/` +4. Extract JS bundle, templates, rules, package.json to `{version}/` +5. Strip `devDependencies` and `optionalDependencies` from package.json +6. Run `vp install --silent` in the version directory to install production dependencies + +#### Step 5: Version Swap + +**Unix (macOS/Linux)** — Atomic symlink swap: + +```rust +// Atomic symlink swap using rename +let temp_link = install_dir.join("current.new"); +std::os::unix::fs::symlink(version, &temp_link)?; +std::fs::rename(&temp_link, install_dir.join("current"))?; +``` + +This is atomic on POSIX systems because `rename()` on a symlink is an atomic operation. + +**Windows** — Junction swap (non-atomic, matching `install.ps1`): + +```rust +// Windows uses junctions (mklink /J) — no admin privileges required +let current_link = install_dir.join("current"); + +// Remove existing junction +if current_link.exists() { + junction::delete(¤t_link)?; +} + +// Create new junction pointing to version directory +junction::create(version_dir, ¤t_link)?; +``` + +Key differences on Windows: + +- **Junctions** (`mklink /J`) are used instead of symlinks — junctions don't require admin privileges +- Junctions only work for directories (which `current` is), and use absolute paths internally +- The swap is **not atomic** — there's a brief window (~milliseconds) where `current` doesn't exist +- `bin/vp` is a `.cmd` wrapper (not a symlink), so it doesn't need updating during self-update +- This matches the existing `install.ps1` behavior exactly + +#### Step 6: Post-Update (Non-Fatal) + +After the symlink swap (the **point of no return**), post-update operations are treated as non-fatal. Errors are printed to stderr as warnings but do not trigger the outer error handler (which would delete the now-active version directory). + +1. **Refresh shims**: Run the equivalent of `vp env setup --refresh` to ensure node/npm/npx shims point to the new version. If this fails, the user can run it manually. +2. **Cleanup old versions**: Remove old version directories, keeping the 5 most recent by **creation time** (matching `install.sh` behavior). The new version and the previous version are always protected from cleanup, even if they fall outside the top 5 (e.g., after a downgrade via `--rollback`). + +#### Step 7: Running Binary Consideration + +The running `vp` process is **not** the binary being replaced. The flow is: + +``` +# Unix +~/.vite-plus/bin/vp → ../current/bin/vp → {old_version}/bin/vp + +# Windows +~/.vite-plus/bin/vp.cmd → current\bin\vp.exe → {old_version}\bin\vp.exe +``` + +After the `current` link swap, any **new** invocation of `vp` will use the new binary. The currently running process continues to execute from the old version's binary file on disk: + +- **Unix**: The old binary remains valid because Unix doesn't delete open files until all file descriptors are closed +- **Windows**: The old `.exe` file is locked while running, but since we install to a **new version directory** (not overwriting in-place), there's no conflict. The old version directory is preserved (kept in the "last 5" cleanup policy) + +### Rollback Design + +The `--rollback` flag switches the `current` symlink to the previously active version. + +To track the previous version, we can: + +1. Read the `current` symlink target before updating +2. After the update, write the previous version to `~/.vite-plus/.previous-version` + +For `--rollback`: + +1. Read `~/.vite-plus/.previous-version` +2. Verify that version directory still exists +3. Swap `current` symlink to point to it +4. Update `.previous-version` to point to the version we just rolled back from + +### Error Handling + +| Error | Recovery | +| ------------------------------- | ------------------------------------------------------------- | +| Network failure during download | Clean up partial temp files, exit with helpful message | +| Integrity mismatch (SHA-512) | Delete downloaded file, report expected vs actual hash, abort | +| Corrupted tarball | Verify extraction success, clean up version dir if partial | +| `vp install` fails | Remove the version dir, keep current version unchanged | +| Disk full | Detect and report, clean up partial state | +| Permission denied | Report with suggestion to check directory ownership | +| Registry returns error | Parse npm error JSON, show human-readable message | + +Key principle: **The `current` symlink is only swapped after all pre-swap steps succeed.** If any pre-swap step fails, the existing installation is untouched. Post-swap operations (shim refresh, old version cleanup) are non-fatal — their errors are printed to stderr as warnings but do not roll back the update. + +### File Structure + +``` +crates/vite_global_cli/ +├── src/ +│ ├── commands/ +│ │ ├── self_update/ +│ │ │ ├── mod.rs # Module root, public execute() function +│ │ │ ├── registry.rs # npm registry client (version resolution, tarball URLs) +│ │ │ ├── platform.rs # Platform detection (os, arch, libc) +│ │ │ ├── download.rs # HTTP download + tarball extraction +│ │ │ └── install.rs # Extract, dependency install, symlink swap, cleanup +│ │ ├── mod.rs # Add self_update module +│ │ └── ... +│ └── cli.rs # Add SelfUpdate command variant +``` + +### Platform Detection + +```rust +fn detect_platform() -> Result { + let os = std::env::consts::OS; // "macos", "linux", "windows" + let arch = std::env::consts::ARCH; // "x86_64", "aarch64" + + let os_name = match os { + "macos" => "darwin", + "linux" => "linux", + "windows" => "win32", + _ => return Err(Error::UnsupportedPlatform(os.into())), + }; + + let arch_name = match arch { + "x86_64" => "x64", + "aarch64" => "arm64", + _ => return Err(Error::UnsupportedArch(arch.into())), + }; + + if os_name == "linux" { + let libc = detect_libc(); // "gnu" or "musl" + Ok(format!("{os_name}-{arch_name}-{libc}")) + } else if os_name == "win32" { + Ok(format!("{os_name}-{arch_name}-msvc")) + } else { + Ok(format!("{os_name}-{arch_name}")) + } +} +``` + +### Registry Client + +Uses `reqwest` (already a dependency via `vite_js_runtime`) for HTTP requests: + +```rust +async fn resolve_version(registry: &str, version_or_tag: &str) -> Result { + let url = format!("{}/vite-plus-cli/{}", registry, version_or_tag); + let response = reqwest::get(&url).await?.json::().await?; + Ok(response) +} +``` + +### CLI Integration + +Add `SelfUpdate` to the `Commands` enum in `cli.rs`: + +```rust +/// Update vp itself to the latest version +#[command(name = "self-update", visible_alias = "upgrade")] +SelfUpdate { + /// Target version (default: latest) + version: Option, + + /// npm dist-tag (default: "latest") + #[arg(long, default_value = "latest")] + tag: String, + + /// Check for updates without installing + #[arg(long)] + check: bool, + + /// Revert to previous version + #[arg(long)] + rollback: bool, + + /// Force reinstall even if up to date + #[arg(long)] + force: bool, + + /// Suppress output + #[arg(long)] + silent: bool, + + /// Custom npm registry URL + #[arg(long)] + registry: Option, +}, +``` + +## Design Decisions + +### 1. Command Name: `self-update` + +**Decision**: Use `vp self-update` (with hyphen). + +**Alternatives considered**: + +- `vp upgrade` — used by Deno, Bun, proto; shorter but ambiguous with `vp update` (packages) +- `vp self upgrade` — used by rustup (`rustup self update`); requires subcommand group + +**Rationale**: + +- Matches pnpm (`pnpm self-update`) and mise (`mise self-update`) conventions +- Zero ambiguity with `vp update` (which updates npm packages) +- The hyphen is consistent with `list-remote` in `vp env` +- Tools without self-update (fnm, volta, nvm) require re-running install scripts — worse UX +- `upgrade` is registered as a visible alias, so `vp upgrade` also works (matches Deno/Bun/proto users' expectations) + +### 2. Pure Rust Implementation (No Shell Script Re-execution) + +**Decision**: Implement the update logic entirely in Rust. + +**Rationale**: + +- No dependency on bash or curl being installed +- Better error handling and progress reporting +- Consistent behavior across platforms +- The install.sh script remains for first-time installation only + +### 3. Reuse npm Distribution Channel + +**Decision**: Download tarballs from the same npm registry used by `install.sh`. + +**Rationale**: + +- No new infrastructure needed +- Same release pipeline, same artifacts +- Supports custom registries and mirrors via `--registry` or `NPM_CONFIG_REGISTRY` +- Users behind corporate proxies already have npm registry access configured + +### 4. No Automatic Update Checks + +**Decision**: Do not check for updates on every `vp` invocation. + +**Rationale**: + +- Avoids unexpected network requests that slow down commands +- Avoids privacy concerns (phoning home on every run) +- Users can opt into periodic checks via their own cron/launchd if desired +- This can be revisited as a future enhancement with proper opt-in + +### 5. Keep 5 Versions for Rollback + +**Decision**: Maintain the same cleanup policy as `install.sh` (keep 5 most recent versions by creation time, with protected versions). + +**Rationale**: + +- Consistent with existing `install.sh` behavior (sorts by creation time, not semver) +- Provides rollback safety net without unbounded disk usage +- Each version is ~20-30MB, so 5 versions is ~100-150MB total +- The active version and previous version are always protected from cleanup, preventing accidental deletion after a downgrade + +## Implementation Phases + +### Phase 0 (P0): Core Self-Update + +**Scope:** + +- `vp self-update` — downloads and installs the latest version +- `vp self-update ` — installs a specific version +- `--tag`, `--force`, `--silent` flags +- Platform detection, npm registry query, download, extract, symlink swap +- Version cleanup (keep 5) +- Error handling with clean rollback + +**Files to create/modify:** + +- `crates/vite_global_cli/src/commands/self_update/mod.rs` (new) +- `crates/vite_global_cli/src/commands/self_update/registry.rs` (new) +- `crates/vite_global_cli/src/commands/self_update/platform.rs` (new) +- `crates/vite_global_cli/src/commands/self_update/download.rs` (new) +- `crates/vite_global_cli/src/commands/self_update/install.rs` (new) +- `crates/vite_global_cli/src/commands/mod.rs` (add module) +- `crates/vite_global_cli/src/cli.rs` (add command variant + routing) + +**Success Criteria:** + +- [ ] `vp self-update` downloads and installs the latest version +- [ ] `vp self-update 0.x.y` installs a specific version +- [ ] Downloaded tarballs are verified against npm registry `integrity` (SHA-512) +- [ ] Running binary is not affected during update +- [ ] Failed update leaves the current installation untouched +- [ ] Old versions are cleaned up (max 5 retained) +- [ ] Works on macOS, Linux, and Windows + +### Phase 1 (P1): Rollback and Check + +**Scope:** + +- `--rollback` flag with `.previous-version` tracking +- `--check` flag for update availability check + +**Success Criteria:** + +- [ ] `vp self-update --rollback` reverts to previous version +- [ ] `vp self-update --check` shows available update without installing + +### Phase 2 (P2): Enhanced UX + +**Scope:** + +- Progress bar for downloads (using `indicatif` or similar) +- Release notes URL in update success message +- `--registry` flag for custom npm registry + +**Success Criteria:** + +- [ ] Download progress is visible for large binaries +- [ ] Release notes link is shown after successful update + +## Testing Strategy + +### Unit Tests + +- Version comparison logic (semver parsing, equality, ordering) +- Platform detection (mock `std::env::consts`) +- Registry URL construction +- Symlink swap atomicity + +### Integration Tests + +- Download and extract a real package from the test npm tag +- Verify version directory structure after install +- Verify `current` symlink points to new version +- Verify old version cleanup + +### Snap Tests + +```bash +# Test: self-update check (mock registry response) +pnpm -F vite-plus-cli snap-test self-update-check + +# Test: self-update to specific version +pnpm -F vite-plus-cli snap-test self-update-version +``` + +### Manual Testing + +```bash +# Build and install current version +pnpm bootstrap-cli + +# Run self-update to latest published version +vp self-update + +# Verify version changed +vp -V + +# Test rollback +vp self-update --rollback +vp -V +``` + +## Future Enhancements + +- **Automatic update check**: Periodic background check with opt-in notification (e.g., once per day, cached result) +- **Update channels**: Allow pinning to a channel (stable, beta, nightly) via config file +- **Delta updates**: Download only changed files instead of full tarballs +- **Windows support**: Extend to PowerShell-based update mechanism for Windows native installs + +## References + +- [RFC: Global CLI (Rust Binary)](./global-cli-rust-binary.md) +- [RFC: Split Global CLI](./split-global-cli.md) +- [RFC: Env Command](./env-command.md) +- [Install Script](../packages/global/install.sh) +- [Release Workflow](../.github/workflows/release.yml)