软件、工具链与 mise

软件、工具链与 mise

•约 8.4 千字

你怎么给你的电脑安装软件的?是不是缺什么装什么,图形软件去官网下载,命令行工具用 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 依然是我安装软件的第一选择。它把安装、依赖、升级、卸载和架构差异都收进了同一套命令。

安装命令行软件和桌面应用分别是:

shell
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 查询更稳妥:

shell
brew --prefix
brew --prefix openssl@3

需要长期运行的服务也适合交给 Homebrew:

shell
brew services start postgresql@17
brew services list
brew services stop postgresql@17

这类服务没必要交给 mise。是否登录后启动、数据目录放在哪里,都属于这台电脑的状态,不该随着 cd 到哪个目录而变化。

Homebrew 还有一个常被低估的能力:Brewfile。你可以把常用的 formula、cask、Mac App Store 应用和编辑器扩展声明出来,再在新机器上执行 brew bundle:

Brewfile
ruby
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:

shell
mise use -g node@lts

进入旧项目后执行:

shell
mise use node@20

mise 会安装匹配版本,并创建或修改项目里的 mise.toml。以后进入这个目录就是 Node.js 20,离开以后回到全局默认。Python、Ruby 和其他工具也是同一套操作,不用在不同管理器之间来回查命令。

界面统一以后,底下的安装过程还是各干各的。Python 可能需要编译和系统库,Node.js 通常直接使用预编译包,GitHub 上的小工具也许只要解压一个 release asset。安装失败时要看具体后端和编译日志,不能只搜「mise 安装失败」。

四、在 macOS 上安装和启用 mise

既然已经决定用 Homebrew 管机器,用 Homebrew 安装 mise 最顺手:

shell
brew install mise
mise --version

这并不矛盾。Homebrew 只负责把 mise 这个稳定入口放到 Mac 上;Node.js、Python 等项目工具仍由 mise 管理。将来 brew upgrade mise 更新的是版本管理器本身,不会顺便把所有项目运行时换掉。

macOS 近年的默认 shell 是 Zsh,可以把下面一行放进 ~/.zshrc:

shell
eval "$(mise activate zsh)"

重新打开终端,或者执行:

shell
source ~/.zshrc
mise doctor

mise activate 会让 mise 在提示符出现前根据当前目录调整环境。与单纯把 shims 目录塞进 PATH 相比,官方更推荐在交互式终端使用这种 PATH activation。shims 仍适合 IDE、非交互程序或不方便执行 shell hook 的环境;CI 则通常根本不需要激活,直接使用 mise install、mise exec 和 mise run 即可。

这里最容易出现的故障是:电脑上已经有不止一个版本管理器,大家都想站在 PATH 最前面。

排查时不要凭感觉,直接看证据:

shell
type -a node
which node
mise which node
mise ls --current
mise config

type -a 会列出 shell 能找到的所有同名命令;mise which 显示 mise 选择的实际路径;mise config 列出当前生效的配置文件。若 node 仍然来自 nvm、asdf 或 /opt/homebrew/bin,检查 ~/.zshrc 中各段初始化的顺序,并移除已经弃用的旧管理器配置。不要通过继续添加五行 export PATH=... 来解决 PATH 太乱的问题,那相当于用更多电线整理线团。

先设置个人默认值

全局工具写入 ~/.config/mise/config.toml,适合作为没有项目配置时的默认环境:

shell
mise use -g node@lts
mise use -g python@3.13

lts 和 3.13 都不是精确版本。放在个人全局配置里问题不大,我想升级时直接升级即可;项目里则应写得更明确,或者配合 lockfile,免得两个人在不同日期装出两个版本。

查看状态常用这些命令:

shell
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。一份不花哨但够用的配置如下:

mise.toml
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"

上面的版本只是配置示例,不代表每个项目都应该使用这些版本。真正写配置时,应选择项目已经验证过的版本。

团队成员拿到仓库后,先阅读配置,再执行:

shell
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 执行一个脚本,不需要先改项目配置:

shell
mise exec python@3.12 -- python ./scripts/migrate.py

也可以使用短别名:

shell
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 可以生成项目锁文件:

shell
mise lock
mise install --locked

也可以在配置中启用自动维护:

mise.toml
toml
[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 的版本来源:

shell
mise settings add idiomatic_version_file_enable_tools node

需要注意,当前 mise 文档说明这项兼容默认并非全部开启。不要看到仓库里有 .nvmrc 就想当然地认为 mise 一定读取了它,使用下面的命令检查实际选择及来源:

shell
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 后端买单。

工具主要范围自动按项目切换配置载体优势主要代价上手难度
HomebrewmacOS 软件、库、服务、CLI不以此为目标Brewfile、formula、caskmacOS 生态成熟,安装与卸载统一任意历史版本和项目级切换较弱低
mise多语言运行时、CLI、环境与任务支持mise.toml、mise.lock、兼容版本文件范围广,日常操作统一,适合本地与 CI后端多,保证和故障模式不完全一致低到中
nvmNode.js默认需手动 nvm use,可加 shell hook.nvmrc专注、普及、概念少shell 初始化较重,只解决 Node.js低
VoltaNode.js 与 JS 包管理器/全局 CLI支持package.jsonshim 切换自然,JS 体验顺滑生态边界明确,不管理其他语言低
asdf多语言运行时与 CLI支持.tool-versions生态成熟、配置简单、插件多要管理插件及其依赖,插件质量不一中
aqua版本化 CLI 与发布制品支持配置查找与代理aqua.yaml、checksums声明式、Registry 与供应链能力强配置和治理概念较多,不以完整开发环境为唯一目标中到高

「上手难度」是我按个人使用给的主观评价。团队已经做好模板和封装以后,情况会不同。

nvm:只做 Node.js,简单而传统

nvm 是许多前端开发者接触的第一个版本管理器。它是加载到 shell 中的函数,而不是普通可执行文件,所以验证安装要用 command -v nvm,which nvm 不一定得到你期待的结果。它的核心工作流非常直白:

shell
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 复用;
  • 项目包含多种语言,不想叠加多个单语言版本管理器;
  • 需要一次性运行某个版本,不想修改全局环境;
  • 希望把工具、环境变量和常用任务放在同一个项目入口下。

两者配合使用

现实里最常见的是配合,而不是二选一:

text
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,记录机器能力:

Brewfile
ruby
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 的全局配置,记录个人默认开发工具:

~/.config/mise/config.toml
toml
[tools]
node = "lts"
python = "3.13"

[settings]
idiomatic_version_file_enable_tools = [
  "node",
  "python"
]

每个项目再提交自己的 mise.toml 与需要的 mise.lock。优先级从项目到个人逐层覆盖:项目说了算,项目没说时才使用个人默认。Brewfile 不负责替项目选择 Node.js,项目配置也不负责替电脑安装浏览器。

新机器的恢复顺序便很清楚:

  1. 安装 Xcode Command Line Tools 与 Homebrew;
  2. 用 Brewfile 恢复机器级应用、库与 mise;
  3. 启用 mise activate zsh;
  4. 进入项目,阅读配置后执行 mise install --locked;
  5. 使用项目自己的包管理器安装应用依赖;
  6. 执行 mise run check 验证环境。

这里只把 Homebrew 和 mise 的声明当作可恢复清单,不要把缓存目录、已安装运行时目录整个同步到另一台 Mac。Intel 与 Apple Silicon 的制品和路径不同,macOS 版本也会影响动态库。复制配置,让工具重新安装,通常比复制一个充满绝对路径的旧目录可靠。

升级也要分层

机器级软件可以周期性执行:

shell
brew update
brew outdated
brew upgrade
brew cleanup

执行前先看 brew outdated,尤其留意数据库、容器运行时和编译依赖。brew upgrade 不是必须每天闭眼运行的仪式;服务型软件跨版本升级前要阅读上游迁移说明并备份数据。

项目工具则在仓库中升级:

shell
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,不要第一天就把它们全删了。先运行:

shell
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 安装的:

shell
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。毕竟切换过去的代价很小,但是简单性很重要。

作者Aoang
发布于2026-09-08
更新于2026-09-08
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。