rgoussu@goussu: ~/library/platform/developer-environments
~/library/platform/developer-environments cat version-managers.md

Version managers — universal vs ecosystem-native

# The layer above every language toolchain — mise and asdf against nvm, corepack, SDKMAN!, rustup and Go's own directive; the native config files, what each covers, and the traps in automating them.

Conceptsaved 2026-08-20 #platform#tooling#toolchain#mise#asdf#sdkman#nvm#rustup#reproducibility

Overview

A version manager installs toolchains, keeps several versions side by side, and makes the right one answer inside the right directory. Two shapes exist. Universal managers (mise, asdf) handle any tool through a plugin/backend system and one config file. Ecosystem-native managers (nvm, Corepack, SDKMAN!, rustup, and Go's built-in mechanism) handle exactly one ecosystem, are what that ecosystem's own documentation assumes, and are usually already installed on the machines that matter.

The choice is not obvious in either direction, and for a polyglot project it is often not a choice at all — see Coverage below.

Key points

  • Every manager's authority is a committed file at the project root. That file, not the manager, is the real interface: it is what a colleague without your manager, an IDE, or a container build reads. Prefer writing the ecosystem's native file over inventing your own format, even when a universal manager could hold everything.
  • Universal managers trade breadth for indirection. One file for the whole polyglot toolchain is genuinely valuable; the cost is that everyone needs the manager, and the file means nothing to a tool that does not know it.
  • Some "native files" belong to the project, not the manager — Corepack's packageManager field lives in package.json, Go's toolchain line in go.mod. Automation must merge one field in place and leave the rest alone; rendering these files from scratch destroys project content.
  • Coverage is the real constraint. An ecosystem manager is only an option when your project's needs sit entirely inside its ecosystem. A JVM project can use SDKMAN!; a project that is JVM and Node cannot, without pairing it with something else.
  • Some managers only compose in pairs. nvm installs Node but cannot install pnpm; Corepack pins pnpm but cannot install Node. Either alone is a half-answer; the pair is a whole one.
  • sdk is a shell function, not a binary — the single most common automation trap in this space. Scripts must run it through a login shell that has sourced sdkman-init.sh; a plain exec of sdk finds nothing on PATH.
  • Not every ecosystem needs a manager. Since Go 1.21 the toolchain directive in go.mod makes any installed Go fetch and run the toolchain the module asks for. The honest answer there is "no manager" — routing around a solved problem adds a dependency and buys nothing.
  • Managers disagree about version precision. Some resolve a prefix (22) natively; others treat their file as a lockfile and demand an exact version. Automation that writes those files has to resolve the prefix first — see Prefixes and lockfiles.

Details

The managers, side by side

Manager Kind Native file Covers Install invocation
mise universal mise.toml anything with a backend mise trust then mise install
asdf universal .tool-versions anything with a plugin asdf plugin add <p> then asdf install
nvm Node .nvmrc node (npm rides along) nvm install
Corepack Node packageManager in package.json pnpm, yarn corepack enable then corepack install
SDKMAN! JVM .sdkmanrc JDK, Gradle, Maven, and the rest of the JVM candidates sdk env install
rustup Rust rust-toolchain.toml the Rust toolchain rustup toolchain install
Go (built-in) Go toolchain directive in go.mod go nothing — the directive is the provisioning

Coverage, and why it is the deciding question

The useful way to choose is not "which manager is best" but "which managers cover everything this project declares?" Work it as a set-cover problem over the project's needs:

  • A project needing only a JDK, Gradle and Maven is covered whole by mise, asdf or SDKMAN! — three real options.
  • Add a Node requirement and SDKMAN! drops out. Not because of any rule of SDKMAN!'s, but because it no longer covers the set. Nothing was configured to make that happen; it falls out of the coverage question.
  • A Node project on pnpm is covered by mise, asdf, or nvm + Corepack together — a combination, because neither member covers it alone.

Two rules make this safe to automate:

  1. Never offer or accept a partial cover. A manager that installs three of your four tools leaves the fourth silently missing, which is worse than having no manager: the environment looks provisioned. If nothing covers the set, the answer is a combination or an honest failure — never a half-install.
  2. A combination is a composition, not a new manager. It renders each member's own file and runs each member's own installer, and its coverage is the union of its members'. Copying one manager's configuration into a "combined" variant is how you end up maintaining two of them.

Prefixes and lockfiles

Projects usually want to pin a series — "JDK 25", "Node 22" — not a patch. Managers split on whether they accept that:

  • Resolve a prefix natively: mise's resolver, rustup's stable channel (a channel, not a version), nvm and Corepack (handed something concrete already).
  • Refuse it: asdf documents .tool-versions as a lockfile wanting exact versions; SDKMAN!'s candidate identifiers always carry a patch, so java=25-tem names nothing installable.

For the second group, resolve the prefix before writing the file, and in lockfile order:

  1. Whatever the file already names wins, as long as it still satisfies the prefix. A lockfile resolves once and then stays put — this is what stops a consistency check flapping the day an upstream patch ships, and what makes a re-run write nothing at all.
  2. Otherwise ask the manager, its own way — asdf latest java temurin-25, sdk list java. That happens on a first install, or after a pin bump moves the series out from under the recorded value.
  3. Failing both, write the prefix as it stands and say so. Never invent the missing half of a version: either reuse what is on disk or ask the tool that knows.

Asking upstream first is the tempting inversion and it is wrong — it calls a perfectly good lockfile stale the moment a patch is released, and makes every check network-bound.

Per-manager notes

  • mise — an increasingly common default for polyglot work: fast, one mise.toml, backends for most things, and it reads .tool-versions too. Its trust step is a security boundary — config files are not honoured until trusted.
  • asdf — the older universal, plugin-per-tool, plugins are third-party of varying quality. Its .tool-versions is the closest thing this space has to a lingua franca.
  • nvm — a shell function, like SDKMAN!'s (same automation caveat). .nvmrc is near-universally understood, including by CI actions that never use nvm itself.
  • Corepack — pins the package-manager client and version from package.json's packageManager field; its bundled-with-Node status has wobbled, but the field is the convention the clients honour regardless. See Package managers.
  • SDKMAN! — the JVM classic, covers far more than the JDK (Gradle, Maven, Kotlin, Scala…). Shell function, exact versions, .sdkmanrc per project.
  • rustup — first-party, assumed by all Rust documentation, and needs no activation story at all: rust-toolchain.toml is honoured natively by every cargo and rustc invocation in the directory. See rustup.
  • Go's built-in mechanism — the toolchain directive is a floor, not a pin: a newer local Go is used as-is, only an older one triggers a download. GOTOOLCHAIN governs whether switching is allowed at all.

Examples

# mise.toml — one file for a polyglot project
[tools]
java = "temurin-25"
gradle = "9.7.0"
node = "22"
# .tool-versions — asdf, a lockfile: exact versions only
java temurin-25.0.4+7
nodejs 22.14.0
# .sdkmanrc — candidate identifiers always carry a patch
java=25.0.4-tem
gradle=9.7.0
# SDKMAN! and nvm are shell functions — automation needs a login shell
bash -lc 'source "$SDKMAN_DIR/bin/sdkman-init.sh" && sdk env install'
bash -lc 'source "$NVM_DIR/nvm.sh" && nvm install'

Related