pnpm-airgap
v2.4.0
Published
Transfer pnpm project dependencies between online and offline environments
Maintainers
Readme
pnpm-airgap
The complete solution for transferring pnpm dependencies to air-gapped environments.
The Problem
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
The Solution
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
Quick Start
1. Download the standalone CLI
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.cjs2. Fetch dependencies (online)
node cli.cjs fetch -l ./pnpm-lock.yaml -o ./packages3. Transfer to airgap
Copy the packages folder and cli.cjs to your air-gapped environment.
4. Publish to local registry (airgap)
# 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:48735. Install your project
pnpm install --registry http://localhost:4873Interactive Mode
Run 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
✖ ExitCommands
fetch - Download packages from lockfile
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 outputpublish - Publish packages to registry
node 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 outputPublish reliability (v2.4.0)
Publishing 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.
sync - Sync between registries
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 changesregistry-state export - Export for incremental sync
Export 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.jsonprune - Remove stale versions from a registry
Private 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.
info - Show bundle information
node cli.cjs info ./packagesinit - Create config file
node cli.cjs initConfiguration
Create 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
}
}Features
| 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 |
Workflow Examples
Complete Airgap Transfer
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 installIncremental Updates
Avoid 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 downloadedKeeping the Registry Lean
A 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 goneProgrammatic API
import { 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,
});Compatibility
| 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 |
Reports
Both fetch and publish commands generate JSON reports:
metadata.json- Package list and metadatabundle-info.json- Download statisticspublish-report.json- Publishing results
Troubleshooting
Authentication Issues
# 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.
Missing Packages
Check bundle-info.json for download failures and ensure lockfile is current.
Publishing Conflicts
The tool automatically handles:
- Version conflicts (uses version-specific tags)
- Prerelease versions (applies correct tags)
- Already-existing packages (skips by default)
Development
# Install dependencies
pnpm install
# Build
pnpm build
# Run tests
pnpm test
# Lint
pnpm lintLicense
MIT
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
