Workspaces
Manage related packages with shared installation, local dependencies, filters, and deployment output.
A workspace groups packages under one root. Each member keeps its own dependencies, scripts, and version. LPM CLI shares the root lockfile and local package links.
# Install the workspace from its root
lpm install
# Add a dependency to one member
lpm install zod --filter web
# Preview the members that depend on shared
lpm filter '...shared'
# Run tests for changed members and their dependents
lpm run test --affected --base origin/mainRegistry dependencies can come from npm, LPM.dev Registry, or private registries. Workspace dependencies use local packages.
Commands inside workspace hooks
Workspace lifecycle hooks cannot start another command that needs the same install lock. The child command fails with a parent-installation conflict.
Run these commands after installation. Commands that need no install lock, such as lpm --help, remain available inside hooks.
Declare members
{
"name": "my-workspace",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}The object form is also supported:
{
"workspaces": {
"packages": ["packages/*", "apps/*"]
}
}Each matching directory must contain a package.json. Use * for one path segment and ** for recursive patterns.
LPM CLI combines patterns from package.json > workspaces and pnpm-workspace.yaml > packages. Duplicate patterns are processed once.
packages:
- packages/*
- apps/*A missing YAML file is allowed. An invalid or oversized file fails workspace discovery. Root manifests, member manifests, and the YAML file each have a 16 MiB limit.
Release and version scans also bound pattern counts, directory entries, and path storage. See project size limits.
lpm.json does not declare workspace members. It can define tasks for those members:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}Each member defines its own tasks. Members do not inherit root task definitions. ^build requires upstream members to finish their build tasks first. Each upstream member must define that task.
lpm run build --filter webInstall and change dependencies
At the workspace root, lpm install refreshes the members and the root. Inside a member, a bare install stays local to that member.
# Refresh the whole workspace from a member
lpm install --recursive
# Refresh only the current project
lpm install --no-recursive
# Refresh selected members and their required local dependencies
lpm install --recursive --filter web
lpm install --recursive --filter-prod web
# Add a dependency to a member or the root
lpm install zod --filter web
lpm install typescript --save-dev -w
# Remove a dependency from a member
lpm uninstall zod --filter webFiltered refreshes include the required workspace dependency closure. They exclude unrelated members and the root. --filter-prod excludes development edges from that selection.
--recursive refreshes existing manifests. It does not add package arguments to every member.
For mutations across multiple members, interactive terminals request confirmation. --yes, JSON mode, and non-interactive input skip that prompt.
Workspace installations use the isolated layout by default. See dependency layouts for overrides and visibility rules.
Workspace protocol
{
"name": "web",
"dependencies": {
"@my-co/shared": "workspace:*"
}
}A workspace: reference requires a matching local package. The provider can be a member or the named package at the workspace root.
| Specification | Requirement |
|---|---|
workspace:* | Any local version. |
workspace:^ | Use the local version with caret semantics. |
workspace:~ | Use the local version with tilde semantics. |
workspace:^1.2.0 | The local version must satisfy ^1.2.0. |
These specifications remain unchanged under save policy.
A workspace: self-reference in production, optional, or peer dependencies fails. A dev-only self-reference is excluded from installation.
Matching local packages can also satisfy compatible ordinary version ranges, including transitive registry dependencies. A nonmatching version requires another candidate.
Shared lockfile
The root lpm.lock records the workspace graph and a separate dependency view for each importer. Member commands use their own view.
A successful recursive install absorbs legacy member lockfiles into the root lockfile and removes obsolete member companions. Workspace lockfiles are TOML-only.
Manifest and lockfile changes commit after the selected installation succeeds. A failure restores those files. Changes inside node_modules can remain until the next install repairs them.
After a root catalog or override change, refresh the workspace and commit the root manifest with its lockfile:
lpm installFor CI, use a frozen install:
lpm install --frozen-lockfileSee lockfile behavior for frozen checks, migration, and recovery.
Filter grammar
Filters select member names or paths. Multiple positive expressions select their union. Each filter group requires a positive expression before exclusions can apply.
| Expression | Selected members |
|---|---|
web | The exact package name. |
@scope/* | Names under that scope. |
./apps/* | Paths that match the glob. |
{./apps/web} | The exact path. |
@scope/*{./apps/web} | Both the name and path conditions. |
[origin/main] | Members with files changed since that Git reference. |
web... | web and its dependencies. |
web^... | Dependencies of web, excluding web. |
...web | web and its dependents. |
...^web | Dependents of web, excluding web. |
!web | Exclude web from that filter group. |
An exact name does not perform substring matching. To select @babel/core by its suffix, use */core.
lpm filter 'web...' --explain
lpm filter --filter-prod '...shared' --json
lpm filter web --fail-if-no-match--filter-prod follows dependencies, optional dependencies, and peer dependencies. It excludes development edges.
The regular filter group, production filter group, and --affected group are evaluated separately and then combined. An exclusion in one group does not remove members selected by another group.
Select changed packages
lpm filter '[origin/main]'
lpm filter '...[origin/main]'
lpm run test --affected --base origin/main --fail-if-no-match[ref] selects directly changed members. ...[ref] and --affected also include dependents. The default affected base is main.
Git paths are relative to the workspace root, including workspaces nested inside a larger repository. Changes outside that workspace do not select its members.
A file move selects both its former member and its new member. A changed root file outside any member selects every member. The comparison includes tracked changes against the base reference. It does not include untracked files.
A missing or invalid reference fails the command. If CI has a shallow clone, fetch the base reference before selection.
lpm run test --affected --base origin/main \
--changed-files-ignore-pattern '**/README.md' \
--test-pattern '**/*.test.js'Ignore patterns remove paths before selection. Test patterns keep the changed member but do not add dependents for changes that contain only tests.
Set shared patterns in the root configuration:
[workspace]
changed-files-ignore-pattern = ["**/README.md", "docs/**"]
test-pattern = ["**/*.test.js", "**/*.spec.ts"]CLI patterns append to these defaults. There is no one-run flag that clears the project patterns.
Commands and selection options
| Command | Selection options |
|---|---|
lpm install | Filters, -w, recursive refresh, and --fail-if-no-match. |
lpm uninstall | Filters, -w, and --fail-if-no-match. |
lpm run, lpm test, lpm bench, lpm lint, lpm fmt, lpm check | Filters, --all, --affected, --base, and --fail-if-no-match. |
lpm filter | Positional expressions, production filters, --explain, and --fail-if-no-match. |
lpm deploy | A regular or production filter that selects exactly one member. |
--all cannot combine with filters or --affected. Git ignore and test patterns apply to commands with Git-based selection.
Catalogs
{
"catalogs": {
"default": { "react": "^19.0.0" },
"testing": { "vitest": "^3.0.0" }
}
}{
"dependencies": { "react": "catalog:" },
"devDependencies": { "vitest": "catalog:testing" }
}Members use the root catalog declarations. After a catalog change, refresh the workspace to update the lockfile.
lpm catalog list --unused
lpm catalog show --resolved --jsonSee lpm catalog for YAML declarations and cleanup. See save policy for automatic conversion.
lpm deploy
lpm deploy /prod/api --filter api --dry-run
lpm deploy /prod/api --filter api
lpm deploy /prod/api --filter api --no-optional
lpm deploy /prod/api --filter api --devlpm deploy copies a member's publishable files and its selected workspace: sources. It then installs the output's dependencies. The output directory must neither contain the workspace nor sit inside it.
The default selects production and optional dependencies. --dev selects the member's development dependencies instead. Copied providers still use their production dependencies.
--no-optional omits optional dependencies, including duplicate names in regular dependencies. An optional declaration takes precedence over a regular declaration.
Selected dependencies declared with workspace: go under .lpm/deploy-workspace/. Their copied manifests use relative file: references. This includes named root providers and peer-only local references. The output's .lpm/ also holds its package store, so its .gitignore lets Git commit store/, deploy-workspace/, and the .gitignore itself, so a clone keeps them committed, and ignores the rest, which belongs to the machine; later commands in the output, and in a clone of it, keep it that way, whatever line endings a checkout gives the file. Copy or commit .lpm/ with the rest of the output. A .gitignore that came with a dependency, as an install script or a source other than a registry can leave one, still keeps Git from committing the files it names, whatever the output's .lpm/.gitignore says; the deploy lists each one (JSON dependency_ignore_files) with the git add -f command that commits what they hide. The output also gets a .gitignore of its own, which only takes files back: Git commits each top-level file and directory the deploy wrote, every directory in those directories, and all of node_modules/, whatever the .gitignore of a repository around the output or a global excludes file says, such as a monorepo's node_modules/, dist/, or .lpm/ rule. Their rules for files, such as *.log, still apply inside those directories, and all of their rules apply to a file or top-level entry you add to the output later, such as an .env file, and to a top-level name with a line break, which no .gitignore line can hold. A rule that ignores the output directory itself keeps Git out of all of it. In the output's repository, lpm doctor --all names what Git ignores of the dependencies (deploy_output_dependencies_ignored) with the command that commits it. An install in the output, such as lpm install there, links its dependencies from your LPM home's store by absolute path instead; deploy again rather than installing in the output.
Catalog references use the root catalog declarations. The copied manifests contain the declared catalog specifications.
Existing file:, link:, and portal: paths are not relocated. Compatible ordinary version ranges do not copy local workspace providers through this mechanism.
A copied peer-only source is not an automatically installed root dependency. The consuming project must supply its required peers.
Each copied source receives the files that lpm publish would pack: its files list, otherwise each directory's .npmignore, otherwise its .gitignore, plus package.json, README and license files, and entrypoints. Paths that publication never packs, such as lpm-changes/ and lpm-sources.json, stay out, and so does lpm-skills/, whose skills a publish adds for the registry. A package's lpm.json is copied as publication ships it, even when files doesn't list it: without vault and vaultSync, and with an envSchema that extends other files resolved into the file.
The deploy then withholds these names at every directory depth, matching them as publication does, regardless of letter case and of the characters HFS+ and Linux casefold directories leave out of names, including explicit files entries:
- Every name that starts with
.env, including secret directories and example files. - Every credential file that publication matches, such as
.npmrc,.netrc,_netrc, SSH private keys, andcredentials.json. node_modules,.lpm,lpm.lock, andlpm.lockb..git, under any spelling Git reads as one, such as.git.orgit~1,.gitignore,.npmignore,.gitattributes,.svn, and.hg. The output's top-level.gitignoreis the deploy's own, described above..DS_StoreandThumbs.db.
An entrypoint that names a file by a spelling its directory doesn't list, such as ENV-CM~1 for .env-cmdrc or INDEX.JS for index.js, stops the deploy, as it stops a publish. When a copied workspace: source stops it, the error names that package.
Publication stops when files selects a credential file; a deploy leaves the file out instead. JSON files_skipped counts the files that publication's selection includes and the deploy withholds; files publication leaves out on its own, such as .npmrc or an .env file that files doesn't name, aren't counted.
Every other file the deploy copies, the member's and each source's, including the lpm.json it writes, receives publication's secret scan. A secret it finds stops the deploy before it writes anything, and stops a dry run too. A file replaced or written after the scan read it stops the deploy as it is copied, naming the file, and the output keeps no copy of it. The report names each package and its directory, the file and line, and what was found; JSON output reports "error": "secret_scan_failed" with one matches entry for each, holding package, directory, path, pattern, description, line, and severity. Remove the secret, or leave its file out of the copy with files or .npmignore. --allow-secrets skips the scan for one invocation.
The output includes its own store and lockfile. Unix installations make internal node_modules links relative.
Source files are copied, as clones where the file system makes them, so the output shares no file with the workspace: editing one never changes the other, and the output keeps the bytes the secret scan read.
Source symbolic links and junctions are not copied, as publication does not pack them. If the application needs a linked file, include the file itself.
The deploy selects every file it copies, the member's and each source's, before it writes anything, so a deploy that stops there leaves the output directory as it was, even with --force. --force deletes and recreates the output directory, a .git directory in it included; an output path that names a file is refused, with or without it. An output that holds the LPM home, sits inside it, or holds your home directory is refused, with or without --force: also where an NFS mount of the same server shows them, by the server directory its source names, such as an automounted view of the export holding your home, and on Linux where a bind mount shows them under another path, as a toolbox container's /run/host/home does. So is an output in a git directory, whose hooks and config git runs: a .git under any name Git reads as one, a bare repository, or the directory a .git file points at. On macOS and Linux, so is an output on a path another user can change: a directory on the way, or the output itself, whose entries someone other than you and root may rename or replace, such as one anyone may write without the sticky bit /tmp has, or one shared with a group that has other members. They could swap what the path leads to while the deploy writes there. The deploy creates an output that doesn't exist, and its missing parents, writable only by you, and stops if the output is replaced while it runs. So is a mount point with --force, such as a disk's or a bind mount's, which it couldn't delete: empty it and deploy without --force, or deploy into a directory inside it. On macOS and Linux, a directory that holds a mount point is refused with --force too, as the deletion would stop at the mount point with part of the output deleted: it never enters another volume, so one mounted there after the check stops it there too. On a Linux kernel before 5.8, telling a mount point needs /proc; without it --force stops before deleting anything. Windows removes a folder a volume is mounted on as the link it is. On macOS and Linux, a deploy that can't read the system's list of mounts stops before writing anything. Links in the output are removed, not followed. A dry run writes no files but makes the same selections, so it stops where the deploy would. JSON output contains one deployment result.
lpm deploy /prod/api --filter api --json