
软件、工具链与 mise
你怎么给你的电脑安装软件的?是不是缺什么装什么,图形软件去官网下载,命令行工具用 Homebrew,Node.js 交给 nvm,Python 再装一个 pyenv。
可能刚装完都挺好的,过上一年,~/.zshrc 里就会长出好几段初始化脚本。此时再问一句「现在运行的 node 到底从哪里来」,往往要先查半天 PATH。
这种环境平时看不出问题。一旦同时维护新旧项目,事情马上多起来:一个项目还在 Node.js 20,另一个已经用了 Node.js 24;Python 版本不同,连能不能装上依赖都不一样;全局 pnpm 升级以后,还可能顺手改掉项目锁文件。若 Node.js 是 Homebrew 装的,想保留两个精确版本,又得折腾 brew link 和 brew unlink。
我的规则是 Homebrew 管软件,mise 管会随项目变化的工具。
Firefox、PostgreSQL、OpenSSL 和 Git 属于软件。Node.js、Python、pnpm、Terraform、protoc 属于项目。这样分完以后,Homebrew 和 mise 并不打架,也不用再为每一种语言装一套长得差不多的版本管理器。
如果你是 Windows 推荐使用 WinGet,Linux 用户高人一等,软件包管理器是自带的,只需要看 mise 部分即可。
一、先别急着选工具,先分清在安装什么
Homebrew 和 mise 看上去都在下载可执行文件,但判断依据不一样。
Homebrew 关心的是操作系统环境。它知道一个软件在 Apple Silicon 和 Intel Mac 上该下载什么、依赖哪些库、如何升级和卸载。Cask 还可以处理 .app、字体和一些带安装器的桌面软件。它回答的是「这台 Mac 装没装 PostgreSQL、Git、Firefox」。
mise 先看当前工作目录,再决定应该把哪一个版本放到 PATH 前面。项目 A 可以用 Node.js 20,项目 B 可以用 Node.js 24,回到主目录则使用个人默认版本。它回答的是「我在这个仓库里应该用什么版本」。
两者的职责可以粗略画成下面这样:
| 层次 | 典型对象 | 版本由谁决定 | 更合适的工具 |
|---|---|---|---|
| macOS 与桌面应用 | Firefox、iTerm2、Docker Desktop、字体 | 使用者或设备维护者 | Homebrew Cask |
| 系统服务与原生库 | PostgreSQL、Redis、OpenSSL、libpq | 本机运维需求 | Homebrew Formula |
| 通用命令行工具 | Git、jq、fzf、shellcheck | 个人工作环境 | 通常用 Homebrew |
| 项目运行时 | Node.js、Python、Go、Ruby、Java | 项目仓库 | mise |
| 项目构建工具 | pnpm、Terraform、protoc、golangci-lint | 项目仓库 | mise |
| 应用依赖 | npm 包、Python 包、Go Module | 应用清单与锁文件 | npm、uv、Go Modules 等生态包管理器 |
最后一行容易弄混。mise 可以安装 Python,却不会取代 uv 或 pip;它可以安装 Node.js 和 pnpm,也不会替 pnpm 解析 package.json 与 pnpm-lock.yaml。mise 管的是工具本身,应用用了哪些包,仍由各语言自己的包管理器负责。
这套分层不是法律。jq 既可以用 Homebrew 全局安装,也可以由 mise 固定在项目里。判断标准不是「它属于哪种语言」,而是它的版本是否构成项目行为的一部分。
如果脚本只用到 jq 的基本功能,我就用 Homebrew,没必要每个仓库都声明一遍。Terraform、golangci-lint、protoc 这类工具不同版本可能改变输出,应该跟着项目走。拿不准时就问一句:换个版本会不会改掉构建结果?会,就写进仓库。
二、Homebrew 擅长什么,又不擅长什么?
在 macOS 上,Homebrew 依然是我安装软件的第一选择。它把安装、依赖、升级、卸载和架构差异都收进了同一套命令。
安装命令行软件和桌面应用分别是:
brew install git jq postgresql@17
brew install --cask firefox visual-studio-code rectangle在 Apple Silicon Mac 上,Homebrew 默认前缀通常是 /opt/homebrew;Intel Mac 通常是 /usr/local。不要把其中一个路径硬编码进共享脚本,使用 brew --prefix 查询更稳妥:
brew --prefix
brew --prefix openssl@3需要长期运行的服务也适合交给 Homebrew:
brew services start postgresql@17
brew services list
brew services stop postgresql@17这类服务没必要交给 mise。是否登录后启动、数据目录放在哪里,都属于这台电脑的状态,不该随着 cd 到哪个目录而变化。
Homebrew 还有一个常被低估的能力:Brewfile。你可以把常用的 formula、cask、Mac App Store 应用和编辑器扩展声明出来,再在新机器上执行 brew bundle:
brew "git"
brew "jq"
brew "postgresql@17", restart_service: true
cask "firefox"
cask "visual-studio-code"
cask "rectangle"它很适合记录「我希望这台 Mac 安装哪些软件」,但不是锁文件。Homebrew 官方明确说明,Brewfile 不提供类似 package-lock.json 的版本锁定。同一份 Brewfile 隔几个月再安装,得到的通常是那时仓库里的版本。作为机器恢复清单没问题,拿它复现项目工具链就不够了。
Homebrew 的多版本只是有限支持
Homebrew 也有 node@22、postgresql@17 这种带版本的 formula,还可以用 brew pin 暂停升级。不过这离项目级多版本管理仍有一段距离。
Homebrew 的版本化 formula 只保留维护者认为值得支持的主要版本,不承诺提供任意历史版本。按照官方的版本化 Formula 规则,它通常区分主版本或次版本,而不是帮你长期保存每个补丁版本;旧版本是否继续存在,还取决于上游支持状态、使用量和维护成本。
brew pin 只是告诉 brew upgrade 暂时别动某个软件。它不会让 A 项目自动使用 20.11.1、B 项目自动使用 22.14.0。固定期间也收不到安全更新,依赖升级还可能被它卡住。我只把它当临时措施。
即使同时安装 node@22 和新版 node,仍然要决定哪一套 bin 排在 PATH 前面。你可以反复执行 brew link、brew unlink,也可以手写 shell 函数,但这些状态没有自然地跟项目仓库绑定。团队成员看不到,CI 不一定复现,几个月后的自己也未必记得。
所以我用 Homebrew 决定「本机装什么」,偶尔用 brew pin 延迟升级;项目究竟用哪个精确版本,则留给 mise。硬让 Homebrew 完成目录级自动切换,最后还是要靠一堆自己维护的 shell 脚本。
三、为什么主要推荐 mise?
mise 吸引我的地方,是它把 nvm、pyenv、rbenv、tfenv 这类工具最常用的部分统一了。项目里放一份 mise.toml,Node.js、Python 和各种 CLI 都按目录切换;有需要还可以顺便声明环境变量和任务。原有项目不必立刻改文件,它也能兼容 .tool-versions、.nvmrc 等格式。
工具的来源也不局限于一种插件。mise 有内置实现,也可以走 asdf 插件、aqua Registry、GitHub Releases、npm、pipx、Cargo 等后端。需要复现安装时,再生成 mise.lock 记录具体版本和校验信息。
最重要的仍然是目录切换。全局版本只是兜底,仓库里的配置优先。
假设全局配置选择 Node.js LTS:
mise use -g node@lts进入旧项目后执行:
mise use node@20mise 会安装匹配版本,并创建或修改项目里的 mise.toml。以后进入这个目录就是 Node.js 20,离开以后回到全局默认。Python、Ruby 和其他工具也是同一套操作,不用在不同管理器之间来回查命令。
界面统一以后,底下的安装过程还是各干各的。Python 可能需要编译和系统库,Node.js 通常直接使用预编译包,GitHub 上的小工具也许只要解压一个 release asset。安装失败时要看具体后端和编译日志,不能只搜「mise 安装失败」。
四、在 macOS 上安装和启用 mise
既然已经决定用 Homebrew 管机器,用 Homebrew 安装 mise 最顺手:
brew install mise
mise --version这并不矛盾。Homebrew 只负责把 mise 这个稳定入口放到 Mac 上;Node.js、Python 等项目工具仍由 mise 管理。将来 brew upgrade mise 更新的是版本管理器本身,不会顺便把所有项目运行时换掉。
macOS 近年的默认 shell 是 Zsh,可以把下面一行放进 ~/.zshrc:
eval "$(mise activate zsh)"重新打开终端,或者执行:
source ~/.zshrc
mise doctormise activate 会让 mise 在提示符出现前根据当前目录调整环境。与单纯把 shims 目录塞进 PATH 相比,官方更推荐在交互式终端使用这种 PATH activation。shims 仍适合 IDE、非交互程序或不方便执行 shell hook 的环境;CI 则通常根本不需要激活,直接使用 mise install、mise exec 和 mise run 即可。
这里最容易出现的故障是:电脑上已经有不止一个版本管理器,大家都想站在 PATH 最前面。
排查时不要凭感觉,直接看证据:
type -a node
which node
mise which node
mise ls --current
mise configtype -a 会列出 shell 能找到的所有同名命令;mise which 显示 mise 选择的实际路径;mise config 列出当前生效的配置文件。若 node 仍然来自 nvm、asdf 或 /opt/homebrew/bin,检查 ~/.zshrc 中各段初始化的顺序,并移除已经弃用的旧管理器配置。不要通过继续添加五行 export PATH=... 来解决 PATH 太乱的问题,那相当于用更多电线整理线团。
先设置个人默认值
全局工具写入 ~/.config/mise/config.toml,适合作为没有项目配置时的默认环境:
mise use -g node@lts
mise use -g python@3.13lts 和 3.13 都不是精确版本。放在个人全局配置里问题不大,我想升级时直接升级即可;项目里则应写得更明确,或者配合 lockfile,免得两个人在不同日期装出两个版本。
查看状态常用这些命令:
mise ls
mise ls --current
mise ls-remote node
mise current
mise outdated不要把「已安装」和「当前已选择」混为一谈。mise install node@20 只是把版本安装到数据目录;mise use node@20 才会同时把版本写入配置,使其在相应作用域生效。平时更常用 mise use,因为它把安装和声明合在了一次操作里。
五、给项目写一份能读懂的 mise.toml
进入项目根目录后,可以逐项执行 mise use,也可以直接创建 mise.toml。一份不花哨但够用的配置如下:
min_version = "2026.8.0"
[tools]
node = "24.7.0"
pnpm = "10.15.1"
python = "3.13.7"
[env]
APP_ENV = "development"
_.path = [ "./bin" ]
[tasks.setup]
description = "安装项目依赖"
run = [
"pnpm install --frozen-lockfile",
"python -m pip install -r requirements.txt",
]
[tasks.check]
description = "运行提交前检查"
depends = [
"lint",
"test"
]
[tasks.lint]
run = "pnpm lint"
[tasks.test]
run = "pnpm test"上面的版本只是配置示例,不代表每个项目都应该使用这些版本。真正写配置时,应选择项目已经验证过的版本。
团队成员拿到仓库后,先阅读配置,再执行:
mise install
mise run setup
mise run check工具版本和使用方式放在一起,比在 wiki 里留一篇环境搭建文档靠谱。CI 也不用模拟我的交互式 Zsh,安装 mise 后执行 mise install 和 mise run check 就够了。shell 激活只是为了平时在终端里方便,不应成为构建成功的隐藏条件。
新同事可以先用 mise tasks ls 看项目提供了哪些入口,不用一上来就背前端、后端各自的命令。
任务功能很好用,也要控制分寸。如果 mise.toml 里塞进几百行部署逻辑,它就从「统一入口」膨胀成了另一种难以测试的脚本语言。短命令、环境准备和任务编排放在这里;复杂逻辑仍然写成有 shebang、能独立执行和测试的脚本,再由任务调用。
一次性运行不必污染全局环境
偶尔只想用 Python 3.12 执行一个脚本,不需要先改项目配置:
mise exec python@3.12 -- python ./scripts/migrate.py也可以使用短别名:
mise x node@22 -- node --version这类命令很适合排错、验证跨版本兼容性,或者运行低频工具。版本若尚未安装,mise 会按需安装;命令结束后,项目配置没有变化。
这条命令仍会下载并执行外部软件。CI 中最好写明确版本,别把 latest 塞进发布流程。
版本号写多精确?
node = "24" 会自动跟上同一主版本的新版本,但不同时间安装可能得到不同结果。node = "24.7.0" 更明确,升级也得自己动手。我通常这样处理:
- 个人全局环境可以使用
lts、latest或主版本前缀,换取省心; - 普通业务项目可以写主版本或次版本,再提交
mise.lock; - 发布工具、代码生成器和兼容性敏感项目,优先写精确版本并使用 lockfile;
- 无论固定多细,都要安排更新,不能把「可复现」理解成「永不升级」。
宽松版本加锁文件比较省心:配置表达「项目使用 Node.js 24」,锁文件保存这次实际解析到的 24.x.y。升级后锁文件会变化,代码审查时能看到。
六、mise.lock 解决了什么,没解决什么?
只在 mise.toml 写 node = "24",今天和三个月后可能解析到不同补丁版本。执行 mise lock 可以生成项目锁文件:
mise lock
mise install --locked也可以在配置中启用自动维护:
[settings]
lockfile = true按照 mise 的 lockfile 文档,锁文件会记录具体解析版本,并在受支持的后端中保存下载 URL 与校验信息。严格模式可以要求安装时使用已经预解析的记录,减少临时查询外部 API 带来的变化,也更适合 CI。
mise 有多种后端,有些直接下载带校验信息的发布制品,有些需要调用 npm、Cargo、pipx 等外部工具,有些通过 asdf 插件在安装时解析和编译。官方文档也明确列出了严格锁定尚不能完整覆盖的后端。锁文件的保证取决于安装后端能提供什么。
即使哈希完全一致,它最多证明下载内容与记录相同,不能证明上游发布的内容没有恶意代码。项目配置与任务还可能修改环境、执行命令,因此克隆陌生仓库后,不应把 mise install 当成无害的文本解析。先读 mise.toml、包含的配置和任务,再决定是否执行。mise 提供配置 trust 机制,但工具不能替人判断一段被允许执行的脚本是否合理。
对要求高一点的项目,我会补上这些限制:
- 提交
mise.toml与mise.lock,让版本和来源变化进入代码审查; - 在 CI 使用
mise install --locked,避免缺少锁定记录时悄悄重新解析; - 限定需要支持的平台,避免锁文件塞满永远不会使用的制品;
- 优先选择能提供校验和、签名或来源证明的后端;
- 对关键工具设置更新机器人,但仍然运行测试和人工审查;
- 不在
[env]中提交密钥,把秘密留给系统钥匙串或专用秘密管理器。
版本当然还是要更新,只是把更新留在一笔看得见、跑过测试的提交里。
七、如何兼容已有的版本文件?
迁移到 mise 时不用先删旧文件,更不用要求全组同一天换工具。
很多仓库已经有 .nvmrc、.node-version、.python-version、.ruby-version、rust-toolchain.toml 或 .tool-versions。这些文件有的属于语言生态惯例,有的属于特定版本管理器。mise 称前一类为 idiomatic version files,可以按工具启用读取。例如希望继续以 .nvmrc 作为 Node.js 的版本来源:
mise settings add idiomatic_version_file_enable_tools node需要注意,当前 mise 文档说明这项兼容默认并非全部开启。不要看到仓库里有 .nvmrc 就想当然地认为 mise 一定读取了它,使用下面的命令检查实际选择及来源:
mise ls --current
mise config
node --version如果团队成员分别使用 nvm、Volta 和 mise,保留 .nvmrc 会比强迫所有人读取 mise.toml 更温和。相反,如果项目有多种运行时、环境变量和任务,mise.toml 的表达力明显更好,可以把它作为主配置,再根据生态需要保留一份兼容文件。
麻烦的是两份文件写了不同版本。例如 .nvmrc 写 Node.js 22,mise.toml 写 Node.js 24,使用不同管理器的人都会觉得自己的环境没问题。迁移期保留两份文件可以,最好让 CI 检查它们是否一致,并明确哪一份才是权威配置。
八、与 nvm、Volta、asdf、aqua 怎么选?
下面这张表只比较日常使用。工具支持得多不一定更好,纯 Node.js 项目没必要为一个用不到的 Python 后端买单。
| 工具 | 主要范围 | 自动按项目切换 | 配置载体 | 优势 | 主要代价 | 上手难度 |
|---|---|---|---|---|---|---|
| Homebrew | macOS 软件、库、服务、CLI | 不以此为目标 | Brewfile、formula、cask | macOS 生态成熟,安装与卸载统一 | 任意历史版本和项目级切换较弱 | 低 |
| mise | 多语言运行时、CLI、环境与任务 | 支持 | mise.toml、mise.lock、兼容版本文件 | 范围广,日常操作统一,适合本地与 CI | 后端多,保证和故障模式不完全一致 | 低到中 |
| nvm | Node.js | 默认需手动 nvm use,可加 shell hook | .nvmrc | 专注、普及、概念少 | shell 初始化较重,只解决 Node.js | 低 |
| Volta | Node.js 与 JS 包管理器/全局 CLI | 支持 | package.json | shim 切换自然,JS 体验顺滑 | 生态边界明确,不管理其他语言 | 低 |
| asdf | 多语言运行时与 CLI | 支持 | .tool-versions | 生态成熟、配置简单、插件多 | 要管理插件及其依赖,插件质量不一 | 中 |
| aqua | 版本化 CLI 与发布制品 | 支持配置查找与代理 | aqua.yaml、checksums | 声明式、Registry 与供应链能力强 | 配置和治理概念较多,不以完整开发环境为唯一目标 | 中到高 |
「上手难度」是我按个人使用给的主观评价。团队已经做好模板和封装以后,情况会不同。
nvm:只做 Node.js,简单而传统
nvm 是许多前端开发者接触的第一个版本管理器。它是加载到 shell 中的函数,而不是普通可执行文件,所以验证安装要用 command -v nvm,which nvm 不一定得到你期待的结果。它的核心工作流非常直白:
nvm install 24
nvm use 24
nvm alias default 24项目在 .nvmrc 中写入版本,再执行 nvm use。nvm 会向上查找该文件,学习成本很低,社区资料也很多。若你只写 JavaScript,团队已经统一 .nvmrc,没有 Python、Go、Java 等多工具链需求,继续用 nvm 完全合理。
它的不足也来自这种传统设计。nvm 通过修改当前 shell 环境切换版本,默认不会只因 cd 就自动执行 nvm use;官方 README 提供 Zsh hook 示例,但那仍是额外的 shell 配置。每次启动 shell 都要加载函数,复杂配置可能影响启动速度。不同 Node.js 版本的全局 npm 包也各自存放,切换后发现某个全局命令消失,是经典节目。
还有一个 macOS 细节:nvm 官方明确不支持 Homebrew 安装的 nvm。可以用 Homebrew 管 Node.js,也可以按 nvm 官方方式安装 nvm,但不要因为所有东西都想从 brew install 进入,就忽略项目自己的支持边界。
从 nvm 迁到 mise 不必立刻删除 .nvmrc。先让 mise 读取它,确认版本和 CI 一致,再决定是否改用 mise.toml。
Volta:JavaScript 单一生态里的优秀体验
Volta 同样专注 JavaScript,但使用 shim 路由命令,不依赖每次切目录执行一段 shell hook。volta pin node@20 会把精确版本写进 package.json 的 volta 字段;进入项目后运行 node,Volta 自动选择对应版本。它还能管理 npm、Yarn,以及通过 npm 分发的全局 CLI,并为这些 CLI 记住合适的 Node.js 引擎。
只写 Node.js 的话,Volta 甚至可能比 mise 更顺手:装完以后几乎感觉不到它,版本配置又在现成的 package.json 里。可它不管 Python、Go、Ruby 和 Terraform,项目一旦混合多种工具,还是得再装一套管理器。但是 Volta 已停止维护了。
asdf:mise 最直接的前辈与对手
asdf 率先把「一个版本管理器配多个插件」推广成常见方案。项目用一份 .tool-versions 声明多种工具,shim 在执行时沿目录向上查找版本。它的模型清晰、生态广,至今仍是可靠选择。
很多比较文章还在批评 asdf 是加载缓慢的 Bash 脚本,这已经过时。asdf 从 0.16.0 起完成了 Go 重写,现在是独立二进制;当前命令也从旧的 asdf global、asdf local 调整为 asdf set。阅读迁移资料时要先确认它讨论的是旧版还是新版,具体变化可参考 asdf 官方升级指南。
即便如此,asdf 的插件仍然是使用体验的关键变量。安装某种语言前通常要先添加插件,并查看该插件在 macOS 上需要哪些系统依赖。Node.js 插件可能需要 GnuPG,Python 构建可能依赖 OpenSSL、readline 和编译工具。核心程序统一了命令,却无法保证每个社区插件有相同的维护质量和安全属性。
mise 与 asdf 的关系不是推倒重来。mise 能读取 .tool-versions,也保留 asdf backend 兼容大量插件;与此同时,它正优先使用内置、aqua、GitHub 等不需要执行插件安装脚本的后端。对现有 asdf 用户而言,迁移可以非常渐进:保留 .tool-versions,先用 mise 解析和安装,遇到依赖插件专属行为的工具再单独处理。
已经围绕 asdf 做好插件白名单和镜像的团队,没必要折腾迁移。从零开始的话,mise 少一步插件管理,还能顺手处理环境变量、任务和 lockfile,我更愿意用它。
aqua:更强调声明、Registry 与供应链
aqua 是一款声明式 CLI 版本管理器。aqua.yaml 不只列出包与版本,也会固定所使用的 Registry 版本。标准 Registry 描述各工具从哪里下载、不同系统和架构对应哪个制品、如何解压以及暴露哪些命令。
它对供应链问题的处理比许多个人版本管理器更主动:支持 checksum 文件、Policy as Code,以及 Cosign、SLSA Provenance、GitHub Artifact Attestations 等验证方式。aqua 的 lazy install 会在第一次执行某个命令时下载对应工具,开发体验方便;对治理要求严格的环境,也可以关闭 lazy install,要求预先显式安装。
代价是概念明显更多。aqua.yaml、Registry 版本、包名、checksum 和策略都要理解。个人只想切两个 Node.js 版本,用它有点重;平台团队要统一几十个仓库里的 Terraform 周边工具,它就很对路。
mise 也能直接使用 aqua backend,并且不要求安装 aqua CLI。因此二者不总是二选一:你可以保留 mise 统一的项目入口,对适合从 aqua Registry 安装的工具借用其配方。但这不代表自动继承 aqua 所有策略能力,安全保证仍要根据 mise 的具体后端与 lockfile 行为判断。
fnm、pyenv、rbenv、rustup 和 Nix 呢?
还有一些工具更专门,没必要硬塞进主表。
fnm 是用 Rust 编写的 Node.js 版本管理器,追求快速启动,并兼容 .nvmrc 等文件。它适合想保留 nvm 工作流、又在意 shell 启动和自动切换体验的人。问题与 Volta 类似:项目一旦需要多语言,仍要组合其他管理器。
pyenv 和 rbenv 分别深耕 Python 与 Ruby。单语言工具通常更贴近各自生态,遇到编译参数、虚拟环境或特殊版本时,资料也更集中。如果你的工作几乎全在一种语言里,这种专注是优点;若每天跨越三四种运行时,多套相似命令和 shell 初始化就开始收维护费。
Rust 的 rustup 更特殊。它不只切换编译器版本,还管理 target、component 和 toolchain channel。mise 可以安装 Rust,但涉及交叉编译、nightly 和组件管理时,我仍会保留 rustup。为了统一而绕开官方工具,没省下什么事情。
Nix 则把问题提升到了另一层:不仅管理几个可执行文件,还能声明完整开发环境与依赖图,可复现能力更强。代价是新的语言、存储模型、构建方式和 macOS 集成成本。已经采用 Nix 的团队未必需要 mise;只是想结束 Node.js 与 Python 版本冲突的个人开发者,也没有必要一步跨进完整的系统级声明模型。
九、什么时候用 Homebrew,什么时候用 mise?
如果前面的比较太长,只记住下面这组判断。
优先使用 Homebrew
- 软件是 macOS 图形应用、字体、Quick Look 插件或带系统集成的 Cask;
- 软件需要作为本机后台服务运行,例如 PostgreSQL、Redis;
- 它是多个项目共同使用、版本差异很少影响项目结果的通用工具;
- 它提供头文件、动态库或其他本机构建依赖,例如 OpenSSL、libpq;
- 你希望跟随 Homebrew 的滚动升级,而不是由每个仓库固定版本。
优先使用 mise
- 不同项目明确需要不同版本;
- 工具版本会影响编译结果、格式、生成代码或部署行为;
- 希望把工具链声明提交到 Git,并让 CI 复用;
- 项目包含多种语言,不想叠加多个单语言版本管理器;
- 需要一次性运行某个版本,不想修改全局环境;
- 希望把工具、环境变量和常用任务放在同一个项目入口下。
两者配合使用
现实里最常见的是配合,而不是二选一:
Homebrew
├── mise
├── git
├── go
├── openssl@3
├── postgresql@17
└── visual-studio-code
mise(随项目变化)
├── node@24.7.0
├── pnpm@10.15.1
├── python@3.13.7
└── terraform@1.x
语言包管理器(随应用变化)
├── pnpm-lock.yaml
├── uv.lock
└── go.sum例如 mise 编译 Python 时需要 Homebrew 提供的 OpenSSL。这不意味着 Python 也该改由 Homebrew 管理,而是下层提供系统构建依赖,上层提供项目运行时。项目里的 Python 包再由 uv 或 pip 管理。三层各司其职,问题出现时也更容易判断应该找谁。
同一个命令不要同时交给两边管理。若 Homebrew 安装了 Node.js,mise 也安装了 Node.js,机器不会立即爆炸,但排错成本会增加。确认所有项目已经迁移到 mise 后,可以卸载 Homebrew 的 Node.js;若某个系统脚本确实依赖 Homebrew 版本,则至少用 type -a node 看清优先级,不要让结果依赖偶然的 PATH 顺序。
十、一套实际可维护的 macOS 配置
我更倾向把个人环境拆成两份清单。
第一份是 Brewfile,记录机器能力:
brew "git"
brew "go"
brew "jq"
brew "mise"
brew "openssl@3"
brew "postgresql@17", restart_service: true
cask "firefox"
cask "visual-studio-code"
cask "rectangle"第二份是 mise 的全局配置,记录个人默认开发工具:
[tools]
node = "lts"
python = "3.13"
[settings]
idiomatic_version_file_enable_tools = [
"node",
"python"
]每个项目再提交自己的 mise.toml 与需要的 mise.lock。优先级从项目到个人逐层覆盖:项目说了算,项目没说时才使用个人默认。Brewfile 不负责替项目选择 Node.js,项目配置也不负责替电脑安装浏览器。
新机器的恢复顺序便很清楚:
- 安装 Xcode Command Line Tools 与 Homebrew;
- 用 Brewfile 恢复机器级应用、库与 mise;
- 启用
mise activate zsh; - 进入项目,阅读配置后执行
mise install --locked; - 使用项目自己的包管理器安装应用依赖;
- 执行
mise run check验证环境。
这里只把 Homebrew 和 mise 的声明当作可恢复清单,不要把缓存目录、已安装运行时目录整个同步到另一台 Mac。Intel 与 Apple Silicon 的制品和路径不同,macOS 版本也会影响动态库。复制配置,让工具重新安装,通常比复制一个充满绝对路径的旧目录可靠。
升级也要分层
机器级软件可以周期性执行:
brew update
brew outdated
brew upgrade
brew cleanup执行前先看 brew outdated,尤其留意数据库、容器运行时和编译依赖。brew upgrade 不是必须每天闭眼运行的仪式;服务型软件跨版本升级前要阅读上游迁移说明并备份数据。
项目工具则在仓库中升级:
mise outdated
mise use node@24.8.0
mise lock
mise run check验证通过后提交 mise.toml 与 mise.lock 的变化。应用依赖继续通过 pnpm、uv 或 Go Modules 各自升级。这样一来,Node.js 更新、npm 包更新和系统 OpenSSL 更新是三笔独立、可审查、可回滚的变更,不会在一条「全部更新」命令里搅成一锅。
十一、从旧环境迁移
如果 Mac 上已经同时装了 Homebrew Node.js、nvm、pyenv 和 asdf,不要第一天就把它们全删了。先运行:
type -a node python go ruby把当前实际命中的路径记下来。然后安装 mise,让它先读取现有的 .nvmrc、.python-version 或 .tool-versions,找几个常用项目跑完测试。确认终端、项目脚本和 CI 没问题后,再逐个删除旧的 shell 初始化。每删一项都新开一个终端复查,最后才卸载旧管理器和它安装的运行时。
IDE 经常是迁移时的漏网之鱼。终端里的 node 已经对了,编辑器启动的语言服务器却未必读取交互式 ~/.zshrc。能配置项目解释器就直接指向项目环境;只能从固定 PATH 找命令时,可以用 mise shims,或者从已经初始化的终端启动 IDE。先弄清编辑器从哪里找命令,别再往 ~/.zshrc 里盲加路径。
我也不会为了整齐,把所有 CLI 都搬进 mise。jq、fzf 这类个人工具,Homebrew 装一份就够了。Terraform 和代码生成器会影响项目结果,才值得放进 mise.toml。多一条声明就多一项需要更新的东西,形式统一本身没有价值。
配置安全也一样朴素。陌生仓库里的 mise.toml 可能下载软件、修改环境并运行任务,执行 mise install 前应该先看内容。[env] 可以放开发模式和本地端口,不要放 API Token、云密钥和生产数据库密码。密钥仍然交给 macOS 钥匙串、1Password CLI、专用秘密管理器或 CI Secret。
十二、最后怎么选
只写 Node.js,nvm 已经够用,Volta 的自动切换还更省心。团队已有 asdf 的插件和镜像体系,也没必要为了换工具而换工具。对下载来源与制品验证要求很高,可以认真研究 aqua。准备连系统依赖一起声明,Nix 又是另一条路。
我的日常工作会碰到多种语言,但还没复杂到需要 Nix 管整套系统,所以最后停在 Homebrew 加 mise:Homebrew 安装这台 Mac 长期使用的软件,mise 处理确实会随项目变化的运行时和 CLI。项目依赖继续交给 pnpm、uv 等各自的包管理器。
这套分法不追求什么都归 mise。谁管理一个工具,取决于我是否真的需要切换它,而不是它理论上能不能被 mise 安装。
十三、结语
前面一直说项目运行时应该交给 mise,但我自己的 Go 是用 Homebrew 安装的:
brew install go原因很简单,我几乎没有同时保留多套 Go 的需求。Go 1 的兼容承诺做得足够好,大多数旧项目直接用较新的工具链也能继续构建。项目要求的语言版本还能写在 go.mod 的 go 指令中;从 Go 1.21 开始,go 命令可以根据 go、toolchain 和 GOTOOLCHAIN 选择或下载合适的工具链。依赖版本则由 Go Modules 管理。对我来说,再在每个仓库里写一遍 mise 的 Go 版本,收益不大。
还有一个更实际的原因,我有提交、测试 CL 的需求。按照官方贡献流程测试 Go 自身的 CL 时,环境里本来就会同时出现几套 Go:系统里现成的 bootstrap toolchain、源码树刚编译出来的 go,以及构建脚本使用的 GOROOT、GOROOT_BOOTSTRAP。这时候我最关心的是眼前这个 go 到底来自哪里。
如果再加一层 mise 的目录解析和 shim,which go、go env GOROOT、bootstrap 版本之间很容易变得乱七八糟。它未必一定导致构建失败,却会多出一层完全没有必要的变量。测试 CL 时,我宁可让 Homebrew 提供一份位置稳定的 Go 作为日常和 bootstrap 工具链,再明确调用源码树里刚构建出来的 go。
所以实际上我的规则是:一切都从简单开始。只有需要多版本管理的软件、工具链才会使用 mise。毕竟切换过去的代价很小,但是简单性很重要。
