diff --git a/crates/vite_global_cli/src/commands/env/list.rs b/crates/vite_global_cli/src/commands/env/list.rs index 2f086a30a8..ad3a3896f1 100644 --- a/crates/vite_global_cli/src/commands/env/list.rs +++ b/crates/vite_global_cli/src/commands/env/list.rs @@ -20,7 +20,7 @@ struct InstalledVersionJson { } /// Scan the node versions directory and return sorted version strings. -fn list_installed_versions(node_dir: &std::path::Path) -> Vec { +pub(super) fn list_installed_versions(node_dir: &std::path::Path) -> Vec { let entries = match std::fs::read_dir(node_dir) { Ok(entries) => entries, Err(_) => return Vec::new(), diff --git a/crates/vite_global_cli/src/commands/env/list_remote.rs b/crates/vite_global_cli/src/commands/env/list_remote.rs index 11ad831e56..a42f160968 100644 --- a/crates/vite_global_cli/src/commands/env/list_remote.rs +++ b/crates/vite_global_cli/src/commands/env/list_remote.rs @@ -7,7 +7,9 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; use serde::Serialize; use vite_js_runtime::{LtsInfo, NodeProvider, NodeVersionEntry}; +use vite_path::AbsolutePathBuf; +use super::config; use crate::{cli::SortingMethod, error::Error}; /// Default number of major versions to show @@ -26,10 +28,24 @@ struct VersionJson { lts: Option, latest: bool, latest_lts: bool, + installed: bool, + current: bool, + default: bool, +} + +/// Locally-derived markers used to annotate remote versions. +struct LocalMarkers { + /// Versions installed under `VP_HOME/js_runtime/node/` (without `v` prefix). + installed: std::collections::HashSet, + /// Version resolved for the current project/cwd (same logic as `vp env current`). + current: Option, + /// Global default version, if configured. + default: Option, } /// Execute the list-remote command. pub async fn execute( + cwd: AbsolutePathBuf, pattern: Option, lts_only: bool, show_all: bool, @@ -44,6 +60,9 @@ pub async fn execute( return Ok(ExitStatus::default()); } + // Locally-derived markers (installed / current / default) used to annotate output. + let markers = local_markers(&cwd, &provider).await; + // Filter versions based on options let mut filtered = filter_versions(&versions, pattern.as_deref(), lts_only, show_all); @@ -54,14 +73,54 @@ pub async fn execute( } if json_output { - print_json(&filtered, &versions)?; + print_json(&filtered, &versions, &markers)?; } else { - print_human(&filtered); + print_human(&filtered, &markers); } Ok(ExitStatus::default()) } +/// Collect the locally-derived markers (installed / current / default). +/// +/// All lookups degrade gracefully: failures yield empty/none so the registry +/// listing still renders. +async fn local_markers(cwd: &AbsolutePathBuf, provider: &NodeProvider) -> LocalMarkers { + let installed = installed_versions(); + // Version resolved for the current project/cwd (same logic as `vp env current`); + // this is already a concrete version, never an alias. + let current = config::resolve_version(cwd).await.ok().map(|r| r.version); + // Global default may be stored as an alias (e.g. `lts`/`latest`) by + // `vp env default`, so resolve it to a concrete version before comparing + // against exact remote versions. + let default = match config::load_config().await.ok().and_then(|c| c.default_node_version) { + Some(alias) => config::resolve_version_alias(&alias, provider).await.ok(), + None => None, + }; + + LocalMarkers { installed, current, default } +} + +/// Collect the set of locally installed Node.js versions (without `v` prefix). +fn installed_versions() -> std::collections::HashSet { + let Ok(home_dir) = vite_shared::get_vp_home() else { + return std::collections::HashSet::new(); + }; + let node_dir = home_dir.join("js_runtime").join("node"); + super::list::list_installed_versions(node_dir.as_path()).into_iter().collect() +} + +/// Strip a leading `v` from a version string, if present. +fn strip_v(version: &str) -> &str { + version.strip_prefix('v').unwrap_or(version) +} + +/// Whether colored output should be emitted on stdout. +fn use_color() -> bool { + use std::io::IsTerminal; + std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + /// Filter versions based on criteria. fn filter_versions<'a>( versions: &'a [NodeVersionEntry], @@ -120,16 +179,17 @@ fn limit_to_recent_majors( .collect() } -/// Print versions as JSON. -fn print_json( +/// Build the JSON entries for the given versions. +fn build_json( versions: &[&NodeVersionEntry], all_versions: &[NodeVersionEntry], -) -> Result<(), Error> { + markers: &LocalMarkers, +) -> Vec { // Find the latest version and latest LTS let latest_version = all_versions.first().map(|v| &v.version); let latest_lts_version = all_versions.iter().find(|v| v.is_lts()).map(|v| &v.version); - let version_list: Vec = versions + versions .iter() .map(|v| { let lts = match &v.lts { @@ -138,42 +198,103 @@ fn print_json( }; let is_latest = latest_version.is_some_and(|lv| lv == &v.version); let is_latest_lts = latest_lts_version.is_some_and(|llv| llv == &v.version); + let version = strip_v(&v.version).to_string(); + let is_installed = markers.installed.contains(&version); + let is_current = markers.current.as_deref() == Some(version.as_str()); + let is_default = markers.default.as_deref() == Some(version.as_str()); VersionJson { - version: v.version.strip_prefix('v').unwrap_or(&v.version).to_string(), + version, lts, latest: is_latest, latest_lts: is_latest_lts, + installed: is_installed, + current: is_current, + default: is_default, } }) - .collect(); + .collect() +} - let output = VersionListJson { versions: version_list }; +/// Print versions as JSON. +fn print_json( + versions: &[&NodeVersionEntry], + all_versions: &[NodeVersionEntry], + markers: &LocalMarkers, +) -> Result<(), Error> { + let output = VersionListJson { versions: build_json(versions, all_versions, markers) }; println!("{}", serde_json::to_string_pretty(&output)?); Ok(()) } /// Print versions in human-readable format (fnm-style). -fn print_human(versions: &[&NodeVersionEntry]) { +/// +/// Installed versions are highlighted (green, blue for the current project version) +/// when stdout supports color, and marked with a leading `*` otherwise so the +/// distinction survives piped output. The current/default versions are annotated +/// with trailing `current`/`default` labels. +fn print_human(versions: &[&NodeVersionEntry], markers: &LocalMarkers) { if versions.is_empty() { eprintln!("{}", "No versions were found!".red()); return; } + let colorize = use_color(); + for version in versions { let version_str = &version.version; + let stripped = strip_v(version_str); // Ensure v prefix let display = if version_str.starts_with('v') { version_str.to_string() } else { format!("v{version_str}") }; + let is_installed = markers.installed.contains(stripped); + let is_current = markers.current.as_deref() == Some(stripped); + let is_default = markers.default.as_deref() == Some(stripped); + + let lts_suffix = match &version.lts { + LtsInfo::Codename(name) => format!(" ({name})"), + _ => String::new(), + }; - if let LtsInfo::Codename(name) = &version.lts { - println!("{}{}", display, format!(" ({name})").bright_blue()); + let mut labels = Vec::new(); + if is_current { + labels.push("current"); + } + if is_default { + labels.push("default"); + } + let label_suffix = + if labels.is_empty() { String::new() } else { format!(" {}", labels.join(" ")) }; + + if colorize { + // Color each segment independently to avoid nested ANSI resets. + // Current project version takes precedence (blue), else installed (green). + let version_part = if is_current { + display.bright_blue().to_string() + } else if is_installed { + display.green().to_string() + } else { + display + }; + let lts_part = if lts_suffix.is_empty() { + String::new() + } else { + lts_suffix.bright_blue().to_string() + }; + let label_part = if label_suffix.is_empty() { + String::new() + } else { + label_suffix.dimmed().to_string() + }; + println!("{version_part}{lts_part}{label_part}"); } else { - println!("{display}"); + // No color: use a `*` marker with an aligned gutter for plain rows. + let marker = if is_installed { "* " } else { " " }; + println!("{marker}{display}{lts_suffix}{label_suffix}"); } } } @@ -192,6 +313,14 @@ mod tests { } } + fn markers(installed: &[&str], current: Option<&str>, default: Option<&str>) -> LocalMarkers { + LocalMarkers { + installed: installed.iter().map(|s| (*s).to_string()).collect(), + current: current.map(str::to_string), + default: default.map(str::to_string), + } + } + #[test] fn test_filter_versions_lts_only() { let versions = vec![ @@ -265,6 +394,63 @@ mod tests { assert_eq!(filtered_all.len(), 12); } + #[test] + fn test_build_json_marks_installed_versions() { + let versions = vec![ + make_version("v24.0.0", None), + make_version("v22.13.0", Some("Jod")), + make_version("v20.18.0", Some("Iron")), + ]; + let all_versions = versions.clone(); + let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); + + // Installed dirs are stored without the leading `v`. + let json = build_json(&refs, &all_versions, &markers(&["22.13.0"], None, None)); + + let installed_entry = json.iter().find(|v| v.version == "22.13.0").unwrap(); + assert!(installed_entry.installed); + + let not_installed = json.iter().find(|v| v.version == "24.0.0").unwrap(); + assert!(!not_installed.installed); + } + + #[test] + fn test_build_json_empty_installed_set() { + let versions = vec![make_version("v24.0.0", None)]; + let all_versions = versions.clone(); + let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); + + let json = build_json(&refs, &all_versions, &markers(&[], None, None)); + assert!(json.iter().all(|v| !v.installed && !v.current && !v.default)); + } + + #[test] + fn test_build_json_marks_current_and_default() { + let versions = vec![ + make_version("v24.0.0", None), + make_version("v22.13.0", Some("Jod")), + make_version("v20.18.0", Some("Iron")), + ]; + let all_versions = versions.clone(); + let refs: Vec<&NodeVersionEntry> = versions.iter().collect(); + + // Current project resolves to 22.13.0; global default is 20.18.0. + let json = build_json( + &refs, + &all_versions, + &markers(&["22.13.0", "20.18.0"], Some("22.13.0"), Some("20.18.0")), + ); + + let current = json.iter().find(|v| v.version == "22.13.0").unwrap(); + assert!(current.current && current.installed && !current.default); + + let default = json.iter().find(|v| v.version == "20.18.0").unwrap(); + assert!(default.default && default.installed && !default.current); + + let plain = json.iter().find(|v| v.version == "24.0.0").unwrap(); + assert!(!plain.current && !plain.default && !plain.installed); + } + #[test] fn test_filter_versions_show_all_with_lts_filter() { let versions = vec![ diff --git a/crates/vite_global_cli/src/commands/env/mod.rs b/crates/vite_global_cli/src/commands/env/mod.rs index 063fa31f9b..65db67ec82 100644 --- a/crates/vite_global_cli/src/commands/env/mod.rs +++ b/crates/vite_global_cli/src/commands/env/mod.rs @@ -72,7 +72,7 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result unpin::execute(cwd, target).await, crate::cli::EnvSubcommands::List { json } => list::execute(cwd, json).await, crate::cli::EnvSubcommands::ListRemote { pattern, lts, all, json, sort } => { - list_remote::execute(pattern, lts, all, json, sort).await + list_remote::execute(cwd, pattern, lts, all, json, sort).await } crate::cli::EnvSubcommands::Exec { node, npm, command } => { exec::execute(node.as_deref(), npm.as_deref(), &command).await diff --git a/packages/cli/snap-tests-global/command-env-list-remote/snap.txt b/packages/cli/snap-tests-global/command-env-list-remote/snap.txt new file mode 100644 index 0000000000..8290f0fdd2 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-list-remote/snap.txt @@ -0,0 +1,11 @@ +> vp env install lts # Install an LTS Node.js version locally +Installing Node.js v... +Installed Node.js v + +> vp env default lts # Set it as the global default (stored as the `lts` alias) +✓ Default Node.js version set to lts (currently ) + +> vp env list-remote --lts --json | node -e "const {versions}=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('installed marked:', versions.some(v=>v.installed)); console.log('current marked:', versions.some(v=>v.current)); console.log('default marked:', versions.some(v=>v.default));" # installed/current/default flags should all resolve, including the `lts` default alias +installed marked: true +current marked: true +default marked: true diff --git a/packages/cli/snap-tests-global/command-env-list-remote/steps.json b/packages/cli/snap-tests-global/command-env-list-remote/steps.json new file mode 100644 index 0000000000..e998f380ea --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-list-remote/steps.json @@ -0,0 +1,10 @@ +{ + "serial": true, + "ignoredPlatforms": ["win32"], + "env": {}, + "commands": [ + "vp env install lts # Install an LTS Node.js version locally", + "vp env default lts # Set it as the global default (stored as the `lts` alias)", + "vp env list-remote --lts --json | node -e \"const {versions}=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('installed marked:', versions.some(v=>v.installed)); console.log('current marked:', versions.some(v=>v.current)); console.log('default marked:', versions.some(v=>v.default));\" # installed/current/default flags should all resolve, including the `lts` default alias" + ] +}