From 2776009380c6704c90ed7dacbae26244e36a6873 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Sat, 25 Jul 2026 17:11:36 +0200 Subject: [PATCH 1/4] fix(global): keep the installed version spec on global update --- .../command_outdated_global/snapshots.toml | 14 +- .../snapshots/command_outdated_global.md | 101 +++++++++- crates/vite_global_cli/src/cli.rs | 66 +++++- .../src/commands/env/package_metadata.rs | 56 ++++++ .../src/commands/global/install.rs | 5 +- .../src/commands/global/mod.rs | 55 +++++ .../src/commands/global/outdated.rs | 189 ++++++++++++------ docs/guide/install.md | 2 + 8 files changed, 418 insertions(+), 70 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml index 611045a7d5..8a857d1fdd 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml @@ -3,10 +3,22 @@ name = "command_outdated_global" vp = "global" skip-platforms = ["windows"] steps = [ - { argv = ["vp", "install", "-g", "testnpm2@1.0.0"], comment = "should prepare global outdated package", snapshot = false, continue-on-failure = true }, + { argv = ["vp", "install", "-g", "testnpm2@1.0.0"], comment = "should prepare a version-pinned global package", snapshot = false, continue-on-failure = true }, { argv = ["vp", "outdated", "definitely-not-installed-vite-plus-snap-pkg", "-g", "--format", "json"], comment = "should support empty global json output", continue-on-failure = true }, + { argv = ["vp", "outdated", "testnpm2", "-g", "--format", "json"], comment = "should report a pinned package without a wanted update", continue-on-failure = true }, + { argv = ["vp", "outdated", "testnpm2", "-g", "--format", "list"], comment = "should render the newer latest as a hint in list format", continue-on-failure = true }, + { tty = false, argv = ["vp", "update", "-g"], comment = "should not move a pinned package to latest", continue-on-failure = true }, + { argv = ["vpt", "json-edit", "$VP_HOME/packages/testnpm2.json", "versionSpec", "no-such-tag"], comment = "should warn and skip when the recorded version spec no longer resolves", snapshot = false }, + { tty = false, argv = ["vp", "update", "-g"], continue-on-failure = true }, + { argv = ["vpt", "json-edit", "$VP_HOME/packages/testnpm2.json", "versionSpec", "null"], comment = "should follow latest again once the recorded version spec is cleared", snapshot = false }, { argv = ["vp", "outdated", "testnpm2", "-g", "--format", "json"], comment = "should support global json output", continue-on-failure = true }, { argv = ["vp", "outdated", "testnpm2", "-g", "--format", "list", "--concurrency", "5"], comment = "should support global list output", continue-on-failure = true }, + { argv = ["vpt", "json-edit", "$VP_HOME/packages/testnpm2.json", "versionSpec", "1.0.0"], comment = "should override a recorded version spec with --latest", snapshot = false }, + { tty = false, argv = ["vp", "update", "-g", "--latest"], continue-on-failure = true }, + { argv = ["vpt", "grep-file", "$VP_HOME/packages/testnpm2.json", "versionSpec"], comment = "should clear the recorded version spec after --latest (grep-file prints missing)", continue-on-failure = true }, + { argv = ["vpt", "json-edit", "$VP_HOME/packages/testnpm2.json", "versionSpec", "1.0.1"], comment = "should clear a recorded version spec with --latest even without a reinstall", snapshot = false }, + { tty = false, argv = ["vp", "update", "-g", "--latest"], continue-on-failure = true }, + { argv = ["vpt", "grep-file", "$VP_HOME/packages/testnpm2.json", "versionSpec"], comment = "should have removed the pin from the up-to-date package (grep-file prints missing)", continue-on-failure = true }, ] after = [ { argv = ["vp", "remove", "-g", "testnpm2"], continue-on-failure = true }, diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md index e083cce7b7..fa49d37084 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md @@ -2,7 +2,7 @@ ## `vp install -g testnpm2@1.0.0` -should prepare global outdated package +should prepare a version-pinned global package ## `vp outdated definitely-not-installed-vite-plus-snap-pkg -g --format json` @@ -13,6 +13,60 @@ should support empty global json output {} ``` +## `vp outdated testnpm2 -g --format json` + +should report a pinned package without a wanted update + +**Exit code:** 1 + +``` +{ + "testnpm2": { + "current": "1.0.0", + "wanted": "1.0.0", + "latest": "1.0.1", + "dependent": "global", + "location": "/.vite-plus/packages/testnpm2#/lib/node_modules/testnpm2" + } +} +``` + +## `vp outdated testnpm2 -g --format list` + +should render the newer latest as a hint in list format + +**Exit code:** 1 + +``` +testnpm2 (global) +1.0.0 => 1.0.0 (latest: 1.0.1) +``` + +## `vp update -g` + +should not move a pinned package to latest + +``` +All global packages are up to date. +``` + +## `vpt json-edit $VP_HOME/packages/testnpm2.json versionSpec no-such-tag` + +should warn and skip when the recorded version spec no longer resolves + + +## `vp update -g` + +``` +All global packages are up to date. +warn: npm view failed for testnpm2@no-such-tag: npm error code E404; skipping +``` + +## `vpt json-edit $VP_HOME/packages/testnpm2.json versionSpec null` + +should follow latest again once the recorded version spec is cleared + + ## `vp outdated testnpm2 -g --format json` should support global json output @@ -41,3 +95,48 @@ should support global list output testnpm2 (global) 1.0.0 => 1.0.1 ``` + +## `vpt json-edit $VP_HOME/packages/testnpm2.json versionSpec 1.0.0` + +should override a recorded version spec with --latest + + +## `vp update -g --latest` + +``` +info: Updating 1 global package with Node.js +✓ Updated testnpm2 to 1.0.1 +``` + +## `vpt grep-file $VP_HOME/packages/testnpm2.json versionSpec` + +should clear the recorded version spec after --latest (grep-file prints missing) + +**Exit code:** 1 + +``` +/.vite-plus/packages/testnpm2.json: missing "versionSpec" +pattern not found +``` + +## `vpt json-edit $VP_HOME/packages/testnpm2.json versionSpec 1.0.1` + +should clear a recorded version spec with --latest even without a reinstall + + +## `vp update -g --latest` + +``` +All global packages are up to date. +``` + +## `vpt grep-file $VP_HOME/packages/testnpm2.json versionSpec` + +should have removed the pin from the up-to-date package (grep-file prints missing) + +**Exit code:** 1 + +``` +/.vite-plus/packages/testnpm2.json: missing "versionSpec" +pattern not found +``` diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 163c2ded7a..2e1b4f4286 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -581,6 +581,7 @@ async fn run_package_manager_command( PackageManagerCommand::Update { global: true, ref packages, + latest, concurrency, reinstall_node_mismatch, ignore_node_mismatch, @@ -592,8 +593,14 @@ async fn run_package_manager_command( ); return Ok(exit_status(1)); } - managed_update(packages, concurrency, reinstall_node_mismatch, ignore_node_mismatch) - .await + managed_update( + packages, + latest, + concurrency, + reinstall_node_mismatch, + ignore_node_mismatch, + ) + .await } PackageManagerCommand::Outdated { @@ -684,6 +691,7 @@ struct NodeMismatchPackage { async fn managed_update( packages: &[String], + latest: bool, concurrency: Option, reinstall_node_mismatch: bool, ignore_node_mismatch: bool, @@ -691,6 +699,11 @@ async fn managed_update( let concurrency = concurrency.unwrap_or(DEFAULT_GLOBAL_INSTALL_CONCURRENCY); let mut to_update: Vec = Vec::new(); let mut node_mismatches: Vec = Vec::new(); + // Packages whose recorded version spec `--latest` clears. Reinstalls + // clear the spec on their own (the bare-name install records none), but + // packages already at the latest version are not reinstalled and must be + // rewritten explicitly. + let mut latest_spec_resets: Vec = Vec::new(); let current_node_version; let packages = if packages.is_empty() { @@ -702,10 +715,13 @@ async fn managed_update( current_node_version = get_current_node_version().await?; for metadata in &all { + if latest && metadata.version_spec.is_some() { + latest_spec_resets.push(metadata.name.clone()); + } if !is_same_node_version(&metadata.platform.node, ¤t_node_version) { node_mismatches.push(NodeMismatchPackage { name: metadata.name.clone(), - spec: metadata.name.clone(), + spec: if latest { metadata.name.clone() } else { metadata.update_spec() }, installed_node: metadata.platform.node.clone(), }); } @@ -724,12 +740,25 @@ async fn managed_update( } // It is not a local package, so `parse_package_spec` there won't return `Err()` - let (package_name, _) = global::parse_package_spec(package).unwrap(); + let (package_name, version_spec) = global::parse_package_spec(package).unwrap(); if let Some(metadata) = PackageMetadata::load(&package_name).await? { + // `--latest` applies to bare names only; explicit specs win. + if latest && version_spec.is_none() && metadata.version_spec.is_some() { + latest_spec_resets.push(package_name.clone()); + } if !is_same_node_version(&metadata.platform.node, ¤t_node_version) { + // Match the spec `get_outdated_packages` resolves for this + // package, so the dedup against outdated results holds. + let spec = if version_spec.is_some() { + package.clone() + } else if latest { + package_name.clone() + } else { + metadata.update_spec() + }; node_mismatches.push(NodeMismatchPackage { name: package_name, - spec: package.clone(), + spec, installed_node: metadata.platform.node, }); } @@ -742,13 +771,34 @@ async fn managed_update( Some(managed_specs) }; - let outdated = global::outdated::get_outdated_packages( + let report = global::outdated::get_outdated_packages( &packages.unwrap_or_default(), concurrency * 3, - true, + latest, ) .await?; - to_update.extend(outdated.into_iter().map(|package| package.spec.unwrap_or(package.name))); + for failure in &report.failures { + output::warn(&format!("{failure}; skipping")); + } + to_update.extend( + report + .outdated + .into_iter() + // A newer `latest` alone (e.g. a version-pinned package) is not + // updatable; only a newer wanted version is. + .filter(|package| package.wanted != package.current) + .map(|package| package.spec.unwrap_or(package.name)), + ); + + // Clearing is idempotent for packages that are also reinstalled below; + // clearing before the install keeps the early up-to-date return covered. + for package_name in &latest_spec_resets { + if let Some(mut metadata) = PackageMetadata::load(package_name).await? { + if metadata.version_spec.take().is_some() { + metadata.save().await?; + } + } + } let to_update_set = to_update.iter().map(String::as_str).collect::>(); node_mismatches.retain(|package| !to_update_set.contains(package.spec.as_str())); diff --git a/crates/vite_global_cli/src/commands/env/package_metadata.rs b/crates/vite_global_cli/src/commands/env/package_metadata.rs index af979b06f8..c7f740c8f4 100644 --- a/crates/vite_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vite_global_cli/src/commands/env/package_metadata.rs @@ -46,6 +46,11 @@ pub struct PackageMetadata { /// `corepack`). Updates keep the restriction; explicit installs reset it. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub bins_restricted: bool, + /// Version spec the package was installed with (a dist-tag like + /// `nightly`, a range, or an exact version), so `vp update -g` keeps + /// resolving within it. `None` means the implicit `latest` tag. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_spec: Option, /// Package manager used for installation (npm, yarn, pnpm) pub manager: String, /// Installation timestamp @@ -81,11 +86,22 @@ impl PackageMetadata { bins, js_bins, bins_restricted: false, + version_spec: None, manager, installed_at: Utc::now(), } } + /// Registry spec update flows should reinstall this package with: + /// qualified with the recorded version spec when the install had one, + /// the bare name (implicit `latest`) otherwise. + pub fn update_spec(&self) -> String { + match &self.version_spec { + Some(spec) => format!("{}@{spec}", self.name), + None => self.name.clone(), + } + } + /// Check if a binary requires Node.js to run. pub fn is_js_binary(&self, bin_name: &str) -> bool { self.js_bins.contains(bin_name) @@ -238,6 +254,46 @@ mod tests { .unwrap(); assert!(metadata.install_id.is_empty()); + assert_eq!(metadata.version_spec, None); + } + + #[test] + fn test_version_spec_roundtrips_through_serialization() { + let mut metadata = PackageMetadata::new( + "some-pkg".to_string(), + "1.2.3-nightly.4".to_string(), + "22.0.0".to_string(), + None, + vec!["some-pkg".to_string()], + HashSet::new(), + "npm".to_string(), + ); + metadata.version_spec = Some("nightly".to_string()); + + let json = serde_json::to_string(&metadata).unwrap(); + assert!(json.contains(r#""versionSpec":"nightly""#)); + let loaded: PackageMetadata = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.version_spec, Some("nightly".to_string())); + } + + #[test] + fn test_update_spec_follows_version_spec() { + let mut metadata = PackageMetadata::new( + "some-pkg".to_string(), + "1.0.0".to_string(), + "22.0.0".to_string(), + None, + vec![], + HashSet::new(), + "npm".to_string(), + ); + assert_eq!(metadata.update_spec(), "some-pkg"); + + metadata.version_spec = Some("nightly".to_string()); + assert_eq!(metadata.update_spec(), "some-pkg@nightly"); + + metadata.version_spec = Some("^1.0.0".to_string()); + assert_eq!(metadata.update_spec(), "some-pkg@^1.0.0"); } #[test] diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index d2618c5314..aa84e63a15 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -27,7 +27,7 @@ use crate::{ config::{get_bin_dir, get_node_modules_dir, resolve_version, resolve_version_alias}, package_metadata::{INSTALL_ID_PREFIX, PackageMetadata, is_install_id}, }, - global::{CORE_SHIMS, is_local_package_spec, parse_package_spec}, + global::{CORE_SHIMS, is_local_package_spec, parse_package_spec, update_version_spec}, }, error::Error, }; @@ -233,7 +233,7 @@ pub async fn install( // 4. Finalize installed packages. let mut bin_owners = HashMap::::new(); - for (index, (package_name, Package { spec: _, install })) in packages.into_iter().enumerate() { + for (index, (package_name, Package { spec, install })) in packages.into_iter().enumerate() { let lock_file = install_locks.remove(&package_name); let Some(InstalledPackage { installed_version, @@ -405,6 +405,7 @@ pub async fn install( ); metadata.install_id = install_id.clone(); metadata.bins_restricted = bins_restricted; + metadata.version_spec = update_version_spec(spec); let mut finalized = true; for bin_name in &stale_bin_names { diff --git a/crates/vite_global_cli/src/commands/global/mod.rs b/crates/vite_global_cli/src/commands/global/mod.rs index 57530e8ae5..6a2cc68252 100644 --- a/crates/vite_global_cli/src/commands/global/mod.rs +++ b/crates/vite_global_cli/src/commands/global/mod.rs @@ -178,6 +178,27 @@ pub(crate) fn parse_package_spec(spec: &str) -> Result<(String, Option), } } +/// Return the version part of a registry package spec that update flows +/// should keep resolving within: a dist-tag (e.g. `nightly` in +/// `some-pkg@nightly`), a range, or an exact version. The implicit `latest` +/// tag and non-registry version parts (URLs, aliases) yield `None`. +pub(crate) fn update_version_spec(spec: &str) -> Option { + if is_local_package_spec(spec) { + return None; + } + let (_, version_spec) = parse_package_spec(spec).ok()?; + let version_spec = version_spec?; + if version_spec.is_empty() + || version_spec == "latest" + || version_spec.contains(':') + || version_spec.contains('/') + { + None + } else { + Some(version_spec) + } +} + fn resolve_local_package_path(spec: &str) -> Result { let path_spec = spec.strip_prefix("file:").unwrap_or(spec); let path = std::path::Path::new(path_spec); @@ -301,4 +322,38 @@ mod tests { let error = parse_npm_view_version(b"\n").unwrap_err(); assert!(error.to_string().contains("empty version")); } + + #[test] + fn update_version_spec_keeps_dist_tags() { + assert_eq!(update_version_spec("some-pkg@nightly"), Some("nightly".to_string())); + assert_eq!(update_version_spec("some-pkg@beta"), Some("beta".to_string())); + assert_eq!(update_version_spec("@scope/pkg@canary"), Some("canary".to_string())); + } + + #[test] + fn update_version_spec_keeps_versions_and_ranges() { + assert_eq!(update_version_spec("some-pkg@1.2.3"), Some("1.2.3".to_string())); + assert_eq!( + update_version_spec("some-pkg@1.2.3-nightly.0"), + Some("1.2.3-nightly.0".to_string()) + ); + assert_eq!(update_version_spec("some-pkg@^1.0.0"), Some("^1.0.0".to_string())); + assert_eq!(update_version_spec("some-pkg@>=2"), Some(">=2".to_string())); + } + + #[test] + fn update_version_spec_ignores_bare_names_and_latest() { + assert_eq!(update_version_spec("some-pkg"), None); + assert_eq!(update_version_spec("@scope/pkg"), None); + assert_eq!(update_version_spec("some-pkg@latest"), None); + assert_eq!(update_version_spec("some-pkg@"), None); + } + + #[test] + fn update_version_spec_ignores_non_registry_specs() { + assert_eq!(update_version_spec("./local-pkg"), None); + assert_eq!(update_version_spec("file:../pkg"), None); + assert_eq!(update_version_spec("some-pkg@npm:other@1.0.0"), None); + assert_eq!(update_version_spec("some-pkg@github:user/repo"), None); + } } diff --git a/crates/vite_global_cli/src/commands/global/outdated.rs b/crates/vite_global_cli/src/commands/global/outdated.rs index 49817a4eab..b79068301a 100644 --- a/crates/vite_global_cli/src/commands/global/outdated.rs +++ b/crates/vite_global_cli/src/commands/global/outdated.rs @@ -1,7 +1,7 @@ //! Check managed global packages for newer registry versions. use std::{ - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, HashMap, HashSet}, process::ExitStatus, }; @@ -20,6 +20,10 @@ use crate::{ pub struct OutdatedPackage { pub name: String, pub current: String, + /// Newest version within the version spec recorded at install time (or + /// given on the command line); what an update would install. + pub wanted: String, + /// Newest version on the registry's `latest` dist-tag. pub latest: String, pub spec: Option, install_id: String, @@ -27,6 +31,15 @@ pub struct OutdatedPackage { bins: Vec, } +/// Outcome of a registry sweep over the managed global packages: the packages +/// with a newer version, plus one message per package whose registry lookup +/// failed (so callers can warn and continue instead of aborting). +#[derive(Debug)] +pub struct OutdatedReport { + pub outdated: Vec, + pub failures: Vec, +} + /// For json output in `vp outdated` command /// Use `npm outdated --json`'s data structure #[derive(Debug, Serialize)] @@ -42,78 +55,111 @@ struct OutdatedPackageJson { pub async fn get_outdated_packages( packages: &[String], concurrency: usize, - error_on_fail: bool, -) -> Result, Error> { + latest: bool, +) -> Result { // 1. Resolve the command arguments to vite-plus-managed global packages. // A missing explicit package is a command result, not an internal error. let installed = if !packages.is_empty() { let mut installed = Vec::new(); for package in packages { - let Ok((package_name, _)) = parse_package_spec(package) else { + let Ok((package_name, version_spec)) = parse_package_spec(package) else { // Silently skip, follow npm's behavior continue; }; if let Some(metadata) = PackageMetadata::load(&package_name).await? { - installed.push((metadata, Some(package.clone()))); + // Bare names follow the version spec recorded at install + // time (unless `--latest` overrides it); explicit versions + // and tags win over both. + let query_spec = if version_spec.is_some() { + Some(package.clone()) + } else if latest { + None + } else { + Some(metadata.update_spec()) + }; + installed.push((metadata, query_spec)); } } installed } else { - PackageMetadata::list_all().await?.into_iter().map(|package| (package, None)).collect() + PackageMetadata::list_all() + .await? + .into_iter() + .map(|package| { + let spec = + (!latest && package.version_spec.is_some()).then(|| package.update_spec()); + (package, spec) + }) + .collect() }; if installed.is_empty() { - return Ok(Vec::new()); + return Ok(OutdatedReport { outdated: Vec::new(), failures: Vec::new() }); } - // 2. Query the registry for the latest version of each matching package. - // A registry setup failure is fatal. A package-level lookup failure is - // returned as an error because there is no version to compare. - let specs = installed - .iter() - .map(|(package, spec)| spec.clone().unwrap_or_else(|| package.name.clone())) - .collect::>(); - - let mut latest_versions_map = HashMap::new(); - for (package_spec, version) in latest_package_versions(&specs, concurrency).await? { - match version { - Ok(version) => { - latest_versions_map.insert(package_spec, version); - } - Err(error) => { - if error_on_fail { - return Err(error); - } + // 2. Query the registry once per distinct spec: the wanted version comes + // from the (possibly spec-qualified) query spec, the latest version + // from the bare package name; for spec-less packages both coincide. + // A registry setup failure is fatal; per-package lookup failures are + // collected so callers can warn and continue. + let mut specs = Vec::new(); + let mut seen = HashSet::new(); + for (package, spec) in &installed { + for query in [spec.as_deref().unwrap_or(&package.name), package.name.as_str()] { + if seen.insert(query.to_string()) { + specs.push(query.to_string()); } } } - let mut latest_versions = latest_versions_map; + + let versions: HashMap> = + latest_package_versions(&specs, concurrency) + .await? + .into_iter() + .map(|(spec, version)| (spec, version.map_err(|error| error.to_string()))) + .collect(); + let resolve = |key: &str| match versions.get(key) { + Some(Ok(version)) => Ok(version.trim().to_string()), + // Keep the first line: npm's stderr goes on to multi-line advice and + // a timestamped log path, which is noise in a one-line warning. + Some(Err(error)) => Err(error.lines().next().unwrap_or("registry lookup failed").into()), + None => Err(format!("no registry response for {key}")), + }; // 3. Compare installed metadata with registry versions. Packages whose - // registry lookup failed are skipped because there is no version to compare. + // registry lookup failed are reported as failures because there is no + // version to compare. let mut outdated = Vec::new(); + let mut failures = Vec::new(); for (package, spec) in installed { - let default_key = package.name.clone(); - let key = spec.as_deref().unwrap_or(&default_key); - let Some(version) = latest_versions.remove(key) else { - continue; + let wanted_key = spec.clone().unwrap_or_else(|| package.name.clone()); + let (wanted, latest) = match (resolve(&wanted_key), resolve(&package.name)) { + (Ok(wanted), Ok(latest)) => (wanted, latest), + (Err(error), _) | (_, Err(error)) => { + failures.push(error); + continue; + } }; - if package.version.trim() == version.trim() { + let current = package.version.trim().to_string(); + if current == wanted && current == latest { continue; } outdated.push(OutdatedPackage { name: package.name, - current: package.version, - latest: version, + current, + wanted, + latest, spec, install_id: package.install_id, node: package.platform.node, bins: package.bins, }); } + // Lookups finish in nondeterministic order; keep warnings stable. + failures.sort(); - Ok(outdated) + Ok(OutdatedReport { outdated, failures }) } pub async fn execute( @@ -122,26 +168,33 @@ pub async fn execute( format: Option, concurrency: usize, ) -> Result { - let outdated = match get_outdated_packages(packages, concurrency, false).await { - Ok(outdated) => outdated, - Err(error) => { - if let Some(Format::Json) = format { - vite_shared::output::raw("{}"); - } else { - vite_shared::output::error(&format!("Could not get outdated packages: {error}")); + let OutdatedReport { outdated, failures } = + match get_outdated_packages(packages, concurrency, false).await { + Ok(report) => report, + Err(error) => { + if let Some(Format::Json) = format { + vite_shared::output::raw("{}"); + } else { + vite_shared::output::error(&format!( + "Could not get outdated packages: {error}" + )); + } + return Err(error); } - return Err(error); - } - }; + }; + + for failure in &failures { + vite_shared::output::warn(&format!("{failure}; skipping")); + } // Exit code 0 means fully checked and up to date; 1 means outdated or incomplete. if outdated.is_empty() { if let Some(Format::Json) = format { vite_shared::output::raw("{}"); - } else { + } else if failures.is_empty() { vite_shared::output::info("All global packages are up to date."); } - return Ok(ExitStatus::default()); + return Ok(if failures.is_empty() { ExitStatus::default() } else { exit_status(1) }); } match format { @@ -165,7 +218,7 @@ fn print_json(packages: &[OutdatedPackage]) -> Result<(), Error> { package.name.clone(), OutdatedPackageJson { current: package.current.clone(), - wanted: package.latest.clone(), + wanted: package.wanted.clone(), latest: package.latest.clone(), dependent: "global", location: location.as_path().display().to_string(), @@ -185,7 +238,17 @@ fn print_list(packages: &[OutdatedPackage], long: bool) { } println!("{} {}", package.name.bold(), "(global)".dimmed()); - println!("{} {} {}", package.current.dimmed(), "=>".dimmed(), package.latest.bold()); + if package.wanted == package.latest { + println!("{} {} {}", package.current.dimmed(), "=>".dimmed(), package.wanted.bold()); + } else { + println!( + "{} {} {} {}", + package.current.dimmed(), + "=>".dimmed(), + package.wanted.bold(), + format!("(latest: {})", package.latest).dimmed() + ); + } if long { println!("{} {}", "node".dimmed(), package.node); @@ -199,18 +262,21 @@ fn print_list(packages: &[OutdatedPackage], long: bool) { fn print_table(packages: &[OutdatedPackage], long: bool) { let col_pkg = "Package"; let col_current = "Current"; + let col_wanted = "Wanted"; let col_latest = "Latest"; let col_node = "Node"; let col_bins = "Bins"; let mut w_pkg = col_pkg.len(); let mut w_current = col_current.len(); + let mut w_wanted = col_wanted.len(); let mut w_latest = col_latest.len(); let mut w_node = col_node.len(); for package in packages { w_pkg = w_pkg.max(package.name.len()); w_current = w_current.max(package.current.len()); + w_wanted = w_wanted.max(package.wanted.len()); w_latest = w_latest.max(package.latest.len()); w_node = w_node.max(package.node.len()); } @@ -218,29 +284,34 @@ fn print_table(packages: &[OutdatedPackage], long: bool) { let gap = 3; if long { println!( - "{:gap$}{:gap$}{:gap$}{:gap$}{}", - col_pkg, "", col_current, "", col_latest, "", col_node, "", col_bins + "{:gap$}{:gap$}{:gap$}{:gap$}{:gap$}{}", + col_pkg, "", col_current, "", col_wanted, "", col_latest, "", col_node, "", col_bins ); println!( - "{:gap$}{:gap$}{:gap$}{:gap$}{}", - "---", "", "---", "", "---", "", "---", "", "---" + "{:gap$}{:gap$}{:gap$}{:gap$}{:gap$}{}", + "---", "", "---", "", "---", "", "---", "", "---", "", "---" ); } else { println!( - "{:gap$}{:gap$}{}", - col_pkg, "", col_current, "", col_latest + "{:gap$}{:gap$}{:gap$}{}", + col_pkg, "", col_current, "", col_wanted, "", col_latest + ); + println!( + "{:gap$}{:gap$}{:gap$}---", + "---", "", "---", "", "---", "" ); - println!("{:gap$}{:gap$}---", "---", "", "---", ""); } for package in packages { if long { println!( - "{}{:>gap$}{:gap$}{:gap$}{:gap$}{}", + "{}{:>gap$}{:gap$}{:gap$}{:gap$}{:gap$}{}", format!("{:gap$}{:gap$}{}", + "{}{:>gap$}{:gap$}{:gap$}{}", format!("{: Date: Sun, 26 Jul 2026 16:39:30 +0200 Subject: [PATCH 2/4] fix(global): harden version spec resolution and update exit codes --- .../command_outdated_global/snapshots.toml | 3 ++ .../snapshots/command_outdated_global.md | 19 +++++++++ crates/vite_global_cli/src/cli.rs | 41 ++++++++++++------- .../src/commands/global/mod.rs | 33 ++++++++++++++- .../src/commands/global/outdated.rs | 10 +++-- 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml index 8a857d1fdd..0191a34982 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml @@ -19,6 +19,9 @@ steps = [ { argv = ["vpt", "json-edit", "$VP_HOME/packages/testnpm2.json", "versionSpec", "1.0.1"], comment = "should clear a recorded version spec with --latest even without a reinstall", snapshot = false }, { tty = false, argv = ["vp", "update", "-g", "--latest"], continue-on-failure = true }, { argv = ["vpt", "grep-file", "$VP_HOME/packages/testnpm2.json", "versionSpec"], comment = "should have removed the pin from the up-to-date package (grep-file prints missing)", continue-on-failure = true }, + { argv = ["vpt", "json-edit", "$VP_HOME/packages/testnpm2.json", "versionSpec", "1.0.0"], comment = "should persist an explicit spec switch without a reinstall", snapshot = false }, + { tty = false, argv = ["vp", "update", "-g", "testnpm2@1.0.1"], continue-on-failure = true }, + { argv = ["vpt", "grep-file", "$VP_HOME/packages/testnpm2.json", "versionSpec"], continue-on-failure = true }, ] after = [ { argv = ["vp", "remove", "-g", "testnpm2"], continue-on-failure = true }, diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md index fa49d37084..0f7dfdf213 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md @@ -57,6 +57,8 @@ should warn and skip when the recorded version spec no longer resolves ## `vp update -g` +**Exit code:** 1 + ``` All global packages are up to date. warn: npm view failed for testnpm2@no-such-tag: npm error code E404; skipping @@ -140,3 +142,20 @@ should have removed the pin from the up-to-date package (grep-file prints missin /.vite-plus/packages/testnpm2.json: missing "versionSpec" pattern not found ``` + +## `vpt json-edit $VP_HOME/packages/testnpm2.json versionSpec 1.0.0` + +should persist an explicit spec switch without a reinstall + + +## `vp update -g testnpm2@1.0.1` + +``` +All global packages are up to date. +``` + +## `vpt grep-file $VP_HOME/packages/testnpm2.json versionSpec` + +``` +/.vite-plus/packages/testnpm2.json: found "versionSpec" +``` diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 2e1b4f4286..fdddc5ace2 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -699,11 +699,11 @@ async fn managed_update( let concurrency = concurrency.unwrap_or(DEFAULT_GLOBAL_INSTALL_CONCURRENCY); let mut to_update: Vec = Vec::new(); let mut node_mismatches: Vec = Vec::new(); - // Packages whose recorded version spec `--latest` clears. Reinstalls - // clear the spec on their own (the bare-name install records none), but - // packages already at the latest version are not reinstalled and must be - // rewritten explicitly. - let mut latest_spec_resets: Vec = Vec::new(); + // Recorded version-spec changes this update implies: `--latest` clears + // specs, an explicit `pkg@spec` argument replaces the stored one. + // Reinstalls record the new spec on their own, but packages already at + // the target version are not reinstalled and must be rewritten here. + let mut spec_rewrites: Vec<(String, Option)> = Vec::new(); let current_node_version; let packages = if packages.is_empty() { @@ -716,7 +716,7 @@ async fn managed_update( for metadata in &all { if latest && metadata.version_spec.is_some() { - latest_spec_resets.push(metadata.name.clone()); + spec_rewrites.push((metadata.name.clone(), None)); } if !is_same_node_version(&metadata.platform.node, ¤t_node_version) { node_mismatches.push(NodeMismatchPackage { @@ -742,9 +742,16 @@ async fn managed_update( // It is not a local package, so `parse_package_spec` there won't return `Err()` let (package_name, version_spec) = global::parse_package_spec(package).unwrap(); if let Some(metadata) = PackageMetadata::load(&package_name).await? { - // `--latest` applies to bare names only; explicit specs win. - if latest && version_spec.is_none() && metadata.version_spec.is_some() { - latest_spec_resets.push(package_name.clone()); + if version_spec.is_some() { + // An explicit spec replaces the recorded one even when + // the installed version already satisfies it. + let new_spec = global::update_version_spec(package); + if new_spec != metadata.version_spec { + spec_rewrites.push((package_name.clone(), new_spec)); + } + } else if latest && metadata.version_spec.is_some() { + // `--latest` applies to bare names only; explicit specs win. + spec_rewrites.push((package_name.clone(), None)); } if !is_same_node_version(&metadata.platform.node, ¤t_node_version) { // Match the spec `get_outdated_packages` resolves for this @@ -780,6 +787,9 @@ async fn managed_update( for failure in &report.failures { output::warn(&format!("{failure}; skipping")); } + // Skipped lookups make the update incomplete; keep going but exit + // nonzero so scripts can tell. + let incomplete = !report.failures.is_empty(); to_update.extend( report .outdated @@ -790,11 +800,12 @@ async fn managed_update( .map(|package| package.spec.unwrap_or(package.name)), ); - // Clearing is idempotent for packages that are also reinstalled below; - // clearing before the install keeps the early up-to-date return covered. - for package_name in &latest_spec_resets { + // Rewrites are idempotent for packages that are also reinstalled below; + // applying before the install keeps the early up-to-date return covered. + for (package_name, new_spec) in &spec_rewrites { if let Some(mut metadata) = PackageMetadata::load(package_name).await? { - if metadata.version_spec.take().is_some() { + if metadata.version_spec != *new_spec { + metadata.version_spec = new_spec.clone(); metadata.save().await?; } } @@ -814,7 +825,7 @@ async fn managed_update( if to_update.is_empty() { vite_shared::output::raw("All global packages are up to date."); - return Ok(ExitStatus::default()); + return Ok(if incomplete { exit_status(1) } else { ExitStatus::default() }); } // Call reinstall logic @@ -836,7 +847,7 @@ async fn managed_update( )); return Ok(exit_status(1)); } - Ok(ExitStatus::default()) + Ok(if incomplete { exit_status(1) } else { ExitStatus::default() }) } async fn get_current_node_version() -> Result { diff --git a/crates/vite_global_cli/src/commands/global/mod.rs b/crates/vite_global_cli/src/commands/global/mod.rs index 6a2cc68252..ee8b7b8f3d 100644 --- a/crates/vite_global_cli/src/commands/global/mod.rs +++ b/crates/vite_global_cli/src/commands/global/mod.rs @@ -192,6 +192,10 @@ pub(crate) fn update_version_spec(spec: &str) -> Option { || version_spec == "latest" || version_spec.contains(':') || version_spec.contains('/') + // npm treats tarball-suffixed spec parts as file installs. + || version_spec.ends_with(".tgz") + || version_spec.ends_with(".tar.gz") + || version_spec.ends_with(".tar") { None } else { @@ -286,7 +290,19 @@ fn parse_npm_view_version(stdout: &[u8]) -> Result { match serde_json::from_str::(trimmed) { Ok(serde_json::Value::String(version)) => Ok(version), Ok(serde_json::Value::Array(versions)) => { - let Some(version) = versions.iter().rev().find_map(|version| version.as_str()) else { + // A range query lists matching versions in publish order, so a + // backport release can come last (e.g. vite 4.5.14 after 5.0.x); + // pick the semver max like installs do, not the last element. + let max = versions + .iter() + .filter_map(|version| version.as_str()) + .filter_map(|version| { + node_semver::Version::parse(version).ok().map(|parsed| (parsed, version)) + }) + .max_by(|(a, _), (b, _)| a.cmp(b)) + .map(|(_, version)| version) + .or_else(|| versions.iter().rev().find_map(|version| version.as_str())); + let Some(version) = max else { return Err(Error::Other("npm view returned an empty version list".into())); }; Ok(version.to_string()) @@ -311,6 +327,18 @@ mod tests { assert_eq!(version, "5.0.0"); } + #[test] + fn parses_json_array_version_by_semver_not_publish_order() { + // A backport released after a higher version lists last in npm's + // publish-ordered output; the semver max must win. + let version = parse_npm_view_version(br#"["5.0.11","5.0.12","4.5.14"]"#).unwrap(); + assert_eq!(version, "5.0.12"); + + let version = + parse_npm_view_version(br#"["2.0.0-beta.2","2.0.0-beta.10","2.0.0-beta.9"]"#).unwrap(); + assert_eq!(version, "2.0.0-beta.10"); + } + #[test] fn parses_plain_version() { let version = parse_npm_view_version(b"5.0.0").unwrap(); @@ -355,5 +383,8 @@ mod tests { assert_eq!(update_version_spec("file:../pkg"), None); assert_eq!(update_version_spec("some-pkg@npm:other@1.0.0"), None); assert_eq!(update_version_spec("some-pkg@github:user/repo"), None); + assert_eq!(update_version_spec("some-pkg@archive.tgz"), None); + assert_eq!(update_version_spec("some-pkg@archive.tar.gz"), None); + assert_eq!(update_version_spec("some-pkg@archive.tar"), None); } } diff --git a/crates/vite_global_cli/src/commands/global/outdated.rs b/crates/vite_global_cli/src/commands/global/outdated.rs index b79068301a..e959844b93 100644 --- a/crates/vite_global_cli/src/commands/global/outdated.rs +++ b/crates/vite_global_cli/src/commands/global/outdated.rs @@ -133,13 +133,17 @@ pub async fn get_outdated_packages( let mut failures = Vec::new(); for (package, spec) in installed { let wanted_key = spec.clone().unwrap_or_else(|| package.name.clone()); - let (wanted, latest) = match (resolve(&wanted_key), resolve(&package.name)) { - (Ok(wanted), Ok(latest)) => (wanted, latest), - (Err(error), _) | (_, Err(error)) => { + let wanted = match resolve(&wanted_key) { + Ok(wanted) => wanted, + Err(error) => { failures.push(error); continue; } }; + // The latest tag is informational; updates only need `wanted`, so a + // failing bare-name lookup degrades the display instead of skipping + // the package. + let latest = resolve(&package.name).unwrap_or_else(|_| wanted.clone()); let current = package.version.trim().to_string(); if current == wanted && current == latest { continue; From 83a2530582ef424ff2222f11431ba43bc5b9b9fb Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Sun, 26 Jul 2026 17:20:12 +0200 Subject: [PATCH 3/4] fix(global): defer version-spec rewrites until lookups succeed --- .../command_outdated_global/snapshots.toml | 4 ++- .../snapshots/command_outdated_global.md | 21 ++++++++++-- crates/vite_global_cli/src/cli.rs | 32 +++++++++++++------ .../src/commands/global/outdated.rs | 13 ++++---- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml index 0191a34982..131bdc556d 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots.toml @@ -21,7 +21,9 @@ steps = [ { argv = ["vpt", "grep-file", "$VP_HOME/packages/testnpm2.json", "versionSpec"], comment = "should have removed the pin from the up-to-date package (grep-file prints missing)", continue-on-failure = true }, { argv = ["vpt", "json-edit", "$VP_HOME/packages/testnpm2.json", "versionSpec", "1.0.0"], comment = "should persist an explicit spec switch without a reinstall", snapshot = false }, { tty = false, argv = ["vp", "update", "-g", "testnpm2@1.0.1"], continue-on-failure = true }, - { argv = ["vpt", "grep-file", "$VP_HOME/packages/testnpm2.json", "versionSpec"], continue-on-failure = true }, + { argv = ["vpt", "grep-file", "$VP_HOME/packages/testnpm2.json", 'versionSpec": "1.0.1'], continue-on-failure = true }, + { tty = false, argv = ["vp", "update", "-g", "testnpm2@no-such-tag"], comment = "should not persist an explicit spec that fails to resolve", continue-on-failure = true }, + { argv = ["vpt", "grep-file", "$VP_HOME/packages/testnpm2.json", 'versionSpec": "1.0.1'], continue-on-failure = true }, ] after = [ { argv = ["vp", "remove", "-g", "testnpm2"], continue-on-failure = true }, diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md index 0f7dfdf213..3d41e7b292 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_outdated_global/snapshots/command_outdated_global.md @@ -154,8 +154,25 @@ should persist an explicit spec switch without a reinstall All global packages are up to date. ``` -## `vpt grep-file $VP_HOME/packages/testnpm2.json versionSpec` +## `vpt grep-file $VP_HOME/packages/testnpm2.json 'versionSpec": "1.0.1'` + +``` +/.vite-plus/packages/testnpm2.json: found "versionSpec\": \"1.0.1" +``` + +## `vp update -g testnpm2@no-such-tag` + +should not persist an explicit spec that fails to resolve + +**Exit code:** 1 + +``` +All global packages are up to date. +warn: npm view failed for testnpm2@no-such-tag: npm error code E404; skipping +``` + +## `vpt grep-file $VP_HOME/packages/testnpm2.json 'versionSpec": "1.0.1'` ``` -/.vite-plus/packages/testnpm2.json: found "versionSpec" +/.vite-plus/packages/testnpm2.json: found "versionSpec\": \"1.0.1" ``` diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index fdddc5ace2..18d90b0190 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -703,7 +703,10 @@ async fn managed_update( // specs, an explicit `pkg@spec` argument replaces the stored one. // Reinstalls record the new spec on their own, but packages already at // the target version are not reinstalled and must be rewritten here. - let mut spec_rewrites: Vec<(String, Option)> = Vec::new(); + // Entries are `(package name, new spec, registry query spec)`; the query + // spec ties each rewrite to its lookup so failed resolutions never + // persist a policy the update could not act on. + let mut spec_rewrites: Vec<(String, Option, String)> = Vec::new(); let current_node_version; let packages = if packages.is_empty() { @@ -716,7 +719,7 @@ async fn managed_update( for metadata in &all { if latest && metadata.version_spec.is_some() { - spec_rewrites.push((metadata.name.clone(), None)); + spec_rewrites.push((metadata.name.clone(), None, metadata.name.clone())); } if !is_same_node_version(&metadata.platform.node, ¤t_node_version) { node_mismatches.push(NodeMismatchPackage { @@ -747,11 +750,11 @@ async fn managed_update( // the installed version already satisfies it. let new_spec = global::update_version_spec(package); if new_spec != metadata.version_spec { - spec_rewrites.push((package_name.clone(), new_spec)); + spec_rewrites.push((package_name.clone(), new_spec, package.clone())); } } else if latest && metadata.version_spec.is_some() { // `--latest` applies to bare names only; explicit specs win. - spec_rewrites.push((package_name.clone(), None)); + spec_rewrites.push((package_name.clone(), None, package_name.clone())); } if !is_same_node_version(&metadata.platform.node, ¤t_node_version) { // Match the spec `get_outdated_packages` resolves for this @@ -784,8 +787,8 @@ async fn managed_update( latest, ) .await?; - for failure in &report.failures { - output::warn(&format!("{failure}; skipping")); + for (_, message) in &report.failures { + output::warn(&format!("{message}; skipping")); } // Skipped lookups make the update incomplete; keep going but exit // nonzero so scripts can tell. @@ -800,9 +803,19 @@ async fn managed_update( .map(|package| package.spec.unwrap_or(package.name)), ); - // Rewrites are idempotent for packages that are also reinstalled below; - // applying before the install keeps the early up-to-date return covered. - for (package_name, new_spec) in &spec_rewrites { + let to_update_set = to_update.iter().map(String::as_str).collect::>(); + + // Persist rewrites only for packages that resolved but need no reinstall: + // reinstalled packages record their spec through the successful install + // (and keep the old one if it fails), and a failed lookup must not + // persist a policy the update could not act on. + let failed_specs = + report.failures.iter().map(|(spec, _)| spec.as_str()).collect::>(); + for (package_name, new_spec, query_spec) in &spec_rewrites { + if failed_specs.contains(query_spec.as_str()) || to_update_set.contains(query_spec.as_str()) + { + continue; + } if let Some(mut metadata) = PackageMetadata::load(package_name).await? { if metadata.version_spec != *new_spec { metadata.version_spec = new_spec.clone(); @@ -811,7 +824,6 @@ async fn managed_update( } } - let to_update_set = to_update.iter().map(String::as_str).collect::>(); node_mismatches.retain(|package| !to_update_set.contains(package.spec.as_str())); if should_reinstall_node_mismatches( diff --git a/crates/vite_global_cli/src/commands/global/outdated.rs b/crates/vite_global_cli/src/commands/global/outdated.rs index e959844b93..d043e0a579 100644 --- a/crates/vite_global_cli/src/commands/global/outdated.rs +++ b/crates/vite_global_cli/src/commands/global/outdated.rs @@ -32,12 +32,13 @@ pub struct OutdatedPackage { } /// Outcome of a registry sweep over the managed global packages: the packages -/// with a newer version, plus one message per package whose registry lookup -/// failed (so callers can warn and continue instead of aborting). +/// with a newer version, plus one `(query spec, message)` entry per package +/// whose registry lookup failed (so callers can warn and continue instead of +/// aborting, and can tell which packages were left unresolved). #[derive(Debug)] pub struct OutdatedReport { pub outdated: Vec, - pub failures: Vec, + pub failures: Vec<(String, String)>, } /// For json output in `vp outdated` command @@ -136,7 +137,7 @@ pub async fn get_outdated_packages( let wanted = match resolve(&wanted_key) { Ok(wanted) => wanted, Err(error) => { - failures.push(error); + failures.push((wanted_key, error)); continue; } }; @@ -187,8 +188,8 @@ pub async fn execute( } }; - for failure in &failures { - vite_shared::output::warn(&format!("{failure}; skipping")); + for (_, message) in &failures { + vite_shared::output::warn(&format!("{message}; skipping")); } // Exit code 0 means fully checked and up to date; 1 means outdated or incomplete. From 2cb092bad0e510f2d9dd3505d5f876ddc06909c0 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Mon, 27 Jul 2026 15:45:17 +0200 Subject: [PATCH 4/4] fix(global): split update and outdated lookups --- crates/vite_global_cli/src/cli.rs | 1 + .../src/commands/global/outdated.rs | 190 ++++++++++++++---- 2 files changed, 148 insertions(+), 43 deletions(-) diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 18d90b0190..5558ae36a9 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -785,6 +785,7 @@ async fn managed_update( &packages.unwrap_or_default(), concurrency * 3, latest, + global::outdated::LookupMode::WantedOnly, ) .await?; for (_, message) in &report.failures { diff --git a/crates/vite_global_cli/src/commands/global/outdated.rs b/crates/vite_global_cli/src/commands/global/outdated.rs index d043e0a579..a454447f1d 100644 --- a/crates/vite_global_cli/src/commands/global/outdated.rs +++ b/crates/vite_global_cli/src/commands/global/outdated.rs @@ -41,6 +41,66 @@ pub struct OutdatedReport { pub failures: Vec<(String, String)>, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LookupMode { + /// Resolve only the version an update would install. + WantedOnly, + /// Also resolve the registry's `latest` dist-tag for outdated reporting. + WantedAndLatest, +} + +fn registry_specs( + installed: &[(PackageMetadata, Option)], + mode: LookupMode, +) -> Vec { + let mut specs = Vec::new(); + let mut seen = HashSet::new(); + for (package, spec) in installed { + let wanted_query = spec.as_deref().unwrap_or(&package.name); + let queries = std::iter::once(wanted_query).chain( + (mode == LookupMode::WantedAndLatest && wanted_query != package.name) + .then_some(package.name.as_str()), + ); + for query in queries { + if seen.insert(query.to_string()) { + specs.push(query.to_string()); + } + } + } + specs +} + +fn resolve_registry_version( + versions: &HashMap>, + key: &str, +) -> Result { + match versions.get(key) { + Some(Ok(version)) => Ok(version.trim().to_string()), + // Keep the first line: npm's stderr goes on to multi-line advice and + // a timestamped log path, which is noise in a one-line warning. + Some(Err(error)) => { + Err(error.lines().next().unwrap_or("registry lookup failed").to_string()) + } + None => Err(format!("no registry response for {key}")), + } +} + +fn resolve_package_versions( + versions: &HashMap>, + package_name: &str, + wanted_key: &str, + mode: LookupMode, +) -> Result<(String, String), (String, String)> { + let wanted = resolve_registry_version(versions, wanted_key) + .map_err(|error| (wanted_key.to_string(), error))?; + let latest = match mode { + LookupMode::WantedOnly => wanted.clone(), + LookupMode::WantedAndLatest => resolve_registry_version(versions, package_name) + .map_err(|error| (package_name.to_string(), error))?, + }; + Ok((wanted, latest)) +} + /// For json output in `vp outdated` command /// Use `npm outdated --json`'s data structure #[derive(Debug, Serialize)] @@ -57,6 +117,7 @@ pub async fn get_outdated_packages( packages: &[String], concurrency: usize, latest: bool, + mode: LookupMode, ) -> Result { // 1. Resolve the command arguments to vite-plus-managed global packages. // A missing explicit package is a command result, not an internal error. @@ -98,20 +159,13 @@ pub async fn get_outdated_packages( return Ok(OutdatedReport { outdated: Vec::new(), failures: Vec::new() }); } - // 2. Query the registry once per distinct spec: the wanted version comes - // from the (possibly spec-qualified) query spec, the latest version - // from the bare package name; for spec-less packages both coincide. + // 2. Query the registry once per distinct spec. The wanted version comes + // from the (possibly spec-qualified) query spec. Outdated reporting + // additionally needs the bare package name for the `latest` dist-tag, + // while updates deliberately skip that informational lookup. // A registry setup failure is fatal; per-package lookup failures are // collected so callers can warn and continue. - let mut specs = Vec::new(); - let mut seen = HashSet::new(); - for (package, spec) in &installed { - for query in [spec.as_deref().unwrap_or(&package.name), package.name.as_str()] { - if seen.insert(query.to_string()) { - specs.push(query.to_string()); - } - } - } + let specs = registry_specs(&installed, mode); let versions: HashMap> = latest_package_versions(&specs, concurrency) @@ -119,13 +173,6 @@ pub async fn get_outdated_packages( .into_iter() .map(|(spec, version)| (spec, version.map_err(|error| error.to_string()))) .collect(); - let resolve = |key: &str| match versions.get(key) { - Some(Ok(version)) => Ok(version.trim().to_string()), - // Keep the first line: npm's stderr goes on to multi-line advice and - // a timestamped log path, which is noise in a one-line warning. - Some(Err(error)) => Err(error.lines().next().unwrap_or("registry lookup failed").into()), - None => Err(format!("no registry response for {key}")), - }; // 3. Compare installed metadata with registry versions. Packages whose // registry lookup failed are reported as failures because there is no @@ -134,17 +181,14 @@ pub async fn get_outdated_packages( let mut failures = Vec::new(); for (package, spec) in installed { let wanted_key = spec.clone().unwrap_or_else(|| package.name.clone()); - let wanted = match resolve(&wanted_key) { - Ok(wanted) => wanted, - Err(error) => { - failures.push((wanted_key, error)); - continue; - } - }; - // The latest tag is informational; updates only need `wanted`, so a - // failing bare-name lookup degrades the display instead of skipping - // the package. - let latest = resolve(&package.name).unwrap_or_else(|_| wanted.clone()); + let (wanted, latest) = + match resolve_package_versions(&versions, &package.name, &wanted_key, mode) { + Ok(versions) => versions, + Err(failure) => { + failures.push(failure); + continue; + } + }; let current = package.version.trim().to_string(); if current == wanted && current == latest { continue; @@ -173,20 +217,24 @@ pub async fn execute( format: Option, concurrency: usize, ) -> Result { - let OutdatedReport { outdated, failures } = - match get_outdated_packages(packages, concurrency, false).await { - Ok(report) => report, - Err(error) => { - if let Some(Format::Json) = format { - vite_shared::output::raw("{}"); - } else { - vite_shared::output::error(&format!( - "Could not get outdated packages: {error}" - )); - } - return Err(error); + let OutdatedReport { outdated, failures } = match get_outdated_packages( + packages, + concurrency, + false, + LookupMode::WantedAndLatest, + ) + .await + { + Ok(report) => report, + Err(error) => { + if let Some(Format::Json) = format { + vite_shared::output::raw("{}"); + } else { + vite_shared::output::error(&format!("Could not get outdated packages: {error}")); } - }; + return Err(error); + } + }; for (_, message) in &failures { vite_shared::output::warn(&format!("{message}; skipping")); @@ -337,3 +385,59 @@ fn print_table(packages: &[OutdatedPackage], long: bool) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn installed(version_spec: Option<&str>) -> Vec<(PackageMetadata, Option)> { + let mut package = PackageMetadata::new( + "some-pkg".into(), + "1.0.0".into(), + "22.0.0".into(), + None, + Vec::new(), + HashSet::new(), + "npm".into(), + ); + package.version_spec = version_spec.map(str::to_string); + let query_spec = package.version_spec.is_some().then(|| package.update_spec()); + vec![(package, query_spec)] + } + + #[test] + fn wanted_only_skips_the_bare_latest_lookup() { + let specs = registry_specs(&installed(Some("^1.0.0")), LookupMode::WantedOnly); + assert_eq!(specs, ["some-pkg@^1.0.0"]); + } + + #[test] + fn wanted_and_latest_resolves_both_distinct_specs() { + let specs = registry_specs(&installed(Some("^1.0.0")), LookupMode::WantedAndLatest); + assert_eq!(specs, ["some-pkg@^1.0.0", "some-pkg"]); + } + + #[test] + fn wanted_and_latest_deduplicates_the_bare_spec() { + let specs = registry_specs(&installed(None), LookupMode::WantedAndLatest); + assert_eq!(specs, ["some-pkg"]); + } + + #[test] + fn wanted_and_latest_reports_a_failed_latest_lookup() { + let versions = HashMap::from([ + ("some-pkg@^1.0.0".into(), Ok("1.2.0".into())), + ("some-pkg".into(), Err("latest lookup failed\nnpm log path".into())), + ]); + + let failure = resolve_package_versions( + &versions, + "some-pkg", + "some-pkg@^1.0.0", + LookupMode::WantedAndLatest, + ) + .unwrap_err(); + + assert_eq!(failure, ("some-pkg".into(), "latest lookup failed".into())); + } +}