Skip to content

pnpm Workspace Migration Plan

Context

The SyRF monorepo currently uses npm to manage three JavaScript/TypeScript projects. This plan proposes migrating to pnpm with workspaces, covering every file that needs updating, risks, and a recommended execution order.

Current State

Project Path Purpose Dependencies
syrf (root) / Monorepo coordinator, husky, worktree tools 3 devDeps
syrfweb src/services/web/ Angular 21 frontend 77 deps + 47 devDeps
syrf-common-chart src/charts/syrf-common/ Helm chart code-gen scripts 4 devDeps

Lock files: 3 separate package-lock.json files (root, web, syrf-common).

Shared devDependencies across projects: tsx, typescript, @types/node, yaml.


Recommendation: pnpm WITH Workspaces

After auditing the repo, workspaces ARE recommended despite the small number of packages. Rationale:

Why workspaces (revised from initial assessment)

  1. Git worktrees - the strongest argument FOR workspaces. With workspaces, setting up a new worktree is a single pnpm install at the root. Without workspaces, you need 3 separate installs in each worktree.
  2. The root already coordinates - the root package.json already has npm --prefix src/services/web run ... convenience scripts. Workspaces formalise this pattern and replace the --prefix hack with proper --filter.
  3. Single lockfile - one pnpm-lock.yaml at root vs three separate lockfiles. Simpler to maintain, review, and keep in sync.
  4. Shared dependency deduplication - tsx, typescript, yaml, @types/node are used by multiple packages and can be hoisted once.
  5. Husky + root scripts - the pre-commit hook and validate:generated script already run from the root and reach into sub-packages. Workspaces make this a first-class pattern.

Why this is safe for git worktrees

pnpm's global content-addressable store (~/.local/share/pnpm/store/) is shared across all worktrees. Each worktree gets its own node_modules with hardlinks into the shared store. This means:

  • Disk usage: ~1x the packages on disk regardless of worktree count (hardlinks, not copies).
  • Install speed: Second worktree installs are near-instant (packages already in store).
  • Isolation: Each worktree has independent node_modules - no conflicts between branches with different dependency versions.

Migration Inventory

Files to Create

File Purpose
pnpm-workspace.yaml Define workspace packages
.npmrc (root) pnpm configuration (peer deps, hoisting)

Files to Delete

File Reason
package-lock.json Replaced by pnpm-lock.yaml
src/services/web/package-lock.json Replaced by root pnpm-lock.yaml
src/charts/syrf-common/package-lock.json Replaced by root pnpm-lock.yaml
src/services/web/.npmrc Moved to root .npmrc

Files to Modify

package.json scripts (npm -> pnpm)

/package.json (root):

 "scripts": {
-  "web": "npm --prefix src/services/web run",
-  "web:start": "npm --prefix src/services/web run start",
-  "web:serve": "npm --prefix src/services/web run serve",
-  "web:build": "npm --prefix src/services/web run build",
-  "web:test": "npm --prefix src/services/web run test",
-  "web:lint": "npm --prefix src/services/web run lint",
+  "web": "pnpm --filter syrfweb run",
+  "web:start": "pnpm --filter syrfweb run start",
+  "web:serve": "pnpm --filter syrfweb run serve",
+  "web:build": "pnpm --filter syrfweb run build",
+  "web:test": "pnpm --filter syrfweb run test",
+  "web:lint": "pnpm --filter syrfweb run lint",
   ...
-  "setup:web": "npm --prefix src/services/web install",
-  "validate:generated": "npx tsx scripts/validate-generated.ts",
+  "setup:web": "pnpm --filter syrfweb install",
+  "validate:generated": "tsx scripts/validate-generated.ts",
   "prepare": "husky"
 }

Note: npx tsx becomes just tsx since tsx is a local devDep and pnpm adds .bin to PATH when running scripts.

src/services/web/package.json:

-  "build": "node --max-old-space-size=7168 node_modules/@angular/cli/bin/ng build && npm run sentry:sourcemaps",
+  "build": "node --max-old-space-size=7168 node_modules/@angular/cli/bin/ng build && pnpm run sentry:sourcemaps",
   ...
-  "nswag:generate": "npm run nswag:client && npm run nswag:checksums",
+  "nswag:generate": "pnpm run nswag:client && pnpm run nswag:checksums",
-  "nswag:checksums": "cd ../../.. && npx tsx scripts/update-checksums.ts ...",
+  "nswag:checksums": "cd ../../.. && tsx scripts/update-checksums.ts ...",

The node_modules/@angular/cli/bin/ng direct path reference works with pnpm because @angular/cli is a direct dependency of the web package - pnpm symlinks direct deps into the package's own node_modules/. No hoisting config needed for this.

src/charts/syrf-common/package.json: No changes needed - scripts use tsx directly (no npm run calls).

CI/CD Workflows

All workflows need the same pattern change - replace npm setup with pnpm:

+- name: Setup pnpm
+  uses: pnpm/action-setup@v4
+  with:
+    version: 10
+
 - name: Setup Node.js
   uses: actions/setup-node@v4
   with:
     node-version: '20'
-    cache: 'npm'
-    cache-dependency-path: src/services/web/package-lock.json
+    cache: 'pnpm'

 - name: Install dependencies
-  run: npm ci
-  working-directory: src/services/web
+  run: pnpm install --frozen-lockfile

With workspaces, pnpm install at root installs everything. No working-directory needed for install.

Specific workflow files:

Workflow Jobs to Update
.github/workflows/ci-cd.yml test-web, build-web-artifacts, sentry .NET releases
.github/workflows/pr-preview.yml build-web-artifacts
.github/workflows/pr-tests.yml validate-generated-code, test-web

CI-specific details:

  1. ci-cd.yml test-web job (~line 753):
  2. npm ci -> pnpm install --frozen-lockfile
  3. npx ng test -> pnpm --filter syrfweb exec ng test

  4. ci-cd.yml build-web-artifacts job (~line 790):

  5. npm ci -> pnpm install --frozen-lockfile
  6. npm run build -> pnpm --filter syrfweb run build
  7. npx sentry-cli ... -> pnpm exec sentry-cli ...

  8. ci-cd.yml sentry .NET releases (~line 1009):

  9. npm install -g @sentry/cli -> pnpm add -g @sentry/cli (or use pnpm dlx @sentry/cli)

  10. pr-preview.yml build-web-artifacts:

  11. npm ci -> pnpm install --frozen-lockfile
  12. node_modules/@angular/cli/bin/ng build -> pnpm --filter syrfweb exec ng build (safer, avoids direct path)
  13. npm run sentry:sourcemaps -> pnpm --filter syrfweb run sentry:sourcemaps

  14. pr-tests.yml validate-generated-code (~line 114):

  15. npm ci -> pnpm install --frozen-lockfile
  16. npm run validate:generated -> pnpm run validate:generated

  17. pr-tests.yml test-web (~line 192):

  18. Same pattern as ci-cd.yml test-web

Shell Scripts

.husky/pre-commit (lines 15, 22, 23, 25):

-  npm run validate:generated
+  pnpm run validate:generated
   ...
-  echo "  cd src/charts/syrf-common && npm run generate:env-blocks  (config files)"
-  echo "  cd src/services/web && npm run generate:flags             (feature flags)"
-  echo "  cd src/services/web && npm run nswag:generate             (API client)"
+  echo "  cd src/charts/syrf-common && pnpm run generate:env-blocks  (config files)"
+  echo "  cd src/services/web && pnpm run generate:flags             (feature flags)"
+  echo "  cd src/services/web && pnpm run nswag:generate             (API client)"

.github/scripts/run-tests.sh (lines 50, 52):

-  npx ng test --no-watch --code-coverage
+  pnpm exec ng test --no-watch --code-coverage
-  npx ng test --no-watch
+  pnpm exec ng test --no-watch

Dockerfile

src/services/web/Dockerfile.full: This is the local full-build Dockerfile. The CI Dockerfile (Dockerfile) is just nginx and doesn't use npm at all.

 FROM node:20-alpine AS build
 WORKDIR /app

-COPY src/services/web/package*.json ./
-COPY src/services/web/.npmrc ./
-RUN npm ci
+# Copy workspace root config
+COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
+# Copy web package.json
+COPY src/services/web/package.json src/services/web/
+# Copy syrf-common package.json (workspace member)
+COPY src/charts/syrf-common/package.json src/charts/syrf-common/
+# Install pnpm and dependencies
+RUN corepack enable && corepack prepare pnpm@10 --activate
+RUN pnpm install --frozen-lockfile --filter syrfweb

 COPY src/services/web/ ./src/services/web/
-RUN npm run build --prod
+RUN pnpm --filter syrfweb run build

Since Dockerfile.full is generated by scripts/generate-dockerfiles.py, the template in that script also needs updating. Or alternatively, this Dockerfile could be excluded from generation and maintained manually since it's the only JS one.

.gitignore

Add pnpm-related entries (note: pnpm-lock.yaml should be committed, not ignored):

 npm-debug.log
 yarn-error.log
+.pnpm-store/
+.pnpm-debug.log

Documentation to Update

Lower priority but needed for accuracy. All npm command references:

File Changes
CLAUDE.md npm install -> pnpm install, npm run -> pnpm run, npm test -> pnpm test, npx ng test -> pnpm exec ng test
src/services/web/README.md Same pattern
docs/how-to/work-with-generated-files.md npm run -> pnpm run
docs/how-to/setup-pre-commit-hooks.md npm install -> pnpm install, npm run -> pnpm run
docs/how-to/manage-feature-flags.md npm run -> pnpm run
docs/how-to/run-tests.md npx ng test -> pnpm exec ng test
docs/how-to/setup-sentry-integration.md npx sentry-cli -> pnpm exec sentry-cli
docs/how-to/extend-env-mapping-schema.md npm run -> pnpm run
docs/how-to/ci-cd-workflow.md npm ci -> pnpm install --frozen-lockfile
docs/architecture/env-mapping-code-generation.md npm run -> pnpm run
docs/architecture/web-npm-vulnerabilities.md npm audit -> pnpm audit
docs/planning/security-vulnerability-backlog.md npm -> pnpm

New Files Content

pnpm-workspace.yaml

packages:
  - "src/services/web"
  - "src/charts/syrf-common"

The root package.json is implicitly the workspace root - it doesn't need listing.

.npmrc (root)

# Automatically install peer dependencies (replaces npm's legacy-peer-deps=true)
auto-install-peers=true

# Don't fail on peer dependency mismatches (Angular ecosystem has many)
strict-peer-dependencies=false

# Use the default isolated node_modules layout (symlinked)
# Direct dependencies are accessible via node_modules/<pkg>, which satisfies
# Angular CLI's node_modules/@angular/cli/bin/ng path references.
# Do NOT use shamefully-hoist=true unless forced to - it defeats pnpm's strictness.

Risks and Mitigations

Risk 1: Angular build breaks due to phantom dependencies

Risk: The Angular app may import packages that are not declared in its own package.json but were accessible via npm's flat node_modules. pnpm's strict isolation would surface these as build errors.

Likelihood: Medium. Angular ecosystem is known for this.

Mitigation: Run the Angular build immediately after migration. If phantom deps surface, explicitly add them to package.json. This is actually a benefit - it makes the dependency graph honest.

Escape hatch: If too many phantom deps surface, temporarily add shamefully-hoist=true to .npmrc, then remove phantom deps incrementally.

Risk 2: node_modules/@angular/cli/bin/ng direct path

Risk: Two locations directly reference node_modules/@angular/cli/bin/ng: - src/services/web/package.json build script - .github/workflows/pr-preview.yml line 651

Likelihood: Low. pnpm symlinks direct dependencies into the package's node_modules/. Since @angular/cli is a direct devDep of the web package, the path resolves correctly.

Mitigation: Replace with pnpm exec ng build in workflows. The package.json reference can stay as-is (pnpm creates the symlink) or be changed to ng build (pnpm puts .bin on PATH during script execution).

Risk 3: Husky hooks fail in fresh worktrees

Risk: A new git worktree might not have node_modules yet. The pre-commit hook calls pnpm run validate:generated, which requires tsx from root node_modules.

Likelihood: Medium. Same problem exists with npm today.

Mitigation: The git-worktree-tools' wt/newpr commands likely run install as part of worktree setup. Verify this. If not, the hook should fail gracefully with a message like "run pnpm install first".

Risk 4: CI cache invalidation

Risk: First CI run after migration will have a cache miss (npm cache is useless for pnpm). Build times will be slower for the first run.

Likelihood: Certain (but one-time).

Mitigation: Expected and acceptable. Subsequent runs will use pnpm's cache. pnpm installs are faster than npm even without cache.

Risk 5: Dockerfile.full generation

Risk: scripts/generate-dockerfiles.py generates Dockerfile.full. After migration, the template needs updating or the file needs excluding from generation.

Likelihood: Certain.

Mitigation: Either update the Python generator template, or mark Dockerfile.full as manually maintained (it's the only JS Dockerfile and already special-cased).


Execution Order

The migration should be done in a single PR, in this order:

Phase 1: Foundation (no breaking changes yet)

  1. Create pnpm-workspace.yaml at repo root
  2. Create .npmrc at repo root with peer dep settings
  3. Import lockfiles: Run pnpm import at root (reads all package-lock.json files and creates pnpm-lock.yaml)
  4. Delete old lockfiles: Remove all three package-lock.json files
  5. Delete src/services/web/.npmrc (config moved to root)
  6. Run pnpm install to verify the lockfile and create node_modules

Phase 2: Package.json scripts

  1. Update root package.json scripts: npm --prefix -> pnpm --filter, npx -> tsx
  2. Update web package.json scripts: npm run -> pnpm run, npx -> pnpm exec

Phase 3: Validate the build

  1. Run pnpm --filter syrfweb run build - verify Angular compiles
  2. Run pnpm --filter syrfweb run test - verify tests pass
  3. Run pnpm --filter syrfweb run lint - verify linting works
  4. Run pnpm run validate:generated - verify root scripts work
  5. Run pnpm --filter syrf-common-chart run validate:env-blocks - verify chart scripts

Phase 4: CI/CD and tooling

  1. Update .github/workflows/ci-cd.yml - add pnpm setup, replace npm commands
  2. Update .github/workflows/pr-preview.yml - same pattern
  3. Update .github/workflows/pr-tests.yml - same pattern
  4. Update .husky/pre-commit - npm run -> pnpm run
  5. Update .github/scripts/run-tests.sh - npx -> pnpm exec
  6. Update src/services/web/Dockerfile.full - pnpm install pattern
  7. Update .gitignore - add pnpm entries

Phase 5: Documentation

  1. Update CLAUDE.md - all npm references
  2. Update src/services/web/README.md - all npm references
  3. Update docs in docs/how-to/ - all npm references
  4. Update docs in docs/architecture/ - all npm references

Phase 6: Verification

  1. Run full build + test cycle locally
  2. Push and verify CI passes
  3. Test a fresh git worktree - clone, create worktree, run pnpm install, verify build

Post-Migration: Developer Workflow Changes

Day-to-day commands

Before (npm) After (pnpm)
npm install pnpm install
npm ci pnpm install --frozen-lockfile
npm run build pnpm run build
npm test pnpm test
npx ng test pnpm exec ng test
npm --prefix src/services/web run start pnpm --filter syrfweb run start
npm install lodash pnpm add lodash --filter syrfweb
npm install -D vitest pnpm add -D vitest --filter syrfweb
npm audit pnpm audit

Root convenience scripts still work

# These still work exactly the same (just backed by pnpm internally):
pnpm run web:start
pnpm run web:build
pnpm run web:test
pnpm run web:lint

New git worktree setup

# Create worktree (assuming git-worktree-tools handles this):
pnpm run newpr my-feature

# Or manually:
git worktree add ../syrf-feature feature-branch
cd ../syrf-feature
pnpm install    # Single command - installs all workspaces
                # Near-instant if global store already has packages

Prerequisites

  • pnpm >= 10.x installed globally (npm install -g pnpm or via corepack)
  • Node.js 20.x (current requirement, unchanged)
  • Corepack (optional but recommended - ships with Node.js 20+, manages pnpm version)

To pin the pnpm version for the team, add to root package.json:

{
  "packageManager": "pnpm@10.5.2"
}

Then developers use corepack enable once and pnpm is automatically available at the correct version.