Work on a library and test it inside a real app, before either one is published.
lnpm is one Go binary for the loop where you edit a library and check it against
an app that depends on it. lnpm publish files a copy of the package in a store
on your machine. lnpm add puts that copy into the consuming project as a real
directory of files rather than a symlink into your working tree, so the app
resolves it much the way it will resolve the package off npm. lnpm push
updates every project you have linked. It works with npm, yarn, pnpm and bun, on
Linux, macOS and Windows.
If you use yalc today, this is the same workflow with a store you can list, tag and roll back through, and it is faster because there is no Node process in it.
Intel N150, 4 cores, Linux. A package of 101 files, 10 iterations per figure. Both tools are built and run as subprocesses, so each pays its own process startup. yalc 1.0.0-pre.53 on Node v24.18.1.
| operation | lnpm | yalc | speedup |
|---|---|---|---|
publish |
181.7ms | 1137.6ms | 6.3x |
add |
33.9ms | 398.5ms | 11.8x |
That is one run of scripts/bench-vs-yalc.sh,
reproduced with ITERATIONS=10 ./scripts/bench-vs-yalc.sh. Three runs on this
machine gave publish speedups of 5.1x, 6.3x and 7.9x, and add speedups of 11.8x,
16.6x and 18.4x. The absolute times moved a lot between runs, yalc's most of
all, so read the ratio and not the milliseconds, then run the script on your own
hardware.
Much of the gap is process startup. Here lnpm --version takes 10ms to 30ms and
node -e '' takes 230ms to 340ms. tests/bench_test.go also times yalc, but it
calls lnpm in-process while spawning yalc as a command, so its ratios come out
too high. ARCHITECTURE.md says which script
measures what.
./scripts/bench-vs-yalc.sh # lnpm against yalc, both as subprocesses
make bench # Go benchmarks, lnpm against itself
make bench-mem # The same, with allocation statsEarlier builds stay addressable, and a consumer can go back to one. The
store keeps a package's previous builds. lnpm list mylib --versions prints
them with their content hashes, and lnpm add mylib@9f8e7d6c rolls one project
back to the build that worked, with no republishing of an old source tree. The
pin holds until you move it. See Version history and rollback.
Dist-tags. lnpm publish --tag beta puts a build in the store without
moving latest, and lnpm add mylib@beta follows that channel while everyone
else stays on the stable release. lnpm tag moves a tag with no republish.
Reflink and hard links instead of a copy. Store to project uses copy-on-write cloning where the filesystem offers it (APFS, Btrfs, XFS), a hard link where it does not, and a parallel copy otherwise. Linked files arrive read-only, so a consumer cannot write through the link into the store.
The store is collectable. lnpm gc reclaims builds that no link and no tag
of yours reaches, with --dry-run and --older-than 30d. lnpm doctor checks
that the store is writable, that the database is intact and that no link is
orphaned, and --verify-content re-hashes every stored file.
Workspaces are handled on publish. lnpm publish --all publishes every
package of a workspace, and workspace: specifiers are resolved to real ranges
in the stored package.json while yours is left byte for byte as you wrote it.
- Smart linking — Automatically uses the fastest method: reflink → hardlink → parallel copy (store → project); reflink → parallel copy into the store
- Monorepo support — Publish all workspace packages at once
- Cross-platform — Works on Linux, macOS, and Windows
- All package managers — npm, yarn, pnpm, and bun
- Full visibility — Know exactly what's linked where
Coming from an older lnpm? See Upgrading first.
brew install pedrosousa13/tap/lnpmscoop bucket add pedrosousa13 https://github.com/pedrosousa13/scoop-bucket
scoop install lnpmUpgrade a Homebrew or Scoop install with that package manager, not with
lnpm update:
brew upgrade lnpm # not `lnpm update`
scoop update lnpm # not `lnpm update`
lnpm update detects a Homebrew or Scoop install and refuses, naming the
command above instead. Replacing the binary in place would leave the package
manager reporting the version it installed while the binary on PATH is a
newer one, and the next upgrade would overwrite the self-updated binary and put
you back on the recorded version. lnpm update --force replaces it anyway.
On macOS the cask clears the quarantine attribute on install, because the binaries are not signed or notarized and the first run would otherwise report lnpm as damaged. That gives up the Gatekeeper check on this binary.
curl -fsSL https://raw.githubusercontent.com/pedrosousa13/lnpm/main/install.sh | shirm https://raw.githubusercontent.com/pedrosousa13/lnpm/main/install.ps1 | iexInstalls to %LOCALAPPDATA%\lnpm. Override with $env:LNPM_INSTALL_DIR.
Note: On Windows, lnpm uses NTFS junction points for linking — no Developer Mode or admin privileges required.
go install github.com/pedrosousa13/lnpm/cmd/lnpm@latestgit clone https://github.com/pedrosousa13/lnpm.git
cd lnpm
make installUpgrading from 3.x to 4.0.0? Re-publish your packages once. 4.0.0 changes how a package's content hash is computed, so entries published by 3.x can no longer be served. Nothing is deleted and nothing is corrupted:
lnpm add,lnpm pullandlnpm restorerefuse the old entries and tell you which package to re-publish. Runlnpm publishin each of your library packages, thenlnpm gcto reclaim the old entries. Alnpm.lockcommitted to a repository is rewritten by that publish.lnpm doctorreports the pending ones as a warning, not an error. See ADR-0009.
On lnpm 1.9.x or older? Reinstall manually. Those versions compare versions byte-wise, so
lnpm updatereportsAlready up to dateand never upgrades you. Runlnpm --versionto check, then reinstall with one of the methods under Installation, or withgo install github.com/pedrosousa13/lnpm/cmd/lnpm@latest. 1.10.0 and later are unaffected.
# In your library package
cd ~/code/my-library
lnpm publish
# In your app that uses the library
cd ~/code/my-app
lnpm add my-library
# Make changes to my-library, then push updates
cd ~/code/my-library
lnpm push- Monorepo Guide — Turborepo, Nx, PNPM/NPM/Yarn workspaces integration
- Architecture — System design and implementation details
- Changelog — Version history and updates
- Releasing — How a release is cut, and the two steps that need a person
- Roadmap — What lnpm does today, and where planned work is tracked
- Contributing — Dev setup, the three test tiers, and what CI enforces
| Command | Description |
|---|---|
lnpm publish |
Publish package to local store (--dry-run lists what would be packed and writes nothing) |
lnpm add <pkg...> |
Add package(s) from store to project |
lnpm remove <pkg> |
Remove linked package |
lnpm pull [pkg...] |
Sync linked packages with the store (all of them when no name is given) |
lnpm push |
Push changes to all linked projects (--tag picks the channel) |
lnpm status |
Show all published packages and active links across every project |
lnpm list |
List this project's linked packages (--store lists the store, --projects lists consumers, --versions lists one package's history) |
lnpm tag <pkg> <tag> |
Point a dist-tag at a published package (--delete removes one) |
lnpm check |
Fail if lnpm has left anything an npm publish would ship (pre-publish guard) |
lnpm doctor |
Diagnose issues |
lnpm gc |
Garbage collect unused packages (--dry-run, --older-than 30d, --fix-links, --yes) |
lnpm forget <project-path> |
Drop the record of a project whose filesystem is gone for good, so lnpm gc can reclaim what it was holding (--yes) |
lnpm retreat |
Remove all lnpm changes |
lnpm restore |
Re-link the packages lnpm retreat removed |
lnpm config |
View/edit configuration |
lnpm update |
Update lnpm to the latest version |
lnpm completion |
Generate shell completions |
# Publish a package
lnpm publish
# Add to a project (supports multiple packages)
lnpm add my-package
lnpm add pkg-a pkg-b pkg-c
lnpm add my-package --link # Link to the live source instead of a store copy
# Push updates after making changes
lnpm pushBy default lnpm add copies the package's published snapshot into
.lnpm/{package}/, so the project only sees changes you deliberately
publish or push. With --link, .lnpm/{package} is instead a link to the
directory the package was published from, and package.json says
link:.lnpm/{package}:
lnpm add my-package --link
# Every edit in the source package is visible to this project immediately,
# with no publish, push or pull.The tradeoff is the isolation you give up: the project builds against whatever
is in the source tree right now, including files that have never been published
and are not even committed. Prefer the default when you want a reproducible
snapshot; use --link while you iterate. lnpm pull and lnpm push skip
live-linked packages rather than replacing them with a snapshot, and
lnpm remove / lnpm retreat delete only the link, never the source.
A publish moves the latest tag, and lnpm add my-package resolves through it.
--tag publishes to another channel instead, leaving latest where it is, so an
experimental build can sit in the store without reaching the projects that are on
the stable release:
# In the package directory
lnpm publish --tag beta
# In a project that wants the experimental build
lnpm add my-package@beta
# Everyone else is unaffected
lnpm add my-package # still the latest releaseWhat follows the @ is read as a tag first, then as an exact version, then as a
content-hash prefix — the last two are what roll a project back.
A project that added a package under a tag keeps following it: lnpm pull
refreshes it to whatever that tag now names, never to latest. Switching
channels is just another lnpm add — lnpm add my-package@beta in a project
already on latest moves it over, and dropping the tag moves it back — and so
does unpinning, which is the same command. None
of the three can be combined with --link, which resolves to the source
directory rather than to any published build.
lnpm push goes to the channel the build in the store already carries, so
pushing a pre-release keeps it a pre-release. An edit gives the tree content no
channel has ever named, and the version in package.json answers instead — a
push loop does not bump it, so the tags naming the stored build with that
version say which channel the tree is on. Bump past everything in the store and
push has nothing left to go on: it goes to latest and says so. Pass --tag to
say otherwise.
Tags can also be moved on a package already in the store, with no republish:
lnpm tag my-package beta # Point beta at the published version
lnpm tag my-package beta --delete # Remove the beta tag
lnpm list --store # Shows which tags name each stored versionlatest cannot be deleted — it is what every lookup by name resolves through.
It is also not a garbage-collection root: only a tag you set keeps a version
alive, because latest moves onto everything a publish writes and treating it
as a root would leave lnpm gc unable to reclaim anything.
Publishing keeps the version published before it, so a release that breaks one consumer can be undone for that consumer alone — no republishing an old source tree from a git checkout:
lnpm list mylib --versions
# mylib versions:
# HASH VERSION PUBLISHED TAGS LINKED IN
# -----------------------------------------------------------------------------
# a1b2c3d4 1.3.0 2 hours ago latest ~/apps/myapp
# 9f8e7d6c 1.2.0 3 days ago
cd ~/apps/myapp
lnpm add mylib@9f8e7d6c # Roll this project back to the build that workedEither identifier the listing prints works. What follows the @ is matched as a
dist-tag first, then as an exact version against every retained version, then as
a content-hash prefix of at least four characters. The eight characters the
listing shows are enough; type more of them if that ever names two builds. A spec
that names two builds is refused with their full hashes rather than resolved to
one of them.
The history is the set lnpm gc has not collected: a version stays for as long
as a link or a tag you set reaches it. A version a project has rolled back to is
linked, so gc keeps it — while the versions in between, which nothing reaches
any more, are reclaimed.
A rollback pins the link, and nothing but you moves it off. Naming a build — either identifier — says follow this build, where naming a channel says follow this channel, so a pinned project stays where you put it:
lnpm pull # Refreshes everything else; leaves mylib pinned and says so
lnpm pull mylib # Refuses: mylib is pinned, and names the way out
lnpm publish # In the package: moves latest, does not drag the pin along
lnpm gc # Keeps the pinned build, with no time limit
lnpm status # Shows the pin next to the linked package
lnpm add mylib # Unpin: follow latest againA pin has no expiry. lnpm gc keeps the build for as long as the pin names it,
so reclaiming that space takes two deliberate steps — unpin, then collect —
exactly as it does for a version you tagged. lnpm retreat and lnpm restore
carry the pin, which travels in lnpm.lock; the field is optional, and a lock
file written before it existed reads as unpinned.
lnpm add mylib@beta does not pin, because a tag is a channel and following one
means being carried along it.
lnpm integrates seamlessly with Turborepo, Nx, and all workspace managers:
# lnpm is installed globally (one-time system install)
# See installation section above
# Publish all workspace packages (from the workspace root)
lnpm publish --all
# Push updates for one package (from that package's own directory)
cd packages/ui
lnpm pushworkspace: specifiers are resolved on publish. A sibling dependency written
with the workspace: protocol means nothing outside the workspace it came from,
so lnpm resolves it in the stored package.json — your own package.json is left
byte-for-byte as you wrote it. All four dependency maps are covered:
dependencies, devDependencies, peerDependencies and optionalDependencies.
| Specifier | Stored as (sibling at 2.3.0) |
|---|---|
workspace:* |
2.3.0 |
workspace:latest |
2.3.0 |
workspace:^ |
^2.3.0 |
workspace:~ |
~2.3.0 |
workspace:^1.2.3, workspace:1.2.3, any explicit range |
the range itself: ^1.2.3, 1.2.3, … |
Publishing fails instead if the sibling is not a package of the workspace, if the
package is not in a workspace at all, or on a bare workspace: — shipping the
literal specifier would only break the consumer's npm install later.
📖 See MONOREPO.md for complete guides on:
- Turborepo integration
- Nx integration
- PNPM/NPM/Yarn workspaces
- Best practices and troubleshooting
# Remove all lnpm links and restore original dependencies
lnpm retreat --force
npm install # Restore original packages
# Guard: fails (non-zero) if any lnpm reference is still in package.json
lnpm check
npm publishlnpm check reports what lnpm has left in the project that an npm publish
would carry into the tarball, and exits non-zero if there is any — drop it in a
prepublishOnly script or CI step. It looks for two things: leftover
file:.lnpm/ or link:.lnpm/ references in package.json, and the
lnpm.lock.retreat snapshot described below, when nothing in the project would
keep that snapshot out of the tarball.
In a monorepo the reference scan covers the workspace root's package.json and
every workspace package's, not only the manifest where you ran it, and each
finding names the package it came from. A workspace whose member list will not
resolve — a malformed pattern in the workspaces field, a member manifest that
will not read or parse, a member with no name — fails the check rather than
passing on the manifest at hand alone.
# Re-link everything the retreat removed
lnpm restorelnpm retreat --force saves what it unlinked to lnpm.lock.retreat, and
lnpm restore links it all back. Packages added again in the meantime are kept
as they are; nothing is unlinked. Packages restored this way are store copies
(file:.lnpm/<pkg>) — re-run lnpm add --link <pkg> for the ones you want
pointed back at their live source. A second lnpm retreat --force merges into
lnpm.lock.retreat rather than replacing it, so an unfinished restore is not
lost.
lnpm.lock.retreat is lnpm's own state, and it records an absolute source path
for every package it lists. lnpm publish never packs it. npm publish knows
nothing about it, so keep it out of your tarball the way you keep any other file
out — a files field in package.json, or a line in .npmignore or
.gitignore. lnpm check fails if the snapshot is there and none of those
would stop it.
The snapshot records no --dev or --pure flag, so a package that had no
package.json entry before the retreat cannot get the same treatment back.
Restore writes it into dependencies and says so. For a --pure package that
entry is spurious — --pure exists to keep a package out of package.json —
so delete it after the restore.
Quick setup (recommended):
lnpm completion installThis auto-detects your shell and installs completions. Follow the on-screen instructions to enable.
Alternative - Dynamic loading (add to shell config):
# Zsh (~/.zshrc)
eval "$(lnpm completion zsh)"
# Bash (~/.bashrc)
eval "$(lnpm completion bash)"
# Fish (~/.config/fish/config.fish)
lnpm completion fish | sourceManual installation:
# Zsh
lnpm completion zsh > ~/.zsh/completions/_lnpm
# Bash
lnpm completion bash > /etc/bash_completion.d/lnpm
# Fish
lnpm completion fish > ~/.config/fish/completions/lnpm.fishConfiguration file: ~/.lnpm/config.yaml
# Custom store location (default: ~/.lnpm)
store_path: /path/to/store
# Link mode: "hardlink" (try reflink/hardlink) or "copy" (force copy)
# Default: hardlink
link_mode: hardlink
# Auto-manage .gitignore (add/remove .lnpm/ entry)
# Default: true
manage_gitignore: true
# Work through a node_modules — or a node_modules/@scope — that is not a real
# directory. Off by default: lnpm creates directories and deletes entries
# there, and a symlink committed to a repository aims both at whatever it
# points at. Turn it on only where the relocation is yours.
#
# It is named for the symlink because that is the case it exists for, but it
# switches the whole check off: a regular file or a device at either path is
# accepted too, exactly as it was before the check existed.
# Default: false
follow_symlinked_node_modules: false
# Build scripts and hooks
hooks:
# Skip prepare scripts (prepare, prepublishOnly, prepack) on publish/push
skip_prepare: false # Default: false (run scripts)
# Custom hooks (optional)
pre_publish: npm run build # Runs before publish/push packs files
post_publish: echo done # Runs after publish/push completes
# Runs only when `lnpm add --install` is used; add does NOT install by default
post_add: npm installView/modify config:
lnpm config # Show all
lnpm config store_path # Get value
lnpm config store_path /data # Set value
lnpm config --path # Show config file path
lnpm config --edit # Open config file in $EDITOREnvironment overrides: LNPM_STORE (store location, wins over store_path), LNPM_CONFIG (config file path), LNPM_DEBUG (debug logging).
lnpm automatically runs your package's build (prepare) scripts before publishing so linked output is up to date. Dependency installation after add is opt-in via --install (see below).
Before packing, lnpm runs every one of these scripts that your package.json defines, always in this order:
prepublishOnly—publishonly, as with npmprepack— Runs before packing filesprepare— General build script
lnpm publish runs all three, the set npm publish runs. lnpm push runs prepack and prepare, the set npm pack runs: a push is a pack, so a build or a slow gate kept in prepublishOnly stays out of the push loop.
If a script fails, the ones after it do not run and the command stops.
The order is npm's own, so a package that builds under npm builds the same way under lnpm. prepack before prepare matters when one reads what the other wrote.
Example package.json:
{
"name": "my-library",
"scripts": {
"build": "tsc",
"prepare": "npm run build"
}
}Commands:
lnpm publish # Runs prepublishOnly, prepack, prepare, then publishes
lnpm push # Runs prepack, prepare, then pushes
lnpm publish --skip-hooks # Skip prepare scripts
lnpm push --skip-hooks # Re-push what is on disk without rebuildingBy default, lnpm add does NOT run npm install (matching yalc). Pass --install to have lnpm run your package manager after adding, to resolve peer dependencies and install the linked package's dependencies.
lnpm add my-package # Adds package, no install (default)
lnpm add my-package --install # Adds package, then runs npm installlnpm remove and lnpm retreat take the same flag, and default the same way. An install runs every dependency's install scripts, so none of these commands starts one unless you ask for it.
lnpm remove my-package # Removes package, no install (default)
lnpm remove my-package --install # Removes package, then runs npm installlnpm validates packages before publishing:
- Checks
package.jsonhasnameandversion - Verifies
mainentry point exists (if declared)
lnpm publish # With validation
lnpm publish --skip-validation # Skip validation (for broken packages)# In your TypeScript library
cd ~/code/my-library
# package.json has "prepare": "tsc"
lnpm publish # Automatically builds TypeScript → dist/
# In your app
cd ~/code/my-app
lnpm add my-library # Links only (pass --install to also run npm install)
# Make changes to library
cd ~/code/my-library
# Edit src/index.ts
lnpm push # Rebuilds and updates all linked projectsHusky/git hooks fail during npm install:
- lnpm strips
prepareandprepublishscripts from stored packages (like yalc) - If you see husky errors, re-publish the package:
lnpm publish
Duplicate React or other peer dependency issues:
- Don't use
--installflag; runnpm installmanually with your preferred flags - Or configure npm:
npm config set legacy-peer-deps true
npm ERESOLVE peer dependency errors:
- npm has a bug where
file:dependencies show as@undefined(npm/cli#2199) - When using
--install, lnpm uses--legacy-peer-depsautomatically - Or run
npm install --legacy-peer-depsmanually
Retreat restores wrong version:
- Fixed in latest version - lnpm now properly tracks original versions
- If stuck, manually edit package.json and delete lnpm.lock
Enable debug mode for verbose logging:
# Flag
lnpm --debug publish
lnpm -d status
# Environment variable
LNPM_DEBUG=1 lnpm publishDebug output goes to stderr with timestamps, useful for diagnosing slow operations or unexpected behavior.
- Publish — Selects files with lnpm's own filtering (see File Filtering), strips lifecycle scripts (
prepare/prepublish), resolvesworkspace:dependency specifiers to versions npm can install (see Monorepo Support), then reflinks or copies to~/.lnpm/store/{name}/{hash}/ - Add — Creates reflinks or hard links from store to
project/.lnpm/{package}/, updates package.json tofile:.lnpm/{package} - Symlink — Links
node_modules/{package}→.lnpm/{package} - Push — Updates store and re-links all files to consuming projects
- Auto .gitignore — Optionally manages
.lnpm/in.gitignore(enabled by default)
Note: lnpm add does NOT run npm install automatically (matches yalc) — pass --install or run it yourself if you need to resolve peer dependencies. lnpm remove and lnpm retreat behave the same way: pass --install to have the removed dependency reinstalled.
Source Package Store Project
────────────── ───── ───────
src/index.ts ──► (reflink/copy)
dist/index.js ──► ~/.lnpm/store/ ══► .lnpm/pkg/ ──► node_modules/pkg
package.json ──► pkg/abc123/ (reflink/hardlink) (symlink)
lnpm decides which files to publish in Go — it does not shell out to the npm CLI. The rules are closely modeled on npm's conventions:
- Respects
package.jsonfilesfield. A file it selects is published whatever the ignore files say, as it is for npm — a.gitignoreholdingdist/out of version control does not stopfiles: ["dist"]publishing the build output - A
filesentry globs with the same engine ignore patterns use, so**means one thing across the whole tool:*matches within one path segment and**spans zero or more of them.files: ["lib/**/*.js"]selectslib/top.jsas well aslib/sub/a.js, and a bare["**"]selects every path in the package. Entries are matched against the full path and never against a basename, as npm's are —["guide.md"]selects nothing when the file isdocs/guide.md. A leading/or./and a trailing/are normalized away, a run of/at either end included, so["dist"],["/dist"],["dist/"],["/dist/"],["./dist"],["//dist"],["dist//"]and["//dist//"]all mean the same thing; a trailing/on a glob is not a directory marker and is left alone, so["dist/**/"]selects nothing, which is what npm does with it - An entry whose last segment is a bare wildcard expands every directory it matches into that directory's whole subtree, as npm does:
["dist/*"]selectsdist/a.js,dist/cli/index.jsanddist/cli/deep/z.js, and a bare["*"]selects the entire tree rather than only the root's own files. The reach belongs to the last segment and to nothing else, so the rule is narrower than "an entry that matches a directory expands it" —["d*"]and["dist/c*"]each select nothing but the always-included set, thoughd*matches the directorydistanddist/c*matchesdist/cli. A globbed prefix is fine, so["di*/*"]expands thedistsubtree; the expansion never reaches a root file, so["*/*"]selectslib/keep.jsand the wholedistsubtree and leaves a rootindex.jsout. All six entries were run against npm 11.16.0 withnpm pack --dry-run --jsonon one fixture package holding exactly the five files named above, and lnpm's packed set came back identical to npm's for every one, so this is parity rather than an approximation of it. Sweeping a directory in is still not naming anything in it, so["*"]reaches adist/.envand does not publish it, for the same reason["dist"]does not - Honors a
.npmignorein every directory, falling back per directory to a.gitignorewhere there is no.npmignorebeside it. An ignore file governs everything below it — it cannot exclude the directory holding it, just as asrc/.gitignorecannot excludesrc— and its patterns are resolved against that directory —/lib/gen.jsinsrc/.npmignorenamessrc/lib/gen.js, notlib/gen.jsat the package root. They decide every path thefilesfield does not select, including the always-included set below — the two exceptions being the package root's ownpackage.json, which nothing can exclude, and the file named bymain, which afilesfield force-includes past them - Where two ignore files both match a path, the deeper one has the last word, as it does in git: a root
*.txtplus!keep.txtinsrc/.npmignorepublishessrc/keep.txt - Supports gitignore pattern syntax — anchoring to the ignore file's own directory (
/dist), directory patterns (dist/) and negation (*.txtfollowed by!keep.txt), with the last matching pattern deciding.*matches within one path segment and**spans zero or more of them, so**/*.pemexcludes a key at any depth including the package root, andsrc/**/*.keyexcludes one at any depth undersrc - Both matchers additionally expand brace alternation (
*.{pem,key}), which git does not and npm's minimatch does. The trade is that{and}are no longer literal characters in a pattern:weird{a,b}.txtmatchesweirda.txtandweirdb.txtrather than the file literally namedweird{a,b}.txt. On the ignore side that direction fails open, and the escape hatch is a pattern spelling out the full path (/src/weird{a,b}.txt), which is compared as a string before any globbing; a bare basename pattern no longer reaches it. On thefilesside the same escape hatch means a literal-brace entry still selects its file, since afilesentry is only ever compared against the full path — sofiles: ["weird{a,b}.txt"]selectsweirda.txt,weirdb.txtand the literal file.docs/adr/0003records both directions - Applies two built-in exclusion lists, which differ in whether you can override them.
- Never published, by any route. VCS metadata (
.git,.hg,.svn,CVS), git's own metadata files (.gitignore,.gitattributes,.gitmodules),.npmrc,node_modules,.DS_Store,*.orig, lockfiles (package-lock.json,yarn.lock,pnpm-lock.yaml,bun.lockb), and lnpm and yalc state (.lnpm/,.yalc/,lnpm.lock,lnpm.lock.retreat,yalc.lock). Nothing re-includes these — not a!pattern, not afilesentry, notmain. Naming one infiles, or negating it in a root ignore file, prints a warning saying so rather than dropping it in silence. The membership rule follows npm's "cannot be included even if specified in the files globs" list, plus the lnpm and yalc state files, which record absolute paths from the machine that published, plus git's own metadata files, which lnpm has always stripped from a finished tarball and now refuses out loud. It is not identical to npm's: lnpm is stricter on.hg,.svn,CVS,.DS_Storeand*.orig, which npm ignores by default but will include for afilesglob, and stricter again on.gitignore,.gitattributesand.gitmodules, which are on neither npm list at all. On the lockfiles what matches npm is the membership: all four names are on npm's short list, so neither tool publishes one for afilesglob. lnpm holds them back at any depth and in every mode; npm's own list is a list of names and does not say how far it reaches into subdirectories, so read the parity as being about which names are unpublishable rather than about matching behaviour path for path. The reason for the entry is that a lockfile records the URL each dependency resolved from, and so publishes a private registry's hostname along with the names and versions of everything private in it. One lockfile is deliberately not on the list: Bun 1.2 and later write a textbun.lockbeside — or instead of — the binarybun.lockb, npm's short list names onlybun.lockb, and lnpm follows npm's membership rather than extending it. So abun.lockpublishes like any ordinary file, at the package root and nested, even though lnpm does recognise the name when it works out which package manager a project uses - Excluded unless you say otherwise.
.npmignore,Thumbs.db, editor backups (*.swp,*.swo,*~),*.log,*.tgz, and exactly.envand.env.*. Afilesentry naming the path, or an!negation in an ignore file, publishes it — so"files": [".env.example"]ships the template, which is what the list is for. Which of the two applies depends on the package. With afilesfield your ignore files stop being consulted for the paths the whitelist selects — that is the same rule that letsfilesbeat a.gitignore— so"files": ["dist"]plus!dist/.envstill keepsdist/.envout, and naming the path infilesis the way in. The one exception is a file that is also in the always-included set below: a rootreadme.logorREADME.md~still goes through your ignore rules even under afilesfield, so!readme.logpublishes it. The entry has to name the path:"files": ["dist"]does not publishdist/.env,dist/app.logordist/pkg.tgz, because naming a build directory is not a statement about what landed inside it. The same holds one level up —"files": ["src"]does not publishsrc/.env/config, since a directory this list covers keeps its contents out too. What counts as naming is a glob's last segment:"files": ["dist/.env"],["dist/*.env"]and[".env.*"]all publish, because each says something about the name, while["dist/*"],["dist/**"]and a bare["*"]do not, because a lone wildcard sweeps a directory rather than naming anything in it — the same reason[""]and["."]do not. Paths the list does not cover are unaffected —dist/a.jsships for"files": ["dist"]as it always has.mainis not a third way in: naming a path inmainsays where the entry point is, not that you meant to publish a.env, so amainthis list covers loses to it and you get a warning instead (docs/adr/0004) — unlessfilesalso names that path directly, in which case thefilesentry is the consent and it ships..npmignoreis the only ignore file on this list:.gitignore,.gitattributesand.gitmodulesare on the never-published list above, so naming one infileswarns instead of publishing it. Publishing an.npmignoreis harmless — it describes what was left out, not a secret — so that one really does publish when you name it - Both lists are literal, not prefixes:
.envrcmatches neither.envnor.env.*, so it is published. Both match case-insensitively on every platform, so.ENVandNode_Modules/are held back exactly as their lowercase forms are
- Never published, by any route. VCS metadata (
- Additional safety: Explicit
.gitfiltering prevents any VCS files from being linked - Symlinks are skipped, never followed — a symlink inside a package can't pull files from outside it (e.g.
~/.ssh) into the store - Package names are restricted to a plain name (
my-pkg) or a single scope (@org/my-pkg), because the name is joined into the store path. Everything else is rejected: a second/, a single/whose first segment is not a scope,.or..segments, absolute paths, backslashes and NUL bytes
Because the filtering is lnpm's own, some cases differ from npm publish:
- The exclusion lists are not npm's. lnpm additionally excludes
.env/.env.*, logs and*.tgzby default. Those additions are the overridable kind, so a manifest naming one directly infiles, or negating it in an ignore file, still publishes it — a divergence in both directions, since npm never ignores.envand so publishesdist/.envfor"files": ["dist"]where lnpm does not..npmignoreis on that same overridable list and is not one of the additions, which was measured rather than assumed: against npm 11.16.0, a package with nofilesfield leaves.npmignoreout of the tarball under both tools, and"files": [".npmignore"]publishes it under both. Git's own metadata files —.gitignore,.gitattributesand.gitmodules— are the deliberate exception: they are on lnpm's never-published list, where nothing overrides them, and they are on neither of npm's two lists, sonpm publishships a.gitignorefor"files": [".gitignore"]— measured against npm 11.16.0, which packs all three when they are named — andlnpm publishwarns and leaves it out. The same npm runs settle the no-filescase, where the three do not behave alike: npm packs.gitattributesand.gitmodulesand drops the.gitignoreit read as ignore rules, while lnpm holds back all three. The reason is that a.gitsafety pass has always stripped all three from the finished tarball whatever the selection rules decided, so the overridable tier was a claim the code did not honour; naming the tier the code enforces is what makes the refusal something lnpm can tell you about. The lockfiles (package-lock.json,yarn.lock,pnpm-lock.yaml,bun.lockb) are not a divergence in membership: all four are on npm's "cannot be included even if specified in the files globs" list, and lnpm carries all four on the never-overridable list above, so neither tool publishes one for afilesglob. That is a statement about which names are unpublishable, not a claim that the two match path for path — the list above says what the quote does and does not settle. Bun 1.2+'sbun.lockis the exception: neither of npm's lists names it, so lnpm's does not either, and anlnpm publishships it.lnpm.lockandyalc.lockare lnpm's and yalc's own state rather than package-manager lockfiles, and are on that list for their own reason — they record absolute paths from the machine that published - Case sensitivity splits by who wrote the rule. Both built-in lists fold case on every platform, because they are guards rather than preferences and
.ENVis the same file as.envon macOS and Windows; patterns you write in a.npmignoreor.gitignorenever fold, sosecret.txtthere does not excludeSECRET.TXT. git splits it the other way round — it folds ignore matching whenevercore.ignorecaseis set, andgit initandgit cloneprobe the filesystem and set it on a case-insensitive one, which is what macOS and Windows default to. So on those two platforms a pattern that hides a file from git may not withhold it from anlnpm publish:secret.txtin a.npmignoreleavesSECRET.TXTin the tarball. That direction is fail-open, and deliberate: lnpm has no per-projectcore.ignorecaseto condition on - With no
filesfield, an excluded directory is pruned during the walk, so a negation can't reach back into it:dist/plus!dist/keep.jspublishes neither file. npm diverges in the opposite direction — the negation defeats thedist/rule entirely and npm publishes the whole directory,dist/other.jsincluded. Spelleddist/*instead, the negation does what it looks like in both. Adist/.npmignorecannot re-include anything out of an excludeddisteither, and that part does not depend on thefilesfield: git never descends into an ignored directory, so an ignore file inside one is never consulted - A
filesentry with a literal{,}or[in it still selects the file of that name, because the entry is compared against the full path as a string before any globbing. npm has no such escape and reads only the expanded form:["weird{a,b}.txt"]selectsweirda.txt,weirdb.txtand the literalweird{a,b}.txthere, where npm ships only the first two. The subtree form inverts npm outright —["weird{a,b}/**"]selects the literal directory's contents and notweirda/'s, and npm does the reverse - The always-included set differs in what is in it: lnpm adds
CHANGELOG,CHANGESandHISTORY, and does not automatically include the files named bybin. Those three additions are matched by name rather than as prefix globs — each is accepted with no extension or with.md,.markdown,.txtor.rst, and nothing else, so a roothistory.dbis left tofilesand your ignore rules like any other path. npm's ownREADME*,LICENSE*andLICENCE*stay prefix globs here because they are prefix globs for npm too, which does mean a rootreadme.dbships past afileswhitelist on both — measured against npm 11.16.0, which under"files": ["dist"]packs a rootreadme.dband leaves a roothistory.dbout. Two entries beat your own.npmignoreand.gitignoreas well as thefileswhitelist; everything else in the set is exempt from the whitelist only. The package root's ownpackage.jsoncannot be excluded by anything — no ignore pattern and nofilesfield drops it, and a pack that somehow ends up without it fails rather than publishing a package no consumer can resolve;docs/adr/0005records the reasoning and the conflict withdocs/adr/0001that it accepts. The file named bymainis included as it is for npm, and it also beats your ignore files, but it still loses to both built-in exclusion lists, only ever selects the one path it names, and does nothing at all in a package with nofilesfield;docs/adr/0004records the reasoning and the conflict withdocs/adr/0001that it accepts. Both exemptions are anchored at the package root, so a nestedsub/package.jsonis an ordinary file your patterns still govern. Where the set applies is not a divergence: it is matched at the package root only, as npm's is, so a rootREADME.mdships whateverfilessays while adocs/README.mdis left tofilesand the ignore rules like any other path
lnpm automatically manages .gitignore entries to prevent committing linked packages (enabled by default, configurable via manage_gitignore: false):
- On
lnpm add— Adds.lnpm/to.gitignorewith marker comment# Added by lnpm - On
lnpm retreat— Removes.lnpm/entry and marker from.gitignore - Smart detection — Skips if pattern already exists
- Atomic writes — Uses temp file + rename to prevent corruption
- Configurable — Set
manage_gitignore: falsein config to disable
lnpm uses an intelligent priority system for maximum performance:
Priority Order:
- Source → store: Reflink → Parallel Copy
- Store → project: Reflink → Hard Link → Parallel Copy
The store never hard links your source files. A hard link would make the source file and the store entry share one inode, so rewriting the source in place (tsc, webpack, an editor saving over its own output) would silently mutate content already filed under its content hash. The store keeps a private copy instead.
| Method | Speed | Platform Support | Disk Usage |
|---|---|---|---|
| Reflink (CoW) | Instant | macOS APFS, Linux Btrfs/XFS | Zero (copy-on-write) |
| Hard Link | Instant | Same filesystem, store → project only | Zero (shared inodes) |
| Parallel Copy | Fast | Always works | Full copy (8 workers) |
Benefits:
- ⚡ 10,000+ files? Instant with reflink or hard links
- 🔄 Copy-on-write — Modify files safely without affecting store
- 🚀 Parallel copying — 4-8x faster when copying is needed
- 💾 Space efficient — No duplication on modern filesystems
lnpm automatically detects the best method and falls back gracefully with helpful warnings.
- Linux — amd64, arm64
- macOS — amd64 (Intel), arm64 (Apple Silicon)
- Windows — amd64, arm64
- Go 1.26+ (for building from source)
- Node.js project with
package.json
CONTRIBUTING.md has the toolchain, the Makefile targets, the three test tiers and what each one proves, the conventional-commit title rule and why a release depends on it, and when a change needs an ADR.
MIT License — see LICENSE for details.
Note: This project was built with assistance from Claude (Anthropic's AI assistant). The architecture, implementation, and documentation were developed through an AI-assisted development process. While the code has been reviewed and tested, users should evaluate it according to their own requirements and standards before use in production environments.