Local package development CLI with a built-in registry. Publish workspace packages locally and auto-update consumer repos.
Runtime: Bun
CLI framework: citty
Colors: Bun.color() via src/lib/color.ts (no picocolors)
IMPORTANT: pkglab is distributed as a standalone binary via BanBinary. In compiled mode, process.execPath points to the pkglab binary itself. There are two categories of subprocess spawning:
- Spawning pkglab's own commands (
pub,--__worker,--__prune,--__listener): useprocess.execPathwith normal env. The compiled binary runs pkglab's CLI. - Spawning bun runtime commands (
publish,run,install): useprocess.execPathwithBUN_BE_BUN=1env var (viabunEnv()fromsrc/lib/proc.ts). This makes the compiled binary ignore its bundled entrypoint and act as the plain bun CLI.
ALWAYS use process.execPath for subprocesses, NEVER hardcode 'bun'. The compiled binary ships the full Bun runtime. Use bunEnv() when you need bun CLI behavior instead of pkglab CLI behavior.
Top-level:
-
pkglab up— start the local registry (pub and add auto-start if down) -
pkglab down— stop the registry. By default, restores all consumer repos first (versions,.npmrc, pre-commit hooks), then stops the daemon. If any restore fails, daemon stays up.--force/-fskips restoration and stops immediately. -
pkglab status— show registry status.--healthexits 0 if healthy, 1 if not (silent, for scripting) -
pkglab logs— show registry logs -
pkglab pub [name...]- publish workspace packages to local registry, auto-updates active consumer repos. Accepts multiple names. Fingerprints packages and skips unchanged ones. Uses mtime+size gating to skip content hashing for unchanged files (disable withPKGLAB_NO_MTIME_CACHE=1). Flags:--singleskip cascade/fingerprinting,--shallowtargets + deps only (no dependent expansion),--force/-fignore fingerprints (republish all),--tag/-tpublish with tag,--worktree/-wauto-detect tag from branch,--rootpublish all packages regardless of cwd (same as running from workspace root, errors if combined with positional names),--pingsend publish request to the registry server via HTTP instead of publishing directly,--no-pm-optimizationsskip lockfile patching and install optimizations (plainpm install),--dry-run,--verbose/-v(includes per-phase timing: fingerprint, cascade, publish, consumer) -
pkglab listen- (deprecated) shows a deprecation notice and displays publish queue status from the registry. Publish coalescing is now built into the registry server. The old Unix socket listener is no longer used. -
pkglab add [name[@tag]...]— add pkglab packages to the current repo. Accepts multiple names. No args for interactive picker. Batch installs in one command. On first add to a repo, injectspkglab checkinto the pre-commit hook (Husky or raw git, warns for Lefthook manual setup). Auto-detects when a package exists in a workspace catalog and uses catalog mode automatically.--catalog/-cenables strict mode (errors if the package is not in any catalog). Supports both bun/npm catalogs (package.json) and pnpm catalogs (pnpm-workspace.yaml). In a workspace, auto-scans all sub-packages for the dependency and updates all of them (sub-packages usingcatalog:protocol are skipped, handled by catalog auto-detection).--packagejson/-popts out of workspace scanning and targets a single sub-package directory (e.g.-p apps/dashboardfrom monorepo root).--tag/-tapplies a tag to all packages (pkglab add pkg --tag feat1is equivalent topkglab add pkg@feat1), errors if combined with inline@tagsyntax.--scope/-sreplaces all packages of a given scope in the workspace (e.g.--scope clerkor--scope @clerk), normalizes to@clerk/, scans workspace root + sub-packages for matching deps, verifies all are published before modifying files. Cannot combine--scopewith positional package names.--dry-runpreviews what would be installed without making changes.--verbose/-vshows detailed output about workspace scanning and decisions. Targets are remembered for restore. -
pkglab restore <name...>— restore pkglab packages to their original versions across all targets that were updated bypkglab add, runs pm install to sync node_modules. Accepts multiple names.--allrestores all packages in the repo.--scope <scope>restores all packages matching a scope (mirrors add--scope).--tag/-trestores only packages installed with a specific tag. Removes pre-commit hook injection when no packages remain. -
pkglab doctor— diagnose issues. Detects dirty state (daemon not running but repos have active packages).--lockfilesanitizesbun.lockfiles by replacing localhost URLs with"". -
pkglab check— check for pkglab artifacts in workspace root and sub-packages. Scans staged lockfiles (bun.lock,bun.lockb,pnpm-lock.yaml) for localhost registry URLs. -
pkglab reset --hard— wipe all pkglab data and registry storage -
pkglab reset --fingerprints— clear fingerprint cache, forces full republish on next pub -
pkglab hooks init— scaffold.pkglab/hooks/in the current repo withpayload.d.ts(typedPkglabHookPayloadinterface) and commented-out stubs for all 7 hook events. Hooks are executable files that run at lifecycle moments:pre-add,post-add,pre-restore,post-restore,pre-update,post-update,on-error. Supports.ts(bun),.sh(bash), and extensionless (direct) formats. Each hook receives a JSON payload as argv[1]. Pre-hooks can abort operations (non-zero exit), post-hooks are advisory, on-error is best-effort. Hook runner module lives atsrc/lib/hooks.ts.
Subcommands:
pkglab repo ls— list consumer repospkglab repo on [name...]— activate repos (accepts multiple paths).--allto activate every repopkglab repo off [name...]— deactivate repos (accepts multiple paths).--allto deactivate every repopkglab repo reset [name]— reset repo state, restores original versions and runs pm install.--allto reset every repo,--staleto remove repos whose directories no longer existpkglab pkg ls— list published packages (checks if registry is running)pkglab pkg rm <name...>— remove packages from registry (also--all)
pkglab up— start the local registrypkglab pub— publish workspace packages (from the library repo)pkglab add <pkg>— install a pkglab package in a consumer repo (run from consumer repo dir)- Iterate: make changes to the library, run
pkglab pubagain — active consumer repos are auto-updated pkglab restore <pkg...>orpkglab restore --all— restore original versions when donepkglab down— stop the registry
For multi-worktree workflows, use tags to isolate version channels:
pkglab pub -t feat1orpkglab pub -w(auto-detect from branch)pkglab add pkg@feat1(consumer pins to that tag)- Each tag's publishes only update consumers pinned to the same tag
src/index.ts— entry point, registers all commands via lazy importssrc/commands/— one file per command, each exportsdefineCommand()as defaultsrc/commands/repos/,src/commands/pkg/— subcommand groups with their own index.tssrc/lib/- shared utilities (config, daemon, publisher, registry, fingerprint, publish-queue, publish-ping, etc.)src/types.ts— all shared interfaces
Config and state live in ~/.pkglab/. Registry storage at ~/.pkglab/registry/storage/. Fingerprint state at ~/.pkglab/fingerprints/ (per-workspace files).
- Bun APIs over Node when available (Bun.file, Bun.write, Bun.spawn)
- Strict tsconfig with noUnusedLocals and noUnusedParameters
- No test framework set up
- No linter/formatter config — tsconfig strict mode is the guardrail
- Custom errors extend
pkglabErrorinsrc/lib/errors.ts - Logging through
src/lib/log.ts(info, success, warn, error, dim, line) - Class/function naming uses lowercase "pkglab" (pkglabConfig, pkglabError, ispkglabVersion)
- Create
src/commands/<name>.tsexportingdefineCommand()as default - Register in
src/index.tssubCommands with a lazy import - Use
argsfor CLI flags,run({ args })for the handler
pkglab pub uses a two-phase cascade to determine the publish scope. Private packages are excluded from the closure.
Phase 1 (initial scope): targets + their transitive workspace deps, closed under deps (every publishable package has its workspace deps in the set).
Fingerprinting: each package in scope is fingerprinted using Bun.Glob + Bun.CryptoHasher (SHA-256) to hash the publishable file set (the files field, always-included files, and entry points from main/module/types/bin/exports). Falls back to npm pack --dry-run --json for packages with bundledDependencies. On repeat runs, mtime+size metadata is checked first: if all files match the previous run's stats, the cached hash is reused without reading file contents. File stats are persisted alongside hashes in ~/.pkglab/fingerprints.json. Set PKGLAB_NO_MTIME_CACHE=1 to disable the fast path and always content-hash. Packages are classified in topological order:
- "changed": content hash differs from previous publish
- "propagated": content same, but a workspace dep was changed/propagated
- "unchanged": content same, no deps changed (skipped, keeps existing version)
Phase 2 (dependent expansion): for each package classified as "changed," expand its transitive dependents into the scope. Expanding from "propagated" is skipped because every dependent of a propagated package is already a transitive dependent of the original changed package. New packages are fingerprinted and classified, and the loop repeats until no new changed packages are found. This ensures that if a dependency (like @clerk/shared) changes, all its dependents (like @clerk/express) are included even if they weren't in the original targets.
Fingerprint state is stored per workspace in separate files under ~/.pkglab/fingerprints/, keyed by a SHA-256 hash of the workspace root path (e.g. ~/.pkglab/fingerprints/a1b2c3d4e5f6.json). Each file contains per-package, per-tag entries. This eliminates cross-workspace race conditions when multiple workspaces publish concurrently. On first load, data is auto-migrated from the old monolithic ~/.pkglab/fingerprints.json if present. Treated as a cache: missing/corrupt state triggers a full republish. State is saved after consumer updates succeed.
The cascade has three steps: (1) DOWN: pull in transitive deps of targets so workspace:^ references resolve, (2) UP: expand dependents from packages whose content actually changed, (3) PUBLISH filter: only publish dependents that a consumer has installed via pkglab add. No active repos means nothing is consumed, so the filter removes all dependents.
--single bypasses cascade and fingerprinting entirely.
--shallow runs step 1 (DOWN) only, skipping dependent expansion and the consumer filter. Targets + their transitive deps, nothing more.
--force/-f ignores previous fingerprint state (republishes all packages) but still computes and saves new fingerprints.
Default output shows a color-coded scope summary (package list with scope/change reasons), then spinners for publishing. Scope reasons show "target", "dependency", or "dependent (via X)" with change status. --verbose/-v adds the initial scope, expansion steps, and private-package warnings.
Pruning runs in a detached subprocess (src/lib/prune-worker.ts) to avoid blocking exit.
Untagged: 0.0.0-pkglab.{timestamp}
Tagged: 0.0.0-pkglab-{tag}.{timestamp}
Old format (0.0.0-pkglab.{YY-MM-DD}--{HH-MM-SS}.{timestamp}) treated as untagged for backwards compat.
extractTimestamp reads after the last dot, extractTag reads between pkglab- and the last dot.
IMPORTANT: After making changes to commands or core lib code, always run bun run test:e2e and verify all tests pass before committing.
IMPORTANT: After changing commands, flags, or CLI behavior, always update README.md and CLAUDE.md. This is not optional. README.md is the public-facing documentation and must reflect the current CLI surface.
Scopes: pub, pkgs, repos, daemon, consumer, registry, version, config, hooks, perf
IMPORTANT: Changesets are MANDATORY for every commit made with /cmt. Always create a changeset file in .changeset/ with the appropriate bump level (patch for fixes, minor for features) and stage it together with the code changes. Never commit without a changeset.