Skip to content

Latest commit

 

History

History
463 lines (357 loc) · 13.4 KB

File metadata and controls

463 lines (357 loc) · 13.4 KB

lnpm - Local NPM Package Development Tool

A faster, more reliable alternative to yalc for local package development.

Note: This project was built with AI assistance (Claude by Anthropic).

Design Principles

  1. Speed - Go for fast startup, hard links for instant syncing
  2. Visibility - bbolt-backed state, always know what's linked where
  3. Reliability - Hard links avoid symlink resolution issues
  4. Simplicity - Minimal commands, sensible defaults
  5. Universal - Works with npm, yarn, pnpm, and bun

Technical Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                           lnpm CLI (Go)                             │
│         Commands: publish | add | remove | push | status    │
└──────────────────────────────────┬──────────────────────────────────┘
                                   │
                    ┌──────────────┴──────────────┐
                    │                             │
                    ▼                             ▼
           ┌───────────────┐            ┌─────────────────┐
           │ Package Store │            │  Link Manager   │
           │ ~/.lnpm/store │            │  (hard links)   │
           └───────┬───────┘            └─────────────────┘
                                  │
                                  ▼
                          ┌───────────────┐
                          │   bbolt DB    │
                          │ ~/.lnpm/lnpm.db│
                          └───────────────┘

Directory Structure

~/.lnpm/
├── lnpm.db                      # bbolt database (key-value store)
├── config.yaml                  # Global config (optional)
├── store/                       # Package store
│   └── {package-name}/
│       └── {content-hash}/      # Versioned by content hash
│           ├── package.json
│           ├── dist/
│           └── ...

project/
├── .lnpm/                       # Local package cache (hard linked)
│   └── {package-name}/
│       └── ...
├── lnpm.lock                    # Lock file (YAML)
├── node_modules/
│   └── {package-name} -> ../.lnpm/{package-name}  # Symlink to .lnpm
└── package.json                 # Modified with file: protocol

Intelligent Linking Strategy

Priority System: Reflink → Hard Link → Parallel Copy

lnpm uses a sophisticated multi-tier approach for maximum performance:

Method Speed Platform Support Use Case Disk Usage
Reflink (CoW) Instant (~1ms) macOS APFS, Linux Btrfs/XFS/OCFS2 Primary method Zero (copy-on-write)
Hard Link Instant (~1ms) Same filesystem (all platforms) Fallback #1 Zero (shared inodes)
Parallel Copy Fast (~100ms for 10k files) All platforms, cross-filesystem Fallback #2 Full copy

Reflink (Copy-on-Write Cloning)

New in v1.2.0 — Revolutionary performance for modern filesystems.

What is Reflink?

  • Creates an instant "clone" of a file without copying data
  • Uses copy-on-write (CoW): data is only copied when modified
  • Provides copy semantics with link performance
  • Safe: modifications don't affect the original

Platform Implementation

  • macOS APFS: Uses clonefile() syscall (SYS_CLONEFILE #462)
  • Linux Btrfs/XFS: Uses FICLONE ioctl (#0x40049409)
  • Other filesystems: Gracefully falls back to hard links

Why Reflink is Better Than Hard Links

Hard Link:     source.js ════► [inode 12345] ◄════ dest.js
               (both point to same inode - modifications affect both)

Reflink:       source.js ──► [inode 12345: blocks 1-100]
               dest.js   ──► [inode 67890: blocks 1-100] (CoW clone)
               (separate inodes, shared blocks until modified)

How It Works

During lnpm publish:

  1. Detect if source and store are on same filesystem
  2. Try reflink first (works even across directories on same FS)
  3. If reflink unsupported, try hard link (requires same filesystem)
  4. If hard link fails, use parallel copy with 8 worker goroutines
  5. Display progress and warnings for user visibility

During lnpm add:

  1. Check user config for link_mode preference
  2. Try reflink from store to project (instant, safe)
  3. If reflink unsupported, try hard link (instant, requires same FS)
  4. If hard link fails, fall back to parallel copy
  5. Warn user about fallback with optimization tips
Source Package          Store                    Project
──────────────          ─────                    ───────
src/index.ts    ──►   (reflink/hardlink)
dist/index.js   ──►     ~/.lnpm/store/      ══► .lnpm/pkg/     ──► node_modules/pkg
package.json    ──►       pkg/abc123/      (reflink/hardlink)     (symlink)
                       (CoW or shared)      (CoW or shared)

Performance Characteristics

10,000 file package:

  • Reflink (APFS/Btrfs): ~5ms
  • Hard link (same FS): ~10ms
  • Sequential copy: ~5000ms 🐌
  • Parallel copy (8 workers): ~800ms 🚀

Result: Up to 1000x faster for large packages!

Cross-Filesystem & Fallback Handling

lnpm intelligently handles edge cases:

  1. Same filesystem: Try reflink → hard link → parallel copy
  2. Different filesystem: Try reflink → parallel copy (skip hard link)
  3. User config link_mode: copy: Skip linking, use parallel copy
  4. User config link_mode: hardlink: Try reflink → hard link → parallel copy

User Feedback:

# Same filesystem, linking works
✓ Linked 10,000 files instantly

# Linking not supported
⚠ Linking not supported, copying files instead
  Copying... 10000/10000 files

# Cross-filesystem scenario
ℹ Store and source on different filesystems - files were copied
💡 Tip: Move your store to the same filesystem for instant linking

Database Schema (bbolt)

lnpm uses bbolt, an embedded key-value database (same as used by etcd).

Buckets

packages           - Package data (key: ID, value: JSON)
packages_by_name   - Index (key: name, value: ID)
projects           - Project data (key: ID, value: JSON)
projects_by_path   - Index (key: path, value: ID)
links              - Link data (key: ID, value: JSON)
links_by_package   - Index (key: package_id, value: [link_ids])
links_by_project   - Index (key: project_id, value: [link_ids])
files              - File manifest (key: package_id, value: [FileEntry])
meta               - Metadata (next_id counter)

Data Structures (JSON)

// Package
{
  "id": 1,
  "name": "my-package",
  "version": "1.0.0",
  "content_hash": "abc123...",
  "source_path": "/path/to/source",
  "store_path": "/home/user/.lnpm/store/my-package/abc123",
  "files_count": 42,
  "total_size": 123456,
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-15T10:30:00Z"
}

// Project
{
  "id": 1,
  "path": "/path/to/project",
  "name": "my-app",
  "package_manager": "pnpm",
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-15T10:30:00Z"
}

// Link
{
  "id": 1,
  "package_id": 1,
  "project_id": 1,
  "link_type": "hardlink",
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-15T10:30:00Z"
}

CLI Commands

lnpm publish

Publish a package to the local store.

# Basic usage (in package directory)
lnpm publish

# Publish and push to all linked projects
lnpm publish --push

# Publish with custom tag
lnpm publish --tag beta

Process:

  1. Read package.json for name/version
  2. Determine files to include (respects .npmignore, files field)
  3. Calculate content hash of all files
  4. Copy files to ~/.lnpm/store/{name}/{hash}/
  5. Record in SQLite database
  6. If --push, update all linked projects

lnpm add <package>

Add a package from the store to current project.

# Add latest published version
lnpm add my-package

# Add specific version/tag
lnpm add my-package@1.0.0
lnpm add my-package@beta

# Add without modifying package.json
lnpm add my-package --pure

# Add with dev dependency
lnpm add my-package --dev

Process:

  1. Find package in store (latest or specified version)
  2. Create .lnpm/{package}/ directory
  3. Hard link all files from store
  4. Create symlink node_modules/{package}.lnpm/{package}
  5. Update package.json with file:.lnpm/{package}
  6. Update lnpm.lock
  7. Register link in SQLite

lnpm remove <package>

Remove a linked package.

lnpm remove my-package

# Remove all linked packages
lnpm remove --all

Process:

  1. Remove .lnpm/{package}/ directory
  2. Remove node_modules/{package} symlink
  3. Restore original package.json dependency (if any)
  4. Update lnpm.lock
  5. Remove link from SQLite

lnpm push

Push updates to all linked projects.

# Push current package to all consumers
lnpm push

# Push with force (re-link all files)
lnpm push --force

Process:

  1. Calculate new content hash
  2. If unchanged and not --force, skip
  3. Update store with new version
  4. For each linked project:
    • Remove old hard links
    • Create new hard links
    • Preserve node_modules symlink

lnpm status

Show current state of all links.

lnpm status

# Output:
# 📦 Published Packages
# ┌──────────────┬─────────┬──────────┬─────────────────────┐
# │ Package      │ Version │ Hash     │ Published           │
# ├──────────────┼─────────┼──────────┼─────────────────────┤
# │ my-package   │ 1.0.0   │ abc123   │ 2 minutes ago       │
# │ other-pkg    │ 2.1.0   │ def456   │ 1 hour ago          │
# └──────────────┴─────────┴──────────┴─────────────────────┘
#
# 🔗 Active Links
# ┌──────────────┬─────────────────────────┬──────────┐
# │ Package      │ Project                 │ Type     │
# ├──────────────┼─────────────────────────┼──────────┤
# │ my-package   │ ~/code/my-app           │ hardlink │
# │ my-package   │ ~/code/other-app        │ hardlink │
# └──────────────┴─────────────────────────┴──────────┘

lnpm list

List packages in a project or the store.

# List packages linked in current project
lnpm list

# List all packages in store
lnpm list --store

# List all projects using a package
lnpm list my-package --projects

lnpm gc

Garbage collect unused packages from store.

# Dry run - show what would be removed
lnpm gc --dry-run

# Remove packages with no active links
lnpm gc

# Remove packages older than 30 days
lnpm gc --older-than 30d

lnpm doctor

Diagnose and fix common issues.

lnpm doctor

# Output:
# 🔍 Checking lnpm health...
#
# ✓ Store directory exists
# ✓ Database is valid
# ✓ 3 packages in store
# ✓ 2 active links
# ⚠ 1 orphaned link found (project deleted)
#   Run: lnpm gc --fix-links
# ✓ No cross-filesystem issues detected

Configuration

Global Config (~/.lnpm/config.toml)

# Store location (default: ~/.lnpm)
store_path = "~/.lnpm"

# Default link type: "hardlink" | "copy"
link_type = "hardlink"

Project Config (.lnpmrc or lnpm field in package.json)

{
  "lnpm": {
    "publishDir": "dist",
    "ignore": ["**/*.test.ts"],
    "prePublish": "npm run build",
    "postAdd": "npm install"
  }
}

Lock File Format (lnpm.lock)

version: 1
packages:
  my-package:
    version: 1.0.0
    hash: abc123def456
    source: ~/code/my-package
    linked: 2024-01-15T10:30:00Z
  other-pkg:
    version: 2.1.0
    hash: 789xyz000111
    source: ~/code/other-pkg
    linked: 2024-01-15T09:00:00Z

Package Manager Integration

Detection

lnpm auto-detects package manager by checking for:

  1. bun.lockb → bun
  2. pnpm-lock.yaml → pnpm
  3. yarn.lock → yarn
  4. package-lock.json → npm

package.json Modification

{
  "dependencies": {
    "my-package": "file:.lnpm/my-package"
  }
}

The file: protocol is universally supported by all package managers.

Cleanup Before Publish

Before publishing to npm registry, run:

lnpm retreat  # or: lnpm remove --all

This restores original version specifiers in package.json.