The complete solution for transferring pnpm dependencies to air-gapped environments.
Getting pnpm projects into secure, offline, or air-gapped environments is challenging:
- No tooling for pnpm lockfiles - Existing airgap tools only work with npm's package-lock.json
- Complex dependency trees - pnpm's advanced resolution (peer deps, optionals, workspaces) makes manual approaches nearly impossible
- Registry population gap - No automated way to populate offline registries with pnpm project dependencies
pnpm-airgap is a standalone tool that:
- Reads
pnpm-lock.yamland downloads ALL dependencies - Publishes packages to any npm-compatible registry (Verdaccio, Nexus, Artifactory)
- Works as a single file - no
npm installrequired in airgap - Supports pnpm lockfile versions 5.x, 6.x, and 9.x
The CLI is a single file (~1.1MB) that runs with just Node.js:
# From npm (online)
npm pack pnpm-airgap
tar -xzf pnpm-airgap-*.tgz
# Use: node package/dist/cli.cjs
# Or build from source
pnpm install && pnpm build
# Use: node dist/cli.cjsnode cli.cjs fetch -l ./pnpm-lock.yaml -o ./packagesCopy the packages folder and cli.cjs to your air-gapped environment.
# Start your registry (e.g., Verdaccio)
verdaccio &
# Login
npm login --registry http://localhost:4873
# Publish all packages
node cli.cjs publish -p ./packages -r http://localhost:4873pnpm install --registry http://localhost:4873Run without arguments for a guided wizard:
node cli.cjs┌───────────────────────────────────────┐
│ pnpm-airgap v2.4.0 │
│ Transfer dependencies to air-gapped │
│ environments with ease │
└───────────────────────────────────────┘
? What would you like to do?
❯ 📦 Fetch dependencies from lockfile
📤 Publish packages to registry
🔄 Sync registries
📊 Export registry state
📖 Quick start guide
✖ Exit
node cli.cjs fetch [options]
Options:
-l, --lockfile <path> Path to pnpm-lock.yaml (default: ./pnpm-lock.yaml)
-o, --output <path> Output directory (default: ./airgap-packages)
-r, --registry <url> Source registry (default: https://registry.npmjs.org)
--registry-state <path> Registry state file for incremental fetching
--skip-optional Skip optional dependencies
--concurrency <number> Parallel downloads (default: 5)
--debug Enable debug outputnode cli.cjs publish [options]
Options:
-p, --packages <path> Packages directory (default: ./airgap-packages)
-r, --registry <url> Target registry (default: http://localhost:4873)
--concurrency <number> Parallel publishes (default: 3)
--no-skip-existing Publish all packages even if they exist
--dry-run Preview without publishing
--debug Enable debug outputPublishing to a private registry has subtle failure modes — publish is hardened against them:
- Authenticated skip-existing. The existence pre-check now authenticates exactly like
npm publishdoes, covering every scheme npm supports —_authToken(bearer),_auth, andusername+_password(basic, which is what a default Verdacciohtpasswdsetup writes) — plusNPM_TOKEN/NODE_AUTH_TOKEN. Credentials are read from the raw.npmrcfiles in npm precedence order (project.npmrc, then--userconfig, globalconfig,~/.npmrc), because npm 9+ protects auth keys:npm config get "//host/:_authToken"errors instead of returning the value. Previously, on an auth-gated registry the probe got401, every package looked "uncertain", and re-runs silently re-published everything. Now an already-populated registry is correctly skipped — re-publish is a true no-op, not a reflush. - Per-package-name serialization. Versions of the same package are published sequentially
(different packages still run in parallel up to
--concurrency). Verdaccio's manifest update is a non-atomic read-modify-write; publishing two versions of one package concurrently can race so that a tarball lands but its manifest entry is lost — an orphan (409on re-publish,404on fetch). Serializing per name removes that race at the source. - Orphan detection, not silent skip. A
409 Conflictis verified against the manifest: if the version is really there, it's an idempotent skip; if the tarball exists but the version is absent from the manifest, it's reported as an ORPHAN error (non-zero exit) with the exact storage path — never masked as success. Orphans can only be cleared by deleting the stray.tgzfrom the registry host's storage and re-publishing (no HTTP API can remove a manifest-absent tarball), so run a periodic orphan sweep on the registry host if multiple machines publish concurrently.
node cli.cjs sync [options]
Options:
-s, --source <url> Source registry URL
-d, --dest <url> Destination registry URL
-o, --output <path> Output directory
--scope <scope> Only sync packages in this scope
--download-only Only download, don't publish
--publish-only Only publish existing packages
--dry-run Preview without changesExport all packages from a registry to enable incremental fetching:
node cli.cjs registry-state export -r http://localhost:4873 -o registry-state.json
# Then use with fetch to skip existing packages
node cli.cjs fetch -l pnpm-lock.yaml --registry-state registry-state.jsonPrivate registries (Verdaccio, etc.) have no garbage collection - every published
version stays forever, so a long-lived airgap registry bloats with old versions no project
installs anymore. prune trims a registry down to the union of your consumer lockfiles:
any version not referenced by a lockfile is safe to remove.
# Dry-run (default): show what would be removed, delete nothing
node cli.cjs prune -l ./pnpm-lock.yaml -r http://localhost:4873
# Multiple consumers: keep-set is the UNION of all their lockfiles
node cli.cjs prune -l repoA/pnpm-lock.yaml -l repoB/pnpm-lock.yaml -r http://localhost:4873
# Execute the removals
node cli.cjs prune -l ./pnpm-lock.yaml -r http://localhost:4873 --yes
Options:
-l, --lockfile <paths...> One or more pnpm-lock.yaml paths (union = keep-set)
-r, --registry <url> Registry URL
--prune-orphans Also remove whole packages absent from every lockfile
--keep <names...> Package names protected from orphan removal
--concurrency <number> Parallel unpublishes (default: 5)
--yes Execute removals (default is dry-run)
--debug List every version in the planSafety: prune works at version granularity, never package granularity - it only removes
stale versions of packages your lockfile references. Packages absent from every lockfile (e.g.
tooling you published deliberately) are left completely untouched unless you opt in with
--prune-orphans. Removals use npm unpublish, so the registry trims its own manifest + tarball;
no on-disk surgery. Anything pruned reappears the next time you publish a lockfile that needs it.
Why not "keep newest N versions"? Because a transitive dependency can pin an exact old version - "keep newest N" would delete it and break offline
--frozen-lockfileinstalls. The lockfile union always keeps exactly what every install needs, no more, no less.
Tip: run prune as the second half of a sync - publish the new closure first (adds current
versions), then prune to the same lockfile (removes what dropped out). Never prune below the
lockfile your target environment actually runs.
node cli.cjs info ./packagesnode cli.cjs initCreate pnpm-airgap.config.json:
{
"fetch": {
"lockfilePath": "./pnpm-lock.yaml",
"outputDir": "./airgap-packages",
"concurrency": 5,
"registryUrl": "https://registry.npmjs.org",
"skipOptional": false
},
"publish": {
"packagesDir": "./airgap-packages",
"registryUrl": "http://localhost:4873",
"concurrency": 3,
"skipExisting": true
},
"sync": {
"sourceRegistry": "",
"destRegistry": "http://localhost:4873",
"outputDir": "./sync-packages",
"skipExisting": true
}
}| Feature | Description |
|---|---|
| Standalone Binary | Single 1.1MB file, runs with just Node.js - no npm install needed |
| Interactive Mode | Guided wizard for all commands |
| Auto-detection | Finds lockfiles and package directories automatically |
| Incremental Sync | Export registry state to skip already-synced packages |
| Storage Prune | Trim a registry to the union of consumer lockfiles (reclaims GBs) |
| Smart Tagging | Auto-detects prerelease tags, handles version conflicts |
| Safety Blocks | Prevents accidental publish to public registries (npmjs.org) |
| Rate Limiting | Automatic backoff for 429 errors |
| Robust Parsing | Handles scoped packages, aliases, patches, peer deps |
Online Machine:
# Fetch all dependencies
node cli.cjs fetch -l pnpm-lock.yaml -o ./packages
# Create transfer archive
tar -czf transfer.tar.gz packages/ cli.cjsOffline Machine:
# Extract
tar -xzf transfer.tar.gz
# Start registry and login
verdaccio &
npm login --registry http://localhost:4873
# Publish
node cli.cjs publish -p ./packages -r http://localhost:4873
# Install your project
echo "registry=http://localhost:4873" > .npmrc
pnpm installAvoid re-downloading packages that already exist:
# Export state from airgap registry
node cli.cjs registry-state export -r http://verdaccio:4873 -o state.json
# Transfer state.json to online machine
# Fetch only missing packages
node cli.cjs fetch -l pnpm-lock.yaml --registry-state state.json -o ./packages
# Result: If lockfile needs 500 packages but 450 exist, only 50 are downloadedA no-uplink registry grows forever. Reclaim space by pruning to what you actually install:
# Preview (safe) - see how much is stale
node cli.cjs prune -l pnpm-lock.yaml -r http://verdaccio:4873
# Reclaim - publish current closure, then prune to the same lockfile
node cli.cjs publish -p ./packages -r http://verdaccio:4873
node cli.cjs prune -l pnpm-lock.yaml -r http://verdaccio:4873 --yes
# Result: registry == exactly your lockfile closure; stale versions goneimport { fetchDependencies, publishPackages } from 'pnpm-airgap';
// Fetch
await fetchDependencies({
lockfilePath: './pnpm-lock.yaml',
outputDir: './packages',
registryUrl: 'https://registry.npmjs.org',
concurrency: 5,
});
// Publish
await publishPackages({
packagesDir: './packages',
registryUrl: 'http://localhost:4873',
concurrency: 3,
skipExisting: true,
});| Component | Supported Versions |
|---|---|
| Node.js | 18.0.0 or higher |
| pnpm lockfile | v5, v6, v9 |
| Registries | Verdaccio, Nexus, Artifactory, any npm-compatible |
| Platforms | Windows, Linux, macOS |
Both fetch and publish commands generate JSON reports:
metadata.json- Package list and metadatabundle-info.json- Download statisticspublish-report.json- Publishing results
# Verify you're logged in
npm whoami --registry http://localhost:4873
# Re-login if needed
npm login --registry http://localhost:4873"Pre-check: 0 exist" on a registry you know is populated. The pre-check could not authenticate,
so every probe returned 401 and was recorded as "uncertain" (which counts as "to publish"). The
run still succeeds — each existing package is skipped on a 409 — but it re-uploads the whole
closure to find that out. Confirm the credentials the pre-check sees:
# Should print 200, not 401
curl -s -o /dev/null -w "%{http_code}\n" --user "<user>:<pass>" http://localhost:4873/lodashThen check .npmrc has an entry for that exact host (//host:port/:_authToken=…, or
username + _password). Note the host key must match the registry URL including port. Run with
--debug to see which file the credentials were resolved from.
Check bundle-info.json for download failures and ensure lockfile is current.
The tool automatically handles:
- Version conflicts (uses version-specific tags)
- Prerelease versions (applies correct tags)
- Already-existing packages (skips by default)
# Install dependencies
pnpm install
# Build
pnpm build
# Run tests
pnpm test
# Lint
pnpm lintMIT
Contributions are welcome! Please feel free to submit a Pull Request.