# Workspaces (/docs/packages/workspaces)



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](/docs/guides/monorepo-setup).

## Declaring workspaces [#declaring-workspaces]

Two equivalent shapes — both accepted in `package.json`:

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

```json title="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](/docs/guides/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](/docs/project-setup#local-configuration-size-limits).

## Filter grammar [#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 &#x2A;*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 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:

```bash
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` [#--affected]

```bash
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:

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

## `--fail-if-no-match` [#--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:

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

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

## Multi-member confirmation [#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 [#workspace-protocol]

Cross-member deps:

```json title="packages/web/package.json"
{
  "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 CLI 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 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](/docs/packages/save-policy) — they never get rewritten.

## Catalogs [#catalogs]

Centralize version pins across members:

```json title="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:

```json title="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:

```bash
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`](/docs/packages/catalog).

## `lpm deploy` [#lpm-deploy]

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

```bash
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:

```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 [#see-also]

* [Monorepo setup guide](/docs/guides/monorepo-setup) — step-by-step walkthrough
* [`lpm catalog`](/docs/packages/catalog) — inspect catalog usage and resolved snapshots
* [`lpm install --filter`](/docs/packages/install#workspaces) — install scoped to members
* [`lpm uninstall --filter`](/docs/packages/uninstall#workspaces) — remove scoped to members
* [`lpm run --filter --affected`](/docs/dev/run#workspaces) — task running with workspace selection
* [`lpm filter`](#filter-grammar) — preview a filter expression with `--explain` / `--json`
* [Save policy](/docs/packages/save-policy) — `workspace:*` is preserved verbatim
