Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fd2bc60
fix(http): use TOML parsing for tool options to fix platform switching
jdx Mar 3, 2026
1ebe9d5
fix(http): store tool opts as native TOML in .mise-installs.toml
jdx Mar 4, 2026
6becea8
refactor: extract split_bracketed_opts/strip_opts helpers
jdx Mar 4, 2026
049a3b0
refactor: extract EPHEMERAL_OPT_KEYS constant for filtered option keys
jdx Mar 4, 2026
61bee69
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 4, 2026
e2cc9bc
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Mar 4, 2026
60c1ef3
fix(http): persist only user-specified opts, not registry defaults
jdx Mar 4, 2026
0872419
refactor: extract str_to_toml_value helper, fix darwin platform key i…
jdx Mar 4, 2026
5532006
fix(http): preserve native TOML opts without string round-trip
jdx Mar 4, 2026
d3cd3a5
fix(http): strip brackets from full when writing backend meta
jdx Mar 4, 2026
c07b2fb
fix(http): unify opts write precedence and filter ephemeral keys
jdx Mar 4, 2026
d9e41a1
fix(http): give manifest opts precedence over registry defaults
jdx Mar 4, 2026
2adacbc
fix(http): fix opts precedence so user opts override manifest cache
jdx Mar 4, 2026
2dc7ecd
refactor: store tool opts as native toml::Value instead of strings
jdx Mar 4, 2026
781a962
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 4, 2026
cdf0736
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Mar 4, 2026
3189980
fix: convert scalar TOML opts to strings to fix strip_components
jdx Mar 4, 2026
2e59229
fix: store native toml::Value in tool stub opts for platform URL lookup
jdx Mar 5, 2026
dd62f65
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 5, 2026
8ccab41
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Mar 5, 2026
6c8ff28
fix(http): address PR feedback for native TOML opts
jdx Mar 6, 2026
eabd6e9
refactor: extract opts_as_strings() helper to deduplicate toml::Value…
jdx Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions e2e/backend/test_http_platform_switch
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash

# Test switching from single url to platform-specific urls
# Regression test for https://github.com/jdx/mise/discussions/7034
#
# When a tool is first installed with a single `url`, the install state stores
# the options (including url) in the manifest. If the user then changes the
# config to use platform-specific urls via [tools."http:X".platforms], the
# stale cached options could shadow the new config, causing
# "Http backend requires 'url' option" error.

# Step 1: Install with a single url
cat <<EOF >mise.toml
[tools]
"http:hello-plat-switch" = { version = "1.0.0", url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz", bin_path = "hello-world-1.0.0/bin", postinstall = "chmod +x \$MISE_TOOL_INSTALL_PATH/hello-world-1.0.0/bin/hello-world" }
EOF

mise install
assert_contains "mise x -- hello-world" "hello world"

# Step 2: Switch to platform-specific urls (same tool name, same version)
# This previously failed with "Http backend requires 'url' option" because
# the stale cached platforms data from parse_tool_options was mangled and
# shadowed the correct config values during merge.
cat <<EOF >mise.toml
[tools."http:hello-plat-switch"]
version = "1.0.0"
bin_path = "hello-world-1.0.0/bin"
postinstall = "chmod +x \$MISE_TOOL_INSTALL_PATH/hello-world-1.0.0/bin/hello-world"

[tools."http:hello-plat-switch".platforms]
linux-x64 = { url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" }
linux-arm64 = { url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" }
darwin-arm64 = { url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" }
darwin-x64 = { url = "https://mise.jdx.dev/test-fixtures/hello-world-1.0.0.tar.gz" }
EOF

# This should succeed, not fail with "Http backend requires 'url' option"
mise install
assert_contains "mise x -- hello-world" "hello world"
75 changes: 54 additions & 21 deletions src/cli/args/backend_arg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ use crate::config::Config;
use crate::plugins::PluginType;
use crate::registry::REGISTRY;
use crate::toolset::install_state::InstallStateTool;
use crate::toolset::{ToolVersionOptions, install_state, parse_tool_options};
use crate::toolset::{EPHEMERAL_OPT_KEYS, ToolVersionOptions, install_state, parse_tool_options};
use crate::{backend, config, dirs, lockfile, registry};
use contracts::requires;
use eyre::{Result, bail};
use heck::{ToKebabCase, ToShoutySnakeCase};
use std::collections::HashSet;
use std::collections::{BTreeMap, HashSet};
use std::env;
use std::fmt::{Debug, Display};
use std::hash::Hash;
Expand Down Expand Up @@ -48,6 +48,9 @@ pub struct BackendArg {
/// ~/.local/share/mise/downloads/<THIS>
pub downloads_path: PathBuf,
pub opts: Option<ToolVersionOptions>,
/// Native TOML opts from the install manifest, preserved to avoid
/// lossy string round-trips when writing back.
pub manifest_opts: BTreeMap<String, toml::Value>,
resolution: BackendResolution,
// TODO: make this not a hash key anymore to use this
// backend: OnceCell<ABackend>,
Expand Down Expand Up @@ -77,16 +80,33 @@ impl<A: AsRef<str>> From<A> for BackendArg {
impl From<InstallStateTool> for BackendArg {
fn from(ist: InstallStateTool) -> Self {
let (short, tool_name, opts) = parse_backend_components(&ist.short, ist.full.as_ref());
Self::new_raw(
let manifest_opts = ist.opts;

let mut ba = Self::new_raw(
short,
ist.full,
tool_name,
opts,
BackendResolution::new(ist.explicit_backend),
)
);
ba.manifest_opts = manifest_opts;
ba
}
}

/// Split a string like `"http:hello[url=...,bin=bin]"` into `("http:hello", "url=...,bin=bin")`.
/// Returns `None` if no bracketed opts are present.
pub fn split_bracketed_opts(s: &str) -> Option<(&str, &str)> {
regex!(r"^(.+)\[(.+)\]$")
.captures(s)
.map(|c| (c.get(1).unwrap().as_str(), c.get(2).unwrap().as_str()))
}

/// Strip trailing `[...]` opts from a string, e.g. `"foo[a=1]"` → `"foo"`.
pub(crate) fn strip_opts(s: &str) -> String {
regex!(r#"\[.+\]$"#).replace_all(s, "").to_string()
}

fn parse_backend_components(
short: &str,
full: Option<&String>,
Expand All @@ -96,12 +116,12 @@ fn parse_backend_components(
.unwrap_or(&short)
.split_once(':')
.unwrap_or(("", full.unwrap_or(&short)));
let short = regex!(r#"\[.+\]$"#).replace_all(&short, "").to_string();
let short = strip_opts(&short);

let mut opts = None;
if let Some(c) = regex!(r"^(.+)\[(.+)\]$").captures(tool_name) {
tool_name = c.get(1).unwrap().as_str();
opts = Some(parse_tool_options(c.get(2).unwrap().as_str()));
if let Some((name, opts_str)) = split_bracketed_opts(tool_name) {
tool_name = name;
opts = Some(parse_tool_options(opts_str));
}

(short, tool_name.to_string(), opts)
Expand Down Expand Up @@ -131,6 +151,7 @@ impl BackendArg {
installs_path: dirs::INSTALLS.join(&pathname),
downloads_path: dirs::DOWNLOADS.join(&pathname),
opts,
manifest_opts: BTreeMap::new(),
resolution,
// backend: Default::default(),
}
Expand Down Expand Up @@ -357,15 +378,15 @@ impl BackendArg {

pub fn full_with_opts(&self) -> String {
let full = self.full();
if regex!(r"^(.+)\[(.+)\]$").is_match(&full) {
if split_bracketed_opts(&full).is_some() {
return full;
}
if let Some(opts) = &self.opts {
let opts_str = opts
.opts
.iter()
// filter out global options that are only relevant for initial installation
.filter(|(k, _)| !["postinstall", "install_env"].contains(&k.as_str()))
.filter(|(k, _)| !EPHEMERAL_OPT_KEYS.contains(&k.as_str()))
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(",");
Expand All @@ -378,8 +399,8 @@ impl BackendArg {

pub fn full_without_opts(&self) -> String {
let full = self.full();
if let Some(c) = regex!(r"^(.+)\[(.+)\]$").captures(&full) {
return c.get(1).unwrap().as_str().to_string();
if let Some((name, _)) = split_bracketed_opts(&full) {
return name.to_string();
}
full
}
Expand All @@ -393,10 +414,10 @@ impl BackendArg {
.map(|rt| rt.backend_options(&full))
.unwrap_or_default();

// Get user-provided options (from self.opts or from full string)
// Get user-provided options (from self.opts, manifest_opts, or from full string)
let user_opts = self.opts.clone().unwrap_or_else(|| {
if let Some(c) = regex!(r"^(.+)\[(.+)\]$").captures(&full) {
parse_tool_options(c.get(2).unwrap().as_str())
if let Some((_, opts_str)) = split_bracketed_opts(&full) {
parse_tool_options(opts_str)
} else {
ToolVersionOptions::default()
}
Expand All @@ -406,6 +427,19 @@ impl BackendArg {
for (k, v) in user_opts.opts {
opts.opts.insert(k, v);
}

// Merge manifest opts (native TOML values from install state).
// These are user-specified opts stored in native TOML format, so they
// override registry defaults (same precedence as user_opts above).
for (k, v) in &self.manifest_opts {
opts.opts.insert(
k.clone(),
match v {
toml::Value::String(s) => s.clone(),
_ => v.to_string(),
},
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
for (k, v) in user_opts.install_env {
opts.install_env.insert(k, v);
}
Expand Down Expand Up @@ -439,8 +473,8 @@ impl BackendArg {
if !self.resolution.explicit {
let full = self.full();
// Strip options since lockfiles have a separate options field
if let Some(c) = regex!(r"^(.+)\[(.+)\]$").captures(&full) {
return c.get(1).unwrap().as_str().to_string();
if let Some((name, _)) = split_bracketed_opts(&full) {
return name.to_string();
}
return full;
}
Expand All @@ -463,17 +497,16 @@ impl BackendArg {
}
};
// Strip options since lockfiles have a separate options field
if let Some(c) = regex!(r"^(.+)\[(.+)\]$").captures(&full) {
return c.get(1).unwrap().as_str().to_string();
if let Some((name, _)) = split_bracketed_opts(&full) {
return name.to_string();
}
full
}

pub fn tool_name(&self) -> String {
let full = self.full();
let (_backend, tool_name) = full.split_once(':').unwrap_or(("", &full));
let tool_name = regex!(r#"\[.+\]$"#).replace_all(tool_name, "").to_string();
tool_name.to_string()
strip_opts(tool_name)
}

/// maps something like cargo:cargo-binstall to cargo-binstall and ubi:cargo-binstall, etc
Expand Down
2 changes: 1 addition & 1 deletion src/cli/args/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pub use backend_arg::{BackendArg, BackendResolution};
pub use backend_arg::{BackendArg, BackendResolution, split_bracketed_opts};
pub use env_var_arg::EnvVarArg;
pub use tool_arg::{ToolArg, ToolVersionType};

Expand Down
Loading
Loading