# Migrating from pnpm (/docs/guides/migrating-from-pnpm)



`lpm migrate` converts a pnpm project to LPM CLI in one command. It detects pnpm, parses `pnpm-lock.yaml`, writes `lpm.lock` plus `lpm.lockb` when the graph fits the binary format, optionally configures `.npmrc`, runs `lpm install`, and (optionally) verifies that `build` + `test` still pass. Backups are taken first, so `--rollback` restores the original state.

The migration shape is the same as [migrating from npm](/docs/guides/migrating-from-npm). This page covers the pnpm-specific differences.

## What carries over cleanly [#what-carries-over-cleanly]

* **Isolated `node_modules` layout for workspaces and peer conflicts.** Workspace projects auto-flip to LPM CLI's isolated layout (symlinks into a virtual store) — same shape pnpm uses, so your imports keep working unchanged and you get the same phantom-dep protection. Single-package projects start hoisted, but default installs auto-switch to isolated when resolution detects incompatible peer requirements. Force isolated with `lpm install --linker=isolated` or `package.json > lpm > linker = "isolated"` if you want pnpm's shape there for every install.
* **`.pnpmfile.cjs` is unaffected.** LPM CLI doesn't use it, but doesn't delete it either. If you have one, decide whether you still need it.
* **Workspaces and catalogs.** LPM CLI reads `package.json > workspaces` first; if it's absent, it falls back to `pnpm-workspace.yaml` for member globs. It also reads pnpm-style `pnpm-workspace.yaml > catalog` and `catalogs` for default and named catalog entries, so `catalog:` dependencies keep resolving without moving catalog data into `package.json`. If `cleanupUnusedCatalogs: true` is set in `pnpm-workspace.yaml`, LPM CLI prunes unused catalog entries after successful installs.
* **`.npmrc`-declared private registries** carry over verbatim. LPM CLI honors `.npmrc` for routing.

## Run the migration [#run-the-migration]

```bash
lpm migrate --dry-run   # preview
lpm migrate             # do it
```

Same flow as the npm path:

1. Detect pnpm, parse `pnpm-lock.yaml`, convert.
2. Write `lpm.lock` and, when supported, `lpm.lockb`. Back up `pnpm-lock.yaml` to `pnpm-lock.yaml.backup`.
3. Touch `.npmrc` (with `--no-npmrc` to skip), backing up the original.
4. Run `lpm install` against the new lockfile.
5. Run `build` + `test` to verify (skip with `--skip-verify`).
6. Print summary + CI hint.

## Optional flags [#optional-flags]

| Flag            | Effect                                                                                             |
| --------------- | -------------------------------------------------------------------------------------------------- |
| `--dry-run`     | Parse + convert only, write nothing                                                                |
| `--force`       | Overwrite an existing `lpm.lock`                                                                   |
| `-y`, `--yes`   | Reserved. The flow is non-interactive today, so this flag is a no-op. It does NOT imply `--force`. |
| `--no-install`  | Convert lockfile only, skip install                                                                |
| `--skip-verify` | Skip `build` + `test` verification                                                                 |
| `--no-npmrc`    | Don't touch `.npmrc`                                                                               |
| `--ci`          | Generate a CI workflow template                                                                    |
| `--no-ci`       | Suppress CI hint                                                                                   |
| `--rollback`    | Restore from `.backup` files                                                                       |

## Verify and commit [#verify-and-commit]

```bash
lpm install --offline       # confirm reproducibility
lpm test
lpm lint
lpm fmt --check
```

```bash
git add lpm.lock .npmrc package.json
test ! -f lpm.lockb || git add lpm.lockb
git rm pnpm-lock.yaml pnpm-lock.yaml.backup .npmrc.backup
git commit -m "Migrate to LPM CLI"
```

## What `lpm migrate` translates from `package.json` [#what-lpm-migrate-translates-from-packagejson]

* **`pnpm.overrides`** is auto-translated to `package.json > lpm.overrides` (same selector grammar). The translation runs before any disk mutation: parse errors, unsupported value shapes (object / array / null), and merge conflicts with an existing `lpm.overrides` are surfaced up-front and abort the migration before any file is touched. Successful entries land in `lpm.overrides`; the original `pnpm.overrides` block stays in place so a parallel `pnpm install` keeps working during the transition.

* **`pnpm.patchedDependencies`** is auto-translated to `package.json > lpm.patchedDependencies` with `originalIntegrity` resolved from the migrated lockfile. Each patch file is copied to LPM CLI's canonical `patches/<safe_key>.patch` location (or treated as a validated no-op if the source is already at that path), and `lpm.lock > [patches]` records the patch path + SHA-256 so the first LPM CLI install can verify the bytes it replays. The plan validates everything up front: missing source files, paths outside the project root, paths through symlinked ancestors, directory targets, and any package whose lockfile entry is missing or has no integrity hash all abort the migration with a structured error before any disk mutation. Like the overrides block, the original `pnpm.patchedDependencies` is left in place after migration.

* **`pnpm.peerDependencyRules`** is auto-translated to `package.json > lpm.peerDependencyRules` with the same shape verbatim — `ignoreMissing`, `allowedVersions`, and `allowAny` all carry over. List entries (`ignoreMissing` / `allowAny`) are unioned with any pre-existing LPM CLI-side entries; map entries (`allowedVersions`) merge selector-by-selector. Same selector with the same range is a no-op idempotent merge; same selector with a different range is a hard conflict that aborts the migration before any disk mutation. Range strings that don't parse as valid semver, and selector keys that don't match LPM CLI's grammar, are surfaced up-front as parse errors with the offending entry named. `allowedVersions` selectors mirror `lpm.overrides`: bare peer names (`"react"`), scoped peers (`"@scope/foo"`), and parent-scoped forms (`"foo>react"`, `"foo@^2>react"`, `"@scope/foo@^2>react"`) all carry over verbatim; multi-segment paths and bare-name-with-version forms (`"foo@2"` without `>`) are rejected on both surfaces. Pattern semantics (`@scope/*`, `*-suffix`, etc.) work for `ignoreMissing` and `allowAny`. The same parser runs at install time, so hand-edits to `lpm.peerDependencyRules` after migration fail loud (`LpmError::Script`) instead of silently no-op'ing.

After migration, `lpm install` is silent for projects whose `lpm.overrides`, `lpm.patchedDependencies`, and `lpm.peerDependencyRules` cover every pnpm-side entry. If they drift apart later (you add a new `pnpm.*` entry without re-running migrate, or change a target / range), `lpm install` emits a diff-aware stderr warning suggesting `lpm migrate`. The same drift signals are exposed as stable codes (`pnpm_overrides_drift`, `pnpm_patches_drift`, `pnpm_peer_rules_drift`) on `lpm doctor --json` for automation.

## pnpm-specific gotchas [#pnpm-specific-gotchas]

* **`pnpm-workspace.yaml`** is read as a fallback when `package.json > workspaces` is absent, and its `catalog` / `catalogs` blocks are read as root catalogs. If the same catalog package entry exists in both `package.json > catalogs` and `pnpm-workspace.yaml`, LPM CLI uses the `package.json` value. `cleanupUnusedCatalogs: true` is honored unless `package.json > lpm.cleanupUnusedCatalogs` explicitly says otherwise.
* **`shamefully-hoist`** in `.npmrc` is pnpm-specific. LPM CLI's closest equivalent is `lpm install --linker=hoisted` or `package.json > lpm > linker = "hoisted"`; hoisted mode still uses LPM CLI's v2 virtual-store layout instead of flattening every transitive dependency to the project root.

## Rollback [#rollback]

```bash
lpm migrate --rollback
```

Restores `pnpm-lock.yaml`, `.npmrc` (if touched), `.gitattributes`, and `package.json` (rolled back when the `pnpm.*` translations mutated it) to pre-migration state. Files the migration newly created — `lpm.lock`, `lpm.lockb`, any new `.gitattributes`, and any patch files copied to `patches/` — are removed.

## Lifecycle scripts [#lifecycle-scripts]

Bare `lpm install` runs your root project lifecycle, including `prepare`. Dependency lifecycle scripts (`postinstall`, etc.) remain under `script-policy: deny` by default. After the migration's `lpm install` step, run:

```bash
lpm rebuild
```

For packages that pnpm allowed to run scripts but LPM CLI hasn't trusted yet, [`lpm approve-scripts`](/docs/packages/approve-scripts) walks the blocked set interactively.

If your project depends on `postinstall` for a tight inner loop and you're confident about the trust set:

```bash
lpm install --auto-build
```

Auto-runs `lpm rebuild` for trusted packages right after install. If a trusted lifecycle script fails, install exits non-zero. Or set `package.json > lpm > scripts.autoBuild: true` to make it sticky.

## See also [#see-also]

* [Migrating from npm](/docs/guides/migrating-from-npm) — same shape, different source lockfile
* [`lpm migrate`](/docs/packages/migrate) — full flag reference
* [Registries](/docs/registries) — `.npmrc` routing
* [`lpm install --linker`](/docs/packages/install) — keeping a flat npm-style layout if you need it
* [`lpm approve-scripts`](/docs/packages/approve-scripts) — review blocked lifecycle scripts after first install
