Workspaces
Monorepo support — workspace declaration, filter grammar, workspace:* protocol, catalogs, deploy.
LPM 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:
{ "workspaces": ["packages/*", "apps/*"] }{ "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.)
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.
| Filter | Matches |
|---|---|
web | The 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) |
...web | web and every member that depends on web (transitive dependents — upstream closure) |
...^web | Same, excluding web itself (dependents-only) |
!web | Exclusion — 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 coredoes NOT match@babel/core— write--filter '*/core'for that. This is the most common surprise for users coming frompnpm.
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 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:
[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-matchAvailable 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 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:
{
"dependencies": {
"@my-co/shared": "workspace:*"
}
}Tells lpm install "use the local workspace member, not a published version." Workspace deps are extracted before dependency resolution and linked from disk afterward — they never touch the registry. The linker symlinks the member 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).
Workspace 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 member, LPM resolves that edge to the local member instead of leaking the lookup to the registry.
Self-dependencies are invalid: a workspace member that declares itself via workspace:* in dependencies, devDependencies, peerDependencies, or optionalDependencies fails before LPM writes lockfiles or node_modules.
Variants:
| Spec | Meaning |
|---|---|
"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:
{
"catalogs": {
"default": { "react": "^18.2.0", "react-dom": "^18.2.0" },
"testing": { "jest": "^29.0.0", "vitest": "^1.0.0" }
}
}Members reference catalog versions:
{
"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 preserves unused catalog entries.
Inspect catalog state with:
lpm catalog list --unused
lpm catalog show --resolved --jsoncatalog 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 exits non-zero and asks you to run lpm install so the snapshot cannot look falsely clean.
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 insteadOutput 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 relativefile:specs - A populated
node_modules/(install pipeline runs at the output dir) - A deploy-local
.lpm/store/andlpm.lockfor 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 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.localriding 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:
--filteror--filter-prodis required and must match exactly one member.--changed-files-ignore-patternand--test-patternapply when the filter contains a[git-ref]atom.- The output directory must be outside the workspace tree.
--prodis the default dependency mode.--devdeploys dev dependencies instead, and--no-optionalomits optional dependencies from the manifest and resolver graph.
See also
- Monorepo setup guide — step-by-step walkthrough
lpm install --filter— install scoped to memberslpm uninstall --filter— remove scoped to memberslpm run --filter --affected— task running with workspace selectionlpm filter— preview a filter expression with--explain/--json- Save policy —
workspace:*is preserved verbatim