Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
29 changes: 26 additions & 3 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,26 @@ leave = "echo 'I left the project'"

## Preinstall/postinstall hook

These hooks are run before and after tools are installed (respectively). Unlike other hooks, these hooks do not require `mise activate`.
These hooks are run before and after each tool is installed (respectively). Unlike other hooks, these hooks do not require `mise activate`.

The hooks run once per tool being installed, with `MISE_TOOL_NAME` and `MISE_TOOL_VERSION` environment variables set to the tool being processed.

```toml
[hooks]
preinstall = "echo 'About to install $MISE_TOOL_NAME@$MISE_TOOL_VERSION'"
postinstall = "echo 'Finished installing $MISE_TOOL_NAME@$MISE_TOOL_VERSION'"
```

You can use these variables to run conditional logic based on the tool:

```toml
[hooks]
preinstall = "echo 'I am about to install tools'"
postinstall = "echo 'I just installed tools'"
postinstall = '''
if [[ "$MISE_TOOL_NAME" == "node" ]]; then
echo "Node.js $MISE_TOOL_VERSION installed, running npm setup..."
npm config set prefix ~/.npm-global
fi
'''
```

## Watch files hook
Expand All @@ -63,6 +77,15 @@ Hooks are executed with the following environment variables set:
- `MISE_PROJECT_ROOT`: The root directory of the project.
- `MISE_PREVIOUS_DIR`: The directory that the user was in before the directory change (only if a directory change occurred).

For `preinstall` and `postinstall` hooks, the following additional environment variables are set:

- `MISE_TOOL_NAME`: The short name of the tool being installed (e.g., `node`, `python`, `go`).
- `MISE_TOOL_VERSION`: The version of the tool being installed (e.g., `20.10.0`, `3.12.0`).

For tool-level postinstall hooks (defined on the tool itself), an additional variable is available:

- `MISE_TOOL_INSTALL_PATH`: The installation path of the tool.

## Shell hooks

Hooks can be executed in the current shell, for example if you'd like to add bash completions when entering a directory:
Expand Down
20 changes: 19 additions & 1 deletion e2e/config/test_hooks_postinstall_env
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ EXPLICIT_PRE_TOOLS = {value = "explicitly_pre_tools", tools = false}
POST_TOOLS_VAR = {value = "available_after_tools", tools = true}

[tools]
dummy = { version = "latest", postinstall = "echo PRE_TOOLS_VAR=\$PRE_TOOLS_VAR; echo EXPLICIT_PRE_TOOLS=\$EXPLICIT_PRE_TOOLS; echo POST_TOOLS_VAR=\$POST_TOOLS_VAR" }
dummy = { version = "latest", postinstall = "echo PRE_TOOLS_VAR=\$PRE_TOOLS_VAR; echo EXPLICIT_PRE_TOOLS=\$EXPLICIT_PRE_TOOLS; echo POST_TOOLS_VAR=\$POST_TOOLS_VAR; echo MISE_TOOL_NAME=\$MISE_TOOL_NAME; echo MISE_TOOL_VERSION=\$MISE_TOOL_VERSION" }
EOF

# Remove any existing dummy installation to force reinstall
Expand Down Expand Up @@ -58,3 +58,21 @@ else
echo "Output: $output"
exit 1
fi

# Verify MISE_TOOL_NAME is set
if [[ $output == *"MISE_TOOL_NAME=dummy"* ]]; then
echo "✓ MISE_TOOL_NAME is set correctly"
else
echo "✗ MISE_TOOL_NAME is not set correctly"
echo "Output: $output"
exit 1
fi

# Verify MISE_TOOL_VERSION is set (should be "latest" resolved version)
if [[ $output == *"MISE_TOOL_VERSION="* ]] && [[ $output != *"MISE_TOOL_VERSION=$"* ]]; then
echo "✓ MISE_TOOL_VERSION is set"
else
echo "✗ MISE_TOOL_VERSION is not set"
echo "Output: $output"
exit 1
fi
20 changes: 20 additions & 0 deletions e2e/config/test_hooks_tool_env
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash

# Test that MISE_TOOL_NAME and MISE_TOOL_VERSION are set in preinstall/postinstall hooks

cat <<EOF >mise.toml
[tools]
dummy = 'latest'

[hooks]
preinstall = 'echo "PREINSTALL: name=\$MISE_TOOL_NAME version=\$MISE_TOOL_VERSION"'
postinstall = 'echo "POSTINSTALL: name=\$MISE_TOOL_NAME version=\$MISE_TOOL_VERSION"'
EOF

# Install dummy tool and check that env vars are set
assert_contains "mise i dummy@1.0.0 2>&1" "PREINSTALL: name=dummy version=1.0.0"
assert_contains "mise i dummy@1.0.0 2>&1" "POSTINSTALL: name=dummy version=1.0.0"

# Test with a different version
assert_contains "mise i dummy@2.0.0 2>&1" "PREINSTALL: name=dummy version=2.0.0"
assert_contains "mise i dummy@2.0.0 2>&1" "POSTINSTALL: name=dummy version=2.0.0"
2 changes: 2 additions & 0 deletions src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,8 @@ pub trait Backend: Debug + Send + Sync {
CmdLineRunner::new(&*env::SHELL)
.env(&*env::PATH_KEY, plugins::core::path_env_with_tv_path(tv)?)
.env("MISE_TOOL_INSTALL_PATH", tv.install_path())
.env("MISE_TOOL_NAME", &tv.ba().short)
.env("MISE_TOOL_VERSION", &tv.version)
.with_pr(ctx.pr.as_ref())
.arg(env::SHELL_COMMAND_FLAG)
.arg(script)
Expand Down
54 changes: 52 additions & 2 deletions src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ use std::sync::Mutex;
use std::{iter::once, sync::Arc};
use tokio::sync::OnceCell;

/// Context for tool-specific hooks (preinstall/postinstall)
#[derive(Debug, Clone)]
pub struct HookToolContext {
pub name: String,
pub version: String,
}

#[derive(
Debug,
Clone,
Expand Down Expand Up @@ -84,6 +91,38 @@ pub async fn run_one_hook(
ts: &Toolset,
hook: Hooks,
shell: Option<&dyn Shell>,
) {
run_one_hook_with_shell(config, ts, hook, shell).await;
}

/// Run a hook with optional tool context for preinstall/postinstall hooks (used during installation)

Copilot AI Dec 15, 2025

Copy link

Choose a reason for hiding this comment

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

The parameter is not optional - tool_ctx is a required &HookToolContext parameter. The docstring should reflect that this function requires tool context rather than describing it as optional.

Suggested change
/// Run a hook with optional tool context for preinstall/postinstall hooks (used during installation)
/// Run a hook with required tool context for preinstall/postinstall hooks (used during installation).

Copilot uses AI. Check for mistakes.
/// This version doesn't take a shell parameter and can be used in spawned async tasks.
#[async_backtrace::framed]
pub async fn run_one_hook_with_tool(
config: &Arc<Config>,
ts: &Toolset,
hook: Hooks,
tool_ctx: &HookToolContext,
) {
for (root, h) in all_hooks(config).await {
if hook != h.hook || h.shell.is_some() {
// Skip shell-specific hooks during installation

Copilot AI Dec 15, 2025

Copy link

Choose a reason for hiding this comment

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

The comment only explains one condition. It should clarify that hooks are skipped if either the hook type doesn't match OR if it's a shell-specific hook, not just the latter.

Suggested change
// Skip shell-specific hooks during installation
// Skip hooks if the type doesn't match or if it's shell-specific (during installation)

Copilot uses AI. Check for mistakes.
continue;
}
trace!("running hook {hook} in {root:?}");
if let Err(e) = execute(config, ts, root, h, Some(tool_ctx)).await {
warn!("error executing hook: {e}");
}
}
}

/// Run a hook with optional shell context (used during activate)
#[async_backtrace::framed]
async fn run_one_hook_with_shell(
config: &Arc<Config>,
ts: &Toolset,
hook: Hooks,
shell: Option<&dyn Shell>,
) {
for (root, h) in all_hooks(config).await {
if hook != h.hook || (h.shell.is_some() && h.shell != shell.map(|s| s.to_string())) {
Expand Down Expand Up @@ -116,7 +155,7 @@ pub async fn run_one_hook(
}
if h.shell.is_some() {
println!("{}", h.script);
} else if let Err(e) = execute(config, ts, root, h).await {
} else if let Err(e) = execute(config, ts, root, h, None).await {
warn!("error executing hook: {e}");
}
}
Expand Down Expand Up @@ -159,7 +198,13 @@ impl Hook {
}
}

async fn execute(config: &Arc<Config>, ts: &Toolset, root: &Path, hook: &Hook) -> Result<()> {
async fn execute(
config: &Arc<Config>,
ts: &Toolset,
root: &Path,
hook: &Hook,
tool_ctx: Option<&HookToolContext>,
) -> Result<()> {
Settings::get().ensure_experimental("hooks")?;
let shell = Settings::get().default_inline_shell()?;

Expand All @@ -186,6 +231,11 @@ async fn execute(config: &Arc<Config>, ts: &Toolset, root: &Path, hook: &Hook) -
old.to_string_lossy().to_string(),
);
}
// Add tool context for preinstall/postinstall hooks
if let Some(ctx) = tool_ctx {
env.insert("MISE_TOOL_NAME".to_string(), ctx.name.clone());
env.insert("MISE_TOOL_VERSION".to_string(), ctx.version.clone());
}
// TODO: this should be different but I don't have easy access to it
// env.insert("MISE_CONFIG_ROOT".to_string(), root.to_string_lossy().to_string());
cmd(&shell[0], args)
Expand Down
50 changes: 36 additions & 14 deletions src/toolset/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::config::settings::{Settings, SettingsStatusMissingTools};
use crate::env::{PATH_KEY, TERM_WIDTH};
use crate::env_diff::EnvMap;
use crate::errors::Error;
use crate::hooks::Hooks;
use crate::hooks::{HookToolContext, Hooks};
use crate::install_context::InstallContext;
use crate::path_env::PathEnv;
use crate::registry::tool_enabled;
Expand Down Expand Up @@ -249,12 +249,6 @@ impl Toolset {
};
mpr.init_footer(opts.dry_run, &footer_reason, versions.len());

// Skip hooks in dry-run mode
if !opts.dry_run {
// Run pre-install hook
hooks::run_one_hook(config, self, Hooks::Preinstall, None).await;
}

self.init_request_options(&mut versions);
show_python_install_hint(&versions);

Expand Down Expand Up @@ -325,12 +319,6 @@ impl Toolset {
}
}

// Skip hooks in dry-run mode
if !opts.dry_run {
// Run post-install hook (ignoring errors)
let _ = hooks::run_one_hook(config, self, Hooks::Postinstall, None).await;
}

// Finish the global footer
if !opts.dry_run {
mpr.footer_finish();
Expand Down Expand Up @@ -468,6 +456,22 @@ impl Toolset {
for tr in filtered_trs {
let result = async {
let tv = tr.resolve(&config, &opts.resolve_options).await?;

// Run per-tool preinstall hook
if !opts.dry_run {
let tool_ctx = HookToolContext {
name: tv.ba().short.clone(),
version: tv.version.clone(),
};
hooks::run_one_hook_with_tool(
&config,
&ts,
Hooks::Preinstall,
&tool_ctx,
)
.await;
}

let ctx = InstallContext {
config: config.clone(),
ts: ts.clone(),
Expand All @@ -478,7 +482,25 @@ impl Toolset {
};
// Avoid wrapping the backend error here so the error location
// points to the backend implementation (more helpful for debugging).
ba.install_version(ctx, tv).await
let result = ba.install_version(ctx, tv).await;

// Run per-tool postinstall hook (only on success)
if !opts.dry_run
&& let Ok(ref installed_tv) = result {
let tool_ctx = HookToolContext {
name: installed_tv.ba().short.clone(),
version: installed_tv.version.clone(),
};
hooks::run_one_hook_with_tool(
&config,
&ts,
Hooks::Postinstall,
&tool_ctx,
)
.await;
}

result
}
.await;

Expand Down
Loading