LPM-cli

Migrating from pnpm

Convert a pnpm-lock.yaml project to LPM, isolated layout preserved.

lpm migrate converts a pnpm project to LPM in one command. It detects pnpm, parses pnpm-lock.yaml, writes lpm.lock + lpm.lockb, 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. This page covers the pnpm-specific differences.

What carries over cleanly

  • Isolated node_modules layout for workspaces and peer conflicts. Workspace projects auto-flip to LPM'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 doesn't use it, but doesn't delete it either. If you have one, decide whether you still need it.
  • Workspaces and catalogs. LPM 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 prunes unused catalog entries after successful installs.
  • .npmrc-declared private registries carry over verbatim. LPM honors .npmrc for routing.

Run the migration

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 + 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

FlagEffect
--dry-runParse + convert only, write nothing
--forceOverwrite an existing lpm.lock
-y, --yesReserved. The flow is non-interactive today, so this flag is a no-op. It does NOT imply --force.
--no-installConvert lockfile only, skip install
--skip-verifySkip build + test verification
--no-npmrcDon't touch .npmrc
--ciGenerate a CI workflow template
--no-ciSuppress CI hint
--rollbackRestore from .backup files

Verify and commit

lpm install --offline       # confirm reproducibility
lpm test
lpm lint
lpm fmt --check
git add lpm.lock lpm.lockb .npmrc package.json
git rm pnpm-lock.yaml pnpm-lock.yaml.backup .npmrc.backup
git commit -m "Migrate to LPM"

What lpm migrate translates from package.json

  • 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's canonical patches/<safe_key>.patch location (or treated as a validated no-op if the source is already at that path). 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-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'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-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 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's equivalent for the npm-flat layout is lpm install --linker=hoisted or package.json > lpm > linker = "hoisted".

Rollback

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

LPM defaults to script-policy: deny — lifecycle scripts (postinstall, etc.) don't run during install. After the migration's lpm install step, run:

lpm rebuild

For packages that pnpm allowed to run scripts but LPM hasn't trusted yet, lpm 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:

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