DEV Community

Cover image for Local package manager
sumbad
sumbad

Posted on

Local package manager

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 commands publish and install.

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.

Diagram showing the slow draft-release loop: code push → CI → npm publish → download → npm install

"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:

  1. 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 link doesn't error when this happens.
  2. Linking multiple packages clobbers earlier links. npm link B reinstalls all other deps and quietly drops your existing link to A.
  3. 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.
  4. Easy to footgun — e.g. accidentally linking a global package because of a typo in the name.
  5. npm unlink is 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_modules and source files nested in your project, causing the same duplication problem as npm 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)
Enter fullscreen mode Exit fullscreen mode

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.

Diagram showing the fast local release flow: kley publish → kley install, no remote registry involvedarticles/v8nyyprq92v5i7wnrl08.png

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:

  1. 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.
  2. yalc link only works for packages without dependencies. It symlinks to the copy in .yalc/ (a published-shape copy, i.e. without node_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
Enter fullscreen mode Exit fullscreen mode
# Windows
powershell -ExecutionPolicy Bypass -c "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; irm https://github.com/sumbad/kley/releases/latest/download/kley-installer.ps1 | iex"
Enter fullscreen mode Exit fullscreen mode

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.

  1. In the library dir — kley publish: copies files to ~/.kley/packages/<name>.
  2. In your project dir — kley install <name>: copies files to .kley/, updates kley.lock, and runs the native package manager to install into node_modules/.
  3. Change code in the library, then kley publish --push: updates every linked project.

Sequence diagram of the kley publish → kley install workflow with publish --push iteration

✅ After step 2, just kley publish --push — no repeated npm 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.

  1. In the library dir — kley publish.
  2. In your project dir — kley link <name>: symlinks node_modules/<name> directly to the library source — no npm install needed.
  3. Edit the library: the project sees changes instantly.

Sequence diagram of the kley publish → kley link workflow, showing direct symlink to library source

⚠️ Running npm install deletes the symlink. Restore it instantly with kley install (restores everything from kley.lock) or kley 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, no node_modules → breaks for packages with dependencies.
  • npm link links 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. kley runs the package manager with --ignore-scripts, so preinstall/install/postinstall from 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/peerDependencies haven't changed since last install, kley skips the package manager and copies files straight to node_modules/<pkg>. For dependency-free packages it goes further and symlinks directly. Iterative publish → install gets noticeably faster.
  • Auto-detects the package manager (npm / pnpm / yarn) from lockfiles or the packageManager field in package.json.
  • Reproduces published output. kley publish respects files in package.json, .npmignore, and .kleyignore — only files that would end up in the real npm package are copied.
  • kley.lock tracks installed packages, versions, and link type (install/link). Run kley install with no args to restore everything after git pull or on a new machine.
  • --no-save. kley install --no-save <name> installs into node_modules/ and updates kley.lock without touching package.json. yalc has no direct equivalent — its "don't touch package.json" is link, which (as above) doesn't pull dependencies (the feature was requested but never implemented — yalc issue #256). kley gives you both at once.
  • Strips devDependencies when copying a package into your project, keeping node_modules lean. 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 .gitignore and use kley link or kley install --no-save to avoid touching package.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)