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:
{ "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.)
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 installLPM 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 installUse --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 webThe 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.
| 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 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:
[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 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:
{
"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:
| 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 CLI 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 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 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 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.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 catalog— inspect catalog usage and resolved snapshotslpm 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