Skip to content
Open
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
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ css-module-lexer = "0.0.15"
dashmap = "6.2.1"
derive_more = { version = "2.0.1", features = ["debug"] }
directories = "6.0.0"
dialoguer = "0.12.0"
dunce = "1.0.5"
fast-glob = "1.0.0"
flate2 = { version = "=1.1.9", features = ["zlib-rs"] }
Expand Down
1 change: 1 addition & 0 deletions crates/vite_global_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
clap_complete = { workspace = true, features = ["unstable-dynamic"] }
directories = { workspace = true }
dialoguer = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
175 changes: 157 additions & 18 deletions crates/vite_global_cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,23 @@
//! This module defines the CLI structure using clap and routes commands
//! to their appropriate handlers.

use std::{ffi::OsStr, process::ExitStatus};
use std::{collections::HashSet, ffi::OsStr, io::IsTerminal, process::ExitStatus};

use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
use clap_complete::ArgValueCompleter;
use dialoguer::{Confirm, theme::ColorfulTheme};
use owo_colors::OwoColorize;
use tokio::runtime::Runtime;
use vite_path::AbsolutePathBuf;
use vite_pm_cli::PackageManagerCommand;
use vite_shared::output;

use crate::{
commands::{self, env::package_metadata::PackageMetadata, global},
commands::{
self,
env::{config::resolve_version, package_metadata::PackageMetadata},
global,
},
error::Error,
help,
};
Expand Down Expand Up @@ -575,8 +581,22 @@ async fn run_package_manager_command(
managed_uninstall(packages, dry_run).await
}

PackageManagerCommand::Update { global: true, ref packages, concurrency, .. } => {
managed_update(packages, concurrency).await
PackageManagerCommand::Update {
global: true,
ref packages,
concurrency,
reinstall_node_mismatch,
ignore_node_mismatch,
..
} => {
if reinstall_node_mismatch && ignore_node_mismatch {
output::error(
"--reinstall-node-mismatch and --ignore-node-mismatch cannot be used together",
);
return Ok(exit_status(1));
}
managed_update(packages, concurrency, reinstall_node_mismatch, ignore_node_mismatch)
.await
}

PackageManagerCommand::Outdated {
Expand Down Expand Up @@ -646,23 +666,55 @@ async fn managed_uninstall(packages: &[String], dry_run: bool) -> Result<ExitSta
Ok(ExitStatus::default())
}

fn is_same_node_version(installed_version: &str, current_version: &str) -> bool {
installed_version.trim().trim_start_matches('v')
== current_version.trim().trim_start_matches('v')
}

fn display_node_version(version: &str) -> String {
let version = version.trim();
if version.starts_with('v') { version.to_string() } else { format!("v{version}") }
}

struct NodeMismatchPackage {
name: String,
spec: String,
installed_node: String,
}

async fn managed_update(
packages: &[String],
concurrency: Option<usize>,
reinstall_node_mismatch: bool,
ignore_node_mismatch: bool,
) -> Result<ExitStatus, Error> {
let concurrency = concurrency.unwrap_or(DEFAULT_GLOBAL_INSTALL_CONCURRENCY);
let mut to_update: Vec<String> = Vec::new();
let mut node_mismatches: Vec<NodeMismatchPackage> = Vec::new();
let current_node_version;

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());
}
current_node_version = get_current_node_version().await?;

for metadata in &all {
if !is_same_node_version(&metadata.platform.node, &current_node_version) {
node_mismatches.push(NodeMismatchPackage {
name: metadata.name.clone(),
spec: metadata.name.clone(),
installed_node: metadata.platform.node.clone(),
});
}
}

None
} else {
let mut managed_specs = Vec::new();
current_node_version = get_current_node_version().await?;

for package in packages {
// Always update local packages
Expand All @@ -672,7 +724,14 @@ async fn managed_update(
}

let (package_name, _) = global::parse_package_spec(package);
if PackageMetadata::load(&package_name).await?.is_some() {
if let Some(metadata) = PackageMetadata::load(&package_name).await? {
if !is_same_node_version(&metadata.platform.node, &current_node_version) {
node_mismatches.push(NodeMismatchPackage {
name: package_name,
spec: package.clone(),
installed_node: metadata.platform.node,
});
}
managed_specs.push(package.clone());
} else {
to_update.push(package.clone());
Expand All @@ -681,16 +740,26 @@ async fn managed_update(

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)),
);

let outdated = global::outdated::get_outdated_packages(
&packages.unwrap_or_default(),
concurrency * 3,
true,
)
.await?;
to_update.extend(outdated.into_iter().map(|package| package.spec.unwrap_or(package.name)));

let to_update_set = to_update.iter().map(String::as_str).collect::<HashSet<_>>();
node_mismatches.retain(|package| !to_update_set.contains(package.spec.as_str()));

if should_reinstall_node_mismatches(
&node_mismatches,
&current_node_version,
reinstall_node_mismatch,
ignore_node_mismatch,
) {
to_update.extend(node_mismatches.into_iter().map(|package| package.spec));
}

if to_update.is_empty() {
vite_shared::output::raw("All global packages are up to date.");
Expand All @@ -699,7 +768,8 @@ async fn managed_update(

// Call reinstall logic
if let Err((package_name, error)) =
global::install::install(&to_update, None, false, concurrency, true).await
global::install::install(&to_update, Some(&current_node_version), false, concurrency, true)
.await
{
output::error(&format!(
"Failed to update {}: {error}",
Expand All @@ -710,6 +780,63 @@ async fn managed_update(
Ok(ExitStatus::default())
}

async fn get_current_node_version() -> Result<String, Error> {
let cwd = vite_path::current_dir().map_err(|error| {
Error::ConfigError(format!("Cannot get current directory: {error}").into())
})?;
Ok(resolve_version(&cwd).await?.version)
}

fn should_reinstall_node_mismatches(
packages: &[NodeMismatchPackage],
current_node_version: &str,
reinstall_node_mismatch: bool,
ignore_node_mismatch: bool,
) -> bool {
if packages.is_empty() || ignore_node_mismatch {
return false;
}

if reinstall_node_mismatch {
return true;
}

if !std::io::stdin().is_terminal() || std::env::var_os("CI").is_some() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic code for determining non-interactive mode has been repeatedly written in many places with the same logic. Can this logic code be extracted and placed in crates/vite_shared to be reused?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, let me refactor it soon.

Copy link
Copy Markdown
Contributor Author

@liangmiQwQ liangmiQwQ May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've done it locally, but it seems a quite huge change, includes some in different crates, I suggest adding it in a separate PR.

By the way, the non-interactive mode checks in different places have some differences. So we couldn't use a single function all the time or very often, we may end up still creating multiple helper functions to satisfy different requirements here.

# Some examples of summaries generated with Codex

vp something < input.txt # stdin is non-interactive, but stdout and stderr are terminal.
vp something > output.txt # stdout is non-interactive, but stdin and stderr are terminal.
vp something 2> error.log # stderr is non-interactive, but stdin and stdout are terminal.
vp something | cat # stdout is non-interactive, but stdin and stderr are terminal
echo yes | vp something # stdin is non-interactive, but stdout and stderr are terminal.
image

let package_names =
packages.iter().map(|package| package.name.as_str()).collect::<Vec<_>>().join(", ");
output::warn(&format!(
"Skipping reinstall for global packages installed with a different Node.js version: {package_names}. Use --reinstall-node-mismatch to reinstall them."
));
return false;
}

prompt_reinstall_node_mismatches(packages, current_node_version)
}

fn prompt_reinstall_node_mismatches(
packages: &[NodeMismatchPackage],
current_node_version: &str,
) -> bool {
output::info("Some global packages were installed with a different Node.js version.");
output::raw("");
output::raw(&format!("Current Node.js: {}", display_node_version(current_node_version).bold()));
output::raw("");
output::raw("Affected packages:");
for package in packages {
output::raw(&format!(
"- {} (installed with {})",
package.name.bold(),
display_node_version(&package.installed_node).bold()
));
}
output::raw("");
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Reinstall them with the current Node.js version?")
.default(false)
.interact()
.unwrap_or(false)
}

/// Run the CLI command.
pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result<ExitStatus, Error> {
run_command_with_options(cwd, args, RenderOptions::default()).await
Expand Down Expand Up @@ -952,10 +1079,22 @@ pub fn try_parse_args_from_with_options(
#[cfg(test)]
mod tests {
use super::{
has_flag_before_terminator, should_force_global_delegate,
should_suppress_header_for_subcommand,
display_node_version, has_flag_before_terminator, is_same_node_version,
should_force_global_delegate, should_suppress_header_for_subcommand,
};

#[test]
fn detects_global_update_node_version_mismatch() {
assert!(is_same_node_version("21.0.0", "v21.0.0"));
assert!(!is_same_node_version("21.0.0", "25.0.0"));
}

#[test]
fn displays_node_versions_with_v_prefix() {
assert_eq!(display_node_version("25.0.0"), "v25.0.0");
assert_eq!(display_node_version("v25.0.0"), "v25.0.0");
}

#[test]
fn detects_flag_before_option_terminator() {
assert!(has_flag_before_terminator(
Expand Down
8 changes: 8 additions & 0 deletions crates/vite_pm_cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,14 @@ pub enum PackageManagerCommand {
#[arg(long, requires = "global", value_parser = parse_positive_usize)]
concurrency: Option<usize>,

/// Reinstall up-to-date global packages installed with a different Node.js version
#[arg(long, requires = "global")]
reinstall_node_mismatch: bool,

/// Skip up-to-date global packages installed with a different Node.js version
#[arg(long, requires = "global")]
ignore_node_mismatch: bool,

/// Update recursively in all workspace packages
#[arg(short = 'r', long)]
recursive: bool,
Expand Down
2 changes: 2 additions & 0 deletions crates/vite_pm_cli/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ pub async fn dispatch(
latest,
global: _,
concurrency: _,
reinstall_node_mismatch: _,
ignore_node_mismatch: _,
recursive,
filter,
workspace_root,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/snap-tests-global/cli-helper-message/snap.txt
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ Options:
-L, --latest Update to latest version (ignore semver range)
-g, --global Update global packages
--concurrency <CONCURRENCY> Number of global package updates to run in parallel (only with -g)
--reinstall-node-mismatch Reinstall up-to-date global packages installed with a different Node.js version
--ignore-node-mismatch Skip up-to-date global packages installed with a different Node.js version
-r, --recursive Update recursively in all workspace packages
--filter <PATTERN> Filter packages in monorepo (can be used multiple times)
-w, --workspace-root Include workspace root
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/snap-tests-global/command-update-bun/snap.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Options:
-L, --latest Update to latest version (ignore semver range)
-g, --global Update global packages
--concurrency <CONCURRENCY> Number of global package updates to run in parallel (only with -g)
--reinstall-node-mismatch Reinstall up-to-date global packages installed with a different Node.js version
--ignore-node-mismatch Skip up-to-date global packages installed with a different Node.js version
-r, --recursive Update recursively in all workspace packages
--filter <PATTERN> Filter packages in monorepo (can be used multiple times)
-w, --workspace-root Include workspace root
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
> vp install -g --node 20 testnpm2
info: Installing 1 global package with Node.js <semver>
✓ Installed testnpm2 <semver>

> vp update -g testnpm2 # should warn and skip node mismatch reinstall in CI
warn: Skipping reinstall for global packages installed with a different Node.js version: testnpm2. Use --reinstall-node-mismatch to reinstall them.
All global packages are up to date.

> vp update -g testnpm2 --ignore-node-mismatch # should explicitly skip node mismatch reinstall
All global packages are up to date.

> vp update -g testnpm2 --reinstall-node-mismatch
info: Updating 1 global package with Node.js <semver>
✓ Updated testnpm2 to <semver>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"serial": true,
"commands": [
"vp install -g --node 20 testnpm2",
"vp update -g testnpm2 # should warn and skip node mismatch reinstall in CI",
"vp update -g testnpm2 --ignore-node-mismatch # should explicitly skip node mismatch reinstall",
"vp update -g testnpm2 --reinstall-node-mismatch"
],
"after": ["vp remove -g testnpm2"]
}
2 changes: 2 additions & 0 deletions packages/cli/snap-tests-global/command-update-pnpm10/snap.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Options:
-L, --latest Update to latest version (ignore semver range)
-g, --global Update global packages
--concurrency <CONCURRENCY> Number of global package updates to run in parallel (only with -g)
--reinstall-node-mismatch Reinstall up-to-date global packages installed with a different Node.js version
--ignore-node-mismatch Skip up-to-date global packages installed with a different Node.js version
-r, --recursive Update recursively in all workspace packages
--filter <PATTERN> Filter packages in monorepo (can be used multiple times)
-w, --workspace-root Include workspace root
Expand Down
Loading
Loading