LPM CLI

Workspaces

Monorepo support — workspace declaration, filter grammar, workspace:* protocol, catalogs, deploy.

LPM CLI treats workspaces as a first-class concept. Declare them in package.json, then every command that touches the dep graph (install, uninstall, run, test, bench, lint, fmt, check, filter, deploy) accepts --filter / --filter-prod, --all, and --affected to scope work. Git-diff selectors can ignore noisy paths with --changed-files-ignore-pattern and classify test-only changes with --test-pattern. The resolver tracks cross-member edges via the workspace:* protocol, and lpm deploy produces a self-contained production tree for a single member.

This page covers the conceptual model. For a step-by-step setup walkthrough, see the Monorepo setup guide.

Declaring workspaces

Two equivalent shapes — both accepted in package.json:

package.json (array form)
{ "workspaces": ["packages/*", "apps/*"] }
package.json (object form)
{ "workspaces": { "packages": ["packages/*", "apps/*"] } }

Both globs match recursively under the project root. Each match is a workspace member — a directory with its own package.json, with its own dependencies, scripts, and version.

pnpm-workspace.yaml is read as a fallback when package.json > workspaces is absent. If both are present, package.json wins. Migrating from pnpm doesn't require deleting pnpm-workspace.yaml — though copying the globs into package.json > workspaces is the cleaner long-term shape. (See Migrating from pnpm.)

Root and member package.json files and pnpm-workspace.yaml each have a 16 MiB limit enforced before parsing. A missing pnpm-workspace.yaml keeps the fallback behavior above; an oversized file fails before workspace discovery proceeds or the resolver contacts a registry. See local configuration size limits.

Installing the workspace

Run no-argument install from the workspace root:

lpm install

LPM CLI installs members in dependency order and installs the root last. On a cold recursive install, eligible members enter one shared union resolution, with shared expansion passes when an importer requires an additional ambient peer package; LPM CLI then projects an isolated dependency graph for each importer, preserving conflicting direct versions, aliases, optional reachability, peer bindings, overrides, and policy decisions. An importer that cannot be projected safely falls back to its own resolver pass without disabling the union for other members.

The workspace writes one authoritative lpm.lock at the root. Its content-addressed workspace-packages table stores each distinct locked package row once, while importers records the package IDs and root state visible to each member. Member commands read only their importer projection, so lpm graph, lpm licenses, lpm sbom, audit, upgrade, patch, uninstall, tidy, doctor, frozen installs, and ordinary member-local installs keep member semantics without per-member lockfiles. Recursive install commits the root lockfile only after every selected target succeeds.

Existing workspaces migrate automatically: a successful recursive install absorbs legacy member lpm.lock files into the root union and removes the obsolete member files and lpm.lockb companions. Union workspace lockfiles are TOML-only because the binary companion format cannot represent importer projections.

Bare install from inside a member stays member-local. Widen it explicitly when needed:

lpm install --recursive
lpm -r install

Use --no-recursive at the workspace root for a root-only refresh. --recursive only refreshes existing manifests; to add a dependency, keep using lpm install <pkg> --filter <member> or -w for the root.

Filters scope recursive refreshes and automatically include the selected members' required workspace dependency closure:

lpm install --filter web
lpm install --filter-prod web

The production form omits devDependencies edges. Filtered refreshes do not install the workspace root or unrelated members.

Filtered mutations update the selected importer projections inside the same root lockfile. Manifest, patch, and lockfile changes share one transaction boundary: if resolution, materialization, or the root lockfile commit fails, LPM CLI restores those files and invalidates affected install hashes. node_modules is not copied for rollback; the missing hash makes the next install re-link and converge it.

Filter grammar

The filter expression — used by --filter and --filter-prod on every workspace-aware command — has its own small DSL. Filters compose: --filter web --filter api unions the two sets.

FilterMatches
webThe member literally named web
@scope/*Every member with a name under a scope
@scope/*{./apps/web}Members matching both the package-name atom and exact path
./apps/*Every member under a path glob
{./apps/web}Exact path match (path with literal characters)
[origin/main]Members changed since a git ref
web...web and every member web depends on (transitive deps — downstream closure)
web^...Same, excluding web itself (deps-only)
...webweb and every member that depends on web (transitive dependents — upstream closure)
...^webSame, excluding web itself (dependents-only)
!webExclusion — remove web from the resulting set

--filter-prod <expr> uses the same grammar, but closure operators walk only production graph edges: dependencies, optionalDependencies, and peerDependencies. devDependencies edges are skipped. This is useful for production deploy/build selections that should not pull in test-only workspace dependents.

Substring matching is not supported. --filter core does NOT match @babel/core — write --filter '*/core' for that. This is the most common surprise for users coming from pnpm.

Preview what an expression selects without running anything:

lpm filter web...                     # terse name list
lpm filter --filter-prod ...shared    # prod dependents of shared
lpm filter '[main]' --changed-files-ignore-pattern '**/README.md'
lpm filter '...[main]' --test-pattern '**/*.test.js'
lpm filter web... --explain           # full per-package trace (which filter matched, how)
lpm filter web --fail-if-no-match     # exit non-zero if empty (useful in CI)

lpm filter drives the same FilterEngine as lpm run --filter, so the selection is identical. In an interactive terminal, the terse preview uses status bullets; when piped or redirected it stays a bare one-name-per-line list. Pass --json for structured output suitable for scripts or agents.

--affected

lpm run test --affected              # default base = main
lpm run test --affected --base develop
lpm run test --affected --changed-files-ignore-pattern '**/README.md'
lpm run test --affected --test-pattern '**/*.test.js'

Selects only members affected by changes vs the base branch. Saves CI minutes — a one-line README change in apps/marketing doesn't trigger a full-monorepo test run.

Affected-ness is computed by walking the git diff against the base ref, then expanding through dep edges (a change in packages/shared triggers everything that imports it).

--changed-files-ignore-pattern <glob> drops matching paths from the git diff before LPM CLI maps files to workspace members. Pass it more than once for multiple globs.

--test-pattern <glob> marks matching changed files as test-only. The directly changed package still runs, but reverse closures and --affected do not add dependents from packages whose changes are only tests. This keeps packages/shared/src/foo.test.ts from rebuilding every app that imports shared.

Both git-diff tuning flags can be set as project defaults:

lpm.toml
[workspace]
changed-files-ignore-pattern = ["**/README.md", "docs/**"]
test-pattern = ["**/*.test.js", "**/*.spec.ts"]

--fail-if-no-match

A typo'd filter (--filter we instead of --filter web) produces an empty match set, and most commands silently exit success with no work done. --fail-if-no-match makes that exit non-zero — strongly recommended in CI:

lpm run test --filter-prod ...shared --fail-if-no-match

Available on install, uninstall, run, test, bench, lint, fmt, check, filter.

Multi-member confirmation

When lpm install <pkg> --filter ... would mutate more than one member's package.json, LPM CLI prompts for confirmation. JSON mode and non-TTY stdin already skip the prompt automatically; -y covers the interactive-terminal-but-no-manual-review case (scripts, agents).

workspace:* protocol

Cross-member deps:

packages/web/package.json
{
  "dependencies": {
    "@my-co/shared": "workspace:*"
  }
}

Tells lpm install "use the local workspace package, not a published version." The target can be a discovered member or the named package at the workspace root, so a docs member can depend on a root package such as vitepress via "vitepress": "workspace:*". Workspace deps are extracted before dependency resolution and linked from disk afterward — they never touch the registry. The linker symlinks the local package into node_modules/ (isolated layout) instead of pulling a published version. Topological order is respected for tasks (dependsOn: ["^build"] waits for upstream member's build to finish first).

Named workspace-root packages and members can also satisfy compatible plain semver edges that appear transitively through registry packages. If a registry package depends on @my-co/shared and the current workspace has a matching local package, LPM CLI resolves that edge locally instead of leaking the lookup to the registry.

Self-dependencies are invalid: a workspace package that declares itself via workspace:* in dependencies, devDependencies, peerDependencies, or optionalDependencies fails before LPM CLI writes lockfiles or node_modules.

Variants:

SpecMeaning
"workspace:*"Any local version
"workspace:^"Caret range against the local version
"workspace:~"Tilde range against the local version
"workspace:^1.2"Explicit range applied as if local were that version

All variants are preserved verbatim by save policy — they never get rewritten.

Catalogs

Centralize version pins across members:

package.json (root)
{
  "catalogs": {
    "default": { "react": "^18.2.0", "react-dom": "^18.2.0" },
    "testing": { "jest": "^29.0.0", "vitest": "^1.0.0" }
  }
}

Members reference catalog versions:

packages/web/package.json
{
  "dependencies": {
    "react": "catalog:",            // → ^18.2.0 (default catalog)
    "react-dom": "catalog:",
    "vitest": "catalog:testing"     // → ^1.0.0 (named catalog)
  }
}

One pin, every member uses it. Bump the catalog entry in one place to update every member. No more "the API package is on react 18.0 but the web package is on 18.2 because someone forgot."

Set package.json > lpm.cleanupUnusedCatalogs = true or pnpm-workspace.yaml > cleanupUnusedCatalogs: true when you want lpm install to remove catalog entries that no root or member manifest references. By default, LPM CLI preserves unused catalog entries.

Inspect catalog state with:

lpm catalog list --unused
lpm catalog show --resolved --json

catalog list reads the root catalog declarations and marks which entries are referenced by root/member manifests. catalog show --resolved reads the resolved catalog snapshot in lpm.lock; if manifests reference a catalog entry that is missing from the lockfile snapshot, LPM CLI exits non-zero and asks you to run lpm install so the snapshot cannot look falsely clean.

For the full command reference, see lpm catalog.

lpm deploy

For Docker / serverless — materialize one member's deploy closure into a self-contained directory:

lpm deploy /prod/api --filter api               # build the deploy output
lpm deploy /prod/api --filter api --dry-run     # preview, no filesystem changes
lpm deploy /prod/api --filter api --force       # overwrite a non-empty output dir
lpm deploy /prod/api --filter api --no-optional # omit optionalDependencies
lpm deploy /prod/api --filter api --dev         # deploy devDependencies instead

Output dir contains:

  • The member's publishable source files, copied via hardlink when possible (zero disk cost on the same filesystem; falls back to byte-copy for cross-device)
  • Local workspace dependencies copied under .lpm/deploy-workspace/ and referenced with relative file: specs
  • A populated node_modules/ (install pipeline runs at the output dir)
  • A deploy-local .lpm/store/ and lpm.lock for the deploy tree

The source copy honors package publishing rules: files when present, otherwise .npmignore, otherwise .gitignore. It also applies a deny list at every level (not just the root). Excluded basenames:

  • LPM CLI internal state that the install pipeline recreates: node_modules, .lpm, lpm.lock, lpm.lockb
  • Secrets — every .env* variant: .env, .env.local, .env.development[.local], .env.production[.local], .env.test[.local]. Critical: a developer-only .env.local riding into a Docker image is a credential leak
  • Version control / packaging control files: .git, .gitignore, .npmignore, .gitattributes, .svn, .hg
  • OS / editor cruft: .DS_Store, Thumbs.db

In a Dockerfile:

FROM workspace as pruned
RUN lpm deploy /prod/api --filter api

FROM node:22-alpine
COPY --from=pruned /prod/api /app
WORKDIR /app
CMD ["node", "server.js"]

Constraints:

  • --filter or --filter-prod is required and must match exactly one member. --changed-files-ignore-pattern and --test-pattern apply when the filter contains a [git-ref] atom.
  • The output directory must be outside the workspace tree.
  • --prod is the default dependency mode. --dev deploys dev dependencies instead, and --no-optional omits optional dependencies from the manifest and resolver graph.

See also