diff --git a/Cargo.toml b/Cargo.toml index 10468809ac7..3c89b1d4271 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -301,6 +301,7 @@ eyre = "0.6" heck = "0.5" indexmap = "2" phf_codegen = "0.14" +semver = "1" serde = "1" serde_yaml = "0.9" toml = "1" diff --git a/build.rs b/build.rs index ae818f83c47..7cd61dc5d3f 100644 --- a/build.rs +++ b/build.rs @@ -199,6 +199,7 @@ fn codegen_registry(aqua_packages: &[RegistryPackageRow]) { r##"RegistryBackend{{ full: r#"{backend}"#, platforms: &[], + min_version: None, options: &[], }}"## )); @@ -215,11 +216,27 @@ fn codegen_registry(aqua_packages: &[RegistryPackageRow]) { .collect::>() }) .unwrap_or_default(); + let min_version = backend + .get("min_version") + .map(|value| { + let value = value + .as_str() + .expect("backend min_version must be a string"); + assert_eq!( + version_order, "VersionOrder::Semver", + "[{short}] backend min_version requires version_order = semver" + ); + semver::Version::parse(value) + .expect("backend min_version must be a semantic version"); + format!("Some({})", raw_string_literal(value)) + }) + .unwrap_or_else(|| "None".to_string()); let backend_options = parse_options(backend.get("options")); backends.push(format!( r##"RegistryBackend{{ full: r#"{full}"#, platforms: &[{platforms}], + min_version: {min_version}, options: &[{options}], }}"##, platforms = platforms diff --git a/docs/cli/test-tool.md b/docs/cli/test-tool.md index 76f16415b6c..eee7b4cf85a 100644 --- a/docs/cli/test-tool.md +++ b/docs/cli/test-tool.md @@ -6,6 +6,9 @@ Test that a tool installs and runs +Includes newly published releases by disabling the global minimum release age +for this command. + ## Arguments - **`[TOOLS]…`** — Tool(s) to test diff --git a/docs/contributing.md b/docs/contributing.md index 26e520e6546..cdb8b3ca041 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -780,6 +780,33 @@ registry's file metadata. Omit `bins` when that inferred list is correct. Set it explicitly when the shorthand needs a different backend-independent command set, such as commands bundled by a fallback backend that Aqua does not describe. +#### Minimum backend versions + +When a backend supports only newer releases, set `min_version` on that backend. +For example, hk publishes Packslip manifests starting at 1.58.1: + +```toml +version_order = "semver" +backends = [ + { full = "packslip:github.com/jdx/hk", min_version = "1.58.1" }, + "aqua:jdx/hk", +] +bins = ["hk"] +``` + +The minimum is inclusive and must be a complete semantic version. It is only +supported for registry tools with `version_order = "semver"`; do not add it to +tools with opaque or source-ordered versions. `mise use hk@1.57` and +`mise use hk@1.58.0` select Aqua, while `mise use hk@1.58.1` selects Packslip. +A prefix overlapping the boundary, such as `1.58`, keeps the preferred backend. +`latest`, channels, and unresolved aliases retain normal backend priority; +aliases are checked again after resolution. + +Selection still respects platform support and disabled backends. Explicit +backend identifiers, backend overrides, and a matching lockfile's recorded +backend remain authoritative. A failed download or signature verification does +not trigger fallback. A backend without `min_version` has no lower bound. + #### Idiomatic version files Registry tools can opt into [idiomatic version files](/configuration.html#idiomatic-version-files) diff --git a/docs/dev-tools/backends/packslip.md b/docs/dev-tools/backends/packslip.md index 492a0280568..2a447da89d5 100644 --- a/docs/dev-tools/backends/packslip.md +++ b/docs/dev-tools/backends/packslip.md @@ -7,7 +7,7 @@ shell completions and agent skills. Packslip is the preferred [Tier 1 backend](/registry.html#backends) for tools whose publishers provide these manifests. For other tools, use -[aqua](/dev-tools/backends/aqua.html), [github](/dev-tools/backends/github.html), +[aqua](/dev-tools/backends/aqua.html), [GitHub](/dev-tools/backends/github.html), or another supported backend. You do not need to install the Packslip CLI. ## Quick start {#usage} @@ -92,17 +92,18 @@ for signed lists, withdrawals, and fallback behavior. ## Completions and skills {#completions} -hk publishes a [usage](https://usage.jdx.dev) CLI specification that mise can use -for shell completions. Install `usage`, then set up completions for your shell: +With [mise activated](/getting-started.html#activate-mise), installing hk also +makes its completions available: ```sh -mise use -g usage -mise completion zsh --tool hk --install +mise use hk ``` -Follow any shell setup instructions printed by the command. The installed -completion file follows the hk version active in each project. Bash, fish, and -PowerShell are also supported. +Type `hk` and press Tab. mise loads the completion script declared by hk's +release and follows the hk version active in each project. No separate +completion installation or `usage` dependency is needed. Bash, zsh, fish, and +PowerShell are supported. See [Packslip completions](/dev-tools/packslip-resources.html#completions) +for details and manual setup without shell activation. diff --git a/docs/dev-tools/packslip-resources.md b/docs/dev-tools/packslip-resources.md index 0dc1978a906..d1e9e6ac3c9 100644 --- a/docs/dev-tools/packslip-resources.md +++ b/docs/dev-tools/packslip-resources.md @@ -13,24 +13,33 @@ mise supports tool completions for zsh, bash, fish, and PowerShell. The installe completion file follows the tool version active in each project, so you do not need to reinstall completions after changing versions. -### Set up hk completions +### Use completions -hk publishes a [usage](https://usage.jdx.dev) CLI specification. Install hk -through Packslip and install `usage` to generate and run its completions: +With [mise activated](/getting-started.html#activate-mise), completions become +available when an installed tool is active in your project. For example: ```sh -mise use packslip:github.com/jdx/hk -mise use -g usage +mise use hk ``` -Install the completion file for your shell: +Type `hk` and press Tab. hk publishes native completion scripts for bash, zsh, +fish, and PowerShell, so no extra setup command or `usage` installation is +needed. mise registers a loader in the shell; it reads the publisher's script +only when you complete a command. Switching projects or tool versions selects +the matching completion, and leaving the project removes its registration. -| Shell | Command | -| ---------- | ------------------------------------------------ | -| zsh | `mise completion zsh --tool hk --install` | -| bash | `mise completion bash --tool hk --install` | -| fish | `mise completion fish --tool hk --install` | -| PowerShell | `mise completion powershell --tool hk --install` | +### Manual setup without shell activation + +For tools that declare completions in their Packslip manifest, mise can install +a completion file that loads those resources. Replace `TOOL` below with the +executable's name: + +| Shell | Command | +| ---------- | -------------------------------------------------- | +| zsh | `mise completion zsh --tool TOOL --install` | +| bash | `mise completion bash --tool TOOL --install` | +| fish | `mise completion fish --tool TOOL --install` | +| PowerShell | `mise completion powershell --tool TOOL --install` | Follow any one-time setup instructions printed by the command, then load the completion file or start a new shell. mise writes the completion file but does @@ -40,11 +49,11 @@ create unless you pass `--force`. To print a completion script without installing it, omit `--install`: ```sh -mise completion zsh --tool hk +mise completion zsh --tool TOOL ``` -For another tool, replace `hk` with its executable name. `--install` takes the -command name, not a backend identifier such as `packslip:github.com/jdx/hk`. +`--install` takes the command name, not a backend identifier such as +`packslip:github.com/jdx/hk`. If a release contains several commands, choose the one you want to complete. Without `--tool`, `mise completion` generates completions for mise itself. @@ -52,8 +61,8 @@ Without `--tool`, `mise completion` generates completions for mise itself. A publisher can provide a completion file, a static usage CLI specification, or a command that generates either one. mise prefers static sources. A -usage-derived completion requires `usage` both when generating the script and -when completing commands in the shell. +usage-derived completion uses the engine embedded in mise; you do not need to +install `usage` separately. If a completion needs a publisher's generator command, mise runs it on demand and caches successful output for the installed version, executable, and shell. @@ -210,7 +219,7 @@ next completion uses that directory's active version. | No completion declared | Confirm the release supports your shell and executable. If it does not, the publisher must add a completion or CLI spec. | | `--install` rejects a tool identifier | Pass the executable name, such as `hk`, rather than `packslip:github.com/jdx/hk`. | | A completion file already exists | Inspect the existing file before choosing to replace it with `--force`. | -| Script prints but tab completion does not work | Follow the shell setup printed by `--install`; check that the completion file is loaded and `usage` is on PATH if required. | +| Script prints but tab completion does not work | Check that mise is activated and the tool is active in this project. With manual setup, follow the instructions printed by `--install`. Mise handles usage-derived completions itself. | | Completion generation fails | Check that the publisher's command produces nonempty output within the time and size limits. Report a failing generator to the publisher. | | No skills listed | Check `mise skills ls`, the active version, and whether its manifest declares skills. Check `skills.fetch`; an exec-only skill also needs `packslip.exec` enabled during installation. | | A skill link is skipped | Inspect the conflicting path; mise preserves user-owned files and directories. | diff --git a/docs/registry.md b/docs/registry.md index a75f6209835..7de3a736ff7 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -97,6 +97,19 @@ This will disable the [asdf](./dev-tools/backends/asdf.html) backend. See [Alias You can also specify the full name for a tool using `mise use aqua:1password/cli` if you want to use a specific backend. +### Version-specific backends + +A registry backend can declare the first tool version it supports. mise skips +it for older version requests and uses the next eligible backend. For example, +`mise use hk@1.58.0` uses Aqua, while `mise use hk@1.58.1` uses Packslip. +Older prefixes such as `hk@1.57` also use Aqua; `latest` and prefixes spanning +the boundary keep the normal backend priority. + +These boundaries apply to registry shorthands. You can still choose a backend +explicitly, and matching lockfile entries preserve their recorded backend. +See [minimum backend versions](/contributing.html#minimum-backend-versions) +for the registry format. + ### Environment Variable Overrides You can override the backend for any tool using environment variables with the pattern `MISE_BACKENDS_`. This takes the highest priority and overrides any registry or alias configuration: diff --git a/e2e/backend/test_registry_min_version b/e2e/backend/test_registry_min_version new file mode 100644 index 00000000000..e4dc453f0d8 --- /dev/null +++ b/e2e/backend/test_registry_min_version @@ -0,0 +1,49 @@ +#!/usr/bin/env bash + +# hk started publishing Packslip manifests in 1.58.1. Earlier releases must +# continue to install through the registry shorthand, using Aqua. +export MISE_EXPERIMENTAL=0 +export MISE_MINIMUM_RELEASE_AGE=0 +export MISE_REGISTRY_FLOATING=0 + +# Registry CI must exercise the newly published preferred backend immediately. +MISE_MINIMUM_RELEASE_AGE=90d mise test-tool hk + +assert_contains "mise ls-remote hk@1.57" "1.57.0" +assert_contains "mise ls-remote hk 1.57" "1.57.0" +assert_contains "mise ls-remote hk@sub-0.1:1.58.1" "1.57.0" +assert_contains "mise ls-remote hk@prefix:1.57" "1.57.0" +assert "mise latest hk@1.57" "1.57.0" +assert "mise latest hk 1.57" "1.57.0" +assert_not_contains "MISE_BACKENDS_HK=packslip:github.com/jdx/hk mise ls-remote hk@1.57" "1.57" +assert_fail_contains "MISE_DISABLE_BACKENDS=aqua mise latest hk@1.57" "none of its backends" + +cat >mise.toml <<'TOML' +[tools] +hk = ["1.57.0", "1.58.1"] + +[settings] +lockfile = true +TOML +mise install +assert_contains "mise exec hk@1.57.0 -- hk --version" "hk 1.57.0" +assert_contains "mise exec hk@1.58.1 -- hk --version" "hk 1.58.1" +assert_contains "cat mise.lock" 'backend = "aqua:jdx/hk"' +assert_contains "cat mise.lock" 'backend = "packslip:github.com/jdx/hk"' +assert_contains "MISE_OFFLINE=1 mise --locked exec hk@1.57.0 -- hk --version" "hk 1.57.0" +assert_contains "MISE_OFFLINE=1 mise --locked exec hk@1.58.1 -- hk --version" "hk 1.58.1" +assert_fail_contains "MISE_DISABLE_BACKENDS=aqua mise latest hk@1.57" "none of its backends" + +# Aliases can cross the boundary after their version string is resolved. +cat >>mise.toml <<'TOML' +[tool_alias.hk.versions] +old = "1.57.0" +old_prefix = "prefix:1.57" +TOML +assert_contains "mise exec hk@old -- hk --version" "hk 1.57.0" +assert "mise latest hk@old" "1.57.0" +assert_contains "mise ls-remote hk@old" "1.57.0" +assert_contains "mise ls-remote hk@old_prefix" "1.57.0" + +# Explicit backends remain explicit; no fallback after a verification error. +assert_fail_contains "mise install --force packslip:github.com/jdx/hk@1.57.0" "no release" diff --git a/e2e/env/test_env_module_tools_no_spurious_warning b/e2e/env/test_env_module_tools_no_spurious_warning index 07ca8a361b8..e5b0037c5c8 100644 --- a/e2e/env/test_env_module_tools_no_spurious_warning +++ b/e2e/env/test_env_module_tools_no_spurious_warning @@ -11,12 +11,15 @@ # The fix changes dependency_env to use full_env_without_tools so it # skips tools=true module resolution entirely. +# Pin this fixture so release timing and backend migrations do not affect +# the dependency_env regression being tested. + # 1. Install fnox tool and fnox-env plugin mise plugins install fnox-env https://github.com/jdx/mise-env-fnox cat >"$MISE_CONFIG_DIR/config.toml" <<'EOF' [tools] -fnox = "latest" +fnox = "1.35.0" [env] _.fnox-env = { tools = true } @@ -71,7 +74,7 @@ chmod +x "$INSTALL_DIR/bin/dummy-vfox-trigger" # 4. Add the vfox tool to config alongside fnox + fnox-env cat >"$MISE_CONFIG_DIR/config.toml" <] ... .PP diff --git a/mise.usage.kdl b/mise.usage.kdl index c099085aae3..11b5d086ab4 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -4089,6 +4089,12 @@ If not specified, validates all tasks } } cmd test-tool help="Test that a tool installs and runs" { + long_help #""" +Test that a tool installs and runs + +Includes newly published releases by disabling the global minimum release age +for this command. +"""# after_long_help "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n $ \u{1b}[1mmise test-tool ripgrep\u{1b}[22m\n" flag "-a --all" help="Test every tool specified in registry/" { conflicts TOOLS "--all-config" diff --git a/registry/aube.toml b/registry/aube.toml index 944f29ab873..3b0ba432b8b 100644 --- a/registry/aube.toml +++ b/registry/aube.toml @@ -1,4 +1,9 @@ -backends = ["aqua:jdx/aube", "github:jdx/aube", "cargo:aube"] +backends = [ + { full = "packslip:github.com/aubepkg/aube", min_version = "2.2.11" }, + "aqua:jdx/aube", + "github:jdx/aube", + "cargo:aube", +] bins = ["aube", "aubr", "aubx"] description = "A fast Node.js package manager" test = { cmd = "aube --version", expected = "{{version}}" } diff --git a/registry/communique.toml b/registry/communique.toml index fb84c2600ac..df564c5588b 100644 --- a/registry/communique.toml +++ b/registry/communique.toml @@ -1,4 +1,7 @@ -backends = ["github:jdx/communique"] +backends = [ + { full = "packslip:github.com/jdx/communique", min_version = "1.3.5" }, + "github:jdx/communique", +] bins = ["communique"] description = "Editorialized release notes powered by AI" test = { cmd = "communique --version", expected = "communique {{version}}" } diff --git a/registry/fnox.toml b/registry/fnox.toml index 7445da40799..dcd8733a359 100644 --- a/registry/fnox.toml +++ b/registry/fnox.toml @@ -1,4 +1,7 @@ -backends = ["github:jdx/fnox"] +backends = [ + { full = "packslip:github.com/jdx/fnox", min_version = "1.35.1" }, + "github:jdx/fnox", +] bins = ["fnox"] description = "Fort Knox for your secrets" test = { cmd = "fnox --version", expected = "fnox {{version}}" } diff --git a/registry/hk.toml b/registry/hk.toml index 9ac1999043c..eb71d014a2d 100644 --- a/registry/hk.toml +++ b/registry/hk.toml @@ -1,4 +1,8 @@ -backends = ["aqua:jdx/hk"] +backends = [ + { full = "packslip:github.com/jdx/hk", min_version = "1.58.1" }, + "aqua:jdx/hk", +] +bins = ["hk"] description = "git hook and pre-commit lint manager" test = { cmd = "hk --version", expected = "hk {{version}}" } version_order = "semver" diff --git a/registry/mr-boxington.toml b/registry/mr-boxington.toml index 1d5e446b047..3cef13a7bed 100644 --- a/registry/mr-boxington.toml +++ b/registry/mr-boxington.toml @@ -1,4 +1,7 @@ -backends = ["github:jdx/mr-boxington"] +backends = [ + { full = "packslip:github.com/jdx/mr-boxington", min_version = "1.8.3" }, + "github:jdx/mr-boxington", +] bins = ["mbx"] description = "A Cargo wrapper with a shared, self-pruning compilation cache" test = { cmd = "mbx --version", expected = "mbx {{version}}" } diff --git a/registry/pitchfork.toml b/registry/pitchfork.toml index 18611ca4814..6569d1742db 100644 --- a/registry/pitchfork.toml +++ b/registry/pitchfork.toml @@ -1,4 +1,8 @@ -backends = ["aqua:jdx/pitchfork"] +backends = [ + { full = "packslip:github.com/jdx/pitchfork", min_version = "2.24.2" }, + "aqua:jdx/pitchfork", +] +bins = ["pitchfork"] description = "Daemons with DX" test = { cmd = "pitchfork --version", expected = "pitchfork {{version}}" } version_order = "semver" diff --git a/registry/usage.toml b/registry/usage.toml index d24372d3e9b..abdc34d6ed8 100644 --- a/registry/usage.toml +++ b/registry/usage.toml @@ -1,4 +1,5 @@ backends = [ + { full = "packslip:github.com/jdx/usage", min_version = "6.7.1" }, "aqua:jdx/usage", { full = "asdf:mise-plugins/mise-usage", platforms = [ "linux", @@ -6,6 +7,7 @@ backends = [ ] }, "cargo:usage-cli", ] +bins = ["usage"] description = "A specification for CLIs" test = { cmd = "usage --version", expected = "usage {{version}}" } version_order = "semver" diff --git a/schema/mise-registry-tool.json b/schema/mise-registry-tool.json index 508e36fddc1..46423423948 100644 --- a/schema/mise-registry-tool.json +++ b/schema/mise-registry-tool.json @@ -123,6 +123,11 @@ "additionalProperties": { "$ref": "#/$defs/optionValue" } + }, + "min_version": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$", + "description": "Inclusive minimum tool version supported by this backend. Requires version_order = semver; older versions use the next eligible backend." } }, "required": ["full"], @@ -235,5 +240,29 @@ } }, "required": ["backends", "version_order"], - "unevaluatedProperties": false + "unevaluatedProperties": false, + "allOf": [ + { + "if": { + "properties": { + "backends": { + "contains": { + "type": "object", + "required": ["min_version"] + }, + "type": "array" + } + }, + "required": ["backends"] + }, + "then": { + "properties": { + "version_order": { + "const": "semver" + } + }, + "required": ["version_order"] + } + } + ] } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 09497685d34..fa680fad12a 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -551,6 +551,9 @@ pub(crate) fn alias_backends() -> BackendList { pub(crate) fn get(ba: &BackendArg) -> Option { // Inline opts are command-scoped, so a short-name cache hit must not drop // the caller's BackendArg options. + if ba.has_registry_version() { + return arg_to_backend(ba.clone()); + } if (ba.explicit_opts().is_some() || ba.has_explicit_backend()) && let Some(backend) = arg_to_backend(ba.clone()) { @@ -4147,6 +4150,7 @@ pub(crate) trait Backend: Debug + Send + Sync { let mut cm = CacheManagerBuilder::new( self.ba().cache_path.join("remote_versions.msgpack.z"), ) + .with_cache_key(self.ba().full()) .with_fresh_duration(Settings::get().fetch_remote_versions_cache()); if let Some(context) = context { cm = cm.with_cache_key(context.to_string()); @@ -5016,10 +5020,39 @@ mod latest_version_tests { ); } - /// The regression this fixes: two option values that produce different listings shared one - /// cache entry, so whichever ran first answered for both. `short` is the same for the two - /// (inline opts are stripped from it), so they do share a cache *directory* — only the key - /// keeps them apart. + #[tokio::test] + async fn registry_min_version_partitions_persisted_backend_lists() { + let config = Config::get().await.unwrap(); + let make_backend = |full: &str, version: &str| { + let mut backend = LatestBackend::new("test-registry-min-version-cache") + .with_remote_versions(vec![VersionInfo { + version: version.to_string(), + ..Default::default() + }]); + backend.ba = Arc::new(BackendArg::new( + "test-registry-min-version-cache".to_string(), + Some(full.to_string()), + )); + backend + }; + let older = make_backend("aqua:example/tool", "1.0.0"); + let newer = make_backend("packslip:github.com/example/tool", "2.0.0"); + assert_eq!(older.ba().cache_path, newer.ba().cache_path); + let _ = fs::remove_dir_all(&older.ba().cache_path); + assert_eq!( + older.list_remote_versions(&config).await.unwrap(), + ["1.0.0"] + ); + // Separate in-memory entries must also have separate files on disk. + assert_eq!( + newer.list_remote_versions(&config).await.unwrap(), + ["2.0.0"] + ); + assert_eq!(newer.list_calls(), 1); + } + + /// Two option values that produce different listings must not share a cache + /// entry, even though inline options are stripped from the cache directory. #[tokio::test] async fn test_remote_versions_cache_is_partitioned_by_listing_options() { let config = Config::get().await.unwrap(); diff --git a/src/cli/args/backend_arg.rs b/src/cli/args/backend_arg.rs index 7aaa973e32e..6fc5bb0ca61 100644 --- a/src/cli/args/backend_arg.rs +++ b/src/cli/args/backend_arg.rs @@ -54,6 +54,7 @@ pub(crate) struct BackendArg { pub opts: Option, opts_source: Option, resolution: BackendResolution, + registry_version: Option, // TODO: make this not a hash key anymore to use this // backend: OnceCell, } @@ -296,6 +297,7 @@ impl BackendArg { opts, opts_source, resolution, + registry_version: None, // backend: Default::default(), } } @@ -354,13 +356,19 @@ impl BackendArg { } else { // Check if the tool is in the registry but has no available backends if let Some(rt) = REGISTRY.get(self.registry_short().as_str()) - && rt.backends().is_empty() + && rt + .backends_for_version(self.registry_version.as_deref()) + .is_empty() && !rt.backends.is_empty() { let all_backends: Vec<&str> = rt.backends.iter().map(|rb| rb.full).collect(); bail!( - "{self} is in the mise tool registry but none of its backends ({}) are supported in the current configuration", - all_backends.join(", ") + "{self} is in the mise tool registry but none of its backends ({}) are supported in the current configuration{}", + all_backends.join(", "), + self.registry_version + .as_ref() + .map(|v| format!(" for version {v}")) + .unwrap_or_default() ); } @@ -417,6 +425,12 @@ impl BackendArg { return backend_type; } + // A version-scoped registry lookup with no eligible backend must not + // revive the backend recorded by another installed version. + if self.has_registry_version() { + return BackendType::Core; + } + // Legacy install state may have a backend type without a full // identifier. Keep it as a fallback when `full()` was inconclusive. if !self.short.contains(':') @@ -522,7 +536,11 @@ impl BackendArg { if let Some(registry_full) = self .aliased_registry_short() .and_then(|name| REGISTRY.get(name.as_str())) - .and_then(|rt| rt.backends().first().cloned()) + .and_then(|rt| { + rt.backends_for_version(self.registry_version.as_deref()) + .first() + .cloned() + }) { return registry_full.to_string(); } @@ -538,7 +556,12 @@ impl BackendArg { } let config = Config::get_(); - if let Some(backend) = lockfile::get_locked_backend(&config, short) { + // With version-dependent backends, the first lockfile entry may + // belong to another version. ToolVersion restores the backend from + // the matching lock entry after resolving this request's binding. + if !self.has_registry_version() + && let Some(backend) = lockfile::get_locked_backend(&config, short) + { return backend; } } @@ -548,9 +571,11 @@ impl BackendArg { // the registry changes (e.g., when a tool moves from one maintainer to another). if !self.resolution.explicit && !plugin_overrides_registry(short) - && let Some(registry_full) = REGISTRY - .get(short) - .and_then(|rt| rt.backends().first().cloned()) + && let Some(registry_full) = REGISTRY.get(short).and_then(|rt| { + rt.backends_for_version(self.registry_version.as_deref()) + .first() + .cloned() + }) { if let Some(stored_full) = &self.full && stored_full != registry_full @@ -562,6 +587,16 @@ impl BackendArg { return registry_full.to_string(); } + if self.has_registry_version() + && !plugin_overrides_registry(short) + && self.registry_tool().is_some_and(|tool| { + tool.backends_for_version(self.registry_version.as_deref()) + .is_empty() + }) + { + return short.to_string(); + } + if let Some(full) = &self.full { full.clone() } else if let Some(full) = install_state::get_tool_full(short) { @@ -597,16 +632,38 @@ impl BackendArg { PluginType::VfoxBackend => short.to_string(), PluginType::Package => short.to_string(), } - } else if let Some(full) = REGISTRY - .get(short) - .and_then(|rt| rt.backends().first().cloned()) - { + } else if let Some(full) = REGISTRY.get(short).and_then(|rt| { + rt.backends_for_version(self.registry_version.as_deref()) + .first() + .cloned() + }) { full.to_string() } else { short.to_string() } } + /// Carry a version boundary without making a registry choice user-explicit. + /// Keeping this separate from `full` lets installed shorthands migrate to a + /// newer backend while explicit identifiers, overrides, and locks stay pinned. + pub(crate) fn with_registry_version(&self, version: &str) -> Option { + if self.has_explicit_backend() + || self.has_env_backend_override() + || !self + .registry_tool() + .is_some_and(|tool| tool.backends.iter().any(|b| b.min_version.is_some())) + { + return None; + } + let mut backend = self.clone(); + backend.registry_version = Some(version.to_string()); + Some(backend) + } + + pub(crate) fn has_registry_version(&self) -> bool { + self.registry_version.is_some() + } + pub(crate) fn full_without_opts(&self) -> String { let full = self.full(); if let Some((name, _)) = split_bracketed_opts(&full) { @@ -883,6 +940,34 @@ mod tests { use crate::config::Config; use pretty_assertions::{assert_eq, assert_str_eq}; + #[tokio::test] + async fn registry_min_version_keeps_backend_instances_separate() { + use crate::cli::args::ToolArg; + let _config = Config::get().await.unwrap(); + // Exercise both orders: a cache hit for the shorthand must not replace + // the backend selected for a different version in the same process. + for (query, expected) in [ + ("hk@1.57.0", "aqua:jdx/hk"), + ("hk@V1.57.0", "aqua:jdx/hk"), + ("hk@1.58.1", "packslip:github.com/jdx/hk"), + ("hk@prefix:1.57", "aqua:jdx/hk"), + ("hk@latest", "packslip:github.com/jdx/hk"), + ] { + let tool: ToolArg = query.parse().unwrap(); + assert_eq!(tool.ba.full(), expected); + assert_eq!(tool.ba.backend().unwrap().ba().full(), expected); + assert_eq!(tool.tvr.unwrap().backend().unwrap().ba().full(), expected); + assert!(!tool.ba.has_explicit_backend()); + } + for full in ["packslip:github.com/jdx/hk", "aqua:jdx/hk"] { + let explicit: ToolArg = format!("{full}@1.57.0").parse().unwrap(); + assert_eq!(explicit.ba.full(), full); + let locked = BackendArg::new("hk".to_string(), Some(full.to_string())); + assert!(locked.with_registry_version("1.57.0").is_none()); + assert_eq!(locked.full(), full); + } + } + #[test] fn test_matches_bin_name_uses_tool_identity() { let codex: BackendArg = "codex".into(); diff --git a/src/cli/args/tool_arg.rs b/src/cli/args/tool_arg.rs index 876cd0e69c3..f41c21a324b 100644 --- a/src/cli/args/tool_arg.rs +++ b/src/cli/args/tool_arg.rs @@ -43,6 +43,10 @@ impl FromStr for ToolArg { .as_ref() .map(|v| ToolRequest::new(ba.clone(), v, ToolSource::Argument)) .transpose()?; + let ba = tvr + .as_ref() + .map(|request| request.ba().clone()) + .unwrap_or(ba); Ok(Self { short: ba.short.clone(), tvr, @@ -109,7 +113,7 @@ impl ToolArg { &b.ba.tool_name, ToolSource::Argument, )?); - tools[1].ba = a.ba; + tools[1].ba = tools[1].tvr.as_ref().unwrap().ba().clone(); tools[1].version_type = b.ba.tool_name.parse()?; tools[1].version = Some(b.ba.tool_name.clone()); tools.remove(0); @@ -119,8 +123,10 @@ impl ToolArg { } pub(crate) fn with_version(self, version: &str) -> Self { + let request = ToolRequest::new(self.ba.clone(), version, ToolSource::Argument).unwrap(); Self { - tvr: Some(ToolRequest::new(self.ba.clone(), version, ToolSource::Argument).unwrap()), + ba: request.ba().clone(), + tvr: Some(request), version: Some(version.into()), version_type: version.parse().unwrap(), ..self diff --git a/src/cli/latest.rs b/src/cli/latest.rs index d6232e2873d..b13d3e70213 100644 --- a/src/cli/latest.rs +++ b/src/cli/latest.rs @@ -54,11 +54,15 @@ impl Latest { _ => bail!("invalid version: {}", tool.style()), }; - let mut backend = tool.ba.backend()?; + let ba = prefix + .as_deref() + .and_then(|prefix| tool.ba.with_registry_version(prefix)); + let ba = ba.as_ref().unwrap_or(&tool.ba); + let mut backend = ba.backend()?; let mpr = MultiProgressReport::get(); if let Some(plugin) = backend.plugin() { plugin.ensure_installed(&config, &mpr, false, false).await?; - backend = tool.ba.backend()?; + backend = ba.backend()?; } let prefix = match &tool.tvr { Some(ToolRequest::Sub { @@ -72,6 +76,12 @@ impl Latest { }, }; + if let Some(ba) = prefix + .as_deref() + .and_then(|prefix| ba.with_registry_version(prefix)) + { + backend = ba.backend()?; + } let latest_version = if installed { backend.latest_installed_version(prefix)? } else { diff --git a/src/cli/ls_remote.rs b/src/cli/ls_remote.rs index 6431fd68578..e00b472e6c0 100644 --- a/src/cli/ls_remote.rs +++ b/src/cli/ls_remote.rs @@ -102,7 +102,7 @@ impl LsRemote { async fn run_single( self, config: &Arc, - plugin: Arc, + mut plugin: Arc, before_date: Option, ) -> Result<()> { let before_date = @@ -110,6 +110,7 @@ impl LsRemote { let prefix = match &self.plugin { Some(tool_arg) => match &tool_arg.tvr { Some(ToolRequest::Version { version: v, .. }) => Some(v.clone()), + Some(ToolRequest::Prefix { prefix, .. }) => Some(prefix.clone()), Some(ToolRequest::Sub { sub, orig_version, .. }) => Some( @@ -120,6 +121,24 @@ impl LsRemote { }, _ => self.prefix.clone(), }; + let prefix = match prefix { + Some(prefix) => { + let prefix = config.resolve_alias(&plugin, &prefix).await?; + Some( + prefix + .strip_prefix("prefix:") + .unwrap_or(&prefix) + .to_string(), + ) + } + None => None, + }; + if let Some(ba) = prefix + .as_deref() + .and_then(|prefix| plugin.ba().with_registry_version(prefix)) + { + plugin = ba.backend()?; + } let matches_prefix = |v: &str| prefix.as_ref().is_none_or(|p| v.starts_with(p)); let versions_matching_prefix = plugin @@ -181,11 +200,16 @@ impl LsRemote { async fn get_plugin(&self, config: &Arc) -> Result>> { match &self.plugin { Some(tool_arg) => { - let mut backend = tool_arg.ba.backend()?; + let ba = self + .prefix + .as_deref() + .and_then(|prefix| tool_arg.ba.with_registry_version(prefix)); + let ba = ba.as_ref().unwrap_or(&tool_arg.ba); + let mut backend = ba.backend()?; let mpr = MultiProgressReport::get(); if let Some(plugin) = backend.plugin() { plugin.ensure_installed(config, &mpr, false, false).await?; - backend = tool_arg.ba.backend()?; + backend = ba.backend()?; } Ok(Some(backend)) } diff --git a/src/cli/test_tool.rs b/src/cli/test_tool.rs index 8bee4024aa8..645dba0aeab 100644 --- a/src/cli/test_tool.rs +++ b/src/cli/test_tool.rs @@ -1,6 +1,6 @@ use crate::cli::args::ToolArg; use crate::cmd::cmd; -use crate::config::Config; +use crate::config::{Config, Settings}; use crate::file::display_path; use crate::registry::{REGISTRY, RegistryTool}; use crate::tera::{contains_template_syntax, get_tera, render_str}; @@ -14,6 +14,9 @@ use std::{collections::BTreeSet, sync::Arc}; use tokio::task::JoinSet; /// Test that a tool installs and runs +/// +/// Includes newly published releases by disabling the global minimum release age +/// for this command. #[derive(Debug, Clone, usage_rs::Args)] #[usage(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] pub(crate) struct TestTool { @@ -42,6 +45,9 @@ pub(crate) struct TestTool { impl TestTool { pub(crate) async fn run(self) -> Result<()> { + // Registry validation must exercise new releases immediately, including + // the first release supported by a newly added backend. + Settings::override_with(|s| s.minimum_release_age = Some("0".to_string())); let mut errored = vec![]; self.github_summary(vec![ "Tool".to_string(), diff --git a/src/registry.rs b/src/registry.rs index 784ee513580..697b62f721b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -168,9 +168,57 @@ pub(crate) struct RegistryToolTest { pub(crate) struct RegistryBackend { pub full: &'static str, pub platforms: &'static [&'static str], + pub min_version: Option<&'static str>, pub options: &'static [(&'static str, &'static str)], } +impl RegistryBackend { + fn supports_version(&self, request: &str) -> bool { + let Some(minimum) = self.min_version else { + return true; + }; + // Validated when loading both bundled and floating registries. This + // boundary is explicitly restricted to semver tools; it never orders + // backend version lists or interprets opaque lockfile versions. + let minimum = semver::Version::parse(minimum).expect("validated registry min_version"); + let request = request.strip_prefix("prefix:").unwrap_or(request); + let request = request.trim_start_matches(['v', 'V']); + if let Ok(version) = semver::Version::parse(request) { + return !version.cmp_precedence(&minimum).is_lt(); + } + // A numeric prefix is excluded only when the entire prefix is below + // the boundary. Let the backend resolve prefixes that overlap it. + let parts = request.split('.').collect::>(); + if !(1..=2).contains(&parts.len()) { + return true; + } + let Some(parts) = parts + .into_iter() + .map(|part| { + if part.is_empty() + || !part.bytes().all(|c| c.is_ascii_digit()) + || (part.len() > 1 && part.starts_with('0')) + { + return None; + } + part.parse::().ok() + }) + .collect::>>() + else { + return true; + }; + let minimum_parts = [minimum.major, minimum.minor]; + for (part, minimum) in parts.into_iter().zip(minimum_parts) { + match part.cmp(&minimum) { + std::cmp::Ordering::Less => return false, + std::cmp::Ordering::Greater => return true, + std::cmp::Ordering::Equal => {} + } + } + true + } +} + fn registry_cache_path() -> PathBuf { dirs::CACHE.join("mise-registry").join("registry.tar.zst") } @@ -377,6 +425,10 @@ fn parse_registry_tool(short: &str, value: &toml::Value) -> Result<(RegistryTool None => VersionOrder::Source, }; + ensure!( + version_order == VersionOrder::Semver || backends.iter().all(|b| b.min_version.is_none()), + "backend min_version requires version_order = \"semver\"" + ); let aliases = string_array(table.get("aliases"), "aliases")?; let bins = if table.contains_key("bins") { string_array(table.get("bins"), "bins")? @@ -488,6 +540,7 @@ fn parse_registry_backend(value: &toml::Value) -> Result { toml::Value::String(full) => Ok(RegistryBackend { full: leak_string(full.clone()), platforms: &[], + min_version: None, options: &[], }), toml::Value::Table(table) => { @@ -496,6 +549,17 @@ fn parse_registry_backend(value: &toml::Value) -> Result { .and_then(toml::Value::as_str) .ok_or_else(|| eyre::eyre!("backend full must be a string"))?; let platforms = string_array(table.get("platforms"), "backend platforms")?; + let min_version = table + .get("min_version") + .map(|value| { + let value = value + .as_str() + .ok_or_else(|| eyre::eyre!("backend min_version must be a string"))?; + semver::Version::parse(value) + .wrap_err("backend min_version must be a semantic version")?; + Ok::<_, eyre::Report>(leak_string(value.to_string())) + }) + .transpose()?; let options = table .get("options") .and_then(toml::Value::as_table) @@ -514,6 +578,7 @@ fn parse_registry_backend(value: &toml::Value) -> Result { Ok(RegistryBackend { full: leak_string(full.to_string()), platforms: leak_vec(platforms), + min_version, options: leak_vec(options), }) } @@ -651,6 +716,20 @@ impl RegistryTool { .collect() } + /// Filter only requests known to be older than a backend's introduction. + /// Channels and unresolved aliases retain the ordinary backend priority. + pub(crate) fn backends_for_version(&self, version: Option<&str>) -> Vec<&'static str> { + self.backends() + .into_iter() + .filter(|full| version.is_none_or(|v| self.backend_supports_version(full, v))) + .collect() + } + + pub(crate) fn backend_supports_version(&self, full: &str, version: &str) -> bool { + self.get_backend(full) + .is_none_or(|backend| backend.supports_version(version)) + } + pub(crate) fn is_supported_os(&self) -> bool { self.os.is_empty() || self.os.contains(&OS) } @@ -827,6 +906,115 @@ mod tests { use super::{BTreeMap, baked_registry, registry_from_sources}; use crate::config::Config; + #[test] + fn registry_min_version_boundaries() { + let backend = super::RegistryBackend { + full: "packslip:github.com/example/tool", + platforms: &[], + min_version: Some("1.58.1"), + options: &[], + }; + for request in [ + "0", + "1.5", + "1.57", + "prefix:1.57", + "1.58.0", + "v1.58.0", + "V1.58.0", + "prefix:V1.57", + "1.58.1-rc.1", + ] { + assert!(!backend.supports_version(request), "{request}"); + } + for request in [ + "1", + "1.58", + "prefix:1.58", + "1.58.1", + "V1.58.1", + "1.58.1+build.2", + "2.0.0", + "latest", + "nightly", + "ref:main", + "lts/iron", + "0.nightly", + "1.58.0.2", + "01.57", + "", + ] { + assert!(backend.supports_version(request), "{request}"); + } + } + + #[test] + fn registry_min_version_parsing_and_validation() { + use super::*; + let parse = |order: &str, minimum: &str| { + let source = format!( + r#" +version_order = "{order}" +backends = [ + {{ full = "packslip:github.com/example/tool", min_version = {minimum} }}, + "aqua:example/tool", +] +"# + ); + parse_registry_tool("example", &toml::from_str::(&source).unwrap()) + }; + let (tool, _) = parse("semver", r#""1.58.1""#).unwrap(); + assert_eq!(tool.backends[0].min_version, Some("1.58.1")); + assert_eq!(tool.backends[1].min_version, None); + assert_eq!( + tool.backends_for_version(Some("1.57")), + ["aqua:example/tool"] + ); + assert_eq!( + tool.backends_for_version(Some("latest")), + ["packslip:github.com/example/tool", "aqua:example/tool"] + ); + for minimum in [ + r#""latest""#, + r#""1.58""#, + r#""01.58.1""#, + r#""1.0.0-01""#, + r#""1.0.0-a..b""#, + r#""1.0.0+a..b""#, + "true", + "12", + ] { + assert!(parse("semver", minimum).is_err(), "{minimum}"); + } + assert!(parse("source", r#""1.58.1""#).is_err()); + } + + #[test] + fn registry_min_version_schema_matches_semver_identifiers() { + let schema: serde_json::Value = + serde_json::from_str(include_str!("../schema/mise-registry-tool.json")).unwrap(); + let pattern = schema["properties"]["backends"]["items"]["oneOf"][1] + ["properties"]["min_version"]["pattern"].as_str().unwrap(); + let pattern = regex::Regex::new(pattern).unwrap(); + for (version, valid) in [ + ("1.58.1", true), + ("0.0.0", true), + ("1.0.0-0", true), + ("1.0.0-0alpha.1+build.01", true), + ("1.0.0+01", true), + ("1.0.0-a..b", false), + ("1.0.0-01", false), + ("1.0.0-alpha.01", false), + ("1.0.0+a..b", false), + ("1.0.0+", false), + ("1.0.0-", false), + ("01.0.0", false), + ] { + assert_eq!(pattern.is_match(version), valid, "{version}"); + assert_eq!(semver::Version::parse(version).is_ok(), valid, "{version}"); + } + } + #[test] fn baked_registry_infers_bins_from_preferred_aqua_backend() { let tool = baked_registry().get("jq").unwrap(); @@ -1286,21 +1474,25 @@ idiomatic_files = [{ path = ".example-version", parser = "shell" }] RegistryBackend { full: "aqua:first/tool", platforms: &["macos"], + min_version: None, options: &[], }, RegistryBackend { full: "github:second/tool", platforms: &["macos-x64"], + min_version: None, options: &[], }, RegistryBackend { full: "cargo:third-tool", platforms: &[], + min_version: None, options: &[], }, RegistryBackend { full: "npm:excluded-tool", platforms: &["linux"], + min_version: None, options: &[], }, ]; @@ -1319,6 +1511,7 @@ idiomatic_files = [{ path = ".example-version", parser = "shell" }] let alias_selector = RegistryBackend { full: "github:owner/repo", platforms: &["darwin-amd64"], + min_version: None, options: &[], }; assert!(!backend_matches_platform( @@ -1347,6 +1540,7 @@ idiomatic_files = [{ path = ".example-version", parser = "shell" }] static BACKENDS: &[RegistryBackend] = &[RegistryBackend { full: "github:owner/repo", platforms: &[], + min_version: None, options: OPTIONS, }]; let tool = RegistryTool { @@ -1389,11 +1583,13 @@ idiomatic_files = [{ path = ".example-version", parser = "shell" }] RegistryBackend { full: "aqua:owner/repo", platforms: &[], + min_version: None, options: &[], }, RegistryBackend { full: "npm:package", platforms: &[], + min_version: None, options: &[], }, ]; diff --git a/src/toolset/tool_request.rs b/src/toolset/tool_request.rs index cfd0b6d3dd0..68b13b355a6 100644 --- a/src/toolset/tool_request.rs +++ b/src/toolset/tool_request.rs @@ -100,6 +100,10 @@ impl ToolRequest { backend.short ); } + let backend = backend + .with_registry_version(&s) + .map(Arc::new) + .unwrap_or(backend); let options = backend.resolve_opts_with_config_and_request(None, Some(request_options)); Ok(match s.split_once(':') { Some((ref_type @ ("ref" | "tag" | "branch" | "rev"), r)) => { @@ -206,6 +210,36 @@ impl ToolRequest { pub(crate) fn backend(&self) -> Result { self.ba().backend() } + /// Reapply registry defaults after an alias, prefix, or subtraction resolves + /// across a backend boundary. Retain every non-registry option's provenance. + pub(super) fn with_registry_version(mut self, version: &str) -> Self { + let Some(backend) = self.ba().with_registry_version(version) else { + return self; + }; + let previous = self.resolved_options().clone(); + let mut options = ResolvedToolOptions::default(); + options.apply_overrides(&backend.registry_opts(), ToolOptionSource::Registry); + for source in [ + ToolOptionSource::InstallManifest, + ToolOptionSource::BackendAlias, + ToolOptionSource::Config, + ToolOptionSource::Request, + ToolOptionSource::InlineBackendArg, + ] { + options.apply_overrides(&previous.options_from_sources(&[source]), source); + } + *self.resolved_options_mut() = options; + match &mut self { + Self::Version { backend: b, .. } + | Self::Prefix { backend: b, .. } + | Self::Ref { backend: b, .. } + | Self::Sub { backend: b, .. } + | Self::Path { backend: b, .. } + | Self::System { backend: b, .. } => *b = Arc::new(backend), + } + self + } + pub(crate) fn source(&self) -> &ToolSource { match self { Self::Version { source, .. } @@ -818,6 +852,38 @@ mod tests { )) } + #[tokio::test] + async fn registry_min_version_replaces_only_registry_defaults() { + use super::*; + let _config = Config::get().await.unwrap(); + let mut request = ToolRequest::new( + Arc::new(BackendArg::from("hk")), + "latest", + ToolSource::Argument, + ) + .unwrap(); + let old_defaults = + crate::toolset::parse_tool_options("identity_prefix=https://example.test/old/"); + request + .resolved_options_mut() + .apply_overrides(&old_defaults, ToolOptionSource::Registry); + let explicit = crate::toolset::parse_tool_options("variant=custom"); + request + .resolved_options_mut() + .apply_overrides(&explicit, ToolOptionSource::Request); + let request = request.with_registry_version("1.57.0"); + assert_eq!(request.ba().full(), "aqua:jdx/hk"); + assert_eq!(request.version(), "latest"); + assert_eq!(request.options().get("identity_prefix"), None); + assert_eq!(request.options().get("variant"), Some("custom")); + assert_eq!( + request.option_source("variant"), + Some(ToolOptionSource::Request) + ); + let resolved = ToolVersion::new(request, "1.58.1".to_string()); + assert_eq!(resolved.ba().full(), "packslip:github.com/jdx/hk"); + } + #[test] fn test_lockfile_version_query_uses_bare_prefix() { // Lockfiles store resolved concrete versions, so the scheme-qualified diff --git a/src/toolset/tool_version.rs b/src/toolset/tool_version.rs index ea881871a00..5fd524ddd83 100644 --- a/src/toolset/tool_version.rs +++ b/src/toolset/tool_version.rs @@ -77,6 +77,7 @@ impl ToolVersion { } pub(crate) fn new(request: ToolRequest, version: String) -> Self { + let request = request.with_registry_version(&version); ToolVersion { request, version, @@ -364,6 +365,8 @@ impl ToolVersion { ) -> Result { let backend = request.backend()?; let v = config.resolve_alias(&backend, v).await?; + let request = request.with_registry_version(&v); + let backend = request.backend()?; // Re-check the lockfile after alias resolution (e.g., "lts" → "24") // The initial lockfile check in resolve() uses the unresolved alias which @@ -670,6 +673,7 @@ impl ToolVersion { v: &str, opts: &ResolveOptions, ) -> Result { + let request = request.with_registry_version(v); let backend = request.backend()?; if v == "latest" && opts.offline { let pathname = request.version().replace([':', '/'], "-"); @@ -707,6 +711,7 @@ impl ToolVersion { prefix: &str, opts: &ResolveOptions, ) -> Result { + let request = request.with_registry_version(prefix); let backend = request.backend()?; let settings = Settings::get(); let is_offline = settings.offline() || opts.offline;