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/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 9e3d29c1cd..014dcf2a50 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -8,6 +8,8 @@ on: paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'crates/vite_installer/**' + - 'crates/vite_setup/**' - '.github/workflows/test-standalone-install.yml' concurrency: @@ -626,3 +628,57 @@ jobs: which npm which npx which vp + + test-vp-setup-exe: + name: Test vp-setup.exe (pwsh) + runs-on: windows-latest + permissions: + contents: read + 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 + run: cargo build --release -p vite_installer + + - name: Install via vp-setup.exe (silent) + shell: pwsh + run: ${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }} + env: + VP_VERSION: alpha + + - name: Set PATH + shell: bash + run: echo "$USERPROFILE/.vite-plus/bin" >> $GITHUB_PATH + + - name: Verify installation + 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 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 = [ diff --git a/Cargo.lock b/Cargo.lock index d4a0672067..6873071dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7394,22 +7394,17 @@ dependencies = [ name = "vite_global_cli" version = "0.0.0" dependencies = [ - "base64-simd", "chrono", "clap", "clap_complete", "crossterm", "directories", - "flate2", - "junction", "node-semver", "owo-colors", "oxc_resolver", "serde", "serde_json", "serial_test", - "sha2", - "tar", "tempfile", "thiserror 2.0.18", "tokio", @@ -7419,6 +7414,7 @@ dependencies = [ "vite_install", "vite_js_runtime", "vite_path", + "vite_setup", "vite_shared", "vite_str", "vite_workspace", @@ -7465,6 +7461,22 @@ dependencies = [ "vite_workspace", ] +[[package]] +name = "vite_installer" +version = "0.0.0" +dependencies = [ + "clap", + "indicatif", + "owo-colors", + "tokio", + "vite_install", + "vite_path", + "vite_setup", + "vite_shared", + "which", + "winreg", +] + [[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" @@ -8344,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 53c60d3d05..ce6c5a16c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -197,6 +197,7 @@ vite_js_runtime = { path = "crates/vite_js_runtime" } vite_glob = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "eb746ad3f35bd994ddb39be001eaf58986f48388" } vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } +vite_setup = { path = "crates/vite_setup" } vite_shared = { path = "crates/vite_shared" } vite_static_config = { path = "crates/vite_static_config" } vite_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "eb746ad3f35bd994ddb39be001eaf58986f48388" } @@ -206,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" @@ -334,3 +336,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..55d1f3d333 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,13 +30,11 @@ 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 } -[target.'cfg(windows)'.dependencies] -junction = { workspace = true } - [dev-dependencies] serial_test = { workspace = true } tempfile = { 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..bf4053a01b 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 { @@ -154,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( @@ -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..d5fd005279 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -11,8 +11,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..d26e973b22 --- /dev/null +++ b/crates/vite_installer/Cargo.toml @@ -0,0 +1,29 @@ +[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"] } +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 } + +[lints] +workspace = true 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/cli.rs b/crates/vite_installer/src/cli.rs new file mode 100644 index 0000000000..61f7e343f7 --- /dev/null +++ b/crates/vite_installer/src/cli.rs @@ -0,0 +1,63 @@ +//! 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")] +pub struct Options { + /// Accept defaults without prompting (for CI/unattended installs) + #[arg(short = 'y', long = "yes")] + pub yes: bool, + + /// Suppress all output except errors + #[arg(short = 'q', long = "quiet")] + pub quiet: bool, + + /// Install a specific version (default: latest) + #[arg(long = "version")] + pub version: Option, + + /// npm dist-tag to install (default: latest) + #[arg(long = "tag", default_value = "latest")] + pub tag: String, + + /// Custom installation directory (default: ~/.vite-plus) + #[arg(long = "install-dir")] + pub install_dir: Option, + + /// Custom npm registry URL + #[arg(long = "registry")] + pub registry: Option, + + /// Skip Node.js version manager setup + #[arg(long = "no-node-manager")] + pub no_node_manager: bool, + + /// Do not modify the User PATH + #[arg(long = "no-modify-path")] + pub no_modify_path: bool, +} + +/// Parse CLI arguments, merging with environment variables. +/// CLI flags take precedence over environment variables. +pub fn parse() -> Options { + let mut opts = Options::parse(); + + // 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(); + } + // CI and quiet both imply non-interactive (no prompts) + if opts.quiet || std::env::var_os("CI").is_some() { + opts.yes = true; + } + + opts +} diff --git a/crates/vite_installer/src/main.rs b/crates/vite_installer/src/main.rs new file mode 100644 index 0000000000..0806bdc5be --- /dev/null +++ b/crates/vite_installer/src/main.rs @@ -0,0 +1,519 @@ +//! 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_path::AbsolutePathBuf; +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. +#[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(mut opts: cli::Options) -> i32 { + 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(); + + // Pre-compute Node.js manager default before showing the menu, + // so the user sees the resolved value and can override it. + if !opts.no_node_manager { + opts.no_node_manager = !auto_detect_node_manager(&install_dir, !opts.yes); + } + + if !opts.yes { + let proceed = show_interactive_menu(&mut opts, &install_dir_display); + if !proceed { + println!("Installation cancelled."); + return 0; + } + } + + let code = match do_install(&opts, &install_dir).await { + Ok(()) => { + print_success(&opts, &install_dir_display); + 0 + } + Err(e) => { + 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)] +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}")); + } + + // 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 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()); + + if same_version { + if !opts.quiet { + 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 {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@{target_version} for {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)?; + + let version_dir = install_dir.join(&target_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 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()); + } + + install::generate_wrapper_package_json(&version_dir, &target_version).await?; + install::write_release_age_overrides(&version_dir).await?; + + if !opts.quiet { + print_info("installing dependencies (this may take a moment)..."); + } + install::install_production_deps(&version_dir, opts.registry.as_deref()).await?; + + let previous_version = if current_version.is_some() { + install::save_previous_version(install_dir).await? + } else { + None + }; + install::swap_current_link(install_dir, &target_version).await?; + + // Cleanup with both new and previous versions protected (matches vp upgrade) + let mut protected = vec![target_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 + { + 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: the core install succeeded once `current` + // points at the right version. + + if !opts.quiet { + print_info("setting up shims..."); + } + if let Err(e) = setup_bin_shims(install_dir).await { + print_warn(&format!("Shim setup failed (non-fatal): {e}")); + } + + if !opts.no_node_manager { + if !opts.quiet { + print_info("setting up Node.js version manager..."); + } + 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}")); + } + + if !opts.no_modify_path { + let bin_dir_str = install_dir.join("bin").as_path().to_string_lossy().to_string(); + if let Err(e) = modify_path(&bin_dir_str, opts.quiet) { + print_warn(&format!("PATH modification failed (non-fatal): {e}")); + } + } + + Ok(()) +} + +/// Auto-detect whether the Node.js version manager should be enabled. +/// +/// 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. 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"); + } + + // 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: 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. +#[cfg(windows)] +async fn replace_windows_exe( + src: &vite_path::AbsolutePathBuf, + dst: &vite_path::AbsolutePathBuf, + bin_dir: &vite_path::AbsolutePathBuf, +) -> Result<(), Box> { + 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/vp` entry point (trampoline copy on Windows, symlink on Unix). +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_dst = bin_dir.join("vp.exe"); + let shim_src = install_dir.join("current").join("bin").join("vp-shim.exe"); + + // 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 { + 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 { + if entry.file_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"); + let _ = tokio::fs::remove_file(&link_path).await; + tokio::fs::symlink(&link_target, &link_path).await?; + } + + Ok(()) +} + +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) +} + +fn resolve_install_dir(opts: &cli::Options) -> Result> { + if let Some(ref dir) = opts.install_dir { + let path = std::path::PathBuf::from(dir); + let abs = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; + AbsolutePathBuf::new(abs).ok_or_else(|| "Invalid installation directory".into()) + } else { + Ok(vite_shared::get_vp_home()?) + } +} + +#[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))] + { + if !quiet { + print_info(&format!("add {bin_dir} to your shell's PATH")); + } + } + + Ok(()) +} + +#[allow(clippy::print_stdout)] +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 { "enabled" }.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."); + } + } + } +} + +#[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 { "enabled" }.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."), + } + } +} + +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)] +fn print_success(opts: &cli::Options, install_dir: &str) { + if opts.quiet { + return; + } + + 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://viteplus.dev/guide/"); + println!(); +} + +#[allow(clippy::print_stderr)] +fn print_info(msg: &str) { + eprint!("{}", "info: ".blue()); + 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()); + 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..61e743599c --- /dev/null +++ b/crates/vite_installer/src/windows_path.rs @@ -0,0 +1,80 @@ +//! 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; + +use winreg::{ + RegKey, + enums::{HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ}, +}; + +/// 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" { + fn SendMessageTimeoutW( + hWnd: isize, + Msg: u32, + wParam: usize, + lParam: isize, + fuFlags: u32, + uTimeout: u32, + lpdwResult: *mut usize, + ) -> isize; + } + + let env_wide: Vec = "Environment".encode_utf16().chain(std::iter::once(0)).collect(); + let mut result: usize = 0; + unsafe { + SendMessageTimeoutW( + HWND_BROADCAST, + WM_SETTINGCHANGE, + 0, + env_wide.as_ptr() as isize, + SMTO_ABORTIFHUNG, + 5000, + &mut result, + ); + } +} + +/// 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)?; + + let current: String = env.get_value("Path").unwrap_or_default(); + let bin_dir_normalized = bin_dir.trim_end_matches('\\'); + + let already_present = current + .split(';') + .any(|entry| entry.trim_end_matches('\\').eq_ignore_ascii_case(bin_dir_normalized)); + + if already_present { + return Ok(()); + } + + let new_path = + if current.is_empty() { bin_dir.to_string() } else { format!("{bin_dir};{current}") }; + + // 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/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 89% rename from crates/vite_global_cli/src/commands/upgrade/install.rs rename to crates/vite_setup/src/install.rs index 569cf47562..f0f8fa8dd8 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,15 +151,15 @@ 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>, ) -> 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::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), @@ -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"); @@ -231,7 +233,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 +261,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 +273,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() @@ -287,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!( @@ -317,6 +318,34 @@ 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(crate::VP_BINARY_NAME); + + if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) { + return Ok(()); + } + + 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(()) +} + /// Clean up old version directories, keeping at most `max_keep` versions. /// /// Sorts by creation time (newest first, matching install.sh behavior) and removes @@ -342,7 +371,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 +507,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 +516,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 +540,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..99dd572c36 --- /dev/null +++ b/crates/vite_setup/src/lib.rs @@ -0,0 +1,20 @@ +//! 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; + +/// Platform-specific binary name for the `vp` CLI. +pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; 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/docs/guide/index.md b/docs/guide/index.md index 6a066f216b..8b13f98e62 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -18,6 +18,12 @@ curl -fsSL https://vite.plus | bash 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 `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: ```bash diff --git a/rfcs/windows-installer.md b/rfcs/windows-installer.md new file mode 100644 index 0000000000..f3c97023d5 --- /dev/null +++ b/rfcs/windows-installer.md @@ -0,0 +1,605 @@ +# RFC: Standalone Windows `.exe` Installer + +## Status + +Implemented + +## 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 + +| 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` + +- 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 (shared installation logic) + ├── vite_install (HTTP client) + ├── vite_shared (home dir resolution) + ├── vite_path (typed path wrappers) + ├── clap (CLI parsing) + ├── tokio (async runtime) + ├── indicatif (progress bars) + └── owo-colors (terminal colors) + +vite_global_cli (existing) + ├── 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: enabled + + 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: + +``` + Customize installation: + + 1) Version: [latest] + 2) npm registry: [(default)] + 3) Node.js manager: [enabled] + 4) Modify PATH: [yes] + + Enter option number to change, or press Enter to go back: + > +``` + +### Silent Mode (CI) + +The installer auto-detects CI environments (`CI=true`) and skips interactive prompts, so `-y` is not required in CI: + +```bash +# CI environments are automatically non-interactive +vp-setup.exe + +# Explicit silent mode (outside CI) +vp-setup.exe -y + +# Customize +vp-setup.exe --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 replicates the same result as `install.ps1`, implemented in Rust via `vite_setup`. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ RESOLVE │ +│ │ +│ ┌─ detect platform ──────── win32-x64-msvc │ +│ │ win32-arm64-msvc │ +│ │ │ +│ ├─ check existing ──────── read %VP_HOME%\current │ +│ │ │ +│ └─ 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 │ +│ │ +│ ┌─ 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 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 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) │ +│ │ │ +│ └─ cleanup old versions ── keep last 5 by creation time │ +│ protects new + previous version │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 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 (pre-computed): │ +│ │ spawn: vp env setup --refresh │ +│ │ if disabled: │ +│ │ spawn: vp env setup --env-only │ +│ │ │ +│ └─ 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 | `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` | +| 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` | +| Activate | `install::cleanup_old_versions()` | `vite_setup` | +| Configure | `install::refresh_shims()` | `vite_setup` | +| Configure | `windows_path::add_to_user_path()` | `vite_installer` | + +**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 + +### PATH Modification via Registry + +Same approach as rustup and `install.ps1`, using the `winreg` crate for registry access: + +```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. + +### 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. Two mitigations, both using raw FFI (no `windows-sys` crate): + +```rust +// build.rs — linker-time: restrict DLL search at load time +#[cfg(windows)] +println!("cargo:rustc-link-arg=/DEPENDENTLOADFLAG:0x800"); + +// main.rs — runtime: restrict DLL search via Win32 API +#[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); } +} +``` + +### 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 | 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 + +**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 + +In `build-upstream/action.yml`, the installer binary is built and cached alongside the CLI: + +```yaml +- name: Build installer binary (Windows only) + if: contains(inputs.target, 'windows') + run: cargo build --release --target ${{ inputs.target }} -p vite_installer +``` + +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: vp-setup-${{ matrix.settings.target }} + path: ./target/${{ matrix.settings.target }}/release/vp-setup.exe +``` + +### Test Workflow + +`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 (pwsh) + runs-on: windows-latest + 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 + env: + VP_VERSION: alpha + - 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/**`. + +## 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-sys` | Windows registry | ~50-100 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` Crate vs Raw FFI for PATH (Decision: `winreg`) + +- `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 `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 + +### Phase 1: Extract `vite_setup` Library (done) + +- 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 (done) + +- Created `crates/vite_installer/` with `[[bin]] name = "vp-setup"` +- Implemented CLI argument parsing (clap) with env var merging +- 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) + +- 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 (future) + +- Update installation docs on website +- Host on `vite.plus/vp-setup.exe` with architecture auto-detection +- Submit to winget, chocolatey, scoop + +## 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 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 + +- [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