brew -- 创建自己的 Formula

摘要

  • brew是一个软件包管理器,同时支持MacOS和Linux,可以很方便地安装各种软件,比如gitnodepython等。

  • 本文介绍如何在macos下创建自己的 Formula

  • 本文基于 MacOS 15.7.2,brew 版本为 Homebrew 5.0.3。

  • 关于 brew 的安装及使用可以参考 MacOS软件包管理器--brew

从一个简单示例开始

  • 这里使用我自己编写的一个命令行脚本为例,脚本名称:color_echo,具体内容可以查看color_echo,这是一个用于终端打印彩色文本的命令。

创建一个Github仓库,用于存储 color_echo 命令

  • 创建一个Github仓库,用于存储 color_echo 命令文件,仓库名称: hanqunfeng/color_echo

  • 编写命令文件

1
2
3
4
5
mkdir color_echo_dir
cd color_echo_dir
# 感兴趣的自己去github上查看文件内容吧,这里就不贴出来了
# 这里将命令存储在了 bin 目录下,这个路径后面编写 Formula 文件时会用到
bin/color_echo
  • 提交代码并打Tag

1
2
3
4
5
6
7
8
9
10
11
# 在 color_echo_dir 目录下初始化仓库
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin https://github.com/hanqunfeng/color_echo.git
git push -u origin main
# 创建Tag
git tag v1.0.0
# 推送Tag
git push --tags
  • 获取tag的sha256值

1
2
3
4
wget https://github.com/hanqunfeng/color_echo/archive/refs/tags/v1.0.0.tar.gz
shasum -a 256 v1.0.0.tar.gz
## 输出
9450952a4b477c83ea2d7e28386d6ae38132bf68c46746aa218c03c21aa75f6d v1.0.0.tar.gz

发布 color_echo

brew create 命令已经失效,需要手工创建

创建 tap 仓库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
brew tap-new hanqunfeng/color_echo
## 输出
Warning: tap-new is a developer command, so Homebrew's
developer mode has been automatically turned on. # 提示开发者模式已自动打开
To turn developer mode off, run:
brew developer off # 如后续需要关闭开发者模式可以运行该命令

## 初始化仓库
Initialized empty Git repository in /usr/local/Homebrew/Library/Taps/hanqunfeng/homebrew-color_echo/.git/
[main (root-commit) 35d602b] Create hanqunfeng/color_echo tap
3 files changed, 107 insertions(+)
create mode 100644 .github/workflows/publish.yml
create mode 100644 .github/workflows/tests.yml
create mode 100644 README.md
==> Created hanqunfeng/color_echo
/usr/local/Homebrew/Library/Taps/hanqunfeng/homebrew-color_echo

When a pull request making changes to a formula (or formulae) becomes green
(all checks passed), then you can publish the built bottles.
To do so, label your PR as `pr-pull` and the workflow will be triggered.

手写一个 Formula 文件

1
2
cd /usr/local/Homebrew/Library/Taps/hanqunfeng/homebrew-color_echo/Formula
touch color_echo.rb

写入内容(模板):模板格式后面会详细介绍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ColorEcho < Formula
desc "Print colorful text in terminal"
homepage "https://github.com/hanqunfeng/color_echo"
url "https://github.com/hanqunfeng/color_echo/archive/refs/tags/v1.0.0.tar.gz"
sha256 "9450952a4b477c83ea2d7e28386d6ae38132bf68c46746aa218c03c21aa75f6d"
license "MIT"

def install
bin.install "bin/color_echo"
end

test do
system "#{bin}/color_echo", "--help"
end
end

提交 Formula 文件到Github仓库

  • 创建一个Github仓库,用于存储 Formula 文件: hanqunfeng/homebrew-color_echo

  • 添加 Formula 文件到仓库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cd $(brew --repo hanqunfeng/color_echo)
git add .
git commit -m "Add color_echo 1.0.0"
git remote add origin https://github.com/hanqunfeng/homebrew-color_echo.git
git push -u origin main
## 输出
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Delta compression using up to 12 threads
Compressing objects: 100% (9/9), done.
Writing objects: 100% (11/11), 2.11 KiB | 2.11 MiB/s, done.
Total 11 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To https://github.com/hanqunfeng/homebrew-color_echo.git
! [remote rejected] main -> main (refusing to allow a Personal Access Token to create or update workflow `.github/workflows/publish.yml` without `workflow` scope)
error: failed to push some refs to 'https://github.com/hanqunfeng/homebrew-color_echo.git'
  • 提示缺少权限,需要给仓库添加权限。在 Github 中添加一个 Personal Access Token,并添加权限repoworkflow,重新推送

1
2
3
4
5
6
7
8
9
10
11
12
13
# 设置远程仓库地址,注意替换为你的仓库地址,并且密钥替换为实际的密钥
git remote set-url origin https://ghp_xxxxx@github.com/hanqunfeng/homebrew-color_echo.git
git push -u origin main
## 输出
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Delta compression using up to 12 threads
Compressing objects: 100% (9/9), done.
Writing objects: 100% (11/11), 2.11 KiB | 2.11 MiB/s, done.
Total 11 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To https://github.com/hanqunfeng/homebrew-color_echo.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.

安装 Formula

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 切换 tap,注意: 执行搜索和安装时需要先切换 tap,否则需要使用完整包名 hanqunfeng/color_echo/color_echo
brew tap hanqunfeng/color_echo

# 搜索
brew search color_echo
## 输出
==> Formulae
hanqunfeng/color_echo/color_echo color-code

# 完整包名搜索
brew search hanqunfeng/color_echo/color_echo
## 输出
==> Formulae
color_echo

# 安装
brew install color_echo
## 输出
==> Fetching downloads for: color_echo
✔︎ Formula color_echo (1.0.0) [Verifying 2.1KB/ 2.1KB]
==> Installing color_echo from hanqunfeng/color_echo
🍺 /usr/local/Cellar/color_echo/1.0.0: 4 files, 7.4KB, built in 5 seconds
==> Running `brew cleanup color_echo`...
Disable this behaviour by setting `HOMEBREW_NO_INSTALL_CLEANUP=1`.
Hide these hints with `HOMEBREW_NO_ENV_HINTS=1` (see `man brew`).

测试 Formula

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
color_echo --help
## 输出
用法: color_echo [参数] 内容

参数说明:
-c, --color <color> 设置前景色(文字颜色)
可选颜色: black red green yellow blue magenta cyan white

-b, --bg <color> 设置背景色
可选颜色: black red green yellow blue magenta cyan white

--bold 加粗字体
--underline 下划线
--italic 斜体字体(仅部分终端,Terminal 不支持,iTerm2 支持)

-n 不换行输出,兼容 echo 的 -n 参数

-h, --help 显示帮助信息

示例:
color_echo --color green "Hello World"
color_echo -c red -b yellow --bold "Error Message"
color_echo --underline "This is underlined"

formula 文件模板

  • Ruby语法,定义一个Formula的子类

1
2
3
4
# class 子类 < 父类
class ColorEcho < Formula

end
  • 添加属性

1
2
3
4
5
desc "Print colorful text in terminal"  # 描述
homepage "https://github.com/hanqunfeng/color_echo" # 项目地址
url "https://github.com/hanqunfeng/color_echo/archive/refs/tags/v1.0.0.tar.gz" # 下载地址,安装时会自动下载并解压
sha256 "9450952a4b477c83ea2d7e28386d6ae38132bf68c46746aa218c03c21aa75f6d" # sha256,校验v1.0.0.tar.gz
license "MIT" # 许可
  • 添加安装方法

1
2
3
4
5
6
7
8
9
def install  # Homebrew 会执行的安装步骤,工作目录为解压后的文件目录
# 这里只有一个步骤,如果要一次安装多个命令,可以多次添加 bin.install
bin.install "bin/color_echo" # 将 bin/color_echo 安装到 /usr/local/bin/color_echo
# 如果要指定安装后的命令名称,可以使用如下方式
# bin.install "bin/color_echo.sh" => "color_echo"

# 如果需要执行系统命令可以使用如下方式
# system "echo", "hello world"
end
  • 内部文件安装到 libexecbin 会被链接进 PATHlibexec 不会,适合放主脚本、函数库、资源和补全文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def install
# libexec 和 bin 一样位于 Cellar,例如 .../Cellar/<formula>/<version>/libexec
# 区别是它不会被链接进 PATH
libexec.install "springboot" # 复制文件
libexec.install "lib" # 复制整个目录
libexec.install "completions"
libexec.install "assets"

# / 用来拼接子路径;mkpath 相当于 mkdir -p,源码包里没有的目录要在这里创建
(libexec/"deps-cache").mkpath

# chmod 第一个参数是八进制权限。0755:所有者可读写执行,其他人可读可执行
chmod 0755, libexec/"springboot"
# Dir 是 Ruby 标准库的类,不是 Homebrew 定义的
# Dir[通配符] 是 Dir.glob 的简写,返回匹配路径组成的数组,each 再逐个处理
# libexec/"lib/*.sh" 能放进方括号,是因为 libexec 是 Pathname,/ 用来拼路径
# { |f| ... } 里的 |f| 是给当前这个路径起的名字,后面的 f 就是在使用它
# 两个 f 是同一个变量,换成 file 也可以:{ |file| chmod 0755, file }
Dir[libexec/"lib/*.sh"].each { |f| chmod 0755, f }
end
  • install 里还可以直接用 Ruby 标准库。DirFilePathname 是类,FileUtils 是模块。Formula 已经 include FileUtils,所以 chmodcpmvrm_rfmkdir_p 不用写 FileUtils. 前缀,也不用再 require

  • binlibexecprefixlibsharebash_completionzsh_completion 不是类名,是 Formula 的方法,返回值是 Pathname

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
def install
libexec.install "lib"

# Dir:按通配符找文件、判断目录、创建目录
# Dir[] 和 Dir.glob 是同一个方法,返回路径数组;** 会进入子目录
# 这两行只取出路径,没有 each,不会改文件
Dir[libexec/"lib/*.sh"]
Dir.glob(libexec/"lib/**/*.sh")
Dir.exist?(libexec/"lib") # 目录是否存在
Dir.children(libexec/"lib") # 只列这一层,不含 . 和 ..
Dir.mkdir(libexec/"cache") # Ruby 的 Dir.mkdir:只建这一层,父目录必须已经存在

# Pathname:bin、libexec 返回的就是它
# glob 的模式相对这个目录,等价于 Dir[libexec/"lib/*.sh"]
(libexec/"lib").glob("*.sh").each { |f| chmod 0755, f }
# mkpath 相当于 mkdir -p,父目录不存在也会一并创建
(libexec/"deps-cache").mkpath
# write 把字符串写入文件;文件不存在会创建
(libexec/"VERSION").write "1.0.0\n"

# File 只处理单个路径,不能写通配符
File.exist?(libexec/"springboot")
File.chmod(0755, libexec/"springboot")

# 下面这些来自 FileUtils,Formula 里可以直接调用
# chmod 的第二个参数可以是一个路径,也可以是 Dir[] 返回的数组
chmod 0755, libexec/"springboot"
chmod 0755, Dir[libexec/"lib/*.sh"]
cp libexec/"springboot", bin/"springboot"
rm_rf libexec/"cache"
# Formula 重写了 mkdir:等价于 mkdir -p;传代码块时还会切进这个目录,块结束后再切回来
mkdir libexec/"cache" do
touch "keep"
end
end
  • bin 里写一个包装脚本。用户执行的是 PATH 上的命令,真正的脚本留在 libexec

1
2
3
4
5
6
7
8
9
10
11
12
def install
# write 把字符串写入文件
# <<~EOS ... EOS 是会去掉公共前导缩进的 heredoc,写出来的脚本不会带上 Ruby 代码的缩进
# #{libexec} 在执行 Formula 时就会替换成绝对路径,写死进生成的脚本
(bin/"springboot").write <<~EOS
#!/usr/bin/env bash
exec "#{libexec}/springboot" "$@"
EOS
# exec 用目标脚本替换当前 shell,退出码和信号会原样传回
# "$@" 原样转发全部参数
chmod 0755, bin/"springboot"
end
  • 安装 shell 补全。=> 右侧是安装后的文件名

1
2
3
4
5
6
def install
# bash 补全一般与命令同名,装到 $(brew --prefix)/etc/bash_completion.d/
bash_completion.install libexec/"completions/springboot.bash" => "springboot"
# zsh 补全文件名习惯以 _ 开头,装到 $(brew --prefix)/share/zsh/site-functions/
zsh_completion.install libexec/"completions/_springboot"
end
  • 添加测试方法

1
2
3
4
5
6
7
8
9
10
test do  # Homebrew 在安装完你的 formula 之后,会在一个隔离环境中执行这段代码
# system 指令用于执行系统命令,逗号分隔相当于空格
system "#{bin}/color_echo", "--help" # 相当于执行 color_echo --help
# 断言测试
# assert_equal "2\n", pipe_output("#{bin}/jq .bar", '{"foo":1, "bar":2}')

# shell_output 执行命令并返回标准输出;退出码不是 0 时,这条测试直接失败
# assert_match 判断输出里是否包含这段文字,参数也可以写成正则
# assert_match "springboot <command>", shell_output("#{bin}/springboot --help")
end

#{bin}: 当前这个 formula 的「安装目录里的 bin 目录」,不同平台会不同

系统 / 架构 实际路径示例
Intel Mac /usr/local/Cellar/color_echo/1.0.0/bin
Apple Silicon /opt/homebrew/Cellar/color_echo/1.0.0/bin
Linuxbrew /home/linuxbrew/.linuxbrew/Cellar/.../bin
  • 如果当前安装的命令依赖其它命令,可以使用如下方式添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 放在 def install 方法之前
depends_on "jq" # 在安装你的 formula 之前,Homebrew 会先自动安装 jq,并保证在你的 install / test 阶段可以用
depends_on "jq" => :build # 只在构建时需要
depends_on "jq" => :test # 测试时需要
depends_on "jq" => [:build, :test] # 构建和测试都需要

# 环境约束
depends_on macos: :sonoma # 仅 macOS Sonoma
depends_on arch: :x86_64 # 仅 x86_64
depends_on xcode: ["9.3", :build] # 仅当 Xcode≥9.3 且用于 build


# 依赖第三方库中的命令要用完整包名
depends_on "hanqunfeng/color_echo/color_echo"

  • 安装结束后给用户一段提示。caveats 的返回值会在 brew install 完成时打印出来,适合说明 formula 没有代装的依赖、可选工具和环境变量

1
2
3
4
5
6
7
def caveats
# <<~EOS 同样会去掉公共前导缩进,打印出来的是对齐后的纯文本
<<~EOS
python3 is required but NOT installed by this formula.
brew install python@3.13
EOS
end
  • 安装完成之后还要在用户机器上再做的事,写 post_install_steps,不要写 def post_install。它和 brew -- 创建自己的 Cask 里的 postflight_steps 是同一类限制:步骤会放进 JSON API,brew postinstall <formula> 可以单独再跑一遍,块里只能调用规定好的步骤,不能写 DirFileif、循环。

  • def install 没有被这套步骤替换,官方 Formula 里也仍然是任意 Ruby。普通 brew install 拿到的是已经编好的 bottle,不会在你机器上再执行一遍 installpost_install 不同,bottle 倒进本机之后还要执行,所以不能带上无法审查的代码。

  • Homebrew 6.0.16(2026-08-10)起,官方 tap 的检查拒绝旧的 def post_install7.0.0(2026-09-13)起,所有 tap 跑 brew style / brew audit 都会报 Formulae must use post_install_steps instead of post_install。第三方 tap 在迁移期间加载旧方法还能执行,但已经废弃。同一个 Formula 里不能同时写 post_installpost_install_steps

  • 和 Cask 不同,Formula 的步骤路径要显式写 base:,例如 :var:etc:prefix。安装时才知道的路径用 {{HOMEBREW_PREFIX}},不要用 #{prefix}#{...} 在读取 Formula 时就算完了。

1
2
3
4
5
6
7
8
9
# 放在 class 里面、def install 之外
post_install_steps do
# 在 $(brew --prefix)/var/log/foo 建目录,父目录不存在也会创建
mkdir_p "log/foo", base: :var
# 配置已存在就整段跳过,避免覆盖用户改过的文件
unless_path_exists "foo.conf", base: :etc do
write_file "foo.conf", "prefix = {{HOMEBREW_PREFIX}}\n", base: :etc
end
end
  • 查上游有没有新版本用 livecheck,再执行 brew livecheck <formula>。它只比较版本号,不下载、不安装。原理是:请求 url 的页面,用 regex 扫描全文,圆括号里捕获的那段才是版本号;匹配到多个时取得最大的一个,和脚本里的 version 比较。

  • 这个命令不走 JSON API,只读本机 tap 里的 .rb。Homebrew 4.0 之后官方 homebrew/core 默认不 clone 到本地,所以直接查 git 会报 These formulae are not in any locally installed taps!。要先 brew tap homebrew/core,并且脚本里写了 livecheck,命令才能按这段配置查出新版本。没写 livecheck 时,它会改去试 urlhomepage;地址如果只是某一个压缩包,页面上没有版本列表,就查不出更新。skip 则明确不请求。

  • 调试时看它请求了哪个地址、匹配到哪些版本:brew livecheck --debug <formula>。Cask 的写法见 brew -- 创建自己的 Cask

1
2
3
4
5
6
7
8
9
10
11
12
# 官方 git formula 的检查:打开目录列表页,从文件名里取出版本
# href="git-2.51.0.tar.gz" 里,括号捕获的是 2.51.0
# git[._-] 兼容 git-、git_、git.;v? 允许文件名里多一个 v;\.t 兼容 .tar.gz、.tar.xz
livecheck do
url "https://mirrors.edge.kernel.org/pub/software/scm/git/"
regex(/href=.*?git[._-]v?(\d+(?:\.\d+)+)\.t/i)
end

# 不检查。字符串是跳过原因,brew livecheck 看到 skip 就停止
livecheck do
skip "Auto-generated on release."
end

一个真实案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class JavaProjectGenerator < Formula
desc "Generate Spring Boot projects quickly with shell scripts"
homepage "https://github.com/hanqunfeng/java-project-generator"
url "https://github.com/hanqunfeng/java-project-generator/archive/refs/tags/v1.0.9.tar.gz"
sha256 "d648d8ab75cc2b961c19c1cb9c281afbbfda6b21c767b2c607cfbb52ce24ca70"
license "MIT"

# 运行时依赖:安装本 formula 之前会先安装这两个命令
depends_on "glow"
depends_on "pandoc"

def install
# 内部文件放进 libexec,不会出现在 PATH 里
libexec.install "springboot" # 真正的主脚本
libexec.install "lib" # 配套的 shell 函数库
libexec.install "completions" # 补全脚本,下面再登记到 shell 的补全目录
libexec.install "assets"
# 源码包里没有这个目录,安装时创建,给依赖列表缓存用
(libexec/"deps-cache").mkpath

# 主脚本和 lib 下的 *.sh 都要有执行权限,否则包装脚本 exec 过去会失败
chmod 0755, libexec/"springboot"
Dir[libexec/"lib/*.sh"].each { |f| chmod 0755, f }

# PATH 上只放这个包装脚本
# #{libexec} 安装时展开成 Cellar 绝对路径;exec 替换当前 shell;"$@" 原样转发参数
(bin/"springboot").write <<~EOS
#!/usr/bin/env bash
exec "#{libexec}/springboot" "$@"
EOS
chmod 0755, bin/"springboot"

# bash 补全改名为命令名;zsh 沿用 _springboot 这个文件名
bash_completion.install libexec/"completions/springboot.bash" => "springboot"
zsh_completion.install libexec/"completions/_springboot"
end

def caveats
# 安装结束时打印:python3 和下面这些工具都不是本 formula 的 depends_on
<<~EOS
python3 is required but NOT installed by this formula. Install before use:
brew install python@3.13
Needed for: deps list cache, --deps validation, and other Initializr metadata parsing.

Optional tools (not installed by this formula):
- jq: brew install jq
- libxml2 (xmllint): brew install libxml2
- xmlstarlet: brew install xmlstarlet
- mvn: brew install maven
xmllint / xmlstarlet / mvn: only for Maven "module add" parent POM parsing (awk fallback still works).

Network access to Spring Initializr is required (default: https://start.spring.io).
Override with: export INITIALIZR_BASE_URL=<mirror-url>
EOS
end

test do
# shell_output 取命令的标准输出,assert_match 确认里面有预期文案
assert_match "springboot <command>", shell_output("#{bin}/springboot --help")
# dry-run 不真正生成项目,用来确认 create 子命令能跑起来
assert_match "Dry-run 模式", shell_output("#{bin}/springboot create --name=brewtest --dry-run")
end
end

后记