From f0f1b225410713aedbfb8d17b762cf0d634e1844 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 27 Mar 2026 22:48:58 +0800 Subject: [PATCH 01/10] feat(cli): add non-blocking upgrade check with cached registry query Show a one-line upgrade notice on stderr when a newer version of vp is available. The check runs as a background async task concurrently with the command, cached to ~/.vite-plus/.upgrade-check.json (24h TTL), and suppressed in CI, test mode, non-TTY, or via VP_NO_UPDATE_CHECK=1. --- crates/vite_global_cli/src/main.rs | 21 +- crates/vite_global_cli/src/upgrade_check.rs | 218 ++++++++++++ rfcs/upgrade-check.md | 347 ++++++++++++++++++++ 3 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 crates/vite_global_cli/src/upgrade_check.rs create mode 100644 rfcs/upgrade-check.md diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 9f8896d859..89ea550593 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 update_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) = update_handle + && let Ok(Ok(Some(new_version))) = + tokio::time::timeout(std::time::Duration::from_millis(500), handle).await + { + upgrade_check::display_upgrade_notice(env!("CARGO_PKG_VERSION"), &new_version); + } + 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..da290a4cbb --- /dev/null +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -0,0 +1,218 @@ +//! 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. + +use std::{ + io::IsTerminal, + time::{SystemTime, UNIX_EPOCH}, +}; + +use owo_colors::OwoColorize; +use serde::{Deserialize, Serialize}; +use vite_install::{config::npm_registry, request::HttpClient}; + +const CHECK_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, Serialize, Deserialize)] +struct UpgradeCheckCache { + latest: String, + checked_at: u64, +} + +#[expect(clippy::disallowed_types)] // String required for serde deserialization +#[derive(Deserialize)] +struct VersionOnly { + version: String, +} + +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); + let tmp_path = install_dir.join(".upgrade-check.json.tmp"); + let Ok(data) = serde_json::to_string(cache) else { + return; + }; + if std::fs::write(tmp_path.as_path(), &data).is_ok() { + let _ = std::fs::rename(tmp_path.as_path(), cache_path.as_path()); + } +} + +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) +} + +#[expect(clippy::disallowed_types)] // String returned from serde deserialization +async fn resolve_latest_version() -> Option { + let registry_raw = npm_registry(); + let registry = registry_raw.trim_end_matches('/'); + let url = vite_str::format!("{registry}/vite-plus/latest"); + let client = HttpClient::new(); + let meta: VersionOnly = client.get_json(&url).await.ok()?; + Some(meta.version) +} + +/// Returns the latest version string if it differs from the current version, +/// or `None` if up to date / check disabled / network error. +#[expect(clippy::disallowed_types)] // String returned to caller for display +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 cache = read_cache(&install_dir); + let now = now_secs(); + + if !should_check(cache.as_ref(), now) { + return cache.filter(|c| c.latest != current_version).map(|c| c.latest); + } + + let latest = resolve_latest_version().await?; + write_cache(&install_dir, &UpgradeCheckCache { latest: latest.clone(), checked_at: now }); + (latest != current_version).then_some(latest) +} + +/// Print a one-line upgrade notice to stderr. +#[expect(clippy::print_stderr, clippy::disallowed_macros)] +pub fn display_upgrade_notice(current_version: &str, new_version: &str) { + if !std::io::stderr().is_terminal() { + return; + } + eprintln!( + "\n{} {} {} {}, run {}", + "vp update available:".bright_black(), + current_version.bright_black(), + "\u{2192}".bright_black(), + new_version.green().bold(), + "`vp upgrade`".bright_black().bold(), + ); +} + +/// Whether the upgrade check should run for the given command args. +pub fn should_run_for_command(args: &crate::cli::Args) -> bool { + if args.version { + return false; + } + + !matches!( + &args.command, + Some(crate::cli::Commands::Upgrade { .. } | crate::cli::Commands::Implode { .. }) + ) +} + +#[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 }; + 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); + } + + #[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 }; + 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 }; + 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())); + }); + } +} diff --git a/rfcs/upgrade-check.md b/rfcs/upgrade-check.md new file mode 100644 index 0000000000..3283dbd545 --- /dev/null +++ b/rfcs/upgrade-check.md @@ -0,0 +1,347 @@ +# 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 } +``` + +- `latest`: The version string returned by the npm registry for the `latest` dist-tag +- `checked_at`: Unix timestamp (seconds) of when the check was performed + +The file is small, atomic to write (write to temp + rename), and cheap to read. + +### Check Logic (Pseudocode) + +```rust +fn should_check(cache: Option<&UpdateCheckCache>) -> bool { + // Skip if disabled + if env_var("VP_NO_UPDATE_CHECK").is_some() { return false; } + if env_var("CI").is_some() { return false; } + if env_var("VITE_PLUS_CLI_TEST").is_some() { return false; } + + match cache { + Some(c) => now() - c.checked_at > 24 * 60 * 60, // 24 hours + None => true, // No cache, first check + } +} + +async fn check_for_update() -> Option { + let cache = read_cache(); // Returns None if file missing or corrupt + + if !should_check(cache.as_ref()) { + // Use cached result (may be None if no cache exists) + return cache.and_then(|c| { + if c.latest != current_version() { Some(c.latest) } else { None } + }); + } + + // Query registry (reuse existing resolve_version from upgrade command) + match resolve_latest_version().await { + Ok(version) => { + write_cache(&UpdateCheckCache { + latest: version.clone(), + checked_at: now(), + }); + if version != current_version() { Some(version) } else { None } + } + Err(_) => None, // Silent failure + } +} +``` + +### 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 | +| `--silent` flag is used | User requested no extra output | +| `--json` output mode | Machine-readable output should not contain notices | +| `vp upgrade` is running | Already upgrading, don't nag | +| `vp upgrade --check` is running | Already checking, don't duplicate | +| Stderr is not a TTY | Piped/redirected output | + +### 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 --version` / `vp -V` (version display, keep it fast) +- 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_update_check(&command) { + 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(new_version))) => { + display_upgrade_notice(¤t_version, &new_version); + } + _ => {} // 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. + +#### Cache Atomicity + +```rust +fn write_cache(cache: &UpdateCheckCache) -> Result<()> { + let cache_path = vite_plus_home().join(".upgrade-check.json"); + let tmp_path = cache_path.with_extension("tmp"); + fs::write(&tmp_path, serde_json::to_string(cache)?)?; + fs::rename(&tmp_path, &cache_path)?; + Ok(()) +} +``` + +Write to a temp file then rename — atomic on both Unix and Windows (NTFS). + +## 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. Flat Version Comparison (Not Semver) + +**Decision**: Compare version strings for equality, not semver ordering. + +**Rationale**: If the registry says `latest` is `0.2.0` and the user is on `0.2.0`, there's no update. If they're on anything else (older or newer), show the notice. This handles edge cases like downgrading from a pre-release to stable. Matches the existing `vp upgrade` comparison logic. + +## 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) From bd8a6271a83193e5947062fbe22fad06d8d8a72a Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 27 Mar 2026 23:07:43 +0800 Subject: [PATCH 02/10] =?UTF-8?q?refactor(cli):=20simplify=20upgrade=20che?= =?UTF-8?q?ck=20=E2=80=94=20direct=20file=20write,=20internalize=20current?= =?UTF-8?q?=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace atomic temp+rename with direct overwrite in write_cache - Move current_version into display_upgrade_notice (callers don't need to pass it) - Update RFC to match --- crates/vite_global_cli/src/main.rs | 2 +- crates/vite_global_cli/src/upgrade_check.rs | 11 ++++------- rfcs/upgrade-check.md | 18 ++---------------- 3 files changed, 7 insertions(+), 24 deletions(-) diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 89ea550593..ebde2e791b 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -371,7 +371,7 @@ async fn main() -> ExitCode { && let Ok(Ok(Some(new_version))) = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await { - upgrade_check::display_upgrade_notice(env!("CARGO_PKG_VERSION"), &new_version); + upgrade_check::display_upgrade_notice(&new_version); } tip_context.exit_code = if exit_code == ExitCode::SUCCESS { 0 } else { 1 }; diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index da290a4cbb..383bef9b33 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -37,12 +37,8 @@ fn read_cache(install_dir: &vite_path::AbsolutePath) -> Option Option { /// Print a one-line upgrade notice to stderr. #[expect(clippy::print_stderr, clippy::disallowed_macros)] -pub fn display_upgrade_notice(current_version: &str, new_version: &str) { +pub fn display_upgrade_notice(new_version: &str) { + let current_version = env!("CARGO_PKG_VERSION"); if !std::io::stderr().is_terminal() { return; } diff --git a/rfcs/upgrade-check.md b/rfcs/upgrade-check.md index 3283dbd545..4c38bf541f 100644 --- a/rfcs/upgrade-check.md +++ b/rfcs/upgrade-check.md @@ -117,7 +117,7 @@ Format (single JSON line for simplicity): - `latest`: The version string returned by the npm registry for the `latest` dist-tag - `checked_at`: Unix timestamp (seconds) of when the check was performed -The file is small, atomic to write (write to temp + rename), and cheap to read. +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) @@ -238,7 +238,7 @@ 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(new_version))) => { - display_upgrade_notice(¤t_version, &new_version); + display_upgrade_notice(&new_version); } _ => {} // Timeout, error, or no update — silent } @@ -247,20 +247,6 @@ if let Some(handle) = update_handle { 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. -#### Cache Atomicity - -```rust -fn write_cache(cache: &UpdateCheckCache) -> Result<()> { - let cache_path = vite_plus_home().join(".upgrade-check.json"); - let tmp_path = cache_path.with_extension("tmp"); - fs::write(&tmp_path, serde_json::to_string(cache)?)?; - fs::rename(&tmp_path, &cache_path)?; - Ok(()) -} -``` - -Write to a temp file then rename — atomic on both Unix and Windows (NTFS). - ## Design Decisions ### 1. Cache-Based Rate Limiting (Not Probabilistic) From 1568bd2d60e6800e3f2cd44a3425c3405f69b6e5 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 27 Mar 2026 23:20:23 +0800 Subject: [PATCH 03/10] fix(cli): suppress upgrade check for --silent, --json, lint, and fmt Skip the background upgrade check when: - Command has --silent or --json flag (respects quiet/machine output) - Command is `vp lint` or `vp fmt` (too fast to benefit) --- crates/vite_global_cli/src/main.rs | 2 +- crates/vite_global_cli/src/upgrade_check.rs | 69 +++++++++++++++++++-- rfcs/upgrade-check.md | 2 + 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index ebde2e791b..26c6b9a01d 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -285,7 +285,7 @@ async fn main() -> ExitCode { // Spawn background upgrade check for eligible commands let update_handle = match &parse_result { - Ok(args) if upgrade_check::should_run_for_command(args) => { + Ok(args) if upgrade_check::should_run_for_command(args, &tip_context.raw_args) => { Some(tokio::spawn(upgrade_check::check_for_update())) } _ => None, diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index 383bef9b33..20c6fbc66d 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -103,15 +103,36 @@ pub fn display_upgrade_notice(new_version: &str) { } /// Whether the upgrade check should run for the given command args. -pub fn should_run_for_command(args: &crate::cli::Args) -> bool { +/// Returns `false` for commands excluded by design (upgrade, implode, --version) +/// and for any command invoked with `--silent` or `--json`. +pub fn should_run_for_command(args: &crate::cli::Args, raw_args: &[String]) -> bool { if args.version { return false; } - !matches!( + if matches!( &args.command, - Some(crate::cli::Commands::Upgrade { .. } | crate::cli::Commands::Implode { .. }) - ) + Some( + crate::cli::Commands::Upgrade { .. } + | crate::cli::Commands::Implode { .. } + | crate::cli::Commands::Lint { .. } + | crate::cli::Commands::Fmt { .. } + ) + ) { + return false; + } + + // Suppress for --silent and --json flags (before -- terminator) + for arg in raw_args { + if arg == "--" { + break; + } + if arg == "--silent" || arg == "--json" { + return false; + } + } + + true } #[cfg(test)] @@ -212,4 +233,44 @@ mod tests { assert!(!should_check(None, now_secs())); }); } + + 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() + } + + fn raw_args(args: &[&str]) -> Vec { + args.iter().map(|s| String::from(*s)).collect() + } + + #[test] + fn should_run_for_normal_command() { + let args = parse_args(&["build"]); + assert!(should_run_for_command(&args, &raw_args(&["build"]))); + } + + #[test] + fn should_not_run_for_upgrade() { + let args = parse_args(&["upgrade"]); + assert!(!should_run_for_command(&args, &raw_args(&["upgrade"]))); + } + + #[test] + fn should_not_run_for_silent_flag() { + let args = parse_args(&["install", "--silent"]); + assert!(!should_run_for_command(&args, &raw_args(&["install", "--silent"]))); + } + + #[test] + fn should_not_run_for_json_flag() { + let args = parse_args(&["why", "lodash", "--json"]); + assert!(!should_run_for_command(&args, &raw_args(&["why", "lodash", "--json"]))); + } + + #[test] + fn should_run_when_json_after_terminator() { + let args = parse_args(&["build"]); + assert!(should_run_for_command(&args, &raw_args(&["build", "--", "--json"]))); + } } diff --git a/rfcs/upgrade-check.md b/rfcs/upgrade-check.md index 4c38bf541f..6a8f9976d1 100644 --- a/rfcs/upgrade-check.md +++ b/rfcs/upgrade-check.md @@ -194,7 +194,9 @@ 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 `--silent` or `--json` (quiet/machine-readable output) - Shim invocations (`node`, `npm`, `npx` via vp) This keeps the check broadly useful without interfering with special commands. From 2c102b2490a87b6fc900394e6230f9cf0ff69e29 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 27 Mar 2026 23:45:28 +0800 Subject: [PATCH 04/10] fix(cli): rate-limit upgrade notice to once per 24h and skip non-interactive Add prompted_at to cache so the notice shows at most once per day (like Deno/gh), not on every run. Skip the entire upgrade check when stderr is not a TTY. --- crates/vite_global_cli/src/main.rs | 4 +- crates/vite_global_cli/src/upgrade_check.rs | 110 ++++++++++++++++---- rfcs/upgrade-check.md | 54 +++------- 3 files changed, 107 insertions(+), 61 deletions(-) diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 26c6b9a01d..e9045362de 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -368,10 +368,10 @@ async fn main() -> ExitCode { // Display upgrade notice if a newer version is available if let Some(handle) = update_handle - && let Ok(Ok(Some(new_version))) = + && let Ok(Ok(Some(result))) = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await { - upgrade_check::display_upgrade_notice(&new_version); + upgrade_check::display_upgrade_notice(&result); } tip_context.exit_code = if exit_code == ExitCode::SUCCESS { 0 } else { 1 }; diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index 20c6fbc66d..681cdfe55f 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -2,7 +2,7 @@ //! //! 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. +//! stderr when a newer version is available, at most once per 24 hours. use std::{ io::IsTerminal, @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use vite_install::{config::npm_registry, request::HttpClient}; 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 @@ -21,6 +22,7 @@ const CACHE_FILE_NAME: &str = ".upgrade-check.json"; struct UpgradeCheckCache { latest: String, checked_at: u64, + prompted_at: u64, } #[expect(clippy::disallowed_types)] // String required for serde deserialization @@ -57,6 +59,10 @@ fn should_check(cache: Option<&UpgradeCheckCache>, now: u64) -> bool { 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) +} + #[expect(clippy::disallowed_types)] // String returned from serde deserialization async fn resolve_latest_version() -> Option { let registry_raw = npm_registry(); @@ -67,45 +73,72 @@ async fn resolve_latest_version() -> Option { Some(meta.version) } -/// Returns the latest version string if it differs from the current version, -/// or `None` if up to date / check disabled / network error. +/// Result of the upgrade check: the new version to display, plus the install +/// dir needed to update `prompted_at` after display. +pub struct UpgradeCheckResult { + pub new_version: String, + install_dir: vite_path::AbsolutePathBuf, +} + +/// 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. #[expect(clippy::disallowed_types)] // String returned to caller for display -pub async fn check_for_update() -> Option { +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 cache = read_cache(&install_dir); let now = now_secs(); + let mut cache = read_cache(&install_dir); + + if should_check(cache.as_ref(), now) { + // Query registry and update cache + let latest = resolve_latest_version().await?; + let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); + let new_cache = UpgradeCheckCache { latest: latest.clone(), checked_at: now, prompted_at }; + write_cache(&install_dir, &new_cache); + cache = Some(new_cache); + } + + let cache = cache?; - if !should_check(cache.as_ref(), now) { - return cache.filter(|c| c.latest != current_version).map(|c| c.latest); + if cache.latest == current_version { + return None; } - let latest = resolve_latest_version().await?; - write_cache(&install_dir, &UpgradeCheckCache { latest: latest.clone(), checked_at: now }); - (latest != current_version).then_some(latest) + if !should_prompt(Some(&cache), now) { + return None; + } + + Some(UpgradeCheckResult { new_version: cache.latest, install_dir }) } -/// Print a one-line upgrade notice to stderr. +/// 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(new_version: &str) { +pub fn display_upgrade_notice(result: &UpgradeCheckResult) { let current_version = env!("CARGO_PKG_VERSION"); - if !std::io::stderr().is_terminal() { - return; - } eprintln!( "\n{} {} {} {}, run {}", "vp update available:".bright_black(), current_version.bright_black(), "\u{2192}".bright_black(), - new_version.green().bold(), + result.new_version.green().bold(), "`vp upgrade`".bright_black().bold(), ); + + // Record that we prompted, so we don't nag again for 24h + if let Some(mut cache) = read_cache(&result.install_dir) { + 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 (upgrade, implode, --version) /// and for any command invoked with `--silent` or `--json`. pub fn should_run_for_command(args: &crate::cli::Args, raw_args: &[String]) -> bool { + if !cfg!(test) && !std::io::stderr().is_terminal() { + return false; + } + if args.version { return false; } @@ -146,12 +179,14 @@ mod tests { 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 }; + 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] @@ -207,7 +242,8 @@ mod tests { 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 }; + let cache = + UpgradeCheckCache { latest: "1.0.0".to_owned(), checked_at: now, prompted_at: 0 }; assert!(!should_check(Some(&cache), now)); }); } @@ -218,7 +254,11 @@ mod tests { 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 }; + let cache = UpgradeCheckCache { + latest: "1.0.0".to_owned(), + checked_at: stale_time, + prompted_at: 0, + }; assert!(should_check(Some(&cache), now)); }); } @@ -234,6 +274,38 @@ mod tests { }); } + #[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)); + } + fn parse_args(args: &[&str]) -> crate::cli::Args { let full: Vec = std::iter::once("vp").chain(args.iter().copied()).map(String::from).collect(); diff --git a/rfcs/upgrade-check.md b/rfcs/upgrade-check.md index 6a8f9976d1..1a5ecc02fb 100644 --- a/rfcs/upgrade-check.md +++ b/rfcs/upgrade-check.md @@ -111,52 +111,23 @@ Location: `~/.vite-plus/.upgrade-check.json` Format (single JSON line for simplicity): ```json -{ "latest": "0.2.0", "checked_at": 1711500000 } +{ "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 check was performed +- `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) -```rust -fn should_check(cache: Option<&UpdateCheckCache>) -> bool { - // Skip if disabled - if env_var("VP_NO_UPDATE_CHECK").is_some() { return false; } - if env_var("CI").is_some() { return false; } - if env_var("VITE_PLUS_CLI_TEST").is_some() { return false; } - - match cache { - Some(c) => now() - c.checked_at > 24 * 60 * 60, // 24 hours - None => true, // No cache, first check - } -} +Two independent rate limits control the behavior: -async fn check_for_update() -> Option { - let cache = read_cache(); // Returns None if file missing or corrupt - - if !should_check(cache.as_ref()) { - // Use cached result (may be None if no cache exists) - return cache.and_then(|c| { - if c.latest != current_version() { Some(c.latest) } else { None } - }); - } +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) - // Query registry (reuse existing resolve_version from upgrade command) - match resolve_latest_version().await { - Ok(version) => { - write_cache(&UpdateCheckCache { - latest: version.clone(), - checked_at: now(), - }); - if version != current_version() { Some(version) } else { None } - } - Err(_) => None, // Silent failure - } -} -``` +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 @@ -186,7 +157,8 @@ The notice is **not shown** when: | `--json` output mode | Machine-readable output should not contain notices | | `vp upgrade` is running | Already upgrading, don't nag | | `vp upgrade --check` is running | Already checking, don't duplicate | -| Stderr is not a TTY | Piped/redirected output | +| 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 @@ -229,7 +201,7 @@ No new crate — this is a small, focused module in the existing `vite_global_cl ```rust // In main.rs, before running the command: -let update_handle = if should_run_update_check(&command) { +let update_handle = if should_run_for_command(&args, &raw_args) { Some(tokio::spawn(check_for_update())) } else { None @@ -239,8 +211,8 @@ let update_handle = if should_run_update_check(&command) { 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(new_version))) => { - display_upgrade_notice(&new_version); + Ok(Ok(Some(result))) => { + display_upgrade_notice(&result); // also records prompted_at } _ => {} // Timeout, error, or no update — silent } @@ -249,6 +221,8 @@ if let Some(handle) = update_handle { 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) From fa90f4374ff95373946733f01ffc9a3aa6c86532 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 27 Mar 2026 23:52:33 +0800 Subject: [PATCH 05/10] =?UTF-8?q?refactor(cli):=20clean=20up=20upgrade=20c?= =?UTF-8?q?heck=20=E2=80=94=20remove=20redundant=20state=20and=20fix=20nam?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant new_version field from UpgradeCheckResult (use cache.latest) - Carry cache through UpgradeCheckResult to avoid re-reading from disk - Remove unnecessary latest.clone() — move ownership directly - Rename update_handle → upgrade_handle for consistency with module name - Remove comments that restate what the code does --- crates/vite_global_cli/src/main.rs | 4 ++-- crates/vite_global_cli/src/upgrade_check.rs | 22 ++++++++------------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index e9045362de..c51e866ef5 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -284,7 +284,7 @@ async fn main() -> ExitCode { let parse_result = try_parse_args_from(normalized_args); // Spawn background upgrade check for eligible commands - let update_handle = match &parse_result { + let upgrade_handle = match &parse_result { Ok(args) if upgrade_check::should_run_for_command(args, &tip_context.raw_args) => { Some(tokio::spawn(upgrade_check::check_for_update())) } @@ -367,7 +367,7 @@ async fn main() -> ExitCode { }; // Display upgrade notice if a newer version is available - if let Some(handle) = update_handle + if let Some(handle) = upgrade_handle && let Ok(Ok(Some(result))) = tokio::time::timeout(std::time::Duration::from_millis(500), handle).await { diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index 681cdfe55f..6ab03e8f67 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -18,7 +18,7 @@ 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, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct UpgradeCheckCache { latest: String, checked_at: u64, @@ -73,11 +73,9 @@ async fn resolve_latest_version() -> Option { Some(meta.version) } -/// Result of the upgrade check: the new version to display, plus the install -/// dir needed to update `prompted_at` after display. pub struct UpgradeCheckResult { - pub new_version: String, install_dir: vite_path::AbsolutePathBuf, + cache: UpgradeCheckCache, } /// Returns an upgrade check result if a newer version is available and the user @@ -90,10 +88,9 @@ pub async fn check_for_update() -> Option { let mut cache = read_cache(&install_dir); if should_check(cache.as_ref(), now) { - // Query registry and update cache let latest = resolve_latest_version().await?; let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); - let new_cache = UpgradeCheckCache { latest: latest.clone(), checked_at: now, prompted_at }; + let new_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; write_cache(&install_dir, &new_cache); cache = Some(new_cache); } @@ -108,7 +105,7 @@ pub async fn check_for_update() -> Option { return None; } - Some(UpgradeCheckResult { new_version: cache.latest, install_dir }) + Some(UpgradeCheckResult { install_dir, cache }) } /// Print a one-line upgrade notice to stderr and record the prompt time. @@ -120,15 +117,13 @@ pub fn display_upgrade_notice(result: &UpgradeCheckResult) { "vp update available:".bright_black(), current_version.bright_black(), "\u{2192}".bright_black(), - result.new_version.green().bold(), + result.cache.latest.bright_green().bold(), "`vp upgrade`".bright_black().bold(), ); - // Record that we prompted, so we don't nag again for 24h - if let Some(mut cache) = read_cache(&result.install_dir) { - cache.prompted_at = now_secs(); - write_cache(&result.install_dir, &cache); - } + 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. @@ -155,7 +150,6 @@ pub fn should_run_for_command(args: &crate::cli::Args, raw_args: &[String]) -> b return false; } - // Suppress for --silent and --json flags (before -- terminator) for arg in raw_args { if arg == "--" { break; From bef4c183f0e4b0987042d30804d3e67bc39f367c Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 28 Mar 2026 00:20:29 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(cli):=20fix=20upgrade=20notice=20styl?= =?UTF-8?q?ing=20=E2=80=94=20dim=20separator,=20highlight=20vp=20upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vite_global_cli/src/upgrade_check.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index 6ab03e8f67..5566bbe5f1 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -113,12 +113,13 @@ pub async fn check_for_update() -> Option { pub fn display_upgrade_notice(result: &UpgradeCheckResult) { let current_version = env!("CARGO_PKG_VERSION"); eprintln!( - "\n{} {} {} {}, run {}", + "\n{} {} {} {}{} {}", "vp update available:".bright_black(), current_version.bright_black(), "\u{2192}".bright_black(), result.cache.latest.bright_green().bold(), - "`vp upgrade`".bright_black().bold(), + ", run".bright_black(), + "vp upgrade".bright_green().bold(), ); let mut cache = result.cache.clone(); From f7bde2979108d76d7a81c2391396624cccc8f5cd Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 28 Mar 2026 00:50:32 +0800 Subject: [PATCH 07/10] refactor(cli): split registry resolution and back off on failed checks Split resolve_version into resolve_version_string (1 HTTP call, resolves tag to version) and resolve_platform_package (1 HTTP call, fetches platform tarball metadata). The upgrade check now uses only resolve_version_string for a single lightweight HTTP request. Also record checked_at on failed registry calls so offline users back off for 24h instead of retrying on every command. --- .../src/commands/upgrade/mod.rs | 2 +- .../src/commands/upgrade/registry.rs | 49 ++++++++++++++----- crates/vite_global_cli/src/upgrade_check.rs | 41 ++++++++-------- 3 files changed, 60 insertions(+), 32 deletions(-) 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/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index 5566bbe5f1..c4e19de20f 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -11,7 +11,8 @@ use std::{ use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; -use vite_install::{config::npm_registry, request::HttpClient}; + +use crate::commands::upgrade::registry; const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; const PROMPT_INTERVAL_SECS: u64 = 24 * 60 * 60; @@ -25,12 +26,6 @@ struct UpgradeCheckCache { prompted_at: u64, } -#[expect(clippy::disallowed_types)] // String required for serde deserialization -#[derive(Deserialize)] -struct VersionOnly { - version: String, -} - 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()?; @@ -64,13 +59,8 @@ fn should_prompt(cache: Option<&UpgradeCheckCache>, now: u64) -> bool { } #[expect(clippy::disallowed_types)] // String returned from serde deserialization -async fn resolve_latest_version() -> Option { - let registry_raw = npm_registry(); - let registry = registry_raw.trim_end_matches('/'); - let url = vite_str::format!("{registry}/vite-plus/latest"); - let client = HttpClient::new(); - let meta: VersionOnly = client.get_json(&url).await.ok()?; - Some(meta.version) +async fn resolve_version_string() -> Option { + registry::resolve_version_string("latest", None).await.ok() } pub struct UpgradeCheckResult { @@ -80,7 +70,6 @@ pub struct UpgradeCheckResult { /// 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. -#[expect(clippy::disallowed_types)] // String returned to caller for display pub async fn check_for_update() -> Option { let install_dir = vite_shared::get_vite_plus_home().ok()?; let current_version = env!("CARGO_PKG_VERSION"); @@ -88,16 +77,28 @@ pub async fn check_for_update() -> Option { let mut cache = read_cache(&install_dir); if should_check(cache.as_ref(), now) { - let latest = resolve_latest_version().await?; let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); - let new_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &new_cache); - cache = Some(new_cache); + + 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 cache.latest == current_version { + if cache.latest.is_empty() || cache.latest == current_version { return None; } From bd3b28886875796ce24fe80bd1dcc9770be6c962 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 28 Mar 2026 09:55:03 +0800 Subject: [PATCH 08/10] fix(cli): use semver comparison for upgrade check, skip dev and prerelease downgrades Replace string equality check with semver-aware is_newer_version() that only prompts when the latest stable version is strictly newer. Prevents prerelease/alpha users from being prompted to downgrade to stable, and skips dev builds (0.0.0) entirely. --- crates/vite_global_cli/src/upgrade_check.rs | 64 ++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index c4e19de20f..23543b23fe 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -58,6 +58,18 @@ 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() @@ -98,7 +110,7 @@ pub async fn check_for_update() -> Option { let cache = cache?; - if cache.latest.is_empty() || cache.latest == current_version { + if !is_newer_version(current_version, &cache.latest) { return None; } @@ -302,6 +314,56 @@ mod tests { 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(); From 47327363c32879ca745af19f030d1246093648f9 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 28 Mar 2026 14:57:23 +0800 Subject: [PATCH 09/10] fix(cli): suppress upgrade check for all parsed quiet/machine-readable flags Replace raw argv string scan with parsed command variant matching via is_quiet_or_machine_readable() methods on Commands and nested enums. Now correctly handles -s (short silent), --parseable, --format json/list, and --json on nested subcommands (pm, env, config, token). --- crates/vite_global_cli/src/cli.rs | 70 +++++++++++++++++ crates/vite_global_cli/src/main.rs | 2 +- crates/vite_global_cli/src/upgrade_check.rs | 87 ++++++++++++--------- 3 files changed, 119 insertions(+), 40 deletions(-) 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/main.rs b/crates/vite_global_cli/src/main.rs index c51e866ef5..0e28863792 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -285,7 +285,7 @@ async fn main() -> ExitCode { // Spawn background upgrade check for eligible commands let upgrade_handle = match &parse_result { - Ok(args) if upgrade_check::should_run_for_command(args, &tip_context.raw_args) => { + Ok(args) if upgrade_check::should_run_for_command(args) => { Some(tokio::spawn(upgrade_check::check_for_update())) } _ => None, diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index 23543b23fe..4b72669eee 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -141,9 +141,9 @@ pub fn display_upgrade_notice(result: &UpgradeCheckResult) { } /// Whether the upgrade check should run for the given command args. -/// Returns `false` for commands excluded by design (upgrade, implode, --version) -/// and for any command invoked with `--silent` or `--json`. -pub fn should_run_for_command(args: &crate::cli::Args, raw_args: &[String]) -> bool { +/// 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; } @@ -152,28 +152,16 @@ pub fn should_run_for_command(args: &crate::cli::Args, raw_args: &[String]) -> b return false; } - if matches!( - &args.command, + match &args.command { Some( crate::cli::Commands::Upgrade { .. } - | crate::cli::Commands::Implode { .. } - | crate::cli::Commands::Lint { .. } - | crate::cli::Commands::Fmt { .. } - ) - ) { - return false; + | crate::cli::Commands::Implode { .. } + | crate::cli::Commands::Lint { .. } + | crate::cli::Commands::Fmt { .. }, + ) => false, + Some(cmd) => !cmd.is_quiet_or_machine_readable(), + None => true, } - - for arg in raw_args { - if arg == "--" { - break; - } - if arg == "--silent" || arg == "--json" { - return false; - } - } - - true } #[cfg(test)] @@ -370,37 +358,58 @@ mod tests { crate::try_parse_args_from(full).unwrap() } - fn raw_args(args: &[&str]) -> Vec { - args.iter().map(|s| String::from(*s)).collect() - } - #[test] fn should_run_for_normal_command() { - let args = parse_args(&["build"]); - assert!(should_run_for_command(&args, &raw_args(&["build"]))); + assert!(should_run_for_command(&parse_args(&["build"]))); } #[test] fn should_not_run_for_upgrade() { - let args = parse_args(&["upgrade"]); - assert!(!should_run_for_command(&args, &raw_args(&["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_silent_flag() { - let args = parse_args(&["install", "--silent"]); - assert!(!should_run_for_command(&args, &raw_args(&["install", "--silent"]))); + fn should_not_run_for_pm_list_json() { + assert!(!should_run_for_command(&parse_args(&["pm", "list", "--json"]))); } #[test] - fn should_not_run_for_json_flag() { - let args = parse_args(&["why", "lodash", "--json"]); - assert!(!should_run_for_command(&args, &raw_args(&["why", "lodash", "--json"]))); + fn should_not_run_for_env_current_json() { + assert!(!should_run_for_command(&parse_args(&["env", "current", "--json"]))); } #[test] - fn should_run_when_json_after_terminator() { - let args = parse_args(&["build"]); - assert!(should_run_for_command(&args, &raw_args(&["build", "--", "--json"]))); + fn should_run_for_outdated_without_format() { + assert!(should_run_for_command(&parse_args(&["outdated"]))); } } From c2a8b8db216f6476745ad223d0a9fc4404d864f4 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 28 Mar 2026 14:58:55 +0800 Subject: [PATCH 10/10] docs: update upgrade-check RFC to reflect parsed flags and semver comparison --- rfcs/upgrade-check.md | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/rfcs/upgrade-check.md b/rfcs/upgrade-check.md index 1a5ecc02fb..d2093d2a1d 100644 --- a/rfcs/upgrade-check.md +++ b/rfcs/upgrade-check.md @@ -148,17 +148,16 @@ The notice is printed **after** the command output and **before** any tip, so it 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 | -| `--silent` flag is used | User requested no extra output | -| `--json` output mode | Machine-readable output should not contain notices | -| `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 | +| 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 @@ -168,7 +167,7 @@ The background check runs on **all** commands except: - `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 `--silent` or `--json` (quiet/machine-readable output) +- 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. @@ -264,11 +263,11 @@ The 500ms timeout ensures that even if the registry is slow, the user's command **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. Flat Version Comparison (Not Semver) +### 5. Semver Comparison (Not String Equality) -**Decision**: Compare version strings for equality, not semver ordering. +**Decision**: Only show the notice when `latest` is strictly greater than `current` per semver. -**Rationale**: If the registry says `latest` is `0.2.0` and the user is on `0.2.0`, there's no update. If they're on anything else (older or newer), show the notice. This handles edge cases like downgrading from a pre-release to stable. Matches the existing `vp upgrade` comparison logic. +**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