Skip to content

Commit db75675

Browse files
committed
feat(cli): non-blocking upgrade check with rate-limited notice (#1173)
## Summary - Add background upgrade check that queries the npm registry for the latest `vp` version - Show a one-line notice on stderr at most **once per 24 hours** (not on every run) - Registry query is also rate-limited to once per 24 hours, cached to `~/.vite-plus/.upgrade-check.json` - Runs as an async background task concurrently with the command — zero latency impact ## Suppression The check is skipped entirely when: - `VP_NO_UPDATE_CHECK=1`, `CI`, or `VITE_PLUS_CLI_TEST` is set - Stderr is not a TTY (non-interactive / piped) - Command is `upgrade`, `implode`, `lint`, or `fmt` - Command has `--silent` or `--json` flag ## RFC See `rfcs/upgrade-check.md` for full design rationale. ![image.png](https://app.graphite.com/user-attachments/assets/da1a8f7b-12bb-49c3-8f0d-9133d04c8bad.png) ## Test plan - [x] `cargo test -p vite_global_cli -- upgrade_check` (16 tests) - [x] Manual: `rm ~/.vite-plus/.upgrade-check.json && vp build` — shows notice if behind latest - [x] Manual: `vp build` again — no notice (prompted_at within 24h) - [ ] Manual: `VP_NO_UPDATE_CHECK=1 vp build` — no notice - [ ] Manual: `vp install --silent` — no notice
1 parent 6910ba8 commit db75675

6 files changed

Lines changed: 852 additions & 13 deletions

File tree

crates/vite_global_cli/src/cli.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,32 @@ pub enum Commands {
690690
},
691691
}
692692

693+
impl Commands {
694+
/// Whether the command was invoked with flags that request quiet or
695+
/// machine-readable output (--silent, -s, --json, --parseable, --format json/list).
696+
pub fn is_quiet_or_machine_readable(&self) -> bool {
697+
match self {
698+
Self::Install { silent, .. }
699+
| Self::Dlx { silent, .. }
700+
| Self::Upgrade { silent, .. } => *silent,
701+
702+
Self::Outdated { format, .. } => {
703+
matches!(format, Some(Format::Json | Format::List))
704+
}
705+
706+
Self::Why { json, parseable, .. } => *json || *parseable,
707+
Self::Info { json, .. } => *json,
708+
709+
Self::Pm(sub) => sub.is_quiet_or_machine_readable(),
710+
Self::Env(args) => {
711+
args.command.as_ref().is_some_and(|sub| sub.is_quiet_or_machine_readable())
712+
}
713+
714+
_ => false,
715+
}
716+
}
717+
}
718+
693719
/// Arguments for the `env` command
694720
#[derive(clap::Args, Debug)]
695721
#[command(after_help = "\
@@ -879,6 +905,15 @@ pub enum EnvSubcommands {
879905
},
880906
}
881907

908+
impl EnvSubcommands {
909+
fn is_quiet_or_machine_readable(&self) -> bool {
910+
match self {
911+
Self::Current { json } | Self::List { json } | Self::ListRemote { json, .. } => *json,
912+
_ => false,
913+
}
914+
}
915+
}
916+
882917
/// Version sorting order for list-remote command
883918
#[derive(clap::ValueEnum, Clone, Debug, Default)]
884919
pub enum SortingMethod {
@@ -1242,6 +1277,23 @@ pub enum PmCommands {
12421277
},
12431278
}
12441279

1280+
impl PmCommands {
1281+
fn is_quiet_or_machine_readable(&self) -> bool {
1282+
match self {
1283+
Self::List { json, parseable, .. } => *json || *parseable,
1284+
Self::Pack { json, .. }
1285+
| Self::View { json, .. }
1286+
| Self::Publish { json, .. }
1287+
| Self::Audit { json, .. }
1288+
| Self::Search { json, .. }
1289+
| Self::Fund { json, .. } => *json,
1290+
Self::Config(sub) => sub.is_quiet_or_machine_readable(),
1291+
Self::Token(sub) => sub.is_quiet_or_machine_readable(),
1292+
_ => false,
1293+
}
1294+
}
1295+
}
1296+
12451297
/// Configuration subcommands
12461298
#[derive(Subcommand, Debug, Clone)]
12471299
pub enum ConfigCommands {
@@ -1314,6 +1366,15 @@ pub enum ConfigCommands {
13141366
},
13151367
}
13161368

1369+
impl ConfigCommands {
1370+
fn is_quiet_or_machine_readable(&self) -> bool {
1371+
match self {
1372+
Self::List { json, .. } | Self::Get { json, .. } | Self::Set { json, .. } => *json,
1373+
_ => false,
1374+
}
1375+
}
1376+
}
1377+
13171378
/// Owner subcommands
13181379
#[derive(Subcommand, Debug, Clone)]
13191380
pub enum OwnerCommands {
@@ -1410,6 +1471,15 @@ pub enum TokenCommands {
14101471
},
14111472
}
14121473

1474+
impl TokenCommands {
1475+
fn is_quiet_or_machine_readable(&self) -> bool {
1476+
match self {
1477+
Self::List { json, .. } | Self::Create { json, .. } => *json,
1478+
_ => false,
1479+
}
1480+
}
1481+
}
1482+
14131483
/// Distribution tag subcommands
14141484
#[derive(Subcommand, Debug, Clone)]
14151485
pub enum DistTagCommands {

crates/vite_global_cli/src/commands/upgrade/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
mod install;
77
mod integrity;
88
mod platform;
9-
mod registry;
9+
pub(crate) mod registry;
1010

1111
use std::process::ExitStatus;
1212

crates/vite_global_cli/src/commands/upgrade/registry.rs

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,33 +34,46 @@ const MAIN_PACKAGE_NAME: &str = "vite-plus";
3434
const PLATFORM_PACKAGE_SCOPE: &str = "@voidzero-dev";
3535
const CLI_PACKAGE_NAME_PREFIX: &str = "vite-plus-cli";
3636

37-
/// Resolve a version from the npm registry.
37+
/// Resolve a version string from the npm registry.
3838
///
39-
/// Makes two HTTP calls:
40-
/// 1. Main package metadata to resolve version tags (e.g., "latest" → "1.2.3")
41-
/// 2. CLI platform package metadata to get tarball URL and integrity
42-
pub async fn resolve_version(
39+
/// Single HTTP call to resolve a version or tag (e.g., "latest" → "1.2.3").
40+
/// Does NOT verify the platform-specific package exists.
41+
pub async fn resolve_version_string(
4342
version_or_tag: &str,
44-
platform_suffix: &str,
4543
registry_override: Option<&str>,
46-
) -> Result<ResolvedVersion, Error> {
44+
) -> Result<String, Error> {
4745
let default_registry = npm_registry();
4846
let registry_raw = registry_override.unwrap_or(&default_registry);
4947
let registry = registry_raw.trim_end_matches('/');
5048
let client = HttpClient::new();
5149

52-
// Step 1: Fetch main package metadata to resolve version
5350
let main_url = format!("{registry}/{MAIN_PACKAGE_NAME}/{version_or_tag}");
5451
tracing::debug!("Fetching main package metadata: {}", main_url);
5552

5653
let main_meta: PackageVersionMetadata = client.get_json(&main_url).await.map_err(|e| {
5754
Error::Upgrade(format!("Failed to fetch package metadata from {main_url}: {e}").into())
5855
})?;
5956

60-
// Step 2: Query CLI platform package directly
57+
Ok(main_meta.version)
58+
}
59+
60+
/// Resolve the platform-specific package metadata for a given version.
61+
///
62+
/// Single HTTP call to fetch the tarball URL and integrity hash for the
63+
/// platform-specific CLI binary package.
64+
pub async fn resolve_platform_package(
65+
version: &str,
66+
platform_suffix: &str,
67+
registry_override: Option<&str>,
68+
) -> Result<ResolvedVersion, Error> {
69+
let default_registry = npm_registry();
70+
let registry_raw = registry_override.unwrap_or(&default_registry);
71+
let registry = registry_raw.trim_end_matches('/');
72+
let client = HttpClient::new();
73+
6174
let cli_package_name =
6275
format!("{PLATFORM_PACKAGE_SCOPE}/{CLI_PACKAGE_NAME_PREFIX}-{platform_suffix}");
63-
let cli_url = format!("{registry}/{cli_package_name}/{}", main_meta.version);
76+
let cli_url = format!("{registry}/{cli_package_name}/{version}");
6477
tracing::debug!("Fetching CLI package metadata: {}", cli_url);
6578

6679
let cli_meta: PackageVersionMetadata = client.get_json(&cli_url).await.map_err(|e| {
@@ -74,12 +87,26 @@ pub async fn resolve_version(
7487
})?;
7588

7689
Ok(ResolvedVersion {
77-
version: main_meta.version,
90+
version: version.to_owned(),
7891
platform_tarball_url: cli_meta.dist.tarball,
7992
platform_integrity: cli_meta.dist.integrity,
8093
})
8194
}
8295

96+
/// Resolve a version from the npm registry with platform package verification.
97+
///
98+
/// Makes two HTTP calls:
99+
/// 1. Main package metadata to resolve version tags (e.g., "latest" → "1.2.3")
100+
/// 2. CLI platform package metadata to get tarball URL and integrity
101+
pub async fn resolve_version(
102+
version_or_tag: &str,
103+
platform_suffix: &str,
104+
registry_override: Option<&str>,
105+
) -> Result<ResolvedVersion, Error> {
106+
let version = resolve_version_string(version_or_tag, registry_override).await?;
107+
resolve_platform_package(&version, platform_suffix, registry_override).await
108+
}
109+
83110
#[cfg(test)]
84111
mod tests {
85112
use super::*;

crates/vite_global_cli/src/main.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ mod help;
1515
mod js_executor;
1616
mod shim;
1717
mod tips;
18+
mod upgrade_check;
1819

1920
use std::{
2021
env,
@@ -293,7 +294,17 @@ async fn main() -> ExitCode {
293294
}
294295

295296
// Parse CLI arguments (using custom help formatting)
296-
let exit_code = match try_parse_args_from(normalized_args) {
297+
let parse_result = try_parse_args_from(normalized_args);
298+
299+
// Spawn background upgrade check for eligible commands
300+
let upgrade_handle = match &parse_result {
301+
Ok(args) if upgrade_check::should_run_for_command(args) => {
302+
Some(tokio::spawn(upgrade_check::check_for_update()))
303+
}
304+
_ => None,
305+
};
306+
307+
let exit_code = match parse_result {
297308
Err(e) => {
298309
use clap::error::ErrorKind;
299310

@@ -368,6 +379,14 @@ async fn main() -> ExitCode {
368379
},
369380
};
370381

382+
// Display upgrade notice if a newer version is available
383+
if let Some(handle) = upgrade_handle
384+
&& let Ok(Ok(Some(result))) =
385+
tokio::time::timeout(std::time::Duration::from_millis(500), handle).await
386+
{
387+
upgrade_check::display_upgrade_notice(&result);
388+
}
389+
371390
tip_context.exit_code = if exit_code == ExitCode::SUCCESS { 0 } else { 1 };
372391

373392
if let Some(tip) = tips::get_tip(&tip_context) {

0 commit comments

Comments
 (0)