<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged mise</title>
<link>https://coderlegion.com/tag/mise</link>
<description>Powered by Question2Answer</description>
<item>
<title>I Replaced nvm, Homebrew Go, and Homebrew Python with a Single Tool</title>
<link>https://coderlegion.com/11429/i-replaced-nvm-homebrew-go-and-homebrew-python-with-a-single-tool</link>
<description>&lt;p&gt;I maintain a symlink-based dotfiles repo for macOS/zsh. Over time, I'd accumulated three different approaches to managing runtime versions: nvm for Node.js, &lt;code&gt;brew install go&lt;/code&gt; for Go, and &lt;code&gt;brew install python@3.13&lt;/code&gt; for Python. Each had its own quirks, its own update ritual, and its own startup cost. &lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/&quot;&gt;mise&lt;/a&gt; consolidates all of them into one tool with a single config file.&lt;/p&gt;
&lt;p&gt;This article walks through the actual migration: what changed, what broke, and the non-obvious tricks that made it worth doing.&lt;/p&gt;
&lt;h3 id=&quot;the-starting-point-an-nvm-lazy-load-hack&quot;&gt;The starting point: an nvm lazy-load hack&lt;/h3&gt;
&lt;p&gt;nvm is slow. Loading it adds 300-500ms to every new shell. My workaround was a lazy-load wrapper in &lt;code&gt;zprofile&lt;/code&gt; that deferred the cost until you actually called &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;npm&lt;/code&gt;, or friends:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# NVM (lazy-loaded for fast startup)
export NVM_DIR=&quot;$HOME/.nvm&quot;

_nvm_lazy_load() {
  unfunction nvm node npm npx corepack 2&amp;gt;/dev/null
  [ -s &quot;/opt/homebrew/opt/nvm/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/nvm.sh&quot;
  [ -s &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot; ] &amp;amp;&amp;amp; \. &quot;/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm&quot;
}

nvm()      { _nvm_lazy_load; nvm &quot;$@&quot; }
node()     { _nvm_lazy_load; node &quot;$@&quot; }
npm()      { _nvm_lazy_load; npm &quot;$@&quot; }
npx()      { _nvm_lazy_load; npx &quot;$@&quot; }
corepack() { _nvm_lazy_load; corepack &quot;$@&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This worked, but it was 15 lines of shell plumbing to avoid a startup penalty from a tool that only manages Node. Go and Python had no version management at all — I just installed whatever Homebrew gave me.&lt;/p&gt;
&lt;h3 id=&quot;the-mise-config&quot;&gt;The mise config&lt;/h3&gt;
&lt;p&gt;mise replaces all of that with a TOML file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tools]
node = &quot;22&quot;
go = &quot;1.24&quot;
python = &quot;3.13&quot;

[settings]
idiomatic_version_file = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it. &lt;code&gt;mise install&lt;/code&gt; downloads the right versions. The &lt;code&gt;idiomatic_version_file&lt;/code&gt; setting means mise reads &lt;code&gt;.nvmrc&lt;/code&gt;, &lt;code&gt;.node-version&lt;/code&gt;, &lt;code&gt;.python-version&lt;/code&gt;, and &lt;code&gt;.go-version&lt;/code&gt; files from existing projects — so teams using nvm or pyenv don't need to change anything.&lt;/p&gt;
&lt;h3 id=&quot;shell-integration-the-dual-activation-trick&quot;&gt;Shell integration: the dual activation trick&lt;/h3&gt;
&lt;p&gt;This is the part that took some thought. mise offers two modes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Shims&lt;/strong&gt; (&lt;code&gt;mise activate zsh --shims&lt;/code&gt;): Lightweight stubs in &lt;code&gt;~/.local/share/mise/shims/&lt;/code&gt; that resolve to the right binary. Fast, works everywhere, but they don't auto-switch versions when you &lt;code&gt;cd&lt;/code&gt; into a project with an &lt;code&gt;.nvmrc&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Activate&lt;/strong&gt; (&lt;code&gt;mise activate zsh&lt;/code&gt;): Hooks into your shell's &lt;code&gt;precmd&lt;/code&gt;/&lt;code&gt;chpwd&lt;/code&gt; to dynamically update &lt;code&gt;PATH&lt;/code&gt;. Supports auto-switching, but doesn't run in non-interactive contexts (scripts, IDE terminals running commands, cron jobs).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You need both.&lt;/p&gt;
&lt;p&gt;In &lt;code&gt;zprofile&lt;/code&gt; (runs for all sessions, including non-interactive):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Mise — shims for non-interactive sessions (scripts, IDEs, cron)
eval &quot;$(mise activate zsh --shims)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In &lt;code&gt;plugins.zsh&lt;/code&gt; (sourced only in interactive shells via &lt;code&gt;zshrc&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;_cache_source mise mise activate zsh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The activate call in interactive shells overrides the shims with real &lt;code&gt;PATH&lt;/code&gt; entries, enabling directory-aware version switching. Non-interactive sessions fall back to shims, which resolve to whatever version the config specifies.&lt;/p&gt;
&lt;h3 id=&quot;the-cache-source-zcompile-trick&quot;&gt;The &lt;code&gt;_cache_source&lt;/code&gt; + zcompile trick&lt;/h3&gt;
&lt;p&gt;That &lt;code&gt;_cache_source&lt;/code&gt; call above is doing more than it looks. Running &lt;code&gt;mise activate zsh&lt;/code&gt; generates a shell script on every new shell — and that generation adds 25-300ms depending on the machine. For a shell that opens in 50ms total, that's unacceptable.&lt;/p&gt;
&lt;p&gt;The solution: cache the output and compile it to zsh bytecode.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;_cache_source() {
  local name=&quot;$1&quot;; shift
  local cache_file=&quot;$ZSH_COMP_CACHE/$name.zsh&quot;
  local -a stale=($cache_file(N.mh+24))
  if [[ ! -f &quot;$cache_file&quot; ]] || (( $#stale )); then
    &quot;$@&quot; &amp;gt; &quot;$cache_file&quot; 2&amp;gt;/dev/null
    zcompile &quot;$cache_file&quot; 2&amp;gt;/dev/null
  fi
  source &quot;$cache_file&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's what it does:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Runs &lt;code&gt;mise activate zsh&lt;/code&gt; and captures the output to a cache file&lt;/li&gt;
&lt;li&gt;Compiles it to zsh bytecode with &lt;code&gt;zcompile&lt;/code&gt; (creates a &lt;code&gt;.zwc&lt;/code&gt; file)&lt;/li&gt;
&lt;li&gt;Sources the cached file on every subsequent shell open&lt;/li&gt;
&lt;li&gt;Regenerates after 24 hours (the &lt;code&gt;(N.mh+24)&lt;/code&gt; glob qualifier checks mtime)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Why does the compiled version load faster? When you &lt;code&gt;source&lt;/code&gt; a plain &lt;code&gt;.zsh&lt;/code&gt; file, zsh reads the text, tokenizes the shell syntax, and parses it into an internal representation — every single time. &lt;code&gt;zcompile&lt;/code&gt; does that work once and writes the result as a &lt;code&gt;.zwc&lt;/code&gt; file — a pre-parsed &quot;wordcode&quot; format that zsh can memory-map and load directly, skipping the tokenize-and-parse steps entirely. For a large generated script like &lt;code&gt;mise activate&lt;/code&gt; output, this eliminates the most expensive part of &lt;code&gt;source&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The clever bit is that zsh handles &lt;code&gt;.zwc&lt;/code&gt; lookup transparently. When you run &lt;code&gt;source foo.zsh&lt;/code&gt;, zsh first checks for &lt;code&gt;foo.zsh.zwc&lt;/code&gt;. If the &lt;code&gt;.zwc&lt;/code&gt; exists and is newer than the source file, zsh loads the compiled version without being told to. This means &lt;code&gt;_cache_source&lt;/code&gt; doesn't need any special logic to prefer the compiled file — &lt;code&gt;source &quot;$cache_file&quot;&lt;/code&gt; does the right thing on its own.&lt;/p&gt;
&lt;p&gt;And if the &lt;code&gt;.zwc&lt;/code&gt; is stale or missing? Zsh silently falls back to parsing the source file the normal way. Nothing breaks — you just lose the speed benefit until the cache regenerates. A stale &lt;code&gt;.zwc&lt;/code&gt; can never serve incorrect code because zsh won't use it if the source has changed. This makes the whole approach safe to leave running unattended.&lt;/p&gt;
&lt;p&gt;The result: mise activation costs &amp;lt;5ms instead of 25-300ms. This same function works for any &quot;run a command, source the output&quot; pattern — I use it for fzf, direnv, and mise.&lt;/p&gt;
&lt;p&gt;This is probably the most reusable thing in the whole migration. If you maintain a zsh config and source any tool's &lt;code&gt;init&lt;/code&gt; or &lt;code&gt;activate&lt;/code&gt; output, wrap it in something like this.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;For the full story on this caching approach — including a subtle glob qualifier bug that silently breaks it — see &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/martin_oehlert/from-14s-to-53ms-optimizing-zsh-startup-on-macos-5f09&quot;&gt;From 1.4s to 53ms: Optimizing zsh Startup on macOS&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;brew-changes&quot;&gt;Brew changes&lt;/h3&gt;
&lt;p&gt;The cleanup was straightforward:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Remove:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nvm&lt;/code&gt; — replaced by mise&lt;/li&gt;
&lt;li&gt;&lt;code&gt;node&lt;/code&gt; — installed by mise&lt;/li&gt;
&lt;li&gt;&lt;code&gt;go&lt;/code&gt; — installed by mise&lt;/li&gt;
&lt;li&gt;&lt;code&gt;python@3.13&lt;/code&gt; — installed by mise&lt;/li&gt;
&lt;li&gt;&lt;code&gt;yarn&lt;/code&gt; — moved to corepack (see below)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Added:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mise&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One gotcha: some Homebrew formulae declare &lt;code&gt;node&lt;/code&gt; as a dependency. In my case, &lt;code&gt;markdownlint-cli2&lt;/code&gt; depends on the Homebrew &lt;code&gt;node&lt;/code&gt; formula. Removing &lt;code&gt;node&lt;/code&gt; from the Brewfile triggers warnings on &lt;code&gt;brew bundle&lt;/code&gt;, but it's cosmetic — mise's shims satisfy the actual runtime need. The formula's dependency declaration is about the build, not about your shell having &lt;code&gt;node&lt;/code&gt; on &lt;code&gt;PATH&lt;/code&gt;. I left it as-is and the warnings are harmless.&lt;/p&gt;
&lt;h3 id=&quot;corepack-for-yarn&quot;&gt;Corepack for yarn&lt;/h3&gt;
&lt;p&gt;With nvm gone, yarn no longer comes from Homebrew either. Node ships with corepack, which manages yarn (and pnpm) natively:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;corepack enable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates symlinks inside the Node install directory. The catch: when mise installs a new Node version, those symlinks live in the old version's directory. You need to re-run &lt;code&gt;corepack enable&lt;/code&gt; after &lt;code&gt;mise install node@&amp;lt;new-version&amp;gt;&lt;/code&gt;. It's a one-liner, but easy to forget when you bump Node versions.&lt;/p&gt;
&lt;h3 id=&quot;the-migration-procedure&quot;&gt;The migration procedure&lt;/h3&gt;
&lt;p&gt;The key insight is that nvm and mise can coexist. This makes the migration safe to do in phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 — Install mise alongside nvm:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;brew install mise&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Create &lt;code&gt;mise/config.toml&lt;/code&gt; with your current versions&lt;/li&gt;
&lt;li&gt;Add the dual activation (shims in &lt;code&gt;zprofile&lt;/code&gt;, activate in &lt;code&gt;plugins.zsh&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Open a new shell, verify &lt;code&gt;node --version&lt;/code&gt;, &lt;code&gt;go version&lt;/code&gt;, &lt;code&gt;python3 --version&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 — Verify everything works:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Run your builds, tests, dev servers&lt;/li&gt;
&lt;li&gt;Check IDE integration (VS Code, JetBrains)&lt;/li&gt;
&lt;li&gt;Verify non-interactive contexts: &lt;code&gt;zsh -c 'node --version'&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;corepack enable&lt;/code&gt; and confirm &lt;code&gt;yarn --version&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Phase 3 — Remove nvm:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Delete the lazy-load block from &lt;code&gt;zprofile&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Remove &lt;code&gt;nvm&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;go&lt;/code&gt;, &lt;code&gt;python@3.13&lt;/code&gt;, &lt;code&gt;yarn&lt;/code&gt; from &lt;code&gt;Brewfile&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;brew bundle cleanup --force&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Commit&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Safety net:&lt;/strong&gt; nvm's data (&lt;code&gt;~/.nvm/&lt;/code&gt;) stays untouched through all of this. If something breaks, you can restore the old &lt;code&gt;zprofile&lt;/code&gt; block and you're back to where you started. The whole thing is one &lt;code&gt;git checkout -- shell/zprofile&lt;/code&gt; away from reverting.&lt;/p&gt;
&lt;h3 id=&quot;-nvmrc-compatibility&quot;&gt;&lt;code&gt;.nvmrc&lt;/code&gt; compatibility&lt;/h3&gt;
&lt;p&gt;If your team uses &lt;code&gt;.nvmrc&lt;/code&gt; files (and most Node teams do), the &lt;code&gt;idiomatic_version_file = true&lt;/code&gt; setting in mise's config handles it. When you &lt;code&gt;cd&lt;/code&gt; into a directory with an &lt;code&gt;.nvmrc&lt;/code&gt;, mise reads it and switches to the specified Node version — same as nvm would.&lt;/p&gt;
&lt;p&gt;This also works for &lt;code&gt;.node-version&lt;/code&gt;, &lt;code&gt;.python-version&lt;/code&gt;, and &lt;code&gt;.go-version&lt;/code&gt;. So mise supports the conventions from nvm, nodenv, pyenv, and goenv without any extra configuration.&lt;/p&gt;
&lt;h3 id=&quot;final-result&quot;&gt;Final result&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;zprofile&lt;/code&gt; went from 30 lines to 18. The nvm lazy-load hack — 15 lines of shell functions — became a single &lt;code&gt;eval&lt;/code&gt; line. Three separate version management approaches (nvm, Homebrew Go, Homebrew Python) became one config file.&lt;/p&gt;
&lt;p&gt;Shell startup stayed at ~0.05s thanks to the caching strategy. The dual activation means shims handle non-interactive contexts while the full activate gives you directory-aware switching in your terminal.&lt;/p&gt;
&lt;p&gt;The commit tells the story:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Replace nvm with mise for Node.js, Go, and Python version management

8 files changed, 17 insertions(+), 19 deletions(-)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;More deletions than insertions. That's usually a good sign.&lt;/p&gt;
&lt;h3 id=&quot;what-else-mise-can-do&quot;&gt;What else mise can do&lt;/h3&gt;
&lt;p&gt;This article focused on version management, but mise is a broader dev environment tool. Here's what else it offers once you have it installed.&lt;/p&gt;
&lt;h4 id=&quot;task-runner&quot;&gt;Task runner&lt;/h4&gt;
&lt;p&gt;mise has a &lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/tasks/&quot;&gt;built-in task runner&lt;/a&gt; that replaces Makefiles and npm scripts. Tasks defined in &lt;code&gt;mise.toml&lt;/code&gt; (or as standalone scripts in a &lt;code&gt;mise-tasks/&lt;/code&gt; directory) run with the full mise environment — correct tool versions and env vars already set. Dependencies between tasks execute in parallel by default, and &lt;code&gt;mise watch&lt;/code&gt; re-runs tasks on file changes. For projects that already have a &lt;code&gt;mise.toml&lt;/code&gt; for version management, adding tasks means one fewer tool in the chain.&lt;/p&gt;
&lt;h4 id=&quot;environment-variables&quot;&gt;Environment variables&lt;/h4&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/environments/&quot;&gt;&lt;code&gt;[env]&lt;/code&gt; section&lt;/a&gt; in &lt;code&gt;mise.toml&lt;/code&gt; sets project-level environment variables that activate when you &lt;code&gt;cd&lt;/code&gt; into the directory — the same behavior as direnv, without a separate tool. It supports dotenv files, required variables with validation, and redactions for secrets that shouldn't appear in logs. Earlier in this article, &lt;code&gt;_cache_source&lt;/code&gt; caches direnv's activation output alongside mise's — mise's native env management could replace direnv entirely, removing one more tool from the shell startup chain.&lt;/p&gt;
&lt;h4 id=&quot;hooks&quot;&gt;Hooks&lt;/h4&gt;
&lt;p&gt;mise can run &lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/hooks.html&quot;&gt;shell commands on directory enter/leave events&lt;/a&gt; and after tool installations. The postinstall hook is particularly relevant to this migration — it could automate the &lt;code&gt;corepack enable&lt;/code&gt; step that's currently manual after Node upgrades.&lt;/p&gt;
&lt;h4 id=&quot;lockfile-for-reproducible-environments&quot;&gt;Lockfile for reproducible environments&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://mise.jdx.dev/dev-tools/mise-lock.html&quot;&gt;&lt;code&gt;mise.lock&lt;/code&gt;&lt;/a&gt; pins exact tool versions and checksums per platform, similar to &lt;code&gt;package-lock.json&lt;/code&gt;. For teams sharing a &lt;code&gt;mise.toml&lt;/code&gt;, the lockfile ensures everyone gets the same binary — not just the same version range. Enable it with &lt;code&gt;mise settings lockfile=true&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This article covered the version management migration, but mise's scope goes well beyond that — it's closer to a unified dev environment than a version manager.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The full &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MO2k4/config&quot;&gt;dotfiles repo&lt;/a&gt; and migration commit are available on GitHub. The &lt;code&gt;_cache_source&lt;/code&gt; function lives in &lt;code&gt;shell/zshrc.d/completions.zsh&lt;/code&gt; if you want to steal it.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/11429/i-replaced-nvm-homebrew-go-and-homebrew-python-with-a-single-tool</guid>
<pubDate>Wed, 11 Feb 2026 08:00:21 +0000</pubDate>
</item>
</channel>
</rss>