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
2 changes: 1 addition & 1 deletion crates/vite_global_cli/src/commands/env/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
pub(super) fn list_installed_versions(node_dir: &std::path::Path) -> Vec<String> {
let entries = match std::fs::read_dir(node_dir) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
Expand Down
212 changes: 199 additions & 13 deletions crates/vite_global_cli/src/commands/env/list_remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,10 +28,24 @@ struct VersionJson {
lts: Option<String>,
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<String>,
/// Version resolved for the current project/cwd (same logic as `vp env current`).
current: Option<String>,
/// Global default version, if configured.
default: Option<String>,
}

/// Execute the list-remote command.
pub async fn execute(
cwd: AbsolutePathBuf,
pattern: Option<String>,
lts_only: bool,
show_all: bool,
Expand All @@ -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);

Expand 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<String> {
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],
Expand Down Expand Up @@ -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<VersionJson> {
// 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<VersionJson> = versions
versions
.iter()
.map(|v| {
let lts = match &v.lts {
Expand All @@ -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}");
}
}
}
Expand All @@ -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![
Expand Down Expand Up @@ -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![
Expand Down
2 changes: 1 addition & 1 deletion crates/vite_global_cli/src/commands/env/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result<ExitStatus,
crate::cli::EnvSubcommands::Unpin { target } => 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
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/snap-tests-global/command-env-list-remote/snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
> vp env install lts # Install an LTS Node.js version locally
Installing Node.js v<semver>...
Installed Node.js v<semver>

> vp env default lts # Set it as the global default (stored as the `lts` alias)
✓ Default Node.js version set to lts (currently <semver>)

> 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

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.

Just need to execute the command vp env list-remote --lts --json, and the subsequent judgment logic code can be removed. We only need to ensure that the snapshot content is consistent.

installed marked: true
current marked: true
default marked: true
10 changes: 10 additions & 0 deletions packages/cli/snap-tests-global/command-env-list-remote/steps.json
Original file line number Diff line number Diff line change
@@ -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)",
Comment thread
fengmk2 marked this conversation as resolved.
"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"
]
}
Loading