From 5eb047185d4143bcd24f3593ab17a14b17850d52 Mon Sep 17 00:00:00 2001 From: default <216188+jdx@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:20:26 +0000 Subject: [PATCH] feat(system): support custom brew taps --- docs/.vitepress/cli_commands.ts | 3 + docs/cli/index.md | 3 + docs/cli/system.md | 1 + docs/cli/system/brew.md | 15 ++ docs/cli/system/brew/tap.md | 30 +++ docs/cli/system/brew/untap.md | 26 +++ docs/system-packages/brew.md | 44 +++- man/man1/mise.1 | 45 +++++ mise.usage.kdl | 31 +++ src/cli/system/brew/mod.rs | 32 +++ src/cli/system/brew/tap.rs | 31 +++ src/cli/system/brew/untap.rs | 27 +++ src/cli/system/install.rs | 3 +- src/cli/system/mod.rs | 6 + src/cli/system/upgrade.rs | 3 +- src/cli/system/use.rs | 2 + src/config/config_file/mise_toml.rs | 7 + src/system/mod.rs | 94 ++++++++- src/system/packages/apt.rs | 1 + src/system/packages/brew/mod.rs | 301 +++++++++++++++++++++++++--- src/system/packages/dnf.rs | 1 + src/system/packages/mod.rs | 3 + src/system/packages/pacman.rs | 1 + xtasks/fig/src/mise.ts | 47 +++++ 24 files changed, 719 insertions(+), 38 deletions(-) create mode 100644 docs/cli/system/brew.md create mode 100644 docs/cli/system/brew/tap.md create mode 100644 docs/cli/system/brew/untap.md create mode 100644 src/cli/system/brew/mod.rs create mode 100644 src/cli/system/brew/tap.rs create mode 100644 src/cli/system/brew/untap.rs diff --git a/docs/.vitepress/cli_commands.ts b/docs/.vitepress/cli_commands.ts index e86e6b4c7f..b569f6ce09 100644 --- a/docs/.vitepress/cli_commands.ts +++ b/docs/.vitepress/cli_commands.ts @@ -319,6 +319,9 @@ export const commands: { [key: string]: Command } = { system: { hide: false, subcommands: { + brew: { + hide: false, + }, install: { hide: false, }, diff --git a/docs/cli/index.md b/docs/cli/index.md index c18b526153..296e857d5b 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -166,6 +166,9 @@ Can also use `MISE_NO_HOOKS=1` - [`mise sync python [--pyenv] [--uv]`](/cli/sync/python.md) - [`mise sync ruby [--brew]`](/cli/sync/ruby.md) - [`mise system `](/cli/system.md) +- [`mise system brew `](/cli/system/brew.md) +- [`mise system brew tap [-n --dry-run] [URL]`](/cli/system/brew/tap.md) +- [`mise system brew untap [-n --dry-run] …`](/cli/system/brew/untap.md) - [`mise system install [FLAGS] [PACKAGE]…`](/cli/system/install.md) - [`mise system status [-J --json] [--missing]`](/cli/system/status.md) - [`mise system upgrade [FLAGS] [PACKAGE]…`](/cli/system/upgrade.md) diff --git a/docs/cli/system.md b/docs/cli/system.md index 2c96d0ab02..05db6dab9a 100644 --- a/docs/cli/system.md +++ b/docs/cli/system.md @@ -20,6 +20,7 @@ ever acted on when explicitly requested with `mise system install` (or ## Subcommands +- [`mise system brew `](/cli/system/brew.md) - [`mise system install [FLAGS] [PACKAGE]…`](/cli/system/install.md) - [`mise system status [-J --json] [--missing]`](/cli/system/status.md) - [`mise system upgrade [FLAGS] [PACKAGE]…`](/cli/system/upgrade.md) diff --git a/docs/cli/system/brew.md b/docs/cli/system/brew.md new file mode 100644 index 0000000000..d1a4c292d0 --- /dev/null +++ b/docs/cli/system/brew.md @@ -0,0 +1,15 @@ + +# `mise system brew` + +- **Usage**: `mise system brew ` +- **Source code**: [`src/cli/system/mod.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/mod.rs) + +Manage Homebrew taps used by system packages + +These commands shell out to Homebrew and do not modify `mise.toml`. Use +`[system.brew.taps]` when you want tap sources shared in config. + +## Subcommands + +- [`mise system brew tap [-n --dry-run] [URL]`](/cli/system/brew/tap.md) +- [`mise system brew untap [-n --dry-run] …`](/cli/system/brew/untap.md) diff --git a/docs/cli/system/brew/tap.md b/docs/cli/system/brew/tap.md new file mode 100644 index 0000000000..698b2e271d --- /dev/null +++ b/docs/cli/system/brew/tap.md @@ -0,0 +1,30 @@ + +# `mise system brew tap` + +- **Usage**: `mise system brew tap [-n --dry-run] [URL]` +- **Source code**: [`src/cli/system/brew/tap.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/brew/tap.rs) + +Tap a Homebrew formula repository + +## Arguments + +### `` + +Tap name, e.g. `owner/repo` + +### `[URL]` + +Git URL for non-GitHub or otherwise custom taps + +## Flags + +### `-n --dry-run` + +Print the command that would run without running it + +Examples: + +``` +mise system brew tap railwaycat/emacsmacport +mise system brew tap acme/tools https://git.example.com/acme/homebrew-tools.git +``` diff --git a/docs/cli/system/brew/untap.md b/docs/cli/system/brew/untap.md new file mode 100644 index 0000000000..742dba0e35 --- /dev/null +++ b/docs/cli/system/brew/untap.md @@ -0,0 +1,26 @@ + +# `mise system brew untap` + +- **Usage**: `mise system brew untap [-n --dry-run] …` +- **Aliases**: `remove`, `rm` +- **Source code**: [`src/cli/system/brew/untap.rs`](https://github.com/jdx/mise/blob/main/src/cli/system/brew/untap.rs) + +Untap Homebrew formula repositories + +## Arguments + +### `…` + +Tap name(s), e.g. `owner/repo` + +## Flags + +### `-n --dry-run` + +Print the command that would run without running it + +Examples: + +``` +mise system brew untap railwaycat/emacsmacport +``` diff --git a/docs/system-packages/brew.md b/docs/system-packages/brew.md index 0bd801664b..d3646ec0ab 100644 --- a/docs/system-packages/brew.md +++ b/docs/system-packages/brew.md @@ -1,6 +1,7 @@ # brew -Homebrew formulae — **without requiring Homebrew to be installed**. +Homebrew formulae from `homebrew/core` — **without requiring Homebrew to be +installed**. ```toml [system.packages] @@ -17,7 +18,41 @@ prebuilt bottles from ghcr.io (verifying sha256 checksums), and performs the same relocation, code-signing, and linking work `brew` does when pouring a bottle. Formulae without a usable bottle are built from source, also without Homebrew (see [Source formulae](#source-formulae)). mise never shells out to -`brew`. +`brew` for homebrew/core formulae. + +Third-party taps are supported when Homebrew itself is installed. Tapped +formulae are delegated to a real `brew` command; use the same +fully-qualified formula name you would pass to `brew install`: + +```toml +[system.packages] +"brew:railwaycat/emacsmacport/emacs-mac" = "latest" +``` + +For non-GitHub taps, or taps whose URL cannot be inferred by Homebrew, add a +tap source. This mirrors `[plugins]`: the key is the tap name and the value +is the git URL. + +```toml +[system.brew.taps] +"acme/tools" = "https://git.example.com/acme/homebrew-tools.git" + +[system.packages] +"brew:acme/tools/widget" = "latest" +``` + +Before installing or upgrading tapped formulae, mise runs `brew tap` for any +configured tap URL and then `brew update-if-needed` so the tap is current. + +You can also manage taps imperatively, matching `mise plugins install` / +`mise plugins uninstall`: these commands shell out to Homebrew and do not +modify `mise.toml`. + +```sh +mise system brew tap railwaycat/emacsmacport +mise system brew tap acme/tools https://git.example.com/acme/homebrew-tools.git +mise system brew untap acme/tools +``` This exists because shared-library packages — postgres, ffmpeg, imagemagick, php — fundamentally can't be served by mise's per-project backends like @@ -131,8 +166,9 @@ operation. - **Formulae only.** Casks (GUI apps) and `brew services` are not implemented. -- **No taps.** Third-party taps are Ruby code that requires Homebrew to - evaluate; only homebrew/core is supported. +- **Tapped formulae require Homebrew.** mise's direct bottle/source installer + is only for homebrew/core. Fully-qualified third-party tap formulae are + delegated to a real `brew` command. - **Source builds cover the common formula shapes.** mise's formula shim implements the widely-used subset of the DSL (see [Source formulae](#source-formulae)); formulae that reach beyond it fail diff --git a/man/man1/mise.1 b/man/man1/mise.1 index de77dae8ef..86087e030c 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -463,6 +463,18 @@ Symlinks all ruby tool versions from an external tool into mise \fBsystem\fR [experimental] Manage system packages from `[system.packages]`, files .TP +\fBsystem brew\fR +Manage Homebrew taps used by system packages +.TP +\fBsystem brew tap\fR +Tap a Homebrew formula repository +.TP +\fBsystem brew untap\fR +Untap Homebrew formula repositories +.RS +\fIAliases: \fRremove, rm +.RE +.TP \fBsystem install\fR Install missing system packages from `[system.packages]`, apply files .RS @@ -2777,6 +2789,39 @@ Symlinks all ruby tool versions from an external tool into mise .TP \fB\-\-brew\fR Get tool versions from Homebrew +.SH "MISE SYSTEM BREW TAP" +Tap a Homebrew formula repository +.PP +\fBUsage:\fR mise system brew tap [OPTIONS] [] +.PP +\fBOptions:\fR +.PP +.TP +\fB\-n, \-\-dry\-run\fR +Print the command that would run without running it +\fBArguments:\fR +.PP +.TP +\fB\fR +Tap name, e.g. `owner/repo` +.TP +\fB\fR +Git URL for non\-GitHub or otherwise custom taps +.SH "MISE SYSTEM BREW UNTAP" +Untap Homebrew formula repositories +.PP +\fBUsage:\fR mise system brew untap [OPTIONS] ... +.PP +\fBOptions:\fR +.PP +.TP +\fB\-n, \-\-dry\-run\fR +Print the command that would run without running it +\fBArguments:\fR +.PP +.TP +\fB\fR +Tap name(s), e.g. `owner/repo` .SH "MISE SYSTEM INSTALL" Install missing system packages from `[system.packages]`, apply files from `[system.files]` and edits from `[system.edits]`, and write macOS diff --git a/mise.usage.kdl b/mise.usage.kdl index 163dd3511d..d7e72aaa64 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -2897,6 +2897,37 @@ defaults are user preferences written with `defaults write`. Unlike ever acted on when explicitly requested with `mise system install` (or `mise bootstrap`). """# + cmd brew subcommand_required=#true help="Manage Homebrew taps used by system packages" { + long_help #""" +Manage Homebrew taps used by system packages + +These commands shell out to Homebrew and do not modify `mise.toml`. Use +`[system.brew.taps]` when you want tap sources shared in config. +"""# + cmd tap help="Tap a Homebrew formula repository" { + after_long_help #""" +Examples: + + $ mise system brew tap railwaycat/emacsmacport + $ mise system brew tap acme/tools https://git.example.com/acme/homebrew-tools.git + +"""# + flag "-n --dry-run" help="Print the command that would run without running it" + arg help="Tap name, e.g. `owner/repo`" + arg "[URL]" help="Git URL for non-GitHub or otherwise custom taps" required=#false + } + cmd untap help="Untap Homebrew formula repositories" { + alias remove rm + after_long_help #""" +Examples: + + $ mise system brew untap railwaycat/emacsmacport + +"""# + flag "-n --dry-run" help="Print the command that would run without running it" + arg … help="Tap name(s), e.g. `owner/repo`" var=#true + } + } cmd install help=#""" Install missing system packages from `[system.packages]`, apply files from `[system.files]` and edits from `[system.edits]`, and write macOS diff --git a/src/cli/system/brew/mod.rs b/src/cli/system/brew/mod.rs new file mode 100644 index 0000000000..19ed1e0580 --- /dev/null +++ b/src/cli/system/brew/mod.rs @@ -0,0 +1,32 @@ +use clap::Subcommand; +use eyre::Result; + +mod tap; +mod untap; + +/// Manage Homebrew taps used by system packages +/// +/// These commands shell out to Homebrew and do not modify `mise.toml`. Use +/// `[system.brew.taps]` when you want tap sources shared in config. +#[derive(Debug, clap::Args)] +#[clap(verbatim_doc_comment)] +pub struct SystemBrew { + #[clap(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand)] +enum Commands { + Tap(tap::SystemBrewTap), + Untap(untap::SystemBrewUntap), +} + +impl SystemBrew { + pub async fn run(self) -> Result<()> { + crate::config::Settings::get().ensure_experimental("mise system")?; + match self.command { + Commands::Tap(cmd) => cmd.run().await, + Commands::Untap(cmd) => cmd.run().await, + } + } +} diff --git a/src/cli/system/brew/tap.rs b/src/cli/system/brew/tap.rs new file mode 100644 index 0000000000..368f227b29 --- /dev/null +++ b/src/cli/system/brew/tap.rs @@ -0,0 +1,31 @@ +use eyre::Result; + +/// Tap a Homebrew formula repository +#[derive(Debug, clap::Args)] +#[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)] +pub struct SystemBrewTap { + /// Tap name, e.g. `owner/repo` + tap: String, + + /// Git URL for non-GitHub or otherwise custom taps + #[clap(value_hint = clap::ValueHint::Url)] + url: Option, + + /// Print the command that would run without running it + #[clap(long, short = 'n')] + dry_run: bool, +} + +impl SystemBrewTap { + pub async fn run(self) -> Result<()> { + crate::system::packages::brew::tap(&self.tap, self.url.as_deref(), self.dry_run).await + } +} + +static AFTER_LONG_HELP: &str = color_print::cstr!( + r#"Examples: + + $ mise system brew tap railwaycat/emacsmacport + $ mise system brew tap acme/tools https://git.example.com/acme/homebrew-tools.git +"# +); diff --git a/src/cli/system/brew/untap.rs b/src/cli/system/brew/untap.rs new file mode 100644 index 0000000000..cf5e62aa3f --- /dev/null +++ b/src/cli/system/brew/untap.rs @@ -0,0 +1,27 @@ +use eyre::Result; + +/// Untap Homebrew formula repositories +#[derive(Debug, clap::Args)] +#[clap(verbatim_doc_comment, visible_aliases = ["remove", "rm"], after_long_help = AFTER_LONG_HELP)] +pub struct SystemBrewUntap { + /// Tap name(s), e.g. `owner/repo` + #[clap(required = true)] + taps: Vec, + + /// Print the command that would run without running it + #[clap(long, short = 'n')] + dry_run: bool, +} + +impl SystemBrewUntap { + pub async fn run(self) -> Result<()> { + crate::system::packages::brew::untap(&self.taps, self.dry_run).await + } +} + +static AFTER_LONG_HELP: &str = color_print::cstr!( + r#"Examples: + + $ mise system brew untap railwaycat/emacsmacport +"# +); diff --git a/src/cli/system/install.rs b/src/cli/system/install.rs index 4aae7392b3..b8398d08cc 100644 --- a/src/cli/system/install.rs +++ b/src/cli/system/install.rs @@ -62,7 +62,8 @@ impl SystemInstall { } system::packages_from_config(&config) } else { - system::packages_from_specs(&self.packages)? + let config = Config::get().await?; + system::packages_from_specs_with_config(&self.packages, Some(&config))? }; // explicit packages or a --manager filter narrow the run to those // packages; files, edits, and defaults are part of the "apply diff --git a/src/cli/system/mod.rs b/src/cli/system/mod.rs index ddd01c3052..02334b8fa8 100644 --- a/src/cli/system/mod.rs +++ b/src/cli/system/mod.rs @@ -1,6 +1,8 @@ use clap::Subcommand; use eyre::Result; +#[cfg(unix)] +mod brew; pub(super) mod driver; pub(super) mod install; mod status; @@ -30,6 +32,8 @@ pub struct System { #[derive(Debug, Subcommand)] enum Commands { + #[cfg(unix)] + Brew(brew::SystemBrew), Install(install::SystemInstall), Status(status::SystemStatus), Upgrade(upgrade::SystemUpgrade), @@ -39,6 +43,8 @@ enum Commands { impl System { pub async fn run(self) -> Result<()> { match self.command { + #[cfg(unix)] + Commands::Brew(cmd) => cmd.run().await, Commands::Install(cmd) => cmd.run().await, Commands::Status(cmd) => cmd.run().await, Commands::Upgrade(cmd) => cmd.run().await, diff --git a/src/cli/system/upgrade.rs b/src/cli/system/upgrade.rs index d7a69b2433..0fec44bf0c 100644 --- a/src/cli/system/upgrade.rs +++ b/src/cli/system/upgrade.rs @@ -41,7 +41,8 @@ impl SystemUpgrade { let config = Config::get().await?; system::packages_from_config(&config) } else { - system::packages_from_specs(&self.packages)? + let config = Config::get().await?; + system::packages_from_specs_with_config(&self.packages, Some(&config))? }; let opts = DriverOpts { manager: self.manager, diff --git a/src/cli/system/use.rs b/src/cli/system/use.rs index 96cb167818..bd45344cc6 100644 --- a/src/cli/system/use.rs +++ b/src/cli/system/use.rs @@ -53,6 +53,7 @@ pub struct SystemUse { impl SystemUse { pub async fn run(self) -> Result<()> { Settings::get().ensure_experimental("mise system")?; + let config = crate::config::Config::get().await?; let mut by_mgr: IndexMap> = IndexMap::new(); let mut entries: Vec<(String, String)> = vec![]; for spec in &self.packages { @@ -71,6 +72,7 @@ impl SystemUse { None => requests.push(request), } } + system::attach_brew_tap_urls(&config, &mut by_mgr); // resolve managers before touching the config file so a typo'd // manager doesn't get written let mgrs = system::packages_from_requests(by_mgr)?; diff --git a/src/config/config_file/mise_toml.rs b/src/config/config_file/mise_toml.rs index 0d576f3473..84b33c4d7d 100644 --- a/src/config/config_file/mise_toml.rs +++ b/src/config/config_file/mise_toml.rs @@ -2148,6 +2148,9 @@ mod tests { "apt:curl" = "8.5.0-2" "brew:postgresql@17" = "latest" "future-manager:whatever" = "latest" + + [system.brew.taps] + "railwaycat/emacsmacport" = "https://github.com/railwaycat/homebrew-emacsmacport" "#, ) .unwrap(); @@ -2156,6 +2159,10 @@ mod tests { assert_eq!(system.packages.get("apt:libssl-dev").unwrap(), "latest"); assert_eq!(system.packages.get("apt:curl").unwrap(), "8.5.0-2"); assert_eq!(system.packages.get("brew:postgresql@17").unwrap(), "latest"); + assert_eq!( + system.brew.taps.get("railwaycat/emacsmacport").unwrap(), + "https://github.com/railwaycat/homebrew-emacsmacport" + ); // unknown managers parse fine (forward compatibility) assert_eq!( system.packages.get("future-manager:whatever").unwrap(), diff --git a/src/system/mod.rs b/src/system/mod.rs index 9c20a9ca68..6efc1d43ab 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -46,6 +46,18 @@ pub struct SystemTomlConfig { /// (see [`edits`]) #[serde(default)] pub edits: IndexMap>, + /// Homebrew-specific system config. + #[serde(default)] + pub brew: SystemBrewTomlConfig, +} + +#[derive(Debug, Default, Clone, Deserialize)] +pub struct SystemBrewTomlConfig { + /// `[system.brew.taps]`: `owner/tap` -> git URL. Like `[plugins]`, + /// this lets shared config name non-GitHub or otherwise custom tap + /// remotes while package entries stay focused on formulae. + #[serde(default)] + pub taps: IndexMap, } /// Packages for one manager, aggregated across the config hierarchy @@ -86,6 +98,7 @@ pub fn parse_use_spec(spec: &str) -> eyre::Result<(String, PackageRequest)> { PackageRequest { name: rest, version: None, + tap_url: None, }, )); } @@ -95,6 +108,7 @@ pub fn parse_use_spec(spec: &str) -> eyre::Result<(String, PackageRequest)> { PackageRequest { name: name.to_string(), version: (version != "latest").then(|| version.to_string()), + tap_url: None, }, )), Some(_) => { @@ -105,6 +119,7 @@ pub fn parse_use_spec(spec: &str) -> eyre::Result<(String, PackageRequest)> { PackageRequest { name: rest, version: None, + tap_url: None, }, )), } @@ -119,6 +134,16 @@ pub fn packages_from_requests( resolve_managers(by_mgr, true) } +pub fn attach_brew_tap_urls(config: &Config, by_mgr: &mut IndexMap>) { + let brew_taps = brew_taps_from_config(config); + let Some(requests) = by_mgr.get_mut("brew") else { + return; + }; + for request in requests { + request.tap_url = brew_tap_name(&request.name).and_then(|tap| brew_taps.get(tap).cloned()); + } +} + /// Aggregate `[system.packages]` across all loaded config files. /// /// Keys union global -> local; a more local config overrides the version pin @@ -128,6 +153,7 @@ pub fn packages_from_requests( /// all. pub fn packages_from_config(config: &Config) -> Vec { let mut merged: IndexMap = IndexMap::new(); + let brew_taps = brew_taps_from_config(config); // config_files is ordered local -> global; reverse for global -> local for cf in config.config_files.values().rev() { if let Some(sys) = cf.system_config() { @@ -141,10 +167,16 @@ pub fn packages_from_config(config: &Config) -> Vec { match parse_spec(&spec) { Ok((mgr, name)) => { let version = (version != "latest").then_some(version); - by_mgr - .entry(mgr) - .or_default() - .push(PackageRequest { name, version }); + let tap_url = if mgr == "brew" { + brew_tap_name(&name).and_then(|tap| brew_taps.get(tap).cloned()) + } else { + None + }; + by_mgr.entry(mgr).or_default().push(PackageRequest { + name, + version, + tap_url, + }); } Err(err) => warn!("[system.packages]: {err}"), } @@ -189,17 +221,29 @@ pub fn defaults_from_config(config: &Config) -> Vec { out } -/// Build [`ManagerPackages`] from explicit CLI specs like `apt:curl`. +/// Build [`ManagerPackages`] from explicit CLI specs, attaching configured +/// brew tap URLs when a config is available. +/// /// Unlike the config path, malformed specs and unknown managers are hard /// errors. CLI specs carry no version pin — pins live in the config value. -pub fn packages_from_specs(specs: &[String]) -> eyre::Result> { +pub fn packages_from_specs_with_config( + specs: &[String], + config: Option<&Config>, +) -> eyre::Result> { + let brew_taps = config.map(brew_taps_from_config).unwrap_or_default(); let mut by_mgr: IndexMap> = IndexMap::new(); for spec in specs { let (mgr, name) = parse_spec(spec)?; + let tap_url = if mgr == "brew" { + brew_tap_name(&name).and_then(|tap| brew_taps.get(tap).cloned()) + } else { + None + }; let requests = by_mgr.entry(mgr).or_default(); let request = PackageRequest { name, version: None, + tap_url, }; if !requests.contains(&request) { requests.push(request); @@ -208,6 +252,33 @@ pub fn packages_from_specs(specs: &[String]) -> eyre::Result Option<&str> { + let mut parts = name.split('/'); + let owner = parts.next()?; + let tap = parts.next()?; + let formula = parts.next()?; + if parts.next().is_some() || owner.is_empty() || tap.is_empty() || formula.is_empty() { + return None; + } + if owner == "homebrew" && tap == "core" { + None + } else { + name.rsplit_once('/').map(|(tap, _)| tap) + } +} + +fn brew_taps_from_config(config: &Config) -> IndexMap { + let mut brew_taps: IndexMap = IndexMap::new(); + for cf in config.config_files.values().rev() { + if let Some(sys) = cf.system_config() { + for (tap, url) in sys.brew.taps { + brew_taps.insert(tap, url); + } + } + } + brew_taps +} + fn resolve_managers( by_mgr: IndexMap>, strict: bool, @@ -284,4 +355,15 @@ mod tests { assert!(parse_use_spec("apt:curl@").is_err()); assert!(parse_use_spec("noprefix").is_err()); } + + #[test] + fn test_brew_tap_name() { + assert_eq!( + brew_tap_name("railwaycat/emacsmacport/emacs-mac"), + Some("railwaycat/emacsmacport") + ); + assert_eq!(brew_tap_name("homebrew/core/jq"), None); + assert_eq!(brew_tap_name("jq"), None); + assert_eq!(brew_tap_name("too/many/slashes/here"), None); + } } diff --git a/src/system/packages/apt.rs b/src/system/packages/apt.rs index c3afafa47c..691c12899b 100644 --- a/src/system/packages/apt.rs +++ b/src/system/packages/apt.rs @@ -203,6 +203,7 @@ mod tests { PackageRequest { name: name.to_string(), version: version.map(str::to_string), + tap_url: None, } } diff --git a/src/system/packages/brew/mod.rs b/src/system/packages/brew/mod.rs index 7896c6f60f..7c2ef66d41 100644 --- a/src/system/packages/brew/mod.rs +++ b/src/system/packages/brew/mod.rs @@ -12,11 +12,15 @@ //! Homebrew: mise provisions a mise-managed ruby and evaluates the formula //! with its own Formula-DSL shim (see source.rs and shim.rb). //! -//! Scope: formulae only. Casks and services are not implemented; taps are -//! unsupported (only homebrew/core formulae are served by the API). +//! Scope: formulae only. Casks and services are not implemented. homebrew/core +//! formulae use mise's direct pour path; fully-qualified third-party tap +//! formulae (`owner/tap/formula`) use a real Homebrew installation. use async_trait::async_trait; -use eyre::bail; +use eyre::{WrapErr, bail, eyre}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; use super::{InstallOpts, PackageRequest, PackageState, PackageStatus, SystemPackageManager}; use crate::result::Result; @@ -35,6 +39,8 @@ mod source; mod state; mod tag; +const BREW_TIMEOUT: Duration = Duration::from_secs(30 * 60); + pub struct BrewManager {} impl BrewManager { @@ -42,6 +48,13 @@ impl BrewManager { Self {} } + fn split_tapped<'a>( + &self, + pkgs: &'a [PackageRequest], + ) -> (Vec<&'a PackageRequest>, Vec<&'a PackageRequest>) { + pkgs.iter().partition(|p| is_tapped_formula(&p.name)) + } + async fn install_via_pour(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { // bottles only exist for a formula's current version — versioning is // expressed in the formula name itself (postgresql@17); the CLI @@ -181,6 +194,30 @@ impl BrewManager { prefix::setup_linux_runtime()?; Ok(()) } + + async fn install_via_brew(&self, pkgs: &[&PackageRequest], opts: &InstallOpts) -> Result<()> { + if pkgs.is_empty() { + return Ok(()); + } + let brew = brew_bin_for_tapped(pkgs, opts.dry_run)?; + ensure_taps(&brew, pkgs, opts.dry_run).await?; + run_brew(&brew, &["update-if-needed".to_string()], opts.dry_run).await?; + let mut args = vec!["install".to_string()]; + args.extend(pkgs.iter().map(|p| p.name.clone())); + run_brew(&brew, &args, opts.dry_run).await + } + + async fn upgrade_via_brew(&self, pkgs: &[&PackageRequest], opts: &InstallOpts) -> Result<()> { + if pkgs.is_empty() { + return Ok(()); + } + let brew = brew_bin_for_tapped(pkgs, opts.dry_run)?; + ensure_taps(&brew, pkgs, opts.dry_run).await?; + run_brew(&brew, &["update-if-needed".to_string()], opts.dry_run).await?; + let mut args = vec!["upgrade".to_string()]; + args.extend(pkgs.iter().map(|p| p.name.clone())); + run_brew(&brew, &args, opts.dry_run).await + } } #[async_trait(?Send)] @@ -210,32 +247,244 @@ impl SystemPackageManager for BrewManager { // or by a real brew; a formula counts as installed only when its opt // symlink resolves to a keg — a Cellar directory without one is a // remnant of a failed install and must not mask a retry - Ok(pkgs - .iter() - .map(|req| { - let state = match pour::linked_version(&req.name) { - // a pin matches the keg version exactly or up to its - // revision suffix ("17.5" matches keg "17.5_1") - Some(version) => match &req.version { - Some(requested) - if version != *requested - && !version.starts_with(&format!("{requested}_")) => - { - PackageState::VersionMismatch { installed: version } - } - _ => PackageState::Installed { version }, - }, - None => PackageState::Missing, - }; - PackageStatus { - request: req.clone(), - state, + let brew = brew_bin(); + let mut statuses = Vec::with_capacity(pkgs.len()); + for req in pkgs { + let linked_name = if is_tapped_formula(&req.name) { + tapped_formula_name(&req.name) + } else { + core_formula_name(&req.name) + }; + let version = if is_tapped_formula(&req.name) { + match &brew { + Some(brew) => brew_list_version(brew, &req.name) + .await? + .or_else(|| pour::linked_version(linked_name)), + None => pour::linked_version(linked_name), } - }) - .collect()) + } else { + pour::linked_version(linked_name) + }; + let state = match version { + // a pin matches the keg version exactly or up to its + // revision suffix ("17.5" matches keg "17.5_1") + Some(version) => match &req.version { + Some(requested) + if version != *requested + && !version.starts_with(&format!("{requested}_")) => + { + PackageState::VersionMismatch { installed: version } + } + _ => PackageState::Installed { version }, + }, + None => PackageState::Missing, + }; + statuses.push(PackageStatus { + request: req.clone(), + state, + }); + } + Ok(statuses) } async fn install(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { - self.install_via_pour(pkgs, opts).await + let (tapped, core) = self.split_tapped(pkgs); + if !tapped.is_empty() { + brew_bin_for_tapped(&tapped, opts.dry_run)?; + } + if !core.is_empty() { + let core = core + .into_iter() + .map(normalize_core_request) + .collect::>(); + self.install_via_pour(&core, opts).await?; + } + self.install_via_brew(&tapped, opts).await + } + + async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> { + let (tapped, core) = self.split_tapped(pkgs); + if !tapped.is_empty() { + brew_bin_for_tapped(&tapped, opts.dry_run)?; + } + if !core.is_empty() { + let core = core + .into_iter() + .map(normalize_core_request) + .collect::>(); + self.install_via_pour(&core, opts).await?; + } + self.upgrade_via_brew(&tapped, opts).await + } +} + +fn is_tapped_formula(name: &str) -> bool { + crate::system::brew_tap_name(name).is_some() +} + +fn tapped_formula_name(name: &str) -> &str { + name.rsplit('/').next().unwrap_or(name) +} + +fn core_formula_name(name: &str) -> &str { + match split_formula_name(name) { + Some(("homebrew", "core", formula)) => formula, + _ => name, + } +} + +fn normalize_core_request(req: &PackageRequest) -> PackageRequest { + let mut req = req.clone(); + req.name = core_formula_name(&req.name).to_string(); + req +} + +fn split_formula_name(name: &str) -> Option<(&str, &str, &str)> { + let mut parts = name.split('/'); + let owner = parts.next()?; + let tap = parts.next()?; + let formula = parts.next()?; + if parts.next().is_some() || owner.is_empty() || tap.is_empty() || formula.is_empty() { + None + } else { + Some((owner, tap, formula)) + } +} + +fn brew_bin() -> Option { + crate::file::which("brew").or_else(|| { + let brew = prefix::prefix().join("bin").join("brew"); + brew.exists().then_some(brew) + }) +} + +fn brew_bin_for_run(dry_run: bool) -> Option { + brew_bin().or_else(|| dry_run.then(|| PathBuf::from("brew"))) +} + +fn brew_bin_for_tapped(pkgs: &[&PackageRequest], dry_run: bool) -> Result { + brew_bin_for_run(dry_run).ok_or_else(|| { + eyre!( + "brew: custom tap formulae require Homebrew to be installed \ + (needed for {})", + pkgs.iter() + .map(|p| p.name.as_str()) + .collect::>() + .join(", ") + ) + }) +} + +fn display_cmd(program: &Path, args: &[String]) -> String { + std::iter::once(program.to_string_lossy().to_string()) + .chain(args.iter().cloned()) + .map(|s| shell_escape::escape(s.into()).to_string()) + .collect::>() + .join(" ") +} + +async fn run_brew(brew: &PathBuf, args: &[String], dry_run: bool) -> Result<()> { + if dry_run { + miseprintln!("{}", display_cmd(brew, args)); + return Ok(()); + } + let display = display_cmd(brew, args); + debug!("$ {display}"); + let mut cmd = tokio::process::Command::new(brew); + cmd.args(args).stdin(Stdio::null()).kill_on_drop(true); + let status = tokio::time::timeout(BREW_TIMEOUT, cmd.status()) + .await + .map_err(|_| eyre!("brew timed out after {:?} running {display}", BREW_TIMEOUT))? + .wrap_err_with(|| format!("failed to run {display}"))?; + if !status.success() { + bail!("{display} failed with {status}"); + } + Ok(()) +} + +pub(crate) async fn tap(tap: &str, url: Option<&str>, dry_run: bool) -> Result<()> { + let brew = brew_bin_for_run(dry_run) + .ok_or_else(|| eyre::eyre!("brew: Homebrew must be installed to tap {tap}"))?; + let mut args = vec!["tap".to_string(), tap.to_string()]; + if let Some(url) = url { + args.push(url.to_string()); + } + run_brew(&brew, &args, dry_run).await +} + +pub(crate) async fn untap(taps: &[String], dry_run: bool) -> Result<()> { + let brew = brew_bin_for_run(dry_run).ok_or_else(|| { + eyre::eyre!( + "brew: Homebrew must be installed to untap {}", + taps.join(", ") + ) + })?; + let mut args = vec!["untap".to_string()]; + args.extend(taps.iter().cloned()); + run_brew(&brew, &args, dry_run).await +} + +async fn ensure_taps(brew: &PathBuf, pkgs: &[&PackageRequest], dry_run: bool) -> Result<()> { + let mut taps = indexmap::IndexMap::>::new(); + for pkg in pkgs { + if let Some(tap) = crate::system::brew_tap_name(&pkg.name) { + taps.entry(tap.to_string()).or_insert(pkg.tap_url.clone()); + } + } + for (tap, url) in taps { + let mut args = vec!["tap".to_string(), tap]; + if let Some(url) = url { + args.push(url); + } + run_brew(brew, &args, dry_run).await?; + } + Ok(()) +} + +async fn brew_list_version(brew: &PathBuf, name: &str) -> Result> { + let args = vec![ + "list".to_string(), + "--versions".to_string(), + name.to_string(), + ]; + let display = display_cmd(brew, &args); + debug!("$ {display}"); + let mut cmd = tokio::process::Command::new(brew); + cmd.args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(BREW_TIMEOUT, cmd.output()) + .await + .map_err(|_| eyre!("brew timed out after {:?} running {display}", BREW_TIMEOUT))? + .wrap_err_with(|| format!("failed to run {display}"))?; + if !output.status.success() { + return Ok(None); + } + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout.lines().find_map(|line| { + let mut tokens = line.split_whitespace(); + tokens.next(); + tokens.next().map(str::to_string) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tapped_formula_detection() { + assert!(!is_tapped_formula("jq")); + assert!(!is_tapped_formula("postgresql@17")); + assert!(!is_tapped_formula("homebrew/core/jq")); + assert!(is_tapped_formula("railwaycat/emacsmacport/emacs-mac")); + assert_eq!(core_formula_name("homebrew/core/jq"), "jq"); + assert_eq!(core_formula_name("jq"), "jq"); + assert_eq!( + tapped_formula_name("railwaycat/emacsmacport/emacs-mac"), + "emacs-mac" + ); } } diff --git a/src/system/packages/dnf.rs b/src/system/packages/dnf.rs index 41944ab457..3676e3223b 100644 --- a/src/system/packages/dnf.rs +++ b/src/system/packages/dnf.rs @@ -156,6 +156,7 @@ mod tests { PackageRequest { name: name.to_string(), version: version.map(str::to_string), + tap_url: None, } } diff --git a/src/system/packages/mod.rs b/src/system/packages/mod.rs index 142a14b60e..31182be130 100644 --- a/src/system/packages/mod.rs +++ b/src/system/packages/mod.rs @@ -26,6 +26,9 @@ pub struct PackageRequest { /// manager renders this into its native pin syntax at install time /// (apt: `name=version`, dnf: `name-version`). pub version: Option, + /// manager-specific source URL. Currently used by brew tapped formulae: + /// `[system.brew.taps]` can attach a git URL to `owner/tap/formula`. + pub tap_url: Option, } impl std::fmt::Display for PackageRequest { diff --git a/src/system/packages/pacman.rs b/src/system/packages/pacman.rs index 16cf23206f..c20793f7ba 100644 --- a/src/system/packages/pacman.rs +++ b/src/system/packages/pacman.rs @@ -182,6 +182,7 @@ mod tests { PackageRequest { name: name.to_string(), version: version.map(str::to_string), + tap_url: None, } } diff --git a/xtasks/fig/src/mise.ts b/xtasks/fig/src/mise.ts index f8cbd6367e..a51b3da019 100644 --- a/xtasks/fig/src/mise.ts +++ b/xtasks/fig/src/mise.ts @@ -3235,6 +3235,53 @@ const completionSpec: Fig.Spec = { description: "[experimental] Manage system packages from `[system.packages]`, files\nfrom `[system.files]`, edits from `[system.edits]`, and macOS defaults\nfrom `[system.defaults]`", subcommands: [ + { + name: "brew", + description: "Manage Homebrew taps used by system packages", + subcommands: [ + { + name: "tap", + description: "Tap a Homebrew formula repository", + options: [ + { + name: ["-n", "--dry-run"], + description: + "Print the command that would run without running it", + isRepeatable: false, + }, + ], + args: [ + { + name: "tap", + description: "Tap name, e.g. `owner/repo`", + }, + { + name: "url", + description: + "Git URL for non-GitHub or otherwise custom taps", + isOptional: true, + }, + ], + }, + { + name: ["untap", "remove", "rm"], + description: "Untap Homebrew formula repositories", + options: [ + { + name: ["-n", "--dry-run"], + description: + "Print the command that would run without running it", + isRepeatable: false, + }, + ], + args: { + name: "taps", + description: "Tap name(s), e.g. `owner/repo`", + isVariadic: true, + }, + }, + ], + }, { name: ["install", "i"], description: