Your Repo Should Own the Setup
Mise lets a repository declare its tools, environment variables, and project-level tasks in one place so setup stops living in your memory, shell history, and scattered docs.
This post is also available in: Español
Every repo has its own little ritual.
The problem starts when you jump between personal projects, company code, and open-source repos, and each one expects a different Node, Python, or package manager setup.
I’ve always hated setting up projects with different language versions. My personal projects are usually on the latest LTS, because that’s what I like using. Then I open a company repo and it needs an older Node version, another project needs a different Python, and some open-source repo I want to contribute to has its own setup.
So now I need nvm, I need to remember the pyenv thing, maybe there is a .tool-versions file somewhere, and after installing half the internet I read the README and it casually says I should be using another version, like, WTF.
It’s always been a mess, because every language has its own popular “nice” version manager, and for a while that feels fine, until you work across enough projects and realize you’re not just managing versions anymore, you’re managing version managers.
That’s where mise started making sense to me, not as a nicer nvm or fnm, but as one place where a project can say: these are the tools, these are the env vars, and these are the commands we actually run.
[tools]
node = "22"
pnpm = "9"
python = "3.12"
uv = "latest" That small file is the whole pitch for me.
Not the Rust implementation, not the benchmark conversation, not the “replace every tool on your machine” pitch (I have Homebrew for that) that makes any tool sound like a cult. Just the boring idea that the repo should know what it needs.
The project setup should not live in your memory
Most setup pain doesn’t come from one big problem, it comes from the gaps between tools, where each piece is reasonable on its own but the full setup only exists if you know the right sequence.
So the first run becomes something like this:
nvm use
corepack enable
pnpm install
pyenv local 3.12
uv sync
direnv allow
pnpm run dev None of those commands are ridiculous by themselves. Stack enough of them together, though, and setup starts feeling like a scavenger hunt.
Mise gives you a place to move that ritual into the repo.
[tools]
node = "22"
pnpm = "9"
python = "3.12"
uv = "latest" Then the first setup step is not “which Node version does the team use?”, it is just:
mise install And when you want to be explicit, especially in scripts or non-interactive shells, you can run through mise instead of hoping your shell has already activated the right thing:
mise exec -- node -v
mise exec -- pnpm -v
mise exec -- python --version That’s not flashy, but I like boring setup tools, because boring is what I want before I’ve even started working on the code.
Use tasks when the command crosses ecosystems
I don’t care about a task runner if the example is this:
[tasks.test]
run = "pnpm run test" That’s worse than typing a command everyone already knows.
The mise task runner starts making sense when the command belongs to the project, not to one language ecosystem, because real repos usually have frontend checks, backend checks, generated files, migrations, or local services that don’t fit cleanly into package.json without turning it into a junk drawer.
[tools]
node = "22"
pnpm = "9"
python = "3.12"
uv = "latest"
[env]
API_BASE_URL = "http://localhost:4000"
[tasks.check]
description = "Run the local verification before opening a PR"
depends = ["check:frontend", "check:backend"]
[tasks."check:frontend"]
run = "pnpm lint && pnpm typecheck"
[tasks."check:backend"]
run = "uv run pytest" Now mise run check isn’t pretending to replace pnpm run test, it’s describing the project-level check, using the Node version, pnpm version, Python version, uv install, and env from the same file.
If a command is truly only a JavaScript package command, keep it in package.json, no need to be weird about it. But if the command explains how the whole repo is supposed to be worked on, I’d rather put it somewhere that doesn’t pretend the world ends at Node.
Env vars are part of setup, not an afterthought
I’ve used direnv before, and I understand why people install it individually, because env files always start simple and then somehow you have .env, .env.local, .env.development, .env.staging, maybe one variable exported in your shell, and nobody is fully sure which one made the dev server work last time.
Many developers solve this in their own way: direnv, dotenv, a custom shell function called loadenv, or some little script they have been carrying between machines for years.
That works fine when it’s your own setup, but it gets weird when the project quietly depends on everyone having the same personal trick installed.
Mise gives that problem a boring place to live with [env], in the same file where the project is already declaring its tools and tasks.
[env]
API_BASE_URL = "http://localhost:4000"
DATABASE_URL = "postgres://localhost/app" And if the project already uses dotenv files, mise can load those too:
[env]
_.file = [".env", ".env.local"] For development, I’d probably reach for this:
[env]
_.file = [".env", ".env.development", ".env.local"] The order matters, like it always does with env files: shared defaults first, environment-specific values after that, local machine overrides last. I wouldn’t commit secrets in .env.local, and I wouldn’t pretend this replaces a real secrets system. But for normal local setup, the env story can live next to the tool and task story instead of becoming another little side quest.
With mise activated in your shell, those variables are available when you cd into the project, so plain commands can work the way you’d expect:
pnpm dev And when you want to be explicit, or when you’re in a script, CI job, editor task, or anything non-interactive, you can still run through mise:
mise exec -- pnpm dev
mise run check This is the part I missed at first: mise exec and mise run aren’t the only way to get the env, they’re the explicit way. If your shell is using mise activate, mise updates the environment for the current directory and pnpm dev can see it.
If you personally love direnv, fine, use it. I just wouldn’t design a new repo where everyone needs to install it before the app can even start, when mise can cover the simple case in the same config that already owns the tools and tasks.
Mise is not your package manager, and that is good
Small distinction, but it matters: mise should pick the tools, not replace the package managers that belong to those tools.
So for a Node and Python project, I want mise to say “this repo uses Node 22, pnpm 9, Python 3.12, and uv”, then I still want pnpm to own JavaScript dependencies and uv to own Python dependencies.
[tools]
node = "22"
pnpm = "9"
python = "3.12"
uv = "latest" Then the normal project commands still look normal:
pnpm install
uv sync Mise sits one layer before that. It makes sure everyone is using the right tools before those tools start doing their own job.
It sounds boring because it is, but it removes the stupid class of bugs where half the team is debugging dependencies while the real problem is that two people aren’t even running the same runtime.
Trust should feel a little serious
The first time a project asks you to run this, it’s worth understanding what it means:
mise trust Mise config can define env, tasks, hooks, and behavior that affects command execution, so trusting a config isn’t the same as reading a JSON file with a version number in it.
That’s actual trust.
I like that mise makes this explicit, because project setup files are code-adjacent, and pretending they’re harmless is how teams end up running random shell scripts from random repos without thinking.
This also helps in a very practical way: if something works in your terminal but fails in CI, an editor task, cron, or another non-interactive shell, check whether the config is trusted before blaming the toolchain.
mise doctor In non-interactive contexts, mise can skip config that hasn’t been trusted yet. Small detail, annoying failure mode.
Lock the result when the project stops being a toy
I’m fine using loose versions while playing with something, but for a real project I want the resolved result written down.
[settings]
lockfile = true
minimum_release_age = "7d"
[tools]
node = "22"
pnpm = "9"
python = "3.12"
uv = "latest" Then:
mise lock I think about it like this: mise.toml says what the project needs, and mise.lock records what mise resolved.
That doesn’t magically make downloads safe, and I wouldn’t sell it like that, but it does make the setup less hand-wavy, especially when you’re using fuzzy versions like latest or node = "22" and you want resolved versions, checksums, and download metadata where mise supports it.
minimum_release_age is a nice little bit of paranoia too, because not every project needs to install something five minutes after it was published.
The win is smaller than the hype and more useful
I don’t need mise to replace every tool on my machine, I just want the repo to say what it needs instead of leaving the setup scattered across your memory, shell history, old docs, and whatever each language ecosystem decided was normal.
Start with the part that always drifts.
[tools]
node = "22"
pnpm = "9"
python = "3.12"
uv = "latest" Add the project-level command people actually run before opening a PR.
[tasks.check]
depends = ["check:frontend", "check:backend"]
[tasks."check:frontend"]
run = "pnpm lint && pnpm typecheck"
[tasks."check:backend"]
run = "uv run pytest" Then setup becomes boring in the way setup should be boring.
mise trust
mise install
mise run check That’s the move for me. Not because mise is better than nvm, but because after years of juggling language-specific version managers, I’d rather let the project own the workflow and get back to writing code.