Skip to main content

npm to pnpm migration

⚠️ Deprecated

This migration has been completed. The steps below are kept for historical reference only — new engineers do not need to run them.

For ongoing pnpm usage notes (gotchas, CI examples, autocompletion, etc.), see the Further info section at the bottom.

Original migration steps

These steps were required when the team migrated from npm to pnpm.

  1. Update local scripts that call npm — replace npm with pnpm for any scripts targeting:

    • gf-app-tng
    • gf-sourcers
    • tech-docs
  2. Install pnpm globally

    npm i -g pnpm@10.23.0
  3. (macOS only) Prepare puppeteer for crawlerService

    Install Chromium:

    brew install chromium --no-quarantine

    Add to ~/.zshrc:

    echo "\nexport PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true" >> ~/.zshrc
    echo "\nexport PUPPETEER_EXECUTABLE_PATH=`which chromium`" >> ~/.zshrc
    source ~/.zshrc
  4. Pull main for gf-app-tng, gf-sourcers, and tech-docs.

  5. Reinstall dependencies at the root of each project:

    rm -rf `find . -type d -name node_modules` && pnpm i
  6. Report any problems to Ian.

Further info

Reference notes for working with pnpm day-to-day. Still relevant.

Migrating a new repository to pnpm

  1. Run pnpm import at the project root to migrate package-lock.json.

  2. Delete the old npm artefacts:

    rm -rf package-lock.json node_modules
  3. Enforce pnpm in package.json:

    {
    "devEngines": {
    "packageManager": {
    "name": "pnpm",
    "onFail": "error"
    }
    },
    "engines": {
    "node": ">=20.0",
    "pnpm": ">=10.0.0"
    }
    }
  4. Install:

    pnpm i
  5. Review (pre/post)install scripts.

  6. Configure security:

    minimumReleaseAge: 10080 # 7 days
  7. Replace npm usages in package scripts, shell scripts, and CI/CD actions.

Building dependencies incrementally

To build a package along with its dependencies (direct and transitive), use selective filtering:

pnpm --filter crawler-service... run build

Hoisted dependencies

npm lets you accidentally use packages not declared in package.json if they happen to be in node_modules. pnpm is stricter — undeclared dependencies fail.

Find undeclared usages with:

pnpx npm-check

Install scripts blocked by default

pnpm blocks pre/postinstall scripts by default for security.

⚠️ This is a safety feature to prevent malicious scripts from running.

To allow specific packages, use onlyBuiltDependencies. When you install a package that needs build scripts, pnpm will guide you through pnpm approve-builds, which updates pnpm-workspace.yaml.

Flat vs nested node_modules

pnpm doesn't create a flat node_modules. This breaks:

  • Legacy build tools that require() undeclared dependencies (e.g. a webpack plugin assuming react is hoisted).
  • Older monorepo tooling that doesn't resolve pnpm's nested layout.
  • Packages that import undeclared deps (e.g. importing lodash without declaring it — works under npm only because of accidental hoisting).

Option 1 — fully flatten (nuclear)

pnpm install --shamefully-hoist

Loses pnpm's isolation benefits.

Option 2 — middle ground

Add to .npmrc / .pnpmfile.cjs:

node-linker=hoisted

Or in pnpm-workspace.yaml:

nodeLinker: hoisted

Hoists as much as possible while keeping symlinks and the pnpm store.

Installing all monorepo deps

pnpm --recursive --filter '*' install

⚠️ Per the pnpm install docs, this is now redundant inside a workspace — pnpm install already installs all projects' deps. Disable with recursive-install=false if needed.

CI installs

Example workflow

name: pnpm Example Workflow
on:
push:

jobs:
build:
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [20]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install

Pinning installs

The pnpm equivalent of npm ci:

pnpm i --frozen-lockfile           # all deps
pnpm i --frozen-lockfile --prod # dependencies only
pnpm i --frozen-lockfile --dev # devDependencies only

Security: minimum release age

Block packages released in the last 24 hours:

minimumReleaseAge: 1440 # minutes — 10080 = 7 days

Tab autocompletion

OhMyZsh:

pnpm completion zsh >| ~/completion-for-pnpm.zsh
echo 'source ~/completion-for-pnpm.zsh' >> ~/.zshrc

Bash:

pnpm completion bash > ~/completion-for-pnpm.bash
echo 'source ~/completion-for-pnpm.bash' >> ~/.bashrc

Fish:

pnpm completion fish > ~/.config/fish/completions/pnpm.fish

Recursive command behaviour

By default, pnpm runs commands recursively if no matching script exists in the current folder.

For example, running pnpm run test at the root with no root test script will run test in every workspace package.

Likewise, pnpm i at the root installs all workspace deps; at a sub-project, only that project's deps.

Control this with:

  • -r, --recursive — run in all projects
  • --filter <package> — run in specific packages
# Run tests only in gf-app-backend
pnpm --filter gf-app-backend test

npx equivalent

The repo enforces pnpm as the package manager, so npx is locked down inside it (still fine elsewhere on your machine).

Use pnpx as a drop-in replacement.

⚠️ package.json scripts should call deps directly. Using npx/pnpx without a pinned version is unpredictable.

{
"scripts": {
"lint": "npx eslint", // ❌ may use a later version than installed
"lint": "eslint" // ✅ uses the declared version
},
"devDependencies": {
"eslint": "8.7.1"
}
}

Deduplicating dependencies

pnpm dedupe          # remove older lockfile entries when a newer version can be used
pnpm dedupe --check # exit non-zero if dedupe would change anything (no install, no edit)