diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index a486a4e3ce..de3a2fa254 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -688,6 +688,32 @@ pub enum Commands { }, } +impl Commands { + /// Whether the command was invoked with flags that request quiet or + /// machine-readable output (--silent, -s, --json, --parseable, --format json/list). + pub fn is_quiet_or_machine_readable(&self) -> bool { + match self { + Self::Install { silent, .. } + | Self::Dlx { silent, .. } + | Self::Upgrade { silent, .. } => *silent, + + Self::Outdated { format, .. } => { + matches!(format, Some(Format::Json | Format::List)) + } + + Self::Why { json, parseable, .. } => *json || *parseable, + Self::Info { json, .. } => *json, + + Self::Pm(sub) => sub.is_quiet_or_machine_readable(), + Self::Env(args) => { + args.command.as_ref().is_some_and(|sub| sub.is_quiet_or_machine_readable()) + } + + _ => false, + } + } +} + /// Arguments for the `env` command #[derive(clap::Args, Debug)] #[command(after_help = "\ @@ -877,6 +903,15 @@ pub enum EnvSubcommands { }, } +impl EnvSubcommands { + fn is_quiet_or_machine_readable(&self) -> bool { + match self { + Self::Current { json } | Self::List { json } | Self::ListRemote { json, .. } => *json, + _ => false, + } + } +} + /// Version sorting order for list-remote command #[derive(clap::ValueEnum, Clone, Debug, Default)] pub enum SortingMethod { @@ -1240,6 +1275,23 @@ pub enum PmCommands { }, } +impl PmCommands { + fn is_quiet_or_machine_readable(&self) -> bool { + match self { + Self::List { json, parseable, .. } => *json || *parseable, + Self::Pack { json, .. } + | Self::View { json, .. } + | Self::Publish { json, .. } + | Self::Audit { json, .. } + | Self::Search { json, .. } + | Self::Fund { json, .. } => *json, + Self::Config(sub) => sub.is_quiet_or_machine_readable(), + Self::Token(sub) => sub.is_quiet_or_machine_readable(), + _ => false, + } + } +} + /// Configuration subcommands #[derive(Subcommand, Debug, Clone)] pub enum ConfigCommands { @@ -1312,6 +1364,15 @@ pub enum ConfigCommands { }, } +impl ConfigCommands { + fn is_quiet_or_machine_readable(&self) -> bool { + match self { + Self::List { json, .. } | Self::Get { json, .. } | Self::Set { json, .. } => *json, + _ => false, + } + } +} + /// Owner subcommands #[derive(Subcommand, Debug, Clone)] pub enum OwnerCommands { @@ -1408,6 +1469,15 @@ pub enum TokenCommands { }, } +impl TokenCommands { + fn is_quiet_or_machine_readable(&self) -> bool { + match self { + Self::List { json, .. } | Self::Create { json, .. } => *json, + _ => false, + } + } +} + /// Distribution tag subcommands #[derive(Subcommand, Debug, Clone)] pub enum DistTagCommands { diff --git a/crates/vite_global_cli/src/commands/upgrade/mod.rs b/crates/vite_global_cli/src/commands/upgrade/mod.rs index 09b0247776..3b832b4a1d 100644 --- a/crates/vite_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vite_global_cli/src/commands/upgrade/mod.rs @@ -6,7 +6,7 @@ mod install; mod integrity; mod platform; -mod registry; +pub(crate) mod registry; use std::process::ExitStatus; diff --git a/crates/vite_global_cli/src/commands/upgrade/registry.rs b/crates/vite_global_cli/src/commands/upgrade/registry.rs index 9fa08a1f27..20fdaa2885 100644 --- a/crates/vite_global_cli/src/commands/upgrade/registry.rs +++ b/crates/vite_global_cli/src/commands/upgrade/registry.rs @@ -34,22 +34,19 @@ const MAIN_PACKAGE_NAME: &str = "vite-plus"; const PLATFORM_PACKAGE_SCOPE: &str = "@voidzero-dev"; const CLI_PACKAGE_NAME_PREFIX: &str = "vite-plus-cli"; -/// Resolve a version from the npm registry. +/// Resolve a version string from the npm registry. /// -/// Makes two HTTP calls: -/// 1. Main package metadata to resolve version tags (e.g., "latest" → "1.2.3") -/// 2. CLI platform package metadata to get tarball URL and integrity -pub async fn resolve_version( +/// Single HTTP call to resolve a version or tag (e.g., "latest" → "1.2.3"). +/// Does NOT verify the platform-specific package exists. +pub async fn resolve_version_string( version_or_tag: &str, - platform_suffix: &str, registry_override: Option<&str>, -) -> Result { +) -> Result { let default_registry = npm_registry(); let registry_raw = registry_override.unwrap_or(&default_registry); let registry = registry_raw.trim_end_matches('/'); let client = HttpClient::new(); - // Step 1: Fetch main package metadata to resolve version let main_url = format!("{registry}/{MAIN_PACKAGE_NAME}/{version_or_tag}"); tracing::debug!("Fetching main package metadata: {}", main_url); @@ -57,10 +54,26 @@ pub async fn resolve_version( Error::Upgrade(format!("Failed to fetch package metadata from {main_url}: {e}").into()) })?; - // Step 2: Query CLI platform package directly + Ok(main_meta.version) +} + +/// Resolve the platform-specific package metadata for a given version. +/// +/// Single HTTP call to fetch the tarball URL and integrity hash for the +/// platform-specific CLI binary package. +pub async fn resolve_platform_package( + version: &str, + platform_suffix: &str, + registry_override: Option<&str>, +) -> Result { + let default_registry = npm_registry(); + let registry_raw = registry_override.unwrap_or(&default_registry); + let registry = registry_raw.trim_end_matches('/'); + let client = HttpClient::new(); + let cli_package_name = format!("{PLATFORM_PACKAGE_SCOPE}/{CLI_PACKAGE_NAME_PREFIX}-{platform_suffix}"); - let cli_url = format!("{registry}/{cli_package_name}/{}", main_meta.version); + let cli_url = format!("{registry}/{cli_package_name}/{version}"); tracing::debug!("Fetching CLI package metadata: {}", cli_url); let cli_meta: PackageVersionMetadata = client.get_json(&cli_url).await.map_err(|e| { @@ -74,12 +87,26 @@ pub async fn resolve_version( })?; Ok(ResolvedVersion { - version: main_meta.version, + version: version.to_owned(), platform_tarball_url: cli_meta.dist.tarball, platform_integrity: cli_meta.dist.integrity, }) } +/// Resolve a version from the npm registry with platform package verification. +/// +/// Makes two HTTP calls: +/// 1. Main package metadata to resolve version tags (e.g., "latest" → "1.2.3") +/// 2. CLI platform package metadata to get tarball URL and integrity +pub async fn resolve_version( + version_or_tag: &str, + platform_suffix: &str, + registry_override: Option<&str>, +) -> Result { + let version = resolve_version_string(version_or_tag, registry_override).await?; + resolve_platform_package(&version, platform_suffix, registry_override).await +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 9f8896d859..0e28863792 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -15,6 +15,7 @@ mod help; mod js_executor; mod shim; mod tips; +mod upgrade_check; use std::{ io::{IsTerminal, Write}, @@ -280,7 +281,17 @@ async fn main() -> ExitCode { } // Parse CLI arguments (using custom help formatting) - let exit_code = match try_parse_args_from(normalized_args) { + let parse_result = try_parse_args_from(normalized_args); + + // Spawn background upgrade check for eligible commands + let upgrade_handle = match &parse_result { + Ok(args) if upgrade_check::should_run_for_command(args) => { + Some(tokio::spawn(upgrade_check::check_for_update())) + } + _ => None, + }; + + let exit_code = match parse_result { Err(e) => { use clap::error::ErrorKind; @@ -355,6 +366,14 @@ async fn main() -> ExitCode { }, }; + // Display upgrade notice if a newer version is available + if let Some(handle) = upgrade_handle + && let Ok(Ok(Some(result))) = + tokio::time::timeout(std::time::Duration::from_millis(500), handle).await + { + upgrade_check::display_upgrade_notice(&result); + } + tip_context.exit_code = if exit_code == ExitCode::SUCCESS { 0 } else { 1 }; if let Some(tip) = tips::get_tip(&tip_context) { diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs new file mode 100644 index 0000000000..4b72669eee --- /dev/null +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -0,0 +1,415 @@ +//! Background upgrade check for the vp CLI. +//! +//! Periodically queries the npm registry for the latest version and caches the +//! result to `~/.vite-plus/.upgrade-check.json`. Displays a one-line notice on +//! stderr when a newer version is available, at most once per 24 hours. + +use std::{ + io::IsTerminal, + time::{SystemTime, UNIX_EPOCH}, +}; + +use owo_colors::OwoColorize; +use serde::{Deserialize, Serialize}; + +use crate::commands::upgrade::registry; + +const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; +const PROMPT_INTERVAL_SECS: u64 = 24 * 60 * 60; +const CACHE_FILE_NAME: &str = ".upgrade-check.json"; + +#[expect(clippy::disallowed_types)] // String required for serde JSON round-trip +#[derive(Debug, Clone, Serialize, Deserialize)] +struct UpgradeCheckCache { + latest: String, + checked_at: u64, + prompted_at: u64, +} + +fn read_cache(install_dir: &vite_path::AbsolutePath) -> Option { + let cache_path = install_dir.join(CACHE_FILE_NAME); + let data = std::fs::read_to_string(cache_path.as_path()).ok()?; + serde_json::from_str(&data).ok() +} + +fn write_cache(install_dir: &vite_path::AbsolutePath, cache: &UpgradeCheckCache) { + let cache_path = install_dir.join(CACHE_FILE_NAME); + if let Ok(data) = serde_json::to_string(cache) { + let _ = std::fs::write(cache_path.as_path(), &data); + } +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() +} + +fn should_check(cache: Option<&UpgradeCheckCache>, now: u64) -> bool { + if std::env::var_os("VP_NO_UPDATE_CHECK").is_some() + || std::env::var_os("CI").is_some() + || std::env::var_os("VITE_PLUS_CLI_TEST").is_some() + { + return false; + } + + cache.is_none_or(|c| now.saturating_sub(c.checked_at) > CHECK_INTERVAL_SECS) +} + +fn should_prompt(cache: Option<&UpgradeCheckCache>, now: u64) -> bool { + cache.is_none_or(|c| now.saturating_sub(c.prompted_at) > PROMPT_INTERVAL_SECS) +} + +/// Returns `true` if `latest` is strictly newer than `current` per semver. +/// Returns `false` for equal versions, downgrades, or unparseable strings. +fn is_newer_version(current: &str, latest: &str) -> bool { + if latest.is_empty() || current == "0.0.0" { + return false; + } + match (node_semver::Version::parse(current), node_semver::Version::parse(latest)) { + (Ok(current), Ok(latest)) => latest > current, + _ => false, + } +} + +#[expect(clippy::disallowed_types)] // String returned from serde deserialization +async fn resolve_version_string() -> Option { + registry::resolve_version_string("latest", None).await.ok() +} + +pub struct UpgradeCheckResult { + install_dir: vite_path::AbsolutePathBuf, + cache: UpgradeCheckCache, +} + +/// Returns an upgrade check result if a newer version is available and the user +/// hasn't been prompted within the last 24 hours. Returns `None` otherwise. +pub async fn check_for_update() -> Option { + let install_dir = vite_shared::get_vite_plus_home().ok()?; + let current_version = env!("CARGO_PKG_VERSION"); + let now = now_secs(); + let mut cache = read_cache(&install_dir); + + if should_check(cache.as_ref(), now) { + let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); + + match resolve_version_string().await { + Some(latest) => { + let new_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; + write_cache(&install_dir, &new_cache); + cache = Some(new_cache); + } + None => { + // Still update checked_at so we back off for 24h instead of + // retrying on every command when the registry is unreachable. + let latest = cache.as_ref().map(|c| c.latest.clone()).unwrap_or_default(); + let failed_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; + write_cache(&install_dir, &failed_cache); + cache = Some(failed_cache); + } + } + } + + let cache = cache?; + + if !is_newer_version(current_version, &cache.latest) { + return None; + } + + if !should_prompt(Some(&cache), now) { + return None; + } + + Some(UpgradeCheckResult { install_dir, cache }) +} + +/// Print a one-line upgrade notice to stderr and record the prompt time. +#[expect(clippy::print_stderr, clippy::disallowed_macros)] +pub fn display_upgrade_notice(result: &UpgradeCheckResult) { + let current_version = env!("CARGO_PKG_VERSION"); + eprintln!( + "\n{} {} {} {}{} {}", + "vp update available:".bright_black(), + current_version.bright_black(), + "\u{2192}".bright_black(), + result.cache.latest.bright_green().bold(), + ", run".bright_black(), + "vp upgrade".bright_green().bold(), + ); + + let mut cache = result.cache.clone(); + cache.prompted_at = now_secs(); + write_cache(&result.install_dir, &cache); +} + +/// Whether the upgrade check should run for the given command args. +/// Returns `false` for commands excluded by design, quiet modes, and +/// machine-readable output flags (--silent, -s, --json, --parseable, --format json). +pub fn should_run_for_command(args: &crate::cli::Args) -> bool { + if !cfg!(test) && !std::io::stderr().is_terminal() { + return false; + } + + if args.version { + return false; + } + + match &args.command { + Some( + crate::cli::Commands::Upgrade { .. } + | crate::cli::Commands::Implode { .. } + | crate::cli::Commands::Lint { .. } + | crate::cli::Commands::Fmt { .. }, + ) => false, + Some(cmd) => !cmd.is_quiet_or_machine_readable(), + None => true, + } +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + + use super::*; + + #[test] + fn cache_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + + let cache = + UpgradeCheckCache { latest: "1.2.3".to_owned(), checked_at: 1000, prompted_at: 900 }; + write_cache(&dir_path, &cache); + + let loaded = read_cache(&dir_path).expect("should read back cache"); + assert_eq!(loaded.latest, "1.2.3"); + assert_eq!(loaded.checked_at, 1000); + assert_eq!(loaded.prompted_at, 900); + } + + #[test] + fn read_cache_returns_none_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + assert!(read_cache(&dir_path).is_none()); + } + + #[test] + fn read_cache_returns_none_for_corrupt_file() { + let dir = tempfile::tempdir().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write(dir_path.join(CACHE_FILE_NAME).as_path(), "not json").unwrap(); + assert!(read_cache(&dir_path).is_none()); + } + + fn with_env_vars_cleared(f: F) { + let ci = std::env::var_os("CI"); + let test = std::env::var_os("VITE_PLUS_CLI_TEST"); + let no_check = std::env::var_os("VP_NO_UPDATE_CHECK"); + unsafe { + std::env::remove_var("CI"); + std::env::remove_var("VITE_PLUS_CLI_TEST"); + std::env::remove_var("VP_NO_UPDATE_CHECK"); + } + + f(); + + unsafe { + if let Some(v) = ci { + std::env::set_var("CI", v); + } + if let Some(v) = test { + std::env::set_var("VITE_PLUS_CLI_TEST", v); + } + if let Some(v) = no_check { + std::env::set_var("VP_NO_UPDATE_CHECK", v); + } + } + } + + #[test] + #[serial] + fn should_check_returns_true_when_no_cache() { + with_env_vars_cleared(|| { + assert!(should_check(None, now_secs())); + }); + } + + #[test] + #[serial] + fn should_check_returns_false_when_cache_fresh() { + with_env_vars_cleared(|| { + let now = now_secs(); + let cache = + UpgradeCheckCache { latest: "1.0.0".to_owned(), checked_at: now, prompted_at: 0 }; + assert!(!should_check(Some(&cache), now)); + }); + } + + #[test] + #[serial] + fn should_check_returns_true_when_cache_stale() { + with_env_vars_cleared(|| { + let now = now_secs(); + let stale_time = now - CHECK_INTERVAL_SECS - 1; + let cache = UpgradeCheckCache { + latest: "1.0.0".to_owned(), + checked_at: stale_time, + prompted_at: 0, + }; + assert!(should_check(Some(&cache), now)); + }); + } + + #[test] + #[serial] + fn should_check_returns_false_when_disabled() { + with_env_vars_cleared(|| { + unsafe { + std::env::set_var("VP_NO_UPDATE_CHECK", "1"); + } + assert!(!should_check(None, now_secs())); + }); + } + + #[test] + fn should_prompt_returns_true_when_no_cache() { + assert!(should_prompt(None, now_secs())); + } + + #[test] + fn should_prompt_returns_true_when_never_prompted() { + let cache = UpgradeCheckCache { + latest: "2.0.0".to_owned(), + checked_at: now_secs(), + prompted_at: 0, + }; + assert!(should_prompt(Some(&cache), now_secs())); + } + + #[test] + fn should_prompt_returns_false_when_recently_prompted() { + let now = now_secs(); + let cache = + UpgradeCheckCache { latest: "2.0.0".to_owned(), checked_at: now, prompted_at: now }; + assert!(!should_prompt(Some(&cache), now)); + } + + #[test] + fn should_prompt_returns_true_when_prompt_stale() { + let now = now_secs(); + let stale = now - PROMPT_INTERVAL_SECS - 1; + let cache = + UpgradeCheckCache { latest: "2.0.0".to_owned(), checked_at: now, prompted_at: stale }; + assert!(should_prompt(Some(&cache), now)); + } + + #[test] + fn is_newer_version_detects_upgrade() { + assert!(is_newer_version("0.1.0", "0.2.0")); + assert!(is_newer_version("0.1.0", "1.0.0")); + assert!(is_newer_version("1.0.0", "1.0.1")); + } + + #[test] + fn is_newer_version_rejects_same() { + assert!(!is_newer_version("0.2.0", "0.2.0")); + } + + #[test] + fn is_newer_version_rejects_downgrade() { + assert!(!is_newer_version("0.2.0", "0.1.0")); + } + + #[test] + fn is_newer_version_rejects_prerelease_downgrade_to_stable() { + // User on alpha, latest stable is older — don't prompt + assert!(!is_newer_version("0.3.0-alpha.1", "0.2.0")); + } + + #[test] + fn is_newer_version_prompts_prerelease_to_newer_stable() { + assert!(is_newer_version("0.1.0-alpha.1", "0.2.0")); + } + + #[test] + fn is_newer_version_prompts_prerelease_to_same_base_release() { + // 1.0.0 is newer than 1.0.0-alpha.1 per semver + assert!(is_newer_version("1.0.0-alpha.1", "1.0.0")); + } + + #[test] + fn is_newer_version_rejects_empty_latest() { + assert!(!is_newer_version("0.1.0", "")); + } + + #[test] + fn is_newer_version_skips_dev_build() { + assert!(!is_newer_version("0.0.0", "0.2.0")); + } + + #[test] + fn is_newer_version_rejects_invalid_versions() { + assert!(!is_newer_version("not-a-version", "0.2.0")); + assert!(!is_newer_version("0.1.0", "not-a-version")); + } + + fn parse_args(args: &[&str]) -> crate::cli::Args { + let full: Vec = + std::iter::once("vp").chain(args.iter().copied()).map(String::from).collect(); + crate::try_parse_args_from(full).unwrap() + } + + #[test] + fn should_run_for_normal_command() { + assert!(should_run_for_command(&parse_args(&["build"]))); + } + + #[test] + fn should_not_run_for_upgrade() { + assert!(!should_run_for_command(&parse_args(&["upgrade"]))); + } + + #[test] + fn should_not_run_for_install_silent() { + assert!(!should_run_for_command(&parse_args(&["install", "--silent"]))); + } + + #[test] + fn should_not_run_for_dlx_short_silent() { + assert!(!should_run_for_command(&parse_args(&["dlx", "-s", "pkg"]))); + } + + #[test] + fn should_not_run_for_why_json() { + assert!(!should_run_for_command(&parse_args(&["why", "lodash", "--json"]))); + } + + #[test] + fn should_not_run_for_why_parseable() { + assert!(!should_run_for_command(&parse_args(&["why", "lodash", "--parseable"]))); + } + + #[test] + fn should_not_run_for_outdated_format_json() { + assert!(!should_run_for_command(&parse_args(&["outdated", "--format", "json"]))); + } + + #[test] + fn should_not_run_for_pm_list_parseable() { + assert!(!should_run_for_command(&parse_args(&["pm", "list", "--parseable"]))); + } + + #[test] + fn should_not_run_for_pm_list_json() { + assert!(!should_run_for_command(&parse_args(&["pm", "list", "--json"]))); + } + + #[test] + fn should_not_run_for_env_current_json() { + assert!(!should_run_for_command(&parse_args(&["env", "current", "--json"]))); + } + + #[test] + fn should_run_for_outdated_without_format() { + assert!(should_run_for_command(&parse_args(&["outdated"]))); + } +} diff --git a/rfcs/upgrade-check.md b/rfcs/upgrade-check.md new file mode 100644 index 0000000000..d2093d2a1d --- /dev/null +++ b/rfcs/upgrade-check.md @@ -0,0 +1,308 @@ +# RFC: Upgrade Check + +## Status + +Draft + +## Background + +Vite+ has a `vp upgrade` command for self-updating, but users only discover new versions if they manually run `vp upgrade --check` or hear about it externally. Most modern CLI tools (npm, rustup, Homebrew) display a brief, non-intrusive notice when a newer version is available. This helps users stay current without requiring them to actively poll for updates. + +The upgrade-command RFC explicitly listed "auto-update on every command invocation" as a non-goal and noted "periodic background check with opt-in notification" as a future enhancement. This RFC defines that enhancement. + +### Design Principles + +1. **Never block the user.** The check must not add latency to any command. +2. **Never be annoying.** The notice should be rare, single-line, and easy to suppress. +3. **Never phone home unexpectedly.** The network request is rate-limited and skipped in CI. + +## Goals + +1. Show a one-line upgrade notice when a newer version of `vp` is available +2. Zero impact on command latency (fully async, cached) +3. Reasonable default frequency (once per 24 hours) +4. Easy to disable via environment variable +5. Reuse the existing npm registry resolution from the upgrade command + +## Non-Goals + +1. Auto-installing updates (user must explicitly run `vp upgrade`) +2. Checking local `vite-plus` package versions (only the global CLI) +3. Showing notices for pre-release/test channel versions + +## User Stories + +### Story 1: New Version Available + +``` +$ vp build +...build output... + +vp update available: 0.1.0 → 0.2.0, run `vp upgrade` +``` + +### Story 2: Already Up to Date (no notice) + +``` +$ vp build +...build output... +``` + +No upgrade notice is shown — the user sees only their command output. + +### Story 3: CI Environment (no notice) + +``` +$ CI=true vp build +...build output... +``` + +Upgrade checks are completely disabled in CI. + +### Story 4: User Opts Out + +``` +$ VP_NO_UPDATE_CHECK=1 vp build +...build output... +``` + +No network request is made and no notice is shown. + +### Story 5: Offline / Registry Unreachable + +``` +$ vp build +...build output... +``` + +The check fails silently. No notice, no error, no retry spam. + +## Technical Design + +### Overview + +``` +Command starts + │ + ├──────────────────────────────┐ + │ │ + ▼ ▼ + Run the actual command Spawn background task: + │ 1. Check if cache is fresh (<24h) + │ → Yes: read cached version + │ → No: query npm registry, + │ write result to cache file + │ │ + ▼ ▼ + Command finishes Background task finishes + │ │ + ▼ ▼ + If newer version found, print one-line notice + Show tip (existing behavior) + Exit +``` + +The background task runs concurrently with the command. When the command finishes, we check if the background task has a result (with a very short timeout — if it hasn't finished, skip the notice this time). + +### Cache File + +Location: `~/.vite-plus/.upgrade-check.json` + +Format (single JSON line for simplicity): + +```json +{ "latest": "0.2.0", "checked_at": 1711500000, "prompted_at": 1711500000 } +``` + +- `latest`: The version string returned by the npm registry for the `latest` dist-tag +- `checked_at`: Unix timestamp (seconds) of when the registry was last queried +- `prompted_at`: Unix timestamp (seconds) of when the user was last shown the notice + +The file is small and cheap to read. A direct overwrite is sufficient — if corruption occurs (e.g., process killed mid-write), the worst case is one extra registry query. + +### Check Logic (Pseudocode) + +Two independent rate limits control the behavior: + +1. **`checked_at`** — controls how often the registry is queried (once per 24h) +2. **`prompted_at`** — controls how often the notice is shown (once per 24h) + +This means: the registry is queried at most once per day, and even if an update exists, the user sees the notice at most once per day. After displaying, `prompted_at` is updated so subsequent runs within 24h are silent. + +### Display + +The upgrade notice is printed to **stderr** (like tips), after the command output and before the tip line: + +``` +vp update available: 0.1.0 → 0.2.0, run `vp upgrade` +``` + +Styling: + +- Single line, no indentation +- Dimmed text with version numbers highlighted (current in dim, new in green bold) and `vp upgrade` highlighted + +The notice is printed **after** the command output and **before** any tip, so it feels like a natural postscript rather than an interruption. + +### Suppression Rules + +The notice is **not shown** when: + +| Condition | Reason | +| ------------------------------- | --------------------------------------------------------------- | +| `VP_NO_UPDATE_CHECK=1` | Explicit opt-out | +| `CI` is set | CI environments should not see upgrade prompts | +| `VITE_PLUS_CLI_TEST` is set | Test environments | +| Quiet/machine-readable flags | `--silent`, `-s`, `--json`, `--parseable`, `--format json/list` | +| `vp upgrade` is running | Already upgrading, don't nag | +| `vp upgrade --check` is running | Already checking, don't duplicate | +| Stderr is not a TTY | Non-interactive / piped / redirected output | +| Already prompted within 24h | Show at most once per day, not on every run | + +### Commands That Trigger the Check + +The background check runs on **all** commands except: + +- `vp upgrade` (already handles version checking) +- `vp implode` (removing the tool) +- `vp lint` / `vp fmt` (too fast to benefit from a background check) +- `vp --version` / `vp -V` (version display, keep it fast) +- Any command with quiet/machine-readable flags (`--silent`, `-s`, `--json`, `--parseable`, `--format json/list`) +- Shim invocations (`node`, `npm`, `npx` via vp) + +This keeps the check broadly useful without interfering with special commands. + +### Integration with Tips System + +The upgrade notice is **not** a tip — it is higher priority and displayed independently. When both an upgrade notice and a tip would be shown, both are displayed (notice first, then tip). The tip system's rate limiting and the upgrade check's rate limiting are independent. + +``` +...command output... + +vp update available: 0.1.0 → 0.2.0, run `vp upgrade` + +tip: short aliases available: i (install), rm (remove), un (uninstall), up (update), ls (list), ln (link) +``` + +### File Structure + +``` +crates/vite_global_cli/src/ +├── upgrade_check.rs # New: cache read/write, background check, display +├── main.rs # Modified: spawn check, display result after command +``` + +No new crate — this is a small, focused module in the existing `vite_global_cli` crate. It imports `resolve_version` from the existing `commands/upgrade/registry.rs`. + +### Implementation Details + +#### Async Background Check + +```rust +// In main.rs, before running the command: +let update_handle = if should_run_for_command(&args, &raw_args) { + Some(tokio::spawn(check_for_update())) +} else { + None +}; + +// After command completes: +if let Some(handle) = update_handle { + // Wait up to 500ms for the result — if the network is slow, skip it + match tokio::time::timeout(Duration::from_millis(500), handle).await { + Ok(Ok(Some(result))) => { + display_upgrade_notice(&result); // also records prompted_at + } + _ => {} // Timeout, error, or no update — silent + } +} +``` + +The 500ms timeout ensures that even if the registry is slow, the user's command exits promptly. In practice, most checks will read from cache (instant) or complete the network request during the time the actual command runs. + +`display_upgrade_notice` updates `prompted_at` in the cache file after showing the notice, so subsequent runs within 24h are silent. + +## Design Decisions + +### 1. Cache-Based Rate Limiting (Not Probabilistic) + +**Decision**: Check once per 24 hours, cached to disk. + +**Alternatives considered**: + +- Probabilistic (1-in-N chance per invocation) — simpler but inconsistent; unlucky users might never see the notice +- Timer-based without cache — would need a background daemon or cron job + +**Rationale**: Deterministic behavior, no surprises. The cache file is tiny and cheap to read. 24 hours is long enough to not annoy, short enough to be useful. + +### 2. Background Async (Not Post-Command Blocking) + +**Decision**: Spawn the registry query concurrently with the command. + +**Alternatives considered**: + +- Check after the command finishes — adds visible latency +- Separate background daemon — heavyweight, harder to manage + +**Rationale**: The registry query runs in parallel with the actual command. By the time the command finishes, the check is usually done. The 500ms timeout is a safety net for slow networks. + +### 3. Stderr for the Notice + +**Decision**: Print to stderr, not stdout. + +**Rationale**: Matches the tip system. Does not pollute stdout which may be piped or parsed. Tools that capture stdout (e.g., `result=$(vp ...)`) are unaffected. + +### 4. No Opt-In Required + +**Decision**: Enabled by default, with easy opt-out via `VP_NO_UPDATE_CHECK=1`. + +**Alternatives considered**: + +- Opt-in only — most users would never discover it +- Ask on first run — adds friction to installation + +**Rationale**: Most CLI tools (npm, pip, gh) enable update checks by default. The check is non-blocking and the notice is rare (at most once per 24 hours, only when an update exists). Users who don't want it can set a single env var. + +### 5. Semver Comparison (Not String Equality) + +**Decision**: Only show the notice when `latest` is strictly greater than `current` per semver. + +**Rationale**: String inequality would prompt prerelease/alpha users to "downgrade" to an older stable release. Semver comparison ensures the notice only appears for genuine upgrades. Dev builds (`0.0.0`) are skipped entirely. + +## Testing Strategy + +### Unit Tests + +- Cache read/write: valid JSON, corrupt file, missing file +- `should_check`: respects env vars, cache freshness, TTY detection +- Version comparison: same version, different version, pre-release + +### Integration Tests + +- Mock registry server returning a version, verify notice is displayed +- Verify no notice when cache is fresh +- Verify no notice in CI mode +- Verify timeout behavior (slow mock server) + +### Manual Testing + +```bash +# Clear cache to force a fresh check +rm ~/.vite-plus/.upgrade-check.json + +# Run any command — should show notice if behind latest +vp --version + +# Run again immediately — should not re-query (cached) +vp build + +# Disable and verify +VP_NO_UPDATE_CHECK=1 vp build +``` + +## References + +- [RFC: Self-Update Command](./upgrade-command.md) +- [RFC: CLI Tips](./cli-tips.md) +- [npm update-notifier pattern](https://github.com/yeoman/update-notifier) +- [Rust CLI update check (cargo-update)](https://github.com/nabijaczleweli/cargo-update)