dep-up-surgeon
v2.2.8
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 --helpUsage
From your project root (where package.json lives):
dep-up-surgeon [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. |
| --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 (merged with .dep-up-surgeonrc). |
| --json | Machine-readable report on stdout (see JSON report). |
| --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. |
| --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. 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, or yarn. auto reads the packageManager field, then falls back to lockfile detection (pnpm-lock.yaml → pnpm, yarn.lock → yarn, 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). |
| --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). |
| --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 uses pnpm install --filter <name>, yarn berry (v2+) with @yarnpkg/plugin-workspace-tools uses yarn workspaces focus <name>, and yarn classic / berry without the plugin falls back to a full root install with a one-time warning explaining the upgrade path. The capability is auto-detected at startup (yarn version + plugin probe) and reported as project.yarnMajorVersion + project.yarnSupportsFocus in --json. 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 (pnpm shared-workspace-lockfile=false, or every workspace member 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 | Read .dep-up-surgeon.last-run.json from the previous run and only re-attempt entries that failed for non-terminal reasons (install, validation-conflicts, versions, unknown). Successful upgrades + terminal failures (peer, validation-script) from the last run are added to the ignore list automatically. See Persisted last-run report below. |
| --no-persist-report | Do not write .dep-up-surgeon.last-run.json after the run. By default the structured report is written next to the workspace root for --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 — 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 for pnpm, resolutions for yarn) pinning each vulnerable transitive to its audit-recommended safe version. 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. |
| --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.
The pre-flight outcome is also surfaced under preflight / preflightAborted in --json output.
Persisted last-run report
After every CLI run the structured report is written to .dep-up-surgeon.last-run.json next to the workspace root (set --no-persist-report to opt out). The file mirrors the --json output and adds a small header (finishedAt, toolVersion, cwd, dryRun) so CI dashboards / bots can pick it up without re-running the tool. Add it to your .gitignore if you don't want it tracked.
Retry-failed mode (--retry-failed)
Pass --retry-failed to resume the previous run instead of starting from scratch:
dep-up-surgeonreads.dep-up-surgeon.last-run.jsonand 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).
- 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, so freezing a peer-failed group correctly freezes every package in it. - If
.dep-up-surgeon.last-run.jsonis missing the CLI exits1with a friendly message; pass--retry-failedonly after at least one prior run.
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 residueSummary 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; .dep-up-surgeon.last-run.json is committed (or uploaded as an artifact) 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. Files modified byprepare/postinstallhooks (e.g..husky/) or other side effects ofnpm installstay uncommitted. - 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: the same async mutex that serializes installs also serializes git invocations, so two targets can't race the index.
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 (
GET /repos/:owner/:repo/releases/tags/:tag), resolved from the package'srepositoryfield in itspackage.json. 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.). - 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:
# 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: 2 # metadata: surfaced in --summary / --json for your bot to consume
autoMerge: false # metadata: dittoHow 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.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,dist,build,coverage,.git,.next,.turbo,.vercel,.cache,.parcel-cache,out,.output. - 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. - 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. - 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
currentRange'sminVersionand 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 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).
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. - 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.
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),pnpm.overridesfor pnpm,resolutionsfor yarn (classic + berry). - 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, the override is removed, 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. - 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.--json:overrides.field+overrides.attempts[]with the full decision trail (ok,skipped,reason,previous,applied,installLog,rolledBack,chain,source). Parent-scoped pins carrychain: ["parent", "child"];sourcedistinguishes"advisory"from"manual".
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. |
| yarn | { "resolutions": { "some-dep/foo": "1.2.3" } } — /-chain keys. |
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 .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 whatever section currently holds the dep, across the root and every workspace target). - For every successful override attempt, drop the pin we added — or, when the attempt recorded a
previousvalue (the run replaced an existing pin), restore that previous value. - Run
<manager> installonce per edited target so the lockfile re-converges to the revertedpackage.json. - 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'. Undo never rewrites state we don't recognize. - 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 dep-up-surgeon undo --no-validateExit codes: 0 = reverse pass succeeded (or was a no-op); 1 = install or validator failed during the reverse pass (the JSON report still explains which rows moved); 2 = the run report was missing / invalid. Pair with --json for CI pipelines — the full UndoResult is emitted on stdout.
Lockfile fix (--fix-lockfile)
--fix-lockfile improves the lockfile's dependency graph without touching package.json. It's the counterpart to --apply-overrides: where overrides fix vulnerable transitives, --fix-lockfile collapses redundant ones and surfaces the stale-but-not-vulnerable tail that a direct upgrade loop can never reach.
What it does:
- Dedupe: runs the package manager's native dedupe command —
npm dedupe --no-audit/pnpm dedupe/yarn dedupe(berry only). These commands collapse multiple copies of the same package when semver ranges allow it. - Stale-transitive scan: for the top 250 packages in the lockfile (by installed-copy count), cross-references registry
latestand flags any package whose highest installed version is more than one minor OR a full major behind. Trivial drift (a patch or a single minor) is filtered out — only the drifted-by-6-months cases show up. - Backup + rollback: lockfile is snapshotted before dedupe. If dedupe exits non-zero OR the post-dedupe validator fails, the snapshot is restored and the manager is re-run to reconcile
node_modules. The worst case is the same tree you started with.
Guard rails:
- Yarn classic (v1) has no
dedupesubcommand — recorded asskipped: "unsupported"and the rest of the run continues normally. - No lockfile on disk →
skipped: "no-lockfile". Nothing to dedupe when the install has never been run. - Runs after
--apply-overridesso the final tree includes security pins before dedupe.
Where it shows up:
- Console: one-line summary —
--fix-lockfile: npm dedupe ... succeeded (12 packages deduped/updated, 3 stale transitives flagged). --summary md/--summary html: a dedicated Lockfile fix section with amerged/updateddiff table, a collapsible Stale transitives details block, and the last lines of the dedupe/validator output on failure.--json:lockfileFix: { status, manager, lockfile, command, dedupeChanges[], stale[], ... }with the full structured diff.
# Post-security-sweep cleanup: dedupe the tree and surface stale transitives in the PR body.
npx dep-up-surgeon --security-only --apply-overrides --fix-lockfile --summary mdDoctor subcommand (dep-up-surgeon doctor)
doctor is a read-only diagnostic that answers one question: "is this project in good shape for an upgrade pass right now?". Run it before trusting an upgrade loop (or as a CI pre-check); it never mutates anything. Output is a traffic-light report — green/yellow/red per check with a remediation hint on anything non-green.
What it checks, in order (stable IDs for --json consumers):
node-version— current Node satisfiesengines.node(if set). Red when a mismatched Node would tear down peer-dep resolution in ways that look like CVE-driven failures later.manager— a single package manager was resolved cleanly. Yellow when multiple lockfiles coexist or the tool had to fall back to thenpmdefault without any signal.lockfile— the lockfile is parseable. Yellow on npm v1 shape (upgrades to v2 recommended); red on unreadable / corrupt files.workspace-coherence— declared workspace members resolve on disk with their ownpackage.json.policy—.dep-up-surgeon.policy.{yaml,json}(when present) parses without warnings.preflight-validator— your<mgr> test/<mgr> run build(or--validate <cmd>) passes right now, before any upgrade. Red here means the project is broken before the upgrade loop — fix that first or every failure downstream will look like a regression.peer-deps— existing peer / missing dep warnings (vianpm ls --all,pnpm install --frozen-lockfile --offline, oryarn check). Catches "already broken before you touched it" cases.audit—<mgr> auditdry-run with severity breakdown. Red on any high/critical advisory, yellow on low/moderate.stale-transitives— up to 100 transitives scanned against registrylatest; yellow when any are more than a minor or a full major behind. Informational (never red); run--fix-lockfileto clean up the easy ones.
Exit codes:
0— all checks green (or yellow-only without--strict)1— any yellow under--strict2— any red
Options are focused (no entanglement with the 70+ upgrade-flow flags):
| Option | Description |
|--------|-------------|
| --json | Emit the full DoctorReport as JSON on stdout instead of the human format. |
| --strict | Treat yellow checks as failures (exit 1 instead of 0). Use for CI gates. |
| --no-validate | Skip the pre-flight validator check. |
| --validate <cmd> | Override the validator command used by the pre-flight check. |
| --skip-audit | Skip the audit dry-run. Use for air-gapped CI / offline dev. |
| --skip-peer-scan | Skip the peer-dep scan (slow on huge trees). |
| --skip-stale-scan | Skip the registry-backed stale-transitive scan. |
| --package-manager <mgr> | Override detected manager: auto, npm, pnpm, yarn. |
| --cwd <path> | Run against a different directory. |
# Quick CI pre-check
npx dep-up-surgeon doctor --strict --json
# Local "should I trust the upgrade loop?" check
npx dep-up-surgeon doctorAuto-opening a PR (--open-pr)
When you've already paid the cost of running --git-commit --git-branch, --open-pr closes the loop by pushing the branch and opening a GitHub pull request via the GitHub CLI (gh). Uses your existing gh auth; the tool handles nothing sensitive.
- Body: the Markdown
--summaryfile when one was written, otherwise a deterministic minimal body listing upgraded packages.gh pr create --body-file -is used so the body is piped via stdin (no argv quoting hell for multi-KB Markdown). - Title: derived from the upgrade counts — e.g.
deps: [breaking+security] bump 3 packages— or any string you pass via--open-pr-title. - Base branch: resolved from
gh repo viewwhen not explicitly given; respects your default branch setting. - Reuses existing PRs: if a PR already exists for the same head branch, we return
{ reused: true }instead of erroring. - Never fatal: a missing
ghbinary, an unauthenticated session, a rejected push, or a 4xx from the API is recorded aspullRequest.errorin the JSON report and printed to stderr — the upgrade commits are still on disk, and a subsequent manualgh pr createorgit pushwill work normally. - Draft mode: pass
--open-pr-draftto open as a draft (recommended with--forceor when the breaking-change badge fires).
# Full "open a proper PR" flow with reviewers + draft mode.
npx dep-up-surgeon --workspaces --summary md \
--git-commit --git-commit-mode per-success --git-branch deps/weekly \
--open-pr --open-pr-draft \
--open-pr-reviewers alice,bob --open-pr-base mainWorkspaces & package managers
dep-up-surgeon is workspace-aware:
Detection. On startup the tool resolves the package manager (
npm/pnpm/yarn) by reading, in order: the--package-managerflag, thepackageManagerfield inpackage.json, the lockfile (pnpm-lock.yaml→ pnpm,yarn.lock→ yarn,package-lock.json→ npm), the presence ofpnpm-workspace.yaml, and finally falls back tonpm. Workspace globs are read fromworkspaces(npm/yarn — both array and{ packages: [...] }forms are supported) orpnpm-workspace.yaml(packages:list).Install + validator follow the manager.
<mgr> installruns after each bump and the default validator becomes<mgr> test→<mgr> run build(yarn classic usesyarn build). Override with--validate "<cmd>"if you need something different (e.g.pnpm -r build).Workspace-internal deps are skipped automatically. If a dependency name matches a local workspace package, the tool does not try to resolve it from the npm registry — it appears in the report as
skippedwithdetail: "workspace-internal dep …". Pass--include-workspace-depsto override (useful when those packages are also published).Workspace child traversal (
--workspaces/--workspaces-only/--workspace <names>). By default only the rootpackage.jsonis mutated. With--workspaces, the tool also scans every member'spackage.json(one engine pass per file), but install + validation always run from the workspace root so the lockfile resolves correctly and the validator sees the entire monorepo. Pre-flight runs once at the workspace root regardless of how many targets are traversed. Everyupgraded/failedrow in the report is tagged with aworkspacefield ("root"or the member's packagename) so you can tell at a glance whichpackage.jsonproduced each change.Install mode (
--install-mode root|filtered). Default isroot: every per-child mutation triggers a full<mgr> installfrom the workspace root — slow on large monorepos but supported by every package manager and impossible to misconfigure. Pass--install-mode filteredto rewrite per-child installs to their workspace-scoped form so only the affected member is resolved/linked:- npm 7+ →
npm install --workspace <name> - pnpm →
pnpm install --filter <name> - yarn berry (v2+) with
@yarnpkg/plugin-workspace-tools→yarn workspaces focus <name>(install the plugin once withyarn plugin import workspace-tools) - yarn classic (v1.x) → falls back to a full root install with a one-time warning suggesting an upgrade to yarn berry
- yarn berry without the plugin → falls back to a full root install with a one-time warning telling you the exact
yarn plugin importcommand to fix it
The yarn capability is auto-probed at startup (
yarn --version+yarn workspaces focus --help) and surfaced asproject.yarnMajorVersionandproject.yarnSupportsFocusin--json. The mode actually used is recorded asinstallModein the report, and the exact filtered command appears underfailed[].install.commandwhen an upgrade rolls back.- npm 7+ →
Parallel target traversal (
--concurrency <n>). With more than one target, pass--concurrency 4(or up to16) to run target scans + plans concurrently. Registry IO (pacote.manifest/pacote.packument) is the slow part of each engine pass and is fully parallel-safe — overlapping it across targets gives a real wall-clock speedup on monorepos with many workspaces. In a shared-lockfile monorepo (the common case) installs and validations stay serialized under a keyed async mutex because they all touch the same root lockfile andnode_modules; running them in parallel would corrupt the lockfile. In an isolated-lockfile monorepo (pnpmshared-workspace-lockfile=false, or every workspace member shipping its own lockfile) the installs and validations ALSO run in parallel — the keyed mutex keys off each target's install directory, so same-dir operations still serialize while different-dir operations unlock. The detection is automatic and surfaced asproject.isolatedLockfiles+parallelInstalls: truein--json; pass--no-parallel-installsto force the old serialized behavior. An in-process registry cache (always on) also deduplicates fetches so the same dependency name in many workspaces only hits the network once. The effective concurrency is reported asconcurrencyin--jsonoutput. Parallelism requires--json; non-JSON mode silently downgrades to1to keep per-target log lines legible.
The detected manager + members are surfaced under project in --json output:
"project": {
"manager": "pnpm",
"managerVersion": "9.10.0",
"managerSource": "package.json:packageManager",
"lockfile": "pnpm-lock.yaml",
"hasWorkspaces": true,
"workspaceGlobs": ["packages/*", "apps/*"],
"workspaceMembers": [
{ "name": "@org/core", "dir": "/path/to/repo/packages/core" }
]
}What gets scanned
Direct entries in dependencies, devDependencies, peerDependencies, and optionalDependencies are considered. Non-registry ranges (workspace:, link:, file:, git: …) are skipped for upgrades.
How linked groups are chosen (--link-groups auto)
There are no framework-specific lists. Groups are derived from your direct dependency names and published package metadata:
- Custom groups from
.dep-up-surgeonrclinkedGroupsare applied first (exact package names). - For each remaining registry dependency, the tool fetches the published manifest (
pacote, cached in-memory for the run) and readspeerDependenciesonly. (Runtimedependencies/optionalDependenciesare not used for clustering: they tend to connect unrelated packages through hubs liketypescript,eslint, orrxjs, producing one giant batch.) - An undirected edge is added between two project packages A and B when B appears in A’s published peerDependencies (and both are direct registry deps in your project). Connected components become one upgrade batch each (single
package.jsonwrite + onenpm install+ one validation). @types/<pkg>is linked to<pkg>when both are direct dependencies (types often move with the runtime package).- Isolated packages are upgraded alone (singleton groups).
Caveats
- Packages only batch if the registry exposes edges between them. If two packages must move together but are not linked in metadata, add a
linkedGroupsentry. - SDK-style tooling (e.g. Expo) may expect
npx expo installfor channel alignment; this tool automates semver bumps when the graph is visible to npm. - Prerelease / canary:
@latestmay not match your channel — pin or ignore as needed.
Performance: manifests are fetched with bounded concurrency (parallel batches); responses are cached for the duration of the run.
Interactive mode (--interactive)
- Single package failures: prompt to continue, pin (ignore) that package, or retry once.
- Linked group failures: prompt to skip the group, retry (same targets; several attempts allowed), force (same as
--forcefor that batch), or freeze (add all packages in the group to.dep-up-surgeonrcignore). Attempts are capped higher when interactive so you can recover without rerunning the whole CLI.
Conflict detection
After each npm install, output is passed through a generic conflict parser (regex-based, no hardcoded package names). Lines that only refer to the root package.json name (for example npm’s While resolving: [email protected]) are filtered out so they do not appear as fake registry-package conflicts. Structured conflicts are classified (e.g. peer mismatch, missing peer, version range, engine, unresolved tree). If npm install exits successfully but conflicts are still detected in the log, the tool rolls back the bump (unless --force). Failed runs also attach parsed conflicts to the report where possible.
Why not only “latest”?
latest may not be adoptable yet (e.g. ESM-only majors, or a TypeScript major that breaks your build). The default strategy tries @latest first, then walks older release lines when fallbacks are enabled.
Live progress (spinner + elapsed timer)
Long phases — pre-flight validation, <mgr> install, the post-install test / build script, and rollbacks — no longer go silent. While each phase is running the CLI prints a single-line status that updates in place with an elapsed-seconds counter, so you can tell at a glance whether the tool is working or genuinely hung.
Typical output during a run:
⠸ Pre-flight: running `npm test` on unchanged tree... (4s)
✔ Pre-flight ok: `npm test` (12s)
Upgrading axios: 1.6.0 → latest 1.7.2 …
⠹ Installing [email protected] with npm... (6s)
⠋ Validating [email protected]: `npm test`... (18s)
✔ upgraded: axios → 1.7.2On failure the spinner switches to the rollback phase before the final error line so you see the recovery happen:
⠹ Installing [email protected] with npm... (9s)
⠸ Rolling back react: `npm test` failed (exit 1)... (23s)
✖ skipped: react — validator (npm test) failed; this is not a dependency conflictFor linked-group upgrades the status line shows a compact batch label (first three members, then …+N more for larger groups):
Linked group [react-pair]: react → 19.0.0; react-dom → 19.0.0; @types/react → 19.0.0 …
⠙ Installing batch (3 pkgs) with npm: [email protected], [email protected], @types/[email protected]... (7s)
⠧ Validating batch (3 pkgs): `npm test`... (21s)
✔ upgraded: react → 19.0.0 (group react-pair)
✔ upgraded: react-dom → 19.0.0 (group react-pair)
✔ upgraded: @types/react → 19.0.0 (group react-pair)Environment handling:
- TTY (local dev) — animated braille spinner updated in place with a live elapsed timer.
- Non-TTY (CI logs, piped output,
tee-ed runs) — auto-degrades to plain› <phase>lines, one per phase transition, no ANSI escapes. Jenkins / GitHub Actions logs stay clean and every phase remains visible in the scrollback. --json/--ci— completely silent. Machine output is untouched so JSON consumers never see progress noise.--concurrency > 1— already requires--json, so the spinner stays off in parallel runs. Serial runs keep a single clean status line regardless of how many workspace targets are traversed.
Configuration
Create .dep-up-surgeonrc in the project root:
{
"ignore": ["some-legacy-package"],
"linkedGroups": [
{
"id": "my-batch",
"packages": ["package-a", "package-b"]
}
],
"validate": "tsc -p tsconfig.json --noEmit",
"overrides": [
{ "chain": ["some-dep", "foo"], "range": "1.2.3", "reason": "CVE-2025-1234" },
{ "selector": "[email protected]", "reason": "awaiting upstream PR #42" }
]
}Ignored packages are never upgraded. The CLI --ignore list is merged with this file.
linkedGroups defines forced batches before the dynamic graph runs (exact npm package names).
validate overrides the validator command. Accepts either a shell string ("tsc --noEmit") or an object: { "command": "pnpm -r build" } or { "skip": true }. CLI flags (--validate, --no-validate) always win over this file.
overrides defines persistent parent-scoped / flat override pins applied on every run. Merges with CLI --override flags (CLI wins on exact-chain conflict). Each entry's reason flows into report.overrides.attempts[].policyReason and the summary's Reason column. See Override policy file for the full schema.
JSON report (--json)
Stdout is a single JSON object including:
upgraded,skipped,failed,conflicts(parsed from npm output),unresolved(failed entries),groups(planned linked groups: ids and package names), andignored.preflight(when not skipped):{ ok, command, exitCode, lastLines, source }for the unchanged-tree validator run.preflightAborted: trueif the run aborted before any upgrade.- For each failed entry caused by the validator, a
validationblock with{ command, exitCode, lastLines, source }so you can tell at a glance whether the failure was a project-side script crash or an actual dependency conflict. - For every failed entry, an
installblock with{ command, exitCode, lastLines, ok }capturing the install step that triggered the failure. `ok:
