From 46cec6a74499c948f250f272909c7fec26ea1a1f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 17:38:26 +0800 Subject: [PATCH 01/41] 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 f651606938..bda6f4b18e 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 c824c29d4d..1f0404da71 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 = "076cef486127e6cd1fefc58945f00dac316888ca" } 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 = "076cef486127e6cd1fefc58945f00dac316888ca" } @@ -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 b5b5a0f2cd7f6e890ccf23d34ee4b68873253151 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 17:47:59 +0800 Subject: [PATCH 02/41] 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 dcb62757a1941745c410acee20361bf0c798e2ad Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:19:52 +0800 Subject: [PATCH 03/41] 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 bb8465b478456fcedbad9b8546f47dd56ad48f02 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:49:27 +0800 Subject: [PATCH 04/41] 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 b4b371a4a1..c8fc620d16 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: @@ -706,3 +708,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 6e91be7215028a56c4be665d9913bb5ae591d941 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:53:55 +0800 Subject: [PATCH 05/41] 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 281ceb8e58ef20b2edf674fc8e86baafde2d16f1 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:55:43 +0800 Subject: [PATCH 06/41] 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 7c7208e07a536da3915139a275e9edeb7724cdc5 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 20:57:01 +0800 Subject: [PATCH 07/41] 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 bda6f4b18e..b982cf725b 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 51d60817cd86351d67700ea04b81a1b941c0a51d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 21:03:35 +0800 Subject: [PATCH 08/41] 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 c685d371c5f87723db50cf79f26b0787cd48bc15 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 21:12:18 +0800 Subject: [PATCH 09/41] 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 b982cf725b..90578913d4 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 1f0404da71..fc4959777c 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 322c17bdf2bebe1d140364a0000a309e335c3c0d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 21:35:03 +0800 Subject: [PATCH 10/41] 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 cdfb8b9f0165161e823fa7b4f02ce7606da2f2f8 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:15:34 +0800 Subject: [PATCH 11/41] 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 90578913d4..95acd4d018 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 ee94e3c5f3ab09636094bf160867c8e6c36f971f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:24:30 +0800 Subject: [PATCH 12/41] 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 79b3c48ad1272af823ca03315cfd8456d41773f3 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:40:12 +0800 Subject: [PATCH 13/41] 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 59e8e0473cad57bdc4bdf19a1eef1706fbc1788a Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:48:42 +0800 Subject: [PATCH 14/41] 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 aa6de53adae28b653c3b1b8d8222cb57a7eb26d0 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 22:57:19 +0800 Subject: [PATCH 15/41] 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 c8fc620d16..c62395da45 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -710,14 +710,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 @@ -727,7 +723,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 @@ -737,7 +733,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 6883c8d477a90392f89fddfacd764abcb15c43c2 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:01:49 +0800 Subject: [PATCH 16/41] 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 e6f713058b1f9a05941b835093930632578f4865 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:05:19 +0800 Subject: [PATCH 17/41] 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 1ee31101819d6aa6e9cc638c25bf4ca46229e2bb Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:06:32 +0800 Subject: [PATCH 18/41] 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 95acd4d018..f59b418b9e 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 09941c932c19afe168ad74a71c35898b80029482 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:09:33 +0800 Subject: [PATCH 19/41] 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 c62395da45..b7eae9d8c1 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -716,6 +716,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 4ea51f8217079f4a45a35413af1ecc3c5f15651e Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:14:17 +0800 Subject: [PATCH 20/41] 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 197db642c02c045805fd56f1c76f94538cc320ef Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:17:52 +0800 Subject: [PATCH 21/41] 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 b7eae9d8c1..1ebcc6a9cf 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -717,7 +717,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 @@ -725,7 +737,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 80806a7ffdc156f041a799589edf21ab831dc5d1 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:20:20 +0800 Subject: [PATCH 22/41] 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 c3bc4184ee8dac36d9339a9c8a58f6ffc4fc5db4 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:24:44 +0800 Subject: [PATCH 23/41] 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 176cb6789f1a59fc2a6bca5eb415008b90570a97 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:25:24 +0800 Subject: [PATCH 24/41] 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 eace8ae48108c846ea2d080f19dae902b8620754 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:28:23 +0800 Subject: [PATCH 25/41] 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 14c78f061acb9d2b539720c724ab4063ec9fd845 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:29:38 +0800 Subject: [PATCH 26/41] 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 1ebcc6a9cf..431d426f1c 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -737,7 +737,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 54cc2c50e3c8cf8ce4d9690ae863a38f2097549b Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:32:36 +0800 Subject: [PATCH 27/41] 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 14adf23fa2a23de86623ae992f55d9fcb9b62981 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:39:37 +0800 Subject: [PATCH 28/41] 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 82e7763a389c2d0cf1557e7bba7dd602403fccc4 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:42:38 +0800 Subject: [PATCH 29/41] 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 9ae7431bc7cd2793da82cea0bf927f7ab373e81d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:50:45 +0800 Subject: [PATCH 30/41] 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 7cfd8391df..6271d474d5 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 ad137d6ee87833ea162dd7a1858fafdf651306f8 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:56:46 +0800 Subject: [PATCH 31/41] 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 6271d474d5..d0576da858 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 2b61b34ca6d19a301ed1043aa279dedac846ff0d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Apr 2026 23:58:53 +0800 Subject: [PATCH 32/41] 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 d0576da858..fa724948df 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 144cb81b7a1d73001299a8a016613989244cfa13 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 00:01:00 +0800 Subject: [PATCH 33/41] 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 fa724948df..c00f3cb297 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 80c041e03e425aa0b80dd392dff312f82da6e589 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 00:01:37 +0800 Subject: [PATCH 34/41] 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 c00f3cb297..682fdca25d 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 ad834cb16fa2aee71696f7accbc95e5c432cdeb2 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 00:08:02 +0800 Subject: [PATCH 35/41] 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 d6a682c4ad6e3673d471e9814a71fd78892ec197 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 09:43:16 +0800 Subject: [PATCH 36/41] 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 f6c15eea354596c5d657a042c7ddea7c2ea0fc00 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 09:52:30 +0800 Subject: [PATCH 37/41] 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 a81f93c938eabc672f5e28a76bfb25c6fd1e5d96 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 13:18:46 +0800 Subject: [PATCH 38/41] 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 682fdca25d..9089ceaf86 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 aa5dfc2b71a28e6ec95daf3750396b2c55ed1a90 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Apr 2026 13:30:01 +0800 Subject: [PATCH 39/41] 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 9089ceaf86..e0bde29acb 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: From b61999499209596dab066c18986343f7da3eb5e1 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 7 Apr 2026 14:08:06 +0800 Subject: [PATCH 40/41] fix(installer): propagate VP_HOME to child vp processes When --install-dir overrides the default location, child vp processes (refresh_shims, create_env_files, install_production_deps) would fall back to ~/.vite-plus because VP_HOME was not set in their environment. Set VP_HOME to the resolved install directory before spawning any child processes so they always use the correct location. --- crates/vite_installer/src/main.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 0806bdc5be..1fbb158195 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -42,6 +42,7 @@ 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); @@ -62,6 +63,12 @@ async fn run(mut opts: cli::Options) -> i32 { }; let install_dir_display = install_dir.as_path().to_string_lossy().to_string(); + // Propagate the resolved install directory to child `vp` processes + // (refresh_shims, create_env_files, install_production_deps) so they + // find the correct home, especially for --install-dir overrides. + // Safety: no other threads are reading env vars at this point. + unsafe { std::env::set_var("VP_HOME", install_dir.as_path()) }; + // 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 { From 998d51d9d833ab3b6ff34afdda3e3f81bd0d929f Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 7 Apr 2026 14:14:12 +0800 Subject: [PATCH 41/41] fix(installer): reinstall when current version directory is incomplete The same-version check now verifies that the binary (current/bin/vp.exe) actually exists, not just that the current symlink points at the target version. If the version directory is corrupted or partially deleted, the installer performs a full reinstall instead of skipping to the best-effort repair steps which cannot restore missing core files. --- crates/vite_installer/src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs index 1fbb158195..d6a73c2868 100644 --- a/crates/vite_installer/src/main.rs +++ b/crates/vite_installer/src/main.rs @@ -127,7 +127,11 @@ async fn do_install( let target_version = registry::resolve_version_string(version_or_tag, opts.registry.as_deref()).await?; - let same_version = current_version.as_deref() == Some(target_version.as_str()); + // Same version only if the binary is intact — a corrupted install needs a full reinstall + let same_version = current_version.as_deref() == Some(target_version.as_str()) + && tokio::fs::try_exists(install_dir.join("current").join("bin").join(VP_BINARY_NAME)) + .await + .unwrap_or(false); if same_version { if !opts.quiet {