Skip to content

Instantly share code, notes, and snippets.

@michalczukm
Created June 24, 2026 13:29
Show Gist options
  • Select an option

  • Save michalczukm/a91e0131537f7ee73d55acff8039f0c3 to your computer and use it in GitHub Desktop.

Select an option

Save michalczukm/a91e0131537f7ee73d55acff8039f0c3 to your computer and use it in GitHub Desktop.
nvm --> pnpm

I just migrated from nvm to pnpm runtime for Node.js version management.

Why? I pretty heavily moved to pnpm with all package-manager & workspace chore, I admire the tool, I just put more eggs in this basket :P

You can use it (in the stable version since v11) https://pnpm.io/cli/runtime

But since I have a lot of .nvmrc files that hold the node.js version, which can be used with nvm use, I made a small script that loads the correct node.js version when the terminal loads.

nvm vs pnpm

  • nvm is per-shell by nature. pnpm env use is global. The mismatch bites.
  • .nvmrc is not read by pnpm. The hook bridges it.

Tools

Below is specific for the tools I use:

  • brew (homebrew)
  • zsh shell

1. If you do not have pnpm

# dah!
brew install pnpm

# Wire pnpm into your shell
pnpm setup
source ~/.zshrc

# Pick a default global Node.js version
pnpm env use --global 24.17.0

2. ~/.zshrc snippet — drop-in block

Self-contained. Replaces the block pnpm setup created.

# ============================================================
# pnpm + per-directory Node (.nvmrc) — nvm replacement
# ============================================================
export PNPM_HOME="$HOME/Library/pnpm"
# pnpm's global bin dir AND PNPM_HOME (holds the node shim) both on PATH
case ":$PATH:" in *":$PNPM_HOME/bin:"*) ;; *) export PATH="$PNPM_HOME/bin:$PATH" ;; esac
case ":$PATH:" in *":$PNPM_HOME:"*)      ;; *) export PATH="$PNPM_HOME:$PATH"      ;; esac

autoload -U add-zsh-hook

# Walk up from $PWD to the nearest .nvmrc and print its path.
_pnpm_find_nvmrc() {
  local dir="$PWD"
  while true; do
    [ -f "$dir/.nvmrc" ] && { print -r -- "$dir/.nvmrc"; return; }
    [ "$dir" = "/" ] && return
    dir="${dir:h}"
  done
}

# Ensure pnpm has fetched Node <version> into ~/Library/pnpm/nodejs/<version>.
# Uses a throwaway project so pnpm's runtime downloads it on demand.
_pnpm_fetch_node() {
  local v="$1" tmp
  [ -x "$PNPM_HOME/nodejs/$v/bin/node" ] && return 0
  command -v pnpm >/dev/null || return 1
  print -r -- "Fetching Node $v via pnpm ..."
  tmp="$(mktemp -d)" || return 1
  print -r -- "useNodeVersion: $v" > "$tmp/pnpm-workspace.yaml"
  print -r -- "{}"                 > "$tmp/package.json"
  ( cd "$tmp" && pnpm node -v ) >/dev/null 2>&1
  rm -rf "$tmp"
  [ -x "$PNPM_HOME/nodejs/$v/bin/node" ]
}

# Inject the wanted Node's bin dir into THIS shell's PATH (per-shell, like nvm).
# No `pnpm env use --global` — that flips one global Node shared by every terminal.
load-pnpm-runtime() {
  export PNPM_HOME="${PNPM_HOME:-$HOME/Library/pnpm}"
  path=("${(@)path:#$PNPM_HOME/nodejs/*/bin}")   # drop any version dir we injected before

  local nvmrc_path want
  nvmrc_path="$(_pnpm_find_nvmrc)"
  if [ -n "$nvmrc_path" ]; then
    want="${$(<"$nvmrc_path")//[[:space:]]/}"; want="${want#v}"
    if [ -n "$want" ]; then
      if _pnpm_fetch_node "$want"; then
        path=("$PNPM_HOME/nodejs/$want/bin" "${path[@]}")
        print -r -- "node -> $want (${nvmrc_path/#$HOME/~})"
      else
        print -r -- "load-pnpm-runtime: Node $want unavailable (pnpm fetch failed)" >&2
      fi
    fi
  fi
  export PATH
}

add-zsh-hook chpwd load-pnpm-runtime
load-pnpm-runtime   # DO IT AFTER ADDING pnpm to PATH!
# ============================================================

⚠️ The hook (last line) must run after pnpm's own PATH block, otherwise the global bin dir shadows your injected version dir.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment