dep-up-surgeon
v4.3.0
Published
Upgrade npm dependencies one-by-one with validation, rollback, and conflict reporting.
Maintainers
Readme
dep-up-surgeon
Website: https://dep-up-surgeon.netlify.app/
Production-oriented CLI that upgrades npm dependencies with npm install + validation after each change, and rolls back on failure. It is framework-agnostic: grouping and conflict handling come from registry metadata and parsed npm output, not hardcoded stacks (React, Angular, etc.).
Install
npm install -g dep-up-surgeonOr run locally after cloning:
npm install
npm run build
npx dep-up-surgeon --helpWhat's new in 4.2
A correctness and safety release from a full review of the tool. What you may notice:
- Policy caps hold everywhere.
maxVersionandallowMajorAfternow also bind linked-group members, coordinated fallbacks, the peer-range resolver and--dry-runplans — before, a group or a fallback could land a version the policy blocks. - Transitive CVEs are fixed more gently. With npm,
--apply-overridesfirst triesnpm update <name>and writes no pin when the refreshed lockfile is clean (method: "lockfile-update"). Every pin is installed and validated; a failed one getspackage.json,pnpm-workspace.yamland the lockfiles back byte-for-byte. pnpm 10+ pins go topnpm-workspace.yamlwhen the project keeps its overrides there.--security-onlyalso lists advisories on transitive packages, with a fix hint, undertransitiveAdvisories. - The branch matches the report. With
--git-commit, override pins and the--fix-lockfilededupe land in one extra commit.--open-prrefuses to push when--git-branchis the PR's base branch. - Safer file restore. Files the run rewrote are only restored inside the project directory, and the changed version is first copied to
node_modules/.cache/dep-up-surgeon/side-effects/, so an edit you saved mid-run is never lost. Your--summary-fileand Yarn zero-install files are kept. - Workspaces. pnpm
shared-workspace-lockfile=falseno longer turns on parallel installs (pnpm installstill installs every project underpnpm-workspace.yaml). yarn--install-mode filteredalways runs a fullyarn install, becauseyarn workspaces focusdoesn't writeyarn.lock. A member's own lockfile is restored on rollback, and a workspace target that throws is recorded as[target:<label>]instead of crashing the run. - Peer conflicts. Dependents npm lists under
Found:whose range is already satisfied are no longer treated as blockers; blockers are never moved below their installed version; a resolver answer that changes nothing is reported as a failure, not a no-op success. undoand--retry-failed. Override pins are reverted newest first, and a pin changed since the run is skipped asdrifted. A report replayed with--fileedits this checkout, not the path recorded on the CI machine. Retries chain: a--retry-failedrun keeps the previous retry's freezes.doctor. The pnpm peer scan no longer runspnpm install(it could rewritepnpm-lock.yaml). A peer scan that can't run is yellow, a missingpackage.jsonis red,bun.lockbis green, andworkspace-coherenceflags declared entries that don't resolve to a member.- Changelogs. Breaking-change detection reads the full release notes plus the first release of each crossed major. Monorepo packages match their own tags (
[email protected]beforev5.0.0). An empty## Breaking Changesheading isn't flagged.@mentionsand issue refs are wrapped in backticks, so your commits and PRs don't ping people or close issues. - Detection.
npm-shrinkwrap.jsonis read beforepackage-lock.json; ayarn.locknext tobun.lockis bun's; dist-tags with digits (ts5.6) stay tags; blast radius counts@types/xfor files importingx. - Interactive mode is ignored with
--json, and Ctrl+C at a prompt stops the run and rolls back the step in progress. .dep-up-surgeonrcaccepts comments, trailing commas and a BOM, as thejsoncexamples here assume. Adding names toignorekeeps the file's formatting; an rc with comments is left for you to edit by hand.
--json consumers: project.isolatedLockfilesSource is now always "per-workspace-lockfiles", and project.yarnSupportsFocus is informational. New fields: transitiveAdvisories, upgraded[].section, overrides.attempts[].method.
What's new in 4.1
- A failed linked group no longer drops its safe members. When the whole group fails, the members that jump to a new major are held back and the rest upgrade on their own (e.g. an
eslint-config-nextpatch lands whileeslint@10is reported as failed). See When a linked group can't move as a whole. Consumers that key on[group:<id>]rows: a held-back member is now reported as a plain row withlinkedGroupId. - A lagging
latestdist-tag no longer hides updates. When the installed version is newer than the tag, the newest release of the installed major is used (@types/node26.5.1 → 26.6.1 whilelatestpointed at 22.20.3);outdatedreports it withlatestTag. - Peer hold-backs name their blocker (
skipped[].blockedBy), and a resolver tuple equal to what's installed is no longer installed and validated again.failed[].requestedLatestrecords the version each failure was aiming for. --progresskeeps the progress lines on stderr together with--json, for tools that show a live log next to the report.
Upgrading from 3.x
4.0 changes a few defaults — check these if you run dep-up-surgeon in CI:
- Run state moved from
.dep-up-surgeon.last-run.jsonin the project root tonode_modules/.cache/dep-up-surgeon/(last-run.json+last-run.<lockfile>), so a run leaves onlypackage.jsonand the lockfile changed. A root report from an older version is still read byundo/--retry-failedwhen the new one is missing, with a warning naming the file, itsfinishedAtandtoolVersion(it may be months old); the next run deletes it unless git tracks it. Cache or upload the new directory instead of the old file. - Validation is stricter. The default validator also runs your
lint/typecheck/type-checkscripts (see Pre-flight check), and every install is followed by a peer-range check between installed direct dependencies (see Conflict detection). Expect rollbacks — and exit1— where 3.x kept an upgrade that broke linting or left the tree uninstallable. - Files the run rewrites are restored. Tracked files other than dependency files that change during the run (e.g.
tsconfig.jsonrewritten bynext build) are checked out again at the end.
Usage
From your project root (where package.json lives):
dep-up-surgeon [options]
dep-up-surgeon doctor [options]
dep-up-surgeon undo [options]
dep-up-surgeon outdated [options]Options
| Option | Description |
|--------|-------------|
| --dry-run | Resolve latest versions and print the plan; does not change package.json or run installs. |
| --interactive | On failure, prompts for next steps (see Interactive mode). After the run, optionally bulk-add failed names to .dep-up-surgeonrc. Ignored with --json (prompts would corrupt the report) and --ci. |
| --force | Keep a version bump even when validation fails; also skips rollback when structured conflicts are detected in npm output after a successful exit code (use with care). |
| --ignore <pkgs> | Comma-separated package names to skip in every workspace (merged with .dep-up-surgeonrc). This is global by name — unlike --retry-failed, which freezes per workspace. |
| --json | Machine-readable report on stdout (see JSON report). Warnings and errors go to stderr, so stdout is always valid JSON. Progress lines are off unless --progress is added. |
| --progress | With --json, still print progress (installs, validation, rollbacks, the lines of Live progress) to stderr; stdout keeps only the JSON report. For tools that show a live log next to the report (e.g. PumpBar). Parallel --concurrency drops to 1 so the lines stay readable. |
| --fallback-strategy <mode> | major-lines (default), minor-lines, or none. After @latest fails, major-lines tries the best stable version per major (e.g. 9.x → 8.x → 7.x …). minor-lines steps one major.minor line at a time. If npm output looks like ESM vs CommonJS (ERR_REQUIRE_ESM), further fallbacks for that package stop. none only attempts @latest. For linked groups, members step back together, and when the whole group still fails the members that jump to a new major are held back while the rest upgrade on their own (see When a linked group can't move as a whole); none turns both off. |
| --link-groups <mode> | auto (default) or none. auto builds linked batches from the registry graph and optional linkedGroups. none upgrades one dependency per step. |
| --validate <cmd> | Override the validator command run after every install. Defaults to <manager> test if a test script exists, else <manager> run build (yarn classic uses yarn build), else nothing — plus any lint / typecheck / type-check script, guarded by its pre-flight exit code (see Pre-flight check). Useful in monorepos where the default build is heavy or fragile (e.g. --validate "tsc -p tsconfig.json --noEmit"). |
| --no-validate | Skip validation entirely. Upgrades are kept regardless of test/build outcome. Different from --force: --force runs the validator and only keeps the bump when it fails, --no-validate doesn’t run a validator at all. |
| --package-manager <mgr> | auto (default), npm, pnpm, yarn, or bun. auto reads the packageManager field, then falls back to lockfile detection (pnpm-lock.yaml → pnpm, yarn.lock → yarn, bun.lock / bun.lockb → bun, package-lock.json → npm), then pnpm-workspace.yaml, then npm. The chosen manager drives both the install command (<mgr> install) and the default validator (<mgr> test / <mgr> run build; bun uses bun run test / bun run build). |
| --cwd <path> | Run against this directory instead of process.cwd(). Default is still the current working directory, so scripts that cd first keep working. Same flag as doctor / undo / outdated. |
| --include-workspace-deps | By default, dependencies whose name matches a local workspace package (resolved via workspaces in package.json or pnpm-workspace.yaml) are skipped — their version comes from the local workspace, not the registry. Pass this flag to upgrade them anyway (e.g. when local workspace packages also publish to the registry). |
| --include-peers | Also upgrade peerDependencies entries. Default: skip peers (they are a consumer contract; auto-bumping them is usually wrong for libraries). |
| --pin-exact | Write bare exact versions (1.2.3) into package.json instead of preserving the previous ^ / ~ range style. |
| --workspaces | Traverse the root package.json and every workspace member (one engine pass per package.json). Install + validation always run from the workspace root so the lockfile and validator see the whole monorepo. |
| --workspaces-only | Like --workspaces but skips the root package.json. Only workspace members are traversed. |
| --workspace <names> | Comma-separated workspace member names (the name field from each child package.json) to traverse. Pass root to also include the root. Example: --workspace "@org/core,@org/web,root". Unknown names produce a friendly error listing the known members. |
| --install-mode <mode> | Workspace install strategy. root (default) always runs <mgr> install from the workspace root after every mutation — the safest option, supported by every package manager. filtered rewrites per-child installs to their workspace-scoped form: npm 7+ uses npm install --workspace <name>, pnpm / bun use <mgr> install --filter <name>, and yarn (classic and berry) always falls back to a full root install with a one-time warning — yarn workspaces focus doesn't write yarn.lock and installs only part of the tree, so a kept bump would ship with a stale lockfile. Only meaningful with --workspaces / --workspaces-only / --workspace <names>. |
| --concurrency <n> | Maximum number of workspace targets to traverse in parallel (1–16; default 1). Higher values overlap registry scan + plan phases across targets while a shared mutex keeps install + validation strictly serialized — the workspace lockfile is shared, so concurrent installs would corrupt it. The default in-process registry cache also deduplicates pacote.manifest / pacote.packument calls across targets, so even at concurrency 1 you get a speedup when the same dep appears in many workspaces. Requires --json so per-target log lines don't interleave; non-JSON mode silently downgrades to 1 with a warning. In an isolated-lockfile monorepo (every workspace member is an independent project shipping its own lockfile) installs + validation are ALSO run in parallel — see Parallel installs below. |
| --no-parallel-installs | Force installs + validation to stay serialized even when an isolated-lockfile monorepo is detected. Useful when debugging a flaky install step (parallel installs mask the ordering) or when a per-workspace postinstall script touches shared state outside its workspace. |
| --retry-failed | Resume the previous run from its last-run report (node_modules/.cache/dep-up-surgeon/last-run.json): freeze last-run successes and terminal failures (peer, validation-script) per workspace, and re-attempt only non-terminal residue (install, validation-conflicts, versions, unknown). User --ignore / rc lists stay global. See Retry-failed mode below. |
| --no-persist-report | Do not write the last-run report after the run. By default it is written to node_modules/.cache/dep-up-surgeon/last-run.json (with pre-run lockfile backups) for undo, --retry-failed and CI consumers. |
| --summary <format> | Write a human-friendly summary of the run as md (default) or html. Destination is $GITHUB_STEP_SUMMARY if set (appended), otherwise --summary-file <path>, otherwise ./dep-up-surgeon-summary.<ext>. |
| --summary-file <path> | Override the destination for --summary. Wins over $GITHUB_STEP_SUMMARY. |
| --ci | Convenience flag for CI / bot use. Disables --interactive, auto-enables --summary md (great with $GITHUB_STEP_SUMMARY), and exits 0 even when individual upgrades fail (only pre-flight failures and fatal errors exit 1) so per-package conflicts surface in the PR description instead of failing the job. |
| --git-commit | Commit successful upgrades to git as the run progresses. Refuses to start on a dirty working tree (override with --git-allow-dirty). Only stages package.json + the lockfile (+ pnpm-workspace.yaml when a catalog: entry moved) — never git add -A, so unrelated WIP, generated files, and prepare/postinstall side effects are never accidentally swept into a commit. Skipped silently in --dry-run. |
| --git-commit-mode <mode> | How to group commits: per-success (default, one commit per upgrade — best for review and git revert-friendly), per-target (one commit per workspace target with all its successes squashed), or all (one commit at the end with everything). Linked-group upgrades (e.g. react + react-dom) always land in a single commit regardless of mode. |
| --git-commit-prefix <prefix> | String prepended to every commit message (default "deps: "). Use "chore(deps): " for Conventional Commits or set it to your team's preferred convention. |
| --git-branch <name> | Create + checkout this branch before any commits. If the branch already exists, switches to it. Pairs nicely with --ci for PR-bot workflows (e.g. --git-branch "deps/auto-$(date +%Y-%m-%d)"). |
| --git-sign | Pass --gpg-sign to every commit. Requires a signing key configured in git (user.signingkey + gpg.format). Failed signatures are recorded as failed commits in the JSON report rather than aborting the run. |
| --git-allow-dirty | Allow --git-commit to run on a dirty working tree. We still only git add files we touched, so your WIP isn't swept up — but if you also git add your own files manually, they'll land in dep-up-surgeon's commits. |
| --changelog / --no-changelog | Fetch the bumped package's release notes (GitHub Releases first, then its published CHANGELOG.md) and include them in commit bodies + --summary. Default ON when --git-commit or --summary is active. Network failures are non-fatal — missing changelogs are silently skipped. See Changelog excerpts below. |
| --security-only | Run npm audit (or pnpm/yarn equivalent) first, then upgrade only the packages with open advisories. Every successful bump carries the advisory severity + ID into its commit subject ([security:high]) and into the summary's Security fixes table. Pairs well with --git-commit-mode per-success to produce one PR per CVE. See Security-first mode below. |
| --min-severity <level> | Minimum advisory severity to consider under --security-only: low (default), moderate, high, or critical. Lower-severity advisories are filtered out before the upgrade plan is built. |
| --blast-radius / --no-blast-radius | Scan project source files to list which files actually import/require each upgraded package, and surface the list in --json + --summary. Default ON when --summary is active. See Blast radius below. |
| --resolve-peers / --no-resolve-peers | When a linked-group bump (e.g. react + react-dom + @types/react) or a single-package bump fails with a peer-dependency conflict, compute the intersection of peer ranges across the registry packument and retry with a satisfiable version tuple (members may land below latest). Linked graphs with 10+ members automatically use a SAT-style AC-3 solver; single-package failures synthesize an ad-hoc group from direct-dep blockers named in the install output. Default ON. See Peer-range intersection resolver below. |
| --apply-overrides | After the main upgrade loop, fix transitive CVEs that no direct bump could reach by writing a package-manager override (overrides for npm, pnpm.overrides / pnpm-workspace.yaml overrides for pnpm, resolutions for yarn) pinning each vulnerable transitive to its audit-recommended safe version. With npm it first tries npm update <name> and writes no pin when the refreshed lockfile is clean. Runs install + validator after each pin and rolls back automatically when the validator fails. Requires --security-only. See Transitive overrides below. |
| --override <spec...> | Apply one or more manual override pins independent of the audit. Repeatable and also accepts comma-separated values. Syntax: <chain>@<range>, where <chain> is a bare name (lodash), a pnpm-style chain (some-dep>foo, any depth), or a yarn-style chain (parent/child). Scoped names (@scope/pkg) are preserved as single chain segments. Written to the manager-native nested form (npm object, pnpm >-keys, yarn /-keys) and run through the same install + validator + rollback loop as --apply-overrides. Works standalone — --security-only is not required. See Transitive overrides below. |
| --override-force | Used with --apply-overrides. Overwrite an existing override entry whose value conflicts with the audit-recommended version. By default we refuse to clobber user-managed pins and record conflict in the report. |
| --fix-lockfile | After the main upgrade loop, run the package manager's native dedupe command (npm dedupe / pnpm dedupe / yarn dedupe) to collapse redundant transitive copies without touching package.json, and flag transitives more than a minor or a full major behind registry latest. Lockfile is backed up before dedupe and restored if dedupe OR the post-dedupe validator fails. Yarn classic (v1) has no dedupe subcommand — recorded as skipped: "unsupported". See Lockfile fix below. |
| --no-sync-lockfiles | Leave other package managers' root lockfiles alone. By default a lockfile the run's manager doesn't write — package-lock.json next to bun.lock, say — is re-resolved after the run by its own manager in lockfile-only mode, so CI or a host that installs from it gets the upgraded versions too. See Other lockfiles below. |
| --open-pr | After --git-commit --git-branch pushes the branch, open a GitHub PR with the --summary markdown as the body (falls back to a deterministic minimal body). Uses the gh CLI (must be installed + authenticated); never fatal — a missing binary, auth failure, or push rejection is recorded as pullRequest.error in the JSON report without aborting the run. See Auto-opening a PR below. |
| --open-pr-title <title> | Override the PR title. Default: derived from the upgrade counts, e.g. deps: [breaking+security] bump 3 packages. |
| --open-pr-draft | Open the PR as a draft. Recommended with --force or on Fridays so merge-queue bots don't auto-land it. |
| --open-pr-base <branch> | Target base branch. Default: the repo default branch as reported by gh repo view. |
| --open-pr-reviewers <users> / --open-pr-assignees <users> | Comma-separated usernames passed straight to gh pr create --reviewer / --assignee. |
Exit code 1 when any upgrade could not be kept (unless --force). The CLI also exits 1 when the pre-flight validator (run on the unchanged tree) fails — see Pre-flight check below. Fatal errors also exit 1.
Pre-flight check
Before mutating any dependency, the CLI runs the resolved validator command once against the unchanged tree:
- If it passes, the run continues normally.
- If it fails, the run aborts immediately with an error containing the validator command, exit code, and last ~40 lines of output. This prevents the common failure mode where every per-group rollback looks identical because the project build was already broken before the run.
- To proceed anyway, use
--validate "<cmd>"to swap the validator,--no-validateto skip it, or--forceto ignore the pre-flight failure.
Extra check scripts. A build alone can pass while an upgrade breaks linting — TypeScript 7, for example, drops the JS API that typescript-eslint loads, so next build succeeds and npm run lint crashes. With the default validator, pre-flight therefore also runs each lint, typecheck and type-check script the project defines:
- scripts that pass on the unchanged tree run after
test/buildon every upgrade (Pre-flight ok: \npm run build && npm run lint``), and a failure there rolls the upgrade back; - scripts that already fail don't block the run (a warning says so) but still run after every upgrade, compared by exit code: the upgrade is rolled back only when the script exits differently than on the unchanged tree. Existing lint errors (ESLint exit 1) are tolerated; a crash the upgrade causes (ESLint exit 2) is not; an upgrade that fixes the script (exit 0) is fine.
An explicit --validate "<cmd>" / rc validate or --no-validate is used exactly as given. The CRA-style watch-mode trap is handled too: the test script runs with CI=true (unless you already set CI), so react-scripts test runs once instead of waiting forever.
The pre-flight outcome is also surfaced under preflight / preflightAborted in --json output (preflight.extraScripts lists the extra checks, preflight.failingScripts the ones that already failed, with their exit code). A run that aborts at pre-flight changed nothing, so it does not overwrite the last-run report.
Persisted last-run report
After every CLI run that changes the project the structured report is written to node_modules/.cache/dep-up-surgeon/last-run.json under the workspace root (set --no-persist-report to opt out). Like the Nx / Babel / Vite caches it lives in node_modules/.cache, and the directory carries its own .gitignore, so a run leaves only package.json and the lockfile changed in git status. Deleting node_modules (e.g. npm ci) deletes the record too — run undo before that. A report an older version wrote to the project root (.dep-up-surgeon.last-run.json) is only read when the cache one is missing, and undo / --retry-failed then warn with its path, finishedAt and toolVersion, since it can describe a much older run. Writing a new report deletes that root file and any .dep-up-surgeon.last-run.<lockfile> backups, unless git tracks them. --dry-run never writes it, so a dry run can't overwrite the record undo and --retry-failed rely on. The file mirrors the --json output and adds a small header (finishedAt, toolVersion, cwd, dryRun, plus retryFrozen after a --retry-failed run) so CI dashboards / bots can pick it up without re-running the tool. Each upgraded / failed row carries a workspace field when more than one target was traversed — --retry-failed uses that label so a freeze in one member does not skip the same package name in another. When the run changed a lockfile, its pre-run bytes are saved alongside as last-run.<lockfile> (e.g. last-run.bun.lock) for undo.
Retry-failed mode (--retry-failed)
Pass --retry-failed to resume the previous run instead of starting from scratch:
dep-up-surgeonreads the last-run report and freezes every package that either:- succeeded in the last run (no need to redo work), or
- failed for a terminal reason:
peer(real peer-dep conflict; bumping the same package alone almost always fails the same way) orvalidation-script(the project's own test/build script crashed; re-running won't help without a code change).
- Freezes are per workspace, keyed as
workspace::name(for example@org/web::lodash). A success or terminal failure in@org/webdoes not skip the same package in@org/api. Bare--ignore/.dep-up-surgeonrcignore entries remain global (the name is skipped in every workspace). Rows from root-only runs or older reports without aworkspacefield still freeze the bare name everywhere (same as before).root::namefreezes from a--workspacesrun also apply when the retry only targets the root. - Retries chain: a
--retry-failedrun records the keys it froze (retryFrozenin the report), and the next--retry-failedkeeps them frozen on top of that run's own successes and terminal failures. A normal run starts over. - It then re-attempts only the residue: failures classified as
install,validation-conflicts,versions, orunknown. These are the cases where another dependency move during the new run can plausibly unblock them. - Linked-group failures (
name === '[group:<id>]') are expanded to every member of the group via the persistedgroupsfield, scoped to the workspace that owned the group, so freezing a peer-failed group correctly freezes every package in it without leaking to other members. - If the last-run report is missing the CLI exits
1with a friendly message; pass--retry-failedonly after at least one prior run. Falling back to an older version's root report prints a warning with its path and age.
Typical workflow:
dep-up-surgeon --workspaces # first pass: lots of moves, some failures
# fix the script that caused a `validation-script` failure (or accept it)
dep-up-surgeon --retry-failed # second pass: only retries install/conflict residueMonorepo example — last run with --workspaces:
| Package | Workspace | Last-run result | --retry-failed |
|---------|-----------|-----------------|------------------|
| lodash | @org/web | succeeded | frozen (skipped) |
| lodash | @org/api | install failure | retried |
| react | @org/web | peer failure | frozen (skipped) |
| react | @org/api | not in last run | planned normally |
--ignore lodash would still skip lodash in both workspaces. --retry-failed after the table above only skips the @org/web rows.
Summary writer (--summary <md|html>)
Pass --summary md (or --summary html) to render a human-friendly report alongside the normal output:
- GitHub Actions: when
GITHUB_STEP_SUMMARYis set, the Markdown summary is appended to that file — it shows up in the job summary tab without any extra workflow plumbing. - Explicit destination:
--summary-file <path>overrides everything (wins over$GITHUB_STEP_SUMMARY). - Default:
./dep-up-surgeon-summary.<md|html>.
The summary contains: counts (upgraded / failed / skipped), detected project info, target list, an Upgraded table (Package | Workspace | From | To | Notes), a Failed table (Package | Workspace | Reason | Attempted | Detail), pre-flight status when it aborted, and the ignored list. HTML output escapes all dynamic content. Designed to be ~40 lines of code on the producer side and easy to embed in PR comments / dashboards.
The HTML output is self-contained and styled: an inline <style> block scoped to .dep-up-surgeon-report gives severity chips (critical / high / moderate / low), breaking / peer-resolved / fallback / forced badges on the Upgraded table, clickable advisory IDs linking to github.com/advisories/..., and responsive tables. No external CSS or web-font requests — the file opens cleanly in any browser, and when the style tag is stripped (GitHub step-summary sanitizer) the tables still render as plain HTML. Release notes and Blast radius per-package blocks are folded inside <details> elements so the summary stays scannable.
CI / bot mode (--ci)
--ci is a convenience flag for unattended runs (GitHub Actions, GitLab CI, Renovate-style bots). It:
- Disables
--interactiveunconditionally — never blocks on stdin. - Auto-enables
--summary mdso a Markdown report lands in$GITHUB_STEP_SUMMARY(or./dep-up-surgeon-summary.mdoutside Actions). Pass an explicit--summary htmlif you'd rather have HTML. - Remaps the exit code: per-package failures (peer conflicts, install crashes, validation script errors) are recorded in the report and the run still exits
0, so the bot's PR carries the diagnostic instead of the job failing red. Pre-flight failures and fatal errors still exit1— those mean the project itself is broken before any upgrade and a human needs to look.
Typical GitHub Actions step:
- name: dep-up-surgeon
run: npx dep-up-surgeon --workspaces --ciThe job stays green; the Summary tab shows the upgraded / failed tables; node_modules/.cache/dep-up-surgeon/ is cached or uploaded as an artifact (restore it into the next job's node_modules/.cache) so a follow-up --retry-failed job can resume the residue.
Git integration (--git-commit)
Pair dep-up-surgeon with git so every successful upgrade lands as its own atomic commit — perfect for code-review-friendly auto-update PRs.
# One commit per upgrade (best for review).
npx dep-up-surgeon --workspaces --git-commit
# One commit per workspace target (squashed) on a fresh branch.
npx dep-up-surgeon --workspaces \
--git-commit --git-commit-mode per-target \
--git-branch "deps/auto-$(date +%Y-%m-%d)"
# CI bot: per-success commits, Conventional Commits prefix, signed.
npx dep-up-surgeon --workspaces --ci \
--git-commit \
--git-commit-prefix "chore(deps): " \
--git-signThree commit modes:
per-success(default) — one commit per upgrade. Each commit contains exactly thepackage.json+ lockfile diff for one dependency. Trivial to revert any single bump (git revert <sha>) and trivially reviewable in a PR. Linked-group upgrades (e.g.react+react-dom) still land as one commit since they were a single install.per-target— one commit per workspace target, listing every successful upgrade in the commit body. Useful for monorepos where you want each member's bumps grouped.all— one commit at the end with everything. Good for tiny single-package projects; avoid in monorepos.
Safety:
- Refuses to start on a dirty working tree unless you pass
--git-allow-dirty. We don't want to accidentally commit your WIP. - Only stages
package.json+ the lockfile — nevergit add -A(in a Yarn zero-install repo also.pnp.cjs/.pnp.loader.mjs/.yarn/cache, which must matchyarn.lock). Files modified byprepare/postinstallhooks (e.g..husky/) or other side effects ofnpm installstay uncommitted. - Override pins (
--apply-overrides/--override) and the--fix-lockfilededupe run after the upgrades and land in one extra commit (deps: apply 2 override pins + dedupe lockfile), so the branch — and a PR opened from it — has what the report lists. - Errors out cleanly when not in a git repo (instead of silently skipping).
- Skipped silently in
--dry-run(no upgrades happen → nothing to commit). - A failed
git commit(signing rejected, pre-commit hook refused, etc.) is recorded ascommits[].ok === falsein the JSON report with the git stderr — the upgrade itself is never rolled back because of a commit failure.
Concurrency-safe. --git-commit works fine with --workspaces --concurrency 8: every stage + commit runs through one queue, so two targets can't race the git index or commit each other's staged files, and each package's install + validation + commit (and each per-target flush) holds the install lock, so another target's install can't land in the lockfile between an upgrade and its commit.
Structured report. Every commit attempt (success or failure) appears under commits in --json output:
{
"gitCommitMode": "per-success",
"commits": [
{
"ok": true,
"sha": "a1b2c3d",
"message": "deps: bump axios from ^1.6.0 to ^1.7.2",
"files": ["package.json", "package-lock.json"],
"workspace": "root"
}
]
}Changelog excerpts
Every successful upgrade can be annotated with the package's release notes so reviewers don't have to open five GitHub tabs per PR. Enabled by default when --git-commit or --summary is set; disable with --no-changelog.
- Source. First preference is the GitHub Releases API (one
GET /repos/:owner/:repo/releasesper package, tag matched locally), resolved from the package'srepositoryfield in itspackage.json. Package-specific tags are tried before generic ones —<pkg>@<v>,<short-name>@<v>,<short-name>-<v>, thenv<v>,<v>, … — so a monorepo package (create-vite,@vitejs/plugin-react) never picks up the main package'sv<v>notes. Fallback is theCHANGELOG.mdextracted from the published tarball viapacote.extract— the matching version section is parsed out with a Markdown-aware heading scanner (handles## 1.2.3,## [1.2.3] - 2024-...,## v1.2.3, etc.). - No pings, no auto-closed issues. Release notes land in your commits and PR bodies, so upstream
@mentionsand issue refs (#123,owner/repo#123,GH-123) are wrapped in backticks: they read the same, but GitHub neither notifies those people nor links (or, viafixes #123, closes) issues in your repo. - Where it shows up. In
--git-commit-mode per-success, the excerpt is embedded directly in the commit body. Inper-target/allmodes it collapses to a compactSee: <release-url>footer so the commit doesn't balloon.--summary md/--summary htmlrenders each excerpt in a collapsible<details>block — clean in PR bodies, compact in GitHub's Job Summary. - Caching & resilience. A run-local cache deduplicates fetches across workspaces. Network errors, missing tags, private repos, and malformed
CHANGELOG.mdfiles are all silently skipped — a missing excerpt never fails a commit. - GitHub auth. Anonymous GitHub API requests are rate-limited to 60/hour. Set
GITHUB_TOKEN(orGH_TOKEN) in the environment to lift that to 5,000/hour —dep-up-surgeonuses it automatically for changelog fetches and nothing else.
Security-first mode
--security-only flips the tool from "bump everything safely" to "bump only packages with known CVEs". Competes directly with Dependabot's security-alert surface, but runs locally and respects your validator / policy / link groups.
- Runs
npm audit --json(orpnpm audit --json/yarn auditdepending on the detected manager) before the upgrade plan is built. - Filters the audit to advisories at or above
--min-severity <low|moderate|high|critical>. - Builds a
restrictToNamesset from the vulnerable package names and passes it to the engine — every other dependency gets added to the ignore list automatically (visible asreason: "ignored"in the report). - Attaches the severity + advisory ID + title to every upgraded record's
securityfield, which the CLI then propagates into:- Commit subjects:
deps: [security:high] bump axios from 1.6.0 to 1.7.2 - Commit bodies: full advisory ID, URL, and title
--summary: a prominent Security fixes table above the normal upgraded table--json:upgraded[].security = { severity, ids, url, title, vulnerableRange, recommendedVersion }
- Commit subjects:
- Advisories on a package that no direct dependency names can't be fixed by an upgrade. They're listed as warnings with a way out —
npm update <name>when the packages depending on it allow the fixed version, or--apply-overridesto pin it — and undertransitiveAdvisoriesin--json.
# Only critical + high; one commit per CVE on a dedicated branch.
npx dep-up-surgeon --workspaces --security-only --min-severity high \
--git-commit --git-commit-mode per-success \
--git-branch "deps/security-$(date +%Y-%m-%d)"The whole path is covered by test/unit/security-only.test.mjs — a hermetic regression harness that drives runAudit with a canned npm audit --json blob, asserts --min-severity filters at every tier, and exercises the full runUpgradeFlow → install → validator → rollback cycle without touching the registry (via the UpgradeFlowOptions.installer injection point).
Policy engine (policy-as-code)
Drop a .dep-up-surgeon.policy.yaml (or .json) in the repo root to encode upgrade rules that survive across runs and humans. Loaded automatically on startup; violations are reported per-package and the engine skips the offending bumps instead of failing.
# .dep-up-surgeon.policy.yaml
freeze:
- pattern: react # never touch it
reason: "React 18 pinned until Q3 refactor"
- pattern: "@types/*" # wildcard — freezes every @types/* scope
maxVersion:
- pattern: next
range: "<=14" # refuse anything outside this semver range
allowMajorAfter:
- pattern: eslint
date: "2026-06-01" # patch/minor OK now, majors blocked until the date
requireReviewers: # metadata: surfaced in --summary / --json for your bot to consume
major: 2
minor: 1
patch: 0
autoMerge: # metadata: ditto
patch: true
minor: false
include:
- "eslint-plugin-*"How rules interact
freezealways wins. Exact names go straight into the ignore list; wildcards are matched against the scanned deps inside the engine so rules like@types/*don't have to be unrolled by hand. Freezes produce areason: "policy"skip record with the originating pattern.maxVersioncaps the candidate list. If no candidate satisfies the range, the package is skipped withreason: "policy"— it won't degrade to a no-op install. Caps (andallowMajorAftergates) hold everywhere a version is picked: single upgrades, linked-group members (a capped member moves to the newest allowed release), coordinated fallbacks, the peer-range resolver, and--dry-runplans.allowMajorAfterblocks cross-major bumps until the specified date (checked againstDate.now()), demoting the candidate to the newest in-major version. Patch/minor still flow through normally.requireReviewersandautoMergeare metadata only — attached to thepolicyblock of--json+--summaryfor downstream automation (GitHub Actions PR-opener, the SaaS bot, etc.) to consume.
Every applied rule appears in the Policy section of --summary and under policy.applied / policy.frozen / policy.warnings in --json, so audits show exactly which rule blocked which package.
Blast radius
Before handing the PR to a reviewer, dep-up-surgeon can list which of your own source files actually import each upgraded package. Surfaced automatically under --summary; attach it to --json too with --blast-radius.
- Scans:
.ts,.tsx,.js,.jsx,.mjs,.cjs,.mts,.cts,.vue,.svelte,.astro. - Skips:
node_modules,.git,.next,.turbo,.vercel,.cache,.parcel-cache,.outputat any depth; the build-output foldersdist,build,out,coverageonly at the project root and in package roots (any folder with its ownpackage.json, e.g. workspace members) — asrc/build/orlib/out/folder deeper down is scanned as source. - Detects: ES imports (
import x from '<pkg>'), re-exports (export … from '<pkg>'), CommonJSrequire('<pkg>'), dynamicimport('<pkg>'), and subpath imports (from '<pkg>/sub'still counts as a hit on<pkg>). Word-boundary safe — looking forreactdoes not falsely matchreact-dom; looking for@types/nodedoes not match@types/node-ipc. Type packages count for the package they type:@types/reacthits files importingreact,@types/babel__corehits@babel/core(@types/nodekeeps its literal name). - Output: per-package
{ total, truncated, files[] }entries inupgraded[].blastRadius, plus a collapsible per-package list in the Markdown / HTML summary. Caps at 20 file paths per package by default;totalkeeps counting past the cap so the summary can honestly say "used in 134 files". - Cost: a single pass over the tree, at most 1 MB read per file, parallel I/O (default concurrency 8). Failures are non-fatal — a broken symlink never aborts the run. Turn it off in huge monorepos with
--no-blast-radius.
Breaking-change detection
Whenever a changelog excerpt is fetched, dep-up-surgeon scans it for breaking-change markers and flags the upgrade so reviewers catch them before clicking merge. Works alongside --changelog (enabled by default with --git-commit / --summary) with no extra flags.
- What we match:
BREAKING CHANGE:/BREAKING CHANGES:footers (Conventional Commits), the💥and⚠️ BREAKINGemoji conventions used by Changesets / tsup / Vitest, explicit Node-version drops (drop support for Node 16,requires Node >= 20), API-removal bullets (- Removed the …), andno longer supported/renamed … to …phrasing. Deprecation notices alone do not trip the scan, and neither does a release template's## Breaking Changesheading with nothing (orNone/N/A) under it. - What we scan: the full release notes, not the 30-line excerpt shown in commits / summaries. When the bump crosses a major (or a
0.xminor) —1.4.0 → 3.2.1,0.3.2 → 0.5.1— the notes of the first release of each crossed line (3.0.0,2.0.0; nearest 3 at most) are scanned too, since a patch-level target rarely repeats its major's breaking changes. They come from the same source: free from the GitHub releases page / the tarball'sCHANGELOG.md, or one extra GitHub lookup each when they fell off a full releases page. Lines found there are prefixed with their version (2.0.0: Dropped support for Node 16). - Where it shows up:
- Commit subjects gain a
[breaking]tag (emitted BEFORE[security:<sev>]when both apply):deps: [breaking][security:high] bump axios from 1.6.0 to 2.0.0. - Commit bodies get a
Breaking changes detected:section listing the exact matched lines, capped at 5 per package. --summary md|htmlrenders a prominent⚠️ Breaking changes detectedsection ABOVE the upgraded table, plus a⚠️ breakingbadge in the Notes column.--json→upgraded[].changelog.breaking = { hasBreaking, matchedLines[], reasons[] }(only present when the scan matched).
- Commit subjects gain a
- Never fatal, never noisy: absence of a changelog means no scan, which means no flag. The scan caps matches at 10 per package and dedupes identical lines so verbose changelogs don't drown out the signal.
Peer-range intersection resolver
Linked-group bumps (e.g. react + react-dom + @types/react, or the Jest / Testing-Library / Vitest families) frequently fail because one member's latest demands a peer version another member can't yet satisfy. Without help, the whole batch rolls back and the user has to figure out the right tuple by hand.
--resolve-peers (default ON) turns this into an automated constraint-satisfaction problem:
- The first batch attempt runs exactly like today — every linked member goes to its registry
latest. - If the install fails with a peer conflict, the resolver fetches each linked package's full registry packument (cached — one call per package per run) and reads every published version's
peerDependenciesblock. - Each member gets a candidate domain: every version between its installed version (the lockfile entry for that workspace; else
currentRange'sminVersion) and the originally-requested target, sorted newest-first, minus deprecated / pre-release versions. - A newest-first backtracking search enumerates version tuples (variable = one package, domain = its candidate versions). For each partial assignment, every peer constraint that has become knowable is checked; peers on packages inside the linked group are checked against the chosen version, peers on packages OUTSIDE the group are checked against that package's installed version (lockfile), else the floor of its range in the current
package.json(viasemver.minVersion). - For large linked graphs (≥ 10 members) — where the 400-tuple backtracking budget can be burned before the solver escapes the first variable's domain — the resolver automatically switches to a SAT-style path (arc-consistency + least-constraining-value DFS). It pre-prunes every member-version that can't be satisfied against external peers, runs up to 128 AC-3 rounds across every ordered member pair until the pruned domains reach a fixed point, then does an MRV-ordered (smallest domain first) newest-first DFS on whatever survived. For monorepo link groups up to ~50 members × ~30 recent versions this finishes in milliseconds where plain DFS would return
undefined. When the SAT path fails the dispatcher falls back to the plain backtracker automatically. - The first complete tuple to satisfy every constraint is also the least-downgrade one. The engine rewrites the batch's target versions and retries the install + validator. On success, every affected row is tagged with
resolvedPeer = { originalTarget, reason, tuplesExplored }—reasoncarries a[backtracking]or[sat]method tag so reviewers can tell which solver path produced the tuple. - If the resolver can't find a satisfiable tuple, or the retried install still fails, the batch falls back to the pre-resolver behavior (rollback +
kind: 'peer'failure row).
Ad-hoc resolver for non-linked bumps. Single-package upgrades that fail with a peer conflict used to be rolled back unconditionally — the resolver was linked-groups-only. Now we synthesize an ad-hoc group from the parsed install output: the primary + every blocker named in the peer-conflict lines that's already a direct dep of the workspace (peers on unknown transitives stay out of scope). The same resolver (and the same SAT fallback) runs on that synthesized group. On success the engine writes a small batch: the primary at whatever version the resolver picked, plus any blocker the resolver wants moved within its current pinned range (the ad-hoc path is allowed to downgrade the primary, never to silently bump a blocker past its pin).
Blockers come from the conflict itself: npm's Could not resolve dependency: / Conflicting peer dependency: lines, plus dependents listed under Found: <pkg>@<version> whose range that version does not satisfy — satisfied ones (peer react@">=16.8.0" from @emotion/[email protected] under Found: [email protected]) are the current tree, not the conflict. Each blocker is modelled at its installed version (the lockfile entry for that workspace) and never moved below it: bumping react 19.2.8 → 19.3.0 while @react-three/[email protected] needs react <19.3 settles on the newest react that fiber 9.7.0 accepts, instead of rolling fiber back to an older release that allowed 19.3. When the only answer is the primary staying at its installed version, nothing is retried and the bump is reported as a peer failure.
Guard rails that keep it safe:
- Bounded search — capped at 400 tuples explored per batch (small graphs) or
400 × memberstuples for the SAT path's DFS phase. Past that the resolver gives up silently instead of hanging the run on pathological inputs. - Optional peers (
peerDependenciesMeta[name].optional === true) are ignored. An unsatisfied optional peer isn't a hard conflict. - Installed direct deps outside the batch still bound it — each member's candidates are narrowed to versions that satisfy the peer ranges declared by the installed direct dependencies that aren't being bumped (a package already at latest never joins the batch, but its peers still count).
- Deprecated versions never appear in the domain. We'd rather fail to find a solution than auto-suggest a known-bad version.
- Ad-hoc group size cap — default 6 members (primary + up to 5 direct-dep blockers). Prevents registry fetch storms on pathological peer graphs.
- Ad-hoc never adds dependencies — a peer on a transitive that isn't already a direct dep is ignored rather than introduced.
--forcebypasses the resolver — the user has explicitly opted into barreling through peer conflicts.--no-resolve-peerskeeps the old behavior when you WANT peer failures to surface so a human resolves them instead of the tool silently nudging versions off latest. Applies to both the linked-group and ad-hoc paths.
Where it shows up:
- Console output:
upgraded: react-dom → 18.3.1 (group react-pair) [peer-resolved from 19.0.0]. --summary md|html: a dedicated Peer-range resolutions table (package / group / requested / installed / tuples explored) above the upgraded table, plus apeer-resolved from <v>badge in the upgraded row's Notes column.- Commit subjects:
[peer-resolved]tag sits between[breaking]and[security:<sev>](stable order). The body gets aPeer-range resolutions (kept linked group satisfiable):footer listing each pinned member. --json:upgraded[].resolvedPeer = { originalTarget, reason, tuplesExplored }plusupgraded[].requestedLateststill reflects the pre-resolver target so downstream tools can diff them.- Kept at the installed version: when the resolver's tuple is what's already installed, the group isn't installed and validated again. The members are reported as skipped
no changerows withrequestedLatestandblockedBy: [{ name, version, range }], e.g.@react-three/fiber9.7.0needingreact>=19 <19.3.
Transitive overrides (--apply-overrides / --override)
--security-only by itself can only fix vulnerabilities reachable from a direct dependency. For CVEs that live in transitives (very common — [email protected] buried six levels deep under a toolchain package), pair --security-only with --apply-overrides and the tool will write a package-manager override to pin the vulnerable transitive to its safe version.
- Which field:
overridesfor npm (>=8.3) and bun,pnpm.overridesfor pnpm,resolutionsfor yarn (classic + berry). With pnpm, pins go to theoverridesmap ofpnpm-workspace.yaml(comments kept) when that file holds one, or when it exists and package.json has nopnpm.overridesblock yet — pnpm 10+ reads it and pnpm 11 ignorespnpm.overrides. ApackageManagerofpnpm@9or older keeps new pins in package.json. An existing pin is always rewritten where it lives. - Lockfile refresh first (npm): before pinning, the tool runs
npm update <name>. When no copy left in the lockfile is in the advisory's vulnerable range and the validator passes, the advisory is fixed without an override (method: "lockfile-update",appliedlists the installed versions). Otherwisepackage.jsonand the lockfiles are put back byte-for-byte and the override is pinned as usual. Skipped when a pin for the package already exists or the audit gave no vulnerable range. - How it picks the pin: uses the audit's own
fixAvailable.versionwhen present; otherwiseminVersionof the first safe range the manager reported. - Rollback on failure: after each override, the tool runs a full install and then the validator. If either fails,
package.json,pnpm-workspace.yamland every root lockfile get their pre-attempt bytes back (so the rejected version doesn't stay locked), install re-runs to restore the starting state, and the next advisory is still attempted. A failed override never strands the workspace —report.overrides.attempts[].rolledBack === trueappears in the JSON and the summary. - Conflict protection: when the user already has a manual override with a value that conflicts with the audit recommendation, we refuse to clobber by default (
reason: "conflicts with target ..."). Pass--override-forceto overwrite explicitly. Pins written as unquoted YAML numbers (qs: 6) count as existing pins too. Pinning a package that already carries child pins ("foo": { ".": "1.0.0", "bar": "2.0.0" }) updates its"."and keeps the children. - Where it shows up:
--summary: dedicatedOverrides appliedtable withPackage / Pinned to / Source / Severity / Advisory. Parent-scoped pins render asa › b › cso the chain is visible at a glance; advisories fixed bynpm updateshowupdated in lockfile (<versions>)instead of a pin.--json:overrides.field+overrides.attempts[]with the full decision trail (ok,skipped,reason,previous,applied,installLog,rolledBack,chain,source,method). Parent-scoped pins carrychain: ["parent", "child"];sourcedistinguishes"advisory"from"manual";methodis"lockfile-update"when no pin was written (absent = an override).
Parent-scoped pins (--override)
--apply-overrides only writes the flat name → version form — every occurrence of the package gets pinned. When you need to pin a transitive only when it appears under a specific parent (e.g. you want [email protected] under some-dep while the rest of the tree uses [email protected]), use --override to write a parent-scoped selector. Works standalone — no --security-only required.
Syntax: <chain>@<range>. The chain supports three forms, all normalized internally:
- Flat:
--override [email protected]→ same shape as a classic flat override. - pnpm-style:
--override "some-dep>[email protected]"— pinfooonly when nested undersome-dep. Chains of any depth (a>b>c>[email protected]) are supported. - yarn-style:
--override "parent/[email protected]"—/separator;@scope/pkgstays intact as a single chain segment.
Each selector is written to the manager's native nested encoding:
| Manager | Shape written |
| --- | --- |
| npm | { "overrides": { "some-dep": { "foo": "1.2.3" } } } — nested object; an existing flat pin for the parent is preserved via npm's "." self-selector. |
| pnpm | { "pnpm": { "overrides": { "some-dep>foo": "1.2.3" } } } — pnpm's >-chain keys, deep chains supported. Lands in pnpm-workspace.yaml overrides instead under the rules in Which field above. |
| yarn | { "resolutions": { "some-dep/foo": "1.2.3" } } — /-chain keys. |
| bun | Flat pins only ({ "overrides": { "foo": "1.2.3" } }): Bun ignores nested overrides, so a parent-scoped selector is skipped with reason: "bun only supports top-level overrides; …". |
Every pin runs through the same install + validator + rollback loop as advisory-driven pins. A failed manual pin is rolled back (only that specific slot) and the rest of the run continues; a flat pin and a parent-scoped pin with the same leaf name coexist as separate entries.
# Pin `[email protected]` ONLY when it's a transitive of `some-dep`, and pin `[email protected]`
# globally. Both live in the same run; one failing never touches the other.
npx dep-up-surgeon \
--override "some-dep>[email protected]" \
--override "[email protected]" \
--validate "npm test"# Weekly security sweep: direct bumps first, then transitive overrides (audit-driven +
# one manual pin), then a draft PR.
npx dep-up-surgeon --workspaces \
--security-only --min-severity high \
--apply-overrides \
--override "@babel/core>@babel/[email protected]" \
--git-commit --git-commit-mode per-success --git-branch "deps/security-$(date +%Y-%m-%d)" \
--summary md \
--open-pr --open-pr-draftOverride policy file (.dep-up-surgeonrc overrides)
Re-typing --override "parent>[email protected]" on every CI run gets old fast. Commit the pins to .dep-up-surgeonrc instead and they'll apply on every run the same way ignore does — merging with any CLI --override flags on the same invocation (CLI wins on chain conflict). The committed form supports a reason string that flows straight into the report + summary so reviewers can see why each transitive is pinned (CVE ID, vendor guidance, upstream PR link) without grepping commit history.
Two input shapes are accepted:
{
"overrides": [
// Structured: explicit chain + range. `chain: "lodash"` is shorthand for the flat case.
{ "chain": ["some-dep", "foo"], "range": "1.2.3", "reason": "CVE-2025-1234" },
// Selector form: same syntax as the `--override` CLI flag.
{ "selector": "@babel/core>@babel/[email protected]", "reason": "upstream PR #16012 pending" },
{ "selector": "[email protected]" }
]
}Behavior:
- Merge + dedupe: entries are merged with CLI
--overrideselectors by exact chain. A CLI pin for the same chain replaces the rc entry (including thereason), so one-off ad-hoc overrides always win over committed policy. - Malformed CLI selectors are warnings, not fatal: a typo in one
--overrideflag won't prevent committed rc pins from applying. rc entries with a bad shape produce per-entry warnings in.dep-up-surgeonrc.warningsand the run continues. - Same lifecycle as
--override: every pin goes through the install + validator + rollback loop. Failures are per-pin — one bad pin never strands the rest. reasonsurfaces in the report: theOverrides appliedtable in--summaryadds aReasoncolumn whenever at least one attempt carries one;--jsonshipsoverrides.attempts[].policyReasonverbatim for bots.
# Run the committed overrides policy. No CLI flags needed; `overrides: [...]` in
# `.dep-up-surgeonrc` is enough to trigger the flow.
npx dep-up-surgeon --summary md
# Add a one-off pin on top of the committed set for this run only:
npx dep-up-surgeon --override "[email protected]" --summary mdDisaster recovery (dep-up-surgeon undo)
Every run writes a last-run report (node_modules/.cache/dep-up-surgeon/last-run.json) — a structured record of what the tool did (previous ranges, to values, every override attempt with applied/previous, workspace targets). dep-up-surgeon undo replays that record in reverse:
- For every successful upgrade row, write the recorded
fromback topackage.json(in the section the row recorded, or — for older reports — the section that holds the recordedto, across the root and every workspace target). - For every successful override attempt, newest first, drop the pin we added — or, when the attempt recorded a
previousvalue (the run replaced an existing pin), restore that previous value where the pin lives (package.jsonorpnpm-workspace.yaml). Newest first means a slot pinned twice in one run ends at its pre-run value; a parent the run turned from"x"into{ ".": "x", child }goes back to"x". Advisories fixed bynpm updatewrote no pin: step 3's lockfile restore reverts them. - Put back the pre-run bytes of every root lockfile the run changed (the run saved them next to the report as
last-run.<lockfile>), then run<manager> installonce per edited target. Reinstalling alone isn't enough: a reverted^16.3.1still allows the16.3.5the run installed, so without the backup those versions would stay. - Run the validator so you see green/red before you commit the revert.
Drift protection:
- If the current
package.jsonvalue for a dep doesn't match thetothe run landed on (another run, or a human edit, moved it), the row is skipped withreason: 'drifted'. The same goes for an override pin that no longer holds theappliedvalue the run wrote. Undo never rewrites state we don't recognize. - Paths in the report are taken relative to the recorded run's
cwdand applied under the directory undo runs in (--cwd, else the current one). Replaying a CI artifact with--filetherefore edits this checkout, never the path recorded on the CI machine; when none of the report'spackage.jsonfiles exist here, undo exits1. - When
node_modules/.cache/dep-up-surgeon/last-run.jsonis missing and an older version's root report is used instead, undo warns with that file's path,finishedAtandtoolVersion(warningsin--json). - The lockfile backup is only restored when every row reverted and the lockfile still hashes to what the run left behind; otherwise undo says why (
lockfile bun.lock: not restored (…)) and falls back to reinstalling from the reverted ranges. - When the recorded run was
--dry-run, undo is a no-op. - Missing / unparseable run report → the command exits with status 2 and a clear error message.
# Replay the newest recorded run in this directory.
npx dep-up-surgeon undo
# Compute the reverse plan WITHOUT touching the disk. Use for review/CI dry-runs.
npx dep-up-surgeon undo --dry-run
# Replay a specific run file (e.g. from a CI artifact).
npx dep-up-surgeon undo --file ./ci-logs/2026-04-18-upgrade.last-run.json
# Skip the validator — handy when the project has no test script but you still want the
# dep ranges rolled back.
npx de