Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 48 additions & 0 deletions e2e/plugins/test_plugins_section_auto_install
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash

# Test that plugins in [plugins] section are auto-installed during mise install
# even when there are no corresponding tools in [tools]

# Use mise-env-fnox - an env-only plugin with no corresponding tool
rm -rf "$MISE_DATA_DIR/plugins/fnox-env"
mise plugin uninstall fnox-env 2>/dev/null || true

# Create a config with only a plugin (no tools using it)
cat <<EOF >mise.toml
[plugins]
fnox-env = "https://github.com/jdx/mise-env-fnox"
EOF

# Run mise install - plugin should be auto-installed even with no tools
mise install

# Verify plugin was actually installed (has git refs, not just registered)
# Using --refs shows URL and git info only for installed plugins
assert_contains "mise plugin ls --refs" "fnox-env"
assert_contains "mise plugin ls --refs" "https://github.com/jdx/mise-env-fnox"

# Now test with both a plugin-only entry and a tool
rm -rf "$MISE_DATA_DIR/plugins/fnox-env"
rm -rf "$MISE_DATA_DIR/plugins/zprint"
mise plugin uninstall fnox-env 2>/dev/null || true
mise plugin uninstall zprint 2>/dev/null || true

cat <<EOF >mise.toml
[plugins]
fnox-env = "https://github.com/jdx/mise-env-fnox"
zprint = "https://github.com/mise-plugins/mise-zprint"

[tools]
zprint = "latest"
EOF

# Run mise install - both plugins should be installed, zprint tool should be installed
mise install

# Verify both plugins were actually installed (have git refs)
# The URL only appears in --refs output when the plugin is actually installed
assert_contains "mise plugin ls --refs" "https://github.com/jdx/mise-env-fnox"
assert_contains "mise plugin ls --refs" "https://github.com/mise-plugins/mise-zprint"

# Verify zprint tool was installed
assert_contains "mise ls --installed zprint" "zprint"
4 changes: 4 additions & 0 deletions src/cli/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ impl Install {
config.get_tool_request_set().await?
});

// Install plugins from [plugins] config section first
// This must happen before checking for missing tools so env-only plugins get installed
Toolset::ensure_config_plugins_installed(&config, self.dry_run).await?;

// Check for tools that don't exist in the registry
// These were tracked during build() before being filtered out
for ba in &trs.unknown_tools {
Expand Down
52 changes: 52 additions & 0 deletions src/toolset/toolset_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::config::settings::Settings;
use crate::errors::Error;
use crate::hooks::{Hooks, InstalledToolInfo};
use crate::install_context::InstallContext;
use crate::plugins::PluginType;
use crate::toolset::Toolset;
use crate::toolset::helpers::{get_leaf_dependencies, show_python_install_hint};
use crate::toolset::install_options::InstallOptions;
Expand Down Expand Up @@ -87,6 +88,11 @@ impl Toolset {
mut versions: Vec<ToolRequest>,
opts: &InstallOptions,
) -> Result<Vec<ToolVersion>> {
// 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() {
return Ok(vec![]);
}
Expand Down Expand Up @@ -464,4 +470,50 @@ impl Toolset {
}
Ok(None)
}

/// Install all plugins defined in [plugins] config section
pub async fn ensure_config_plugins_installed(
config: &Arc<Config>,
dry_run: bool,
) -> Result<()> {
if config.repo_urls.is_empty() {
return Ok(());
}

let mpr = MultiProgressReport::get();

for (plugin_key, url) in &config.repo_urls {
let (plugin_type, name) = Self::parse_plugin_key(plugin_key, url);

// Skip empty plugin names (e.g., from malformed keys like "" or "vfox:")
if name.is_empty() {
warn!("skipping empty plugin name from key: {plugin_key}");
continue;
}

let plugin = plugin_type.plugin(name.to_string());

if !plugin.is_installed() {
plugin
.ensure_installed(config, &mpr, false, dry_run)
.await?;
}
}
Ok(())
}

fn parse_plugin_key<'a>(key: &'a str, url: &str) -> (PluginType, &'a str) {
if let Some(name) = key.strip_prefix("vfox:") {
(PluginType::Vfox, name)
} else if let Some(name) = key.strip_prefix("vfox-backend:") {
(PluginType::VfoxBackend, name)
} else if let Some(name) = key.strip_prefix("asdf:") {
(PluginType::Asdf, name)
} else if url.contains("vfox-") {
// Match existing behavior from config/mod.rs:226-228
(PluginType::Vfox, key)
} else {
(PluginType::Asdf, key)
}
}
Comment on lines +505 to +518
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin type parsing logic is duplicated from config/mod.rs (lines 226-228). Consider extracting this logic into a shared helper function in a common module to avoid code duplication and ensure consistent behavior across the codebase.

Copilot uses AI. Check for mistakes.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty plugin name causes panic in debug builds

Medium Severity

The parse_plugin_key function can return an empty string for the plugin name when the config key is empty ("") or contains only a prefix like "vfox:". This empty name is then passed to plugin_type.plugin(), which calls AsdfPlugin::new or VfoxPlugin::new - both have #[requires(!name.is_empty())] contracts that panic in debug builds. In release builds, the plugin path becomes the plugins directory itself (dirs::PLUGINS), causing silent incorrect behavior.

Additional Locations (1)

Fix in Cursor Fix in Web

}
Loading