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)¶
- Git worktrees - the strongest argument FOR workspaces. With workspaces, setting up a new worktree is a single
pnpm installat the root. Without workspaces, you need 3 separate installs in each worktree. - The root already coordinates - the root
package.jsonalready hasnpm --prefix src/services/web run ...convenience scripts. Workspaces formalise this pattern and replace the--prefixhack with proper--filter. - Single lockfile - one
pnpm-lock.yamlat root vs three separate lockfiles. Simpler to maintain, review, and keep in sync. - Shared dependency deduplication -
tsx,typescript,yaml,@types/nodeare used by multiple packages and can be hoisted once. - Husky + root scripts - the pre-commit hook and
validate:generatedscript 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 tsxbecomes justtsxsince tsx is a local devDep and pnpm adds.binto 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/ngdirect path reference works with pnpm because@angular/cliis a direct dependency of the web package - pnpm symlinks direct deps into the package's ownnode_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 installat root installs everything. Noworking-directoryneeded 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:
ci-cd.ymltest-web job (~line 753):npm ci->pnpm install --frozen-lockfile-
npx ng test->pnpm --filter syrfweb exec ng test -
ci-cd.ymlbuild-web-artifacts job (~line 790): npm ci->pnpm install --frozen-lockfilenpm run build->pnpm --filter syrfweb run build-
npx sentry-cli ...->pnpm exec sentry-cli ... -
ci-cd.ymlsentry .NET releases (~line 1009): -
npm install -g @sentry/cli->pnpm add -g @sentry/cli(or usepnpm dlx @sentry/cli) -
pr-preview.ymlbuild-web-artifacts: npm ci->pnpm install --frozen-lockfilenode_modules/@angular/cli/bin/ng build->pnpm --filter syrfweb exec ng build(safer, avoids direct path)-
npm run sentry:sourcemaps->pnpm --filter syrfweb run sentry:sourcemaps -
pr-tests.ymlvalidate-generated-code (~line 114): npm ci->pnpm install --frozen-lockfile-
npm run validate:generated->pnpm run validate:generated -
pr-tests.ymltest-web (~line 192): - 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):
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¶
The root
package.jsonis 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)¶
- Create
pnpm-workspace.yamlat repo root - Create
.npmrcat repo root with peer dep settings - Import lockfiles: Run
pnpm importat root (reads allpackage-lock.jsonfiles and createspnpm-lock.yaml) - Delete old lockfiles: Remove all three
package-lock.jsonfiles - Delete
src/services/web/.npmrc(config moved to root) - Run
pnpm installto verify the lockfile and createnode_modules
Phase 2: Package.json scripts¶
- Update root
package.jsonscripts:npm --prefix->pnpm --filter,npx->tsx - Update web
package.jsonscripts:npm run->pnpm run,npx->pnpm exec
Phase 3: Validate the build¶
- Run
pnpm --filter syrfweb run build- verify Angular compiles - Run
pnpm --filter syrfweb run test- verify tests pass - Run
pnpm --filter syrfweb run lint- verify linting works - Run
pnpm run validate:generated- verify root scripts work - Run
pnpm --filter syrf-common-chart run validate:env-blocks- verify chart scripts
Phase 4: CI/CD and tooling¶
- Update
.github/workflows/ci-cd.yml- add pnpm setup, replace npm commands - Update
.github/workflows/pr-preview.yml- same pattern - Update
.github/workflows/pr-tests.yml- same pattern - Update
.husky/pre-commit-npm run->pnpm run - Update
.github/scripts/run-tests.sh-npx->pnpm exec - Update
src/services/web/Dockerfile.full- pnpm install pattern - Update
.gitignore- add pnpm entries
Phase 5: Documentation¶
- Update
CLAUDE.md- all npm references - Update
src/services/web/README.md- all npm references - Update docs in
docs/how-to/- all npm references - Update docs in
docs/architecture/- all npm references
Phase 6: Verification¶
- Run full build + test cycle locally
- Push and verify CI passes
- 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 pnpmor 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:
Then developers use corepack enable once and pnpm is automatically available at the correct version.