Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 128 additions & 9 deletions crates/vite_global_cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::{ffi::OsStr, process::ExitStatus};
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use clap_complete::ArgValueCompleter;
use tokio::runtime::Runtime;
use vite_path::AbsolutePathBuf;
use vite_path::{AbsolutePath, AbsolutePathBuf};
use vite_pm_cli::PackageManagerCommand;

use crate::{commands, error::Error, help};
Expand Down Expand Up @@ -528,7 +528,7 @@ async fn run_package_manager_command(
}

PackageManagerCommand::Update { global: true, ref packages, .. } => {
managed_update(packages).await
managed_update(&cwd, packages).await
}

// `pm list -g` lists vite-plus-managed globals, not the underlying PM's.
Expand Down Expand Up @@ -573,21 +573,123 @@ async fn managed_uninstall(packages: &[String], dry_run: bool) -> Result<ExitSta
Ok(ExitStatus::default())
}

async fn managed_update(packages: &[String]) -> Result<ExitStatus, Error> {
use crate::commands::env::package_metadata::PackageMetadata;
fn is_global_package_up_to_date(
installed_version: &str,
registry_version: &str,
installed_node_version: &str,
target_node_version: &str,
) -> bool {
installed_version.trim() == registry_version.trim()
&& installed_node_version.trim() == target_node_version.trim()
}

async fn managed_update(cwd: &AbsolutePath, packages: &[String]) -> Result<ExitStatus, Error> {
use crate::commands::env::{
config::resolve_version, global_install, package_metadata::PackageMetadata,
};

let to_update: Vec<String> = if packages.is_empty() {
let all_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());
}
all.iter().map(|p| p.name.clone()).collect()
Some(all)
} else {
packages.to_vec()
None
};

let target_node_version = resolve_version(cwd).await?.version;
let mut to_update: Vec<String> = Vec::new();
let mut skipped = 0usize;

if let Some(all) = all_packages {
for metadata in all {
if metadata.platform.node.trim() != target_node_version.trim() {
to_update.push(metadata.name.clone());
continue;
}

match global_install::latest_package_version(&metadata.name, Some(&target_node_version))
.await
{
Ok(latest_version)
if is_global_package_up_to_date(
&metadata.version,
&latest_version,
&metadata.platform.node,
&target_node_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());
}
}
}
} else {
for package in packages {
if global_install::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? {
if metadata.platform.node.trim() != target_node_version.trim() {
to_update.push(package.clone());
continue;
}

match global_install::latest_package_version(package, Some(&target_node_version))
.await
{
Ok(latest_version)
if is_global_package_up_to_date(
&metadata.version,
&latest_version,
&metadata.platform.node,
&target_node_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."
));
}
}
}
to_update.push(package.clone());
}
}

if to_update.is_empty() {
if skipped > 0 {
vite_shared::output::raw("All global packages are up to date.");
}
return Ok(ExitStatus::default());
}

for package in &to_update {
if let Err(e) = crate::commands::env::global_install::install(package, None, false).await {
if let Err(e) = global_install::install(package, Some(&target_node_version), false).await {
vite_shared::output::raw_stderr(&format!("Failed to update {package}: {e}"));
return Ok(exit_status(1));
}
Expand Down Expand Up @@ -829,7 +931,24 @@ pub fn try_parse_args_from_with_options(

#[cfg(test)]
mod tests {
use super::{has_flag_before_terminator, should_force_global_delegate};
use super::{
has_flag_before_terminator, is_global_package_up_to_date, should_force_global_delegate,
};

#[test]
fn skips_global_update_when_registry_and_node_versions_match() {
assert!(is_global_package_up_to_date("5.9.3", "5.9.3", "20.18.0", "20.18.0"));
}

#[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", "20.18.0", "20.18.0"));
}

#[test]
fn updates_global_package_when_node_version_differs_from_target_version() {
assert!(!is_global_package_up_to_date("5.9.3", "5.9.3", "20.18.0", "24.15.0"));
}

#[test]
fn detects_flag_before_option_terminator() {
Expand Down
120 changes: 119 additions & 1 deletion crates/vite_global_cli/src/commands/env/global_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,85 @@ 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
Comment thread
leno23 marked this conversation as resolved.
/// version that npm resolves for that spec.
pub(crate) async fn latest_package_version(
package_spec: &str,
node_version: Option<&str>,
Comment thread
leno23 marked this conversation as resolved.
) -> Result<String, Error> {
let version = if let Some(v) = node_version {
let provider = NodeProvider::new();
resolve_version_alias(v, &provider).await?
} else {
let cwd = current_dir().map_err(|e| {
Error::ConfigError(format!("Cannot get current directory: {}", e).into())
})?;
let resolution = resolve_version(&cwd).await?;
resolution.version
};

let runtime =
vite_js_runtime::download_runtime(vite_js_runtime::JsRuntimeType::Node, &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") };

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<String, Error> {
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::<serde_json::Value>(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.
fn parse_package_spec(spec: &str) -> (String, Option<String>) {
pub(crate) fn parse_package_spec(spec: &str) -> (String, Option<String>) {
// Handle scoped packages: @scope/name@version
if spec.starts_with('@') {
// Find the second @ for version
Expand Down Expand Up @@ -699,6 +776,47 @@ 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("."));
assert!(is_local_package_spec(".."));
assert!(is_local_package_spec("./pkg"));
assert!(is_local_package_spec("../pkg"));
assert!(is_local_package_spec("file:../pkg"));
}

#[test]
fn test_is_local_package_spec_registry_packages() {
assert!(!is_local_package_spec("typescript"));
assert!(!is_local_package_spec("typescript@5.9.3"));
assert!(!is_local_package_spec("@scope/pkg"));
assert!(!is_local_package_spec("@scope/pkg@1.0.0"));
}

#[test]
fn test_parse_package_spec_simple() {
let (name, version) = parse_package_spec("typescript");
Expand Down
Loading