TL;DR — Developing an npm package and testing it inside a host project usually means a slow loop of draft releases, or fighting with
npm link. I built kley: a small local package manager written in Rust. A local registry, safe-by-default installs — two core commandspublishandinstall.Who this is for: if you maintain JS/TS libraries, juggle several Node.js versions with
nvm, and are tired of publishing throwaway versions just to test them — this is for you.
The problem: the draft-release loop
Modern JS/TS development is, to a large extent, juggling npm packages. Need an HTTP call? axios. Environment variables? dotenv. Unique IDs? uuid. And splitting your own code into packages has become the norm — for reuse, for separation of concerns, for sharing across teams.
Things get harder when you don't just use a library but develop one. Sometimes you can work on it in isolation. But often you can't — when the library is tightly coupled to its runtime context, when it's an SDK, or when test coverage isn't enough to trust on its own.
So we fall back on a familiar ritual: cut a draft/pre-release version, push it, wait for CI, publish to the remote registry, then pull and install it in the host project to see if it actually works. Then change one line and do it all again.
💡 I always validate a library in at least one real consuming project before a stable release, even when tests pass. Front-end and back-end builds almost always involve transpilation and bundling — code that's correct in tests can behave differently after the host project's build.
This loop inflates TTM (time to market), burns CI minutes, and when you're cutting many drafts, the overhead multiplies. Push → CI → publish → download → install. Round and round.
"Just use npm link" — except it barely holds up
There's a built-in answer: npm link. It symlinks your library into the host project, no remote publishing needed. Great — when it fits. But it's narrow:
-
It's tied to one Node.js version. With
nvm, the link is created for the current node version's global modules. If your host project runs Node 18 and the library was linked under Node 20 — the link is simply invisible. Worse,npm linkdoesn't error when this happens. -
Linking multiple packages clobbers earlier links.
npm link Breinstalls all other deps and quietly drops your existing link toA. - Duplicate dependencies. The library resolves its own deps from its own folder — so if both the app and the library use React, you can end up with two Reacts in the bundle.
- Easy to footgun — e.g. accidentally linking a global package because of a typo in the name.
npm unlinkis its own special hell.
And any npm install after linking wipes the link, so you re-link again.
"Then npm install <path>" — better, still not it
Installing from a local path is closer, but:
-
It installs all the library's dependencies, including
devDependencies. - It can't really run offline — even with zero deps, npm still hits the network to validate.
- Since npm 5 it also creates symlinks — but they point to the library's *source directory*, so you get its
node_modulesand source files nested in your project, causing the same duplication problem asnpm link. - It runs pre/post-install scripts by default — the exact mechanism behind a lot of supply-chain security incidents.
- The command is clunky (an absolute local path that differs per machine), so you can't easily script it for a whole team.
The dependency-duplication trap
This one deserves its own spotlight, because it's the subtlest failure. If my-lib has its own node_modules (say, after you ran npm install inside it), linking it gives you:
my-app/node_modules/react ← used by my-app
/path/to/my-lib/node_modules/react ← used by my-lib (its own copy)
Two independent copies of the same library at runtime. For stateless utilities — lodash, axios, zod — harmless. But for anything stateful or using instanceof:
- React – "Invalid hook call", because hooks are bound to one specific React instance.
- React Router, Redux – context doesn't cross the two instances.
- Any singleton – two objects where you needed one.
And the deepest issue: neither npm link nor npm install <path> reproduce the package as it will look once published to npm. They wire a direct connection to your source directory — source files and the package's own deps included. Sometimes useful, but if your goal is to eliminate draft releases, you want to get as close as possible to the published artifact.
What I actually wanted: a tiny local registry
Strip the problem down: the pain is slow draft releases through a remote registry. The obvious fix is to use a local one during development. A heavily simplified one — for most work, two commands are enough: publish to the local store, and install from it.
This already exists — yalc. It's a local repository for your in-progress packages, and it's genuinely good. I used it for years and recommended it to colleagues.
But two things bother me about it:
-
It's written in TypeScript, so it runs on Node.js. I work across a dozen Node projects — some on 18, some on 24 — switching with
nvm. Install yalc under Node 20, publish, switch the host project to Node 18 — and yalc is just gone. Reinstall it. For every Node version. That's the recurring tax of CLIs built on the runtime they operate on. -
yalc linkonly works for packages without dependencies. It symlinks to the copy in.yalc/(a published-shape copy, i.e. withoutnode_modules), so a library with its own dependencies can't resolve them.
What is kley
So: why not make a yalc-style tool that fixes the parts that bug me. A plain binary with no Node.js dependency, and an even simpler, more explicit API (in yalc you have to run npm i manually after add).
That's kley — a local package manager that doesn't depend on Node.js directly. A tool that glues local packages and projects together.
It's written in Rust. The ecosystem has everything the job needs out of the box: filesystem work, JSON handling, spawning processes, cross-platform compilation. Binaries for Linux, Windows, and macOS are built with cargo-dist.
If you want a follow-up on the Rust implementation details — say so in the comments and I'll write it up next.
Install
# Linux / macOS
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/sumbad/kley/releases/latest/download/kley-installer.sh | sh
# Windows
powershell -ExecutionPolicy Bypass -c "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; irm https://github.com/sumbad/kley/releases/latest/download/kley-installer.ps1 | iex"
You can also install via npm (
kley-cli), but I wouldn't recommend it unless you stick to a single Node version — that reintroduces the very problem we're solving.
Using kley
If you've used yalc, the concept will feel familiar. Two common scenarios.
Scenario 1 — stable development: publish → install
The main, most reliable flow. You're actively developing a library and testing it through a project on the same machine.
- In the library dir —
kley publish: copies files to~/.kley/packages/<name>. - In your project dir —
kley install <name>: copies files to.kley/, updateskley.lock, and runs the native package manager to install intonode_modules/. - Change code in the library, then
kley publish --push: updates every linked project.
✅ After step 2, just
kley publish --push— no repeatednpm install.⚠️ This modifies
package.json. Before committing, restore the real npm version, or the dependency list will look "broken" to CI and teammates.
Scenario 2 — quick edits: publish → link
For fast, temporary edits when you don't want to touch package.json. It creates a direct symlink from your project's node_modules to the library's source. Fastest feedback, but — like npm link — less durable.
- In the library dir —
kley publish. - In your project dir —
kley link <name>: symlinksnode_modules/<name>directly to the library source — nonpm installneeded. - Edit the library: the project sees changes instantly.
⚠️ Running
npm installdeletes the symlink. Restore it instantly withkley install(restores everything fromkley.lock) orkley link <name>again.
The link insight: best of yalc and npm link
This is the part I'm most happy with. There are two ways a tool can wire a link:
-
yalc links to the
.yalc/copy — published-shape, nonode_modules→ breaks for packages with dependencies. -
npm linklinks to the source directory → dependencies resolve, but it has none of yalc's registry, restore, or multi-link conveniences.
kley takes the best of both: from yalc, a local registry with metadata, link restoration, and multiple simultaneous links without conflicts; from npm link, a symlink pointing at the library's source directory — so kley link works for packages with dependencies too, resolving them from the library's own node_modules.
The honest trade-off: because it links to source (like npm link), it inherits the singleton-duplication issue — if the library resolves React from its own node_modules, you can get two Reacts. kley detects this and warns you, suggesting kley install instead. And kley link deliberately does not reproduce the "as-published" environment — you see the library in its dev state (source files, local deps). That's a conscious trade for live-development speed. When you need fidelity to the published artifact, that's kley install/add, not link.
A few things worth knowing
-
Safe by default.
kleyruns the package manager with--ignore-scripts, sopreinstall/install/postinstallfrom the installed package don't run. Less risk of arbitrary code execution on a local install. Native modules that genuinely need scripts: run the PM manually. -
Fast paths. If a package's
dependencies/peerDependencieshaven't changed since last install,kleyskips the package manager and copies files straight tonode_modules/<pkg>. For dependency-free packages it goes further and symlinks directly. Iterativepublish → installgets noticeably faster. -
Auto-detects the package manager (npm / pnpm / yarn) from lockfiles or the
packageManagerfield inpackage.json. -
Reproduces published output.
kley publishrespectsfilesinpackage.json,.npmignore, and.kleyignore— only files that would end up in the real npm package are copied. -
kley.locktracks installed packages, versions, and link type (install/link). Runkley installwith no args to restore everything aftergit pullor on a new machine. -
--no-save.kley install --no-save <name>installs intonode_modules/and updateskley.lockwithout touchingpackage.json. yalc has no direct equivalent — its "don't touch package.json" islink, which (as above) doesn't pull dependencies (the feature was requested but never implemented — yalc issue #256). kley gives you both at once. -
Strips
devDependencieswhen copying a package into your project, keepingnode_moduleslean. The original in the registry is untouched.
Version control
Treat .kley/ and kley.lock following two-mode approach:
- For temporary local development, add both to
.gitignoreand usekley linkorkley install --no-saveto avoid touchingpackage.json. - For shared WIP packages that are part of the project's codebase, keep them under version control to ensure consistency across the team, swapping to remote-registry versions when the package stabilises.
More commands
Beyond publish, install, and link, kley provides kley add (copy to .kley/ and update package.json without running the PM), kley update (refresh from registry), kley remove (unlink a dependency), and kley unpublish (remove from the central store). See the README for the full reference.
Comparison
Performance
Benchmarked on a realistic fixture: a library with files: ["dist"], devDependencies, and compiled output; a host app with package-lock.json. For yalc tools, cold start includes yalc add + npm install — to reach the same final node_modules state as kley install.
To be honest, there are now some forks of yalc, we will use @jimsheen/yalc for comparison. It's a drop-in replacement with better performance and proper pnpm/workspaces support.
| Operation | kley |
yalc |
@jimsheen/yalc |
|---|---|---|---|
| Cold start (publish → install) | ~9 ms | ~390 ms | ~437 ms |
| Iteration (publish --push) | ~7 ms | ~106 ms | ~125 ms |
On a MacBook M4 Pro, kley is ~45× faster than yalc on the cold start and ~16× faster on iteration. The gap comes from startup cost: every yalc invocation spins up the Node.js runtime; kley is a native binary.
Features
A subjective overview — its point is to show kley's motivation, not to crown a winner.
npm link |
npm install |
yalc |
@jimsheen/yalc |
kley |
|
|---|---|---|---|---|---|
| Project & library on different Node.js versions | ❌ | ✅ | ❌ | ❌ | ✅ |
| Reproduces the published package | ❌ | ❌ | ✅ | ✅ | ✅ |
| Works for libraries with dependencies | ✅ | ✅ | ❌ | ⚠️ | ✅ |
| Multiple local dependencies at once | ❌ | ✅ | ✅ | ✅ | ✅ |
| Monorepo / workspaces | ❌ | ⚠️ | ✅ | ✅ | ⚠️ |
The takeaways: if you live on Node and need workspaces, the maintained fork is a fine choice. kley's edge is being a single self-contained binary independent of Node.js, with safe-by-default installs and a link that handles dependencies.
kley is early. Today it covers the core workflow well, but the roadmap is real work, not a victory lap:
- Workspaces / monorepo support is partial — if that's central to you, the fork above is currently stronger.
-
No watch mode yet — updates are semi-manual via
publish --push.
Planned: deeper pnpm/yarn/workspace support, watch mode, pre/post hooks, registry-validation commands.
Try it
If you've ever fought the draft-release loop or npm link, give kley a shot and tell me how it goes. Questions and ideas welcome in GitHub issues.
Resources:




Top comments (0)