LPM CLI

Monorepo setup

Workspaces, --filter grammar, --affected, lpm deploy.

LPM CLI treats workspaces as a first-class concept — declare them in package.json, then every command that operates on the dep graph (install, uninstall, run, lint, fmt, check) accepts --filter, --all, and --affected to scope work. This guide covers a real monorepo from scratch.

1. Declare workspaces

package.json
{
  "name": "my-repo",
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

Or the object form (yarn-style):

{ "workspaces": { "packages": ["packages/*", "apps/*"] } }

LPM CLI accepts both. Add member packages under those globs — each member has its own package.json. Run:

lpm install

LPM CLI walks the workspace, resolves eligible importers together, projects the shared result back into isolated member graphs, and links every member's deps (including cross-member workspace:* references) into the right node_modules/.

The result is one authoritative lpm.lock at the workspace root. Commit that file; do not create or keep one lockfile per member. Each importer in the root lockfile points at its own content-addressed package closure, so member-local commands still see only that member's graph. Workspace lockfiles are TOML-only and do not have an lpm.lockb companion.

No flag is required at the workspace root: bare lpm install installs every member in dependency order and the root last. From inside a member it remains member-local; use lpm install --recursive to widen to the owning workspace, or lpm install --no-recursive at the root when you intentionally want only the root project.

The first successful recursive install after upgrading migrates legacy member lockfiles into the root union and removes the obsolete member lpm.lock / lpm.lockb files. The root lockfile is committed only after every selected target succeeds, so a failed migration leaves the previous lockfiles intact.

The workspace root can also be a named local package. A member may depend on that root package with workspace:*; LPM CLI resolves and links it locally just like a member-to-member edge.

2. Add a dep to one member

lpm install react --filter web

Adds react to packages/web/package.json and runs the install pipeline scoped to packages/web/. The other members are untouched.

The command updates packages/web's importer projection in the root lockfile. Manifest and lockfile replacement are atomic as a workspace mutation: if the install fails, LPM CLI restores the prior manifest and lockfile.

For a tooling dep that lives at the workspace root:

lpm install -D typescript -w

-w / --workspace-root writes to the root package.json > devDependencies instead of any member.

3. The filter grammar

--filter is the most-used flag in monorepo workflows. --filter-prod accepts the same grammar but skips devDependencies during closure expansion. Both are available across install, uninstall, run, lint, fmt, check, deploy, and the standalone lpm filter preview command.

FilterMatches
webThe member literally named web
@scope/*Every member with a name under a scope
@scope/*{./apps/web}Members matching both the 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, but excluding web itself (deps-only)
...webweb and every member that depends on web (transitive dependents — upstream closure)
...^webSame, but excluding web itself (dependents-only)
!webExclusion — remove web from the resulting set

Filters compose: --filter web --filter api unions the two sets. --filter web --filter '!web-tests' selects web minus web-tests.

Use --filter-prod ...shared for production-only dependent closures: members that reach shared only through devDependencies are not selected.

Substring matching is not supported. --filter core does NOT match @babel/core. Write --filter '*/core' for that.

Preview what a filter expression would select without running anything:

lpm filter web...
lpm filter --filter-prod ...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)

4. --affected for CI

lpm run test --affected

Selects only members affected by changes vs the base branch (default main). Pair with --base develop to change the branch:

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

Saves CI minutes — a one-line README change in apps/marketing doesn't trigger a full-monorepo test run.

Persist noisy git-diff paths and test-only globs in lpm.toml when every CI run should use them:

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

--test-pattern keeps the directly changed package selected, but it stops test-only changes from expanding to every dependent package.

--fail-if-no-match makes a typo'd filter exit non-zero. Recommended in CI:

lpm run test --filter web --fail-if-no-match

5. Cross-member deps

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

workspace:* tells the resolver "use the local workspace member, not a published version." LPM CLI links the two via symlink (in isolated layout) and respects topological order in tasks (dependsOn: ["^build"] waits for upstream member's build task to finish first).

6. Deploy builds with lpm deploy

For Docker / serverless deploys, you don't want to ship the entire monorepo. lpm deploy materializes one member's deploy closure into a self-contained directory:

lpm deploy /prod/api --filter api
lpm deploy /prod/api --filter api --no-optional
lpm deploy /prod/api --filter api --dev

The output dir has:

  • The targeted member's publishable source files (honors files, .npmignore, then .gitignore, while still excluding .env*, node_modules, .git, etc.)
  • Local workspace dependencies copied under .lpm/deploy-workspace/ and referenced with relative file: specs
  • A populated node_modules/
  • A deploy-local .lpm/store/ and lpm.lock for the deploy tree

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.
  • The output directory must be outside the workspace tree.
  • --prod is the default dependency mode. Use --dev for a dev-dependency deploy tree, and --no-optional to omit optional dependencies.

Common pitfalls

  • --filter core doesn't match @babel/core. Substring matching is not supported. Use --filter '*/core'.
  • Multi-member install prompts you for confirmation by default. If a --filter expression would mutate more than one member's package.json, lpm install asks before writing. Use -y in scripts.
  • Keep only the root lpm.lock. A successful recursive install removes migrated member lockfiles. lpm.lockb is not written for a workspace union.
  • lpm deploy targets one member. If your filter matches more than one workspace package, narrow it before deploying.

See also