skills/pnpm-workspace-monorepo/SKILL.md
Use when configuring a pnpm-workspaces monorepo, debugging hoisting/peer-dep resolution, adopting the catalog protocol, integrating with Turborepo or Nx, fixing "cannot find module" issues that only happen in CI, or migrating from npm/yarn workspaces. Triggers: pnpm-workspace.yaml setup, packageExtensions for broken peer deps, .npmrc tuning (public-hoist-pattern, shamefully-hoist), workspace protocol (workspace:*), filtering with --filter, version mismatches across packages, "Cannot find module \"foo\"" only after lockfile update, ESM/CJS interop in workspace packages. NOT for npm/yarn-specific workspace bugs, monorepos using Bazel/Lerna/Rush, or single-package projects.
npx skillsauth add curiositech/windags-skills pnpm-workspace-monorepoInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
pnpm's strict resolution catches dependency mistakes that npm and yarn silently allow. That's its biggest feature and its biggest source of confusion. Most "weird CI failure" stories in pnpm monorepos boil down to a missing peer-dep declaration that npm-style hoisting was hiding.
repo/
├── pnpm-workspace.yaml
├── package.json # workspace root, no app deps
├── packages/
│ ├── core/ # @myorg/core
│ ├── ui/ # @myorg/ui
│ └── tsconfig/ # @myorg/tsconfig (shared base)
└── apps/
├── api/ # @myorg/api
└── marketing/ # @myorg/marketing
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
# Catalog protocol — pinned versions referenced by name.
catalog:
react: ^19.0.0
typescript: 5.7.2
zod: ^3.25.0
# Multiple named catalogs for divergent stacks.
catalogs:
legacy:
react: ^18.2.0
In each package:
{
"name": "@myorg/ui",
"dependencies": {
"@myorg/core": "workspace:*",
"react": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@myorg/tsconfig": "workspace:*",
"typescript": "catalog:"
}
}
workspace:* resolves to the local package on install and to a real version range when published. catalog: resolves to whatever the workspace catalog pins. One source of truth, no version drift.
# Run a script in one package.
pnpm --filter @myorg/api dev
# In a package and everything that depends on it.
pnpm --filter @myorg/core... build
# In a package and everything it depends on.
pnpm --filter ...@myorg/api build
# Changes since main.
pnpm --filter "...[origin/main]" test
# Multiple filters.
pnpm --filter "@myorg/api" --filter "@myorg/marketing" build
The dot ellipses are dependency direction: pkg... includes downstream consumers; ...pkg includes upstream deps.
.npmrc knobs that matter# Strict by default — pnpm doesn't hoist. Tools that assume hoisting break.
# Allow specific things to hoist when a tool needs them at the root.
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@types/*
# When a dep doesn't declare a peer correctly and patching upstream isn't viable.
auto-install-peers=true
# Catalog mode: 'strict' fails the install if a workspace package uses a non-catalog version.
# Encourage the catalog without forcing it during migration.
manage-package-manager-versions=true
# Avoid lifecycle scripts running in CI for postinstalled deps you don't trust.
side-effects-cache-readonly=true
shamefully-hoist=true is a last resort. It defeats pnpm's strictness and reintroduces npm-style implicit deps.
packageExtensions — fix broken peer deps without forking# pnpm-workspace.yaml
packageExtensions:
some-broken-pkg@*:
peerDependencies:
react: '*'
other-pkg@<2:
dependencies:
buffer: ^6.0.0
This is a non-disruptive way to patch package.json for installed deps. Write the issue link as a comment so future-you knows to remove it after upstream fixes.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
^build means "build all upstream deps first." Outputs declare what to cache. Persistent: dev servers shouldn't cache.
# Run build, cached by content hash of inputs.
turbo build
# Run only the affected packages since main.
turbo build --filter "...[origin/main]"
// packages/tsconfig/base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"composite": true
}
}
// packages/ui/tsconfig.json
{
"extends": "@myorg/tsconfig/base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../core" }]
}
tsc -b builds packages in dependency order with incremental caches. Each package gets a tsconfig.tsbuildinfo next to its outputs.
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm turbo build --filter "...[origin/main]"
- name: Test
run: pnpm turbo test --filter "...[origin/main]"
--frozen-lockfile fails CI if pnpm-lock.yaml would change. Always use it in CI.
Symptom: App imports lodash (which it doesn't declare) but the build works locally because some other dep transitively pulled it in.
Diagnosis: pnpm's strict resolution hides transitive deps from your code; this works on npm. Until pnpm.
Fix: Declare every dep you import. pnpm why lodash shows who's actually pulling it in.
workspace:* accidentally publishedSymptom: Consumer of a published package gets workspace:* in node_modules and chokes.
Diagnosis: Forgot to convert workspace:* → real semver on publish.
Fix: Use pnpm publish (handles the rewrite) or pnpm pack to inspect first. Never run npm publish directly in a pnpm monorepo.
shamefully-hoist=true to "fix" a missing peerSymptom: App works locally with shamefully-hoist; CI fails when hoisting differs.
Diagnosis: Hiding a real bug under hoisting. Some tool needs a peer that's not declared.
Fix: Find the missing peer, add it to the package's peerDependencies or use packageExtensions. Remove shamefully-hoist.
Symptom: No turbo cache hits despite config looking right.
Diagnosis: inputs not declared — turbo defaults to "everything in the package," so an unrelated file change busts the cache.
Fix: Set inputs per task: ["src/**/*.{ts,tsx}", "package.json", "tsconfig.json"].
Symptom: Half the workspace is on react@^19, half on ^18.
Diagnosis: Someone bumped react in one package's package.json instead of in the catalog.
Fix: Adopt catalog: for shared deps. Set pnpm.overrides for transitive single-version enforcement. Lint with manage-package-manager-versions.
Symptom: A postinstall in a transitive dep prints sensitive env vars (or worse).
Diagnosis: pnpm runs lifecycle scripts by default.
Fix: pnpm install --ignore-scripts for installs in CI sensitive paths; allowlist scripts via pnpm.onlyBuiltDependencies.
pnpm-lock.yaml committed; --frozen-lockfile enforced in CI.workspace:* ranges in published artifacts.catalog: protocol).shamefully-hoist=true; missing peers fixed via packageExtensions or proper declarations.inputs declared so the cache reflects real dependencies.--filter "...[origin/main]" so only affected packages build.engines.node matching the repo Node version.pnpm.onlyBuiltDependencies allowlist for postinstall scripts.pnpm --filter ..., the matrix shape is upstream. → github-actions-matrix-patterns.data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.