Skip to main content

Add A Global pnpm Package

Global pnpm packages are managed via a plain list. The sync command installs missing packages, removes unmanaged packages, then asks pnpm outdated -g --json which unpinned packages are behind their latest release and runs pnpm update -g --latest only for those, so installed packages are refreshed instead of staying within the semver range recorded at install time while packages already at latest are left untouched.

Preconditionsโ€‹

  • Node.js and pnpm are installed (both through mise in this setup; ~/.config/mise/config.toml pins the latest pnpm release for global use).
  • ~/.local/share/pnpm/bin is on PATH (the shell configs add it; the installer adds it for itself).
  • You verified the package name.

Stepsโ€‹

Example packages managed this way include @earendil-works/pi-coding-agent.

  1. Add the package name to:

    This file is installed as ~/.default-pnpm-pkgs. name@x.y.z pins an exact version; bare names track latest.

  2. Apply dotfiles (which triggers the hook):

    chezmoi apply

Or run the installer command directly:

Verificationโ€‹

pnpm ls -g --depth 0 | rg '<package-name>'

What It Doesโ€‹

The installer reads ~/.default-pnpm-pkgs, resolves pnpm root -g, and merges every exact desired package name into <pnpm root -g>/pnpm-workspace.yaml with allowBuilds[<name>] set to true before reading inventory or changing packages. Pins reduce to the bare name. Unrelated approval, denial, and placeholder entries remain unchanged, and this only approves listed top-level packages; it does not approve third-party transitive packages. This durable workspace setting means direct pnpm -g update commands inherit the same build decisions after a sync.

It then compares the desired list with pnpm ls -g --json, installs missing packages (pnpm add -g), re-pins packages whose installed version differs from an exact pin, uninstalls global packages not on the list (pnpm remove -g), then runs pnpm update -g --latest for each unpinned package that pnpm outdated -g --json reports behind (pnpm exits 1 when anything is outdated and 0 when nothing is; any other exit or unreadable body stops the upgrade step and fails the sync). A package already at latest is never re-updated, because pnpm update rebuilds the package's hashed project directory even at the same version.

After any add, remove, or update, the installer runs lsof -n -P (no host or port name lookups, which can otherwise outlast its 60-second timeout) and prints Restart needed: <command> (pid <n>) still runs from removed <dir> for every process that still has files open under a global project directory that no longer exists. On 2026-09-14 a one-package list edit rebuilt every global directory and a running Pi session silently lost its subagent launcher; this report names that process at the moment it happens. It is best effort: without lsof, or on a timeout, nothing is printed and the exit status is unchanged.

pnpm 11+ installs every global package into its own hashed project directory under ~/.local/share/pnpm/global/v11/, and that directory moves on every add or update. Consumers that need a stable module path (Pi's packages setting loads pi-subagents by path; scoped names keep their @scope/name directory shape) read ~/.local/share/pnpm-global-links/node_modules/<package> instead; the installer rebuilds that symlink tree after every sync.

Package operations pass --yes and disconnect stdin so pnpm does not prompt during a chezmoi run, including when launched from a terminal. Unapproved dependency build scripts remain ignored.

If a package operation fails, the installer prints pnpm's error and stops with a nonzero exit status. It refreshes links from the installed state even after a partial sync. If that state cannot be read or parsed, it preserves the existing link tree and reports the failure.

Release-Age Quarantineโ€‹

pnpm 11+ ships a built-in minimumReleaseAge of 1440 minutes: pnpm update -g --latest and pnpm outdated -g silently skip any version published less than a day ago, so ,update can report success while omp update (which reads the latest dist-tag directly) already sees a newer release.

home/dot_config/pnpm/readonly_config.yaml.tmpl renders ~/.config/pnpm/config.yaml with minimumReleaseAgeExclude covering every package in ~/.default-pnpm-pkgs (pins reduce to the bare name), so listed packages update as soon as they publish. Scoped packages are listed as @scope/*, not by exact name: pnpm's non-strict default auto-approves same-day transitive versions into the global project's own pnpm-workspace.yaml, and that project list replaces the global one, which would re-quarantine the top-level package on the next sync. Same-scope companions (for example the 17 @oh-my-pi/* packages behind omp) are the case that triggers this on every release, so the scope glob keeps the global list in effect. Unscoped packages keep exact names, so a same-day third-party transitive release can still trigger that write. The 05 hook hashes the config template, so changing exclusions re-runs the sync.

Check the effective list with:

pnpm config get --global minimum-release-age-exclude

Rollback / Undoโ€‹

  1. Remove the package from home/readonly_dot_default-pnpm-pkgs.
  2. Re-apply:
chezmoi apply

(The package will be automatically uninstalled because it is no longer in the desired list).