diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 509bab7a40..4835ea917d 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -13,15 +13,13 @@ use vite_pm_cli::PackageManagerCommand; use vite_shared::output; use crate::{ - commands::{ - self, - env::{global_install, package_metadata::PackageMetadata}, - }, + commands::{self, env::package_metadata::PackageMetadata, global}, error::Error, help, }; const DEFAULT_GLOBAL_INSTALL_CONCURRENCY: usize = 5; +const DEFAULT_GLOBAL_VIEW_CONCURRENCY: usize = 3 * DEFAULT_GLOBAL_INSTALL_CONCURRENCY; #[derive(Clone, Copy, Debug)] pub struct RenderOptions { @@ -533,7 +531,7 @@ fn run_tasks_completions(current: &OsStr) -> Vec { + global::outdated::execute( + packages, + long, + format, + concurrency.unwrap_or(DEFAULT_GLOBAL_VIEW_CONCURRENCY), + ) + .await + } + // `pm list -g` lists vite-plus-managed globals, not the underlying PM's. PackageManagerCommand::Pm(vite_pm_cli::cli::PmCommands::List { global: true, json, ref pattern, .. - }) => crate::commands::env::packages::execute(json, pattern.as_deref()).await, + }) => global::packages::execute(json, pattern.as_deref()).await, cmd => { commands::prepend_js_runtime_to_path_env(&cwd).await?; @@ -583,7 +598,7 @@ async fn managed_install( force: bool, concurrency: Option, ) -> Result { - if let Err((package_name, error)) = crate::commands::env::global_install::install( + if let Err((package_name, error)) = global::install::install( packages, node, force, @@ -604,7 +619,7 @@ async fn managed_install( async fn managed_uninstall(packages: &[String], dry_run: bool) -> Result { for package in packages { - if let Err(e) = crate::commands::env::global_install::uninstall(package, dry_run).await { + if let Err(e) = global::install::uninstall(package, dry_run).await { vite_shared::output::raw_stderr(&format!("Failed to uninstall {package}: {e}")); return Ok(exit_status(1)); } @@ -612,98 +627,60 @@ async fn managed_uninstall(packages: &[String], dry_run: bool) -> Result bool { - installed_version.trim() == registry_version.trim() -} - async fn managed_update( packages: &[String], concurrency: Option, ) -> Result { - let all_packages = if packages.is_empty() { + let concurrency = concurrency.unwrap_or(DEFAULT_GLOBAL_INSTALL_CONCURRENCY); + let mut to_update: Vec = Vec::new(); + + let packages = if packages.is_empty() { let all = PackageMetadata::list_all().await?; if all.is_empty() { vite_shared::output::raw("No global packages installed."); return Ok(ExitStatus::default()); } - Some(all) - } else { - None - }; - let mut to_update: Vec = Vec::new(); - let mut skipped = 0usize; - - if let Some(all) = all_packages { - for metadata in all { - match global_install::latest_package_version(&metadata.name).await { - Ok(latest_version) - if is_global_package_up_to_date(&metadata.version, &latest_version) => - { - vite_shared::output::raw(&format!( - "{} is already up to date (v{}).", - metadata.name, metadata.version - )); - skipped += 1; - } - Ok(_) => to_update.push(metadata.name.clone()), - Err(e) => { - vite_shared::output::raw_stderr(&format!( - "Could not check latest version for {}: {e}; updating anyway.", - metadata.name - )); - to_update.push(metadata.name.clone()); - } - } - } + None } else { + let mut managed_specs = Vec::new(); + for package in packages { - if global_install::is_local_package_spec(package) { + // Always update local packages + if global::is_local_package_spec(package) { to_update.push(package.clone()); continue; } - let (package_name, _) = global_install::parse_package_spec(package); - if let Some(metadata) = PackageMetadata::load(&package_name).await? { - match global_install::latest_package_version(package).await { - Ok(latest_version) - if is_global_package_up_to_date(&metadata.version, &latest_version) => - { - vite_shared::output::raw(&format!( - "{} is already up to date (v{}).", - package_name, metadata.version - )); - skipped += 1; - continue; - } - Ok(_) => {} - Err(e) => { - vite_shared::output::raw_stderr(&format!( - "Could not check latest version for {package}: {e}; updating anyway." - )); - } - } + let (package_name, _) = global::parse_package_spec(package); + if PackageMetadata::load(&package_name).await?.is_some() { + managed_specs.push(package.clone()); + } else { + to_update.push(package.clone()); } - to_update.push(package.clone()); } - } + + Some(managed_specs) + }; + to_update.extend( + global::outdated::get_outdated_packages( + &packages.unwrap_or(Vec::new()), + concurrency * 3, + true, + ) + .await? + .into_iter() + .map(|package| package.spec.unwrap_or(package.name)), + ); if to_update.is_empty() { - if skipped > 0 { - vite_shared::output::raw("All global packages are up to date."); - } + vite_shared::output::raw("All global packages are up to date."); return Ok(ExitStatus::default()); } // Call reinstall logic - if let Err((package_name, error)) = global_install::install( - &to_update, - None, - false, - concurrency.unwrap_or(DEFAULT_GLOBAL_INSTALL_CONCURRENCY), - true, - ) - .await + if let Err((package_name, error)) = + global::install::install(&to_update, None, false, concurrency, true).await { output::error(&format!( "Failed to update {}: {error}", @@ -956,20 +933,10 @@ pub fn try_parse_args_from_with_options( #[cfg(test)] mod tests { use super::{ - has_flag_before_terminator, is_global_package_up_to_date, should_force_global_delegate, + has_flag_before_terminator, should_force_global_delegate, should_suppress_header_for_subcommand, }; - #[test] - fn skips_global_update_when_registry_and_node_versions_match() { - assert!(is_global_package_up_to_date("5.9.3", "5.9.3")); - } - - #[test] - fn updates_global_package_when_registry_version_differs_from_installed_version() { - assert!(!is_global_package_up_to_date("5.9.2", "5.9.3")); - } - #[test] fn detects_flag_before_option_terminator() { assert!(has_flag_before_terminator( diff --git a/crates/vite_global_cli/src/commands/env/mod.rs b/crates/vite_global_cli/src/commands/env/mod.rs index 6202039cb9..c4e8610592 100644 --- a/crates/vite_global_cli/src/commands/env/mod.rs +++ b/crates/vite_global_cli/src/commands/env/mod.rs @@ -9,13 +9,11 @@ mod current; mod default; mod doctor; mod exec; -pub mod global_install; mod list; mod list_remote; mod off; mod on; pub mod package_metadata; -pub mod packages; mod pin; mod setup; mod unpin; @@ -24,6 +22,8 @@ mod which; use std::process::ExitStatus; +#[cfg(windows)] +pub(crate) use setup::{cleanup_legacy_windows_shim, get_trampoline_path, remove_or_rename_to_old}; use vite_path::AbsolutePathBuf; use crate::{ diff --git a/crates/vite_global_cli/src/commands/env/global_install.rs b/crates/vite_global_cli/src/commands/global/install.rs similarity index 87% rename from crates/vite_global_cli/src/commands/env/global_install.rs rename to crates/vite_global_cli/src/commands/global/install.rs index b1d6c38b83..b28bdc39e2 100644 --- a/crates/vite_global_cli/src/commands/env/global_install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -16,15 +16,20 @@ use vite_js_runtime::NodeProvider; use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_shared::{format_path_prepended, output}; -use super::{ - bin_config::BinConfig, - config::{ - get_bin_dir, get_node_modules_dir, get_packages_dir, get_tmp_dir, resolve_version, - resolve_version_alias, +use crate::{ + commands::{ + env::{ + bin_config::BinConfig, + config::{ + get_bin_dir, get_node_modules_dir, get_packages_dir, get_tmp_dir, resolve_version, + resolve_version_alias, + }, + package_metadata::PackageMetadata, + }, + global::{CORE_SHIMS, parse_package_spec}, }, - package_metadata::PackageMetadata, + error::Error, }; -use crate::error::Error; struct Package<'a> { spec: &'a str, @@ -443,118 +448,6 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { Ok(()) } -/// Resolve the version currently published for a package spec. -/// -/// `package_spec` may be a bare package name (`typescript`) or include a -/// version/tag (`typescript@beta`, `@scope/pkg@1.0.0`). The command returns the -/// version that npm resolves for that spec. -pub(crate) async fn latest_package_version(package_spec: &str) -> Result { - // Resolve from current directory - let node_version = { - let cwd = match current_dir() { - Ok(cwd) => cwd, - Err(error) => { - let error = - Error::ConfigError(format!("Cannot get current directory: {}", error).into()); - return Err(error); - } - }; - let resolution = match resolve_version(&cwd).await { - Ok(resolution) => resolution, - Err(error) => return Err(error), - }; - resolution.version - }; - - // Ensure Node.js is installed - let runtime = match vite_js_runtime::download_runtime( - vite_js_runtime::JsRuntimeType::Node, - &node_version, - ) - .await - { - Ok(runtime) => runtime, - Err(error) => { - let error = Error::RuntimeDownload(error); - return Err(error); - } - }; - - let node_bin_dir = runtime.get_bin_prefix(); - let npm_path = - if cfg!(windows) { node_bin_dir.join("npm.cmd") } else { node_bin_dir.join("npm") }; - - let output = Command::new(npm_path.as_path()) - .args(["view", package_spec, "version", "--json"]) - .env("PATH", format_path_prepended(node_bin_dir.as_path())) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(Error::ConfigError( - format!("npm view failed for {package_spec}: {stderr}").into(), - )); - } - - parse_npm_view_version(&output.stdout) -} - -fn parse_npm_view_version(stdout: &[u8]) -> Result { - let raw = String::from_utf8_lossy(stdout); - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(Error::ConfigError("npm view returned an empty version".into())); - } - - match serde_json::from_str::(trimmed) { - Ok(serde_json::Value::String(version)) => Ok(version), - Ok(serde_json::Value::Array(versions)) => versions - .iter() - .rev() - .find_map(|version| version.as_str()) - .map(str::to_string) - .ok_or_else(|| Error::ConfigError("npm view returned an empty version list".into())), - _ => Ok(trimmed.to_string()), - } -} - -/// Return true for package specs that refer to local filesystem content. -pub(crate) fn is_local_package_spec(spec: &str) -> bool { - spec == "." - || spec == ".." - || spec.starts_with("./") - || spec.starts_with("../") - || spec.starts_with('/') - || spec.starts_with("file:") - || (cfg!(windows) - && spec.len() >= 3 - && spec.as_bytes()[1] == b':' - && (spec.as_bytes()[2] == b'\\' || spec.as_bytes()[2] == b'/')) -} - -/// Parse package spec into name and optional version. -pub(crate) fn parse_package_spec(spec: &str) -> (String, Option) { - // Handle scoped packages: @scope/name@version - if spec.starts_with('@') { - // Find the second @ for version - if let Some(idx) = spec[1..].find('@') { - let idx = idx + 1; // Adjust for the skipped first char - return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string())); - } - return (spec.to_string(), None); - } - - // Handle regular packages: name@version - if let Some(idx) = spec.find('@') { - return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string())); - } - - (spec.to_string(), None) -} - /// Binary info extracted from package.json. struct BinaryInfo { /// Binary name (the command users will run) @@ -630,9 +523,6 @@ fn is_javascript_binary(path: &AbsolutePath) -> bool { false } -/// Core shims that should not be overwritten by package binaries. -pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"]; - /// Create a shim for a package binary. /// /// On Unix: Creates a symlink to ../current/bin/vp @@ -674,21 +564,25 @@ async fn create_package_shim( #[cfg(windows)] { + use crate::commands::env::{ + cleanup_legacy_windows_shim, get_trampoline_path, remove_or_rename_to_old, + }; + let shim_path = bin_dir.join(format!("{}.exe", bin_name)); // Delete before overwrite; falls back to rename if the exe is locked. - super::setup::remove_or_rename_to_old(&shim_path).await; + remove_or_rename_to_old(&shim_path).await; // Copy the trampoline binary as .exe. // The trampoline detects the tool name from its own filename and sets // VP_SHIM_TOOL env var before spawning vp.exe. - let trampoline_src = super::setup::get_trampoline_path()?; + let trampoline_src = get_trampoline_path()?; tokio::fs::copy(trampoline_src.as_path(), &shim_path).await?; // Remove legacy .cmd and shell script wrappers from previous versions. // In Git Bash/MSYS, the extensionless script takes precedence over .exe, // so leftover wrappers would bypass the trampoline. - super::setup::cleanup_legacy_windows_shim(bin_dir, bin_name).await; + cleanup_legacy_windows_shim(bin_dir, bin_name).await; tracing::debug!("Created package trampoline shim {:?}", shim_path); } @@ -735,6 +629,7 @@ async fn remove_package_shim( #[cfg(test)] mod tests { use super::*; + use crate::commands::global::is_local_package_spec; /// RAII guard that sets `VP_TRAMPOLINE_PATH` to a fake binary on creation /// and clears it on drop. Ensures cleanup even on test panics. @@ -967,30 +862,6 @@ mod tests { } } - #[test] - fn test_parse_npm_view_version_json_string() { - let version = parse_npm_view_version(b"\"5.9.3\"\n").unwrap(); - assert_eq!(version, "5.9.3"); - } - - #[test] - fn test_parse_npm_view_version_plain_string() { - let version = parse_npm_view_version(b"5.9.3\n").unwrap(); - assert_eq!(version, "5.9.3"); - } - - #[test] - fn test_parse_npm_view_version_json_array_uses_latest_entry() { - let version = parse_npm_view_version(b"[\"5.9.2\", \"5.9.3\"]\n").unwrap(); - assert_eq!(version, "5.9.3"); - } - - #[test] - fn test_parse_npm_view_version_rejects_empty_output() { - let err = parse_npm_view_version(b"\n").unwrap_err(); - assert!(err.to_string().contains("empty version")); - } - #[test] fn test_is_local_package_spec_relative_paths() { assert!(is_local_package_spec(".")); diff --git a/crates/vite_global_cli/src/commands/global/mod.rs b/crates/vite_global_cli/src/commands/global/mod.rs new file mode 100644 index 0000000000..4915a68d37 --- /dev/null +++ b/crates/vite_global_cli/src/commands/global/mod.rs @@ -0,0 +1,198 @@ +//! Managed global package utilities. + +use std::{collections::HashMap, io::IsTerminal, process::Stdio, time::Duration}; + +use futures::{StreamExt, stream::FuturesUnordered}; +use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; +use tokio::process::Command; +use vite_path::{AbsolutePathBuf, current_dir}; +use vite_shared::format_path_prepended; + +use crate::{commands::env::config::resolve_version, error::Error}; + +pub mod install; +pub mod outdated; +pub mod packages; + +/// Core shims that should not be overwritten by package binaries. +pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"]; + +#[derive(Debug)] +struct PackageVersion { + package_spec: String, + version: Result, +} + +struct NpmRegistry { + npm_path: AbsolutePathBuf, + node_bin_dir: AbsolutePathBuf, +} + +impl NpmRegistry { + async fn resolve() -> Result { + let cwd = current_dir().map_err(|error| { + Error::ConfigError(format!("Cannot get current directory: {error}").into()) + })?; + let resolution = resolve_version(&cwd).await?; + let runtime = vite_js_runtime::download_runtime( + vite_js_runtime::JsRuntimeType::Node, + &resolution.version, + ) + .await?; + + let node_bin_dir = runtime.get_bin_prefix(); + let npm_path = + if cfg!(windows) { node_bin_dir.join("npm.cmd") } else { node_bin_dir.join("npm") }; + + Ok(Self { npm_path, node_bin_dir }) + } + + async fn latest_package_version(&self, package_spec: &str) -> Result { + let output = Command::new(self.npm_path.as_path()) + .args(["view", package_spec, "version", "--json"]) + .env("PATH", format_path_prepended(self.node_bin_dir.as_path())) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(Error::ConfigError( + format!("npm view failed for {package_spec}: {stderr}").into(), + )); + } + + parse_npm_view_version(&output.stdout) + } +} + +pub(crate) async fn latest_package_versions( + specs: &[String], + concurrency: usize, +) -> Result>, Error> { + if specs.is_empty() { + return Ok(HashMap::new()); + } + + let registry = NpmRegistry::resolve().await?; + let concurrency = concurrency.max(1); + let mut package_specs = specs.iter(); + let mut versions = HashMap::with_capacity(specs.len()); + + let progress = ProgressBar::new(specs.len() as u64); + if std::io::stderr().is_terminal() && std::env::var_os("CI").is_none() { + let style = ProgressStyle::with_template("{spinner:.cyan} {msg} ({pos}/{len})") + .unwrap_or_else(|_| ProgressStyle::default_spinner()) + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); + progress.set_style(style); + progress.set_message("Checking latest package versions"); + progress.enable_steady_tick(Duration::from_millis(80)); + } else { + progress.set_draw_target(ProgressDrawTarget::hidden()); + } + + let mut queries = FuturesUnordered::new(); + + loop { + while queries.len() < concurrency { + let Some(package_spec) = package_specs.next() else { break }; + queries.push(async { + let package_spec = package_spec.clone(); + let version = registry.latest_package_version(&package_spec).await; + PackageVersion { package_spec, version } + }); + } + + if queries.is_empty() { + break; + } + + if let Some(version) = queries.next().await { + progress.inc(1); + versions.insert(version.package_spec, version.version); + } + } + progress.finish_and_clear(); + + Ok(versions) +} + +/// Return true for package specs that refer to local filesystem content. +pub(crate) fn is_local_package_spec(spec: &str) -> bool { + spec == "." + || spec == ".." + || spec.starts_with("./") + || spec.starts_with("../") + || spec.starts_with('/') + || spec.starts_with("file:") + || (cfg!(windows) + && spec.len() >= 3 + && spec.as_bytes()[1] == b':' + && (spec.as_bytes()[2] == b'\\' || spec.as_bytes()[2] == b'/')) +} + +/// Parse package spec into name and optional version. +pub(crate) fn parse_package_spec(spec: &str) -> (String, Option) { + if spec.starts_with('@') { + if let Some(idx) = spec[1..].find('@') { + let idx = idx + 1; + return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string())); + } + return (spec.to_string(), None); + } + + if let Some(idx) = spec.find('@') { + return (spec[..idx].to_string(), Some(spec[idx + 1..].to_string())); + } + + (spec.to_string(), None) +} + +fn parse_npm_view_version(stdout: &[u8]) -> Result { + let raw = String::from_utf8_lossy(stdout); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(Error::ConfigError("npm view returned an empty version".into())); + } + + 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 { + return Err(Error::ConfigError("npm view returned an empty version list".into())); + }; + Ok(version.to_string()) + } + _ => Ok(trimmed.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_json_string_version() { + let version = parse_npm_view_version(br#""5.0.0""#).unwrap(); + assert_eq!(version, "5.0.0"); + } + + #[test] + fn parses_json_array_version() { + let version = parse_npm_view_version(br#"["4.9.5","5.0.0"]"#).unwrap(); + assert_eq!(version, "5.0.0"); + } + + #[test] + fn parses_plain_version() { + let version = parse_npm_view_version(b"5.0.0").unwrap(); + assert_eq!(version, "5.0.0"); + } + + #[test] + fn rejects_empty_output() { + let error = parse_npm_view_version(b"\n").unwrap_err(); + assert!(error.to_string().contains("empty version")); + } +} diff --git a/crates/vite_global_cli/src/commands/global/outdated.rs b/crates/vite_global_cli/src/commands/global/outdated.rs new file mode 100644 index 0000000000..233473caf0 --- /dev/null +++ b/crates/vite_global_cli/src/commands/global/outdated.rs @@ -0,0 +1,272 @@ +//! Check managed global packages for newer registry versions. + +use std::{ + collections::{BTreeMap, HashMap}, + process::ExitStatus, +}; + +use owo_colors::OwoColorize; +use serde::Serialize; +use vite_install::commands::outdated::Format; + +use super::{latest_package_versions, parse_package_spec}; +use crate::{ + commands::env::{ + config::{get_node_modules_dir, get_packages_dir}, + package_metadata::PackageMetadata, + }, + error::Error, +}; + +#[derive(Debug)] +pub struct OutdatedPackage { + pub name: String, + pub current: String, + pub latest: String, + pub spec: Option, + node: String, + bins: Vec, +} + +/// For json output in `vp outdated` command +/// Use `npm outdated --json`'s data structure +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct OutdatedPackageJson { + current: String, + wanted: String, + latest: String, + dependent: &'static str, + location: String, +} + +pub async fn get_outdated_packages( + packages: &[String], + concurrency: usize, + error_on_fail: bool, +) -> Result, Error> { + // 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 (package_name, _) = parse_package_spec(package); + if let Some(metadata) = PackageMetadata::load(&package_name).await? { + installed.push((metadata, Some(package.clone()))); + } + } + installed + } else { + PackageMetadata::list_all().await?.into_iter().map(|package| (package, None)).collect() + }; + + if installed.is_empty() { + return Ok(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); + } + } + } + } + let mut latest_versions = latest_versions_map; + + // 3. Compare installed metadata with registry versions. Packages whose + // registry lookup failed are skipped because there is no version to compare. + let mut outdated = 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; + }; + if package.version.trim() == version.trim() { + continue; + } + + outdated.push(OutdatedPackage { + name: package.name, + current: package.version, + latest: version, + spec, + node: package.platform.node, + bins: package.bins, + }); + } + + Ok(outdated) +} + +pub async fn execute( + packages: &[String], + long: bool, + 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}")); + } + return Err(error); + } + }; + + // 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 { + vite_shared::output::info("All global packages are up to date."); + } + return Ok(ExitStatus::default()); + } + + match format { + Some(Format::Json) => print_json(&outdated)?, + Some(Format::List) => print_list(&outdated, long), + _ => print_table(&outdated, long), + } + + Ok(exit_status(1)) +} + +fn print_json(packages: &[OutdatedPackage]) -> Result<(), Error> { + let packages_dir = get_packages_dir()?; + let mut output = BTreeMap::new(); + + for package in packages { + let package_dir = packages_dir.join(&package.name); + let location = get_node_modules_dir(&package_dir, &package.name); + + output.insert( + package.name.clone(), + OutdatedPackageJson { + current: package.current.clone(), + wanted: package.latest.clone(), + latest: package.latest.clone(), + dependent: "global", + location: location.as_path().display().to_string(), + }, + ); + } + + let json = serde_json::to_string_pretty(&output)?; + println!("{json}"); + Ok(()) +} + +fn print_list(packages: &[OutdatedPackage], long: bool) { + for (index, package) in packages.iter().enumerate() { + if index > 0 { + println!(); + } + + println!("{} {}", package.name.bold(), "(global)".dimmed()); + println!("{} {} {}", package.current.dimmed(), "=>".dimmed(), package.latest.bold()); + + if long { + println!("{} {}", "node".dimmed(), package.node); + if !package.bins.is_empty() { + println!("{} {}", "bins".dimmed(), package.bins.join(", ")); + } + } + } +} + +fn print_table(packages: &[OutdatedPackage], long: bool) { + let col_pkg = "Package"; + let col_current = "Current"; + 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_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_latest = w_latest.max(package.latest.len()); + w_node = w_node.max(package.node.len()); + } + + let gap = 3; + if long { + println!( + "{:gap$}{:gap$}{:gap$}{:gap$}{}", + col_pkg, "", col_current, "", col_latest, "", col_node, "", col_bins + ); + println!( + "{:gap$}{:gap$}{:gap$}{:gap$}{}", + "---", "", "---", "", "---", "", "---", "", "---" + ); + } else { + println!( + "{:gap$}{:gap$}{}", + col_pkg, "", col_current, "", col_latest + ); + println!("{:gap$}{:gap$}---", "---", "", "---", ""); + } + + for package in packages { + if long { + println!( + "{}{:>gap$}{:gap$}{:gap$}{:gap$}{}", + format!("{:gap$}{:gap$}{}", + format!("{: ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(code << 8) + } + + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(code as u32) + } +} diff --git a/crates/vite_global_cli/src/commands/env/packages.rs b/crates/vite_global_cli/src/commands/global/packages.rs similarity index 96% rename from crates/vite_global_cli/src/commands/env/packages.rs rename to crates/vite_global_cli/src/commands/global/packages.rs index 07ef5bda00..916585c162 100644 --- a/crates/vite_global_cli/src/commands/env/packages.rs +++ b/crates/vite_global_cli/src/commands/global/packages.rs @@ -4,8 +4,7 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; -use super::package_metadata::PackageMetadata; -use crate::error::Error; +use crate::{commands::env::package_metadata::PackageMetadata, error::Error}; /// Execute the packages command. pub async fn execute(json: bool, pattern: Option<&str>) -> Result { diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 9f2fc26932..18679f0de6 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -85,6 +85,9 @@ pub async fn prepend_js_runtime_to_path_env(project_path: &AbsolutePath) -> Resu Ok(()) } +// Global package management +pub mod global; + // Category B: JS Script Commands pub mod config; pub mod create; diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 1275113aa4..55c18a83c6 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -17,11 +17,13 @@ use super::{ exec, is_core_shim_tool, }; use crate::{ - commands::env::{ - bin_config::{BinConfig, BinSource}, - config::{self, ShimMode}, - global_install::CORE_SHIMS, - package_metadata::PackageMetadata, + commands::{ + env::{ + bin_config::{BinConfig, BinSource}, + config::{self, ShimMode}, + package_metadata::PackageMetadata, + }, + global::CORE_SHIMS, }, error::Error, }; diff --git a/crates/vite_pm_cli/src/cli.rs b/crates/vite_pm_cli/src/cli.rs index 64c25cd54f..ca196609e9 100644 --- a/crates/vite_pm_cli/src/cli.rs +++ b/crates/vite_pm_cli/src/cli.rs @@ -361,6 +361,10 @@ pub enum PackageManagerCommand { #[arg(short = 'g', long)] global: bool, + /// Number of global package checks to run in parallel (only with -g) + #[arg(long, requires = "global", value_parser = parse_positive_usize)] + concurrency: Option, + /// Additional arguments to pass through to the package manager #[arg(last = true, allow_hyphen_values = true)] pass_through_args: Option>, @@ -524,6 +528,7 @@ impl PackageManagerCommand { Self::Install { global, .. } | Self::Add { global, .. } | Self::Remove { global, .. } + | Self::Outdated { global, .. } | Self::Update { global, .. } => *global, Self::Pm(PmCommands::List { global, .. }) => *global, _ => false, diff --git a/crates/vite_pm_cli/src/dispatch.rs b/crates/vite_pm_cli/src/dispatch.rs index 035b3c1c2b..344ccd17d5 100644 --- a/crates/vite_pm_cli/src/dispatch.rs +++ b/crates/vite_pm_cli/src/dispatch.rs @@ -217,6 +217,7 @@ pub async fn dispatch( compatible, sort_by, global, + concurrency: _, pass_through_args, } => { let options = OutdatedCommandOptions { diff --git a/crates/vite_pm_cli/src/handlers.rs b/crates/vite_pm_cli/src/handlers.rs index 3ed2eec5a0..0b8fadc074 100644 --- a/crates/vite_pm_cli/src/handlers.rs +++ b/crates/vite_pm_cli/src/handlers.rs @@ -95,11 +95,7 @@ pub async fn run_outdated( cwd: &AbsolutePath, options: &OutdatedCommandOptions<'_>, ) -> Result { - let pm = if options.global { - default_npm_package_manager(cwd) - } else { - build_package_manager(cwd).await? - }; + let pm = build_package_manager(cwd).await?; Ok(pm.run_outdated_command(options, cwd).await?) } diff --git a/packages/cli/snap-tests-global/cli-helper-message/snap.txt b/packages/cli/snap-tests-global/cli-helper-message/snap.txt index fbbed52b04..3a3c2993f1 100644 --- a/packages/cli/snap-tests-global/cli-helper-message/snap.txt +++ b/packages/cli/snap-tests-global/cli-helper-message/snap.txt @@ -246,18 +246,19 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - --long Show extended information - --format Output format: table (default), list, or json - -r, --recursive Check recursively across all workspaces - --filter Filter packages in monorepo - -w, --workspace-root Include workspace root - -P, --prod Only production and optional dependencies - -D, --dev Only dev dependencies - --no-optional Exclude optional dependencies - --compatible Only show compatible versions - --sort-by Sort results by field - -g, --global Check globally installed packages - -h, --help Print help + --long Show extended information + --format Output format: table (default), list, or json + -r, --recursive Check recursively across all workspaces + --filter Filter packages in monorepo + -w, --workspace-root Include workspace root + -P, --prod Only production and optional dependencies + -D, --dev Only dev dependencies + --no-optional Exclude optional dependencies + --compatible Only show compatible versions + --sort-by Sort results by field + -g, --global Check globally installed packages + --concurrency Number of global package checks to run in parallel (only with -g) + -h, --help Print help Documentation: https://viteplus.dev/guide/install diff --git a/packages/cli/snap-tests-global/command-outdated-bun/snap.txt b/packages/cli/snap-tests-global/command-outdated-bun/snap.txt index edc726829d..94e810797e 100644 --- a/packages/cli/snap-tests-global/command-outdated-bun/snap.txt +++ b/packages/cli/snap-tests-global/command-outdated-bun/snap.txt @@ -8,18 +8,19 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - --long Show extended information - --format Output format: table (default), list, or json - -r, --recursive Check recursively across all workspaces - --filter Filter packages in monorepo - -w, --workspace-root Include workspace root - -P, --prod Only production and optional dependencies - -D, --dev Only dev dependencies - --no-optional Exclude optional dependencies - --compatible Only show compatible versions - --sort-by Sort results by field - -g, --global Check globally installed packages - -h, --help Print help + --long Show extended information + --format Output format: table (default), list, or json + -r, --recursive Check recursively across all workspaces + --filter Filter packages in monorepo + -w, --workspace-root Include workspace root + -P, --prod Only production and optional dependencies + -D, --dev Only dev dependencies + --no-optional Exclude optional dependencies + --compatible Only show compatible versions + --sort-by Sort results by field + -g, --global Check globally installed packages + --concurrency Number of global package checks to run in parallel (only with -g) + -h, --help Print help Documentation: https://viteplus.dev/guide/install diff --git a/packages/cli/snap-tests-global/command-outdated-global/package.json b/packages/cli/snap-tests-global/command-outdated-global/package.json new file mode 100644 index 0000000000..a6353dc52e --- /dev/null +++ b/packages/cli/snap-tests-global/command-outdated-global/package.json @@ -0,0 +1,3 @@ +{ + "name": "command-outdated-global" +} diff --git a/packages/cli/snap-tests-global/command-outdated-global/snap.txt b/packages/cli/snap-tests-global/command-outdated-global/snap.txt new file mode 100644 index 0000000000..2e610fa165 --- /dev/null +++ b/packages/cli/snap-tests-global/command-outdated-global/snap.txt @@ -0,0 +1,18 @@ +> vp install -g testnpm2@1.0.0 # should prepare global outdated package +> vp outdated definitely-not-installed-vite-plus-snap-pkg -g --format json # should support empty global json output +{} + +[1]> vp outdated testnpm2 -g --format json # should support global json output +{ + "testnpm2": { + "current": "1.0.0", + "wanted": "1.0.1", + "latest": "1.0.1", + "dependent": "global", + "location": "/packages/testnpm2/lib/node_modules/testnpm2" + } +} + +[1]> vp outdated testnpm2 -g --format list --concurrency 5 # should support global list output +testnpm2 (global) + => diff --git a/packages/cli/snap-tests-global/command-outdated-global/steps.json b/packages/cli/snap-tests-global/command-outdated-global/steps.json new file mode 100644 index 0000000000..b634d67ea3 --- /dev/null +++ b/packages/cli/snap-tests-global/command-outdated-global/steps.json @@ -0,0 +1,13 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": [ + { + "command": "vp install -g testnpm2@1.0.0 # should prepare global outdated package", + "ignoreOutput": true + }, + "vp outdated definitely-not-installed-vite-plus-snap-pkg -g --format json # should support empty global json output", + "vp outdated testnpm2 -g --format json # should support global json output", + "vp outdated testnpm2 -g --format list --concurrency 5 # should support global list output" + ], + "after": ["vp remove -g testnpm2"] +} diff --git a/packages/cli/snap-tests-global/command-outdated-pnpm10/snap.txt b/packages/cli/snap-tests-global/command-outdated-pnpm10/snap.txt index ec085ee577..b3ed2cbeed 100644 --- a/packages/cli/snap-tests-global/command-outdated-pnpm10/snap.txt +++ b/packages/cli/snap-tests-global/command-outdated-pnpm10/snap.txt @@ -8,18 +8,19 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - --long Show extended information - --format Output format: table (default), list, or json - -r, --recursive Check recursively across all workspaces - --filter Filter packages in monorepo - -w, --workspace-root Include workspace root - -P, --prod Only production and optional dependencies - -D, --dev Only dev dependencies - --no-optional Exclude optional dependencies - --compatible Only show compatible versions - --sort-by Sort results by field - -g, --global Check globally installed packages - -h, --help Print help + --long Show extended information + --format Output format: table (default), list, or json + -r, --recursive Check recursively across all workspaces + --filter Filter packages in monorepo + -w, --workspace-root Include workspace root + -P, --prod Only production and optional dependencies + -D, --dev Only dev dependencies + --no-optional Exclude optional dependencies + --compatible Only show compatible versions + --sort-by Sort results by field + -g, --global Check globally installed packages + --concurrency Number of global package checks to run in parallel (only with -g) + -h, --help Print help Documentation: https://viteplus.dev/guide/install @@ -171,6 +172,3 @@ testnpm2 ├──────────────────────────────────────────┼─────────┼────────┤ │ testnpm2 │ │ └──────────────────────────────────────────┴─────────┴────────┘ - -> vp outdated testnpm2 -g --format json # should support global output -{} diff --git a/packages/cli/snap-tests-global/command-outdated-pnpm10/steps.json b/packages/cli/snap-tests-global/command-outdated-pnpm10/steps.json index b1e9e87cf3..5614bae240 100644 --- a/packages/cli/snap-tests-global/command-outdated-pnpm10/steps.json +++ b/packages/cli/snap-tests-global/command-outdated-pnpm10/steps.json @@ -16,7 +16,6 @@ "vp outdated --no-optional # should support no-optional output", "vp outdated --compatible # should compatible output nothing", "json-edit package.json '_.optionalDependencies[\"test-vite-plus-other-optional\"] = \"^1.0.0\"' && vp outdated --compatible # should support compatible output with optional dependencies", - "vp outdated --sort-by name # should support sort-by output", - "vp outdated testnpm2 -g --format json # should support global output" + "vp outdated --sort-by name # should support sort-by output" ] } diff --git a/packages/cli/snap-tests-global/command-outdated-pnpm11/snap.txt b/packages/cli/snap-tests-global/command-outdated-pnpm11/snap.txt index d1701b9216..2ed676e0ba 100644 --- a/packages/cli/snap-tests-global/command-outdated-pnpm11/snap.txt +++ b/packages/cli/snap-tests-global/command-outdated-pnpm11/snap.txt @@ -8,18 +8,19 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - --long Show extended information - --format Output format: table (default), list, or json - -r, --recursive Check recursively across all workspaces - --filter Filter packages in monorepo - -w, --workspace-root Include workspace root - -P, --prod Only production and optional dependencies - -D, --dev Only dev dependencies - --no-optional Exclude optional dependencies - --compatible Only show compatible versions - --sort-by Sort results by field - -g, --global Check globally installed packages - -h, --help Print help + --long Show extended information + --format Output format: table (default), list, or json + -r, --recursive Check recursively across all workspaces + --filter Filter packages in monorepo + -w, --workspace-root Include workspace root + -P, --prod Only production and optional dependencies + -D, --dev Only dev dependencies + --no-optional Exclude optional dependencies + --compatible Only show compatible versions + --sort-by Sort results by field + -g, --global Check globally installed packages + --concurrency Number of global package checks to run in parallel (only with -g) + -h, --help Print help Documentation: https://viteplus.dev/guide/install @@ -171,6 +172,3 @@ testnpm2 ├──────────────────────────────────────────┼─────────┼────────┤ │ testnpm2 │ │ └──────────────────────────────────────────┴─────────┴────────┘ - -> vp outdated testnpm2 -g --format json # should support global output -{} diff --git a/packages/cli/snap-tests-global/command-outdated-pnpm11/steps.json b/packages/cli/snap-tests-global/command-outdated-pnpm11/steps.json index b1e9e87cf3..5614bae240 100644 --- a/packages/cli/snap-tests-global/command-outdated-pnpm11/steps.json +++ b/packages/cli/snap-tests-global/command-outdated-pnpm11/steps.json @@ -16,7 +16,6 @@ "vp outdated --no-optional # should support no-optional output", "vp outdated --compatible # should compatible output nothing", "json-edit package.json '_.optionalDependencies[\"test-vite-plus-other-optional\"] = \"^1.0.0\"' && vp outdated --compatible # should support compatible output with optional dependencies", - "vp outdated --sort-by name # should support sort-by output", - "vp outdated testnpm2 -g --format json # should support global output" + "vp outdated --sort-by name # should support sort-by output" ] } diff --git a/packages/cli/snap-tests/command-outdated-pnpm10/snap.txt b/packages/cli/snap-tests/command-outdated-pnpm10/snap.txt index e3dd24cf81..71163b2b7f 100644 --- a/packages/cli/snap-tests/command-outdated-pnpm10/snap.txt +++ b/packages/cli/snap-tests/command-outdated-pnpm10/snap.txt @@ -8,18 +8,19 @@ Arguments: [PASS_THROUGH_ARGS]... Additional arguments to pass through to the package manager Options: - --long Show extended information - --format Output format: table (default), list, or json - -r, --recursive Check recursively across all workspaces - --filter Filter packages in monorepo - -w, --workspace-root Include workspace root - -P, --prod Only production and optional dependencies - -D, --dev Only dev dependencies - --no-optional Exclude optional dependencies - --compatible Only show compatible versions - --sort-by Sort results by field - -g, --global Check globally installed packages - -h, --help Print help + --long Show extended information + --format Output format: table (default), list, or json + -r, --recursive Check recursively across all workspaces + --filter Filter packages in monorepo + -w, --workspace-root Include workspace root + -P, --prod Only production and optional dependencies + -D, --dev Only dev dependencies + --no-optional Exclude optional dependencies + --compatible Only show compatible versions + --sort-by Sort results by field + -g, --global Check globally installed packages + --concurrency Number of global package checks to run in parallel (only with -g) + -h, --help Print help > vp install # should install packages first Packages: + @@ -168,6 +169,3 @@ testnpm2 ├──────────────────────────────────────────┼─────────┼────────┤ │ testnpm2 │ │ └──────────────────────────────────────────┴─────────┴────────┘ - -> vp outdated testnpm2 -g --format json # should support global output -{} diff --git a/packages/cli/snap-tests/command-outdated-pnpm10/steps.json b/packages/cli/snap-tests/command-outdated-pnpm10/steps.json index b1e9e87cf3..5614bae240 100644 --- a/packages/cli/snap-tests/command-outdated-pnpm10/steps.json +++ b/packages/cli/snap-tests/command-outdated-pnpm10/steps.json @@ -16,7 +16,6 @@ "vp outdated --no-optional # should support no-optional output", "vp outdated --compatible # should compatible output nothing", "json-edit package.json '_.optionalDependencies[\"test-vite-plus-other-optional\"] = \"^1.0.0\"' && vp outdated --compatible # should support compatible output with optional dependencies", - "vp outdated --sort-by name # should support sort-by output", - "vp outdated testnpm2 -g --format json # should support global output" + "vp outdated --sort-by name # should support sort-by output" ] } diff --git a/packages/cli/snap-tests/command-pm-no-package-json/snap.txt b/packages/cli/snap-tests/command-pm-no-package-json/snap.txt index cca707ab25..f30fb0a9c3 100644 --- a/packages/cli/snap-tests/command-pm-no-package-json/snap.txt +++ b/packages/cli/snap-tests/command-pm-no-package-json/snap.txt @@ -10,9 +10,6 @@ error: No package.json found. [1]> vp why lodash # should show friendly error error: No package.json found. -> vp outdated definitely-not-installed-vite-plus-snap-pkg -g --format json # should not require package.json for global outdated -{} - [1]> vp why definitely-not-installed-vite-plus-snap-pkg -g --json # should not require package.json for global why npm error No dependencies found matching definitely-not-installed-vite-plus-snap-pkg { diff --git a/packages/cli/snap-tests/command-pm-no-package-json/steps.json b/packages/cli/snap-tests/command-pm-no-package-json/steps.json index f1c5f05d3c..49dc5cbff7 100644 --- a/packages/cli/snap-tests/command-pm-no-package-json/steps.json +++ b/packages/cli/snap-tests/command-pm-no-package-json/steps.json @@ -5,7 +5,6 @@ "vp pm prune # should show friendly error", "vp outdated # should show friendly error", "vp why lodash # should show friendly error", - "vp outdated definitely-not-installed-vite-plus-snap-pkg -g --format json # should not require package.json for global outdated", "vp why definitely-not-installed-vite-plus-snap-pkg -g --json # should not require package.json for global why" ] }