Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions .claude/rules/package-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This document defines the rules for managing dependencies in package.json.

## Package Manager: pnpm

This project uses **pnpm** for development. The `packageManager` field in `package.json` ensures the correct version is used (via Corepack).
This project uses **pnpm** for development. The `packageManager` field in `package.json` pins the exact version and is the single source of truth — pnpm itself follows it (`managePackageManagerVersions`, on by default since pnpm 10), so no Corepack is involved. See [The pnpm pin contract](#the-pnpm-pin-contract-packagemanager-as-single-source-of-truth).

```bash
# Install dependencies
Expand Down Expand Up @@ -99,7 +99,7 @@ The `pnpm-lock.yaml` file must always be committed. It provides additional repro

## Package Manager: pnpm 11

This repo is pinned to **pnpm 11** via the `packageManager` field (corepack/`pnpm/action-setup` follow it, so CI and Docker use it automatically — no version is hardcoded anywhere else).
This repo is pinned to **pnpm 11** via the `packageManager` field — see [The pnpm pin contract](#the-pnpm-pin-contract-packagemanager-as-single-source-of-truth) for how CI and Docker consume it without corepack.

pnpm 11 **no longer reads the `pnpm` field in `package.json`**, and `.npmrc` is auth/registry only. All pnpm-specific settings live in **`pnpm-workspace.yaml`**:

Expand All @@ -109,6 +109,61 @@ pnpm 11 **no longer reads the `pnpm` field in `package.json`**, and `.npmrc` is

`pnpm audit`: pnpm 10.x is broken (npm retired the legacy audit endpoint → HTTP 410); pnpm 11 uses the working bulk-advisory endpoint. `scripts/check.mjs` degrades the retired-endpoint failure to a non-blocking warning as a safety net, so `check` stays green + honest even if a future endpoint change lands.

## The pnpm pin contract: `packageManager` as single source of truth

**Rule: the pnpm version is pinned in exactly one place — `package.json#packageManager`. Nothing may hardcode it, and nothing may rely on corepack.**

```jsonc
{
"packageManager": "pnpm@11.13.1+sha512.b2fc7683…", // THE pin: exact, with integrity hash
"engines": { "node": ">= 22", "pnpm": "^11.0.0" } // soft major gate — NOT a pin
}
```

**Corepack is not part of this contract.** It used to be the thing that read `packageManager`, but **Node >= 25 no longer ships it** — a build stage running `corepack enable` on such an image dies with `corepack: not found`. Nothing here needs it: pnpm follows the field itself (`managePackageManagerVersions`, on by default since pnpm 10), and `pnpm/action-setup` reads it directly.

### How each consumer gets the pinned pnpm

| Consumer | Mechanism |
|----------|-----------|
| Local dev | pnpm self-switches to the pinned version (`managePackageManagerVersions`) |
| GitHub Actions | `pnpm/action-setup@v6` with **no** `version:` input — it reads `packageManager` |
| Docker / other CI | the derive-line (below), once per pnpm-running stage |

### The derive-line

```dockerfile
# Provision the exact pnpm declared in package.json (single source of truth).
# No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped;
# npm enforces registry integrity for the tarball itself.
RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"
```

`.split('+')[0]` reduces `pnpm@11.13.1+sha512.…` to the plain spec `npm install -g` accepts. Dropping the hash does not weaken integrity — npm verifies the tarball against the registry's own metadata.

Two ordering rules, both enforced by the contract test:

1. It must run **after** the `COPY` that puts `package.json` in the WORKDIR — it reads that file.
2. It must be repeated in **every** stage that runs `pnpm`. A global install in `deps` does not survive into `builder`; stages inherit only what is explicitly `COPY --from=`'d.

### Rules

| Rule | Why |
|------|-----|
| `packageManager` is exact (`x.y.z+sha512.<hash>`), never a range | Same fixed-version rule as dependencies; corepack rejects ranges outright |
| `engines.pnpm` tracks the pin's major (`^<major>.0.0`) | A soft gate that warns pnpm 10 users. It is not a pin and cannot tell CI what to install |
| Never declare `devEngines.packageManager` | npm/npx abort with `EBADDEVENGINES`; corepack rejects ranges inside it |
| Never pass `version:` to `pnpm/action-setup` | Two sources drift; the action hard-errors on a mismatch |
| Never `RUN corepack …` | Absent on Node >= 25 |
| Never `npm install -g pnpm@<literal>` | That is a second pin — derive it instead |
| Monorepo: pin at the **workspace root** | The derive-line reads `/app/package.json`, which in monorepo mode (`API_DIR=projects/api`) is the root manifest. Missing there → `TypeError: Cannot read properties of undefined (reading 'split')` |

### Enforcement

`tests/unit/pnpm-pin-contract.spec.ts` asserts every rule above structurally (unit suite, no network), so a re-introduced `with: version: 11` or a stray `corepack enable` fails the build rather than drifting silently. A twelfth test proves the chain functionally — derive the spec, `npm install -g` it into a throwaway prefix, assert the binary reports the pinned version. It needs network + ~10 MB and is gated to `CI` / `PIN_PROVISION_TEST=1`.

**When bumping pnpm:** run `pnpm self-update` (writes the field with a fresh hash), align `engines.pnpm` if the major moved, and commit `package.json` + `pnpm-lock.yaml` together. Nothing else needs touching — that is the point.

## Overrides

Package overrides live in the `overrides:` section of **`pnpm-workspace.yaml`** (they moved out of `package.json`'s `pnpm.overrides` in the pnpm 11 upgrade). They force transitive dependencies to a security-patched version.
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ jobs:
uses: actions/checkout@v7

- name: Install pnpm
# No version input: the exact version is read from package.json's packageManager field.
uses: pnpm/action-setup@v6
with:
version: 11

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ jobs:
uses: actions/checkout@v7

- name: Install pnpm
# No version input: the exact version is read from package.json's packageManager field.
uses: pnpm/action-setup@v6
with:
version: 11

- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
Expand Down
13 changes: 11 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432
WORKDIR /app

# Build tools for bcrypt native addon
RUN apk add --no-cache python3 make g++ && corepack enable
RUN apk add --no-cache python3 make g++

# Copy all manifests for dependency resolution.
# In monorepo mode pnpm needs workspace config + all project manifests;
Expand All @@ -25,6 +25,11 @@ RUN find /tmp/src -maxdepth 3 -name "package.json" -not -path "*/node_modules/*"
&& cp -f /tmp/src/.npmrc /app/ 2>/dev/null || true \
&& rm -rf /tmp/src

# Provision the exact pnpm declared in package.json (single source of truth).
# No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped;
# npm enforces registry integrity for the tarball itself.
RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"

# Install dependencies (--ignore-scripts prevents husky/prepare errors in Docker)
# Rebuild bcrypt native addon separately
RUN pnpm install --frozen-lockfile --ignore-scripts && pnpm rebuild bcrypt
Expand All @@ -33,11 +38,15 @@ RUN pnpm install --frozen-lockfile --ignore-scripts && pnpm rebuild bcrypt
FROM node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS builder
ARG API_DIR=.
WORKDIR /app
RUN corepack enable

COPY --from=deps /app ./
COPY ${API_DIR}/ ./${API_DIR}/

# Provision the exact pnpm declared in package.json (single source of truth).
# No corepack: Node >= 25 no longer ships it. The +sha512 suffix is stripped;
# npm enforces registry integrity for the tarball itself.
RUN npm install -g "$(node -p "require('./package.json').packageManager.split('+')[0]")"

RUN cd ${API_DIR} && pnpm run build

# Remove devDependencies after build
Expand Down
2 changes: 1 addition & 1 deletion FRAMEWORK-API.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @lenne.tech/nest-server — Framework API Reference

> Auto-generated from source code on 2026-07-16 (v11.29.0)
> Auto-generated from source code on 2026-07-17 (v11.29.1)
> File: `FRAMEWORK-API.md` — compact, machine-readable API surface for Claude Code

## CoreModule.forRoot()
Expand Down
Loading