From 606dc94ae52e9d596f383d852aeb4d2be7342de9 Mon Sep 17 00:00:00 2001 From: Marukome0743 Date: Sat, 15 Aug 2026 01:55:09 +0900 Subject: [PATCH] feat(install): include task-specific tools --- docs/cli/install.md | 8 ++ docs/dev-tools/index.md | 5 + docs/tasks/task-configuration.md | 4 + e2e/cli/test_install_monorepo | 15 +++ e2e/cli/test_install_task_tools | 123 ++++++++++++++++++++++ man/man1/mise.1 | 6 ++ mise.usage.kdl | 9 ++ src/cli/install.rs | 175 ++++++++++++++++++++++++++++--- src/task/task_tool_installer.rs | 67 ++++++------ src/toolset/mod.rs | 1 + src/toolset/tool_deps.rs | 96 ++++++++++++++++- src/toolset/toolset_install.rs | 32 +++--- 12 files changed, 472 insertions(+), 69 deletions(-) create mode 100644 e2e/cli/test_install_task_tools diff --git a/docs/cli/install.md b/docs/cli/install.md index 790de12733e..1df1422de0a 100644 --- a/docs/cli/install.md +++ b/docs/cli/install.md @@ -51,6 +51,13 @@ Like --dry-run but exits with code 1 if there are tools to install This is useful for scripts to check if tools need to be installed. +### `--include-task-tools` + +Also install tools required by tasks in the current scope + +This prepares task tools without running task commands or dependencies. +Combine with --monorepo to include tasks from every configured root. + ### `--minimum-release-age ` Only install versions released before this date or older than this duration @@ -89,4 +96,5 @@ mise install node@20.0.0 # install specific node version mise install node@20 # install fuzzy node version mise install node # install version specified in mise.toml mise install # installs everything specified in mise.toml +mise install --include-task-tools # also install tools required by tasks ``` diff --git a/docs/dev-tools/index.md b/docs/dev-tools/index.md index 502e99d72b0..9c2c8d2364e 100644 --- a/docs/dev-tools/index.md +++ b/docs/dev-tools/index.md @@ -374,6 +374,11 @@ There are many ways it can be used: - `mise install node` - install whatever version of node currently specified in `mise.toml` (or other config files) - `mise install` - install all plugins and tools specified in the config files +- `mise install --include-task-tools` - also install every tool required by tasks in the current + scope without running those tasks + +The last form is useful for warming CI, container, or offline caches before running any task. Add +`--monorepo` to include task tools from every configured monorepo root. ### [`mise exec`|`mise x`](/cli/exec) diff --git a/docs/tasks/task-configuration.md b/docs/tasks/task-configuration.md index ae0d604283c..54d52c34483 100644 --- a/docs/tasks/task-configuration.md +++ b/docs/tasks/task-configuration.md @@ -271,6 +271,10 @@ Run [`mise lock`](/dev-tools/mise-lock.html) to resolve task-specific tools into config's lockfile before running the task. This reads the task definition without executing the task or installing its tools. +Run `mise install --include-task-tools` to install tools for every task in the current scope without +executing task commands or dependencies. This is useful for preparing CI caches or container images; +combine it with `--monorepo` to include every configured monorepo root. + ### `dir` - **Type**: `string` diff --git a/e2e/cli/test_install_monorepo b/e2e/cli/test_install_monorepo index c0ad8fe8515..0010b58389b 100644 --- a/e2e/cli/test_install_monorepo +++ b/e2e/cli/test_install_monorepo @@ -17,11 +17,19 @@ EOF cat >monorepo/packages/a/mise.toml <<'EOF' [tools] tiny = "1.0.0" + +[tasks.prepare] +tools.tiny = "3.1.0" +run = "touch task-ran" EOF cat >monorepo/packages/b/mise.toml <<'EOF' [tools] tiny = "2.1.0" dummy = "1.0.0" + +[tasks.prepare] +tools.dummy = "2.0.0" +run = "touch task-ran" EOF echo "2" >monorepo/packages/c/.dummy-version @@ -51,6 +59,13 @@ assert_fail "test -d '$MISE_DATA_DIR/installs/tiny/3.1.0'" assert_fail "test -d '$MISE_DATA_DIR/installs/dummy/1.0.0'" assert_fail "test -d '$MISE_DATA_DIR/installs/dummy/2.0.0'" +echo "=== install --monorepo --include-task-tools includes every root task ===" +assert "cd monorepo && mise install --monorepo --include-task-tools" +assert "test -d '$MISE_DATA_DIR/installs/tiny/3.1.0'" +assert "test -d '$MISE_DATA_DIR/installs/dummy/2.0.0'" +assert_fail "test -e monorepo/packages/a/task-ran" +assert_fail "test -e monorepo/packages/b/task-ran" + echo "=== ls --monorepo includes every configured root ===" output=$(cd monorepo && mise ls --monorepo 2>&1) assert_contains "echo '$output'" "tiny" diff --git a/e2e/cli/test_install_task_tools b/e2e/cli/test_install_task_tools new file mode 100644 index 00000000000..b50d4ad0f2f --- /dev/null +++ b/e2e/cli/test_install_task_tools @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +rm -rf "$MISE_DATA_DIR/plugins/tiny" +ln -s "$ROOT/test/data/plugins/tiny" "$MISE_DATA_DIR/plugins/tiny" + +mkdir -p .mise/tasks +cat >mise.toml <<'EOF' +[tools] +tiny = "1.0.0" + +[task_config] +includes = ["tasks.toml", ".mise/tasks"] + +[hooks] +preinstall = "printf 'pre\n' >> hooks.log" +postinstall = ''' +printf 'post:%s\n' "$MISE_INSTALLED_TOOLS" >> hooks.log +if [ -d "$MISE_DATA_DIR/installs/dummy/1.0.0" ]; then + command -v dummy >postinstall-task-tool-path +fi +''' + +[task_templates.with-tools] +tools.tiny = "3.1.0" + +[tasks.inline] +hide = true +tools.tiny = "2.1.0" +run = "touch inline-ran" + +[tasks.from-template] +extends = "with-tools" +run = "touch template-ran" +EOF + +cat >tasks.toml <<'EOF' +[included] +tools.dummy = "1.0.0" +run = "touch included-ran" +EOF + +cat >.mise/tasks/file-task <<'EOF' +#!/usr/bin/env bash +#MISE tools.tiny="1.1.0" +touch file-task-ran +EOF +chmod +x .mise/tasks/file-task + +# The existing bare install behavior remains limited to configured tools. +mise install +assert "test -d '$MISE_DATA_DIR/installs/tiny/1.0.0'" +assert_fail "test -d '$MISE_DATA_DIR/installs/tiny/1.1.0'" +assert_fail "test -d '$MISE_DATA_DIR/installs/tiny/2.1.0'" +assert_fail "test -d '$MISE_DATA_DIR/installs/tiny/3.1.0'" +assert_fail "test -d '$MISE_DATA_DIR/installs/dummy/1.0.0'" + +# Preview every inline, inherited, included, file, and hidden task tool without +# executing a task or changing the install tree. +rm -f hooks.log +output=$(mise install --include-task-tools --dry-run 2>&1) +grep -Fq "tiny@1.1.0" <<<"$output" +grep -Fq "tiny@2.1.0" <<<"$output" +grep -Fq "tiny@3.1.0" <<<"$output" +grep -Fq "dummy@1.0.0" <<<"$output" +assert_fail "test -d '$MISE_DATA_DIR/installs/tiny/1.1.0'" +assert_fail "test -e inline-ran" +assert_fail "test -e template-ran" +assert_fail "test -e included-ran" +assert_fail "test -e file-task-ran" +assert_fail "mise install --include-task-tools --dry-run-code" + +mise install --include-task-tools +assert "test -d '$MISE_DATA_DIR/installs/tiny/1.1.0'" +assert "test -d '$MISE_DATA_DIR/installs/tiny/2.1.0'" +assert "test -d '$MISE_DATA_DIR/installs/tiny/3.1.0'" +assert "test -d '$MISE_DATA_DIR/installs/dummy/1.0.0'" +assert "test \"$(grep -c '^pre$' hooks.log)\" -eq 1" +assert "test \"$(grep -c '^post:' hooks.log)\" -eq 1" +assert_contains "cat postinstall-task-tool-path" "$MISE_DATA_DIR/installs/dummy/1.0.0" +assert_fail "test -e inline-ran" +assert_fail "test -e template-ran" +assert_fail "test -e included-ran" +assert_fail "test -e file-task-ran" + +# The no-op install path uses the same union for postinstall PATH construction. +rm postinstall-task-tool-path +mise install --include-task-tools +assert_contains "cat postinstall-task-tool-path" "$MISE_DATA_DIR/installs/dummy/1.0.0" + +# Explicit tools form a union with task tools rather than filtering them. +rm -rf "$MISE_DATA_DIR/installs/dummy" "$MISE_DATA_DIR/installs/tiny/2.1.0" +mise install dummy@2.0.0 --include-task-tools +assert "test -d '$MISE_DATA_DIR/installs/dummy/1.0.0'" +assert "test -d '$MISE_DATA_DIR/installs/dummy/2.0.0'" +assert "test -d '$MISE_DATA_DIR/installs/tiny/2.1.0'" + +# Force applies to the complete union. +touch "$MISE_DATA_DIR/installs/tiny/1.0.0/force-marker" +touch "$MISE_DATA_DIR/installs/tiny/2.1.0/force-marker" +mise install --force --include-task-tools +assert_fail "test -e '$MISE_DATA_DIR/installs/tiny/1.0.0/force-marker'" +assert_fail "test -e '$MISE_DATA_DIR/installs/tiny/2.1.0/force-marker'" + +# Conflicting task/config options fail before [plugins] mutates plugin state. +mkdir conflict-plugin +rm -rf "$MISE_DATA_DIR/plugins/conflict-plugin" +cat >mise.toml <\fR Only install versions released before this date or older than this duration diff --git a/mise.usage.kdl b/mise.usage.kdl index 6263fde178f..771a67a12fe 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -1942,6 +1942,7 @@ Examples: $ mise install node@20 # install fuzzy node version $ mise install node # install version specified in mise.toml $ mise install # installs everything specified in mise.toml + $ mise install --include-task-tools # also install tools required by tasks """# flag "-f --force" help=#""" @@ -1968,6 +1969,14 @@ This argument will print backend output such as download, configuration, and com Like --dry-run but exits with code 1 if there are tools to install This is useful for scripts to check if tools need to be installed. +"""# + } + flag --include-task-tools help="Also install tools required by tasks in the current scope" { + long_help #""" +Also install tools required by tasks in the current scope + +This prepares task tools without running task commands or dependencies. +Combine with --monorepo to include tasks from every configured root. """# } flag --minimum-release-age help="Only install versions released before this date or older than this duration" { diff --git a/src/cli/install.rs b/src/cli/install.rs index 1a2bf575b03..66a1d9b7070 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -8,8 +8,12 @@ use crate::config::Settings; use crate::errors::split_install_result; use crate::hooks::Hooks; use crate::install_before::resolve_cli_minimum_release_age; +use crate::task::TaskLoadContext; +use crate::task::task_context_builder::TaskContextBuilder; +use crate::task::task_tool_installer::TaskToolInstaller; use crate::toolset::{ - InstallOptions, ResolveOptions, ToolRequest, ToolRequestSet, ToolSource, Toolset, tool_env_vars, + InstallOptions, ResolveOptions, ToolRequest, ToolRequestSet, ToolSource, ToolVersionOptions, + Toolset, ensure_compatible_install_requests, tool_env_vars, }; use crate::{config, env, exit, hooks}; use clap::ValueHint; @@ -63,6 +67,13 @@ pub struct Install { #[clap(long, verbatim_doc_comment)] dry_run_code: bool, + /// Also install tools required by tasks in the current scope + /// + /// This prepares task tools without running task commands or dependencies. + /// Combine with --monorepo to include tasks from every configured root. + #[clap(long, verbatim_doc_comment)] + include_task_tools: bool, + /// Only install versions released before this date or older than this duration /// /// Supports absolute dates like "2024-06-01" and relative durations like "90d" or "1y". @@ -122,19 +133,34 @@ impl Install { if !self.is_dry_run() { crate::lockfile::migrate_monorepo_lockfiles(&config)?; } + let task_requests = self.collect_task_tool_requests(&config).await?; match &self.tool { Some(runtime) => { let original_tool_args = env::TOOL_ARGS.read().unwrap().clone(); env::TOOL_ARGS.write().unwrap().clone_from(runtime); - self.install_runtimes(config, runtime, original_tool_args) + self.install_runtimes(config, runtime, original_tool_args, task_requests) .await? } - None => self.install_missing_runtimes(config).await?, + None => self.install_missing_runtimes(config, task_requests).await?, }; self.hint_missing_system_packages().await; Ok(()) } + async fn collect_task_tool_requests(&self, config: &Arc) -> Result> { + if !self.include_task_tools { + return Ok(vec![]); + } + + let task_context = self.monorepo.then(TaskLoadContext::all); + let tasks = config.tasks_with_context(task_context.as_ref()).await?; + let context_builder = TaskContextBuilder::new(); + let installer = TaskToolInstaller::new(&context_builder, &[]); + installer + .collect_tool_requests(config, tasks.values()) + .await + } + /// one-time hint when `[bootstrap.packages]` entries are missing — mise /// never installs system packages implicitly async fn hint_missing_system_packages(&self) { @@ -222,6 +248,7 @@ impl Install { config: Arc, runtimes: &[ToolArg], original_tool_args: Vec, + task_requests: Vec, ) -> Result<()> { let monorepo_union = if self.monorepo { Some(config.monorepo_union().await?) @@ -231,10 +258,11 @@ impl Install { let mut install_config = self .effective_config(&config, monorepo_union.as_ref()) .await?; - let trs = match &monorepo_union { + let base_trs = match &monorepo_union { Some(union) => union.tool_request_set.clone(), None => config.get_tool_request_set().await?.clone(), }; + let trs = extend_tool_request_set(base_trs.clone(), &task_requests); // Expand wildcards (e.g., "pipx:*") to actual ToolArgs from config let mut has_unmatched_wildcard = false; @@ -285,6 +313,7 @@ impl Install { .filter_map(|cf| cf.to_tool_request_set().ok()) .flat_map(|cf_trs| cf_trs.tools.into_keys().map(|ba| ba.short.clone())) .chain(tool_env_vars().map(|(name, _, _)| name)) + .chain(task_requests.iter().map(|tr| tr.ba().short.clone())) .collect(); let inactive_tools: Vec = tools .iter() @@ -292,7 +321,11 @@ impl Install { .cloned() .collect(); let mut ts: Toolset = trs.filter_by_tool(tools).into(); - let tool_versions = self.get_requested_tool_versions(&ts, &expanded_runtimes)?; + extend_toolset(&mut ts, &task_requests); + let mut tool_versions = self.get_requested_tool_versions(&ts, &expanded_runtimes)?; + tool_versions.extend(task_requests); + dedup_tool_requests(&mut tool_versions); + ensure_compatible_install_requests(&tool_versions)?; let (mut versions, install_error) = if tool_versions.is_empty() { warn!("no runtimes to install"); warn!("specify a version with `mise install @`"); @@ -337,7 +370,8 @@ impl Install { let rebuild_config = self.effective_config(&config, None).await?; let ts_owned; let ts = if self.monorepo { - ts_owned = Self::resolved_toolset_from_trs(&rebuild_config, trs.clone()).await?; + ts_owned = + Self::resolved_toolset_from_trs(&rebuild_config, base_trs.clone()).await?; &ts_owned } else { rebuild_config.get_toolset().await? @@ -463,18 +497,31 @@ impl Install { Ok(requests) } - async fn install_missing_runtimes(&self, config: Arc) -> eyre::Result<()> { + async fn install_missing_runtimes( + &self, + config: Arc, + task_requests: Vec, + ) -> eyre::Result<()> { let monorepo_union = if self.monorepo { Some(config.monorepo_union().await?) } else { None }; - let trs = measure!("get_tool_request_set", { + let base_trs = measure!("get_tool_request_set", { match &monorepo_union { Some(union) => union.tool_request_set.clone(), None => config.get_tool_request_set().await?.clone(), } }); + let trs = extend_tool_request_set(base_trs.clone(), &task_requests); + let install_candidates = trs + .tools + .values() + .flatten() + .filter(|tr| tr.is_os_supported() && !matches!(tr, ToolRequest::System { .. })) + .cloned() + .collect_vec(); + ensure_compatible_install_requests(&install_candidates)?; let mut install_config = self .effective_config(&config, monorepo_union.as_ref()) .await?; @@ -500,12 +547,7 @@ impl Install { } let requests = measure!("fetching install runtimes", { if self.force { - trs.tools - .values() - .flatten() - .filter(|tr| tr.is_os_supported() && !matches!(tr, ToolRequest::System { .. })) - .cloned() - .collect_vec() + install_candidates } else { trs.missing_tools(&install_config) .await @@ -525,9 +567,9 @@ impl Install { let ts = if self.is_dry_run() { // Preview mode only needs the hook-selection context. Avoid // resolving the full toolset solely to describe the hook. - ts_owned = Toolset::from(trs.clone()); + ts_owned = Toolset::from(base_trs.clone()); &ts_owned - } else if self.monorepo { + } else if self.monorepo || self.include_task_tools { ts_owned = Self::resolved_toolset_from_trs(&install_config, trs.clone()).await?; &ts_owned @@ -566,11 +608,17 @@ impl Install { let ts_owned; let ts = if self.monorepo { ts_owned = - Self::resolved_toolset_from_trs(&rebuild_config, trs.clone()).await?; + Self::resolved_toolset_from_trs(&rebuild_config, base_trs.clone()).await?; &ts_owned } else { rebuild_config.get_toolset().await? }; + let current_versions = ts.list_current_versions(); + let versions = versions + .iter() + .filter(|tv| current_versions.iter().any(|(_, current)| *tv == current)) + .cloned() + .collect::>(); config::rebuild_shims_and_runtime_symlinks( &rebuild_config, ts, @@ -609,6 +657,49 @@ impl Install { } } +type ToolRequestIdentity = (String, String, ToolVersionOptions); + +fn tool_request_identity(request: &ToolRequest) -> ToolRequestIdentity { + (request.ba().full(), request.version(), request.options()) +} + +fn extend_tool_request_set( + mut requests: ToolRequestSet, + additional: &[ToolRequest], +) -> ToolRequestSet { + let mut seen = requests + .tools + .values() + .flatten() + .map(tool_request_identity) + .collect::>(); + for request in additional { + if seen.insert(tool_request_identity(request)) { + requests.add_version(request.clone(), request.source()); + } + } + requests +} + +fn dedup_tool_requests(requests: &mut Vec) { + let mut seen = HashSet::new(); + requests.retain(|request| seen.insert(tool_request_identity(request))); +} + +fn extend_toolset(toolset: &mut Toolset, additional: &[ToolRequest]) { + let mut seen = toolset + .versions + .values() + .flat_map(|versions| &versions.requests) + .map(tool_request_identity) + .collect::>(); + for request in additional { + if seen.insert(tool_request_identity(request)) { + toolset.add_version(request.clone()); + } + } +} + static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: @@ -616,5 +707,55 @@ static AFTER_LONG_HELP: &str = color_print::cstr!( $ mise install node@20 # install fuzzy node version $ mise install node # install version specified in mise.toml $ mise install # installs everything specified in mise.toml + $ mise install --include-task-tools # also install tools required by tasks "# ); + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::args::BackendArg; + use crate::toolset::parse_tool_options; + + fn request(version: &str, options: &str, source: ToolSource) -> ToolRequest { + ToolRequest::new_opts( + Arc::new(BackendArg::new( + "tiny".to_string(), + Some("asdf:mise-plugins/mise-tiny".to_string()), + )), + version, + parse_tool_options(options), + source, + ) + .unwrap() + } + + #[test] + fn task_request_union_deduplicates_identical_requests_across_sources() { + let configured = request("1.0.0", "", ToolSource::Argument); + let task = request( + "1.0.0", + "", + ToolSource::MiseToml(PathBuf::from("mise.toml")), + ); + let mut requests = ToolRequestSet::new(); + requests.add_version(configured.clone(), configured.source()); + + let requests = extend_tool_request_set(requests, &[task]); + + assert_eq!(requests.tools.values().flatten().count(), 1); + } + + #[test] + fn task_request_union_preserves_versions_and_options() { + let configured = request("1.0.0", "flavor=one", ToolSource::Argument); + let task_version = request("2.0.0", "flavor=one", ToolSource::Argument); + let task_options = request("1.0.0", "flavor=two", ToolSource::Argument); + let mut requests = ToolRequestSet::new(); + requests.add_version(configured.clone(), configured.source()); + + let requests = extend_tool_request_set(requests, &[task_version, task_options]); + + assert_eq!(requests.tools.values().flatten().count(), 3); + } +} diff --git a/src/task/task_tool_installer.rs b/src/task/task_tool_installer.rs index e028a2ec658..b30ab3d2cff 100644 --- a/src/task/task_tool_installer.rs +++ b/src/task/task_tool_installer.rs @@ -1,8 +1,8 @@ use crate::cli::args::ToolArg; use crate::config::{Config, Settings}; -use crate::task::Deps; use crate::task::task_context_builder::TaskContextBuilder; use crate::task::task_helpers::canonicalize_path; +use crate::task::{Deps, Task}; use crate::toolset::{InstallOptions, ToolSource, ToolVersion, Toolset}; use eyre::Result; use std::collections::HashSet; @@ -31,34 +31,12 @@ impl<'a> TaskToolInstaller<'a> { dry_run: bool, previewed_tools: &HashSet, ) -> Result<()> { - let mut all_tools = self.cli_tools.to_vec(); - let mut all_tool_requests = vec![]; let all_tasks: Vec<_> = tasks.all().collect(); - - trace!("Collecting tools from {} tasks", all_tasks.len()); - - // Collect tools from tasks - for t in &all_tasks { - // Collect tools from task.tools (task-level tool overrides) - all_tools.extend(t.tool_args()?); - - // Collect tools from monorepo task config files - if let Some(task_cf) = t.cf(config) { - let tool_requests = self - .collect_tools_from_config_file(task_cf.clone(), &t.name) - .await?; - all_tool_requests.extend(tool_requests); - } else if let Some(config_root) = &t.config_root { - // For file tasks without a config file (e.g. scripts in .mise-tasks/), - // fall back to loading tools from the project's config hierarchy - let tool_requests = self.collect_tools_from_dir(config_root, &t.name).await?; - all_tool_requests.extend(tool_requests); - } - } + let all_tool_requests = self.collect_tool_requests(config, all_tasks).await?; // Build and install toolset let toolset = self - .build_toolset(config, all_tools, all_tool_requests) + .build_toolset(config, self.cli_tools.to_vec(), all_tool_requests) .await?; self.install_toolset(config, toolset, dry_run, previewed_tools) .await?; @@ -66,14 +44,41 @@ impl<'a> TaskToolInstaller<'a> { Ok(()) } - /// Collect tools from a task's config file hierarchy - async fn collect_tools_from_config_file( + /// Collect every tool request needed to prepare the supplied tasks without + /// executing their commands or dependency graphs. + pub async fn collect_tool_requests<'t>( &self, - task_cf: Arc, - task_name: &str, + config: &Arc, + tasks: impl IntoIterator, ) -> Result> { - let task_dir = task_cf.config_root(); - self.collect_tools_from_dir(&task_dir, task_name).await + let tasks = tasks.into_iter().collect::>(); + let mut requests = vec![]; + let mut seen_config_roots = HashSet::new(); + + trace!("Collecting tools from {} tasks", tasks.len()); + + for task in tasks { + requests.extend(task.tool_args()?.into_iter().filter_map(|tool| tool.tvr)); + + // Task execution combines task-level tools with the config hierarchy + // that owns the task. Keep pre-installation consistent with that + // environment, including file and monorepo tasks. + let config_root = task + .cf(config) + .map(|task_cf| task_cf.config_root()) + .or_else(|| task.config_root.clone()); + if let Some(config_root) = config_root { + let config_root = canonicalize_path(&config_root); + if seen_config_roots.insert(config_root.clone()) { + requests.extend( + self.collect_tools_from_dir(&config_root, &task.name) + .await?, + ); + } + } + } + + Ok(requests) } /// Collect tools from config files found in a directory hierarchy diff --git a/src/toolset/mod.rs b/src/toolset/mod.rs index 0a2039b979a..8fec89e3dad 100644 --- a/src/toolset/mod.rs +++ b/src/toolset/mod.rs @@ -33,6 +33,7 @@ use std::{ use tokio::sync::OnceCell; pub use install_options::InstallOptions; +pub(crate) use tool_deps::ensure_compatible_install_requests; pub use tool_request::ToolRequest; pub use tool_request_set::{ ToolRequestSet, ToolRequestSetBuilder, tool_env_var_name, tool_env_vars, tool_from_env_var_name, diff --git a/src/toolset/tool_deps.rs b/src/toolset/tool_deps.rs index 47b0d7cffff..7b21ad2f3d4 100644 --- a/src/toolset/tool_deps.rs +++ b/src/toolset/tool_deps.rs @@ -1,13 +1,13 @@ use std::collections::HashSet; -use eyre::Result; +use eyre::{Result, bail}; use tokio::sync::mpsc; use crate::cli::args::BackendArg; use crate::deps_graph::DepsGraph; use crate::toolset::tool_request::ToolRequest; -/// Unique key for a tool request (tool short name + version) +/// Unique key for a tool request (tool short name + version). pub type ToolKey = String; /// Creates a unique key for a ToolRequest @@ -15,6 +15,26 @@ pub(crate) fn tool_key(tr: &ToolRequest) -> ToolKey { format!("{}@{}", tr.ba().short, tr.version()) } +/// Multiple option variants cannot safely share one install destination. +/// Reject them before any install job starts instead of letting one variant +/// silently satisfy (or race with) another. +pub(crate) fn ensure_compatible_install_requests(requests: &[ToolRequest]) -> Result<()> { + let mut options_by_destination = std::collections::HashMap::new(); + for request in requests { + let key = tool_key(request); + let options = request.options(); + if let Some(existing) = options_by_destination.get(&key) + && existing != &options + { + bail!( + "conflicting options for {key}: multiple requests share the same install destination" + ); + } + options_by_destination.insert(key, options); + } + Ok(()) +} + /// Manages a dependency graph of tools for installation scheduling. /// Thin wrapper around `DepsGraph` with /// tool-specific dependency resolution. @@ -28,8 +48,10 @@ impl ToolDeps { /// Builds the dependency graph based on each tool's dependencies. /// Duplicate tool requests (same tool short name and version) are deduplicated. /// Distinct aliases may resolve to the same backend/version but still need separate - /// install jobs because they can have different options and install directories. + /// install jobs because they have distinct short names and install directories. pub fn new(requests: Vec) -> Result { + ensure_compatible_install_requests(&requests)?; + // Build nodes let nodes: Vec<(ToolKey, ToolRequest)> = requests .iter() @@ -124,7 +146,7 @@ mod tests { use std::sync::Arc; use crate::config::Config; - use crate::toolset::{ToolSource, ToolVersionOptions}; + use crate::toolset::{CoreToolOptions, ToolSource, ToolVersionOptions, parse_tool_options}; #[test] fn test_empty_deps() { @@ -168,4 +190,70 @@ mod tests { emitted.sort(); assert_eq!(emitted, vec!["bar".to_string(), "foo".to_string()]); } + + #[test] + fn test_same_tool_and_version_with_different_options_are_rejected() { + let backend = Arc::new(BackendArg::from("tiny")); + let requests = vec![ + ToolRequest::new_opts( + backend.clone(), + "1.0.0", + parse_tool_options("flavor=one"), + ToolSource::Argument, + ) + .unwrap(), + ToolRequest::new_opts( + backend, + "1.0.0", + parse_tool_options("flavor=two"), + ToolSource::Argument, + ) + .unwrap(), + ]; + + let err = ToolDeps::new(requests).unwrap_err(); + assert!( + err.to_string() + .contains("conflicting options for tiny@1.0.0") + ); + } + + #[test] + fn test_same_tool_and_version_with_different_core_options_are_rejected() { + let backend = Arc::new(BackendArg::from("tiny")); + let first_options = ToolVersionOptions { + core: CoreToolOptions { + depends: Some(vec!["node".to_string()]), + ..Default::default() + }, + ..Default::default() + }; + let second_options = ToolVersionOptions { + core: CoreToolOptions { + depends: Some(vec!["python".to_string()]), + ..Default::default() + }, + ..Default::default() + }; + let requests = vec![ + ToolRequest::Version { + backend: backend.clone(), + version: "1.0.0".to_string(), + options: first_options, + source: ToolSource::Argument, + }, + ToolRequest::Version { + backend, + version: "1.0.0".to_string(), + options: second_options, + source: ToolSource::Argument, + }, + ]; + + let err = ToolDeps::new(requests).unwrap_err(); + assert!( + err.to_string() + .contains("conflicting options for tiny@1.0.0") + ); + } } diff --git a/src/toolset/toolset_install.rs b/src/toolset/toolset_install.rs index 169e3791ef2..5f6395c542b 100644 --- a/src/toolset/toolset_install.rs +++ b/src/toolset/toolset_install.rs @@ -17,7 +17,7 @@ use crate::registry::REGISTRY; use crate::toolset::Toolset; use crate::toolset::helpers::{preflight_system_deps, show_python_install_hint}; use crate::toolset::install_options::InstallOptions; -use crate::toolset::tool_deps::{ToolDeps, tool_key}; +use crate::toolset::tool_deps::{ToolDeps, ensure_compatible_install_requests, tool_key}; use crate::toolset::tool_request::ToolRequest; use crate::toolset::tool_source::ToolSource; use crate::toolset::tool_version::{ResolveOptions, ToolVersion}; @@ -152,15 +152,20 @@ impl Toolset { mut versions: Vec, opts: &InstallOptions, ) -> Result> { - // Install all plugins from [plugins] config section first - // This must happen before the empty check so plugins are installed - // even when there are no tools to install (e.g., env-only plugins) - Self::ensure_config_plugins_installed(config, opts.dry_run).await?; - if versions.is_empty() { + // Configured plugins are still installed when no tools need work + // (for example, env-only plugins). + Self::ensure_config_plugins_installed(config, opts.dry_run).await?; return Ok(vec![]); } + self.init_request_options(&mut versions); + ensure_compatible_install_requests(&versions)?; + + // Validate shared install destinations before plugin installation or + // hooks introduce side effects. + Self::ensure_config_plugins_installed(config, opts.dry_run).await?; + // Initialize a footer for the entire install session once (before batching) let mpr = MultiProgressReport::get(); let footer_reason = if opts.dry_run { @@ -172,7 +177,6 @@ impl Toolset { hooks::run_one_hook(config, self, Hooks::Preinstall, None, opts.dry_run).await; - self.init_request_options(&mut versions); show_python_install_hint(&versions); let mut disabled_backend_errors = vec![]; @@ -280,20 +284,14 @@ impl Toolset { .await; } else { // Run post-install hook with installed tools info - // Use the full resolved toolset so all installed tools are on PATH - // Fall back to self if toolset resolution fails (e.g. due to config issues) + // `self` was re-resolved after the config reload above and still + // contains explicitly requested and task-only tools that are not + // present in the reloaded project config. let installed_tools: Vec = installed.iter().map(InstalledToolInfo::from).collect(); - let ts = match config.get_toolset().await { - Ok(ts) => ts, - Err(e) => { - debug!("error resolving toolset for postinstall hook: {e:#}"); - self - } - }; hooks::run_one_hook_with_context( config, - ts, + self, Hooks::Postinstall, None, Some(&installed_tools),