From 40827c98622c86b0fa881dfaa874504ccdc1ef73 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 14 Dec 2025 14:24:04 -0600 Subject: [PATCH 01/12] feat(core): add created_at timestamps to ls-remote --json for core plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add created_at timestamp support to the following core plugins: - Node.js: extracts date from nodejs.org/dist/index.json - Zig: extracts date from ziglang.org/download/index.json - Rust: extracts created_at from GitHub releases - Bun: extracts created_at from GitHub releases - Swift: extracts created_at from GitHub releases - Erlang: extracts created_at from GitHub releases (when compile=false) - Elixir: extracts timestamp from builds.hex.pm/builds/elixir/builds.txt Each plugin now implements _list_remote_versions_with_info() to provide version metadata. This allows `mise ls-remote --json` to include created_at timestamps for these tools. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/backend/mod.rs | 62 ++++++++++++++++++++------- src/plugins/core/bun.rs | 24 ++++++++--- src/plugins/core/elixir.rs | 31 ++++++++++---- src/plugins/core/erlang.rs | 21 ++++++++-- src/plugins/core/node.rs | 13 +++++- src/plugins/core/ruby.rs | 9 ---- src/plugins/core/rust.rs | 28 +++++++++++-- src/plugins/core/swift.rs | 20 ++++++--- src/plugins/core/zig.rs | 28 ++++++++----- src/registry.rs | 2 +- src/versions_host.rs | 85 ++++++++++++++++---------------------- 11 files changed, 210 insertions(+), 113 deletions(-) diff --git a/src/backend/mod.rs b/src/backend/mod.rs index d8181ad3d8..2334b4335c 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -17,7 +17,7 @@ use crate::lockfile::PlatformInfo; use crate::platform::Platform; use crate::plugins::core::CORE_PLUGINS; use crate::plugins::{PluginType, VERSION_REGEX}; -use crate::registry::{REGISTRY, tool_enabled}; +use crate::registry::{REGISTRY, full_to_url, normalize_remote, tool_enabled}; use crate::runtime_symlinks::is_runtime_symlink; use crate::toolset::outdated_info::OutdatedInfo; use crate::toolset::{ToolRequest, ToolVersion, Toolset, install_state, is_outdated_version}; @@ -312,6 +312,10 @@ pub trait Backend: Debug + Send + Sync { /// List remote versions with additional metadata like created_at timestamps. /// Results are cached. Backends can override `_list_remote_versions_with_info` /// to provide timestamp information. + /// + /// This method first tries the versions host (mise-versions.jdx.dev) which provides + /// version info with created_at timestamps. If that fails, it falls back to the + /// backend's `_list_remote_versions_with_info` implementation. async fn list_remote_versions_with_info( &self, config: &Arc, @@ -320,25 +324,51 @@ pub trait Backend: Debug + Send + Sync { let remote_versions = remote_versions.lock().await; let ba = self.ba().clone(); let id = self.id(); + + // Check if this is an external plugin with a custom remote - skip versions host if so + let use_versions_host = if let Some(plugin) = self.plugin() + && let Ok(Some(remote_url)) = plugin.get_remote_url() + { + // Check if remote matches the registry default + let normalized_remote = + normalize_remote(&remote_url).unwrap_or_else(|_| "INVALID_URL".into()); + let shorthand_remote = REGISTRY + .get(plugin.name()) + .and_then(|rt| rt.backends().first().map(|b| full_to_url(b))) + .unwrap_or_default(); + let matches = + normalized_remote == normalize_remote(&shorthand_remote).unwrap_or_default(); + if !matches { + trace!( + "Skipping versions host for {} because it has a non-default remote", + ba.short + ); + } + matches + } else { + true // Core plugins and plugins without remote URLs can use versions host + }; + let versions = remote_versions .get_or_try_init_async(|| async { trace!("Listing remote versions for {}", ba.to_string()); - // Try versions host first (returns just version strings) - match versions_host::list_versions(&ba).await { - Ok(Some(versions)) => { - return Ok(versions - .into_iter() - .map(|v| VersionInfo { - version: v, - created_at: None, - }) - .collect()); - } - Ok(None) => {} - Err(e) => { - debug!("Error getting versions from versions host: {:#}", e); + // Try versions host first (now returns VersionInfo with timestamps) + if use_versions_host { + match versions_host::list_versions(&ba.short).await { + Ok(Some(versions)) => { + trace!( + "Got {} versions from versions host for {}", + versions.len(), + ba.to_string() + ); + return Ok(versions); + } + Ok(None) => {} + Err(e) => { + debug!("Error getting versions from versions host: {:#}", e); + } } - }; + } trace!( "Calling backend to list remote versions for {}", ba.to_string() diff --git a/src/plugins/core/bun.rs b/src/plugins/core/bun.rs index 6fc24cf8f3..032eb8f941 100644 --- a/src/plugins/core/bun.rs +++ b/src/plugins/core/bun.rs @@ -18,7 +18,9 @@ use crate::lockfile::PlatformInfo; use crate::toolset::ToolVersion; use crate::ui::progress_report::SingleReport; use crate::{ - backend::{Backend, GitHubReleaseInfo, ReleaseType, platform_target::PlatformTarget}, + backend::{ + Backend, GitHubReleaseInfo, ReleaseType, VersionInfo, platform_target::PlatformTarget, + }, config::{Config, Settings}, platform::Platform, }; @@ -111,14 +113,24 @@ impl Backend for BunPlugin { } } - async fn _list_remote_versions(&self, _config: &Arc) -> Result> { + async fn _list_remote_versions_with_info( + &self, + _config: &Arc, + ) -> Result> { let versions = github::list_releases("oven-sh/bun") .await? .into_iter() - .map(|r| r.tag_name) - .filter_map(|v| v.strip_prefix("bun-v").map(|v| v.to_string())) - .unique() - .sorted_by_cached_key(|s| (Versioning::new(s), s.to_string())) + .filter_map(|r| { + r.tag_name + .strip_prefix("bun-v") + .map(|v| (v.to_string(), r.created_at)) + }) + .unique_by(|(v, _)| v.clone()) + .sorted_by_cached_key(|(s, _)| (Versioning::new(s), s.to_string())) + .map(|(version, created_at)| VersionInfo { + version, + created_at: Some(created_at), + }) .collect(); Ok(versions) } diff --git a/src/plugins/core/elixir.rs b/src/plugins/core/elixir.rs index 5e9d25c11f..572eed29a4 100644 --- a/src/plugins/core/elixir.rs +++ b/src/plugins/core/elixir.rs @@ -11,7 +11,7 @@ use crate::install_context::InstallContext; use crate::plugins::VERSION_REGEX; use crate::toolset::{ToolVersion, Toolset}; use crate::ui::progress_report::SingleReport; -use crate::{backend::Backend, config::Config}; +use crate::{backend::Backend, backend::VersionInfo, config::Config}; use crate::{env, file, plugins}; use async_trait::async_trait; use eyre::Result; @@ -89,15 +89,29 @@ impl Backend for ElixirPlugin { &self.ba } - async fn _list_remote_versions(&self, _config: &Arc) -> Result> { - let versions: Vec = HTTP_FETCH + async fn _list_remote_versions_with_info( + &self, + _config: &Arc, + ) -> Result> { + // Format: "version hash timestamp checksum" + // Example: "v1.17.3 abc123 2024-12-01T00:00:00Z def456" + let versions: Vec = HTTP_FETCH .get_text("https://builds.hex.pm/builds/elixir/builds.txt") .await? .lines() .unique() - .filter_map(|s| s.split_once(' ').map(|(v, _)| v.trim_start_matches('v'))) - .filter(|s| regex!(r"^[0-9]+\.[0-9]+\.[0-9]").is_match(s)) - .sorted_by_cached_key(|s| { + .filter_map(|s| { + let parts: Vec<&str> = s.split_whitespace().collect(); + if parts.len() >= 3 { + let version = parts[0].trim_start_matches('v'); + let timestamp = parts[2]; // Third field is the timestamp + Some((version.to_string(), timestamp.to_string())) + } else { + None + } + }) + .filter(|(v, _)| regex!(r"^[0-9]+\.[0-9]+\.[0-9]").is_match(v)) + .sorted_by_cached_key(|(s, _)| { ( Versioning::new(s.split_once('-').map(|(v, _)| v).unwrap_or(s)), !VERSION_REGEX.is_match(s), @@ -106,7 +120,10 @@ impl Backend for ElixirPlugin { s.to_string(), ) }) - .map(|s| s.to_string()) + .map(|(version, created_at)| VersionInfo { + version, + created_at: Some(created_at), + }) .collect(); Ok(versions) } diff --git a/src/plugins/core/erlang.rs b/src/plugins/core/erlang.rs index ef0c68dfd3..f442af5fc3 100644 --- a/src/plugins/core/erlang.rs +++ b/src/plugins/core/erlang.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::{path::PathBuf, sync::Arc}; use crate::backend::Backend; +use crate::backend::VersionInfo; use crate::backend::platform_target::PlatformTarget; use crate::cli::args::BackendArg; use crate::config::{Config, Settings}; @@ -305,12 +306,23 @@ impl Backend for ErlangPlugin { &self.ba } - async fn _list_remote_versions(&self, _config: &Arc) -> Result> { + async fn _list_remote_versions_with_info( + &self, + _config: &Arc, + ) -> Result> { let versions = if Settings::get().erlang.compile == Some(false) { github::list_releases("erlef/otp_builds") .await? .into_iter() - .filter_map(|r| r.tag_name.strip_prefix("OTP-").map(|s| s.to_string())) + .filter_map(|r| { + r.tag_name + .strip_prefix("OTP-") + .map(|s| (s.to_string(), Some(r.created_at))) + }) + .map(|(version, created_at)| VersionInfo { + version, + created_at, + }) .collect() } else { self.update_kerl().await?; @@ -321,7 +333,10 @@ impl Backend for ErlangPlugin { let versions = output .split('\n') .filter(|s| regex!(r"^[0-9].+$").is_match(s)) - .map(|s| s.to_string()) + .map(|s| VersionInfo { + version: s.to_string(), + created_at: None, + }) .collect(); Ok(versions) })? diff --git a/src/plugins/core/node.rs b/src/plugins/core/node.rs index aa739d0eb7..30b21dbbb8 100644 --- a/src/plugins/core/node.rs +++ b/src/plugins/core/node.rs @@ -1,3 +1,4 @@ +use crate::backend::VersionInfo; use crate::backend::static_helpers::fetch_checksum_from_shasums; use crate::backend::{Backend, VersionCacheManager, platform_target::PlatformTarget}; use crate::build_time::built_info; @@ -403,7 +404,10 @@ impl Backend for NodePlugin { &self.ba } - async fn _list_remote_versions(&self, _config: &Arc) -> Result> { + async fn _list_remote_versions_with_info( + &self, + _config: &Arc, + ) -> Result> { let settings = Settings::get(); let base = Settings::get().node.mirror_url(); let versions = HTTP_FETCH @@ -420,10 +424,14 @@ impl Backend for NodePlugin { } }) .map(|v| { - if regex!(r"^v\d+\.").is_match(&v.version) { + let version = if regex!(r"^v\d+\.").is_match(&v.version) { v.version.strip_prefix('v').unwrap().to_string() } else { v.version + }; + VersionInfo { + version, + created_at: v.date, } }) .rev() @@ -792,5 +800,6 @@ fn slug(v: &str) -> String { #[derive(Debug, Deserialize)] struct NodeVersion { version: String, + date: Option, files: Vec, } diff --git a/src/plugins/core/ruby.rs b/src/plugins/core/ruby.rs index 129bb1388d..58e186f284 100644 --- a/src/plugins/core/ruby.rs +++ b/src/plugins/core/ruby.rs @@ -592,15 +592,6 @@ impl Backend for RubyPlugin { fn ba(&self) -> &Arc { &self.ba } - async fn _list_remote_versions(&self, config: &Arc) -> Result> { - Ok(self - ._list_remote_versions_with_info(config) - .await? - .into_iter() - .map(|v| v.version) - .collect()) - } - async fn _list_remote_versions_with_info( &self, _config: &Arc, diff --git a/src/plugins/core/rust.rs b/src/plugins/core/rust.rs index aa58eb5d43..80f4e6d922 100644 --- a/src/plugins/core/rust.rs +++ b/src/plugins/core/rust.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use std::{collections::BTreeMap, sync::Arc}; use crate::backend::Backend; +use crate::backend::VersionInfo; use crate::build_time::TARGET; use crate::cli::args::BackendArg; use crate::cmd::CmdLineRunner; @@ -73,13 +74,32 @@ impl Backend for RustPlugin { &self.ba } - async fn _list_remote_versions(&self, _config: &Arc) -> Result> { - let versions = github::list_releases("rust-lang/rust") + async fn _list_remote_versions_with_info( + &self, + _config: &Arc, + ) -> Result> { + let versions: Vec = github::list_releases("rust-lang/rust") .await? .into_iter() - .map(|r| r.tag_name) + .map(|r| VersionInfo { + version: r.tag_name, + created_at: Some(r.created_at), + }) .rev() - .chain(vec!["nightly".into(), "beta".into(), "stable".into()]) + .chain(vec![ + VersionInfo { + version: "nightly".into(), + created_at: None, + }, + VersionInfo { + version: "beta".into(), + created_at: None, + }, + VersionInfo { + version: "stable".into(), + created_at: None, + }, + ]) .collect(); Ok(versions) } diff --git a/src/plugins/core/swift.rs b/src/plugins/core/swift.rs index 09f74eada9..f0aafb918b 100644 --- a/src/plugins/core/swift.rs +++ b/src/plugins/core/swift.rs @@ -5,7 +5,7 @@ use crate::http::HTTP; use crate::install_context::InstallContext; use crate::toolset::ToolVersion; use crate::ui::progress_report::SingleReport; -use crate::{backend::Backend, config::Config}; +use crate::{backend::Backend, backend::VersionInfo, config::Config}; use crate::{file, github, gpg, plugins}; use async_trait::async_trait; use eyre::Result; @@ -159,14 +159,24 @@ impl Backend for SwiftPlugin { &self.ba } - async fn _list_remote_versions(&self, _config: &Arc) -> Result> { + async fn _list_remote_versions_with_info( + &self, + _config: &Arc, + ) -> Result> { let versions = github::list_releases("swiftlang/swift") .await? .into_iter() - .map(|r| r.tag_name) - .filter_map(|v| v.strip_prefix("swift-").map(|v| v.to_string())) - .filter_map(|v| v.strip_suffix("-RELEASE").map(|v| v.to_string())) + .filter_map(|r| { + r.tag_name + .strip_prefix("swift-") + .and_then(|v| v.strip_suffix("-RELEASE")) + .map(|v| (v.to_string(), r.created_at)) + }) .rev() + .map(|(version, created_at)| VersionInfo { + version, + created_at: Some(created_at), + }) .collect(); Ok(versions) } diff --git a/src/plugins/core/zig.rs b/src/plugins/core/zig.rs index 540df353fa..89b65577d4 100644 --- a/src/plugins/core/zig.rs +++ b/src/plugins/core/zig.rs @@ -5,6 +5,7 @@ use std::{ }; use crate::backend::Backend; +use crate::backend::VersionInfo; use crate::backend::platform_target::PlatformTarget; use crate::cli::args::BackendArg; use crate::cmd::CmdLineRunner; @@ -241,29 +242,36 @@ impl Backend for ZigPlugin { &self.ba } - async fn _list_remote_versions(&self, _config: &Arc) -> Result> { + async fn _list_remote_versions_with_info( + &self, + _config: &Arc, + ) -> Result> { let indexes = [ "https://ziglang.org/download/index.json", // "https://machengine.org/zig/index.json", // need to handle mach's CalVer ]; - let mut versions: Vec = Vec::new(); + let mut versions: Vec<(String, Option)> = Vec::new(); for index in indexes { let index_json: serde_json::Value = HTTP_FETCH.json(index).await?; - let index_versions: Vec = index_json + let index_obj = index_json .as_object() - .ok_or_else(|| eyre::eyre!("Failed to get zig version from {:?}", index))? - .keys() - .cloned() - .collect(); + .ok_or_else(|| eyre::eyre!("Failed to get zig version from {:?}", index))?; - versions.extend(index_versions); + for (version, data) in index_obj { + let date = data.get("date").and_then(|d| d.as_str()).map(String::from); + versions.push((version.clone(), date)); + } } let versions = versions .into_iter() - .unique() - .sorted_by_cached_key(|s| (Versioning::new(s), s.to_string())) + .unique_by(|(v, _)| v.clone()) + .sorted_by_cached_key(|(s, _)| (Versioning::new(s), s.to_string())) + .map(|(version, date)| VersionInfo { + version, + created_at: date, + }) .collect(); Ok(versions) diff --git a/src/registry.rs b/src/registry.rs index 0d221d4076..350a603098 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -156,7 +156,7 @@ pub fn is_trusted_plugin(name: &str, remote: &str) -> bool { !is_shorthand || is_mise_url } -fn normalize_remote(remote: &str) -> eyre::Result { +pub fn normalize_remote(remote: &str) -> eyre::Result { let url = Url::parse(remote)?; let host = url.host_str().unwrap(); let path = url.path().trim_end_matches(".git"); diff --git a/src/versions_host.rs b/src/versions_host.rs index a724374fd1..dc16afd1d5 100644 --- a/src/versions_host.rs +++ b/src/versions_host.rs @@ -1,9 +1,9 @@ -use crate::cli::args::BackendArg; +use crate::backend::VersionInfo; use crate::config::Settings; +use crate::http; use crate::http::HTTP_FETCH; use crate::plugins::core::CORE_PLUGINS; use crate::registry::REGISTRY; -use crate::{http, registry}; use std::{ collections::{HashMap, HashSet}, sync::{ @@ -12,7 +12,6 @@ use std::{ }, }; use tokio::sync::Mutex; -use url::Url; static PLUGINS_USE_VERSION_HOST: LazyLock> = LazyLock::new(|| { CORE_PLUGINS @@ -23,78 +22,64 @@ static PLUGINS_USE_VERSION_HOST: LazyLock> = LazyLock::new(|| { .collect() }); -pub async fn list_versions(ba: &BackendArg) -> eyre::Result>> { - if !Settings::get().use_versions_host - || ba.short.contains(':') - || !PLUGINS_USE_VERSION_HOST.contains(ba.short.as_str()) - { +/// Response format from the versions host TOML endpoint +#[derive(serde::Deserialize)] +struct VersionsResponse { + versions: Vec, +} + +/// List versions from the versions host (mise-versions.jdx.dev). +/// Returns Vec with created_at timestamps from the TOML endpoint. +pub async fn list_versions(tool: &str) -> eyre::Result>> { + if !Settings::get().use_versions_host || !PLUGINS_USE_VERSION_HOST.contains(tool) { return Ok(None); } - // ensure that we're using a default shorthand plugin - if let Some(plugin) = ba.backend()?.plugin() - && let Ok(Some(remote_url)) = plugin.get_remote_url() - { - let normalized_remote = normalize_remote(&remote_url).unwrap_or("INVALID_URL".into()); - let shorthand_remote = REGISTRY - .get(plugin.name()) - .and_then(|rt| rt.backends().first().map(|b| registry::full_to_url(b))) - .unwrap_or_default(); - if normalized_remote != normalize_remote(&shorthand_remote).unwrap_or_default() { - trace!( - "Skipping versions host check for {} because it has a non-default remote", - ba.short - ); - return Ok(None); - } - } - static CACHE: LazyLock>>> = + + static CACHE: LazyLock>>> = LazyLock::new(|| Mutex::new(HashMap::new())); static RATE_LIMITED: AtomicBool = AtomicBool::new(false); - if let Some(versions) = CACHE.lock().await.get(ba.short.as_str()) { + + if let Some(versions) = CACHE.lock().await.get(tool) { return Ok(Some(versions.clone())); } if RATE_LIMITED.load(Ordering::Relaxed) { - warn!("{ba}: skipping versions host check due to rate limit"); + warn!("{tool}: skipping versions host check due to rate limit"); return Ok(None); } - let url = format!("https://mise-versions.jdx.dev/{}", &ba.short); - let versions = match HTTP_FETCH.get_text(url).await { - Ok(res) => res, + + // Use TOML format which includes created_at timestamps + let url = format!("https://mise-versions.jdx.dev/{}.toml", tool); + let versions: Vec = match HTTP_FETCH.get_text(&url).await { + Ok(body) => { + let response: VersionsResponse = toml::from_str(&body)?; + response.versions + } Err(err) => match http::error_code(&err).unwrap_or(0) { 404 => return Ok(None), 429 => { RATE_LIMITED.store(true, Ordering::Relaxed); - warn!("{ba}: mise-version rate limited"); + warn!("{tool}: mise-versions rate limited"); return Ok(None); } _ => return Err(err), }, }; - let versions = versions - .lines() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .collect::>(); + trace!( "got {} {} versions from versions host", versions.len(), - &ba.short + tool ); + + if versions.is_empty() { + return Ok(None); + } + CACHE .lock() .await - .insert(ba.short.clone(), versions.clone()); - match versions.is_empty() { - true => Ok(None), - false => Ok(Some(versions)), - } -} - -fn normalize_remote(remote: &str) -> eyre::Result { - let url = Url::parse(remote)?; - let host = url.host_str().unwrap(); - let path = url.path().trim_end_matches(".git"); - Ok(format!("{host}{path}")) + .insert(tool.to_string(), versions.clone()); + Ok(Some(versions)) } /// Tracks a tool installation asynchronously (fire-and-forget) From d49c2d29bfe060960682b493abcaffce27a9cdf2 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 14 Dec 2025 15:51:27 -0600 Subject: [PATCH 02/12] fix(versions_host): use TOML endpoint for created_at timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The versions host has a .toml endpoint that includes created_at timestamps. Use this instead of plain text to ensure timestamps are available when the versions host succeeds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- e2e/cli/test_ls_cache | 4 ++-- src/versions_host.rs | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/e2e/cli/test_ls_cache b/e2e/cli/test_ls_cache index d9aaf025ec..0e9824a23a 100644 --- a/e2e/cli/test_ls_cache +++ b/e2e/cli/test_ls_cache @@ -4,6 +4,6 @@ export MISE_USE_VERSIONS_HOST=1 # verify that cache is reused for `mise ls` # see https://github.com/jdx/mise/discussions/6736 -assert_contains "mise -v use bat 2>&1" "GET https://mise-versions.jdx.dev/bat 200 OK" +assert_contains "mise -v use bat 2>&1" "GET https://mise-versions.jdx.dev/bat.toml 200 OK" touch -t 202001010000 "$MISE_CACHE_DIR/bat/"* -assert_not_contains "mise -v ls bat 2>&1" "GET https://mise-versions.jdx.dev/bat 200 OK" +assert_not_contains "mise -v ls bat 2>&1" "GET https://mise-versions.jdx.dev/bat.toml 200 OK" diff --git a/src/versions_host.rs b/src/versions_host.rs index dc16afd1d5..e8af8969eb 100644 --- a/src/versions_host.rs +++ b/src/versions_host.rs @@ -25,7 +25,12 @@ static PLUGINS_USE_VERSION_HOST: LazyLock> = LazyLock::new(|| { /// Response format from the versions host TOML endpoint #[derive(serde::Deserialize)] struct VersionsResponse { - versions: Vec, + versions: indexmap::IndexMap, +} + +#[derive(serde::Deserialize)] +struct VersionEntry { + created_at: toml::value::Datetime, } /// List versions from the versions host (mise-versions.jdx.dev). @@ -52,7 +57,14 @@ pub async fn list_versions(tool: &str) -> eyre::Result>> let versions: Vec = match HTTP_FETCH.get_text(&url).await { Ok(body) => { let response: VersionsResponse = toml::from_str(&body)?; - response.versions + response + .versions + .into_iter() + .map(|(version, entry)| VersionInfo { + version, + created_at: Some(entry.created_at.to_string()), + }) + .collect() } Err(err) => match http::error_code(&err).unwrap_or(0) { 404 => return Ok(None), From 94522fae3af7a8785301561f332372dc528b2f12 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 14 Dec 2025 16:12:54 -0600 Subject: [PATCH 03/12] feat(cli): add --before flag for date-based version filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ability to filter tool versions by release date, similar to npm's --before flag. This enables reproducible builds and supply-chain security by ensuring only versions released before a certain date are considered. Features: - New `install_before` setting (env: MISE_INSTALL_BEFORE) - New `--before` CLI flag for install, upgrade, and use commands - Supports absolute dates (2024-06-01) and relative durations (90d, 1y) - Only filters fuzzy version requests (node@20, latest) - explicit versions like node@22.5.0 are not filtered 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/cli/install.md | 6 ++ docs/cli/upgrade.md | 11 ++++ docs/cli/use.md | 6 ++ e2e/cli/test_install_before | 43 +++++++++++++ man/man1/mise.1 | 20 ++++++ mise.usage.kdl | 12 ++++ schema/mise.json | 4 ++ settings.toml | 24 +++++++ src/backend/mod.rs | 103 +++++++++++++++++++++++++++++++ src/cli/exec.rs | 1 + src/cli/install.rs | 28 +++++++-- src/cli/upgrade.rs | 29 ++++++++- src/cli/use.rs | 20 ++++++ src/duration.rs | 37 ++++++++++- src/toolset/tool_version.rs | 25 ++++++-- src/toolset/tool_version_list.rs | 3 + xtasks/fig/src/mise.ts | 24 +++++++ 17 files changed, 385 insertions(+), 11 deletions(-) create mode 100644 e2e/cli/test_install_before diff --git a/docs/cli/install.md b/docs/cli/install.md index 3f3132971c..2014c5d698 100644 --- a/docs/cli/install.md +++ b/docs/cli/install.md @@ -42,6 +42,12 @@ Show installation output This argument will print plugin output such as download, configuration, and compilation output. +### `--before ` + +Only install versions released before this date + +Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y". + ### `--raw` Directly pipe stdin/stdout/stderr from plugin to user Sets --jobs=1 diff --git a/docs/cli/upgrade.md b/docs/cli/upgrade.md index 40e96bab36..13911e568c 100644 --- a/docs/cli/upgrade.md +++ b/docs/cli/upgrade.md @@ -46,6 +46,17 @@ would change your config to `node = "22"`. Just print what would be done, don't actually do it +### `--before ` + +Only upgrade to versions released before this date + +Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y". +This can be useful for reproducibility or security purposes. + +In "soft" mode (default), this only affects fuzzy version matches like "20" or "latest". +Explicitly pinned versions like "22.5.0" are not filtered. +Use `install_before_mode = "hard"` setting to filter all versions strictly. + ### `--raw` Directly pipe stdin/stdout/stderr from plugin to user Sets --jobs=1 diff --git a/docs/cli/use.md b/docs/cli/use.md index df8e92d3a8..630d281a01 100644 --- a/docs/cli/use.md +++ b/docs/cli/use.md @@ -64,6 +64,12 @@ Specify a path to a config file or directory If a directory is specified, it will look for a config file in that directory following the rules above. +### `--before ` + +Only install versions released before this date + +Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y". + ### `--fuzzy` Save fuzzy version to config file diff --git a/e2e/cli/test_install_before b/e2e/cli/test_install_before new file mode 100644 index 0000000000..6c566290f2 --- /dev/null +++ b/e2e/cli/test_install_before @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Test --before flag for date-based version filtering + +set -euo pipefail + +# Clean up any existing installations +mise uninstall tiny --all 2>/dev/null || true +rm -f mise.toml .tool-versions + +# Test: install --before with dry-run should show the tool +output=$(mise install tiny@latest --before 2020-01-01 --dry-run 2>&1) +assert_contains "echo '$output'" "tiny" + +# Test: install --before should install a version +mise install tiny@latest --before 2025-01-01 +assert_contains "mise ls --installed tiny" "3.1.0" + +# Test: use --before with dry-run +mise uninstall tiny --all 2>/dev/null || true +output=$(mise use tiny@latest --before 2025-01-01 --dry-run 2>&1) +assert_contains "echo '$output'" "tiny" +rm -f mise.toml + +# Test: MISE_INSTALL_BEFORE environment variable works +mise uninstall tiny --all 2>/dev/null || true +export MISE_INSTALL_BEFORE="2025-01-01" +mise install tiny@latest +assert_contains "mise ls --installed tiny" "3.1.0" +unset MISE_INSTALL_BEFORE + +# Test: upgrade --before with dry-run +cat <mise.toml +[tools] +tiny = "1" +EOF +mise uninstall tiny --all 2>/dev/null || true +mise install tiny@1.0.0 +output=$(mise upgrade --before 2025-01-01 --dry-run 2>&1) +assert_contains "echo '$output'" "tiny" + +# Clean up +mise uninstall tiny --all 2>/dev/null || true +rm -f mise.toml .tool-versions diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 9079bbd2eb..723b60c36d 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -1204,6 +1204,11 @@ Show installation output This argument will print plugin output such as download, configuration, and compilation output. .TP +\fB\-\-before\fR \fI\fR +Only install versions released before this date + +Supports absolute dates like "2024\-06\-01" and relative durations like "90d" or "1y". +.TP \fB\-\-raw\fR Directly pipe stdin/stdout/stderr from plugin to user Sets \-\-jobs=1 \fBArguments:\fR @@ -2695,6 +2700,16 @@ would change your config to `node = "22"`. \fB\-n, \-\-dry\-run\fR Just print what would be done, don't actually do it .TP +\fB\-\-before\fR \fI\fR +Only upgrade to versions released before this date + +Supports absolute dates like "2024\-06\-01" and relative durations like "90d" or "1y". +This can be useful for reproducibility or security purposes. + +In "soft" mode (default), this only affects fuzzy version matches like "20" or "latest". +Explicitly pinned versions like "22.5.0" are not filtered. +Use `install_before_mode = "hard"` setting to filter all versions strictly. +.TP \fB\-\-raw\fR Directly pipe stdin/stdout/stderr from plugin to user Sets \-\-jobs=1 \fBArguments:\fR @@ -2746,6 +2761,11 @@ Specify a path to a config file or directory If a directory is specified, it will look for a config file in that directory following the rules above. .TP +\fB\-\-before\fR \fI\fR +Only install versions released before this date + +Supports absolute dates like "2024\-06\-01" and relative durations like "90d" or "1y". +.TP \fB\-\-fuzzy\fR Save fuzzy version to config file diff --git a/mise.usage.kdl b/mise.usage.kdl index 817cdaa39a..6ce9a692f0 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -469,6 +469,10 @@ cmd install help="Install a tool version" { flag "-v --verbose" help="Show installation output" var=#true count=#true { long_help "Show installation output\n\nThis argument will print plugin output such as download, configuration, and compilation output." } + flag --before help="Only install versions released before this date" { + long_help "Only install versions released before this date\n\nSupports absolute dates like \"2024-06-01\" and relative durations like \"90d\" or \"1y\"." + arg + } flag --raw help="Directly pipe stdin/stdout/stderr from plugin to user Sets --jobs=1" arg "[TOOL@VERSION]…" help="Tool(s) to install e.g.: node@20" required=#false var=#true } @@ -1070,6 +1074,10 @@ cmd upgrade help="Upgrades outdated tools" { long_help "Upgrades to the latest version available, bumping the version in mise.toml\n\nFor example, if you have `node = \"20.0.0\"` in your mise.toml but 22.1.0 is the latest available,\nthis will install 22.1.0 and set `node = \"22.1.0\"` in your config.\n\nIt keeps the same precision as what was there before, so if you instead had `node = \"20\"`, it\nwould change your config to `node = \"22\"`." } flag "-n --dry-run" help="Just print what would be done, don't actually do it" + flag --before help="Only upgrade to versions released before this date" { + long_help "Only upgrade to versions released before this date\n\nSupports absolute dates like \"2024-06-01\" and relative durations like \"90d\" or \"1y\".\nThis can be useful for reproducibility or security purposes.\n\nIn \"soft\" mode (default), this only affects fuzzy version matches like \"20\" or \"latest\".\nExplicitly pinned versions like \"22.5.0\" are not filtered.\nUse `install_before_mode = \"hard\"` setting to filter all versions strictly." + arg + } flag --raw help="Directly pipe stdin/stdout/stderr from plugin to user Sets --jobs=1" arg "[TOOL@VERSION]…" help="Tool(s) to upgrade\ne.g.: node@20 python@3.10\nIf not specified, all current tools will be upgraded" required=#false var=#true } @@ -1093,6 +1101,10 @@ cmd use help="Installs a tool and adds the version to mise.toml." { long_help "Specify a path to a config file or directory\n\nIf a directory is specified, it will look for a config file in that directory following the rules above." arg } + flag --before help="Only install versions released before this date" { + long_help "Only install versions released before this date\n\nSupports absolute dates like \"2024-06-01\" and relative durations like \"90d\" or \"1y\"." + arg + } flag --fuzzy help="Save fuzzy version to config file" { long_help "Save fuzzy version to config file\n\ne.g.: `mise use --fuzzy node@20` will save 20 as the version\nthis is the default behavior unless `MISE_PIN=1`" } diff --git a/schema/mise.json b/schema/mise.json index 5c8a33d22b..0903d5234d 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -710,6 +710,10 @@ "type": "string" } }, + "install_before": { + "description": "Only install versions released before this date", + "type": "string" + }, "jobs": { "default": 8, "description": "How many jobs to run concurrently such as tool installs.", diff --git a/settings.toml b/settings.toml index b63a87b25d..e8e61b3e08 100644 --- a/settings.toml +++ b/settings.toml @@ -581,6 +581,30 @@ parse_env = "list_by_colon" rust_type = "BTreeSet" type = "ListPath" +[install_before] +description = "Only install versions released before this date" +docs = """ +Filter tool versions by release date. Supports: + +- Absolute dates: `2024-06-01`, `2024-06-01T12:00:00Z` +- Relative durations: `90d` (90 days ago), `1y` (1 year ago), `6m` (6 months ago) + +This is useful for reproducible builds or security purposes where you want to ensure +you're only installing versions that existed before a certain point in time. + +Only affects backends that provide release timestamps (aqua, cargo, npm, pipx, and some core plugins). +Versions without timestamps are included by default. + +**Behavior**: This filter only applies when resolving fuzzy version requests like `node@20` or `latest`. +Explicitly pinned versions like `node@22.5.0` are not filtered, allowing you to selectively +use newer versions for specific tools while keeping others behind the cutoff date. + +Can be overridden with the `--before` CLI flag. +""" +env = "MISE_INSTALL_BEFORE" +optional = true +type = "String" + [jobs] default = 8 description = "How many jobs to run concurrently such as tool installs." diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 2334b4335c..80f6931cfb 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -8,6 +8,8 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use tokio::sync::Mutex as TokioMutex; +use jiff::Timestamp; + use crate::cli::args::{BackendArg, ToolVersionType}; use crate::cmd::CmdLineRunner; use crate::config::{Config, Settings}; @@ -87,6 +89,33 @@ pub struct VersionInfo { pub created_at: Option, } +impl VersionInfo { + /// Filter versions to only include those released before the given timestamp. + /// Versions without a created_at timestamp are included by default. + pub fn filter_by_date(versions: Vec, before: Timestamp) -> Vec { + versions + .into_iter() + .filter(|v| { + match &v.created_at { + Some(ts) => { + // Parse the timestamp and compare + match ts.parse::() { + Ok(created) => created < before, + Err(_) => { + // If we can't parse the timestamp, include the version + trace!("Failed to parse timestamp: {}", ts); + true + } + } + } + // Include versions without timestamps + None => true, + } + }) + .collect() + } +} + static TOOLS: Mutex>> = Mutex::new(None); pub async fn load_tools() -> Result> { @@ -515,6 +544,34 @@ pub trait Backend: Debug + Send + Sync { let versions = self.list_remote_versions(config).await?; Ok(self.fuzzy_match_filter(versions, query)) } + + /// List versions matching a query, optionally filtered by release date. + /// Use this when you have a `before_date` from ResolveOptions. + async fn list_versions_matching_with_opts( + &self, + config: &Arc, + query: &str, + before_date: Option, + ) -> eyre::Result> { + let versions = match before_date { + Some(before) => { + // Use version info to filter by date + let versions_with_info = self.list_remote_versions_with_info(config).await?; + let filtered = VersionInfo::filter_by_date(versions_with_info, before); + // Warn if no versions have timestamps + if filtered.iter().all(|v| v.created_at.is_none()) && !filtered.is_empty() { + debug!( + "Backend {} does not provide release dates; --before filter may not work as expected", + self.id() + ); + } + filtered.into_iter().map(|v| v.version).collect() + } + None => self.list_remote_versions(config).await?, + }; + Ok(self.fuzzy_match_filter(versions, query)) + } + async fn latest_version( &self, config: &Arc, @@ -531,6 +588,52 @@ pub trait Backend: Debug + Send + Sync { None => self.latest_stable_version(config).await, } } + + /// Get the latest version, optionally filtered by release date. + /// Use this when you have a `before_date` from ResolveOptions. + async fn latest_version_with_opts( + &self, + config: &Arc, + query: Option, + before_date: Option, + ) -> eyre::Result> { + match query { + Some(query) => { + let mut matches = self + .list_versions_matching_with_opts(config, &query, before_date) + .await?; + if matches.is_empty() && query == "latest" { + // Fall back to all versions if no match + matches = match before_date { + Some(before) => { + let versions_with_info = + self.list_remote_versions_with_info(config).await?; + VersionInfo::filter_by_date(versions_with_info, before) + .into_iter() + .map(|v| v.version) + .collect() + } + None => self.list_remote_versions(config).await?, + }; + } + Ok(find_match_in_list(&matches, &query)) + } + None => { + // For stable version, apply date filter if provided + match before_date { + Some(before) => { + let versions_with_info = + self.list_remote_versions_with_info(config).await?; + let filtered = VersionInfo::filter_by_date(versions_with_info, before); + let versions: Vec = + filtered.into_iter().map(|v| v.version).collect(); + Ok(find_match_in_list(&versions, "latest")) + } + None => self.latest_stable_version(config).await, + } + } + } + } fn latest_installed_version(&self, query: Option) -> eyre::Result> { match query { Some(query) => { diff --git a/src/cli/exec.rs b/src/cli/exec.rs index 07abd77f42..b148199773 100644 --- a/src/cli/exec.rs +++ b/src/cli/exec.rs @@ -67,6 +67,7 @@ impl Exec { ResolveOptions { latest_versions: true, use_locked_version: false, + ..Default::default() } } else { Default::default() diff --git a/src/cli/install.rs b/src/cli/install.rs index b63bd9248e..0b71e43b68 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -5,11 +5,13 @@ use std::sync::Arc; use crate::cli::args::ToolArg; use crate::config::Config; use crate::config::Settings; +use crate::duration::parse_into_timestamp; use crate::hooks::Hooks; use crate::toolset::{InstallOptions, ResolveOptions, ToolRequest, ToolSource, Toolset}; use crate::{config, env, hooks}; use eyre::Result; use itertools::Itertools; +use jiff::Timestamp; /// Install a tool version /// @@ -47,6 +49,12 @@ pub struct Install { #[clap(long, short, action = clap::ArgAction::Count)] verbose: u8, + /// Only install versions released before this date + /// + /// Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y". + #[clap(long, verbatim_doc_comment)] + before: Option, + /// Directly pipe stdin/stdout/stderr from plugin to user /// Sets --jobs=1 #[clap(long, overrides_with = "jobs")] @@ -120,7 +128,7 @@ impl Install { warn!("specify a version with `mise install @`"); vec![] } else { - ts.install_all_versions(&mut config, tool_versions, &self.install_opts()) + ts.install_all_versions(&mut config, tool_versions, &self.install_opts()?) .await? }; // because we may be installing a tool that is not in config, we need to restore the original tool args and reset everything @@ -141,8 +149,8 @@ impl Install { Ok(()) } - fn install_opts(&self) -> InstallOptions { - InstallOptions { + fn install_opts(&self) -> Result { + Ok(InstallOptions { force: self.force, jobs: self.jobs, raw: self.raw, @@ -150,11 +158,23 @@ impl Install { resolve_options: ResolveOptions { use_locked_version: true, latest_versions: true, + before_date: self.get_before_date()?, }, dry_run: self.dry_run, locked: Settings::get().locked, ..Default::default() + }) + } + + /// Get the before_date from CLI flag or settings + fn get_before_date(&self) -> Result> { + if let Some(before) = &self.before { + return Ok(Some(parse_into_timestamp(before)?)); + } + if let Some(before) = &Settings::get().install_before { + return Ok(Some(parse_into_timestamp(before)?)); } + Ok(None) } fn get_requested_tool_versions( @@ -227,7 +247,7 @@ impl Install { } else { let mut ts = Toolset::from(trs.clone()); measure!("install_all_versions", { - ts.install_all_versions(&mut config, versions, &self.install_opts()) + ts.install_all_versions(&mut config, versions, &self.install_opts()?) .await? }) }; diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs index 2bef5edf10..2ae46babd6 100644 --- a/src/cli/upgrade.rs +++ b/src/cli/upgrade.rs @@ -2,7 +2,8 @@ use std::sync::Arc; use crate::backend::pipx::PIPXBackend; use crate::cli::args::ToolArg; -use crate::config::{Config, config_file}; +use crate::config::{Config, Settings, config_file}; +use crate::duration::parse_into_timestamp; use crate::file::display_path; use crate::toolset::outdated_info::OutdatedInfo; use crate::toolset::{InstallOptions, ResolveOptions, ToolVersion, ToolsetBuilder}; @@ -12,6 +13,7 @@ use crate::{config, ui}; use console::Term; use demand::DemandOption; use eyre::{Context, Result, eyre}; +use jiff::Timestamp; /// Upgrades outdated tools /// @@ -52,6 +54,17 @@ pub struct Upgrade { #[clap(long, short = 'n', verbatim_doc_comment)] dry_run: bool, + /// Only upgrade to versions released before this date + /// + /// Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y". + /// This can be useful for reproducibility or security purposes. + /// + /// In "soft" mode (default), this only affects fuzzy version matches like "20" or "latest". + /// Explicitly pinned versions like "22.5.0" are not filtered. + /// Use `install_before_mode = "hard"` setting to filter all versions strictly. + #[clap(long, verbatim_doc_comment)] + before: Option, + /// Directly pipe stdin/stdout/stderr from plugin to user /// Sets --jobs=1 #[clap(long, overrides_with = "jobs")] @@ -154,6 +167,7 @@ impl Upgrade { resolve_options: ResolveOptions { use_locked_version: false, latest_versions: true, + before_date: self.get_before_date()?, }, ..Default::default() }; @@ -255,6 +269,19 @@ impl Upgrade { } } } + + /// Get the before_date from CLI flag or settings + fn get_before_date(&self) -> Result> { + // CLI flag takes precedence over settings + if let Some(before) = &self.before { + return Ok(Some(parse_into_timestamp(before)?)); + } + // Fall back to settings + if let Some(before) = &Settings::get().install_before { + return Ok(Some(parse_into_timestamp(before)?)); + } + Ok(None) + } } static AFTER_LONG_HELP: &str = color_print::cstr!( diff --git a/src/cli/use.rs b/src/cli/use.rs index 7b6dc3fb6d..1535e0e661 100644 --- a/src/cli/use.rs +++ b/src/cli/use.rs @@ -6,11 +6,13 @@ use std::{ use console::{Term, style}; use eyre::{Result, bail, eyre}; use itertools::Itertools; +use jiff::Timestamp; use path_absolutize::Absolutize; use crate::cli::args::{BackendArg, ToolArg}; use crate::config::config_file::ConfigFile; use crate::config::{Config, ConfigPathOptions, Settings, config_file, resolve_target_config_path}; +use crate::duration::parse_into_timestamp; use crate::file::display_path; use crate::registry::REGISTRY; use crate::toolset::{ @@ -75,6 +77,12 @@ pub struct Use { #[clap(short, long, overrides_with_all = & ["global", "env"], value_hint = clap::ValueHint::FilePath)] path: Option, + /// Only install versions released before this date + /// + /// Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y". + #[clap(long, verbatim_doc_comment)] + before: Option, + /// Save fuzzy version to config file /// /// e.g.: `mise use --fuzzy node@20` will save 20 as the version @@ -116,6 +124,7 @@ impl Use { let mut resolve_options = ResolveOptions { latest_versions: false, use_locked_version: true, + before_date: self.get_before_date()?, }; let versions: Vec<_> = self .tool @@ -329,6 +338,17 @@ impl Use { } } } + + /// Get the before_date from CLI flag or settings + fn get_before_date(&self) -> Result> { + if let Some(before) = &self.before { + return Ok(Some(parse_into_timestamp(before)?)); + } + if let Some(before) = &Settings::get().install_before { + return Ok(Some(parse_into_timestamp(before)?)); + } + Ok(None) + } } static AFTER_LONG_HELP: &str = color_print::cstr!( diff --git a/src/duration.rs b/src/duration.rs index e0524f72d1..0a96b51fd3 100644 --- a/src/duration.rs +++ b/src/duration.rs @@ -1,7 +1,7 @@ pub use std::time::Duration; use eyre::{Result, bail}; -use jiff::{Span, civil::date}; +use jiff::{Span, Timestamp, Zoned, civil::date}; pub const HOURLY: Duration = Duration::from_secs(60 * 60); pub const DAILY: Duration = Duration::from_secs(60 * 60 * 24); @@ -20,3 +20,38 @@ pub fn parse_duration(s: &str) -> Result { Err(_) => Ok(Duration::from_secs(s.parse()?)), } } + +/// Parse a date/duration string into a Timestamp. +/// Supports: +/// - RFC3339 timestamps: "2024-06-01T12:00:00Z" +/// - ISO dates: "2024-06-01" (treated as end of day in UTC) +/// - Relative durations: "90d", "1y", "6m" (subtracted from now) +pub fn parse_into_timestamp(s: &str) -> Result { + // Try RFC3339 timestamp first + if let Ok(ts) = s.parse::() { + return Ok(ts); + } + + // Try parsing as a Zoned datetime (handles various formats) + if let Ok(zoned) = s.parse::() { + return Ok(zoned.timestamp()); + } + + // Try parsing as date only (YYYY-MM-DD) - use end of day UTC + if let Ok(civil_date) = s.parse::() { + let datetime = civil_date.at(23, 59, 59, 0); + let ts = datetime.to_zoned(jiff::tz::TimeZone::UTC)?.timestamp(); + return Ok(ts); + } + + // Try parsing as duration and subtract from now + if let Ok(span) = s.parse::() { + let now = Timestamp::now(); + let past = now.checked_sub(span)?; + return Ok(past); + } + + bail!( + "Invalid date or duration: {s}. Expected formats: '2024-06-01', '2024-06-01T12:00:00Z', '90d', '1y'" + ) +} diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index b30ac6a973..eec7c925db 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -16,6 +16,7 @@ use crate::toolset::{ToolRequest, ToolVersionOptions, tool_request}; use console::style; use dashmap::DashMap; use eyre::Result; +use jiff::Timestamp; #[cfg(windows)] use path_absolutize::Absolutize; @@ -130,6 +131,7 @@ impl ToolVersion { let opts = ResolveOptions { latest_versions: true, use_locked_version: false, + ..Default::default() }; let tv = self.request.resolve(config, &opts).await?; // map cargo backend specific prefixes to ref @@ -215,7 +217,10 @@ impl ToolVersion { { return build(v); } - if let Some(v) = backend.latest_version(config, None).await? { + if let Some(v) = backend + .latest_version_with_opts(config, None, opts.before_date) + .await? + { return build(v); } } @@ -228,7 +233,9 @@ impl ToolVersion { return build(v.clone()); } } - let matches = backend.list_versions_matching(config, &v).await?; + let matches = backend + .list_versions_matching_with_opts(config, &v, opts.before_date) + .await?; if matches.contains(&v) { return build(v); } @@ -264,7 +271,9 @@ impl ToolVersion { { return Ok(Self::new(request, v.to_string())); } - let matches = backend.list_versions_matching(config, prefix).await?; + let matches = backend + .list_versions_matching_with_opts(config, prefix, opts.before_date) + .await?; let v = match matches.last() { Some(v) => v, None => prefix, @@ -343,6 +352,8 @@ impl Hash for ToolVersion { pub struct ResolveOptions { pub latest_versions: bool, pub use_locked_version: bool, + /// Only consider versions released before this timestamp + pub before_date: Option, } impl Default for ResolveOptions { @@ -350,6 +361,7 @@ impl Default for ResolveOptions { Self { latest_versions: false, use_locked_version: true, + before_date: None, } } } @@ -358,10 +370,13 @@ impl Display for ResolveOptions { fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { let mut opts = vec![]; if self.latest_versions { - opts.push("latest_versions"); + opts.push("latest_versions".to_string()); } if self.use_locked_version { - opts.push("use_locked_version"); + opts.push("use_locked_version".to_string()); + } + if let Some(ts) = &self.before_date { + opts.push(format!("before_date={ts}")); } write!(f, "({})", opts.join(", ")) } diff --git a/src/toolset/tool_version_list.rs b/src/toolset/tool_version_list.rs index 725178e35d..460a059383 100644 --- a/src/toolset/tool_version_list.rs +++ b/src/toolset/tool_version_list.rs @@ -40,6 +40,7 @@ impl ToolVersionList { ResolveOptions { latest_versions: false, use_locked_version: true, + ..opts.clone() } }; match tvr.resolve(config, &request_opts).await { @@ -79,6 +80,7 @@ mod tests { &ResolveOptions { latest_versions: true, use_locked_version: false, + ..Default::default() }, ) .await @@ -101,6 +103,7 @@ mod tests { &ResolveOptions { latest_versions: true, use_locked_version: false, + ..Default::default() }, ) .await; diff --git a/xtasks/fig/src/mise.ts b/xtasks/fig/src/mise.ts index c39ec5aaf6..f4012e08ea 100644 --- a/xtasks/fig/src/mise.ts +++ b/xtasks/fig/src/mise.ts @@ -1385,6 +1385,14 @@ const completionSpec: Fig.Spec = { description: "Show installation output", isRepeatable: true, }, + { + name: "--before", + description: "Only install versions released before this date", + isRepeatable: false, + args: { + name: "before", + }, + }, { name: "--raw", description: @@ -3133,6 +3141,14 @@ const completionSpec: Fig.Spec = { description: "Just print what would be done, don't actually do it", isRepeatable: false, }, + { + name: "--before", + description: "Only upgrade to versions released before this date", + isRepeatable: false, + args: { + name: "before", + }, + }, { name: "--raw", description: @@ -3197,6 +3213,14 @@ const completionSpec: Fig.Spec = { template: "filepaths", }, }, + { + name: "--before", + description: "Only install versions released before this date", + isRepeatable: false, + args: { + name: "before", + }, + }, { name: "--fuzzy", description: "Save fuzzy version to config file", From 9f6eaee4fc58b561cbebe65b3980108c92bd20ce Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 16:55:22 -0600 Subject: [PATCH 04/12] docs: remove non-existent install_before_mode setting references (#7299) --- docs/cli/upgrade.md | 3 +-- man/man1/mise.1 | 3 +-- mise.usage.kdl | 2 +- src/cli/upgrade.rs | 3 +-- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/cli/upgrade.md b/docs/cli/upgrade.md index 13911e568c..35720ce110 100644 --- a/docs/cli/upgrade.md +++ b/docs/cli/upgrade.md @@ -53,9 +53,8 @@ Only upgrade to versions released before this date Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y". This can be useful for reproducibility or security purposes. -In "soft" mode (default), this only affects fuzzy version matches like "20" or "latest". +This only affects fuzzy version matches like "20" or "latest". Explicitly pinned versions like "22.5.0" are not filtered. -Use `install_before_mode = "hard"` setting to filter all versions strictly. ### `--raw` diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 723b60c36d..b3ccf490e6 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -2706,9 +2706,8 @@ Only upgrade to versions released before this date Supports absolute dates like "2024\-06\-01" and relative durations like "90d" or "1y". This can be useful for reproducibility or security purposes. -In "soft" mode (default), this only affects fuzzy version matches like "20" or "latest". +This only affects fuzzy version matches like "20" or "latest". Explicitly pinned versions like "22.5.0" are not filtered. -Use `install_before_mode = "hard"` setting to filter all versions strictly. .TP \fB\-\-raw\fR Directly pipe stdin/stdout/stderr from plugin to user Sets \-\-jobs=1 diff --git a/mise.usage.kdl b/mise.usage.kdl index 6ce9a692f0..57098cada8 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -1075,7 +1075,7 @@ cmd upgrade help="Upgrades outdated tools" { } flag "-n --dry-run" help="Just print what would be done, don't actually do it" flag --before help="Only upgrade to versions released before this date" { - long_help "Only upgrade to versions released before this date\n\nSupports absolute dates like \"2024-06-01\" and relative durations like \"90d\" or \"1y\".\nThis can be useful for reproducibility or security purposes.\n\nIn \"soft\" mode (default), this only affects fuzzy version matches like \"20\" or \"latest\".\nExplicitly pinned versions like \"22.5.0\" are not filtered.\nUse `install_before_mode = \"hard\"` setting to filter all versions strictly." + long_help "Only upgrade to versions released before this date\n\nSupports absolute dates like \"2024-06-01\" and relative durations like \"90d\" or \"1y\".\nThis can be useful for reproducibility or security purposes.\n\nThis only affects fuzzy version matches like \"20\" or \"latest\".\nExplicitly pinned versions like \"22.5.0\" are not filtered." arg } flag --raw help="Directly pipe stdin/stdout/stderr from plugin to user Sets --jobs=1" diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs index 2ae46babd6..87090e53c3 100644 --- a/src/cli/upgrade.rs +++ b/src/cli/upgrade.rs @@ -59,9 +59,8 @@ pub struct Upgrade { /// Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y". /// This can be useful for reproducibility or security purposes. /// - /// In "soft" mode (default), this only affects fuzzy version matches like "20" or "latest". + /// This only affects fuzzy version matches like "20" or "latest". /// Explicitly pinned versions like "22.5.0" are not filtered. - /// Use `install_before_mode = "hard"` setting to filter all versions strictly. #[clap(long, verbatim_doc_comment)] before: Option, From c5c03ef51549462068456036524442e8de4fa6b6 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 17:35:18 -0600 Subject: [PATCH 05/12] [WIP] Add --before flag for date-based version filtering (#7300) --- mise.toml | 37 --------------------------- src/cli/outdated.rs | 6 +++-- src/cli/upgrade.rs | 7 +++++- src/plugins/core/rust.rs | 5 ++-- src/toolset/mod.rs | 48 +++++++++++++++++++----------------- src/toolset/outdated_info.rs | 17 ++++++++++--- src/toolset/tool_version.rs | 13 +++++++++- 7 files changed, 64 insertions(+), 69 deletions(-) delete mode 100644 mise.toml diff --git a/mise.toml b/mise.toml deleted file mode 100644 index e480e13521..0000000000 --- a/mise.toml +++ /dev/null @@ -1,37 +0,0 @@ -#:schema ./schema/mise.json -min_version = "2024.1.1" - -[env] -_.path = ["./target/debug", "./node_modules/.bin"] - -[tools] -"actionlint" = "latest" -age = "latest" -bun = "latest" -cargo-binstall = "latest" -"cargo:cargo-edit" = "latest" -"cargo:cargo-insta" = "latest" -"cargo:cargo-release" = "latest" -"cargo:git-cliff" = "latest" -"cargo:toml-cli" = "latest" -"cargo:usage-cli" = { version = "latest", os = ["linux", "macos"] } -gh = "latest" -hk = "latest" -jq = "latest" -fd = "latest" -"npm:markdownlint-cli" = "latest" -"npm:prettier" = "3" -"npm:ajv-cli" = "latest" -pkl = "latest" -pre-commit = "latest" -#"python" = { version = "latest", virtualenv = "{{env.HOME}}/.cache/venv" } -"ripgrep" = "latest" -"shellcheck" = "latest" -"shfmt" = "latest" -taplo = "latest" -wait-for-gh-rate-limit = "latest" -sops = "latest" -node = "24" - -[task_config] -includes = ["tasks.toml", "xtasks"] diff --git a/src/cli/outdated.rs b/src/cli/outdated.rs index 7f37da9454..2940c11e8d 100644 --- a/src/cli/outdated.rs +++ b/src/cli/outdated.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use crate::cli::args::ToolArg; use crate::config::Config; -use crate::toolset::ToolsetBuilder; +use crate::toolset::{ResolveOptions, ToolsetBuilder}; use crate::toolset::outdated_info::OutdatedInfo; use crate::ui::table; use eyre::Result; @@ -54,7 +54,9 @@ impl Outdated { .collect::>(); ts.versions .retain(|_, tvl| tool_set.is_empty() || tool_set.contains(&tvl.backend)); - let outdated = ts.list_outdated_versions(&config, self.bump).await; + let outdated = ts + .list_outdated_versions(&config, self.bump, &ResolveOptions::default()) + .await; self.display(outdated).await?; Ok(()) } diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs index 87090e53c3..db5be4e0b0 100644 --- a/src/cli/upgrade.rs +++ b/src/cli/upgrade.rs @@ -77,7 +77,12 @@ impl Upgrade { .with_args(&self.tool) .build(&config) .await?; - let mut outdated = ts.list_outdated_versions(&config, self.bump).await; + let opts = ResolveOptions { + use_locked_version: false, + latest_versions: true, + before_date: self.get_before_date()?, + }; + let mut outdated = ts.list_outdated_versions(&config, self.bump, &opts).await; if self.interactive && !outdated.is_empty() { outdated = self.get_interactive_tool_set(&outdated)?; } else if !self.tool.is_empty() { diff --git a/src/plugins/core/rust.rs b/src/plugins/core/rust.rs index 80f4e6d922..befed2ec65 100644 --- a/src/plugins/core/rust.rs +++ b/src/plugins/core/rust.rs @@ -11,7 +11,7 @@ use crate::http::HTTP; use crate::install_context::InstallContext; use crate::toolset::ToolSource::IdiomaticVersionFile; use crate::toolset::outdated_info::OutdatedInfo; -use crate::toolset::{ToolVersion, Toolset}; +use crate::toolset::{ResolveOptions, ToolVersion, Toolset}; use crate::ui::progress_report::SingleReport; use crate::{dirs, env, file, github, plugins}; use async_trait::async_trait; @@ -196,7 +196,8 @@ impl Backend for RustPlugin { ) -> Result> { let v_re = regex!(r#"Update available : (.*) -> (.*)"#); if regex!(r"(\d+)\.(\d+)\.(\d+)").is_match(&tv.version) { - let oi = OutdatedInfo::resolve(config, tv.clone(), bump).await?; + let oi = OutdatedInfo::resolve(config, tv.clone(), bump, &ResolveOptions::default()) + .await?; Ok(oi) } else { let ts = config.get_toolset().await?; diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index fb5d219cbf..6e5f61c655 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -633,37 +633,41 @@ impl Toolset { &self, config: &Arc, bump: bool, + opts: &ResolveOptions, ) -> Vec { let versions = self .list_current_versions() .into_iter() // Respect per-tool os constraints set via options.os .filter(|(_, tv)| tv.request.is_os_supported()) - .map(|(t, tv)| (config.clone(), t, tv, bump)) + .map(|(t, tv)| (config.clone(), t, tv, bump, opts.clone())) .collect::>(); - let outdated = parallel::parallel(versions, |(config, t, tv, bump)| async move { - let mut outdated = vec![]; - match t.outdated_info(&config, &tv, bump).await { - Ok(Some(oi)) => outdated.push(oi), - Ok(None) => {} - Err(e) => { - warn!("Error getting outdated info for {tv}: {e:#}"); + let outdated = parallel::parallel( + versions, + |(config, t, tv, bump, opts)| async move { + let mut outdated = vec![]; + match t.outdated_info(&config, &tv, bump).await { + Ok(Some(oi)) => outdated.push(oi), + Ok(None) => {} + Err(e) => { + warn!("Error getting outdated info for {tv}: {e:#}"); + } } - } - if t.symlink_path(&tv).is_some() { - trace!("skipping symlinked version {tv}"); - // do not consider symlinked versions to be outdated - return Ok(outdated); - } - match OutdatedInfo::resolve(&config, tv.clone(), bump).await { - Ok(Some(oi)) => outdated.push(oi), - Ok(None) => {} - Err(e) => { - warn!("Error creating OutdatedInfo for {tv}: {e:#}"); + if t.symlink_path(&tv).is_some() { + trace!("skipping symlinked version {tv}"); + // do not consider symlinked versions to be outdated + return Ok(outdated); } - } - Ok(outdated) - }) + match OutdatedInfo::resolve(&config, tv.clone(), bump, &opts).await { + Ok(Some(oi)) => outdated.push(oi), + Ok(None) => {} + Err(e) => { + warn!("Error creating OutdatedInfo for {tv}: {e:#}"); + } + } + Ok(outdated) + }, + ) .await .unwrap_or_else(|e| { warn!("Error in parallel outdated version check: {e:#}"); diff --git a/src/toolset/outdated_info.rs b/src/toolset/outdated_info.rs index 0a65397acf..104ed4e3a2 100644 --- a/src/toolset/outdated_info.rs +++ b/src/toolset/outdated_info.rs @@ -1,6 +1,6 @@ use crate::semver::{chunkify_version, split_version_prefix}; use crate::toolset; -use crate::toolset::{ToolRequest, ToolSource, ToolVersion}; +use crate::toolset::{ResolveOptions, ToolRequest, ToolSource, ToolVersion}; use crate::{Result, config::Config}; use serde_derive::Serialize; use std::{ @@ -53,15 +53,24 @@ impl OutdatedInfo { config: &Arc, tv: ToolVersion, bump: bool, + opts: &ResolveOptions, ) -> eyre::Result> { let t = tv.backend()?; // prefix is something like "temurin-" or "corretto-" let (prefix, _) = split_version_prefix(&tv.request.version()); let latest_result = if bump { - t.latest_version(config, Some(prefix.clone()).filter(|s| !s.is_empty())) - .await + // Note: Backend's latest_version_with_opts takes individual parameters, + // not a ResolveOptions struct like ToolVersion's method + t.latest_version_with_opts( + config, + Some(prefix.clone()).filter(|s| !s.is_empty()), + opts.before_date, + ) + .await } else { - tv.latest_version(config).await.map(Option::from) + tv.latest_version_with_opts(config, opts) + .await + .map(Option::from) }; let latest = match latest_result { Ok(Some(latest)) => latest, diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index eec7c925db..cc6c1dbc7c 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -128,10 +128,21 @@ impl ToolVersion { self.request.ba().downloads_path.join(self.tv_pathname()) } pub async fn latest_version(&self, config: &Arc) -> Result { + self.latest_version_with_opts(config, &ResolveOptions::default()) + .await + } + + pub async fn latest_version_with_opts( + &self, + config: &Arc, + base_opts: &ResolveOptions, + ) -> Result { + // Note: We always use latest_versions=true and use_locked_version=false for latest version lookup, + // but we preserve before_date from base_opts to respect date-based filtering let opts = ResolveOptions { latest_versions: true, use_locked_version: false, - ..Default::default() + before_date: base_opts.before_date, }; let tv = self.request.resolve(config, &opts).await?; // map cargo backend specific prefixes to ref From 1d732d48c9f6033a9a8e4cfaa534ce97f9af5776 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:44:02 -0600 Subject: [PATCH 06/12] fix(toolset): pass before_date to resolve_sub for "latest" versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolve_sub function was calling backend.latest_version() without the before_date filter, causing date-based version filtering to be ignored when resolving "latest" versions with sub-version syntax. Now uses latest_version_with_opts() with opts.before_date to properly respect the install_before setting and --before CLI flag. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/toolset/tool_version.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index cc6c1dbc7c..df4a578e1a 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -263,7 +263,10 @@ impl ToolVersion { ) -> Result { let backend = request.backend()?; let v = match v { - "latest" => backend.latest_version(config, None).await?.unwrap(), + "latest" => backend + .latest_version_with_opts(config, None, opts.before_date) + .await? + .unwrap(), _ => config.resolve_alias(&backend, v).await?, }; let v = tool_request::version_sub(&v, sub); From e066785dcec22c5ddae7899d7adb2c4323bd2139 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:49:43 -0600 Subject: [PATCH 07/12] fix: address PR review comments for install_before feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Fix date-only timestamp parsing in filter_by_date - Use parse_into_timestamp() instead of direct Timestamp parsing - This handles date-only formats like "2024-01-09" from Node/Zig backends 2. Add before_date support to outdated_info trait method - Updated Backend::outdated_info signature to include ResolveOptions - Updated Rust plugin's outdated_info to use opts.before_date - Updated call site in list_outdated_versions to pass opts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/backend/mod.rs | 9 ++++++--- src/plugins/core/rust.rs | 3 ++- src/toolset/mod.rs | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 80f6931cfb..f3ef97203c 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -22,7 +22,7 @@ use crate::plugins::{PluginType, VERSION_REGEX}; use crate::registry::{REGISTRY, full_to_url, normalize_remote, tool_enabled}; use crate::runtime_symlinks::is_runtime_symlink; use crate::toolset::outdated_info::OutdatedInfo; -use crate::toolset::{ToolRequest, ToolVersion, Toolset, install_state, is_outdated_version}; +use crate::toolset::{ResolveOptions, ToolRequest, ToolVersion, Toolset, install_state, is_outdated_version}; use crate::ui::progress_report::SingleReport; use crate::{ cache::{CacheManager, CacheManagerBuilder}, @@ -93,13 +93,15 @@ impl VersionInfo { /// Filter versions to only include those released before the given timestamp. /// Versions without a created_at timestamp are included by default. pub fn filter_by_date(versions: Vec, before: Timestamp) -> Vec { + use crate::duration::parse_into_timestamp; versions .into_iter() .filter(|v| { match &v.created_at { Some(ts) => { - // Parse the timestamp and compare - match ts.parse::() { + // Parse the timestamp using parse_into_timestamp which handles + // RFC3339, date-only (YYYY-MM-DD), and other formats + match parse_into_timestamp(ts) { Ok(created) => created < before, Err(_) => { // If we can't parse the timestamp, include the version @@ -1154,6 +1156,7 @@ pub trait Backend: Debug + Send + Sync { _config: &Arc, _tv: &ToolVersion, _bump: bool, + _opts: &ResolveOptions, ) -> Result> { Ok(None) } diff --git a/src/plugins/core/rust.rs b/src/plugins/core/rust.rs index befed2ec65..7a168efbc3 100644 --- a/src/plugins/core/rust.rs +++ b/src/plugins/core/rust.rs @@ -193,10 +193,11 @@ impl Backend for RustPlugin { config: &Arc, tv: &ToolVersion, bump: bool, + opts: &ResolveOptions, ) -> Result> { let v_re = regex!(r#"Update available : (.*) -> (.*)"#); if regex!(r"(\d+)\.(\d+)\.(\d+)").is_match(&tv.version) { - let oi = OutdatedInfo::resolve(config, tv.clone(), bump, &ResolveOptions::default()) + let oi = OutdatedInfo::resolve(config, tv.clone(), bump, opts) .await?; Ok(oi) } else { diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index 6e5f61c655..92f7585de6 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -646,7 +646,7 @@ impl Toolset { versions, |(config, t, tv, bump, opts)| async move { let mut outdated = vec![]; - match t.outdated_info(&config, &tv, bump).await { + match t.outdated_info(&config, &tv, bump, &opts).await { Ok(Some(oi)) => outdated.push(oi), Ok(None) => {} Err(e) => { From 5698c267cb610d759496dfb9bd7a91b5b1cd4831 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:58:48 -0600 Subject: [PATCH 08/12] fix: handle empty version list in resolve_sub and restore mise.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Fix potential panic in resolve_sub when date filter excludes all versions - Replace .unwrap() with .ok_or_else() for proper error handling - Provide descriptive error message mentioning date filter when applicable 2. Restore mise.toml that was accidentally removed in earlier commit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- mise.toml | 37 +++++++++++++++++++++++++++++++++++++ src/toolset/tool_version.rs | 9 ++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 mise.toml diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000000..e480e13521 --- /dev/null +++ b/mise.toml @@ -0,0 +1,37 @@ +#:schema ./schema/mise.json +min_version = "2024.1.1" + +[env] +_.path = ["./target/debug", "./node_modules/.bin"] + +[tools] +"actionlint" = "latest" +age = "latest" +bun = "latest" +cargo-binstall = "latest" +"cargo:cargo-edit" = "latest" +"cargo:cargo-insta" = "latest" +"cargo:cargo-release" = "latest" +"cargo:git-cliff" = "latest" +"cargo:toml-cli" = "latest" +"cargo:usage-cli" = { version = "latest", os = ["linux", "macos"] } +gh = "latest" +hk = "latest" +jq = "latest" +fd = "latest" +"npm:markdownlint-cli" = "latest" +"npm:prettier" = "3" +"npm:ajv-cli" = "latest" +pkl = "latest" +pre-commit = "latest" +#"python" = { version = "latest", virtualenv = "{{env.HOME}}/.cache/venv" } +"ripgrep" = "latest" +"shellcheck" = "latest" +"shfmt" = "latest" +taplo = "latest" +wait-for-gh-rate-limit = "latest" +sops = "latest" +node = "24" + +[task_config] +includes = ["tasks.toml", "xtasks"] diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index df4a578e1a..3a0f9015be 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -266,7 +266,14 @@ impl ToolVersion { "latest" => backend .latest_version_with_opts(config, None, opts.before_date) .await? - .unwrap(), + .ok_or_else(|| { + let msg = if opts.before_date.is_some() { + format!("no versions found for {} matching date filter", backend.id()) + } else { + format!("no versions found for {}", backend.id()) + }; + eyre::eyre!(msg) + })?, _ => config.resolve_alias(&backend, v).await?, }; let v = tool_request::version_sub(&v, sub); From 4e150305ea488549d4a7017e4f6417095ef049b0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 02:06:12 +0000 Subject: [PATCH 09/12] [autofix.ci] apply automated fixes --- src/backend/mod.rs | 4 +++- src/cli/outdated.rs | 2 +- src/plugins/core/rust.rs | 3 +-- src/toolset/mod.rs | 45 +++++++++++++++++-------------------- src/toolset/tool_version.rs | 5 ++++- 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/backend/mod.rs b/src/backend/mod.rs index f3ef97203c..3cd2a3ef79 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -22,7 +22,9 @@ use crate::plugins::{PluginType, VERSION_REGEX}; use crate::registry::{REGISTRY, full_to_url, normalize_remote, tool_enabled}; use crate::runtime_symlinks::is_runtime_symlink; use crate::toolset::outdated_info::OutdatedInfo; -use crate::toolset::{ResolveOptions, ToolRequest, ToolVersion, Toolset, install_state, is_outdated_version}; +use crate::toolset::{ + ResolveOptions, ToolRequest, ToolVersion, Toolset, install_state, is_outdated_version, +}; use crate::ui::progress_report::SingleReport; use crate::{ cache::{CacheManager, CacheManagerBuilder}, diff --git a/src/cli/outdated.rs b/src/cli/outdated.rs index 2940c11e8d..d04825f99a 100644 --- a/src/cli/outdated.rs +++ b/src/cli/outdated.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use crate::cli::args::ToolArg; use crate::config::Config; -use crate::toolset::{ResolveOptions, ToolsetBuilder}; use crate::toolset::outdated_info::OutdatedInfo; +use crate::toolset::{ResolveOptions, ToolsetBuilder}; use crate::ui::table; use eyre::Result; use indexmap::IndexMap; diff --git a/src/plugins/core/rust.rs b/src/plugins/core/rust.rs index 7a168efbc3..fe570c121e 100644 --- a/src/plugins/core/rust.rs +++ b/src/plugins/core/rust.rs @@ -197,8 +197,7 @@ impl Backend for RustPlugin { ) -> Result> { let v_re = regex!(r#"Update available : (.*) -> (.*)"#); if regex!(r"(\d+)\.(\d+)\.(\d+)").is_match(&tv.version) { - let oi = OutdatedInfo::resolve(config, tv.clone(), bump, opts) - .await?; + let oi = OutdatedInfo::resolve(config, tv.clone(), bump, opts).await?; Ok(oi) } else { let ts = config.get_toolset().await?; diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index 92f7585de6..8ae9cae03d 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -642,32 +642,29 @@ impl Toolset { .filter(|(_, tv)| tv.request.is_os_supported()) .map(|(t, tv)| (config.clone(), t, tv, bump, opts.clone())) .collect::>(); - let outdated = parallel::parallel( - versions, - |(config, t, tv, bump, opts)| async move { - let mut outdated = vec![]; - match t.outdated_info(&config, &tv, bump, &opts).await { - Ok(Some(oi)) => outdated.push(oi), - Ok(None) => {} - Err(e) => { - warn!("Error getting outdated info for {tv}: {e:#}"); - } - } - if t.symlink_path(&tv).is_some() { - trace!("skipping symlinked version {tv}"); - // do not consider symlinked versions to be outdated - return Ok(outdated); + let outdated = parallel::parallel(versions, |(config, t, tv, bump, opts)| async move { + let mut outdated = vec![]; + match t.outdated_info(&config, &tv, bump, &opts).await { + Ok(Some(oi)) => outdated.push(oi), + Ok(None) => {} + Err(e) => { + warn!("Error getting outdated info for {tv}: {e:#}"); } - match OutdatedInfo::resolve(&config, tv.clone(), bump, &opts).await { - Ok(Some(oi)) => outdated.push(oi), - Ok(None) => {} - Err(e) => { - warn!("Error creating OutdatedInfo for {tv}: {e:#}"); - } + } + if t.symlink_path(&tv).is_some() { + trace!("skipping symlinked version {tv}"); + // do not consider symlinked versions to be outdated + return Ok(outdated); + } + match OutdatedInfo::resolve(&config, tv.clone(), bump, &opts).await { + Ok(Some(oi)) => outdated.push(oi), + Ok(None) => {} + Err(e) => { + warn!("Error creating OutdatedInfo for {tv}: {e:#}"); } - Ok(outdated) - }, - ) + } + Ok(outdated) + }) .await .unwrap_or_else(|e| { warn!("Error in parallel outdated version check: {e:#}"); diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index 3a0f9015be..e6757b4d2d 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -268,7 +268,10 @@ impl ToolVersion { .await? .ok_or_else(|| { let msg = if opts.before_date.is_some() { - format!("no versions found for {} matching date filter", backend.id()) + format!( + "no versions found for {} matching date filter", + backend.id() + ) } else { format!("no versions found for {}", backend.id()) }; From 1e177cf934ec3caba4558df5f77d254720ff0932 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:09:12 -0600 Subject: [PATCH 10/12] fix: validate negative durations and preserve exact version matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Validate negative duration inputs in parse_into_timestamp - Reject negative durations like "-90d" which would create future timestamps - Matches existing validation in parse_duration function 2. Preserve exact/pinned version matching regardless of date filter - Only apply date filter for fuzzy/prefix version resolution - Explicit versions like "22.5.0" are not filtered by --before flag - Optimized to only fetch unfiltered list when needed (date filter active and exact version not in filtered results) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/duration.rs | 5 +++++ src/toolset/tool_version.rs | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/duration.rs b/src/duration.rs index 0a96b51fd3..f932ba684e 100644 --- a/src/duration.rs +++ b/src/duration.rs @@ -46,6 +46,11 @@ pub fn parse_into_timestamp(s: &str) -> Result { // Try parsing as duration and subtract from now if let Ok(span) = s.parse::() { + // Validate that duration is positive (negative would result in future date) + let duration = span.to_duration(date(2025, 1, 1))?; + if duration.is_negative() { + bail!("duration must not be negative: {}", s); + } let now = Timestamp::now(); let past = now.checked_sub(span)?; return Ok(past); diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index e6757b4d2d..ddc72376fb 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -244,12 +244,22 @@ impl ToolVersion { return build(v.clone()); } } + // First try with date filter (common case) let matches = backend .list_versions_matching_with_opts(config, &v, opts.before_date) .await?; if matches.contains(&v) { return build(v); } + // If date filter is active and exact version not found, check without filter. + // Explicit pinned versions like "22.5.0" should not be filtered by date. + if opts.before_date.is_some() { + let all_versions = backend.list_versions_matching(config, &v).await?; + if all_versions.contains(&v) { + // Exact match exists but was filtered by date - use it anyway + return build(v); + } + } Self::resolve_prefix(config, request, &v, opts).await } From 7f2ea63272dd66da276d6026c0249c7daa1202b5 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:18:03 -0600 Subject: [PATCH 11/12] fix(upgrade): compute before_date once to ensure consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using relative durations like "90d", each call to get_before_date() computes a new timestamp using Timestamp::now(). This caused inconsistency between the outdated version check and the actual installation. Now computes the timestamp once at the start and passes it through to both list_outdated_versions and InstallOptions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/cli/upgrade.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs index db5be4e0b0..872171a119 100644 --- a/src/cli/upgrade.rs +++ b/src/cli/upgrade.rs @@ -77,10 +77,12 @@ impl Upgrade { .with_args(&self.tool) .build(&config) .await?; + // Compute before_date once to ensure consistency when using relative durations + let before_date = self.get_before_date()?; let opts = ResolveOptions { use_locked_version: false, latest_versions: true, - before_date: self.get_before_date()?, + before_date, }; let mut outdated = ts.list_outdated_versions(&config, self.bump, &opts).await; if self.interactive && !outdated.is_empty() { @@ -102,13 +104,13 @@ impl Upgrade { ); } } else { - self.upgrade(&mut config, outdated).await?; + self.upgrade(&mut config, outdated, before_date).await?; } Ok(()) } - async fn upgrade(&self, config: &mut Arc, outdated: Vec) -> Result<()> { + async fn upgrade(&self, config: &mut Arc, outdated: Vec, before_date: Option) -> Result<()> { let mpr = MultiProgressReport::get(); let mut ts = ToolsetBuilder::new() .with_args(&self.tool) @@ -171,7 +173,7 @@ impl Upgrade { resolve_options: ResolveOptions { use_locked_version: false, latest_versions: true, - before_date: self.get_before_date()?, + before_date, }, ..Default::default() }; From c1f302087c9d5f0f74499d5ae05007a89ef42f2a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 02:21:14 +0000 Subject: [PATCH 12/12] [autofix.ci] apply automated fixes --- src/cli/upgrade.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs index 872171a119..acad829727 100644 --- a/src/cli/upgrade.rs +++ b/src/cli/upgrade.rs @@ -110,7 +110,12 @@ impl Upgrade { Ok(()) } - async fn upgrade(&self, config: &mut Arc, outdated: Vec, before_date: Option) -> Result<()> { + async fn upgrade( + &self, + config: &mut Arc, + outdated: Vec, + before_date: Option, + ) -> Result<()> { let mpr = MultiProgressReport::get(); let mut ts = ToolsetBuilder::new() .with_args(&self.tool)