From 6b58a58ebc25780745f74d90577f0261d473bdcb Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:28:26 +0000 Subject: [PATCH 1/6] feat(bootstrap): add phase hooks --- docs/bootstrap.md | 50 +++++++- docs/cli/bootstrap.md | 7 ++ docs/tips-and-tricks.md | 3 + mise.usage.kdl | 7 ++ src/cli/bootstrap.rs | 107 ++++++++++++++++- src/config/config_file/mise_toml.rs | 8 ++ src/system/hooks.rs | 179 ++++++++++++++++++++++++++++ src/system/mod.rs | 28 ++++- 8 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 src/system/hooks.rs diff --git a/docs/bootstrap.md b/docs/bootstrap.md index 85071791c4..f777f8fbab 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -2,7 +2,8 @@ `mise bootstrap` sets up the machine-level pieces around a mise config: OS packages, dotfiles, macOS defaults, macOS LaunchAgents, the user's login -shell, tools, and any final project-specific task. +shell, tools, and any final project-specific task. You can also add hooks that +run at named points in the bootstrap sequence. Use bootstrap for things that are needed before a project or workstation is ready, but that do not belong in `[tools]`: native libraries, Homebrew @@ -20,6 +21,12 @@ machine setup. 5. `mise bootstrap user apply` applies `[bootstrap.user]`. 6. `mise install` installs missing `[tools]`. 7. `mise run bootstrap` runs a task named `bootstrap`, if one exists. +8. `[bootstrap.hooks.final]` runs after the bootstrap task, if configured. + +Hook phases can also run before and after the built-in steps: +`pre-packages`, `post-packages`, `pre-dotfiles`, `post-dotfiles`, +`pre-defaults`, `post-defaults`, `pre-user`, `post-user`, `pre-tools`, and +`post-tools`. The declarative steps converge: if a package is already installed, a dotfile already matches, or a default is already set, mise skips it. The `bootstrap` @@ -48,6 +55,12 @@ run_at_load = true [bootstrap.user] login_shell = "/bin/zsh" +[bootstrap.hooks.pre-packages] +run = "softwareupdate --install-rosetta --agree-to-license" + +[bootstrap.hooks.post-defaults] +run = "killall Dock || true" + [tools] node = "lts" python = "3.12" @@ -96,6 +109,7 @@ place but should not install anything during that check. | `[bootstrap.macos.defaults]` | macOS user preferences written through `defaults write` | | `[bootstrap.macos.launchd.agents]` | macOS user LaunchAgents written and loaded with `launchctl` | | `[bootstrap.user]` | Current-user settings such as `login_shell` | +| `[bootstrap.hooks]` | Commands that run at named bootstrap phases | | `[tools]` | Versioned dev tools managed by mise | | `[tasks.bootstrap]` | Anything custom that should run after tools are installed | @@ -104,6 +118,40 @@ Use declarative sections when mise can inspect and converge the state. Use such as cloning a private repository, running an auth flow, or seeding local data. +## Hooks + +Hooks run only during explicit `mise bootstrap` invocations. They may be +specified as a command string, an array of command strings, or a table with a +`run` field. They use the same default inline shell setting as tasks, stop the +bootstrap if they fail, and print the command instead of running it during +`mise bootstrap --dry-run`. Hooks run in the current process environment; use +`mise exec -- ...` inside a hook, or use `[tasks.bootstrap]`, when the command +needs tools from `[tools]` on PATH. + +```toml +[bootstrap.hooks.pre-packages] +run = "softwareupdate --install-rosetta --agree-to-license" + +[bootstrap.hooks.post-tools] +run = [ + "mise exec -- corepack enable", + "mise exec -- rustup component add rustfmt clippy", +] + +[bootstrap.hooks.final] +run = "gh auth status || gh auth login" +``` + +As shorthand, a hook phase can also be set directly: + +```toml +[bootstrap.hooks] +post-defaults = "killall Dock || true" +``` + +Hooks merge across the config hierarchy from global to local, so shared config +can define broad machine setup while a project adds its own phase commands. + ## Common Workflows ### New Machine diff --git a/docs/cli/bootstrap.md b/docs/cli/bootstrap.md index 0bdf6ee98f..f149e7c578 100644 --- a/docs/cli/bootstrap.md +++ b/docs/cli/bootstrap.md @@ -8,16 +8,23 @@ Runs the bootstrap steps for the current config in order: +0. `[bootstrap.hooks.pre-packages]` — optional setup hook 1. `mise bootstrap packages install` — install missing `[bootstrap.packages]` + then `[bootstrap.hooks.post-packages]` 2. `mise dotfiles apply` — apply dotfiles from `[dotfiles]` + surrounded by `pre-dotfiles`/`post-dotfiles` hooks 3. `mise bootstrap macos-defaults apply` — write `[bootstrap.macos.defaults]` entries (macOS) + surrounded by `pre-defaults`/`post-defaults` hooks 4. `mise bootstrap launchd apply` — install/load macOS LaunchAgents 5. `mise bootstrap user apply` — set `[bootstrap.user].login_shell` (Unix) + surrounded by `pre-user`/`post-user` hooks 6. `mise install` — install missing tools from `[tools]` + surrounded by `pre-tools`/`post-tools` hooks 7. `mise run bootstrap` — if a task named `bootstrap` is defined +8. `[bootstrap.hooks.final]` — optional final hook The declarative steps converge — anything already in its desired state is skipped, so re-running is safe. The `bootstrap` task runs on every diff --git a/docs/tips-and-tricks.md b/docs/tips-and-tricks.md index 9f75a9892e..04ad261dfc 100644 --- a/docs/tips-and-tricks.md +++ b/docs/tips-and-tricks.md @@ -102,6 +102,9 @@ run_at_load = true [bootstrap.user] # current user's login shell login_shell = "/bin/zsh" +[bootstrap.hooks.post-defaults] # optional phase hooks +run = "killall Dock || true" + [tasks.bootstrap] # anything else, with tools on PATH run = "gh auth status || gh auth login" ``` diff --git a/mise.usage.kdl b/mise.usage.kdl index ed597e8ab0..5b74380e9b 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -286,16 +286,23 @@ cmd bootstrap help="[experimental] Set up a machine for the current config in on Runs the bootstrap steps for the current config in order: +0. `[bootstrap.hooks.pre-packages]` — optional setup hook 1. `mise bootstrap packages install` — install missing `[bootstrap.packages]` + then `[bootstrap.hooks.post-packages]` 2. `mise dotfiles apply` — apply dotfiles from `[dotfiles]` + surrounded by `pre-dotfiles`/`post-dotfiles` hooks 3. `mise bootstrap macos-defaults apply` — write `[bootstrap.macos.defaults]` entries (macOS) + surrounded by `pre-defaults`/`post-defaults` hooks 4. `mise bootstrap launchd apply` — install/load macOS LaunchAgents 5. `mise bootstrap user apply` — set `[bootstrap.user].login_shell` (Unix) + surrounded by `pre-user`/`post-user` hooks 6. `mise install` — install missing tools from `[tools]` + surrounded by `pre-tools`/`post-tools` hooks 7. `mise run bootstrap` — if a task named `bootstrap` is defined +8. `[bootstrap.hooks.final]` — optional final hook The declarative steps converge — anything already in its desired state is skipped, so re-running is safe. The `bootstrap` task runs on every diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs index 40e51987b1..16dca113c8 100644 --- a/src/cli/bootstrap.rs +++ b/src/cli/bootstrap.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use eyre::Result; use serde_json::json; @@ -5,9 +7,12 @@ use super::install::Install; use super::run; use super::system::driver::{self, Action, DriverOpts}; use super::system::{install, status, upgrade, r#use}; -use crate::config::{Config, Settings}; +use crate::config::{self, Config, Settings}; +use crate::dirs; use crate::system; use crate::system::defaults::DefaultsState; +use crate::system::files::{FileMode, FileRequest}; +use crate::system::hooks::{self, BootstrapHookPhase}; use crate::system::launchd::LaunchdState; use crate::system::login_shell::LoginShellState; use crate::ui::table::MiseTable; @@ -17,16 +22,23 @@ use clap::Subcommand; /// /// Runs the bootstrap steps for the current config in order: /// +/// 0. `[bootstrap.hooks.pre-packages]` — optional setup hook /// 1. `mise bootstrap packages install` — install missing /// `[bootstrap.packages]` +/// then `[bootstrap.hooks.post-packages]` /// 2. `mise dotfiles apply` — apply dotfiles from `[dotfiles]` +/// surrounded by `pre-dotfiles`/`post-dotfiles` hooks /// 3. `mise bootstrap macos-defaults apply` — write /// `[bootstrap.macos.defaults]` entries (macOS) +/// surrounded by `pre-defaults`/`post-defaults` hooks /// 4. `mise bootstrap launchd apply` — install/load macOS LaunchAgents /// 5. `mise bootstrap user apply` — set `[bootstrap.user].login_shell` /// (Unix) +/// surrounded by `pre-user`/`post-user` hooks /// 6. `mise install` — install missing tools from `[tools]` +/// surrounded by `pre-tools`/`post-tools` hooks /// 7. `mise run bootstrap` — if a task named `bootstrap` is defined +/// 8. `[bootstrap.hooks.final]` — optional final hook /// /// The declarative steps converge — anything already in its desired state /// is skipped, so re-running is safe. The `bootstrap` task runs on every @@ -192,8 +204,11 @@ impl Bootstrap { if let Some(command) = self.command { return command.run().await; } - let config = Config::get().await?; + let mut config = Config::get().await?; + let mut hooks = system::hooks_from_config(&config); + self.run_hooks(&hooks, BootstrapHookPhase::PrePackages) + .await?; let mgrs = system::packages_from_config(&config); if mgrs.is_empty() { debug!("bootstrap: no [bootstrap.packages] configured, skipping"); @@ -208,7 +223,11 @@ impl Bootstrap { }; driver::run(mgrs, Action::Install, &opts).await?; } + self.run_hooks(&hooks, BootstrapHookPhase::PostPackages) + .await?; + self.run_hooks(&hooks, BootstrapHookPhase::PreDotfiles) + .await?; let files = system::files::files_from_config(&config); if files.is_empty() { debug!("bootstrap: no whole-file [dotfiles] entries configured, skipping"); @@ -237,7 +256,17 @@ impl Bootstrap { }; system::edits::apply(&config, &edits, &opts)?; } + if self.dry_run { + hooks = self.hooks_after_dotfiles_dry_run(&config, &files)?; + } else { + config = Config::reset().await?; + hooks = system::hooks_from_config(&config); + } + self.run_hooks(&hooks, BootstrapHookPhase::PostDotfiles) + .await?; + self.run_hooks(&hooks, BootstrapHookPhase::PreDefaults) + .await?; let defaults = system::defaults_from_config(&config); if defaults.is_empty() { debug!("bootstrap: no [bootstrap.macos.defaults] configured, skipping"); @@ -245,6 +274,8 @@ impl Bootstrap { info!("bootstrap: system defaults"); install::apply_defaults(defaults, self.dry_run, self.yes).await?; } + self.run_hooks(&hooks, BootstrapHookPhase::PostDefaults) + .await?; let agents = system::launchd_from_config(&config); if agents.is_empty() { @@ -254,6 +285,7 @@ impl Bootstrap { install::apply_launchd(agents, self.dry_run, self.yes).await?; } + self.run_hooks(&hooks, BootstrapHookPhase::PreUser).await?; let login_shell = system::login_shell_from_config(&config); if login_shell.is_none() { debug!("bootstrap: no [bootstrap.user].login_shell configured, skipping"); @@ -261,13 +293,18 @@ impl Bootstrap { info!("bootstrap: login shell"); install::apply_login_shell(login_shell, self.dry_run, self.yes)?; } + self.run_hooks(&hooks, BootstrapHookPhase::PostUser).await?; + self.run_hooks(&hooks, BootstrapHookPhase::PreTools).await?; info!("bootstrap: tools"); Install::new_bare(self.dry_run).run().await?; + if !self.dry_run { + config = Config::reset().await?; + hooks = system::hooks_from_config(&config); + } + self.run_hooks(&hooks, BootstrapHookPhase::PostTools) + .await?; - // installs may have changed the env (and `mise install` resets config - // internally), so re-fetch before looking up tasks - let config = Config::get().await?; let tasks = config.tasks().await?; if tasks.iter().any(|(_, t)| t.is_match("bootstrap")) { info!("bootstrap: running `bootstrap` task"); @@ -275,9 +312,44 @@ impl Bootstrap { } else { debug!("bootstrap: no `bootstrap` task defined, skipping"); } + self.run_hooks(&hooks, BootstrapHookPhase::Final).await?; Ok(()) } + async fn run_hooks( + &self, + hooks: &[hooks::BootstrapHook], + phase: BootstrapHookPhase, + ) -> Result<()> { + hooks::run_phase(hooks, phase, self.dry_run).await + } + + fn hooks_after_dotfiles_dry_run( + &self, + config: &Config, + files: &[FileRequest], + ) -> Result> { + let mut config_files = config.config_files.clone(); + for file in files { + if !is_mise_config_target(&file.target) || !file.source.is_file() { + continue; + } + match parse_dotfile_mise_config(config, file) { + Ok(cf) => { + config_files.insert(file.target.clone(), cf); + } + Err(err) => { + warn!( + "[dotfiles].\"{}\": failed to parse config source {}: {err}", + file.target_raw, + file.source.display() + ); + } + } + } + Ok(system::hooks_from_config_files(&config_files)) + } + async fn run_task(&self, task: &str) -> Result<()> { run::Run { task: task.into(), @@ -324,6 +396,31 @@ impl Bootstrap { } } +fn parse_dotfile_mise_config( + config: &Config, + file: &FileRequest, +) -> Result> { + let body = match file.mode { + FileMode::Template => system::files::render_template(config, file)?, + _ => crate::file::read_to_string(&file.source)?, + }; + Ok(Arc::new( + config::config_file::mise_toml::MiseToml::from_str(&body, &file.target)?, + )) +} + +fn is_mise_config_target(path: &std::path::Path) -> bool { + path.starts_with(*dirs::CONFIG) + || path.starts_with(*dirs::SYSTEM_CONFIG) + || config::DEFAULT_CONFIG_FILENAMES.iter().any(|filename| { + filename.ends_with(".toml") && !filename.contains('*') && path.ends_with(filename) + }) + || (path.extension().is_some_and(|ext| ext == "toml") + && path + .parent() + .is_some_and(|parent| parent.ends_with(".config/mise/conf.d"))) +} + impl Commands { async fn run(self) -> Result<()> { match self { diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 5ae297bf28..40bdf8248e 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -2215,6 +2215,12 @@ mod tests { [bootstrap.brew.taps] "railwaycat/emacsmacport" = "https://github.com/railwaycat/homebrew-emacsmacport" + + [bootstrap.hooks.pre-packages] + run = "echo preparing" + + [bootstrap.hooks.post-tools] + run = ["echo one", "echo two"] "#, ) .unwrap(); @@ -2227,6 +2233,8 @@ mod tests { system.brew.taps.get("railwaycat/emacsmacport").unwrap(), "https://github.com/railwaycat/homebrew-emacsmacport" ); + assert!(system.hooks.get("pre-packages").unwrap().is_table()); + assert!(system.hooks.get("post-tools").unwrap().is_table()); assert_eq!(system.user.login_shell, None); // unknown managers parse fine (forward compatibility) assert_eq!( diff --git a/src/system/hooks.rs b/src/system/hooks.rs new file mode 100644 index 0000000000..ef54020b1e --- /dev/null +++ b/src/system/hooks.rs @@ -0,0 +1,179 @@ +//! Bootstrap phase hooks for `[bootstrap.hooks]`. +//! +//! Hooks are imperative commands that run at named points during +//! `mise bootstrap`. They are intentionally explicit bootstrap behavior, not +//! part of `mise install` or shell activation. + +use std::fmt; + +use eyre::{Result, bail}; +use serde::Serialize; +use strum::{EnumIter, IntoEnumIterator}; + +use crate::config::Settings; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum BootstrapHookPhase { + PrePackages, + PostPackages, + PreDotfiles, + PostDotfiles, + PreDefaults, + PostDefaults, + PreUser, + PostUser, + PreTools, + PostTools, + Final, +} + +impl BootstrapHookPhase { + pub fn parse(raw: &str) -> Option { + let normalized = raw.replace('_', "-"); + Self::iter().find(|phase| phase.as_str() == normalized) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::PrePackages => "pre-packages", + Self::PostPackages => "post-packages", + Self::PreDotfiles => "pre-dotfiles", + Self::PostDotfiles => "post-dotfiles", + Self::PreDefaults => "pre-defaults", + Self::PostDefaults => "post-defaults", + Self::PreUser => "pre-user", + Self::PostUser => "post-user", + Self::PreTools => "pre-tools", + Self::PostTools => "post-tools", + Self::Final => "final", + } + } +} + +impl fmt::Display for BootstrapHookPhase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BootstrapHook { + pub phase: BootstrapHookPhase, + pub run: String, +} + +impl BootstrapHook { + pub fn from_toml(phase_raw: &str, value: toml::Value) -> Result> { + let Some(phase) = BootstrapHookPhase::parse(phase_raw) else { + bail!("unknown bootstrap hook phase"); + }; + let runs = match value { + toml::Value::String(run) => vec![run], + toml::Value::Array(values) => string_array(values, "expected string commands")?, + toml::Value::Table(mut table) => match table.remove("run") { + Some(toml::Value::String(run)) => vec![run], + Some(toml::Value::Array(values)) => { + string_array(values, "expected `run` to contain string commands")? + } + Some(_) => bail!("expected `run` to be a string or array of strings"), + None => bail!("expected a `run` command"), + }, + _ => bail!("expected a string, array of strings, or table with `run`"), + }; + let hooks = runs + .into_iter() + .filter_map(|run| { + let run = run.trim().to_string(); + if run.is_empty() { + warn!("[bootstrap.hooks.{phase}]: empty command, ignoring entry"); + None + } else { + Some(Self { phase, run }) + } + }) + .collect(); + Ok(hooks) + } +} + +fn string_array(values: Vec, message: &str) -> Result> { + let mut out = vec![]; + for value in values { + match value { + toml::Value::String(s) => out.push(s), + _ => bail!("{message}"), + } + } + Ok(out) +} + +pub async fn run_phase( + hooks: &[BootstrapHook], + phase: BootstrapHookPhase, + dry_run: bool, +) -> Result<()> { + let phase_hooks: Vec<_> = hooks.iter().filter(|hook| hook.phase == phase).collect(); + if phase_hooks.is_empty() { + return Ok(()); + } + info!("bootstrap: {phase} hooks"); + let shell = Settings::get().default_inline_shell()?; + for hook in phase_hooks { + if dry_run { + miseprintln!("{} {}", shell.join(" "), shell_words::quote(&hook.run)); + continue; + } + info!("$ {}", hook.run); + crate::cmd::CmdLineRunner::new(&shell[0]) + .cmd_body_args(&shell[1..], &hook.run) + .raw(true) + .execute_async() + .await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_known_phases_with_hyphen_or_underscore() { + assert_eq!( + BootstrapHookPhase::parse("pre-packages"), + Some(BootstrapHookPhase::PrePackages) + ); + assert_eq!( + BootstrapHookPhase::parse("post_tools"), + Some(BootstrapHookPhase::PostTools) + ); + assert_eq!(BootstrapHookPhase::parse("nope"), None); + } + + #[test] + fn parses_hook_values() { + let hooks = + BootstrapHook::from_toml("pre-packages", toml::Value::String("echo preparing".into())) + .unwrap(); + assert_eq!( + hooks, + vec![BootstrapHook { + phase: BootstrapHookPhase::PrePackages, + run: "echo preparing".into(), + }] + ); + + let mut table = toml::map::Map::new(); + table.insert( + "run".into(), + toml::Value::Array(vec![ + toml::Value::String("echo one".into()), + toml::Value::String("echo two".into()), + ]), + ); + let hooks = BootstrapHook::from_toml("final", toml::Value::Table(table)).unwrap(); + assert_eq!(hooks.len(), 2); + assert_eq!(hooks[1].run, "echo two"); + } +} diff --git a/src/system/mod.rs b/src/system/mod.rs index 4d42445905..d0a3a61be8 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -3,7 +3,9 @@ //! This is `[bootstrap.packages]` — declarative system packages installed //! by `mise bootstrap packages install` — `[dotfiles]` — declarative config //! files applied by `mise dotfiles apply` — `[bootstrap.macos.defaults]` -//! — declarative macOS user defaults — and `[bootstrap.user].login_shell`. +//! — declarative macOS user defaults — `[bootstrap.macos.launchd.agents]` +//! — declarative macOS LaunchAgents — `[bootstrap.user].login_shell` — and +//! `[bootstrap.hooks]` bootstrap phase hooks. //! These are intentionally not part of `[tools]`: they're unversioned, //! machine-global settings and resources, not mise's per-project toolset. @@ -23,6 +25,7 @@ use crate::system::packages::{PackageRequest, SystemPackageManager}; pub mod defaults; pub mod edits; pub mod files; +pub mod hooks; pub mod launchd; pub mod login_shell; pub mod packages; @@ -45,6 +48,10 @@ pub struct BootstrapTomlConfig { /// Homebrew-specific bootstrap package config. #[serde(default)] pub brew: SystemBrewTomlConfig, + /// Bootstrap phase hooks. Values stay raw TOML so newer hook shapes can + /// warn and be skipped without rejecting the whole config. + #[serde(default)] + pub hooks: IndexMap, } #[derive(Debug, Default, Clone, Deserialize)] @@ -316,6 +323,25 @@ pub fn login_shell_from_config(config: &Config) -> Option local. A hook value can be a string +/// command, an array of string commands, or a table with a `run` string/array. +pub fn hooks_from_config(config: &Config) -> Vec { + let mut out = vec![]; + for cf in config.config_files.values().rev() { + if let Some(sys) = cf.bootstrap_config() { + for (phase, value) in sys.hooks { + match hooks::BootstrapHook::from_toml(&phase, value) { + Ok(hooks) => out.extend(hooks), + Err(err) => warn!("[bootstrap.hooks.{phase}]: {err}"), + } + } + } + } + out +} + /// Build [`ManagerPackages`] from explicit CLI specs, attaching configured /// brew tap URLs when a config is available. /// From 10dfd7d197a4aebb292390d933bf8e161c69b82d Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 13 Jun 2026 13:46:38 +0000 Subject: [PATCH 2/6] fix(bootstrap): address hook review feedback --- docs/bootstrap.md | 2 +- docs/tips-and-tricks.md | 5 ++++- man/man1/mise.1 | 7 +++++++ src/system/hooks.rs | 25 ++++++++++++++++++++++--- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/bootstrap.md b/docs/bootstrap.md index f777f8fbab..9f90bd59ce 100644 --- a/docs/bootstrap.md +++ b/docs/bootstrap.md @@ -120,7 +120,7 @@ data. ## Hooks -Hooks run only during explicit `mise bootstrap` invocations. They may be +Hooks run only during explicit `mise bootstrap` invocations. A hook can be specified as a command string, an array of command strings, or a table with a `run` field. They use the same default inline shell setting as tasks, stop the bootstrap if they fail, and print the command instead of running it during diff --git a/docs/tips-and-tricks.md b/docs/tips-and-tricks.md index 04ad261dfc..9137edfcef 100644 --- a/docs/tips-and-tricks.md +++ b/docs/tips-and-tricks.md @@ -116,7 +116,10 @@ mise bootstrap --yes # new laptop or container -> ready to work Everything is declarative and idempotent: re-running skips whatever is already in its desired state, `mise bootstrap packages status --missing` and `mise dotfiles status --missing` make CI checks, and nothing is ever applied -implicitly. See +implicitly. The exceptions are `[bootstrap.hooks]` and `[tasks.bootstrap]`, +which are imperative commands run during `mise bootstrap` and may have side +effects; treat hook commands as non-idempotent unless they are written to +converge safely. See [Bootstrap](/bootstrap.html), [Bootstrap Packages](/bootstrap/packages/), [Dotfiles](/dotfiles.html), [macOS Defaults](/bootstrap/macos-defaults.html), [launchd](/bootstrap/launchd.html), and [User Login Shell](/bootstrap/user.html). diff --git a/man/man1/mise.1 b/man/man1/mise.1 index cabdbd92d3..e6ce1f0b97 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -793,16 +793,23 @@ e.g.: ruby@3 Runs the bootstrap steps for the current config in order: +0. `[bootstrap.hooks.pre\-packages]` — optional setup hook 1. `mise bootstrap packages install` — install missing `[bootstrap.packages]` + then `[bootstrap.hooks.post\-packages]` 2. `mise dotfiles apply` — apply dotfiles from `[dotfiles]` + surrounded by `pre\-dotfiles`/`post\-dotfiles` hooks 3. `mise bootstrap macos\-defaults apply` — write `[bootstrap.macos.defaults]` entries (macOS) + surrounded by `pre\-defaults`/`post\-defaults` hooks 4. `mise bootstrap launchd apply` — install/load macOS LaunchAgents 5. `mise bootstrap user apply` — set `[bootstrap.user].login_shell` (Unix) + surrounded by `pre\-user`/`post\-user` hooks 6. `mise install` — install missing tools from `[tools]` + surrounded by `pre\-tools`/`post\-tools` hooks 7. `mise run bootstrap` — if a task named `bootstrap` is defined +8. `[bootstrap.hooks.final]` — optional final hook The declarative steps converge — anything already in its desired state is skipped, so re\-running is safe. The `bootstrap` task runs on every diff --git a/src/system/hooks.rs b/src/system/hooks.rs index ef54020b1e..32981a359f 100644 --- a/src/system/hooks.rs +++ b/src/system/hooks.rs @@ -66,7 +66,13 @@ pub struct BootstrapHook { impl BootstrapHook { pub fn from_toml(phase_raw: &str, value: toml::Value) -> Result> { let Some(phase) = BootstrapHookPhase::parse(phase_raw) else { - bail!("unknown bootstrap hook phase"); + let valid = BootstrapHookPhase::iter() + .map(|phase| phase.as_str()) + .collect::>(); + bail!( + "unknown bootstrap hook phase {phase_raw:?}; valid phases are: {}", + valid.join(", ") + ); }; let runs = match value { toml::Value::String(run) => vec![run], @@ -119,14 +125,17 @@ pub async fn run_phase( } info!("bootstrap: {phase} hooks"); let shell = Settings::get().default_inline_shell()?; + let Some((program, shell_args)) = shell.split_first() else { + bail!("default inline shell args must not be empty"); + }; for hook in phase_hooks { if dry_run { miseprintln!("{} {}", shell.join(" "), shell_words::quote(&hook.run)); continue; } info!("$ {}", hook.run); - crate::cmd::CmdLineRunner::new(&shell[0]) - .cmd_body_args(&shell[1..], &hook.run) + crate::cmd::CmdLineRunner::new(program) + .cmd_body_args(shell_args, &hook.run) .raw(true) .execute_async() .await?; @@ -151,6 +160,16 @@ mod tests { assert_eq!(BootstrapHookPhase::parse("nope"), None); } + #[test] + fn unknown_phase_error_lists_valid_phases() { + let err = BootstrapHook::from_toml("pre-things", toml::Value::String("echo nope".into())) + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("pre-things")); + assert!(msg.contains("pre-packages")); + assert!(msg.contains("final")); + } + #[test] fn parses_hook_values() { let hooks = From a58f8a784bf6324367e0aff233242e5784568edd Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:05:20 +0000 Subject: [PATCH 3/6] fix(bootstrap): refresh hooks after config writes --- e2e/cli/test_bootstrap | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/e2e/cli/test_bootstrap b/e2e/cli/test_bootstrap index 8e7a733b71..9c8ce84532 100644 --- a/e2e/cli/test_bootstrap +++ b/e2e/cli/test_bootstrap @@ -60,3 +60,21 @@ cat <mise.toml "pacman:bc" = "latest" EOF assert_succeed "mise bootstrap --dry-run" + +# dotfiles can add config that contributes hooks to later phases in the same run +mkdir -p dotfiles/mise +cat <<'EOF' >dotfiles/mise/config.toml +[bootstrap.hooks.post-dotfiles] +run = "echo post-dotfiles > post_dotfiles_hook_ran" + +[bootstrap.hooks.final] +run = "echo final > final_hook_ran" +EOF +cat <mise.toml +[dotfiles] +"~/.config/mise/config.toml" = "dotfiles/mise/config.toml" +EOF +rm -f post_dotfiles_hook_ran final_hook_ran +assert_succeed "mise bootstrap --yes" +assert "cat post_dotfiles_hook_ran" "post-dotfiles" +assert "cat final_hook_ran" "final" From 7319e95b06ed1e3ae130b0d710627f99de225b3a Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:20:33 +0000 Subject: [PATCH 4/6] fix(bootstrap): preview dotfile hooks in dry run --- e2e/cli/test_bootstrap | 4 ++++ src/system/mod.rs | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/e2e/cli/test_bootstrap b/e2e/cli/test_bootstrap index 9c8ce84532..a501070bde 100644 --- a/e2e/cli/test_bootstrap +++ b/e2e/cli/test_bootstrap @@ -75,6 +75,10 @@ cat <mise.toml "~/.config/mise/config.toml" = "dotfiles/mise/config.toml" EOF rm -f post_dotfiles_hook_ran final_hook_ran +assert_contains "mise bootstrap --dry-run" "echo post-dotfiles > post_dotfiles_hook_ran" +assert_contains "mise bootstrap --dry-run" "echo final > final_hook_ran" +assert_fail "cat post_dotfiles_hook_ran" +assert_fail "cat final_hook_ran" assert_succeed "mise bootstrap --yes" assert "cat post_dotfiles_hook_ran" "post-dotfiles" assert "cat final_hook_ran" "final" diff --git a/src/system/mod.rs b/src/system/mod.rs index d0a3a61be8..e7da79d53b 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -16,8 +16,7 @@ use eyre::bail; use indexmap::IndexMap; use serde::Deserialize; -use crate::config::Config; -use crate::config::ConfigMap; +use crate::config::{Config, ConfigMap}; use crate::system::defaults::{DefaultsRequest, DefaultsValue}; use crate::system::launchd::{LaunchdRequest, LaunchdTomlConfig}; use crate::system::packages::{PackageRequest, SystemPackageManager}; @@ -328,8 +327,12 @@ pub fn login_shell_from_config(config: &Config) -> Option local. A hook value can be a string /// command, an array of string commands, or a table with a `run` string/array. pub fn hooks_from_config(config: &Config) -> Vec { + hooks_from_config_files(&config.config_files) +} + +pub(crate) fn hooks_from_config_files(config_files: &ConfigMap) -> Vec { let mut out = vec![]; - for cf in config.config_files.values().rev() { + for cf in config_files.values().rev() { if let Some(sys) = cf.bootstrap_config() { for (phase, value) in sys.hooks { match hooks::BootstrapHook::from_toml(&phase, value) { From cfe36aa39ba017a0ead7b81a8626c695096b7ca1 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:35:57 +0000 Subject: [PATCH 5/6] fix(bootstrap): preview rendered config dotfile hooks --- e2e/cli/test_bootstrap | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/e2e/cli/test_bootstrap b/e2e/cli/test_bootstrap index a501070bde..51644c38f6 100644 --- a/e2e/cli/test_bootstrap +++ b/e2e/cli/test_bootstrap @@ -82,3 +82,32 @@ assert_fail "cat final_hook_ran" assert_succeed "mise bootstrap --yes" assert "cat post_dotfiles_hook_ran" "post-dotfiles" assert "cat final_hook_ran" "final" + +# dry-run previews hooks from templated config dotfiles +cat <<'EOF' >dotfiles/mise/config.local.toml.tmpl +[bootstrap.hooks.final] +run = "echo {{ vars.templated_hook_value }} > templated_hook_ran" +EOF +cat <mise.toml +[vars] +templated_hook_value = "template-final" + +[dotfiles] +"~/.config/mise/config.local.toml" = { source = "dotfiles/mise/config.local.toml.tmpl", mode = "template" } +EOF +rm -f templated_hook_ran +assert_contains "mise bootstrap --dry-run" "echo template-final > templated_hook_ran" +assert_fail "cat templated_hook_ran" + +# dry-run recognizes local config targets such as ~/.mise/config.toml +cat <<'EOF' >dotfiles/mise/local-config.toml +[bootstrap.hooks.post-dotfiles] +run = "echo mise-dir > mise_dir_hook_ran" +EOF +cat <mise.toml +[dotfiles] +"~/.mise/config.toml" = "dotfiles/mise/local-config.toml" +EOF +rm -f mise_dir_hook_ran +assert_contains "mise bootstrap --dry-run" "echo mise-dir > mise_dir_hook_ran" +assert_fail "cat mise_dir_hook_ran" From 1bd40a8fbf9a48a0c30febb0cb7a98b65cc9eed0 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:47:41 +0000 Subject: [PATCH 6/6] test(ubi): replace stale direct url fixture --- e2e/backend/test_ubi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/backend/test_ubi b/e2e/backend/test_ubi index 9d83c81150..63941be93e 100644 --- a/e2e/backend/test_ubi +++ b/e2e/backend/test_ubi @@ -7,8 +7,8 @@ assert_contains "$MISE_DATA_DIR/shims/jc --version" "jc version: 1.25.3" # only run on linux/amd64 if [ "$(uname -m)" = "x86_64" ] && [ "$(uname -s)" = "Linux" ]; then - mise use 'ubi:https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz[exe=ffmpeg]' - assert_contains "$MISE_DATA_DIR/shims/ffmpeg -version" "ffmpeg version" + mise use 'ubi:https://github.com/sharkdp/fd/releases/download/v10.3.0/fd-v10.3.0-x86_64-unknown-linux-gnu.tar.gz[exe=fd]' + assert_contains "$MISE_DATA_DIR/shims/fd --version" "fd 10.3.0" fi cat <mise.toml