From c8a4af6de07b5fdc5f7fb3871185ebbeabbfb1e7 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 17:38:26 +0800 Subject: [PATCH 01/39] feat(installer): add standalone Windows .exe installer (vp-setup.exe) Add a standalone `vp-setup.exe` Windows installer binary that installs the vp CLI without requiring PowerShell, complementing the existing `irm https://vite.plus/ps1 | iex` script-based installer. - Create `vite_setup` shared library crate extracting installation logic (platform detection, registry queries, integrity verification, tarball extraction, symlink/junction management) from `vite_global_cli` - Create `vite_installer` binary crate producing `vp-setup.exe` with interactive prompts, silent mode (-y), progress bars, and Windows PATH modification via direct registry API (no PowerShell dependency) - Update `vite_global_cli` to use `vite_setup` instead of inline upgrade modules, ensuring upgrade and installer share identical logic - Add CI build/upload steps for installer in release workflow, attached as GitHub Release assets - Add RFC document at rfcs/windows-installer.md --- .github/actions/build-upstream/action.yml | 7 + .github/workflows/release.yml | 31 ++ Cargo.lock | 41 +- Cargo.toml | 5 + crates/vite_global_cli/Cargo.toml | 5 +- .../src/commands/upgrade/mod.rs | 12 +- crates/vite_global_cli/src/error.rs | 7 +- crates/vite_global_cli/src/upgrade_check.rs | 2 +- crates/vite_installer/Cargo.toml | 26 + crates/vite_installer/src/cli.rs | 83 +++ crates/vite_installer/src/main.rs | 438 +++++++++++++++ crates/vite_installer/src/windows_path.rs | 244 +++++++++ crates/vite_setup/Cargo.toml | 32 ++ crates/vite_setup/src/error.rs | 24 + .../upgrade => vite_setup/src}/install.rs | 34 +- .../upgrade => vite_setup/src}/integrity.rs | 0 crates/vite_setup/src/lib.rs | 17 + .../upgrade => vite_setup/src}/platform.rs | 6 +- .../upgrade => vite_setup/src}/registry.rs | 4 +- rfcs/windows-installer.md | 511 ++++++++++++++++++ 20 files changed, 1484 insertions(+), 45 deletions(-) create mode 100644 crates/vite_installer/Cargo.toml create mode 100644 crates/vite_installer/src/cli.rs create mode 100644 crates/vite_installer/src/main.rs create mode 100644 crates/vite_installer/src/windows_path.rs create mode 100644 crates/vite_setup/Cargo.toml create mode 100644 crates/vite_setup/src/error.rs rename crates/{vite_global_cli/src/commands/upgrade => vite_setup/src}/install.rs (95%) rename crates/{vite_global_cli/src/commands/upgrade => vite_setup/src}/integrity.rs (100%) create mode 100644 crates/vite_setup/src/lib.rs rename crates/{vite_global_cli/src/commands/upgrade => vite_setup/src}/platform.rs (95%) rename crates/{vite_global_cli/src/commands/upgrade => vite_setup/src}/registry.rs (97%) create mode 100644 rfcs/windows-installer.md diff --git a/.github/actions/build-upstream/action.yml b/.github/actions/build-upstream/action.yml index 8b905b4ef3..958298471f 100644 --- a/.github/actions/build-upstream/action.yml +++ b/.github/actions/build-upstream/action.yml @@ -47,6 +47,7 @@ runs: ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp.exe ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp-shim.exe + ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp-setup.exe key: ${{ steps.cache-key.outputs.key }} # Apply Vite+ branding patches to vite source (CI checks out @@ -143,6 +144,11 @@ runs: shell: bash run: cargo build --release --target ${{ inputs.target }} -p vite_trampoline + - name: Build installer binary (Windows only) + if: steps.cache-restore.outputs.cache-hit != 'true' && contains(inputs.target, 'windows') + shell: bash + run: cargo build --release --target ${{ inputs.target }} -p vite_installer + - name: Save NAPI binding cache if: steps.cache-restore.outputs.cache-hit != 'true' uses: actions/cache/save@94b89442628ad1d101e352b7ee38f30e1bef108e # v5 @@ -156,6 +162,7 @@ runs: ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp.exe ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp-shim.exe + ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp-setup.exe key: ${{ steps.cache-key.outputs.key }} # Build vite-plus TypeScript after native bindings are ready diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77228993c6..1f78b139d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -131,6 +131,14 @@ jobs: ./target/${{ matrix.settings.target }}/release/vp-shim.exe if-no-files-found: error + - name: Upload installer binary artifact (Windows only) + if: contains(matrix.settings.target, 'windows') + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: vp-setup-${{ matrix.settings.target }} + path: ./target/${{ matrix.settings.target }}/release/vp-setup.exe + if-no-files-found: error + - name: Remove .node files before upload dist if: ${{ matrix.settings.target == 'x86_64-unknown-linux-gnu' }} run: | @@ -241,6 +249,12 @@ jobs: path: rust-cli-artifacts pattern: vite-global-cli-* + - name: Download installer binaries (Windows) + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: installer-artifacts + pattern: vp-setup-* + - name: Move Rust CLI binaries to target directories run: | # Move each artifact's binary to the correct target directory @@ -265,6 +279,19 @@ jobs: echo "Found binaries:" echo "$vp_files" + - name: Prepare installer binaries for release + run: | + mkdir -p installer-release + for artifact_dir in installer-artifacts/vp-setup-*/; do + if [ -d "$artifact_dir" ]; then + dir_name=$(basename "$artifact_dir") + target_name=${dir_name#vp-setup-} + cp "$artifact_dir/vp-setup.exe" "installer-release/vp-setup-${target_name}.exe" + fi + done + echo "Installer binaries:" + ls -la installer-release/ || echo "No installer binaries found" + - name: Set npm packages version run: | sed -i 's/"version": "0.0.0"/"version": "${{ env.VERSION }}"/' packages/core/package.json @@ -318,6 +345,8 @@ jobs: ${INSTALL_PS1} \`\`\` + Or download and run \`vp-setup.exe\` from the assets below. + View the full commit: https://github.com/${{ github.repository }}/commit/${{ github.sha }} EOF @@ -332,6 +361,8 @@ jobs: name: vite-plus v${{ env.VERSION }} tag_name: v${{ env.VERSION }} target_commitish: ${{ github.sha }} + files: | + installer-release/vp-setup-*.exe - name: Send Discord notification if: ${{ inputs.npm_tag == 'latest' }} diff --git a/Cargo.lock b/Cargo.lock index d4a0672067..3b733dcdd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7394,13 +7394,11 @@ dependencies = [ name = "vite_global_cli" version = "0.0.0" dependencies = [ - "base64-simd", "chrono", "clap", "clap_complete", "crossterm", "directories", - "flate2", "junction", "node-semver", "owo-colors", @@ -7408,8 +7406,6 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha2", - "tar", "tempfile", "thiserror 2.0.18", "tokio", @@ -7419,6 +7415,7 @@ dependencies = [ "vite_install", "vite_js_runtime", "vite_path", + "vite_setup", "vite_shared", "vite_str", "vite_workspace", @@ -7465,6 +7462,21 @@ dependencies = [ "vite_workspace", ] +[[package]] +name = "vite_installer" +version = "0.0.0" +dependencies = [ + "clap", + "indicatif", + "owo-colors", + "tokio", + "tracing", + "vite_install", + "vite_path", + "vite_setup", + "vite_shared", +] + [[package]] name = "vite_js_runtime" version = "0.0.0" @@ -7532,6 +7544,27 @@ dependencies = [ "vite_str", ] +[[package]] +name = "vite_setup" +version = "0.0.0" +dependencies = [ + "base64-simd", + "flate2", + "junction", + "node-semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "vite_install", + "vite_path", + "vite_str", +] + [[package]] name = "vite_shared" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 53c60d3d05..325b25bb96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -197,6 +197,7 @@ vite_js_runtime = { path = "crates/vite_js_runtime" } vite_glob = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "eb746ad3f35bd994ddb39be001eaf58986f48388" } vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } +vite_setup = { path = "crates/vite_setup" } vite_shared = { path = "crates/vite_shared" } vite_static_config = { path = "crates/vite_static_config" } vite_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "eb746ad3f35bd994ddb39be001eaf58986f48388" } @@ -334,3 +335,7 @@ panic = "abort" # Let it crash and force ourselves to write safe Rust. # size instead of speed. This reduces it from ~200KB to ~100KB on Windows. [profile.release.package.vite_trampoline] opt-level = "z" + +# The installer binary is downloaded by users, so optimize for size. +[profile.release.package.vite_installer] +opt-level = "z" diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index fa1b693d69..da2300973a 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -12,17 +12,13 @@ name = "vp" path = "src/main.rs" [dependencies] -base64-simd = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true, features = ["unstable-dynamic"] } directories = { workspace = true } -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 } @@ -34,6 +30,7 @@ vite_install = { workspace = true } vite_js_runtime = { workspace = true } vite_path = { workspace = true } vite_command = { workspace = true } +vite_setup = { workspace = true } vite_shared = { workspace = true } vite_str = { workspace = true } vite_workspace = { workspace = true } diff --git a/crates/vite_global_cli/src/commands/upgrade/mod.rs b/crates/vite_global_cli/src/commands/upgrade/mod.rs index 6fbde07ff2..8e748f3805 100644 --- a/crates/vite_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vite_global_cli/src/commands/upgrade/mod.rs @@ -3,16 +3,12 @@ //! Downloads and installs a new version of the CLI from the npm registry //! with SHA-512 integrity verification. -mod install; -mod integrity; -mod platform; -pub(crate) mod registry; - use std::process::ExitStatus; use owo_colors::OwoColorize; use vite_install::request::HttpClient; use vite_path::AbsolutePathBuf; +use vite_setup::{install, integrity, platform, registry}; use vite_shared::output; use crate::{commands::env::config::get_vp_home, error::Error}; @@ -35,9 +31,6 @@ pub struct UpgradeOptions { pub registry: Option, } -/// Maximum number of old versions to keep. -const MAX_VERSIONS_KEEP: usize = 5; - /// Execute the upgrade command. #[allow(clippy::print_stdout, clippy::print_stderr)] pub async fn execute(options: UpgradeOptions) -> Result { @@ -189,7 +182,8 @@ async fn install_platform_and_main( 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 + if let Err(e) = + install::cleanup_old_versions(install_dir, vite_setup::MAX_VERSIONS_KEEP, &protected).await { output::warn(&format!("Old version cleanup failed (non-fatal): {e}")); } diff --git a/crates/vite_global_cli/src/error.rs b/crates/vite_global_cli/src/error.rs index 870e7801f7..00d999d2d3 100644 --- a/crates/vite_global_cli/src/error.rs +++ b/crates/vite_global_cli/src/error.rs @@ -52,9 +52,6 @@ pub enum Error { #[error("Upgrade error: {0}")] Upgrade(Str), - #[error("Integrity mismatch: expected {expected}, got {actual}")] - IntegrityMismatch { expected: Str, actual: Str }, - - #[error("Unsupported integrity format: {0} (only sha512 is supported)")] - UnsupportedIntegrity(Str), + #[error("{0}")] + Setup(#[from] vite_setup::error::Error), } diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index 111c699c4a..0a3daddb50 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -12,7 +12,7 @@ use std::{ use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use crate::commands::upgrade::registry; +use vite_setup::registry; const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; const PROMPT_INTERVAL_SECS: u64 = 24 * 60 * 60; diff --git a/crates/vite_installer/Cargo.toml b/crates/vite_installer/Cargo.toml new file mode 100644 index 0000000000..ac125640a5 --- /dev/null +++ b/crates/vite_installer/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "vite_installer" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[[bin]] +name = "vp-setup" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true, features = ["derive"] } +indicatif = { workspace = true } +owo-colors = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +vite_install = { workspace = true } +vite_path = { workspace = true } +vite_setup = { workspace = true } +vite_shared = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_installer/src/cli.rs b/crates/vite_installer/src/cli.rs new file mode 100644 index 0000000000..ab05a47848 --- /dev/null +++ b/crates/vite_installer/src/cli.rs @@ -0,0 +1,83 @@ +//! CLI argument parsing for `vp-setup`. + +use clap::Parser; + +/// Vite+ Installer — standalone installer for the vp CLI. +#[derive(Parser, Debug)] +#[command(name = "vp-setup", about = "Install the Vite+ CLI")] +struct Cli { + /// Accept defaults without prompting (for CI/unattended installs) + #[arg(short = 'y', long = "yes")] + yes: bool, + + /// Suppress all output except errors + #[arg(short = 'q', long = "quiet")] + quiet: bool, + + /// Install a specific version (default: latest) + #[arg(long = "version")] + version: Option, + + /// npm dist-tag to install (default: latest) + #[arg(long = "tag", default_value = "latest")] + tag: String, + + /// Custom installation directory (default: ~/.vite-plus) + #[arg(long = "install-dir")] + install_dir: Option, + + /// Custom npm registry URL + #[arg(long = "registry")] + registry: Option, + + /// Skip Node.js version manager setup + #[arg(long = "no-node-manager")] + no_node_manager: bool, + + /// Do not modify the User PATH + #[arg(long = "no-modify-path")] + no_modify_path: bool, +} + +/// Parsed installation options. +pub struct Options { + pub yes: bool, + pub quiet: bool, + pub version: Option, + pub tag: String, + pub install_dir: Option, + pub registry: Option, + pub no_node_manager: bool, + pub no_modify_path: bool, +} + +/// Parse CLI arguments, merging with environment variables. +/// +/// CLI flags take precedence over environment variables. +pub fn parse() -> Options { + let cli = Cli::parse(); + + // Environment variable overrides (CLI flags take precedence) + let version = cli.version.or_else(|| std::env::var("VP_VERSION").ok()); + let install_dir = cli.install_dir.or_else(|| std::env::var("VP_HOME").ok()); + let registry = cli.registry.or_else(|| std::env::var("NPM_CONFIG_REGISTRY").ok()); + + let no_node_manager = cli.no_node_manager + || std::env::var("VP_NODE_MANAGER") + .ok() + .is_some_and(|v| v.eq_ignore_ascii_case("no")); + + // quiet implies yes + let yes = cli.yes || cli.quiet; + + Options { + yes, + quiet: cli.quiet, + version, + tag: cli.tag, + install_dir, + registry, + no_node_manager, + no_modify_path: cli.no_modify_path, + } +} diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs new file mode 100644 index 0000000000..2364bc613b --- /dev/null +++ b/crates/vite_installer/src/main.rs @@ -0,0 +1,438 @@ +//! Standalone Windows installer for the Vite+ CLI (`vp-setup.exe`). +//! +//! This binary provides a download-and-run installation experience for Windows, +//! complementing the existing PowerShell installer (`install.ps1`). +//! +//! Modeled after `rustup-init.exe`: +//! - Console-based (no GUI) +//! - Interactive prompts with numbered menu +//! - Silent mode via `-y` for CI +//! - Works from cmd.exe, PowerShell, Git Bash, or double-click + +mod cli; + +#[cfg(windows)] +mod windows_path; + +use std::io::{self, Write}; + +use indicatif::{ProgressBar, ProgressStyle}; +use owo_colors::OwoColorize; +use vite_install::request::HttpClient; +use vite_setup::{install, integrity, platform, registry}; + +/// DLL security: restrict DLL search to system32 only. +/// Prevents DLL hijacking when the installer is run from a Downloads folder. +#[cfg(windows)] +fn init_dll_security() { + unsafe extern "system" { + fn SetDefaultDllDirectories(directory_flags: u32) -> i32; + } + const LOAD_LIBRARY_SEARCH_SYSTEM32: u32 = 0x0000_0800; + unsafe { + SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32); + } +} + +#[cfg(not(windows))] +fn init_dll_security() {} + +fn main() { + init_dll_security(); + + let opts = cli::parse(); + let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap_or_else(|e| { + print_error(&format!("Failed to create async runtime: {e}")); + std::process::exit(1); + }); + + let code = rt.block_on(run(opts)); + std::process::exit(code); +} + +#[allow(clippy::print_stdout, clippy::print_stderr)] +async fn run(opts: cli::Options) -> i32 { + // Interactive mode: show welcome and prompt + if !opts.yes { + let proceed = show_interactive_menu(&opts); + if !proceed { + println!("Installation cancelled."); + return 0; + } + } + + match do_install(&opts).await { + Ok(()) => { + print_success(&opts); + 0 + } + Err(e) => { + print_error(&format!("{e}")); + 1 + } + } +} + +/// The core installation flow, matching what `install.ps1` does. +#[allow(clippy::print_stdout)] +async fn do_install(opts: &cli::Options) -> Result<(), Box> { + // Step 1: Detect platform + let platform_suffix = platform::detect_platform_suffix()?; + if !opts.quiet { + print_info(&format!("detected platform: {platform_suffix}")); + } + + // Step 2: Resolve version from npm registry + let version_or_tag = opts.version.as_deref().unwrap_or(&opts.tag); + if !opts.quiet { + print_info(&format!("resolving version '{version_or_tag}'...")); + } + let resolved = + registry::resolve_version(version_or_tag, &platform_suffix, opts.registry.as_deref()) + .await?; + if !opts.quiet { + print_info(&format!("found vite-plus@{}", resolved.version)); + } + + // Step 3: Check for existing installation + let install_dir = resolve_install_dir(opts)?; + tokio::fs::create_dir_all(&install_dir).await?; + + let current_version = read_current_version(&install_dir).await; + if let Some(ref current) = current_version { + if current == &resolved.version { + if !opts.quiet { + println!( + "\n{} Already installed ({})", + "\u{2714}".green(), + resolved.version + ); + } + return Ok(()); + } + if !opts.quiet { + print_info(&format!("upgrading from {current} to {}", resolved.version)); + } + } + + // Step 4: Download platform tarball + if !opts.quiet { + print_info(&format!( + "downloading vite-plus@{} for {}...", + resolved.version, platform_suffix + )); + } + let client = HttpClient::new(); + let platform_data = download_with_progress( + &client, + &resolved.platform_tarball_url, + opts.quiet, + ) + .await?; + + // Step 5: Verify integrity + if !opts.quiet { + print_info("verifying integrity..."); + } + integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; + + // Step 6: Create version directory + let version_dir = install_dir.join(&resolved.version); + tokio::fs::create_dir_all(&version_dir).await?; + + // Step 7: Extract binary + if !opts.quiet { + print_info("extracting binary..."); + } + install::extract_platform_package(&platform_data, &version_dir).await?; + + // Verify binary was 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("Binary not found after extraction. The download may be corrupted.".into()); + } + + // Step 8: Generate wrapper package.json + install::generate_wrapper_package_json(&version_dir, &resolved.version).await?; + + // Step 9: Write .npmrc overrides + install::write_release_age_overrides(&version_dir).await?; + + // Step 10: Install production dependencies + if !opts.quiet { + print_info("installing dependencies (this may take a moment)..."); + } + install::install_production_deps(&version_dir, opts.registry.as_deref()).await?; + + // Step 11: Swap current symlink/junction + if current_version.is_some() { + install::save_previous_version(&install_dir).await?; + } + install::swap_current_link(&install_dir, &resolved.version).await?; + + // Step 12: Create bin shims + if !opts.quiet { + print_info("setting up shims..."); + } + setup_bin_shims(&install_dir).await?; + + // Step 13: Refresh shims (Node.js manager) + if !opts.no_node_manager { + if !opts.quiet { + print_info("setting up Node.js version manager..."); + } + install::refresh_shims(&install_dir).await?; + } + + // Step 14: Cleanup old versions + if let Err(e) = install::cleanup_old_versions( + &install_dir, + vite_setup::MAX_VERSIONS_KEEP, + &[&resolved.version], + ) + .await + { + tracing::warn!("Old version cleanup failed (non-fatal): {e}"); + } + + // Step 15: Modify PATH + if !opts.no_modify_path { + let bin_dir_str = install_dir.join("bin").as_path().to_string_lossy().to_string(); + modify_path(&bin_dir_str, opts.quiet)?; + } + + Ok(()) +} + +/// Set up the bin/ directory with the initial `vp` shim. +/// +/// On Windows, copies `vp-shim.exe` from `current/bin/` to `bin/vp.exe`. +/// On Unix, creates a symlink from `bin/vp` to `../current/bin/vp`. +async fn setup_bin_shims( + install_dir: &vite_path::AbsolutePath, +) -> Result<(), Box> { + let bin_dir = install_dir.join("bin"); + tokio::fs::create_dir_all(&bin_dir).await?; + + #[cfg(windows)] + { + let shim_src = install_dir.join("current").join("bin").join("vp-shim.exe"); + let shim_dst = bin_dir.join("vp.exe"); + + if tokio::fs::try_exists(&shim_src).await.unwrap_or(false) { + // Handle running exe: rename old, copy new + if shim_dst.as_path().exists() { + let old_name = format!( + "vp.exe.{}.old", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + ); + let old_path = bin_dir.join(&old_name); + let _ = tokio::fs::rename(&shim_dst, &old_path).await; + } + tokio::fs::copy(&shim_src, &shim_dst).await?; + } else { + // Fallback: copy vp.exe directly + let vp_src = install_dir.join("current").join("bin").join("vp.exe"); + if tokio::fs::try_exists(&vp_src).await.unwrap_or(false) { + if shim_dst.as_path().exists() { + let old_name = format!( + "vp.exe.{}.old", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + ); + let old_path = bin_dir.join(&old_name); + let _ = tokio::fs::rename(&shim_dst, &old_path).await; + } + tokio::fs::copy(&vp_src, &shim_dst).await?; + } + } + + // Best-effort cleanup of old shim files + if let Ok(mut entries) = tokio::fs::read_dir(&bin_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + if name.to_string_lossy().ends_with(".old") { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } + } + + #[cfg(unix)] + { + let link_target = std::path::PathBuf::from("../current/bin/vp"); + let link_path = bin_dir.join("vp"); + + // Remove existing symlink + let _ = tokio::fs::remove_file(&link_path).await; + tokio::fs::symlink(&link_target, &link_path).await?; + } + + Ok(()) +} + +/// Download bytes with a progress bar. +async fn download_with_progress( + client: &HttpClient, + url: &str, + quiet: bool, +) -> Result, Box> { + if quiet { + return Ok(client.get_bytes(url).await?); + } + + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}") + .unwrap_or_else(|_| ProgressStyle::default_spinner()), + ); + pb.set_message("downloading..."); + pb.enable_steady_tick(std::time::Duration::from_millis(100)); + + let data = client.get_bytes(url).await?; + + pb.finish_and_clear(); + Ok(data) +} + +/// Read the current installed version by following the `current` symlink/junction. +async fn read_current_version( + install_dir: &vite_path::AbsolutePath, +) -> Option { + let current_link = install_dir.join("current"); + let target = tokio::fs::read_link(¤t_link).await.ok()?; + target.file_name()?.to_str().map(String::from) +} + +/// Resolve the installation directory. +fn resolve_install_dir( + opts: &cli::Options, +) -> Result> { + if let Some(ref dir) = opts.install_dir { + let path = std::path::PathBuf::from(dir); + let abs = if path.is_absolute() { + path + } else { + std::env::current_dir()?.join(path) + }; + vite_path::AbsolutePathBuf::new(abs) + .ok_or_else(|| "Invalid installation directory".into()) + } else if let Ok(dir) = vite_shared::get_vp_home() { + Ok(dir) + } else { + // Fallback: ~/.vite-plus + let home = dirs_home().ok_or("Could not determine home directory")?; + vite_path::AbsolutePathBuf::new(home.join(".vite-plus")) + .ok_or_else(|| "Invalid home directory".into()) + } +} + +fn dirs_home() -> Option { + #[cfg(windows)] + { + std::env::var_os("USERPROFILE").map(std::path::PathBuf::from) + } + #[cfg(not(windows))] + { + std::env::var_os("HOME").map(std::path::PathBuf::from) + } +} + +/// Modify the user's PATH to include the bin directory. +#[allow(clippy::print_stdout)] +fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box> { + #[cfg(windows)] + { + windows_path::add_to_user_path(bin_dir)?; + if !quiet { + print_info("added to User PATH (restart your terminal to pick up changes)"); + } + } + + #[cfg(not(windows))] + { + // On non-Windows, env file setup is handled by `vp env setup` + if !quiet { + print_info(&format!("add {bin_dir} to your shell's PATH")); + } + } + + Ok(()) +} + +/// Show the interactive installation menu. Returns `true` if user wants to proceed. +#[allow(clippy::print_stdout)] +fn show_interactive_menu(opts: &cli::Options) -> bool { + let install_dir = resolve_install_dir(opts) + .map(|p| p.as_path().to_string_lossy().to_string()) + .unwrap_or_else(|_| "~/.vite-plus".to_string()); + let version = opts.version.as_deref().unwrap_or("latest"); + let bin_dir = format!("{install_dir}/bin"); + + println!(); + println!(" {}", "Welcome to Vite+ Installer!".bold()); + println!(); + println!(" This will install the {} CLI and monorepo task runner.", "vp".cyan()); + println!(); + println!(" Install directory: {}", install_dir.cyan()); + println!(" PATH modification: {}", if opts.no_modify_path { "no".to_string() } else { format!("{bin_dir} → User PATH") }.cyan()); + println!(" Version: {}", version.cyan()); + println!(" Node.js manager: {}", if opts.no_node_manager { "disabled" } else { "auto-detect" }.cyan()); + println!(); + println!(" 1) {} (default)", "Proceed with installation".bold()); + println!(" 2) Cancel"); + println!(); + print!(" > "); + let _ = io::stdout().flush(); + + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_err() { + return false; + } + + let choice = input.trim(); + choice.is_empty() || choice == "1" +} + +#[allow(clippy::print_stdout)] +fn print_success(opts: &cli::Options) { + if opts.quiet { + return; + } + + let install_dir = resolve_install_dir(opts) + .map(|p| p.as_path().to_string_lossy().to_string()) + .unwrap_or_else(|_| "~/.vite-plus".to_string()); + + println!(); + println!( + " {} Vite+ has been installed successfully!", + "\u{2714}".green().bold() + ); + println!(); + println!(" To get started, restart your terminal, then run:"); + println!(); + println!(" {}", "vp --help".cyan()); + println!(); + println!(" Install directory: {install_dir}"); + println!(" Documentation: {}", "https://github.com/voidzero-dev/vite-plus"); + println!(); +} + +#[allow(clippy::print_stderr)] +fn print_info(msg: &str) { + eprint!("{}", "info: ".blue()); + eprintln!("{msg}"); +} + +#[allow(clippy::print_stderr)] +fn print_error(msg: &str) { + eprint!("{}", "error: ".red()); + eprintln!("{msg}"); +} diff --git a/crates/vite_installer/src/windows_path.rs b/crates/vite_installer/src/windows_path.rs new file mode 100644 index 0000000000..235aeb0613 --- /dev/null +++ b/crates/vite_installer/src/windows_path.rs @@ -0,0 +1,244 @@ +//! Windows User PATH modification via registry. +//! +//! Adds the vp bin directory to `HKCU\Environment\Path` so that `vp` is +//! available from cmd.exe, PowerShell, Git Bash, and any new terminal session. + +use std::io; + +/// Raw Win32 FFI declarations for registry and environment broadcast. +/// +/// We declare these inline to avoid pulling in the `windows-sys` crate, +/// following the same zero-dependency pattern as `vite_trampoline`. +mod ffi { + #![allow(non_snake_case, clippy::upper_case_acronyms)] + + pub type HKEY = isize; + pub type DWORD = u32; + pub type LONG = i32; + pub type LPCWSTR = *const u16; + pub type LPWSTR = *mut u16; + pub type HWND = isize; + pub type WPARAM = usize; + pub type LPARAM = isize; + pub type UINT = u32; + + pub const HKEY_CURRENT_USER: HKEY = -2_147_483_647; + pub const KEY_READ: DWORD = 0x0002_0019; + pub const KEY_WRITE: DWORD = 0x0002_0006; + pub const REG_EXPAND_SZ: DWORD = 2; + pub const ERROR_SUCCESS: LONG = 0; + pub const ERROR_FILE_NOT_FOUND: LONG = 2; + pub const HWND_BROADCAST: HWND = 0xFFFF; + pub const WM_SETTINGCHANGE: UINT = 0x001A; + pub const SMTO_ABORTIFHUNG: UINT = 0x0002; + + unsafe extern "system" { + pub fn RegOpenKeyExW( + hKey: HKEY, + lpSubKey: LPCWSTR, + ulOptions: DWORD, + samDesired: DWORD, + phkResult: *mut HKEY, + ) -> LONG; + + pub fn RegQueryValueExW( + hKey: HKEY, + lpValueName: LPCWSTR, + lpReserved: *mut DWORD, + lpType: *mut DWORD, + lpData: *mut u8, + lpcbData: *mut DWORD, + ) -> LONG; + + pub fn RegSetValueExW( + hKey: HKEY, + lpValueName: LPCWSTR, + Reserved: DWORD, + dwType: DWORD, + lpData: *const u8, + cbData: DWORD, + ) -> LONG; + + pub fn RegCloseKey(hKey: HKEY) -> LONG; + + pub fn SendMessageTimeoutW( + hWnd: HWND, + Msg: UINT, + wParam: WPARAM, + lParam: LPARAM, + fuFlags: UINT, + uTimeout: UINT, + lpdwResult: *mut usize, + ) -> isize; + } +} + +/// Encode a Rust string as a null-terminated wide (UTF-16) string. +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Read the current User PATH from the registry. +fn read_user_path() -> io::Result { + let sub_key = to_wide("Environment"); + let value_name = to_wide("Path"); + + let mut hkey: ffi::HKEY = 0; + let result = unsafe { + ffi::RegOpenKeyExW( + ffi::HKEY_CURRENT_USER, + sub_key.as_ptr(), + 0, + ffi::KEY_READ, + &mut hkey, + ) + }; + + if result == ffi::ERROR_FILE_NOT_FOUND { + return Ok(String::new()); + } + if result != ffi::ERROR_SUCCESS { + return Err(io::Error::from_raw_os_error(result)); + } + + // Query the size first + let mut data_type: ffi::DWORD = 0; + let mut data_size: ffi::DWORD = 0; + let result = unsafe { + ffi::RegQueryValueExW( + hkey, + value_name.as_ptr(), + std::ptr::null_mut(), + &mut data_type, + std::ptr::null_mut(), + &mut data_size, + ) + }; + + if result == ffi::ERROR_FILE_NOT_FOUND { + unsafe { ffi::RegCloseKey(hkey) }; + return Ok(String::new()); + } + if result != ffi::ERROR_SUCCESS { + unsafe { ffi::RegCloseKey(hkey) }; + return Err(io::Error::from_raw_os_error(result)); + } + + // Read the data + let mut buf = vec![0u8; data_size as usize]; + let result = unsafe { + ffi::RegQueryValueExW( + hkey, + value_name.as_ptr(), + std::ptr::null_mut(), + &mut data_type, + buf.as_mut_ptr(), + &mut data_size, + ) + }; + + unsafe { ffi::RegCloseKey(hkey) }; + + if result != ffi::ERROR_SUCCESS { + return Err(io::Error::from_raw_os_error(result)); + } + + // Convert UTF-16 to String (strip trailing null) + let wide: Vec = buf + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let s = String::from_utf16_lossy(&wide); + Ok(s.trim_end_matches('\0').to_string()) +} + +/// Write the User PATH to the registry. +fn write_user_path(path: &str) -> io::Result<()> { + let sub_key = to_wide("Environment"); + let value_name = to_wide("Path"); + let wide_path = to_wide(path); + + let mut hkey: ffi::HKEY = 0; + let result = unsafe { + ffi::RegOpenKeyExW( + ffi::HKEY_CURRENT_USER, + sub_key.as_ptr(), + 0, + ffi::KEY_WRITE, + &mut hkey, + ) + }; + + if result != ffi::ERROR_SUCCESS { + return Err(io::Error::from_raw_os_error(result)); + } + + let byte_len = (wide_path.len() * 2) as ffi::DWORD; + let result = unsafe { + ffi::RegSetValueExW( + hkey, + value_name.as_ptr(), + 0, + ffi::REG_EXPAND_SZ, + wide_path.as_ptr().cast::(), + byte_len, + ) + }; + + unsafe { ffi::RegCloseKey(hkey) }; + + if result != ffi::ERROR_SUCCESS { + return Err(io::Error::from_raw_os_error(result)); + } + + Ok(()) +} + +/// Broadcast `WM_SETTINGCHANGE` so other processes pick up the PATH change. +fn broadcast_settings_change() { + let env_wide = to_wide("Environment"); + let mut _result: usize = 0; + unsafe { + ffi::SendMessageTimeoutW( + ffi::HWND_BROADCAST, + ffi::WM_SETTINGCHANGE, + 0, + env_wide.as_ptr() as ffi::LPARAM, + ffi::SMTO_ABORTIFHUNG, + 5000, + &mut _result, + ); + } +} + +/// Add a directory to the User PATH if not already present. +/// +/// Reads `HKCU\Environment\Path`, checks if `bin_dir` is already there +/// (case-insensitive, with/without trailing backslash), and prepends if not. +/// Broadcasts `WM_SETTINGCHANGE` so new terminal sessions see the change. +pub fn add_to_user_path(bin_dir: &str) -> io::Result<()> { + let current = read_user_path()?; + let bin_dir_normalized = bin_dir.trim_end_matches('\\'); + + // Check if already in PATH (case-insensitive, handle trailing backslash) + let already_present = current.split(';').any(|entry| { + let entry_normalized = entry.trim_end_matches('\\'); + entry_normalized.eq_ignore_ascii_case(bin_dir_normalized) + }); + + if already_present { + return Ok(()); + } + + // Prepend to PATH + let new_path = if current.is_empty() { + bin_dir.to_string() + } else { + format!("{bin_dir};{current}") + }; + + write_user_path(&new_path)?; + broadcast_settings_change(); + + Ok(()) +} diff --git a/crates/vite_setup/Cargo.toml b/crates/vite_setup/Cargo.toml new file mode 100644 index 0000000000..59f55d9606 --- /dev/null +++ b/crates/vite_setup/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "vite_setup" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +base64-simd = { workspace = true } +flate2 = { workspace = true } +node-semver = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tar = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +vite_install = { workspace = true } +vite_path = { workspace = true } +vite_str = { workspace = true } + +[target.'cfg(windows)'.dependencies] +junction = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_setup/src/error.rs b/crates/vite_setup/src/error.rs new file mode 100644 index 0000000000..dfc05208b6 --- /dev/null +++ b/crates/vite_setup/src/error.rs @@ -0,0 +1,24 @@ +//! Error types for the setup library. + +use std::io; + +use vite_str::Str; + +/// Error type for setup operations. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Setup error: {0}")] + Setup(Str), + + #[error("I/O error: {0}")] + Io(#[from] io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[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_global_cli/src/commands/upgrade/install.rs b/crates/vite_setup/src/install.rs similarity index 95% rename from crates/vite_global_cli/src/commands/upgrade/install.rs rename to crates/vite_setup/src/install.rs index 569cf47562..0a4d3ba312 100644 --- a/crates/vite_global_cli/src/commands/upgrade/install.rs +++ b/crates/vite_setup/src/install.rs @@ -1,4 +1,4 @@ -//! Installation logic for upgrade. +//! Installation logic shared between `vp upgrade` and `vp-setup.exe`. //! //! Handles tarball extraction, dependency installation, symlink swapping, //! and version cleanup. @@ -82,7 +82,7 @@ pub async fn extract_platform_package( Ok::<(), Error>(()) }) .await - .map_err(|e| Error::Upgrade(format!("Task join error: {e}").into()))??; + .map_err(|e| Error::Setup(format!("Task join error: {e}").into()))??; Ok(()) } @@ -128,7 +128,7 @@ pub async fn write_release_age_overrides(version_dir: &AbsolutePath) -> Result<( /// so it survives the cleanup that removes `version_dir` on failure. /// /// Returns the log file path on success, or `None` if writing failed. -pub async fn write_upgrade_log( +pub async fn write_install_log( version_dir: &AbsolutePath, stdout: &[u8], stderr: &[u8], @@ -142,7 +142,7 @@ pub async fn write_upgrade_log( match tokio::fs::write(&log_path, &content).await { Ok(()) => Some(log_path), Err(e) => { - tracing::warn!("Failed to write upgrade log: {}", e); + tracing::warn!("Failed to write install log: {}", e); None } } @@ -151,7 +151,7 @@ pub async fn write_upgrade_log( /// Install production dependencies using the new version's binary. /// /// Spawns: `{version_dir}/bin/vp install --silent [--registry ]` with `CI=true`. -/// On failure, writes stdout+stderr to `{version_dir}/upgrade.log` for debugging. +/// On failure, writes stdout+stderr to `{install_dir}/upgrade.log` for debugging. pub async fn install_production_deps( version_dir: &AbsolutePath, registry: Option<&str>, @@ -159,7 +159,7 @@ pub async fn install_production_deps( 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::Upgrade( + return Err(Error::Setup( format!("New binary not found at {}", vp_binary.as_path().display()).into(), )); } @@ -181,12 +181,12 @@ pub async fn install_production_deps( .await?; if !output.status.success() { - let log_path = write_upgrade_log(version_dir, &output.stdout, &output.stderr).await; + let log_path = write_install_log(version_dir, &output.stdout, &output.stderr).await; let log_msg = log_path.map_or_else( || String::new(), |p| format!(". See log for details: {}", p.as_path().display()), ); - return Err(Error::Upgrade( + return Err(Error::Setup( format!( "Failed to install production dependencies (exit code: {}){}", output.status.code().unwrap_or(-1), @@ -231,7 +231,7 @@ pub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Res // Verify the version directory exists if !tokio::fs::try_exists(&version_dir).await.unwrap_or(false) { - return Err(Error::Upgrade( + return Err(Error::Setup( format!("Version directory does not exist: {}", version_dir.as_path().display()).into(), )); } @@ -259,7 +259,7 @@ pub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Res 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::Upgrade( + Error::Setup( format!( "Failed to remove existing junction at {}: {e}", current_link.as_path().display() @@ -271,7 +271,7 @@ pub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Res } junction::create(&version_dir, ¤t_link).map_err(|e| { - Error::Upgrade( + Error::Setup( format!( "Failed to create junction at {}: {e}\nTry removing it manually and run again.", current_link.as_path().display() @@ -342,7 +342,7 @@ pub async fn cleanup_old_versions( metadata.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH) }); let path = AbsolutePathBuf::new(entry.path()).ok_or_else(|| { - Error::Upgrade(format!("Invalid absolute path: {}", entry.path().display()).into()) + Error::Setup(format!("Invalid absolute path: {}", entry.path().display()).into()) })?; versions.push((time, path)); } @@ -478,7 +478,7 @@ mod tests { } #[tokio::test] - async fn test_write_upgrade_log_creates_log_in_parent_dir() { + async fn test_write_install_log_creates_log_in_parent_dir() { let temp = tempfile::tempdir().unwrap(); // Simulate ~/.vite-plus/0.1.15/ structure let version_dir = AbsolutePathBuf::new(temp.path().join("0.1.15").to_path_buf()).unwrap(); @@ -487,8 +487,8 @@ mod tests { let stdout = b"some stdout output"; let stderr = b"error: something went wrong"; - let result = write_upgrade_log(&version_dir, stdout, stderr).await; - assert!(result.is_some(), "write_upgrade_log should return log path"); + let result = write_install_log(&version_dir, stdout, stderr).await; + assert!(result.is_some(), "write_install_log should return log path"); let log_path = result.unwrap(); // Log should be in parent dir, not version_dir @@ -511,12 +511,12 @@ mod tests { } #[tokio::test] - async fn test_write_upgrade_log_handles_empty_output() { + async fn test_write_install_log_handles_empty_output() { let temp = tempfile::tempdir().unwrap(); let version_dir = AbsolutePathBuf::new(temp.path().join("0.1.15").to_path_buf()).unwrap(); tokio::fs::create_dir(&version_dir).await.unwrap(); - let result = write_upgrade_log(&version_dir, b"", b"").await; + let result = write_install_log(&version_dir, b"", b"").await; assert!(result.is_some()); let content = tokio::fs::read_to_string(result.unwrap()).await.unwrap(); diff --git a/crates/vite_global_cli/src/commands/upgrade/integrity.rs b/crates/vite_setup/src/integrity.rs similarity index 100% rename from crates/vite_global_cli/src/commands/upgrade/integrity.rs rename to crates/vite_setup/src/integrity.rs diff --git a/crates/vite_setup/src/lib.rs b/crates/vite_setup/src/lib.rs new file mode 100644 index 0000000000..573a056cc4 --- /dev/null +++ b/crates/vite_setup/src/lib.rs @@ -0,0 +1,17 @@ +//! Shared installation logic for `vp upgrade` and `vp-setup.exe`. +//! +//! This library extracts common code for: +//! - Platform detection +//! - npm registry queries +//! - Integrity verification +//! - Tarball extraction +//! - Directory structure management (symlinks, junctions, cleanup) + +pub mod error; +pub mod install; +pub mod integrity; +pub mod platform; +pub mod registry; + +/// Maximum number of old versions to keep. +pub const MAX_VERSIONS_KEEP: usize = 5; diff --git a/crates/vite_global_cli/src/commands/upgrade/platform.rs b/crates/vite_setup/src/platform.rs similarity index 95% rename from crates/vite_global_cli/src/commands/upgrade/platform.rs rename to crates/vite_setup/src/platform.rs index fd21490e85..5283e51e59 100644 --- a/crates/vite_global_cli/src/commands/upgrade/platform.rs +++ b/crates/vite_setup/src/platform.rs @@ -1,4 +1,4 @@ -//! Platform detection for upgrade. +//! Platform detection for installation. //! //! Detects the current platform and returns the npm package suffix //! used to find the correct platform-specific binary package. @@ -16,7 +16,7 @@ pub fn detect_platform_suffix() -> Result { } else if cfg!(target_os = "windows") { "win32" } else { - return Err(Error::Upgrade( + return Err(Error::Setup( format!("Unsupported operating system: {}", std::env::consts::OS).into(), )); }; @@ -26,7 +26,7 @@ pub fn detect_platform_suffix() -> Result { } else if cfg!(target_arch = "aarch64") { "arm64" } else { - return Err(Error::Upgrade( + return Err(Error::Setup( format!("Unsupported architecture: {}", std::env::consts::ARCH).into(), )); }; diff --git a/crates/vite_global_cli/src/commands/upgrade/registry.rs b/crates/vite_setup/src/registry.rs similarity index 97% rename from crates/vite_global_cli/src/commands/upgrade/registry.rs rename to crates/vite_setup/src/registry.rs index 20fdaa2885..6540690ef9 100644 --- a/crates/vite_global_cli/src/commands/upgrade/registry.rs +++ b/crates/vite_setup/src/registry.rs @@ -51,7 +51,7 @@ pub async fn resolve_version_string( tracing::debug!("Fetching main package metadata: {}", main_url); let main_meta: PackageVersionMetadata = client.get_json(&main_url).await.map_err(|e| { - Error::Upgrade(format!("Failed to fetch package metadata from {main_url}: {e}").into()) + Error::Setup(format!("Failed to fetch package metadata from {main_url}: {e}").into()) })?; Ok(main_meta.version) @@ -77,7 +77,7 @@ pub async fn resolve_platform_package( tracing::debug!("Fetching CLI package metadata: {}", cli_url); let cli_meta: PackageVersionMetadata = client.get_json(&cli_url).await.map_err(|e| { - Error::Upgrade( + Error::Setup( format!( "Failed to fetch CLI package metadata from {cli_url}: {e}. \ Your platform ({platform_suffix}) may not be supported." diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md new file mode 100644 index 0000000000..05aeba9e7e --- /dev/null +++ b/rfcs/windows-installer.md @@ -0,0 +1,511 @@ +# RFC: Standalone Windows `.exe` Installer + +## Status + +Draft + +## Summary + +Add a standalone `vp-setup.exe` Windows installer binary, distributed via GitHub Releases, that installs the vp CLI without requiring PowerShell. This complements the existing `irm https://vite.plus/ps1 | iex` script-based installer. Modeled after `rustup-init.exe`. + +## Motivation + +### The Problem + +The current Windows installation requires running a PowerShell command: + +```powershell +irm https://vite.plus/ps1 | iex +``` + +This has several friction points: + +1. **Execution policy barriers**: Many corporate/enterprise Windows machines restrict PowerShell script execution (`Set-ExecutionPolicy` changes required). +2. **No cmd.exe support**: Users in `cmd.exe` or Git Bash cannot use the `irm | iex` idiom without first opening PowerShell. +3. **No double-click install**: Users following documentation cannot simply download-and-run an installer. +4. **CI friction**: GitHub Actions using `shell: cmd` or `shell: bash` on Windows need workarounds to invoke PowerShell. +5. **PowerShell version fragmentation**: PowerShell 5.1 (built-in) and PowerShell 7+ (pwsh) have subtle differences that the script must handle. + +### rustup Reference + +rustup provides `rustup-init.exe` — a single console binary that users download and run from any shell or by double-clicking. Key characteristics: + +- Console-only (no GUI), interactive prompts with numbered menu +- Silent mode via `-y` flag for CI +- Single binary that is both installer and main tool (detects behavior from `argv[0]`) +- Modifies Windows User PATH via registry +- Registers in Add/Remove Programs +- DLL security mitigations for download-folder execution + +## Goals + +1. Provide a single `.exe` that installs vp from any Windows shell or double-click +2. Support silent/unattended installation for CI environments +3. Reuse existing installation logic from the `vp upgrade` command +4. Keep the installer binary small (target: 3-5 MB) +5. Replicate the exact same installation result as `install.ps1` + +## Non-Goals + +1. GUI installer (MSI, NSIS, Inno Setup) — console-only like rustup +2. Cross-platform installer binary (Linux/macOS are well-served by `install.sh`) +3. winget/chocolatey/scoop package submission (future work) +4. Code signing (required for GA, but out of scope for this RFC) + +## Architecture Decision: Single Binary vs. Separate Crate + +### Option A: Single Binary (rustup model) + +rustup uses one binary for everything — `rustup-init.exe` copies itself to `~/.cargo/bin/rustup.exe` and changes behavior based on `argv[0]`. This works because rustup IS the toolchain manager. + +**Not suitable for vp** because: +- `vp.exe` is downloaded from the npm registry as a platform-specific package +- The installer cannot copy itself as `vp.exe` — they are fundamentally different binaries +- `vp.exe` links `vite_js_runtime`, `vite_workspace`, `oxc_resolver` (~15-20 MB) — the installer needs none of these + +### Option B: Separate Crate with Shared Library (recommended) + +Create two new crates: + +``` +crates/vite_setup/ — shared installation logic (library) +crates/vite_installer/ — standalone installer binary +``` + +`vite_setup` extracts the reusable installation logic currently in `vite_global_cli/src/commands/upgrade/`. Both `vp upgrade` and `vp-setup.exe` call into `vite_setup`. + +**Benefits:** +- Installer binary stays small (3-5 MB) +- `vp upgrade` and `vp-setup.exe` share identical installation logic — no drift +- Clear separation of concerns + +## Code Sharing: The `vite_setup` Library + +### What Gets Extracted + +| Current location in `upgrade/` | Extracted to `vite_setup::` | Purpose | +|---|---|---| +| `platform.rs` → `detect_platform_suffix()` | `platform` | OS/arch detection | +| `registry.rs` → `resolve_version()`, `resolve_platform_package()` | `registry` | npm registry queries | +| `integrity.rs` → `verify_integrity()` | `integrity` | SHA-512 verification | +| `install.rs` → `extract_platform_package()` | `extract` | Tarball extraction | +| `install.rs` → `generate_wrapper_package_json()` | `package_json` | Wrapper package.json | +| `install.rs` → `write_release_age_overrides()` | `npmrc` | .npmrc overrides | +| `install.rs` → `install_production_deps()` | `deps` | Run `vp install --silent` | +| `install.rs` → `swap_current_link()` | `link` | Symlink/junction swap | +| `install.rs` → `cleanup_old_versions()` | `cleanup` | Old version cleanup | + +### What Stays in `vite_global_cli` + +- CLI argument parsing for `vp upgrade` +- Version comparison (current vs available) +- Rollback logic +- Output formatting specific to upgrade UX + +### What's New in `vite_installer` + +- Interactive installation prompts (numbered menu) +- Windows User PATH modification via registry +- Node.js version manager setup prompt +- Shell env file creation +- Existing installation detection +- DLL security mitigations (for download-folder execution) + +### Dependency Graph + +``` +vite_installer (binary, ~3-5 MB) + ├── vite_setup (new library) + ├── vite_install (HTTP client) + ├── vite_shared (home dir, output) + ├── clap (CLI parsing) + ├── tokio (async runtime) + ├── indicatif (progress bars) + └── junction (Windows junctions) + +vite_global_cli (existing, unchanged) + ├── vite_setup (replaces inline upgrade code) + └── ... (all existing deps) +``` + +## User Experience + +### Interactive Mode (default) + +When run without flags (double-click or plain `vp-setup.exe`): + +``` +Welcome to Vite+ Installer! + +This will install the vp CLI and monorepo task runner. + + Install directory: C:\Users\alice\.vite-plus + PATH modification: C:\Users\alice\.vite-plus\bin → User PATH + Version: latest + Node.js manager: auto-detect + +1) Proceed with installation (default) +2) Customize installation +3) Cancel + +> +``` + +Customization submenu: + +``` + Install directory [C:\Users\alice\.vite-plus] + Version [latest] + npm registry [https://registry.npmjs.org] + Node.js manager [auto] + Modify PATH [yes] + +Enter option number to change, or press Enter to go back: +> +``` + +### Silent Mode (CI) + +```bash +# Accept all defaults +vp-setup.exe -y + +# Customize +vp-setup.exe -y --version 0.3.0 --no-node-manager --registry https://registry.npmmirror.com +``` + +### CLI Flags + +| Flag | Description | Default | +|---|---|---| +| `-y` / `--yes` | Accept defaults, no prompts | interactive | +| `-q` / `--quiet` | Suppress output except errors | false | +| `--version ` | Install specific version | latest | +| `--tag ` | npm dist-tag | latest | +| `--install-dir ` | Installation directory | `%USERPROFILE%\.vite-plus` | +| `--registry ` | npm registry URL | `https://registry.npmjs.org` | +| `--no-node-manager` | Skip Node.js manager setup | auto-detect | +| `--no-modify-path` | Don't modify User PATH | modify | + +### Environment Variables (compatible with `install.ps1`) + +| Variable | Maps to | +|---|---| +| `VP_VERSION` | `--version` | +| `VP_HOME` | `--install-dir` | +| `NPM_CONFIG_REGISTRY` | `--registry` | +| `VP_NODE_MANAGER=yes\|no` | `--no-node-manager` | + +CLI flags take precedence over environment variables. + +## Installation Flow + +The installer performs the exact same steps as `install.ps1`, in Rust: + +``` +1. Detect platform → vite_setup::platform::detect_platform_suffix() + (win32-x64-msvc or win32-arm64-msvc) + +2. Resolve version → vite_setup::registry::resolve_version() + Query npm registry for latest/specified version + +3. Check existing install → Read %VP_HOME%\current target, compare versions + Skip if already at target version + +4. Download tarball → vite_install::HttpClient::get_bytes() + With progress bar via indicatif + +5. Verify integrity → vite_setup::integrity::verify_integrity() + SHA-512 SRI hash from npm metadata + +6. Create version dir → %VP_HOME%\{version}\bin\ + +7. Extract binary → vite_setup::extract::extract_platform_package() + Extracts vp.exe and vp-shim.exe + +8. Generate package.json → vite_setup::package_json::generate() + Wrapper package.json in version dir + +9. Write .npmrc → vite_setup::npmrc::write_release_age_overrides() + minimum-release-age=0 + +10. Install deps → Spawn: {version_dir}\bin\vp.exe install --silent + +11. Swap current junction → vite_setup::link::swap_current_link() + mklink /J current → {version} + +12. Create bin shims → Copy vp-shim.exe → %VP_HOME%\bin\vp.exe + +13. Setup Node.js manager → Prompt or auto-detect, then: + Spawn: vp.exe env setup --refresh + +14. Cleanup old versions → vite_setup::cleanup::cleanup_old_versions() + Keep last 5 + +15. Modify User PATH → Registry: HKCU\Environment\Path + Add %VP_HOME%\bin if not present + Broadcast WM_SETTINGCHANGE + +16. Create env files → Spawn: vp.exe env setup --env-only + +17. Print success → Show getting-started commands +``` + +## Windows-Specific Details + +### PATH Modification via Registry + +Same approach as rustup and `install.ps1`: + +```rust +use winreg::RegKey; +use winreg::enums::*; + +fn add_to_path(bin_dir: &str) -> Result<()> { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?; + + let current_path: String = env.get_value("Path")?; + if !current_path.split(';').any(|p| p.eq_ignore_ascii_case(bin_dir)) { + let new_path = format!("{bin_dir};{current_path}"); + env.set_value("Path", &new_path)?; + // Broadcast WM_SETTINGCHANGE so other processes pick up the change + broadcast_settings_change(); + } + Ok(()) +} +``` + +### DLL Security (for download-folder execution) + +Following rustup's approach — when the `.exe` is downloaded to `Downloads/` and double-clicked, malicious DLLs in the same folder could be loaded. Mitigations: + +```rust +// In build.rs — linker flags +#[cfg(windows)] +println!("cargo:rustc-link-arg=/DEPENDENTLOADFLAG:0x800"); + +// In main() — runtime mitigation +#[cfg(windows)] +unsafe { + windows_sys::Win32::System::LibraryLoader::SetDefaultDllDirectories( + LOAD_LIBRARY_SEARCH_SYSTEM32, + ); +} +``` + +### Console Allocation + +The binary uses the console subsystem (default for Rust binaries on Windows). When double-clicked, Windows allocates a console window automatically. No special handling needed. + +### Existing Installation Handling + +| Scenario | Behavior | +|---|---| +| No existing install | Fresh install | +| Same version installed | Print "already up to date", exit 0 | +| Different version installed | Upgrade to target version | +| Corrupt/partial install (broken junction) | Recreate directory structure | +| Running `vp.exe` in bin/ | Rename to `.old`, copy new (same as trampoline pattern) | + +## Add/Remove Programs Registration + +**Phase 1: Skip.** `vp implode` already handles full uninstallation. + +**Phase 2: Register.** Write to `HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\VitePlus`: + +``` +DisplayName = "Vite+" +UninstallString = "C:\Users\alice\.vite-plus\current\bin\vp.exe implode --yes" +DisplayVersion = "0.3.0" +Publisher = "VoidZero" +InstallLocation = "C:\Users\alice\.vite-plus" +``` + +## Distribution + +### Phase 1: GitHub Releases + +Attach installer binaries to each GitHub Release: + +- `vp-setup-x86_64-pc-windows-msvc.exe` +- `vp-setup-aarch64-pc-windows-msvc.exe` + +The release workflow already creates GitHub Releases. Add build + upload steps for the init binary. + +### Phase 2: Direct Download URL + +Host at `https://vite.plus/vp-setup.exe` with architecture auto-detection (default x64). + +Update installation docs: + +``` +**Windows:** + Download and run: https://vite.plus/vp-setup.exe + Or via PowerShell: irm https://vite.plus/ps1 | iex +``` + +### Phase 3: Package Managers + +Submit to winget, chocolatey, scoop. Each has its own manifest format and review process. + +## CI/Build Changes + +### Release Workflow Additions + +```yaml +# In build-rust job matrix (already has windows targets) +- name: Build installer (Windows only) + if: contains(matrix.settings.target, 'windows') + run: cargo build --release --target ${{ matrix.settings.target }} -p vite_installer + +- name: Upload installer artifact + if: contains(matrix.settings.target, 'windows') + uses: actions/upload-artifact@v4 + with: + name: vite-init-${{ matrix.settings.target }} + path: ./target/${{ matrix.settings.target }}/release/vp-setup.exe +``` + +### Test Workflow + +Extend `test-standalone-install.yml` with new jobs: + +```yaml +test-init-exe: + strategy: + matrix: + shell: [cmd, pwsh, powershell, bash] + runs-on: windows-latest + steps: + - name: Download vp-setup.exe + run: # download from artifacts or latest release + - name: Install (silent) + run: vp-setup.exe -y + - name: Verify installation + run: | + vp --version + vp --help +``` + +## Code Signing + +Windows Defender SmartScreen flags unsigned executables downloaded from the internet. This is a significant UX problem for a download-and-run installer. + +**Recommendation**: Obtain an EV (Extended Validation) code signing certificate before GA release. EV certificates immediately remove SmartScreen warnings (no reputation building period needed). + +This is an organizational decision (cost: ~$300-500/year) and out of scope for the implementation, but critical for user experience. + +## Binary Size Budget + +Target: 3-5 MB (release, stripped, LTO). + +Key dependencies and their approximate contribution: + +| Dependency | Purpose | Size impact | +|---|---|---| +| `reqwest` + `native-tls-vendored` | HTTP + TLS | ~1.5 MB | +| `flate2` + `tar` | Tarball extraction | ~200 KB | +| `clap` | CLI parsing | ~300 KB | +| `tokio` (minimal features) | Async runtime | ~400 KB | +| `indicatif` | Progress bars | ~100 KB | +| `sha2` | Integrity verification | ~50 KB | +| `serde_json` | Registry JSON parsing | ~200 KB | +| `winreg` | Windows registry | ~50 KB | +| Rust std + overhead | | ~500 KB | + +Use `opt-level = "z"` (optimize for size) in package profile override, matching the trampoline approach. + +## Alternatives Considered + +### 1. MSI/NSIS/Inno Setup Installer (Rejected) + +Traditional Windows installers provide GUI, Add/Remove Programs, and Start Menu integration. However: +- Adds build-time dependency on external tooling (WiX, NSIS) +- GUI is unnecessary for a developer CLI tool +- MSI has complex authoring requirements +- rustup chose console-only and it works well for the developer audience + +### 2. Extend `vp.exe` with Init Mode (Rejected) + +Like rustup, make `vp.exe` detect when called as `vp-setup.exe` and switch to installer mode. + +- Would bloat the installer to ~15-20 MB (all of vp's dependencies) +- vp.exe is downloaded FROM the installer — circular dependency +- The installation payload (vp.exe) and the installer are fundamentally different + +### 3. Static-linked PowerShell in .exe (Rejected) + +Embed the PowerShell script in a self-extracting exe. Fragile, still requires PowerShell runtime. + +### 4. Use `winreg` vs PowerShell for PATH (Decision: `winreg`) + +- `winreg` crate: Direct registry API, no subprocess, reliable +- PowerShell subprocess: Proven in `install.ps1` but adds process spawn overhead and PowerShell dependency +- Decision: Use `winreg` for direct registry access — the whole point of the exe installer is to not depend on PowerShell + +## Implementation Phases + +### Phase 1: Extract `vite_setup` Library + +- Create `crates/vite_setup/Cargo.toml` +- Move shared code from `vite_global_cli/src/commands/upgrade/` into `vite_setup` +- Update `vite_global_cli` to import from `vite_setup` +- Run existing tests to verify no regressions + +### Phase 2: Create `vite_installer` Binary + +- Create `crates/vite_installer/` with `[[bin]] name = "vp-setup"` +- Implement CLI argument parsing (clap) +- Implement installation flow calling `vite_setup` +- Implement Windows PATH modification via `winreg` +- Implement interactive prompts +- Implement progress bar for downloads +- Add DLL security mitigations + +### Phase 3: CI Integration + +- Add init binary build to release workflow +- Add artifact upload and GitHub Release attachment +- Add test jobs for `vp-setup.exe` across shell types + +### Phase 4: Documentation & Distribution + +- Update installation docs +- Host on `vite.plus/vp-setup.exe` +- Update release body template with download link + +## Testing Strategy + +### Unit Tests +- Platform detection (mock different architectures) +- PATH modification logic (registry read/write) +- Version comparison and existing install detection + +### Integration Tests (CI) +- Fresh install from cmd.exe, PowerShell, Git Bash +- Silent mode (`-y`) installation +- Custom registry, custom install dir +- Upgrade over existing installation +- Verify `vp --version` works after install +- Verify PATH is modified correctly + +### Manual Tests +- Double-click from Downloads folder +- SmartScreen behavior (signed vs unsigned) +- Windows Defender scan behavior +- ARM64 Windows (if available) + +## Decisions + +- **Binary name**: `vp-setup.exe` +- **Uninstall**: Rely on `vp implode` — no `--uninstall` flag in the installer +- **Minimum Windows version**: Windows 10 1809+ (same as Rust's MSVC target) + +## References + +- [rustup-init.exe source](https://github.com/rust-lang/rustup/blob/master/src/bin/rustup-init.rs) — single-binary installer model +- [rustup self_update.rs](https://github.com/rust-lang/rustup/blob/master/src/cli/self_update.rs) — installation flow +- [rustup windows.rs](https://github.com/rust-lang/rustup/blob/master/src/cli/self_update/windows.rs) — Windows PATH/registry handling +- [RFC: Windows Trampoline](./trampoline-exe-for-shims.md) — existing Windows .exe shim approach +- [RFC: Self-Update Command](./upgrade-command.md) — existing upgrade logic to share From 6b4200d68d3d93610c0c706389a5e53f5246c474 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 17:47:59 +0800 Subject: [PATCH 02/39] refactor(installer): simplify code after review - Remove dirs_home() and dead fallback in resolve_install_dir(), use get_vp_home() directly - Resolve install dir once in run() and pass through to all functions - Extract read_current_version() into vite_setup as a public function, reuse in save_previous_version() and the installer - Merge Cli/Options structs into single Options struct in cli.rs - Extract replace_windows_exe() helper to eliminate copy-paste in setup_bin_shims() - Remove unused LPWSTR type alias from windows_path.rs - Remove excessive "Step N" comments - Fix mixed path separator in interactive menu display --- crates/vite_installer/src/cli.rs | 66 +++----- crates/vite_installer/src/main.rs | 185 ++++++++-------------- crates/vite_installer/src/windows_path.rs | 1 - crates/vite_setup/src/install.rs | 18 ++- 4 files changed, 102 insertions(+), 168 deletions(-) diff --git a/crates/vite_installer/src/cli.rs b/crates/vite_installer/src/cli.rs index ab05a47848..f6c60ea594 100644 --- a/crates/vite_installer/src/cli.rs +++ b/crates/vite_installer/src/cli.rs @@ -5,79 +5,65 @@ use clap::Parser; /// Vite+ Installer — standalone installer for the vp CLI. #[derive(Parser, Debug)] #[command(name = "vp-setup", about = "Install the Vite+ CLI")] -struct Cli { +pub struct Options { /// Accept defaults without prompting (for CI/unattended installs) #[arg(short = 'y', long = "yes")] - yes: bool, + pub yes: bool, /// Suppress all output except errors #[arg(short = 'q', long = "quiet")] - quiet: bool, + pub quiet: bool, /// Install a specific version (default: latest) #[arg(long = "version")] - version: Option, + pub version: Option, /// npm dist-tag to install (default: latest) #[arg(long = "tag", default_value = "latest")] - tag: String, + pub tag: String, /// Custom installation directory (default: ~/.vite-plus) #[arg(long = "install-dir")] - install_dir: Option, + pub install_dir: Option, /// Custom npm registry URL #[arg(long = "registry")] - registry: Option, + pub registry: Option, /// Skip Node.js version manager setup #[arg(long = "no-node-manager")] - no_node_manager: bool, + pub no_node_manager: bool, /// Do not modify the User PATH #[arg(long = "no-modify-path")] - no_modify_path: bool, -} - -/// Parsed installation options. -pub struct Options { - pub yes: bool, - pub quiet: bool, - pub version: Option, - pub tag: String, - pub install_dir: Option, - pub registry: Option, - pub no_node_manager: bool, pub no_modify_path: bool, } /// Parse CLI arguments, merging with environment variables. -/// /// CLI flags take precedence over environment variables. pub fn parse() -> Options { - let cli = Cli::parse(); - - // Environment variable overrides (CLI flags take precedence) - let version = cli.version.or_else(|| std::env::var("VP_VERSION").ok()); - let install_dir = cli.install_dir.or_else(|| std::env::var("VP_HOME").ok()); - let registry = cli.registry.or_else(|| std::env::var("NPM_CONFIG_REGISTRY").ok()); + let mut opts = Options::parse(); - let no_node_manager = cli.no_node_manager - || std::env::var("VP_NODE_MANAGER") + // Merge env var overrides (CLI flags already set take precedence) + if opts.version.is_none() { + opts.version = std::env::var("VP_VERSION").ok(); + } + if opts.install_dir.is_none() { + opts.install_dir = std::env::var("VP_HOME").ok(); + } + if opts.registry.is_none() { + opts.registry = std::env::var("NPM_CONFIG_REGISTRY").ok(); + } + if !opts.no_node_manager { + opts.no_node_manager = std::env::var("VP_NODE_MANAGER") .ok() .is_some_and(|v| v.eq_ignore_ascii_case("no")); + } // quiet implies yes - let yes = cli.yes || cli.quiet; - - Options { - yes, - quiet: cli.quiet, - version, - tag: cli.tag, - install_dir, - registry, - no_node_manager, - no_modify_path: cli.no_modify_path, + if opts.quiet { + opts.yes = true; } + + opts } diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 2364bc613b..48e80afb29 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -19,10 +19,11 @@ use std::io::{self, Write}; use indicatif::{ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; use vite_install::request::HttpClient; +use vite_path::AbsolutePathBuf; use vite_setup::{install, integrity, platform, registry}; -/// DLL security: restrict DLL search to system32 only. -/// Prevents DLL hijacking when the installer is run from a Downloads folder. +/// Restrict DLL search to system32 only to prevent DLL hijacking +/// when the installer is run from a Downloads folder. #[cfg(windows)] fn init_dll_security() { unsafe extern "system" { @@ -52,18 +53,26 @@ fn main() { #[allow(clippy::print_stdout, clippy::print_stderr)] async fn run(opts: cli::Options) -> i32 { - // Interactive mode: show welcome and prompt + let install_dir = match resolve_install_dir(&opts) { + Ok(dir) => dir, + Err(e) => { + print_error(&format!("Failed to resolve install directory: {e}")); + return 1; + } + }; + let install_dir_display = install_dir.as_path().to_string_lossy().to_string(); + if !opts.yes { - let proceed = show_interactive_menu(&opts); + let proceed = show_interactive_menu(&opts, &install_dir_display); if !proceed { println!("Installation cancelled."); return 0; } } - match do_install(&opts).await { + match do_install(&opts, &install_dir).await { Ok(()) => { - print_success(&opts); + print_success(&opts, &install_dir_display); 0 } Err(e) => { @@ -73,16 +82,16 @@ async fn run(opts: cli::Options) -> i32 { } } -/// The core installation flow, matching what `install.ps1` does. #[allow(clippy::print_stdout)] -async fn do_install(opts: &cli::Options) -> Result<(), Box> { - // Step 1: Detect platform +async fn do_install( + opts: &cli::Options, + install_dir: &AbsolutePathBuf, +) -> Result<(), Box> { let platform_suffix = platform::detect_platform_suffix()?; if !opts.quiet { print_info(&format!("detected platform: {platform_suffix}")); } - // Step 2: Resolve version from npm registry let version_or_tag = opts.version.as_deref().unwrap_or(&opts.tag); if !opts.quiet { print_info(&format!("resolving version '{version_or_tag}'...")); @@ -94,11 +103,9 @@ async fn do_install(opts: &cli::Options) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { + if dst.as_path().exists() { + let old_name = format!( + "vp.exe.{}.old", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + ); + let _ = tokio::fs::rename(dst, &bin_dir.join(&old_name)).await; + } + tokio::fs::copy(src, dst).await?; + Ok(()) +} + /// Set up the bin/ directory with the initial `vp` shim. /// /// On Windows, copies `vp-shim.exe` from `current/bin/` to `bin/vp.exe`. @@ -217,47 +227,24 @@ async fn setup_bin_shims( #[cfg(windows)] { - let shim_src = install_dir.join("current").join("bin").join("vp-shim.exe"); let shim_dst = bin_dir.join("vp.exe"); + let shim_src = install_dir.join("current").join("bin").join("vp-shim.exe"); - if tokio::fs::try_exists(&shim_src).await.unwrap_or(false) { - // Handle running exe: rename old, copy new - if shim_dst.as_path().exists() { - let old_name = format!( - "vp.exe.{}.old", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - ); - let old_path = bin_dir.join(&old_name); - let _ = tokio::fs::rename(&shim_dst, &old_path).await; - } - tokio::fs::copy(&shim_src, &shim_dst).await?; + // Prefer vp-shim.exe (lightweight trampoline), fall back to vp.exe + let src = if tokio::fs::try_exists(&shim_src).await.unwrap_or(false) { + shim_src } else { - // Fallback: copy vp.exe directly - let vp_src = install_dir.join("current").join("bin").join("vp.exe"); - if tokio::fs::try_exists(&vp_src).await.unwrap_or(false) { - if shim_dst.as_path().exists() { - let old_name = format!( - "vp.exe.{}.old", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - ); - let old_path = bin_dir.join(&old_name); - let _ = tokio::fs::rename(&shim_dst, &old_path).await; - } - tokio::fs::copy(&vp_src, &shim_dst).await?; - } + install_dir.join("current").join("bin").join("vp.exe") + }; + + if tokio::fs::try_exists(&src).await.unwrap_or(false) { + replace_windows_exe(&src, &shim_dst, &bin_dir).await?; } // Best-effort cleanup of old shim files if let Ok(mut entries) = tokio::fs::read_dir(&bin_dir).await { while let Ok(Some(entry)) = entries.next_entry().await { - let name = entry.file_name(); - if name.to_string_lossy().ends_with(".old") { + if entry.file_name().to_string_lossy().ends_with(".old") { let _ = tokio::fs::remove_file(entry.path()).await; } } @@ -268,8 +255,6 @@ async fn setup_bin_shims( { let link_target = std::path::PathBuf::from("../current/bin/vp"); let link_path = bin_dir.join("vp"); - - // Remove existing symlink let _ = tokio::fs::remove_file(&link_path).await; tokio::fs::symlink(&link_target, &link_path).await?; } @@ -277,7 +262,6 @@ async fn setup_bin_shims( Ok(()) } -/// Download bytes with a progress bar. async fn download_with_progress( client: &HttpClient, url: &str, @@ -301,19 +285,9 @@ async fn download_with_progress( Ok(data) } -/// Read the current installed version by following the `current` symlink/junction. -async fn read_current_version( - install_dir: &vite_path::AbsolutePath, -) -> Option { - let current_link = install_dir.join("current"); - let target = tokio::fs::read_link(¤t_link).await.ok()?; - target.file_name()?.to_str().map(String::from) -} - -/// Resolve the installation directory. fn resolve_install_dir( opts: &cli::Options, -) -> Result> { +) -> Result> { if let Some(ref dir) = opts.install_dir { let path = std::path::PathBuf::from(dir); let abs = if path.is_absolute() { @@ -321,30 +295,12 @@ fn resolve_install_dir( } else { std::env::current_dir()?.join(path) }; - vite_path::AbsolutePathBuf::new(abs) - .ok_or_else(|| "Invalid installation directory".into()) - } else if let Ok(dir) = vite_shared::get_vp_home() { - Ok(dir) + AbsolutePathBuf::new(abs).ok_or_else(|| "Invalid installation directory".into()) } else { - // Fallback: ~/.vite-plus - let home = dirs_home().ok_or("Could not determine home directory")?; - vite_path::AbsolutePathBuf::new(home.join(".vite-plus")) - .ok_or_else(|| "Invalid home directory".into()) + Ok(vite_shared::get_vp_home()?) } } -fn dirs_home() -> Option { - #[cfg(windows)] - { - std::env::var_os("USERPROFILE").map(std::path::PathBuf::from) - } - #[cfg(not(windows))] - { - std::env::var_os("HOME").map(std::path::PathBuf::from) - } -} - -/// Modify the user's PATH to include the bin directory. #[allow(clippy::print_stdout)] fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box> { #[cfg(windows)] @@ -357,7 +313,6 @@ fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box Result<(), Box bool { - let install_dir = resolve_install_dir(opts) - .map(|p| p.as_path().to_string_lossy().to_string()) - .unwrap_or_else(|_| "~/.vite-plus".to_string()); +fn show_interactive_menu(opts: &cli::Options, install_dir: &str) -> bool { let version = opts.version.as_deref().unwrap_or("latest"); - let bin_dir = format!("{install_dir}/bin"); + let bin_dir = format!("{install_dir}{sep}bin", sep = std::path::MAIN_SEPARATOR); println!(); println!(" {}", "Welcome to Vite+ Installer!".bold()); @@ -381,7 +332,7 @@ fn show_interactive_menu(opts: &cli::Options) -> bool { println!(" This will install the {} CLI and monorepo task runner.", "vp".cyan()); println!(); println!(" Install directory: {}", install_dir.cyan()); - println!(" PATH modification: {}", if opts.no_modify_path { "no".to_string() } else { format!("{bin_dir} → User PATH") }.cyan()); + println!(" PATH modification: {}", if opts.no_modify_path { "no".to_string() } else { format!("{bin_dir} \u{2192} User PATH") }.cyan()); println!(" Version: {}", version.cyan()); println!(" Node.js manager: {}", if opts.no_node_manager { "disabled" } else { "auto-detect" }.cyan()); println!(); @@ -401,15 +352,11 @@ fn show_interactive_menu(opts: &cli::Options) -> bool { } #[allow(clippy::print_stdout)] -fn print_success(opts: &cli::Options) { +fn print_success(opts: &cli::Options, install_dir: &str) { if opts.quiet { return; } - let install_dir = resolve_install_dir(opts) - .map(|p| p.as_path().to_string_lossy().to_string()) - .unwrap_or_else(|_| "~/.vite-plus".to_string()); - println!(); println!( " {} Vite+ has been installed successfully!", diff --git a/crates/vite_installer/src/windows_path.rs b/crates/vite_installer/src/windows_path.rs index 235aeb0613..125e7089ba 100644 --- a/crates/vite_installer/src/windows_path.rs +++ b/crates/vite_installer/src/windows_path.rs @@ -16,7 +16,6 @@ mod ffi { pub type DWORD = u32; pub type LONG = i32; pub type LPCWSTR = *const u16; - pub type LPWSTR = *mut u16; pub type HWND = isize; pub type WPARAM = usize; pub type LPARAM = isize; diff --git a/crates/vite_setup/src/install.rs b/crates/vite_setup/src/install.rs index 0a4d3ba312..20b07f7a32 100644 --- a/crates/vite_setup/src/install.rs +++ b/crates/vite_setup/src/install.rs @@ -199,18 +199,20 @@ pub async fn install_production_deps( Ok(()) } +/// Read the current installed version by following the `current` symlink/junction. +/// +/// Returns `None` if no installation exists or the link target cannot be read. +pub async fn read_current_version(install_dir: &AbsolutePath) -> Option { + let current_link = install_dir.join("current"); + let target = tokio::fs::read_link(¤t_link).await.ok()?; + target.file_name().and_then(|n| n.to_str()).map(String::from) +} + /// 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); + let version = read_current_version(install_dir).await; if let Some(ref v) = version { let prev_file = install_dir.join(".previous-version"); From c562ab006d1b15471bc4b92dc9da79d4df690fe9 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:19:52 +0800 Subject: [PATCH 03/39] style: apply formatting fixes from vp check --fix --- crates/vite_installer/src/cli.rs | 5 +- crates/vite_installer/src/main.rs | 36 ++++----- crates/vite_installer/src/windows_path.rs | 28 ++----- rfcs/windows-installer.md | 94 ++++++++++++----------- 4 files changed, 74 insertions(+), 89 deletions(-) diff --git a/crates/vite_installer/src/cli.rs b/crates/vite_installer/src/cli.rs index f6c60ea594..5f9f4ed166 100644 --- a/crates/vite_installer/src/cli.rs +++ b/crates/vite_installer/src/cli.rs @@ -55,9 +55,8 @@ pub fn parse() -> Options { opts.registry = std::env::var("NPM_CONFIG_REGISTRY").ok(); } if !opts.no_node_manager { - opts.no_node_manager = std::env::var("VP_NODE_MANAGER") - .ok() - .is_some_and(|v| v.eq_ignore_ascii_case("no")); + opts.no_node_manager = + std::env::var("VP_NODE_MANAGER").ok().is_some_and(|v| v.eq_ignore_ascii_case("no")); } // quiet implies yes diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 48e80afb29..33fc96243d 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -109,11 +109,7 @@ async fn do_install( if let Some(ref current) = current_version { if current == &resolved.version { if !opts.quiet { - println!( - "\n{} Already installed ({})", - "\u{2714}".green(), - resolved.version - ); + println!("\n{} Already installed ({})", "\u{2714}".green(), resolved.version); } return Ok(()); } @@ -285,16 +281,10 @@ async fn download_with_progress( Ok(data) } -fn resolve_install_dir( - opts: &cli::Options, -) -> Result> { +fn resolve_install_dir(opts: &cli::Options) -> Result> { if let Some(ref dir) = opts.install_dir { let path = std::path::PathBuf::from(dir); - let abs = if path.is_absolute() { - path - } else { - std::env::current_dir()?.join(path) - }; + let abs = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; AbsolutePathBuf::new(abs).ok_or_else(|| "Invalid installation directory".into()) } else { Ok(vite_shared::get_vp_home()?) @@ -332,9 +322,20 @@ fn show_interactive_menu(opts: &cli::Options, install_dir: &str) -> bool { println!(" This will install the {} CLI and monorepo task runner.", "vp".cyan()); println!(); println!(" Install directory: {}", install_dir.cyan()); - println!(" PATH modification: {}", if opts.no_modify_path { "no".to_string() } else { format!("{bin_dir} \u{2192} User PATH") }.cyan()); + println!( + " PATH modification: {}", + if opts.no_modify_path { + "no".to_string() + } else { + format!("{bin_dir} \u{2192} User PATH") + } + .cyan() + ); println!(" Version: {}", version.cyan()); - println!(" Node.js manager: {}", if opts.no_node_manager { "disabled" } else { "auto-detect" }.cyan()); + println!( + " Node.js manager: {}", + if opts.no_node_manager { "disabled" } else { "auto-detect" }.cyan() + ); println!(); println!(" 1) {} (default)", "Proceed with installation".bold()); println!(" 2) Cancel"); @@ -358,10 +359,7 @@ fn print_success(opts: &cli::Options, install_dir: &str) { } println!(); - println!( - " {} Vite+ has been installed successfully!", - "\u{2714}".green().bold() - ); + println!(" {} Vite+ has been installed successfully!", "\u{2714}".green().bold()); println!(); println!(" To get started, restart your terminal, then run:"); println!(); diff --git a/crates/vite_installer/src/windows_path.rs b/crates/vite_installer/src/windows_path.rs index 125e7089ba..4a38ea1713 100644 --- a/crates/vite_installer/src/windows_path.rs +++ b/crates/vite_installer/src/windows_path.rs @@ -84,13 +84,7 @@ fn read_user_path() -> io::Result { let mut hkey: ffi::HKEY = 0; let result = unsafe { - ffi::RegOpenKeyExW( - ffi::HKEY_CURRENT_USER, - sub_key.as_ptr(), - 0, - ffi::KEY_READ, - &mut hkey, - ) + ffi::RegOpenKeyExW(ffi::HKEY_CURRENT_USER, sub_key.as_ptr(), 0, ffi::KEY_READ, &mut hkey) }; if result == ffi::ERROR_FILE_NOT_FOUND { @@ -143,10 +137,7 @@ fn read_user_path() -> io::Result { } // Convert UTF-16 to String (strip trailing null) - let wide: Vec = buf - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); + let wide: Vec = buf.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); let s = String::from_utf16_lossy(&wide); Ok(s.trim_end_matches('\0').to_string()) } @@ -159,13 +150,7 @@ fn write_user_path(path: &str) -> io::Result<()> { let mut hkey: ffi::HKEY = 0; let result = unsafe { - ffi::RegOpenKeyExW( - ffi::HKEY_CURRENT_USER, - sub_key.as_ptr(), - 0, - ffi::KEY_WRITE, - &mut hkey, - ) + ffi::RegOpenKeyExW(ffi::HKEY_CURRENT_USER, sub_key.as_ptr(), 0, ffi::KEY_WRITE, &mut hkey) }; if result != ffi::ERROR_SUCCESS { @@ -230,11 +215,8 @@ pub fn add_to_user_path(bin_dir: &str) -> io::Result<()> { } // Prepend to PATH - let new_path = if current.is_empty() { - bin_dir.to_string() - } else { - format!("{bin_dir};{current}") - }; + let new_path = + if current.is_empty() { bin_dir.to_string() } else { format!("{bin_dir};{current}") }; write_user_path(&new_path)?; broadcast_settings_change(); diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index 05aeba9e7e..85de207332 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -59,6 +59,7 @@ rustup provides `rustup-init.exe` — a single console binary that users downloa rustup uses one binary for everything — `rustup-init.exe` copies itself to `~/.cargo/bin/rustup.exe` and changes behavior based on `argv[0]`. This works because rustup IS the toolchain manager. **Not suitable for vp** because: + - `vp.exe` is downloaded from the npm registry as a platform-specific package - The installer cannot copy itself as `vp.exe` — they are fundamentally different binaries - `vp.exe` links `vite_js_runtime`, `vite_workspace`, `oxc_resolver` (~15-20 MB) — the installer needs none of these @@ -75,6 +76,7 @@ crates/vite_installer/ — standalone installer binary `vite_setup` extracts the reusable installation logic currently in `vite_global_cli/src/commands/upgrade/`. Both `vp upgrade` and `vp-setup.exe` call into `vite_setup`. **Benefits:** + - Installer binary stays small (3-5 MB) - `vp upgrade` and `vp-setup.exe` share identical installation logic — no drift - Clear separation of concerns @@ -83,17 +85,17 @@ crates/vite_installer/ — standalone installer binary ### What Gets Extracted -| Current location in `upgrade/` | Extracted to `vite_setup::` | Purpose | -|---|---|---| -| `platform.rs` → `detect_platform_suffix()` | `platform` | OS/arch detection | -| `registry.rs` → `resolve_version()`, `resolve_platform_package()` | `registry` | npm registry queries | -| `integrity.rs` → `verify_integrity()` | `integrity` | SHA-512 verification | -| `install.rs` → `extract_platform_package()` | `extract` | Tarball extraction | -| `install.rs` → `generate_wrapper_package_json()` | `package_json` | Wrapper package.json | -| `install.rs` → `write_release_age_overrides()` | `npmrc` | .npmrc overrides | -| `install.rs` → `install_production_deps()` | `deps` | Run `vp install --silent` | -| `install.rs` → `swap_current_link()` | `link` | Symlink/junction swap | -| `install.rs` → `cleanup_old_versions()` | `cleanup` | Old version cleanup | +| Current location in `upgrade/` | Extracted to `vite_setup::` | Purpose | +| ----------------------------------------------------------------- | --------------------------- | ------------------------- | +| `platform.rs` → `detect_platform_suffix()` | `platform` | OS/arch detection | +| `registry.rs` → `resolve_version()`, `resolve_platform_package()` | `registry` | npm registry queries | +| `integrity.rs` → `verify_integrity()` | `integrity` | SHA-512 verification | +| `install.rs` → `extract_platform_package()` | `extract` | Tarball extraction | +| `install.rs` → `generate_wrapper_package_json()` | `package_json` | Wrapper package.json | +| `install.rs` → `write_release_age_overrides()` | `npmrc` | .npmrc overrides | +| `install.rs` → `install_production_deps()` | `deps` | Run `vp install --silent` | +| `install.rs` → `swap_current_link()` | `link` | Symlink/junction swap | +| `install.rs` → `cleanup_old_versions()` | `cleanup` | Old version cleanup | ### What Stays in `vite_global_cli` @@ -176,24 +178,24 @@ vp-setup.exe -y --version 0.3.0 --no-node-manager --registry https://registry.np ### CLI Flags -| Flag | Description | Default | -|---|---|---| -| `-y` / `--yes` | Accept defaults, no prompts | interactive | -| `-q` / `--quiet` | Suppress output except errors | false | -| `--version ` | Install specific version | latest | -| `--tag ` | npm dist-tag | latest | -| `--install-dir ` | Installation directory | `%USERPROFILE%\.vite-plus` | -| `--registry ` | npm registry URL | `https://registry.npmjs.org` | -| `--no-node-manager` | Skip Node.js manager setup | auto-detect | -| `--no-modify-path` | Don't modify User PATH | modify | +| Flag | Description | Default | +| ---------------------- | ----------------------------- | ---------------------------- | +| `-y` / `--yes` | Accept defaults, no prompts | interactive | +| `-q` / `--quiet` | Suppress output except errors | false | +| `--version ` | Install specific version | latest | +| `--tag ` | npm dist-tag | latest | +| `--install-dir ` | Installation directory | `%USERPROFILE%\.vite-plus` | +| `--registry ` | npm registry URL | `https://registry.npmjs.org` | +| `--no-node-manager` | Skip Node.js manager setup | auto-detect | +| `--no-modify-path` | Don't modify User PATH | modify | ### Environment Variables (compatible with `install.ps1`) -| Variable | Maps to | -|---|---| -| `VP_VERSION` | `--version` | -| `VP_HOME` | `--install-dir` | -| `NPM_CONFIG_REGISTRY` | `--registry` | +| Variable | Maps to | +| ------------------------- | ------------------- | +| `VP_VERSION` | `--version` | +| `VP_HOME` | `--install-dir` | +| `NPM_CONFIG_REGISTRY` | `--registry` | | `VP_NODE_MANAGER=yes\|no` | `--no-node-manager` | CLI flags take precedence over environment variables. @@ -300,13 +302,13 @@ The binary uses the console subsystem (default for Rust binaries on Windows). Wh ### Existing Installation Handling -| Scenario | Behavior | -|---|---| -| No existing install | Fresh install | -| Same version installed | Print "already up to date", exit 0 | -| Different version installed | Upgrade to target version | -| Corrupt/partial install (broken junction) | Recreate directory structure | -| Running `vp.exe` in bin/ | Rename to `.old`, copy new (same as trampoline pattern) | +| Scenario | Behavior | +| ----------------------------------------- | ------------------------------------------------------- | +| No existing install | Fresh install | +| Same version installed | Print "already up to date", exit 0 | +| Different version installed | Upgrade to target version | +| Corrupt/partial install (broken junction) | Recreate directory structure | +| Running `vp.exe` in bin/ | Rename to `.old`, copy new (same as trampoline pattern) | ## Add/Remove Programs Registration @@ -402,17 +404,17 @@ Target: 3-5 MB (release, stripped, LTO). Key dependencies and their approximate contribution: -| Dependency | Purpose | Size impact | -|---|---|---| -| `reqwest` + `native-tls-vendored` | HTTP + TLS | ~1.5 MB | -| `flate2` + `tar` | Tarball extraction | ~200 KB | -| `clap` | CLI parsing | ~300 KB | -| `tokio` (minimal features) | Async runtime | ~400 KB | -| `indicatif` | Progress bars | ~100 KB | -| `sha2` | Integrity verification | ~50 KB | -| `serde_json` | Registry JSON parsing | ~200 KB | -| `winreg` | Windows registry | ~50 KB | -| Rust std + overhead | | ~500 KB | +| Dependency | Purpose | Size impact | +| --------------------------------- | ---------------------- | ----------- | +| `reqwest` + `native-tls-vendored` | HTTP + TLS | ~1.5 MB | +| `flate2` + `tar` | Tarball extraction | ~200 KB | +| `clap` | CLI parsing | ~300 KB | +| `tokio` (minimal features) | Async runtime | ~400 KB | +| `indicatif` | Progress bars | ~100 KB | +| `sha2` | Integrity verification | ~50 KB | +| `serde_json` | Registry JSON parsing | ~200 KB | +| `winreg` | Windows registry | ~50 KB | +| Rust std + overhead | | ~500 KB | Use `opt-level = "z"` (optimize for size) in package profile override, matching the trampoline approach. @@ -421,6 +423,7 @@ Use `opt-level = "z"` (optimize for size) in package profile override, matching ### 1. MSI/NSIS/Inno Setup Installer (Rejected) Traditional Windows installers provide GUI, Add/Remove Programs, and Start Menu integration. However: + - Adds build-time dependency on external tooling (WiX, NSIS) - GUI is unnecessary for a developer CLI tool - MSI has complex authoring requirements @@ -478,11 +481,13 @@ Embed the PowerShell script in a self-extracting exe. Fragile, still requires Po ## Testing Strategy ### Unit Tests + - Platform detection (mock different architectures) - PATH modification logic (registry read/write) - Version comparison and existing install detection ### Integration Tests (CI) + - Fresh install from cmd.exe, PowerShell, Git Bash - Silent mode (`-y`) installation - Custom registry, custom install dir @@ -491,6 +496,7 @@ Embed the PowerShell script in a self-extracting exe. Fragile, still requires Po - Verify PATH is modified correctly ### Manual Tests + - Double-click from Downloads folder - SmartScreen behavior (signed vs unsigned) - Windows Defender scan behavior From e5f2fc333aa25e71a602ea6ebe9740ea5cc18fcf Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:49:27 +0800 Subject: [PATCH 04/39] feat(installer): implement remaining RFC gaps - Add interactive "Customize installation" submenu (option 2) allowing users to change version, registry, Node.js manager, and PATH settings - Add env file creation via `vp env setup --env-only` when Node.js manager is skipped (ensures shell env files exist in all code paths) - Add build.rs with /DEPENDENTLOADFLAG:0x800 linker flag for DLL hijacking prevention at load time (complements runtime mitigation) - Add test-vp-setup-exe CI job to test-standalone-install.yml testing silent installation from cmd, pwsh, and bash on Windows --- .github/workflows/test-standalone-install.yml | 35 +++++ crates/vite_installer/build.rs | 7 + crates/vite_installer/src/main.rs | 148 +++++++++++++----- 3 files changed, 154 insertions(+), 36 deletions(-) create mode 100644 crates/vite_installer/build.rs diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 9e3d29c1cd..dcd0d15f47 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -8,6 +8,8 @@ on: paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'crates/vite_installer/**' + - 'crates/vite_setup/**' - '.github/workflows/test-standalone-install.yml' concurrency: @@ -626,3 +628,36 @@ jobs: which npm which npx which vp + + test-vp-setup-exe: + name: Test vp-setup.exe (${{ matrix.shell }}) + runs-on: windows-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + shell: [cmd, pwsh, bash] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: oxc-project/setup-rust@23f38cfb0c04af97a055f76acee94d5be71c7c82 # v1.0.16 + + - name: Build vp-setup.exe + shell: bash + run: cargo build --release -p vite_installer + + - name: Install via vp-setup.exe (silent) + shell: ${{ matrix.shell }} + run: ./target/release/vp-setup.exe -y + env: + VP_VERSION: alpha + + - name: Set PATH + shell: bash + run: echo "$USERPROFILE/.vite-plus/bin" >> $GITHUB_PATH + + - name: Verify installation + shell: ${{ matrix.shell }} + run: | + vp --version + vp --help diff --git a/crates/vite_installer/build.rs b/crates/vite_installer/build.rs new file mode 100644 index 0000000000..09f86a6a16 --- /dev/null +++ b/crates/vite_installer/build.rs @@ -0,0 +1,7 @@ +fn main() { + // On Windows, set DEPENDENTLOADFLAG to only search system32 for DLLs at load time. + // This prevents DLL hijacking when the installer is downloaded to a folder + // containing malicious DLLs (e.g. Downloads). Matches rustup's approach. + #[cfg(windows)] + println!("cargo:rustc-link-arg=/DEPENDENTLOADFLAG:0x800"); +} diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 33fc96243d..9445d19073 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -52,7 +52,7 @@ fn main() { } #[allow(clippy::print_stdout, clippy::print_stderr)] -async fn run(opts: cli::Options) -> i32 { +async fn run(mut opts: cli::Options) -> i32 { let install_dir = match resolve_install_dir(&opts) { Ok(dir) => dir, Err(e) => { @@ -63,7 +63,7 @@ async fn run(opts: cli::Options) -> i32 { let install_dir_display = install_dir.as_path().to_string_lossy().to_string(); if !opts.yes { - let proceed = show_interactive_menu(&opts, &install_dir_display); + let proceed = show_interactive_menu(&mut opts, &install_dir_display); if !proceed { println!("Installation cancelled."); return 0; @@ -170,6 +170,9 @@ async fn do_install( print_info("setting up Node.js version manager..."); } install::refresh_shims(install_dir).await?; + } else { + // When skipping Node.js manager, still create shell env files + create_env_files(install_dir).await; } if let Err(e) = install::cleanup_old_versions( @@ -281,6 +284,25 @@ async fn download_with_progress( Ok(data) } +/// Create shell env files by spawning `vp env setup --env-only`. +async fn create_env_files(install_dir: &vite_path::AbsolutePath) { + 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) { + return; + } + + let output = tokio::process::Command::new(vp_binary.as_path()) + .args(["env", "setup", "--env-only"]) + .output() + .await; + + if let Err(e) = output { + tracing::warn!("Failed to create env files (non-fatal): {e}"); + } +} + fn resolve_install_dir(opts: &cli::Options) -> Result> { if let Some(ref dir) = opts.install_dir { let path = std::path::PathBuf::from(dir); @@ -312,44 +334,98 @@ fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box bool { - let version = opts.version.as_deref().unwrap_or("latest"); - let bin_dir = format!("{install_dir}{sep}bin", sep = std::path::MAIN_SEPARATOR); - - println!(); - println!(" {}", "Welcome to Vite+ Installer!".bold()); - println!(); - println!(" This will install the {} CLI and monorepo task runner.", "vp".cyan()); - println!(); - println!(" Install directory: {}", install_dir.cyan()); - println!( - " PATH modification: {}", - if opts.no_modify_path { - "no".to_string() - } else { - format!("{bin_dir} \u{2192} User PATH") +fn show_interactive_menu(opts: &mut cli::Options, install_dir: &str) -> bool { + loop { + let version = opts.version.as_deref().unwrap_or("latest"); + let bin_dir = format!("{install_dir}{sep}bin", sep = std::path::MAIN_SEPARATOR); + + println!(); + println!(" {}", "Welcome to Vite+ Installer!".bold()); + println!(); + println!(" This will install the {} CLI and monorepo task runner.", "vp".cyan()); + println!(); + println!(" Install directory: {}", install_dir.cyan()); + println!( + " PATH modification: {}", + if opts.no_modify_path { + "no".to_string() + } else { + format!("{bin_dir} \u{2192} User PATH") + } + .cyan() + ); + println!(" Version: {}", version.cyan()); + println!( + " Node.js manager: {}", + if opts.no_node_manager { "disabled" } else { "auto-detect" }.cyan() + ); + println!(); + println!(" 1) {} (default)", "Proceed with installation".bold()); + println!(" 2) Customize installation"); + println!(" 3) Cancel"); + println!(); + + let choice = read_input(" > "); + match choice.as_str() { + "" | "1" => return true, + "2" => show_customize_menu(opts), + "3" => return false, + _ => { + println!(" Invalid choice. Please enter 1, 2, or 3."); + } } - .cyan() - ); - println!(" Version: {}", version.cyan()); - println!( - " Node.js manager: {}", - if opts.no_node_manager { "disabled" } else { "auto-detect" }.cyan() - ); - println!(); - println!(" 1) {} (default)", "Proceed with installation".bold()); - println!(" 2) Cancel"); - println!(); - print!(" > "); - let _ = io::stdout().flush(); + } +} - let mut input = String::new(); - if io::stdin().read_line(&mut input).is_err() { - return false; +#[allow(clippy::print_stdout)] +fn show_customize_menu(opts: &mut cli::Options) { + loop { + let version_display = opts.version.as_deref().unwrap_or("latest"); + let registry_display = opts.registry.as_deref().unwrap_or("(default)"); + + println!(); + println!(" {}", "Customize installation:".bold()); + println!(); + println!(" 1) Version: [{}]", version_display.cyan()); + println!(" 2) npm registry: [{}]", registry_display.cyan()); + println!( + " 3) Node.js manager: [{}]", + if opts.no_node_manager { "disabled" } else { "auto-detect" }.cyan() + ); + println!( + " 4) Modify PATH: [{}]", + if opts.no_modify_path { "no" } else { "yes" }.cyan() + ); + println!(); + + let choice = read_input(" Enter option number to change, or press Enter to go back: "); + match choice.as_str() { + "" => return, + "1" => { + let v = read_input(" Version (e.g. 0.3.0, or 'latest'): "); + if v == "latest" || v.is_empty() { + opts.version = None; + } else { + opts.version = Some(v); + } + } + "2" => { + let r = read_input(" npm registry URL (or empty for default): "); + opts.registry = if r.is_empty() { None } else { Some(r) }; + } + "3" => opts.no_node_manager = !opts.no_node_manager, + "4" => opts.no_modify_path = !opts.no_modify_path, + _ => println!(" Invalid option."), + } } +} - let choice = input.trim(); - choice.is_empty() || choice == "1" +fn read_input(prompt: &str) -> String { + print!("{prompt}"); + let _ = io::stdout().flush(); + let mut input = String::new(); + let _ = io::stdin().read_line(&mut input); + input.trim().to_string() } #[allow(clippy::print_stdout)] From d5bdac22df46d7ada67ecbc33f22d2d4e0b1441e Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:53:55 +0800 Subject: [PATCH 05/39] fix(build): remove unused junction dependency from vite_global_cli The junction crate was moved to vite_setup when extracting the shared installation logic. cargo-shear correctly flagged it as unused. --- crates/vite_global_cli/Cargo.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index da2300973a..55d1f3d333 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -35,9 +35,6 @@ vite_shared = { workspace = true } vite_str = { workspace = true } vite_workspace = { workspace = true } -[target.'cfg(windows)'.dependencies] -junction = { workspace = true } - [dev-dependencies] serial_test = { workspace = true } tempfile = { workspace = true } From 974cf120933872bea28729b5f048bdf592c59286 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:55:43 +0800 Subject: [PATCH 06/39] docs(rfc): restructure Installation Flow with visual diagram Replace the flat numbered list with a phased ASCII diagram grouped into Resolve, Download & Verify, Install, Activate, and Configure phases. Add a function-to-crate mapping table and document the failure recovery boundary (pre/post Activate phase). Annotate conditional steps (save_previous_version only on upgrade, modify PATH gated by --no-modify-path). --- rfcs/windows-installer.md | 143 +++++++++++++++++++++++++------------- 1 file changed, 96 insertions(+), 47 deletions(-) diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index 85de207332..07b5789d11 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -202,57 +202,106 @@ CLI flags take precedence over environment variables. ## Installation Flow -The installer performs the exact same steps as `install.ps1`, in Rust: +The installer replicates the same result as `install.ps1`, implemented in Rust via `vite_setup`. ``` -1. Detect platform → vite_setup::platform::detect_platform_suffix() - (win32-x64-msvc or win32-arm64-msvc) - -2. Resolve version → vite_setup::registry::resolve_version() - Query npm registry for latest/specified version - -3. Check existing install → Read %VP_HOME%\current target, compare versions - Skip if already at target version - -4. Download tarball → vite_install::HttpClient::get_bytes() - With progress bar via indicatif - -5. Verify integrity → vite_setup::integrity::verify_integrity() - SHA-512 SRI hash from npm metadata - -6. Create version dir → %VP_HOME%\{version}\bin\ - -7. Extract binary → vite_setup::extract::extract_platform_package() - Extracts vp.exe and vp-shim.exe - -8. Generate package.json → vite_setup::package_json::generate() - Wrapper package.json in version dir - -9. Write .npmrc → vite_setup::npmrc::write_release_age_overrides() - minimum-release-age=0 - -10. Install deps → Spawn: {version_dir}\bin\vp.exe install --silent - -11. Swap current junction → vite_setup::link::swap_current_link() - mklink /J current → {version} - -12. Create bin shims → Copy vp-shim.exe → %VP_HOME%\bin\vp.exe - -13. Setup Node.js manager → Prompt or auto-detect, then: - Spawn: vp.exe env setup --refresh - -14. Cleanup old versions → vite_setup::cleanup::cleanup_old_versions() - Keep last 5 - -15. Modify User PATH → Registry: HKCU\Environment\Path - Add %VP_HOME%\bin if not present - Broadcast WM_SETTINGCHANGE - -16. Create env files → Spawn: vp.exe env setup --env-only - -17. Print success → Show getting-started commands +┌─────────────────────────────────────────────────────────────┐ +│ RESOLVE │ +│ │ +│ ┌─ detect platform ──────── win32-x64-msvc │ +│ │ win32-arm64-msvc │ +│ │ │ +│ ├─ resolve version ──────── query npm registry │ +│ │ "latest" → "0.3.0" │ +│ │ │ +│ └─ check existing ──────── read %VP_HOME%\current │ +│ same version? → exit early │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DOWNLOAD & VERIFY │ +│ │ +│ ┌─ download tarball ─────── GET tarball URL from registry │ +│ │ progress spinner via indicatif │ +│ │ │ +│ └─ verify integrity ─────── SHA-512 SRI hash comparison │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ INSTALL │ +│ │ +│ ┌─ extract binary ──────── %VP_HOME%\{version}\bin\ │ +│ │ vp.exe + vp-shim.exe │ +│ │ │ +│ ├─ generate package.json ─ wrapper with vite-plus dep │ +│ │ pins pnpm@10.33.0 │ +│ │ │ +│ ├─ write .npmrc ────────── minimum-release-age=0 │ +│ │ │ +│ └─ install deps ────────── spawn: vp install --silent │ +│ installs vite-plus + transitive │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ACTIVATE ◄── point of no │ +│ return │ +│ ┌─ save previous version ── .previous-version (rollback) │ +│ │ (only if upgrading existing) │ +│ │ │ +│ └─ swap current ───────���── mklink /J current → {version} │ +│ (junction on Windows, │ +│ atomic symlink on Unix) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CONFIGURE │ +│ │ +│ ┌─ create bin shims ────── copy vp-shim.exe → bin\vp.exe │ +│ │ (rename-to-.old if running) │ +│ │ │ +│ ├─ Node.js manager ────── if enabled: │ +│ │ spawn: vp env setup --refresh │ +│ │ if disabled: │ +│ │ spawn: vp env setup --env-only │ +│ │ │ +│ ├─ cleanup old versions ── keep last 5 by creation time │ +│ │ (non-fatal on error) │ +│ │ │ +│ └─ modify User PATH ────── if --no-modify-path not set: │ +│ HKCU\Environment\Path │ +│ prepend %VP_HOME%\bin ��� +│ broadcast WM_SETTINGCHANGE │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ + ✔ Print success ``` +Each phase maps to `vite_setup` library functions shared with `vp upgrade`: + +| Phase | Key function | Crate | +| ----------------- | ------------------------------------------ | ---------------- | +| Resolve | `platform::detect_platform_suffix()` | `vite_setup` | +| Resolve | `registry::resolve_version()` | `vite_setup` | +| Resolve | `install::read_current_version()` | `vite_setup` | +| Download & Verify | `HttpClient::get_bytes()` | `vite_install` | +| Download & Verify | `integrity::verify_integrity()` | `vite_setup` | +| Install | `install::extract_platform_package()` | `vite_setup` | +| Install | `install::generate_wrapper_package_json()` | `vite_setup` | +| Install | `install::write_release_age_overrides()` | `vite_setup` | +| Install | `install::install_production_deps()` | `vite_setup` | +| Activate | `install::save_previous_version()` | `vite_setup` | +| Activate | `install::swap_current_link()` | `vite_setup` | +| Configure | `install::refresh_shims()` | `vite_setup` | +| Configure | `install::cleanup_old_versions()` | `vite_setup` | +| Configure | `windows_path::add_to_user_path()` | `vite_installer` | + +On failure before the **Activate** phase, the version directory is cleaned up and the existing installation remains untouched. After the **Activate** phase (junction swap), the update has already succeeded — subsequent configure steps are best-effort (non-fatal on error). + ## Windows-Specific Details ### PATH Modification via Registry From ba29a0db2d6ac35d0f6b92fbc27f95a4dc49efec Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:57:01 +0800 Subject: [PATCH 07/39] fix(rfc): remove garbled Unicode characters in ASCII diagram --- Cargo.lock | 1 - rfcs/windows-installer.md | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b733dcdd4..bf31a199fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7399,7 +7399,6 @@ dependencies = [ "clap_complete", "crossterm", "directories", - "junction", "node-semver", "owo-colors", "oxc_resolver", diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index 07b5789d11..53192e9ceb 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -251,7 +251,7 @@ The installer replicates the same result as `install.ps1`, implemented in Rust v │ ┌─ save previous version ── .previous-version (rollback) │ │ │ (only if upgrading existing) │ │ │ │ -│ └─ swap current ───────���── mklink /J current → {version} │ +│ └─ swap current ────────── mklink /J current → {version} │ │ (junction on Windows, │ │ atomic symlink on Unix) │ └─────────────────────────────────────────────────────────────┘ @@ -273,7 +273,7 @@ The installer replicates the same result as `install.ps1`, implemented in Rust v │ │ │ │ └─ modify User PATH ────── if --no-modify-path not set: │ │ HKCU\Environment\Path │ -│ prepend %VP_HOME%\bin ��� +│ prepend %VP_HOME%\bin │ │ broadcast WM_SETTINGCHANGE │ └─────────────────────────────────────────────────────────────┘ │ From ef1b30ed420c730d168ebb2b55dffbb59ab2ea0a Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 21:03:35 +0800 Subject: [PATCH 08/39] docs(rfc): sync RFC with implementation - Update status from "Draft" to "Implemented" - Fix Code Sharing table: all install functions are in vite_setup::install, not separate submodules - Fix Dependency Graph: remove junction (indirect via vite_setup), add actual deps (vite_path, owo-colors) - Fix Customization submenu to match code (numbered items, no install dir) - Replace winreg code sample with raw FFI description (matches implementation) - Replace windows_sys DLL sample with raw FFI (matches implementation) - Remove winreg from Binary Size Budget, add raw FFI note - Fix Alternatives #4: raw FFI, not winreg - Fix CI snippets: rename vite-init to vp-setup, update test workflow to match actual test-vp-setup-exe job - Mark Implementation Phases 1-3 as done, Phase 4 as future --- rfcs/windows-installer.md | 176 +++++++++++++++++++------------------- 1 file changed, 89 insertions(+), 87 deletions(-) diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index 53192e9ceb..55ae041217 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -2,7 +2,7 @@ ## Status -Draft +Implemented ## Summary @@ -85,17 +85,12 @@ crates/vite_installer/ — standalone installer binary ### What Gets Extracted -| Current location in `upgrade/` | Extracted to `vite_setup::` | Purpose | -| ----------------------------------------------------------------- | --------------------------- | ------------------------- | -| `platform.rs` → `detect_platform_suffix()` | `platform` | OS/arch detection | -| `registry.rs` → `resolve_version()`, `resolve_platform_package()` | `registry` | npm registry queries | -| `integrity.rs` → `verify_integrity()` | `integrity` | SHA-512 verification | -| `install.rs` → `extract_platform_package()` | `extract` | Tarball extraction | -| `install.rs` → `generate_wrapper_package_json()` | `package_json` | Wrapper package.json | -| `install.rs` → `write_release_age_overrides()` | `npmrc` | .npmrc overrides | -| `install.rs` → `install_production_deps()` | `deps` | Run `vp install --silent` | -| `install.rs` → `swap_current_link()` | `link` | Symlink/junction swap | -| `install.rs` → `cleanup_old_versions()` | `cleanup` | Old version cleanup | +| Original location in `upgrade/` | Extracted to `vite_setup::` | Purpose | +| ------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `platform.rs` | `platform` | OS/arch detection | +| `registry.rs` | `registry` | npm registry queries | +| `integrity.rs` | `integrity` | SHA-512 verification | +| `install.rs` (all functions) | `install` | Tarball extraction, package.json generation, .npmrc overrides, dep install, symlink/junction swap, version cleanup, rollback support | ### What Stays in `vite_global_cli` @@ -117,15 +112,16 @@ crates/vite_installer/ — standalone installer binary ``` vite_installer (binary, ~3-5 MB) - ├── vite_setup (new library) + ├── vite_setup (shared installation logic) ├── vite_install (HTTP client) - ├── vite_shared (home dir, output) + ├── vite_shared (home dir resolution) + ├── vite_path (typed path wrappers) ├── clap (CLI parsing) ├── tokio (async runtime) ├── indicatif (progress bars) - └── junction (Windows junctions) + └── owo-colors (terminal colors) -vite_global_cli (existing, unchanged) +vite_global_cli (existing) ├── vite_setup (replaces inline upgrade code) └── ... (all existing deps) ``` @@ -156,13 +152,14 @@ This will install the vp CLI and monorepo task runner. Customization submenu: ``` - Install directory [C:\Users\alice\.vite-plus] - Version [latest] - npm registry [https://registry.npmjs.org] - Node.js manager [auto] - Modify PATH [yes] + Customize installation: -Enter option number to change, or press Enter to go back: + 1) Version: [latest] + 2) npm registry: [(default)] + 3) Node.js manager: [auto-detect] + 4) Modify PATH: [yes] + + Enter option number to change, or press Enter to go back: > ``` @@ -306,42 +303,32 @@ On failure before the **Activate** phase, the version directory is cleaned up an ### PATH Modification via Registry -Same approach as rustup and `install.ps1`: +Same approach as rustup and `install.ps1`, using raw Win32 FFI (no external crate) following the same zero-dependency pattern as `vite_trampoline`: -```rust -use winreg::RegKey; -use winreg::enums::*; - -fn add_to_path(bin_dir: &str) -> Result<()> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?; - - let current_path: String = env.get_value("Path")?; - if !current_path.split(';').any(|p| p.eq_ignore_ascii_case(bin_dir)) { - let new_path = format!("{bin_dir};{current_path}"); - env.set_value("Path", &new_path)?; - // Broadcast WM_SETTINGCHANGE so other processes pick up the change - broadcast_settings_change(); - } - Ok(()) -} -``` +1. Read current `HKCU\Environment\Path` via `RegQueryValueExW` +2. Check if bin dir is already present (case-insensitive, handles trailing backslash) +3. Prepend `%VP_HOME%\bin` if not present, write back via `RegSetValueExW` as `REG_EXPAND_SZ` +4. Broadcast `WM_SETTINGCHANGE` via `SendMessageTimeoutW` so other processes pick up the change + +See `crates/vite_installer/src/windows_path.rs` for the full implementation. ### DLL Security (for download-folder execution) -Following rustup's approach — when the `.exe` is downloaded to `Downloads/` and double-clicked, malicious DLLs in the same folder could be loaded. Mitigations: +Following rustup's approach — when the `.exe` is downloaded to `Downloads/` and double-clicked, malicious DLLs in the same folder could be loaded. Two mitigations, both using raw FFI (no `windows-sys` crate): ```rust -// In build.rs — linker flags +// build.rs — linker-time: restrict DLL search at load time #[cfg(windows)] println!("cargo:rustc-link-arg=/DEPENDENTLOADFLAG:0x800"); -// In main() — runtime mitigation +// main.rs — runtime: restrict DLL search via Win32 API #[cfg(windows)] -unsafe { - windows_sys::Win32::System::LibraryLoader::SetDefaultDllDirectories( - LOAD_LIBRARY_SEARCH_SYSTEM32, - ); +fn init_dll_security() { + unsafe extern "system" { + fn SetDefaultDllDirectories(directory_flags: u32) -> i32; + } + const LOAD_LIBRARY_SEARCH_SYSTEM32: u32 = 0x0000_0800; + unsafe { SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32); } } ``` @@ -404,41 +391,53 @@ Submit to winget, chocolatey, scoop. Each has its own manifest format and review ### Release Workflow Additions +In `build-upstream/action.yml`, the installer binary is built and cached alongside the CLI: + ```yaml -# In build-rust job matrix (already has windows targets) -- name: Build installer (Windows only) - if: contains(matrix.settings.target, 'windows') - run: cargo build --release --target ${{ matrix.settings.target }} -p vite_installer +- name: Build installer binary (Windows only) + if: contains(inputs.target, 'windows') + run: cargo build --release --target ${{ inputs.target }} -p vite_installer +``` -- name: Upload installer artifact +In `release.yml`, installer artifacts are uploaded per-target, renamed with the target triple, and attached to the GitHub Release: + +```yaml +- name: Upload installer binary artifact (Windows only) if: contains(matrix.settings.target, 'windows') uses: actions/upload-artifact@v4 with: - name: vite-init-${{ matrix.settings.target }} + name: vp-setup-${{ matrix.settings.target }} path: ./target/${{ matrix.settings.target }}/release/vp-setup.exe ``` ### Test Workflow -Extend `test-standalone-install.yml` with new jobs: +`test-standalone-install.yml` includes a `test-vp-setup-exe` job that builds the installer from source and tests silent installation across three shells: ```yaml -test-init-exe: +test-vp-setup-exe: + name: Test vp-setup.exe (${{ matrix.shell }}) + runs-on: windows-latest strategy: matrix: - shell: [cmd, pwsh, powershell, bash] - runs-on: windows-latest + shell: [cmd, pwsh, bash] steps: - - name: Download vp-setup.exe - run: # download from artifacts or latest release - - name: Install (silent) - run: vp-setup.exe -y + - uses: actions/checkout@v4 + - uses: oxc-project/setup-rust@v1 + - name: Build vp-setup.exe + run: cargo build --release -p vite_installer + - name: Install via vp-setup.exe (silent) + run: ./target/release/vp-setup.exe -y + env: + VP_VERSION: alpha - name: Verify installation run: | vp --version vp --help ``` +The workflow triggers on changes to `crates/vite_installer/**` and `crates/vite_setup/**`. + ## Code Signing Windows Defender SmartScreen flags unsigned executables downloaded from the internet. This is a significant UX problem for a download-and-run installer. @@ -462,9 +461,10 @@ Key dependencies and their approximate contribution: | `indicatif` | Progress bars | ~100 KB | | `sha2` | Integrity verification | ~50 KB | | `serde_json` | Registry JSON parsing | ~200 KB | -| `winreg` | Windows registry | ~50 KB | | Rust std + overhead | | ~500 KB | +Note: Windows registry access uses raw FFI (~0 KB overhead) instead of the `winreg` crate, following the same zero-dependency pattern as `vite_trampoline`. + Use `opt-level = "z"` (optimize for size) in package profile override, matching the trampoline approach. ## Alternatives Considered @@ -490,42 +490,44 @@ Like rustup, make `vp.exe` detect when called as `vp-setup.exe` and switch to in Embed the PowerShell script in a self-extracting exe. Fragile, still requires PowerShell runtime. -### 4. Use `winreg` vs PowerShell for PATH (Decision: `winreg`) +### 4. Use `winreg` Crate vs Raw FFI for PATH (Decision: Raw FFI) -- `winreg` crate: Direct registry API, no subprocess, reliable +- `winreg` crate: Higher-level API, adds ~50 KB dependency +- Raw Win32 FFI: Zero external dependencies, matches `vite_trampoline` pattern, slightly more code - PowerShell subprocess: Proven in `install.ps1` but adds process spawn overhead and PowerShell dependency -- Decision: Use `winreg` for direct registry access — the whole point of the exe installer is to not depend on PowerShell +- Decision: Use raw FFI for direct registry access — keeps the installer dependency-free for Win32 operations, consistent with the trampoline's approach ## Implementation Phases -### Phase 1: Extract `vite_setup` Library +### Phase 1: Extract `vite_setup` Library (done) -- Create `crates/vite_setup/Cargo.toml` -- Move shared code from `vite_global_cli/src/commands/upgrade/` into `vite_setup` -- Update `vite_global_cli` to import from `vite_setup` -- Run existing tests to verify no regressions +- Created `crates/vite_setup/` with `platform`, `registry`, `integrity`, `install` modules +- Moved shared code from `vite_global_cli/src/commands/upgrade/` into `vite_setup` +- Updated `vite_global_cli` to import from `vite_setup` +- All 353 existing tests pass -### Phase 2: Create `vite_installer` Binary +### Phase 2: Create `vite_installer` Binary (done) -- Create `crates/vite_installer/` with `[[bin]] name = "vp-setup"` -- Implement CLI argument parsing (clap) -- Implement installation flow calling `vite_setup` -- Implement Windows PATH modification via `winreg` -- Implement interactive prompts -- Implement progress bar for downloads -- Add DLL security mitigations +- Created `crates/vite_installer/` with `[[bin]] name = "vp-setup"` +- Implemented CLI argument parsing (clap) with env var merging +- Implemented installation flow calling `vite_setup` +- Implemented Windows PATH modification via raw Win32 FFI +- Implemented interactive prompts with customization submenu +- Implemented progress spinner for downloads +- Added DLL security mitigations (build.rs linker flag + runtime `SetDefaultDllDirectories`) -### Phase 3: CI Integration +### Phase 3: CI Integration (done) -- Add init binary build to release workflow -- Add artifact upload and GitHub Release attachment -- Add test jobs for `vp-setup.exe` across shell types +- Added installer binary build to `build-upstream/action.yml` (Windows targets only) +- Added artifact upload and GitHub Release attachment in `release.yml` +- Added `test-vp-setup-exe` job to `test-standalone-install.yml` (cmd, pwsh, bash) +- Updated release body with `vp-setup.exe` download mention -### Phase 4: Documentation & Distribution +### Phase 4: Documentation & Distribution (future) -- Update installation docs -- Host on `vite.plus/vp-setup.exe` -- Update release body template with download link +- Update installation docs on website +- Host on `vite.plus/vp-setup.exe` with architecture auto-detection +- Submit to winget, chocolatey, scoop ## Testing Strategy From 1898d385bf1374a3fd8b7b28f471ed7180dfa055 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 21:12:18 +0800 Subject: [PATCH 09/39] refactor(installer): replace raw FFI with winreg crate for registry access The zero-dependency pattern made sense for vite_trampoline (copied 5-10 times as shim files) but not for a single downloadable installer where readability matters more. Switch from 225 lines of unsafe raw Win32 FFI to ~80 lines of safe Rust using the winreg crate (~50-100 KB after LTO). WM_SETTINGCHANGE broadcast still uses a single raw FFI call since winreg doesn't wrap SendMessageTimeoutW. --- Cargo.lock | 11 ++ Cargo.toml | 1 + crates/vite_installer/Cargo.toml | 3 + crates/vite_installer/src/windows_path.rs | 229 +++++----------------- rfcs/windows-installer.md | 25 +-- 5 files changed, 73 insertions(+), 196 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf31a199fa..00596f8c67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7474,6 +7474,7 @@ dependencies = [ "vite_path", "vite_setup", "vite_shared", + "winreg", ] [[package]] @@ -8376,6 +8377,16 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "winsafe" version = "0.0.24" diff --git a/Cargo.toml b/Cargo.toml index 325b25bb96..ce6c5a16c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -207,6 +207,7 @@ vite_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = walkdir = "2.5.0" wax = "0.6.0" which = "8.0.0" +winreg = "0.56.0" xxhash-rust = "0.8.15" zip = "7.2" diff --git a/crates/vite_installer/Cargo.toml b/crates/vite_installer/Cargo.toml index ac125640a5..bcc1df0694 100644 --- a/crates/vite_installer/Cargo.toml +++ b/crates/vite_installer/Cargo.toml @@ -22,5 +22,8 @@ vite_path = { workspace = true } vite_setup = { workspace = true } vite_shared = { workspace = true } +[target.'cfg(windows)'.dependencies] +winreg = { workspace = true } + [lints] workspace = true diff --git a/crates/vite_installer/src/windows_path.rs b/crates/vite_installer/src/windows_path.rs index 4a38ea1713..bb8720beb9 100644 --- a/crates/vite_installer/src/windows_path.rs +++ b/crates/vite_installer/src/windows_path.rs @@ -5,192 +5,40 @@ use std::io; -/// Raw Win32 FFI declarations for registry and environment broadcast. -/// -/// We declare these inline to avoid pulling in the `windows-sys` crate, -/// following the same zero-dependency pattern as `vite_trampoline`. -mod ffi { - #![allow(non_snake_case, clippy::upper_case_acronyms)] - - pub type HKEY = isize; - pub type DWORD = u32; - pub type LONG = i32; - pub type LPCWSTR = *const u16; - pub type HWND = isize; - pub type WPARAM = usize; - pub type LPARAM = isize; - pub type UINT = u32; +use winreg::{ + RegKey, + enums::{HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ}, +}; - pub const HKEY_CURRENT_USER: HKEY = -2_147_483_647; - pub const KEY_READ: DWORD = 0x0002_0019; - pub const KEY_WRITE: DWORD = 0x0002_0006; - pub const REG_EXPAND_SZ: DWORD = 2; - pub const ERROR_SUCCESS: LONG = 0; - pub const ERROR_FILE_NOT_FOUND: LONG = 2; - pub const HWND_BROADCAST: HWND = 0xFFFF; - pub const WM_SETTINGCHANGE: UINT = 0x001A; - pub const SMTO_ABORTIFHUNG: UINT = 0x0002; +/// Broadcast `WM_SETTINGCHANGE` so other processes pick up the PATH change. +fn broadcast_settings_change() { + const HWND_BROADCAST: isize = 0xFFFF; + const WM_SETTINGCHANGE: u32 = 0x001A; + const SMTO_ABORTIFHUNG: u32 = 0x0002; unsafe extern "system" { - pub fn RegOpenKeyExW( - hKey: HKEY, - lpSubKey: LPCWSTR, - ulOptions: DWORD, - samDesired: DWORD, - phkResult: *mut HKEY, - ) -> LONG; - - pub fn RegQueryValueExW( - hKey: HKEY, - lpValueName: LPCWSTR, - lpReserved: *mut DWORD, - lpType: *mut DWORD, - lpData: *mut u8, - lpcbData: *mut DWORD, - ) -> LONG; - - pub fn RegSetValueExW( - hKey: HKEY, - lpValueName: LPCWSTR, - Reserved: DWORD, - dwType: DWORD, - lpData: *const u8, - cbData: DWORD, - ) -> LONG; - - pub fn RegCloseKey(hKey: HKEY) -> LONG; - - pub fn SendMessageTimeoutW( - hWnd: HWND, - Msg: UINT, - wParam: WPARAM, - lParam: LPARAM, - fuFlags: UINT, - uTimeout: UINT, + fn SendMessageTimeoutW( + hWnd: isize, + Msg: u32, + wParam: usize, + lParam: isize, + fuFlags: u32, + uTimeout: u32, lpdwResult: *mut usize, ) -> isize; } -} - -/// Encode a Rust string as a null-terminated wide (UTF-16) string. -fn to_wide(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() -} - -/// Read the current User PATH from the registry. -fn read_user_path() -> io::Result { - let sub_key = to_wide("Environment"); - let value_name = to_wide("Path"); - - let mut hkey: ffi::HKEY = 0; - let result = unsafe { - ffi::RegOpenKeyExW(ffi::HKEY_CURRENT_USER, sub_key.as_ptr(), 0, ffi::KEY_READ, &mut hkey) - }; - - if result == ffi::ERROR_FILE_NOT_FOUND { - return Ok(String::new()); - } - if result != ffi::ERROR_SUCCESS { - return Err(io::Error::from_raw_os_error(result)); - } - - // Query the size first - let mut data_type: ffi::DWORD = 0; - let mut data_size: ffi::DWORD = 0; - let result = unsafe { - ffi::RegQueryValueExW( - hkey, - value_name.as_ptr(), - std::ptr::null_mut(), - &mut data_type, - std::ptr::null_mut(), - &mut data_size, - ) - }; - - if result == ffi::ERROR_FILE_NOT_FOUND { - unsafe { ffi::RegCloseKey(hkey) }; - return Ok(String::new()); - } - if result != ffi::ERROR_SUCCESS { - unsafe { ffi::RegCloseKey(hkey) }; - return Err(io::Error::from_raw_os_error(result)); - } - - // Read the data - let mut buf = vec![0u8; data_size as usize]; - let result = unsafe { - ffi::RegQueryValueExW( - hkey, - value_name.as_ptr(), - std::ptr::null_mut(), - &mut data_type, - buf.as_mut_ptr(), - &mut data_size, - ) - }; - - unsafe { ffi::RegCloseKey(hkey) }; - if result != ffi::ERROR_SUCCESS { - return Err(io::Error::from_raw_os_error(result)); - } - - // Convert UTF-16 to String (strip trailing null) - let wide: Vec = buf.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect(); - let s = String::from_utf16_lossy(&wide); - Ok(s.trim_end_matches('\0').to_string()) -} - -/// Write the User PATH to the registry. -fn write_user_path(path: &str) -> io::Result<()> { - let sub_key = to_wide("Environment"); - let value_name = to_wide("Path"); - let wide_path = to_wide(path); - - let mut hkey: ffi::HKEY = 0; - let result = unsafe { - ffi::RegOpenKeyExW(ffi::HKEY_CURRENT_USER, sub_key.as_ptr(), 0, ffi::KEY_WRITE, &mut hkey) - }; - - if result != ffi::ERROR_SUCCESS { - return Err(io::Error::from_raw_os_error(result)); - } - - let byte_len = (wide_path.len() * 2) as ffi::DWORD; - let result = unsafe { - ffi::RegSetValueExW( - hkey, - value_name.as_ptr(), - 0, - ffi::REG_EXPAND_SZ, - wide_path.as_ptr().cast::(), - byte_len, - ) - }; - - unsafe { ffi::RegCloseKey(hkey) }; - - if result != ffi::ERROR_SUCCESS { - return Err(io::Error::from_raw_os_error(result)); - } - - Ok(()) -} - -/// Broadcast `WM_SETTINGCHANGE` so other processes pick up the PATH change. -fn broadcast_settings_change() { - let env_wide = to_wide("Environment"); - let mut _result: usize = 0; + let env_wide: Vec = "Environment".encode_utf16().chain(std::iter::once(0)).collect(); + let mut result: usize = 0; unsafe { - ffi::SendMessageTimeoutW( - ffi::HWND_BROADCAST, - ffi::WM_SETTINGCHANGE, + SendMessageTimeoutW( + HWND_BROADCAST, + WM_SETTINGCHANGE, 0, - env_wide.as_ptr() as ffi::LPARAM, - ffi::SMTO_ABORTIFHUNG, + env_wide.as_ptr() as isize, + SMTO_ABORTIFHUNG, 5000, - &mut _result, + &mut result, ); } } @@ -201,25 +49,36 @@ fn broadcast_settings_change() { /// (case-insensitive, with/without trailing backslash), and prepends if not. /// Broadcasts `WM_SETTINGCHANGE` so new terminal sessions see the change. pub fn add_to_user_path(bin_dir: &str) -> io::Result<()> { - let current = read_user_path()?; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?; + + let current: String = env.get_value("Path").unwrap_or_default(); let bin_dir_normalized = bin_dir.trim_end_matches('\\'); - // Check if already in PATH (case-insensitive, handle trailing backslash) - let already_present = current.split(';').any(|entry| { - let entry_normalized = entry.trim_end_matches('\\'); - entry_normalized.eq_ignore_ascii_case(bin_dir_normalized) - }); + let already_present = current + .split(';') + .any(|entry| entry.trim_end_matches('\\').eq_ignore_ascii_case(bin_dir_normalized)); if already_present { return Ok(()); } - // Prepend to PATH let new_path = if current.is_empty() { bin_dir.to_string() } else { format!("{bin_dir};{current}") }; - write_user_path(&new_path)?; - broadcast_settings_change(); + // Write as REG_EXPAND_SZ to support %VARIABLE% expansion in PATH entries + env.set_raw_value( + "Path", + &winreg::RegValue { + vtype: REG_EXPAND_SZ, + bytes: new_path + .encode_utf16() + .chain(std::iter::once(0)) + .flat_map(|c| c.to_le_bytes()) + .collect(), + }, + )?; + broadcast_settings_change(); Ok(()) } diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index 55ae041217..8ca5565e9d 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -303,12 +303,16 @@ On failure before the **Activate** phase, the version directory is cleaned up an ### PATH Modification via Registry -Same approach as rustup and `install.ps1`, using raw Win32 FFI (no external crate) following the same zero-dependency pattern as `vite_trampoline`: +Same approach as rustup and `install.ps1`, using the `winreg` crate for registry access: -1. Read current `HKCU\Environment\Path` via `RegQueryValueExW` -2. Check if bin dir is already present (case-insensitive, handles trailing backslash) -3. Prepend `%VP_HOME%\bin` if not present, write back via `RegSetValueExW` as `REG_EXPAND_SZ` -4. Broadcast `WM_SETTINGCHANGE` via `SendMessageTimeoutW` so other processes pick up the change +```rust +let hkcu = RegKey::predef(HKEY_CURRENT_USER); +let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?; +let current: String = env.get_value("Path").unwrap_or_default(); +// ... check if already present (case-insensitive, handles trailing backslash) +// ... prepend bin_dir, write back as REG_EXPAND_SZ +// ... broadcast WM_SETTINGCHANGE via SendMessageTimeoutW (raw FFI, single call) +``` See `crates/vite_installer/src/windows_path.rs` for the full implementation. @@ -461,10 +465,9 @@ Key dependencies and their approximate contribution: | `indicatif` | Progress bars | ~100 KB | | `sha2` | Integrity verification | ~50 KB | | `serde_json` | Registry JSON parsing | ~200 KB | +| `winreg` + `windows-sys` | Windows registry | ~50-100 KB | | Rust std + overhead | | ~500 KB | -Note: Windows registry access uses raw FFI (~0 KB overhead) instead of the `winreg` crate, following the same zero-dependency pattern as `vite_trampoline`. - Use `opt-level = "z"` (optimize for size) in package profile override, matching the trampoline approach. ## Alternatives Considered @@ -490,12 +493,12 @@ Like rustup, make `vp.exe` detect when called as `vp-setup.exe` and switch to in Embed the PowerShell script in a self-extracting exe. Fragile, still requires PowerShell runtime. -### 4. Use `winreg` Crate vs Raw FFI for PATH (Decision: Raw FFI) +### 4. Use `winreg` Crate vs Raw FFI for PATH (Decision: `winreg`) -- `winreg` crate: Higher-level API, adds ~50 KB dependency -- Raw Win32 FFI: Zero external dependencies, matches `vite_trampoline` pattern, slightly more code +- `winreg` crate: Higher-level safe API, ~50-100 KB after LTO, significantly less code (~80 lines vs ~225 lines) +- Raw Win32 FFI: Zero dependencies but 225 lines of unsafe code with manual UTF-16 encoding and registry choreography - PowerShell subprocess: Proven in `install.ps1` but adds process spawn overhead and PowerShell dependency -- Decision: Use raw FFI for direct registry access — keeps the installer dependency-free for Win32 operations, consistent with the trampoline's approach +- Decision: Use `winreg` for registry access — the zero-dependency pattern makes sense for `vite_trampoline` (copied 5-10 times as shims) but not for a single downloadable installer where readability matters more. `WM_SETTINGCHANGE` broadcast still uses a single raw FFI call since `winreg` doesn't wrap it. ## Implementation Phases From ead7a8a0113e5de86b77f7ad05fe7e0a6896d181 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 21:35:03 +0800 Subject: [PATCH 10/39] refactor(installer): minor cleanup from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract VP_BINARY_NAME constant to avoid duplicated cfg!(windows) binary name literals - Remove redundant exists() check in replace_windows_exe — just attempt the rename unconditionally (rename failure is already ignored) - Trim verbose WHAT-comments to concise single-line doc comments --- crates/vite_installer/src/main.rs | 34 ++++++++++------------- crates/vite_installer/src/windows_path.rs | 6 +--- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 9445d19073..52c26fa457 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -22,6 +22,8 @@ use vite_install::request::HttpClient; use vite_path::AbsolutePathBuf; use vite_setup::{install, integrity, platform, registry}; +const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; + /// Restrict DLL search to system32 only to prevent DLL hijacking /// when the installer is run from a Downloads folder. #[cfg(windows)] @@ -141,8 +143,7 @@ async fn do_install( } install::extract_platform_package(&platform_data, &version_dir).await?; - let binary_name = if cfg!(windows) { "vp.exe" } else { "vp" }; - let binary_path = version_dir.join("bin").join(binary_name); + let binary_path = version_dir.join("bin").join(VP_BINARY_NAME); if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { return Err("Binary not found after extraction. The download may be corrupted.".into()); } @@ -193,31 +194,26 @@ async fn do_install( Ok(()) } -/// On Windows, rename a running exe to `.old` then copy the new one in place. +/// Windows locks running `.exe` files — rename the old one out of the way before copying. #[cfg(windows)] async fn replace_windows_exe( src: &vite_path::AbsolutePathBuf, dst: &vite_path::AbsolutePathBuf, bin_dir: &vite_path::AbsolutePathBuf, ) -> Result<(), Box> { - if dst.as_path().exists() { - let old_name = format!( - "vp.exe.{}.old", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - ); - let _ = tokio::fs::rename(dst, &bin_dir.join(&old_name)).await; - } + let old_name = format!( + "vp.exe.{}.old", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + ); + let _ = tokio::fs::rename(dst, &bin_dir.join(&old_name)).await; tokio::fs::copy(src, dst).await?; Ok(()) } -/// Set up the bin/ directory with the initial `vp` shim. -/// -/// On Windows, copies `vp-shim.exe` from `current/bin/` to `bin/vp.exe`. -/// On Unix, creates a symlink from `bin/vp` to `../current/bin/vp`. +/// Set up the `bin/vp` entry point (trampoline copy on Windows, symlink on Unix). async fn setup_bin_shims( install_dir: &vite_path::AbsolutePath, ) -> Result<(), Box> { @@ -284,10 +280,8 @@ async fn download_with_progress( Ok(data) } -/// Create shell env files by spawning `vp env setup --env-only`. async fn create_env_files(install_dir: &vite_path::AbsolutePath) { - let vp_binary = - install_dir.join("current").join("bin").join(if cfg!(windows) { "vp.exe" } else { "vp" }); + let vp_binary = install_dir.join("current").join("bin").join(VP_BINARY_NAME); if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { return; diff --git a/crates/vite_installer/src/windows_path.rs b/crates/vite_installer/src/windows_path.rs index bb8720beb9..61e743599c 100644 --- a/crates/vite_installer/src/windows_path.rs +++ b/crates/vite_installer/src/windows_path.rs @@ -43,11 +43,7 @@ fn broadcast_settings_change() { } } -/// Add a directory to the User PATH if not already present. -/// -/// Reads `HKCU\Environment\Path`, checks if `bin_dir` is already there -/// (case-insensitive, with/without trailing backslash), and prepends if not. -/// Broadcasts `WM_SETTINGCHANGE` so new terminal sessions see the change. +/// Add a directory to the User PATH (`HKCU\Environment\Path`) if not already present. pub fn add_to_user_path(bin_dir: &str) -> io::Result<()> { let hkcu = RegKey::predef(HKEY_CURRENT_USER); let env = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?; From d27f56f67b3b8073d834c0c49c6993f556ee4245 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:15:34 +0800 Subject: [PATCH 11/39] fix(installer): address Codex review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix all high/P1/P2 issues from both adversarial and standard reviews: 1. Node.js manager auto-detect: port the full auto-detect logic from install.ps1/install.sh instead of unconditionally enabling shims. Checks VP_NODE_MANAGER env, existing shims, CI/devcontainer, system node availability — and prompts interactively when system node exists (matching install.ps1 behavior). Silent mode (-y) skips the prompt and does not enable shims when system node is present. 2. Same-version repair: when the target version is already installed, skip download/extract/deps but still run all post-activation setup (shims, Node.js manager, PATH, env files). This allows rerunning the installer to repair a broken installation. 3. Rollback protection: include the previous version in protected_versions during cleanup, matching the vp upgrade implementation. Prevents cleanup from deleting the rollback target. 4. Post-activation best-effort: setup_bin_shims, refresh_shims, and modify_path are now wrapped in if-let-Err with warnings instead of propagating errors. After activation (current junction swap), the core install has succeeded — configuration failures should not cause exit code 1. --- Cargo.lock | 1 + crates/vite_installer/Cargo.toml | 1 + crates/vite_installer/src/main.rs | 190 +++++++++++++++++++++--------- 3 files changed, 139 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00596f8c67..6c59cbacb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7474,6 +7474,7 @@ dependencies = [ "vite_path", "vite_setup", "vite_shared", + "which", "winreg", ] diff --git a/crates/vite_installer/Cargo.toml b/crates/vite_installer/Cargo.toml index bcc1df0694..1d92b70576 100644 --- a/crates/vite_installer/Cargo.toml +++ b/crates/vite_installer/Cargo.toml @@ -21,6 +21,7 @@ vite_install = { workspace = true } vite_path = { workspace = true } vite_setup = { workspace = true } vite_shared = { workspace = true } +which = { workspace = true } [target.'cfg(windows)'.dependencies] winreg = { workspace = true } diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 52c26fa457..f64e07b37a 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -108,92 +108,170 @@ async fn do_install( tokio::fs::create_dir_all(install_dir).await?; let current_version = install::read_current_version(install_dir).await; - if let Some(ref current) = current_version { - if current == &resolved.version { + let same_version = current_version.as_deref() == Some(resolved.version.as_str()); + + if same_version { + if !opts.quiet { + print_info(&format!( + "version {} already installed, verifying setup...", + resolved.version + )); + } + } else { + if let Some(ref current) = current_version { if !opts.quiet { - println!("\n{} Already installed ({})", "\u{2714}".green(), resolved.version); + print_info(&format!("upgrading from {current} to {}", resolved.version)); } - return Ok(()); } + if !opts.quiet { - print_info(&format!("upgrading from {current} to {}", resolved.version)); + print_info(&format!( + "downloading vite-plus@{} for {}...", + resolved.version, platform_suffix + )); } - } + let client = HttpClient::new(); + let platform_data = + download_with_progress(&client, &resolved.platform_tarball_url, opts.quiet).await?; - if !opts.quiet { - print_info(&format!( - "downloading vite-plus@{} for {}...", - resolved.version, platform_suffix - )); - } - let client = HttpClient::new(); - let platform_data = - download_with_progress(&client, &resolved.platform_tarball_url, opts.quiet).await?; + if !opts.quiet { + print_info("verifying integrity..."); + } + integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; - if !opts.quiet { - print_info("verifying integrity..."); - } - integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; + let version_dir = install_dir.join(&resolved.version); + tokio::fs::create_dir_all(&version_dir).await?; + + if !opts.quiet { + print_info("extracting binary..."); + } + install::extract_platform_package(&platform_data, &version_dir).await?; - let version_dir = install_dir.join(&resolved.version); - tokio::fs::create_dir_all(&version_dir).await?; + let binary_path = version_dir.join("bin").join(VP_BINARY_NAME); + if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + return Err("Binary not found after extraction. The download may be corrupted.".into()); + } - if !opts.quiet { - print_info("extracting binary..."); - } - install::extract_platform_package(&platform_data, &version_dir).await?; + install::generate_wrapper_package_json(&version_dir, &resolved.version).await?; + install::write_release_age_overrides(&version_dir).await?; - let binary_path = version_dir.join("bin").join(VP_BINARY_NAME); - if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { - return Err("Binary not found after extraction. The download may be corrupted.".into()); - } + if !opts.quiet { + print_info("installing dependencies (this may take a moment)..."); + } + install::install_production_deps(&version_dir, opts.registry.as_deref()).await?; - install::generate_wrapper_package_json(&version_dir, &resolved.version).await?; - install::write_release_age_overrides(&version_dir).await?; + let previous_version = if current_version.is_some() { + install::save_previous_version(install_dir).await? + } else { + None + }; + install::swap_current_link(install_dir, &resolved.version).await?; - if !opts.quiet { - print_info("installing dependencies (this may take a moment)..."); + // Cleanup with both new and previous versions protected (matches vp upgrade) + let mut protected = vec![resolved.version.as_str()]; + if let Some(ref prev) = previous_version { + protected.push(prev.as_str()); + } + if let Err(e) = + install::cleanup_old_versions(install_dir, vite_setup::MAX_VERSIONS_KEEP, &protected) + .await + { + tracing::warn!("Old version cleanup failed (non-fatal): {e}"); + } } - install::install_production_deps(&version_dir, opts.registry.as_deref()).await?; - if current_version.is_some() { - install::save_previous_version(install_dir).await?; - } - install::swap_current_link(install_dir, &resolved.version).await?; + // --- Post-activation setup (always runs, even for same-version repair) --- + // All steps below are best-effort after activation: the core install succeeded + // once `current` points at the right version. if !opts.quiet { print_info("setting up shims..."); } - setup_bin_shims(install_dir).await?; + if let Err(e) = setup_bin_shims(install_dir).await { + print_warn(&format!("Shim setup failed (non-fatal): {e}")); + } - if !opts.no_node_manager { + // Node.js manager: match install.ps1/install.sh auto-detect logic + let enable_node_manager = should_enable_node_manager(opts, install_dir); + if enable_node_manager { if !opts.quiet { print_info("setting up Node.js version manager..."); } - install::refresh_shims(install_dir).await?; + if let Err(e) = install::refresh_shims(install_dir).await { + print_warn(&format!("Node.js manager setup failed (non-fatal): {e}")); + } } else { - // When skipping Node.js manager, still create shell env files + // Still create shell env files even without Node.js manager create_env_files(install_dir).await; } - if let Err(e) = install::cleanup_old_versions( - install_dir, - vite_setup::MAX_VERSIONS_KEEP, - &[&resolved.version], - ) - .await - { - tracing::warn!("Old version cleanup failed (non-fatal): {e}"); - } - if !opts.no_modify_path { let bin_dir_str = install_dir.join("bin").as_path().to_string_lossy().to_string(); - modify_path(&bin_dir_str, opts.quiet)?; + if let Err(e) = modify_path(&bin_dir_str, opts.quiet) { + print_warn(&format!("PATH modification failed (non-fatal): {e}")); + } } Ok(()) } +/// Determine whether to enable the Node.js version manager (node/npm/npx shims). +/// +/// Matches the auto-detect logic from install.ps1/install.sh: +/// 1. VP_NODE_MANAGER=yes → enable; VP_NODE_MANAGER=no or --no-node-manager → disable +/// 2. Already managing Node (bin/node.exe exists) → enable (refresh) +/// 3. CI / Codespaces / DevContainer / DevPod → enable +/// 4. No system `node` found → enable +/// 5. Interactive mode with system node → prompt the user +/// 6. Silent mode with system node → disable (don't silently take over) +#[allow(clippy::print_stdout)] +fn should_enable_node_manager(opts: &cli::Options, install_dir: &vite_path::AbsolutePath) -> bool { + if opts.no_node_manager { + return false; + } + + if std::env::var("VP_NODE_MANAGER").ok().is_some_and(|v| v.eq_ignore_ascii_case("yes")) { + return true; + } + + // Already managing Node (shims exist from a previous install) + let node_shim = install_dir.join("bin").join(if cfg!(windows) { "node.exe" } else { "node" }); + if node_shim.as_path().exists() { + return true; + } + + // Auto-enable on CI / devcontainer environments + if std::env::var_os("CI").is_some() + || std::env::var_os("CODESPACES").is_some() + || std::env::var_os("REMOTE_CONTAINERS").is_some() + || std::env::var_os("DEVPOD").is_some() + { + return true; + } + + // Auto-enable if no system node available + if which::which("node").is_err() { + return true; + } + + // System node exists — prompt in interactive mode, skip in silent mode + if opts.yes { + return false; + } + + println!(); + println!(" Would you like Vite+ to manage your Node.js versions?"); + println!( + " It adds {}, {}, and {} shims to ~/.vite-plus/bin/ and automatically uses the right version.", + "node".cyan(), + "npm".cyan(), + "npx".cyan() + ); + println!(" Opt out anytime with {}.", "vp env off".cyan()); + let answer = read_input(" Press Enter to accept (Y/n): "); + answer.is_empty() || answer.eq_ignore_ascii_case("y") +} + /// Windows locks running `.exe` files — rename the old one out of the way before copying. #[cfg(windows)] async fn replace_windows_exe( @@ -446,6 +524,12 @@ fn print_info(msg: &str) { eprintln!("{msg}"); } +#[allow(clippy::print_stderr)] +fn print_warn(msg: &str) { + eprint!("{}", "warn: ".yellow()); + eprintln!("{msg}"); +} + #[allow(clippy::print_stderr)] fn print_error(msg: &str) { eprint!("{}", "error: ".red()); From b0a864d4125b0faf5475f4448ba54ca7b25ae406 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:24:30 +0800 Subject: [PATCH 12/39] refactor(installer): address simplify review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move same-version check before platform package HTTP request: use resolve_version_string (1 HTTP call) first, then skip resolve_platform_package (2nd HTTP call) when version matches. Saves 1 HTTP request for tag matches, both for exact version matches. - Fix else { if let → else if let (clippy collapsible_else_if) - Consolidate VP_NODE_MANAGER handling: both "yes" and "no" now checked in should_enable_node_manager instead of split across cli.rs and main.rs - Make create_env_files return Result and report via print_warn, consistent with other best-effort post-activation steps --- crates/vite_installer/src/cli.rs | 6 +-- crates/vite_installer/src/main.rs | 88 ++++++++++++++++--------------- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/crates/vite_installer/src/cli.rs b/crates/vite_installer/src/cli.rs index 5f9f4ed166..ab922b923d 100644 --- a/crates/vite_installer/src/cli.rs +++ b/crates/vite_installer/src/cli.rs @@ -54,10 +54,8 @@ pub fn parse() -> Options { if opts.registry.is_none() { opts.registry = std::env::var("NPM_CONFIG_REGISTRY").ok(); } - if !opts.no_node_manager { - opts.no_node_manager = - std::env::var("VP_NODE_MANAGER").ok().is_some_and(|v| v.eq_ignore_ascii_case("no")); - } + // VP_NODE_MANAGER env var is handled in should_enable_node_manager() + // to keep both "yes" and "no" logic in one place. // quiet implies yes if opts.quiet { diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index f64e07b37a..02d8eb941c 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -94,41 +94,43 @@ async fn do_install( print_info(&format!("detected platform: {platform_suffix}")); } + // Check local version first to potentially skip HTTP requests + tokio::fs::create_dir_all(install_dir).await?; + let current_version = install::read_current_version(install_dir).await; + let version_or_tag = opts.version.as_deref().unwrap_or(&opts.tag); + + // Resolve the target version — use resolve_version_string first so we can + // skip the platform package fetch if the version is already installed if !opts.quiet { print_info(&format!("resolving version '{version_or_tag}'...")); } - let resolved = - registry::resolve_version(version_or_tag, &platform_suffix, opts.registry.as_deref()) - .await?; - if !opts.quiet { - print_info(&format!("found vite-plus@{}", resolved.version)); - } + let target_version = + registry::resolve_version_string(version_or_tag, opts.registry.as_deref()).await?; - tokio::fs::create_dir_all(install_dir).await?; - - let current_version = install::read_current_version(install_dir).await; - let same_version = current_version.as_deref() == Some(resolved.version.as_str()); + let same_version = current_version.as_deref() == Some(target_version.as_str()); if same_version { if !opts.quiet { - print_info(&format!( - "version {} already installed, verifying setup...", - resolved.version - )); + print_info(&format!("version {target_version} already installed, verifying setup...")); } - } else { - if let Some(ref current) = current_version { - if !opts.quiet { - print_info(&format!("upgrading from {current} to {}", resolved.version)); - } + } else if let Some(ref current) = current_version { + if !opts.quiet { + print_info(&format!("upgrading from {current} to {target_version}")); } + } + + if !same_version { + // Only fetch platform metadata + download when we actually need to install + let resolved = registry::resolve_platform_package( + &target_version, + &platform_suffix, + opts.registry.as_deref(), + ) + .await?; if !opts.quiet { - print_info(&format!( - "downloading vite-plus@{} for {}...", - resolved.version, platform_suffix - )); + print_info(&format!("downloading vite-plus@{target_version} for {platform_suffix}...")); } let client = HttpClient::new(); let platform_data = @@ -139,7 +141,7 @@ async fn do_install( } integrity::verify_integrity(&platform_data, &resolved.platform_integrity)?; - let version_dir = install_dir.join(&resolved.version); + let version_dir = install_dir.join(&target_version); tokio::fs::create_dir_all(&version_dir).await?; if !opts.quiet { @@ -152,7 +154,7 @@ async fn do_install( return Err("Binary not found after extraction. The download may be corrupted.".into()); } - install::generate_wrapper_package_json(&version_dir, &resolved.version).await?; + install::generate_wrapper_package_json(&version_dir, &target_version).await?; install::write_release_age_overrides(&version_dir).await?; if !opts.quiet { @@ -165,10 +167,10 @@ async fn do_install( } else { None }; - install::swap_current_link(install_dir, &resolved.version).await?; + install::swap_current_link(install_dir, &target_version).await?; // Cleanup with both new and previous versions protected (matches vp upgrade) - let mut protected = vec![resolved.version.as_str()]; + let mut protected = vec![target_version.as_str()]; if let Some(ref prev) = previous_version { protected.push(prev.as_str()); } @@ -176,13 +178,13 @@ async fn do_install( install::cleanup_old_versions(install_dir, vite_setup::MAX_VERSIONS_KEEP, &protected) .await { - tracing::warn!("Old version cleanup failed (non-fatal): {e}"); + print_warn(&format!("Old version cleanup failed (non-fatal): {e}")); } } // --- Post-activation setup (always runs, even for same-version repair) --- - // All steps below are best-effort after activation: the core install succeeded - // once `current` points at the right version. + // All steps below are best-effort: the core install succeeded once `current` + // points at the right version. if !opts.quiet { print_info("setting up shims..."); @@ -191,7 +193,6 @@ async fn do_install( print_warn(&format!("Shim setup failed (non-fatal): {e}")); } - // Node.js manager: match install.ps1/install.sh auto-detect logic let enable_node_manager = should_enable_node_manager(opts, install_dir); if enable_node_manager { if !opts.quiet { @@ -201,8 +202,9 @@ async fn do_install( print_warn(&format!("Node.js manager setup failed (non-fatal): {e}")); } } else { - // Still create shell env files even without Node.js manager - create_env_files(install_dir).await; + if let Err(e) = create_env_files(install_dir).await { + print_warn(&format!("Env file creation failed (non-fatal): {e}")); + } } if !opts.no_modify_path { @@ -226,12 +228,14 @@ async fn do_install( /// 6. Silent mode with system node → disable (don't silently take over) #[allow(clippy::print_stdout)] fn should_enable_node_manager(opts: &cli::Options, install_dir: &vite_path::AbsolutePath) -> bool { + // --no-node-manager CLI flag if opts.no_node_manager { return false; } - if std::env::var("VP_NODE_MANAGER").ok().is_some_and(|v| v.eq_ignore_ascii_case("yes")) { - return true; + // VP_NODE_MANAGER env var: "yes" or "no" (both handled here) + if let Ok(val) = std::env::var("VP_NODE_MANAGER") { + return val.eq_ignore_ascii_case("yes"); } // Already managing Node (shims exist from a previous install) @@ -358,21 +362,21 @@ async fn download_with_progress( Ok(data) } -async fn create_env_files(install_dir: &vite_path::AbsolutePath) { +async fn create_env_files( + install_dir: &vite_path::AbsolutePath, +) -> Result<(), Box> { let vp_binary = install_dir.join("current").join("bin").join(VP_BINARY_NAME); if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { - return; + return Ok(()); } - let output = tokio::process::Command::new(vp_binary.as_path()) + tokio::process::Command::new(vp_binary.as_path()) .args(["env", "setup", "--env-only"]) .output() - .await; + .await?; - if let Err(e) = output { - tracing::warn!("Failed to create env files (non-fatal): {e}"); - } + Ok(()) } fn resolve_install_dir(opts: &cli::Options) -> Result> { From 617e63e5bbadc1720093ee82ece184b68200d180 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:40:12 +0800 Subject: [PATCH 13/39] refactor(installer): pre-compute Node.js manager decision before menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all user prompts to the menu phase — do_install is now prompt-free. The auto_detect_node_manager() function is pure logic (no I/O) that resolves the Node.js manager default based on: VP_NODE_MANAGER env, existing shims, CI/devcontainer, system node presence. The result is stored in opts.no_node_manager before showing the interactive menu, so the user sees the resolved value ("enabled"/"disabled") and can toggle it in the customize submenu before installation begins. When system node is present and no other signal overrides, the default is "disabled" — the user can enable it via the customize menu. In silent mode (-y), this means shims are not created unless explicitly requested via VP_NODE_MANAGER=yes or auto-detected (CI, no system node). --- crates/vite_installer/src/main.rs | 59 +++++++++++-------------------- 1 file changed, 21 insertions(+), 38 deletions(-) diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 02d8eb941c..630953f9db 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -64,6 +64,12 @@ async fn run(mut opts: cli::Options) -> i32 { }; let install_dir_display = install_dir.as_path().to_string_lossy().to_string(); + // Pre-compute Node.js manager default before showing the menu, + // so the user sees the resolved value and can override it. + if !opts.no_node_manager { + opts.no_node_manager = !auto_detect_node_manager(&install_dir); + } + if !opts.yes { let proceed = show_interactive_menu(&mut opts, &install_dir_display); if !proceed { @@ -193,8 +199,7 @@ async fn do_install( print_warn(&format!("Shim setup failed (non-fatal): {e}")); } - let enable_node_manager = should_enable_node_manager(opts, install_dir); - if enable_node_manager { + if !opts.no_node_manager { if !opts.quiet { print_info("setting up Node.js version manager..."); } @@ -217,23 +222,19 @@ async fn do_install( Ok(()) } -/// Determine whether to enable the Node.js version manager (node/npm/npx shims). +/// Auto-detect whether the Node.js version manager should be enabled. /// -/// Matches the auto-detect logic from install.ps1/install.sh: -/// 1. VP_NODE_MANAGER=yes → enable; VP_NODE_MANAGER=no or --no-node-manager → disable +/// Pure logic — no user prompts. Called once before the interactive menu +/// so the user sees the resolved default and can override it. +/// +/// Matches install.ps1/install.sh auto-detect logic: +/// 1. VP_NODE_MANAGER=yes → enable; VP_NODE_MANAGER=no → disable /// 2. Already managing Node (bin/node.exe exists) → enable (refresh) /// 3. CI / Codespaces / DevContainer / DevPod → enable /// 4. No system `node` found → enable -/// 5. Interactive mode with system node → prompt the user -/// 6. Silent mode with system node → disable (don't silently take over) -#[allow(clippy::print_stdout)] -fn should_enable_node_manager(opts: &cli::Options, install_dir: &vite_path::AbsolutePath) -> bool { - // --no-node-manager CLI flag - if opts.no_node_manager { - return false; - } - - // VP_NODE_MANAGER env var: "yes" or "no" (both handled here) +/// 5. System node present → disable (user can enable via customize menu) +fn auto_detect_node_manager(install_dir: &vite_path::AbsolutePath) -> bool { + // VP_NODE_MANAGER env var: "yes" or "no" if let Ok(val) = std::env::var("VP_NODE_MANAGER") { return val.eq_ignore_ascii_case("yes"); } @@ -253,27 +254,9 @@ fn should_enable_node_manager(opts: &cli::Options, install_dir: &vite_path::Abso return true; } - // Auto-enable if no system node available - if which::which("node").is_err() { - return true; - } - - // System node exists — prompt in interactive mode, skip in silent mode - if opts.yes { - return false; - } - - println!(); - println!(" Would you like Vite+ to manage your Node.js versions?"); - println!( - " It adds {}, {}, and {} shims to ~/.vite-plus/bin/ and automatically uses the right version.", - "node".cyan(), - "npm".cyan(), - "npx".cyan() - ); - println!(" Opt out anytime with {}.", "vp env off".cyan()); - let answer = read_input(" Press Enter to accept (Y/n): "); - answer.is_empty() || answer.eq_ignore_ascii_case("y") + // Auto-enable if no system node available; otherwise default to disabled + // (the interactive menu lets users enable it before proceeding) + which::which("node").is_err() } /// Windows locks running `.exe` files — rename the old one out of the way before copying. @@ -433,7 +416,7 @@ fn show_interactive_menu(opts: &mut cli::Options, install_dir: &str) -> bool { println!(" Version: {}", version.cyan()); println!( " Node.js manager: {}", - if opts.no_node_manager { "disabled" } else { "auto-detect" }.cyan() + if opts.no_node_manager { "disabled" } else { "enabled" }.cyan() ); println!(); println!(" 1) {} (default)", "Proceed with installation".bold()); @@ -466,7 +449,7 @@ fn show_customize_menu(opts: &mut cli::Options) { println!(" 2) npm registry: [{}]", registry_display.cyan()); println!( " 3) Node.js manager: [{}]", - if opts.no_node_manager { "disabled" } else { "auto-detect" }.cyan() + if opts.no_node_manager { "disabled" } else { "enabled" }.cyan() ); println!( " 4) Modify PATH: [{}]", From bdf01e297b40e562be47631803b220ed15db1749 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:48:42 +0800 Subject: [PATCH 14/39] fix(installer): match install.ps1 Node.js manager default for interactive mode When system node is present: - Interactive mode: default to enabled (matching install.ps1's Y/n prompt where Enter = yes). User can disable via customize menu. - Silent mode (-y): default to disabled (don't silently take over). This matches install.ps1 behavior where most interactive users who press Enter get node management enabled by default. --- crates/vite_installer/src/main.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 630953f9db..c6091806b5 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -67,7 +67,7 @@ async fn run(mut opts: cli::Options) -> i32 { // Pre-compute Node.js manager default before showing the menu, // so the user sees the resolved value and can override it. if !opts.no_node_manager { - opts.no_node_manager = !auto_detect_node_manager(&install_dir); + opts.no_node_manager = !auto_detect_node_manager(&install_dir, !opts.yes); } if !opts.yes { @@ -232,8 +232,10 @@ async fn do_install( /// 2. Already managing Node (bin/node.exe exists) → enable (refresh) /// 3. CI / Codespaces / DevContainer / DevPod → enable /// 4. No system `node` found → enable -/// 5. System node present → disable (user can enable via customize menu) -fn auto_detect_node_manager(install_dir: &vite_path::AbsolutePath) -> bool { +/// 5. System node present, interactive → enable (matching install.ps1's default-Y prompt; +/// user can disable via customize menu before proceeding) +/// 6. System node present, silent → disable (don't silently take over) +fn auto_detect_node_manager(install_dir: &vite_path::AbsolutePath, interactive: bool) -> bool { // VP_NODE_MANAGER env var: "yes" or "no" if let Ok(val) = std::env::var("VP_NODE_MANAGER") { return val.eq_ignore_ascii_case("yes"); @@ -254,9 +256,15 @@ fn auto_detect_node_manager(install_dir: &vite_path::AbsolutePath) -> bool { return true; } - // Auto-enable if no system node available; otherwise default to disabled - // (the interactive menu lets users enable it before proceeding) - which::which("node").is_err() + // Auto-enable if no system node available + if which::which("node").is_err() { + return true; + } + + // System node exists: in interactive mode, default to enabled (matching + // install.ps1's Y/n prompt where Enter = yes). The user can disable it + // in the customize menu. In silent mode, don't take over. + interactive } /// Windows locks running `.exe` files — rename the old one out of the way before copying. From 54e370170575aee50855e8a3ef0e5c6f047c07c0 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:57:19 +0800 Subject: [PATCH 15/39] fix(ci): fix test-vp-setup-exe workflow syntax error Replace dynamic matrix.shell in shell: field with explicit shell values per step. The matrix.shell expression was not recognized by the GitHub Actions YAML parser when used in the shell: context. Use a single job that verifies installation from pwsh, cmd, and bash sequentially. --- .github/workflows/test-standalone-install.yml | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index dcd0d15f47..3f615192f3 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -630,14 +630,10 @@ jobs: which vp test-vp-setup-exe: - name: Test vp-setup.exe (${{ matrix.shell }}) + name: Test vp-setup.exe (pwsh) runs-on: windows-latest permissions: contents: read - strategy: - fail-fast: false - matrix: - shell: [cmd, pwsh, bash] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: oxc-project/setup-rust@23f38cfb0c04af97a055f76acee94d5be71c7c82 # v1.0.16 @@ -647,7 +643,7 @@ jobs: run: cargo build --release -p vite_installer - name: Install via vp-setup.exe (silent) - shell: ${{ matrix.shell }} + shell: pwsh run: ./target/release/vp-setup.exe -y env: VP_VERSION: alpha @@ -657,7 +653,19 @@ jobs: run: echo "$USERPROFILE/.vite-plus/bin" >> $GITHUB_PATH - name: Verify installation - shell: ${{ matrix.shell }} + shell: pwsh + run: | + vp --version + vp --help + + - name: Verify installation (cmd) + shell: cmd + run: | + vp --version + vp --help + + - name: Verify installation (bash) + shell: bash run: | vp --version vp --help From 530c474c52f7a4e4ab66e7aa126fe7452e139314 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:01:49 +0800 Subject: [PATCH 16/39] docs(rfc): sync RFC with latest implementation changes - Update interactive menu examples to show "enabled"/"disabled" instead of "auto-detect" (value is now pre-computed) - Add Node.js Manager Auto-Detection section with priority table documenting the full auto-detect logic matching install.ps1/install.sh - Restructure Installation Flow diagram: local version check before HTTP, split resolve_version into resolve_version_string + resolve_platform_package, same-version repair path skips to CONFIGURE, cleanup moved to ACTIVATE - Update Existing Installation Handling: same version now repairs instead of exiting early - Document best-effort post-activation behavior and failure recovery - Update function mapping table with split registry functions - Update Test Workflow snippet to match actual single-job structure - Update Phase 2 description (winreg, pre-computed node manager, repair) --- rfcs/windows-installer.md | 117 ++++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 44 deletions(-) diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index 8ca5565e9d..cdf1de4c8f 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -137,18 +137,20 @@ Welcome to Vite+ Installer! This will install the vp CLI and monorepo task runner. - Install directory: C:\Users\alice\.vite-plus - PATH modification: C:\Users\alice\.vite-plus\bin → User PATH - Version: latest - Node.js manager: auto-detect + Install directory: C:\Users\alice\.vite-plus + PATH modification: C:\Users\alice\.vite-plus\bin → User PATH + Version: latest + Node.js manager: enabled -1) Proceed with installation (default) -2) Customize installation -3) Cancel + 1) Proceed with installation (default) + 2) Customize installation + 3) Cancel -> + > ``` +The Node.js manager value is pre-computed via auto-detection before the menu is shown (see [Node.js Manager Auto-Detection](#nodejs-manager-auto-detection)). The user can override it in the customize submenu before proceeding. + Customization submenu: ``` @@ -156,11 +158,11 @@ Customization submenu: 1) Version: [latest] 2) npm registry: [(default)] - 3) Node.js manager: [auto-detect] + 3) Node.js manager: [enabled] 4) Modify PATH: [yes] Enter option number to change, or press Enter to go back: -> + > ``` ### Silent Mode (CI) @@ -208,18 +210,24 @@ The installer replicates the same result as `install.ps1`, implemented in Rust v │ ┌─ detect platform ──────── win32-x64-msvc │ │ │ win32-arm64-msvc │ │ │ │ -│ ├─ resolve version ──────── query npm registry │ -│ │ "latest" → "0.3.0" │ +│ ├─ check existing ──────── read %VP_HOME%\current │ │ │ │ -│ └─ check existing ──────── read %VP_HOME%\current │ -│ same version? → exit early │ +│ └─ resolve version ──────── resolve_version_string() │ +│ 1 HTTP call: "latest" → "0.3.0" │ +│ same version? → skip to │ +│ CONFIGURE (repair path) │ └─────────────────────────────────────────────────────────────┘ │ + (only if version differs) + │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ DOWNLOAD & VERIFY │ │ │ -│ ┌─ download tarball ─────── GET tarball URL from registry │ +│ ┌─ resolve platform pkg ── resolve_platform_package() │ +│ │ 2nd HTTP call: tarball URL + SRI │ +│ │ │ +│ ├─ download tarball ─────── GET tarball URL from registry │ │ │ progress spinner via indicatif │ │ │ │ │ └─ verify integrity ─────── SHA-512 SRI hash comparison │ @@ -248,26 +256,27 @@ The installer replicates the same result as `install.ps1`, implemented in Rust v │ ┌─ save previous version ── .previous-version (rollback) │ │ │ (only if upgrading existing) │ │ │ │ -│ └─ swap current ────────── mklink /J current → {version} │ -│ (junction on Windows, │ -│ atomic symlink on Unix) │ +│ ├─ swap current ────────── mklink /J current → {version} │ +│ │ (junction on Windows, │ +│ │ atomic symlink on Unix) │ +│ │ │ +│ └─ cleanup old versions ── keep last 5 by creation time │ +│ protects new + previous version │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ -│ CONFIGURE │ +│ CONFIGURE (best-effort, always runs, │ +│ even for same-version repair) │ │ │ │ ┌─ create bin shims ────── copy vp-shim.exe → bin\vp.exe │ │ │ (rename-to-.old if running) │ │ │ │ -│ ├─ Node.js manager ────── if enabled: │ +│ ├─ Node.js manager ────── if enabled (pre-computed): │ │ │ spawn: vp env setup --refresh │ │ │ if disabled: │ │ │ spawn: vp env setup --env-only │ │ │ │ -│ ├─ cleanup old versions ── keep last 5 by creation time │ -│ │ (non-fatal on error) │ -│ │ │ │ └─ modify User PATH ────── if --no-modify-path not set: │ │ HKCU\Environment\Path │ │ prepend %VP_HOME%\bin │ @@ -283,8 +292,9 @@ Each phase maps to `vite_setup` library functions shared with `vp upgrade`: | Phase | Key function | Crate | | ----------------- | ------------------------------------------ | ---------------- | | Resolve | `platform::detect_platform_suffix()` | `vite_setup` | -| Resolve | `registry::resolve_version()` | `vite_setup` | | Resolve | `install::read_current_version()` | `vite_setup` | +| Resolve | `registry::resolve_version_string()` | `vite_setup` | +| Download & Verify | `registry::resolve_platform_package()` | `vite_setup` | | Download & Verify | `HttpClient::get_bytes()` | `vite_install` | | Download & Verify | `integrity::verify_integrity()` | `vite_setup` | | Install | `install::extract_platform_package()` | `vite_setup` | @@ -293,11 +303,32 @@ Each phase maps to `vite_setup` library functions shared with `vp upgrade`: | Install | `install::install_production_deps()` | `vite_setup` | | Activate | `install::save_previous_version()` | `vite_setup` | | Activate | `install::swap_current_link()` | `vite_setup` | +| Activate | `install::cleanup_old_versions()` | `vite_setup` | | Configure | `install::refresh_shims()` | `vite_setup` | -| Configure | `install::cleanup_old_versions()` | `vite_setup` | | Configure | `windows_path::add_to_user_path()` | `vite_installer` | -On failure before the **Activate** phase, the version directory is cleaned up and the existing installation remains untouched. After the **Activate** phase (junction swap), the update has already succeeded — subsequent configure steps are best-effort (non-fatal on error). +**Same-version repair**: When the resolved version matches the installed version, the DOWNLOAD/INSTALL/ACTIVATE phases are skipped entirely (saving 1 HTTP request + all I/O). The CONFIGURE phase always runs to repair shims, env files, and PATH if needed. + +**Failure recovery**: Before the **Activate** phase, failures clean up the version directory and leave the existing installation untouched. After **Activate**, all CONFIGURE steps are best-effort — failures log a warning but do not cause exit code 1. Rerunning the installer always retries CONFIGURE. + +## Node.js Manager Auto-Detection + +The Node.js manager decision (`enabled`/`disabled`) is pre-computed before the interactive menu is shown, so the user sees the resolved value and can override it via the customize submenu. No prompts occur during the installation phase. + +The auto-detection logic matches `install.ps1`/`install.sh`: + +| Priority | Condition | Result | +| -------- | ----------------------------------------- | -------- | +| 1 | `--no-node-manager` CLI flag | disabled | +| 2 | `VP_NODE_MANAGER=yes` | enabled | +| 3 | `VP_NODE_MANAGER=no` | disabled | +| 4 | `bin/node.exe` shim already exists | enabled | +| 5 | CI / Codespaces / DevContainer / DevPod | enabled | +| 6 | No system `node` found | enabled | +| 7 | System `node` present, interactive mode | enabled | +| 8 | System `node` present, silent mode (`-y`) | disabled | + +In interactive mode (rules 7), the default matches `install.ps1`'s Y/n prompt where pressing Enter enables it. The user can disable it in the customize menu before installation begins. In silent mode (rule 8), shims are not created unless explicitly requested, avoiding silently taking over an existing Node toolchain. ## Windows-Specific Details @@ -342,13 +373,13 @@ The binary uses the console subsystem (default for Rust binaries on Windows). Wh ### Existing Installation Handling -| Scenario | Behavior | -| ----------------------------------------- | ------------------------------------------------------- | -| No existing install | Fresh install | -| Same version installed | Print "already up to date", exit 0 | -| Different version installed | Upgrade to target version | -| Corrupt/partial install (broken junction) | Recreate directory structure | -| Running `vp.exe` in bin/ | Rename to `.old`, copy new (same as trampoline pattern) | +| Scenario | Behavior | +| ----------------------------------------- | ------------------------------------------------------------ | +| No existing install | Fresh install | +| Same version installed | Skip download, rerun CONFIGURE phase (repair shims/PATH/env) | +| Different version installed | Upgrade to target version | +| Corrupt/partial install (broken junction) | Recreate directory structure | +| Running `vp.exe` in bin/ | Rename to `.old`, copy new (same as trampoline pattern) | ## Add/Remove Programs Registration @@ -416,28 +447,24 @@ In `release.yml`, installer artifacts are uploaded per-target, renamed with the ### Test Workflow -`test-standalone-install.yml` includes a `test-vp-setup-exe` job that builds the installer from source and tests silent installation across three shells: +`test-standalone-install.yml` includes a `test-vp-setup-exe` job that builds the installer from source, installs via pwsh, and verifies from all three shells (pwsh, cmd, bash): ```yaml test-vp-setup-exe: - name: Test vp-setup.exe (${{ matrix.shell }}) + name: Test vp-setup.exe (pwsh) runs-on: windows-latest - strategy: - matrix: - shell: [cmd, pwsh, bash] steps: - uses: actions/checkout@v4 - uses: oxc-project/setup-rust@v1 - name: Build vp-setup.exe run: cargo build --release -p vite_installer - name: Install via vp-setup.exe (silent) + shell: pwsh run: ./target/release/vp-setup.exe -y env: VP_VERSION: alpha - - name: Verify installation - run: | - vp --version - vp --help + - name: Verify installation (pwsh/cmd/bash) + # verifies from all three shells after a single install ``` The workflow triggers on changes to `crates/vite_installer/**` and `crates/vite_setup/**`. @@ -513,11 +540,13 @@ Embed the PowerShell script in a self-extracting exe. Fragile, still requires Po - Created `crates/vite_installer/` with `[[bin]] name = "vp-setup"` - Implemented CLI argument parsing (clap) with env var merging -- Implemented installation flow calling `vite_setup` -- Implemented Windows PATH modification via raw Win32 FFI +- Implemented installation flow calling `vite_setup` with same-version repair path +- Implemented Windows PATH modification via `winreg` crate - Implemented interactive prompts with customization submenu +- Implemented Node.js manager auto-detection (pre-computed, no mid-install prompts) - Implemented progress spinner for downloads - Added DLL security mitigations (build.rs linker flag + runtime `SetDefaultDllDirectories`) +- Post-activation steps are best-effort (non-fatal on error) ### Phase 3: CI Integration (done) From 3000abd7c12230acbea23fa526ff7ab661abfab0 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:05:19 +0800 Subject: [PATCH 17/39] refactor(setup): move create_env_files from vite_installer to vite_setup This function follows the same pattern as refresh_shims (spawn vp with different args) and belongs in the shared library alongside it. --- crates/vite_installer/src/main.rs | 19 +------------------ crates/vite_setup/src/install.rs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index c6091806b5..0f65124676 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -207,7 +207,7 @@ async fn do_install( print_warn(&format!("Node.js manager setup failed (non-fatal): {e}")); } } else { - if let Err(e) = create_env_files(install_dir).await { + if let Err(e) = install::create_env_files(install_dir).await { print_warn(&format!("Env file creation failed (non-fatal): {e}")); } } @@ -353,23 +353,6 @@ async fn download_with_progress( Ok(data) } -async fn create_env_files( - install_dir: &vite_path::AbsolutePath, -) -> Result<(), Box> { - let vp_binary = install_dir.join("current").join("bin").join(VP_BINARY_NAME); - - if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { - return Ok(()); - } - - tokio::process::Command::new(vp_binary.as_path()) - .args(["env", "setup", "--env-only"]) - .output() - .await?; - - Ok(()) -} - fn resolve_install_dir(opts: &cli::Options) -> Result> { if let Some(ref dir) = opts.install_dir { let path = std::path::PathBuf::from(dir); diff --git a/crates/vite_setup/src/install.rs b/crates/vite_setup/src/install.rs index 20b07f7a32..9a18136479 100644 --- a/crates/vite_setup/src/install.rs +++ b/crates/vite_setup/src/install.rs @@ -319,6 +319,26 @@ pub async fn refresh_shims(install_dir: &AbsolutePath) -> Result<(), Error> { Ok(()) } +/// Create shell env files by running `vp env setup --env-only`. +/// +/// Used when the Node.js manager is disabled — ensures env files exist +/// even without a full shim refresh. +pub async fn create_env_files(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) { + return Ok(()); + } + + tokio::process::Command::new(vp_binary.as_path()) + .args(["env", "setup", "--env-only"]) + .output() + .await?; + + Ok(()) +} + /// Clean up old version directories, keeping at most `max_keep` versions. /// /// Sorts by creation time (newest first, matching install.sh behavior) and removes From b4ab395fe9f3b2ddfa4ac036b1f289cedef7d286 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:06:32 +0800 Subject: [PATCH 18/39] fix(build): remove unused tracing dependency from vite_installer --- Cargo.lock | 1 - crates/vite_installer/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c59cbacb3..6873071dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7469,7 +7469,6 @@ dependencies = [ "indicatif", "owo-colors", "tokio", - "tracing", "vite_install", "vite_path", "vite_setup", diff --git a/crates/vite_installer/Cargo.toml b/crates/vite_installer/Cargo.toml index 1d92b70576..d26e973b22 100644 --- a/crates/vite_installer/Cargo.toml +++ b/crates/vite_installer/Cargo.toml @@ -16,7 +16,6 @@ clap = { workspace = true, features = ["derive"] } indicatif = { workspace = true } owo-colors = { workspace = true } tokio = { workspace = true, features = ["full"] } -tracing = { workspace = true } vite_install = { workspace = true } vite_path = { workspace = true } vite_setup = { workspace = true } From 332ca31ece97434a13cd7bd8c197406fc112c3ec Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:09:33 +0800 Subject: [PATCH 19/39] fix(ci): add clone step to test-vp-setup-exe for workspace resolution --- .github/workflows/test-standalone-install.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 3f615192f3..b1acd576ab 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -636,6 +636,7 @@ jobs: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/clone - uses: oxc-project/setup-rust@23f38cfb0c04af97a055f76acee94d5be71c7c82 # v1.0.16 - name: Build vp-setup.exe From a518d50a4b70e4209a340b4bdba1eddb9d466abf Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:14:17 +0800 Subject: [PATCH 20/39] fix(build): fix rustfmt blank line in upgrade_check.rs imports --- crates/vite_global_cli/src/upgrade_check.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index 0a3daddb50..d5fd005279 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -11,7 +11,6 @@ use std::{ use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; - use vite_setup::registry; const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; From 33a45783bffa37403b959a1c3c664ea39a052d04 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:17:52 +0800 Subject: [PATCH 21/39] perf(ci): add Dev Drive setup to test-vp-setup-exe for faster Windows builds Use setup-dev-drive with ReFS (matching ci.yml) to speed up cargo build. Windows Defender skips ReFS dev drives, which significantly reduces build times on GitHub Actions Windows runners. --- .github/workflows/test-standalone-install.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index b1acd576ab..8a6fc27266 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -637,7 +637,19 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/clone + + - name: Setup Dev Drive + uses: samypr100/setup-dev-drive@30f0f98ae5636b2b6501e181dfb3631b9974818d # v4.0.0 + with: + drive-size: 12GB + drive-format: ReFS + env-mapping: | + CARGO_HOME,{{ DEV_DRIVE }}/.cargo + RUSTUP_HOME,{{ DEV_DRIVE }}/.rustup + - uses: oxc-project/setup-rust@23f38cfb0c04af97a055f76acee94d5be71c7c82 # v1.0.16 + with: + target-dir: ${{ format('{0}/target', env.DEV_DRIVE) }} - name: Build vp-setup.exe shell: bash @@ -645,7 +657,7 @@ jobs: - name: Install via vp-setup.exe (silent) shell: pwsh - run: ./target/release/vp-setup.exe -y + run: ${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }} -y env: VP_VERSION: alpha From 26c3671135fb8bc4e12a717c3ef561af9464dee6 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:20:20 +0800 Subject: [PATCH 22/39] fix(installer): update documentation URL to viteplus.dev/guide/ --- crates/vite_installer/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 0f65124676..ed105b888a 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -492,7 +492,7 @@ fn print_success(opts: &cli::Options, install_dir: &str) { println!(" {}", "vp --help".cyan()); println!(); println!(" Install directory: {install_dir}"); - println!(" Documentation: {}", "https://github.com/voidzero-dev/vite-plus"); + println!(" Documentation: {}", "https://viteplus.dev/guide/"); println!(); } From ba39ecb039bce9e5e7c6ff9e15c57745789211f9 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:24:44 +0800 Subject: [PATCH 23/39] fix(installer): update outdated comment to reference auto_detect_node_manager --- crates/vite_installer/src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_installer/src/cli.rs b/crates/vite_installer/src/cli.rs index ab922b923d..10afab2d70 100644 --- a/crates/vite_installer/src/cli.rs +++ b/crates/vite_installer/src/cli.rs @@ -54,7 +54,7 @@ pub fn parse() -> Options { if opts.registry.is_none() { opts.registry = std::env::var("NPM_CONFIG_REGISTRY").ok(); } - // VP_NODE_MANAGER env var is handled in should_enable_node_manager() + // VP_NODE_MANAGER env var is handled in auto_detect_node_manager() // to keep both "yes" and "no" logic in one place. // quiet implies yes From 19c3e243c67e1f6b537a922191e3670fbd5611e3 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:25:24 +0800 Subject: [PATCH 24/39] chore: remove unnecessary comment in cli.rs --- crates/vite_installer/src/cli.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/vite_installer/src/cli.rs b/crates/vite_installer/src/cli.rs index 10afab2d70..8321c90f9c 100644 --- a/crates/vite_installer/src/cli.rs +++ b/crates/vite_installer/src/cli.rs @@ -54,9 +54,6 @@ pub fn parse() -> Options { if opts.registry.is_none() { opts.registry = std::env::var("NPM_CONFIG_REGISTRY").ok(); } - // VP_NODE_MANAGER env var is handled in auto_detect_node_manager() - // to keep both "yes" and "no" logic in one place. - // quiet implies yes if opts.quiet { opts.yes = true; From e415d43140d7d89c971accf732f44cbbd7e34b8a Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:28:23 +0800 Subject: [PATCH 25/39] feat(installer): auto-detect CI environment to skip interactive prompts --- crates/vite_installer/src/cli.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vite_installer/src/cli.rs b/crates/vite_installer/src/cli.rs index 8321c90f9c..61f7e343f7 100644 --- a/crates/vite_installer/src/cli.rs +++ b/crates/vite_installer/src/cli.rs @@ -54,8 +54,8 @@ pub fn parse() -> Options { if opts.registry.is_none() { opts.registry = std::env::var("NPM_CONFIG_REGISTRY").ok(); } - // quiet implies yes - if opts.quiet { + // CI and quiet both imply non-interactive (no prompts) + if opts.quiet || std::env::var_os("CI").is_some() { opts.yes = true; } From 61d53a00924a854accdc436426a2784cdc254b58 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:29:38 +0800 Subject: [PATCH 26/39] chore: remove unnecessary -y flag from CI (auto-detected) --- .github/workflows/test-standalone-install.yml | 2 +- rfcs/windows-installer.md | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 8a6fc27266..014dcf2a50 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -657,7 +657,7 @@ jobs: - name: Install via vp-setup.exe (silent) shell: pwsh - run: ${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }} -y + run: ${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }} env: VP_VERSION: alpha diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index cdf1de4c8f..b1889ed4fb 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -167,12 +167,17 @@ Customization submenu: ### Silent Mode (CI) +The installer auto-detects CI environments (`CI=true`) and skips interactive prompts, so `-y` is not required in CI: + ```bash -# Accept all defaults +# CI environments are automatically non-interactive +vp-setup.exe + +# Explicit silent mode (outside CI) vp-setup.exe -y # Customize -vp-setup.exe -y --version 0.3.0 --no-node-manager --registry https://registry.npmmirror.com +vp-setup.exe --version 0.3.0 --no-node-manager --registry https://registry.npmmirror.com ``` ### CLI Flags @@ -460,7 +465,7 @@ test-vp-setup-exe: run: cargo build --release -p vite_installer - name: Install via vp-setup.exe (silent) shell: pwsh - run: ./target/release/vp-setup.exe -y + run: ./target/release/vp-setup.exe env: VP_VERSION: alpha - name: Verify installation (pwsh/cmd/bash) From 75433ab9e398b31754d2c9571566bdba561f384c Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:32:36 +0800 Subject: [PATCH 27/39] fix(ci): add flate to typos allowlist (flate2 crate name) --- .typos.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.typos.toml b/.typos.toml index 323db14e20..996242eb1f 100644 --- a/.typos.toml +++ b/.typos.toml @@ -2,6 +2,7 @@ ratatui = "ratatui" PUNICODE = "PUNICODE" Jod = "Jod" # Node.js v22 LTS codename +flate = "flate" # flate2 crate name (gzip/deflate compression) [files] extend-exclude = [ From aa5c3063c4c0de702163305c1d3860dc89ff9704 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:39:37 +0800 Subject: [PATCH 28/39] docs(rfc): clarify minimum Windows version with release date --- rfcs/windows-installer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index b1889ed4fb..e2e4971b5c 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -594,7 +594,7 @@ Embed the PowerShell script in a self-extracting exe. Fragile, still requires Po - **Binary name**: `vp-setup.exe` - **Uninstall**: Rely on `vp implode` — no `--uninstall` flag in the installer -- **Minimum Windows version**: Windows 10 1809+ (same as Rust's MSVC target) +- **Minimum Windows version**: Windows 10 version 1809 (October 2018 Update) or later, same as Rust's `x86_64-pc-windows-msvc` target requirement ## References From d080c559ad2a80dd97c5b9895b059a1628ac7cd1 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:42:38 +0800 Subject: [PATCH 29/39] docs(rfc): add link to Rust platform support page for Windows target requirement --- rfcs/windows-installer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md index e2e4971b5c..f3c97023d5 100644 --- a/rfcs/windows-installer.md +++ b/rfcs/windows-installer.md @@ -594,7 +594,7 @@ Embed the PowerShell script in a self-extracting exe. Fragile, still requires Po - **Binary name**: `vp-setup.exe` - **Uninstall**: Rely on `vp implode` — no `--uninstall` flag in the installer -- **Minimum Windows version**: Windows 10 version 1809 (October 2018 Update) or later, same as Rust's `x86_64-pc-windows-msvc` target requirement +- **Minimum Windows version**: Windows 10 version 1809 (October 2018 Update) or later, same as [Rust's `x86_64-pc-windows-msvc` target requirement](https://doc.rust-lang.org/rustc/platform-support.html) ## References From 356be0dfae1999784a19b1601925c5d5f9e1fd09 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:50:45 +0800 Subject: [PATCH 30/39] docs: add vp-setup.exe as Windows installation option --- docs/guide/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guide/index.md b/docs/guide/index.md index 6a066f216b..83d72fe916 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -18,6 +18,8 @@ curl -fsSL https://vite.plus | bash irm https://vite.plus/ps1 | iex ``` +Or download and run `vp-setup.exe` from the [latest release](https://github.com/voidzero-dev/vite-plus/releases/latest) — works from cmd.exe, PowerShell, Git Bash, or double-click. No PowerShell required. + After installation, open a new shell and run: ```bash From 05d5b1ca48342cfa94912ed60db81cbb9a0eabc1 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:56:46 +0800 Subject: [PATCH 31/39] docs: improve vp-setup.exe installation wording --- docs/guide/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/index.md b/docs/guide/index.md index 83d72fe916..ae0f354185 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -18,7 +18,7 @@ curl -fsSL https://vite.plus | bash irm https://vite.plus/ps1 | iex ``` -Or download and run `vp-setup.exe` from the [latest release](https://github.com/voidzero-dev/vite-plus/releases/latest) — works from cmd.exe, PowerShell, Git Bash, or double-click. No PowerShell required. +Alternatively, download [`vp-setup.exe`](https://github.com/voidzero-dev/vite-plus/releases/latest) from the latest GitHub release and run it. It works from any shell (cmd.exe, PowerShell, Git Bash) or by double-clicking — no PowerShell required. After installation, open a new shell and run: From f058f863f3bd3c95ba3eed1e06ab5fff364a533f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:58:53 +0800 Subject: [PATCH 32/39] docs: tweak vp-setup.exe wording --- docs/guide/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/index.md b/docs/guide/index.md index ae0f354185..e2b749e67a 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -18,7 +18,7 @@ curl -fsSL https://vite.plus | bash irm https://vite.plus/ps1 | iex ``` -Alternatively, download [`vp-setup.exe`](https://github.com/voidzero-dev/vite-plus/releases/latest) from the latest GitHub release and run it. It works from any shell (cmd.exe, PowerShell, Git Bash) or by double-clicking — no PowerShell required. +Alternatively, download and run [`vp-setup.exe`](https://github.com/voidzero-dev/vite-plus/releases/latest) from the latest release. It works from any shell (cmd.exe, PowerShell, Git Bash) or by double-clicking — no PowerShell required. After installation, open a new shell and run: From 8e5e87bbc923dab830c0c2ac64171e2e0e9fd118 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 00:01:00 +0800 Subject: [PATCH 33/39] docs: simplify vp-setup.exe installation line --- docs/guide/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/index.md b/docs/guide/index.md index e2b749e67a..829891b873 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -18,7 +18,7 @@ curl -fsSL https://vite.plus | bash irm https://vite.plus/ps1 | iex ``` -Alternatively, download and run [`vp-setup.exe`](https://github.com/voidzero-dev/vite-plus/releases/latest) from the latest release. It works from any shell (cmd.exe, PowerShell, Git Bash) or by double-clicking — no PowerShell required. +Alternatively, download and run [`vp-setup.exe`](https://github.com/voidzero-dev/vite-plus/releases/latest) from the latest release. After installation, open a new shell and run: From deef4072ed1a458ab6c07438941443fbbbfe9702 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 00:01:37 +0800 Subject: [PATCH 34/39] docs: move link from vp-setup.exe to 'latest release' --- docs/guide/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/index.md b/docs/guide/index.md index 829891b873..77833e06fa 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -18,7 +18,7 @@ curl -fsSL https://vite.plus | bash irm https://vite.plus/ps1 | iex ``` -Alternatively, download and run [`vp-setup.exe`](https://github.com/voidzero-dev/vite-plus/releases/latest) from the latest release. +Alternatively, download and run `vp-setup.exe` from the [latest release](https://github.com/voidzero-dev/vite-plus/releases/latest). After installation, open a new shell and run: From ec79df1dd70e5b4a53ad97ce2c568bc3b7185523 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 00:08:02 +0800 Subject: [PATCH 35/39] refactor: extract VP_BINARY_NAME const and fix review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract VP_BINARY_NAME const to vite_setup::lib.rs, replacing 5 inline cfg!(windows) expressions across vite_setup, vite_installer, and vite_global_cli - Fix create_env_files to check exit code and log warning on failure, matching refresh_shims behavior - Fix else { if let → else if let (clippy collapsible_else_if) --- .../src/commands/upgrade/mod.rs | 2 +- crates/vite_installer/src/main.rs | 10 +++------- crates/vite_setup/src/install.rs | 19 +++++++++++++------ crates/vite_setup/src/lib.rs | 3 +++ 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/crates/vite_global_cli/src/commands/upgrade/mod.rs b/crates/vite_global_cli/src/commands/upgrade/mod.rs index 8e748f3805..bf4053a01b 100644 --- a/crates/vite_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vite_global_cli/src/commands/upgrade/mod.rs @@ -147,7 +147,7 @@ async fn install_platform_and_main( install::extract_platform_package(platform_data, version_dir).await?; // Verify binary was extracted - let binary_name = if cfg!(windows) { "vp.exe" } else { "vp" }; + let binary_name = vite_setup::VP_BINARY_NAME; let binary_path = version_dir.join("bin").join(binary_name); if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { return Err(Error::Upgrade( diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index ed105b888a..51e445d0db 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -20,9 +20,7 @@ use indicatif::{ProgressBar, ProgressStyle}; use owo_colors::OwoColorize; use vite_install::request::HttpClient; use vite_path::AbsolutePathBuf; -use vite_setup::{install, integrity, platform, registry}; - -const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; +use vite_setup::{VP_BINARY_NAME, install, integrity, platform, registry}; /// Restrict DLL search to system32 only to prevent DLL hijacking /// when the installer is run from a Downloads folder. @@ -206,10 +204,8 @@ async fn do_install( if let Err(e) = install::refresh_shims(install_dir).await { print_warn(&format!("Node.js manager setup failed (non-fatal): {e}")); } - } else { - if let Err(e) = install::create_env_files(install_dir).await { - print_warn(&format!("Env file creation failed (non-fatal): {e}")); - } + } else if let Err(e) = install::create_env_files(install_dir).await { + print_warn(&format!("Env file creation failed (non-fatal): {e}")); } if !opts.no_modify_path { diff --git a/crates/vite_setup/src/install.rs b/crates/vite_setup/src/install.rs index 9a18136479..f0f8fa8dd8 100644 --- a/crates/vite_setup/src/install.rs +++ b/crates/vite_setup/src/install.rs @@ -156,7 +156,7 @@ pub async fn install_production_deps( version_dir: &AbsolutePath, registry: Option<&str>, ) -> Result<(), Error> { - let vp_binary = version_dir.join("bin").join(if cfg!(windows) { "vp.exe" } else { "vp" }); + let vp_binary = version_dir.join("bin").join(crate::VP_BINARY_NAME); if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { return Err(Error::Setup( @@ -289,8 +289,7 @@ pub async fn swap_current_link(install_dir: &AbsolutePath, version: &str) -> Res /// 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" }); + let vp_binary = install_dir.join("current").join("bin").join(crate::VP_BINARY_NAME); if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { tracing::warn!( @@ -324,18 +323,26 @@ pub async fn refresh_shims(install_dir: &AbsolutePath) -> Result<(), Error> { /// Used when the Node.js manager is disabled — ensures env files exist /// even without a full shim refresh. pub async fn create_env_files(install_dir: &AbsolutePath) -> Result<(), Error> { - let vp_binary = - install_dir.join("current").join("bin").join(if cfg!(windows) { "vp.exe" } else { "vp" }); + let vp_binary = install_dir.join("current").join("bin").join(crate::VP_BINARY_NAME); if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { return Ok(()); } - tokio::process::Command::new(vp_binary.as_path()) + let output = tokio::process::Command::new(vp_binary.as_path()) .args(["env", "setup", "--env-only"]) .output() .await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "env setup --env-only exited with code {}, continuing anyway\n{}", + output.status.code().unwrap_or(-1), + stderr.trim() + ); + } + Ok(()) } diff --git a/crates/vite_setup/src/lib.rs b/crates/vite_setup/src/lib.rs index 573a056cc4..99dd572c36 100644 --- a/crates/vite_setup/src/lib.rs +++ b/crates/vite_setup/src/lib.rs @@ -15,3 +15,6 @@ pub mod registry; /// Maximum number of old versions to keep. pub const MAX_VERSIONS_KEEP: usize = 5; + +/// Platform-specific binary name for the `vp` CLI. +pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; From 5b52a254b774d00472f6ae517b95c93a80d6ce79 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 09:43:16 +0800 Subject: [PATCH 36/39] fix(installer): pause before closing in interactive mode When double-clicked, the console window closes immediately after installation finishes. Users never see the success/error message. Add a 'Press Enter to close...' prompt in interactive mode so users can read the output. Silent mode (-y, CI) exits immediately. --- crates/vite_installer/src/main.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 51e445d0db..a113dfac8a 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -76,7 +76,7 @@ async fn run(mut opts: cli::Options) -> i32 { } } - match do_install(&opts, &install_dir).await { + let code = match do_install(&opts, &install_dir).await { Ok(()) => { print_success(&opts, &install_dir_display); 0 @@ -85,7 +85,15 @@ async fn run(mut opts: cli::Options) -> i32 { print_error(&format!("{e}")); 1 } + }; + + // When running interactively (double-click), pause so the user can + // read the output before the console window closes. + if !opts.yes { + read_input(" Press Enter to close..."); } + + code } #[allow(clippy::print_stdout)] From 1d3e4a189b32840c95a266b678e6920cfa006802 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 09:52:30 +0800 Subject: [PATCH 37/39] fix(installer): remove misleading quotes from version input prompt --- crates/vite_installer/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index a113dfac8a..0806bdc5be 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -456,7 +456,7 @@ fn show_customize_menu(opts: &mut cli::Options) { match choice.as_str() { "" => return, "1" => { - let v = read_input(" Version (e.g. 0.3.0, or 'latest'): "); + let v = read_input(" Version (e.g. 0.3.0 or latest): "); if v == "latest" || v.is_empty() { opts.version = None; } else { From a87b42c098cc43c7011f8da9bae020f2431bb343 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 13:18:46 +0800 Subject: [PATCH 38/39] docs: add SmartScreen warning guide for vp-setup.exe download --- docs/guide/index.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/guide/index.md b/docs/guide/index.md index 77833e06fa..da73483d8d 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -20,6 +20,10 @@ irm https://vite.plus/ps1 | iex Alternatively, download and run `vp-setup.exe` from the [latest release](https://github.com/voidzero-dev/vite-plus/releases/latest). +::: tip SmartScreen warning +The `.exe` is not yet code-signed. Your browser may show a warning when downloading. Click **"..."** → **"Keep"** → **"Keep anyway"** to proceed. If Windows Defender SmartScreen blocks the file when you run it, click **"More info"** → **"Run anyway"**. +::: + After installation, open a new shell and run: ```bash From a8c95ab916f8317912fc31bbfc68edd0bdcd0972 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 13:30:01 +0800 Subject: [PATCH 39/39] docs: use vp-setup.exe in SmartScreen warning text --- docs/guide/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/index.md b/docs/guide/index.md index da73483d8d..8b13f98e62 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -21,7 +21,7 @@ irm https://vite.plus/ps1 | iex Alternatively, download and run `vp-setup.exe` from the [latest release](https://github.com/voidzero-dev/vite-plus/releases/latest). ::: tip SmartScreen warning -The `.exe` is not yet code-signed. Your browser may show a warning when downloading. Click **"..."** → **"Keep"** → **"Keep anyway"** to proceed. If Windows Defender SmartScreen blocks the file when you run it, click **"More info"** → **"Run anyway"**. +The `vp-setup.exe` is not yet code-signed. Your browser may show a warning when downloading. Click **"..."** → **"Keep"** → **"Keep anyway"** to proceed. If Windows Defender SmartScreen blocks the file when you run it, click **"More info"** → **"Run anyway"**. ::: After installation, open a new shell and run: