Search by

webhubworks / package-updater

webhubworksmarventhieme

Bulk-update Composer packages (and Craft / Craft plugins) across all your local repos.

Package info

github.com/webhubworks/package-updater

Homepage

Type:project

pkg:composer/webhubworks/package-updater

Statistics

Installs: 116

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.49 2026-09-18 12:53 UTC

README

A Laravel Zero CLI for bulk-updating Composer packages — and Craft itself / Craft plugins — across every repo on your machine. Fetches from origin, then runs git pull on the appropriate long-lived branch (developdevstagingstagstagemainmasterprodlive), picking it up even when it only exists on the remote so far. After the pull come the package update, optional composer prep, and optional site-crawler. Skips dirty repos, captures full per-repo transcripts, summarises everything, and offers to open repos with uncommitted changes in GitKraken for review.

1. Installation

Global (recommended)

composer global require webhubworks/package-updater

Make sure Composer's global bin directory is on your PATH — typically ~/.composer/vendor/bin (macOS/Linux) or %APPDATA%\Composer\vendor\bin (Windows). After that, both package-updater and the shorter alias pu are available from anywhere.

Set REPOS_DIR in your shell (or via .env next to the tool) so it points at the directory you want scanned (defaults to $HOME/reps). The walker descends through grouping folders (e.g. ~/reps/my7steps/<repo>), considers a directory a candidate only when it contains a composer.lock, and filters out anything that's not a git repo or that itself declares "type": "craft-plugin".

To upgrade later:

composer global update webhubworks/package-updater

Local clone (for development)

cd ~/reps
git clone <repo-url> package-updater
cd package-updater
composer install
./package-updater list

2. Commands

Run pu <name> (or the longer package-updater <name>). Bare pu prints the command list. Every command is fully interactive — it will prompt for any answer it needs.

update

Single-repo helper. Run it from inside a repo: it verifies the working tree is clean, runs composer update (auto-detecting ddev when .ddev/config.yaml exists), parses Upgrading / Downgrading / Installing / Removing lines from composer's output, and commits the result with title Package updates and a body listing each change. Optional package arguments (pu update vendor/foo vendor/bar) restrict the update to those packages. Use --no-ddev to force host composer, --commit / --no-commit to skip the commit prompt.

update:all

Universal bulk update — one composer package across every local repo that depends on it. Pick a vendor/name, pick a target version, pick which of the matching repos to run on, pick parallelism, confirm. Each repo runs git fetch --prunegit pullddev startddev composer updatecomposer prep (if defined) → ddev stop (optional). After checking out the lowest long-lived branch, the run checks the higher branches (e.g. main/master a teammate committed to directly) for work it doesn't have. A main that's ahead only by a merge of this branch into it carries nothing, so the routine merge-down of develop into main is ignored. Work that this branch really is missing is merged down for you when git can fast-forward to it, which is the case whenever the branch has no commits of its own yet. Only a real divergence, both branches carrying work the other doesn't have, aborts the repo, because updating a stale branch would base the commit on the wrong tree; that one is left for you to merge down first. Repos already at the target version are pre-skipped; with a bare target version, repos on a different major are pre-skipped too (prefix the version with ! to force across majors).

--filter-name=<substring> narrows the match set to repos whose composer.json name contains the given substring — e.g. pu update:all vendor/foo --filter-name=mvb only considers repos with mvb in their composer name.

The package argument also accepts a wildcard — e.g. pu update:all 'laravel-lang/*' matches every locked package under that vendor prefix and runs composer update laravel-lang/* -W per repo. Wildcards skip the target-version and transitive-parent prompts and always pass -W (composer almost always needs it to bump siblings together). Quote the pattern in your shell so it isn't glob-expanded.

remove

Bulk counterpart for removing a package. Same scan as update:all, including wildcard support (pu remove 'laravel-lang/*'). Pre-skips repos where the package is only a transitive dependency (composer remove only works on declared deps) and pre-skips dirty repos. For each remaining repo it detects whether the package is in require or require-dev and groups the removals so composer is called with --dev for the dev-only ones (plain for the rest). On success it commits the change with title Remove <package> (or Remove N packages for wildcards) and a body listing the removed names.

pu remove vendor/foo                   # exact name
pu remove 'laravel-lang/*' --dry-run   # preview which packages would be removed

update:craft

Craft-aware variant. Same flow, but:

  • Identifies repos by Craft plugin handle (or the literal craft to match every site with craftcms/cms in composer.json).
  • After ddev start, always syncs the working copy before the update: ddev composer installddev php craft migrate/allddev php craft project-config/apply.
  • Runs ddev php craft update <handle> (with sensible defaults you can edit at the prompt) instead of composer update.
  • After composer prep, optionally runs site-crawler crawl:ddev in a second multiselect-chosen subset of repos. Parses the crawler's "Failed requests" table and warns on any 5xx URLs even if the crawler itself exited cleanly.
  • Skips both verification steps — composer prep and the crawl — for any repo the run left untouched. They're there to catch breakage the run introduced, so when there was nothing to update, git pull brought no new commits, and the working tree came out clean, the repo is exactly what it was and re-running the test suite and the crawler only re-confirms what already passed. The summary marks those repos prep + crawl skipped (repo unchanged) and their Tests column reads -. Anything that could have changed the site vetoes the skip: a parsed update, a dirty working tree (which also covers an update whose output we failed to parse), a moved HEAD, or a --maintenance dirty-repo reset. Because a colleague's pushed commits move HEAD, new code still gets the full tests-and-crawl treatment — what's dropped is only the repeat work on repos nothing happened to. This is what makes a back-to-back run over an already up-to-date set of repos fast.

--filter-name=<substring> works the same way as on update:all: restricts the match set to repos whose composer.json name contains the substring.

--maintenance is a one-flag preset for a dedicated update server. It implies --yes and pre-seeds every prompt with the semi-automated defaults: handle all (update every Craft package in every matched repo), --parallel=3, --stop-ddev (stop each project after a successful update), commit + push, crawl every repo, and a craft command with --backup=0. When a Slack webhook is configured (see Configuration) it also posts a run summary at the end. Any explicit flag still wins, so pu update:craft --maintenance --parallel=5 --no-push overrides just those two defaults.

Because a dedicated update server never carries real local work, --maintenance also hard-resets any repo with uncommitted changes before updating it (git reset --hard HEAD + git clean -fd) instead of skipping it, then lets the normal sync steps (ddev composer install, migrate/all, project-config/apply --force) bring dependencies and project config back in line. Gitignored paths (vendor/, .env, storage/) are left untouched. This only happens under --maintenance — a normal update:craft run still skips dirty repos.

--maintenance also retries the craft update once on a transient composer download failure (a corrupt/0-byte dist zip, a truncated download). Before retrying it restores a healthy state — git reset --hard + git clean -fd, ddev composer clear-cache, then ddev composer install from the committed lock — so a half-extracted vendor tree (which can stop Craft from even booting) can't wedge the retry. Genuine dependency conflicts are never retried.

retry

Re-runs the most recent update:all or update:craft non-interactively using the answers persisted in logs/last-run.json. Useful for working through a batch in chunks — already-up-to-date repos are skipped by the target-version filter, so each retry picks up where the previous one left off.

open

Opens repos from the most recent run in GitKraken (one tab per repo via the gitkraken:// URL scheme). By default it surfaces repos that warrant review: uncommitted working-tree changes, failed steps, failing tests after composer prep, crawler failures, or 5xx URLs from the crawler. You can narrow the pool with --filter=changed, --filter=failed, or --filter=all. Both update:all and update:craft also offer this prompt directly at the end of a run — push the resulting commits via GitKraken without context-switching.

Logs and transcripts

  • logs/transcripts/<repo>-<timestamp>.log — full output of every step for one repo, from git status to ddev stop. Always written, success or fail.
  • logs/<repo>-<step>-<timestamp>.log — narrow per-step log written when a specific command fails or its tests don't pass.
  • logs/last-run.json — the resolved command + arguments + options of the last run (powers retry).
  • logs/last-results.json — per-repo results of the last run (powers open).

In sequential mode (--parallel=1) every step's output streams live to the terminal. Parallel mode does not stream (output would interleave) — the transcript and per-step logs are how you investigate.

3. Configuration

Repo directory

REPOS_DIR (env var, or .env next to the tool when running from a local clone) — single source of truth for where to scan. Default: $HOME/reps. Per-run override available on every command via --reps-dir=.

Slack webhook (maintenance runs)

pu update:craft --maintenance posts a run summary to Slack when a webhook is configured. To set it up:

  1. At https://api.slack.com/apps, create a Slack app (or reuse one), enable Incoming Webhooks, then Add New Webhook to Workspace and pick the channel the summary should post to.
  2. Copy the generated https://hooks.slack.com/services/... URL.
  3. Save it - interactively via pu setup, or non-interactively:
pu setup --slack-webhook-url="https://hooks.slack.com/services/T.../B.../..."
pu setup --no-slack   # persist an empty webhook (notifications off)

The URL is stored in ~/.config/package-updater/config.json. Leave it blank to disable notifications; once you have answered, pu setup won't keep asking.

The summary groups the run into three buckets: repos updated, committed and pushed; repos updated but not pushed (uncommitted, or committed with the push held back by failing tests / PHPStan / a site-crawler issue); and failed, skipped, or otherwise-flagged repos. The last bucket also catches an otherwise up-to-date repo that still hit a problem during the run — a 5xx during the crawl (with the offending URLs listed), a crawler crash, or failing tests — so those never disappear into the "already up to date" count. It's only sent on --maintenance runs, and only when a webhook is configured. Any send error is logged as a warning and never fails the run.

The message posts under the Slack app's own name and icon (the app the webhook belongs to). Slack ignores per-message sender overrides for app-based webhooks, so rename the Slack app itself if you want a different display name.

Git credentials (HTTPS remotes)

The tool shells out to git, which uses your CLI credentials — not GitKraken's. For HTTPS GitHub remotes, install and configure the GitHub CLI once:

brew install gh
gh auth login          # pick HTTPS, browser auth
gh auth setup-git      # register gh as git's credential helper

For Bitbucket / GitLab / Azure DevOps HTTPS remotes, use Git Credential Manager:

brew install --cask git-credential-manager
git-credential-manager configure

After either, git pull runs without prompting on matching remotes.

Host SSH agent (SSH remotes)

Repos cloned over SSH (git@bitbucket.org:…, git@github.com:…) bypass those credential helpers and need your host's SSH agent to have the right key loaded. Add a per-host block to ~/.ssh/config so macOS loads it on login:

Host bitbucket.org
  AddKeysToAgent yes
  UseKeychain yes
  IdentityFile ~/.ssh/id_rsa

Then once:

ssh-add --apple-use-keychain ~/.ssh/id_rsa

To find which local key matches the fingerprint shown in your git host's UI:

for f in ~/.ssh/*.pub; do ssh-keygen -lf "$f"; done

DDEV SSH for private composer dependencies

composer update runs inside ddev, which uses a global, shared SSH-agent container. The tool runs ddev auth ssh for you once at the start of a real run (after the confirm prompt, before any repo work) so private GitHub composer sources resolve without per-repo setup. If your SSH keys have passphrases, you'll be prompted at that point. The step can be skipped if you've already loaded keys in this shell.

DDEV hostnames / sudo

The first time ddev starts a given project on this device it may need sudo to add the project's .ddev.site hostname to /etc/hosts. In a piped/parallel run that would hang forever on the password prompt — the tool watches for the trigger lines (needs to run with administrative privileges / may need to enter your password for sudo) and kills ddev immediately, then fails that repo with a hint telling you to run ddev start manually once and then retry.

GitKraken (for the open feature)

Uses the gitkraken://repo/path/<absolute-path> URL scheme via macOS open. No extra setup beyond installing the GitKraken app.