From bc12a3b7eac56df57c180bafe3f57343b359ddad Mon Sep 17 00:00:00 2001 From: semimikoh Date: Mon, 22 Jun 2026 09:10:05 +0900 Subject: [PATCH 1/3] feat(env): mark installed/current/default versions in `vp env list-remote` Annotate the registry listing with locally-derived markers so it is easy to see which versions are already available: - installed versions are highlighted (green; blue for the current project-resolved version), and prefixed with `*` when colors are disabled so the distinction survives piped/`NO_COLOR` output - the current project version (same resolution logic as `vp env current`) and the global default are annotated with `current` / `default` labels - `--json` output gains `installed`, `current`, and `default` fields All local lookups degrade gracefully, so the registry listing still renders if they fail. --- .../vite_global_cli/src/commands/env/list.rs | 2 +- .../src/commands/env/list_remote.rs | 206 ++++++++++++++++-- .../vite_global_cli/src/commands/env/mod.rs | 2 +- 3 files changed, 195 insertions(+), 15 deletions(-) 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..b2ab736302 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).await; + // Filter versions based on options let mut filtered = filter_versions(&versions, pattern.as_deref(), lts_only, show_all); @@ -54,14 +73,48 @@ 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) -> LocalMarkers { + let installed = installed_versions(); + // Version resolved for the current project/cwd (same logic as `vp env current`). + let current = config::resolve_version(cwd).await.ok().map(|r| r.version); + // Global default version, if configured. + let default = config::load_config().await.ok().and_then(|c| c.default_node_version); + + 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 +173,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 +192,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 +307,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 +388,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 From 721927287a96bdfa4afdd312754f56466ca9fa30 Mon Sep 17 00:00:00 2001 From: semimikoh Date: Mon, 22 Jun 2026 11:04:54 +0900 Subject: [PATCH 2/3] fix(env): resolve default alias before marking remote versions `vp env default lts`/`latest` stores the raw alias in config, so comparing it directly against exact remote versions never matched and the `default` marker (and JSON `default: true`) was never emitted. Resolve the stored default via `resolve_version_alias` to a concrete version first. Also add a global snap test that installs an LTS version, sets it as the default via the `lts` alias, and asserts the installed/current/default flags all resolve in `vp env list-remote --json`. --- .../src/commands/env/list_remote.rs | 16 +++++++++++----- .../command-env-list-remote/snap.txt | 11 +++++++++++ .../command-env-list-remote/steps.json | 9 +++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 packages/cli/snap-tests-global/command-env-list-remote/snap.txt create mode 100644 packages/cli/snap-tests-global/command-env-list-remote/steps.json 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 b2ab736302..a42f160968 100644 --- a/crates/vite_global_cli/src/commands/env/list_remote.rs +++ b/crates/vite_global_cli/src/commands/env/list_remote.rs @@ -61,7 +61,7 @@ pub async fn execute( } // Locally-derived markers (installed / current / default) used to annotate output. - let markers = local_markers(&cwd).await; + 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); @@ -85,12 +85,18 @@ pub async fn execute( /// /// All lookups degrade gracefully: failures yield empty/none so the registry /// listing still renders. -async fn local_markers(cwd: &AbsolutePathBuf) -> LocalMarkers { +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`). + // 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 version, if configured. - let default = config::load_config().await.ok().and_then(|c| c.default_node_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 } } 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..994e05f0f6 --- /dev/null +++ b/packages/cli/snap-tests-global/command-env-list-remote/steps.json @@ -0,0 +1,9 @@ +{ + "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" + ] +} From 0e3621a13bdce1a6e35ad7da05c93ba64d9ca219 Mon Sep 17 00:00:00 2001 From: semimikoh Date: Mon, 22 Jun 2026 23:21:28 +0900 Subject: [PATCH 3/3] test(env): mark list-remote snap test serial It writes the shared VP_HOME config via `vp env default`, matching the existing default-mutating snap tests that run serially. --- .../cli/snap-tests-global/command-env-list-remote/steps.json | 1 + 1 file changed, 1 insertion(+) 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 index 994e05f0f6..e998f380ea 100644 --- a/packages/cli/snap-tests-global/command-env-list-remote/steps.json +++ b/packages/cli/snap-tests-global/command-env-list-remote/steps.json @@ -1,4 +1,5 @@ { + "serial": true, "ignoredPlatforms": ["win32"], "env": {}, "commands": [