feat: better node version management and resolution

This commit is contained in:
zhom
2025-07-26 18:52:41 +04:00
parent e23c7ae263
commit 6cdfa5dadf
5 changed files with 402 additions and 4 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ edition = "2021"
[dependencies]
clap = { version = "4.4", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.12", features = ["stream"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
lazy_static = "1.4"
+1 -1
View File
@@ -67,7 +67,7 @@ pub async fn bundle_project(
let output_path = resolve_output_path(output_path, &app_name, custom_name.as_deref())?;
// 6. Ensure portable Node binary is available.
let node_downloader = NodeDownloader::new_with_persistent_cache(node_version.clone())?;
let node_downloader = NodeDownloader::new_with_persistent_cache(&node_version).await?;
let node_executable = node_downloader.ensure_node_binary().await?;
let node_root = node_executable
.parent()
+1
View File
@@ -1,5 +1,6 @@
mod bundler;
mod node_downloader;
mod node_version_manager;
mod platform;
use clap::{Parser, Subcommand};
+26 -2
View File
@@ -1,3 +1,4 @@
use crate::node_version_manager::NodeVersionManager;
use crate::platform::Platform;
use anyhow::{Context, Result};
use futures_util::StreamExt;
@@ -16,18 +17,41 @@ pub struct NodeDownloader {
platform: Platform,
cache_dir: PathBuf,
node_version: String,
version_resolver: NodeVersionManager,
}
impl NodeDownloader {
pub fn new_with_persistent_cache(node_version: String) -> Result<Self> {
pub async fn new_with_persistent_cache(version_spec: &str) -> Result<Self> {
let cache_dir = Self::get_persistent_cache_dir()?;
let version_resolver = NodeVersionManager::new();
// Resolve the version specification to a concrete version
let resolved_version = version_resolver
.resolve_version(version_spec)
.await
.context(format!(
"Failed to resolve Node.js version '{}'",
version_spec
))?;
println!(
"Resolved '{}' to Node.js version {}",
version_spec, resolved_version
);
Ok(Self {
platform: Platform::current(),
cache_dir,
node_version,
node_version: resolved_version,
version_resolver,
})
}
/// Get the resolved Node.js version
pub fn get_version(&self) -> &str {
&self.node_version
}
fn get_persistent_cache_dir() -> Result<PathBuf> {
let cache_dir = if let Some(cache_home) = std::env::var_os("XDG_CACHE_HOME") {
PathBuf::from(cache_home).join("banderole")
+373
View File
@@ -0,0 +1,373 @@
use anyhow::{Context, Result};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::sync::Mutex;
use tokio::time::{Duration, Instant};
lazy_static! {
static ref VERSION_CACHE: Mutex<VersionCache> = Mutex::new(VersionCache::new());
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeVersion {
pub version: String,
pub date: String,
}
#[derive(Debug, Clone)]
struct VersionCache {
versions: Vec<NodeVersion>,
last_updated: Option<Instant>,
cache_duration: Duration,
}
impl VersionCache {
fn new() -> Self {
Self {
versions: Vec::new(),
last_updated: None,
cache_duration: Duration::from_secs(3600), // 1 hour
}
}
fn is_expired(&self) -> bool {
match self.last_updated {
Some(last_updated) => last_updated.elapsed() > self.cache_duration,
None => true,
}
}
fn update(&mut self, versions: Vec<NodeVersion>) {
self.versions = versions;
self.last_updated = Some(Instant::now());
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedVersion {
pub major: u32,
pub minor: Option<u32>,
pub patch: Option<u32>,
}
impl ParsedVersion {
pub fn new(major: u32, minor: Option<u32>, patch: Option<u32>) -> Self {
Self {
major,
minor,
patch,
}
}
pub fn matches(&self, other: &ParsedVersion) -> bool {
if self.major != other.major {
return false;
}
if let Some(self_minor) = self.minor {
if let Some(other_minor) = other.minor {
if self_minor != other_minor {
return false;
}
} else {
return false;
}
}
if let Some(self_patch) = self.patch {
if let Some(other_patch) = other.patch {
if self_patch != other_patch {
return false;
}
} else {
return false;
}
}
true
}
pub fn to_string(&self) -> String {
match (self.minor, self.patch) {
(Some(minor), Some(patch)) => format!("{}.{}.{}", self.major, minor, patch),
(Some(minor), None) => format!("{}.{}", self.major, minor),
(None, None) => format!("{}", self.major),
(None, Some(_)) => unreachable!("Cannot have patch without minor"),
}
}
}
impl PartialOrd for ParsedVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ParsedVersion {
fn cmp(&self, other: &Self) -> Ordering {
match self.major.cmp(&other.major) {
Ordering::Equal => {}
other => return other,
}
match (self.minor, other.minor) {
(Some(a), Some(b)) => match a.cmp(&b) {
Ordering::Equal => {}
other => return other,
},
(Some(_), None) => return Ordering::Greater,
(None, Some(_)) => return Ordering::Less,
(None, None) => {}
}
match (self.patch, other.patch) {
(Some(a), Some(b)) => a.cmp(&b),
(Some(_), None) => Ordering::Greater,
(None, Some(_)) => Ordering::Less,
(None, None) => Ordering::Equal,
}
}
}
impl Eq for ParsedVersion {}
pub struct NodeVersionManager {
client: reqwest::Client,
}
impl NodeVersionManager {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
/// Resolve a version specification like "23", "23.5", "v22.1.0" to a complete version
pub async fn resolve_version(&self, version_spec: &str) -> Result<String> {
let versions = self.fetch_versions().await?;
let parsed_spec = self.parse_version_spec(version_spec)?;
let matching_versions = self.find_matching_versions(&versions, &parsed_spec);
if matching_versions.is_empty() {
anyhow::bail!("No Node.js version found matching '{}'", version_spec);
}
// Return the latest matching version
let latest = matching_versions.last().unwrap();
Ok(latest.version.trim_start_matches('v').to_string())
}
/// Get the latest version for a major version (e.g., latest v23.x.x)
pub async fn get_latest_for_major(&self, major: u32) -> Result<String> {
self.resolve_version(&major.to_string()).await
}
/// List all available versions matching a pattern
pub async fn list_versions(&self, pattern: Option<&str>) -> Result<Vec<String>> {
let versions = self.fetch_versions().await?;
let filtered_versions = if let Some(pattern) = pattern {
let parsed_spec = self.parse_version_spec(pattern)?;
self.find_matching_versions(&versions, &parsed_spec)
} else {
versions.iter().collect()
};
Ok(filtered_versions
.iter()
.map(|v| v.version.trim_start_matches('v').to_string())
.collect())
}
async fn fetch_versions(&self) -> Result<Vec<NodeVersion>> {
// Check cache first
{
let cache = VERSION_CACHE
.lock()
.map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?;
if !cache.is_expired() && !cache.versions.is_empty() {
return Ok(cache.versions.clone());
}
}
// Fetch from Node.js API
let url = "https://nodejs.org/dist/index.json";
let response = self
.client
.get(url)
.timeout(Duration::from_secs(30))
.send()
.await
.context("Failed to fetch Node.js versions")?;
if !response.status().is_success() {
anyhow::bail!("Failed to fetch versions: HTTP {}", response.status());
}
let mut versions: Vec<NodeVersion> = response
.json()
.await
.context("Failed to parse Node.js versions JSON")?;
// Sort versions (Node.js API returns them in reverse chronological order)
versions.sort_by(|a, b| {
let version_a = self.parse_node_version(&a.version).unwrap_or_default();
let version_b = self.parse_node_version(&b.version).unwrap_or_default();
version_a.cmp(&version_b)
});
// Update cache
{
let mut cache = VERSION_CACHE
.lock()
.map_err(|e| anyhow::anyhow!("Failed to acquire cache lock: {}", e))?;
cache.update(versions.clone());
}
Ok(versions)
}
fn parse_version_spec(&self, spec: &str) -> Result<ParsedVersion> {
let cleaned = spec.trim().trim_start_matches('v');
let parts: Vec<&str> = cleaned.split('.').collect();
match parts.len() {
1 => {
let major = parts[0]
.parse::<u32>()
.context("Invalid major version number")?;
Ok(ParsedVersion::new(major, None, None))
}
2 => {
let major = parts[0]
.parse::<u32>()
.context("Invalid major version number")?;
let minor = parts[1]
.parse::<u32>()
.context("Invalid minor version number")?;
Ok(ParsedVersion::new(major, Some(minor), None))
}
3 => {
let major = parts[0]
.parse::<u32>()
.context("Invalid major version number")?;
let minor = parts[1]
.parse::<u32>()
.context("Invalid minor version number")?;
let patch = parts[2]
.parse::<u32>()
.context("Invalid patch version number")?;
Ok(ParsedVersion::new(major, Some(minor), Some(patch)))
}
_ => anyhow::bail!("Invalid version specification: {}", spec),
}
}
fn parse_node_version(&self, version: &str) -> Result<ParsedVersion> {
self.parse_version_spec(version)
}
fn find_matching_versions<'a>(
&self,
versions: &'a [NodeVersion],
spec: &ParsedVersion,
) -> Vec<&'a NodeVersion> {
let mut matching = Vec::new();
for version in versions {
if let Ok(parsed) = self.parse_node_version(&version.version) {
if spec.matches(&parsed) {
matching.push(version);
}
}
}
// Sort by version number
matching.sort_by(|a, b| {
let version_a = self.parse_node_version(&a.version).unwrap_or_default();
let version_b = self.parse_node_version(&b.version).unwrap_or_default();
version_a.cmp(&version_b)
});
matching
}
}
impl Default for ParsedVersion {
fn default() -> Self {
Self {
major: 0,
minor: Some(0),
patch: Some(0),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_version_spec() {
let resolver = NodeVersionManager::new();
assert_eq!(
resolver.parse_version_spec("23").unwrap(),
ParsedVersion::new(23, None, None)
);
assert_eq!(
resolver.parse_version_spec("23.5").unwrap(),
ParsedVersion::new(23, Some(5), None)
);
assert_eq!(
resolver.parse_version_spec("v22.1.0").unwrap(),
ParsedVersion::new(22, Some(1), Some(0))
);
assert_eq!(
resolver.parse_version_spec("18.17.0").unwrap(),
ParsedVersion::new(18, Some(17), Some(0))
);
}
#[test]
fn test_version_matching() {
let spec = ParsedVersion::new(23, None, None);
let version1 = ParsedVersion::new(23, Some(0), Some(0));
let version2 = ParsedVersion::new(23, Some(5), Some(1));
let version3 = ParsedVersion::new(22, Some(1), Some(0));
assert!(spec.matches(&version1));
assert!(spec.matches(&version2));
assert!(!spec.matches(&version3));
}
#[test]
fn test_version_ordering() {
let v1 = ParsedVersion::new(18, Some(17), Some(0));
let v2 = ParsedVersion::new(18, Some(17), Some(1));
let v3 = ParsedVersion::new(18, Some(18), Some(0));
let v4 = ParsedVersion::new(19, Some(0), Some(0));
assert!(v1 < v2);
assert!(v2 < v3);
assert!(v3 < v4);
}
#[tokio::test]
async fn test_version_resolution() {
let resolver = NodeVersionManager::new();
// This test requires internet connection
if let Ok(version) = resolver.resolve_version("18").await {
assert!(version.starts_with("18."));
println!("Resolved '18' to: {}", version);
}
}
}